@canmi/seam-server 0.4.18 → 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
 
@@ -141,24 +141,42 @@ const t = {
141
141
  };
142
142
 
143
143
  //#endregion
144
- //#region src/manifest/index.ts
145
- function buildManifest(definitions, channels) {
146
- const mapped = {};
147
- for (const [name, def] of Object.entries(definitions)) {
148
- const entry = {
149
- type: def.type === "subscription" ? "subscription" : def.type === "command" ? "command" : "query",
150
- input: def.input._schema,
151
- output: def.output._schema
152
- };
153
- if (def.error) entry.error = def.error._schema;
154
- mapped[name] = entry;
155
- }
156
- const manifest = {
157
- version: 1,
158
- procedures: mapped
144
+ //#region src/validation/index.ts
145
+ function validateInput(schema, data) {
146
+ const errors = validate(schema, data, {
147
+ maxDepth: 32,
148
+ maxErrors: 10
149
+ });
150
+ return {
151
+ valid: errors.length === 0,
152
+ errors
159
153
  };
160
- if (channels && Object.keys(channels).length > 0) manifest.channels = channels;
161
- return manifest;
154
+ }
155
+ function formatValidationErrors(errors) {
156
+ return errors.map((e) => {
157
+ return `${e.instancePath.length > 0 ? e.instancePath.join("/") : "(root)"} (schema: ${e.schemaPath.join("/")})`;
158
+ }).join("; ");
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
+ });
162
180
  }
163
181
 
164
182
  //#endregion
@@ -174,57 +192,248 @@ const DEFAULT_STATUS = {
174
192
  var SeamError = class extends Error {
175
193
  code;
176
194
  status;
177
- constructor(code, message, status) {
195
+ details;
196
+ constructor(code, message, status, details) {
178
197
  super(message);
179
198
  this.code = code;
180
199
  this.status = status ?? DEFAULT_STATUS[code] ?? 500;
200
+ this.details = details;
181
201
  this.name = "SeamError";
182
202
  }
183
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;
184
210
  return {
185
211
  ok: false,
186
- error: {
187
- code: this.code,
188
- message: this.message,
189
- transient: false
190
- }
212
+ error
191
213
  };
192
214
  }
193
215
  };
194
216
 
195
217
  //#endregion
196
- //#region src/validation/index.ts
197
- function validateInput(schema, data) {
198
- const errors = validate(schema, data, {
199
- maxDepth: 32,
200
- maxErrors: 10
201
- });
218
+ //#region src/context.ts
219
+ /** Parse extract rule into source type and key, e.g. "header:authorization" -> { source: "header", key: "authorization" } */
220
+ function parseExtractRule(rule) {
221
+ const idx = rule.indexOf(":");
222
+ if (idx === -1) throw new Error(`Invalid extract rule "${rule}": expected "source:key" format`);
223
+ const source = rule.slice(0, idx);
224
+ const key = rule.slice(idx + 1);
225
+ if (!source || !key) throw new Error(`Invalid extract rule "${rule}": source and key must be non-empty`);
202
226
  return {
203
- valid: errors.length === 0,
204
- errors
227
+ source,
228
+ key
205
229
  };
206
230
  }
207
- function formatValidationErrors(errors) {
208
- return errors.map((e) => {
209
- return `${e.instancePath.length > 0 ? e.instancePath.join("/") : "(root)"} (schema: ${e.schemaPath.join("/")})`;
210
- }).join("; ");
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
+ }
263
+ }
264
+ return raw;
265
+ }
266
+ /**
267
+ * Resolve raw strings into validated context object.
268
+ *
269
+ * For each requested key:
270
+ * - If raw value is null/missing -> pass null to JTD; schema decides via nullable()
271
+ * - If schema expects string -> use raw value directly
272
+ * - If schema expects object -> JSON.parse then validate
273
+ */
274
+ function resolveContext(config, raw, requestedKeys) {
275
+ const result = {};
276
+ for (const key of requestedKeys) {
277
+ const field = config[key];
278
+ if (!field) throw new SeamError("CONTEXT_ERROR", `Context field "${key}" is not defined in router context config`, 400);
279
+ const rawValue = raw[key] ?? null;
280
+ let value;
281
+ if (rawValue === null) value = null;
282
+ else {
283
+ const schema = field.schema._schema;
284
+ const isStringSchema = "type" in schema && schema.type === "string" && !("nullable" in schema && schema.nullable);
285
+ const isNullableStringSchema = "type" in schema && schema.type === "string" && "nullable" in schema && schema.nullable;
286
+ if (isStringSchema || isNullableStringSchema) value = rawValue;
287
+ else try {
288
+ value = JSON.parse(rawValue);
289
+ } catch {
290
+ throw new SeamError("CONTEXT_ERROR", `Context field "${key}": failed to parse value as JSON`, 400);
291
+ }
292
+ }
293
+ const validation = validateInput(field.schema._schema, value);
294
+ if (!validation.valid) throw new SeamError("CONTEXT_ERROR", `Context field "${key}" validation failed: ${formatValidationErrors(validation.errors)}`, 400);
295
+ result[key] = value;
296
+ }
297
+ return result;
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
+ };
305
+
306
+ //#endregion
307
+ //#region src/manifest/index.ts
308
+ function normalizeInvalidates(targets) {
309
+ return targets.map((t) => {
310
+ if (typeof t === "string") return { query: t };
311
+ const normalized = { query: t.query };
312
+ if (t.mapping) normalized.mapping = Object.fromEntries(Object.entries(t.mapping).map(([k, v]) => [k, typeof v === "string" ? { from: v } : v]));
313
+ return normalized;
314
+ });
315
+ }
316
+ function buildManifest(definitions, channels, contextConfig, transportDefaults) {
317
+ const mapped = {};
318
+ for (const [name, def] of Object.entries(definitions)) {
319
+ const k = def.kind ?? def.type;
320
+ const kind = k === "upload" ? "upload" : k === "stream" ? "stream" : k === "subscription" ? "subscription" : k === "command" ? "command" : "query";
321
+ const entry = {
322
+ kind,
323
+ input: def.input._schema
324
+ };
325
+ if (kind === "stream") entry.chunkOutput = def.output._schema;
326
+ else entry.output = def.output._schema;
327
+ if (def.error) entry.error = def.error._schema;
328
+ if (kind === "command" && def.invalidates && def.invalidates.length > 0) entry.invalidates = normalizeInvalidates(def.invalidates);
329
+ if (def.context && def.context.length > 0) entry.context = def.context;
330
+ const defAny = def;
331
+ if (defAny.transport) entry.transport = defAny.transport;
332
+ if (defAny.suppress) entry.suppress = defAny.suppress;
333
+ if (defAny.cache !== void 0) entry.cache = defAny.cache;
334
+ mapped[name] = entry;
335
+ }
336
+ const context = {};
337
+ if (contextConfig) for (const [key, field] of Object.entries(contextConfig)) context[key] = {
338
+ extract: field.extract,
339
+ schema: field.schema._schema
340
+ };
341
+ const manifest = {
342
+ version: 2,
343
+ context,
344
+ procedures: mapped,
345
+ transportDefaults: transportDefaults ?? {}
346
+ };
347
+ if (channels && Object.keys(channels).length > 0) manifest.channels = channels;
348
+ return manifest;
349
+ }
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
+ }) };
211
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
+ };
212
413
 
