@spfn/core 0.3.0-beta.17 → 0.3.0-beta.19

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.
@@ -379,6 +379,7 @@ declare class RouteMapGeneratorError extends Error {
379
379
  * defineGenerator({
380
380
  * name: '@spfn/core:contract',
381
381
  * routerPath: './src/server/router.ts',
382
+ * eventRouterPath: './src/server/events.ts', // optional: contracted SSE events
382
383
  * outputDir: './contracts',
383
384
  * })
384
385
  * ]
@@ -401,6 +402,18 @@ interface ContractGeneratorConfig {
401
402
  * @default 'appRouter', falling back to the default export
402
403
  */
403
404
  routerExport?: string;
405
+ /**
406
+ * Path to the file exporting the `defineEventRouter()` result (relative to
407
+ * project root). When set, its contracted events are written into the
408
+ * document's `events` section; unset, the document has none.
409
+ * @example './src/server/events.ts'
410
+ */
411
+ eventRouterPath?: string;
412
+ /**
413
+ * Named export holding the event router.
414
+ * @default 'eventRouter', falling back to the default export
415
+ */
416
+ eventRouterExport?: string;
404
417
  /**
405
418
  * Directory holding current.json, released/ and usage/ (relative to project root)
406
419
  * @default './contracts'
@@ -466,7 +479,14 @@ interface RegistrationScan {
466
479
  exportName: string;
467
480
  /** What is being generated, named in the message: `route map`, `contract`. */
468
481
  subject: string;
482
+ /**
483
+ * The call the router is built with. `defineEventRouter` scans an event
484
+ * router file, whose event set the contract publishes the same way.
485
+ * @default 'defineRouter'
486
+ */
487
+ factory?: RouterFactory;
469
488
  }
489
+ type RouterFactory = 'defineRouter' | 'defineEventRouter';
470
490
  /**
471
491
  * Refuse a router whose route set depends on a condition.
472
492
  */
@@ -348,6 +348,18 @@ var ConditionalRegistrationError = class extends Error {
348
348
  this.name = "ConditionalRegistrationError";
349
349
  }
350
350
  };
