@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 +149 -3
- package/dist/adapters/ts-rest.d.ts +2 -2
- package/dist/adapters/ts-rest.js +13 -11
- package/dist/index.d.ts +3108 -180
- package/dist/index.js +140 -18
- package/package.json +5 -5
- package/schema/module-manifest.schema.json +261 -18
- package/schema/route-definition.schema.json +230 -16
- package/schema/route-output.schema.json +133 -30
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
|
-
|
|
122
|
-
|
|
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>):
|
|
37
|
+
export declare function fromTsRestRouter<TContext extends RequestContext>(options: FromTsRestRouterOptions<TContext>): LooseRouteSpec<TContext>[];
|
|
38
38
|
export {};
|
package/dist/adapters/ts-rest.js
CHANGED
|
@@ -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 ??
|
|
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 {
|