lambder 7.3.1 → 8.1.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 (237) hide show
  1. package/CHANGELOG.md +1047 -3
  2. package/README.md +46 -21
  3. package/dist/api/LambderApiAnswer.d.ts +18 -22
  4. package/dist/api/LambderApiAnswer.js +6 -7
  5. package/dist/api/LambderApiCallContext.d.ts +21 -8
  6. package/dist/api/LambderApiCallContext.js +22 -4
  7. package/dist/api/LambderApiDefinition.d.ts +4 -3
  8. package/dist/api/LambderApiEnvelope.d.ts +14 -9
  9. package/dist/api/LambderApiEnvelope.js +33 -34
  10. package/dist/api/LambderApiGuards.d.ts +78 -51
  11. package/dist/api/LambderApiGuards.js +34 -36
  12. package/dist/api/LambderApiIdempotency.d.ts +68 -62
  13. package/dist/api/LambderApiIdempotency.js +214 -151
  14. package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
  15. package/dist/api/LambderApiOutputValidationError.js +50 -0
  16. package/dist/api/LambderApiPipeline.d.ts +47 -38
  17. package/dist/api/LambderApiPipeline.js +122 -63
  18. package/dist/api/LambderApiRateLimits.d.ts +201 -54
  19. package/dist/api/LambderApiRateLimits.js +185 -108
  20. package/dist/api/LambderApiRequest.d.ts +27 -21
  21. package/dist/api/LambderApiRequest.js +26 -19
  22. package/dist/api/LambderApiSignature.d.ts +12 -15
  23. package/dist/api/LambderApiSignature.js +28 -51
  24. package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
  25. package/dist/api/LambderApiValidationRefusal.js +10 -10
  26. package/dist/build/ContractTypePrinter.d.ts +85 -0
  27. package/dist/build/ContractTypePrinter.js +402 -0
  28. package/dist/build/freshProcessVerifier.d.ts +13 -0
  29. package/dist/build/freshProcessVerifier.js +19 -0
  30. package/dist/build/moduleLocation.d.ts +11 -0
  31. package/dist/build/moduleLocation.js +6 -0
  32. package/dist/build/writeApiContract.d.ts +78 -0
  33. package/dist/build/writeApiContract.js +302 -0
  34. package/dist/build/writeApiSignatures.d.ts +114 -0
  35. package/dist/build/writeApiSignatures.js +217 -0
  36. package/dist/build/writeFileAtomically.d.ts +8 -0
  37. package/dist/build/writeFileAtomically.js +22 -0
  38. package/dist/build.d.ts +14 -0
  39. package/dist/build.js +11 -0
  40. package/dist/client/LambderCaller.d.ts +13 -44
  41. package/dist/client/LambderCaller.js +77 -84
  42. package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
  43. package/dist/client/LambderReloadLoopBreaker.js +90 -46
  44. package/dist/client/LambderUploadRunner.d.ts +96 -0
  45. package/dist/client/LambderUploadRunner.js +234 -0
  46. package/dist/client/lambderFetchTransport.d.ts +4 -1
  47. package/dist/client/lambderFetchTransport.js +52 -28
  48. package/dist/client.d.ts +9 -3
  49. package/dist/client.js +6 -1
  50. package/dist/core/Lambder.d.ts +143 -79
  51. package/dist/core/Lambder.js +350 -231
  52. package/dist/core/LambderContext.d.ts +82 -15
  53. package/dist/core/LambderContext.js +107 -20
  54. package/dist/core/LambderCors.d.ts +21 -3
  55. package/dist/core/LambderCors.js +35 -16
  56. package/dist/core/LambderCrashHandling.d.ts +40 -0
  57. package/dist/core/LambderCrashHandling.js +97 -0
  58. package/dist/core/LambderCreateOptions.d.ts +151 -75
  59. package/dist/core/LambderCreateOptions.js +16 -23
  60. package/dist/core/LambderFiles.d.ts +21 -7
  61. package/dist/core/LambderFiles.js +62 -34
  62. package/dist/core/LambderIndexHtml.js +12 -11
  63. package/dist/core/LambderPolicyBuilders.d.ts +17 -5
  64. package/dist/core/LambderPolicyBuilders.js +17 -5
  65. package/dist/core/LambderPublicFiles.d.ts +11 -5
  66. package/dist/core/LambderPublicFiles.js +32 -4
  67. package/dist/core/LambderRequestPath.d.ts +43 -0
  68. package/dist/core/LambderRequestPath.js +63 -0
  69. package/dist/core/LambderResponse.d.ts +26 -5
  70. package/dist/core/LambderResponse.js +157 -70
  71. package/dist/core/LambderResponseBuilder.d.ts +49 -4
  72. package/dist/core/LambderResponseBuilder.js +64 -3
  73. package/dist/core/LambderRouting.d.ts +2 -3
  74. package/dist/core/LambderRouting.js +22 -7
  75. package/dist/core/LambderTemplatingEngine.js +211 -32
  76. package/dist/index.d.ts +25 -8
  77. package/dist/index.js +13 -4
  78. package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
  79. package/dist/invoke/LambderInvokeCaller.js +76 -66
  80. package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
  81. package/dist/invoke/LambderInvokeOutcome.js +9 -22
  82. package/dist/invoke/LambderLambdaEvent.d.ts +29 -9
  83. package/dist/invoke/LambderLambdaEvent.js +40 -22
  84. package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
  85. package/dist/invoke/lambderHandlerTransport.js +15 -18
  86. package/dist/mock/LambderMockApp.d.ts +67 -83
  87. package/dist/mock/LambderMockApp.js +167 -153
  88. package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
  89. package/dist/mock/LambderMockBrowserCookies.js +24 -28
  90. package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
  91. package/dist/mock/LambderMockCallRecorder.js +19 -28
  92. package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
  93. package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
  94. package/dist/mock/LambderMockEntryRegistry.js +24 -29
  95. package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
  96. package/dist/mock/LambderMockFailureInjector.js +3 -6
  97. package/dist/mock/LambderMockTypes.d.ts +78 -108
  98. package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
  99. package/dist/mock/lambderMockInvokeTransport.js +11 -10
  100. package/dist/mock/lambderMockMswHandler.d.ts +43 -33
  101. package/dist/mock/lambderMockMswHandler.js +50 -39
  102. package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
  103. package/dist/mock/lambderMockUploadMswHandler.js +28 -0
  104. package/dist/mock.d.ts +4 -1
  105. package/dist/mock.js +6 -3
  106. package/dist/session/LambderSessionController.d.ts +108 -89
  107. package/dist/session/LambderSessionController.js +187 -168
  108. package/dist/session/LambderSessionCrypto.d.ts +16 -7
  109. package/dist/session/LambderSessionCrypto.js +26 -12
  110. package/dist/session/LambderSessionManager.d.ts +124 -46
  111. package/dist/session/LambderSessionManager.js +262 -137
  112. package/dist/shared/LambderHtml.d.ts +42 -3
  113. package/dist/shared/LambderHtml.js +127 -7
  114. package/dist/shared/LambderHtmlPositions.d.ts +173 -0
  115. package/dist/shared/LambderHtmlPositions.js +652 -0
  116. package/dist/shared/LambderI18n.d.ts +10 -11
  117. package/dist/shared/LambderI18n.js +33 -21
  118. package/dist/shared/contracts/LambderCache.d.ts +66 -0
  119. package/dist/shared/contracts/LambderCache.js +11 -0
  120. package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
  121. package/dist/shared/contracts/LambderFileSource.js +5 -8
  122. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
  123. package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
  124. package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
  125. package/dist/shared/contracts/LambderRateLimiter.js +4 -5
  126. package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
  127. package/dist/shared/contracts/LambderSessionStore.js +5 -6
  128. package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
  129. package/dist/shared/contracts/LambderUploadBucket.js +74 -0
  130. package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
  131. package/dist/shared/transport/LambderApiTransport.js +7 -7
  132. package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
  133. package/dist/shared/transport/LambderCookieJar.js +54 -66
  134. package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
  135. package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
  136. package/dist/shared/util/LambderCallAbort.d.ts +5 -5
  137. package/dist/shared/util/LambderCallAbort.js +5 -5
  138. package/dist/shared/util/LambderClientIp.d.ts +27 -11
  139. package/dist/shared/util/LambderClientIp.js +96 -13
  140. package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
  141. package/dist/shared/util/LambderContentDisposition.js +13 -0
  142. package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
  143. package/dist/shared/util/LambderExpiringMap.js +41 -57
  144. package/dist/shared/util/LambderNodeModules.js +6 -7
  145. package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
  146. package/dist/shared/util/LambderOptionChecks.js +4 -4
  147. package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
  148. package/dist/shared/util/LambderResponseBrand.js +5 -5
  149. package/dist/shared/util/LambderTextDigest.d.ts +7 -5
  150. package/dist/shared/util/LambderTextDigest.js +11 -5
  151. package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
  152. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  153. package/dist/shared/util/boundKeyField.d.ts +20 -0
  154. package/dist/shared/util/boundKeyField.js +34 -0
  155. package/dist/shared/util/canonicalJson.d.ts +11 -0
  156. package/dist/shared/util/canonicalJson.js +28 -0
  157. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  158. package/dist/shared/util/joinKeyFields.js +22 -0
  159. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  160. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  161. package/dist/shared/wire/LambderApiContract.d.ts +98 -53
  162. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  163. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  164. package/dist/shared/wire/LambderApiRefusal.d.ts +45 -27
  165. package/dist/shared/wire/LambderApiRefusal.js +42 -7
  166. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  167. package/dist/shared/wire/LambderApiSignature.js +16 -19
  168. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  169. package/dist/shared/wire/LambderCallOptions.js +9 -11
  170. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  171. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  172. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  173. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  174. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  175. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  176. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  177. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  178. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  179. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  180. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  181. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  182. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  183. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
  184. package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
  185. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  186. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  187. package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -0
  188. package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
  189. package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
  190. package/dist/shared/wire/LambderUploadRefusal.js +18 -0
  191. package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
  192. package/dist/shared/wire/LambderUploadSchemas.js +30 -0
  193. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  194. package/dist/stores/LambderCacheFiller.js +119 -0
  195. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  196. package/dist/stores/LambderCacheKeys.js +54 -0
  197. package/dist/stores/LambderCacheValues.d.ts +45 -0
  198. package/dist/stores/LambderCacheValues.js +74 -0
  199. package/dist/stores/LambderDdbCache.d.ts +121 -56
  200. package/dist/stores/LambderDdbCache.js +528 -225
  201. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  202. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  203. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  204. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  205. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  206. package/dist/stores/LambderDdbSdk.js +80 -38
  207. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  208. package/dist/stores/LambderDdbSessionStore.js +119 -47
  209. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  210. package/dist/stores/LambderHttpFileSource.js +15 -13
  211. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  212. package/dist/stores/LambderMemoryCache.js +113 -0
  213. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  214. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  215. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  216. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  217. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  218. package/dist/stores/LambderMemorySessionStore.js +38 -19
  219. package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
  220. package/dist/stores/LambderMemoryUploadBucket.js +219 -0
  221. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  222. package/dist/stores/LambderS3FileSource.js +12 -7
  223. package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
  224. package/dist/stores/LambderS3UploadBucket.js +144 -0
  225. package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
  226. package/dist/stores/LambderSdkInstallHint.js +14 -0
  227. package/dist/testing/LambderTestApp.d.ts +23 -25
  228. package/dist/testing/LambderTestApp.js +22 -24
  229. package/dist/testing/LambderTestVisitor.d.ts +10 -12
  230. package/dist/testing/LambderTestVisitor.js +15 -15
  231. package/dist/testing.d.ts +3 -0
  232. package/dist/testing.js +2 -0
  233. package/package.json +26 -3
  234. package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
  235. package/dist/api/LambderApiPolicyEngine.js +0 -85
  236. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  237. package/dist/shared/util/LambderKeyFields.js +0 -34