213
414
  //#endregion
214
415
  //#region src/router/handler.ts
215
- async function handleRequest(procedures, procedureName, rawBody, validateOutput) {
416
+ async function handleRequest(procedures, procedureName, rawBody, shouldValidateInput = true, validateOutput, ctx) {
216
417
  const procedure = procedures.get(procedureName);
217
418
  if (!procedure) return {
218
419
  status: 404,
219
420
  body: new SeamError("NOT_FOUND", `Procedure '${procedureName}' not found`).toJSON()
220
421
  };
221
- const validation = validateInput(procedure.inputSchema, rawBody);
222
- if (!validation.valid) return {
223
- status: 400,
224
- body: new SeamError("VALIDATION_ERROR", `Input validation failed: ${formatValidationErrors(validation.errors)}`).toJSON()
225
- };
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
+ }
226
432
  try {
227
- const result = await procedure.handler({ input: rawBody });
433
+ const result = await procedure.handler({
434
+ input: rawBody,
435
+ ctx: ctx ?? {}
436
+ });
228
437
  if (validateOutput) {
229
438
  const outValidation = validateInput(procedure.outputSchema, result);
230
439
  if (!outValidation.valid) return {
@@ -250,9 +459,10 @@ async function handleRequest(procedures, procedureName, rawBody, validateOutput)
250
459
  };
251
460
  }
252
461
  }
253
- async function handleBatchRequest(procedures, calls, validateOutput) {
462
+ async function handleBatchRequest(procedures, calls, shouldValidateInput = true, validateOutput, ctxResolver) {
254
463
  return { results: await Promise.all(calls.map(async (call) => {
255
- const result = await handleRequest(procedures, call.procedure, call.input, validateOutput);
464
+ const ctx = ctxResolver ? ctxResolver(call.procedure) : void 0;
465
+ const result = await handleRequest(procedures, call.procedure, call.input, shouldValidateInput, validateOutput, ctx);
256
466
  if (result.status === 200) return {
257
467
  ok: true,
258
468
  data: result.body.data
@@ -263,12 +473,20 @@ async function handleBatchRequest(procedures, calls, validateOutput) {
263
473
  };
264
474
  })) };
265
475
  }
266
- async function* handleSubscription(subscriptions, name, rawInput, validateOutput) {
476
+ async function* handleSubscription(subscriptions, name, rawInput, shouldValidateInput = true, validateOutput, ctx) {
267
477
  const sub = subscriptions.get(name);
268
478
  if (!sub) throw new SeamError("NOT_FOUND", `Subscription '${name}' not found`);
269
- const validation = validateInput(sub.inputSchema, rawInput);
270
- if (!validation.valid) throw new SeamError("VALIDATION_ERROR", `Input validation failed: ${formatValidationErrors(validation.errors)}`);
271
- for await (const value of sub.handler({ input: rawInput })) {
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
+ }
486
+ for await (const value of sub.handler({
487
+ input: rawInput,
488
+ ctx: ctx ?? {}
489
+ })) {
272
490
  if (validateOutput) {
273
491
  const outValidation = validateInput(sub.outputSchema, value);
274
492
  if (!outValidation.valid) throw new SeamError("INTERNAL_ERROR", `Output validation failed: ${formatValidationErrors(outValidation.errors)}`);
@@ -276,19 +494,259 @@ async function* handleSubscription(subscriptions, name, rawInput, validateOutput
276
494
  yield value;
277
495
  }
278
496
  }
497
+ async function handleUploadRequest(uploads, procedureName, rawBody, file, shouldValidateInput = true, validateOutput, ctx) {
498
+ const upload = uploads.get(procedureName);
499
+ if (!upload) return {
500
+ status: 404,
501
+ body: new SeamError("NOT_FOUND", `Procedure '${procedureName}' not found`).toJSON()
502
+ };
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
+ }
513
+ try {
514
+ const result = await upload.handler({
515
+ input: rawBody,
516
+ file,
517
+ ctx: ctx ?? {}
518
+ });
519
+ if (validateOutput) {
520
+ const outValidation = validateInput(upload.outputSchema, result);
521
+ if (!outValidation.valid) return {
522
+ status: 500,
523
+ body: new SeamError("INTERNAL_ERROR", `Output validation failed: ${formatValidationErrors(outValidation.errors)}`).toJSON()
524
+ };
525
+ }
526
+ return {
527
+ status: 200,
528
+ body: {
529
+ ok: true,
530
+ data: result
531
+ }
532
+ };
533
+ } catch (error) {
534
+ if (error instanceof SeamError) return {
535
+ status: error.status,
536
+ body: error.toJSON()
537
+ };
538
+ return {
539
+ status: 500,
540
+ body: new SeamError("INTERNAL_ERROR", error instanceof Error ? error.message : "Unknown error").toJSON()
541
+ };
542
+ }
543
+ }
544
+ async function* handleStream(streams, name, rawInput, shouldValidateInput = true, validateOutput, ctx) {
545
+ const stream = streams.get(name);
546
+ if (!stream) throw new SeamError("NOT_FOUND", `Stream '${name}' not found`);
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
+ }
554
+ for await (const value of stream.handler({
555
+ input: rawInput,
556
+ ctx: ctx ?? {}
557
+ })) {
558
+ if (validateOutput) {
559
+ const outValidation = validateInput(stream.chunkOutputSchema, value);
560
+ if (!outValidation.valid) throw new SeamError("INTERNAL_ERROR", `Output validation failed: ${formatValidationErrors(outValidation.errors)}`);
561
+ }
562
+ yield value;
563
+ }
564
+ }
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
+
630
+ //#endregion
631
+ //#region src/page/projection.ts
632
+ /** Set a nested field by dot-separated path, creating intermediate objects as needed. */
633
+ function setNestedField(target, path, value) {
634
+ const parts = path.split(".");
635
+ let current = target;
636
+ for (let i = 0; i < parts.length - 1; i++) {
637
+ const key = parts[i];
638
+ if (!(key in current) || typeof current[key] !== "object" || current[key] === null) current[key] = {};
639
+ current = current[key];
640
+ }
641
+ current[parts[parts.length - 1]] = value;
642
+ }
643
+ /** Get a nested field by dot-separated path. */
644
+ function getNestedField(source, path) {
645
+ const parts = path.split(".");
646
+ let current = source;
647
+ for (const part of parts) {
648
+ if (current === null || current === void 0 || typeof current !== "object") return;
649
+ current = current[part];
650
+ }
651
+ return current;
652
+ }
653
+ /** Prune a single value according to its projected field paths. */
654
+ function pruneValue(value, fields) {
655
+ const arrayFields = [];
656
+ const plainFields = [];
657
+ for (const f of fields) if (f === "$") return value;
658
+ else if (f.startsWith("$.")) arrayFields.push(f.slice(2));
659
+ else plainFields.push(f);
660
+ if (arrayFields.length > 0 && Array.isArray(value)) return value.map((item) => {
661
+ if (typeof item !== "object" || item === null) return item;
662
+ const pruned = {};
663
+ for (const field of arrayFields) {
664
+ const val = getNestedField(item, field);
665
+ if (val !== void 0) setNestedField(pruned, field, val);
666
+ }
667
+ return pruned;
668
+ });
669
+ if (plainFields.length > 0 && typeof value === "object" && value !== null) {
670
+ const source = value;
671
+ const pruned = {};
672
+ for (const field of plainFields) {
673
+ const val = getNestedField(source, field);
674
+ if (val !== void 0) setNestedField(pruned, field, val);
675
+ }
676
+ return pruned;
677
+ }
678
+ return value;
679
+ }
680
+ /** Prune data to only include projected fields. Missing projection = keep all. */
681
+ function applyProjection(data, projections) {
682
+ if (!projections) return data;
683
+ const result = {};
684
+ for (const [key, value] of Object.entries(data)) {
685
+ if (isLoaderError(value)) {
686
+ result[key] = value;
687
+ continue;
688
+ }
689
+ const fields = projections[key];
690
+ if (!fields) result[key] = value;
691
+ else result[key] = pruneValue(value, fields);
692
+ }
693
+ return result;
694
+ }
279
695
 
280
696
  //#endregion
281
697
  //#region src/page/handler.ts
282
- /** Execute loaders, returning keyed results */
283
- async function executeLoaders(loaders, params, procedures) {
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) {
284
702
  const entries = Object.entries(loaders);
285
703
  const results = await Promise.all(entries.map(async ([key, loader]) => {
286
- const { procedure, input } = loader(params);
287
- const proc = procedures.get(procedure);
288
- if (!proc) throw new SeamError("INTERNAL_ERROR", `Procedure '${procedure}' not found`);
289
- return [key, await proc.handler({ input })];
704
+ const { procedure, input } = loader(params, searchParams);
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
+ }
290
738
  }));
291
- 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
+ };
292
750
  }
293
751
  /** Select the template for a given locale, falling back to the default template */
294
752
  function selectTemplate(defaultTemplate, localeTemplates, locale) {
@@ -306,15 +764,20 @@ function lookupMessages(config, routePattern, locale) {
306
764
  }
307
765
  return config.messages[locale]?.[routeHash] ?? {};
308
766
  }
309
- async function handlePageRequest(page, params, procedures, i18nOpts) {
767
+ async function handlePageRequest(page, params, procedures, i18nOpts, searchParams, ctxResolver, shouldValidateInput) {
310
768
  try {
311
769
  const t0 = performance.now();
312
770
  const layoutChain = page.layoutChain ?? [];
313
771
  const locale = i18nOpts?.locale;
314
- const loaderResults = await Promise.all([...layoutChain.map((layout) => executeLoaders(layout.loaders, params, procedures)), executeLoaders(page.loaders, params, procedures)]);
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)]);
315
773
  const t1 = performance.now();
316
774
  const allData = {};
317
- 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
+ }
780
+ const prunedData = applyProjection(allData, page.projections);
318
781
  let composedTemplate = selectTemplate(page.template, page.localeTemplates, locale);
319
782
  for (let i = layoutChain.length - 1; i >= 0; i--) {
320
783
  const layout = layoutChain[i];
@@ -322,109 +785,47 @@ async function handlePageRequest(page, params, procedures, i18nOpts) {
322
785
  }
323
786
  const config = {
324
787
  layout_chain: layoutChain.map((l) => ({
325
- id: l.id,
326
- loader_keys: Object.keys(l.loaders)
327
- })),
328
- data_id: page.dataId ?? "__data",
329
- head_meta: page.headMeta
330
- };
331
- if (page.pageAssets) config.page_assets = page.pageAssets;
332
- let i18nOptsJson;
333
- if (i18nOpts) {
334
- const { config: i18nConfig, routePattern } = i18nOpts;
335
- const messages = lookupMessages(i18nConfig, routePattern, i18nOpts.locale);
336
- const routeHash = i18nConfig.routeHashes[routePattern];
337
- const i18nData = {
338
- locale: i18nOpts.locale,
339
- default_locale: i18nConfig.default,
340
- messages
341
- };
342
- if (i18nConfig.cache && routeHash) {
343
- i18nData.hash = i18nConfig.contentHashes[routeHash]?.[i18nOpts.locale];
344
- i18nData.router = i18nConfig.contentHashes;
345
- }
346
- i18nOptsJson = JSON.stringify(i18nData);
347
- }
348
- const html = renderPage(composedTemplate, JSON.stringify(allData), JSON.stringify(config), i18nOptsJson);
349
- const t2 = performance.now();
350
- return {
351
- status: 200,
352
- html,
353
- timing: {
354
- dataFetch: t1 - t0,
355
- inject: t2 - t1
356
- }
357
- };
358
- } catch (error) {
359
- return {
360
- status: 500,
361
- html: `<!DOCTYPE html><html><body><h1>500 Internal Server Error</h1><p>${escapeHtml(error instanceof Error ? error.message : "Unknown error")}</p></body></html>`
362
- };
363
- }
364
- }
365
-
366
- //#endregion
367
- //#region src/page/route-matcher.ts
368
- function compileRoute(pattern) {
369
- return { segments: pattern.split("/").filter(Boolean).map((seg) => {
370
- if (seg.startsWith("*")) {
371
- const optional = seg.endsWith("?");
372
- return {
373
- kind: "catch-all",
374
- name: optional ? seg.slice(1, -1) : seg.slice(1),
375
- optional
376
- };
377
- }
378
- if (seg.startsWith(":")) return {
379
- kind: "param",
380
- name: seg.slice(1)
381
- };
382
- return {
383
- kind: "static",
384
- value: seg
385
- };
386
- }) };
387
- }
388
- function matchRoute(segments, pathParts) {
389
- const params = {};
390
- for (let i = 0; i < segments.length; i++) {
391
- const seg = segments[i];
392
- if (seg.kind === "catch-all") {
393
- const rest = pathParts.slice(i);
394
- if (rest.length === 0 && !seg.optional) return null;
395
- params[seg.name] = rest.join("/");
396
- return params;
397
- }
398
- if (i >= pathParts.length) return null;
399
- if (seg.kind === "static") {
400
- if (seg.value !== pathParts[i]) return null;
401
- } else params[seg.name] = pathParts[i];
402
- }
403
- if (segments.length !== pathParts.length) return null;
404
- return params;
405
- }
406
- var RouteMatcher = class {
407
- routes = [];
408
- add(pattern, value) {
409
- this.routes.push({
410
- pattern,
411
- compiled: compileRoute(pattern),
412
- value
413
- });
414
- }
415
- match(path) {
416
- const parts = path.split("/").filter(Boolean);
417
- for (const route of this.routes) {
418
- const params = matchRoute(route.compiled.segments, parts);
419
- if (params) return {
420
- value: route.value,
421
- params,
422
- pattern: route.pattern
788
+ id: l.id,
789
+ loader_keys: Object.keys(l.loaders)
790
+ })),
791
+ data_id: page.dataId ?? "__data",
792
+ head_meta: page.headMeta,
793
+ loader_metadata: allMeta
794
+ };
795
+ if (page.pageAssets) config.page_assets = page.pageAssets;
796
+ let i18nOptsJson;
797
+ if (i18nOpts) {
798
+ const { config: i18nConfig, routePattern } = i18nOpts;
799
+ const messages = lookupMessages(i18nConfig, routePattern, i18nOpts.locale);
800
+ const routeHash = i18nConfig.routeHashes[routePattern];
801
+ const i18nData = {
802
+ locale: i18nOpts.locale,
803
+ default_locale: i18nConfig.default,
804
+ messages
423
805
  };
806
+ if (i18nConfig.cache && routeHash) {
807
+ i18nData.hash = i18nConfig.contentHashes[routeHash]?.[i18nOpts.locale];
808
+ i18nData.router = i18nConfig.contentHashes;
809
+ }
810
+ i18nOptsJson = JSON.stringify(i18nData);
424
811
  }
