@canmi/seam-server 0.5.10 → 0.5.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 +2 -2
- package/dist/index.d.ts +234 -64
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +784 -315
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -157,6 +157,27 @@ function formatValidationErrors(errors) {
|
|
|
157
157
|
return `${e.instancePath.length > 0 ? e.instancePath.join("/") : "(root)"} (schema: ${e.schemaPath.join("/")})`;
|
|
158
158
|
}).join("; ");
|
|
159
159
|
}
|
|
160
|
+
/** Walk a nested object along a key path, returning undefined if unreachable */
|
|
161
|
+
function walkPath(obj, keys) {
|
|
162
|
+
let cur = obj;
|
|
163
|
+
for (const k of keys) {
|
|
164
|
+
if (cur === null || cur === void 0 || typeof cur !== "object") return void 0;
|
|
165
|
+
cur = cur[k];
|
|
166
|
+
}
|
|
167
|
+
return cur;
|
|
168
|
+
}
|
|
169
|
+
/** Extract structured details from JTD validation errors (best-effort) */
|
|
170
|
+
function formatValidationDetails(errors, schema, data) {
|
|
171
|
+
return errors.map((e) => {
|
|
172
|
+
const detail = { path: "/" + e.instancePath.join("/") };
|
|
173
|
+
const schemaValue = walkPath(schema, e.schemaPath);
|
|
174
|
+
if (typeof schemaValue === "string") detail.expected = schemaValue;
|
|
175
|
+
const actualValue = walkPath(data, e.instancePath);
|
|
176
|
+
if (actualValue !== void 0) detail.actual = typeof actualValue;
|
|
177
|
+
else if (e.instancePath.length === 0) detail.actual = typeof data;
|
|
178
|
+
return detail;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
160
181
|
|
|
161
182
|
//#endregion
|
|
162
183
|
//#region src/errors.ts
|
|
@@ -171,20 +192,24 @@ const DEFAULT_STATUS = {
|
|
|
171
192
|
var SeamError = class extends Error {
|
|
172
193
|
code;
|
|
173
194
|
status;
|
|
174
|
-
|
|
195
|
+
details;
|
|
196
|
+
constructor(code, message, status, details) {
|
|
175
197
|
super(message);
|
|
176
198
|
this.code = code;
|
|
177
199
|
this.status = status ?? DEFAULT_STATUS[code] ?? 500;
|
|
200
|
+
this.details = details;
|
|
178
201
|
this.name = "SeamError";
|
|
179
202
|
}
|
|
180
203
|
toJSON() {
|
|
204
|
+
const error = {
|
|
205
|
+
code: this.code,
|
|
206
|
+
message: this.message,
|
|
207
|
+
transient: false
|
|
208
|
+
};
|
|
209
|
+
if (this.details) error.details = this.details;
|
|
181
210
|
return {
|
|
182
211
|
ok: false,
|
|
183
|
-
error
|
|
184
|
-
code: this.code,
|
|
185
|
-
message: this.message,
|
|
186
|
-
transient: false
|
|
187
|
-
}
|
|
212
|
+
error
|
|
188
213
|
};
|
|
189
214
|
}
|
|
190
215
|
};
|
|
@@ -203,14 +228,40 @@ function parseExtractRule(rule) {
|
|
|
203
228
|
key
|
|
204
229
|
};
|
|
205
230
|
}
|
|
206
|
-
/**
|
|
207
|
-
function
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
231
|
+
/** Check whether any context fields are defined */
|
|
232
|
+
function contextHasExtracts(config) {
|
|
233
|
+
return Object.keys(config).length > 0;
|
|
234
|
+
}
|
|
235
|
+
/** Parse a Cookie header into key-value pairs */
|
|
236
|
+
function parseCookieHeader(header) {
|
|
237
|
+
const result = {};
|
|
238
|
+
for (const pair of header.split(";")) {
|
|
239
|
+
const idx = pair.indexOf("=");
|
|
240
|
+
if (idx > 0) result[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
|
|
241
|
+
}
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
/** Build a RawContextMap keyed by config key from request headers, cookies, and query params */
|
|
245
|
+
function buildRawContext(config, headerFn, url) {
|
|
246
|
+
const raw = {};
|
|
247
|
+
let cookieCache;
|
|
248
|
+
for (const [key, field] of Object.entries(config)) {
|
|
249
|
+
const { source, key: extractKey } = parseExtractRule(field.extract);
|
|
250
|
+
switch (source) {
|
|
251
|
+
case "header":
|
|
252
|
+
raw[key] = headerFn?.(extractKey) ?? null;
|
|
253
|
+
break;
|
|
254
|
+
case "cookie":
|
|
255
|
+
if (!cookieCache) cookieCache = parseCookieHeader(headerFn?.("cookie") ?? "");
|
|
256
|
+
raw[key] = cookieCache[extractKey] ?? null;
|
|
257
|
+
break;
|
|
258
|
+
case "query":
|
|
259
|
+
raw[key] = url.searchParams.get(extractKey) ?? null;
|
|
260
|
+
break;
|
|
261
|
+
default: raw[key] = null;
|
|
262
|
+
}
|
|
212
263
|
}
|
|
213
|
-
return
|
|
264
|
+
return raw;
|
|
214
265
|
}
|
|
215
266
|
/**
|
|
216
267
|
* Resolve raw strings into validated context object.
|
|
@@ -225,8 +276,7 @@ function resolveContext(config, raw, requestedKeys) {
|
|
|
225
276
|
for (const key of requestedKeys) {
|
|
226
277
|
const field = config[key];
|
|
227
278
|
if (!field) throw new SeamError("CONTEXT_ERROR", `Context field "${key}" is not defined in router context config`, 400);
|
|
228
|
-
const
|
|
229
|
-
const rawValue = raw[source === "header" ? extractKey : extractKey] ?? null;
|
|
279
|
+
const rawValue = raw[key] ?? null;
|
|
230
280
|
let value;
|
|
231
281
|
if (rawValue === null) value = null;
|
|
232
282
|
else {
|
|
@@ -246,6 +296,12 @@ function resolveContext(config, raw, requestedKeys) {
|
|
|
246
296
|
}
|
|
247
297
|
return result;
|
|
248
298
|
}
|
|
299
|
+
/** Syntax sugar for building extract rules; the underlying "source:key" format is unchanged. */
|
|
300
|
+
const extract = {
|
|
301
|
+
header: (name) => `header:${name}`,
|
|
302
|
+
cookie: (name) => `cookie:${name}`,
|
|
303
|
+
query: (name) => `query:${name}`
|
|
304
|
+
};
|
|
249
305
|
|
|
250
306
|
//#endregion
|
|
251
307
|
//#region src/manifest/index.ts
|
|
@@ -292,19 +348,87 @@ function buildManifest(definitions, channels, contextConfig, transportDefaults)
|
|
|
292
348
|
return manifest;
|
|
293
349
|
}
|
|
294
350
|
|
|
351
|
+
//#endregion
|
|
352
|
+
//#region src/page/route-matcher.ts
|
|
353
|
+
function compileRoute(pattern) {
|
|
354
|
+
return { segments: pattern.split("/").filter(Boolean).map((seg) => {
|
|
355
|
+
if (seg.startsWith("*")) {
|
|
356
|
+
const optional = seg.endsWith("?");
|
|
357
|
+
return {
|
|
358
|
+
kind: "catch-all",
|
|
359
|
+
name: optional ? seg.slice(1, -1) : seg.slice(1),
|
|
360
|
+
optional
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
if (seg.startsWith(":")) return {
|
|
364
|
+
kind: "param",
|
|
365
|
+
name: seg.slice(1)
|
|
366
|
+
};
|
|
367
|
+
return {
|
|
368
|
+
kind: "static",
|
|
369
|
+
value: seg
|
|
370
|
+
};
|
|
371
|
+
}) };
|
|
372
|
+
}
|
|
373
|
+
function matchRoute(segments, pathParts) {
|
|
374
|
+
const params = {};
|
|
375
|
+
for (let i = 0; i < segments.length; i++) {
|
|
376
|
+
const seg = segments[i];
|
|
377
|
+
if (seg.kind === "catch-all") {
|
|
378
|
+
const rest = pathParts.slice(i);
|
|
379
|
+
if (rest.length === 0 && !seg.optional) return null;
|
|
380
|
+
params[seg.name] = rest.join("/");
|
|
381
|
+
return params;
|
|
382
|
+
}
|
|
383
|
+
if (i >= pathParts.length) return null;
|
|
384
|
+
if (seg.kind === "static") {
|
|
385
|
+
if (seg.value !== pathParts[i]) return null;
|
|
386
|
+
} else params[seg.name] = pathParts[i];
|
|
387
|
+
}
|
|
388
|
+
if (segments.length !== pathParts.length) return null;
|
|
389
|
+
return params;
|
|
390
|
+
}
|
|
391
|
+
var RouteMatcher = class {
|
|
392
|
+
routes = [];
|
|
393
|
+
add(pattern, value) {
|
|
394
|
+
this.routes.push({
|
|
395
|
+
pattern,
|
|
396
|
+
compiled: compileRoute(pattern),
|
|
397
|
+
value
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
match(path) {
|
|
401
|
+
const parts = path.split("/").filter(Boolean);
|
|
402
|
+
for (const route of this.routes) {
|
|
403
|
+
const params = matchRoute(route.compiled.segments, parts);
|
|
404
|
+
if (params) return {
|
|
405
|
+
value: route.value,
|
|
406
|
+
params,
|
|
407
|
+
pattern: route.pattern
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
295
414
|
//#endregion
|
|
296
415
|
//#region src/router/handler.ts
|
|
297
|
-
async function handleRequest(procedures, procedureName, rawBody, validateOutput, ctx) {
|
|
416
|
+
async function handleRequest(procedures, procedureName, rawBody, shouldValidateInput = true, validateOutput, ctx) {
|
|
298
417
|
const procedure = procedures.get(procedureName);
|
|
299
418
|
if (!procedure) return {
|
|
300
419
|
status: 404,
|
|
301
420
|
body: new SeamError("NOT_FOUND", `Procedure '${procedureName}' not found`).toJSON()
|
|
302
421
|
};
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
422
|
+
if (shouldValidateInput) {
|
|
423
|
+
const validation = validateInput(procedure.inputSchema, rawBody);
|
|
424
|
+
if (!validation.valid) {
|
|
425
|
+
const details = formatValidationDetails(validation.errors, procedure.inputSchema, rawBody);
|
|
426
|
+
return {
|
|
427
|
+
status: 400,
|
|
428
|
+
body: new SeamError("VALIDATION_ERROR", `Input validation failed for procedure '${procedureName}': ${formatValidationErrors(validation.errors)}`, void 0, details).toJSON()
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
}
|
|
308
432
|
try {
|
|
309
433
|
const result = await procedure.handler({
|
|
310
434
|
input: rawBody,
|
|
@@ -335,10 +459,10 @@ async function handleRequest(procedures, procedureName, rawBody, validateOutput,
|
|
|
335
459
|
};
|
|
336
460
|
}
|
|
337
461
|
}
|
|
338
|
-
async function handleBatchRequest(procedures, calls, validateOutput, ctxResolver) {
|
|
462
|
+
async function handleBatchRequest(procedures, calls, shouldValidateInput = true, validateOutput, ctxResolver) {
|
|
339
463
|
return { results: await Promise.all(calls.map(async (call) => {
|
|
340
464
|
const ctx = ctxResolver ? ctxResolver(call.procedure) : void 0;
|
|
341
|
-
const result = await handleRequest(procedures, call.procedure, call.input, validateOutput, ctx);
|
|
465
|
+
const result = await handleRequest(procedures, call.procedure, call.input, shouldValidateInput, validateOutput, ctx);
|
|
342
466
|
if (result.status === 200) return {
|
|
343
467
|
ok: true,
|
|
344
468
|
data: result.body.data
|
|
@@ -349,14 +473,20 @@ async function handleBatchRequest(procedures, calls, validateOutput, ctxResolver
|
|
|
349
473
|
};
|
|
350
474
|
})) };
|
|
351
475
|
}
|
|
352
|
-
async function* handleSubscription(subscriptions, name, rawInput, validateOutput, ctx) {
|
|
476
|
+
async function* handleSubscription(subscriptions, name, rawInput, shouldValidateInput = true, validateOutput, ctx, lastEventId) {
|
|
353
477
|
const sub = subscriptions.get(name);
|
|
354
478
|
if (!sub) throw new SeamError("NOT_FOUND", `Subscription '${name}' not found`);
|
|
355
|
-
|
|
356
|
-
|
|
479
|
+
if (shouldValidateInput) {
|
|
480
|
+
const validation = validateInput(sub.inputSchema, rawInput);
|
|
481
|
+
if (!validation.valid) {
|
|
482
|
+
const details = formatValidationDetails(validation.errors, sub.inputSchema, rawInput);
|
|
483
|
+
throw new SeamError("VALIDATION_ERROR", `Input validation failed: ${formatValidationErrors(validation.errors)}`, void 0, details);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
357
486
|
for await (const value of sub.handler({
|
|
358
487
|
input: rawInput,
|
|
359
|
-
ctx: ctx ?? {}
|
|
488
|
+
ctx: ctx ?? {},
|
|
489
|
+
lastEventId
|
|
360
490
|
})) {
|
|
361
491
|
if (validateOutput) {
|
|
362
492
|
const outValidation = validateInput(sub.outputSchema, value);
|
|
@@ -365,17 +495,22 @@ async function* handleSubscription(subscriptions, name, rawInput, validateOutput
|
|
|
365
495
|
yield value;
|
|
366
496
|
}
|
|
367
497
|
}
|
|
368
|
-
async function handleUploadRequest(uploads, procedureName, rawBody, file, validateOutput, ctx) {
|
|
498
|
+
async function handleUploadRequest(uploads, procedureName, rawBody, file, shouldValidateInput = true, validateOutput, ctx) {
|
|
369
499
|
const upload = uploads.get(procedureName);
|
|
370
500
|
if (!upload) return {
|
|
371
501
|
status: 404,
|
|
372
502
|
body: new SeamError("NOT_FOUND", `Procedure '${procedureName}' not found`).toJSON()
|
|
373
503
|
};
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
504
|
+
if (shouldValidateInput) {
|
|
505
|
+
const validation = validateInput(upload.inputSchema, rawBody);
|
|
506
|
+
if (!validation.valid) {
|
|
507
|
+
const details = formatValidationDetails(validation.errors, upload.inputSchema, rawBody);
|
|
508
|
+
return {
|
|
509
|
+
status: 400,
|
|
510
|
+
body: new SeamError("VALIDATION_ERROR", `Input validation failed for procedure '${procedureName}': ${formatValidationErrors(validation.errors)}`, void 0, details).toJSON()
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
}
|
|
379
514
|
try {
|
|
380
515
|
const result = await upload.handler({
|
|
381
516
|
input: rawBody,
|
|
@@ -407,11 +542,16 @@ async function handleUploadRequest(uploads, procedureName, rawBody, file, valida
|
|
|
407
542
|
};
|
|
408
543
|
}
|
|
409
544
|
}
|
|
410
|
-
async function* handleStream(streams, name, rawInput, validateOutput, ctx) {
|
|
545
|
+
async function* handleStream(streams, name, rawInput, shouldValidateInput = true, validateOutput, ctx) {
|
|
411
546
|
const stream = streams.get(name);
|
|
412
547
|
if (!stream) throw new SeamError("NOT_FOUND", `Stream '${name}' not found`);
|
|
413
|
-
|
|
414
|
-
|
|
548
|
+
if (shouldValidateInput) {
|
|
549
|
+
const validation = validateInput(stream.inputSchema, rawInput);
|
|
550
|
+
if (!validation.valid) {
|
|
551
|
+
const details = formatValidationDetails(validation.errors, stream.inputSchema, rawInput);
|
|
552
|
+
throw new SeamError("VALIDATION_ERROR", `Input validation failed: ${formatValidationErrors(validation.errors)}`, void 0, details);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
415
555
|
for await (const value of stream.handler({
|
|
416
556
|
input: rawInput,
|
|
417
557
|
ctx: ctx ?? {}
|
|
@@ -424,6 +564,93 @@ async function* handleStream(streams, name, rawInput, validateOutput, ctx) {
|
|
|
424
564
|
}
|
|
425
565
|
}
|
|
426
566
|
|
|
567
|
+
//#endregion
|
|
568
|
+
//#region src/router/categorize.ts
|
|
569
|
+
function resolveKind(name, def) {
|
|
570
|
+
if ("kind" in def && def.kind) return def.kind;
|
|
571
|
+
if ("type" in def && def.type) {
|
|
572
|
+
console.warn(`[seam] "${name}": "type" field in procedure definition is deprecated, use "kind" instead`);
|
|
573
|
+
return def.type;
|
|
574
|
+
}
|
|
575
|
+
return "query";
|
|
576
|
+
}
|
|
577
|
+
/** Split a flat definition map into typed procedure/subscription/stream maps */
|
|
578
|
+
function categorizeProcedures(definitions, contextConfig) {
|
|
579
|
+
const procedureMap = /* @__PURE__ */ new Map();
|
|
580
|
+
const subscriptionMap = /* @__PURE__ */ new Map();
|
|
581
|
+
const streamMap = /* @__PURE__ */ new Map();
|
|
582
|
+
const uploadMap = /* @__PURE__ */ new Map();
|
|
583
|
+
const kindMap = /* @__PURE__ */ new Map();
|
|
584
|
+
for (const [name, def] of Object.entries(definitions)) {
|
|
585
|
+
if (name.startsWith("seam.")) throw new Error(`Procedure name "${name}" uses reserved "seam." namespace`);
|
|
586
|
+
const kind = resolveKind(name, def);
|
|
587
|
+
kindMap.set(name, kind);
|
|
588
|
+
const contextKeys = def.context ?? [];
|
|
589
|
+
if (contextConfig && contextKeys.length > 0) {
|
|
590
|
+
for (const key of contextKeys) if (!(key in contextConfig)) throw new Error(`Procedure "${name}" references undefined context field "${key}"`);
|
|
591
|
+
}
|
|
592
|
+
if (kind === "upload") uploadMap.set(name, {
|
|
593
|
+
inputSchema: def.input._schema,
|
|
594
|
+
outputSchema: def.output._schema,
|
|
595
|
+
contextKeys,
|
|
596
|
+
handler: def.handler
|
|
597
|
+
});
|
|
598
|
+
else if (kind === "stream") streamMap.set(name, {
|
|
599
|
+
inputSchema: def.input._schema,
|
|
600
|
+
chunkOutputSchema: def.output._schema,
|
|
601
|
+
contextKeys,
|
|
602
|
+
handler: def.handler
|
|
603
|
+
});
|
|
604
|
+
else if (kind === "subscription") subscriptionMap.set(name, {
|
|
605
|
+
inputSchema: def.input._schema,
|
|
606
|
+
outputSchema: def.output._schema,
|
|
607
|
+
contextKeys,
|
|
608
|
+
handler: def.handler
|
|
609
|
+
});
|
|
610
|
+
else procedureMap.set(name, {
|
|
611
|
+
inputSchema: def.input._schema,
|
|
612
|
+
outputSchema: def.output._schema,
|
|
613
|
+
contextKeys,
|
|
614
|
+
handler: def.handler
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
return {
|
|
618
|
+
procedureMap,
|
|
619
|
+
subscriptionMap,
|
|
620
|
+
streamMap,
|
|
621
|
+
uploadMap,
|
|
622
|
+
kindMap
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
//#endregion
|
|
627
|
+
//#region src/page/head.ts
|
|
628
|
+
/**
|
|
629
|
+
* Convert a HeadConfig with real values into escaped HTML.
|
|
630
|
+
* Used at request-time by TS server backends.
|
|
631
|
+
*/
|
|
632
|
+
function headConfigToHtml(config) {
|
|
633
|
+
let html = "";
|
|
634
|
+
if (config.title !== void 0) html += `<title>${escapeHtml(config.title)}</title>`;
|
|
635
|
+
for (const meta of config.meta ?? []) {
|
|
636
|
+
html += "<meta";
|
|
637
|
+
for (const [k, v] of Object.entries(meta)) if (v !== void 0) html += ` ${k}="${escapeHtml(v)}"`;
|
|
638
|
+
html += ">";
|
|
639
|
+
}
|
|
640
|
+
for (const link of config.link ?? []) {
|
|
641
|
+
html += "<link";
|
|
642
|
+
for (const [k, v] of Object.entries(link)) if (v !== void 0) html += ` ${k}="${escapeHtml(v)}"`;
|
|
643
|
+
html += ">";
|
|
644
|
+
}
|
|
645
|
+
return html;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
//#endregion
|
|
649
|
+
//#region src/page/loader-error.ts
|
|
650
|
+
function isLoaderError(value) {
|
|
651
|
+
return typeof value === "object" && value !== null && value.__error === true && typeof value.code === "string" && typeof value.message === "string";
|
|
652
|
+
}
|
|
653
|
+
|
|
427
654
|
//#endregion
|
|
428
655
|
//#region src/page/projection.ts
|
|
429
656
|
/** Set a nested field by dot-separated path, creating intermediate objects as needed. */
|
|
@@ -479,6 +706,10 @@ function applyProjection(data, projections) {
|
|
|
479
706
|
if (!projections) return data;
|
|
480
707
|
const result = {};
|
|
481
708
|
for (const [key, value] of Object.entries(data)) {
|
|
709
|
+
if (isLoaderError(value)) {
|
|
710
|
+
result[key] = value;
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
482
713
|
const fields = projections[key];
|
|
483
714
|
if (!fields) result[key] = value;
|
|
484
715
|
else result[key] = pruneValue(value, fields);
|
|
@@ -488,19 +719,58 @@ function applyProjection(data, projections) {
|
|
|
488
719
|
|
|
489
720
|
//#endregion
|
|
490
721
|
//#region src/page/handler.ts
|
|
491
|
-
/** Execute loaders, returning keyed results
|
|
492
|
-
|
|
722
|
+
/** Execute loaders, returning keyed results and metadata.
|
|
723
|
+
* Each loader is wrapped in its own try-catch so a single failure
|
|
724
|
+
* does not abort sibling loaders — the page renders at 200 with partial data. */
|
|
725
|
+
async function executeLoaders(loaders, params, procedures, searchParams, ctxResolver, shouldValidateInput) {
|
|
493
726
|
const entries = Object.entries(loaders);
|
|
494
727
|
const results = await Promise.all(entries.map(async ([key, loader]) => {
|
|
495
728
|
const { procedure, input } = loader(params, searchParams);
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
729
|
+
try {
|
|
730
|
+
const proc = procedures.get(procedure);
|
|
731
|
+
if (!proc) throw new SeamError("INTERNAL_ERROR", `Procedure '${procedure}' not found`);
|
|
732
|
+
if (shouldValidateInput) {
|
|
733
|
+
const v = validateInput(proc.inputSchema, input);
|
|
734
|
+
if (!v.valid) throw new SeamError("VALIDATION_ERROR", `Input validation failed: ${formatValidationErrors(v.errors)}`);
|
|
735
|
+
}
|
|
736
|
+
const ctx = ctxResolver ? ctxResolver(proc) : {};
|
|
737
|
+
return {
|
|
738
|
+
key,
|
|
739
|
+
result: await proc.handler({
|
|
740
|
+
input,
|
|
741
|
+
ctx
|
|
742
|
+
}),
|
|
743
|
+
procedure,
|
|
744
|
+
input
|
|
745
|
+
};
|
|
746
|
+
} catch (err) {
|
|
747
|
+
const code = err instanceof SeamError ? err.code : "INTERNAL_ERROR";
|
|
748
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
749
|
+
console.error(`[seam] Loader "${key}" failed:`, err);
|
|
750
|
+
return {
|
|
751
|
+
key,
|
|
752
|
+
result: {
|
|
753
|
+
__error: true,
|
|
754
|
+
code,
|
|
755
|
+
message
|
|
756
|
+
},
|
|
757
|
+
procedure,
|
|
758
|
+
input,
|
|
759
|
+
error: true
|
|
760
|
+
};
|
|
761
|
+
}
|
|
502
762
|
}));
|
|
503
|
-
return
|
|
763
|
+
return {
|
|
764
|
+
data: Object.fromEntries(results.map((r) => [r.key, r.result])),
|
|
765
|
+
meta: Object.fromEntries(results.map((r) => {
|
|
766
|
+
const entry = {
|
|
767
|
+
procedure: r.procedure,
|
|
768
|
+
input: r.input
|
|
769
|
+
};
|
|
770
|
+
if (r.error) entry.error = true;
|
|
771
|
+
return [r.key, entry];
|
|
772
|
+
}))
|
|
773
|
+
};
|
|
504
774
|
}
|
|
505
775
|
/** Select the template for a given locale, falling back to the default template */
|
|
506
776
|
function selectTemplate(defaultTemplate, localeTemplates, locale) {
|
|
@@ -518,46 +788,58 @@ function lookupMessages(config, routePattern, locale) {
|
|
|
518
788
|
}
|
|
519
789
|
return config.messages[locale]?.[routeHash] ?? {};
|
|
520
790
|
}
|
|
521
|
-
|
|
791
|
+
/** Build JSON payload for engine i18n injection */
|
|
792
|
+
function buildI18nPayload(opts) {
|
|
793
|
+
const { config: i18nConfig, routePattern, locale } = opts;
|
|
794
|
+
const messages = lookupMessages(i18nConfig, routePattern, locale);
|
|
795
|
+
const routeHash = i18nConfig.routeHashes[routePattern];
|
|
796
|
+
const i18nData = {
|
|
797
|
+
locale,
|
|
798
|
+
default_locale: i18nConfig.default,
|
|
799
|
+
messages
|
|
800
|
+
};
|
|
801
|
+
if (i18nConfig.cache && routeHash) {
|
|
802
|
+
i18nData.hash = i18nConfig.contentHashes[routeHash]?.[locale];
|
|
803
|
+
i18nData.router = i18nConfig.contentHashes;
|
|
804
|
+
}
|
|
805
|
+
return JSON.stringify(i18nData);
|
|
806
|
+
}
|
|
807
|
+
async function handlePageRequest(page, params, procedures, i18nOpts, searchParams, ctxResolver, shouldValidateInput) {
|
|
522
808
|
try {
|
|
523
809
|
const t0 = performance.now();
|
|
524
810
|
const layoutChain = page.layoutChain ?? [];
|
|
525
811
|
const locale = i18nOpts?.locale;
|
|
526
|
-
const loaderResults = await Promise.all([...layoutChain.map((layout) => executeLoaders(layout.loaders, params, procedures, searchParams)), executeLoaders(page.loaders, params, procedures, searchParams)]);
|
|
812
|
+
const loaderResults = await Promise.all([...layoutChain.map((layout) => executeLoaders(layout.loaders, params, procedures, searchParams, ctxResolver, shouldValidateInput)), executeLoaders(page.loaders, params, procedures, searchParams, ctxResolver, shouldValidateInput)]);
|
|
527
813
|
const t1 = performance.now();
|
|
528
814
|
const allData = {};
|
|
529
|
-
|
|
815
|
+
const allMeta = {};
|
|
816
|
+
for (const { data, meta } of loaderResults) {
|
|
817
|
+
Object.assign(allData, data);
|
|
818
|
+
Object.assign(allMeta, meta);
|
|
819
|
+
}
|
|
530
820
|
const prunedData = applyProjection(allData, page.projections);
|
|
531
821
|
let composedTemplate = selectTemplate(page.template, page.localeTemplates, locale);
|
|
532
822
|
for (let i = layoutChain.length - 1; i >= 0; i--) {
|
|
533
823
|
const layout = layoutChain[i];
|
|
534
824
|
composedTemplate = selectTemplate(layout.template, layout.localeTemplates, locale).replace("<!--seam:outlet-->", composedTemplate);
|
|
535
825
|
}
|
|
826
|
+
let resolvedHeadMeta = page.headMeta;
|
|
827
|
+
if (page.headFn) try {
|
|
828
|
+
resolvedHeadMeta = headConfigToHtml(page.headFn(allData));
|
|
829
|
+
} catch (err) {
|
|
830
|
+
console.error("[seam] head function failed:", err);
|
|
831
|
+
}
|
|
536
832
|
const config = {
|
|
537
833
|
layout_chain: layoutChain.map((l) => ({
|
|
538
834
|
id: l.id,
|
|
539
835
|
loader_keys: Object.keys(l.loaders)
|
|
540
836
|
})),
|
|
541
837
|
data_id: page.dataId ?? "__data",
|
|
542
|
-
head_meta:
|
|
838
|
+
head_meta: resolvedHeadMeta,
|
|
839
|
+
loader_metadata: allMeta
|
|
543
840
|
};
|
|
544
841
|
if (page.pageAssets) config.page_assets = page.pageAssets;
|
|
545
|
-
|
|
546
|
-
if (i18nOpts) {
|
|
547
|
-
const { config: i18nConfig, routePattern } = i18nOpts;
|
|
548
|
-
const messages = lookupMessages(i18nConfig, routePattern, i18nOpts.locale);
|
|
549
|
-
const routeHash = i18nConfig.routeHashes[routePattern];
|
|
550
|
-
const i18nData = {
|
|
551
|
-
locale: i18nOpts.locale,
|
|
552
|
-
default_locale: i18nConfig.default,
|
|
553
|
-
messages
|
|
554
|
-
};
|
|
555
|
-
if (i18nConfig.cache && routeHash) {
|
|
556
|
-
i18nData.hash = i18nConfig.contentHashes[routeHash]?.[i18nOpts.locale];
|
|
557
|
-
i18nData.router = i18nConfig.contentHashes;
|
|
558
|
-
}
|
|
559
|
-
i18nOptsJson = JSON.stringify(i18nData);
|
|
560
|
-
}
|
|
842
|
+
const i18nOptsJson = i18nOpts ? buildI18nPayload(i18nOpts) : void 0;
|
|
561
843
|
const html = renderPage(composedTemplate, JSON.stringify(prunedData), JSON.stringify(config), i18nOptsJson);
|
|
562
844
|
const t2 = performance.now();
|
|
563
845
|
return {
|
|
@@ -571,73 +853,14 @@ async function handlePageRequest(page, params, procedures, i18nOpts, searchParam
|
|
|
571
853
|
} catch (error) {
|
|
572
854
|
return {
|
|
573
855
|
status: 500,
|
|
574
|
-
html: `<!DOCTYPE html><html><body><h1>500 Internal Server Error</h1><p>${escapeHtml(error instanceof Error ? error.message : "Unknown error")}</p></body></html
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
//#endregion
|
|
580
|
-
//#region src/page/route-matcher.ts
|
|
581
|
-
function compileRoute(pattern) {
|
|
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
|
|
856
|
+
html: `<!DOCTYPE html><html><body><h1>500 Internal Server Error</h1><p>${escapeHtml(error instanceof Error ? error.message : "Unknown error")}</p></body></html>`,
|
|
857
|
+
timing: {
|
|
858
|
+
dataFetch: 0,
|
|
859
|
+
inject: 0
|
|
860
|
+
}
|
|
598
861
|
};
|
|
599
|
-
}) };
|
|
600
|
-
}
|
|
601
|
-
function matchRoute(segments, pathParts) {
|
|
602
|
-
const params = {};
|
|
603
|
-
for (let i = 0; i < segments.length; i++) {
|
|
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;
|
|
612
|
-
if (seg.kind === "static") {
|
|
613
|
-
if (seg.value !== pathParts[i]) return null;
|
|
614
|
-
} else params[seg.name] = pathParts[i];
|
|
615
862
|
}
|
|
616
|
-
if (segments.length !== pathParts.length) return null;
|
|
617
|
-
return params;
|
|
618
863
|
}
|
|
619
|
-
var RouteMatcher = class {
|
|
620
|
-
routes = [];
|
|
621
|
-
add(pattern, value) {
|
|
622
|
-
this.routes.push({
|
|
623
|
-
pattern,
|
|
624
|
-
compiled: compileRoute(pattern),
|
|
625
|
-
value
|
|
626
|
-
});
|
|
627
|
-
}
|
|
628
|
-
match(path) {
|
|
629
|
-
const parts = path.split("/").filter(Boolean);
|
|
630
|
-
for (const route of this.routes) {
|
|
631
|
-
const params = matchRoute(route.compiled.segments, parts);
|
|
632
|
-
if (params) return {
|
|
633
|
-
value: route.value,
|
|
634
|
-
params,
|
|
635
|
-
pattern: route.pattern
|
|
636
|
-
};
|
|
637
|
-
}
|
|
638
|
-
return null;
|
|
639
|
-
}
|
|
640
|
-
};
|
|
641
864
|
|
|
642
865
|
//#endregion
|
|
643
866
|
//#region src/resolve.ts
|
|
@@ -730,65 +953,14 @@ function defaultStrategies() {
|
|
|
730
953
|
}
|
|
731
954
|
|
|
732
955
|
//#endregion
|
|
733
|
-
//#region src/router/
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
};
|
|
956
|
+
//#region src/router/helpers.ts
|
|
957
|
+
/** Resolve a ValidationMode to a boolean flag */
|
|
958
|
+
function resolveValidationMode(mode) {
|
|
959
|
+
const m = mode ?? "dev";
|
|
960
|
+
if (m === "always") return true;
|
|
961
|
+
if (m === "never") return false;
|
|
962
|
+
return typeof process !== "undefined" && process.env.NODE_ENV !== "production";
|
|
788
963
|
}
|
|
789
|
-
|
|
790
|
-
//#endregion
|
|
791
|
-
//#region src/router/index.ts
|
|
792
964
|
/** Build the resolve strategy list from options */
|
|
793
965
|
function buildStrategies(opts) {
|
|
794
966
|
const strategies = opts?.resolve ?? defaultStrategies();
|
|
@@ -797,14 +969,16 @@ function buildStrategies(opts) {
|
|
|
797
969
|
hasUrlPrefix: strategies.some((s) => s.kind === "url_prefix")
|
|
798
970
|
};
|
|
799
971
|
}
|
|
800
|
-
/** Register built-in
|
|
972
|
+
/** Register built-in seam.i18n.query procedure (route-hash-based lookup) */
|
|
801
973
|
function registerI18nQuery(procedureMap, config) {
|
|
802
|
-
|
|
974
|
+
const localeSet = new Set(config.locales);
|
|
975
|
+
procedureMap.set("seam.i18n.query", {
|
|
803
976
|
inputSchema: {},
|
|
804
977
|
outputSchema: {},
|
|
805
978
|
contextKeys: [],
|
|
806
979
|
handler: ({ input }) => {
|
|
807
|
-
const { route, locale } = input;
|
|
980
|
+
const { route, locale: rawLocale } = input;
|
|
981
|
+
const locale = localeSet.has(rawLocale) ? rawLocale : config.default;
|
|
808
982
|
const messages = lookupI18nMessages(config, route, locale);
|
|
809
983
|
return {
|
|
810
984
|
hash: config.contentHashes[route]?.[locale] ?? "",
|
|
@@ -831,14 +1005,14 @@ function collectChannelMeta(channels) {
|
|
|
831
1005
|
}));
|
|
832
1006
|
}
|
|
833
1007
|
/** Resolve context for a procedure, returning undefined if no context needed */
|
|
834
|
-
function resolveCtxFor(map, name, rawCtx,
|
|
835
|
-
if (!rawCtx
|
|
1008
|
+
function resolveCtxFor(map, name, rawCtx, ctxConfig) {
|
|
1009
|
+
if (!rawCtx) return void 0;
|
|
836
1010
|
const proc = map.get(name);
|
|
837
1011
|
if (!proc || proc.contextKeys.length === 0) return void 0;
|
|
838
1012
|
return resolveContext(ctxConfig, rawCtx, proc.contextKeys);
|
|
839
1013
|
}
|
|
840
1014
|
/** Resolve locale and match page route */
|
|
841
|
-
async function matchAndHandlePage(pageMatcher, procedureMap, i18nConfig, strategies, hasUrlPrefix, path, headers) {
|
|
1015
|
+
async function matchAndHandlePage(pageMatcher, procedureMap, i18nConfig, strategies, hasUrlPrefix, path, headers, rawCtx, ctxConfig, shouldValidateInput) {
|
|
842
1016
|
let pathLocale = null;
|
|
843
1017
|
let routePath = path;
|
|
844
1018
|
if (hasUrlPrefix && i18nConfig) {
|
|
@@ -861,6 +1035,15 @@ async function matchAndHandlePage(pageMatcher, procedureMap, i18nConfig, strateg
|
|
|
861
1035
|
});
|
|
862
1036
|
const match = pageMatcher.match(routePath);
|
|
863
1037
|
if (!match) return null;
|
|
1038
|
+
if (match.value.prerender && match.value.staticDir) {
|
|
1039
|
+
const htmlPath = join(match.value.staticDir, routePath === "/" ? "" : routePath, "index.html");
|
|
1040
|
+
try {
|
|
1041
|
+
return {
|
|
1042
|
+
status: 200,
|
|
1043
|
+
html: readFileSync(htmlPath, "utf-8")
|
|
1044
|
+
};
|
|
1045
|
+
} catch {}
|
|
1046
|
+
}
|
|
864
1047
|
let searchParams;
|
|
865
1048
|
if (headers?.url) try {
|
|
866
1049
|
const url = new URL(headers.url, "http://localhost");
|
|
@@ -871,12 +1054,16 @@ async function matchAndHandlePage(pageMatcher, procedureMap, i18nConfig, strateg
|
|
|
871
1054
|
config: i18nConfig,
|
|
872
1055
|
routePattern: match.pattern
|
|
873
1056
|
} : void 0;
|
|
874
|
-
|
|
1057
|
+
const ctxResolver = rawCtx ? (proc) => {
|
|
1058
|
+
if (proc.contextKeys.length === 0) return {};
|
|
1059
|
+
return resolveContext(ctxConfig ?? {}, rawCtx, proc.contextKeys);
|
|
1060
|
+
} : void 0;
|
|
1061
|
+
return handlePageRequest(match.value, match.params, procedureMap, i18nOpts, searchParams, ctxResolver, shouldValidateInput);
|
|
875
1062
|
}
|
|
876
1063
|
/** Catch context resolution errors and return them as HandleResult */
|
|
877
|
-
function resolveCtxSafe(map, name, rawCtx,
|
|
1064
|
+
function resolveCtxSafe(map, name, rawCtx, ctxConfig) {
|
|
878
1065
|
try {
|
|
879
|
-
return { ctx: resolveCtxFor(map, name, rawCtx,
|
|
1066
|
+
return { ctx: resolveCtxFor(map, name, rawCtx, ctxConfig) };
|
|
880
1067
|
} catch (err) {
|
|
881
1068
|
if (err instanceof SeamError) return { error: {
|
|
882
1069
|
status: err.status,
|
|
@@ -885,9 +1072,14 @@ function resolveCtxSafe(map, name, rawCtx, extractKeys, ctxConfig) {
|
|
|
885
1072
|
throw err;
|
|
886
1073
|
}
|
|
887
1074
|
}
|
|
888
|
-
|
|
1075
|
+
|
|
1076
|
+
//#endregion
|
|
1077
|
+
//#region src/router/state.ts
|
|
1078
|
+
/** Build all shared state that createRouter methods close over */
|
|
1079
|
+
function initRouterState(procedures, opts) {
|
|
889
1080
|
const ctxConfig = opts?.context ?? {};
|
|
890
1081
|
const { procedureMap, subscriptionMap, streamMap, uploadMap, kindMap } = categorizeProcedures(procedures, Object.keys(ctxConfig).length > 0 ? ctxConfig : void 0);
|
|
1082
|
+
const shouldValidateInput = resolveValidationMode(opts?.validation?.input);
|
|
891
1083
|
const shouldValidateOutput = opts?.validateOutput ?? (typeof process !== "undefined" && process.env.NODE_ENV !== "production");
|
|
892
1084
|
const pageMatcher = new RouteMatcher();
|
|
893
1085
|
const pages = opts?.pages;
|
|
@@ -895,45 +1087,191 @@ function createRouter(procedures, opts) {
|
|
|
895
1087
|
const i18nConfig = opts?.i18n ?? null;
|
|
896
1088
|
const { strategies, hasUrlPrefix } = buildStrategies(opts);
|
|
897
1089
|
if (i18nConfig) registerI18nQuery(procedureMap, i18nConfig);
|
|
898
|
-
const channelsMeta = collectChannelMeta(opts?.channels);
|
|
899
|
-
const extractKeys = contextExtractKeys(ctxConfig);
|
|
900
1090
|
return {
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1091
|
+
ctxConfig,
|
|
1092
|
+
procedureMap,
|
|
1093
|
+
subscriptionMap,
|
|
1094
|
+
streamMap,
|
|
1095
|
+
uploadMap,
|
|
1096
|
+
kindMap,
|
|
1097
|
+
shouldValidateInput,
|
|
1098
|
+
shouldValidateOutput,
|
|
1099
|
+
pageMatcher,
|
|
1100
|
+
pages,
|
|
1101
|
+
i18nConfig,
|
|
1102
|
+
strategies,
|
|
1103
|
+
hasUrlPrefix,
|
|
1104
|
+
channelsMeta: collectChannelMeta(opts?.channels),
|
|
1105
|
+
hasCtx: contextHasExtracts(ctxConfig)
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
/** Build request-response methods: handle, handleBatch, handleUpload */
|
|
1109
|
+
function buildRpcMethods(state) {
|
|
1110
|
+
return {
|
|
909
1111
|
async handle(procedureName, body, rawCtx) {
|
|
910
|
-
const { ctx, error } = resolveCtxSafe(procedureMap, procedureName, rawCtx,
|
|
1112
|
+
const { ctx, error } = resolveCtxSafe(state.procedureMap, procedureName, rawCtx, state.ctxConfig);
|
|
911
1113
|
if (error) return error;
|
|
912
|
-
return handleRequest(procedureMap, procedureName, body, shouldValidateOutput, ctx);
|
|
1114
|
+
return handleRequest(state.procedureMap, procedureName, body, state.shouldValidateInput, state.shouldValidateOutput, ctx);
|
|
913
1115
|
},
|
|
914
1116
|
handleBatch(calls, rawCtx) {
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
handleSubscription(name, input, rawCtx) {
|
|
918
|
-
return handleSubscription(subscriptionMap, name, input, shouldValidateOutput, resolveCtxFor(subscriptionMap, name, rawCtx, extractKeys, ctxConfig));
|
|
919
|
-
},
|
|
920
|
-
handleStream(name, input, rawCtx) {
|
|
921
|
-
return handleStream(streamMap, name, input, shouldValidateOutput, resolveCtxFor(streamMap, name, rawCtx, extractKeys, ctxConfig));
|
|
1117
|
+
const ctxResolver = rawCtx ? (name) => resolveCtxFor(state.procedureMap, name, rawCtx, state.ctxConfig) ?? {} : void 0;
|
|
1118
|
+
return handleBatchRequest(state.procedureMap, calls, state.shouldValidateInput, state.shouldValidateOutput, ctxResolver);
|
|
922
1119
|
},
|
|
923
1120
|
async handleUpload(name, body, file, rawCtx) {
|
|
924
|
-
const { ctx, error } = resolveCtxSafe(uploadMap, name, rawCtx,
|
|
1121
|
+
const { ctx, error } = resolveCtxSafe(state.uploadMap, name, rawCtx, state.ctxConfig);
|
|
925
1122
|
if (error) return error;
|
|
926
|
-
return handleUploadRequest(uploadMap, name, body, file, shouldValidateOutput, ctx);
|
|
1123
|
+
return handleUploadRequest(state.uploadMap, name, body, file, state.shouldValidateInput, state.shouldValidateOutput, ctx);
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
/** Build all Router method implementations from shared state */
|
|
1128
|
+
function buildRouterMethods(state, procedures, opts) {
|
|
1129
|
+
return {
|
|
1130
|
+
hasPages: !!state.pages && Object.keys(state.pages).length > 0,
|
|
1131
|
+
ctxConfig: state.ctxConfig,
|
|
1132
|
+
hasContext() {
|
|
1133
|
+
return state.hasCtx;
|
|
1134
|
+
},
|
|
1135
|
+
manifest() {
|
|
1136
|
+
return buildManifest(procedures, state.channelsMeta, state.ctxConfig, opts?.transportDefaults);
|
|
1137
|
+
},
|
|
1138
|
+
...buildRpcMethods(state),
|
|
1139
|
+
handleSubscription(name, input, rawCtx, lastEventId) {
|
|
1140
|
+
const ctx = resolveCtxFor(state.subscriptionMap, name, rawCtx, state.ctxConfig);
|
|
1141
|
+
return handleSubscription(state.subscriptionMap, name, input, state.shouldValidateInput, state.shouldValidateOutput, ctx, lastEventId);
|
|
1142
|
+
},
|
|
1143
|
+
handleStream(name, input, rawCtx) {
|
|
1144
|
+
const ctx = resolveCtxFor(state.streamMap, name, rawCtx, state.ctxConfig);
|
|
1145
|
+
return handleStream(state.streamMap, name, input, state.shouldValidateInput, state.shouldValidateOutput, ctx);
|
|
927
1146
|
},
|
|
928
1147
|
getKind(name) {
|
|
929
|
-
return kindMap.get(name) ?? null;
|
|
1148
|
+
return state.kindMap.get(name) ?? null;
|
|
930
1149
|
},
|
|
931
|
-
handlePage(path, headers) {
|
|
932
|
-
return matchAndHandlePage(pageMatcher, procedureMap, i18nConfig, strategies, hasUrlPrefix, path, headers);
|
|
1150
|
+
handlePage(path, headers, rawCtx) {
|
|
1151
|
+
return matchAndHandlePage(state.pageMatcher, state.procedureMap, state.i18nConfig, state.strategies, state.hasUrlPrefix, path, headers, rawCtx, state.ctxConfig, state.shouldValidateInput);
|
|
1152
|
+
},
|
|
1153
|
+
handlePageData(path) {
|
|
1154
|
+
const match = state.pageMatcher?.match(path);
|
|
1155
|
+
if (!match) return Promise.resolve(null);
|
|
1156
|
+
const page = match.value;
|
|
1157
|
+
if (!page.prerender || !page.staticDir) return Promise.resolve(null);
|
|
1158
|
+
const dataPath = join(page.staticDir, path === "/" ? "" : path, "__data.json");
|
|
1159
|
+
try {
|
|
1160
|
+
if (!existsSync(dataPath)) return Promise.resolve(null);
|
|
1161
|
+
return Promise.resolve(JSON.parse(readFileSync(dataPath, "utf-8")));
|
|
1162
|
+
} catch {
|
|
1163
|
+
return Promise.resolve(null);
|
|
1164
|
+
}
|
|
933
1165
|
}
|
|
934
1166
|
};
|
|
935
1167
|
}
|
|
936
1168
|
|
|
1169
|
+
//#endregion
|
|
1170
|
+
//#region src/router/index.ts
|
|
1171
|
+
function isProcedureDef(value) {
|
|
1172
|
+
return typeof value === "object" && value !== null && "input" in value && "handler" in value;
|
|
1173
|
+
}
|
|
1174
|
+
function flattenDefinitions(nested, prefix = "") {
|
|
1175
|
+
const flat = {};
|
|
1176
|
+
for (const [key, value] of Object.entries(nested)) {
|
|
1177
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
1178
|
+
if (isProcedureDef(value)) flat[fullKey] = value;
|
|
1179
|
+
else Object.assign(flat, flattenDefinitions(value, fullKey));
|
|
1180
|
+
}
|
|
1181
|
+
return flat;
|
|
1182
|
+
}
|
|
1183
|
+
function createRouter(procedures, opts) {
|
|
1184
|
+
const flat = flattenDefinitions(procedures);
|
|
1185
|
+
const state = initRouterState(flat, opts);
|
|
1186
|
+
return {
|
|
1187
|
+
procedures: flat,
|
|
1188
|
+
rpcHashMap: opts?.rpcHashMap,
|
|
1189
|
+
...buildRouterMethods(state, flat, opts)
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
//#endregion
|
|
1194
|
+
//#region src/factory.ts
|
|
1195
|
+
function query(def) {
|
|
1196
|
+
return {
|
|
1197
|
+
...def,
|
|
1198
|
+
kind: "query"
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
function command(def) {
|
|
1202
|
+
return {
|
|
1203
|
+
...def,
|
|
1204
|
+
kind: "command"
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
function subscription(def) {
|
|
1208
|
+
return {
|
|
1209
|
+
...def,
|
|
1210
|
+
kind: "subscription"
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
function stream(def) {
|
|
1214
|
+
return {
|
|
1215
|
+
...def,
|
|
1216
|
+
kind: "stream"
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
function upload(def) {
|
|
1220
|
+
return {
|
|
1221
|
+
...def,
|
|
1222
|
+
kind: "upload"
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
//#endregion
|
|
1227
|
+
//#region src/seam-router.ts
|
|
1228
|
+
function createSeamRouter(config) {
|
|
1229
|
+
const { context, ...restConfig } = config;
|
|
1230
|
+
const define = {
|
|
1231
|
+
query(def) {
|
|
1232
|
+
return {
|
|
1233
|
+
...def,
|
|
1234
|
+
kind: "query"
|
|
1235
|
+
};
|
|
1236
|
+
},
|
|
1237
|
+
command(def) {
|
|
1238
|
+
return {
|
|
1239
|
+
...def,
|
|
1240
|
+
kind: "command"
|
|
1241
|
+
};
|
|
1242
|
+
},
|
|
1243
|
+
subscription(def) {
|
|
1244
|
+
return {
|
|
1245
|
+
...def,
|
|
1246
|
+
kind: "subscription"
|
|
1247
|
+
};
|
|
1248
|
+
},
|
|
1249
|
+
stream(def) {
|
|
1250
|
+
return {
|
|
1251
|
+
...def,
|
|
1252
|
+
kind: "stream"
|
|
1253
|
+
};
|
|
1254
|
+
},
|
|
1255
|
+
upload(def) {
|
|
1256
|
+
return {
|
|
1257
|
+
...def,
|
|
1258
|
+
kind: "upload"
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
};
|
|
1262
|
+
function router(procedures, extraOpts) {
|
|
1263
|
+
return createRouter(procedures, {
|
|
1264
|
+
...restConfig,
|
|
1265
|
+
context,
|
|
1266
|
+
...extraOpts
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
return {
|
|
1270
|
+
router,
|
|
1271
|
+
define
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
|
|
937
1275
|
//#endregion
|
|
938
1276
|
//#region src/channel.ts
|
|
939
1277
|
/** Merge channel-level and message-level JTD properties schemas */
|
|
@@ -1044,6 +1382,7 @@ const MIME_TYPES = {
|
|
|
1044
1382
|
//#region src/http.ts
|
|
1045
1383
|
const PROCEDURE_PREFIX = "/_seam/procedure/";
|
|
1046
1384
|
const PAGE_PREFIX = "/_seam/page/";
|
|
1385
|
+
const DATA_PREFIX = "/_seam/data/";
|
|
1047
1386
|
const STATIC_PREFIX = "/_seam/static/";
|
|
1048
1387
|
const MANIFEST_PATH = "/_seam/manifest.json";
|
|
1049
1388
|
const JSON_HEADER = { "Content-Type": "application/json" };
|
|
@@ -1102,13 +1441,78 @@ function sseErrorEvent(code, message, transient = false) {
|
|
|
1102
1441
|
function sseCompleteEvent() {
|
|
1103
1442
|
return "event: complete\ndata: {}\n\n";
|
|
1104
1443
|
}
|
|
1105
|
-
|
|
1444
|
+
function formatSseError(error) {
|
|
1445
|
+
if (error instanceof SeamError) return sseErrorEvent(error.code, error.message);
|
|
1446
|
+
return sseErrorEvent("INTERNAL_ERROR", error instanceof Error ? error.message : "Unknown error");
|
|
1447
|
+
}
|
|
1448
|
+
const DEFAULT_HEARTBEAT_MS$1 = 21e3;
|
|
1449
|
+
const DEFAULT_SSE_IDLE_MS = 3e4;
|
|
1450
|
+
async function* withSseLifecycle(inner, opts) {
|
|
1451
|
+
const heartbeatMs = opts?.heartbeatInterval ?? DEFAULT_HEARTBEAT_MS$1;
|
|
1452
|
+
const idleMs = opts?.sseIdleTimeout ?? DEFAULT_SSE_IDLE_MS;
|
|
1453
|
+
const idleEnabled = idleMs > 0;
|
|
1454
|
+
const queue = [];
|
|
1455
|
+
let resolve = null;
|
|
1456
|
+
const signal = () => {
|
|
1457
|
+
if (resolve) {
|
|
1458
|
+
resolve();
|
|
1459
|
+
resolve = null;
|
|
1460
|
+
}
|
|
1461
|
+
};
|
|
1462
|
+
let idleTimer = null;
|
|
1463
|
+
const resetIdle = () => {
|
|
1464
|
+
if (!idleEnabled) return;
|
|
1465
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1466
|
+
idleTimer = setTimeout(() => {
|
|
1467
|
+
queue.push({ type: "idle" });
|
|
1468
|
+
signal();
|
|
1469
|
+
}, idleMs);
|
|
1470
|
+
};
|
|
1471
|
+
const heartbeatTimer = setInterval(() => {
|
|
1472
|
+
queue.push({ type: "heartbeat" });
|
|
1473
|
+
signal();
|
|
1474
|
+
}, heartbeatMs);
|
|
1475
|
+
resetIdle();
|
|
1476
|
+
(async () => {
|
|
1477
|
+
try {
|
|
1478
|
+
for await (const chunk of inner) {
|
|
1479
|
+
queue.push({
|
|
1480
|
+
type: "data",
|
|
1481
|
+
value: chunk
|
|
1482
|
+
});
|
|
1483
|
+
resetIdle();
|
|
1484
|
+
signal();
|
|
1485
|
+
}
|
|
1486
|
+
} catch {}
|
|
1487
|
+
queue.push({ type: "done" });
|
|
1488
|
+
signal();
|
|
1489
|
+
})();
|
|
1106
1490
|
try {
|
|
1107
|
-
for
|
|
1491
|
+
for (;;) {
|
|
1492
|
+
while (queue.length === 0) await new Promise((r) => {
|
|
1493
|
+
resolve = r;
|
|
1494
|
+
});
|
|
1495
|
+
const item = queue.shift();
|
|
1496
|
+
if (!item) continue;
|
|
1497
|
+
if (item.type === "data") yield item.value;
|
|
1498
|
+
else if (item.type === "heartbeat") yield ": heartbeat\n\n";
|
|
1499
|
+
else if (item.type === "idle") {
|
|
1500
|
+
yield sseCompleteEvent();
|
|
1501
|
+
return;
|
|
1502
|
+
} else return;
|
|
1503
|
+
}
|
|
1504
|
+
} finally {
|
|
1505
|
+
clearInterval(heartbeatTimer);
|
|
1506
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
async function* sseStream(router, name, input, rawCtx, lastEventId) {
|
|
1510
|
+
try {
|
|
1511
|
+
let seq = 0;
|
|
1512
|
+
for await (const value of router.handleSubscription(name, input, rawCtx, lastEventId)) yield sseDataEventWithId(value, seq++);
|
|
1108
1513
|
yield sseCompleteEvent();
|
|
1109
1514
|
} catch (error) {
|
|
1110
|
-
|
|
1111
|
-
else yield sseErrorEvent("INTERNAL_ERROR", error instanceof Error ? error.message : "Unknown error");
|
|
1515
|
+
yield formatSseError(error);
|
|
1112
1516
|
}
|
|
1113
1517
|
}
|
|
1114
1518
|
async function* sseStreamForStream(router, name, input, signal, rawCtx) {
|
|
@@ -1121,8 +1525,7 @@ async function* sseStreamForStream(router, name, input, signal, rawCtx) {
|
|
|
1121
1525
|
for await (const value of gen) yield sseDataEventWithId(value, seq++);
|
|
1122
1526
|
yield sseCompleteEvent();
|
|
1123
1527
|
} catch (error) {
|
|
1124
|
-
|
|
1125
|
-
else yield sseErrorEvent("INTERNAL_ERROR", error instanceof Error ? error.message : "Unknown error");
|
|
1528
|
+
yield formatSseError(error);
|
|
1126
1529
|
}
|
|
1127
1530
|
}
|
|
1128
1531
|
async function handleBatchHttp(req, router, hashToName, rawCtx) {
|
|
@@ -1142,22 +1545,49 @@ async function handleBatchHttp(req, router, hashToName, rawCtx) {
|
|
|
1142
1545
|
data: await router.handleBatch(calls, rawCtx)
|
|
1143
1546
|
});
|
|
1144
1547
|
}
|
|
1145
|
-
/** Resolve hash -> original name when obfuscation is active.
|
|
1548
|
+
/** Resolve hash -> original name when obfuscation is active. Accepts both hashed and raw names. */
|
|
1146
1549
|
function resolveHashName(hashToName, name) {
|
|
1147
1550
|
if (!hashToName) return name;
|
|
1148
|
-
return hashToName.get(name) ??
|
|
1551
|
+
return hashToName.get(name) ?? name;
|
|
1552
|
+
}
|
|
1553
|
+
async function handleProcedurePost(req, router, name, rawCtx, sseOptions) {
|
|
1554
|
+
let body;
|
|
1555
|
+
try {
|
|
1556
|
+
body = await req.body();
|
|
1557
|
+
} catch {
|
|
1558
|
+
return errorResponse(400, "VALIDATION_ERROR", "Invalid JSON body");
|
|
1559
|
+
}
|
|
1560
|
+
if (router.getKind(name) === "stream") {
|
|
1561
|
+
const controller = new AbortController();
|
|
1562
|
+
return {
|
|
1563
|
+
status: 200,
|
|
1564
|
+
headers: SSE_HEADER,
|
|
1565
|
+
stream: withSseLifecycle(sseStreamForStream(router, name, body, controller.signal, rawCtx), sseOptions),
|
|
1566
|
+
onCancel: () => controller.abort()
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
if (router.getKind(name) === "upload") {
|
|
1570
|
+
if (!req.file) return errorResponse(400, "VALIDATION_ERROR", "Upload requires multipart/form-data");
|
|
1571
|
+
const file = await req.file();
|
|
1572
|
+
if (!file) return errorResponse(400, "VALIDATION_ERROR", "Upload requires file in multipart body");
|
|
1573
|
+
const result = await router.handleUpload(name, body, file, rawCtx);
|
|
1574
|
+
return jsonResponse(result.status, result.body);
|
|
1575
|
+
}
|
|
1576
|
+
const result = await router.handle(name, body, rawCtx);
|
|
1577
|
+
return jsonResponse(result.status, result.body);
|
|
1149
1578
|
}
|
|
1150
1579
|
function createHttpHandler(router, opts) {
|
|
1151
|
-
const
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
const
|
|
1580
|
+
const effectiveHashMap = opts?.rpcHashMap ?? router.rpcHashMap;
|
|
1581
|
+
const hashToName = effectiveHashMap ? new Map(Object.entries(effectiveHashMap.procedures).map(([n, h]) => [h, n])) : null;
|
|
1582
|
+
if (hashToName) hashToName.set("seam.i18n.query", "seam.i18n.query");
|
|
1583
|
+
const batchHash = effectiveHashMap?.batch ?? null;
|
|
1584
|
+
const hasCtx = router.hasContext();
|
|
1155
1585
|
return async (req) => {
|
|
1156
1586
|
const url = new URL(req.url, "http://localhost");
|
|
1157
1587
|
const { pathname } = url;
|
|
1158
|
-
const rawCtx =
|
|
1588
|
+
const rawCtx = hasCtx ? buildRawContext(router.ctxConfig, req.header, url) : void 0;
|
|
1159
1589
|
if (req.method === "GET" && pathname === MANIFEST_PATH) {
|
|
1160
|
-
if (
|
|
1590
|
+
if (effectiveHashMap) return errorResponse(403, "FORBIDDEN", "Manifest disabled");
|
|
1161
1591
|
return jsonResponse(200, router.manifest());
|
|
1162
1592
|
}
|
|
1163
1593
|
if (pathname.startsWith(PROCEDURE_PREFIX)) {
|
|
@@ -1165,36 +1595,10 @@ function createHttpHandler(router, opts) {
|
|
|
1165
1595
|
if (!rawName) return errorResponse(404, "NOT_FOUND", "Empty procedure name");
|
|
1166
1596
|
if (req.method === "POST") {
|
|
1167
1597
|
if (rawName === "_batch" || batchHash && rawName === batchHash) return handleBatchHttp(req, router, hashToName, rawCtx);
|
|
1168
|
-
|
|
1169
|
-
if (!name) return errorResponse(404, "NOT_FOUND", "Not found");
|
|
1170
|
-
let body;
|
|
1171
|
-
try {
|
|
1172
|
-
body = await req.body();
|
|
1173
|
-
} catch {
|
|
1174
|
-
return errorResponse(400, "VALIDATION_ERROR", "Invalid JSON body");
|
|
1175
|
-
}
|
|
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);
|
|
1193
|
-
return jsonResponse(result.status, result.body);
|
|
1598
|
+
return handleProcedurePost(req, router, resolveHashName(hashToName, rawName), rawCtx, opts?.sseOptions);
|
|
1194
1599
|
}
|
|
1195
1600
|
if (req.method === "GET") {
|
|
1196
1601
|
const name = resolveHashName(hashToName, rawName);
|
|
1197
|
-
if (!name) return errorResponse(404, "NOT_FOUND", "Not found");
|
|
1198
1602
|
const rawInput = url.searchParams.get("input");
|
|
1199
1603
|
let input;
|
|
1200
1604
|
try {
|
|
@@ -1202,10 +1606,11 @@ function createHttpHandler(router, opts) {
|
|
|
1202
1606
|
} catch {
|
|
1203
1607
|
return errorResponse(400, "VALIDATION_ERROR", "Invalid input query parameter");
|
|
1204
1608
|
}
|
|
1609
|
+
const lastEventId = req.header?.("last-event-id") ?? void 0;
|
|
1205
1610
|
return {
|
|
1206
1611
|
status: 200,
|
|
1207
1612
|
headers: SSE_HEADER,
|
|
1208
|
-
stream: sseStream(router, name, input, rawCtx)
|
|
1613
|
+
stream: withSseLifecycle(sseStream(router, name, input, rawCtx, lastEventId), opts?.sseOptions)
|
|
1209
1614
|
};
|
|
1210
1615
|
}
|
|
1211
1616
|
}
|
|
@@ -1216,13 +1621,18 @@ function createHttpHandler(router, opts) {
|
|
|
1216
1621
|
cookie: req.header("cookie") ?? void 0,
|
|
1217
1622
|
acceptLanguage: req.header("accept-language") ?? void 0
|
|
1218
1623
|
} : void 0;
|
|
1219
|
-
const result = await router.handlePage(pagePath, headers);
|
|
1624
|
+
const result = await router.handlePage(pagePath, headers, rawCtx);
|
|
1220
1625
|
if (result) return {
|
|
1221
1626
|
status: result.status,
|
|
1222
1627
|
headers: HTML_HEADER,
|
|
1223
1628
|
body: result.html
|
|
1224
1629
|
};
|
|
1225
1630
|
}
|
|
1631
|
+
if (req.method === "GET" && pathname.startsWith(DATA_PREFIX) && router.hasPages) {
|
|
1632
|
+
const pagePath = "/" + pathname.slice(12).replace(/\/$/, "");
|
|
1633
|
+
const dataResult = await router.handlePageData(pagePath);
|
|
1634
|
+
if (dataResult !== null) return jsonResponse(200, dataResult);
|
|
1635
|
+
}
|
|
1226
1636
|
if (req.method === "GET" && pathname.startsWith(STATIC_PREFIX) && opts?.staticDir) return handleStaticAsset(pathname.slice(14), opts.staticDir);
|
|
1227
1637
|
if (opts?.fallback) return opts.fallback(req);
|
|
1228
1638
|
return errorResponse(404, "NOT_FOUND", "Not found");
|
|
@@ -1267,10 +1677,14 @@ function toWebResponse(result) {
|
|
|
1267
1677
|
|
|
1268
1678
|
//#endregion
|
|
1269
1679
|
//#region src/page/build-loader.ts
|
|
1680
|
+
function normalizeParamConfig(value) {
|
|
1681
|
+
return typeof value === "string" ? { from: value } : value;
|
|
1682
|
+
}
|
|
1270
1683
|
function buildLoaderFn(config) {
|
|
1271
1684
|
return (params, searchParams) => {
|
|
1272
1685
|
const input = {};
|
|
1273
|
-
if (config.params) for (const [key,
|
|
1686
|
+
if (config.params) for (const [key, raw_mapping] of Object.entries(config.params)) {
|
|
1687
|
+
const mapping = normalizeParamConfig(raw_mapping);
|
|
1274
1688
|
const raw = mapping.from === "query" ? searchParams?.get(key) ?? void 0 : params[key];
|
|
1275
1689
|
if (raw !== void 0) input[key] = mapping.type === "int" ? Number(raw) : raw;
|
|
1276
1690
|
}
|
|
@@ -1348,6 +1762,22 @@ function mergeI18nKeys(route, layoutEntries) {
|
|
|
1348
1762
|
if (route.i18n_keys) keys.push(...route.i18n_keys);
|
|
1349
1763
|
return keys.length > 0 ? keys : void 0;
|
|
1350
1764
|
}
|
|
1765
|
+
/** Load all build artifacts (pages, rpcHashMap, i18n) in one call */
|
|
1766
|
+
function loadBuild(distDir) {
|
|
1767
|
+
return {
|
|
1768
|
+
pages: loadBuildOutput(distDir),
|
|
1769
|
+
rpcHashMap: loadRpcHashMap(distDir),
|
|
1770
|
+
i18n: loadI18nMessages(distDir)
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
/** Load all build artifacts with lazy template getters (for dev mode) */
|
|
1774
|
+
function loadBuildDev(distDir) {
|
|
1775
|
+
return {
|
|
1776
|
+
pages: loadBuildOutputDev(distDir),
|
|
1777
|
+
rpcHashMap: loadRpcHashMap(distDir),
|
|
1778
|
+
i18n: loadI18nMessages(distDir)
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1351
1781
|
/** Load the RPC hash map from build output (returns undefined when obfuscation is off) */
|
|
1352
1782
|
function loadRpcHashMap(distDir) {
|
|
1353
1783
|
const hashMapPath = join(distDir, "rpc-hash-map.json");
|
|
@@ -1402,6 +1832,8 @@ function loadBuildOutput(distDir) {
|
|
|
1402
1832
|
const lt = loadLocaleTemplates(entry, distDir);
|
|
1403
1833
|
if (lt) layoutLocaleTemplates[id] = lt;
|
|
1404
1834
|
}
|
|
1835
|
+
const staticDir = join(distDir, "..", "static");
|
|
1836
|
+
const hasStaticDir = existsSync(staticDir);
|
|
1405
1837
|
const pages = {};
|
|
1406
1838
|
for (const [path, entry] of Object.entries(manifest.routes)) {
|
|
1407
1839
|
const template = readFileSync(join(distDir, resolveTemplatePath(entry, defaultLocale)), "utf-8");
|
|
@@ -1411,7 +1843,7 @@ function loadBuildOutput(distDir) {
|
|
|
1411
1843
|
localeTemplates: layoutLocaleTemplates[id]
|
|
1412
1844
|
})) : [];
|
|
1413
1845
|
const i18nKeys = mergeI18nKeys(entry, layoutEntries);
|
|
1414
|
-
|
|
1846
|
+
const page = {
|
|
1415
1847
|
template,
|
|
1416
1848
|
localeTemplates: loadLocaleTemplates(entry, distDir),
|
|
1417
1849
|
loaders,
|
|
@@ -1422,6 +1854,11 @@ function loadBuildOutput(distDir) {
|
|
|
1422
1854
|
pageAssets: entry.assets,
|
|
1423
1855
|
projections: entry.projections
|
|
1424
1856
|
};
|
|
1857
|
+
if (entry.prerender && hasStaticDir) {
|
|
1858
|
+
page.prerender = true;
|
|
1859
|
+
page.staticDir = staticDir;
|
|
1860
|
+
}
|
|
1861
|
+
pages[path] = page;
|
|
1425
1862
|
}
|
|
1426
1863
|
return pages;
|
|
1427
1864
|
}
|
|
@@ -1529,7 +1966,8 @@ function fromCallback(setup) {
|
|
|
1529
1966
|
|
|
1530
1967
|
//#endregion
|
|
1531
1968
|
//#region src/ws.ts
|
|
1532
|
-
const DEFAULT_HEARTBEAT_MS =
|
|
1969
|
+
const DEFAULT_HEARTBEAT_MS = 21e3;
|
|
1970
|
+
const DEFAULT_PONG_TIMEOUT_MS = 5e3;
|
|
1533
1971
|
function sendError(ws, id, code, message) {
|
|
1534
1972
|
ws.send(JSON.stringify({
|
|
1535
1973
|
id,
|
|
@@ -1541,6 +1979,60 @@ function sendError(ws, id, code, message) {
|
|
|
1541
1979
|
}
|
|
1542
1980
|
}));
|
|
1543
1981
|
}
|
|
1982
|
+
/** Validate uplink message fields and channel scope */
|
|
1983
|
+
function parseUplink(ws, data, channelName) {
|
|
1984
|
+
let msg;
|
|
1985
|
+
try {
|
|
1986
|
+
msg = JSON.parse(data);
|
|
1987
|
+
} catch {
|
|
1988
|
+
sendError(ws, null, "VALIDATION_ERROR", "Invalid JSON");
|
|
1989
|
+
return null;
|
|
1990
|
+
}
|
|
1991
|
+
if (!msg.id || typeof msg.id !== "string") {
|
|
1992
|
+
sendError(ws, null, "VALIDATION_ERROR", "Missing 'id' field");
|
|
1993
|
+
return null;
|
|
1994
|
+
}
|
|
1995
|
+
if (!msg.procedure || typeof msg.procedure !== "string") {
|
|
1996
|
+
sendError(ws, msg.id, "VALIDATION_ERROR", "Missing 'procedure' field");
|
|
1997
|
+
return null;
|
|
1998
|
+
}
|
|
1999
|
+
const prefix = channelName + ".";
|
|
2000
|
+
if (!msg.procedure.startsWith(prefix) || msg.procedure === `${channelName}.events`) {
|
|
2001
|
+
sendError(ws, msg.id, "VALIDATION_ERROR", `Procedure '${msg.procedure}' is not a command of channel '${channelName}'`);
|
|
2002
|
+
return null;
|
|
2003
|
+
}
|
|
2004
|
+
return msg;
|
|
2005
|
+
}
|
|
2006
|
+
/** Dispatch validated uplink command through the router */
|
|
2007
|
+
function dispatchUplink(router, ws, msg, channelInput) {
|
|
2008
|
+
const mergedInput = {
|
|
2009
|
+
...channelInput,
|
|
2010
|
+
...msg.input ?? {}
|
|
2011
|
+
};
|
|
2012
|
+
(async () => {
|
|
2013
|
+
try {
|
|
2014
|
+
const result = await router.handle(msg.procedure, mergedInput);
|
|
2015
|
+
if (result.status === 200) {
|
|
2016
|
+
const envelope = result.body;
|
|
2017
|
+
ws.send(JSON.stringify({
|
|
2018
|
+
id: msg.id,
|
|
2019
|
+
ok: true,
|
|
2020
|
+
data: envelope.data
|
|
2021
|
+
}));
|
|
2022
|
+
} else {
|
|
2023
|
+
const envelope = result.body;
|
|
2024
|
+
ws.send(JSON.stringify({
|
|
2025
|
+
id: msg.id,
|
|
2026
|
+
ok: false,
|
|
2027
|
+
error: envelope.error
|
|
2028
|
+
}));
|
|
2029
|
+
}
|
|
2030
|
+
} catch (err) {
|
|
2031
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
2032
|
+
sendError(ws, msg.id, "INTERNAL_ERROR", message);
|
|
2033
|
+
}
|
|
2034
|
+
})();
|
|
2035
|
+
}
|
|
1544
2036
|
/**
|
|
1545
2037
|
* Start a WebSocket session for a channel.
|
|
1546
2038
|
*
|
|
@@ -1549,9 +2041,24 @@ function sendError(ws, id, code, message) {
|
|
|
1549
2041
|
*/
|
|
1550
2042
|
function startChannelWs(router, channelName, channelInput, ws, opts) {
|
|
1551
2043
|
const heartbeatMs = opts?.heartbeatInterval ?? DEFAULT_HEARTBEAT_MS;
|
|
2044
|
+
const pongTimeoutMs = opts?.pongTimeout ?? DEFAULT_PONG_TIMEOUT_MS;
|
|
1552
2045
|
let closed = false;
|
|
2046
|
+
let pongTimer = null;
|
|
1553
2047
|
const heartbeatTimer = setInterval(() => {
|
|
1554
|
-
if (
|
|
2048
|
+
if (closed) return;
|
|
2049
|
+
ws.send(JSON.stringify({ heartbeat: true }));
|
|
2050
|
+
if (ws.ping) {
|
|
2051
|
+
ws.ping();
|
|
2052
|
+
if (pongTimer) clearTimeout(pongTimer);
|
|
2053
|
+
pongTimer = setTimeout(() => {
|
|
2054
|
+
if (!closed) {
|
|
2055
|
+
closed = true;
|
|
2056
|
+
clearInterval(heartbeatTimer);
|
|
2057
|
+
iter.return?.(void 0);
|
|
2058
|
+
ws.close?.();
|
|
2059
|
+
}
|
|
2060
|
+
}, pongTimeoutMs);
|
|
2061
|
+
}
|
|
1555
2062
|
}, heartbeatMs);
|
|
1556
2063
|
const iter = router.handleSubscription(`${channelName}.events`, channelInput)[Symbol.asyncIterator]();
|
|
1557
2064
|
(async () => {
|
|
@@ -1582,58 +2089,20 @@ function startChannelWs(router, channelName, channelInput, ws, opts) {
|
|
|
1582
2089
|
return {
|
|
1583
2090
|
onMessage(data) {
|
|
1584
2091
|
if (closed) return;
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
if (!msg.id || typeof msg.id !== "string") {
|
|
1593
|
-
sendError(ws, null, "VALIDATION_ERROR", "Missing 'id' field");
|
|
1594
|
-
return;
|
|
1595
|
-
}
|
|
1596
|
-
if (!msg.procedure || typeof msg.procedure !== "string") {
|
|
1597
|
-
sendError(ws, msg.id, "VALIDATION_ERROR", "Missing 'procedure' field");
|
|
1598
|
-
return;
|
|
1599
|
-
}
|
|
1600
|
-
const prefix = channelName + ".";
|
|
1601
|
-
if (!msg.procedure.startsWith(prefix) || msg.procedure === `${channelName}.events`) {
|
|
1602
|
-
sendError(ws, msg.id, "VALIDATION_ERROR", `Procedure '${msg.procedure}' is not a command of channel '${channelName}'`);
|
|
1603
|
-
return;
|
|
2092
|
+
const msg = parseUplink(ws, data, channelName);
|
|
2093
|
+
if (msg) dispatchUplink(router, ws, msg, channelInput);
|
|
2094
|
+
},
|
|
2095
|
+
onPong() {
|
|
2096
|
+
if (pongTimer) {
|
|
2097
|
+
clearTimeout(pongTimer);
|
|
2098
|
+
pongTimer = null;
|
|
1604
2099
|
}
|
|
1605
|
-
const mergedInput = {
|
|
1606
|
-
...channelInput,
|
|
1607
|
-
...msg.input ?? {}
|
|
1608
|
-
};
|
|
1609
|
-
(async () => {
|
|
1610
|
-
try {
|
|
1611
|
-
const result = await router.handle(msg.procedure, mergedInput);
|
|
1612
|
-
if (result.status === 200) {
|
|
1613
|
-
const envelope = result.body;
|
|
1614
|
-
ws.send(JSON.stringify({
|
|
1615
|
-
id: msg.id,
|
|
1616
|
-
ok: true,
|
|
1617
|
-
data: envelope.data
|
|
1618
|
-
}));
|
|
1619
|
-
} else {
|
|
1620
|
-
const envelope = result.body;
|
|
1621
|
-
ws.send(JSON.stringify({
|
|
1622
|
-
id: msg.id,
|
|
1623
|
-
ok: false,
|
|
1624
|
-
error: envelope.error
|
|
1625
|
-
}));
|
|
1626
|
-
}
|
|
1627
|
-
} catch (err) {
|
|
1628
|
-
const message = err instanceof Error ? err.message : "Unknown error";
|
|
1629
|
-
sendError(ws, msg.id, "INTERNAL_ERROR", message);
|
|
1630
|
-
}
|
|
1631
|
-
})();
|
|
1632
2100
|
},
|
|
1633
2101
|
close() {
|
|
1634
2102
|
if (closed) return;
|
|
1635
2103
|
closed = true;
|
|
1636
2104
|
clearInterval(heartbeatTimer);
|
|
2105
|
+
if (pongTimer) clearTimeout(pongTimer);
|
|
1637
2106
|
iter.return?.(void 0);
|
|
1638
2107
|
}
|
|
1639
2108
|
};
|
|
@@ -1759,5 +2228,5 @@ function watchReloadTrigger(distDir, onReload) {
|
|
|
1759
2228
|
}
|
|
1760
2229
|
|
|
1761
2230
|
//#endregion
|
|
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 };
|
|
2231
|
+
export { SeamError, buildRawContext, command, contextHasExtracts, createChannel, createDevProxy, createHttpHandler, createRouter, createSeamRouter, createStaticHandler, defaultStrategies, definePage, drainStream, extract, fromAcceptLanguage, fromCallback, fromCookie, fromUrlPrefix, fromUrlQuery, isLoaderError, loadBuild, loadBuildDev, loadBuildOutput, loadBuildOutputDev, loadI18nMessages, loadRpcHashMap, parseCookieHeader, query, resolveChain, serialize, sseCompleteEvent, sseDataEvent, sseDataEventWithId, sseErrorEvent, startChannelWs, stream, subscription, t, toWebResponse, upload, watchReloadTrigger };
|
|
1763
2232
|
//# sourceMappingURL=index.js.map
|