-
-
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, atomic Vite regeneration, and radix-trie server matching are current. The server middleware pipeline is complete end to end: descriptors, compiled scope graph, onion dispatch, filesystem discovery, generated registry with dev watching, bounded rewrites, abort propagation, tracing, and the reserved internal-path policy. Deferred-head streaming and both caching layers (response and data) are current. #301 is closed, with a conformance suite covering nested scopes, targets, concurrency isolation, failures, and streaming. Broader engine phases continue 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);Effuse exports all three history implementations from @effuse/router:
| History | Runtime | Ownership |
|---|---|---|
createWebHistory() |
Browser applications using normal URLs | One browser application instance. |
createHashHistory() |
Browser applications using hash URLs | One browser application instance. |
createMemoryHistory(initialPath) |
SSR, tests, workers, and non-DOM runtimes | One isolated render or request. |
createWebHistory() and createHashHistory() are safe to construct in Node:
outside a browser they report /, perform no-op navigation, and install no
listeners. Use memory history for server route resolution because it can start
at the request path and cannot mutate browser state.
Create the server router per request. A module-level memory router would share navigation state across concurrent users:
import {
createMemoryHistory,
createRouter,
createWebHistory,
} from '@effuse/router';
export const createServerRouter = (requestUrl: string) => {
const url = new URL(requestUrl);
return createRouter({
history: createMemoryHistory(`${url.pathname}${url.search}${url.hash}`),
routes,
});
};
export const browserRouter = createRouter({
history: createWebHistory(),
routes,
});The public memory history removes the need for application-owned copies of router internals. Its listeners and current path belong to that history instance, which makes request isolation explicit and deterministic.
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.
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.
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.
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, and the compiled graph now orders and
selects it. compileServerMiddlewareGraph takes scope-tagged descriptors and
produces one deterministic pipeline ordered by explicit scope — engine,
global, layer, then route — then by ascending order and declaration
index. Ordering is derived from ownership, never filename accidents; layer
scope requires an owner; duplicate names are rejected; the graph is frozen.
selectServerMiddlewareChain(graph, { pathname, method, target }) returns the
ordered chain whose match covers a request, so a route mismatch never selects
scoped middleware while global matches still run:
import {
compileServerMiddlewareGraph,
selectServerMiddlewareChain,
} from '@effuse/core/server';
const graph = compileServerMiddlewareGraph([
{ scope: 'engine', middleware: securityMiddleware },
{ scope: 'global', middleware: requestLogger },
{ scope: 'layer', owner: 'auth', middleware: sessionMiddleware },
{ scope: 'route', middleware: auditUserRoute },
]);
const chain = selectServerMiddlewareChain(graph, {
pathname: '/api/users/42',
method: 'PATCH',
target: 'api',
});runServerRequestMiddleware executes a selected request-phase chain as a
single-pass onion around a terminal handler. Each middleware runs once; its
next() advances inward and is single-use (a second call rejects), returning
without calling next() short-circuits so downstream middleware and the
terminal never run, and next(request) threads a replacement Request
downstream. Request-scoped locals are shared across the chain, and defer
disposers run after the response settles in LIFO order:
import {
selectServerMiddlewareChain,
runServerRequestMiddleware,
} from '@effuse/core/server';
const chain = selectServerMiddlewareChain(graph, {
pathname: url.pathname,
method: request.method,
target: 'api',
});
const response = await runServerRequestMiddleware(
chain.map((entry) => entry.middleware.handler),
request,
(finalRequest) => dispatchRoute(finalRequest)
);The CLI discovers filesystem middleware with discoverServerMiddleware, which
scans src/server/middleware in deterministic order and derives each entry's
onion scope from directory convention: layers/<owner>/… is layer-scoped with
that owner, routes/… is route-scoped, and the rest is application-global. The
engine scope stays framework-owned. Name collisions and a missing owner
segment after layers/ fail compilation. Each discovered entry maps directly
to a compileServerMiddlewareGraph input once its module is loaded.
generateServerMiddlewareRegistryModule turns a discovered middleware registry
into a stable, dependency-free module: lazy per-middleware imports with
scope/owner/name metadata, plus a loadServerMiddlewareGraph that imports each
module's default export, builds scope-tagged inputs, and returns a compiled
graph. This closes the loop — discoverServerMiddleware → generated module →
loadServerMiddlewareGraph → selectServerMiddlewareChain →
runServerRequestMiddleware.
In development the server registry Vite plugin owns both generated modules. It
watches src/server/middleware alongside the API and action directories,
rewrites the middleware registry atomically on change, invalidates both
generated modules, and triggers a reload. A middleware name collision keeps the
previously generated module and reports the failure instead of emitting a
broken registry, matching route-collision behaviour.
runServerRequestPipeline ties selection and dispatch together and owns
rewrite safety. It selects the chain for the request's current path, method,
and target and runs it as an onion. When middleware replaces the request with a
different path, the pipeline re-selects and re-runs from the top, so the
middleware owning the new path always runs — a rewrite can never skip the
guards protecting its destination:
const response = await runServerRequestPipeline(graph, {
request,
target: 'api',
resolve: (finalRequest) => dispatchRoute(finalRequest),
});Rewrites are bounded by DEFAULT_MAX_REWRITES; exceeding the bound throws
ServerRewriteLimitError instead of looping, and a cyclic rewrite is detected
immediately and reported with its cyclic flag set. A replacement request that
keeps the same path — header mutation, for example — is threaded downstream as
a pass-through and costs no extra pass.
Aborts and cleanup are part of the contract. Every dispatch step checks the
request signal, so a client abort skips any further middleware, the terminal,
and any lazily imported route chunk, surfacing the original abort reason.
Deferred disposers run exactly once in LIFO order even when the terminal or a
middleware throws. A failing disposer never skips the remaining disposers and
never replaces the response the request already earned — cleanup runs after the
request has settled. Report those failures with onCleanupError, which
defaults to console.error and receives an AggregateError when several fail:
await runServerRequestPipeline(graph, {
request,
target: 'api',
resolve: dispatchRoute,
onCleanupError: (error) => logger.error(error),
});Pass onTrace to attribute cost and failure. Each executed middleware emits a
ServerMiddlewareTrace carrying name, scope, owner, target,
pathname, durationMs, and failed:
await runServerRequestPipeline(graph, {
request,
target: 'api',
resolve: dispatchRoute,
onTrace: ({ name, scope, owner, durationMs, failed }) =>
metrics.record({ name, scope, owner, durationMs, failed }),
});Traces carry no headers, body, locals, or error message, so a failing
middleware is identified without leaking what it was handling. Spans close
inner-first as the onion unwinds, so durationMs is inclusive of downstream
work. Only selected middleware is traced — a route mismatch produces no
records — and a rewritten pass reports against the path it actually resolved.
Supplying no observer skips instrumentation entirely.
/_effuse is reserved for framework endpoints such as server actions. It is an
enforced boundary, not a convention. Application-scoped middleware — global,
layer, and route — that explicitly claims a path inside it fails graph
compilation, so internal endpoints cannot be intercepted. Only the
framework-owned engine scope may claim them. A rewrite whose target lands in
the reserved namespace is rejected, so an ordinary request cannot escalate into
a namespace the compiled graph would never address for it directly.
Default wildcard matches keep working; only an explicit claim fails. Matching
is segment-aware, so an application path like /_effusive/thing is not
reserved. Use isReservedServerPath to test a pathname directly.
Middleware selection compiles its match patterns once at graph compile time, so
matched dispatch does not rebuild a regex per middleware per request. Compile
cost and dispatch overhead are both tracked in the
Performance Lab. 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.
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.
renderToString embeds the full merged head — including anything useHead()
collects during render — directly in <head>. Use it when full-head SEO in the
initial HTML matters.
createStreamingHandler / renderToStream use deferred-head streaming: the
document shell flushes with the head known before render (layer and static
head), so time-to-first-chunk is the shell-flush cost and does not grow with
document size. Head discovered during render via useHead() ships in the
hydration payload and is applied by the client head reconciler — the standard
streaming tradeoff. The shell also carries the entry modulepreload, so the
browser fetches the JS bundle while the server is still rendering the body.
Choose renderToString when late head must be present in the initial <head>
for non-JS crawlers; choose streaming for the fastest shell.
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.
Effuse caches at two independent layers. Both are per-process by design; multi-instance deployments must invalidate each instance or front them with a shared store.
Nothing in Effuse caches unless you ask for it, on two independent axes:
| You write | You get |
|---|---|
| nothing | no caching anywhere — every request runs the handler |
revalidate in a route policy |
CDN headers (Cache-Control, X-Effuse-Cache-Tags); the origin still runs |
revalidate and a cache passed to dispatch |
the origin caches too |
The two are not redundant. revalidate declares that a response may be
cached; supplying a cache says this process should also cache it. A
CDN-fronted deployment wants only the headers, while a self-hosted deployment
wants both.
This is deliberate. Frameworks that cached by default produced stale-data bugs that were hard to attribute, and the industry has since converged on explicit opt-in. Effuse starts there rather than arriving there after a breaking change.
createResponseCache() caches the HTTP response at the origin, honouring the
same revalidate/tags policy that already compiles to Cache-Control.
Pass it to dispatch and matching routes cache automatically:
const cache = createResponseCache({ maxEntries: 500 });
const response = await handleLayerServerRequest(request, layers, { cache });
cache.invalidateTags(['products']);Route matching happens first — a cheap trie lookup — so the compiled policy is known before the cache decides. A hit then skips SSR runtime creation, middleware, and the handler, not merely the response serialisation.
Only GET/HEAD and 200 responses are cached. Keys are Vary-aware, learned
from the stored response, so a negotiated response is never served to the wrong
client. Bodies are buffered and each serve builds a fresh Response, so a
cached body is never a reused single-use stream.
cached() memoises the expensive work itself — a query or upstream call —
independently of the response wrapping it. It is Effuse's answer to a
"use cache" directive, as an explicit typed wrapper:
const getProducts = cached(
async (categoryId: string) => db.products.findMany({ categoryId }),
{ life: { stale: 60, expire: 3600 }, tags: (id) => [`category:${id}`] }
);The wrapped function keeps the exact parameter and return types of the original, needs no build step, and is visible at the call site — a directive is a string that changes runtime behaviour invisibly and only when a build step runs.
Two properties exist because their absence was a real bug:
-
Cached values are deep-frozen. A cached value is one object handed to
every later caller, so a caller mutating it would corrupt the data for all of
them — including privilege-shaped fields such as a roles array. Freezing
happens once at store time, so the cost is amortised across hits rather than
paid per serve. Use
freeze: falseonly for values that must stay mutable. -
Cached work runs detached from the request. A value computed for one
request is served to every later request, so reading request state inside it
would leak one caller's data to another. The function runs outside the request
context, making such a read fail rather than silently capture the wrong
request. A plain closure over an application variable is invisible to any
cache — put that dependency in the arguments or
key.
Both layers use single-flight coalescing: concurrent callers for a cold or expired key run the work once and share the result. Without it, an expiring hot key stampedes the origin harder than having no cache at all. A rejected run is never cached and never poisons the key.
Both bound memory with an O(1) LRU and invalidate through an inverted tag index,
so invalidateTags costs the affected entries rather than a scan. maxEntries
bounds entry count, not bytes — lower it when caching large payloads.
Stale-while-revalidate serves a stale value immediately while exactly one coalesced refresh runs behind it.
createPluginHost composes server-side resources with an ordering contract, so
cache, tasks, storage, and application resources start and stop correctly:
const host = createPluginHost();
host.use({
name: 'redis',
setup: async (ctx) => {
const client = await connect();
ctx.onTeardown(() => client.quit());
return { storage: createRedisStorage(client) };
},
});
await host.start();
await host.stop();- Setup runs in registration order; teardown runs in strict reverse, so a backend is never closed before the task still using it.
- A failed setup rolls back: already-started plugins tear down in reverse before the error propagates, so a failed boot leaves nothing running.
- Teardown failures are isolated and aggregated — one failure never prevents the remaining resources from releasing.
-
stop()is bounded by a timeout and idempotent withstart(); a timeout is a bounded shutdown, not an error. - Duplicate names and registration after
start()are rejected.
createMemoryStorage in @effuse/server implements EffuseStorage, a small
async key-value contract that framework features and applications can both
build on:
const storage = createMemoryStorage({ maxEntries: 10_000 });
const sessions = storage.namespace('sessions');
await sessions.set('u1', { role: 'admin' }, { ttlMs: 60_000 });
await sessions.get<Session>('u1');- Async even in memory, because every real backend is async. A synchronous contract would need redesigning the moment a second adapter existed.
- Namespaces rather than convention-based key prefixing, so independent concerns cannot collide and one namespace can be cleared without touching its neighbours. Nesting is supported.
-
TTL is a first-class option on
set. - Values are structurally copied on write and on read, so a caller mutating an object it stored or retrieved cannot corrupt the store.
- The memory adapter bounds entries with an O(1) LRU.
runStorageConformance is exported so a Redis, filesystem, or vendor adapter
proves the same semantics verbatim rather than re-deriving them. No remote
backend ships yet; those belong in slices where they can be tested against a
real service.
createTaskScheduler in @effuse/server runs recurring background work with
the guarantees a bare setInterval does not give you:
const scheduler = createTaskScheduler({ onEvent: (event) => metrics.record(event) });
scheduler.register({
name: 'refresh-catalog',
intervalMs: 60_000,
run: async (ctx) => refreshCatalog(ctx.signal),
});
scheduler.start();
await scheduler.stop(); // stops scheduling, then awaits in-flight runs- A tick arriving while the previous run is still in flight is skipped, not queued. Queueing turns a temporarily slow dependency into an unbounded backlog exactly when it is already struggling.
- A throwing task is reported and rescheduled. It never rejects into the process and never stops sibling tasks.
-
stop()cancels future ticks, abortsctx.signalso a run can wind down cooperatively, and awaits in-flight work within a timeout, so a task that ignores the signal cannot hang shutdown.start()andstop()are idempotent. -
start,success,failure, andskipevents carry durations, so a task that quietly stopped working is visible. -
runOnStartruns a task immediately instead of after one interval. - Timers are unreferenced, so background work never holds the process open.
Multi-instance boundary. Without a shared lock, N instances run a task N times. Applications that need exactly-once semantics across instances must supply their own lock. This is a property of a per-process scheduler, stated rather than implied.
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.