Architecture

07. From Server Function calls to refreshed UI

How a Server Function call becomes server-side work and a refreshed page.

A valid Flight response to a browser Server Function call carries both an invocation outcome and a refreshed route tree. A function failure can therefore arrive with HTTP 200, while a successful but stale response must not restore a page the user has left. For application usage, see the execution and refresh guide.

1. Identify and validate the incoming call

client/call-server.ts records the current history entry or URL and an increasing invocation order. It encodes arguments with React's encodeReply and uses FlightClient to POST to that URL, sending x-effront-server-fn and requesting Flight. The destination's POST handler calls prepareServerFnRequest.

Before decoding, validateOrigin compares the parsed Origin URL's host with the lowercased Host header. Missing headers, an invalid Origin URL, or a mismatch return 403. This compares hosts, not schemes or complete origins, and does not authenticate or authorize the operation. The request becomes a Web Request with the request Scope's AbortSignal. An action-ID header selects the client-call path. Its absence selects the progressive form path used without JavaScript.

Both paths count actual bytes read and reject bodies over 10 MiB with 400. The HTTP entry's Content-Length check is separate and can return 413 before reading. Buffered multipart bodies become FormData; other bodies become text. Read and multipart-parsing failures return 400.

Excerpt from packages/core/src/server/server-fn-request.ts
  const temporaryReferences = createTemporaryReferenceSet();
  const body = yield* readBody(request);
  const decoded = yield* Effect.tryPromise({
    try: () =>
      decodeReply(body, {
        arraySizeLimit: ServerFnArraySizeLimit,
        temporaryReferences,
      }),
    catch: (cause) => requestError("Failed to decode Server Function arguments.", 400, cause),
  });
  const args = yield* decodeArguments(decoded).pipe(
    Effect.mapError((cause) =>
      requestError("Expected a Server Function argument array.", 400, cause),
    ),
  );
  const action = yield* Effect.tryPromise({
    try: () => loadServerAction(actionId),
    catch: (cause) => requestError("The requested Server Function does not exist.", 400, cause),
  });

decodeReply reconstructs arguments with an array-size limit of 10,000 and temporary references. Effront verifies an array result, then loadServerAction resolves the function. Decode, array-shape, and lookup failures return 400 before application input validation. The server carries temporary references into Flight, and the browser decodes with the set passed to encodeReply.

2. Recover the typed operation behind the React reference

Invoking a Server Function created by makeServerFnFactory returns a branded Promise describing an Effect instead of immediately running the handler. This lets HTTP processing recover its application identity and middleware before execution:

Excerpt from packages/core/src/application/server-fn.ts
    const schemas = Array.ensure<Schema.ConstraintDecoder<unknown, AvailableServices>>(input);
    const decode = Schema.decodeUnknownEffect(Schema.Tuple(schemas));

Callers supply Schema Encoded values; handlers receive decoded Type values after Schema.Tuple succeeds. A single Schema decodes the first argument, ignores extra native arguments, and decodes undefined when omitted. A Schema array validates the positional list. Decoding and handlers can require AvailableServices, and their typed failures become ServerFnOperationError.

The returned Promise carries a brand with the Effect, identity, and middleware. Directly awaiting it in the server graph rejects with TypeError. HTTP instead uses matchServerFnInvocation to recover the operation and verify application identity. A different EFFRONT identity is rejected. Unbranded native React Server Functions follow a separate path that awaits their result in an Effect without Effront function middleware.

3. Execute within middleware and render the outcome

PreparedServerFnRequest contains execute and the function's middleware. executeServerFnAndRefresh in server/application.ts wraps execution and rendering in that middleware. Middleware needed only by the destination wraps rendering, excluding entries already applied for the function. The renderer receives the combined list for runtime scope checks.

For client calls, serverFnOutcome converts the operation's Exit to Success or Failure in serverFnResult. Both render with status 200, so HTTP success does not establish function success. Interruption remains interruption rather than Failure data. The Flight payload carries both the result and the route tree.

Progressive forms require multipart FormData and React's decodeAction. Missing or undecodable actions return 400. After execution, decodeFormState supplies state to SSR and hydration. Success renders status 200 with formState and serverFnResult: null, producing HTML for a normal document form request. Typed execution failures and form-state decode failures return 500. The POST route turns ServerFnRequestError into its specified status and a text response, not a function-result payload.

4. Settle the call before choosing a UI refresh

Non-2xx, non-Flight, and missing-result responses reject the browser call without result-driven refresh. A valid Success resolves the caller's Promise; a valid Failure rejects it with ServerFnCallError. Effront registers its continuation after settlement so React's existing Action reactions run before the refresh Transition. Both outcomes then use the same refresh decision: function failure does not imply an unchanged UI.

The returned tree is reusable only when:

  • It belongs to the most recently started invocation.
  • No navigation transition is in progress.
  • The current history entry matches the captured ID, or both have no entry and the URL is unchanged.

Effront checks again after interrupting an older refresh because cleanup can allow another invocation or navigation to win. An unsuitable response is released, and RouteRefresher.refreshCurrentRoute("server-function") refreshes the current destination instead. That path waits for navigation to settle and races the refresh against new routed navigation.

For a reusable response, RouteLoader.prepareRefresh invalidates the cache and startTransition publishes the tree with type server-function. The Transition Action does not return the commit Promise, which would block the commit it awaits. A separate scoped Fiber waits for response completion and React commit before caching, or stops if the publication retires first. Cleanup releases the response in either case. This shares the ownership boundaries of navigation within the request-to-browser flow.