@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 +156 -18
- package/dist/adapters/ts-rest.d.ts +2 -2
- package/dist/adapters/ts-rest.js +13 -11
- package/dist/index.d.ts +3116 -180
- package/dist/index.js +141 -18
- package/package.json +13 -17
- package/schema/module-manifest.schema.json +264 -18
- package/schema/route-definition.schema.json +230 -16
- package/schema/route-output.schema.json +133 -30
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
# Release helper (bumps version
|
|
212
|
-
|
|
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 `
|
|
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>):
|
|
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 {
|