@spfn/core 0.3.0-beta.19 → 0.3.0-beta.20
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 +61 -0
- package/dist/contract/index.d.ts +4 -4
- package/dist/error-BcdBVfRA.d.ts +150 -0
- package/dist/ops/index.d.ts +31 -71
- package/dist/ops/index.js +333 -182
- package/dist/ops/index.js.map +1 -1
- package/dist/route/index.d.ts +4 -4
- package/dist/route/index.js +16 -0
- package/dist/route/index.js.map +1 -1
- package/dist/{route-builder-2ani2jEI.d.ts → route-builder-BHKgGkwI.d.ts} +1 -1
- package/dist/{router-BqyEARoY.d.ts → router-DaDrprJd.d.ts} +8 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +16 -0
- package/dist/server/index.js.map +1 -1
- package/dist/surface/index.d.ts +109 -0
- package/dist/surface/index.js +908 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/{types-B4DuGCyM.d.ts → types-D52SokSM.d.ts} +1 -1
- package/package.json +6 -1
package/dist/ops/index.js
CHANGED
|
@@ -283,6 +283,7 @@ function createRouterInstance(state) {
|
|
|
283
283
|
newPackageRouters.push(...pkgRouter._packageRouters);
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
|
+
assertDistinctSurfaces(newPackageRouters);
|
|
286
287
|
return createRouterInstance({ ...state, packageRouters: newPackageRouters });
|
|
287
288
|
},
|
|
288
289
|
use(middlewares) {
|
|
@@ -304,6 +305,21 @@ function assertContractVersion(version) {
|
|
|
304
305
|
);
|
|
305
306
|
}
|
|
306
307
|
}
|
|
308
|
+
function assertDistinctSurfaces(packageRouters) {
|
|
309
|
+
const namespaces = /* @__PURE__ */ new Set();
|
|
310
|
+
for (const router of packageRouters) {
|
|
311
|
+
const namespace = router._surfaceNamespace;
|
|
312
|
+
if (namespace === void 0) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (namespaces.has(namespace)) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`.packages() mounts two command surfaces on "${namespace}". Both serve ${namespace}/_manifest, so the second one's commands would answer while no manifest lists them. Mount one router per namespace.`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
namespaces.add(namespace);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
307
323
|
function defineUnmappedRouter(routes) {
|
|
308
324
|
return createRouterInstance({
|
|
309
325
|
routes,
|
|
@@ -314,15 +330,25 @@ function defineUnmappedRouter(routes) {
|
|
|
314
330
|
});
|
|
315
331
|
}
|
|
316
332
|
|
|
317
|
-
// src/
|
|
318
|
-
var
|
|
333
|
+
// src/surface/error.ts
|
|
334
|
+
var SurfaceError = class extends Error {
|
|
319
335
|
constructor(message) {
|
|
320
336
|
super(message);
|
|
321
|
-
this.name = "
|
|
337
|
+
this.name = "SurfaceError";
|
|
322
338
|
}
|
|
323
339
|
};
|
|
324
340
|
|
|
325
|
-
// src/
|
|
341
|
+
// src/surface/wording.ts
|
|
342
|
+
var SURFACE_WORDING = {
|
|
343
|
+
noun: "surface",
|
|
344
|
+
builder: "`<surface>.route`",
|
|
345
|
+
factory: "defineSurface"
|
|
346
|
+
};
|
|
347
|
+
function capitalized(noun) {
|
|
348
|
+
return noun.charAt(0).toUpperCase() + noun.slice(1);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/surface/manifest.ts
|
|
326
352
|
function isRouter(value) {
|
|
327
353
|
return value !== null && typeof value === "object" && "routes" in value && "_routes" in value;
|
|
328
354
|
}
|
|
@@ -346,22 +372,22 @@ function toCommandInput(input) {
|
|
|
346
372
|
}
|
|
347
373
|
return sections;
|
|
348
374
|
}
|
|
349
|
-
function
|
|
375
|
+
function collectSurfaceCommands(routes, wording = SURFACE_WORDING) {
|
|
350
376
|
const commands = [];
|
|
351
|
-
visit(routes, commands, /* @__PURE__ */ new Map());
|
|
377
|
+
visit(routes, commands, /* @__PURE__ */ new Map(), wording);
|
|
352
378
|
commands.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
353
379
|
return commands;
|
|
354
380
|
}
|
|
355
|
-
function visit(routes, commands, claimed) {
|
|
381
|
+
function visit(routes, commands, claimed, wording) {
|
|
356
382
|
for (const [name, entry] of Object.entries(routes)) {
|
|
357
383
|
if (isRouter(entry)) {
|
|
358
|
-
visit(entry.routes, commands, claimed);
|
|
384
|
+
visit(entry.routes, commands, claimed, wording);
|
|
359
385
|
continue;
|
|
360
386
|
}
|
|
361
387
|
if (!isRouteDef(entry) || !entry.method || !entry.path) {
|
|
362
388
|
continue;
|
|
363
389
|
}
|
|
364
|
-
assertUnclaimedName(name, entry.path, claimed);
|
|
390
|
+
assertUnclaimedName(name, entry.path, claimed, wording);
|
|
365
391
|
commands.push({
|
|
366
392
|
name,
|
|
367
393
|
method: entry.method,
|
|
@@ -370,43 +396,17 @@ function visit(routes, commands, claimed) {
|
|
|
370
396
|
});
|
|
371
397
|
}
|
|
372
398
|
}
|
|
373
|
-
function assertUnclaimedName(name, path, claimed) {
|
|
399
|
+
function assertUnclaimedName(name, path, claimed, wording) {
|
|
374
400
|
const existing = claimed.get(name);
|
|
375
401
|
if (existing !== void 0) {
|
|
376
|
-
throw new
|
|
377
|
-
`Two
|
|
402
|
+
throw new SurfaceError(
|
|
403
|
+
`Two ${wording.noun} routes are named "${name}" ("${existing}" and "${path}"). Command names are flattened across nested routers, so each must be unique for a client to resolve the one its caller asked for.`
|
|
378
404
|
);
|
|
379
405
|
}
|
|
380
406
|
claimed.set(name, path);
|
|
381
407
|
}
|
|
382
408
|
|
|
383
|
-
// src/
|
|
384
|
-
var OPS_PATH_ROOT = "/_ops";
|
|
385
|
-
function toOpsPath(path) {
|
|
386
|
-
if (!path.startsWith("/")) {
|
|
387
|
-
throw new OpsRouterError(
|
|
388
|
-
`Ops route path "${path}" must start with "/". It is appended to "${OPS_PATH_ROOT}", so "${path}" would read as "${OPS_PATH_ROOT}${path}".`
|
|
389
|
-
);
|
|
390
|
-
}
|
|
391
|
-
if (path === "/") {
|
|
392
|
-
throw new OpsRouterError(
|
|
393
|
-
`Ops route path "/" names no command \u2014 "${OPS_PATH_ROOT}" itself is not a command.`
|
|
394
|
-
);
|
|
395
|
-
}
|
|
396
|
-
return OPS_PATH_ROOT + path;
|
|
397
|
-
}
|
|
398
|
-
function opsMethod(method) {
|
|
399
|
-
return (path) => route[method](toOpsPath(path));
|
|
400
|
-
}
|
|
401
|
-
var opsRoute = {
|
|
402
|
-
get: opsMethod("get"),
|
|
403
|
-
post: opsMethod("post"),
|
|
404
|
-
put: opsMethod("put"),
|
|
405
|
-
patch: opsMethod("patch"),
|
|
406
|
-
delete: opsMethod("delete")
|
|
407
|
-
};
|
|
408
|
-
|
|
409
|
-
// src/ops/route-overlap.ts
|
|
409
|
+
// src/surface/route-overlap.ts
|
|
410
410
|
function routeSegments(path) {
|
|
411
411
|
return path.split("/").slice(1).map((raw) => {
|
|
412
412
|
if (raw === "*" || raw.endsWith("*")) {
|
|
@@ -441,7 +441,7 @@ function remainingStaticPath(segments, from) {
|
|
|
441
441
|
const remaining = segments.slice(from);
|
|
442
442
|
return remaining.every((segment) => segment.kind === "static") ? remaining.map((segment) => segment.value).join("/") : null;
|
|
443
443
|
}
|
|
444
|
-
function
|
|
444
|
+
function routePatternsOverlap(firstPath, secondPath) {
|
|
445
445
|
const first = routeSegments(firstPath);
|
|
446
446
|
const second = routeSegments(secondPath);
|
|
447
447
|
const length = Math.max(first.length, second.length);
|
|
@@ -492,28 +492,28 @@ function opsRoutePatternsOverlap(firstPath, secondPath) {
|
|
|
492
492
|
return true;
|
|
493
493
|
}
|
|
494
494
|
|
|
495
|
-
// src/
|
|
495
|
+
// src/surface/module.ts
|
|
496
496
|
var MODULE_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
497
497
|
var COMMAND_NAME = /^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$/;
|
|
498
498
|
var CONTRACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/;
|
|
499
499
|
var EFFECTS = /* @__PURE__ */ new Set(["read", "write", "destructive"]);
|
|
500
|
-
var
|
|
500
|
+
var PATH_BASE = "https://spfn.invalid";
|
|
501
501
|
function assertText(label, value) {
|
|
502
502
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
503
|
-
throw new
|
|
503
|
+
throw new SurfaceError(`${label} must be a non-empty string.`);
|
|
504
504
|
}
|
|
505
505
|
}
|
|
506
|
-
function assertStableModulePath(moduleId, commandName, path) {
|
|
507
|
-
const label =
|
|
506
|
+
function assertStableModulePath(at, moduleId, commandName, path) {
|
|
507
|
+
const label = `${at.label} command "${moduleId}.${commandName}" path "${path}"`;
|
|
508
508
|
const transportPath = path.replace(/[{}?#]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
509
509
|
let url;
|
|
510
510
|
try {
|
|
511
|
-
url = new URL(transportPath,
|
|
511
|
+
url = new URL(transportPath, PATH_BASE);
|
|
512
512
|
} catch {
|
|
513
|
-
throw new
|
|
513
|
+
throw new SurfaceError(`${label} is not a valid URL path.`);
|
|
514
514
|
}
|
|
515
|
-
if (url.origin !==
|
|
516
|
-
throw new
|
|
515
|
+
if (url.origin !== PATH_BASE || url.search !== "" || url.hash !== "" || url.pathname !== transportPath) {
|
|
516
|
+
throw new SurfaceError(`${label} is not a stable plain absolute path.`);
|
|
517
517
|
}
|
|
518
518
|
const slashCount = (path.match(/\//g) ?? []).length;
|
|
519
519
|
let decoded = path;
|
|
@@ -522,89 +522,90 @@ function assertStableModulePath(moduleId, commandName, path) {
|
|
|
522
522
|
try {
|
|
523
523
|
next = decodeURIComponent(decoded);
|
|
524
524
|
} catch {
|
|
525
|
-
throw new
|
|
525
|
+
throw new SurfaceError(`${label} contains malformed percent encoding.`);
|
|
526
526
|
}
|
|
527
527
|
if (next.includes("\\") || (next.match(/\//g) ?? []).length !== slashCount || next.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
528
|
-
throw new
|
|
528
|
+
throw new SurfaceError(`${label} contains encoded path separators or dot segments.`);
|
|
529
529
|
}
|
|
530
530
|
if (next === decoded) {
|
|
531
531
|
return;
|
|
532
532
|
}
|
|
533
533
|
decoded = next;
|
|
534
534
|
}
|
|
535
|
-
throw new
|
|
535
|
+
throw new SurfaceError(`${label} uses too many percent-encoding layers.`);
|
|
536
536
|
}
|
|
537
|
-
function assertCliCallableModulePath(moduleId, commandName, path) {
|
|
537
|
+
function assertCliCallableModulePath(at, moduleId, commandName, path) {
|
|
538
538
|
const unsupported = path.split("/").slice(1).find((segment) => segment === "*" || segment.includes("*") || (segment.startsWith(":") ? !/^:[A-Za-z0-9_]+$/.test(segment) : !/^[A-Za-z0-9._~-]+$/.test(segment)));
|
|
539
539
|
if (unsupported !== void 0) {
|
|
540
|
-
throw new
|
|
541
|
-
|
|
540
|
+
throw new SurfaceError(
|
|
541
|
+
`${at.label} command "${moduleId}.${commandName}" path segment "${unsupported}" is not CLI-callable. Module routes support URL-safe static segments and simple :name parameters only; optional, wildcard, and custom-regex parameters are not supported.`
|
|
542
542
|
);
|
|
543
543
|
}
|
|
544
544
|
}
|
|
545
|
-
function assertCommand(moduleId, name, command) {
|
|
545
|
+
function assertCommand(at, moduleId, name, command) {
|
|
546
546
|
if (!COMMAND_NAME.test(name)) {
|
|
547
|
-
throw new
|
|
548
|
-
|
|
547
|
+
throw new SurfaceError(
|
|
548
|
+
`${at.label} module "${moduleId}" has invalid command name "${name}". Use dot-separated alphanumeric names.`
|
|
549
549
|
);
|
|
550
550
|
}
|
|
551
|
-
assertText(
|
|
551
|
+
assertText(`${at.label} command "${moduleId}.${name}" summary`, command?.summary);
|
|
552
552
|
if (!EFFECTS.has(command?.effect)) {
|
|
553
|
-
throw new
|
|
554
|
-
|
|
553
|
+
throw new SurfaceError(
|
|
554
|
+
`${at.label} command "${moduleId}.${name}" has invalid effect ${JSON.stringify(command?.effect)}.`
|
|
555
555
|
);
|
|
556
556
|
}
|
|
557
557
|
if (!Array.isArray(command?.scopes) || command.scopes.length === 0 || command.scopes.some((scope) => typeof scope !== "string" || scope.trim().length === 0)) {
|
|
558
|
-
throw new
|
|
558
|
+
throw new SurfaceError(`${at.label} command "${moduleId}.${name}" must declare at least one non-empty scope.`);
|
|
559
559
|
}
|
|
560
560
|
const route2 = command?.route;
|
|
561
561
|
if (!route2 || typeof route2.handler !== "function" || !route2.method || !route2.path) {
|
|
562
|
-
throw new
|
|
562
|
+
throw new SurfaceError(`${at.label} command "${moduleId}.${name}" must carry a complete route.`);
|
|
563
563
|
}
|
|
564
|
-
assertStableModulePath(moduleId, name, route2.path);
|
|
565
|
-
assertCliCallableModulePath(moduleId, name, route2.path);
|
|
566
|
-
const modulePath = `${
|
|
564
|
+
assertStableModulePath(at, moduleId, name, route2.path);
|
|
565
|
+
assertCliCallableModulePath(at, moduleId, name, route2.path);
|
|
566
|
+
const modulePath = `${at.namespace}/${moduleId}/`;
|
|
567
567
|
if (!route2.path.startsWith(modulePath)) {
|
|
568
|
-
throw new
|
|
569
|
-
|
|
568
|
+
throw new SurfaceError(
|
|
569
|
+
`${at.label} command "${moduleId}.${name}" is at "${route2.path}", outside "${modulePath}". A module mounted on "${at.namespace}" keeps every command under "${modulePath}".`
|
|
570
570
|
);
|
|
571
571
|
}
|
|
572
572
|
if (route2.path.split("/").includes("..")) {
|
|
573
|
-
throw new
|
|
573
|
+
throw new SurfaceError(`${at.label} command "${moduleId}.${name}" path climbs out of its module namespace.`);
|
|
574
574
|
}
|
|
575
575
|
}
|
|
576
|
-
function
|
|
576
|
+
function validateSurfaceModule(module, namespace, wording = SURFACE_WORDING) {
|
|
577
|
+
const at = { namespace, label: capitalized(wording.noun) };
|
|
577
578
|
if (!MODULE_ID.test(module?.id ?? "")) {
|
|
578
|
-
throw new
|
|
579
|
-
|
|
579
|
+
throw new SurfaceError(
|
|
580
|
+
`${at.label} module id ${JSON.stringify(module?.id)} is invalid. Use lower-kebab-case.`
|
|
580
581
|
);
|
|
581
582
|
}
|
|
582
|
-
assertText(
|
|
583
|
-
assertText(
|
|
583
|
+
assertText(`${at.label} module "${module.id}" source`, module.source);
|
|
584
|
+
assertText(`${at.label} module "${module.id}" summary`, module.summary);
|
|
584
585
|
if (!CONTRACT_VERSION.test(module.contractVersion)) {
|
|
585
|
-
throw new
|
|
586
|
-
|
|
586
|
+
throw new SurfaceError(
|
|
587
|
+
`${at.label} module "${module.id}" contractVersion must be a semantic version.`
|
|
587
588
|
);
|
|
588
589
|
}
|
|
589
590
|
if (!module.commands || typeof module.commands !== "object" || Array.isArray(module.commands)) {
|
|
590
|
-
throw new
|
|
591
|
+
throw new SurfaceError(`${at.label} module "${module.id}" commands must be an object.`);
|
|
591
592
|
}
|
|
592
593
|
const signatures = /* @__PURE__ */ new Map();
|
|
593
594
|
const claimedRoutes = [];
|
|
594
595
|
for (const [name, command] of Object.entries(module.commands)) {
|
|
595
|
-
assertCommand(module.id, name, command);
|
|
596
|
+
assertCommand(at, module.id, name, command);
|
|
596
597
|
const signature = `${command.route.method} ${command.route.path}`;
|
|
597
598
|
const existing = signatures.get(signature);
|
|
598
599
|
if (existing) {
|
|
599
|
-
throw new
|
|
600
|
-
|
|
600
|
+
throw new SurfaceError(
|
|
601
|
+
`${at.label} module "${module.id}" commands "${existing}" and "${name}" both use ${signature}.`
|
|
601
602
|
);
|
|
602
603
|
}
|
|
603
604
|
signatures.set(signature, name);
|
|
604
|
-
const overlapping = claimedRoutes.find((claim) => claim.method === command.route.method &&
|
|
605
|
+
const overlapping = claimedRoutes.find((claim) => claim.method === command.route.method && routePatternsOverlap(claim.path, command.route.path));
|
|
605
606
|
if (overlapping) {
|
|
606
|
-
throw new
|
|
607
|
-
|
|
607
|
+
throw new SurfaceError(
|
|
608
|
+
`${at.label} module "${module.id}" commands "${overlapping.name}" and "${name}" have overlapping ${command.route.method} routes ("${overlapping.path}" and "${command.route.path}").`
|
|
608
609
|
);
|
|
609
610
|
}
|
|
610
611
|
claimedRoutes.push({
|
|
@@ -616,180 +617,330 @@ function defineOpsModule(module) {
|
|
|
616
617
|
return module;
|
|
617
618
|
}
|
|
618
619
|
|
|
619
|
-
// src/
|
|
620
|
-
var
|
|
621
|
-
|
|
622
|
-
|
|
620
|
+
// src/surface/namespace.ts
|
|
621
|
+
var NAMESPACE = /^(?:\/[A-Za-z0-9_-]+)+$/;
|
|
622
|
+
function assertNamespace(namespace) {
|
|
623
|
+
if (typeof namespace !== "string" || !NAMESPACE.test(namespace)) {
|
|
624
|
+
throw new SurfaceError(
|
|
625
|
+
`Surface namespace ${JSON.stringify(namespace)} is invalid. Use an absolute path of [A-Za-z0-9_-] segments without a trailing slash, such as "/_app".`
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function manifestPathOf(namespace) {
|
|
630
|
+
return `${namespace}/_manifest`;
|
|
631
|
+
}
|
|
632
|
+
function manifestNameOf(namespace) {
|
|
633
|
+
return `get${namespace.slice(1).replace(/_[a-z]|[A-Z_/-]/g, encodedCharacter)}Manifest`;
|
|
634
|
+
}
|
|
635
|
+
var ESCAPES = { "_": "_", "-": "d", "/": "s" };
|
|
636
|
+
function encodedCharacter(match) {
|
|
637
|
+
if (match.length === 2) {
|
|
638
|
+
return match.charAt(1).toUpperCase();
|
|
639
|
+
}
|
|
640
|
+
return `_${ESCAPES[match] ?? match}`;
|
|
641
|
+
}
|
|
642
|
+
function toSurfacePath(namespace, path, wording) {
|
|
643
|
+
const label = capitalized(wording.noun);
|
|
644
|
+
if (!path.startsWith("/")) {
|
|
645
|
+
throw new SurfaceError(
|
|
646
|
+
`${label} route path "${path}" must start with "/". It is appended to "${namespace}", so "${path}" would read as "${namespace}${path}".`
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
if (path === "/") {
|
|
650
|
+
throw new SurfaceError(
|
|
651
|
+
`${label} route path "/" names no command \u2014 "${namespace}" itself is not a command.`
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
return namespace + path;
|
|
655
|
+
}
|
|
656
|
+
function surfaceRouteBuilder(namespace, wording) {
|
|
657
|
+
const method = (name) => (path) => route[name](toSurfacePath(namespace, path, wording));
|
|
658
|
+
return {
|
|
659
|
+
get: method("get"),
|
|
660
|
+
post: method("post"),
|
|
661
|
+
put: method("put"),
|
|
662
|
+
patch: method("patch"),
|
|
663
|
+
delete: method("delete")
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// src/surface/define-surface.ts
|
|
623
668
|
function isRouter2(value) {
|
|
624
669
|
return value !== null && typeof value === "object" && "routes" in value && "_routes" in value;
|
|
625
670
|
}
|
|
626
671
|
function isRouteDef2(value) {
|
|
627
672
|
return value !== null && typeof value === "object" && "handler" in value;
|
|
628
673
|
}
|
|
629
|
-
function
|
|
674
|
+
function assertSurfaceRoute(plan, name, def) {
|
|
675
|
+
const label = capitalized(plan.wording.noun);
|
|
630
676
|
if (!def.method || !def.path) {
|
|
631
|
-
throw new
|
|
632
|
-
|
|
677
|
+
throw new SurfaceError(
|
|
678
|
+
`${label} route "${name}" has no method or path. A command is invoked on the wire, so both are required.`
|
|
633
679
|
);
|
|
634
680
|
}
|
|
635
|
-
if (!def.path.startsWith(
|
|
636
|
-
throw new
|
|
637
|
-
|
|
681
|
+
if (!def.path.startsWith(plan.prefix)) {
|
|
682
|
+
throw new SurfaceError(
|
|
683
|
+
`${label} route "${name}" is at "${def.path}", outside "${plan.prefix}". Build ${plan.wording.noun} routes with ${plan.wording.builder} rather than \`route\` \u2014 it applies the namespace, so the path a definition carries is only the part the app owns.`
|
|
638
684
|
);
|
|
639
685
|
}
|
|
640
|
-
if (def.path ===
|
|
641
|
-
throw new
|
|
642
|
-
|
|
686
|
+
if (def.path === plan.manifestPath) {
|
|
687
|
+
throw new SurfaceError(
|
|
688
|
+
`${label} route "${name}" claims "${plan.manifestPath}", which is reserved for the manifest. The manifest is registered first, so this route would never answer.`
|
|
643
689
|
);
|
|
644
690
|
}
|
|
645
691
|
}
|
|
646
|
-
function
|
|
647
|
-
if (name ===
|
|
648
|
-
throw new
|
|
649
|
-
|
|
692
|
+
function assertSurfaceName(plan, name) {
|
|
693
|
+
if (name === plan.manifestName) {
|
|
694
|
+
throw new SurfaceError(
|
|
695
|
+
`${capitalized(plan.wording.noun)} route name "${plan.manifestName}" is reserved for the manifest route.`
|
|
650
696
|
);
|
|
651
697
|
}
|
|
652
698
|
}
|
|
653
|
-
function rebuildNestedRouter(name, router,
|
|
699
|
+
function rebuildNestedRouter(plan, name, router, guards, inherited) {
|
|
654
700
|
if (router._packageRouters?.length > 0) {
|
|
655
|
-
throw new
|
|
656
|
-
|
|
701
|
+
throw new SurfaceError(
|
|
702
|
+
`${capitalized(plan.wording.noun)} router "${name}" mounts package routers with .packages(). Their routes bypass the prefix check and the auth injection, so a surface cannot carry them.`
|
|
657
703
|
);
|
|
658
704
|
}
|
|
659
705
|
const handedDown = [...inherited, ...router._globalMiddlewares ?? []];
|
|
660
706
|
let rebuilt = defineUnmappedRouter(
|
|
661
|
-
secureRoutes(router.routes,
|
|
707
|
+
secureRoutes(plan, router.routes, guards, handedDown)
|
|
662
708
|
);
|
|
663
709
|
if (router._contractVersion) {
|
|
664
710
|
rebuilt = rebuilt.contractVersion(router._contractVersion);
|
|
665
711
|
}
|
|
666
712
|
return rebuilt;
|
|
667
713
|
}
|
|
668
|
-
function secureRoutes(routes,
|
|
714
|
+
function secureRoutes(plan, routes, guards, inherited = []) {
|
|
669
715
|
const secured = {};
|
|
670
716
|
for (const [name, entry] of Object.entries(routes)) {
|
|
671
|
-
|
|
717
|
+
assertSurfaceName(plan, name);
|
|
672
718
|
if (isRouter2(entry)) {
|
|
673
|
-
secured[name] = rebuildNestedRouter(name, entry,
|
|
719
|
+
secured[name] = rebuildNestedRouter(plan, name, entry, guards, inherited);
|
|
674
720
|
continue;
|
|
675
721
|
}
|
|
676
722
|
if (!isRouteDef2(entry)) {
|
|
677
|
-
throw new
|
|
723
|
+
throw new SurfaceError(
|
|
724
|
+
`${capitalized(plan.wording.noun)} router entry "${name}" is neither a route nor a router.`
|
|
725
|
+
);
|
|
678
726
|
}
|
|
679
|
-
|
|
727
|
+
assertSurfaceRoute(plan, name, entry);
|
|
680
728
|
secured[name] = {
|
|
681
729
|
...entry,
|
|
682
|
-
middlewares:
|
|
730
|
+
middlewares: commandChain(plan, name, guards.get(name), [...inherited, ...entry.middlewares ?? []])
|
|
683
731
|
};
|
|
684
732
|
}
|
|
685
733
|
return secured;
|
|
686
734
|
}
|
|
687
|
-
function
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
735
|
+
function commandChain(plan, name, guard, declared) {
|
|
736
|
+
if (guard === void 0) {
|
|
737
|
+
return [plan.auth, ...declared];
|
|
738
|
+
}
|
|
739
|
+
const guardName = middlewareNameOf(guard);
|
|
740
|
+
if (guardName !== void 0) {
|
|
741
|
+
refuseGuardCollision(plan, name, guardName, declared);
|
|
742
|
+
}
|
|
743
|
+
return [plan.auth, guard, ...declared];
|
|
744
|
+
}
|
|
745
|
+
function refuseGuardCollision(plan, name, guardName, declared) {
|
|
746
|
+
const noun = capitalized(plan.wording.noun);
|
|
747
|
+
if (middlewareNameOf(plan.auth) === guardName) {
|
|
748
|
+
throw new SurfaceError(
|
|
749
|
+
`${noun} command "${name}"'s guard shares the name "${guardName}" with the surface's auth middleware. Route registration keeps one middleware per name, so the command's guard would never run. Give one of the two a distinct name.`
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
if (declared.some((middleware) => middlewareNameOf(middleware) === guardName)) {
|
|
753
|
+
throw new SurfaceError(
|
|
754
|
+
`${noun} command "${name}" declares a middleware named "${guardName}", the name of the guard authorize gave it. Route registration keeps one middleware per name, so the command's own guard would never run. Give one of the two a distinct name.`
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
function middlewareNameOf(middleware) {
|
|
759
|
+
return typeof middleware === "function" ? void 0 : middleware.name;
|
|
760
|
+
}
|
|
761
|
+
function guardsFor(plan, commands) {
|
|
762
|
+
return new Map(commands.map((command) => [command.name, plan.authorize?.(command)]));
|
|
763
|
+
}
|
|
764
|
+
function assertModuleCommandFree(plan, command, appCommands, claimed) {
|
|
765
|
+
const noun = plan.wording.noun;
|
|
766
|
+
if (claimed.names.has(command.name)) {
|
|
767
|
+
throw new SurfaceError(`Two ${noun} commands are named "${command.name}".`);
|
|
768
|
+
}
|
|
769
|
+
const signature = `${command.method} ${command.path}`;
|
|
770
|
+
const existing = claimed.signatures.get(signature);
|
|
771
|
+
if (existing) {
|
|
772
|
+
throw new SurfaceError(
|
|
773
|
+
`${capitalized(noun)} commands "${existing}" and "${command.name}" both use ${signature}.`
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
const overlapping = appCommands.find((app) => app.method === command.method && routePatternsOverlap(app.path, command.path));
|
|
777
|
+
if (overlapping) {
|
|
778
|
+
throw new SurfaceError(
|
|
779
|
+
`App ${noun} command "${overlapping.name}" at ${command.method} ${overlapping.path} overlaps module command "${command.name}" at ${command.method} ${command.path}. Which one answers cannot depend on route registration order.`
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
claimed.names.add(command.name);
|
|
783
|
+
claimed.signatures.set(signature, command.name);
|
|
784
|
+
}
|
|
785
|
+
function compileModules(plan, appCommands) {
|
|
786
|
+
const compiled = { descriptors: [], commands: [], routes: {} };
|
|
691
787
|
const moduleIds = /* @__PURE__ */ new Set();
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
appCommands.map((command) => [`${command.method} ${command.path}`, command.name])
|
|
695
|
-
|
|
696
|
-
for (const rawModule of modules) {
|
|
697
|
-
const module =
|
|
788
|
+
const claimed = {
|
|
789
|
+
names: new Set(appCommands.map((command) => command.name)),
|
|
790
|
+
signatures: new Map(appCommands.map((command) => [`${command.method} ${command.path}`, command.name]))
|
|
791
|
+
};
|
|
792
|
+
for (const rawModule of plan.modules) {
|
|
793
|
+
const module = validateSurfaceModule(rawModule, plan.namespace, plan.wording);
|
|
698
794
|
if (moduleIds.has(module.id)) {
|
|
699
|
-
throw new
|
|
795
|
+
throw new SurfaceError(`Two ${plan.wording.noun} modules use id "${module.id}".`);
|
|
700
796
|
}
|
|
701
797
|
moduleIds.add(module.id);
|
|
702
|
-
descriptors.push({
|
|
798
|
+
compiled.descriptors.push({
|
|
703
799
|
id: module.id,
|
|
704
800
|
source: module.source,
|
|
705
801
|
contractVersion: module.contractVersion,
|
|
706
802
|
summary: module.summary
|
|
707
803
|
});
|
|
708
804
|
for (const [localName, definition] of Object.entries(module.commands)) {
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
throw new OpsRouterError(`Two ops commands are named "${name}".`);
|
|
712
|
-
}
|
|
713
|
-
commandNames.add(name);
|
|
714
|
-
const method = definition.route.method;
|
|
715
|
-
const path = definition.route.path;
|
|
716
|
-
const signature = `${method} ${path}`;
|
|
717
|
-
const existing = routeSignatures.get(signature);
|
|
718
|
-
if (existing) {
|
|
719
|
-
throw new OpsRouterError(
|
|
720
|
-
`Ops commands "${existing}" and "${name}" both use ${signature}.`
|
|
721
|
-
);
|
|
722
|
-
}
|
|
723
|
-
const overlappingAppCommand = appCommands.find((command) => command.method === method && opsRoutePatternsOverlap(command.path, path));
|
|
724
|
-
if (overlappingAppCommand) {
|
|
725
|
-
throw new OpsRouterError(
|
|
726
|
-
`App ops command "${overlappingAppCommand.name}" at ${method} ${overlappingAppCommand.path} overlaps module command "${name}" at ${method} ${path}. Which one answers cannot depend on route registration order.`
|
|
727
|
-
);
|
|
728
|
-
}
|
|
729
|
-
routeSignatures.set(signature, name);
|
|
730
|
-
commands.push({
|
|
731
|
-
name,
|
|
805
|
+
const command = {
|
|
806
|
+
name: `${module.id}.${localName}`,
|
|
732
807
|
module: module.id,
|
|
733
808
|
summary: definition.summary,
|
|
734
809
|
effect: definition.effect,
|
|
735
810
|
scopes: [...definition.scopes],
|
|
736
|
-
method,
|
|
737
|
-
path,
|
|
738
|
-
input:
|
|
739
|
-
}
|
|
740
|
-
|
|
811
|
+
method: definition.route.method,
|
|
812
|
+
path: definition.route.path,
|
|
813
|
+
input: collectSurfaceCommands({ [localName]: definition.route }, plan.wording)[0].input
|
|
814
|
+
};
|
|
815
|
+
assertModuleCommandFree(plan, command, appCommands, claimed);
|
|
816
|
+
compiled.commands.push(command);
|
|
817
|
+
compiled.routes[command.name] = { route: definition.route, command };
|
|
741
818
|
}
|
|
742
819
|
}
|
|
743
|
-
descriptors.sort((a, b) => a.id.localeCompare(b.id));
|
|
744
|
-
commands.sort((a, b) => a.name.localeCompare(b.name));
|
|
745
|
-
return
|
|
820
|
+
compiled.descriptors.sort((a, b) => a.id.localeCompare(b.id));
|
|
821
|
+
compiled.commands.sort((a, b) => a.name.localeCompare(b.name));
|
|
822
|
+
return compiled;
|
|
746
823
|
}
|
|
747
|
-
function secureModuleRoutes(
|
|
824
|
+
function secureModuleRoutes(plan, routes) {
|
|
748
825
|
const secured = {};
|
|
749
826
|
for (const [name, definition] of Object.entries(routes)) {
|
|
750
|
-
|
|
751
|
-
|
|
827
|
+
assertSurfaceName(plan, name);
|
|
828
|
+
assertSurfaceRoute(plan, name, definition.route);
|
|
752
829
|
secured[name] = {
|
|
753
830
|
...definition.route,
|
|
754
|
-
middlewares:
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
831
|
+
middlewares: commandChain(
|
|
832
|
+
plan,
|
|
833
|
+
name,
|
|
834
|
+
plan.authorize?.(definition.command),
|
|
835
|
+
definition.route.middlewares ?? []
|
|
836
|
+
)
|
|
759
837
|
};
|
|
760
838
|
}
|
|
761
839
|
return secured;
|
|
762
840
|
}
|
|
763
|
-
function
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
const
|
|
770
|
-
if (modules.length > 0 && !options.authorize) {
|
|
771
|
-
throw new OpsRouterError(
|
|
772
|
-
"createOpsRouter requires an authorize scope factory when modules are mounted."
|
|
773
|
-
);
|
|
774
|
-
}
|
|
775
|
-
const appCommands = collectOpsCommands(routes);
|
|
776
|
-
const moduleSurface = compileModules(modules, appCommands);
|
|
841
|
+
function manifestRoute(plan, manifest) {
|
|
842
|
+
const builder = route.get(plan.manifestPath);
|
|
843
|
+
return (plan.manifest === "public" ? builder.skip(["auth"]) : builder.use([plan.auth])).handler(async () => manifest);
|
|
844
|
+
}
|
|
845
|
+
function buildRouter(plan, routes) {
|
|
846
|
+
const appCommands = collectSurfaceCommands(routes, plan.wording);
|
|
847
|
+
const moduleSurface = compileModules(plan, appCommands);
|
|
777
848
|
const commands = [...appCommands, ...moduleSurface.commands].sort((a, b) => a.name.localeCompare(b.name));
|
|
778
849
|
const manifest = {
|
|
779
850
|
manifestVersion: 1,
|
|
780
851
|
...moduleSurface.descriptors.length > 0 ? { modules: moduleSurface.descriptors } : {},
|
|
781
852
|
commands
|
|
782
853
|
};
|
|
783
|
-
const secured = secureRoutes(routes,
|
|
784
|
-
const securedModules =
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
[OPS_MANIFEST_NAME]: manifestRoute,
|
|
854
|
+
const secured = secureRoutes(plan, routes, guardsFor(plan, appCommands));
|
|
855
|
+
const securedModules = secureModuleRoutes(plan, moduleSurface.routes);
|
|
856
|
+
return Object.defineProperty(defineUnmappedRouter({
|
|
857
|
+
[plan.manifestName]: manifestRoute(plan, manifest),
|
|
788
858
|
...secured,
|
|
789
859
|
...securedModules
|
|
790
|
-
});
|
|
860
|
+
}), "_surfaceNamespace", { value: plan.namespace, enumerable: false });
|
|
861
|
+
}
|
|
862
|
+
function planOf(options, wording) {
|
|
863
|
+
assertNamespace(options?.namespace);
|
|
864
|
+
if (!options.auth) {
|
|
865
|
+
throw new SurfaceError(
|
|
866
|
+
`${wording.factory} requires an auth middleware ({ auth: ... }). A surface reachable without authentication cannot be created.`
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
const modules = options.modules ?? [];
|
|
870
|
+
if (modules.length > 0 && !options.authorize) {
|
|
871
|
+
throw new SurfaceError(`${wording.factory} requires an authorize function when modules are mounted.`);
|
|
872
|
+
}
|
|
873
|
+
const manifest = options.manifest ?? "authenticated";
|
|
874
|
+
if (manifest !== "authenticated" && manifest !== "public") {
|
|
875
|
+
throw new SurfaceError(
|
|
876
|
+
`${wording.factory} manifest must be 'authenticated' or 'public', got ${JSON.stringify(manifest)}.`
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
namespace: options.namespace,
|
|
881
|
+
prefix: `${options.namespace}/`,
|
|
882
|
+
manifestPath: manifestPathOf(options.namespace),
|
|
883
|
+
manifestName: manifestNameOf(options.namespace),
|
|
884
|
+
auth: options.auth,
|
|
885
|
+
authorize: options.authorize,
|
|
886
|
+
manifest,
|
|
887
|
+
modules,
|
|
888
|
+
wording
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
function createSurface(options, wording) {
|
|
892
|
+
const plan = planOf(options, wording);
|
|
893
|
+
return {
|
|
894
|
+
namespace: plan.namespace,
|
|
895
|
+
route: surfaceRouteBuilder(plan.namespace, wording),
|
|
896
|
+
router: (routes) => buildRouter(plan, routes)
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// src/ops/wording.ts
|
|
901
|
+
var OPS_WORDING = {
|
|
902
|
+
noun: "ops",
|
|
903
|
+
builder: "`opsRoute`",
|
|
904
|
+
factory: "createOpsRouter"
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
// src/ops/ops-route.ts
|
|
908
|
+
var OPS_PATH_ROOT = "/_ops";
|
|
909
|
+
var opsRoute = surfaceRouteBuilder(OPS_PATH_ROOT, OPS_WORDING);
|
|
910
|
+
|
|
911
|
+
// src/ops/create-ops-router.ts
|
|
912
|
+
var OPS_PATH_PREFIX = "/_ops/";
|
|
913
|
+
var OPS_MANIFEST_PATH = "/_ops/_manifest";
|
|
914
|
+
function moduleScopeGuard(authorize) {
|
|
915
|
+
if (!authorize) {
|
|
916
|
+
return void 0;
|
|
917
|
+
}
|
|
918
|
+
return (command) => command.module === void 0 ? void 0 : authorize(...command.scopes ?? []);
|
|
919
|
+
}
|
|
920
|
+
function createOpsRouter(routes, options) {
|
|
921
|
+
if ((options?.modules ?? []).length > 0 && !options.authorize) {
|
|
922
|
+
throw new SurfaceError(
|
|
923
|
+
"createOpsRouter requires an authorize scope factory when modules are mounted."
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
return createSurface({
|
|
927
|
+
namespace: OPS_PATH_ROOT,
|
|
928
|
+
auth: options?.auth,
|
|
929
|
+
authorize: moduleScopeGuard(options?.authorize),
|
|
930
|
+
modules: options?.modules
|
|
931
|
+
}, OPS_WORDING).router(routes);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// src/ops/manifest.ts
|
|
935
|
+
function collectOpsCommands(routes) {
|
|
936
|
+
return collectSurfaceCommands(routes, OPS_WORDING);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// src/ops/module.ts
|
|
940
|
+
function defineOpsModule(module) {
|
|
941
|
+
return validateSurfaceModule(module, OPS_PATH_ROOT, OPS_WORDING);
|
|
791
942
|
}
|
|
792
943
|
|
|
793
|
-
export { OPS_MANIFEST_PATH, OPS_PATH_PREFIX, OPS_PATH_ROOT, OpsRouterError, collectOpsCommands, createOpsRouter, defineOpsModule, opsRoute };
|
|
944
|
+
export { OPS_MANIFEST_PATH, OPS_PATH_PREFIX, OPS_PATH_ROOT, SurfaceError as OpsRouterError, collectOpsCommands, createOpsRouter, defineOpsModule, opsRoute };
|
|
794
945
|
//# sourceMappingURL=index.js.map
|
|
795
946
|
//# sourceMappingURL=index.js.map
|