Server & APIs
Effuse uses Web-standard Request and Response objects from route definition
to runtime adapter. The same application handler runs on the reference Node and
Bun servers, in process during tests, or behind a conforming deployment adapter.
File-based API routes
Place API handlers under src/server/api. Folders become URL segments,
[id] creates a dynamic parameter, [...slug] captures the remainder, and
route groups such as (admin) organize files without changing the URL.
src/server/api/health/route.ts
src/server/api/users/[id]/route.ts
src/server/middleware/auth.ts
A method export is enough for a simple route:
// src/server/api/health/route.ts
export const GET = () => ({ ok: true });
Type-inferred params and contracts
Use defineServerFileHandler when the handler needs compile-time knowledge of
its path. The literal path is checked against the file-derived path, preventing
a route from silently drifting during refactoring.
// src/server/api/users/[id]/route.ts
import {
defineServerFileHandler,
defineServerRequest,
serverSchema,
} from '@effuse/core/server';
const request = defineServerRequest({
params: serverSchema.object({ id: serverSchema.string }),
query: serverSchema.object({ limit: serverSchema.numberFromString }),
});
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 as number, and
the handler result must satisfy the response contract. Effuse schemas are
public, framework-owned values; application developers do not install or import
the internal validation engine.
Invalid input returns a stable 400 body with the
EFFUSE_VALIDATION_FAILED code and structured issues.
Middleware
Middleware can match paths, methods, and targets. Request-phase middleware can short-circuit, rewrite the request, call the next stage, and decorate the response.
// src/server/middleware/auth.ts
import { defineServerMiddleware } from '@effuse/core/server';
export default defineServerMiddleware({
name: 'admin-auth',
phase: 'request',
order: 10,
match: {
paths: '/api/admin/[...rest]',
methods: ['GET', 'POST'],
targets: 'api',
},
handler: ({ request }, next) => {
if (request.headers.get('authorization') !== 'Bearer expected-token') {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
return next();
},
});
The compiled middleware graph produces deterministic engine, global, layer, route, and file ordering. Request cleanup runs even after aborts, exceptions, or short-circuits, and rewrite loops are bounded.
Layer-owned APIs and actions
File routes provide discoverability. Layers remain the canonical capability graph for services, routes, actions, middleware, and policy:
const UsersLayer = defineLayer({
name: 'users',
services: { users: () => userRepository },
server: {
api: {
'/api/users/[id]': {
GET: ({ params, services }) => services.users.find(params.id),
cache: { maxAge: 60, tags: ['users'] },
},
},
actions: {
refresh: ({ services }) => services.users.refresh(),
},
},
});
createLayerActionClient(UsersLayer) creates a typed browser client for layer
actions. createLayerServerManifest() and the CLI manifest command expose the
same graph to adapters and generated clients.
Response and data caches
Portable caches are exported by core and may be wired into route dispatch:
import { createDataCache, createResponseCache } from '@effuse/core/server';
const responses = createResponseCache({ maxEntries: 1_000 });
const data = createDataCache({ maxEntries: 5_000 });
const getUser = data.cached(
'users:by-id',
async (id: string) => database.users.find(id),
{ life: { stale: 30_000, expire: 300_000 }, tags: ['users'] }
);
Both caches support bounded storage, single-flight work, tag invalidation, and observable events. Invalidate tags after mutations instead of scattering key knowledge through handlers.
Portable storage, tasks, and plugins
@effuse/server includes process infrastructure that is independent of the
HTTP adapter:
import {
createMemoryStorage,
createPluginHost,
createTaskScheduler,
} from '@effuse/server';
const storage = createMemoryStorage({ maxEntries: 10_000 });
const sessions = storage.namespace('sessions');
await sessions.set('s1', { userId: 'u1' }, { ttlMs: 3_600_000 });
const tasks = createTaskScheduler();
tasks.register({
name: 'expire-sessions',
intervalMs: 60_000,
run: async ({ signal }) => {
if (!signal.aborted) await removeExpiredSessions();
},
});
const plugins = createPluginHost();
plugins.use({
name: 'tasks',
setup: async ({ onTeardown }) => {
tasks.start();
onTeardown(() => tasks.stop());
return tasks;
},
});
await plugins.start();
Storage supports namespaces, TTL, structural isolation, and LRU bounds. The task scheduler prevents overlapping runs and aborts work during bounded shutdown. The plugin host starts in registration order, rolls back partial startup, and tears down in reverse order.
Node and Bun adapters
import { createNodeServer } from '@effuse/server/node';
import { handleRequest } from './entry-server';
const server = createNodeServer(handleRequest, {
maxBodyBytes: 5 * 1024 * 1024,
onError: (error) => logger.error(error),
});
await server.listen({ host: '127.0.0.1', port: 3000 });
process.on('SIGTERM', () => void server.close({ timeoutMs: 10_000 }));
Replace the import with @effuse/server/bun and createBunServer for Bun. Both
reference adapters pass the shared conformance suite for streaming, request
cancellation, graceful shutdown, multipart bodies, multiple cookies, and
ephemeral ports.
Production checklist
Validate params, query, headers, and request bodies at the route boundary.
Set body limits and shutdown budgets explicitly.
Keep secrets and server adapters out of browser dependency graphs.
Attach cache, CORS, runtime, region, and duration policy to the owning route.
Run the generated manifest check in CI and test handlers through
server.fetch.Use the conformance suite before publishing a custom runtime adapter.
Next Steps