@canmi/seam-server 0.4.18 → 0.5.10

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