@spfn/core 0.3.0-beta.3 → 0.3.0-beta.31
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 +304 -12
- package/dist/app-config/index.d.ts +39 -1
- package/dist/app-config/index.js.map +1 -1
- package/dist/authz/index.js +47 -391
- package/dist/authz/index.js.map +1 -1
- package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
- package/dist/codegen/index.d.ts +157 -16
- package/dist/codegen/index.js +772 -166
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.js +4 -4
- package/dist/config/index.js.map +1 -1
- package/dist/contract/index.d.ts +39 -16
- package/dist/contract/index.js +147 -23
- package/dist/contract/index.js.map +1 -1
- package/dist/db/index.d.ts +230 -35
- package/dist/db/index.js +316 -160
- package/dist/db/index.js.map +1 -1
- package/dist/env/index.d.ts +6 -442
- package/dist/env/index.js +13 -16
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/error-CIZlYJlL.d.ts +157 -0
- package/dist/errors/index.d.ts +18 -1
- package/dist/errors/index.js +47 -391
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.d.ts +5 -5
- package/dist/event/index.js +202 -3
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +18 -4
- package/dist/event/sse/client.js +94 -18
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +18 -5
- package/dist/event/sse/index.js +452 -114
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +3 -2
- package/dist/event/ws/index.d.ts +4 -3
- package/dist/job/index.d.ts +48 -3
- package/dist/job/index.js +131 -52
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.js +0 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.js +52 -397
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +75 -14
- package/dist/nextjs/index.js +200 -84
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +46 -8
- package/dist/nextjs/server.js +15 -1
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +61 -42
- package/dist/ops/index.js +648 -97
- package/dist/ops/index.js.map +1 -1
- package/dist/registry-C2KQCh0m.d.ts +473 -0
- package/dist/route/index.d.ts +5 -2
- package/dist/route/index.js +107 -28
- package/dist/route/index.js.map +1 -1
- package/dist/route/types.d.ts +7 -1
- package/dist/{router-Cy7rAfmj.d.ts → route-builder-DRW-MSCJ.d.ts} +51 -126
- package/dist/router-C57y-vz1.d.ts +184 -0
- package/dist/router-CX5uVjmU.d.ts +406 -0
- package/dist/server/index.d.ts +130 -7
- package/dist/server/index.js +1174 -164
- package/dist/server/index.js.map +1 -1
- package/dist/state/client.d.ts +206 -0
- package/dist/state/client.js +678 -0
- package/dist/state/client.js.map +1 -0
- package/dist/state/index.d.ts +443 -0
- package/dist/state/index.js +644 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/react.d.ts +49 -0
- package/dist/state/react.js +50 -0
- package/dist/state/react.js.map +1 -0
- package/dist/surface/index.d.ts +115 -0
- package/dist/surface/index.js +1013 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
- package/dist/{types-CfYVhIQ9.d.ts → types-5Rl11_Nb.d.ts} +43 -4
- package/dist/types-CKH0D3Dy.d.ts +441 -0
- package/dist/{types-ZQODsBft.d.ts → types-CurMab2Q.d.ts} +52 -2
- package/dist/{types-2AbaW4Ie.d.ts → types-DncqXfWN.d.ts} +4 -3
- package/docs/file-upload.md +195 -333
- package/package.json +41 -11
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +721 -0
- package/src/config/README.md +333 -0
- package/src/contract/README.md +442 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +354 -0
- package/src/db/transaction/README.md +845 -0
- package/src/env/README.md +787 -0
- package/src/errors/README.md +445 -0
- package/src/event/README.md +994 -0
- package/src/job/README.md +613 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +756 -0
- package/src/route/README.md +763 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
- package/src/state/README.md +806 -0
- package/dist/token-manager-BT5EnUAR.d.ts +0 -278
- package/dist/types-9oszaJqp.d.ts +0 -250
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
// src/route/route-builder.ts
|
|
2
|
+
var RouteBuilder = class _RouteBuilder {
|
|
3
|
+
_method;
|
|
4
|
+
_path;
|
|
5
|
+
_input;
|
|
6
|
+
_interceptor;
|
|
7
|
+
_middlewares;
|
|
8
|
+
_skipMiddlewares;
|
|
9
|
+
_contract;
|
|
10
|
+
_effect;
|
|
11
|
+
_summary;
|
|
12
|
+
/**
|
|
13
|
+
* Create a new RouteBuilder with copied properties and optional overrides
|
|
14
|
+
*/
|
|
15
|
+
clone(overrides) {
|
|
16
|
+
const builder = new _RouteBuilder();
|
|
17
|
+
builder._method = this._method;
|
|
18
|
+
builder._path = this._path;
|
|
19
|
+
builder._input = overrides?.input ?? this._input;
|
|
20
|
+
builder._interceptor = overrides?.interceptor ?? this._interceptor;
|
|
21
|
+
builder._middlewares = overrides?.middlewares ?? this._middlewares;
|
|
22
|
+
builder._skipMiddlewares = overrides?.skipMiddlewares ?? this._skipMiddlewares;
|
|
23
|
+
builder._contract = overrides?.contract ?? this._contract;
|
|
24
|
+
builder._effect = overrides?.effect ?? this._effect;
|
|
25
|
+
builder._summary = overrides?.summary ?? this._summary;
|
|
26
|
+
return builder;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Define input schemas
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* route.get('/users/:id')
|
|
34
|
+
* .input({
|
|
35
|
+
* params: Type.Object({ id: Type.String() }),
|
|
36
|
+
* query: Type.Object({ page: Type.Number() }),
|
|
37
|
+
* headers: Type.Object({ authorization: Type.String() })
|
|
38
|
+
* })
|
|
39
|
+
* .handler(async (c) => {
|
|
40
|
+
* const { params, query, headers } = await c.data();
|
|
41
|
+
* // params = { id: string }
|
|
42
|
+
* // query = { page: number }
|
|
43
|
+
* // headers = { authorization: string }
|
|
44
|
+
* })
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
input(input) {
|
|
48
|
+
return this.clone({ input });
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Define fields injected by interceptors
|
|
52
|
+
*
|
|
53
|
+
* These fields are:
|
|
54
|
+
* - Available in the handler (merged with input)
|
|
55
|
+
* - Excluded from client types (codegen uses only input)
|
|
56
|
+
* - Not validated by route input schema (injected by middleware)
|
|
57
|
+
*
|
|
58
|
+
* Use this when middleware/interceptors add fields to the request
|
|
59
|
+
* before it reaches the handler.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* // Auth interceptor injects crypto key fields
|
|
64
|
+
* route.post('/_auth/login')
|
|
65
|
+
* .input({
|
|
66
|
+
* body: Type.Object({
|
|
67
|
+
* email: Type.String(),
|
|
68
|
+
* password: Type.String()
|
|
69
|
+
* })
|
|
70
|
+
* })
|
|
71
|
+
* .interceptor({
|
|
72
|
+
* body: Type.Object({
|
|
73
|
+
* publicKey: Type.String(),
|
|
74
|
+
* keyId: Type.String(),
|
|
75
|
+
* fingerprint: Type.String()
|
|
76
|
+
* })
|
|
77
|
+
* })
|
|
78
|
+
* .handler(async (c) => {
|
|
79
|
+
* const { body } = await c.data();
|
|
80
|
+
* // body type: { email, password, publicKey, keyId, fingerprint }
|
|
81
|
+
* // Client only sees: { email, password }
|
|
82
|
+
* return loginService(body);
|
|
83
|
+
* });
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
interceptor(interceptor) {
|
|
87
|
+
return this.clone({ interceptor });
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Add middlewares to the route
|
|
91
|
+
*
|
|
92
|
+
* Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
|
|
93
|
+
* Named middlewares that are already registered globally will be automatically
|
|
94
|
+
* deduplicated to prevent double execution.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```ts
|
|
98
|
+
* import { authenticate } from '@spfn/auth/server/middleware';
|
|
99
|
+
*
|
|
100
|
+
* // With NamedMiddleware (auto-deduped if registered globally)
|
|
101
|
+
* route.get('/users')
|
|
102
|
+
* .use([authenticate, RateLimitMiddleware()])
|
|
103
|
+
*
|
|
104
|
+
* // With regular middleware handlers
|
|
105
|
+
* route.get('/users')
|
|
106
|
+
* .use([AuthMiddleware(), RateLimitMiddleware()])
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
middleware(middlewares) {
|
|
110
|
+
return this.clone({ middlewares });
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Add middlewares to the route (alias for `.middleware()`)
|
|
114
|
+
*
|
|
115
|
+
* Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
|
|
116
|
+
* Named middlewares that are already registered globally will be automatically
|
|
117
|
+
* deduplicated to prevent double execution.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* import { authenticate } from '@spfn/auth/server/middleware';
|
|
122
|
+
*
|
|
123
|
+
* // With NamedMiddleware (auto-deduped if registered globally)
|
|
124
|
+
* route.get('/users')
|
|
125
|
+
* .use([authenticate, RateLimitMiddleware()])
|
|
126
|
+
*
|
|
127
|
+
* // With regular middleware handlers
|
|
128
|
+
* route.get('/users')
|
|
129
|
+
* .use([AuthMiddleware(), RateLimitMiddleware()])
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
use(middlewares) {
|
|
133
|
+
return this.middleware(middlewares);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Skip server-level named middlewares
|
|
137
|
+
*
|
|
138
|
+
* Useful for public endpoints that should bypass auth or rate limiting
|
|
139
|
+
*
|
|
140
|
+
* @param middlewareNames - Array of middleware names to skip, or '*' to skip all
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* ```ts
|
|
144
|
+
* // Skip specific middlewares
|
|
145
|
+
* route.get('/status')
|
|
146
|
+
* .skip(['auth', 'rateLimit'])
|
|
147
|
+
* .handler(async (c) => c.json({ status: 'ok' }));
|
|
148
|
+
*
|
|
149
|
+
* // Skip only auth (still apply rate limiting)
|
|
150
|
+
* route.get('/public-data')
|
|
151
|
+
* .skip(['auth'])
|
|
152
|
+
* .handler(async (c) => { ... });
|
|
153
|
+
*
|
|
154
|
+
* // Skip all middlewares
|
|
155
|
+
* route.get('/public-health')
|
|
156
|
+
* .skip('*')
|
|
157
|
+
* .handler(async (c) => c.json({ status: 'ok' }));
|
|
158
|
+
* ```
|
|
159
|
+
*/
|
|
160
|
+
skip(middlewareNames) {
|
|
161
|
+
return this.clone({ skipMiddlewares: middlewareNames });
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Publish this route as a versioned contract operation
|
|
165
|
+
*
|
|
166
|
+
* Marks the route as a promise to clients that are compiled and deployed
|
|
167
|
+
* separately from the server — a mobile app, an external API consumer.
|
|
168
|
+
* The `@spfn/core:contract` generator writes every contracted route into
|
|
169
|
+
* `contracts/current.json`, and the build refuses a change that would break
|
|
170
|
+
* an already-released client.
|
|
171
|
+
*
|
|
172
|
+
* Routes without `.contract()` are unaffected: they simply do not appear in
|
|
173
|
+
* the contract. A web client needs nothing here — it derives its types from
|
|
174
|
+
* the router in the same build.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```ts
|
|
178
|
+
* export const getUser = route.get('/users/:id')
|
|
179
|
+
* .input({ params: Type.Object({ id: Type.String() }) })
|
|
180
|
+
* .contract({
|
|
181
|
+
* since: '1.2.0',
|
|
182
|
+
* auth: 'clientProofV1',
|
|
183
|
+
* requiresSession: true,
|
|
184
|
+
* response: Type.Object({
|
|
185
|
+
* id: Type.String(),
|
|
186
|
+
* name: Type.String(),
|
|
187
|
+
* email: Type.Optional(Type.String()),
|
|
188
|
+
* }),
|
|
189
|
+
* })
|
|
190
|
+
* .handler(async (c) => { ... });
|
|
191
|
+
* ```
|
|
192
|
+
*/
|
|
193
|
+
contract(contract) {
|
|
194
|
+
return this.clone({ contract });
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Declare what invoking this route does
|
|
198
|
+
*
|
|
199
|
+
* Read by a command surface (`@spfn/core/surface`, `@spfn/core/ops`),
|
|
200
|
+
* which announces it in its manifest so a machine caller can tell a read
|
|
201
|
+
* from a write before it calls — `spfn ops call` asks for `--yes` before a
|
|
202
|
+
* `destructive` command. A surface built with `defineSurface` refuses an
|
|
203
|
+
* app command that does not declare one; on `/_ops` it is optional.
|
|
204
|
+
*
|
|
205
|
+
* Nothing else reads it: codegen, the route map, and the contract are
|
|
206
|
+
* unaffected, and a route outside any surface simply carries it.
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```ts
|
|
210
|
+
* export const pageAdd = app.route.post('/pages')
|
|
211
|
+
* .input({ body: PageAddBody })
|
|
212
|
+
* .effect('write')
|
|
213
|
+
* .summary('Add a page to the blueprint')
|
|
214
|
+
* .handler(async (c) => { ... });
|
|
215
|
+
* ```
|
|
216
|
+
*/
|
|
217
|
+
effect(effect) {
|
|
218
|
+
return this.clone({ effect });
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Describe this route in one line for a command surface's manifest
|
|
222
|
+
*
|
|
223
|
+
* A surface announces it beside the command in `spfn ops list` and
|
|
224
|
+
* `--describe`, and refuses one that is empty or longer than 500
|
|
225
|
+
* characters. Codegen, the route map, and the contract ignore it. On a
|
|
226
|
+
* module command the module's own summary wins.
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* ```ts
|
|
230
|
+
* export const pageAdd = app.route.post('/pages')
|
|
231
|
+
* .effect('write')
|
|
232
|
+
* .summary('Add a page to the blueprint')
|
|
233
|
+
* .handler(async (c) => { ... });
|
|
234
|
+
* ```
|
|
235
|
+
*/
|
|
236
|
+
summary(summary) {
|
|
237
|
+
return this.clone({ summary });
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Define handler function
|
|
241
|
+
*
|
|
242
|
+
* Response type is automatically inferred from the return value.
|
|
243
|
+
* Use helper methods like `c.created()`, `c.paginated()` for proper type inference.
|
|
244
|
+
*
|
|
245
|
+
* @example
|
|
246
|
+
* ```ts
|
|
247
|
+
* // Direct return - type inferred from data
|
|
248
|
+
* route.get('/users/:id')
|
|
249
|
+
* .input({ params: Type.Object({ id: Type.String() }) })
|
|
250
|
+
* .handler(async (c) => {
|
|
251
|
+
* const { params } = await c.data();
|
|
252
|
+
* return await getUser(params.id); // Type: User
|
|
253
|
+
* })
|
|
254
|
+
*
|
|
255
|
+
* // Using c.created() - returns data with 201 status, type preserved
|
|
256
|
+
* route.post('/users')
|
|
257
|
+
* .input({ body: Type.Object({ name: Type.String() }) })
|
|
258
|
+
* .handler(async (c) => {
|
|
259
|
+
* const { body } = await c.data();
|
|
260
|
+
* return c.created(await createUser(body)); // Type: User
|
|
261
|
+
* })
|
|
262
|
+
*
|
|
263
|
+
* // Using c.paginated() - returns PaginatedResult<T>
|
|
264
|
+
* route.get('/users')
|
|
265
|
+
* .handler(async (c) => {
|
|
266
|
+
* const users = await getUsers();
|
|
267
|
+
* return c.paginated(users, 1, 20, 100); // Type: PaginatedResult<User>
|
|
268
|
+
* })
|
|
269
|
+
*
|
|
270
|
+
* // Using c.noContent() - returns void
|
|
271
|
+
* route.delete('/users/:id')
|
|
272
|
+
* .handler(async (c) => {
|
|
273
|
+
* await deleteUser(params.id);
|
|
274
|
+
* return c.noContent(); // Type: void
|
|
275
|
+
* })
|
|
276
|
+
*
|
|
277
|
+
* // Using c.json() - returns Response (type inference lost)
|
|
278
|
+
* // Use only when you need custom status codes not covered by helpers
|
|
279
|
+
* route.get('/custom')
|
|
280
|
+
* .handler(async (c) => {
|
|
281
|
+
* return c.json({ data }, 418); // Type: Response
|
|
282
|
+
* })
|
|
283
|
+
* ```
|
|
284
|
+
*/
|
|
285
|
+
handler(fn) {
|
|
286
|
+
return {
|
|
287
|
+
method: this._method,
|
|
288
|
+
path: this._path,
|
|
289
|
+
input: this._input,
|
|
290
|
+
interceptor: this._interceptor,
|
|
291
|
+
middlewares: this._middlewares,
|
|
292
|
+
skipMiddlewares: this._skipMiddlewares,
|
|
293
|
+
contract: this._contract,
|
|
294
|
+
...this._effect !== void 0 ? { effect: this._effect } : {},
|
|
295
|
+
...this._summary !== void 0 ? { summary: this._summary } : {},
|
|
296
|
+
handler: fn,
|
|
297
|
+
_input: {},
|
|
298
|
+
_interceptor: {},
|
|
299
|
+
_response: {}
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
function createMethodRoute(method) {
|
|
304
|
+
return (path) => {
|
|
305
|
+
const builder = new RouteBuilder();
|
|
306
|
+
builder._method = method;
|
|
307
|
+
builder._path = path;
|
|
308
|
+
return builder;
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
var route = {
|
|
312
|
+
get: createMethodRoute("GET"),
|
|
313
|
+
post: createMethodRoute("POST"),
|
|
314
|
+
put: createMethodRoute("PUT"),
|
|
315
|
+
patch: createMethodRoute("PATCH"),
|
|
316
|
+
delete: createMethodRoute("DELETE")
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// src/route/router.ts
|
|
320
|
+
function createRouterInstance(state) {
|
|
321
|
+
const router = {
|
|
322
|
+
routes: state.routes,
|
|
323
|
+
_routes: state.routes,
|
|
324
|
+
_packageRouters: state.packageRouters,
|
|
325
|
+
_globalMiddlewares: state.globalMiddlewares,
|
|
326
|
+
_contractVersion: state.contractVersion,
|
|
327
|
+
_publishesRouteMap: state.publishesRouteMap,
|
|
328
|
+
packages(routers) {
|
|
329
|
+
const newPackageRouters = [...state.packageRouters, ...routers];
|
|
330
|
+
for (const pkgRouter of routers) {
|
|
331
|
+
if (pkgRouter._packageRouters?.length > 0) {
|
|
332
|
+
newPackageRouters.push(...pkgRouter._packageRouters);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
assertDistinctSurfaces(newPackageRouters);
|
|
336
|
+
return carryMarkers(router, createRouterInstance({ ...state, packageRouters: newPackageRouters }));
|
|
337
|
+
},
|
|
338
|
+
use(middlewares) {
|
|
339
|
+
return carryMarkers(router, createRouterInstance({
|
|
340
|
+
...state,
|
|
341
|
+
globalMiddlewares: [...state.globalMiddlewares, ...middlewares]
|
|
342
|
+
}));
|
|
343
|
+
},
|
|
344
|
+
contractVersion(version) {
|
|
345
|
+
assertContractVersion(version);
|
|
346
|
+
return carryMarkers(router, createRouterInstance({ ...state, contractVersion: version }));
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
return router;
|
|
350
|
+
}
|
|
351
|
+
var MARKERS = ["_surfaceNamespace", "_stateManifest"];
|
|
352
|
+
function carryMarkers(from, to) {
|
|
353
|
+
for (const marker of MARKERS) {
|
|
354
|
+
if (from[marker] !== void 0) {
|
|
355
|
+
Object.defineProperty(to, marker, { value: from[marker], enumerable: false });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return to;
|
|
359
|
+
}
|
|
360
|
+
function assertContractVersion(version) {
|
|
361
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/.test(version)) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`contractVersion("${version}") is not a version of the form major.minor.patch. The released snapshot is named from this value and releases are compared by it.`
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function assertDistinctSurfaces(packageRouters) {
|
|
368
|
+
const namespaces = /* @__PURE__ */ new Set();
|
|
369
|
+
for (const router of packageRouters) {
|
|
370
|
+
const namespace = router._surfaceNamespace;
|
|
371
|
+
if (namespace === void 0) {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (namespaces.has(namespace)) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
`.packages() mounts two command surfaces on "${namespace}". Both serve ${namespace}/_manifest, so the second one's commands would answer while no manifest lists them. Mount one router per namespace.`
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
namespaces.add(namespace);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function defineUnmappedRouter(routes) {
|
|
383
|
+
return createRouterInstance({
|
|
384
|
+
routes,
|
|
385
|
+
packageRouters: [],
|
|
386
|
+
globalMiddlewares: [],
|
|
387
|
+
contractVersion: null,
|
|
388
|
+
publishesRouteMap: false
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/surface/error.ts
|
|
393
|
+
var SurfaceError = class extends Error {
|
|
394
|
+
constructor(message) {
|
|
395
|
+
super(message);
|
|
396
|
+
this.name = "SurfaceError";
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// src/surface/wording.ts
|
|
401
|
+
var SURFACE_WORDING = {
|
|
402
|
+
noun: "surface",
|
|
403
|
+
builder: "`<surface>.route`",
|
|
404
|
+
factory: "defineSurface"
|
|
405
|
+
};
|
|
406
|
+
function capitalized(noun) {
|
|
407
|
+
return noun.charAt(0).toUpperCase() + noun.slice(1);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// src/surface/manifest.ts
|
|
411
|
+
function isRouter(value) {
|
|
412
|
+
return value !== null && typeof value === "object" && "routes" in value && "_routes" in value;
|
|
413
|
+
}
|
|
414
|
+
function isRouteDef(value) {
|
|
415
|
+
return value !== null && typeof value === "object" && "handler" in value;
|
|
416
|
+
}
|
|
417
|
+
function toJsonSchema(schema) {
|
|
418
|
+
return JSON.parse(JSON.stringify(schema));
|
|
419
|
+
}
|
|
420
|
+
var INPUT_SECTIONS = ["params", "query", "body"];
|
|
421
|
+
function toCommandInput(input) {
|
|
422
|
+
const sections = {};
|
|
423
|
+
if (!input) {
|
|
424
|
+
return sections;
|
|
425
|
+
}
|
|
426
|
+
for (const section of INPUT_SECTIONS) {
|
|
427
|
+
const schema = input[section];
|
|
428
|
+
if (schema) {
|
|
429
|
+
sections[section] = toJsonSchema(schema);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return sections;
|
|
433
|
+
}
|
|
434
|
+
function collectSurfaceCommands(routes, wording = SURFACE_WORDING) {
|
|
435
|
+
const commands = [];
|
|
436
|
+
visit(routes, commands, /* @__PURE__ */ new Map(), wording);
|
|
437
|
+
commands.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
438
|
+
return commands;
|
|
439
|
+
}
|
|
440
|
+
function visit(routes, commands, claimed, wording) {
|
|
441
|
+
for (const [name, entry] of Object.entries(routes)) {
|
|
442
|
+
if (isRouter(entry)) {
|
|
443
|
+
visit(entry.routes, commands, claimed, wording);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (!isRouteDef(entry) || !entry.method || !entry.path) {
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
assertUnclaimedName(name, entry.path, claimed, wording);
|
|
450
|
+
commands.push({
|
|
451
|
+
name,
|
|
452
|
+
method: entry.method,
|
|
453
|
+
path: entry.path,
|
|
454
|
+
...entry.summary !== void 0 ? { summary: entry.summary } : {},
|
|
455
|
+
...entry.effect !== void 0 ? { effect: entry.effect } : {},
|
|
456
|
+
input: toCommandInput(entry.input)
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function assertUnclaimedName(name, path, claimed, wording) {
|
|
461
|
+
const existing = claimed.get(name);
|
|
462
|
+
if (existing !== void 0) {
|
|
463
|
+
throw new SurfaceError(
|
|
464
|
+
`Two ${wording.noun} routes are named "${name}" ("${existing}" and "${path}"). Command names are flattened across nested routers, so each must be unique for a client to resolve the one its caller asked for.`
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
claimed.set(name, path);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/surface/route-overlap.ts
|
|
471
|
+
function routeSegments(path) {
|
|
472
|
+
return path.split("/").slice(1).map((raw) => {
|
|
473
|
+
if (raw === "*" || raw.endsWith("*")) {
|
|
474
|
+
return { kind: "wildcard", value: raw, optional: true };
|
|
475
|
+
}
|
|
476
|
+
if (raw.startsWith(":")) {
|
|
477
|
+
return {
|
|
478
|
+
kind: "parameter",
|
|
479
|
+
value: raw.slice(1).replace(/\?$/, ""),
|
|
480
|
+
optional: raw.endsWith("?")
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
return { kind: "static", value: raw, optional: false };
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
function customPattern(parameter) {
|
|
487
|
+
const openingBrace = parameter.indexOf("{");
|
|
488
|
+
return openingBrace >= 0 && parameter.endsWith("}") ? parameter.slice(openingBrace + 1, -1) : null;
|
|
489
|
+
}
|
|
490
|
+
function parameterAccepts(parameter, value) {
|
|
491
|
+
const pattern = customPattern(parameter);
|
|
492
|
+
if (pattern === null) {
|
|
493
|
+
return value.length > 0 && !value.includes("/");
|
|
494
|
+
}
|
|
495
|
+
try {
|
|
496
|
+
return new RegExp(`^(?:${pattern})$`).test(value);
|
|
497
|
+
} catch {
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function remainingStaticPath(segments, from) {
|
|
502
|
+
const remaining = segments.slice(from);
|
|
503
|
+
return remaining.every((segment) => segment.kind === "static") ? remaining.map((segment) => segment.value).join("/") : null;
|
|
504
|
+
}
|
|
505
|
+
function routePatternsOverlap(firstPath, secondPath) {
|
|
506
|
+
const first = routeSegments(firstPath);
|
|
507
|
+
const second = routeSegments(secondPath);
|
|
508
|
+
const length = Math.max(first.length, second.length);
|
|
509
|
+
for (let index = 0; index < length; index++) {
|
|
510
|
+
const left = first[index];
|
|
511
|
+
const right = second[index];
|
|
512
|
+
if (!left || !right) {
|
|
513
|
+
const remaining = left ? first.slice(index) : second.slice(index);
|
|
514
|
+
return remaining.every((segment) => segment.optional || segment.kind === "wildcard");
|
|
515
|
+
}
|
|
516
|
+
if (left.kind === "wildcard" || right.kind === "wildcard") {
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
if (left.kind === "static" && right.kind === "static") {
|
|
520
|
+
if (left.value !== right.value) {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if (left.kind === "parameter" && right.kind === "static") {
|
|
526
|
+
const remaining = remainingStaticPath(second, index);
|
|
527
|
+
if (customPattern(left.value) !== null && remaining !== null && parameterAccepts(left.value, remaining)) {
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
if (!parameterAccepts(left.value, right.value)) {
|
|
531
|
+
return customPattern(left.value) !== null && remaining === null;
|
|
532
|
+
}
|
|
533
|
+
if (customPattern(left.value) !== null && first.length !== second.length) {
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (left.kind === "static" && right.kind === "parameter") {
|
|
539
|
+
const remaining = remainingStaticPath(first, index);
|
|
540
|
+
if (customPattern(right.value) !== null && remaining !== null && parameterAccepts(right.value, remaining)) {
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
if (!parameterAccepts(right.value, left.value)) {
|
|
544
|
+
return customPattern(right.value) !== null && remaining === null;
|
|
545
|
+
}
|
|
546
|
+
if (customPattern(right.value) !== null && first.length !== second.length) {
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// src/surface/module.ts
|
|
557
|
+
var MODULE_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
558
|
+
var COMMAND_NAME = /^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$/;
|
|
559
|
+
var CONTRACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/;
|
|
560
|
+
var EFFECTS = /* @__PURE__ */ new Set(["read", "write", "destructive"]);
|
|
561
|
+
var PATH_BASE = "https://spfn.invalid";
|
|
562
|
+
function isSurfaceEffect(value) {
|
|
563
|
+
return EFFECTS.has(value);
|
|
564
|
+
}
|
|
565
|
+
function assertText(label, value) {
|
|
566
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
567
|
+
throw new SurfaceError(`${label} must be a non-empty string.`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
function assertStableModulePath(at, moduleId, commandName, path) {
|
|
571
|
+
const label = `${at.label} command "${moduleId}.${commandName}" path "${path}"`;
|
|
572
|
+
const transportPath = path.replace(/[{}?#]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
573
|
+
let url;
|
|
574
|
+
try {
|
|
575
|
+
url = new URL(transportPath, PATH_BASE);
|
|
576
|
+
} catch {
|
|
577
|
+
throw new SurfaceError(`${label} is not a valid URL path.`);
|
|
578
|
+
}
|
|
579
|
+
if (url.origin !== PATH_BASE || url.search !== "" || url.hash !== "" || url.pathname !== transportPath) {
|
|
580
|
+
throw new SurfaceError(`${label} is not a stable plain absolute path.`);
|
|
581
|
+
}
|
|
582
|
+
const slashCount = (path.match(/\//g) ?? []).length;
|
|
583
|
+
let decoded = path;
|
|
584
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
585
|
+
let next;
|
|
586
|
+
try {
|
|
587
|
+
next = decodeURIComponent(decoded);
|
|
588
|
+
} catch {
|
|
589
|
+
throw new SurfaceError(`${label} contains malformed percent encoding.`);
|
|
590
|
+
}
|
|
591
|
+
if (next.includes("\\") || (next.match(/\//g) ?? []).length !== slashCount || next.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
592
|
+
throw new SurfaceError(`${label} contains encoded path separators or dot segments.`);
|
|
593
|
+
}
|
|
594
|
+
if (next === decoded) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
decoded = next;
|
|
598
|
+
}
|
|
599
|
+
throw new SurfaceError(`${label} uses too many percent-encoding layers.`);
|
|
600
|
+
}
|
|
601
|
+
function assertCliCallableModulePath(at, moduleId, commandName, path) {
|
|
602
|
+
const unsupported = path.split("/").slice(1).find((segment) => segment === "*" || segment.includes("*") || (segment.startsWith(":") ? !/^:[A-Za-z0-9_]+$/.test(segment) : !/^[A-Za-z0-9._~-]+$/.test(segment)));
|
|
603
|
+
if (unsupported !== void 0) {
|
|
604
|
+
throw new SurfaceError(
|
|
605
|
+
`${at.label} command "${moduleId}.${commandName}" path segment "${unsupported}" is not CLI-callable. Module routes support URL-safe static segments and simple :name parameters only; optional, wildcard, and custom-regex parameters are not supported.`
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
function assertCommand(at, moduleId, name, command) {
|
|
610
|
+
if (!COMMAND_NAME.test(name)) {
|
|
611
|
+
throw new SurfaceError(
|
|
612
|
+
`${at.label} module "${moduleId}" has invalid command name "${name}". Use dot-separated alphanumeric names.`
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
assertText(`${at.label} command "${moduleId}.${name}" summary`, command?.summary);
|
|
616
|
+
if (!EFFECTS.has(command?.effect)) {
|
|
617
|
+
throw new SurfaceError(
|
|
618
|
+
`${at.label} command "${moduleId}.${name}" has invalid effect ${JSON.stringify(command?.effect)}.`
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
if (!Array.isArray(command?.scopes) || command.scopes.length === 0 || command.scopes.some((scope) => typeof scope !== "string" || scope.trim().length === 0)) {
|
|
622
|
+
throw new SurfaceError(`${at.label} command "${moduleId}.${name}" must declare at least one non-empty scope.`);
|
|
623
|
+
}
|
|
624
|
+
const route2 = command?.route;
|
|
625
|
+
if (!route2 || typeof route2.handler !== "function" || !route2.method || !route2.path) {
|
|
626
|
+
throw new SurfaceError(`${at.label} command "${moduleId}.${name}" must carry a complete route.`);
|
|
627
|
+
}
|
|
628
|
+
assertStableModulePath(at, moduleId, name, route2.path);
|
|
629
|
+
assertCliCallableModulePath(at, moduleId, name, route2.path);
|
|
630
|
+
const modulePath = `${at.namespace}/${moduleId}/`;
|
|
631
|
+
if (!route2.path.startsWith(modulePath)) {
|
|
632
|
+
throw new SurfaceError(
|
|
633
|
+
`${at.label} command "${moduleId}.${name}" is at "${route2.path}", outside "${modulePath}". A module mounted on "${at.namespace}" keeps every command under "${modulePath}".`
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
if (route2.path.split("/").includes("..")) {
|
|
637
|
+
throw new SurfaceError(`${at.label} command "${moduleId}.${name}" path climbs out of its module namespace.`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
function validateSurfaceModule(module, namespace, wording = SURFACE_WORDING) {
|
|
641
|
+
const at = { namespace, label: capitalized(wording.noun) };
|
|
642
|
+
if (!MODULE_ID.test(module?.id ?? "")) {
|
|
643
|
+
throw new SurfaceError(
|
|
644
|
+
`${at.label} module id ${JSON.stringify(module?.id)} is invalid. Use lower-kebab-case.`
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
assertText(`${at.label} module "${module.id}" source`, module.source);
|
|
648
|
+
assertText(`${at.label} module "${module.id}" summary`, module.summary);
|
|
649
|
+
if (!CONTRACT_VERSION.test(module.contractVersion)) {
|
|
650
|
+
throw new SurfaceError(
|
|
651
|
+
`${at.label} module "${module.id}" contractVersion must be a semantic version.`
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
if (!module.commands || typeof module.commands !== "object" || Array.isArray(module.commands)) {
|
|
655
|
+
throw new SurfaceError(`${at.label} module "${module.id}" commands must be an object.`);
|
|
656
|
+
}
|
|
657
|
+
const signatures = /* @__PURE__ */ new Map();
|
|
658
|
+
const claimedRoutes = [];
|
|
659
|
+
for (const [name, command] of Object.entries(module.commands)) {
|
|
660
|
+
assertCommand(at, module.id, name, command);
|
|
661
|
+
const signature = `${command.route.method} ${command.route.path}`;
|
|
662
|
+
const existing = signatures.get(signature);
|
|
663
|
+
if (existing) {
|
|
664
|
+
throw new SurfaceError(
|
|
665
|
+
`${at.label} module "${module.id}" commands "${existing}" and "${name}" both use ${signature}.`
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
signatures.set(signature, name);
|
|
669
|
+
const overlapping = claimedRoutes.find((claim) => claim.method === command.route.method && routePatternsOverlap(claim.path, command.route.path));
|
|
670
|
+
if (overlapping) {
|
|
671
|
+
throw new SurfaceError(
|
|
672
|
+
`${at.label} module "${module.id}" commands "${overlapping.name}" and "${name}" have overlapping ${command.route.method} routes ("${overlapping.path}" and "${command.route.path}").`
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
claimedRoutes.push({
|
|
676
|
+
name,
|
|
677
|
+
method: command.route.method,
|
|
678
|
+
path: command.route.path
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
return module;
|
|
682
|
+
}
|
|
683
|
+
function defineSurfaceModule(module, namespace) {
|
|
684
|
+
return validateSurfaceModule(module, namespace);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/surface/namespace.ts
|
|
688
|
+
var NAMESPACE = /^(?:\/[A-Za-z0-9_-]+)+$/;
|
|
689
|
+
function assertNamespace(namespace) {
|
|
690
|
+
if (typeof namespace !== "string" || !NAMESPACE.test(namespace)) {
|
|
691
|
+
throw new SurfaceError(
|
|
692
|
+
`Surface namespace ${JSON.stringify(namespace)} is invalid. Use an absolute path of [A-Za-z0-9_-] segments without a trailing slash, such as "/_app".`
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
function manifestPathOf(namespace) {
|
|
697
|
+
return `${namespace}/_manifest`;
|
|
698
|
+
}
|
|
699
|
+
function manifestNameOf(namespace) {
|
|
700
|
+
return `get${namespace.slice(1).replace(/_[a-z]|[A-Z_/-]/g, encodedCharacter)}Manifest`;
|
|
701
|
+
}
|
|
702
|
+
var ESCAPES = { "_": "_", "-": "d", "/": "s" };
|
|
703
|
+
function encodedCharacter(match) {
|
|
704
|
+
if (match.length === 2) {
|
|
705
|
+
return match.charAt(1).toUpperCase();
|
|
706
|
+
}
|
|
707
|
+
return `_${ESCAPES[match] ?? match}`;
|
|
708
|
+
}
|
|
709
|
+
function toSurfacePath(namespace, path, wording) {
|
|
710
|
+
const label = capitalized(wording.noun);
|
|
711
|
+
if (!path.startsWith("/")) {
|
|
712
|
+
throw new SurfaceError(
|
|
713
|
+
`${label} route path "${path}" must start with "/". It is appended to "${namespace}", so "${path}" would read as "${namespace}${path}".`
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
if (path === "/") {
|
|
717
|
+
throw new SurfaceError(
|
|
718
|
+
`${label} route path "/" names no command \u2014 "${namespace}" itself is not a command.`
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
return namespace + path;
|
|
722
|
+
}
|
|
723
|
+
function surfaceRouteBuilder(namespace, wording) {
|
|
724
|
+
const method = (name) => (path) => route[name](toSurfacePath(namespace, path, wording));
|
|
725
|
+
return {
|
|
726
|
+
get: method("get"),
|
|
727
|
+
post: method("post"),
|
|
728
|
+
put: method("put"),
|
|
729
|
+
patch: method("patch"),
|
|
730
|
+
delete: method("delete")
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// src/surface/define-surface.ts
|
|
735
|
+
var MAX_COMMAND_SUMMARY_LENGTH = 500;
|
|
736
|
+
var EFFECT_METHOD = ".effect('read' | 'write' | 'destructive')";
|
|
737
|
+
function isRouter2(value) {
|
|
738
|
+
return value !== null && typeof value === "object" && "routes" in value && "_routes" in value;
|
|
739
|
+
}
|
|
740
|
+
function isRouteDef2(value) {
|
|
741
|
+
return value !== null && typeof value === "object" && "handler" in value;
|
|
742
|
+
}
|
|
743
|
+
function assertSurfaceRoute(plan, name, def) {
|
|
744
|
+
const label = capitalized(plan.wording.noun);
|
|
745
|
+
if (!def.method || !def.path) {
|
|
746
|
+
throw new SurfaceError(
|
|
747
|
+
`${label} route "${name}" has no method or path. A command is invoked on the wire, so both are required.`
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
if (!def.path.startsWith(plan.prefix)) {
|
|
751
|
+
throw new SurfaceError(
|
|
752
|
+
`${label} route "${name}" is at "${def.path}", outside "${plan.prefix}". Build ${plan.wording.noun} routes with ${plan.wording.builder} rather than \`route\` \u2014 it applies the namespace, so the path a definition carries is only the part the app owns.`
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
if (def.path === plan.manifestPath) {
|
|
756
|
+
throw new SurfaceError(
|
|
757
|
+
`${label} route "${name}" claims "${plan.manifestPath}", which is reserved for the manifest. The manifest is registered first, so this route would never answer.`
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
function assertCommandMetadata(plan, name, def) {
|
|
762
|
+
const label = `${capitalized(plan.wording.noun)} command "${name}"`;
|
|
763
|
+
if (def.effect === void 0 && plan.requireEffect) {
|
|
764
|
+
throw new SurfaceError(
|
|
765
|
+
`${label} declares no effect. A client reads it to decide whether to call, retry, or ask for confirmation, so ${plan.wording.factory} refuses a command without one. Declare it on the route with ${EFFECT_METHOD}.`
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
if (def.effect !== void 0 && !isSurfaceEffect(def.effect)) {
|
|
769
|
+
throw new SurfaceError(
|
|
770
|
+
`${label} has invalid effect ${JSON.stringify(def.effect)}. A client knows no other, so declare it with ${EFFECT_METHOD}.`
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
assertCommandSummary(label, def.summary);
|
|
774
|
+
}
|
|
775
|
+
function assertCommandSummary(label, summary) {
|
|
776
|
+
if (summary === void 0) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (typeof summary !== "string" || summary.trim().length === 0) {
|
|
780
|
+
throw new SurfaceError(
|
|
781
|
+
`${label} has an empty summary. A client would list it as a command described by nothing; give .summary() one line of text, or leave it out.`
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
if (summary.length > MAX_COMMAND_SUMMARY_LENGTH) {
|
|
785
|
+
throw new SurfaceError(
|
|
786
|
+
`${label} has a summary of ${summary.length} characters, over the ${MAX_COMMAND_SUMMARY_LENGTH} a client accepts \u2014 it would drop the command's metadata and treat it as destructive. Shorten it.`
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
function assertModuleRouteEffect(plan, name, definition) {
|
|
791
|
+
const routeEffect = definition.route.effect;
|
|
792
|
+
if (routeEffect !== void 0 && routeEffect !== definition.effect) {
|
|
793
|
+
throw new SurfaceError(
|
|
794
|
+
`${capitalized(plan.wording.noun)} command "${name}" is declared ${JSON.stringify(definition.effect)} by its module, but its route says .effect(${JSON.stringify(routeEffect)}). The manifest announces the module's declaration, so the two cannot disagree. Drop the route's .effect() or make it match.`
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
function assertSurfaceName(plan, name) {
|
|
799
|
+
if (name === plan.manifestName) {
|
|
800
|
+
throw new SurfaceError(
|
|
801
|
+
`${capitalized(plan.wording.noun)} route name "${plan.manifestName}" is reserved for the manifest route.`
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
function rebuildNestedRouter(plan, name, router, commandsByName, inherited) {
|
|
806
|
+
if (router._packageRouters?.length > 0) {
|
|
807
|
+
throw new SurfaceError(
|
|
808
|
+
`${capitalized(plan.wording.noun)} router "${name}" mounts package routers with .packages(). Their routes bypass the prefix check and the auth injection, so a surface cannot carry them.`
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
const handedDown = [...inherited, ...router._globalMiddlewares ?? []];
|
|
812
|
+
let rebuilt = defineUnmappedRouter(
|
|
813
|
+
secureRoutes(plan, router.routes, commandsByName, handedDown)
|
|
814
|
+
);
|
|
815
|
+
if (router._contractVersion) {
|
|
816
|
+
rebuilt = rebuilt.contractVersion(router._contractVersion);
|
|
817
|
+
}
|
|
818
|
+
return rebuilt;
|
|
819
|
+
}
|
|
820
|
+
function secureRoutes(plan, routes, commandsByName, inherited = []) {
|
|
821
|
+
const secured = {};
|
|
822
|
+
for (const [name, entry] of Object.entries(routes)) {
|
|
823
|
+
assertSurfaceName(plan, name);
|
|
824
|
+
if (isRouter2(entry)) {
|
|
825
|
+
secured[name] = rebuildNestedRouter(plan, name, entry, commandsByName, inherited);
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
if (!isRouteDef2(entry)) {
|
|
829
|
+
throw new SurfaceError(
|
|
830
|
+
`${capitalized(plan.wording.noun)} router entry "${name}" is neither a route nor a router.`
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
assertSurfaceRoute(plan, name, entry);
|
|
834
|
+
assertCommandMetadata(plan, name, entry);
|
|
835
|
+
const guard = plan.authorize?.(commandsByName.get(name));
|
|
836
|
+
secured[name] = {
|
|
837
|
+
...entry,
|
|
838
|
+
middlewares: commandChain(plan, name, guard, [...inherited, ...entry.middlewares ?? []])
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
return secured;
|
|
842
|
+
}
|
|
843
|
+
function commandChain(plan, name, guard, declared) {
|
|
844
|
+
if (guard === void 0) {
|
|
845
|
+
return [plan.auth, ...declared];
|
|
846
|
+
}
|
|
847
|
+
const guardName = middlewareNameOf(guard);
|
|
848
|
+
if (guardName !== void 0) {
|
|
849
|
+
refuseGuardCollision(plan, name, guardName, declared);
|
|
850
|
+
}
|
|
851
|
+
return [plan.auth, guard, ...declared];
|
|
852
|
+
}
|
|
853
|
+
function refuseGuardCollision(plan, name, guardName, declared) {
|
|
854
|
+
const noun = capitalized(plan.wording.noun);
|
|
855
|
+
if (middlewareNameOf(plan.auth) === guardName) {
|
|
856
|
+
throw new SurfaceError(
|
|
857
|
+
`${noun} command "${name}"'s guard shares the name "${guardName}" with the surface's auth middleware. Route registration keeps one middleware per name, so the command's guard would never run. Give one of the two a distinct name.`
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
if (declared.some((middleware) => middlewareNameOf(middleware) === guardName)) {
|
|
861
|
+
throw new SurfaceError(
|
|
862
|
+
`${noun} command "${name}" declares a middleware named "${guardName}", the name of the guard authorize gave it. Route registration keeps one middleware per name, so the command's own guard would never run. Give one of the two a distinct name.`
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
function middlewareNameOf(middleware) {
|
|
867
|
+
return typeof middleware === "function" ? void 0 : middleware.name;
|
|
868
|
+
}
|
|
869
|
+
function assertModuleCommandFree(plan, command, appCommands, claimed) {
|
|
870
|
+
const noun = plan.wording.noun;
|
|
871
|
+
if (claimed.names.has(command.name)) {
|
|
872
|
+
throw new SurfaceError(`Two ${noun} commands are named "${command.name}".`);
|
|
873
|
+
}
|
|
874
|
+
const signature = `${command.method} ${command.path}`;
|
|
875
|
+
const existing = claimed.signatures.get(signature);
|
|
876
|
+
if (existing) {
|
|
877
|
+
throw new SurfaceError(
|
|
878
|
+
`${capitalized(noun)} commands "${existing}" and "${command.name}" both use ${signature}.`
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
const overlapping = appCommands.find((app) => app.method === command.method && routePatternsOverlap(app.path, command.path));
|
|
882
|
+
if (overlapping) {
|
|
883
|
+
throw new SurfaceError(
|
|
884
|
+
`App ${noun} command "${overlapping.name}" at ${command.method} ${overlapping.path} overlaps module command "${command.name}" at ${command.method} ${command.path}. Which one answers cannot depend on route registration order.`
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
claimed.names.add(command.name);
|
|
888
|
+
claimed.signatures.set(signature, command.name);
|
|
889
|
+
}
|
|
890
|
+
function compileModules(plan, appCommands) {
|
|
891
|
+
const compiled = { descriptors: [], commands: [], routes: {} };
|
|
892
|
+
const moduleIds = /* @__PURE__ */ new Set();
|
|
893
|
+
const claimed = {
|
|
894
|
+
names: new Set(appCommands.map((command) => command.name)),
|
|
895
|
+
signatures: new Map(appCommands.map((command) => [`${command.method} ${command.path}`, command.name]))
|
|
896
|
+
};
|
|
897
|
+
for (const rawModule of plan.modules) {
|
|
898
|
+
const module = validateSurfaceModule(rawModule, plan.namespace, plan.wording);
|
|
899
|
+
if (moduleIds.has(module.id)) {
|
|
900
|
+
throw new SurfaceError(`Two ${plan.wording.noun} modules use id "${module.id}".`);
|
|
901
|
+
}
|
|
902
|
+
moduleIds.add(module.id);
|
|
903
|
+
compiled.descriptors.push({
|
|
904
|
+
id: module.id,
|
|
905
|
+
source: module.source,
|
|
906
|
+
contractVersion: module.contractVersion,
|
|
907
|
+
summary: module.summary
|
|
908
|
+
});
|
|
909
|
+
for (const [localName, definition] of Object.entries(module.commands)) {
|
|
910
|
+
const command = {
|
|
911
|
+
name: `${module.id}.${localName}`,
|
|
912
|
+
module: module.id,
|
|
913
|
+
summary: definition.summary,
|
|
914
|
+
effect: definition.effect,
|
|
915
|
+
scopes: [...definition.scopes],
|
|
916
|
+
method: definition.route.method,
|
|
917
|
+
path: definition.route.path,
|
|
918
|
+
input: collectSurfaceCommands({ [localName]: definition.route }, plan.wording)[0].input
|
|
919
|
+
};
|
|
920
|
+
assertModuleRouteEffect(plan, command.name, definition);
|
|
921
|
+
assertModuleCommandFree(plan, command, appCommands, claimed);
|
|
922
|
+
compiled.commands.push(command);
|
|
923
|
+
compiled.routes[command.name] = { route: definition.route, command };
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
compiled.descriptors.sort((a, b) => a.id.localeCompare(b.id));
|
|
927
|
+
compiled.commands.sort((a, b) => a.name.localeCompare(b.name));
|
|
928
|
+
return compiled;
|
|
929
|
+
}
|
|
930
|
+
function secureModuleRoutes(plan, routes) {
|
|
931
|
+
const secured = {};
|
|
932
|
+
for (const [name, definition] of Object.entries(routes)) {
|
|
933
|
+
assertSurfaceName(plan, name);
|
|
934
|
+
assertSurfaceRoute(plan, name, definition.route);
|
|
935
|
+
secured[name] = {
|
|
936
|
+
...definition.route,
|
|
937
|
+
middlewares: commandChain(
|
|
938
|
+
plan,
|
|
939
|
+
name,
|
|
940
|
+
plan.authorize?.(definition.command),
|
|
941
|
+
definition.route.middlewares ?? []
|
|
942
|
+
)
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
return secured;
|
|
946
|
+
}
|
|
947
|
+
function manifestRoute(plan, manifest) {
|
|
948
|
+
const builder = route.get(plan.manifestPath);
|
|
949
|
+
return (plan.manifest === "public" ? builder.skip(["auth"]) : builder.use([plan.auth])).handler(async () => manifest);
|
|
950
|
+
}
|
|
951
|
+
function buildRouter(plan, routes) {
|
|
952
|
+
const appCommands = collectSurfaceCommands(routes, plan.wording);
|
|
953
|
+
const moduleSurface = compileModules(plan, appCommands);
|
|
954
|
+
const commands = [...appCommands, ...moduleSurface.commands].sort((a, b) => a.name.localeCompare(b.name));
|
|
955
|
+
const manifest = {
|
|
956
|
+
manifestVersion: 1,
|
|
957
|
+
...moduleSurface.descriptors.length > 0 ? { modules: moduleSurface.descriptors } : {},
|
|
958
|
+
commands
|
|
959
|
+
};
|
|
960
|
+
const commandsByName = new Map(appCommands.map((command) => [command.name, command]));
|
|
961
|
+
const secured = secureRoutes(plan, routes, commandsByName);
|
|
962
|
+
const securedModules = secureModuleRoutes(plan, moduleSurface.routes);
|
|
963
|
+
return Object.defineProperty(defineUnmappedRouter({
|
|
964
|
+
[plan.manifestName]: manifestRoute(plan, manifest),
|
|
965
|
+
...secured,
|
|
966
|
+
...securedModules
|
|
967
|
+
}), "_surfaceNamespace", { value: plan.namespace, enumerable: false });
|
|
968
|
+
}
|
|
969
|
+
function planOf(options, wording, rules) {
|
|
970
|
+
assertNamespace(options?.namespace);
|
|
971
|
+
if (!options.auth) {
|
|
972
|
+
throw new SurfaceError(
|
|
973
|
+
`${wording.factory} requires an auth middleware ({ auth: ... }). A surface reachable without authentication cannot be created.`
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
const modules = options.modules ?? [];
|
|
977
|
+
if (modules.length > 0 && !options.authorize) {
|
|
978
|
+
throw new SurfaceError(`${wording.factory} requires an authorize function when modules are mounted.`);
|
|
979
|
+
}
|
|
980
|
+
const manifest = options.manifest ?? "authenticated";
|
|
981
|
+
if (manifest !== "authenticated" && manifest !== "public") {
|
|
982
|
+
throw new SurfaceError(
|
|
983
|
+
`${wording.factory} manifest must be 'authenticated' or 'public', got ${JSON.stringify(manifest)}.`
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
return {
|
|
987
|
+
namespace: options.namespace,
|
|
988
|
+
prefix: `${options.namespace}/`,
|
|
989
|
+
manifestPath: manifestPathOf(options.namespace),
|
|
990
|
+
manifestName: manifestNameOf(options.namespace),
|
|
991
|
+
auth: options.auth,
|
|
992
|
+
authorize: options.authorize,
|
|
993
|
+
manifest,
|
|
994
|
+
modules,
|
|
995
|
+
wording,
|
|
996
|
+
requireEffect: rules.requireEffect
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
function createSurface(options, wording, rules) {
|
|
1000
|
+
const plan = planOf(options, wording, rules);
|
|
1001
|
+
return {
|
|
1002
|
+
namespace: plan.namespace,
|
|
1003
|
+
route: surfaceRouteBuilder(plan.namespace, wording),
|
|
1004
|
+
router: (routes) => buildRouter(plan, routes)
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
function defineSurface(options) {
|
|
1008
|
+
return createSurface(options, SURFACE_WORDING, { requireEffect: true });
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
export { SurfaceError, collectSurfaceCommands, defineSurface, defineSurfaceModule };
|
|
1012
|
+
//# sourceMappingURL=index.js.map
|
|
1013
|
+
//# sourceMappingURL=index.js.map
|