-
-
Notifications
You must be signed in to change notification settings - Fork 0
Routing SSR And Server APIs
Status: Client routing, layer server APIs, typed file handlers, compiled server registries, and atomic Vite regeneration are current. Precompiled production matching continues under #279.
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.
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.
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.
A parent component that owns child routes must render a nested RouterView.
Parent and child components have independent lifecycle and route identity.
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.
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.
@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.
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 production matching and matched-route-only module loading are the next phase of #279.
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.
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.
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.