@@ -0,0 +1,302 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { dirname, resolve } from "path";
3
+ import { ContractTypePrinter } from "./ContractTypePrinter.js";
4
+ import { modulePathOf } from "./moduleLocation.js";
5
+ import { writeFileAtomically } from "./writeFileAtomically.js";
6
+ const DEFAULT_HEADER = [
7
+ "Generated by writeApiContract() from lambder/build. Do not edit.",
8
+ "",
9
+ "The server's API contract written out as plain types, so a client compiles",
10
+ "the contract without compiling the server's schemas and code.",
11
+ ].join("\n");
12
+ const INDENT = " ";
13
+ /** How many compiler diagnostics a failure prints before it stops. */
14
+ const DIAGNOSTIC_LIMIT = 20;
15
+ /**
16
+ * Writes an app's API contract type to a TypeScript module as plain types,
17
+ * or checks the one on disk, and names the APIs whose types moved.
18
+ *
19
+ * The contract is the ApiContract property of the instance the module
20
+ * exports, read through the TypeScript compiler under the server's own
21
+ * tsconfig; nothing of the server runs. Every type in it is printed as the
22
+ * structure it resolves to: zod's inferences, mapped and conditional types and
23
+ * the server's own types become plain object types, unions and literals. The
24
+ * written module imports nothing, not even lambder, and exports one type
25
+ * alias, `typeName`. Only the default library's interfaces (Date) are printed
26
+ * by name. A non-generic named type is printed once, as a declaration of its
27
+ * own that the entries refer to.
28
+ *
29
+ * Anything with no plain form fails the call and names where it sits: a
30
+ * function, a symbol-keyed property, an enum, a class's private member, or a
31
+ * type parameter the contract leaves open. Property `readonly` modifiers are
32
+ * not carried over (they never decide assignability); readonly arrays and
33
+ * tuples are.
34
+ *
35
+ * ```ts
36
+ * import { writeApiContract } from "lambder/build";
37
+ *
38
+ * const result = await writeApiContract({
39
+ * module: "server/src/index.ts", // export const lambder = initLambder()...
40
+ * exportName: "lambder",
41
+ * file: "shared/generated/apiContract.generated.ts",
42
+ * check: process.argv.includes("--check"),
43
+ * });
44
+ * console.log(result.lines.join("\n"));
45
+ * process.exit(result.ok ? 0 : 1);
46
+ * ```
47
+ *
48
+ * A check compares the text, so the file should be left out of formatters;
49
+ * each declaration carries a `// prettier-ignore` line for Prettier. A write
50
+ * that changes the file first compiles the new text beside the server's
51
+ * sources and checks every entry against the contract both ways, and writes
52
+ * nothing when one differs. The file is written to a temporary file renamed
53
+ * over the old one, so a build reading it meanwhile never sees half of it.
54
+ */
55
+ export const writeApiContract = async (options) => {
56
+ const ts = await loadTypeScript();
57
+ const file = resolve(options.file);
58
+ const modulePath = modulePathOf(options.module);
59
+ const exportName = options.exportName ?? "default";
60
+ const typeName = options.typeName ?? "ApiContractType";
61
+ const style = { quote: options.quotes === "single" ? "'" : "\"", semicolon: options.semicolons === false ? "" : ";" };
62
+ let count = 0;
63
+ const result = (ok, written, lines, moved) => ({ ok, file, count, written, changed: moved?.changed ?? [], added: moved?.added ?? [], removed: moved?.removed ?? [], lines });
64
+ const project = readProject(ts, options.tsconfig, modulePath);
65
+ if ("failure" in project)
66
+ return result(false, false, [`✗ ${options.module}: ${project.failure}`, ...(project.details ?? [])]);
67
+ // The program is used for the printing alone, and dropped before the
68
+ // verification builds its own: only the parsed files carry over.
69
+ const printing = printContractFile(ts, project, modulePath, exportName, typeName, style, options.header);
70
+ if ("failure" in printing)
71
+ return result(false, false, [`✗ ${options.module}: ${printing.failure}`, ...(printing.details ?? [])]);
72
+ count = printing.count;
73
+ const { text, parsedFiles } = printing;
74
+ const previous = existsSync(file) ? readFileSync(file, "utf8") : null;
75
+ const moved = describeContractChanges(ts, typeName, previous, text);
76
+ const unchanged = previous !== null && previous.replace(/\r\n/g, "\n") === text;
77
+ const movedSummary = moved.lines.length ? [` ${moved.summary}`, ...moved.lines] : [];
78
+ if (options.check) {
79
+ if (unchanged)
80
+ return result(true, false, [`✓ ${options.file} matches the ${count} APIs of ${typeName}`], moved);
81
+ return result(false, false, [`✗ ${options.file} is stale: regenerate it`, ...(movedSummary.length ? movedSummary : [" no API's types changed; the header or layout did"])], moved);
82
+ }
83
+ if (unchanged)
84
+ return result(true, false, [`✓ ${options.file} is up to date (${count} APIs)`], moved);
85
+ const mismatches = verifyPrintedContract(ts, project, parsedFiles, { modulePath, exportName }, { file, text, typeName });
86
+ if (mismatches.length)
87
+ return result(false, false, [`✗ the printed ${typeName} is not the contract of ${exportName} in ${options.module}, so ${options.file} was not written:`, ...mismatches.map((line) => ` ${line}`)], moved);
88
+ writeFileAtomically(file, text, previous !== null);
89
+ return result(true, true, [`✓ Wrote ${options.file} (${count} APIs)`, ...(movedSummary.length ? movedSummary : [" no API's types changed; the header or layout did"])], moved);
90
+ };
91
+ /** The compiler API writeApiContract reads with. TypeScript 7 ships none, so its package answers the import without one. */
92
+ const loadTypeScript = async () => {
93
+ const requirement = "writeApiContract reads the contract through the TypeScript compiler API (typescript 5.4 to 6.x): install one beside lambder, such as in the generator's own package when the app is on TypeScript 7";
94
+ let ts;
95
+ try {
96
+ ts = (await import("typescript")).default;
97
+ }
98
+ catch (err) {
99
+ throw new Error(requirement, { cause: err });
100
+ }
101
+ if (typeof ts?.createProgram !== "function")
102
+ throw new Error(`${requirement}; the typescript installed (${ts?.version ?? "unknown"}) has no compiler API`);
103
+ return ts;
104
+ };
105
+ /** The server's compiler options, and the root files a program over the module needs: the module, and the project's own declaration files, which may declare globals the module relies on. */
106
+ const readProject = (ts, tsconfig, modulePath) => {
107
+ const configPath = tsconfig ? resolve(tsconfig) : ts.findConfigFile(dirname(modulePath), ts.sys.fileExists);
108
+ if (!configPath)
109
+ return { failure: "no tsconfig.json in its directory or above it; name the server's in tsconfig" };
110
+ const read = ts.readConfigFile(configPath, ts.sys.readFile);
111
+ if (read.error)
112
+ return { failure: `${configPath} could not be read`, details: formatDiagnostics(ts, [read.error]) };
113
+ const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(configPath), undefined, configPath);
114
+ if (parsed.errors.length)
115
+ return { failure: `${configPath} has errors`, details: formatDiagnostics(ts, parsed.errors) };
116
+ return {
117
+ // Nothing is emitted, and a composite project's rule that every file be
118
+ // listed in it does not hold for a program rooted at one module.
119
+ options: { ...parsed.options, noEmit: true, composite: false, incremental: false, declaration: false },
120
+ rootNames: [modulePath, ...parsed.fileNames.filter((fileName) => fileName.endsWith(".d.ts"))],
121
+ };
122
+ };
123
+ /** An export of a module, followed through a re-export or an `export default` of a name. */
124
+ const readExport = (ts, program, checker, modulePath, exportName) => {
125
+ const sourceFile = program.getSourceFile(modulePath);
126
+ if (!sourceFile)
127
+ return { failure: "the module was not found" };
128
+ const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
129
+ const exported = moduleSymbol && checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.name === exportName);
130
+ if (!exported)
131
+ return { failure: `the module has no export named "${exportName}"` };
132
+ return exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported;
133
+ };
134
+ /** The ApiContract of the instance a module exports, from a module that compiles. */
135
+ const readInstanceContract = (ts, program, checker, modulePath, exportName) => {
136
+ const sourceFile = program.getSourceFile(modulePath);
137
+ if (!sourceFile)
138
+ return { failure: "the module was not found" };
139
+ // A module with errors can infer `any` where the contract meant a type,
140
+ // and `any` prints and verifies as itself.
141
+ const diagnostics = [...program.getSyntacticDiagnostics(sourceFile), ...program.getSemanticDiagnostics(sourceFile)];
142
+ if (diagnostics.length)
143
+ return { failure: "the module does not compile", details: formatDiagnostics(ts, diagnostics) };
144
+ const instance = readExport(ts, program, checker, modulePath, exportName);
145
+ if ("failure" in instance)
146
+ return { failure: `${instance.failure}; name the export that holds the Lambder instance in exportName` };
147
+ const contract = checker.getPropertyOfType(checker.getTypeOfSymbol(instance), "ApiContract");
148
+ if (!contract)
149
+ return { failure: `"${exportName}" is not a Lambder instance: it has no ApiContract` };
150
+ return checker.getTypeOfSymbol(contract);
151
+ };
152
+ /** The contract type a printed module exports. */
153
+ const readPrintedContract = (ts, program, checker, file, typeName) => {
154
+ const exported = readExport(ts, program, checker, file, typeName);
155
+ if ("failure" in exported)
156
+ return exported;
157
+ if (!(exported.flags & ts.SymbolFlags.Type))
158
+ return { failure: `${typeName} is not a type` };
159
+ return checker.getDeclaredTypeOfSymbol(exported);
160
+ };
161
+ const printContractFile = (ts, project, modulePath, exportName, typeName, style, header) => {
162
+ const program = ts.createProgram({ rootNames: project.rootNames, options: project.options });
163
+ const checker = program.getTypeChecker();
164
+ const contract = readInstanceContract(ts, program, checker, modulePath, exportName);
165
+ if ("failure" in contract)
166
+ return contract;
167
+ const printer = new ContractTypePrinter(ts, program, checker, style, typeName);
168
+ const printed = printer.printContract(contract);
169
+ if (printed.failures.length) {
170
+ return { failure: `${typeName} holds types with no plain form, which only the server's sources could name:`, details: printed.failures.map((line) => ` ${line}`) };
171
+ }
172
+ return {
173
+ text: renderContractFile(printer, printed, typeName, style, header),
174
+ count: printed.entries.length,
175
+ parsedFiles: new Map(program.getSourceFiles().map((sourceFile) => [sourceFile.fileName, sourceFile])),
176
+ };
177
+ };
178
+ const renderContractFile = (printer, printed, typeName, style, header = DEFAULT_HEADER) => {
179
+ const indented = (text) => INDENT + text.replace(/\n/g, `\n${INDENT}`);
180
+ const entries = printed.entries.map(({ name, text }) => indented(printer.labeledValue(`${printer.keyOf(name)}:`, text)) + style.semicolon);
181
+ return [
182
+ ...header.split("\n").map((line) => line ? `// ${line}` : "//"),
183
+ "",
184
+ // Kept as written by a formatter that honours it: a check compares
185
+ // the text.
186
+ "// prettier-ignore",
187
+ entries.length ? [`export type ${typeName} = {`, ...entries, `}${style.semicolon}`].join("\n") : `export type ${typeName} = {}${style.semicolon}`,
188
+ ...printed.declarations.flatMap(({ name, text }) => ["", "// prettier-ignore", printer.labeledValue(`type ${name} =`, text) + style.semicolon]),
189
+ "",
190
+ ].join("\n");
191
+ };
192
+ /**
193
+ * Compiles the printed text beside the server's sources, from the files the
194
+ * printing parsed, and checks each entry against the contract's in both
195
+ * directions. Answers the entries that differ, or the diagnostics of a text
196
+ * that does not compile; nothing when the two are the same type.
197
+ */
198
+ const verifyPrintedContract = (ts, project, parsedFiles, { modulePath, exportName }, { file, text, typeName }) => {
199
+ const canonical = (fileName) => {
200
+ const slashed = fileName.replace(/\\/g, "/");
201
+ return ts.sys.useCaseSensitiveFileNames ? slashed : slashed.toLowerCase();
202
+ };
203
+ const printedFile = canonical(file);
204
+ const isPrinted = (fileName) => canonical(fileName) === printedFile;
205
+ const host = ts.createCompilerHost(project.options, true);
206
+ const { getSourceFile, fileExists, readFile } = host;
207
+ host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => isPrinted(fileName)
208
+ ? ts.createSourceFile(fileName, text, languageVersion, true)
209
+ : parsedFiles.get(fileName) ?? getSourceFile.call(host, fileName, languageVersion, onError, shouldCreateNewSourceFile);
210
+ host.fileExists = (fileName) => isPrinted(fileName) || fileExists.call(host, fileName);
211
+ host.readFile = (fileName) => isPrinted(fileName) ? text : readFile.call(host, fileName);
212
+ const program = ts.createProgram({ rootNames: [...project.rootNames, file], options: project.options, host });
213
+ const printedSource = program.getSourceFile(file);
214
+ const diagnostics = [...program.getSyntacticDiagnostics(printedSource), ...program.getSemanticDiagnostics(printedSource)];
215
+ if (diagnostics.length)
216
+ return ["the printed text does not compile:", ...formatDiagnostics(ts, diagnostics)];
217
+ const checker = program.getTypeChecker();
218
+ const contract = readInstanceContract(ts, program, checker, modulePath, exportName);
219
+ const reprinted = readPrintedContract(ts, program, checker, file, typeName);
220
+ if ("failure" in contract)
221
+ return [contract.failure];
222
+ if ("failure" in reprinted)
223
+ return [`the printed text: ${reprinted.failure}`];
224
+ const mismatches = [];
225
+ for (const entry of checker.getPropertiesOfType(contract)) {
226
+ const counterpart = checker.getPropertyOfType(reprinted, entry.name);
227
+ if (!counterpart) {
228
+ mismatches.push(`${entry.name}: missing from the printed type`);
229
+ continue;
230
+ }
231
+ const original = checker.getTypeOfSymbol(entry);
232
+ const copy = checker.getTypeOfSymbol(counterpart);
233
+ if (!checker.isTypeAssignableTo(original, copy))
234
+ mismatches.push(`${entry.name}: the contract's type is not assignable to the printed one`);
235
+ if (!checker.isTypeAssignableTo(copy, original))
236
+ mismatches.push(`${entry.name}: the printed type is not assignable to the contract's`);
237
+ }
238
+ for (const entry of checker.getPropertiesOfType(reprinted)) {
239
+ if (!checker.getPropertyOfType(contract, entry.name))
240
+ mismatches.push(`${entry.name}: in the printed type, not in the contract`);
241
+ }
242
+ return mismatches;
243
+ };
244
+ /**
245
+ * Which entries a file's text and the new text print differently, counting
246
+ * the declarations each entry refers to, directly or through another one: a
247
+ * change to a shared named type moves every entry that uses it.
248
+ */
249
+ const describeContractChanges = (ts, typeName, previous, text) => {
250
+ const before = previous === null ? new Map() : entryClosures(ts, typeName, previous);
251
+ const after = entryClosures(ts, typeName, text);
252
+ const changed = [...after.keys()].filter((name) => before.has(name) && before.get(name) !== after.get(name));
253
+ const added = [...after.keys()].filter((name) => !before.has(name));
254
+ const removed = [...before.keys()].filter((name) => !after.has(name));
255
+ return {
256
+ changed, added, removed,
257
+ lines: [...changed.map((name) => ` ~ ${name}`), ...added.map((name) => ` + ${name}`), ...removed.map((name) => ` - ${name}`)],
258
+ summary: `${changed.length} changed, ${added.length} added, ${removed.length} removed (${after.size - changed.length - added.length} unchanged)`,
259
+ };
260
+ };
261
+ /** Each entry of the contract type in a printed module, as its own text followed by every declaration it reaches. Empty when the text holds no such type. */
262
+ const entryClosures = (ts, typeName, text) => {
263
+ const sourceFile = ts.createSourceFile("contract.ts", text, ts.ScriptTarget.Latest, true);
264
+ const declared = new Map();
265
+ let contract;
266
+ for (const statement of sourceFile.statements) {
267
+ if (!ts.isTypeAliasDeclaration(statement))
268
+ continue;
269
+ if (statement.name.text === typeName && ts.isTypeLiteralNode(statement.type))
270
+ contract = statement.type;
271
+ else
272
+ declared.set(statement.name.text, statement.type);
273
+ }
274
+ const closures = new Map();
275
+ for (const member of contract?.members ?? []) {
276
+ if (!ts.isPropertySignature(member) || !member.type || !(ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)))
277
+ continue;
278
+ const reached = new Set();
279
+ const visit = (node) => {
280
+ if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) {
281
+ const name = node.typeName.text;
282
+ const body = declared.get(name);
283
+ if (body && !reached.has(name)) {
284
+ reached.add(name);
285
+ visit(body);
286
+ }
287
+ }
288
+ ts.forEachChild(node, visit);
289
+ };
290
+ visit(member.type);
291
+ closures.set(member.name.text, [member.type.getText(sourceFile), ...[...reached].sort().map((name) => `type ${name} = ${declared.get(name).getText(sourceFile)}`)].join("\n"));
292
+ }
293
+ return closures;
294
+ };
295
+ const formatDiagnostics = (ts, diagnostics) => {
296
+ const shown = ts.formatDiagnostics(diagnostics.slice(0, DIAGNOSTIC_LIMIT), {
297
+ getCanonicalFileName: (fileName) => fileName,
298
+ getCurrentDirectory: () => process.cwd(),
299
+ getNewLine: () => "\n",
300
+ }).trim().split("\n").map((line) => ` ${line}`);
301
+ return diagnostics.length > DIAGNOSTIC_LIMIT ? [...shown, ` ... and ${diagnostics.length - DIAGNOSTIC_LIMIT} more`] : shown;
302
+ };
@@ -0,0 +1,114 @@
1
+ import type { LambderApiSignatureEntry } from "../api/LambderApiSignature.js";
2
+ import { type LambderModuleLocation } from "./moduleLocation.js";
3
+ /**
4
+ * How the fresh process reports its comparison: one line on stdout starting
5
+ * with this, then the verdict as JSON. The line, not the exit status, is the
6
+ * answer: the app's module prints what it likes while it loads, and a
7
+ * process that fails before the line never compared anything.
8
+ */
9
+ export declare const VERIFY_REPORT_PREFIX = "lambder-api-signatures-verified:";
10
+ /** This process's execArgv, less the flags only it may run with (see PARENT_ONLY_FLAG). */
11
+ export declare const freshProcessNodeFlags: (execArgv: readonly string[]) => string[];
12
+ /** What writeApiSignatures reads the signatures from: a Lambder instance, or anything else that lists them the same way. */
13
+ export type LambderApiSignatureSource = {
14
+ apiSignatureEntries(): Promise<LambderApiSignatureEntry[]>;
15
+ };
16
+ export type LambderApiSignatureFileOptions = {
17
+ /**
18
+ * The module that exports the instance, usually the server's entry: a
19
+ * path relative to the working directory, or a file URL. It is imported
20
+ * in this process, so a TypeScript module needs the process's loader
21
+ * (`tsx`, `node --import tsx`), as the generator script itself does.
22
+ */
23
+ module: LambderModuleLocation;
24
+ /** The export that holds the instance. Default: "default", the module's default export. */
25
+ exportName?: string;
26
+ /** The TypeScript module to write, exporting `apiSignatures`. Relative to the working directory. */
27
+ file: string;
28
+ /** Write nothing, and answer whether the file on disk is what the registrations produce now. Default: false. */
29
+ check?: boolean;
30
+ /** The comment the file opens with, one `//` line per line. It should name what generates the file. Default: a note naming writeApiSignatures(). */
31
+ header?: string;
32
+ /** Quotes in the generated module. Default: "double". */
33
+ quotes?: "single" | "double";
34
+ /** End the generated statements with semicolons. Default: true. */
35
+ semicolons?: boolean;
36
+ /**
37
+ * After writing, or after a check that found the file current, load the
38
+ * module again in a fresh Node process and check the file against what
39
+ * it digests there. A schema built from the clock or a random source
40
+ * digests differently in every process; this catches it by endpoint name
41
+ * instead of letting signatures change on every build. Only the module is
42
+ * loaded there, never the calling script, so nothing the script does runs
43
+ * twice; the module's own top-level code does run again. The fresh
44
+ * process gets this process's Node flags (`--import`, `--require`,
45
+ * `--loader`, `--conditions`) less the inspector, watch mode, the test
46
+ * runner and the eval flags (`-e`, `-p`, `--input-type`), so a TypeScript
47
+ * module loads there as it did here when its loader is on the command
48
+ * line or in NODE_OPTIONS. Default: true.
49
+ */
50
+ verifyInFreshProcess?: boolean;
51
+ };
52
+ export type LambderApiSignatureFileResult = {
53
+ /** False when a check found the file stale, or a fresh process digested different signatures or never compared them. */
54
+ ok: boolean;
55
+ /** The absolute path. */
56
+ file: string;
57
+ /** How many APIs the file holds. */
58
+ count: number;
59
+ /** True when the file was (re)written; a file that already holds these signatures is left untouched, however it is formatted. */
60
+ written: boolean;
61
+ /** Endpoints whose signature differs from the file that was on disk, by name. */
62
+ changed: string[];
63
+ added: string[];
64
+ /** Endpoints the file held and the registrations no longer have. Named by key only: a key is a one-way hash of a name that is gone. */
65
+ removedKeys: string[];
66
+ /** What happened, as lines to print: a summary, then one line per endpoint that moved. */
67
+ lines: string[];
68
+ };
69
+ /**
70
+ * The map a generated file holds, read back by its hex pairs. Either quote
71
+ * style parses, and so does a key without quotes: a formatter that quotes
72
+ * properties only as needed (Prettier's default, Biome's, ESLint's
73
+ * quote-props) unquotes every key that starts with a letter.
74
+ */
75
+ export declare const readSignatureMap: (contents: string) => Record<string, string>;
76
+ /** How the registrations differ from a map read off a file, by endpoint, as the lines both this process and a fresh one print. */
77
+ export declare const describeSignatureChanges: (entries: LambderApiSignatureEntry[], previousMap: Record<string, string>) => {
78
+ changed: string[];
79
+ added: string[];
80
+ removedKeys: string[];
81
+ movedLines: string[];
82
+ summary: string;
83
+ };
84
+ /**
85
+ * Writes the signature map both sides ship (see Lambder.apiSignatures()) to
86
+ * a TypeScript module, or checks the one on disk, and says which endpoints
87
+ * moved: every changed signature is a forced reload for the tabs calling
88
+ * that endpoint, so this is the line that says how wide a deploy's reload
89
+ * will be.
90
+ *
91
+ * Call it from a generator script, naming the module that exports the app's
92
+ * instance, as writeApiContract takes it:
93
+ *
94
+ * ```ts
95
+ * import { writeApiSignatures } from "lambder/build";
96
+ *
97
+ * const result = await writeApiSignatures({
98
+ * module: "server/src/index.ts", // export const lambder = initLambder()...
99
+ * exportName: "lambder",
100
+ * file: "shared/generated/apiSignatures.generated.ts",
101
+ * check: process.argv.includes("--check"),
102
+ * });
103
+ * console.log(result.lines.join("\n"));
104
+ * process.exit(result.ok ? 0 : 1);
105
+ * ```
106
+ *
107
+ * Signatures are compared as the map the file holds, so a checkout that
108
+ * rewrote its line endings or a formatter that re-indented it or unquoted
109
+ * its keys is neither stale nor rewritten. The file, written or found
110
+ * current, is then checked again against the module loaded in a fresh
111
+ * process (see `verifyInFreshProcess`). A module that does not load, or an
112
+ * export that is not an instance, throws.
113
+ */
114
+ export declare const writeApiSignatures: (options: LambderApiSignatureFileOptions) => Promise<LambderApiSignatureFileResult>;
@@ -0,0 +1,217 @@
1
+ import { spawnSync } from "child_process";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { resolve } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { moduleUrlOf } from "./moduleLocation.js";
6
+ import { writeFileAtomically } from "./writeFileAtomically.js";
7
+ /*
8
+ * The signature file every app with apiSignatures needs, written and checked
9
+ * by the framework that defines it.
10
+ *
11
+ * The digests are computed once, by the generator, into this file, and both
12
+ * sides read the file: the frontend hands it to LambderCaller and the server
13
+ * hands it to create(), so the two can never disagree. What can still go wrong is the file itself (stale against the
14
+ * registrations, or different in every process because a schema reads the
15
+ * clock), and both are checked here.
16
+ */
17
+ /**
18
+ * How the fresh process reports its comparison: one line on stdout starting
19
+ * with this, then the verdict as JSON. The line, not the exit status, is the
20
+ * answer: the app's module prints what it likes while it loads, and a
21
+ * process that fails before the line never compared anything.
22
+ */
23
+ export const VERIFY_REPORT_PREFIX = "lambder-api-signatures-verified:";
24
+ /** How long a fresh-process verification may run before it counts as failed. */
25
+ const VERIFY_TIMEOUT_MS = 5 * 60 * 1000;
26
+ /** What a fresh process may print before its verdict line: a chatty module must not fill the pipe and fail the check. */
27
+ const VERIFY_OUTPUT_BYTES = 64 * 1024 * 1024;
28
+ /** The fresh process's entry, beside this module in the build. */
29
+ const FRESH_PROCESS_ENTRY = new URL("./freshProcessVerifier.js", import.meta.url);
30
+ /**
31
+ * This process's own Node flags that the fresh process must not inherit,
32
+ * because they make it something other than a plain run of the verifier: the
33
+ * inspector (`--inspect-brk` waits for a debugger forever, and a fixed port
34
+ * collides with this process's), watch mode (a watched process never exits),
35
+ * the test runner (`--test` runs the entry as a test file), and the eval
36
+ * flags: `-e`, `-p` and a cluster of them such as `-pe` (which run their code
37
+ * in place of the entry), and `--input-type` (which Node refuses beside an
38
+ * entry file). Everything else, `--import`, `--require`, `--loader`,
39
+ * `--conditions` and the rest, is how this process loads the app's modules,
40
+ * and the fresh one needs it to load the same way.
41
+ */
42
+ const PARENT_ONLY_FLAG = /^(--(inspect|debug-port|watch|test)([-=].*)?|--(eval|print|input-type)(=.*)?|-[a-z]*[ep][a-z]*)$/;
43
+ /** This process's execArgv, less the flags only it may run with (see PARENT_ONLY_FLAG). */
44
+ export const freshProcessNodeFlags = (execArgv) => {
45
+ const kept = [];
46
+ for (let index = 0; index < execArgv.length; index++) {
47
+ const flag = execArgv[index];
48
+ if (!PARENT_ONLY_FLAG.test(flag)) {
49
+ kept.push(flag);
50
+ continue;
51
+ }
52
+ // execArgv holds only Node's own options (the script and its
53
+ // arguments are argv), so an element that is not a flag is the value
54
+ // of the flag before it, and goes with it.
55
+ const next = execArgv[index + 1];
56
+ if (!flag.includes("=") && next !== undefined && !next.startsWith("-"))
57
+ index++;
58
+ }
59
+ return kept;
60
+ };
61
+ const DEFAULT_HEADER = [
62
+ "Generated by writeApiSignatures() from lambder/build. Do not edit.",
63
+ "",
64
+ "Every API the server registers, keyed by the hash of its name, with the",
65
+ "digest of its client-facing shape. The client sends the value with every",
66
+ "call and the server compares it with its own copy of this file, so a",
67
+ "deploy reloads only the tabs that call an endpoint whose shape changed.",
68
+ ].join("\n");
69
+ /**
70
+ * The map a generated file holds, read back by its hex pairs. Either quote
71
+ * style parses, and so does a key without quotes: a formatter that quotes
72
+ * properties only as needed (Prettier's default, Biome's, ESLint's
73
+ * quote-props) unquotes every key that starts with a letter.
74
+ */
75
+ export const readSignatureMap = (contents) => {
76
+ const map = {};
77
+ for (const match of contents.matchAll(/(?<![\w$])(['"]?)([0-9a-f]+)\1\s*:\s*['"]([0-9a-f]+)['"]/g))
78
+ map[match[2]] = match[3];
79
+ return map;
80
+ };
81
+ /** How the registrations differ from a map read off a file, by endpoint, as the lines both this process and a fresh one print. */
82
+ export const describeSignatureChanges = (entries, previousMap) => {
83
+ const byName = (a, b) => a.name.localeCompare(b.name);
84
+ const changed = entries.filter(({ key, signature }) => key in previousMap && previousMap[key] !== signature).sort(byName).map(({ name }) => name);
85
+ const added = entries.filter(({ key }) => !(key in previousMap)).sort(byName).map(({ name }) => name);
86
+ const keys = new Set(entries.map(({ key }) => key));
87
+ const removedKeys = Object.keys(previousMap).filter((key) => !keys.has(key));
88
+ const movedLines = [
89
+ ...changed.map((name) => ` ~ ${name}`),
90
+ ...added.map((name) => ` + ${name}`),
91
+ ...removedKeys.map((key) => ` - ${key} (removed: a key cannot be resolved back to its name)`),
92
+ ];
93
+ const summary = `${changed.length} changed, ${added.length} added, ${removedKeys.length} removed (${entries.length - changed.length - added.length} unchanged)`;
94
+ return { changed, added, removedKeys, movedLines, summary };
95
+ };
96
+ const renderSignatureFile = (entries, options) => {
97
+ const quote = options.quotes === "single" ? "'" : "\"";
98
+ const semicolon = options.semicolons === false ? "" : ";";
99
+ const header = (options.header ?? DEFAULT_HEADER).split("\n").map((line) => line ? `// ${line}` : "//");
100
+ // Keys and signatures are hex, so swapping the quote character touches nothing inside them.
101
+ const map = JSON.stringify(Object.fromEntries(entries.map(({ key, signature }) => [key, signature])), null, 4).replace(/"/g, quote);
102
+ return [
103
+ ...header,
104
+ `import type { LambderApiSignatureMap } from ${quote}lambder/client${quote}${semicolon}`,
105
+ "",
106
+ // Kept as written by a formatter that honours it, so a format pass
107
+ // after every generation has nothing to change. One that does not
108
+ // honour it changes the text only: the map reads back the same.
109
+ "// prettier-ignore",
110
+ `export const apiSignatures: LambderApiSignatureMap = ${map}${semicolon}`,
111
+ "",
112
+ ].join("\n");
113
+ };
114
+ /**
115
+ * Writes the signature map both sides ship (see Lambder.apiSignatures()) to
116
+ * a TypeScript module, or checks the one on disk, and says which endpoints
117
+ * moved: every changed signature is a forced reload for the tabs calling
118
+ * that endpoint, so this is the line that says how wide a deploy's reload
119
+ * will be.
120
+ *
121
+ * Call it from a generator script, naming the module that exports the app's
122
+ * instance, as writeApiContract takes it:
123
+ *
124
+ * ```ts
125
+ * import { writeApiSignatures } from "lambder/build";
126
+ *
127
+ * const result = await writeApiSignatures({
128
+ * module: "server/src/index.ts", // export const lambder = initLambder()...
129
+ * exportName: "lambder",
130
+ * file: "shared/generated/apiSignatures.generated.ts",
131
+ * check: process.argv.includes("--check"),
132
+ * });
133
+ * console.log(result.lines.join("\n"));
134
+ * process.exit(result.ok ? 0 : 1);
135
+ * ```
136
+ *
137
+ * Signatures are compared as the map the file holds, so a checkout that
138
+ * rewrote its line endings or a formatter that re-indented it or unquoted
139
+ * its keys is neither stale nor rewritten. The file, written or found
140
+ * current, is then checked again against the module loaded in a fresh
141
+ * process (see `verifyInFreshProcess`). A module that does not load, or an
142
+ * export that is not an instance, throws.
143
+ */
144
+ export const writeApiSignatures = async (options) => {
145
+ const file = resolve(options.file);
146
+ const moduleUrl = moduleUrlOf(options.module);
147
+ const exportName = options.exportName ?? "default";
148
+ let namespace;
149
+ try {
150
+ namespace = await import(moduleUrl);
151
+ }
152
+ catch (err) {
153
+ throw new Error(`writeApiSignatures could not load ${moduleUrl}`, { cause: err });
154
+ }
155
+ const source = namespace[exportName];
156
+ if (typeof source?.apiSignatureEntries !== "function") {
157
+ throw new Error(`${moduleUrl} has no export "${exportName}" that lists API signatures: name the export holding the instance in exportName`);
158
+ }
159
+ const entries = await source.apiSignatureEntries();
160
+ const previous = existsSync(file) ? readFileSync(file, "utf8") : null;
161
+ const { changed, added, removedKeys, movedLines, summary } = describeSignatureChanges(entries, previous === null ? {} : readSignatureMap(previous));
162
+ const unchanged = previous !== null && movedLines.length === 0;
163
+ const result = (ok, written, lines) => ({ ok, file, count: entries.length, written, changed, added, removedKeys, lines });
164
+ // A stale file is the answer by itself: a fresh process could only find
165
+ // it stale again. A current one goes on to the verification, as a write
166
+ // does.
167
+ let written = false;
168
+ let lines;
169
+ if (options.check) {
170
+ if (!unchanged)
171
+ return result(false, false, [`✗ ${options.file} is stale: regenerate it`, ` ${summary}`, ...movedLines]);
172
+ lines = [`✓ ${options.file} matches the ${entries.length} registered APIs`];
173
+ }
174
+ else {
175
+ // A file that already holds this map is left as it is, however it is
176
+ // formatted, so a watcher or an incremental build sees no change
177
+ // where there is none, and a formatter's or a checkout's version of
178
+ // the file is not rewritten back on every run. A change of header,
179
+ // quotes or semicolons shows the next time the map changes.
180
+ written = !unchanged;
181
+ if (written)
182
+ writeFileAtomically(file, renderSignatureFile(entries, options), previous !== null);
183
+ lines = [
184
+ written ? `✓ Wrote ${options.file} (${entries.length} APIs)` : `✓ ${options.file} is up to date (${entries.length} APIs)`,
185
+ ...(movedLines.length ? [` ${summary}`, ...movedLines] : [" no signatures changed: this build forces no reloads"]),
186
+ ];
187
+ }
188
+ if (options.verifyInFreshProcess === false)
189
+ return result(true, written, lines);
190
+ const request = { moduleUrl, exportName, file };
191
+ const child = spawnSync(process.execPath, [...freshProcessNodeFlags(process.execArgv), fileURLToPath(FRESH_PROCESS_ENTRY), JSON.stringify(request)], {
192
+ encoding: "utf8",
193
+ timeout: VERIFY_TIMEOUT_MS,
194
+ maxBuffer: VERIFY_OUTPUT_BYTES,
195
+ });
196
+ const reportLine = (child.stdout ?? "").split("\n").findLast((line) => line.startsWith(VERIFY_REPORT_PREFIX));
197
+ if (reportLine === undefined) {
198
+ const stderrTail = (child.stderr ?? "").trim().split("\n").slice(-20).filter(Boolean);
199
+ return result(false, written, [
200
+ ...lines,
201
+ child.error
202
+ ? `✗ the fresh process that verifies ${options.file} could not finish: ${child.error.message}`
203
+ : `✗ the fresh process that verifies ${options.file} never compared it: loading ${request.moduleUrl} failed (exit status ${child.status ?? "none"})`,
204
+ ...stderrTail.map((line) => ` ${line}`),
205
+ ]);
206
+ }
207
+ const verdict = JSON.parse(reportLine.slice(VERIFY_REPORT_PREFIX.length));
208
+ if ("failure" in verdict)
209
+ return result(false, written, [...lines, `✗ the fresh process that verifies ${options.file} never compared it: ${verdict.failure}`]);
210
+ if (verdict.same)
211
+ return result(true, written, [...lines, "✓ a fresh process digests the same signatures"]);
212
+ return result(false, written, [
213
+ ...lines,
214
+ `✗ a fresh process digests different signatures for ${options.file}: a schema reads the clock or a random source`,
215
+ ...verdict.lines.map((line) => ` ${line}`),
216
+ ]);
217
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Writes a generated file so that a build reading it meanwhile sees the old
3
+ * contents or the new, never half of either: the text goes to a file beside
4
+ * the target and is renamed over it. A symlink is followed to the file it
5
+ * names, since renamed over, the link itself would become the new file and
6
+ * its target would keep the old contents.
7
+ */
8
+ export declare const writeFileAtomically: (file: string, contents: string, exists: boolean) => void;
@@ -0,0 +1,22 @@
1
+ import { mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "fs";
2
+ import { dirname } from "path";
3
+ /**
4
+ * Writes a generated file so that a build reading it meanwhile sees the old
5
+ * contents or the new, never half of either: the text goes to a file beside
6
+ * the target and is renamed over it. A symlink is followed to the file it
7
+ * names, since renamed over, the link itself would become the new file and
8
+ * its target would keep the old contents.
9
+ */
10
+ export const writeFileAtomically = (file, contents, exists) => {
11
+ const target = exists ? realpathSync(file) : file;
12
+ mkdirSync(dirname(target), { recursive: true });
13
+ const partial = `${target}.${process.pid}.tmp`;
14
+ try {
15
+ writeFileSync(partial, contents);
16
+ renameSync(partial, target);
17
+ }
18
+ catch (err) {
19
+ rmSync(partial, { force: true });
20
+ throw err;
21
+ }
22
+ };