@webstir-io/module-contract
v0.1.16
Published
Public contract definitions for Webstir frontend and backend module providers.
Readme
@webstir-io/module-contract
TypeScript interfaces, helper utilities, and JSON schema describing Webstir modules and providers. The contract covers build-time provider APIs and the runtime surface (contexts, manifests, routes, views) that providers expose to the orchestrator.
Status
- Experimental contract for the Webstir ecosystem — shapes and helpers may change as providers and orchestrator behavior are refined.
- Expect breaking changes across early versions; pin carefully if you integrate it outside Webstir itself.
Install
npm install @webstir-io/module-contractProvided Types
import {
ModuleProvider,
ModuleBuildResult,
moduleManifestSchema,
routeDefinitionSchema,
viewDefinitionSchema,
defineRoute,
defineView,
createModule,
CONTRACT_VERSION,
type RequestContext,
type SSRContext,
} from '@webstir-io/module-contract';
// Optional adapters live under a secondary export for teams using ts-rest
import { fromTsRestRoute, fromTsRestRouter } from '@webstir-io/module-contract/ts-rest';ModuleProviderremains the build-time contract (metadata,resolveWorkspace,build).moduleManifestSchema/routeDefinitionSchema/viewDefinitionSchemamodel the runtime manifest that orchestrators ingest; the package publishes matching JSON schema underschema/.defineRoute,defineView, andcreateModulegive providers ergonomic helpers with strong TypeScript inference.RequestContextandSSRContextdescribe what the orchestrator supplies to route and view handlers.fromTsRestRouteconverts an@ts-rest/coreroute contract into a WebstirRouteSpec, andfromTsRestRouteradapts an entire ts-rest router tree at once.- Routes and views support optional SSG metadata:
renderMode?: 'ssg' | 'ssr' | 'spa',staticPaths?: string[], and a reservedssg?: { revalidateSeconds?: number }bag for future incremental/static revalidation hints. - Modules can declare
requestHooks?: { id, phase, order }[], and routes can opt into them withrequestHooks?: { id }[]. - Routes can also declare progressive-enhancement metadata with
interaction?: 'navigation' | 'mutation', optionalsession/flashmetadata, optional form-levelsession/flashmetadata, and an optionalfragmenttarget for partial updates. - Route
outputcan describe a single response case or aresponses: [...]list when native and enhanced flows diverge. - Route handlers may now return normal document/json bodies, targeted fragment updates, or redirects through the shared result contract, with
RouteNavigationResultandRouteMutationResultaliases for the common progressive-enhancement patterns.
Install
@ts-rest/coreto use the adapters; it's published as an optional peer dependency of this package.
Schema References
SchemaReference objects describe how a manifest entry maps back to the typed schema that produced it. Webstir modules emit these references in route.input, route.output, and view definitions so downstream tooling can point developers to the right file when something fails validation.
Format:
kind— schema system. Supported values:zod(default),json-schema,ts-rest.name— PascalCase identifier that matches the exported symbol or schema$id.source— optional module specifier (usually a workspace-relative path likesrc/backend/routes/accounts.tsor a package entry such as@demo/contracts/accounts.ts).
Naming & source guidance:
- Keep
namestable across builds. Use the TypeScript identifier forzod, the$id(or filename) for JSON Schema, and the router key for ts-rest adapters. - Use
sourcewhenever the schema lives outside the generated manifest (for example, insrc/shared/contracts/), and prefer workspace-relative paths so the CLI can resolve them after scaffolding. - When emitting references from the CLI, serialize the tuple as
kind:name@source(dropkind:forzodand omit@sourcewhen not needed). This is the same string accepted by--*-schemaflags onwebstir add-route. - Keep
kind/nameunique per file to avoid ambiguity when generators pre-populate manifest entries.
Usage Example
import { createModule, defineRoute, defineView, RequestContext, SSRContext } from '@webstir-io/module-contract';
import { z } from 'zod';
const paramsSchema = z.object({ id: z.string().uuid() });
const responseSchema = z.object({
id: z.string().uuid(),
email: z.string().email()
});
const viewDataSchema = z.object({ account: responseSchema });
const updateAccountEmailSchema = z.object({ email: z.string().email() });
function isEnhancedRequest(request: unknown): boolean {
if (!request || typeof request !== 'object') {
return false;
}
const headers = (request as { headers?: Record<string, string | string[] | undefined> }).headers;
const header = headers?.['x-webstir-client-nav'];
return header === '1' || (Array.isArray(header) && header.includes('1'));
}
const getAccount = defineRoute<RequestContext, typeof paramsSchema, undefined, undefined, typeof responseSchema>({
definition: {
name: 'getAccount',
method: 'GET',
path: '/accounts/:id',
requestHooks: [{ id: 'resolve-session' }, { id: 'audit-log' }],
session: {
mode: 'optional'
},
flash: {
consume: ['account-email-updated']
},
input: {
params: { kind: 'zod', name: 'AccountRouteParams', source: 'src/backend/server/routes/accounts.ts' }
},
output: {
body: { kind: 'zod', name: 'AccountRouteResponse', source: 'src/backend/server/routes/accounts.ts' },
status: 200
}
},
schemas: {
params: paramsSchema,
response: responseSchema
},
handler: async (ctx) => ({
status: 200,
body: await ctx.db.accounts.findById(ctx.params.id)
})
});
const updateAccountEmail = defineRoute<RequestContext, typeof paramsSchema, undefined, typeof updateAccountEmailSchema, typeof responseSchema>({
definition: {
name: 'updateAccountEmail',
method: 'POST',
path: '/accounts/:id/email',
interaction: 'mutation',
requestHooks: [{ id: 'resolve-session' }, { id: 'audit-log' }],
session: {
mode: 'required',
write: true
},
form: {
contentType: 'application/x-www-form-urlencoded',
csrf: true,
session: {
mode: 'required',
write: true
},
flash: {
publish: [{ key: 'account-email-updated', level: 'success', when: 'success' }]
}
},
fragment: {
target: 'account-email',
mode: 'replace'
},
input: {
params: { kind: 'zod', name: 'AccountRouteParams', source: 'src/backend/server/routes/accounts.ts' },
body: { kind: 'zod', name: 'UpdateAccountEmailInput', source: 'src/backend/server/routes/accounts.ts' }
},
output: {
responses: [
{
status: 200,
body: { kind: 'zod', name: 'AccountRouteResponse', source: 'src/backend/server/routes/accounts.ts' },
fragment: {
target: 'account-email',
mode: 'replace'
}
},
{
redirect: {
status: 303
}
}
]
}
},
schemas: {
params: paramsSchema,
body: updateAccountEmailSchema,
response: responseSchema
},
handler: async (ctx) => {
const account = await ctx.db.accounts.updateEmail(ctx.params.id, ctx.body.email);
if (isEnhancedRequest(ctx.request)) {
return {
status: 200,
fragment: {
target: 'account-email',
mode: 'replace',
body: account
}
};
}
return {
status: 303,
redirect: {
location: `/accounts/${ctx.params.id}`
}
};
}
});
const accountView = defineView<SSRContext, typeof paramsSchema, typeof viewDataSchema>({
definition: {
name: 'AccountView',
path: '/accounts/:id',
params: { kind: 'zod', name: 'AccountViewParams', source: 'src/backend/views/account.ts' },
data: { kind: 'zod', name: 'AccountViewData', source: 'src/backend/views/account.ts' },
// Optional SSG hints for frontend providers
renderMode: 'ssg',
staticPaths: ['/accounts/demo']
},
params: paramsSchema,
data: viewDataSchema,
load: async (ctx) => ({ account: await ctx.env.api.fetchAccount(ctx.params.id) })
});
export const accountsModule = createModule({
manifest: {
contractVersion: CONTRACT_VERSION,
name: '@demo/accounts',
version: '0.0.1',
kind: 'backend',
capabilities: ['auth', 'db', 'views'],
// Optional: pass-through metadata for providers
assets: [],
requestHooks: [
{
id: 'resolve-session',
phase: 'beforeAuth',
order: 10
},
{
id: 'audit-log',
phase: 'afterHandler',
order: 90
}
],
routes: [getAccount.definition, updateAccountEmail.definition],
views: [accountView.definition]
},
routes: [getAccount, updateAccountEmail],
views: [accountView]
});Use output.responses when a route may return different enhanced and baseline shapes from the same handler:
output: {
responses: [
{
status: 200,
body: { kind: 'zod', name: 'AccountRouteResponse' },
fragment: {
target: 'account-email',
mode: 'replace'
}
},
{
redirect: {
status: 303
}
}
]
};Fragment-capable handlers can still return partial updates directly:
return {
status: 200,
fragment: {
target: 'account-email',
mode: 'replace',
body: {
id: ctx.params.id,
email: '[email protected]'
}
}
};ts-rest Router Example
import { initContract } from '@ts-rest/core';
import { fromTsRestRouter, type RequestContext } from '@webstir-io/module-contract/ts-rest';
import { z } from 'zod';
const c = initContract();
const accountsRouter = c.router({
list: c.query({
path: '/accounts',
method: 'GET',
responses: {
200: z.object({
data: z.array(z.object({ id: z.string(), email: z.string().email() }))
})
}
}),
detail: c.query({
path: '/accounts/:id',
method: 'GET',
pathParams: z.object({ id: z.string().uuid() }),
responses: {
200: z.object({ id: z.string().uuid(), email: z.string().email() }),
404: z.null()
}
})
});
const routeSpecs = fromTsRestRouter<RequestContext>({
router: accountsRouter,
baseName: 'accounts',
createRoute: ({ keyPath, appRoute }) => ({
handler: async (ctx) => {
if (keyPath.at(-1) === 'detail') {
const account = await ctx.db.accounts.findById(ctx.params.id);
return account
? { status: 200, body: account }
: { status: 404, errors: [{ code: 'not_found', message: 'Account missing' }] };
}
const accounts = await ctx.db.accounts.list();
return { status: 200, body: { data: accounts } };
},
successStatus: appRoute.method === 'GET' ? 200 : undefined
})
});
// routeSpecs is a RouteSpec[] ready to feed into createModule({ routes: routeSpecs })When authoring a provider:
- Populate
metadatawith id, version, and CLI compatibility info. - Use
createModule/defineRoute/defineViewto declare runtime capabilities with Zod-powered validation. - Return absolute filesystem paths in
ModuleArtifact.pathfrom the build step. - Emit
ModuleDiagnostics for recoverable issues and include the module manifest inModuleBuildResult.manifest.module.
Community & Support
- Code of Conduct: https://github.com/webstir-io/.github/blob/main/CODE_OF_CONDUCT.md
- Contributing guidelines: https://github.com/webstir-io/.github/blob/main/CONTRIBUTING.md
- Security policy and disclosure process: https://github.com/webstir-io/.github/blob/main/SECURITY.md
- Support expectations and contact channels: https://github.com/webstir-io/.github/blob/main/SUPPORT.md
Maintainer Workflow
bun install
bun run clean # remove dist/schema artifacts
bun run build # compiles TypeScript, regenerates schema/*.schema.json
bun run test # type-checks the Accounts example module
# Release helper (bumps version and pushes a package-scoped release tag)
bun run release -- patch- The
schema/folder contains*-definition.schema.jsonfiles derived from the exported Zod schemas. Commit them with contract changes. - Ensure CI runs
bun install --frozen-lockfile,bun run clean,bun run build, andbun run testbefore publish.
License
MIT © Webstir
