@webstir-io/module-contract 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Electric Coding LLC and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # @webstir-io/module-contract
2
+
3
+ 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.
4
+
5
+ ## Status
6
+
7
+ - Experimental contract for the Webstir ecosystem — shapes and helpers may change as providers and orchestrator behavior are refined.
8
+ - Expect breaking changes across early versions; pin carefully if you integrate it outside Webstir itself.
9
+
10
+ ## Install
11
+
12
+ ```ini
13
+ # .npmrc
14
+ @webstir-io:registry=https://npm.pkg.github.com
15
+ //npm.pkg.github.com/:_authToken=${GH_PACKAGES_TOKEN}
16
+ ```
17
+
18
+ ```bash
19
+ npm install @webstir-io/module-contract
20
+ ```
21
+
22
+ Consumers only need `read:packages`; publishers also require `write:packages`.
23
+
24
+ ## Provided Types
25
+
26
+ ```ts
27
+ import {
28
+ ModuleProvider,
29
+ ModuleBuildResult,
30
+ moduleManifestSchema,
31
+ routeDefinitionSchema,
32
+ viewDefinitionSchema,
33
+ defineRoute,
34
+ defineView,
35
+ createModule,
36
+ CONTRACT_VERSION,
37
+ type RequestContext,
38
+ type SSRContext,
39
+ } from '@webstir-io/module-contract';
40
+
41
+ // Optional adapters live under a secondary export for teams using ts-rest
42
+ import { fromTsRestRoute, fromTsRestRouter } from '@webstir-io/module-contract/ts-rest';
43
+ ```
44
+
45
+ - `ModuleProvider` remains the build-time contract (`metadata`, `resolveWorkspace`, `build`).
46
+ - `moduleManifestSchema` / `routeDefinitionSchema` / `viewDefinitionSchema` model the runtime manifest that orchestrators ingest; the package publishes matching JSON schema under `schema/`.
47
+ - `defineRoute`, `defineView`, and `createModule` give providers ergonomic helpers with strong TypeScript inference.
48
+ - `RequestContext` and `SSRContext` describe what the orchestrator supplies to route and view handlers.
49
+ - `fromTsRestRoute` converts an `@ts-rest/core` route contract into a Webstir `RouteSpec`, and `fromTsRestRouter` adapts an entire ts-rest router tree at once.
50
+ - Routes and views support optional SSG metadata: `renderMode?: 'ssg' | 'ssr' | 'spa'`, `staticPaths?: string[]`, and a reserved `ssg?: { revalidateSeconds?: number }` bag for future incremental/static revalidation hints.
51
+
52
+ > Install `@ts-rest/core` to use the adapters; it's published as an optional peer dependency of this package.
53
+
54
+ ## Schema References
55
+
56
+ `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.
57
+
58
+ Format:
59
+ - `kind` — schema system. Supported values: `zod` (default), `json-schema`, `ts-rest`.
60
+ - `name` — PascalCase identifier that matches the exported symbol or schema `$id`.
61
+ - `source` — optional module specifier (usually a workspace-relative path like `src/backend/routes/accounts.ts` or a package entry such as `@demo/contracts/accounts.ts`).
62
+
63
+ Naming & source guidance:
64
+ - Keep `name` stable across builds. Use the TypeScript identifier for `zod`, the `$id` (or filename) for JSON Schema, and the router key for ts-rest adapters.
65
+ - Use `source` whenever the schema lives outside the generated manifest (for example, in `src/shared/contracts/`), and prefer workspace-relative paths so the CLI can resolve them after scaffolding.
66
+ - When emitting references from the CLI, serialize the tuple as `kind:name@source` (drop `kind:` for `zod` and omit `@source` when not needed). This is the same string accepted by `--*-schema` flags on `webstir add-route`.
67
+ - Keep `kind`/`name` unique per file to avoid ambiguity when generators pre-populate manifest entries.
68
+
69
+ ## Usage Example
70
+
71
+ ```ts
72
+ import { createModule, defineRoute, defineView, RequestContext, SSRContext } from '@webstir-io/module-contract';
73
+ import { z } from 'zod';
74
+
75
+ const paramsSchema = z.object({ id: z.string().uuid() });
76
+ const responseSchema = z.object({
77
+ id: z.string().uuid(),
78
+ email: z.string().email()
79
+ });
80
+ const viewDataSchema = z.object({ account: responseSchema });
81
+
82
+ const getAccount = defineRoute<RequestContext, typeof paramsSchema, undefined, undefined, typeof responseSchema>({
83
+ definition: {
84
+ name: 'getAccount',
85
+ method: 'GET',
86
+ path: '/accounts/:id',
87
+ input: {
88
+ params: { kind: 'zod', name: 'AccountRouteParams', source: 'src/backend/server/routes/accounts.ts' }
89
+ },
90
+ output: {
91
+ body: { kind: 'zod', name: 'AccountRouteResponse', source: 'src/backend/server/routes/accounts.ts' },
92
+ status: 200
93
+ }
94
+ },
95
+ schemas: {
96
+ params: paramsSchema,
97
+ response: responseSchema
98
+ },
99
+ handler: async (ctx) => ({
100
+ status: 200,
101
+ body: await ctx.db.accounts.findById(ctx.params.id)
102
+ })
103
+ });
104
+
105
+ const accountView = defineView<SSRContext, typeof paramsSchema, typeof viewDataSchema>({
106
+ definition: {
107
+ name: 'AccountView',
108
+ path: '/accounts/:id',
109
+ params: { kind: 'zod', name: 'AccountViewParams', source: 'src/backend/views/account.ts' },
110
+ data: { kind: 'zod', name: 'AccountViewData', source: 'src/backend/views/account.ts' },
111
+ // Optional SSG hints for frontend providers
112
+ renderMode: 'ssg',
113
+ staticPaths: ['/accounts/demo']
114
+ },
115
+ params: paramsSchema,
116
+ data: viewDataSchema,
117
+ load: async (ctx) => ({ account: await ctx.env.api.fetchAccount(ctx.params.id) })
118
+ });
119
+
120
+ export const accountsModule = createModule({
121
+ manifest: {
122
+ contractVersion: CONTRACT_VERSION,
123
+ name: '@demo/accounts',
124
+ version: '0.0.1',
125
+ kind: 'backend',
126
+ capabilities: ['auth', 'db', 'views'],
127
+ // Optional: pass-through metadata for providers
128
+ assets: [],
129
+ middlewares: [],
130
+ routes: [getAccount.definition],
131
+ views: [accountView.definition]
132
+ },
133
+ routes: [getAccount],
134
+ views: [accountView]
135
+ });
136
+ ```
137
+
138
+ ### ts-rest Router Example
139
+
140
+ ```ts
141
+ import { initContract } from '@ts-rest/core';
142
+ import { fromTsRestRouter, type RequestContext } from '@webstir-io/module-contract/ts-rest';
143
+ import { z } from 'zod';
144
+
145
+ const c = initContract();
146
+
147
+ const accountsRouter = c.router({
148
+ list: c.query({
149
+ path: '/accounts',
150
+ method: 'GET',
151
+ responses: {
152
+ 200: z.object({
153
+ data: z.array(z.object({ id: z.string(), email: z.string().email() }))
154
+ })
155
+ }
156
+ }),
157
+ detail: c.query({
158
+ path: '/accounts/:id',
159
+ method: 'GET',
160
+ pathParams: z.object({ id: z.string().uuid() }),
161
+ responses: {
162
+ 200: z.object({ id: z.string().uuid(), email: z.string().email() }),
163
+ 404: z.null()
164
+ }
165
+ })
166
+ });
167
+
168
+ const routeSpecs = fromTsRestRouter<RequestContext>({
169
+ router: accountsRouter,
170
+ baseName: 'accounts',
171
+ createRoute: ({ keyPath, appRoute }) => ({
172
+ handler: async (ctx) => {
173
+ if (keyPath.at(-1) === 'detail') {
174
+ const account = await ctx.db.accounts.findById(ctx.params.id);
175
+ return account
176
+ ? { status: 200, body: account }
177
+ : { status: 404, errors: [{ code: 'not_found', message: 'Account missing' }] };
178
+ }
179
+
180
+ const accounts = await ctx.db.accounts.list();
181
+ return { status: 200, body: { data: accounts } };
182
+ },
183
+ successStatus: appRoute.method === 'GET' ? 200 : undefined
184
+ })
185
+ });
186
+
187
+ // routeSpecs is a RouteSpec[] ready to feed into createModule({ routes: routeSpecs })
188
+ ```
189
+
190
+ When authoring a provider:
191
+
192
+ 1. Populate `metadata` with id, version, and CLI compatibility info.
193
+ 2. Use `createModule`/`defineRoute`/`defineView` to declare runtime capabilities with Zod-powered validation.
194
+ 3. Return absolute filesystem paths in `ModuleArtifact.path` from the build step.
195
+ 4. Emit `ModuleDiagnostic`s for recoverable issues and include the module manifest in `ModuleBuildResult.manifest.module`.
196
+
197
+ ## Community & Support
198
+
199
+ - Code of Conduct: https://github.com/webstir-io/.github/blob/main/CODE_OF_CONDUCT.md
200
+ - Contributing guidelines: https://github.com/webstir-io/.github/blob/main/CONTRIBUTING.md
201
+ - Security policy and disclosure process: https://github.com/webstir-io/.github/blob/main/SECURITY.md
202
+ - Support expectations and contact channels: https://github.com/webstir-io/.github/blob/main/SUPPORT.md
203
+
204
+ ## Maintainer Workflow
205
+
206
+ ```bash
207
+ npm install
208
+ npm run clean # remove dist/schema artifacts
209
+ npm run build # compiles TypeScript, regenerates schema/*.schema.json
210
+ npm run test # type-checks the Accounts example module
211
+ # Release helper (bumps version, pushes tags to trigger release workflow)
212
+ npm run release -- patch
213
+ ```
214
+
215
+ - The `schema/` folder contains `*-definition.schema.json` files derived from the exported Zod schemas. Commit them with contract changes.
216
+ - Ensure CI runs `npm ci`, `npm run clean`, `npm run build`, `npm run test`, and `npm run smoke` before publish.
217
+
218
+ ## License
219
+
220
+ MIT © Webstir
@@ -0,0 +1,38 @@
1
+ import type { AppRoute, AppRouter } from '@ts-rest/core';
2
+ import { z } from 'zod';
3
+ import type { ModuleError, RequestContext, RouteHandler, RouteSpec } from '../index.js';
4
+ interface BaseFromTsRestRouteOptions {
5
+ readonly name: string;
6
+ readonly appRoute: AppRoute;
7
+ readonly summary?: string;
8
+ readonly description?: string;
9
+ readonly tags?: readonly string[];
10
+ readonly schemaBaseName?: string;
11
+ readonly successStatus?: number;
12
+ readonly errors?: readonly ModuleError[];
13
+ readonly errorSchemas?: readonly z.ZodTypeAny[];
14
+ }
15
+ export interface FromTsRestRouteOptions<TContext extends RequestContext, TParams extends z.ZodTypeAny | undefined, TQuery extends z.ZodTypeAny | undefined, TBody extends z.ZodTypeAny | undefined, TResponse extends z.ZodTypeAny> extends BaseFromTsRestRouteOptions {
16
+ readonly handler: RouteHandler<TContext, TParams, TQuery, TBody, TResponse>;
17
+ readonly paramsSchema?: TParams;
18
+ readonly querySchema?: TQuery;
19
+ readonly bodySchema?: TBody;
20
+ readonly headersSchema?: z.ZodTypeAny;
21
+ readonly responseSchema?: TResponse;
22
+ }
23
+ type AnyRouteOptions<TContext extends RequestContext> = FromTsRestRouteOptions<TContext, z.ZodTypeAny | undefined, z.ZodTypeAny | undefined, z.ZodTypeAny | undefined, z.ZodTypeAny>;
24
+ export interface RouterRouteConfig<TContext extends RequestContext> extends Omit<AnyRouteOptions<TContext>, 'appRoute' | 'name'> {
25
+ readonly name?: string;
26
+ }
27
+ export interface FromTsRestRouterOptions<TContext extends RequestContext> {
28
+ readonly router: AppRouter;
29
+ readonly baseName?: string;
30
+ readonly createRoute: (info: {
31
+ readonly key: string;
32
+ readonly keyPath: readonly string[];
33
+ readonly appRoute: AppRoute;
34
+ }) => RouterRouteConfig<TContext>;
35
+ }
36
+ export declare function fromTsRestRoute<TContext extends RequestContext, TParams extends z.ZodTypeAny | undefined = undefined, TQuery extends z.ZodTypeAny | undefined = undefined, TBody extends z.ZodTypeAny | undefined = undefined, TResponse extends z.ZodTypeAny = z.ZodTypeAny>(options: FromTsRestRouteOptions<TContext, TParams, TQuery, TBody, TResponse>): RouteSpec<TContext, TParams, TQuery, TBody, TResponse>;
37
+ export declare function fromTsRestRouter<TContext extends RequestContext>(options: FromTsRestRouterOptions<TContext>): RouteSpec<TContext, any, any, any, any>[];
38
+ export {};
@@ -0,0 +1,147 @@
1
+ import { ContractNoBody, isAppRoute, isAppRouteMutation, isAppRouteNoBody, isAppRouteOtherResponse, isZodType } from '@ts-rest/core';
2
+ import { z } from 'zod';
3
+ const DEFAULT_SUCCESS_CODES = [200, 201, 202, 204];
4
+ const noopResponseSchema = z.void();
5
+ const isNonEmpty = (value) => Array.isArray(value) && value.length > 0;
6
+ const sanitizeSchemaBase = (raw) => raw.replace(/[^A-Za-z0-9]+/g, '_');
7
+ const capitalize = (value) => value.length === 0 ? value : value[0].toUpperCase() + value.slice(1);
8
+ const buildSchemaNames = (baseName, schemas) => {
9
+ const names = {};
10
+ const safeBase = sanitizeSchemaBase(baseName);
11
+ if (schemas.params) {
12
+ names.params = { kind: 'zod', name: `${safeBase}${capitalize('params')}` };
13
+ }
14
+ if (schemas.query) {
15
+ names.query = { kind: 'zod', name: `${safeBase}${capitalize('query')}` };
16
+ }
17
+ if (schemas.body) {
18
+ names.body = { kind: 'zod', name: `${safeBase}${capitalize('body')}` };
19
+ }
20
+ if (schemas.headers) {
21
+ names.headers = { kind: 'zod', name: `${safeBase}${capitalize('headers')}` };
22
+ }
23
+ if (schemas.response) {
24
+ names.response = { kind: 'zod', name: `${safeBase}${capitalize('response')}` };
25
+ }
26
+ return names;
27
+ };
28
+ const selectResponse = (route, preferred) => {
29
+ const statusCodes = Object.keys(route.responses).map((status) => Number.parseInt(status, 10));
30
+ if (statusCodes.length === 0) {
31
+ return { response: undefined };
32
+ }
33
+ const ordered = statusCodes.sort((a, b) => a - b);
34
+ const preferredStatus = preferred ?? ordered.find((code) => DEFAULT_SUCCESS_CODES.includes(code));
35
+ const status = preferredStatus ?? ordered[0];
36
+ return { status, response: route.responses[status] };
37
+ };
38
+ const toZod = (value) => {
39
+ if (!value) {
40
+ return undefined;
41
+ }
42
+ if (isZodType(value)) {
43
+ return value;
44
+ }
45
+ if (typeof value === 'object' && value !== null && 'body' in value) {
46
+ const body = value.body;
47
+ if (isZodType(body)) {
48
+ return body;
49
+ }
50
+ }
51
+ return undefined;
52
+ };
53
+ const extractResponseSchema = (response) => {
54
+ if (!response) {
55
+ return undefined;
56
+ }
57
+ if (response === ContractNoBody || isAppRouteNoBody(response)) {
58
+ return undefined;
59
+ }
60
+ if (isAppRouteOtherResponse(response)) {
61
+ return toZod(response.body);
62
+ }
63
+ return toZod(response);
64
+ };
65
+ export function fromTsRestRoute(options) {
66
+ const { appRoute, name, summary, description, tags, schemaBaseName, successStatus, paramsSchema, querySchema, bodySchema, headersSchema, responseSchema, handler, errors, errorSchemas } = options;
67
+ const resolvedParamsSchema = (paramsSchema ?? toZod(appRoute.pathParams));
68
+ const resolvedQuerySchema = (querySchema ?? toZod(appRoute.query));
69
+ const resolvedHeadersSchema = headersSchema ?? toZod(appRoute.headers);
70
+ const mutationBodySchema = isAppRouteMutation(appRoute) ? toZod(appRoute.body) : undefined;
71
+ const resolvedBodySchema = (bodySchema ?? mutationBodySchema);
72
+ const { status: derivedStatus, response: routeResponse } = selectResponse(appRoute, successStatus);
73
+ const baseResponseSchema = extractResponseSchema(routeResponse);
74
+ const resolvedResponseSchema = (responseSchema ?? baseResponseSchema ?? noopResponseSchema);
75
+ const schemaNames = buildSchemaNames(schemaBaseName ?? name, {
76
+ params: resolvedParamsSchema,
77
+ query: resolvedQuerySchema,
78
+ body: resolvedBodySchema,
79
+ headers: resolvedHeadersSchema,
80
+ response: resolvedResponseSchema
81
+ });
82
+ const definitionInput = {
83
+ params: schemaNames.params,
84
+ query: schemaNames.query,
85
+ body: schemaNames.body,
86
+ headers: schemaNames.headers
87
+ };
88
+ const hasInput = Object.values(definitionInput).some((value) => value !== undefined);
89
+ return {
90
+ definition: {
91
+ name,
92
+ method: appRoute.method,
93
+ path: appRoute.path,
94
+ summary: summary ?? appRoute.summary,
95
+ description: description ?? appRoute.description,
96
+ tags: isNonEmpty(tags) ? [...tags] : undefined,
97
+ input: hasInput
98
+ ? {
99
+ params: schemaNames.params,
100
+ query: schemaNames.query,
101
+ body: schemaNames.body,
102
+ headers: schemaNames.headers
103
+ }
104
+ : undefined,
105
+ output: schemaNames.response
106
+ ? {
107
+ body: schemaNames.response,
108
+ status: derivedStatus
109
+ }
110
+ : undefined,
111
+ errors: isNonEmpty(errors) ? [...errors] : undefined
112
+ },
113
+ schemas: {
114
+ params: resolvedParamsSchema,
115
+ query: resolvedQuerySchema,
116
+ body: resolvedBodySchema,
117
+ headers: resolvedHeadersSchema,
118
+ response: resolvedResponseSchema,
119
+ errors: errorSchemas
120
+ },
121
+ handler
122
+ };
123
+ }
124
+ export function fromTsRestRouter(options) {
125
+ const { router, createRoute, baseName } = options;
126
+ const specs = [];
127
+ const visit = (node, path) => {
128
+ for (const [key, value] of Object.entries(node)) {
129
+ if (isAppRoute(value)) {
130
+ const keyPath = [...path, key];
131
+ const routeConfig = createRoute({ key, keyPath, appRoute: value });
132
+ const nameSegments = [baseName, ...keyPath.filter(Boolean)].filter(Boolean);
133
+ const routeName = routeConfig.name ?? nameSegments.join('.');
134
+ specs.push(fromTsRestRoute({
135
+ appRoute: value,
136
+ name: routeName,
137
+ ...routeConfig
138
+ }));
139
+ }
140
+ else {
141
+ visit(value, [...path, key]);
142
+ }
143
+ }
144
+ };
145
+ visit(router, []);
146
+ return specs;
147
+ }