Skip to content

Routing SSR And Server APIs

Chris Michael edited this page Jul 23, 2026 · 28 revisions

Effuse

Routing, SSR, And Server APIs

Status: Client routing, layer server APIs, typed file handlers, compiled server registries, atomic Vite regeneration, precompiled server matching, and typed server middleware descriptors are current. Generated registries support matched-route-only module loading. Middleware discovery and dispatch, plus integrating lazy files into layer dispatch, continue under #301 and #279.

Client Router

const routes = defineRoutes([
  { path: '/', name: 'home', component: HomePage },
  { path: '/users/[id]', name: 'user', component: UserPage },
  { path: '/docs/[...slug]', name: 'docs', component: DocsPage },
  { path: '/shop/[[...slug]]', name: 'shop', component: ShopPage },
] as const);

const router = createRouter({
  history: createWebHistory(),
  routes,
});

installRouter(router);

Supported path forms include:

Form Meaning
:id Colon-style dynamic segment.
[id] Bracket dynamic segment.
[...slug] Required catch-all.
[[...slug]] Optional catch-all.
(group) Organizational segment omitted from the URL.

Route groups remain available in normalized route metadata for architecture and policy decisions. Matching uses deterministic specificity and validates conflicting normalized paths.

Installation Ownership

Call installRouter(router) before mounting the application. The returned router includes an idempotent cleanup() method for test, micro-frontend, or custom runtime teardown:

const installedRouter = installRouter(router);
const app = createApp(App);

await app.mount('#app');

// Custom teardown only; normal app bootstrap keeps the router installed.
installedRouter.cleanup();

Router installation is generation-owned across the router context, route signal, and core script context. Runtime state is shared across development module replacement, so a new HMR module sees the currently active router before application code reruns. Cleanup from an older module removes only its own generation and cannot clear a newer installation.

Installation is transactional. If route context setup or router.start() fails, Effuse restores the previous working router in every public context and rethrows the failure. Applications do not need HMR-specific router code.

Navigation

Use Link for declarative navigation and RouterView for the matched route component. useRoute() exposes path, full path, params, query, hash, matched records, name, groups, pattern, and merged metadata. useRouter() exposes navigation, resolution, guards, and dynamic route operations.

Nested Routes

A parent component that owns child routes must render a nested RouterView. Parent and child components have independent lifecycle and route identity.

Layer-Owned Server APIs

const UserLayer = defineLayer({
  name: 'users',
  services: {
    users: () => ({ find: (id: string) => ({ id, name: 'Chris' }) }),
  },
  server: {
    api: {
      '/api/users/[id]': {
        GET: ({ params, services }) => services.users.find(params.id),
      },
    },
    actions: {
      refreshUser: ({ services }) => services.users.find('u1'),
    },
  },
});

Server routes are matched before SSR fallback. Handlers receive route params, query/body helpers, services, validation, response helpers, and request context.

File-Derived Server Endpoints

fromServerFiles converts an imported file map into layer server configuration. Default roots include API directories and action directories such as src/server/actions, app/actions, and src/actions. Files can export HTTP methods, default handlers, named actions, middleware, validation, and metadata.

import type { ServerApiFileModule, ServerActionFileModule } from '@effuse/core';
import { defineLayer, fromServerFiles } from '@effuse/core';

const files = import.meta.glob<
  ServerApiFileModule | ServerActionFileModule
>(['/src/server/api/**/*.ts', '/src/server/actions/**/*.ts'], { eager: true });

export const AppServerLayer = defineLayer({
  name: 'app-server',
  server: fromServerFiles(files),
});

A file at src/server/api/users/[id]/route.ts should use a path-aware handler witness when it needs exact params and request/response inference:

import {
  defineServerFileHandler,
  defineServerRequest,
  serverSchema,
} from '@effuse/core';

export const request = defineServerRequest({
  params: serverSchema.object({ id: serverSchema.string }),
  query: serverSchema.object({
    limit: serverSchema.optional(serverSchema.numberFromString, 20),
  }),
});

export const response = serverSchema.object({
  id: serverSchema.string,
  limit: serverSchema.number,
});

export const GET = defineServerFileHandler(
  '/api/users/[id]',
  { request, response },
  ({ input }) => ({
    id: input.params.id,
    limit: input.query.limit,
  }),
);

input.params.id is inferred as string, input.query.limit is inferred as number, and the handler return value must match response. Native Effuse schemas preserve literal response fields without requiring as const.

TypeScript cannot derive a module's types from its filesystem path while it is checking a raw export. This means the concise form below remains runtime-valid, but params cannot receive the exact [id] shape from the filename alone:

export const GET = ({ params }) => ({ id: params.id });

