@webstir-io/module-contract 0.1.15 → 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
@@ -40,6 +40,10 @@ import { fromTsRestRoute, fromTsRestRouter } from '@webstir-io/module-contract/t
40
40
  - `RequestContext` and `SSRContext` describe what the orchestrator supplies to route and view handlers.
41
41
  - `fromTsRestRoute` converts an `@ts-rest/core` route contract into a Webstir `RouteSpec`, and `fromTsRestRouter` adapts an entire ts-rest router tree at once.
42
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.
43
47
 
44
48
  > Install `@ts-rest/core` to use the adapters; it's published as an optional peer dependency of this package.
45
49
 
@@ -70,12 +74,30 @@ const responseSchema = z.object({
70
74
  email: z.string().email()
71
75
  });
72
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
+ }
73
88
 
74
89
  const getAccount = defineRoute<RequestContext, typeof paramsSchema, undefined, undefined, typeof responseSchema>({
75
90
  definition: {
76
91
  name: 'getAccount',
77
92
  method: 'GET',
78
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
+ },
79
101
  input: {
80
102
  params: { kind: 'zod', name: 'AccountRouteParams', source: 'src/backend/server/routes/accounts.ts' }
81
103
  },
@@ -94,6 +116,81 @@ const getAccount = defineRoute<RequestContext, typeof paramsSchema, undefined, u
94
116
  })
95
117
  });
96
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
+
97
194
  const accountView = defineView<SSRContext, typeof paramsSchema, typeof viewDataSchema>({
98
195
  definition: {
99
196
  name: 'AccountView',
@@ -118,15 +215,64 @@ export const accountsModule = createModule({
118
215
  capabilities: ['auth', 'db', 'views'],
119
216
  // Optional: pass-through metadata for providers
120
217
  assets: [],
121
- middlewares: [],
122
- 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],
123
231
  views: [accountView.definition]
124
232
  },
125
- routes: [getAccount],
233
+ routes: [getAccount, updateAccountEmail],
126
234
  views: [accountView]
127
235
  });
128
236
  ```
129
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
+
130
276
  ### ts-rest Router Example
131
277
 
132
278
  ```ts
@@ -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 {