lambder 14.0.1 → 15.0.1

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.
Files changed (163) hide show
  1. package/CHANGELOG.md +450 -0
  2. package/README.md +14 -17
  3. package/dist/api/LambderApiDeclarations.d.ts +11 -5
  4. package/dist/api/LambderApiDefinition.d.ts +2 -1
  5. package/dist/api/LambderApiEnvelope.d.ts +2 -2
  6. package/dist/api/LambderApiEnvelope.js +8 -2
  7. package/dist/api/LambderApiGuards.d.ts +44 -5
  8. package/dist/api/LambderApiGuards.js +27 -0
  9. package/dist/api/LambderApiIdempotency.d.ts +14 -7
  10. package/dist/api/LambderApiIdempotency.js +39 -27
  11. package/dist/api/LambderApiPipeline.d.ts +47 -13
  12. package/dist/api/LambderApiPipeline.js +62 -20
  13. package/dist/api/LambderApiRateLimits.d.ts +28 -21
  14. package/dist/api/LambderApiRateLimits.js +55 -35
  15. package/dist/api/LambderApiRefusals.d.ts +26 -10
  16. package/dist/api/LambderApiRefusals.js +48 -15
  17. package/dist/api/LambderApiRequest.d.ts +21 -1
  18. package/dist/api/LambderApiRequest.js +31 -2
  19. package/dist/api/LambderApiSignature.d.ts +5 -5
  20. package/dist/api/LambderApiSignature.js +7 -5
  21. package/dist/build/checkApiRefusals.d.ts +59 -0
  22. package/dist/build/checkApiRefusals.js +442 -0
  23. package/dist/build/contractPrinterProcess.d.ts +11 -0
  24. package/dist/build/contractPrinterProcess.js +17 -0
  25. package/dist/build/generateApiFiles.d.ts +26 -5
  26. package/dist/build/generateApiFiles.js +68 -7
  27. package/dist/build/loadTypeScriptCompiler.d.ts +10 -0
  28. package/dist/build/loadTypeScriptCompiler.js +20 -0
  29. package/dist/build/writeApiContract.js +21 -15
  30. package/dist/build/writeApiSchemas.d.ts +109 -0
  31. package/dist/build/writeApiSchemas.js +308 -0
  32. package/dist/build.d.ts +8 -3
  33. package/dist/build.js +6 -2
  34. package/dist/client/LambderCaller.d.ts +25 -11
  35. package/dist/client/LambderCaller.js +57 -49
  36. package/dist/client/LambderUploadRunner.d.ts +55 -27
  37. package/dist/client/LambderUploadRunner.js +89 -34
  38. package/dist/client.d.ts +6 -6
  39. package/dist/client.js +2 -2
  40. package/dist/core/Lambder.d.ts +37 -26
  41. package/dist/core/Lambder.js +89 -32
  42. package/dist/core/LambderCallSummary.d.ts +1 -1
  43. package/dist/core/LambderContext.d.ts +59 -7
  44. package/dist/core/LambderContext.js +31 -15
  45. package/dist/core/LambderCrashHandling.d.ts +5 -0
  46. package/dist/core/LambderCrashHandling.js +11 -1
  47. package/dist/core/LambderCreateOptions.d.ts +28 -17
  48. package/dist/core/LambderCreateOptions.js +14 -7
  49. package/dist/core/LambderResolver.d.ts +3 -2
  50. package/dist/core/LambderResponseBuilder.d.ts +20 -9
  51. package/dist/core/LambderResponseBuilder.js +39 -8
  52. package/dist/core/LambderTemplatingEngine.d.ts +24 -6
  53. package/dist/core/LambderTemplatingEngine.js +23 -3
  54. package/dist/index.d.ts +15 -12
  55. package/dist/index.js +7 -5
  56. package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
  57. package/dist/invoke/LambderInvokeCaller.js +47 -9
  58. package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
  59. package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
  60. package/dist/mock/LambderMockAdapterDoor.js +10 -0
  61. package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
  62. package/dist/mock/LambderMockApiSchemas.js +150 -0
  63. package/dist/mock/LambderMockApp.d.ts +56 -48
  64. package/dist/mock/LambderMockApp.js +107 -62
  65. package/dist/mock/LambderMockBrowserCookies.js +18 -5
  66. package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
  67. package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
  68. package/dist/mock/LambderMockFailureInjector.js +12 -8
  69. package/dist/mock/LambderMockTypes.d.ts +8 -4
  70. package/dist/mock/lambderMockInvokeTransport.js +2 -9
  71. package/dist/mock/lambderMockMswHandler.d.ts +19 -13
  72. package/dist/mock/lambderMockMswHandler.js +25 -22
  73. package/dist/mock.d.ts +1 -0
  74. package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
  75. package/dist/secrets/LambderOneShotSecrets.js +6 -36
  76. package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
  77. package/dist/secrets/LambderPasswordHasher.js +168 -0
  78. package/dist/session/LambderSessionController.d.ts +37 -4
  79. package/dist/session/LambderSessionController.js +36 -6
  80. package/dist/session/LambderSessionManager.d.ts +33 -3
  81. package/dist/session/LambderSessionManager.js +55 -16
  82. package/dist/shared/LambderHtml.d.ts +23 -4
  83. package/dist/shared/LambderHtml.js +17 -5
  84. package/dist/shared/LambderI18n.d.ts +106 -16
  85. package/dist/shared/LambderI18n.js +259 -74
  86. package/dist/shared/contracts/LambderCache.d.ts +14 -1
  87. package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
  88. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  89. package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
  90. package/dist/shared/util/LambderBackoffTimer.js +40 -12
  91. package/dist/shared/util/LambderCallAbort.d.ts +22 -6
  92. package/dist/shared/util/LambderCallAbort.js +37 -9
  93. package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
  94. package/dist/shared/util/LambderErrorReportMark.js +22 -0
  95. package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
  96. package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
  97. package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
  98. package/dist/shared/util/LambderSignedClaims.js +54 -0
  99. package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
  100. package/dist/shared/util/LambderSwappableInstances.js +54 -0
  101. package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
  102. package/dist/shared/util/LambderTestingDoors.js +6 -2
  103. package/dist/shared/util/LambderTextDigest.d.ts +5 -5
  104. package/dist/shared/util/LambderTextDigest.js +5 -5
  105. package/dist/shared/wire/LambderApiContract.d.ts +1 -1
  106. package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
  107. package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
  108. package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
  109. package/dist/shared/wire/LambderApiOutcome.d.ts +21 -12
  110. package/dist/shared/wire/LambderApiOutcome.js +1 -1
  111. package/dist/shared/wire/LambderApiRefusal.d.ts +41 -14
  112. package/dist/shared/wire/LambderApiRefusal.js +3 -3
  113. package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
  114. package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
  115. package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
  116. package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
  117. package/dist/shared/wire/LambderCallOutcome.js +3 -2
  118. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
  119. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
  120. package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
  121. package/dist/shared/wire/LambderInvokeApiId.js +3 -3
  122. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
  123. package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
  124. package/dist/stores/LambderCacheValues.d.ts +2 -5
  125. package/dist/stores/LambderDdbCache.d.ts +15 -9
  126. package/dist/stores/LambderDdbCache.js +25 -6
  127. package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
  128. package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
  129. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
  130. package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
  131. package/dist/stores/LambderDdbSdk.d.ts +59 -29
  132. package/dist/stores/LambderDdbSdk.js +116 -44
  133. package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
  134. package/dist/stores/LambderDdbSessionStore.js +133 -68
  135. package/dist/stores/LambderMemoryCache.d.ts +2 -2
  136. package/dist/stores/LambderS3FileSource.d.ts +3 -2
  137. package/dist/stores/LambderS3FileSource.js +11 -13
  138. package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
  139. package/dist/stores/LambderS3UploadBucket.js +23 -0
  140. package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
  141. package/dist/stores/LambderStoredText.d.ts +26 -0
  142. package/dist/stores/LambderStoredText.js +25 -0
  143. package/dist/testing/LambderConformanceRunner.d.ts +9 -2
  144. package/dist/testing/LambderConformanceRunner.js +9 -2
  145. package/dist/testing/LambderTestApp.d.ts +65 -7
  146. package/dist/testing/LambderTestApp.js +101 -6
  147. package/dist/testing/LambderTestVisitor.d.ts +43 -12
  148. package/dist/testing/LambderTestVisitor.js +35 -11
  149. package/dist/testing/bootLambdaPackage.d.ts +105 -0
  150. package/dist/testing/bootLambdaPackage.js +265 -0
  151. package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
  152. package/dist/testing/lambdaPackageBootProcess.js +130 -0
  153. package/dist/testing/lambderCacheConformance.d.ts +26 -0
  154. package/dist/testing/lambderCacheConformance.js +287 -0
  155. package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
  156. package/dist/testing/lambderCacheStorageConformance.js +159 -0
  157. package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
  158. package/dist/testing/lambderSessionStoreConformance.js +3 -3
  159. package/dist/testing.d.ts +7 -1
  160. package/dist/testing.js +5 -0
  161. package/package.json +1 -6
  162. package/dist/shared/util/boundKeyField.d.ts +0 -20
  163. package/dist/shared/util/boundKeyField.js +0 -34