Use defineServerFileHandler(path, handler) when only route-param inference is needed. Use the { request, response } form for the complete contract. The helper returns the original function and records its witnesses without adding a runtime wrapper.

The declared path and exported contracts are checked during file discovery. A path mismatch emits server_file_path_mismatch; request or response identity drift emits server_file_contract_mismatch, and the invalid route is omitted from the manifest. This makes refactors fail before traffic reaches a handler.

When response is declared, return its structured inferred value so runtime validation and generated clients share one contract. Return a raw Response from a path-only or request-only handler when explicit status, headers, or stream ownership is required instead of structured response validation.

The same adapter recognizes Next-style app/api and app/actions roots. Route groups are removed from URLs while bracket params retain their runtime names.

This is an adapter into the layer server model, not a second runtime.

Compiled Server Registry

@effuse/cli can discover the canonical src/server/api and src/server/actions roots and emit .effuse/server-registry.ts:

import {
  discoverServerRegistry,
  writeServerRegistryModule,
} from '@effuse/cli';

const registry = discoverServerRegistry(process.cwd());
writeServerRegistryModule(registry);

effuse dev and effuse build install this compiler automatically. A custom Vite setup can use the same integration directly:

import { defineConfig } from 'vite';
import { effuseServerRegistryPlugin } from '@effuse/cli';

export default defineConfig({
  plugins: [effuseServerRegistryPlugin()],
});

Discovery is a build-time operation. It excludes tests, declarations, fixtures, hidden files, and symlinks; rejects source or output paths outside the project; sorts entries deterministically; and freezes the resulting metadata. Missing source directories produce a valid empty registry.

Route ownership uses the same serverFileToRoutePath and parseRoutePattern contracts as core request matching. Route groups and dynamic parameter names therefore cannot conceal a collision. For example, (admin)/users/[id]/route.ts conflicts with (public)/users/[userId]/route.ts, and the diagnostic names both files before source generation. Actions receive the same collision protection after route groups are removed.

The generated module contains literal dynamic imports and imports its contracts from @effuse/core/server. Bundlers can create a chunk per handler without shipping Node filesystem code into request-time output:

import { defineLayer } from '@effuse/core';
import { fromServerFiles } from '@effuse/core/server';
import { loadServerFiles } from './.effuse/server-registry.js';

const serverFiles = await loadServerFiles();

export const AppServerLayer = defineLayer({
  name: 'app-server',
  server: fromServerFiles(serverFiles),
});

The eager loadServerFiles() bridge preserves compatibility with the current layer runtime while every registry entry retains its own lazy loader.

Generated registries also export a portable precompiled matcher:

import {
  compiledServerRegistry,
  matchServerFile,
} from './.effuse/server-registry.js';

const match = matchServerFile(request, { actionLayer: 'app-server' });

if (
  match?.kind === 'action' &&
  !match.allowedMethods.some((method) => method === request.method)
) {
  return new Response(null, {
    status: 405,
    headers: { Allow: match.allowedMethods.join(', ') },
  });
}

const routeModule = await match?.load();

compiledServerRegistry snapshots and privately owns frozen entries, compiles route patterns once, sorts them with the canonical Effuse specificity rules, and indexes exact action names. matchServerFile() returns frozen ownership metadata and decoded params without importing a handler. It supports static, bracket, grouped, required catch-all, optional catch-all, nested action, and layer-qualified action paths.

Calling load() imports only that match. Concurrent calls share one pending promise and one module identity. A rejected import is evicted only if it is still the active promise, allowing a later request or repaired development module to retry without an older failure deleting newer state. Generated source retains literal import() expressions, so production bundlers can emit separate handler chunks; no filesystem dependency crosses into request-time output.

The public compiler rejects stale supplied signatures, duplicate canonical route shapes, and duplicate action names before publishing a registry. The legacy eager loader remains available during migration.

During development, structural file events are debounced into one complete snapshot. A valid snapshot is written beside the destination and atomically renamed, then Vite invalidates the generated module and reloads the application without restarting the server process. A collision or malformed route leaves the last valid graph untouched and opens a Vite error; fixing the source commits the next valid generation. Generated output is outside watched source roots, so it cannot recursively trigger compilation. Watch listeners and pending timers are released with Vite lifecycle cleanup.

Precompiled Server Matching

compileLayerServerRouter creates an opaque immutable dispatch graph:

import {
  compileLayerServerRouter,
  handleLayerServerRequest,
} from '@effuse/core/server';

const router = compileLayerServerRouter([AppServerLayer]);
const response = await handleLayerServerRequest(request, router);

