@spfn/core 0.3.0-beta.11 → 0.3.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codegen/index.d.ts +64 -15
- package/dist/codegen/index.js +474 -166
- package/dist/codegen/index.js.map +1 -1
- package/dist/contract/index.d.ts +1 -1
- package/dist/nextjs/index.d.ts +2 -2
- package/dist/nextjs/index.js +13 -13
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +1 -1
- package/dist/ops/index.d.ts +1 -1
- package/dist/ops/index.js +24 -29
- package/dist/ops/index.js.map +1 -1
- package/dist/route/index.d.ts +2 -2
- package/dist/route/index.js +31 -27
- package/dist/route/index.js.map +1 -1
- package/dist/{router-DJdpwuB6.d.ts → router-9L3El7nO.d.ts} +37 -1
- package/dist/server/index.js +21 -26
- package/dist/server/index.js.map +1 -1
- package/dist/{types-yv6z_mK7.d.ts → types-QoyjaJWw.d.ts} +6 -0
- package/package.json +1 -1
- package/src/codegen/README.md +117 -22
- package/src/contract/README.md +7 -1
- package/src/nextjs/README.md +4 -0
- package/src/route/README.md +14 -0
package/dist/codegen/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { watch } from 'chokidar';
|
|
2
|
-
import { join, relative, sep, dirname, resolve } from 'path';
|
|
2
|
+
import { join, relative, sep, dirname, resolve, isAbsolute } from 'path';
|
|
3
3
|
import mm from 'micromatch';
|
|
4
4
|
import { logger } from '@spfn/core/logger';
|
|
5
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
|
|
5
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, statSync, readdirSync } from 'fs';
|
|
6
6
|
import { createJiti } from 'jiti';
|
|
7
|
+
import { createRequire } from 'module';
|
|
7
8
|
import { collectContractDocument, writeCurrentDocument, checkContract, formatViolations } from '@spfn/core/contract';
|
|
8
9
|
|
|
9
10
|
// src/codegen/core/orchestrator.ts
|
|
@@ -334,70 +335,454 @@ async function createGeneratorsFromConfig(config, cwd) {
|
|
|
334
335
|
}
|
|
335
336
|
return generators2;
|
|
336
337
|
}
|
|
337
|
-
var
|
|
338
|
-
function
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
338
|
+
var compiler;
|
|
339
|
+
function typescript() {
|
|
340
|
+
compiler ??= createRequire(import.meta.url)("typescript");
|
|
341
|
+
return compiler;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/codegen/generators/contract-guard.ts
|
|
345
|
+
var ConditionalRegistrationError = class extends Error {
|
|
346
|
+
constructor(message) {
|
|
347
|
+
super(message);
|
|
348
|
+
this.name = "ConditionalRegistrationError";
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
function assertUnconditionalRegistration(scan) {
|
|
352
|
+
const compiler2 = typescript();
|
|
353
|
+
const file = compiler2.createSourceFile(
|
|
354
|
+
scan.routerPath,
|
|
355
|
+
scan.source,
|
|
356
|
+
compiler2.ScriptTarget.Latest,
|
|
357
|
+
/* setParentNodes */
|
|
358
|
+
false
|
|
359
|
+
);
|
|
360
|
+
const declarations = topLevelInitialisers(compiler2, file);
|
|
361
|
+
const routes = routesLiteral(compiler2, declarations.get(scan.exportName), declarations, /* @__PURE__ */ new Set());
|
|
362
|
+
if (routes) {
|
|
363
|
+
assertLiteral(compiler2, file, routes, declarations, /* @__PURE__ */ new Set(), scan);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function topLevelInitialisers(compiler2, file) {
|
|
367
|
+
const found = /* @__PURE__ */ new Map();
|
|
368
|
+
for (const statement of file.statements) {
|
|
369
|
+
if (compiler2.isExportAssignment(statement) && !statement.isExportEquals) {
|
|
370
|
+
found.set("default", statement.expression);
|
|
352
371
|
}
|
|
353
|
-
|
|
354
|
-
|
|
372
|
+
if (compiler2.isVariableStatement(statement)) {
|
|
373
|
+
collectDeclarations(compiler2, statement, found);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
for (const statement of file.statements) {
|
|
377
|
+
collectRenamedExports(compiler2, statement, found);
|
|
378
|
+
}
|
|
379
|
+
return found;
|
|
380
|
+
}
|
|
381
|
+
function collectDeclarations(compiler2, statement, found) {
|
|
382
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
383
|
+
if (compiler2.isIdentifier(declaration.name) && declaration.initializer) {
|
|
384
|
+
found.set(declaration.name.text, declaration.initializer);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function collectRenamedExports(compiler2, statement, found) {
|
|
389
|
+
if (!compiler2.isExportDeclaration(statement) || !statement.exportClause || !compiler2.isNamedExports(statement.exportClause)) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
for (const specifier of statement.exportClause.elements) {
|
|
393
|
+
if (specifier.propertyName && !found.has(specifier.name.text)) {
|
|
394
|
+
found.set(specifier.name.text, specifier.propertyName);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function unwrap(compiler2, expression) {
|
|
399
|
+
if (!expression) {
|
|
400
|
+
return void 0;
|
|
401
|
+
}
|
|
402
|
+
if (compiler2.isParenthesizedExpression(expression) || compiler2.isAsExpression(expression) || compiler2.isSatisfiesExpression(expression) || compiler2.isNonNullExpression(expression) || compiler2.isTypeAssertionExpression(expression)) {
|
|
403
|
+
return unwrap(compiler2, expression.expression);
|
|
404
|
+
}
|
|
405
|
+
return expression;
|
|
406
|
+
}
|
|
407
|
+
function routesLiteral(compiler2, expression, declarations, seen) {
|
|
408
|
+
const node = unwrap(compiler2, expression);
|
|
409
|
+
if (!node) {
|
|
410
|
+
return void 0;
|
|
411
|
+
}
|
|
412
|
+
if (compiler2.isIdentifier(node)) {
|
|
413
|
+
if (seen.has(node.text)) {
|
|
414
|
+
return void 0;
|
|
415
|
+
}
|
|
416
|
+
seen.add(node.text);
|
|
417
|
+
return routesLiteral(compiler2, declarations.get(node.text), declarations, seen);
|
|
418
|
+
}
|
|
419
|
+
if (!compiler2.isCallExpression(node)) {
|
|
420
|
+
return void 0;
|
|
421
|
+
}
|
|
422
|
+
const callee = unwrap(compiler2, node.expression);
|
|
423
|
+
if (callee && compiler2.isIdentifier(callee) && callee.text === "defineRouter") {
|
|
424
|
+
const routes = unwrap(compiler2, node.arguments[0]);
|
|
425
|
+
return routes && compiler2.isObjectLiteralExpression(routes) ? routes : void 0;
|
|
426
|
+
}
|
|
427
|
+
return callee && compiler2.isPropertyAccessExpression(callee) ? routesLiteral(compiler2, callee.expression, declarations, seen) : void 0;
|
|
428
|
+
}
|
|
429
|
+
function assertLiteral(compiler2, file, literal, declarations, seen, scan) {
|
|
430
|
+
for (const property of literal.properties) {
|
|
431
|
+
if (compiler2.isSpreadAssignment(property)) {
|
|
432
|
+
assertUnconditionalSpread(compiler2, file, property.expression, scan);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const nested = routesLiteral(compiler2, mountedRouter(compiler2, property), declarations, seen);
|
|
436
|
+
if (nested) {
|
|
437
|
+
assertLiteral(compiler2, file, nested, declarations, seen, scan);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function mountedRouter(compiler2, property) {
|
|
442
|
+
if (compiler2.isPropertyAssignment(property)) {
|
|
443
|
+
return property.initializer;
|
|
444
|
+
}
|
|
445
|
+
return compiler2.isShorthandPropertyAssignment(property) ? property.name : void 0;
|
|
446
|
+
}
|
|
447
|
+
function assertUnconditionalSpread(compiler2, file, expression, scan) {
|
|
448
|
+
const condition = conditionKind(compiler2, expression);
|
|
449
|
+
if (!condition) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
throw new ConditionalRegistrationError(
|
|
453
|
+
`${scan.routerPath} registers routes conditionally: "...${spreadText(file, expression)}" (${condition}).
|
|
454
|
+
|
|
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.`
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
function conditionKind(compiler2, expression) {
|
|
460
|
+
const node = unwrap(compiler2, expression);
|
|
461
|
+
if (!node) {
|
|
462
|
+
return void 0;
|
|
355
463
|
}
|
|
356
|
-
|
|
464
|
+
if (compiler2.isConditionalExpression(node)) {
|
|
465
|
+
return "a ternary";
|
|
466
|
+
}
|
|
467
|
+
if (!compiler2.isBinaryExpression(node)) {
|
|
468
|
+
return void 0;
|
|
469
|
+
}
|
|
470
|
+
switch (node.operatorToken.kind) {
|
|
471
|
+
case compiler2.SyntaxKind.AmpersandAmpersandToken:
|
|
472
|
+
return "&&";
|
|
473
|
+
case compiler2.SyntaxKind.BarBarToken:
|
|
474
|
+
return "||";
|
|
475
|
+
case compiler2.SyntaxKind.QuestionQuestionToken:
|
|
476
|
+
return "??";
|
|
477
|
+
default:
|
|
478
|
+
return void 0;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function spreadText(file, expression) {
|
|
482
|
+
return expression.getText(file).replace(/\s+/g, " ");
|
|
483
|
+
}
|
|
484
|
+
var loadLogger = logger.child("@spfn/core:codegen");
|
|
485
|
+
function pinNodeEnv() {
|
|
486
|
+
if (process.env.NODE_ENV) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
process.env.NODE_ENV = "production";
|
|
490
|
+
loadLogger.info('NODE_ENV was unset; pinned to "production" so generated output does not depend on the shell');
|
|
491
|
+
}
|
|
492
|
+
function parseHost(compiler2) {
|
|
493
|
+
return {
|
|
494
|
+
useCaseSensitiveFileNames: compiler2.sys.useCaseSensitiveFileNames,
|
|
495
|
+
readDirectory: () => [],
|
|
496
|
+
fileExists: compiler2.sys.fileExists,
|
|
497
|
+
readFile: compiler2.sys.readFile
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
var NO_INPUTS = 18003;
|
|
501
|
+
function targetPath(target, base) {
|
|
502
|
+
return resolve(base, target.endsWith("/*") ? target.slice(0, -2) : target);
|
|
503
|
+
}
|
|
504
|
+
var UNREADABLE_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM"]);
|
|
505
|
+
function isUnreadable(error) {
|
|
506
|
+
return UNREADABLE_CODES.has(error?.code ?? "");
|
|
357
507
|
}
|
|
358
|
-
function
|
|
359
|
-
const
|
|
360
|
-
|
|
508
|
+
function targetResolves(target, base) {
|
|
509
|
+
const path = targetPath(target, base);
|
|
510
|
+
if (!target.endsWith("/*")) {
|
|
511
|
+
return existsSync(path);
|
|
512
|
+
}
|
|
361
513
|
try {
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
if (importPath.startsWith(".")) {
|
|
368
|
-
importPaths.push(importPath);
|
|
369
|
-
}
|
|
514
|
+
const stats = statSync(path, { throwIfNoEntry: false });
|
|
515
|
+
return stats?.isDirectory() === true && readdirSync(path).length > 0;
|
|
516
|
+
} catch (error) {
|
|
517
|
+
if (!isUnreadable(error)) {
|
|
518
|
+
throw error;
|
|
370
519
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function chooseTarget(pattern, targets, base) {
|
|
524
|
+
const present = targets.filter((target2) => targetResolves(target2, base));
|
|
525
|
+
const [target] = present.length > 0 ? present : targets;
|
|
526
|
+
if (!target) {
|
|
527
|
+
return void 0;
|
|
528
|
+
}
|
|
529
|
+
if (present.length > 1) {
|
|
530
|
+
loadLogger.warn(
|
|
531
|
+
`tsconfig "paths" maps ${pattern} to ${present.length} targets that could each resolve a module; while the router loads, only the first (${target}) does`
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
if (present.length === 0 && targets.length > 1) {
|
|
535
|
+
loadLogger.warn(
|
|
536
|
+
`tsconfig "paths" maps ${pattern} to ${targets.length} targets and none of them exists with anything in it; ${target} is used, and an import through it will name itself as unresolved`
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
return { target, others: targets.filter((candidate) => candidate !== target) };
|
|
540
|
+
}
|
|
541
|
+
function toAlias(pattern, targets, base) {
|
|
542
|
+
const choice = chooseTarget(pattern, targets, base);
|
|
543
|
+
if (!choice) {
|
|
544
|
+
return void 0;
|
|
545
|
+
}
|
|
546
|
+
const { target, others } = choice;
|
|
547
|
+
if (!pattern.includes("*")) {
|
|
548
|
+
return { from: pattern, to: resolve(base, target), pattern, target, others };
|
|
549
|
+
}
|
|
550
|
+
if (!pattern.endsWith("/*") || !target.endsWith("/*")) {
|
|
551
|
+
loadLogger.warn(
|
|
552
|
+
`tsconfig "paths" maps ${pattern} to ${target}, which is not a directory prefix mapping; it will not resolve while the router loads`
|
|
553
|
+
);
|
|
554
|
+
return void 0;
|
|
555
|
+
}
|
|
556
|
+
return { from: pattern.slice(0, -1), to: resolve(base, target.slice(0, -2)), pattern, target, others };
|
|
557
|
+
}
|
|
558
|
+
function readCompilerOptions(configPath, cwd) {
|
|
559
|
+
if (!existsSync(configPath)) {
|
|
560
|
+
return void 0;
|
|
561
|
+
}
|
|
562
|
+
const compiler2 = typescript();
|
|
563
|
+
const { config, error } = compiler2.readConfigFile(configPath, compiler2.sys.readFile);
|
|
564
|
+
if (error || !config) {
|
|
565
|
+
loadLogger.warn(`Could not read ${relative(cwd, configPath)}; its path aliases will not resolve`);
|
|
566
|
+
return void 0;
|
|
567
|
+
}
|
|
568
|
+
const parsed = compiler2.parseJsonConfigFileContent(config, parseHost(compiler2), dirname(configPath));
|
|
569
|
+
for (const diagnostic of parsed.errors) {
|
|
570
|
+
if (diagnostic.code !== NO_INPUTS) {
|
|
571
|
+
loadLogger.warn(
|
|
572
|
+
`${relative(cwd, configPath)}: ${compiler2.flattenDiagnosticMessageText(diagnostic.messageText, " ")} \u2014 path aliases it should have declared may be missing while the router loads`
|
|
573
|
+
);
|
|
389
574
|
}
|
|
575
|
+
}
|
|
576
|
+
return parsed.options;
|
|
577
|
+
}
|
|
578
|
+
function isWithin(root, directory) {
|
|
579
|
+
const step = relative(root, directory);
|
|
580
|
+
return step === "" || !step.startsWith("..") && !isAbsolute(step);
|
|
581
|
+
}
|
|
582
|
+
function searchDirectories(cwd, startDir) {
|
|
583
|
+
const root = resolve(cwd);
|
|
584
|
+
const directories = [];
|
|
585
|
+
for (let directory = resolve(startDir); isWithin(root, directory); directory = dirname(directory)) {
|
|
586
|
+
directories.push(directory);
|
|
587
|
+
if (directory === root) {
|
|
588
|
+
return directories;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return [root];
|
|
592
|
+
}
|
|
593
|
+
function aliasesOf(paths, base) {
|
|
594
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
595
|
+
for (const [pattern, targets] of Object.entries(paths)) {
|
|
596
|
+
const alias = toAlias(pattern, targets, base);
|
|
597
|
+
if (alias && !aliases.has(alias.from)) {
|
|
598
|
+
aliases.set(alias.from, alias);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return [...aliases.values()];
|
|
602
|
+
}
|
|
603
|
+
function projectAliases(cwd, startDir) {
|
|
604
|
+
for (const directory of searchDirectories(cwd, startDir)) {
|
|
605
|
+
const options = readCompilerOptions(join(directory, "tsconfig.json"), cwd);
|
|
606
|
+
if (options?.paths) {
|
|
607
|
+
const declaredIn = options.pathsBasePath;
|
|
608
|
+
return aliasesOf(options.paths, options.baseUrl ?? declaredIn ?? directory);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return [];
|
|
612
|
+
}
|
|
613
|
+
function describeFailure(error, subject, aliases) {
|
|
614
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
615
|
+
const unresolved = /Cannot find module '([^']+)'/.exec(message);
|
|
616
|
+
if (!unresolved) {
|
|
617
|
+
return `${message}
|
|
618
|
+
|
|
619
|
+
The ${subject} is read from the loaded router, so a route module must be importable without side effects. Check that nothing at module scope opens a connection or reads a missing environment value.`;
|
|
620
|
+
}
|
|
621
|
+
return `${message.split("\n")[0]}
|
|
622
|
+
|
|
623
|
+
"${unresolved[1]}" did not resolve while the router was being imported. Imports resolve through Node and through the "paths" of the nearest tsconfig.json that declares them, searching from the router file up to the project root. Check that the specifier is mapped in one of those, and that what it points at exists: a workspace package resolves to a dist/ that has to have been built.` + describePassedOverTargets(unresolved[1], aliases);
|
|
624
|
+
}
|
|
625
|
+
function describePassedOverTargets(specifier, aliases) {
|
|
626
|
+
const alias = aliases.find((entry) => specifier.startsWith(entry.from) || specifier.startsWith(entry.to));
|
|
627
|
+
if (!alias || alias.others.length === 0) {
|
|
628
|
+
return "";
|
|
629
|
+
}
|
|
630
|
+
return `
|
|
631
|
+
|
|
632
|
+
It was resolved through "${alias.target}", the first target of the "paths" entry "${alias.pattern}" that names a directory holding anything. That entry also lists ${alias.others.map((other) => `"${other}"`).join(", ")}, which the router load never tried: one alias per key is all the loader can express, so the whole load goes through a single target.`;
|
|
633
|
+
}
|
|
634
|
+
function loadRouterModule(load) {
|
|
635
|
+
const { cwd, absoluteRouterPath, subject, fail } = load;
|
|
636
|
+
const aliases = projectAliases(cwd, dirname(absoluteRouterPath));
|
|
637
|
+
const jiti = createJiti(cwd, {
|
|
638
|
+
interopDefault: true,
|
|
639
|
+
moduleCache: false,
|
|
640
|
+
alias: Object.fromEntries(aliases.map((alias) => [alias.from, alias.to]))
|
|
641
|
+
});
|
|
642
|
+
try {
|
|
643
|
+
return jiti(absoluteRouterPath);
|
|
390
644
|
} catch (error) {
|
|
391
|
-
|
|
645
|
+
throw fail(
|
|
646
|
+
`Failed to load ${relative(cwd, absoluteRouterPath)}: ${describeFailure(error, subject, aliases)}`
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
function isRouter(value) {
|
|
651
|
+
return value !== null && typeof value === "object" && "routes" in value && "_routes" in value;
|
|
652
|
+
}
|
|
653
|
+
function resolveRouterExport(module, candidates) {
|
|
654
|
+
for (const exportName of candidates) {
|
|
655
|
+
const candidate = module[exportName];
|
|
656
|
+
if (isRouter(candidate)) {
|
|
657
|
+
return { router: candidate, exportName };
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return void 0;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// src/codegen/generators/route-map.ts
|
|
664
|
+
var genLogger = logger.child("@spfn/core:route-map-generator");
|
|
665
|
+
var RouteMapGeneratorError = class extends Error {
|
|
666
|
+
constructor(message) {
|
|
667
|
+
super(message);
|
|
668
|
+
this.name = "RouteMapGeneratorError";
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
function isRouteDef(value) {
|
|
672
|
+
return value !== null && typeof value === "object" && "handler" in value;
|
|
673
|
+
}
|
|
674
|
+
var ROUTER_EXPORTS = ["appRouter", "default", "router"];
|
|
675
|
+
function loadRouter(cwd, absoluteRouterPath) {
|
|
676
|
+
const module = loadRouterModule({
|
|
677
|
+
cwd,
|
|
678
|
+
absoluteRouterPath,
|
|
679
|
+
subject: "route map",
|
|
680
|
+
fail: (message) => new RouteMapGeneratorError(message)
|
|
681
|
+
});
|
|
682
|
+
const resolved = resolveRouterExport(module, ROUTER_EXPORTS);
|
|
683
|
+
if (resolved) {
|
|
684
|
+
return resolved;
|
|
685
|
+
}
|
|
686
|
+
throw new RouteMapGeneratorError(
|
|
687
|
+
`No router found in ${relative(cwd, absoluteRouterPath)}. Looked for: ${ROUTER_EXPORTS.join(", ")}. Export the defineRouter() result under one of those names.`
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
var HTTP_METHODS = { GET: true, POST: true, PUT: true, PATCH: true, DELETE: true };
|
|
691
|
+
function addRoute(name, routeDef, trail, found) {
|
|
692
|
+
const where = trail.join(".");
|
|
693
|
+
if (!routeDef.method || !routeDef.path) {
|
|
694
|
+
genLogger.warn(
|
|
695
|
+
`Route "${where}" has no method or path and is left out of the map. The RPC client resolves a name to a method and a path, so both are required. A route reaches this state by being registered before .handler() was called \u2014 the server skips it too.`
|
|
696
|
+
);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (!Object.hasOwn(HTTP_METHODS, routeDef.method)) {
|
|
700
|
+
throw new RouteMapGeneratorError(
|
|
701
|
+
`Route "${where}" declares the method "${routeDef.method}", which is not an HttpMethod (${Object.keys(HTTP_METHODS).join(", ")}). The generated map is typed HttpMethod, so naming it would write a file that does not compile.`
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
const existing = found.get(name);
|
|
705
|
+
if (existing) {
|
|
706
|
+
throw new RouteMapGeneratorError(
|
|
707
|
+
`Two routes are both named "${name}" (${existing.trail} and ${where}). The RPC client addresses a route by its name alone, and a nested route registers under its own key, so a name must be unique across the whole router.`
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
found.set(name, { method: routeDef.method, path: routeDef.path, trail: where });
|
|
711
|
+
}
|
|
712
|
+
function collectPackageNames(router, trail, names) {
|
|
713
|
+
if (router._publishesRouteMap === false) {
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
for (const [name, entry] of Object.entries(router.routes)) {
|
|
717
|
+
if (isRouter(entry)) {
|
|
718
|
+
collectPackageNames(entry, [...trail, name], names);
|
|
719
|
+
} else if (isRouteDef(entry) && !names.has(name)) {
|
|
720
|
+
names.set(name, [...trail, name].join("."));
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
for (const [index, packageRouter] of (router._packageRouters ?? []).entries()) {
|
|
724
|
+
collectPackageNames(packageRouter, [...trail, `packages[${index}]`], names);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
function collectRoutes(router, trail, found, packageNames) {
|
|
728
|
+
for (const [name, entry] of Object.entries(router.routes)) {
|
|
729
|
+
if (isRouter(entry)) {
|
|
730
|
+
collectRoutes(entry, [...trail, name], found, packageNames);
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
if (!isRouteDef(entry)) {
|
|
734
|
+
genLogger.warn(
|
|
735
|
+
`Router entry "${[...trail, name].join(".")}" is neither a route nor a router (got ${typeof entry}) and is left out of the map. \`registerRoutes\` skips it too, so no name the client can call goes unanswered.`
|
|
736
|
+
);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
addRoute(name, entry, [...trail, name], found);
|
|
740
|
+
}
|
|
741
|
+
for (const [index, packageRouter] of (router._packageRouters ?? []).entries()) {
|
|
742
|
+
collectPackageNames(packageRouter, [...trail, `packages[${index}]`], packageNames);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
function assertNoPackageCollision(found, packageNames) {
|
|
746
|
+
for (const [name, where] of packageNames) {
|
|
747
|
+
const route = found.get(name);
|
|
748
|
+
if (!route) {
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
throw new RouteMapGeneratorError(
|
|
752
|
+
`The app route "${name}" (${route.trail}) is also registered by a package router (${where}). The app merges the two maps as { ...routeMap, ...packageRouteMap }, so the package entry wins the name at runtime while the generated types still describe the app's route \u2014 every call would go to the package's path. Rename the app route.`
|
|
753
|
+
);
|
|
392
754
|
}
|
|
393
|
-
return { importPaths, routeNames };
|
|
394
755
|
}
|
|
395
|
-
function
|
|
756
|
+
function collectRouteMap(router) {
|
|
757
|
+
const found = /* @__PURE__ */ new Map();
|
|
758
|
+
const packageNames = /* @__PURE__ */ new Map();
|
|
759
|
+
collectRoutes(router, ["router"], found, packageNames);
|
|
760
|
+
assertNoPackageCollision(found, packageNames);
|
|
761
|
+
return found;
|
|
762
|
+
}
|
|
763
|
+
function toObjectKey(name) {
|
|
764
|
+
if (name === "__proto__") {
|
|
765
|
+
return `[${JSON.stringify(name)}]`;
|
|
766
|
+
}
|
|
767
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
768
|
+
}
|
|
769
|
+
var SINGLE_QUOTABLE = /^[\x20-\x26\x28-\x5B\x5D-\x7E]*$/;
|
|
770
|
+
function toStringLiteral(value) {
|
|
771
|
+
if (SINGLE_QUOTABLE.test(value)) {
|
|
772
|
+
return `'${value}'`;
|
|
773
|
+
}
|
|
774
|
+
return JSON.stringify(value).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
775
|
+
}
|
|
776
|
+
function generateRouteMapContent(routes, nodeEnv) {
|
|
396
777
|
const lines = [
|
|
397
778
|
"/**",
|
|
398
779
|
" * Route Map (Auto-generated)",
|
|
399
780
|
" *",
|
|
400
781
|
" * DO NOT EDIT - This file is generated by @spfn/core:route-map generator",
|
|
782
|
+
" *",
|
|
783
|
+
` * Generated with NODE_ENV=${nodeEnv}. A route registered only under one environment is`,
|
|
784
|
+
" * named here only when the generator ran under it, so this line changing is that",
|
|
785
|
+
" * difference rather than an edit.",
|
|
401
786
|
" */",
|
|
402
787
|
"",
|
|
403
788
|
"import type { HttpMethod } from '@spfn/core/route';",
|
|
@@ -410,8 +795,8 @@ function generateRouteMapContent(routes) {
|
|
|
410
795
|
"",
|
|
411
796
|
"export const routeMap: Record<string, RouteInfo> = {"
|
|
412
797
|
];
|
|
413
|
-
for (const route of routes) {
|
|
414
|
-
lines.push(` ${
|
|
798
|
+
for (const [name, route] of routes) {
|
|
799
|
+
lines.push(` ${toObjectKey(name)}: { method: '${route.method}', path: ${toStringLiteral(route.path)} },`);
|
|
415
800
|
}
|
|
416
801
|
lines.push("};");
|
|
417
802
|
lines.push("");
|
|
@@ -440,9 +825,11 @@ Usage:
|
|
|
440
825
|
}
|
|
441
826
|
return {
|
|
442
827
|
name: "@spfn/core:route-map",
|
|
828
|
+
// Unchanged, including the deprecated dirs: what the map is *built* from
|
|
829
|
+
// moved to the loaded router, but what a rebuild should be *triggered* by
|
|
830
|
+
// is still every file a route can live in.
|
|
443
831
|
watchPatterns: [
|
|
444
832
|
routerPath,
|
|
445
|
-
// Watch route directories derived from router imports
|
|
446
833
|
"src/server/routes/**/*.ts",
|
|
447
834
|
...additionalRouteDirs.map((dir) => `${dir}/**/*.ts`)
|
|
448
835
|
],
|
|
@@ -456,97 +843,30 @@ Usage:
|
|
|
456
843
|
return;
|
|
457
844
|
}
|
|
458
845
|
if (debug) {
|
|
459
|
-
genLogger.info("
|
|
460
|
-
}
|
|
461
|
-
const { importPaths, routeNames } = parseRouterFile(absoluteRouterPath);
|
|
462
|
-
if (debug) {
|
|
463
|
-
genLogger.info("Found route imports", { count: importPaths.length, names: routeNames });
|
|
464
|
-
}
|
|
465
|
-
const routerDir = dirname(absoluteRouterPath);
|
|
466
|
-
const allRoutes = [];
|
|
467
|
-
for (const importPath of importPaths) {
|
|
468
|
-
let resolvedPath = resolve(routerDir, importPath);
|
|
469
|
-
if (!resolvedPath.endsWith(".ts")) {
|
|
470
|
-
const withTs = resolvedPath + ".ts";
|
|
471
|
-
if (existsSync(withTs)) {
|
|
472
|
-
resolvedPath = withTs;
|
|
473
|
-
} else {
|
|
474
|
-
const indexPath = join(resolvedPath, "index.ts");
|
|
475
|
-
if (existsSync(indexPath)) {
|
|
476
|
-
resolvedPath = indexPath;
|
|
477
|
-
} else {
|
|
478
|
-
resolvedPath = withTs;
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
if (existsSync(resolvedPath)) {
|
|
483
|
-
const routes = parseRouteFile(resolvedPath);
|
|
484
|
-
allRoutes.push(...routes);
|
|
485
|
-
if (debug) {
|
|
486
|
-
genLogger.info(`Parsed ${routes.length} routes from ${relative(cwd, resolvedPath)}`);
|
|
487
|
-
}
|
|
488
|
-
}
|
|
846
|
+
genLogger.info("Loading router", { path: absoluteRouterPath });
|
|
489
847
|
}
|
|
490
|
-
|
|
848
|
+
pinNodeEnv();
|
|
849
|
+
const { router, exportName } = loadRouter(cwd, absoluteRouterPath);
|
|
850
|
+
assertUnconditionalRegistration({
|
|
851
|
+
routerPath,
|
|
852
|
+
source: readFileSync(absoluteRouterPath, "utf-8"),
|
|
853
|
+
exportName,
|
|
854
|
+
subject: "route map"
|
|
855
|
+
});
|
|
856
|
+
const routes = collectRouteMap(router);
|
|
491
857
|
if (debug) {
|
|
492
|
-
genLogger.info(`Found ${
|
|
858
|
+
genLogger.info(`Found ${routes.size} routes`, { names: [...routes.keys()] });
|
|
493
859
|
}
|
|
494
|
-
const content = generateRouteMapContent(exportedRoutes);
|
|
495
860
|
const outputDir = dirname(absoluteOutputPath);
|
|
496
861
|
if (!existsSync(outputDir)) {
|
|
497
862
|
mkdirSync(outputDir, { recursive: true });
|
|
498
863
|
}
|
|
499
|
-
|
|
500
|
-
|
|
864
|
+
const nodeEnv = process.env.NODE_ENV ?? "unset";
|
|
865
|
+
writeFileSync(absoluteOutputPath, generateRouteMapContent(routes, nodeEnv), "utf-8");
|
|
866
|
+
genLogger.info(`Generated route map: ${relative(cwd, absoluteOutputPath)} (${routes.size} routes)`);
|
|
501
867
|
}
|
|
502
868
|
};
|
|
503
869
|
}
|
|
504
|
-
|
|
505
|
-
// src/codegen/generators/contract-guard.ts
|
|
506
|
-
var ConditionalRegistrationError = class extends Error {
|
|
507
|
-
constructor(message) {
|
|
508
|
-
super(message);
|
|
509
|
-
this.name = "ConditionalRegistrationError";
|
|
510
|
-
}
|
|
511
|
-
};
|
|
512
|
-
function defineRouterBlock(source) {
|
|
513
|
-
const start = source.indexOf("defineRouter(");
|
|
514
|
-
if (start === -1) {
|
|
515
|
-
return void 0;
|
|
516
|
-
}
|
|
517
|
-
const open = source.indexOf("{", start);
|
|
518
|
-
if (open === -1) {
|
|
519
|
-
return void 0;
|
|
520
|
-
}
|
|
521
|
-
let depth = 1;
|
|
522
|
-
let cursor = open + 1;
|
|
523
|
-
while (depth > 0 && cursor < source.length) {
|
|
524
|
-
if (source[cursor] === "{") depth++;
|
|
525
|
-
else if (source[cursor] === "}") depth--;
|
|
526
|
-
cursor++;
|
|
527
|
-
}
|
|
528
|
-
return source.slice(open + 1, cursor - 1).replace(/\/\/[^\n]*/g, "");
|
|
529
|
-
}
|
|
530
|
-
var PLAIN_REFERENCE = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*\s*$/;
|
|
531
|
-
function assertUnconditionalRegistration(routerPath, source) {
|
|
532
|
-
const block = defineRouterBlock(source);
|
|
533
|
-
if (!block) {
|
|
534
|
-
return;
|
|
535
|
-
}
|
|
536
|
-
for (const [, expression] of block.matchAll(/\.\.\.\s*([^,\n]+)/g)) {
|
|
537
|
-
if (PLAIN_REFERENCE.test(expression)) {
|
|
538
|
-
continue;
|
|
539
|
-
}
|
|
540
|
-
throw new ConditionalRegistrationError(
|
|
541
|
-
`${routerPath} registers routes conditionally: "...${expression.trim()}".
|
|
542
|
-
|
|
543
|
-
A contract has to describe what production serves. When the route set depends on a flag or an environment, the generated contract describes whichever way the generator happened to run.
|
|
544
|
-
Register contracted routes unconditionally, and gate behaviour inside the handler instead.`
|
|
545
|
-
);
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// src/codegen/generators/contract.ts
|
|
550
870
|
var genLogger2 = logger.child("@spfn/core:contract-generator");
|
|
551
871
|
var ContractGeneratorError = class extends Error {
|
|
552
872
|
constructor(message) {
|
|
@@ -554,39 +874,22 @@ var ContractGeneratorError = class extends Error {
|
|
|
554
874
|
this.name = "ContractGeneratorError";
|
|
555
875
|
}
|
|
556
876
|
};
|
|
557
|
-
function
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
module = jiti(absoluteRouterPath);
|
|
565
|
-
} catch (error) {
|
|
566
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
567
|
-
throw new ContractGeneratorError(
|
|
568
|
-
`Failed to load ${relative(cwd, absoluteRouterPath)}: ${message}
|
|
569
|
-
|
|
570
|
-
The contract is read from the loaded router, so a route module must be importable without side effects. Check that nothing at module scope opens a connection or reads a missing environment value.`
|
|
571
|
-
);
|
|
572
|
-
}
|
|
877
|
+
function loadRouter2(cwd, absoluteRouterPath, routerExport) {
|
|
878
|
+
const module = loadRouterModule({
|
|
879
|
+
cwd,
|
|
880
|
+
absoluteRouterPath,
|
|
881
|
+
subject: "contract",
|
|
882
|
+
fail: (message) => new ContractGeneratorError(message)
|
|
883
|
+
});
|
|
573
884
|
const candidates = routerExport ? [routerExport] : ["appRouter", "default", "router"];
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
return candidate;
|
|
578
|
-
}
|
|
885
|
+
const resolved = resolveRouterExport(module, candidates);
|
|
886
|
+
if (resolved) {
|
|
887
|
+
return resolved;
|
|
579
888
|
}
|
|
580
889
|
throw new ContractGeneratorError(
|
|
581
890
|
`No router found in ${relative(cwd, absoluteRouterPath)}. Looked for: ${candidates.join(", ")}. Set "routerExport" to the export holding the defineRouter() result.`
|
|
582
891
|
);
|
|
583
892
|
}
|
|
584
|
-
function pinNodeEnv() {
|
|
585
|
-
if (!process.env.NODE_ENV) {
|
|
586
|
-
process.env.NODE_ENV = "production";
|
|
587
|
-
genLogger2.info('NODE_ENV was unset; pinned to "production" so the contract does not depend on the shell');
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
893
|
function createContractGenerator(config) {
|
|
591
894
|
const {
|
|
592
895
|
routerPath,
|
|
@@ -623,8 +926,13 @@ Usage:
|
|
|
623
926
|
);
|
|
624
927
|
}
|
|
625
928
|
pinNodeEnv();
|
|
626
|
-
|
|
627
|
-
|
|
929
|
+
const { router, exportName } = loadRouter2(cwd, absoluteRouterPath, routerExport);
|
|
930
|
+
assertUnconditionalRegistration({
|
|
931
|
+
routerPath,
|
|
932
|
+
source: readFileSync(absoluteRouterPath, "utf-8"),
|
|
933
|
+
exportName,
|
|
934
|
+
subject: "contract"
|
|
935
|
+
});
|
|
628
936
|
const document = collectContractDocument(router);
|
|
629
937
|
const changed = writeCurrentDocument(contractsDir, document);
|
|
630
938
|
genLogger2.info(
|
|
@@ -660,6 +968,6 @@ var generators = {
|
|
|
660
968
|
contract: createContractGenerator
|
|
661
969
|
};
|
|
662
970
|
|
|
663
|
-
export { CodegenOrchestrator, ConditionalRegistrationError, ContractGeneratorError, assertUnconditionalRegistration, createGeneratorsFromConfig, defineConfig, defineGenerator, generators, loadCodegenConfig };
|
|
971
|
+
export { CodegenOrchestrator, ConditionalRegistrationError, ContractGeneratorError, RouteMapGeneratorError, assertUnconditionalRegistration, createGeneratorsFromConfig, defineConfig, defineGenerator, generators, loadCodegenConfig };
|
|
664
972
|
//# sourceMappingURL=index.js.map
|
|
665
973
|
//# sourceMappingURL=index.js.map
|