@@ -18,17 +18,17 @@ export type LambderApiSignatureEntry = {
18
18
  * schema that guard validates (the guardInput the client sends separately,
19
19
  * or the apiInput slice of the payload), whether it demands an idempotency
20
20
  * key, and every refusal code it may send with its data's schema, its status
21
- * and its notAuthorized flag. Anything
22
- * else (rate limits, a guard's parameter, the handler) changes nothing for a
23
- * client and is left out, so changing it never forces a reload.
21
+ * and its flags. Anything else (rate limits, a guard's parameter, the
22
+ * handler) changes nothing for a client and is left out, so changing it
23
+ * never forces a reload.
24
24
  *
25
25
  * The refusal codes count in full: a client's refusal type lists exactly the
26
26
  * codes the endpoint declares, so one built before a code was added would be
27
27
  * handed a code its types say cannot arrive. Adding a code, or changing a
28
28
  * code's data, reloads that endpoint's clients. A code's data is received,
29
29
  * so it is digested in the output position, where an extensibleEnum's values
30
- * leave the digest. Its status and flag count too: a caller reads the one
31
- * and routes on the other.
30
+ * leave the digest. Its status and flags count too: a caller reads the one
31
+ * and routes on the others.
32
32
  *
33
33
  * Schemas are hashed as built, descriptions and titles included, so a client
34
34
  * built against a different one reloads once. The exception is an output
@@ -56,17 +56,17 @@ const ownGuard = (guards, name) => guards !== undefined && Object.prototype.hasO
56
56
  * schema that guard validates (the guardInput the client sends separately,
57
57
  * or the apiInput slice of the payload), whether it demands an idempotency
58
58
  * key, and every refusal code it may send with its data's schema, its status
59
- * and its notAuthorized flag. Anything
60
- * else (rate limits, a guard's parameter, the handler) changes nothing for a
61
- * client and is left out, so changing it never forces a reload.
59
+ * and its flags. Anything else (rate limits, a guard's parameter, the
60
+ * handler) changes nothing for a client and is left out, so changing it
61
+ * never forces a reload.
62
62
  *
63
63
  * The refusal codes count in full: a client's refusal type lists exactly the
64
64
  * codes the endpoint declares, so one built before a code was added would be
65
65
  * handed a code its types say cannot arrive. Adding a code, or changing a
66
66
  * code's data, reloads that endpoint's clients. A code's data is received,
67
67
  * so it is digested in the output position, where an extensibleEnum's values
68
- * leave the digest. Its status and flag count too: a caller reads the one
69
- * and routes on the other.
68
+ * leave the digest. Its status and flags count too: a caller reads the one
69
+ * and routes on the others.
70
70
  *
71
71
  * Schemas are hashed as built, descriptions and titles included, so a client
72
72
  * built against a different one reloads once. The exception is an output
@@ -89,6 +89,8 @@ export const apiSignatureOf = async (definition, guards) => {
89
89
  data: jsonSchemaOf(declaration.data ? declaration.schema : undefined, "output"),
90
90
  status: declaration.status ?? 200,
91
91
  notAuthorized: declaration.notAuthorized === true,
92
+ // Written only when set, so a vocabulary with no such code digests as it did before the flag existed.
93
+ ...(declaration.sessionExpired ? { sessionExpired: true } : {}),
92
94
  }]);
93
95
  refusalShapes.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
94
96
  const description = {
@@ -0,0 +1,59 @@
1
+ export type LambderApiRefusalCheckOptions = {
2
+ /** The tsconfig.json the project compiles under, whose options and path aliases resolve its imports. Relative to the working directory. */
3
+ tsconfig: string;
4
+ /** The files the program starts from, relative to the working directory; everything they import is read too. Default: the tsconfig's own files. */
5
+ files?: string[];
6
+ /** Whether a refusal with no code is a finding, as it is a crash in an app whose vocabulary requires codes (declareRefusals's requireCodes). Default: true. */
7
+ requireCodes?: boolean;
8
+ /** Whether a handler handed no typed refuse is a finding, since nothing it reaches could be checked. Default: true; false lists such handlers without failing. */
9
+ requireTypedRefuse?: boolean;
10
+ };
11
+ /** What one handler's check found. */
12
+ export type LambderRefusalCheckFinding = {
13
+ /** The handler, as its registration names it: an endpoint's `group.action`, a mock entry's name, a guard's name. */
14
+ handler: string;
15
+ /** Where the handler is registered, as `file:line` relative to the working directory. */
16
+ at: string;
17
+ /**
18
+ * - `undeclared`: a code the handler can reach that it may not send.
19
+ * - `unused`: a code the handler's own `refusals` option names that nothing it reaches raises.
20
+ * - `uncoded`: a refusal with no code it can reach, where codes are required.
21
+ * - `unreadable`: a refusal it can reach whose code is not a string literal type.
22
+ * - `untraced`: a handler handed a typed refuse whose function cannot be
23
+ * found (a parameter, a dependency's value), so nothing it reaches was
24
+ * checked.
25
+ * - `unchecked`: a handler handed no typed refuse, built by an init that
26
+ * declared no refusal vocabulary, so there is nothing to hold what it
27
+ * reaches to. Not a finding with `requireTypedRefuse: false`.
28
+ */
29
+ problem: "undeclared" | "unused" | "uncoded" | "unreadable" | "untraced" | "unchecked";
30
+ /** The code, for `undeclared` and `unused`. */
31
+ code?: string;
32
+ /** Where the refusal is raised, as `file:line`, for `undeclared`, `uncoded` and `unreadable`. */
33
+ raisedAt?: string;
34
+ };
35
+ export type LambderApiRefusalCheckResult = {
36
+ /** False when the project could not be read, no handler in it could be checked, or any handler has a finding, an unchecked one included unless `requireTypedRefuse` is false. */
37
+ ok: boolean;
38
+ /** How many handlers were checked. */
39
+ handlers: number;
40
+ findings: LambderRefusalCheckFinding[];
41
+ /** What happened, as lines to print: a summary, then one line per finding. */
42
+ lines: string[];
43
+ };
44
+ /**
45
+ * Checks every handler of a project against the codes it may send, and
46
+ * answers what it found.
47
+ *
48
+ * ```ts
49
+ * import { checkApiRefusals } from "lambder/build";
50
+ *
51
+ * const result = await checkApiRefusals({ tsconfig: "server/tsconfig.json" });
52
+ * console.log(result.lines.join("\n"));
53
+ * process.exit(result.ok ? 0 : 1);
54
+ * ```
55
+ *
56
+ * Run it where the app runs its other build checks, or from a test: it
57
+ * compiles the project, so it takes as long as a type check does.
58
+ */
59
+ export declare const checkApiRefusals: (options: LambderApiRefusalCheckOptions) => Promise<LambderApiRefusalCheckResult>;
@@ -0,0 +1,442 @@
1
+ import { dirname, relative, resolve, sep } from "path";
2
+ import { fileURLToPath } from "url";
3
+ import { loadTypeScriptCompiler } from "./loadTypeScriptCompiler.js";
4
+ /** The registrations whose handler Lambder types a refuse for, by the name they are called under, and where each one's handler is. */
5
+ const HANDLER_REGISTRATIONS = {
6
+ defineApi: "second",
7
+ guard: "optionsHandler",
8
+ lambderGuard: "optionsHandler",
9
+ api: "secondOrItsHandler",
10
+ override: "secondOrItsHandler",
11
+ };
12
+ /**
13
+ * Checks every handler of a project against the codes it may send, and
14
+ * answers what it found.
15
+ *
16
+ * ```ts
17
+ * import { checkApiRefusals } from "lambder/build";
18
+ *
19
+ * const result = await checkApiRefusals({ tsconfig: "server/tsconfig.json" });
20
+ * console.log(result.lines.join("\n"));
21
+ * process.exit(result.ok ? 0 : 1);
22
+ * ```
23
+ *
24
+ * Run it where the app runs its other build checks, or from a test: it
25
+ * compiles the project, so it takes as long as a type check does.
26
+ */
27
+ export const checkApiRefusals = async (options) => {
28
+ const ts = await loadTypeScriptCompiler("checkApiRefusals reads the project");
29
+ const requireCodes = options.requireCodes ?? true;
30
+ const requireTypedRefuse = options.requireTypedRefuse ?? true;
31
+ const configPath = resolve(options.tsconfig);
32
+ const read = ts.readConfigFile(configPath, ts.sys.readFile);
33
+ if (read.error)
34
+ return failed(`✗ ${options.tsconfig} could not be read: ${ts.flattenDiagnosticMessageText(read.error.messageText, " ")}`);
35
+ const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(configPath), undefined, configPath);
36
+ if (parsed.errors.length)
37
+ return failed(`✗ ${options.tsconfig} has errors: ${parsed.errors.map((error) => ts.flattenDiagnosticMessageText(error.messageText, " ")).join("; ")}`);
38
+ const rootNames = options.files?.map((file) => resolve(file)) ?? parsed.fileNames;
39
+ const program = ts.createProgram({ rootNames, options: { ...parsed.options, noEmit: true, composite: false, incremental: false, declaration: false } });
40
+ const checker = program.getTypeChecker();
41
+ const lambderRoot = realPathOf(ts, resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."));
42
+ const ownFiles = new Map();
43
+ /** A file of the project itself: not a dependency's, not a declaration file, and not Lambder's own (its tests excepted, which are apps). */
44
+ const isOwnFile = (sourceFile) => {
45
+ const known = ownFiles.get(sourceFile.fileName);
46
+ if (known !== undefined)
47
+ return known;
48
+ const path = realPathOf(ts, sourceFile.fileName);
49
+ const own = !sourceFile.isDeclarationFile
50
+ && !path.split(sep).includes("node_modules")
51
+ && (!isInside(path, lambderRoot) || isInside(path, resolve(lambderRoot, "tests")));
52
+ ownFiles.set(sourceFile.fileName, own);
53
+ return own;
54
+ };
55
+ /**
56
+ * A declaration of Lambder's: in the declaration files of any installed
57
+ * copy (a workspace holds one per set of peer versions, and a package's
58
+ * imports resolve to its own), or in its sources when this runs over its
59
+ * own tests.
60
+ */
61
+ const isLambderDeclaration = (node) => {
62
+ if (!node)
63
+ return false;
64
+ const path = realPathOf(ts, node.getSourceFile().fileName);
65
+ const parts = path.split(sep);
66
+ if (parts.some((part, index) => part === "node_modules" && parts[index + 1] === "lambder"))
67
+ return true;
68
+ return isInside(path, lambderRoot) && !isInside(path, resolve(lambderRoot, "tests")) && !parts.slice(lambderRoot.split(sep).length).includes("node_modules");
69
+ };
70
+ const where = (node) => {
71
+ const sourceFile = node.getSourceFile();
72
+ const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
73
+ return `${relative(process.cwd(), sourceFile.fileName)}:${line + 1}`;
74
+ };
75
+ // ── Raise sites ──────────────────────────────────────────────────────
76
+ /** The string literal values of a type, or null when it is not made of string literals alone. */
77
+ const literalsOf = (type) => {
78
+ const parts = type.isUnion() ? type.types : [type];
79
+ const values = [];
80
+ for (const part of parts) {
81
+ if (part.isStringLiteral())
82
+ values.push(part.value);
83
+ else
84
+ return null;
85
+ }
86
+ return values;
87
+ };
88
+ /** The code a refuse options argument names, read off its type. */
89
+ const codeOfOptions = (argument, property, at) => {
90
+ if (!argument)
91
+ return [{ at, uncoded: true }];
92
+ let optionsType = checker.getTypeAtLocation(argument);
93
+ if (property === "refusal") {
94
+ const refusal = checker.getPropertyOfType(optionsType, "refusal");
95
+ if (!refusal)
96
+ return [{ at, uncoded: true }];
97
+ optionsType = checker.getTypeOfSymbolAtLocation(refusal, argument);
98
+ }
99
+ const code = checker.getPropertyOfType(optionsType, "code");
100
+ if (!code)
101
+ return [{ at, uncoded: true }];
102
+ const codeType = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(code, argument));
103
+ if (codeType.flags & ts.TypeFlags.Never)
104
+ return [{ at, uncoded: true }];
105
+ const values = literalsOf(codeType);
106
+ if (values === null)
107
+ return [{ at, unreadable: true }];
108
+ return values.map((value) => ({ at, code: value }));
109
+ };
110
+ /**
111
+ * Whether a signature's declaration (one of Lambder's) is a refuse
112
+ * function: a function type in LambderDeclaredRefuse, which types every
113
+ * context's, guard's and init's refuse, or the type of something Lambder
114
+ * declares as `refuse`, the free refuse() and the members an emitted
115
+ * declaration file spells out in place of the alias. Read off the
116
+ * declaration rather than off the call, so a refuse the app reaches under
117
+ * a name of its own (an import renamed, a context's refuse held in a
118
+ * variable or destructured under another name, a parameter typed as one)
119
+ * is a raise all the same.
120
+ */
121
+ const isRefuseDeclaration = (declaration) => {
122
+ let node = declaration;
123
+ while (ts.isTypeNode(node.parent))
124
+ node = node.parent;
125
+ const holder = node.parent;
126
+ if (ts.isTypeAliasDeclaration(holder))
127
+ return holder.name.text === "LambderDeclaredRefuse";
128
+ return (ts.isVariableDeclaration(holder) || ts.isPropertySignature(holder) || ts.isPropertyDeclaration(holder))
129
+ && holder.type === node && ts.isIdentifier(holder.name) && holder.name.text === "refuse";
130
+ };
131
+ /**
132
+ * The refusal a call or construction raises, when it is one of Lambder's
133
+ * refuse functions or its refusal class's constructor. Both are told by
134
+ * the signature the call resolves to rather than by the name written, so
135
+ * a refuse counts however the app names it, and the constructor counts
136
+ * constructed as itself, as a class of the app's own that declares no
137
+ * constructor and inherits it, or as the super() of one that declares
138
+ * its own, which is then raised wherever that class is constructed.
139
+ */
140
+ const raiseSitesOf = (node) => {
141
+ const constructs = ts.isNewExpression(node) || node.expression.kind === ts.SyntaxKind.SuperKeyword;
142
+ const declaration = checker.getResolvedSignature(node)?.declaration;
143
+ if (!declaration || !isLambderDeclaration(declaration))
144
+ return [];
145
+ if (constructs ? !(ts.isConstructorDeclaration(declaration) && declaration.parent.name?.text === "LambderApiRefusal") : !isRefuseDeclaration(declaration))
146
+ return [];
147
+ const args = node.arguments ?? ts.factory.createNodeArray();
148
+ return constructs ? codeOfOptions(args[1], "refusal", where(node)) : codeOfOptions(args[1], "code", where(node));
149
+ };
150
+ // ── The call graph ───────────────────────────────────────────────────
151
+ const unwrap = (expression) => {
152
+ let current = expression;
153
+ while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isTypeAssertionExpression(current) || ts.isNonNullExpression(current)) {
154
+ current = current.expression;
155
+ }
156
+ return current;
157
+ };
158
+ const isFunctionWithBody = (node) => (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node)
159
+ || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node) || ts.isConstructorDeclaration(node)) && node.body !== undefined;
160
+ /** The functions a declaration stands for: itself, the function it is initialized to, or a class's constructor where it is constructed. */
161
+ const functionsOfDeclaration = (declaration, reference) => {
162
+ if (isFunctionWithBody(declaration))
163
+ return [declaration];
164
+ if ((ts.isVariableDeclaration(declaration) || ts.isPropertyAssignment(declaration) || ts.isPropertyDeclaration(declaration)) && declaration.initializer) {
165
+ const initializer = unwrap(declaration.initializer);
166
+ return isFunctionWithBody(initializer) ? [initializer] : [];
167
+ }
168
+ if (ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration)) {
169
+ const constructed = ts.isNewExpression(reference.parent) && reference.parent.expression === reference;
170
+ return constructed ? declaration.members.filter((member) => ts.isConstructorDeclaration(member) && member.body !== undefined) : [];
171
+ }
172
+ return [];
173
+ };
174
+ /** The functions of the project an identifier refers to, followed through an import, a shorthand property or a destructured binding. */
175
+ const functionsReferredToBy = (reference) => {
176
+ let symbol = checker.getSymbolAtLocation(reference);
177
+ if (!symbol)
178
+ return [];
179
+ if (ts.isShorthandPropertyAssignment(reference.parent) && reference.parent.name === reference) {
180
+ symbol = checker.getShorthandAssignmentValueSymbol(reference.parent) ?? symbol;
181
+ }
182
+ if (symbol.flags & ts.SymbolFlags.Alias)
183
+ symbol = checker.getAliasedSymbol(symbol);
184
+ const found = [];
185
+ for (const declaration of symbol.declarations ?? []) {
186
+ if (!isOwnFile(declaration.getSourceFile()))
187
+ continue;
188
+ if (ts.isBindingElement(declaration)) {
189
+ // `const { stamp } = await import("./stamper.js")`: the binding names no
190
+ // function, and the type it is given does.
191
+ for (const signature of checker.getTypeOfSymbolAtLocation(symbol, reference).getCallSignatures()) {
192
+ if (signature.declaration && isFunctionWithBody(signature.declaration) && isOwnFile(signature.declaration.getSourceFile()))
193
+ found.push(signature.declaration);
194
+ }
195
+ continue;
196
+ }
197
+ found.push(...functionsOfDeclaration(declaration, reference));
198
+ }
199
+ return found;
200
+ };
201
+ const scanned = new Map();
202
+ /**
203
+ * A function's own raise sites and the functions it refers to, or an
204
+ * expression's (a handler written as a wrapper's call, say). A function
205
+ * written inside it is one it reaches, since it runs as part of it.
206
+ */
207
+ const scan = (root) => {
208
+ const known = scanned.get(root);
209
+ if (known)
210
+ return known;
211
+ const result = { raises: [], reaches: [] };
212
+ const visit = (node) => {
213
+ if (node !== root && isFunctionWithBody(node)) {
214
+ result.reaches.push(node);
215
+ return;
216
+ }
217
+ if (ts.isCallExpression(node) || ts.isNewExpression(node))
218
+ result.raises.push(...raiseSitesOf(node));
219
+ if (ts.isIdentifier(node))
220
+ result.reaches.push(...functionsReferredToBy(node));
221
+ ts.forEachChild(node, visit);
222
+ };
223
+ visit(root);
224
+ scanned.set(root, result);
225
+ return result;
226
+ };
227
+ /** Every raise site a handler can reach, through everything it refers to. */
228
+ const reachOf = (handler) => {
229
+ const seen = new Set();
230
+ const stack = [handler];
231
+ const raises = [];
232
+ let functions = 0;
233
+ while (stack.length) {
234
+ const node = stack.pop();
235
+ if (seen.has(node))
236
+ continue;
237
+ seen.add(node);
238
+ if (isFunctionWithBody(node))
239
+ functions += 1;
240
+ const { raises: own, reaches } = scan(node);
241
+ raises.push(...own);
242
+ stack.push(...reaches);
243
+ }
244
+ return { raises, functions };
245
+ };
246
+ // ── Handlers ─────────────────────────────────────────────────────────
247
+ /** The codes a handler's `ctx.refuse` takes, or null when its context carries no typed refuse. */
248
+ const allowedCodesOf = (handler) => {
249
+ const contextNode = handler;
250
+ // The type the registration expects the handler to have, which holds
251
+ // however the handler is written (a function, one held elsewhere, a
252
+ // wrapper's result) and whether or not it names its context. A
253
+ // registration that takes a handler or an entry holding one expects a
254
+ // union: the function member is the handler's. A method written in an
255
+ // options object is read off its own type, which the registration
256
+ // gave it.
257
+ const expected = ts.isMethodDeclaration(handler) ? checker.getTypeAtLocation(handler) : checker.getContextualType(handler);
258
+ const members = expected?.isUnion() ? expected.types : expected ? [expected] : [];
259
+ const parameter = members.flatMap((member) => member.getCallSignatures())[0]?.parameters[0];
260
+ const contextType = parameter && checker.getTypeOfSymbolAtLocation(parameter, contextNode);
261
+ const refuse = contextType && checker.getPropertyOfType(contextType, "refuse");
262
+ if (!refuse)
263
+ return null;
264
+ const codes = new Set();
265
+ for (const signature of checker.getTypeOfSymbolAtLocation(refuse, contextNode).getCallSignatures()) {
266
+ const optionsParameter = signature.parameters[1];
267
+ if (!optionsParameter)
268
+ return null;
269
+ const optionsType = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(optionsParameter, contextNode));
270
+ for (const part of optionsType.isUnion() ? optionsType.types : [optionsType]) {
271
+ const code = checker.getPropertyOfType(part, "code");
272
+ if (!code)
273
+ continue;
274
+ const codeType = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(code, contextNode));
275
+ // A handler whose endpoint declares no code is handed a refuse that takes none.
276
+ if (codeType.flags & ts.TypeFlags.Never)
277
+ continue;
278
+ const values = literalsOf(codeType);
279
+ // A refuse whose code is any string is the untyped one: it says nothing of what the handler may send.
280
+ if (values === null)
281
+ return null;
282
+ for (const value of values)
283
+ codes.add(value);
284
+ }
285
+ }
286
+ return [...codes];
287
+ };
288
+ /**
289
+ * The codes an options object's `refusals` property names: one code or a
290
+ * list, each a literal or a constant typed as one. Null where it has no
291
+ * such property, which for a guard built without the vocabulary is the
292
+ * difference between declaring no codes (`refusals: []`) and saying
293
+ * nothing of what it may send.
294
+ */
295
+ const ownRefusalsOf = (optionsNode) => {
296
+ const options = optionsNode && unwrap(optionsNode);
297
+ if (!options || !ts.isObjectLiteralExpression(options))
298
+ return null;
299
+ const property = options.properties.find((candidate) => ts.isPropertyAssignment(candidate) && ts.isIdentifier(candidate.name) && candidate.name.text === "refusals");
300
+ if (!property || !ts.isPropertyAssignment(property))
301
+ return null;
302
+ const value = unwrap(property.initializer);
303
+ const entries = ts.isArrayLiteralExpression(value) ? [...value.elements] : [value];
304
+ return entries.flatMap((entry) => ts.isStringLiteralLike(entry) ? [entry.text] : literalsOf(checker.getTypeAtLocation(entry)) ?? []);
305
+ };
306
+ /** How a registration names its handler: `group.action` for an endpoint in a group, the key or variable it is held under otherwise, the name a mock entry is given, or the registration's own name when it is held under none. */
307
+ const handlerNameOf = (call, registration) => {
308
+ if (registration === "api" || registration === "override") {
309
+ const [name] = call.arguments;
310
+ if (name && ts.isStringLiteralLike(name))
311
+ return name.text;
312
+ }
313
+ const holder = call.parent;
314
+ const key = ts.isPropertyAssignment(holder) ? holder.name.getText()
315
+ : ts.isVariableDeclaration(holder) ? holder.name.getText() : null;
316
+ if (registration === "defineApi" && key && ts.isPropertyAssignment(holder)) {
317
+ const group = holder.parent.parent;
318
+ if (ts.isCallExpression(group) && group.arguments[1] === holder.parent && group.arguments[0] && ts.isStringLiteralLike(group.arguments[0])) {
319
+ return `${group.arguments[0].text}.${key}`;
320
+ }
321
+ }
322
+ // Unnamed (inside an app's own wrapper, say): the finding's `at` says where.
323
+ return key ?? registration;
324
+ };
325
+ const findings = [];
326
+ const unchecked = [];
327
+ let handlers = 0;
328
+ const checkRegistration = (call) => {
329
+ const callee = call.expression;
330
+ const registration = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
331
+ const form = registration ? HANDLER_REGISTRATIONS[registration] : undefined;
332
+ if (!registration || !form)
333
+ return;
334
+ if (!isLambderDeclaration(checker.getResolvedSignature(call)?.declaration))
335
+ return;
336
+ const [first, second] = call.arguments;
337
+ const handlerOf = (holder) => {
338
+ if (!holder || !ts.isObjectLiteralExpression(unwrap(holder)))
339
+ return undefined;
340
+ const property = unwrap(holder).properties.find((candidate) => candidate.name?.getText() === "handler");
341
+ if (!property)
342
+ return undefined;
343
+ if (ts.isPropertyAssignment(property))
344
+ return property.initializer;
345
+ if (ts.isMethodDeclaration(property))
346
+ return property;
347
+ if (ts.isShorthandPropertyAssignment(property))
348
+ return property.name;
349
+ return undefined;
350
+ };
351
+ const handler = form === "second" ? second
352
+ : form === "optionsHandler" ? handlerOf(first)
353
+ : second && isFunctionWithBody(unwrap(second)) ? second : handlerOf(second) ?? second;
354
+ const optionsNode = form === "second" ? first : form === "optionsHandler" ? first : second;
355
+ if (!handler)
356
+ return;
357
+ const name = handlerNameOf(call, registration);
358
+ const at = where(call);
359
+ // A guard built without the vocabulary may send the codes its own
360
+ // refusals option names, which is what the server checks it against.
361
+ const allowed = allowedCodesOf(handler) ?? (registration === "guard" || registration === "lambderGuard" ? ownRefusalsOf(optionsNode) : null);
362
+ if (allowed === null) {
363
+ unchecked.push({ handler: name, at, problem: "unchecked" });
364
+ return;
365
+ }
366
+ handlers += 1;
367
+ const { raises, functions } = reachOf(ts.isMethodDeclaration(handler) ? handler : unwrap(handler));
368
+ if (functions === 0) {
369
+ findings.push({ handler: name, at, problem: "untraced" });
370
+ return;
371
+ }
372
+ const allowedSet = new Set(allowed);
373
+ const reached = new Set();
374
+ const reported = new Set();
375
+ for (const raise of raises) {
376
+ if ("code" in raise) {
377
+ if (raise.code.startsWith("lambder/"))
378
+ continue;
379
+ reached.add(raise.code);
380
+ if (!allowedSet.has(raise.code) && !reported.has(raise.code)) {
381
+ reported.add(raise.code);
382
+ findings.push({ handler: name, at, problem: "undeclared", code: raise.code, raisedAt: raise.at });
383
+ }
384
+ }
385
+ else if ("uncoded" in raise) {
386
+ if (requireCodes)
387
+ findings.push({ handler: name, at, problem: "uncoded", raisedAt: raise.at });
388
+ }
389
+ else {
390
+ findings.push({ handler: name, at, problem: "unreadable", raisedAt: raise.at });
391
+ }
392
+ }
393
+ for (const code of ownRefusalsOf(optionsNode) ?? []) {
394
+ if (!reached.has(code))
395
+ findings.push({ handler: name, at, problem: "unused", code });
396
+ }
397
+ };
398
+ for (const sourceFile of program.getSourceFiles()) {
399
+ if (!isOwnFile(sourceFile))
400
+ continue;
401
+ const visit = (node) => {
402
+ if (ts.isCallExpression(node))
403
+ checkRegistration(node);
404
+ ts.forEachChild(node, visit);
405
+ };
406
+ visit(sourceFile);
407
+ }
408
+ // Found, or only listed where the caller lets them stand unchecked.
409
+ if (requireTypedRefuse)
410
+ findings.push(...unchecked);
411
+ const uncheckedLine = !requireTypedRefuse && unchecked.length
412
+ ? [` ${unchecked.length} ${unchecked.length === 1 ? "handler has" : "handlers have"} no typed refuse to check against: ${unchecked.map(({ handler, at }) => `${handler} (${at})`).join(", ")}`]
413
+ : [];
414
+ if (handlers === 0) {
415
+ const nothingChecked = `✗ ${options.tsconfig}: no handler Lambder hands a typed refuse was found, so nothing was checked. Do its APIs come from an init that declares its refusals (declareRefusals), and do its files resolve lambder?`;
416
+ return { ...failed(nothingChecked, [...findings.map(describeFinding), ...uncheckedLine]), findings };
417
+ }
418
+ // Duplicate uncoded or unreadable findings of one site through two paths say the same thing once.
419
+ const unique = [...new Map(findings.map((finding) => [JSON.stringify(finding), finding])).values()];
420
+ const lines = [
421
+ unique.length === 0
422
+ ? `✓ ${handlers} handlers reach only the refusals they may send`
423
+ // Out of every handler found, the unchecked ones among them, which are never counted as checked.
424
+ : `✗ ${unique.length} refusal ${unique.length === 1 ? "finding" : "findings"} in ${new Set(unique.map((finding) => finding.handler)).size} of ${handlers + unchecked.length} handlers`,
425
+ ...unique.map(describeFinding),
426
+ ...uncheckedLine,
427
+ ];
428
+ return { ok: unique.length === 0, handlers, findings: unique, lines };
429
+ };
430
+ const describeFinding = (finding) => {
431
+ switch (finding.problem) {
432
+ case "undeclared": return ` ${finding.handler} (${finding.at}) can refuse with "${finding.code}" (${finding.raisedAt}), which it may not send: name it in its refusals, or keep the handler from reaching it`;
433
+ case "unused": return ` ${finding.handler} (${finding.at}) declares "${finding.code}", which nothing it reaches raises`;
434
+ case "uncoded": return ` ${finding.handler} (${finding.at}) can reach a refusal with no code (${finding.raisedAt})`;
435
+ case "unreadable": return ` ${finding.handler} (${finding.at}) can reach a refusal whose code is not a string literal (${finding.raisedAt}), so which code it sends cannot be told`;
436
+ case "untraced": return ` ${finding.handler} (${finding.at}) is handed a typed refuse, but its function cannot be found (a parameter, a dependency's value), so nothing it reaches was checked`;
437
+ case "unchecked": return ` ${finding.handler} (${finding.at}) is handed no typed refuse, so nothing it reaches could be checked: build it from an init that declares the refusal vocabulary (declareRefusals, on initLambderMock() as on initLambder()), or give a guard a refusals option naming the codes it may send`;
438
+ }
439
+ };
440
+ const failed = (line, details = []) => ({ ok: false, handlers: 0, findings: [], lines: [line, ...details] });
441
+ const isInside = (path, directory) => path === directory || path.startsWith(directory + sep);
442
+ const realPathOf = (ts, path) => ts.sys.realpath?.(path) ?? path;
@@ -0,0 +1,11 @@
1
+ import { type LambderApiContractFileOptions, type LambderApiContractFileResult } from "./writeApiContract.js";
2
+ /** writeApiContract's options as they cross the process boundary: the module as a string, a URL by its href. */
3
+ export type ContractPrinterRequest = Omit<LambderApiContractFileOptions, "module"> & {
4
+ module: string;
5
+ };
6
+ /** The result, or the messages of what was thrown, outermost first, down its chain of causes. */
7
+ export type ContractPrinterReport = {
8
+ result: LambderApiContractFileResult;
9
+ } | {
10
+ thrown: string[];
11
+ };
@@ -0,0 +1,17 @@
1
+ import { CONTRACT_REPORT_PREFIX } from "./generateApiFiles.js";
2
+ import { writeApiContract } from "./writeApiContract.js";
3
+ const request = JSON.parse(process.argv[2] ?? "null");
4
+ let report;
5
+ try {
6
+ report = { result: await writeApiContract(request) };
7
+ }
8
+ catch (err) {
9
+ const thrown = err instanceof Error ? [] : [String(err)];
10
+ for (let cause = err; cause instanceof Error && thrown.length < 8; cause = cause.cause)
11
+ thrown.push(cause.message);
12
+ report = { thrown };
13
+ }
14
+ // Waited for: a write to a pipe can still be pending when the process exits,
15
+ // and the parent reads the pipe.
16
+ await new Promise((resolveWrite) => process.stdout.write(`\n${CONTRACT_REPORT_PREFIX}${JSON.stringify(report)}\n`, () => resolveWrite()));
17
+ process.exit(0);
@@ -1,8 +1,9 @@
1
1
  import type { LambderModuleLocation } from "./moduleLocation.js";
