@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/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
- constructor(code, message, status) {
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
- /** 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);
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 [...new Set(keys)];
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 { source, key: extractKey } = parseExtractRule(field.extract);
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
- const validation = validateInput(procedure.inputSchema, rawBody);
304
- if (!validation.valid) return {
305
- status: 400,
306
- body: new SeamError("VALIDATION_ERROR", `Input validation failed: ${formatValidationErrors(validation.errors)}`).toJSON()
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
- const validation = validateInput(sub.inputSchema, rawInput);
356
- if (!validation.valid) throw new SeamError("VALIDATION_ERROR", `Input validation failed: ${formatValidationErrors(validation.errors)}`);
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
- 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
- };
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
- const validation = validateInput(stream.inputSchema, rawInput);
414
- if (!validation.valid) throw new SeamError("VALIDATION_ERROR", `Input validation failed: ${formatValidationErrors(validation.errors)}`);
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
- async function executeLoaders(loaders, params, procedures, searchParams) {
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
- const proc = procedures.get(procedure);
497
- if (!proc) throw new SeamError("INTERNAL_ERROR", `Procedure '${procedure}' not found`);
498
- return [key, await proc.handler({
499
- input,
500
- ctx: {}
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 Object.fromEntries(results);
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
- async function handlePageRequest(page, params, procedures, i18nOpts, searchParams) {
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
- for (const result of loaderResults) Object.assign(allData, result);
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: page.headMeta
838
+ head_meta: resolvedHeadMeta,
839
+ loader_metadata: allMeta
543
840
  };
544
841
  if (page.pageAssets) config.page_assets = page.pageAssets;
545
- let i18nOptsJson;
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/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
- };
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 __seam_i18n_query procedure (route-hash-based lookup) */
972
+ /** Register built-in seam.i18n.query procedure (route-hash-based lookup) */
801
973
  function registerI18nQuery(procedureMap, config) {
802
- procedureMap.set("__seam_i18n_query", {
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, extractKeys, ctxConfig) {
835
- if (!rawCtx || extractKeys.length === 0) return void 0;
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
- return handlePageRequest(match.value, match.params, procedureMap, i18nOpts, searchParams);
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, extractKeys, ctxConfig) {
1064
+ function resolveCtxSafe(map, name, rawCtx, ctxConfig) {
878
1065
  try {
879
- return { ctx: resolveCtxFor(map, name, rawCtx, extractKeys, ctxConfig) };
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
- function createRouter(procedures, opts) {
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
- procedures,
902
- hasPages: !!pages && Object.keys(pages).length > 0,
903
- contextExtractKeys() {
904
- return extractKeys;
905
- },
906
- manifest() {
907
- return buildManifest(procedures, channelsMeta, ctxConfig, opts?.transportDefaults);
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, extractKeys, ctxConfig);
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
- return handleBatchRequest(procedureMap, calls, shouldValidateOutput, rawCtx ? (name) => resolveCtxFor(procedureMap, name, rawCtx, extractKeys, ctxConfig) ?? {} : void 0);
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, extractKeys, ctxConfig);
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
- async function* sseStream(router, name, input, rawCtx) {
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 await (const value of router.handleSubscription(name, input, rawCtx)) yield sseDataEvent(value);
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
- if (error instanceof SeamError) yield sseErrorEvent(error.code, error.message);
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
- if (error instanceof SeamError) yield sseErrorEvent(error.code, error.message);
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. Returns null on miss. */
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) ?? null;
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 hashToName = opts?.rpcHashMap ? new Map(Object.entries(opts.rpcHashMap.procedures).map(([n, h]) => [h, n])) : null;
1152
- if (hashToName) hashToName.set("__seam_i18n_query", "__seam_i18n_query");
1153
- const batchHash = opts?.rpcHashMap?.batch ?? null;
1154
- const ctxExtractKeys = router.contextExtractKeys();
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 = ctxExtractKeys.length > 0 && req.header ? Object.fromEntries(ctxExtractKeys.map((k) => [k, req.header?.(k) ?? null])) : void 0;
1588
+ const rawCtx = hasCtx ? buildRawContext(router.ctxConfig, req.header, url) : void 0;
1159
1589
  if (req.method === "GET" && pathname === MANIFEST_PATH) {
1160
- if (opts?.rpcHashMap) return errorResponse(403, "FORBIDDEN", "Manifest disabled");
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
- const name = resolveHashName(hashToName, rawName);
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, mapping] of Object.entries(config.params)) {
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
- pages[path] = {
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 = 3e4;
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 (!closed) ws.send(JSON.stringify({ heartbeat: true }));
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
- let msg;
1586
- try {
1587
- msg = JSON.parse(data);
1588
- } catch {
1589
- sendError(ws, null, "VALIDATION_ERROR", "Invalid JSON");
1590
- return;
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