425
- return null;
812
+ const html = renderPage(composedTemplate, JSON.stringify(prunedData), JSON.stringify(config), i18nOptsJson);
813
+ const t2 = performance.now();
814
+ return {
815
+ status: 200,
816
+ html,
817
+ timing: {
818
+ dataFetch: t1 - t0,
819
+ inject: t2 - t1
820
+ }
821
+ };
822
+ } catch (error) {
823
+ return {
824
+ status: 500,
825
+ html: `<!DOCTYPE html><html><body><h1>500 Internal Server Error</h1><p>${escapeHtml(error instanceof Error ? error.message : "Unknown error")}</p></body></html>`
826
+ };
426
827
  }
427
- };
828
+ }
428
829
 
429
830
  //#endregion
430
831
  //#region src/resolve.ts
@@ -517,9 +918,13 @@ function defaultStrategies() {
517
918
  }
518
919
 
519
920
  //#endregion
520
- //#region src/router/index.ts
521
- function isSubscriptionDef(def) {
522
- return "type" in def && def.type === "subscription";
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";
523
928
  }
524
929
  /** Build the resolve strategy list from options */
525
930
  function buildStrategies(opts) {
@@ -534,6 +939,7 @@ function registerI18nQuery(procedureMap, config) {
534
939
  procedureMap.set("__seam_i18n_query", {
535
940
  inputSchema: {},
536
941
  outputSchema: {},
942
+ contextKeys: [],
537
943
  handler: ({ input }) => {
538
944
  const { route, locale } = input;
539
945
  const messages = lookupI18nMessages(config, route, locale);
@@ -553,19 +959,81 @@ function lookupI18nMessages(config, routeHash, locale) {
553
959
  }
554
960
  return config.messages[locale]?.[routeHash] ?? {};
555
961
  }
556
- function createRouter(procedures, opts) {
557
- const procedureMap = /* @__PURE__ */ new Map();
558
- const subscriptionMap = /* @__PURE__ */ new Map();
559
- for (const [name, def] of Object.entries(procedures)) if (isSubscriptionDef(def)) subscriptionMap.set(name, {
560
- inputSchema: def.input._schema,
561
- outputSchema: def.output._schema,
562
- handler: def.handler
563
- });
564
- else procedureMap.set(name, {
565
- inputSchema: def.input._schema,
566
- outputSchema: def.output._schema,
567
- handler: def.handler
962
+ /** Collect channel metadata from channel results for manifest */
963
+ function collectChannelMeta(channels) {
964
+ if (!channels || channels.length === 0) return void 0;
965
+ return Object.fromEntries(channels.map((ch) => {
966
+ const firstKey = Object.keys(ch.procedures)[0] ?? "";
967
+ return [firstKey.includes(".") ? firstKey.slice(0, firstKey.indexOf(".")) : firstKey, ch.channelMeta];
968
+ }));
969
+ }
970
+ /** Resolve context for a procedure, returning undefined if no context needed */
971
+ function resolveCtxFor(map, name, rawCtx, ctxConfig) {
972
+ if (!rawCtx) return void 0;
973
+ const proc = map.get(name);
974
+ if (!proc || proc.contextKeys.length === 0) return void 0;
975
+ return resolveContext(ctxConfig, rawCtx, proc.contextKeys);
976
+ }
977
+ /** Resolve locale and match page route */
978
+ async function matchAndHandlePage(pageMatcher, procedureMap, i18nConfig, strategies, hasUrlPrefix, path, headers, rawCtx, ctxConfig, shouldValidateInput) {
979
+ let pathLocale = null;
980
+ let routePath = path;
981
+ if (hasUrlPrefix && i18nConfig) {
982
+ const segments = path.split("/").filter(Boolean);
983
+ const localeSet = new Set(i18nConfig.locales);
984
+ const first = segments[0];
985
+ if (first && localeSet.has(first)) {
986
+ pathLocale = first;
987
+ routePath = "/" + segments.slice(1).join("/") || "/";
988
+ }
989
+ }
990
+ let locale;
991
+ if (i18nConfig) locale = resolveChain(strategies, {
992
+ url: headers?.url ?? "",
993
+ pathLocale,
994
+ cookie: headers?.cookie,
995
+ acceptLanguage: headers?.acceptLanguage,
996
+ locales: i18nConfig.locales,
997
+ defaultLocale: i18nConfig.default
568
998
  });
999
+ const match = pageMatcher.match(routePath);
1000
+ if (!match) return null;
1001
+ let searchParams;
1002
+ if (headers?.url) try {
1003
+ const url = new URL(headers.url, "http://localhost");
1004
+ if (url.search) searchParams = url.searchParams;
1005
+ } catch {}
1006
+ const i18nOpts = locale && i18nConfig ? {
1007
+ locale,
1008
+ config: i18nConfig,
1009
+ routePattern: match.pattern
1010
+ } : void 0;
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);
1016
+ }
1017
+ /** Catch context resolution errors and return them as HandleResult */
1018
+ function resolveCtxSafe(map, name, rawCtx, ctxConfig) {
1019
+ try {
1020
+ return { ctx: resolveCtxFor(map, name, rawCtx, ctxConfig) };
1021
+ } catch (err) {
1022
+ if (err instanceof SeamError) return { error: {
1023
+ status: err.status,
1024
+ body: err.toJSON()
1025
+ } };
1026
+ throw err;
1027
+ }
1028
+ }
1029
+
1030
+ //#endregion
1031
+ //#region src/router/state.ts
1032
+ /** Build all shared state that createRouter methods close over */
1033
+ function initRouterState(procedures, opts) {
1034
+ const ctxConfig = opts?.context ?? {};
1035
+ const { procedureMap, subscriptionMap, streamMap, uploadMap, kindMap } = categorizeProcedures(procedures, Object.keys(ctxConfig).length > 0 ? ctxConfig : void 0);
1036
+ const shouldValidateInput = resolveValidationMode(opts?.validation?.input);
569
1037
  const shouldValidateOutput = opts?.validateOutput ?? (typeof process !== "undefined" && process.env.NODE_ENV !== "production");
570
1038
  const pageMatcher = new RouteMatcher();
571
1039
  const pages = opts?.pages;
@@ -573,56 +1041,163 @@ function createRouter(procedures, opts) {
573
1041
  const i18nConfig = opts?.i18n ?? null;
574
1042
  const { strategies, hasUrlPrefix } = buildStrategies(opts);
575
1043
  if (i18nConfig) registerI18nQuery(procedureMap, i18nConfig);
576
- const channelsMeta = opts?.channels && opts.channels.length > 0 ? Object.fromEntries(opts.channels.map((ch) => {
577
- const firstKey = Object.keys(ch.procedures)[0] ?? "";
578
- return [firstKey.includes(".") ? firstKey.slice(0, firstKey.indexOf(".")) : firstKey, ch.channelMeta];
579
- })) : void 0;
580
1044
  return {
581
- procedures,
582
- hasPages: !!pages && Object.keys(pages).length > 0,
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 {
1065
+ async handle(procedureName, body, rawCtx) {
1066
+ const { ctx, error } = resolveCtxSafe(state.procedureMap, procedureName, rawCtx, state.ctxConfig);
1067
+ if (error) return error;
1068
+ return handleRequest(state.procedureMap, procedureName, body, state.shouldValidateInput, state.shouldValidateOutput, ctx);
1069
+ },
1070
+ handleBatch(calls, rawCtx) {
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;
1088
+ },
583
1089
  manifest() {
584
- return buildManifest(procedures, channelsMeta);
1090
+ return buildManifest(procedures, state.channelsMeta, state.ctxConfig, opts?.transportDefaults);
585
1091
  },
586
- handle(procedureName, body) {
587
- return handleRequest(procedureMap, procedureName, body, shouldValidateOutput);
1092
+ ...buildRpcMethods(state),
1093
+ handleSubscription(name, input, rawCtx) {
1094
+ const ctx = resolveCtxFor(state.subscriptionMap, name, rawCtx, state.ctxConfig);
1095
+ return handleSubscription(state.subscriptionMap, name, input, state.shouldValidateInput, state.shouldValidateOutput, ctx);
588
1096
  },
589
- handleBatch(calls) {
590
- return handleBatchRequest(procedureMap, calls, shouldValidateOutput);
1097
+ handleStream(name, input, rawCtx) {
1098
+ const ctx = resolveCtxFor(state.streamMap, name, rawCtx, state.ctxConfig);
1099
+ return handleStream(state.streamMap, name, input, state.shouldValidateInput, state.shouldValidateOutput, ctx);
591
1100
  },
592
- handleSubscription(name, input) {
593
- return handleSubscription(subscriptionMap, name, input, shouldValidateOutput);
1101
+ getKind(name) {
1102
+ return state.kindMap.get(name) ?? null;
594
1103
  },
595
- async handlePage(path, headers) {
596
- let pathLocale = null;
597
- let routePath = path;
598
- if (hasUrlPrefix && i18nConfig) {
599
- const segments = path.split("/").filter(Boolean);
600
- const localeSet = new Set(i18nConfig.locales);
601
- const first = segments[0];
602
- if (first && localeSet.has(first)) {
603
- pathLocale = first;
604
- routePath = "/" + segments.slice(1).join("/") || "/";
605
- }
606
- }
607
- let locale;
608
- if (i18nConfig) locale = resolveChain(strategies, {
609
- url: headers?.url ?? "",
610
- pathLocale,
611
- cookie: headers?.cookie,
612
- acceptLanguage: headers?.acceptLanguage,
613
- locales: i18nConfig.locales,
614
- defaultLocale: i18nConfig.default
615
- });
616
- const match = pageMatcher.match(routePath);
617
- if (!match) return null;
618
- const i18nOpts = locale && i18nConfig ? {
619
- locale,
620
- config: i18nConfig,
621
- routePattern: match.pattern
622
- } : void 0;
623
- return handlePageRequest(match.value, match.params, procedureMap, i18nOpts);
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
+ };
1164
+ },
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
+ };
624
1188
  }
625
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
+ };
626
1201
  }
627
1202
 
628
1203
  //#endregion
@@ -661,7 +1236,7 @@ function createChannel(name, def) {
661
1236
  const channelInputSchema = def.input._schema;
662
1237
  for (const [msgName, msgDef] of Object.entries(def.incoming)) {
663
1238
  const command = {
664
- type: "command",
1239
+ kind: "command",
665
1240
  input: { _schema: mergeObjectSchemas(channelInputSchema, msgDef.input._schema) },
666
1241
  output: msgDef.output,
667
1242
  handler: msgDef.handler
@@ -671,7 +1246,7 @@ function createChannel(name, def) {
671
1246
  }
672
1247
  const unionSchema = buildOutgoingUnionSchema(def.outgoing);
673
1248
  const subscription = {
674
- type: "subscription",
1249
+ kind: "subscription",
675
1250
  input: def.input,
676
1251
  output: { _schema: unionSchema },
677
1252
  handler: def.subscribe
@@ -688,13 +1263,15 @@ function createChannel(name, def) {
688
1263
  }
689
1264
  const outgoingMeta = {};
690
1265
  for (const [eventName, node] of Object.entries(def.outgoing)) outgoingMeta[eventName] = node._schema;
1266
+ const channelMeta = {
1267
+ input: channelInputSchema,
1268
+ incoming: incomingMeta,
1269
+ outgoing: outgoingMeta
1270
+ };
1271
+ if (def.transport) channelMeta.transport = def.transport;
691
1272
  return {
692
1273
  procedures,
693
- channelMeta: {
694
- input: channelInputSchema,
695
- incoming: incomingMeta,
696
- outgoing: outgoingMeta
697
- }
1274
+ channelMeta
698
1275
  };
699
1276
  }
700
1277
 
@@ -775,6 +1352,10 @@ async function handleStaticAsset(assetPath, staticDir) {
775
1352
  function sseDataEvent(data) {
776
1353
  return `event: data\ndata: ${JSON.stringify(data)}\n\n`;
777
1354
  }
1355
+ /** Format an SSE data event with a sequence id (for streams) */
1356
+ function sseDataEventWithId(data, id) {
1357
+ return `event: data\nid: ${id}\ndata: ${JSON.stringify(data)}\n\n`;
1358
+ }
778
1359
  /** Format an SSE error event */
779
1360
  function sseErrorEvent(code, message, transient = false) {
780
1361
  return `event: error\ndata: ${JSON.stringify({
@@ -787,16 +1368,93 @@ function sseErrorEvent(code, message, transient = false) {
787
1368
  function sseCompleteEvent() {
788
1369
  return "event: complete\ndata: {}\n\n";
789
1370
  }
790
- async function* sseStream(router, name, input) {
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
+ }
1436
+ async function* sseStream(router, name, input, rawCtx) {
1437
+ try {
1438
+ for await (const value of router.handleSubscription(name, input, rawCtx)) yield sseDataEvent(value);
1439
+ yield sseCompleteEvent();
1440
+ } catch (error) {
1441
+ yield formatSseError(error);
1442
+ }
1443
+ }
1444
+ async function* sseStreamForStream(router, name, input, signal, rawCtx) {
1445
+ const gen = router.handleStream(name, input, rawCtx);
1446
+ if (signal) signal.addEventListener("abort", () => {
1447
+ gen.return(void 0);
1448
+ }, { once: true });
791
1449
  try {
792
- for await (const value of router.handleSubscription(name, input)) yield sseDataEvent(value);
1450
+ let seq = 0;
1451
+ for await (const value of gen) yield sseDataEventWithId(value, seq++);
793
1452
  yield sseCompleteEvent();
794
1453
  } catch (error) {
795
- if (error instanceof SeamError) yield sseErrorEvent(error.code, error.message);
796
- else yield sseErrorEvent("INTERNAL_ERROR", error instanceof Error ? error.message : "Unknown error");
1454
+ yield formatSseError(error);
797
1455
  }
798
1456
  }
799
- async function handleBatchHttp(req, router, hashToName) {
1457
+ async function handleBatchHttp(req, router, hashToName, rawCtx) {
800
1458
  let body;
801
1459
  try {
802
1460
  body = await req.body();
@@ -810,45 +1468,63 @@ async function handleBatchHttp(req, router, hashToName) {
810
1468
  }));
811
1469
  return jsonResponse(200, {
812
1470
  ok: true,
813
- data: await router.handleBatch(calls)
1471
+ data: await router.handleBatch(calls, rawCtx)
814
1472
  });
815
1473
  }
1474
+ /** Resolve hash -> original name when obfuscation is active. Accepts both hashed and raw names. */
1475
+ function resolveHashName(hashToName, name) {
1476
+ if (!hashToName) return name;
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);
1504
+ }
816
1505
  function createHttpHandler(router, opts) {
817
- 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;
818
1508
  if (hashToName) hashToName.set("__seam_i18n_query", "__seam_i18n_query");
819
- const batchHash = opts?.rpcHashMap?.batch ?? null;
1509
+ const batchHash = effectiveHashMap?.batch ?? null;
1510
+ const hasCtx = router.hasContext();
820
1511
  return async (req) => {
821
1512
  const url = new URL(req.url, "http://localhost");
822
1513
  const { pathname } = url;
1514
+ const rawCtx = hasCtx ? buildRawContext(router.ctxConfig, req.header, url) : void 0;
823
1515
  if (req.method === "GET" && pathname === MANIFEST_PATH) {
824
- if (opts?.rpcHashMap) return errorResponse(403, "FORBIDDEN", "Manifest disabled");
1516
+ if (effectiveHashMap) return errorResponse(403, "FORBIDDEN", "Manifest disabled");
825
1517
  return jsonResponse(200, router.manifest());
826
1518
  }
827
1519
  if (pathname.startsWith(PROCEDURE_PREFIX)) {
828
- let name = pathname.slice(17);
829
- if (!name) return errorResponse(404, "NOT_FOUND", "Empty procedure name");
1520
+ const rawName = pathname.slice(17);
1521
+ if (!rawName) return errorResponse(404, "NOT_FOUND", "Empty procedure name");
830
1522
  if (req.method === "POST") {
831
- if (name === "_batch" || batchHash && name === batchHash) return handleBatchHttp(req, router, hashToName);
832
- if (hashToName) {
833
- const resolved = hashToName.get(name);
834
- if (!resolved) return errorResponse(404, "NOT_FOUND", "Not found");
835
- name = resolved;
836
- }
837
- let body;
838
- try {
839
- body = await req.body();
840
- } catch {
841
- return errorResponse(400, "VALIDATION_ERROR", "Invalid JSON body");
842
- }
843
- const result = await router.handle(name, body);
844
- return jsonResponse(result.status, result.body);
1523
+ if (rawName === "_batch" || batchHash && rawName === batchHash) return handleBatchHttp(req, router, hashToName, rawCtx);
1524
+ return handleProcedurePost(req, router, resolveHashName(hashToName, rawName), rawCtx, opts?.sseOptions);
845
1525
  }
846
1526
  if (req.method === "GET") {
847
- if (hashToName) {
848
- const resolved = hashToName.get(name);
849
- if (!resolved) return errorResponse(404, "NOT_FOUND", "Not found");
850
- name = resolved;
851
- }
1527
+ const name = resolveHashName(hashToName, rawName);
852
1528
  const rawInput = url.searchParams.get("input");
853
1529
  let input;
854
1530
  try {
@@ -859,7 +1535,7 @@ function createHttpHandler(router, opts) {
859
1535
  return {
860
1536
  status: 200,
861
1537
  headers: SSE_HEADER,
862
- stream: sseStream(router, name, input)
1538
+ stream: withSseLifecycle(sseStream(router, name, input, rawCtx), opts?.sseOptions)
863
1539
  };
864
1540
  }
865
1541
  }
@@ -870,7 +1546,7 @@ function createHttpHandler(router, opts) {
870
1546
  cookie: req.header("cookie") ?? void 0,
871
1547
  acceptLanguage: req.header("accept-language") ?? void 0
872
1548
  } : void 0;
873
- const result = await router.handlePage(pagePath, headers);
1549
+ const result = await router.handlePage(pagePath, headers, rawCtx);
874
1550
  if (result) return {
875
1551
  status: result.status,
876
1552
  headers: HTML_HEADER,
@@ -895,13 +1571,19 @@ async function drainStream(stream, write) {
895
1571
  function toWebResponse(result) {
896
1572
  if ("stream" in result) {
897
1573
  const stream = result.stream;
1574
+ const onCancel = result.onCancel;
898
1575
  const encoder = new TextEncoder();
899
- const readable = new ReadableStream({ async start(controller) {
900
- await drainStream(stream, (chunk) => {
901
- controller.enqueue(encoder.encode(chunk));
902
- });
903
- controller.close();
904
- } });
1576
+ const readable = new ReadableStream({
1577
+ async start(controller) {
1578
+ await drainStream(stream, (chunk) => {
1579
+ controller.enqueue(encoder.encode(chunk));
1580
+ });
1581
+ controller.close();
1582
+ },
1583
+ cancel() {
1584
+ onCancel?.();
1585
+ }
1586
+ });
905
1587
  return new Response(readable, {
906
1588
  status: result.status,
907
1589
  headers: result.headers
@@ -915,12 +1597,16 @@ function toWebResponse(result) {
915
1597
 
916
1598
  //#endregion
917
1599
  //#region src/page/build-loader.ts
1600
+ function normalizeParamConfig(value) {
1601
+ return typeof value === "string" ? { from: value } : value;
1602
+ }
918
1603
  function buildLoaderFn(config) {
919
- return (params) => {
1604
+ return (params, searchParams) => {
920
1605
  const input = {};
921
- if (config.params) for (const [key, mapping] of Object.entries(config.params)) {
922
- const raw = params[key];
923
- input[key] = mapping.type === "int" ? Number(raw) : raw;
1606
+ if (config.params) for (const [key, raw_mapping] of Object.entries(config.params)) {
1607
+ const mapping = normalizeParamConfig(raw_mapping);
1608
+ const raw = mapping.from === "query" ? searchParams?.get(key) ?? void 0 : params[key];
1609
+ if (raw !== void 0) input[key] = mapping.type === "int" ? Number(raw) : raw;
924
1610
  }
925
1611
  return {
926
1612
  procedure: config.procedure,
@@ -951,42 +1637,19 @@ function loadLocaleTemplates(entry, distDir) {
951
1637
  return result;
952
1638
  }
953
1639
  /** Resolve parent chain for a layout, returning outer-to-inner order */
954
- function resolveLayoutChain(layoutId, layoutEntries, templates, localeTemplatesMap) {
1640
+ function resolveLayoutChain(layoutId, layoutEntries, getTemplates) {
955
1641
  const chain = [];
956
1642
  let currentId = layoutId;
957
1643
  while (currentId) {
958
1644
  const entry = layoutEntries[currentId];
959
1645
  if (!entry) break;
1646
+ const { template, localeTemplates } = getTemplates(currentId, entry);
960
1647
  chain.push({
961
1648
  id: currentId,
962
- template: templates[currentId] ?? "",
963
- localeTemplates: localeTemplatesMap[currentId],
964
- loaders: buildLoaderFns(entry.loaders ?? {})
965
- });
966
- currentId = entry.parent;
967
- }
968
- chain.reverse();
969
- return chain;
970
- }
971
- /** Resolve layout chain with lazy template getters (re-read from disk on each access) */
972
- function resolveLayoutChainDev(layoutId, layoutEntries, distDir, defaultLocale) {
973
- const chain = [];
974
- let currentId = layoutId;
975
- while (currentId) {
976
- const entry = layoutEntries[currentId];
977
- if (!entry) break;
978
- const layoutTemplatePath = join(distDir, resolveTemplatePath(entry, defaultLocale));
979
- const def = {
980
- id: currentId,
981
- template: "",
982
- localeTemplates: entry.templates ? makeLocaleTemplateGetters(entry.templates, distDir) : void 0,
1649
+ template,
1650
+ localeTemplates,
983
1651
  loaders: buildLoaderFns(entry.loaders ?? {})
984
- };
985
- Object.defineProperty(def, "template", {
986
- get: () => readFileSync(layoutTemplatePath, "utf-8"),
987
- enumerable: true
988
1652
  });
989
- chain.push(def);
990
1653
  currentId = entry.parent;
991
1654
  }
992
1655
  chain.reverse();
@@ -1019,6 +1682,22 @@ function mergeI18nKeys(route, layoutEntries) {
1019
1682
  if (route.i18n_keys) keys.push(...route.i18n_keys);
1020
1683
  return keys.length > 0 ? keys : void 0;
1021
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
+ }
1022
1701
  /** Load the RPC hash map from build output (returns undefined when obfuscation is off) */
1023
1702
  function loadRpcHashMap(distDir) {
1024
1703
  const hashMapPath = join(distDir, "rpc-hash-map.json");
@@ -1077,7 +1756,10 @@ function loadBuildOutput(distDir) {
1077
1756
  for (const [path, entry] of Object.entries(manifest.routes)) {
1078
1757
  const template = readFileSync(join(distDir, resolveTemplatePath(entry, defaultLocale)), "utf-8");
1079
1758
  const loaders = buildLoaderFns(entry.loaders);
1080
- const layoutChain = entry.layout ? resolveLayoutChain(entry.layout, layoutEntries, layoutTemplates, layoutLocaleTemplates) : [];
1759
+ const layoutChain = entry.layout ? resolveLayoutChain(entry.layout, layoutEntries, (id) => ({
1760
+ template: layoutTemplates[id] ?? "",
1761
+ localeTemplates: layoutLocaleTemplates[id]
1762
+ })) : [];
1081
1763
  const i18nKeys = mergeI18nKeys(entry, layoutEntries);
1082
1764
  pages[path] = {
1083
1765
  template,
@@ -1087,7 +1769,8 @@ function loadBuildOutput(distDir) {
1087
1769
  headMeta: entry.head_meta,
1088
1770
  dataId: manifest.data_id,
1089
1771
  i18nKeys,
1090
- pageAssets: entry.assets
1772
+ pageAssets: entry.assets,
1773
+ projections: entry.projections
1091
1774
  };
1092
1775
  }
1093
1776
  return pages;
@@ -1102,7 +1785,18 @@ function loadBuildOutputDev(distDir) {
1102
1785
  for (const [path, entry] of Object.entries(manifest.routes)) {
1103
1786
  const templatePath = join(distDir, resolveTemplatePath(entry, defaultLocale));
1104
1787
  const loaders = buildLoaderFns(entry.loaders);
1105
- const layoutChain = entry.layout ? resolveLayoutChainDev(entry.layout, layoutEntries, distDir, defaultLocale) : [];
1788
+ const layoutChain = entry.layout ? resolveLayoutChain(entry.layout, layoutEntries, (id, layoutEntry) => {
1789
+ const tmplPath = join(distDir, resolveTemplatePath(layoutEntry, defaultLocale));
1790
+ const def = {
1791
+ template: "",
1792
+ localeTemplates: layoutEntry.templates ? makeLocaleTemplateGetters(layoutEntry.templates, distDir) : void 0
1793
+ };
1794
+ Object.defineProperty(def, "template", {
1795
+ get: () => readFileSync(tmplPath, "utf-8"),
1796
+ enumerable: true
1797
+ });
1798
+ return def;
1799
+ }) : [];
1106
1800
  const localeTemplates = entry.templates ? makeLocaleTemplateGetters(entry.templates, distDir) : void 0;
1107
1801
  const i18nKeys = mergeI18nKeys(entry, layoutEntries);
1108
1802
  const page = {
@@ -1112,7 +1806,8 @@ function loadBuildOutputDev(distDir) {
1112
1806
  layoutChain,
1113
1807
  dataId: manifest.data_id,
1114
1808
  i18nKeys,
1115
- pageAssets: entry.assets
1809
+ pageAssets: entry.assets,
1810
+ projections: entry.projections
1116
1811
  };
1117
1812
  Object.defineProperty(page, "template", {
1118
1813
  get: () => readFileSync(templatePath, "utf-8"),
@@ -1184,7 +1879,8 @@ function fromCallback(setup) {
1184
1879
 
1185
1880
  //#endregion
1186
1881
  //#region src/ws.ts
1187
- const DEFAULT_HEARTBEAT_MS = 3e4;
1882
+ const DEFAULT_HEARTBEAT_MS = 21e3;
1883
+ const DEFAULT_PONG_TIMEOUT_MS = 5e3;
1188
1884
  function sendError(ws, id, code, message) {
1189
1885
  ws.send(JSON.stringify({
1190
1886
  id,
@@ -1196,6 +1892,60 @@ function sendError(ws, id, code, message) {
1196
1892
  }
1197
1893
  }));
1198
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
+ }
1199
1949
  /**
1200
1950
  * Start a WebSocket session for a channel.
1201
1951
  *
@@ -1204,9 +1954,24 @@ function sendError(ws, id, code, message) {
1204
1954
  */
1205
1955
  function startChannelWs(router, channelName, channelInput, ws, opts) {
1206
1956
  const heartbeatMs = opts?.heartbeatInterval ?? DEFAULT_HEARTBEAT_MS;
1957
+ const pongTimeoutMs = opts?.pongTimeout ?? DEFAULT_PONG_TIMEOUT_MS;
1207
1958
  let closed = false;
1959
+ let pongTimer = null;
1208
1960
  const heartbeatTimer = setInterval(() => {
1209
- 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
+ }
1210
1975
  }, heartbeatMs);
1211
1976
  const iter = router.handleSubscription(`${channelName}.events`, channelInput)[Symbol.asyncIterator]();
1212
1977
  (async () => {
@@ -1237,58 +2002,20 @@ function startChannelWs(router, channelName, channelInput, ws, opts) {
1237
2002
  return {
1238
2003
  onMessage(data) {
1239
2004
  if (closed) return;
1240
- let msg;
1241
- try {
1242
- msg = JSON.parse(data);
1243
- } catch {
1244
- sendError(ws, null, "VALIDATION_ERROR", "Invalid JSON");
1245
- return;
1246
- }
1247
- if (!msg.id || typeof msg.id !== "string") {
1248
- sendError(ws, null, "VALIDATION_ERROR", "Missing 'id' field");
1249
- return;
1250
- }
1251
- if (!msg.procedure || typeof msg.procedure !== "string") {
1252
- sendError(ws, msg.id, "VALIDATION_ERROR", "Missing 'procedure' field");
1253
- return;
1254
- }
1255
- const prefix = channelName + ".";
1256
- if (!msg.procedure.startsWith(prefix) || msg.procedure === `${channelName}.events`) {
1257
- sendError(ws, msg.id, "VALIDATION_ERROR", `Procedure '${msg.procedure}' is not a command of channel '${channelName}'`);
1258
- 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;
1259
2012
  }
1260
- const mergedInput = {
1261
- ...channelInput,
1262
- ...msg.input ?? {}
1263
- };
1264
- (async () => {
1265
- try {
1266
- const result = await router.handle(msg.procedure, mergedInput);
1267
- if (result.status === 200) {
1268
- const envelope = result.body;
1269
- ws.send(JSON.stringify({
1270
- id: msg.id,
1271
- ok: true,
1272
- data: envelope.data
1273
- }));
1274
- } else {
1275
- const envelope = result.body;
1276
- ws.send(JSON.stringify({
1277
- id: msg.id,
1278
- ok: false,
1279
- error: envelope.error
1280
- }));
1281
- }
1282
- } catch (err) {
1283
- const message = err instanceof Error ? err.message : "Unknown error";
1284
- sendError(ws, msg.id, "INTERNAL_ERROR", message);
1285
- }
1286
- })();
1287
2013
  },
1288
2014
  close() {
1289
2015
  if (closed) return;
1290
2016
  closed = true;
1291
2017
  clearInterval(heartbeatTimer);
2018
+ if (pongTimer) clearTimeout(pongTimer);
1292
2019
  iter.return?.(void 0);
1293
2020
  }
1294
2021
  };
@@ -1361,26 +2088,58 @@ function createStaticHandler(opts) {
1361
2088
  function watchReloadTrigger(distDir, onReload) {
1362
2089
  const triggerPath = join(distDir, ".reload-trigger");
1363
2090
  let watcher = null;
2091
+ let closed = false;
2092
+ let pending = [];
2093
+ const notify = () => {
2094
+ onReload();
2095
+ const batch = pending;
2096
+ pending = [];
2097
+ for (const p of batch) p.resolve();
2098
+ };
2099
+ const nextReload = () => {
2100
+ if (closed) return Promise.reject(/* @__PURE__ */ new Error("watcher closed"));
2101
+ return new Promise((resolve, reject) => {
2102
+ pending.push({
2103
+ resolve,
2104
+ reject
2105
+ });
2106
+ });
2107
+ };
2108
+ const closeAll = () => {
2109
+ closed = true;
2110
+ const batch = pending;
2111
+ pending = [];
2112
+ const err = /* @__PURE__ */ new Error("watcher closed");
2113
+ for (const p of batch) p.reject(err);
2114
+ };
1364
2115
  try {
1365
- watcher = watch(triggerPath, () => onReload());
2116
+ watcher = watch(triggerPath, () => notify());
1366
2117
  } catch {
1367
2118
  const dirWatcher = watch(distDir, (_event, filename) => {
1368
2119
  if (filename === ".reload-trigger") {
1369
2120
  dirWatcher.close();
1370
- watcher = watch(triggerPath, () => onReload());
1371
- onReload();
2121
+ watcher = watch(triggerPath, () => notify());
2122
+ notify();
1372
2123
  }
1373
2124
  });
1374
- return { close() {
1375
- dirWatcher.close();
1376
- watcher?.close();
1377
- } };
2125
+ return {
2126
+ close() {
2127
+ dirWatcher.close();
2128
+ watcher?.close();
2129
+ closeAll();
2130
+ },
2131
+ nextReload
2132
+ };
1378
2133
  }
1379
- return { close() {
1380
- watcher?.close();
1381
- } };
2134
+ return {
2135
+ close() {
2136
+ watcher?.close();
2137
+ closeAll();
2138
+ },
2139
+ nextReload
2140
+ };
1382
2141
  }
1383
2142
 
1384
2143
  //#endregion
1385
- export { SeamError, createChannel, createDevProxy, createHttpHandler, createRouter, createStaticHandler, defaultStrategies, definePage, drainStream, fromAcceptLanguage, fromCallback, fromCookie, fromUrlPrefix, fromUrlQuery, loadBuildOutput, loadBuildOutputDev, loadI18nMessages, loadRpcHashMap, resolveChain, serialize, sseCompleteEvent, sseDataEvent, sseErrorEvent, startChannelWs, t, toWebResponse, watchReloadTrigger };
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 };
1386
2145
  //# sourceMappingURL=index.js.map