2
- import { type LambderApiContractFileOptions } from "./writeApiContract.js";
2
+ import type { LambderApiContractFileOptions } from "./writeApiContract.js";
3
3
  import { type LambderApiSignatureFileOptions } from "./writeApiSignatures.js";
4
4
  import { type LambderApiOptionsFileOptions } from "./writeApiOptions.js";
5
5
  import { type LambderApiGuardParamsFileOptions } from "./writeApiGuardParams.js";
6
+ import { type LambderApiSchemasFileOptions } from "./writeApiSchemas.js";
6
7
  /** What a file's writer takes beyond where the instance is, which the app gives once for all its files. */
7
8
  type LambderAppFile<TOptions> = Omit<TOptions, "module" | "exportName" | "tsconfig" | "check">;
8
9
  /** One app: where its instance is, and which files it is written to. Each file is optional. */
@@ -21,21 +22,43 @@ export type LambderApiFilesApp = {
21
22
  options?: LambderAppFile<LambderApiOptionsFileOptions>;
22
23
  /** One file per guard whose parameters a browser needs (writeApiGuardParams). */
23
24
  guardParams?: readonly LambderAppFile<LambderApiGuardParamsFileOptions>[];
25
+ /** The input and output schemas as JSON Schema, for a mock to validate against in development (writeApiSchemas). */
26
+ schemas?: LambderAppFile<LambderApiSchemasFileOptions>;
24
27
  };
