@webstir-io/module-contract 0.1.14 → 0.1.16

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/README.md CHANGED
@@ -9,18 +9,10 @@ TypeScript interfaces, helper utilities, and JSON schema describing Webstir modu
9
9
 
10
10
  ## Install
11
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
12
  ```bash
19
13
  npm install @webstir-io/module-contract
20
14
  ```
21
15
 
22
- Consumers only need `read:packages`; publishers also require `write:packages`.
23
-
24
16
  ## Provided Types
25
17
 
26
18
  ```ts
@@ -48,6 +40,10 @@ import { fromTsRestRoute, fromTsRestRouter } from '@webstir-io/module-contract/t
48
40
  - `RequestContext` and `SSRContext` describe what the orchestrator supplies to route and view handlers.
49
41
  - `fromTsRestRoute` converts an `@ts-rest/core` route contract into a Webstir `RouteSpec`, and `fromTsRestRouter` adapts an entire ts-rest router tree at once.
50
42
  - 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.
43
+ - Modules can declare `requestHooks?: { id, phase, order }[]`, and routes can opt into them with `requestHooks?: { id }[]`.
44
+ - Routes can also declare progressive-enhancement metadata with `interaction?: 'navigation' | 'mutation'`, optional `session`/`flash` metadata, optional form-level `session`/`flash` metadata, and an optional `fragment` target for partial updates.
45
+ - Route `output` can describe a single response case or a `responses: [...]` list when native and enhanced flows diverge.
46
+ - Route handlers may now return normal document/json bodies, targeted fragment updates, or redirects through the shared result contract, with `RouteNavigationResult` and `RouteMutationResult` aliases for the common progressive-enhancement patterns.
51
47
 
52
48
  > Install `@ts-rest/core` to use the adapters; it's published as an optional peer dependency of this package.
53
49
 
@@ -78,12 +74,30 @@ const responseSchema = z.object({
78
74
  email: z.string().email()
79
75
  });
80
76
  const viewDataSchema = z.object({ account: responseSchema });
77
+ const updateAccountEmailSchema = z.object({ email: z.string().email() });
78
+
79
+ function isEnhancedRequest(request: unknown): boolean {
80
+ if (!request || typeof request !== 'object') {
81
+ return false;
82
+ }
83
+
84
+ const headers = (request as { headers?: Record<string, string | string[] | undefined> }).headers;
85
+ const header = headers?.['x-webstir-client-nav'];
86
+ return header === '1' || (Array.isArray(header) && header.includes('1'));
87
+ }
81
88
 
82
89
  const getAccount = defineRoute<RequestContext, typeof paramsSchema, undefined, undefined, typeof responseSchema>({
83
90
  definition: {
84
91
  name: 'getAccount',
85
92
  method: 'GET',
86
93
  path: '/accounts/:id',
94
+ requestHooks: [{ id: 'resolve-session' }, { id: 'audit-log' }],
95
+ session: {
96
+ mode: 'optional'
97
+ },
98
+ flash: {
99
+ consume: ['account-email-updated']
100
+ },
87
101
  input: {
88
102
  params: { kind: 'zod', name: 'AccountRouteParams', source: 'src/backend/server/routes/accounts.ts' }
89
103
  },
@@ -102,6 +116,81 @@ const getAccount = defineRoute<RequestContext, typeof paramsSchema, undefined, u
102
116
  })
103
117
  });
104
118
 
119
+ const updateAccountEmail = defineRoute<RequestContext, typeof paramsSchema, undefined, typeof updateAccountEmailSchema, typeof responseSchema>({
120
+ definition: {
121
+ name: 'updateAccountEmail',
122
+ method: 'POST',
123
+ path: '/accounts/:id/email',
124
+ interaction: 'mutation',
125
+ requestHooks: [{ id: 'resolve-session' }, { id: 'audit-log' }],
126
+ session: {
127
+ mode: 'required',
128
+ write: true
129
+ },
130
+ form: {
131
+ contentType: 'application/x-www-form-urlencoded',
132
+ csrf: true,
133
+ session: {
134
+ mode: 'required',
135
+ write: true
136
+ },
137
+ flash: {
138
+ publish: [{ key: 'account-email-updated', level: 'success', when: 'success' }]
139
+ }
140
+ },
141
+ fragment: {
142
+ target: 'account-email',
143
+ mode: 'replace'
144
+ },
145
+ input: {
146
+ params: { kind: 'zod', name: 'AccountRouteParams', source: 'src/backend/server/routes/accounts.ts' },
147
+ body: { kind: 'zod', name: 'UpdateAccountEmailInput', source: 'src/backend/server/routes/accounts.ts' }
148
+ },
149
+ output: {
150
+ responses: [
151
+ {
152
+ status: 200,
153
+ body: { kind: 'zod', name: 'AccountRouteResponse', source: 'src/backend/server/routes/accounts.ts' },
154
+ fragment: {
155
+ target: 'account-email',
156
+ mode: 'replace'
157
+ }
158
+ },
159
+ {
160
+ redirect: {
161
+ status: 303
162
+ }
163
+ }
164
+ ]
165
+ }
166
+ },
167
+ schemas: {
168
+ params: paramsSchema,
169
+ body: updateAccountEmailSchema,
170
+ response: responseSchema
171
+ },
172
+ handler: async (ctx) => {
173
+ const account = await ctx.db.accounts.updateEmail(ctx.params.id, ctx.body.email);
174
+ if (isEnhancedRequest(ctx.request)) {
175
+ return {
176
+ status: 200,
177
+ fragment: {
178
+ target: 'account-email',
179
+ mode: 'replace',
180
+ body: account
181
+ }
182
+ };
183
+ }
184
+
185
+ return {
186
+ status: 303,
187
+ redirect: {
188
+ location: `/accounts/${ctx.params.id}`
189
+ }
190
+ };
191
+ }
192
+ });
193
+
105
194
  const accountView = defineView<SSRContext, typeof paramsSchema, typeof viewDataSchema>({
106
195
  definition: {
107
196
  name: 'AccountView',
@@ -126,15 +215,64 @@ export const accountsModule = createModule({
126
215
  capabilities: ['auth', 'db', 'views'],
127
216
  // Optional: pass-through metadata for providers
128
217
  assets: [],
129
- middlewares: [],
130
- routes: [getAccount.definition],
218
+ requestHooks: [
219
+ {
220
+ id: 'resolve-session',
221
+ phase: 'beforeAuth',
222
+ order: 10
223
+ },
224
+ {
225
+ id: 'audit-log',
226
+ phase: 'afterHandler',
227
+ order: 90
228
+ }
229
+ ],
230
+ routes: [getAccount.definition, updateAccountEmail.definition],
131
231
  views: [accountView.definition]
132
232
  },
133
- routes: [getAccount],
233
+ routes: [getAccount, updateAccountEmail],
134
234
  views: [accountView]
135
235
  });
136
236
  ```
