Architecture

02. Application definitions

How application definitions connect pages and services to request-time execution.

Application definitions describe which services a Page may use without acquiring them. Layers and middleware supply those services during a request, while a shared application identity connects the definitions to their rendering runtime.

Describe services before acquiring them

Application.effront<Services>() in application/effront.ts returns factories for Page, Layout, Routes, and related definitions. It starts neither services nor an HTTP server. The returned EFFRONT<ApplicationServices, AvailableServices> separates two contracts:

  • ApplicationServices are supplied by the application's Layer.
  • AvailableServices are usable by definitions from these factories, including Page rendering and parameter Schema decoding. Initially they match ApplicationServices, but middleware can extend them for one branch.

In application/definition.tsx, ApplicationLayerOptions requires a Layer unless Services is never. Omitting an optional Layer selects Layer.empty. EFFRONT.make stores that provider alongside the compiled Routes. The root must share the application's identity, define a Layout, contain at least one Page, and avoid reserved paths.

The provider's own dependencies remain Requirements in ApplicationDefinition<Services, ApplicationError, Requirements>, with construction failures retained as ApplicationError. Requirements are not automatically available to Pages. Exposing an external Service requires the application Layer to provide it, for example by forwarding its existing instance with Layer.effect(Service, Service).

Extend one branch with middleware

A service needed only by an authenticated branch belongs in that branch's AvailableServices. withMiddleware creates new factories that record both the added service type and the middleware responsible for providing it:

Excerpt from packages/core/src/application/effront.ts
  const withMiddleware = <Value extends AnyMiddleware<ApplicationServices>>(
    value: Value & ApplicableMiddleware<AvailableServices, Value>,
  ): EFFRONT<ApplicationServices, AvailableServices | MiddlewareProvidedServices<Value>> => {
    getMiddlewareState(value);
    if (getEFFRONTIdentity(value) !== identity) {
      throw new TypeError("Middleware was created by a different EFFRONT module.");
    }
    if (middleware.includes(value)) {
      throw new TypeError("Middleware cannot appear twice in the same scope.");
    }

    return makeEFFRONT(identity, Object.freeze([...middleware, value]), allocateRouteScopeId, make);
  };

ApplicableMiddleware checks that the next middleware's requirements are already available. The returned factory adds MiddlewareProvidedServices to that set. Runtime checks reject the wrong member kind, a different application identity, or a duplicate in the same scope. The original factories remain unchanged, so scoped and unscoped branches can coexist.

Neither registration nor the provides type declaration injects a service. The handler in application/middleware.ts must provide it to the HTTP response Effect it wraps. Definitions from the extended factories retain the middleware chain for request-time execution.

  • Page GET/HEAD: native Effect HTTP middleware descriptors preserve routing behavior, including HEAD fallback.
  • Server Function POST: React decoding identifies the function's scope before applyMiddleware wraps its Effect with handlers. reduceRight makes earlier registrations outer wrappers, so they can supply services to later ones.

Keep related definitions in one application

Every factory derived from one Application.effront call shares an identity, a route-scope ID allocator, and the same make function. withMiddleware preserves these rather than creating another application:

Excerpt from packages/core/src/application/effront.ts
const effront = <Services = never>(): EFFRONT<Services> => {
  const identity = makeEFFRONTIdentity<Services>();
  const make: EFFRONTMake<Services> = (options) => makeApplication(identity, options);
  let nextRouteScopeId = 0;
  const allocateRouteScopeId = () => {
    const scopeId = nextRouteScopeId;
    nextRouteScopeId += 1;
    return scopeId;
  };

  return makeEFFRONT(identity, Object.freeze([]), allocateRouteScopeId, make);
};

Separate calls create separate identities even when their Services types match. Routes.page, Routes.mount, and makeApplication reject mixed identities with TypeError. These are definition-wiring errors, not invalid request input.

application/effront-identity.ts records membership through EFFRONTMember symbols for identity and member kind. The identity holds an invariant Services type marker and its own renderRuntime, linking definition-time contracts to request-time execution.

Run the definition inside a request

server/application.ts builds the application and renderer Layers for the request. RequestContextMiddleware merges the acquired services into the HTTP Context, and scoped middleware can add services required by the selected definitions.

React invokes Page and Layout outside an Effect call stack. application/render-runtime.ts bridges that boundary: bind stores an Effect runner and active middleware in AsyncLocalStorage, and run delegates each render Effect to that runner. Missing runtime bindings or inactive required middleware produce TypeError. Correct service types do not replace these runtime scope checks.

Route assembly connects these definitions to destinations. Request processing explains service ownership, and rendering follows the React boundary.