25
28
  /** The files generateApiFiles writes: every app's, by a name the output lines use. */
26
29
  export type LambderApiFilesConfig = {
27
30
  apps: Record<string, LambderApiFilesApp>;
28
31
  };
32
+ /** How generateApiFiles runs. */
33
+ export type LambderApiFilesOptions = {
34
+ /** Write nothing, and answer whether each file on disk is what its writer produces now. Default: false. */
35
+ check?: boolean;
36
+ /**
37
+ * The heap, in megabytes, of the Node process each contract is printed
38
+ * in (its --max-old-space-size). Reading a contract compiles the server,
39
+ * twice for a write that changes the file, which in a large app takes
40
+ * gigabytes; the process is started for that alone, so the heap is the
41
+ * printing's whatever the script's own is. Default: 8192.
42
+ */
43
+ contractHeapMegabytes?: number;
44
+ };
29
45
  export type LambderApiFilesResult = {
30
46
  /** False when a check found a file stale, or a writer failed. */
31
47
  ok: boolean;
32
48
  /** What happened, per app and file, as lines to print. */
33
49
  lines: string[];
34
50
  };
51
+ /**
52
+ * How the contract printing process reports: one line on stdout starting
53
+ * with this, then its report as JSON. The line, not the exit status, is the
54
+ * answer: a process that dies before it (out of heap, say) answered nothing.
55
+ */
56
+ export declare const CONTRACT_REPORT_PREFIX = "lambder-api-contract-printed:";
35
57
  /**
36
58
  * Writes every file the config names, or with `check` writes nothing and
37
59
  * answers whether each one on disk is current. Paths are relative to the
38
- * working directory, as each writer takes them.
60
+ * working directory, as each writer takes them. Each contract is printed in
61
+ * a Node process of its own, with a heap of `contractHeapMegabytes`.
39
62
  *
40
63
  * ```ts
41
64
  * import { generateApiFiles } from "lambder/build";
@@ -54,7 +77,5 @@ export type LambderApiFilesResult = {
54
77
  * process.exit(result.ok ? 0 : 1);
55
78
  * ```
56
79
  */
57
- export declare const generateApiFiles: (config: LambderApiFilesConfig, options?: {
58
- check?: boolean;
59
- }) => Promise<LambderApiFilesResult>;
80
+ export declare const generateApiFiles: (config: LambderApiFilesConfig, options?: LambderApiFilesOptions) => Promise<LambderApiFilesResult>;
60
81
  export {};