137
237
 
238
+ Use `output.responses` when a route may return different enhanced and baseline shapes from the same handler:
239
+
240
+ ```ts
241
+ output: {
242
+ responses: [
243
+ {
244
+ status: 200,
245
+ body: { kind: 'zod', name: 'AccountRouteResponse' },
246
+ fragment: {
247
+ target: 'account-email',
248
+ mode: 'replace'
249
+ }
250
+ },
251
+ {
252
+ redirect: {
253
+ status: 303
254
+ }
255
+ }
256
+ ]
257
+ };
258
+ ```
259
+
260
+ Fragment-capable handlers can still return partial updates directly:
261
+
262
+ ```ts
263
+ return {
264
+ status: 200,
265
+ fragment: {
266
+ target: 'account-email',
267
+ mode: 'replace',
268
+ body: {
269
+ id: ctx.params.id,
270
+ email: 'updated@example.com'
271
+ }
272
+ }
273
+ };
274
+ ```
275
+
138
276
  ### ts-rest Router Example
139
277
 
140
278
  ```ts
@@ -204,16 +342,16 @@ When authoring a provider:
204
342
  ## Maintainer Workflow
205
343
 
206
344
  ```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
345
+ bun install
346
+ bun run clean # remove dist/schema artifacts
347
+ bun run build # compiles TypeScript, regenerates schema/*.schema.json
348
+ bun run test # type-checks the Accounts example module
349
+ # Release helper (bumps version and pushes a package-scoped release tag)
350
+ bun run release -- patch
213
351
  ```
214
352
 
215
353
  - 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.
354
+ - Ensure CI runs `bun install --frozen-lockfile`, `bun run clean`, `bun run build`, and `bun run test` before publish.
217
355
 
218
356
  ## License
219
357
 
@@ -1,6 +1,6 @@
1
1
  import type { AppRoute, AppRouter } from '@ts-rest/core';
2
2
  import { z } from 'zod';
3
- import type { ModuleError, RequestContext, RouteHandler, RouteSpec } from '../index.js';
3
+ import type { LooseRouteSpec, ModuleError, RequestContext, RouteHandler, RouteSpec } from '../index.js';
4
4
  interface BaseFromTsRestRouteOptions {
5
5
  readonly name: string;
6
6
  readonly appRoute: AppRoute;
@@ -34,5 +34,5 @@ export interface FromTsRestRouterOptions<TContext extends RequestContext> {
34
34
  }) => RouterRouteConfig<TContext>;
35
35
  }
36
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>[];
37
+ export declare function fromTsRestRouter<TContext extends RequestContext>(options: FromTsRestRouterOptions<TContext>): LooseRouteSpec<TContext>[];
38
38
  export {};
@@ -1,4 +1,4 @@
1
- import { ContractNoBody, isAppRoute, isAppRouteMutation, isAppRouteNoBody, isAppRouteOtherResponse, isZodType } from '@ts-rest/core';
1
+ import { ContractNoBody, isAppRoute, isAppRouteMutation, isAppRouteNoBody, isAppRouteOtherResponse, isZodType, } from '@ts-rest/core';
2
2
  import { z } from 'zod';
3
3
  const DEFAULT_SUCCESS_CODES = [200, 201, 202, 204];
4
4
  const noopResponseSchema = z.void();
@@ -63,7 +63,7 @@ const extractResponseSchema = (response) => {
63
63
  return toZod(response);
64
64
  };
65
65
  export function fromTsRestRoute(options) {
66
- const { appRoute, name, summary, description, tags, schemaBaseName, successStatus, paramsSchema, querySchema, bodySchema, headersSchema, responseSchema, handler, errors, errorSchemas } = options;
66
+ const { appRoute, name, summary, description, tags, schemaBaseName, successStatus, paramsSchema, querySchema, bodySchema, headersSchema, responseSchema, handler, errors, errorSchemas, } = options;
67
67
  const resolvedParamsSchema = (paramsSchema ?? toZod(appRoute.pathParams));
68
68
  const resolvedQuerySchema = (querySchema ?? toZod(appRoute.query));
69
69
  const resolvedHeadersSchema = headersSchema ?? toZod(appRoute.headers);
@@ -71,19 +71,21 @@ export function fromTsRestRoute(options) {
71
71
  const resolvedBodySchema = (bodySchema ?? mutationBodySchema);
72
72
  const { status: derivedStatus, response: routeResponse } = selectResponse(appRoute, successStatus);
73
73
  const baseResponseSchema = extractResponseSchema(routeResponse);
74
- const resolvedResponseSchema = (responseSchema ?? baseResponseSchema ?? noopResponseSchema);
74
+ const resolvedResponseSchema = (responseSchema ??
75
+ baseResponseSchema ??
76
+ noopResponseSchema);
75
77
  const schemaNames = buildSchemaNames(schemaBaseName ?? name, {
76
78
  params: resolvedParamsSchema,
77
79
  query: resolvedQuerySchema,
78
80
  body: resolvedBodySchema,
79
81
  headers: resolvedHeadersSchema,
80
- response: resolvedResponseSchema
82
+ response: resolvedResponseSchema,
81
83
  });
82
84
  const definitionInput = {
83
85
  params: schemaNames.params,
84
86
  query: schemaNames.query,
85
87
  body: schemaNames.body,
86
- headers: schemaNames.headers
88
+ headers: schemaNames.headers,
87
89
  };
88
90
  const hasInput = Object.values(definitionInput).some((value) => value !== undefined);
89
91
  return {
@@ -99,16 +101,16 @@ export function fromTsRestRoute(options) {
99
101
  params: schemaNames.params,
100
102
  query: schemaNames.query,
101
103
  body: schemaNames.body,
102
- headers: schemaNames.headers
104
+ headers: schemaNames.headers,
103
105
  }
104
106
  : undefined,
105
107
  output: schemaNames.response
106
108
  ? {
107
109
  body: schemaNames.response,
108
- status: derivedStatus
110
+ status: derivedStatus,
109
111
  }
110
112
  : undefined,
111
- errors: isNonEmpty(errors) ? [...errors] : undefined
113
+ errors: isNonEmpty(errors) ? [...errors] : undefined,
112
114
  },
113
115
  schemas: {
114
116
  params: resolvedParamsSchema,
@@ -116,9 +118,9 @@ export function fromTsRestRoute(options) {
116
118
  body: resolvedBodySchema,
117
119
  headers: resolvedHeadersSchema,
118
120
  response: resolvedResponseSchema,
119
- errors: errorSchemas
121
+ errors: errorSchemas,
120
122
  },
121
- handler
123
+ handler,
122
124
  };
123
125
  }
124
126
  export function fromTsRestRouter(options) {
@@ -134,7 +136,7 @@ export function fromTsRestRouter(options) {
134
136
  specs.push(fromTsRestRoute({
135
137
  appRoute: value,
136
138
  name: routeName,
137
- ...routeConfig
139
+ ...routeConfig,
138
140
  }));
139
141
  }
140
142
  else {