internal/handlers/manifest_handler.go
Rama main · réplica de lectura del repositorio original.
package handlers
import (
"context"
"errors"
"log"
"net/http"
"strconv"
"time"
"xprem/config"
"xprem/internal/services"
"xprem/internal/types"
"github.com/google/uuid"
)
// DeviceCheckIn is everything one manifest poll tells the device registry:
// who polled, from where, what it currently runs, and which updates crashed
// on it. The seam's vocabulary type, MIT-side on purpose (the leaf-vocabulary
// pattern): the wired EE recorder consumes it without the handler importing
// any EE package.
type DeviceCheckIn struct {
AppID string
EASClientID string
// CurrentUpdateID is the update the device is RUNNING (the launched
// update: one that crashed at launch never appears here). A device on the
// embedded bundle reports the embedded update's OWN id, which no updates
// table row matches; absent only on clients that sent no header.
CurrentUpdateID string
// FailedUpdateIDsRaw is the Expo-Recent-Failed-Update-IDs header verbatim
// (a structured-field list of quoted UUIDs); parsing belongs to the
// consumer.
FailedUpdateIDsRaw string
// FatalError is the Expo-Fatal-Error header: the crash detail, sent by
// the client exactly once, on the first poll after the crash.
FatalError string
// Rejected marks a poll the server answered with an error. Such a poll is
// no evidence that a device exists and must leave nothing durable behind,
// so the recorder keeps only the crash detail, in memory, to re-attach to
// the next poll that does resolve. Only set on a poll carrying one: every
// other signal a check-in holds survives on its own (the failed-id header
// is sticky, the device re-registers on its next poll), while the crash
// detail is sent once and is gone if this request drops it.
Rejected bool
// Hardware and OS of the device, spelled as expo-device spells them, plus
// the store version of the binary. Telemetry-only: the manifest headers
// carry nothing of the sort, so these are empty on every poll and empty
// always means "not reported", never "changed to empty".
DeviceModel string
OSName string
OSVersion string
AppVersion string
// ChannelName is the expo-channel-name header; empty on telemetry check-ins.
ChannelName string
// Platform and RuntimeVersion are what the device declares it runs, which
// names the embedded bundle where CurrentUpdateID cannot.
Platform string
RuntimeVersion string
// ObservedAt is when the device was running CurrentUpdateID. Zero means
// "as this arrives", which is what a manifest poll means: it answers the
// device live. Telemetry sets it to the newest record of the batch, which
// may be much older, and that is the difference the registry needs to stop
// a late backlog from overwriting fresher news.
ObservedAt time.Time
}
type ExpoProtocolHandler struct {
protocolService *services.ExpoProtocolService
// onDeviceCheckIn, when wired, registers the polling device in the universal
// device registry (the Observe feature's). A method-value seam like the
// audit recorder: the composition root wires it when Observe is enabled,
// and it must never block or fail the manifest path (the wired side runs
// its registry write in the background).
onDeviceCheckIn func(ctx context.Context, checkIn DeviceCheckIn)
}
func NewExpoProtocolHandler(ps *services.ExpoProtocolService) *ExpoProtocolHandler {
return &ExpoProtocolHandler{protocolService: ps}
}
// SetOnDeviceCheckIn wires the device check-in recorder; nil (never called) keeps
// manifest polls side-effect free.
func (h *ExpoProtocolHandler) SetOnDeviceCheckIn(fn func(ctx context.Context, checkIn DeviceCheckIn)) {
h.onDeviceCheckIn = fn
}
// resolveAppID returns the app a manifest or asset request targets. The
// expo-app-id header wins when present. When it is absent the caller is a v1
// client that cannot send it, so we fall back to the deploy's legacy app:
// see config.LegacyFallbackAppId, which returns "" when there is none and
// leaves the request to be rejected.
func resolveAppID(r *http.Request) string {
if appId := r.Header.Get("expo-app-id"); appId != "" {
return appId
}
return config.LegacyFallbackAppId()
}
// reportRejectedCrash hands the one-shot crash detail of a refused poll to the
// registry seam, and only that: no remote address, so nothing resolves a place
// for a device we are not registering. A poll carrying no crash detail is not
// reported at all, which keeps a refused request exactly as free of side
// effects as it was. The caller decides WHEN to reach here, and only does so
// for a transient failure: there is nothing to hold a detail for when the
// answer is that the app does not exist.
func (h *ExpoProtocolHandler) reportRejectedCrash(r *http.Request, appId string, params services.ManifestRequestParams) {
if h.onDeviceCheckIn == nil || params.ClientID == "" || params.ExpoFatalError == "" {
return
}
h.onDeviceCheckIn(r.Context(), DeviceCheckIn{
AppID: appId,
EASClientID: params.ClientID,
FatalError: params.ExpoFatalError,
Rejected: true,
})
}
// manifestParams reads a manifest poll into its parameters. Kept apart from the
// handler so the header-to-field mapping is reachable without an HTTP round trip:
// every field below is a wire contract, and a dropped one fails silently.
// maxRequestedBranchLen bounds the branch a device may ask for. A name longer
// than one that could ever exist is not a branch, and this header is the only
// client-supplied value on the manifest path that reaches the query layer and
// the logs; its two siblings are both bounded before use.
const maxRequestedBranchLen = 128
// requestedBranch reads the surf header, dropping a value too long to name a
// branch. Dropped rather than rejected: an over-long header makes the request a
// plain poll, where refusing it would take an update away from a device whose
// only fault is a header nobody asked it to send.
func requestedBranch(r *http.Request) string {
branchName := r.Header.Get("xprem-branch")
if len(branchName) > maxRequestedBranchLen {
return ""
}
return branchName
}
func manifestParams(r *http.Request, requestID string) (services.ManifestRequestParams, error) {
appId := resolveAppID(r)
if appId == "" {
return services.ManifestRequestParams{}, errors.New("No app id provided")
}
channelName := r.Header.Get("expo-channel-name")
if channelName == "" {
return services.ManifestRequestParams{}, errors.New("No channel name provided")
}
protocolVersion, err := strconv.ParseInt(r.Header.Get("expo-protocol-version"), 10, 64)
if err != nil {
return services.ManifestRequestParams{}, errors.New("Invalid protocol version")
}
rawPlatform := r.Header.Get("expo-platform")
if rawPlatform == "" {
rawPlatform = r.URL.Query().Get("platform")
}
platform, err := types.ParsePlatform(rawPlatform)
if err != nil {
return services.ManifestRequestParams{}, errors.New("Invalid platform")
}
runtimeVersion := r.Header.Get("expo-runtime-version")
if runtimeVersion == "" {
runtimeVersion = r.URL.Query().Get("runtimeVersion")
}
if runtimeVersion == "" {
return services.ManifestRequestParams{}, errors.New("No runtime version provided")
}
return services.ManifestRequestParams{
RequestID: requestID,
AppID: appId,
ChannelName: channelName,
Platform: platform,
RuntimeVersion: runtimeVersion,
ProtocolVersion: protocolVersion,
XpremBranch: requestedBranch(r),
SurfBlockTokens: r.Header.Get(services.SurfBlockedHeader),
ClientID: r.Header.Get("EAS-Client-ID"),
CurrentUpdateID: r.Header.Get("expo-current-update-id"),
EmbeddedUpdateID: r.Header.Get("expo-embedded-update-id"),
ExpectSignature: r.Header.Get("expo-expect-signature"),
ExpoFatalError: r.Header.Get("expo-fatal-error"),
RecentFailedUpdateIDs: r.Header.Get("Expo-Recent-Failed-Update-Ids"),
}, nil
}
func serverDefinedHeaders(params services.ManifestRequestParams, result services.UpdateDecision) *services.HeaderDictionary {
dictionary := services.NewHeaderDictionary()
if result.BlockedSurf != nil {
services.SetSurfBlocked(dictionary, params.SurfBlockTokens, result.BlockedSurf.Token)
}
return dictionary
}
func writeServerDefinedHeaders(w http.ResponseWriter, dictionary *services.HeaderDictionary) {
if encoded := dictionary.Encode(); encoded != "" {
w.Header().Set(services.ServerDefinedHeadersHeader, encoded)
}
}
func (h *ExpoProtocolHandler) HandleManifest(w http.ResponseWriter, r *http.Request) {
requestID := uuid.New().String()
params, err := manifestParams(r, requestID)
if err != nil {
log.Printf("[RequestID: %s] %v", requestID, err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
appId := params.AppID
result, err := h.protocolService.ResolveUpdateForDevice(r.Context(), params)
if err != nil {
var svcErr *services.ExpoProtocolError
status := http.StatusInternalServerError
message := "Internal operational error"
if errors.As(err, &svcErr) {
status, message = svcErr.StatusCode, svcErr.Message
}
if status >= http.StatusInternalServerError {
h.reportRejectedCrash(r, appId, params)
}
http.Error(w, message, status)
return
}
if h.onDeviceCheckIn != nil && params.ClientID != "" {
h.onDeviceCheckIn(r.Context(), DeviceCheckIn{
AppID: appId,
EASClientID: params.ClientID,
CurrentUpdateID: params.CurrentUpdateID,
FailedUpdateIDsRaw: params.RecentFailedUpdateIDs,
FatalError: params.ExpoFatalError,
ChannelName: params.ChannelName,
Platform: string(params.Platform),
RuntimeVersion: params.RuntimeVersion,
})
}
// Set before any response is written so it also rides a noUpdateAvailable
writeServerDefinedHeaders(w, serverDefinedHeaders(params, result))
refusedBranch := ""
if result.BlockedSurf != nil {
refusedBranch = result.BlockedSurf.BranchName
}
if result.Update == nil {
log.Printf("[RequestID: %s] No update found for runtimeVersion: %s in branch: %s", requestID, params.RuntimeVersion, result.BranchName)
h.protocolService.PutNoUpdateAvailableInResponse(r.Context(), w, params)
return
}
if result.UpdateType == types.NormalUpdate {
h.protocolService.PutUpdateInResponse(r.Context(), w, params, *result.Update, refusedBranch)
} else {
h.protocolService.PutRollbackInResponse(r.Context(), w, params, *result.Update)
}
}