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) } }