Compilation resolves layer order, compiles and sorts route patterns, snapshots allowed methods, and indexes qualified and first-owner actions once. The public handle exposes only layerCount, routeCount, and actionCount; handlers, patterns, maps, layers, middleware, metadata, and contracts remain in private core storage. Passing an already compiled handle back to the compiler is idempotent.

createHandler, createStreamingHandler, and createInProcessRouteFetch memoize one compiled graph on their first request. Lazy first-request ownership preserves existing onError behavior for invalid layer graphs while every successful later request skips layer resolution, route compilation, sorting, and action scanning. Raw-layer calls remain supported when explicit one-shot matching is preferred.

The performance gate measures graph construction separately from steady-state matching for a 49-route graph on Node and Bun. Current regression ceilings are 1 ms median / 3 ms p95 for compilation and 50 us median / 100 us p95 for matching. These are reproducible guardrails, not cross-framework marketing claims; run pnpm bench:routes and pnpm bench:routes:bun to verify them.

The current layer example still uses the eager loadServerFiles() compatibility bridge before constructing a layer. The generated matcher already selects and imports one module; the next #279 slice connects that boundary to layer services, middleware, contracts, actions, observability, and direct SSR dispatch without changing their semantics.

Server Middleware Descriptors

defineServerMiddleware defines portable HTTP middleware independently from client navigation guards. The descriptor snapshots and freezes its match metadata, validates paths with the canonical Effuse route parser, and preserves literal path, method, and target types for generated registries.

Request-phase middleware runs before route selection. Its context deliberately contains no route params or layer services because neither exists yet:

import { defineServerMiddleware } from '@effuse/core/server';

export default defineServerMiddleware({
  phase: 'request',
  name: 'admin-auth',
  order: 100,
  match: {
    paths: '/api/admin/[...path]',
    methods: ['GET', 'POST'],
    targets: 'api',
  },
  handler: async ({ request, locals }, next) => {
    const token = request.headers.get('authorization');

    if (!token) {
      return Response.json({ error: 'Unauthorized' }, { status: 401 });
    }

    locals.authToken = token;
    const headers = new Headers(request.headers);
    headers.set('x-effuse-authenticated', 'true');
    return next(new Request(request, { headers }));
  },
});

Request-phase code uses standard Request, Response, URL, and Promise types. Effect remains an internal implementation detail; application authors do not import or learn it to define middleware.

Route-phase middleware runs after matching and retains the established typed ServerLayerContext<Services> contract, including params and selected layer services:

export const auditUserRoute = defineServerMiddleware<{
  audit: { write(event: string): Promise<void> };
}>({
  phase: 'route',
  match: { paths: '/api/users/[id]', methods: 'PATCH', targets: 'api' },
  handler: async ({ params, services }, next) => {
    await services.audit.write(`users:${params.id}`);
    return next();
  },
});

Defaults apply to every path and supported HTTP method, with api, action, and page targets. Static assets are excluded by default to preserve caching and avoid accidental interception; use targets: 'asset' only for an explicit asset policy. Duplicate canonical path shapes, methods, targets, malformed paths, unsafe order values, and invalid names fail when the descriptor is created.

The descriptor contract is shipped. Static discovery from src/server/middleware, deterministic engine/application/layer/route/method ordering, single-use next(), reserved internal-path policy, abort propagation, observability, and bounded request rematching remain implementation work in #301. A replacement Request passed to next(request) is therefore a portable contract, not a promise of unbounded rewriting. Production dispatch must enforce the bound so authentication and policy cannot be bypassed.

Validation

Handlers can validate JSON, form data, headers, params, query values, or an arbitrary value. Validators may be functions or objects with parse or safeParse methods.

const parseUser = (value: unknown): { name: string } => {
  if (!value || typeof value !== 'object' || !('name' in value)) {
    throw new Error('name is required');
  }
  return { name: String(value.name) };
};

const createUser = async ({ validate }) => {
  const input = await validate.json(parseUser);
  return { id: crypto.randomUUID(), ...input };
};

Validation failures become a 400 response with the stable EFFUSE_VALIDATION_FAILED code, source, message, and normalized issues.

SSR

createServerApp and createHandler build a request handler from a root component and layer graph. The SSR runtime supports rendered HTML, head collection, hydration data, asset manifests, API/action dispatch, and cleanup. createStreamingHandler exists as an experimental streaming path.

Attribute rendering matches the first client render: zero-arg function values (the reactive getter idiom, class={() => ...}) are evaluated, signals returned by getters resolve, class records and arrays normalize to class strings, and ref/use: directive props are never serialized into markup.

Metadata

Head and SEO primitives exist, including useSeoMeta, server SEO collection, head merging, Open Graph, Twitter metadata, links, and scripts. Full route-tree metadata generation and merge policy remain part of #218.

Related

Clone this wiki locally