@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/README.md +2 -2
- package/dist/index.d.ts +341 -66
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1114 -355
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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/
|
|
145
|
-
function
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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/
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
227
|
+
source,
|
|
228
|
+
key
|
|
205
229
|
};
|
|
206
230
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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({
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
const
|
|
336
|
-
const
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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/
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
582
|
-
|
|
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
|
-
|
|
587
|
-
|
|
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
|
-
|
|
590
|
-
|
|
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
|
-
|
|
593
|
-
return
|
|
1101
|
+
getKind(name) {
|
|
1102
|
+
return state.kindMap.get(name) ?? null;
|
|
594
1103
|
},
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1450
|
+
let seq = 0;
|
|
1451
|
+
for await (const value of gen) yield sseDataEventWithId(value, seq++);
|
|
793
1452
|
yield sseCompleteEvent();
|
|
794
1453
|
} catch (error) {
|
|
795
|
-
|
|
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
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
829
|
-
if (!
|
|
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 (
|
|
832
|
-
|
|
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
|
-
|
|
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({
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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,
|
|
922
|
-
const
|
|
923
|
-
|
|
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,
|
|
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
|
|
963
|
-
localeTemplates
|
|
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,
|
|
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 ?
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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, () =>
|
|
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, () =>
|
|
1371
|
-
|
|
2121
|
+
watcher = watch(triggerPath, () => notify());
|
|
2122
|
+
notify();
|
|
1372
2123
|
}
|
|
1373
2124
|
});
|
|
1374
|
-
return {
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
2125
|
+
return {
|
|
2126
|
+
close() {
|
|
2127
|
+
dirWatcher.close();
|
|
2128
|
+
watcher?.close();
|
|
2129
|
+
closeAll();
|
|
2130
|
+
},
|
|
2131
|
+
nextReload
|
|
2132
|
+
};
|
|
1378
2133
|
}
|
|
1379
|
-
return {
|
|
1380
|
-
|
|
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
|