351
+ var REFUSAL_WORDING = {
352
+ defineRouter: {
353
+ noun: "routes",
354
+ consequence: "a route production registers is left out, and the client that addresses it by name gets a 404",
355
+ remedy: "Register the route unconditionally and gate its behaviour inside the handler instead."
356
+ },
357
+ defineEventRouter: {
358
+ noun: "events",
359
+ consequence: "an event production streams is left out, and the client that subscribes to it by name gets a 400",
360
+ remedy: "Register the event unconditionally and decide who receives it with the stream's authorize hook instead."
361
+ }
362
+ };
351
363
  function assertUnconditionalRegistration(scan) {
352
364
  const compiler2 = typescript();
353
365
  const file = compiler2.createSourceFile(
@@ -358,11 +370,14 @@ function assertUnconditionalRegistration(scan) {
358
370
  false
359
371
  );
360
372
  const declarations = topLevelInitialisers(compiler2, file);
361
- const routes = routesLiteral(compiler2, declarations.get(scan.exportName), declarations, /* @__PURE__ */ new Set());
373
+ const routes = routesLiteral(compiler2, declarations.get(scan.exportName), declarations, /* @__PURE__ */ new Set(), factoryOf(scan));
362
374
  if (routes) {
363
375
  assertLiteral(compiler2, file, routes, declarations, /* @__PURE__ */ new Set(), scan);
364
376
  }
365
377
  }
378
+ function factoryOf(scan) {
379
+ return scan.factory ?? "defineRouter";
380
+ }
366
381
  function topLevelInitialisers(compiler2, file) {
367
382
  const found = /* @__PURE__ */ new Map();
368
383
  for (const statement of file.statements) {
@@ -404,7 +419,7 @@ function unwrap(compiler2, expression) {
404
419
  }
405
420
  return expression;
406
421
  }
407
- function routesLiteral(compiler2, expression, declarations, seen) {
422
+ function routesLiteral(compiler2, expression, declarations, seen, factory) {
408
423
  const node = unwrap(compiler2, expression);
409
424
  if (!node) {
410
425
  return void 0;
@@ -414,17 +429,17 @@ function routesLiteral(compiler2, expression, declarations, seen) {
414
429
  return void 0;
415
430
  }
416
431
  seen.add(node.text);
417
- return routesLiteral(compiler2, declarations.get(node.text), declarations, seen);
432
+ return routesLiteral(compiler2, declarations.get(node.text), declarations, seen, factory);
418
433
  }
419
434
  if (!compiler2.isCallExpression(node)) {
420
435
  return void 0;
421
436
  }
422
437
  const callee = unwrap(compiler2, node.expression);
423
- if (callee && compiler2.isIdentifier(callee) && callee.text === "defineRouter") {
438
+ if (callee && compiler2.isIdentifier(callee) && callee.text === factory) {
424
439
  const routes = unwrap(compiler2, node.arguments[0]);
425
440
  return routes && compiler2.isObjectLiteralExpression(routes) ? routes : void 0;
426
441
  }
427
- return callee && compiler2.isPropertyAccessExpression(callee) ? routesLiteral(compiler2, callee.expression, declarations, seen) : void 0;
442
+ return callee && compiler2.isPropertyAccessExpression(callee) ? routesLiteral(compiler2, callee.expression, declarations, seen, factory) : void 0;
428
443
  }
429
444
  function assertLiteral(compiler2, file, literal, declarations, seen, scan) {
430
445
  for (const property of literal.properties) {
@@ -432,7 +447,7 @@ function assertLiteral(compiler2, file, literal, declarations, seen, scan) {
432
447
  assertUnconditionalSpread(compiler2, file, property.expression, scan);
433
448
  continue;
434
449
  }
435
- const nested = routesLiteral(compiler2, mountedRouter(compiler2, property), declarations, seen);
450
+ const nested = routesLiteral(compiler2, mountedRouter(compiler2, property), declarations, seen, factoryOf(scan));
436
451
  if (nested) {
437
452
  assertLiteral(compiler2, file, nested, declarations, seen, scan);
438
453
  }
@@ -449,11 +464,12 @@ function assertUnconditionalSpread(compiler2, file, expression, scan) {
449
464
  if (!condition) {
450
465
  return;
451
466
  }
467
+ const wording = REFUSAL_WORDING[factoryOf(scan)];
452
468
  throw new ConditionalRegistrationError(
453
- `${scan.routerPath} registers routes conditionally: "...${spreadText(file, expression)}" (${condition}).
469
+ `${scan.routerPath} registers ${wording.noun} conditionally: "...${spreadText(file, expression)}" (${condition}).
454
470
 
455
- The ${scan.subject} is generated from the router as it loads, so a route set that depends on a flag or an environment describes whichever way the generator happened to run: a route production registers is left out, and the client that addresses it by name gets a 404.
456
- Register the route unconditionally and gate its behaviour inside the handler instead.`
471
+ The ${scan.subject} is generated from the router as it loads, so a set of ${wording.noun} that depends on a flag or an environment describes whichever way the generator happened to run: ${wording.consequence}.
472
+ ` + wording.remedy
457
473
  );
458
474
  }
459
475
  function conditionKind(compiler2, expression) {
@@ -985,10 +1001,51 @@ function loadRouter2(cwd, absoluteRouterPath, routerExport) {
985
1001
  `No router found in ${relative(cwd, absoluteRouterPath)}. Looked for: ${candidates.join(", ")}. Set "routerExport" to the export holding the defineRouter() result.`
986
1002
  );
987
1003
  }
1004
+ function isEventRouter(value) {
1005
+ return value !== null && typeof value === "object" && "events" in value && "eventNames" in value;
1006
+ }
1007
+ function loadEventRouter(cwd, absolutePath, eventRouterExport) {
1008
+ const module = loadRouterModule({
1009
+ cwd,
1010
+ absoluteRouterPath: absolutePath,
1011
+ subject: "contract",
1012
+ fail: (message) => new ContractGeneratorError(message)
1013
+ });
1014
+ const candidates = eventRouterExport ? [eventRouterExport] : ["eventRouter", "default"];
1015
+ const exportName = candidates.find((candidate) => isEventRouter(module[candidate]));
1016
+ if (exportName) {
1017
+ return { eventRouter: module[exportName], exportName };
1018
+ }
1019
+ throw new ContractGeneratorError(
1020
+ `No event router found in ${relative(cwd, absolutePath)}. Looked for: ${candidates.join(", ")}. Set "eventRouterExport" to the export holding the defineEventRouter() result.`
1021
+ );
1022
+ }
1023
+ function readEventRouter(cwd, eventRouterPath, eventRouterExport) {
1024
+ if (!eventRouterPath) {
1025
+ return void 0;
1026
+ }
1027
+ const absolutePath = join(cwd, eventRouterPath);
1028
+ if (!existsSync(absolutePath)) {
1029
+ throw new ContractGeneratorError(
1030
+ `Event router file not found: ${eventRouterPath}. The contract generator is configured with "eventRouterPath" but has nothing to read there.`
1031
+ );
1032
+ }
1033
+ const { eventRouter, exportName } = loadEventRouter(cwd, absolutePath, eventRouterExport);
1034
+ assertUnconditionalRegistration({
1035
+ routerPath: eventRouterPath,
1036
+ source: readFileSync(absolutePath, "utf-8"),
1037
+ exportName,
1038
+ subject: "contract",
1039
+ factory: "defineEventRouter"
1040
+ });
1041
+ return eventRouter;
1042
+ }
988
1043
  function createContractGenerator(config) {
989
1044
  const {
990
1045
  routerPath,
991
1046
  routerExport,
1047
+ eventRouterPath,
1048
+ eventRouterExport,
992
1049
  outputDir = "./contracts",
993
1050
  additionalRouteDirs = []
994
1051
  } = config;
@@ -1007,6 +1064,7 @@ Usage:
1007
1064
  name: "@spfn/core:contract",
1008
1065
  watchPatterns: [
1009
1066
  routerPath,
1067
+ ...eventRouterPath ? [eventRouterPath] : [],
1010
1068
  "src/server/routes/**/*.ts",
1011
1069
  ...additionalRouteDirs.map((dir) => `${dir}/**/*.ts`)
1012
1070
  ],
@@ -1028,7 +1086,7 @@ Usage:
1028
1086
  exportName,
1029
1087
  subject: "contract"
1030
1088
  });
1031
- const document = collectContractDocument(router);
1089
+ const document = collectContractDocument(router, readEventRouter(cwd, eventRouterPath, eventRouterExport));
1032
1090
  const changed = writeCurrentDocument(contractsDir, document);
1033
1091
  genLogger2.info(
1034
1092
  `${changed ? "Wrote" : "Verified"} ${relative(cwd, join(contractsDir, "current.json"))} (${document.operations.length} contracted operation(s))`