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.
- package/CHANGELOG.md +1047 -3
- package/README.md +46 -21
- package/dist/api/LambderApiAnswer.d.ts +18 -22
- package/dist/api/LambderApiAnswer.js +6 -7
- package/dist/api/LambderApiCallContext.d.ts +21 -8
- package/dist/api/LambderApiCallContext.js +22 -4
- package/dist/api/LambderApiDefinition.d.ts +4 -3
- package/dist/api/LambderApiEnvelope.d.ts +14 -9
- package/dist/api/LambderApiEnvelope.js +33 -34
- package/dist/api/LambderApiGuards.d.ts +78 -51
- package/dist/api/LambderApiGuards.js +34 -36
- package/dist/api/LambderApiIdempotency.d.ts +68 -62
- package/dist/api/LambderApiIdempotency.js +214 -151
- package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
- package/dist/api/LambderApiOutputValidationError.js +50 -0
- package/dist/api/LambderApiPipeline.d.ts +47 -38
- package/dist/api/LambderApiPipeline.js +122 -63
- package/dist/api/LambderApiRateLimits.d.ts +201 -54
- package/dist/api/LambderApiRateLimits.js +185 -108
- package/dist/api/LambderApiRequest.d.ts +27 -21
- package/dist/api/LambderApiRequest.js +26 -19
- package/dist/api/LambderApiSignature.d.ts +12 -15
- package/dist/api/LambderApiSignature.js +28 -51
- package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
- package/dist/api/LambderApiValidationRefusal.js +10 -10
- package/dist/build/ContractTypePrinter.d.ts +85 -0
- package/dist/build/ContractTypePrinter.js +402 -0
- package/dist/build/freshProcessVerifier.d.ts +13 -0
- package/dist/build/freshProcessVerifier.js +19 -0
- package/dist/build/moduleLocation.d.ts +11 -0
- package/dist/build/moduleLocation.js +6 -0
- package/dist/build/writeApiContract.d.ts +78 -0
- package/dist/build/writeApiContract.js +302 -0
- package/dist/build/writeApiSignatures.d.ts +114 -0
- package/dist/build/writeApiSignatures.js +217 -0
- package/dist/build/writeFileAtomically.d.ts +8 -0
- package/dist/build/writeFileAtomically.js +22 -0
- package/dist/build.d.ts +14 -0
- package/dist/build.js +11 -0
- package/dist/client/LambderCaller.d.ts +13 -44
- package/dist/client/LambderCaller.js +77 -84
- package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
- package/dist/client/LambderReloadLoopBreaker.js +90 -46
- package/dist/client/LambderUploadRunner.d.ts +96 -0
- package/dist/client/LambderUploadRunner.js +234 -0
- package/dist/client/lambderFetchTransport.d.ts +4 -1
- package/dist/client/lambderFetchTransport.js +52 -28
- package/dist/client.d.ts +9 -3
- package/dist/client.js +6 -1
- package/dist/core/Lambder.d.ts +143 -79
- package/dist/core/Lambder.js +350 -231
- package/dist/core/LambderContext.d.ts +82 -15
- package/dist/core/LambderContext.js +107 -20
- package/dist/core/LambderCors.d.ts +21 -3
- package/dist/core/LambderCors.js +35 -16
- package/dist/core/LambderCrashHandling.d.ts +40 -0
- package/dist/core/LambderCrashHandling.js +97 -0
- package/dist/core/LambderCreateOptions.d.ts +151 -75
- package/dist/core/LambderCreateOptions.js +16 -23
- package/dist/core/LambderFiles.d.ts +21 -7
- package/dist/core/LambderFiles.js +62 -34
- package/dist/core/LambderIndexHtml.js +12 -11
- package/dist/core/LambderPolicyBuilders.d.ts +17 -5
- package/dist/core/LambderPolicyBuilders.js +17 -5
- package/dist/core/LambderPublicFiles.d.ts +11 -5
- package/dist/core/LambderPublicFiles.js +32 -4
- package/dist/core/LambderRequestPath.d.ts +43 -0
- package/dist/core/LambderRequestPath.js +63 -0
- package/dist/core/LambderResponse.d.ts +26 -5
- package/dist/core/LambderResponse.js +157 -70
- package/dist/core/LambderResponseBuilder.d.ts +49 -4
- package/dist/core/LambderResponseBuilder.js +64 -3
- package/dist/core/LambderRouting.d.ts +2 -3
- package/dist/core/LambderRouting.js +22 -7
- package/dist/core/LambderTemplatingEngine.js +211 -32
- package/dist/index.d.ts +25 -8
- package/dist/index.js +13 -4
- package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
- package/dist/invoke/LambderInvokeCaller.js +76 -66
- package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
- package/dist/invoke/LambderInvokeOutcome.js +9 -22
- package/dist/invoke/LambderLambdaEvent.d.ts +29 -9
- package/dist/invoke/LambderLambdaEvent.js +40 -22
- package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
- package/dist/invoke/lambderHandlerTransport.js +15 -18
- package/dist/mock/LambderMockApp.d.ts +67 -83
- package/dist/mock/LambderMockApp.js +167 -153
- package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
- package/dist/mock/LambderMockBrowserCookies.js +24 -28
- package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
- package/dist/mock/LambderMockCallRecorder.js +19 -28
- package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
- package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
- package/dist/mock/LambderMockEntryRegistry.js +24 -29
- package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
- package/dist/mock/LambderMockFailureInjector.js +3 -6
- package/dist/mock/LambderMockTypes.d.ts +78 -108
- package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
- package/dist/mock/lambderMockInvokeTransport.js +11 -10
- package/dist/mock/lambderMockMswHandler.d.ts +43 -33
- package/dist/mock/lambderMockMswHandler.js +50 -39
- package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
- package/dist/mock/lambderMockUploadMswHandler.js +28 -0
- package/dist/mock.d.ts +4 -1
- package/dist/mock.js +6 -3
- package/dist/session/LambderSessionController.d.ts +108 -89
- package/dist/session/LambderSessionController.js +187 -168
- package/dist/session/LambderSessionCrypto.d.ts +16 -7
- package/dist/session/LambderSessionCrypto.js +26 -12
- package/dist/session/LambderSessionManager.d.ts +124 -46
- package/dist/session/LambderSessionManager.js +262 -137
- package/dist/shared/LambderHtml.d.ts +42 -3
- package/dist/shared/LambderHtml.js +127 -7
- package/dist/shared/LambderHtmlPositions.d.ts +173 -0
- package/dist/shared/LambderHtmlPositions.js +652 -0
- package/dist/shared/LambderI18n.d.ts +10 -11
- package/dist/shared/LambderI18n.js +33 -21
- package/dist/shared/contracts/LambderCache.d.ts +66 -0
- package/dist/shared/contracts/LambderCache.js +11 -0
- package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
- package/dist/shared/contracts/LambderFileSource.js +5 -8
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
- package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
- package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
- package/dist/shared/contracts/LambderRateLimiter.js +4 -5
- package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
- package/dist/shared/contracts/LambderSessionStore.js +5 -6
- package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
- package/dist/shared/contracts/LambderUploadBucket.js +74 -0
- package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
- package/dist/shared/transport/LambderApiTransport.js +7 -7
- package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
- package/dist/shared/transport/LambderCookieJar.js +54 -66
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
- package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
- package/dist/shared/util/LambderCallAbort.d.ts +5 -5
- package/dist/shared/util/LambderCallAbort.js +5 -5
- package/dist/shared/util/LambderClientIp.d.ts +27 -11
- package/dist/shared/util/LambderClientIp.js +96 -13
- package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
- package/dist/shared/util/LambderContentDisposition.js +13 -0
- package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
- package/dist/shared/util/LambderExpiringMap.js +41 -57
- package/dist/shared/util/LambderNodeModules.js +6 -7
- package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
- package/dist/shared/util/LambderOptionChecks.js +4 -4
- package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
- package/dist/shared/util/LambderResponseBrand.js +5 -5
- package/dist/shared/util/LambderTextDigest.d.ts +7 -5
- package/dist/shared/util/LambderTextDigest.js +11 -5
- package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
- package/dist/shared/util/LambderTypeUtilities.js +3 -3
- package/dist/shared/util/boundKeyField.d.ts +20 -0
- package/dist/shared/util/boundKeyField.js +34 -0
- package/dist/shared/util/canonicalJson.d.ts +11 -0
- package/dist/shared/util/canonicalJson.js +28 -0
- package/dist/shared/util/joinKeyFields.d.ts +20 -0
- package/dist/shared/util/joinKeyFields.js +22 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
- package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
- package/dist/shared/wire/LambderApiContract.d.ts +98 -53
- package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
- package/dist/shared/wire/LambderApiOutcome.js +48 -23
- package/dist/shared/wire/LambderApiRefusal.d.ts +45 -27
- package/dist/shared/wire/LambderApiRefusal.js +42 -7
- package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
- package/dist/shared/wire/LambderApiSignature.js +16 -19
- package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
- package/dist/shared/wire/LambderCallOptions.js +9 -11
- package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
- package/dist/shared/wire/LambderCompressionCodec.js +31 -36
- package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
- package/dist/shared/wire/LambderCompressionOption.js +9 -9
- package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
- package/dist/shared/wire/LambderCrashDetail.js +12 -15
- package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
- package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
- package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
- package/dist/shared/wire/LambderInvokeApiId.js +27 -0
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
- package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
- package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
- package/dist/shared/wire/LambderRequestPayload.js +4 -6
- package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -0
- package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
- package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
- package/dist/shared/wire/LambderUploadRefusal.js +18 -0
- package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
- package/dist/shared/wire/LambderUploadSchemas.js +30 -0
- package/dist/stores/LambderCacheFiller.d.ts +48 -0
- package/dist/stores/LambderCacheFiller.js +119 -0
- package/dist/stores/LambderCacheKeys.d.ts +26 -0
- package/dist/stores/LambderCacheKeys.js +54 -0
- package/dist/stores/LambderCacheValues.d.ts +45 -0
- package/dist/stores/LambderCacheValues.js +74 -0
- package/dist/stores/LambderDdbCache.d.ts +121 -56
- package/dist/stores/LambderDdbCache.js +528 -225
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
- package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
- package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
- package/dist/stores/LambderDdbRateLimiter.js +151 -39
- package/dist/stores/LambderDdbSdk.d.ts +43 -31
- package/dist/stores/LambderDdbSdk.js +80 -38
- package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
- package/dist/stores/LambderDdbSessionStore.js +119 -47
- package/dist/stores/LambderHttpFileSource.d.ts +15 -6
- package/dist/stores/LambderHttpFileSource.js +15 -13
- package/dist/stores/LambderMemoryCache.d.ts +49 -0
- package/dist/stores/LambderMemoryCache.js +113 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
- package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
- package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
- package/dist/stores/LambderMemoryRateLimiter.js +14 -13
- package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
- package/dist/stores/LambderMemorySessionStore.js +38 -19
- package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
- package/dist/stores/LambderMemoryUploadBucket.js +219 -0
- package/dist/stores/LambderS3FileSource.d.ts +21 -6
- package/dist/stores/LambderS3FileSource.js +12 -7
- package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
- package/dist/stores/LambderS3UploadBucket.js +144 -0
- package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
- package/dist/stores/LambderSdkInstallHint.js +14 -0
- package/dist/testing/LambderTestApp.d.ts +23 -25
- package/dist/testing/LambderTestApp.js +22 -24
- package/dist/testing/LambderTestVisitor.d.ts +10 -12
- package/dist/testing/LambderTestVisitor.js +15 -15
- package/dist/testing.d.ts +3 -0
- package/dist/testing.js +2 -0
- package/package.json +26 -3
- package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
- package/dist/api/LambderApiPolicyEngine.js +0 -85
- package/dist/shared/util/LambderKeyFields.d.ts +0 -32
- 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
|
+
};
|