@canmi/seam-server 0.4.16 → 0.5.10
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 +2 -1
- package/dist/index.d.ts +136 -14
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +582 -183
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -141,24 +141,21 @@ const t = {
|
|
|
141
141
|
};
|
|
142
142
|
|
|
143
143
|
//#endregion
|
|
144
|
-
//#region src/
|
|
145
|
-
function
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (def.error) entry.error = def.error._schema;
|
|
154
|
-
mapped[name] = entry;
|
|
155
|
-
}
|
|
156
|
-
const manifest = {
|
|
157
|
-
version: 1,
|
|
158
|
-
procedures: mapped
|
|
144
|
+
//#region src/validation/index.ts
|
|
145
|
+
function validateInput(schema, data) {
|
|
146
|
+
const errors = validate(schema, data, {
|
|
147
|
+
maxDepth: 32,
|
|
148
|
+
maxErrors: 10
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
valid: errors.length === 0,
|
|
152
|
+
errors
|
|
159
153
|
};
|
|
160
|
-
|
|
161
|
-
|
|
154
|
+
}
|
|
155
|
+
function formatValidationErrors(errors) {
|
|
156
|
+
return errors.map((e) => {
|
|
157
|
+
return `${e.instancePath.length > 0 ? e.instancePath.join("/") : "(root)"} (schema: ${e.schemaPath.join("/")})`;
|
|
158
|
+
}).join("; ");
|
|
162
159
|
}
|
|
163
160
|
|
|
164
161
|
//#endregion
|
|
@@ -193,26 +190,111 @@ var SeamError = class extends Error {
|
|
|
193
190
|
};
|
|
194
191
|
|
|
195
192
|
//#endregion
|
|
196
|
-
//#region src/
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
193
|
+
//#region src/context.ts
|
|
194
|
+
/** Parse extract rule into source type and key, e.g. "header:authorization" -> { source: "header", key: "authorization" } */
|
|
195
|
+
function parseExtractRule(rule) {
|
|
196
|
+
const idx = rule.indexOf(":");
|
|
197
|
+
if (idx === -1) throw new Error(`Invalid extract rule "${rule}": expected "source:key" format`);
|
|
198
|
+
const source = rule.slice(0, idx);
|
|
199
|
+
const key = rule.slice(idx + 1);
|
|
200
|
+
if (!source || !key) throw new Error(`Invalid extract rule "${rule}": source and key must be non-empty`);
|
|
202
201
|
return {
|
|
203
|
-
|
|
204
|
-
|
|
202
|
+
source,
|
|
203
|
+
key
|
|
205
204
|
};
|
|
206
205
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
206
|
+
/** Collect all header names needed by the context config */
|
|
207
|
+
function contextExtractKeys(config) {
|
|
208
|
+
const keys = [];
|
|
209
|
+
for (const field of Object.values(config)) {
|
|
210
|
+
const { source, key } = parseExtractRule(field.extract);
|
|
211
|
+
if (source === "header") keys.push(key);
|
|
212
|
+
}
|
|
213
|
+
return [...new Set(keys)];
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Resolve raw strings into validated context object.
|
|
217
|
+
*
|
|
218
|
+
* For each requested key:
|
|
219
|
+
* - If raw value is null/missing -> pass null to JTD; schema decides via nullable()
|
|
220
|
+
* - If schema expects string -> use raw value directly
|
|
221
|
+
* - If schema expects object -> JSON.parse then validate
|
|
222
|
+
*/
|
|
223
|
+
function resolveContext(config, raw, requestedKeys) {
|
|
224
|
+
const result = {};
|
|
225
|
+
for (const key of requestedKeys) {
|
|
226
|
+
const field = config[key];
|
|
227
|
+
if (!field) throw new SeamError("CONTEXT_ERROR", `Context field "${key}" is not defined in router context config`, 400);
|
|
228
|
+
const { source, key: extractKey } = parseExtractRule(field.extract);
|
|
229
|
+
const rawValue = raw[source === "header" ? extractKey : extractKey] ?? null;
|
|
230
|
+
let value;
|
|
231
|
+
if (rawValue === null) value = null;
|
|
232
|
+
else {
|
|
233
|
+
const schema = field.schema._schema;
|
|
234
|
+
const isStringSchema = "type" in schema && schema.type === "string" && !("nullable" in schema && schema.nullable);
|
|
235
|
+
const isNullableStringSchema = "type" in schema && schema.type === "string" && "nullable" in schema && schema.nullable;
|
|
236
|
+
if (isStringSchema || isNullableStringSchema) value = rawValue;
|
|
237
|
+
else try {
|
|
238
|
+
value = JSON.parse(rawValue);
|
|
239
|
+
} catch {
|
|
240
|
+
throw new SeamError("CONTEXT_ERROR", `Context field "${key}": failed to parse value as JSON`, 400);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const validation = validateInput(field.schema._schema, value);
|
|
244
|
+
if (!validation.valid) throw new SeamError("CONTEXT_ERROR", `Context field "${key}" validation failed: ${formatValidationErrors(validation.errors)}`, 400);
|
|
245
|
+
result[key] = value;
|
|
246
|
+
}
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region src/manifest/index.ts
|
|
252
|
+
function normalizeInvalidates(targets) {
|
|
253
|
+
return targets.map((t) => {
|
|
254
|
+
if (typeof t === "string") return { query: t };
|
|
255
|
+
const normalized = { query: t.query };
|
|
256
|
+
if (t.mapping) normalized.mapping = Object.fromEntries(Object.entries(t.mapping).map(([k, v]) => [k, typeof v === "string" ? { from: v } : v]));
|
|
257
|
+
return normalized;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
function buildManifest(definitions, channels, contextConfig, transportDefaults) {
|
|
261
|
+
const mapped = {};
|
|
262
|
+
for (const [name, def] of Object.entries(definitions)) {
|
|
263
|
+
const k = def.kind ?? def.type;
|
|
264
|
+
const kind = k === "upload" ? "upload" : k === "stream" ? "stream" : k === "subscription" ? "subscription" : k === "command" ? "command" : "query";
|
|
265
|
+
const entry = {
|
|
266
|
+
kind,
|
|
267
|
+
input: def.input._schema
|
|
268
|
+
};
|
|
269
|
+
if (kind === "stream") entry.chunkOutput = def.output._schema;
|
|
270
|
+
else entry.output = def.output._schema;
|
|
271
|
+
if (def.error) entry.error = def.error._schema;
|
|
272
|
+
if (kind === "command" && def.invalidates && def.invalidates.length > 0) entry.invalidates = normalizeInvalidates(def.invalidates);
|
|
273
|
+
if (def.context && def.context.length > 0) entry.context = def.context;
|
|
274
|
+
const defAny = def;
|
|
275
|
+
if (defAny.transport) entry.transport = defAny.transport;
|
|
276
|
+
if (defAny.suppress) entry.suppress = defAny.suppress;
|
|
277
|
+
if (defAny.cache !== void 0) entry.cache = defAny.cache;
|
|
278
|
+
mapped[name] = entry;
|
|
279
|
+
}
|
|
280
|
+
const context = {};
|
|
281
|
+
if (contextConfig) for (const [key, field] of Object.entries(contextConfig)) context[key] = {
|
|
282
|
+
extract: field.extract,
|
|
283
|
+
schema: field.schema._schema
|
|
284
|
+
};
|
|
285
|
+
const manifest = {
|
|
286
|
+
version: 2,
|
|
287
|
+
context,
|
|
288
|
+
procedures: mapped,
|
|
289
|
+
transportDefaults: transportDefaults ?? {}
|
|
290
|
+
};
|
|
291
|
+
if (channels && Object.keys(channels).length > 0) manifest.channels = channels;
|
|
292
|
+
return manifest;
|
|
211
293
|
}
|
|
212
294
|
|
|
213
295
|
//#endregion
|
|
214
296
|
//#region src/router/handler.ts
|
|
215
|
-
async function handleRequest(procedures, procedureName, rawBody, validateOutput) {
|
|
297
|
+
async function handleRequest(procedures, procedureName, rawBody, validateOutput, ctx) {
|
|
216
298
|
const procedure = procedures.get(procedureName);
|
|
217
299
|
if (!procedure) return {
|
|
218
300
|
status: 404,
|
|
@@ -224,7 +306,10 @@ async function handleRequest(procedures, procedureName, rawBody, validateOutput)
|
|
|
224
306
|
body: new SeamError("VALIDATION_ERROR", `Input validation failed: ${formatValidationErrors(validation.errors)}`).toJSON()
|
|
225
307
|
};
|
|
226
308
|
try {
|
|
227
|
-
const result = await procedure.handler({
|
|
309
|
+
const result = await procedure.handler({
|
|
310
|
+
input: rawBody,
|
|
311
|
+
ctx: ctx ?? {}
|
|
312
|
+
});
|
|
228
313
|
if (validateOutput) {
|
|
229
314
|
const outValidation = validateInput(procedure.outputSchema, result);
|
|
230
315
|
if (!outValidation.valid) return {
|
|
@@ -250,9 +335,10 @@ async function handleRequest(procedures, procedureName, rawBody, validateOutput)
|
|
|
250
335
|
};
|
|
251
336
|
}
|
|
252
337
|
}
|
|
253
|
-
async function handleBatchRequest(procedures, calls, validateOutput) {
|
|
338
|
+
async function handleBatchRequest(procedures, calls, validateOutput, ctxResolver) {
|
|
254
339
|
return { results: await Promise.all(calls.map(async (call) => {
|
|
255
|
-
const
|
|
340
|
+
const ctx = ctxResolver ? ctxResolver(call.procedure) : void 0;
|
|
341
|
+
const result = await handleRequest(procedures, call.procedure, call.input, validateOutput, ctx);
|
|
256
342
|
if (result.status === 200) return {
|
|
257
343
|
ok: true,
|
|
258
344
|
data: result.body.data
|
|
@@ -263,12 +349,15 @@ async function handleBatchRequest(procedures, calls, validateOutput) {
|
|
|
263
349
|
};
|
|
264
350
|
})) };
|
|
265
351
|
}
|
|
266
|
-
async function* handleSubscription(subscriptions, name, rawInput, validateOutput) {
|
|
352
|
+
async function* handleSubscription(subscriptions, name, rawInput, validateOutput, ctx) {
|
|
267
353
|
const sub = subscriptions.get(name);
|
|
268
354
|
if (!sub) throw new SeamError("NOT_FOUND", `Subscription '${name}' not found`);
|
|
269
355
|
const validation = validateInput(sub.inputSchema, rawInput);
|
|
270
356
|
if (!validation.valid) throw new SeamError("VALIDATION_ERROR", `Input validation failed: ${formatValidationErrors(validation.errors)}`);
|
|
271
|
-
for await (const value of sub.handler({
|
|
357
|
+
for await (const value of sub.handler({
|
|
358
|
+
input: rawInput,
|
|
359
|
+
ctx: ctx ?? {}
|
|
360
|
+
})) {
|
|
272
361
|
if (validateOutput) {
|
|
273
362
|
const outValidation = validateInput(sub.outputSchema, value);
|
|
274
363
|
if (!outValidation.valid) throw new SeamError("INTERNAL_ERROR", `Output validation failed: ${formatValidationErrors(outValidation.errors)}`);
|
|
@@ -276,17 +365,140 @@ async function* handleSubscription(subscriptions, name, rawInput, validateOutput
|
|
|
276
365
|
yield value;
|
|
277
366
|
}
|
|
278
367
|
}
|
|
368
|
+
async function handleUploadRequest(uploads, procedureName, rawBody, file, validateOutput, ctx) {
|
|
369
|
+
const upload = uploads.get(procedureName);
|
|
370
|
+
if (!upload) return {
|
|
371
|
+
status: 404,
|
|
372
|
+
body: new SeamError("NOT_FOUND", `Procedure '${procedureName}' not found`).toJSON()
|
|
373
|
+
};
|
|
374
|
+
const validation = validateInput(upload.inputSchema, rawBody);
|
|
375
|
+
if (!validation.valid) return {
|
|
376
|
+
status: 400,
|
|
377
|
+
body: new SeamError("VALIDATION_ERROR", `Input validation failed: ${formatValidationErrors(validation.errors)}`).toJSON()
|
|
378
|
+
};
|
|
379
|
+
try {
|
|
380
|
+
const result = await upload.handler({
|
|
381
|
+
input: rawBody,
|
|
382
|
+
file,
|
|
383
|
+
ctx: ctx ?? {}
|
|
384
|
+
});
|
|
385
|
+
if (validateOutput) {
|
|
386
|
+
const outValidation = validateInput(upload.outputSchema, result);
|
|
387
|
+
if (!outValidation.valid) return {
|
|
388
|
+
status: 500,
|
|
389
|
+
body: new SeamError("INTERNAL_ERROR", `Output validation failed: ${formatValidationErrors(outValidation.errors)}`).toJSON()
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
status: 200,
|
|
394
|
+
body: {
|
|
395
|
+
ok: true,
|
|
396
|
+
data: result
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (error instanceof SeamError) return {
|
|
401
|
+
status: error.status,
|
|
402
|
+
body: error.toJSON()
|
|
403
|
+
};
|
|
404
|
+
return {
|
|
405
|
+
status: 500,
|
|
406
|
+
body: new SeamError("INTERNAL_ERROR", error instanceof Error ? error.message : "Unknown error").toJSON()
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async function* handleStream(streams, name, rawInput, validateOutput, ctx) {
|
|
411
|
+
const stream = streams.get(name);
|
|
412
|
+
if (!stream) throw new SeamError("NOT_FOUND", `Stream '${name}' not found`);
|
|
413
|
+
const validation = validateInput(stream.inputSchema, rawInput);
|
|
414
|
+
if (!validation.valid) throw new SeamError("VALIDATION_ERROR", `Input validation failed: ${formatValidationErrors(validation.errors)}`);
|
|
415
|
+
for await (const value of stream.handler({
|
|
416
|
+
input: rawInput,
|
|
417
|
+
ctx: ctx ?? {}
|
|
418
|
+
})) {
|
|
419
|
+
if (validateOutput) {
|
|
420
|
+
const outValidation = validateInput(stream.chunkOutputSchema, value);
|
|
421
|
+
if (!outValidation.valid) throw new SeamError("INTERNAL_ERROR", `Output validation failed: ${formatValidationErrors(outValidation.errors)}`);
|
|
422
|
+
}
|
|
423
|
+
yield value;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region src/page/projection.ts
|
|
429
|
+
/** Set a nested field by dot-separated path, creating intermediate objects as needed. */
|
|
430
|
+
function setNestedField(target, path, value) {
|
|
431
|
+
const parts = path.split(".");
|
|
432
|
+
let current = target;
|
|
433
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
434
|
+
const key = parts[i];
|
|
435
|
+
if (!(key in current) || typeof current[key] !== "object" || current[key] === null) current[key] = {};
|
|
436
|
+
current = current[key];
|
|
437
|
+
}
|
|
438
|
+
current[parts[parts.length - 1]] = value;
|
|
439
|
+
}
|
|
440
|
+
/** Get a nested field by dot-separated path. */
|
|
441
|
+
function getNestedField(source, path) {
|
|
442
|
+
const parts = path.split(".");
|
|
443
|
+
let current = source;
|
|
444
|
+
for (const part of parts) {
|
|
445
|
+
if (current === null || current === void 0 || typeof current !== "object") return;
|
|
446
|
+
current = current[part];
|
|
447
|
+
}
|
|
448
|
+
return current;
|
|
449
|
+
}
|
|
450
|
+
/** Prune a single value according to its projected field paths. */
|
|
451
|
+
function pruneValue(value, fields) {
|
|
452
|
+
const arrayFields = [];
|
|
453
|
+
const plainFields = [];
|
|
454
|
+
for (const f of fields) if (f === "$") return value;
|
|
455
|
+
else if (f.startsWith("$.")) arrayFields.push(f.slice(2));
|
|
456
|
+
else plainFields.push(f);
|
|
457
|
+
if (arrayFields.length > 0 && Array.isArray(value)) return value.map((item) => {
|
|
458
|
+
if (typeof item !== "object" || item === null) return item;
|
|
459
|
+
const pruned = {};
|
|
460
|
+
for (const field of arrayFields) {
|
|
461
|
+
const val = getNestedField(item, field);
|
|
462
|
+
if (val !== void 0) setNestedField(pruned, field, val);
|
|
463
|
+
}
|
|
464
|
+
return pruned;
|
|
465
|
+
});
|
|
466
|
+
if (plainFields.length > 0 && typeof value === "object" && value !== null) {
|
|
467
|
+
const source = value;
|
|
468
|
+
const pruned = {};
|
|
469
|
+
for (const field of plainFields) {
|
|
470
|
+
const val = getNestedField(source, field);
|
|
471
|
+
if (val !== void 0) setNestedField(pruned, field, val);
|
|
472
|
+
}
|
|
473
|
+
return pruned;
|
|
474
|
+
}
|
|
475
|
+
return value;
|
|
476
|
+
}
|
|
477
|
+
/** Prune data to only include projected fields. Missing projection = keep all. */
|
|
478
|
+
function applyProjection(data, projections) {
|
|
479
|
+
if (!projections) return data;
|
|
480
|
+
const result = {};
|
|
481
|
+
for (const [key, value] of Object.entries(data)) {
|
|
482
|
+
const fields = projections[key];
|
|
483
|
+
if (!fields) result[key] = value;
|
|
484
|
+
else result[key] = pruneValue(value, fields);
|
|
485
|
+
}
|
|
486
|
+
return result;
|
|
487
|
+
}
|
|
279
488
|
|
|
280
489
|
//#endregion
|
|
281
490
|
//#region src/page/handler.ts
|
|
282
491
|
/** Execute loaders, returning keyed results */
|
|
283
|
-
async function executeLoaders(loaders, params, procedures) {
|
|
492
|
+
async function executeLoaders(loaders, params, procedures, searchParams) {
|
|
284
493
|
const entries = Object.entries(loaders);
|
|
285
494
|
const results = await Promise.all(entries.map(async ([key, loader]) => {
|
|
286
|
-
const { procedure, input } = loader(params);
|
|
495
|
+
const { procedure, input } = loader(params, searchParams);
|
|
287
496
|
const proc = procedures.get(procedure);
|
|
288
497
|
if (!proc) throw new SeamError("INTERNAL_ERROR", `Procedure '${procedure}' not found`);
|
|
289
|
-
return [key, await proc.handler({
|
|
498
|
+
return [key, await proc.handler({
|
|
499
|
+
input,
|
|
500
|
+
ctx: {}
|
|
501
|
+
})];
|
|
290
502
|
}));
|
|
291
503
|
return Object.fromEntries(results);
|
|
292
504
|
}
|
|
@@ -306,15 +518,16 @@ function lookupMessages(config, routePattern, locale) {
|
|
|
306
518
|
}
|
|
307
519
|
return config.messages[locale]?.[routeHash] ?? {};
|
|
308
520
|
}
|
|
309
|
-
async function handlePageRequest(page, params, procedures, i18nOpts) {
|
|
521
|
+
async function handlePageRequest(page, params, procedures, i18nOpts, searchParams) {
|
|
310
522
|
try {
|
|
311
523
|
const t0 = performance.now();
|
|
312
524
|
const layoutChain = page.layoutChain ?? [];
|
|
313
525
|
const locale = i18nOpts?.locale;
|
|
314
|
-
const loaderResults = await Promise.all([...layoutChain.map((layout) => executeLoaders(layout.loaders, params, procedures)), executeLoaders(page.loaders, params, procedures)]);
|
|
526
|
+
const loaderResults = await Promise.all([...layoutChain.map((layout) => executeLoaders(layout.loaders, params, procedures, searchParams)), executeLoaders(page.loaders, params, procedures, searchParams)]);
|
|
315
527
|
const t1 = performance.now();
|
|
316
528
|
const allData = {};
|
|
317
529
|
for (const result of loaderResults) Object.assign(allData, result);
|
|
530
|
+
const prunedData = applyProjection(allData, page.projections);
|
|
318
531
|
let composedTemplate = selectTemplate(page.template, page.localeTemplates, locale);
|
|
319
532
|
for (let i = layoutChain.length - 1; i >= 0; i--) {
|
|
320
533
|
const layout = layoutChain[i];
|
|
@@ -345,7 +558,7 @@ async function handlePageRequest(page, params, procedures, i18nOpts) {
|
|
|
345
558
|
}
|
|
346
559
|
i18nOptsJson = JSON.stringify(i18nData);
|
|
347
560
|
}
|
|
348
|
-
const html = renderPage(composedTemplate, JSON.stringify(
|
|
561
|
+
const html = renderPage(composedTemplate, JSON.stringify(prunedData), JSON.stringify(config), i18nOptsJson);
|
|
349
562
|
const t2 = performance.now();
|
|
350
563
|
return {
|
|
351
564
|
status: 200,
|
|
@@ -366,23 +579,41 @@ async function handlePageRequest(page, params, procedures, i18nOpts) {
|
|
|
366
579
|
//#endregion
|
|
367
580
|
//#region src/page/route-matcher.ts
|
|
368
581
|
function compileRoute(pattern) {
|
|
369
|
-
return { segments: pattern.split("/").filter(Boolean).map((seg) =>
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
582
|
+
return { segments: pattern.split("/").filter(Boolean).map((seg) => {
|
|
583
|
+
if (seg.startsWith("*")) {
|
|
584
|
+
const optional = seg.endsWith("?");
|
|
585
|
+
return {
|
|
586
|
+
kind: "catch-all",
|
|
587
|
+
name: optional ? seg.slice(1, -1) : seg.slice(1),
|
|
588
|
+
optional
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
if (seg.startsWith(":")) return {
|
|
592
|
+
kind: "param",
|
|
593
|
+
name: seg.slice(1)
|
|
594
|
+
};
|
|
595
|
+
return {
|
|
596
|
+
kind: "static",
|
|
597
|
+
value: seg
|
|
598
|
+
};
|
|
375
599
|
}) };
|
|
376
600
|
}
|
|
377
601
|
function matchRoute(segments, pathParts) {
|
|
378
|
-
if (segments.length !== pathParts.length) return null;
|
|
379
602
|
const params = {};
|
|
380
603
|
for (let i = 0; i < segments.length; i++) {
|
|
381
604
|
const seg = segments[i];
|
|
605
|
+
if (seg.kind === "catch-all") {
|
|
606
|
+
const rest = pathParts.slice(i);
|
|
607
|
+
if (rest.length === 0 && !seg.optional) return null;
|
|
608
|
+
params[seg.name] = rest.join("/");
|
|
609
|
+
return params;
|
|
610
|
+
}
|
|
611
|
+
if (i >= pathParts.length) return null;
|
|
382
612
|
if (seg.kind === "static") {
|
|
383
613
|
if (seg.value !== pathParts[i]) return null;
|
|
384
614
|
} else params[seg.name] = pathParts[i];
|
|
385
615
|
}
|
|
616
|
+
if (segments.length !== pathParts.length) return null;
|
|
386
617
|
return params;
|
|
387
618
|
}
|
|
388
619
|
var RouteMatcher = class {
|
|
@@ -427,7 +658,9 @@ function fromCookie(name = "seam-locale") {
|
|
|
427
658
|
resolve(data) {
|
|
428
659
|
if (!data.cookie) return null;
|
|
429
660
|
for (const pair of data.cookie.split(";")) {
|
|
430
|
-
const
|
|
661
|
+
const parts = pair.trim().split("=");
|
|
662
|
+
const k = parts[0];
|
|
663
|
+
const v = parts[1];
|
|
431
664
|
if (k === name && v && data.locales.includes(v)) return v;
|
|
432
665
|
}
|
|
433
666
|
return null;
|
|
@@ -442,10 +675,11 @@ function fromAcceptLanguage() {
|
|
|
442
675
|
if (!data.acceptLanguage) return null;
|
|
443
676
|
const entries = [];
|
|
444
677
|
for (const part of data.acceptLanguage.split(",")) {
|
|
445
|
-
const
|
|
678
|
+
const parts = part.trim().split(";");
|
|
679
|
+
const lang = parts[0];
|
|
446
680
|
let q = 1;
|
|
447
|
-
for (
|
|
448
|
-
const match =
|
|
681
|
+
for (let j = 1; j < parts.length; j++) {
|
|
682
|
+
const match = parts[j].trim().match(/^q=(\d+(?:\.\d+)?)$/);
|
|
449
683
|
if (match) q = parseFloat(match[1]);
|
|
450
684
|
}
|
|
451
685
|
entries.push({
|
|
@@ -496,10 +730,65 @@ function defaultStrategies() {
|
|
|
496
730
|
}
|
|
497
731
|
|
|
498
732
|
//#endregion
|
|
499
|
-
//#region src/router/
|
|
500
|
-
function
|
|
501
|
-
|
|
733
|
+
//#region src/router/categorize.ts
|
|
734
|
+
function resolveKind(name, def) {
|
|
735
|
+
if ("kind" in def && def.kind) return def.kind;
|
|
736
|
+
if ("type" in def && def.type) {
|
|
737
|
+
console.warn(`[seam] "${name}": "type" field in procedure definition is deprecated, use "kind" instead`);
|
|
738
|
+
return def.type;
|
|
739
|
+
}
|
|
740
|
+
return "query";
|
|
741
|
+
}
|
|
742
|
+
/** Split a flat definition map into typed procedure/subscription/stream maps */
|
|
743
|
+
function categorizeProcedures(definitions, contextConfig) {
|
|
744
|
+
const procedureMap = /* @__PURE__ */ new Map();
|
|
745
|
+
const subscriptionMap = /* @__PURE__ */ new Map();
|
|
746
|
+
const streamMap = /* @__PURE__ */ new Map();
|
|
747
|
+
const uploadMap = /* @__PURE__ */ new Map();
|
|
748
|
+
const kindMap = /* @__PURE__ */ new Map();
|
|
749
|
+
for (const [name, def] of Object.entries(definitions)) {
|
|
750
|
+
const kind = resolveKind(name, def);
|
|
751
|
+
kindMap.set(name, kind);
|
|
752
|
+
const contextKeys = def.context ?? [];
|
|
753
|
+
if (contextConfig && contextKeys.length > 0) {
|
|
754
|
+
for (const key of contextKeys) if (!(key in contextConfig)) throw new Error(`Procedure "${name}" references undefined context field "${key}"`);
|
|
755
|
+
}
|
|
756
|
+
if (kind === "upload") uploadMap.set(name, {
|
|
757
|
+
inputSchema: def.input._schema,
|
|
758
|
+
outputSchema: def.output._schema,
|
|
759
|
+
contextKeys,
|
|
760
|
+
handler: def.handler
|
|
761
|
+
});
|
|
762
|
+
else if (kind === "stream") streamMap.set(name, {
|
|
763
|
+
inputSchema: def.input._schema,
|
|
764
|
+
chunkOutputSchema: def.output._schema,
|
|
765
|
+
contextKeys,
|
|
766
|
+
handler: def.handler
|
|
767
|
+
});
|
|
768
|
+
else if (kind === "subscription") subscriptionMap.set(name, {
|
|
769
|
+
inputSchema: def.input._schema,
|
|
770
|
+
outputSchema: def.output._schema,
|
|
771
|
+
contextKeys,
|
|
772
|
+
handler: def.handler
|
|
773
|
+
});
|
|
774
|
+
else procedureMap.set(name, {
|
|
775
|
+
inputSchema: def.input._schema,
|
|
776
|
+
outputSchema: def.output._schema,
|
|
777
|
+
contextKeys,
|
|
778
|
+
handler: def.handler
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
return {
|
|
782
|
+
procedureMap,
|
|
783
|
+
subscriptionMap,
|
|
784
|
+
streamMap,
|
|
785
|
+
uploadMap,
|
|
786
|
+
kindMap
|
|
787
|
+
};
|
|
502
788
|
}
|
|
789
|
+
|
|
790
|
+
//#endregion
|
|
791
|
+
//#region src/router/index.ts
|
|
503
792
|
/** Build the resolve strategy list from options */
|
|
504
793
|
function buildStrategies(opts) {
|
|
505
794
|
const strategies = opts?.resolve ?? defaultStrategies();
|
|
@@ -513,6 +802,7 @@ function registerI18nQuery(procedureMap, config) {
|
|
|
513
802
|
procedureMap.set("__seam_i18n_query", {
|
|
514
803
|
inputSchema: {},
|
|
515
804
|
outputSchema: {},
|
|
805
|
+
contextKeys: [],
|
|
516
806
|
handler: ({ input }) => {
|
|
517
807
|
const { route, locale } = input;
|
|
518
808
|
const messages = lookupI18nMessages(config, route, locale);
|
|
@@ -532,19 +822,72 @@ function lookupI18nMessages(config, routeHash, locale) {
|
|
|
532
822
|
}
|
|
533
823
|
return config.messages[locale]?.[routeHash] ?? {};
|
|
534
824
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
825
|
+
/** Collect channel metadata from channel results for manifest */
|
|
826
|
+
function collectChannelMeta(channels) {
|
|
827
|
+
if (!channels || channels.length === 0) return void 0;
|
|
828
|
+
return Object.fromEntries(channels.map((ch) => {
|
|
829
|
+
const firstKey = Object.keys(ch.procedures)[0] ?? "";
|
|
830
|
+
return [firstKey.includes(".") ? firstKey.slice(0, firstKey.indexOf(".")) : firstKey, ch.channelMeta];
|
|
831
|
+
}));
|
|
832
|
+
}
|
|
833
|
+
/** Resolve context for a procedure, returning undefined if no context needed */
|
|
834
|
+
function resolveCtxFor(map, name, rawCtx, extractKeys, ctxConfig) {
|
|
835
|
+
if (!rawCtx || extractKeys.length === 0) return void 0;
|
|
836
|
+
const proc = map.get(name);
|
|
837
|
+
if (!proc || proc.contextKeys.length === 0) return void 0;
|
|
838
|
+
return resolveContext(ctxConfig, rawCtx, proc.contextKeys);
|
|
839
|
+
}
|
|
840
|
+
/** Resolve locale and match page route */
|
|
841
|
+
async function matchAndHandlePage(pageMatcher, procedureMap, i18nConfig, strategies, hasUrlPrefix, path, headers) {
|
|
842
|
+
let pathLocale = null;
|
|
843
|
+
let routePath = path;
|
|
844
|
+
if (hasUrlPrefix && i18nConfig) {
|
|
845
|
+
const segments = path.split("/").filter(Boolean);
|
|
846
|
+
const localeSet = new Set(i18nConfig.locales);
|
|
847
|
+
const first = segments[0];
|
|
848
|
+
if (first && localeSet.has(first)) {
|
|
849
|
+
pathLocale = first;
|
|
850
|
+
routePath = "/" + segments.slice(1).join("/") || "/";
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
let locale;
|
|
854
|
+
if (i18nConfig) locale = resolveChain(strategies, {
|
|
855
|
+
url: headers?.url ?? "",
|
|
856
|
+
pathLocale,
|
|
857
|
+
cookie: headers?.cookie,
|
|
858
|
+
acceptLanguage: headers?.acceptLanguage,
|
|
859
|
+
locales: i18nConfig.locales,
|
|
860
|
+
defaultLocale: i18nConfig.default
|
|
547
861
|
});
|
|
862
|
+
const match = pageMatcher.match(routePath);
|
|
863
|
+
if (!match) return null;
|
|
864
|
+
let searchParams;
|
|
865
|
+
if (headers?.url) try {
|
|
866
|
+
const url = new URL(headers.url, "http://localhost");
|
|
867
|
+
if (url.search) searchParams = url.searchParams;
|
|
868
|
+
} catch {}
|
|
869
|
+
const i18nOpts = locale && i18nConfig ? {
|
|
870
|
+
locale,
|
|
871
|
+
config: i18nConfig,
|
|
872
|
+
routePattern: match.pattern
|
|
873
|
+
} : void 0;
|
|
874
|
+
return handlePageRequest(match.value, match.params, procedureMap, i18nOpts, searchParams);
|
|
875
|
+
}
|
|
876
|
+
/** Catch context resolution errors and return them as HandleResult */
|
|
877
|
+
function resolveCtxSafe(map, name, rawCtx, extractKeys, ctxConfig) {
|
|
878
|
+
try {
|
|
879
|
+
return { ctx: resolveCtxFor(map, name, rawCtx, extractKeys, ctxConfig) };
|
|
880
|
+
} catch (err) {
|
|
881
|
+
if (err instanceof SeamError) return { error: {
|
|
882
|
+
status: err.status,
|
|
883
|
+
body: err.toJSON()
|
|
884
|
+
} };
|
|
885
|
+
throw err;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
function createRouter(procedures, opts) {
|
|
889
|
+
const ctxConfig = opts?.context ?? {};
|
|
890
|
+
const { procedureMap, subscriptionMap, streamMap, uploadMap, kindMap } = categorizeProcedures(procedures, Object.keys(ctxConfig).length > 0 ? ctxConfig : void 0);
|
|
548
891
|
const shouldValidateOutput = opts?.validateOutput ?? (typeof process !== "undefined" && process.env.NODE_ENV !== "production");
|
|
549
892
|
const pageMatcher = new RouteMatcher();
|
|
550
893
|
const pages = opts?.pages;
|
|
@@ -552,53 +895,41 @@ function createRouter(procedures, opts) {
|
|
|
552
895
|
const i18nConfig = opts?.i18n ?? null;
|
|
553
896
|
const { strategies, hasUrlPrefix } = buildStrategies(opts);
|
|
554
897
|
if (i18nConfig) registerI18nQuery(procedureMap, i18nConfig);
|
|
555
|
-
const channelsMeta = opts?.channels
|
|
556
|
-
|
|
557
|
-
return [firstKey.includes(".") ? firstKey.slice(0, firstKey.indexOf(".")) : firstKey, ch.channelMeta];
|
|
558
|
-
})) : void 0;
|
|
898
|
+
const channelsMeta = collectChannelMeta(opts?.channels);
|
|
899
|
+
const extractKeys = contextExtractKeys(ctxConfig);
|
|
559
900
|
return {
|
|
560
901
|
procedures,
|
|
561
902
|
hasPages: !!pages && Object.keys(pages).length > 0,
|
|
903
|
+
contextExtractKeys() {
|
|
904
|
+
return extractKeys;
|
|
905
|
+
},
|
|
562
906
|
manifest() {
|
|
563
|
-
return buildManifest(procedures, channelsMeta);
|
|
907
|
+
return buildManifest(procedures, channelsMeta, ctxConfig, opts?.transportDefaults);
|
|
564
908
|
},
|
|
565
|
-
handle(procedureName, body) {
|
|
566
|
-
|
|
909
|
+
async handle(procedureName, body, rawCtx) {
|
|
910
|
+
const { ctx, error } = resolveCtxSafe(procedureMap, procedureName, rawCtx, extractKeys, ctxConfig);
|
|
911
|
+
if (error) return error;
|
|
912
|
+
return handleRequest(procedureMap, procedureName, body, shouldValidateOutput, ctx);
|
|
567
913
|
},
|
|
568
|
-
handleBatch(calls) {
|
|
569
|
-
return handleBatchRequest(procedureMap, calls, shouldValidateOutput);
|
|
914
|
+
handleBatch(calls, rawCtx) {
|
|
915
|
+
return handleBatchRequest(procedureMap, calls, shouldValidateOutput, rawCtx ? (name) => resolveCtxFor(procedureMap, name, rawCtx, extractKeys, ctxConfig) ?? {} : void 0);
|
|
570
916
|
},
|
|
571
|
-
handleSubscription(name, input) {
|
|
572
|
-
return handleSubscription(subscriptionMap, name, input, shouldValidateOutput);
|
|
917
|
+
handleSubscription(name, input, rawCtx) {
|
|
918
|
+
return handleSubscription(subscriptionMap, name, input, shouldValidateOutput, resolveCtxFor(subscriptionMap, name, rawCtx, extractKeys, ctxConfig));
|
|
573
919
|
},
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
url: headers?.url ?? "",
|
|
588
|
-
pathLocale,
|
|
589
|
-
cookie: headers?.cookie,
|
|
590
|
-
acceptLanguage: headers?.acceptLanguage,
|
|
591
|
-
locales: i18nConfig.locales,
|
|
592
|
-
defaultLocale: i18nConfig.default
|
|
593
|
-
});
|
|
594
|
-
const match = pageMatcher.match(routePath);
|
|
595
|
-
if (!match) return null;
|
|
596
|
-
const i18nOpts = locale && i18nConfig ? {
|
|
597
|
-
locale,
|
|
598
|
-
config: i18nConfig,
|
|
599
|
-
routePattern: match.pattern
|
|
600
|
-
} : void 0;
|
|
601
|
-
return handlePageRequest(match.value, match.params, procedureMap, i18nOpts);
|
|
920
|
+
handleStream(name, input, rawCtx) {
|
|
921
|
+
return handleStream(streamMap, name, input, shouldValidateOutput, resolveCtxFor(streamMap, name, rawCtx, extractKeys, ctxConfig));
|
|
922
|
+
},
|
|
923
|
+
async handleUpload(name, body, file, rawCtx) {
|
|
924
|
+
const { ctx, error } = resolveCtxSafe(uploadMap, name, rawCtx, extractKeys, ctxConfig);
|
|
925
|
+
if (error) return error;
|
|
926
|
+
return handleUploadRequest(uploadMap, name, body, file, shouldValidateOutput, ctx);
|
|
927
|
+
},
|
|
928
|
+
getKind(name) {
|
|
929
|
+
return kindMap.get(name) ?? null;
|
|
930
|
+
},
|
|
931
|
+
handlePage(path, headers) {
|
|
932
|
+
return matchAndHandlePage(pageMatcher, procedureMap, i18nConfig, strategies, hasUrlPrefix, path, headers);
|
|
602
933
|
}
|
|
603
934
|
};
|
|
604
935
|
}
|
|
@@ -639,7 +970,7 @@ function createChannel(name, def) {
|
|
|
639
970
|
const channelInputSchema = def.input._schema;
|
|
640
971
|
for (const [msgName, msgDef] of Object.entries(def.incoming)) {
|
|
641
972
|
const command = {
|
|
642
|
-
|
|
973
|
+
kind: "command",
|
|
643
974
|
input: { _schema: mergeObjectSchemas(channelInputSchema, msgDef.input._schema) },
|
|
644
975
|
output: msgDef.output,
|
|
645
976
|
handler: msgDef.handler
|
|
@@ -649,7 +980,7 @@ function createChannel(name, def) {
|
|
|
649
980
|
}
|
|
650
981
|
const unionSchema = buildOutgoingUnionSchema(def.outgoing);
|
|
651
982
|
const subscription = {
|
|
652
|
-
|
|
983
|
+
kind: "subscription",
|
|
653
984
|
input: def.input,
|
|
654
985
|
output: { _schema: unionSchema },
|
|
655
986
|
handler: def.subscribe
|
|
@@ -666,13 +997,15 @@ function createChannel(name, def) {
|
|
|
666
997
|
}
|
|
667
998
|
const outgoingMeta = {};
|
|
668
999
|
for (const [eventName, node] of Object.entries(def.outgoing)) outgoingMeta[eventName] = node._schema;
|
|
1000
|
+
const channelMeta = {
|
|
1001
|
+
input: channelInputSchema,
|
|
1002
|
+
incoming: incomingMeta,
|
|
1003
|
+
outgoing: outgoingMeta
|
|
1004
|
+
};
|
|
1005
|
+
if (def.transport) channelMeta.transport = def.transport;
|
|
669
1006
|
return {
|
|
670
1007
|
procedures,
|
|
671
|
-
channelMeta
|
|
672
|
-
input: channelInputSchema,
|
|
673
|
-
incoming: incomingMeta,
|
|
674
|
-
outgoing: outgoingMeta
|
|
675
|
-
}
|
|
1008
|
+
channelMeta
|
|
676
1009
|
};
|
|
677
1010
|
}
|
|
678
1011
|
|
|
@@ -753,6 +1086,10 @@ async function handleStaticAsset(assetPath, staticDir) {
|
|
|
753
1086
|
function sseDataEvent(data) {
|
|
754
1087
|
return `event: data\ndata: ${JSON.stringify(data)}\n\n`;
|
|
755
1088
|
}
|
|
1089
|
+
/** Format an SSE data event with a sequence id (for streams) */
|
|
1090
|
+
function sseDataEventWithId(data, id) {
|
|
1091
|
+
return `event: data\nid: ${id}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
1092
|
+
}
|
|
756
1093
|
/** Format an SSE error event */
|
|
757
1094
|
function sseErrorEvent(code, message, transient = false) {
|
|
758
1095
|
return `event: error\ndata: ${JSON.stringify({
|
|
@@ -765,16 +1102,30 @@ function sseErrorEvent(code, message, transient = false) {
|
|
|
765
1102
|
function sseCompleteEvent() {
|
|
766
1103
|
return "event: complete\ndata: {}\n\n";
|
|
767
1104
|
}
|
|
768
|
-
async function* sseStream(router, name, input) {
|
|
1105
|
+
async function* sseStream(router, name, input, rawCtx) {
|
|
769
1106
|
try {
|
|
770
|
-
for await (const value of router.handleSubscription(name, input)) yield sseDataEvent(value);
|
|
1107
|
+
for await (const value of router.handleSubscription(name, input, rawCtx)) yield sseDataEvent(value);
|
|
771
1108
|
yield sseCompleteEvent();
|
|
772
1109
|
} catch (error) {
|
|
773
1110
|
if (error instanceof SeamError) yield sseErrorEvent(error.code, error.message);
|
|
774
1111
|
else yield sseErrorEvent("INTERNAL_ERROR", error instanceof Error ? error.message : "Unknown error");
|
|
775
1112
|
}
|
|
776
1113
|
}
|
|
777
|
-
async function
|
|
1114
|
+
async function* sseStreamForStream(router, name, input, signal, rawCtx) {
|
|
1115
|
+
const gen = router.handleStream(name, input, rawCtx);
|
|
1116
|
+
if (signal) signal.addEventListener("abort", () => {
|
|
1117
|
+
gen.return(void 0);
|
|
1118
|
+
}, { once: true });
|
|
1119
|
+
try {
|
|
1120
|
+
let seq = 0;
|
|
1121
|
+
for await (const value of gen) yield sseDataEventWithId(value, seq++);
|
|
1122
|
+
yield sseCompleteEvent();
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
if (error instanceof SeamError) yield sseErrorEvent(error.code, error.message);
|
|
1125
|
+
else yield sseErrorEvent("INTERNAL_ERROR", error instanceof Error ? error.message : "Unknown error");
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
async function handleBatchHttp(req, router, hashToName, rawCtx) {
|
|
778
1129
|
let body;
|
|
779
1130
|
try {
|
|
780
1131
|
body = await req.body();
|
|
@@ -788,45 +1139,62 @@ async function handleBatchHttp(req, router, hashToName) {
|
|
|
788
1139
|
}));
|
|
789
1140
|
return jsonResponse(200, {
|
|
790
1141
|
ok: true,
|
|
791
|
-
data: await router.handleBatch(calls)
|
|
1142
|
+
data: await router.handleBatch(calls, rawCtx)
|
|
792
1143
|
});
|
|
793
1144
|
}
|
|
1145
|
+
/** Resolve hash -> original name when obfuscation is active. Returns null on miss. */
|
|
1146
|
+
function resolveHashName(hashToName, name) {
|
|
1147
|
+
if (!hashToName) return name;
|
|
1148
|
+
return hashToName.get(name) ?? null;
|
|
1149
|
+
}
|
|
794
1150
|
function createHttpHandler(router, opts) {
|
|
795
1151
|
const hashToName = opts?.rpcHashMap ? new Map(Object.entries(opts.rpcHashMap.procedures).map(([n, h]) => [h, n])) : null;
|
|
796
1152
|
if (hashToName) hashToName.set("__seam_i18n_query", "__seam_i18n_query");
|
|
797
1153
|
const batchHash = opts?.rpcHashMap?.batch ?? null;
|
|
1154
|
+
const ctxExtractKeys = router.contextExtractKeys();
|
|
798
1155
|
return async (req) => {
|
|
799
1156
|
const url = new URL(req.url, "http://localhost");
|
|
800
1157
|
const { pathname } = url;
|
|
1158
|
+
const rawCtx = ctxExtractKeys.length > 0 && req.header ? Object.fromEntries(ctxExtractKeys.map((k) => [k, req.header?.(k) ?? null])) : void 0;
|
|
801
1159
|
if (req.method === "GET" && pathname === MANIFEST_PATH) {
|
|
802
1160
|
if (opts?.rpcHashMap) return errorResponse(403, "FORBIDDEN", "Manifest disabled");
|
|
803
1161
|
return jsonResponse(200, router.manifest());
|
|
804
1162
|
}
|
|
805
1163
|
if (pathname.startsWith(PROCEDURE_PREFIX)) {
|
|
806
|
-
|
|
807
|
-
if (!
|
|
1164
|
+
const rawName = pathname.slice(17);
|
|
1165
|
+
if (!rawName) return errorResponse(404, "NOT_FOUND", "Empty procedure name");
|
|
808
1166
|
if (req.method === "POST") {
|
|
809
|
-
if (
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
if (!resolved) return errorResponse(404, "NOT_FOUND", "Not found");
|
|
813
|
-
name = resolved;
|
|
814
|
-
}
|
|
1167
|
+
if (rawName === "_batch" || batchHash && rawName === batchHash) return handleBatchHttp(req, router, hashToName, rawCtx);
|
|
1168
|
+
const name = resolveHashName(hashToName, rawName);
|
|
1169
|
+
if (!name) return errorResponse(404, "NOT_FOUND", "Not found");
|
|
815
1170
|
let body;
|
|
816
1171
|
try {
|
|
817
1172
|
body = await req.body();
|
|
818
1173
|
} catch {
|
|
819
1174
|
return errorResponse(400, "VALIDATION_ERROR", "Invalid JSON body");
|
|
820
1175
|
}
|
|
821
|
-
|
|
1176
|
+
if (router.getKind(name) === "stream") {
|
|
1177
|
+
const controller = new AbortController();
|
|
1178
|
+
return {
|
|
1179
|
+
status: 200,
|
|
1180
|
+
headers: SSE_HEADER,
|
|
1181
|
+
stream: sseStreamForStream(router, name, body, controller.signal, rawCtx),
|
|
1182
|
+
onCancel: () => controller.abort()
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
if (router.getKind(name) === "upload") {
|
|
1186
|
+
if (!req.file) return errorResponse(400, "VALIDATION_ERROR", "Upload requires multipart/form-data");
|
|
1187
|
+
const file = await req.file();
|
|
1188
|
+
if (!file) return errorResponse(400, "VALIDATION_ERROR", "Upload requires file in multipart body");
|
|
1189
|
+
const result = await router.handleUpload(name, body, file, rawCtx);
|
|
1190
|
+
return jsonResponse(result.status, result.body);
|
|
1191
|
+
}
|
|
1192
|
+
const result = await router.handle(name, body, rawCtx);
|
|
822
1193
|
return jsonResponse(result.status, result.body);
|
|
823
1194
|
}
|
|
824
1195
|
if (req.method === "GET") {
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
if (!resolved) return errorResponse(404, "NOT_FOUND", "Not found");
|
|
828
|
-
name = resolved;
|
|
829
|
-
}
|
|
1196
|
+
const name = resolveHashName(hashToName, rawName);
|
|
1197
|
+
if (!name) return errorResponse(404, "NOT_FOUND", "Not found");
|
|
830
1198
|
const rawInput = url.searchParams.get("input");
|
|
831
1199
|
let input;
|
|
832
1200
|
try {
|
|
@@ -837,7 +1205,7 @@ function createHttpHandler(router, opts) {
|
|
|
837
1205
|
return {
|
|
838
1206
|
status: 200,
|
|
839
1207
|
headers: SSE_HEADER,
|
|
840
|
-
stream: sseStream(router, name, input)
|
|
1208
|
+
stream: sseStream(router, name, input, rawCtx)
|
|
841
1209
|
};
|
|
842
1210
|
}
|
|
843
1211
|
}
|
|
@@ -873,13 +1241,19 @@ async function drainStream(stream, write) {
|
|
|
873
1241
|
function toWebResponse(result) {
|
|
874
1242
|
if ("stream" in result) {
|
|
875
1243
|
const stream = result.stream;
|
|
1244
|
+
const onCancel = result.onCancel;
|
|
876
1245
|
const encoder = new TextEncoder();
|
|
877
|
-
const readable = new ReadableStream({
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1246
|
+
const readable = new ReadableStream({
|
|
1247
|
+
async start(controller) {
|
|
1248
|
+
await drainStream(stream, (chunk) => {
|
|
1249
|
+
controller.enqueue(encoder.encode(chunk));
|
|
1250
|
+
});
|
|
1251
|
+
controller.close();
|
|
1252
|
+
},
|
|
1253
|
+
cancel() {
|
|
1254
|
+
onCancel?.();
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
883
1257
|
return new Response(readable, {
|
|
884
1258
|
status: result.status,
|
|
885
1259
|
headers: result.headers
|
|
@@ -894,11 +1268,11 @@ function toWebResponse(result) {
|
|
|
894
1268
|
//#endregion
|
|
895
1269
|
//#region src/page/build-loader.ts
|
|
896
1270
|
function buildLoaderFn(config) {
|
|
897
|
-
return (params) => {
|
|
1271
|
+
return (params, searchParams) => {
|
|
898
1272
|
const input = {};
|
|
899
1273
|
if (config.params) for (const [key, mapping] of Object.entries(config.params)) {
|
|
900
|
-
const raw = params[key];
|
|
901
|
-
input[key] = mapping.type === "int" ? Number(raw) : raw;
|
|
1274
|
+
const raw = mapping.from === "query" ? searchParams?.get(key) ?? void 0 : params[key];
|
|
1275
|
+
if (raw !== void 0) input[key] = mapping.type === "int" ? Number(raw) : raw;
|
|
902
1276
|
}
|
|
903
1277
|
return {
|
|
904
1278
|
procedure: config.procedure,
|
|
@@ -929,42 +1303,19 @@ function loadLocaleTemplates(entry, distDir) {
|
|
|
929
1303
|
return result;
|
|
930
1304
|
}
|
|
931
1305
|
/** Resolve parent chain for a layout, returning outer-to-inner order */
|
|
932
|
-
function resolveLayoutChain(layoutId, layoutEntries,
|
|
1306
|
+
function resolveLayoutChain(layoutId, layoutEntries, getTemplates) {
|
|
933
1307
|
const chain = [];
|
|
934
1308
|
let currentId = layoutId;
|
|
935
1309
|
while (currentId) {
|
|
936
1310
|
const entry = layoutEntries[currentId];
|
|
937
1311
|
if (!entry) break;
|
|
1312
|
+
const { template, localeTemplates } = getTemplates(currentId, entry);
|
|
938
1313
|
chain.push({
|
|
939
1314
|
id: currentId,
|
|
940
|
-
template
|
|
941
|
-
localeTemplates
|
|
942
|
-
loaders: buildLoaderFns(entry.loaders ?? {})
|
|
943
|
-
});
|
|
944
|
-
currentId = entry.parent;
|
|
945
|
-
}
|
|
946
|
-
chain.reverse();
|
|
947
|
-
return chain;
|
|
948
|
-
}
|
|
949
|
-
/** Resolve layout chain with lazy template getters (re-read from disk on each access) */
|
|
950
|
-
function resolveLayoutChainDev(layoutId, layoutEntries, distDir, defaultLocale) {
|
|
951
|
-
const chain = [];
|
|
952
|
-
let currentId = layoutId;
|
|
953
|
-
while (currentId) {
|
|
954
|
-
const entry = layoutEntries[currentId];
|
|
955
|
-
if (!entry) break;
|
|
956
|
-
const layoutTemplatePath = join(distDir, resolveTemplatePath(entry, defaultLocale));
|
|
957
|
-
const def = {
|
|
958
|
-
id: currentId,
|
|
959
|
-
template: "",
|
|
960
|
-
localeTemplates: entry.templates ? makeLocaleTemplateGetters(entry.templates, distDir) : void 0,
|
|
1315
|
+
template,
|
|
1316
|
+
localeTemplates,
|
|
961
1317
|
loaders: buildLoaderFns(entry.loaders ?? {})
|
|
962
|
-
};
|
|
963
|
-
Object.defineProperty(def, "template", {
|
|
964
|
-
get: () => readFileSync(layoutTemplatePath, "utf-8"),
|
|
965
|
-
enumerable: true
|
|
966
1318
|
});
|
|
967
|
-
chain.push(def);
|
|
968
1319
|
currentId = entry.parent;
|
|
969
1320
|
}
|
|
970
1321
|
chain.reverse();
|
|
@@ -1055,7 +1406,10 @@ function loadBuildOutput(distDir) {
|
|
|
1055
1406
|
for (const [path, entry] of Object.entries(manifest.routes)) {
|
|
1056
1407
|
const template = readFileSync(join(distDir, resolveTemplatePath(entry, defaultLocale)), "utf-8");
|
|
1057
1408
|
const loaders = buildLoaderFns(entry.loaders);
|
|
1058
|
-
const layoutChain = entry.layout ? resolveLayoutChain(entry.layout, layoutEntries,
|
|
1409
|
+
const layoutChain = entry.layout ? resolveLayoutChain(entry.layout, layoutEntries, (id) => ({
|
|
1410
|
+
template: layoutTemplates[id] ?? "",
|
|
1411
|
+
localeTemplates: layoutLocaleTemplates[id]
|
|
1412
|
+
})) : [];
|
|
1059
1413
|
const i18nKeys = mergeI18nKeys(entry, layoutEntries);
|
|
1060
1414
|
pages[path] = {
|
|
1061
1415
|
template,
|
|
@@ -1065,7 +1419,8 @@ function loadBuildOutput(distDir) {
|
|
|
1065
1419
|
headMeta: entry.head_meta,
|
|
1066
1420
|
dataId: manifest.data_id,
|
|
1067
1421
|
i18nKeys,
|
|
1068
|
-
pageAssets: entry.assets
|
|
1422
|
+
pageAssets: entry.assets,
|
|
1423
|
+
projections: entry.projections
|
|
1069
1424
|
};
|
|
1070
1425
|
}
|
|
1071
1426
|
return pages;
|
|
@@ -1080,7 +1435,18 @@ function loadBuildOutputDev(distDir) {
|
|
|
1080
1435
|
for (const [path, entry] of Object.entries(manifest.routes)) {
|
|
1081
1436
|
const templatePath = join(distDir, resolveTemplatePath(entry, defaultLocale));
|
|
1082
1437
|
const loaders = buildLoaderFns(entry.loaders);
|
|
1083
|
-
const layoutChain = entry.layout ?
|
|
1438
|
+
const layoutChain = entry.layout ? resolveLayoutChain(entry.layout, layoutEntries, (id, layoutEntry) => {
|
|
1439
|
+
const tmplPath = join(distDir, resolveTemplatePath(layoutEntry, defaultLocale));
|
|
1440
|
+
const def = {
|
|
1441
|
+
template: "",
|
|
1442
|
+
localeTemplates: layoutEntry.templates ? makeLocaleTemplateGetters(layoutEntry.templates, distDir) : void 0
|
|
1443
|
+
};
|
|
1444
|
+
Object.defineProperty(def, "template", {
|
|
1445
|
+
get: () => readFileSync(tmplPath, "utf-8"),
|
|
1446
|
+
enumerable: true
|
|
1447
|
+
});
|
|
1448
|
+
return def;
|
|
1449
|
+
}) : [];
|
|
1084
1450
|
const localeTemplates = entry.templates ? makeLocaleTemplateGetters(entry.templates, distDir) : void 0;
|
|
1085
1451
|
const i18nKeys = mergeI18nKeys(entry, layoutEntries);
|
|
1086
1452
|
const page = {
|
|
@@ -1090,7 +1456,8 @@ function loadBuildOutputDev(distDir) {
|
|
|
1090
1456
|
layoutChain,
|
|
1091
1457
|
dataId: manifest.data_id,
|
|
1092
1458
|
i18nKeys,
|
|
1093
|
-
pageAssets: entry.assets
|
|
1459
|
+
pageAssets: entry.assets,
|
|
1460
|
+
projections: entry.projections
|
|
1094
1461
|
};
|
|
1095
1462
|
Object.defineProperty(page, "template", {
|
|
1096
1463
|
get: () => readFileSync(templatePath, "utf-8"),
|
|
@@ -1339,26 +1706,58 @@ function createStaticHandler(opts) {
|
|
|
1339
1706
|
function watchReloadTrigger(distDir, onReload) {
|
|
1340
1707
|
const triggerPath = join(distDir, ".reload-trigger");
|
|
1341
1708
|
let watcher = null;
|
|
1709
|
+
let closed = false;
|
|
1710
|
+
let pending = [];
|
|
1711
|
+
const notify = () => {
|
|
1712
|
+
onReload();
|
|
1713
|
+
const batch = pending;
|
|
1714
|
+
pending = [];
|
|
1715
|
+
for (const p of batch) p.resolve();
|
|
1716
|
+
};
|
|
1717
|
+
const nextReload = () => {
|
|
1718
|
+
if (closed) return Promise.reject(/* @__PURE__ */ new Error("watcher closed"));
|
|
1719
|
+
return new Promise((resolve, reject) => {
|
|
1720
|
+
pending.push({
|
|
1721
|
+
resolve,
|
|
1722
|
+
reject
|
|
1723
|
+
});
|
|
1724
|
+
});
|
|
1725
|
+
};
|
|
1726
|
+
const closeAll = () => {
|
|
1727
|
+
closed = true;
|
|
1728
|
+
const batch = pending;
|
|
1729
|
+
pending = [];
|
|
1730
|
+
const err = /* @__PURE__ */ new Error("watcher closed");
|
|
1731
|
+
for (const p of batch) p.reject(err);
|
|
1732
|
+
};
|
|
1342
1733
|
try {
|
|
1343
|
-
watcher = watch(triggerPath, () =>
|
|
1734
|
+
watcher = watch(triggerPath, () => notify());
|
|
1344
1735
|
} catch {
|
|
1345
1736
|
const dirWatcher = watch(distDir, (_event, filename) => {
|
|
1346
1737
|
if (filename === ".reload-trigger") {
|
|
1347
1738
|
dirWatcher.close();
|
|
1348
|
-
watcher = watch(triggerPath, () =>
|
|
1349
|
-
|
|
1739
|
+
watcher = watch(triggerPath, () => notify());
|
|
1740
|
+
notify();
|
|
1350
1741
|
}
|
|
1351
1742
|
});
|
|
1352
|
-
return {
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1743
|
+
return {
|
|
1744
|
+
close() {
|
|
1745
|
+
dirWatcher.close();
|
|
1746
|
+
watcher?.close();
|
|
1747
|
+
closeAll();
|
|
1748
|
+
},
|
|
1749
|
+
nextReload
|
|
1750
|
+
};
|
|
1356
1751
|
}
|
|
1357
|
-
return {
|
|
1358
|
-
|
|
1359
|
-
|
|
1752
|
+
return {
|
|
1753
|
+
close() {
|
|
1754
|
+
watcher?.close();
|
|
1755
|
+
closeAll();
|
|
1756
|
+
},
|
|
1757
|
+
nextReload
|
|
1758
|
+
};
|
|
1360
1759
|
}
|
|
1361
1760
|
|
|
1362
1761
|
//#endregion
|
|
1363
|
-
export { SeamError, createChannel, createDevProxy, createHttpHandler, createRouter, createStaticHandler, defaultStrategies, definePage, drainStream, fromAcceptLanguage, fromCallback, fromCookie, fromUrlPrefix, fromUrlQuery, loadBuildOutput, loadBuildOutputDev, loadI18nMessages, loadRpcHashMap, resolveChain, serialize, sseCompleteEvent, sseDataEvent, sseErrorEvent, startChannelWs, t, toWebResponse, watchReloadTrigger };
|
|
1762
|
+
export { SeamError, createChannel, createDevProxy, createHttpHandler, createRouter, createStaticHandler, defaultStrategies, definePage, drainStream, fromAcceptLanguage, fromCallback, fromCookie, fromUrlPrefix, fromUrlQuery, loadBuildOutput, loadBuildOutputDev, loadI18nMessages, loadRpcHashMap, resolveChain, serialize, sseCompleteEvent, sseDataEvent, sseDataEventWithId, sseErrorEvent, startChannelWs, t, toWebResponse, watchReloadTrigger };
|
|
1364
1763
|
//# sourceMappingURL=index.js.map
|