@canmi/seam-server 0.5.10 → 0.5.27

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
@@ -1,6 +1,6 @@
1
+ import { validate } from "jtd";
1
2
  import { existsSync, readFileSync, watch } from "node:fs";
2
3
  import { extname, join } from "node:path";
3
- import { validate } from "jtd";
4
4
  import { escapeHtml, renderPage } from "@canmi/seam-engine";
5
5
  import { readFile } from "node:fs/promises";
6
6
 
@@ -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();
212
241
  }
213
- return [...new Set(keys)];
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
+ }
263
+ }
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,11 +473,16 @@ 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) {
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
488
  ctx: ctx ?? {}
@@ -365,17 +494,22 @@ async function* handleSubscription(subscriptions, name, rawInput, validateOutput
365
494
  yield value;
366
495
  }
367
496
  }
368
- async function handleUploadRequest(uploads, procedureName, rawBody, file, validateOutput, ctx) {
497
+ async function handleUploadRequest(uploads, procedureName, rawBody, file, shouldValidateInput = true, validateOutput, ctx) {
369
498
  const upload = uploads.get(procedureName);
370
499
  if (!upload) return {
371
500
  status: 404,
372
501
  body: new SeamError("NOT_FOUND", `Procedure '${procedureName}' not found`).toJSON()
373
502
  };
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
- };
503
+ if (shouldValidateInput) {
504
+ const validation = validateInput(upload.inputSchema, rawBody);
505
+ if (!validation.valid) {
506
+ const details = formatValidationDetails(validation.errors, upload.inputSchema, rawBody);
507
+ return {
508
+ status: 400,
509
+ body: new SeamError("VALIDATION_ERROR", `Input validation failed for procedure '${procedureName}': ${formatValidationErrors(validation.errors)}`, void 0, details).toJSON()
510
+ };
511
+ }
512
+ }
379
513
  try {
380
514
  const result = await upload.handler({
381
515
  input: rawBody,
@@ -407,11 +541,16 @@ async function handleUploadRequest(uploads, procedureName, rawBody, file, valida
407
541
  };
408
542
  }
409
543
  }
410
- async function* handleStream(streams, name, rawInput, validateOutput, ctx) {
544
+ async function* handleStream(streams, name, rawInput, shouldValidateInput = true, validateOutput, ctx) {
411
545
  const stream = streams.get(name);
412
546
  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)}`);
547
+ if (shouldValidateInput) {
548
+ const validation = validateInput(stream.inputSchema, rawInput);
549
+ if (!validation.valid) {
550
+ const details = formatValidationDetails(validation.errors, stream.inputSchema, rawInput);
551
+ throw new SeamError("VALIDATION_ERROR", `Input validation failed: ${formatValidationErrors(validation.errors)}`, void 0, details);
552
+ }
553
+ }
415
554
  for await (const value of stream.handler({
416
555
  input: rawInput,
417
556
  ctx: ctx ?? {}
@@ -424,6 +563,70 @@ async function* handleStream(streams, name, rawInput, validateOutput, ctx) {
424
563
  }
425
564
  }
426
565
 
566
+ //#endregion
567
+ //#region src/router/categorize.ts
568
+ function resolveKind(name, def) {
569
+ if ("kind" in def && def.kind) return def.kind;
570
+ if ("type" in def && def.type) {
571
+ console.warn(`[seam] "${name}": "type" field in procedure definition is deprecated, use "kind" instead`);
572
+ return def.type;
573
+ }
574
+ return "query";
575
+ }
576
+ /** Split a flat definition map into typed procedure/subscription/stream maps */
577
+ function categorizeProcedures(definitions, contextConfig) {
578
+ const procedureMap = /* @__PURE__ */ new Map();
579
+ const subscriptionMap = /* @__PURE__ */ new Map();
580
+ const streamMap = /* @__PURE__ */ new Map();
581
+ const uploadMap = /* @__PURE__ */ new Map();
582
+ const kindMap = /* @__PURE__ */ new Map();
583
+ for (const [name, def] of Object.entries(definitions)) {
584
+ const kind = resolveKind(name, def);
585
+ kindMap.set(name, kind);
586
+ const contextKeys = def.context ?? [];
587
+ if (contextConfig && contextKeys.length > 0) {
588
+ for (const key of contextKeys) if (!(key in contextConfig)) throw new Error(`Procedure "${name}" references undefined context field "${key}"`);
589
+ }
590
+ if (kind === "upload") uploadMap.set(name, {
591
+ inputSchema: def.input._schema,
592
+ outputSchema: def.output._schema,
593
+ contextKeys,
594
+ handler: def.handler
595
+ });
596
+ else if (kind === "stream") streamMap.set(name, {
597
+ inputSchema: def.input._schema,
598
+ chunkOutputSchema: def.output._schema,
599
+ contextKeys,
600
+ handler: def.handler
601
+ });
602
+ else if (kind === "subscription") subscriptionMap.set(name, {
603
+ inputSchema: def.input._schema,
604
+ outputSchema: def.output._schema,
605
+ contextKeys,
606
+ handler: def.handler
607
+ });
608
+ else procedureMap.set(name, {
609
+ inputSchema: def.input._schema,
610
+ outputSchema: def.output._schema,
611
+ contextKeys,
612
+ handler: def.handler
613
+ });
614
+ }
615
+ return {
616
+ procedureMap,
617
+ subscriptionMap,
618
+ streamMap,
619
+ uploadMap,
620
+ kindMap
621
+ };
622
+ }
623
+
624
+ //#endregion
625
+ //#region src/page/loader-error.ts
626
+ function isLoaderError(value) {
627
+ return typeof value === "object" && value !== null && value.__error === true && typeof value.code === "string" && typeof value.message === "string";
628
+ }
629
+
427
630
  //#endregion
428
631
  //#region src/page/projection.ts
429
632
  /** Set a nested field by dot-separated path, creating intermediate objects as needed. */
@@ -479,6 +682,10 @@ function applyProjection(data, projections) {
479
682
  if (!projections) return data;
480
683
  const result = {};
481
684
  for (const [key, value] of Object.entries(data)) {
685
+ if (isLoaderError(value)) {
686
+ result[key] = value;
687
+ continue;
688
+ }
482
689
  const fields = projections[key];
483
690
  if (!fields) result[key] = value;
484
691
  else result[key] = pruneValue(value, fields);
@@ -488,19 +695,58 @@ function applyProjection(data, projections) {
488
695
 
489
696
  //#endregion
490
697
  //#region src/page/handler.ts
491
- /** Execute loaders, returning keyed results */
492
- async function executeLoaders(loaders, params, procedures, searchParams) {
698
+ /** Execute loaders, returning keyed results and metadata.
699
+ * Each loader is wrapped in its own try-catch so a single failure
700
+ * does not abort sibling loaders — the page renders at 200 with partial data. */
701
+ async function executeLoaders(loaders, params, procedures, searchParams, ctxResolver, shouldValidateInput) {
493
702
  const entries = Object.entries(loaders);
494
703
  const results = await Promise.all(entries.map(async ([key, loader]) => {
495
704
  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
- })];
705
+ try {
706
+ const proc = procedures.get(procedure);
707
+ if (!proc) throw new SeamError("INTERNAL_ERROR", `Procedure '${procedure}' not found`);
708
+ if (shouldValidateInput) {
709
+ const v = validateInput(proc.inputSchema, input);
710
+ if (!v.valid) throw new SeamError("VALIDATION_ERROR", `Input validation failed: ${formatValidationErrors(v.errors)}`);
711
+ }
712
+ const ctx = ctxResolver ? ctxResolver(proc) : {};
713
+ return {
714
+ key,
715
+ result: await proc.handler({
716
+ input,
717
+ ctx
718
+ }),
719
+ procedure,
720
+ input
721
+ };
722
+ } catch (err) {
723
+ const code = err instanceof SeamError ? err.code : "INTERNAL_ERROR";
724
+ const message = err instanceof Error ? err.message : "Unknown error";
725
+ console.error(`[seam] Loader "${key}" failed:`, err);
726
+ return {
727
+ key,
728
+ result: {
729
+ __error: true,
730
+ code,
731
+ message
732
+ },
733
+ procedure,
734
+ input,
735
+ error: true
736
+ };
737
+ }
502
738
  }));
503
- return Object.fromEntries(results);
739
+ return {
740
+ data: Object.fromEntries(results.map((r) => [r.key, r.result])),
741
+ meta: Object.fromEntries(results.map((r) => {
742
+ const entry = {
743
+ procedure: r.procedure,
744
+ input: r.input
745
+ };
746
+ if (r.error) entry.error = true;
747
+ return [r.key, entry];
748
+ }))
749
+ };
504
750
  }
505
751
  /** Select the template for a given locale, falling back to the default template */
506
752
  function selectTemplate(defaultTemplate, localeTemplates, locale) {
@@ -518,15 +764,19 @@ function lookupMessages(config, routePattern, locale) {
518
764
  }
519
765
  return config.messages[locale]?.[routeHash] ?? {};
520
766
  }
521
- async function handlePageRequest(page, params, procedures, i18nOpts, searchParams) {
767
+ async function handlePageRequest(page, params, procedures, i18nOpts, searchParams, ctxResolver, shouldValidateInput) {
522
768
  try {
523
769
  const t0 = performance.now();
524
770
  const layoutChain = page.layoutChain ?? [];
525
771
  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)]);
772
+ 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
773
  const t1 = performance.now();
528
774
  const allData = {};
529
- for (const result of loaderResults) Object.assign(allData, result);
775
+ const allMeta = {};
776
+ for (const { data, meta } of loaderResults) {
777
+ Object.assign(allData, data);
778
+ Object.assign(allMeta, meta);
779
+ }
530
780
  const prunedData = applyProjection(allData, page.projections);
531
781
  let composedTemplate = selectTemplate(page.template, page.localeTemplates, locale);
532
782
  for (let i = layoutChain.length - 1; i >= 0; i--) {
@@ -539,7 +789,8 @@ async function handlePageRequest(page, params, procedures, i18nOpts, searchParam
539
789
  loader_keys: Object.keys(l.loaders)
540
790
  })),
541
791
  data_id: page.dataId ?? "__data",
542
- head_meta: page.headMeta
792
+ head_meta: page.headMeta,
793
+ loader_metadata: allMeta
543
794
  };
544
795
  if (page.pageAssets) config.page_assets = page.pageAssets;
545
796
  let i18nOptsJson;
@@ -576,69 +827,6 @@ async function handlePageRequest(page, params, procedures, i18nOpts, searchParam
576
827
  }
577
828
  }
578
829
 
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
598
- };
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
- }
616
- if (segments.length !== pathParts.length) return null;
617
- return params;
618
- }
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
-
642
830
  //#endregion
643
831
  //#region src/resolve.ts
644
832
  /** URL prefix strategy: trusts pathLocale if it is a known locale */
@@ -730,65 +918,14 @@ function defaultStrategies() {
730
918
  }
731
919
 
732
920
  //#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";
921
+ //#region src/router/helpers.ts
922
+ /** Resolve a ValidationMode to a boolean flag */
923
+ function resolveValidationMode(mode) {
924
+ const m = mode ?? "dev";
925
+ if (m === "always") return true;
926
+ if (m === "never") return false;
927
+ return typeof process !== "undefined" && process.env.NODE_ENV !== "production";
741
928
  }
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
- };
788
- }
789
-
790
- //#endregion
791
- //#region src/router/index.ts
792
929
  /** Build the resolve strategy list from options */
793
930
  function buildStrategies(opts) {
794
931
  const strategies = opts?.resolve ?? defaultStrategies();
@@ -831,14 +968,14 @@ function collectChannelMeta(channels) {
831
968
  }));
832
969
  }
833
970
  /** 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;
971
+ function resolveCtxFor(map, name, rawCtx, ctxConfig) {
972
+ if (!rawCtx) return void 0;
836
973
  const proc = map.get(name);
837
974
  if (!proc || proc.contextKeys.length === 0) return void 0;
838
975
  return resolveContext(ctxConfig, rawCtx, proc.contextKeys);
839
976
  }
840
977
  /** Resolve locale and match page route */
841
- async function matchAndHandlePage(pageMatcher, procedureMap, i18nConfig, strategies, hasUrlPrefix, path, headers) {
978
+ async function matchAndHandlePage(pageMatcher, procedureMap, i18nConfig, strategies, hasUrlPrefix, path, headers, rawCtx, ctxConfig, shouldValidateInput) {
842
979
  let pathLocale = null;
843
980
  let routePath = path;
844
981
  if (hasUrlPrefix && i18nConfig) {
@@ -871,12 +1008,16 @@ async function matchAndHandlePage(pageMatcher, procedureMap, i18nConfig, strateg
871
1008
  config: i18nConfig,
872
1009
  routePattern: match.pattern
873
1010
  } : void 0;
874
- return handlePageRequest(match.value, match.params, procedureMap, i18nOpts, searchParams);
1011
+ const ctxResolver = rawCtx ? (proc) => {
1012
+ if (proc.contextKeys.length === 0) return {};
1013
+ return resolveContext(ctxConfig ?? {}, rawCtx, proc.contextKeys);
1014
+ } : void 0;
1015
+ return handlePageRequest(match.value, match.params, procedureMap, i18nOpts, searchParams, ctxResolver, shouldValidateInput);
875
1016
  }
876
1017
  /** Catch context resolution errors and return them as HandleResult */
877
- function resolveCtxSafe(map, name, rawCtx, extractKeys, ctxConfig) {
1018
+ function resolveCtxSafe(map, name, rawCtx, ctxConfig) {
878
1019
  try {
879
- return { ctx: resolveCtxFor(map, name, rawCtx, extractKeys, ctxConfig) };
1020
+ return { ctx: resolveCtxFor(map, name, rawCtx, ctxConfig) };
880
1021
  } catch (err) {
881
1022
  if (err instanceof SeamError) return { error: {
882
1023
  status: err.status,
@@ -885,9 +1026,14 @@ function resolveCtxSafe(map, name, rawCtx, extractKeys, ctxConfig) {
885
1026
  throw err;
886
1027
  }
887
1028
  }
888
- function createRouter(procedures, opts) {
1029
+
1030
+ //#endregion
1031
+ //#region src/router/state.ts
1032
+ /** Build all shared state that createRouter methods close over */
1033
+ function initRouterState(procedures, opts) {
889
1034
  const ctxConfig = opts?.context ?? {};
890
1035
  const { procedureMap, subscriptionMap, streamMap, uploadMap, kindMap } = categorizeProcedures(procedures, Object.keys(ctxConfig).length > 0 ? ctxConfig : void 0);
1036
+ const shouldValidateInput = resolveValidationMode(opts?.validation?.input);
891
1037
  const shouldValidateOutput = opts?.validateOutput ?? (typeof process !== "undefined" && process.env.NODE_ENV !== "production");
892
1038
  const pageMatcher = new RouteMatcher();
893
1039
  const pages = opts?.pages;
@@ -895,43 +1041,163 @@ function createRouter(procedures, opts) {
895
1041
  const i18nConfig = opts?.i18n ?? null;
896
1042
  const { strategies, hasUrlPrefix } = buildStrategies(opts);
897
1043
  if (i18nConfig) registerI18nQuery(procedureMap, i18nConfig);
898
- const channelsMeta = collectChannelMeta(opts?.channels);
899
- const extractKeys = contextExtractKeys(ctxConfig);
900
1044
  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
- },
1045
+ ctxConfig,
1046
+ procedureMap,
1047
+ subscriptionMap,
1048
+ streamMap,
1049
+ uploadMap,
1050
+ kindMap,
1051
+ shouldValidateInput,
1052
+ shouldValidateOutput,
1053
+ pageMatcher,
1054
+ pages,
1055
+ i18nConfig,
1056
+ strategies,
1057
+ hasUrlPrefix,
1058
+ channelsMeta: collectChannelMeta(opts?.channels),
1059
+ hasCtx: contextHasExtracts(ctxConfig)
1060
+ };
1061
+ }
1062
+ /** Build request-response methods: handle, handleBatch, handleUpload */
1063
+ function buildRpcMethods(state) {
1064
+ return {
909
1065
  async handle(procedureName, body, rawCtx) {
910
- const { ctx, error } = resolveCtxSafe(procedureMap, procedureName, rawCtx, extractKeys, ctxConfig);
1066
+ const { ctx, error } = resolveCtxSafe(state.procedureMap, procedureName, rawCtx, state.ctxConfig);
911
1067
  if (error) return error;
912
- return handleRequest(procedureMap, procedureName, body, shouldValidateOutput, ctx);
1068
+ return handleRequest(state.procedureMap, procedureName, body, state.shouldValidateInput, state.shouldValidateOutput, ctx);
913
1069
  },
914
1070
  handleBatch(calls, rawCtx) {
915
- return handleBatchRequest(procedureMap, calls, shouldValidateOutput, rawCtx ? (name) => resolveCtxFor(procedureMap, name, rawCtx, extractKeys, ctxConfig) ?? {} : void 0);
1071
+ const ctxResolver = rawCtx ? (name) => resolveCtxFor(state.procedureMap, name, rawCtx, state.ctxConfig) ?? {} : void 0;
1072
+ return handleBatchRequest(state.procedureMap, calls, state.shouldValidateInput, state.shouldValidateOutput, ctxResolver);
1073
+ },
1074
+ async handleUpload(name, body, file, rawCtx) {
1075
+ const { ctx, error } = resolveCtxSafe(state.uploadMap, name, rawCtx, state.ctxConfig);
1076
+ if (error) return error;
1077
+ return handleUploadRequest(state.uploadMap, name, body, file, state.shouldValidateInput, state.shouldValidateOutput, ctx);
1078
+ }
1079
+ };
1080
+ }
1081
+ /** Build all Router method implementations from shared state */
1082
+ function buildRouterMethods(state, procedures, opts) {
1083
+ return {
1084
+ hasPages: !!state.pages && Object.keys(state.pages).length > 0,
1085
+ ctxConfig: state.ctxConfig,
1086
+ hasContext() {
1087
+ return state.hasCtx;
916
1088
  },
1089
+ manifest() {
1090
+ return buildManifest(procedures, state.channelsMeta, state.ctxConfig, opts?.transportDefaults);
1091
+ },
1092
+ ...buildRpcMethods(state),
917
1093
  handleSubscription(name, input, rawCtx) {
918
- return handleSubscription(subscriptionMap, name, input, shouldValidateOutput, resolveCtxFor(subscriptionMap, name, rawCtx, extractKeys, ctxConfig));
1094
+ const ctx = resolveCtxFor(state.subscriptionMap, name, rawCtx, state.ctxConfig);
1095
+ return handleSubscription(state.subscriptionMap, name, input, state.shouldValidateInput, state.shouldValidateOutput, ctx);
919
1096
  },
920
1097
  handleStream(name, input, rawCtx) {
921
- return handleStream(streamMap, name, input, shouldValidateOutput, resolveCtxFor(streamMap, name, rawCtx, extractKeys, ctxConfig));
922
- },
923
- async handleUpload(name, body, file, rawCtx) {
924
- const { ctx, error } = resolveCtxSafe(uploadMap, name, rawCtx, extractKeys, ctxConfig);
925
- if (error) return error;
926
- return handleUploadRequest(uploadMap, name, body, file, shouldValidateOutput, ctx);
1098
+ const ctx = resolveCtxFor(state.streamMap, name, rawCtx, state.ctxConfig);
1099
+ return handleStream(state.streamMap, name, input, state.shouldValidateInput, state.shouldValidateOutput, ctx);
927
1100
  },
928
1101
  getKind(name) {
929
- return kindMap.get(name) ?? null;
1102
+ return state.kindMap.get(name) ?? null;
1103
+ },
1104
+ handlePage(path, headers, rawCtx) {
1105
+ return matchAndHandlePage(state.pageMatcher, state.procedureMap, state.i18nConfig, state.strategies, state.hasUrlPrefix, path, headers, rawCtx, state.ctxConfig, state.shouldValidateInput);
1106
+ }
1107
+ };
1108
+ }
1109
+
1110
+ //#endregion
1111
+ //#region src/router/index.ts
1112
+ function createRouter(procedures, opts) {
1113
+ const state = initRouterState(procedures, opts);
1114
+ return {
1115
+ procedures,
1116
+ rpcHashMap: opts?.rpcHashMap,
1117
+ ...buildRouterMethods(state, procedures, opts)
1118
+ };
1119
+ }
1120
+
1121
+ //#endregion
1122
+ //#region src/factory.ts
1123
+ function query(def) {
1124
+ return {
1125
+ ...def,
1126
+ kind: "query"
1127
+ };
1128
+ }
1129
+ function command(def) {
1130
+ return {
1131
+ ...def,
1132
+ kind: "command"
1133
+ };
1134
+ }
1135
+ function subscription(def) {
1136
+ return {
1137
+ ...def,
1138
+ kind: "subscription"
1139
+ };
1140
+ }
1141
+ function stream(def) {
1142
+ return {
1143
+ ...def,
1144
+ kind: "stream"
1145
+ };
1146
+ }
1147
+ function upload(def) {
1148
+ return {
1149
+ ...def,
1150
+ kind: "upload"
1151
+ };
1152
+ }
1153
+
1154
+ //#endregion
1155
+ //#region src/seam-router.ts
1156
+ function createSeamRouter(config) {
1157
+ const { context, ...restConfig } = config;
1158
+ const define = {
1159
+ query(def) {
1160
+ return {
1161
+ ...def,
1162
+ kind: "query"
1163
+ };
930
1164
  },
931
- handlePage(path, headers) {
932
- return matchAndHandlePage(pageMatcher, procedureMap, i18nConfig, strategies, hasUrlPrefix, path, headers);
1165
+ command(def) {
1166
+ return {
1167
+ ...def,
1168
+ kind: "command"
1169
+ };
1170
+ },
1171
+ subscription(def) {
1172
+ return {
1173
+ ...def,
1174
+ kind: "subscription"
1175
+ };
1176
+ },
1177
+ stream(def) {
1178
+ return {
1179
+ ...def,
1180
+ kind: "stream"
1181
+ };
1182
+ },
1183
+ upload(def) {
1184
+ return {
1185
+ ...def,
1186
+ kind: "upload"
1187
+ };
933
1188
  }
934
1189
  };
1190
+ function router(procedures, extraOpts) {
1191
+ return createRouter(procedures, {
1192
+ ...restConfig,
1193
+ context,
1194
+ ...extraOpts
1195
+ });
1196
+ }
1197
+ return {
1198
+ router,
1199
+ define
1200
+ };
935
1201
  }
936
1202
 
937
1203
  //#endregion
@@ -1102,13 +1368,77 @@ function sseErrorEvent(code, message, transient = false) {
1102
1368
  function sseCompleteEvent() {
1103
1369
  return "event: complete\ndata: {}\n\n";
1104
1370
  }
1371
+ function formatSseError(error) {
1372
+ if (error instanceof SeamError) return sseErrorEvent(error.code, error.message);
1373
+ return sseErrorEvent("INTERNAL_ERROR", error instanceof Error ? error.message : "Unknown error");
1374
+ }
1375
+ const DEFAULT_HEARTBEAT_MS$1 = 21e3;
1376
+ const DEFAULT_SSE_IDLE_MS = 3e4;
1377
+ async function* withSseLifecycle(inner, opts) {
1378
+ const heartbeatMs = opts?.heartbeatInterval ?? DEFAULT_HEARTBEAT_MS$1;
1379
+ const idleMs = opts?.sseIdleTimeout ?? DEFAULT_SSE_IDLE_MS;
1380
+ const idleEnabled = idleMs > 0;
1381
+ const queue = [];
1382
+ let resolve = null;
1383
+ const signal = () => {
1384
+ if (resolve) {
1385
+ resolve();
1386
+ resolve = null;
1387
+ }
1388
+ };
1389
+ let idleTimer = null;
1390
+ const resetIdle = () => {
1391
+ if (!idleEnabled) return;
1392
+ if (idleTimer) clearTimeout(idleTimer);
1393
+ idleTimer = setTimeout(() => {
1394
+ queue.push({ type: "idle" });
1395
+ signal();
1396
+ }, idleMs);
1397
+ };
1398
+ const heartbeatTimer = setInterval(() => {
1399
+ queue.push({ type: "heartbeat" });
1400
+ signal();
1401
+ }, heartbeatMs);
1402
+ resetIdle();
1403
+ (async () => {
1404
+ try {
1405
+ for await (const chunk of inner) {
1406
+ queue.push({
1407
+ type: "data",
1408
+ value: chunk
1409
+ });
1410
+ resetIdle();
1411
+ signal();
1412
+ }
1413
+ } catch {}
1414
+ queue.push({ type: "done" });
1415
+ signal();
1416
+ })();
1417
+ try {
1418
+ for (;;) {
1419
+ while (queue.length === 0) await new Promise((r) => {
1420
+ resolve = r;
1421
+ });
1422
+ const item = queue.shift();
1423
+ if (!item) continue;
1424
+ if (item.type === "data") yield item.value;
1425
+ else if (item.type === "heartbeat") yield ": heartbeat\n\n";
1426
+ else if (item.type === "idle") {
1427
+ yield sseCompleteEvent();
1428
+ return;
1429
+ } else return;
1430
+ }
1431
+ } finally {
1432
+ clearInterval(heartbeatTimer);
1433
+ if (idleTimer) clearTimeout(idleTimer);
1434
+ }
1435
+ }
1105
1436
  async function* sseStream(router, name, input, rawCtx) {
1106
1437
  try {
1107
1438
  for await (const value of router.handleSubscription(name, input, rawCtx)) yield sseDataEvent(value);
1108
1439
  yield sseCompleteEvent();
1109
1440
  } 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");
1441
+ yield formatSseError(error);
1112
1442
  }
1113
1443
  }
1114
1444
  async function* sseStreamForStream(router, name, input, signal, rawCtx) {
@@ -1121,8 +1451,7 @@ async function* sseStreamForStream(router, name, input, signal, rawCtx) {
1121
1451
  for await (const value of gen) yield sseDataEventWithId(value, seq++);
1122
1452
  yield sseCompleteEvent();
1123
1453
  } 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");
1454
+ yield formatSseError(error);
1126
1455
  }
1127
1456
  }
1128
1457
  async function handleBatchHttp(req, router, hashToName, rawCtx) {
@@ -1142,22 +1471,49 @@ async function handleBatchHttp(req, router, hashToName, rawCtx) {
1142
1471
  data: await router.handleBatch(calls, rawCtx)
1143
1472
  });
1144
1473
  }
1145
- /** Resolve hash -> original name when obfuscation is active. Returns null on miss. */
1474
+ /** Resolve hash -> original name when obfuscation is active. Accepts both hashed and raw names. */
1146
1475
  function resolveHashName(hashToName, name) {
1147
1476
  if (!hashToName) return name;
1148
- return hashToName.get(name) ?? null;
1477
+ return hashToName.get(name) ?? name;
1478
+ }
1479
+ async function handleProcedurePost(req, router, name, rawCtx, sseOptions) {
1480
+ let body;
1481
+ try {
1482
+ body = await req.body();
1483
+ } catch {
1484
+ return errorResponse(400, "VALIDATION_ERROR", "Invalid JSON body");
1485
+ }
1486
+ if (router.getKind(name) === "stream") {
1487
+ const controller = new AbortController();
1488
+ return {
1489
+ status: 200,
1490
+ headers: SSE_HEADER,
1491
+ stream: withSseLifecycle(sseStreamForStream(router, name, body, controller.signal, rawCtx), sseOptions),
1492
+ onCancel: () => controller.abort()
1493
+ };
1494
+ }
1495
+ if (router.getKind(name) === "upload") {
1496
+ if (!req.file) return errorResponse(400, "VALIDATION_ERROR", "Upload requires multipart/form-data");
1497
+ const file = await req.file();
1498
+ if (!file) return errorResponse(400, "VALIDATION_ERROR", "Upload requires file in multipart body");
1499
+ const result = await router.handleUpload(name, body, file, rawCtx);
1500
+ return jsonResponse(result.status, result.body);
1501
+ }
1502
+ const result = await router.handle(name, body, rawCtx);
1503
+ return jsonResponse(result.status, result.body);
1149
1504
  }
1150
1505
  function createHttpHandler(router, opts) {
1151
- const hashToName = opts?.rpcHashMap ? new Map(Object.entries(opts.rpcHashMap.procedures).map(([n, h]) => [h, n])) : null;
1506
+ const effectiveHashMap = opts?.rpcHashMap ?? router.rpcHashMap;
1507
+ const hashToName = effectiveHashMap ? new Map(Object.entries(effectiveHashMap.procedures).map(([n, h]) => [h, n])) : null;
1152
1508
  if (hashToName) hashToName.set("__seam_i18n_query", "__seam_i18n_query");
1153
- const batchHash = opts?.rpcHashMap?.batch ?? null;
1154
- const ctxExtractKeys = router.contextExtractKeys();
1509
+ const batchHash = effectiveHashMap?.batch ?? null;
1510
+ const hasCtx = router.hasContext();
1155
1511
  return async (req) => {
1156
1512
  const url = new URL(req.url, "http://localhost");
1157
1513
  const { pathname } = url;
1158
- const rawCtx = ctxExtractKeys.length > 0 && req.header ? Object.fromEntries(ctxExtractKeys.map((k) => [k, req.header?.(k) ?? null])) : void 0;
1514
+ const rawCtx = hasCtx ? buildRawContext(router.ctxConfig, req.header, url) : void 0;
1159
1515
  if (req.method === "GET" && pathname === MANIFEST_PATH) {
1160
- if (opts?.rpcHashMap) return errorResponse(403, "FORBIDDEN", "Manifest disabled");
1516
+ if (effectiveHashMap) return errorResponse(403, "FORBIDDEN", "Manifest disabled");
1161
1517
  return jsonResponse(200, router.manifest());
1162
1518
  }
1163
1519
  if (pathname.startsWith(PROCEDURE_PREFIX)) {
@@ -1165,36 +1521,10 @@ function createHttpHandler(router, opts) {
1165
1521
  if (!rawName) return errorResponse(404, "NOT_FOUND", "Empty procedure name");
1166
1522
  if (req.method === "POST") {
1167
1523
  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);
1524
+ return handleProcedurePost(req, router, resolveHashName(hashToName, rawName), rawCtx, opts?.sseOptions);
1194
1525
  }
1195
1526
  if (req.method === "GET") {
1196
1527
  const name = resolveHashName(hashToName, rawName);
1197
- if (!name) return errorResponse(404, "NOT_FOUND", "Not found");
1198
1528
  const rawInput = url.searchParams.get("input");
1199
1529
  let input;
1200
1530
  try {
@@ -1205,7 +1535,7 @@ function createHttpHandler(router, opts) {
1205
1535
  return {
1206
1536
  status: 200,
1207
1537
  headers: SSE_HEADER,
1208
- stream: sseStream(router, name, input, rawCtx)
1538
+ stream: withSseLifecycle(sseStream(router, name, input, rawCtx), opts?.sseOptions)
1209
1539
  };
1210
1540
  }
1211
1541
  }
@@ -1216,7 +1546,7 @@ function createHttpHandler(router, opts) {
1216
1546
  cookie: req.header("cookie") ?? void 0,
1217
1547
  acceptLanguage: req.header("accept-language") ?? void 0
1218
1548
  } : void 0;
1219
- const result = await router.handlePage(pagePath, headers);
1549
+ const result = await router.handlePage(pagePath, headers, rawCtx);
1220
1550
  if (result) return {
1221
1551
  status: result.status,
1222
1552
  headers: HTML_HEADER,
@@ -1267,10 +1597,14 @@ function toWebResponse(result) {
1267
1597
 
1268
1598
  //#endregion
1269
1599
  //#region src/page/build-loader.ts
1600
+ function normalizeParamConfig(value) {
1601
+ return typeof value === "string" ? { from: value } : value;
1602
+ }
1270
1603
  function buildLoaderFn(config) {
1271
1604
  return (params, searchParams) => {
1272
1605
  const input = {};
1273
- if (config.params) for (const [key, mapping] of Object.entries(config.params)) {
1606
+ if (config.params) for (const [key, raw_mapping] of Object.entries(config.params)) {
1607
+ const mapping = normalizeParamConfig(raw_mapping);
1274
1608
  const raw = mapping.from === "query" ? searchParams?.get(key) ?? void 0 : params[key];
1275
1609
  if (raw !== void 0) input[key] = mapping.type === "int" ? Number(raw) : raw;
1276
1610
  }
@@ -1348,6 +1682,22 @@ function mergeI18nKeys(route, layoutEntries) {
1348
1682
  if (route.i18n_keys) keys.push(...route.i18n_keys);
1349
1683
  return keys.length > 0 ? keys : void 0;
1350
1684
  }
1685
+ /** Load all build artifacts (pages, rpcHashMap, i18n) in one call */
1686
+ function loadBuild(distDir) {
1687
+ return {
1688
+ pages: loadBuildOutput(distDir),
1689
+ rpcHashMap: loadRpcHashMap(distDir),
1690
+ i18n: loadI18nMessages(distDir)
1691
+ };
1692
+ }
1693
+ /** Load all build artifacts with lazy template getters (for dev mode) */
1694
+ function loadBuildDev(distDir) {
1695
+ return {
1696
+ pages: loadBuildOutputDev(distDir),
1697
+ rpcHashMap: loadRpcHashMap(distDir),
1698
+ i18n: loadI18nMessages(distDir)
1699
+ };
1700
+ }
1351
1701
  /** Load the RPC hash map from build output (returns undefined when obfuscation is off) */
1352
1702
  function loadRpcHashMap(distDir) {
1353
1703
  const hashMapPath = join(distDir, "rpc-hash-map.json");
@@ -1529,7 +1879,8 @@ function fromCallback(setup) {
1529
1879
 
1530
1880
  //#endregion
1531
1881
  //#region src/ws.ts
1532
- const DEFAULT_HEARTBEAT_MS = 3e4;
1882
+ const DEFAULT_HEARTBEAT_MS = 21e3;
1883
+ const DEFAULT_PONG_TIMEOUT_MS = 5e3;
1533
1884
  function sendError(ws, id, code, message) {
1534
1885
  ws.send(JSON.stringify({
1535
1886
  id,
@@ -1541,6 +1892,60 @@ function sendError(ws, id, code, message) {
1541
1892
  }
1542
1893
  }));
1543
1894
  }
1895
+ /** Validate uplink message fields and channel scope */
1896
+ function parseUplink(ws, data, channelName) {
1897
+ let msg;
1898
+ try {
1899
+ msg = JSON.parse(data);
1900
+ } catch {
1901
+ sendError(ws, null, "VALIDATION_ERROR", "Invalid JSON");
1902
+ return null;
1903
+ }
1904
+ if (!msg.id || typeof msg.id !== "string") {
1905
+ sendError(ws, null, "VALIDATION_ERROR", "Missing 'id' field");
1906
+ return null;
1907
+ }
1908
+ if (!msg.procedure || typeof msg.procedure !== "string") {
1909
+ sendError(ws, msg.id, "VALIDATION_ERROR", "Missing 'procedure' field");
1910
+ return null;
1911
+ }
1912
+ const prefix = channelName + ".";
1913
+ if (!msg.procedure.startsWith(prefix) || msg.procedure === `${channelName}.events`) {
1914
+ sendError(ws, msg.id, "VALIDATION_ERROR", `Procedure '${msg.procedure}' is not a command of channel '${channelName}'`);
1915
+ return null;
1916
+ }
1917
+ return msg;
1918
+ }
1919
+ /** Dispatch validated uplink command through the router */
1920
+ function dispatchUplink(router, ws, msg, channelInput) {
1921
+ const mergedInput = {
1922
+ ...channelInput,
1923
+ ...msg.input ?? {}
1924
+ };
1925
+ (async () => {
1926
+ try {
1927
+ const result = await router.handle(msg.procedure, mergedInput);
1928
+ if (result.status === 200) {
1929
+ const envelope = result.body;
1930
+ ws.send(JSON.stringify({
1931
+ id: msg.id,
1932
+ ok: true,
1933
+ data: envelope.data
1934
+ }));
1935
+ } else {
1936
+ const envelope = result.body;
1937
+ ws.send(JSON.stringify({
1938
+ id: msg.id,
1939
+ ok: false,
1940
+ error: envelope.error
1941
+ }));
1942
+ }
1943
+ } catch (err) {
1944
+ const message = err instanceof Error ? err.message : "Unknown error";
1945
+ sendError(ws, msg.id, "INTERNAL_ERROR", message);
1946
+ }
1947
+ })();
1948
+ }
1544
1949
  /**
1545
1950
  * Start a WebSocket session for a channel.
1546
1951
  *
@@ -1549,9 +1954,24 @@ function sendError(ws, id, code, message) {
1549
1954
  */
1550
1955
  function startChannelWs(router, channelName, channelInput, ws, opts) {
1551
1956
  const heartbeatMs = opts?.heartbeatInterval ?? DEFAULT_HEARTBEAT_MS;
1957
+ const pongTimeoutMs = opts?.pongTimeout ?? DEFAULT_PONG_TIMEOUT_MS;
1552
1958
  let closed = false;
1959
+ let pongTimer = null;
1553
1960
  const heartbeatTimer = setInterval(() => {
1554
- if (!closed) ws.send(JSON.stringify({ heartbeat: true }));
1961
+ if (closed) return;
1962
+ ws.send(JSON.stringify({ heartbeat: true }));
1963
+ if (ws.ping) {
1964
+ ws.ping();
1965
+ if (pongTimer) clearTimeout(pongTimer);
1966
+ pongTimer = setTimeout(() => {
1967
+ if (!closed) {
1968
+ closed = true;
1969
+ clearInterval(heartbeatTimer);
1970
+ iter.return?.(void 0);
1971
+ ws.close?.();
1972
+ }
1973
+ }, pongTimeoutMs);
1974
+ }
1555
1975
  }, heartbeatMs);
1556
1976
  const iter = router.handleSubscription(`${channelName}.events`, channelInput)[Symbol.asyncIterator]();
1557
1977
  (async () => {
@@ -1582,58 +2002,20 @@ function startChannelWs(router, channelName, channelInput, ws, opts) {
1582
2002
  return {
1583
2003
  onMessage(data) {
1584
2004
  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;
2005
+ const msg = parseUplink(ws, data, channelName);
2006
+ if (msg) dispatchUplink(router, ws, msg, channelInput);
2007
+ },
2008
+ onPong() {
2009
+ if (pongTimer) {
2010
+ clearTimeout(pongTimer);
2011
+ pongTimer = null;
1604
2012
  }
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
2013
  },
1633
2014
  close() {
1634
2015
  if (closed) return;
1635
2016
  closed = true;
1636
2017
  clearInterval(heartbeatTimer);
2018
+ if (pongTimer) clearTimeout(pongTimer);
1637
2019
  iter.return?.(void 0);
1638
2020
  }
1639
2021
  };
@@ -1759,5 +2141,5 @@ function watchReloadTrigger(distDir, onReload) {
1759
2141
  }
1760
2142
 
1761
2143
  //#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 };
2144
+ 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
2145
  //# sourceMappingURL=index.js.map