lambder 8.0.2 → 8.1.2

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 (55) hide show
  1. package/CHANGELOG.md +143 -1
  2. package/README.md +7 -2
  3. package/dist/build/ContractTypePrinter.d.ts +115 -0
  4. package/dist/build/ContractTypePrinter.js +460 -0
  5. package/dist/build/moduleLocation.d.ts +11 -0
  6. package/dist/build/moduleLocation.js +6 -0
  7. package/dist/build/writeApiContract.d.ts +79 -0
  8. package/dist/build/writeApiContract.js +303 -0
  9. package/dist/build/writeApiSignatures.d.ts +32 -27
  10. package/dist/build/writeApiSignatures.js +37 -42
  11. package/dist/build/writeFileAtomically.d.ts +8 -0
  12. package/dist/build/writeFileAtomically.js +22 -0
  13. package/dist/build.d.ts +8 -3
  14. package/dist/build.js +6 -3
  15. package/dist/client/LambderUploadRunner.d.ts +96 -0
  16. package/dist/client/LambderUploadRunner.js +234 -0
  17. package/dist/client.d.ts +4 -0
  18. package/dist/client.js +4 -0
  19. package/dist/core/Lambder.d.ts +9 -10
  20. package/dist/core/Lambder.js +9 -10
  21. package/dist/index.d.ts +11 -1
  22. package/dist/index.js +8 -0
  23. package/dist/mock/lambderMockMswHandler.d.ts +10 -4
  24. package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
  25. package/dist/mock/lambderMockUploadMswHandler.js +28 -0
  26. package/dist/mock.d.ts +3 -0
  27. package/dist/mock.js +4 -0
  28. package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
  29. package/dist/shared/contracts/LambderUploadBucket.js +74 -0
  30. package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
  31. package/dist/shared/util/LambderContentDisposition.js +13 -0
  32. package/dist/shared/util/LambderTextDigest.d.ts +7 -5
  33. package/dist/shared/util/LambderTextDigest.js +11 -5
  34. package/dist/shared/util/escapeXmlText.d.ts +8 -0
  35. package/dist/shared/util/escapeXmlText.js +8 -0
  36. package/dist/shared/wire/LambderApiContract.d.ts +10 -40
  37. package/dist/shared/wire/LambderApiRefusal.d.ts +6 -0
  38. package/dist/shared/wire/LambderApiRefusal.js +6 -0
  39. package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -0
  40. package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
  41. package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
  42. package/dist/shared/wire/LambderUploadRefusal.js +18 -0
  43. package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
  44. package/dist/shared/wire/LambderUploadSchemas.js +30 -0
  45. package/dist/stores/LambderDdbSdk.js +1 -5
  46. package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
  47. package/dist/stores/LambderMemoryUploadBucket.js +219 -0
  48. package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
  49. package/dist/stores/LambderS3UploadBucket.js +144 -0
  50. package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
  51. package/dist/stores/LambderSdkInstallHint.js +14 -0
  52. package/dist/testing/LambderTestApp.d.ts +4 -4
  53. package/dist/testing.d.ts +2 -0
  54. package/dist/testing.js +1 -0
  55. package/package.json +15 -1
@@ -0,0 +1,303 @@
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; two that want one name are numbered by where
28
+ * each is declared, never by the order the APIs were registered in.
29
+ *
30
+ * Anything with no plain form fails the call and names where it sits: a
31
+ * function, a symbol-keyed property, an enum, a class's private member, or a
32
+ * type parameter the contract leaves open. Property `readonly` modifiers are
33
+ * not carried over (they never decide assignability); readonly arrays and
34
+ * tuples are.
35
+ *
36
+ * ```ts
37
+ * import { writeApiContract } from "lambder/build";
38
+ *
39
+ * const result = await writeApiContract({
40
+ * module: "server/src/index.ts", // export const lambder = initLambder()...
41
+ * exportName: "lambder",
42
+ * file: "shared/generated/apiContract.generated.ts",
43
+ * check: process.argv.includes("--check"),
44
+ * });
45
+ * console.log(result.lines.join("\n"));
46
+ * process.exit(result.ok ? 0 : 1);
47
+ * ```
48
+ *
49
+ * A check compares the text, so the file should be left out of formatters;
50
+ * each declaration carries a `// prettier-ignore` line for Prettier. A write
51
+ * that changes the file first compiles the new text beside the server's
52
+ * sources and checks every entry against the contract both ways, and writes
53
+ * nothing when one differs. The file is written to a temporary file renamed
54
+ * over the old one, so a build reading it meanwhile never sees half of it.
55
+ */
56
+ export const writeApiContract = async (options) => {
57
+ const ts = await loadTypeScript();
58
+ const file = resolve(options.file);
59
+ const modulePath = modulePathOf(options.module);
60
+ const exportName = options.exportName ?? "default";
61
+ const typeName = options.typeName ?? "ApiContractType";
62
+ const style = { quote: options.quotes === "single" ? "'" : "\"", semicolon: options.semicolons === false ? "" : ";" };
63
+ let count = 0;
64
+ const result = (ok, written, lines, moved) => ({ ok, file, count, written, changed: moved?.changed ?? [], added: moved?.added ?? [], removed: moved?.removed ?? [], lines });
65
+ const project = readProject(ts, options.tsconfig, modulePath);
66
+ if ("failure" in project)
67
+ return result(false, false, [`✗ ${options.module}: ${project.failure}`, ...(project.details ?? [])]);
68
+ // The program is used for the printing alone, and dropped before the
69
+ // verification builds its own: only the parsed files carry over.
70
+ const printing = printContractFile(ts, project, modulePath, exportName, typeName, style, options.header);
71
+ if ("failure" in printing)
72
+ return result(false, false, [`✗ ${options.module}: ${printing.failure}`, ...(printing.details ?? [])]);
73
+ count = printing.count;
74
+ const { text, parsedFiles } = printing;
75
+ const previous = existsSync(file) ? readFileSync(file, "utf8") : null;
76
+ const moved = describeContractChanges(ts, typeName, previous, text);
77
+ const unchanged = previous !== null && previous.replace(/\r\n/g, "\n") === text;
78
+ const movedSummary = moved.lines.length ? [` ${moved.summary}`, ...moved.lines] : [];
79
+ if (options.check) {
80
+ if (unchanged)
81
+ return result(true, false, [`✓ ${options.file} matches the ${count} APIs of ${typeName}`], moved);
82
+ return result(false, false, [`✗ ${options.file} is stale: regenerate it`, ...(movedSummary.length ? movedSummary : [" no API's types changed; the header or layout did"])], moved);
83
+ }
84
+ if (unchanged)
85
+ return result(true, false, [`✓ ${options.file} is up to date (${count} APIs)`], moved);
86
+ const mismatches = verifyPrintedContract(ts, project, parsedFiles, { modulePath, exportName }, { file, text, typeName });
87
+ if (mismatches.length)
88
+ 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);
89
+ writeFileAtomically(file, text, previous !== null);
90
+ return result(true, true, [`✓ Wrote ${options.file} (${count} APIs)`, ...(movedSummary.length ? movedSummary : [" no API's types changed; the header or layout did"])], moved);
91
+ };
92
+ /** The compiler API writeApiContract reads with. TypeScript 7 ships none, so its package answers the import without one. */
93
+ const loadTypeScript = async () => {
94
+ 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";
95
+ let ts;
96
+ try {
97
+ ts = (await import("typescript")).default;
98
+ }
99
+ catch (err) {
100
+ throw new Error(requirement, { cause: err });
101
+ }
102
+ if (typeof ts?.createProgram !== "function")
103
+ throw new Error(`${requirement}; the typescript installed (${ts?.version ?? "unknown"}) has no compiler API`);
104
+ return ts;
105
+ };
106
+ /** 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. */
107
+ const readProject = (ts, tsconfig, modulePath) => {
108
+ const configPath = tsconfig ? resolve(tsconfig) : ts.findConfigFile(dirname(modulePath), ts.sys.fileExists);
109
+ if (!configPath)
110
+ return { failure: "no tsconfig.json in its directory or above it; name the server's in tsconfig" };
111
+ const read = ts.readConfigFile(configPath, ts.sys.readFile);
112
+ if (read.error)
113
+ return { failure: `${configPath} could not be read`, details: formatDiagnostics(ts, [read.error]) };
114
+ const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(configPath), undefined, configPath);
115
+ if (parsed.errors.length)
116
+ return { failure: `${configPath} has errors`, details: formatDiagnostics(ts, parsed.errors) };
117
+ return {
118
+ // Nothing is emitted, and a composite project's rule that every file be
119
+ // listed in it does not hold for a program rooted at one module.
120
+ options: { ...parsed.options, noEmit: true, composite: false, incremental: false, declaration: false },
121
+ rootNames: [modulePath, ...parsed.fileNames.filter((fileName) => fileName.endsWith(".d.ts"))],
122
+ };
123
+ };
124
+ /** An export of a module, followed through a re-export or an `export default` of a name. */
125
+ const readExport = (ts, program, checker, modulePath, exportName) => {
126
+ const sourceFile = program.getSourceFile(modulePath);
127
+ if (!sourceFile)
128
+ return { failure: "the module was not found" };
129
+ const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
130
+ const exported = moduleSymbol && checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.name === exportName);
131
+ if (!exported)
132
+ return { failure: `the module has no export named "${exportName}"` };
133
+ return exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported;
134
+ };
135
+ /** The ApiContract of the instance a module exports, from a module that compiles. */
136
+ const readInstanceContract = (ts, program, checker, modulePath, exportName) => {
137
+ const sourceFile = program.getSourceFile(modulePath);
138
+ if (!sourceFile)
139
+ return { failure: "the module was not found" };
140
+ // A module with errors can infer `any` where the contract meant a type,
141
+ // and `any` prints and verifies as itself.
142
+ const diagnostics = [...program.getSyntacticDiagnostics(sourceFile), ...program.getSemanticDiagnostics(sourceFile)];
143
+ if (diagnostics.length)
144
+ return { failure: "the module does not compile", details: formatDiagnostics(ts, diagnostics) };
145
+ const instance = readExport(ts, program, checker, modulePath, exportName);
146
+ if ("failure" in instance)
147
+ return { failure: `${instance.failure}; name the export that holds the Lambder instance in exportName` };
148
+ const contract = checker.getPropertyOfType(checker.getTypeOfSymbol(instance), "ApiContract");
149
+ if (!contract)
150
+ return { failure: `"${exportName}" is not a Lambder instance: it has no ApiContract` };
151
+ return checker.getTypeOfSymbol(contract);
152
+ };
153
+ /** The contract type a printed module exports. */
154
+ const readPrintedContract = (ts, program, checker, file, typeName) => {
155
+ const exported = readExport(ts, program, checker, file, typeName);
156
+ if ("failure" in exported)
157
+ return exported;
158
+ if (!(exported.flags & ts.SymbolFlags.Type))
159
+ return { failure: `${typeName} is not a type` };
160
+ return checker.getDeclaredTypeOfSymbol(exported);
161
+ };
162
+ const printContractFile = (ts, project, modulePath, exportName, typeName, style, header) => {
163
+ const program = ts.createProgram({ rootNames: project.rootNames, options: project.options });
164
+ const checker = program.getTypeChecker();
165
+ const contract = readInstanceContract(ts, program, checker, modulePath, exportName);
166
+ if ("failure" in contract)
167
+ return contract;
168
+ const printer = new ContractTypePrinter(ts, program, checker, style, typeName);
169
+ const printed = printer.printContract(contract);
170
+ if (printed.failures.length) {
171
+ return { failure: `${typeName} holds types with no plain form, which only the server's sources could name:`, details: printed.failures.map((line) => ` ${line}`) };
172
+ }
173
+ return {
174
+ text: renderContractFile(printer, printed, typeName, style, header),
175
+ count: printed.entries.length,
176
+ parsedFiles: new Map(program.getSourceFiles().map((sourceFile) => [sourceFile.fileName, sourceFile])),
177
+ };
178
+ };
179
+ const renderContractFile = (printer, printed, typeName, style, header = DEFAULT_HEADER) => {
180
+ const indented = (text) => INDENT + text.replace(/\n/g, `\n${INDENT}`);
181
+ const entries = printed.entries.map(({ name, text }) => indented(printer.labeledValue(`${printer.keyOf(name)}:`, text)) + style.semicolon);
182
+ return [
183
+ ...header.split("\n").map((line) => line ? `// ${line}` : "//"),
184
+ "",
185
+ // Kept as written by a formatter that honours it: a check compares
186
+ // the text.
187
+ "// prettier-ignore",
188
+ entries.length ? [`export type ${typeName} = {`, ...entries, `}${style.semicolon}`].join("\n") : `export type ${typeName} = {}${style.semicolon}`,
189
+ ...printed.declarations.flatMap(({ name, text }) => ["", "// prettier-ignore", printer.labeledValue(`type ${name} =`, text) + style.semicolon]),
190
+ "",
191
+ ].join("\n");
192
+ };
193
+ /**
194
+ * Compiles the printed text beside the server's sources, from the files the
195
+ * printing parsed, and checks each entry against the contract's in both
196
+ * directions. Answers the entries that differ, or the diagnostics of a text
197
+ * that does not compile; nothing when the two are the same type.
198
+ */
199
+ const verifyPrintedContract = (ts, project, parsedFiles, { modulePath, exportName }, { file, text, typeName }) => {
200
+ const canonical = (fileName) => {
201
+ const slashed = fileName.replace(/\\/g, "/");
202
+ return ts.sys.useCaseSensitiveFileNames ? slashed : slashed.toLowerCase();
203
+ };
204
+ const printedFile = canonical(file);
205
+ const isPrinted = (fileName) => canonical(fileName) === printedFile;
206
+ const host = ts.createCompilerHost(project.options, true);
207
+ const { getSourceFile, fileExists, readFile } = host;
208
+ host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => isPrinted(fileName)
209
+ ? ts.createSourceFile(fileName, text, languageVersion, true)
210
+ : parsedFiles.get(fileName) ?? getSourceFile.call(host, fileName, languageVersion, onError, shouldCreateNewSourceFile);
211
+ host.fileExists = (fileName) => isPrinted(fileName) || fileExists.call(host, fileName);
212
+ host.readFile = (fileName) => isPrinted(fileName) ? text : readFile.call(host, fileName);
213
+ const program = ts.createProgram({ rootNames: [...project.rootNames, file], options: project.options, host });
214
+ const printedSource = program.getSourceFile(file);
215
+ const diagnostics = [...program.getSyntacticDiagnostics(printedSource), ...program.getSemanticDiagnostics(printedSource)];
216
+ if (diagnostics.length)
217
+ return ["the printed text does not compile:", ...formatDiagnostics(ts, diagnostics)];
218
+ const checker = program.getTypeChecker();
219
+ const contract = readInstanceContract(ts, program, checker, modulePath, exportName);
220
+ const reprinted = readPrintedContract(ts, program, checker, file, typeName);
221
+ if ("failure" in contract)
222
+ return [contract.failure];
223
+ if ("failure" in reprinted)
224
+ return [`the printed text: ${reprinted.failure}`];
225
+ const mismatches = [];
226
+ for (const entry of checker.getPropertiesOfType(contract)) {
227
+ const counterpart = checker.getPropertyOfType(reprinted, entry.name);
228
+ if (!counterpart) {
229
+ mismatches.push(`${entry.name}: missing from the printed type`);
230
+ continue;
231
+ }
232
+ const original = checker.getTypeOfSymbol(entry);
233
+ const copy = checker.getTypeOfSymbol(counterpart);
234
+ if (!checker.isTypeAssignableTo(original, copy))
235
+ mismatches.push(`${entry.name}: the contract's type is not assignable to the printed one`);
236
+ if (!checker.isTypeAssignableTo(copy, original))
237
+ mismatches.push(`${entry.name}: the printed type is not assignable to the contract's`);
238
+ }
239
+ for (const entry of checker.getPropertiesOfType(reprinted)) {
240
+ if (!checker.getPropertyOfType(contract, entry.name))
241
+ mismatches.push(`${entry.name}: in the printed type, not in the contract`);
242
+ }
243
+ return mismatches;
244
+ };
245
+ /**
246
+ * Which entries a file's text and the new text print differently, counting
247
+ * the declarations each entry refers to, directly or through another one: a
248
+ * change to a shared named type moves every entry that uses it.
249
+ */
250
+ const describeContractChanges = (ts, typeName, previous, text) => {
251
+ const before = previous === null ? new Map() : entryClosures(ts, typeName, previous);
252
+ const after = entryClosures(ts, typeName, text);
253
+ const changed = [...after.keys()].filter((name) => before.has(name) && before.get(name) !== after.get(name));
254
+ const added = [...after.keys()].filter((name) => !before.has(name));
255
+ const removed = [...before.keys()].filter((name) => !after.has(name));
256
+ return {
257
+ changed, added, removed,
258
+ lines: [...changed.map((name) => ` ~ ${name}`), ...added.map((name) => ` + ${name}`), ...removed.map((name) => ` - ${name}`)],
259
+ summary: `${changed.length} changed, ${added.length} added, ${removed.length} removed (${after.size - changed.length - added.length} unchanged)`,
260
+ };
261
+ };
262
+ /** 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. */
263
+ const entryClosures = (ts, typeName, text) => {
264
+ const sourceFile = ts.createSourceFile("contract.ts", text, ts.ScriptTarget.Latest, true);
265
+ const declared = new Map();
266
+ let contract;
267
+ for (const statement of sourceFile.statements) {
268
+ if (!ts.isTypeAliasDeclaration(statement))
269
+ continue;
270
+ if (statement.name.text === typeName && ts.isTypeLiteralNode(statement.type))
271
+ contract = statement.type;
272
+ else
273
+ declared.set(statement.name.text, statement.type);
274
+ }
275
+ const closures = new Map();
276
+ for (const member of contract?.members ?? []) {
277
+ if (!ts.isPropertySignature(member) || !member.type || !(ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)))
278
+ continue;
279
+ const reached = new Set();
280
+ const visit = (node) => {
281
+ if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) {
282
+ const name = node.typeName.text;
283
+ const body = declared.get(name);
284
+ if (body && !reached.has(name)) {
285
+ reached.add(name);
286
+ visit(body);
287
+ }
288
+ }
289
+ ts.forEachChild(node, visit);
290
+ };
291
+ visit(member.type);
292
+ closures.set(member.name.text, [member.type.getText(sourceFile), ...[...reached].sort().map((name) => `type ${name} = ${declared.get(name).getText(sourceFile)}`)].join("\n"));
293
+ }
294
+ return closures;
295
+ };
296
+ const formatDiagnostics = (ts, diagnostics) => {
297
+ const shown = ts.formatDiagnostics(diagnostics.slice(0, DIAGNOSTIC_LIMIT), {
298
+ getCanonicalFileName: (fileName) => fileName,
299
+ getCurrentDirectory: () => process.cwd(),
300
+ getNewLine: () => "\n",
301
+ }).trim().split("\n").map((line) => ` ${line}`);
302
+ return diagnostics.length > DIAGNOSTIC_LIMIT ? [...shown, ` ... and ${diagnostics.length - DIAGNOSTIC_LIMIT} more`] : shown;
303
+ };
@@ -1,4 +1,5 @@
1
1
  import type { LambderApiSignatureEntry } from "../api/LambderApiSignature.js";
2
+ import { type LambderModuleLocation } from "./moduleLocation.js";
2
3
  /**
3
4
  * How the fresh process reports its comparison: one line on stdout starting
4
5
  * with this, then the verdict as JSON. The line, not the exit status, is the
@@ -13,6 +14,15 @@ export type LambderApiSignatureSource = {
13
14
  apiSignatureEntries(): Promise<LambderApiSignatureEntry[]>;
14
15
  };
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;
16
26
  /** The TypeScript module to write, exporting `apiSignatures`. Relative to the working directory. */
17
27
  file: string;
18
28
  /** Write nothing, and answer whether the file on disk is what the registrations produce now. Default: false. */
@@ -25,30 +35,19 @@ export type LambderApiSignatureFileOptions = {
25
35
  semicolons?: boolean;
26
36
  /**
27
37
  * After writing, or after a check that found the file current, load the
28
- * module that holds the instance in a fresh Node process and check the
29
- * file against what it digests there. A schema built from the clock or a
30
- * random source digests differently in every process; this catches it by
31
- * endpoint name instead of letting signatures change on every build. Only
32
- * that module is loaded, never the calling script, so nothing the script
33
- * does runs twice; the module's own top-level code does run again. The
34
- * fresh process gets this process's Node flags (`--import`, `--require`,
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`,
35
45
  * `--loader`, `--conditions`) less the inspector, watch mode, the test
36
46
  * runner and the eval flags (`-e`, `-p`, `--input-type`), so a TypeScript
37
47
  * module loads there as it did here when its loader is on the command
38
- * line or in NODE_OPTIONS. Default: not verified.
48
+ * line or in NODE_OPTIONS. Default: true.
39
49
  */
40
- verifyInFreshProcess?: {
41
- /**
42
- * The module that exports the instance: a path relative to the
43
- * working directory, or a file URL, as a URL such as
44
- * `new URL("../backend/index.js", import.meta.url)` beside the
45
- * generator's own import of it, or as the string
46
- * `import.meta.resolve()` answers.
47
- */
48
- module: string | URL;
49
- /** The export that holds the instance. Default: "default", the module's default export. */
50
- exportName?: string;
51
- };
50
+ verifyInFreshProcess?: boolean;
52
51
  };
53
52
  export type LambderApiSignatureFileResult = {
54
53
  /** False when a check found the file stale, or a fresh process digested different signatures or never compared them. */
@@ -89,21 +88,27 @@ export declare const describeSignatureChanges: (entries: LambderApiSignatureEntr
89
88
  * that endpoint, so this is the line that says how wide a deploy's reload
90
89
  * will be.
91
90
  *
92
- * Call it from a generator script that imports the app's instance:
91
+ * Call it from a generator script, naming the module that exports the app's
92
+ * instance, as writeApiContract takes it:
93
93
  *
94
94
  * ```ts
95
95
  * import { writeApiSignatures } from "lambder/build";
96
- * import { lambder } from "../server/src/index.js";
97
96
  *
98
- * const result = await writeApiSignatures(lambder, { file: "shared/generated/apiSignatures.generated.ts", check: process.argv.includes("--check") });
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
+ * });
99
103
  * console.log(result.lines.join("\n"));
100
104
  * process.exit(result.ok ? 0 : 1);
101
105
  * ```
102
106
  *
103
107
  * Signatures are compared as the map the file holds, so a checkout that
104
108
  * rewrote its line endings or a formatter that re-indented it or unquoted
105
- * its keys is neither stale nor rewritten. `verifyInFreshProcess` also
106
- * checks the file, written or found current, against the instance's module
107
- * loaded in a fresh process.
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.
108
113
  */
109
- export declare const writeApiSignatures: (source: LambderApiSignatureSource, options: LambderApiSignatureFileOptions) => Promise<LambderApiSignatureFileResult>;
114
+ export declare const writeApiSignatures: (options: LambderApiSignatureFileOptions) => Promise<LambderApiSignatureFileResult>;
@@ -1,7 +1,9 @@
1
1
  import { spawnSync } from "child_process";
2
- import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "fs";
3
- import { dirname, resolve } from "path";
4
- import { fileURLToPath, pathToFileURL } from "url";
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";
5
7
  /*
6
8
  * The signature file every app with apiSignatures needs, written and checked
7
9
  * by the framework that defines it.
@@ -116,25 +118,44 @@ const renderSignatureFile = (entries, options) => {
116
118
  * that endpoint, so this is the line that says how wide a deploy's reload
117
119
  * will be.
118
120
  *
119
- * Call it from a generator script that imports the app's instance:
121
+ * Call it from a generator script, naming the module that exports the app's
122
+ * instance, as writeApiContract takes it:
120
123
  *
121
124
  * ```ts
122
125
  * import { writeApiSignatures } from "lambder/build";
123
- * import { lambder } from "../server/src/index.js";
124
126
  *
125
- * const result = await writeApiSignatures(lambder, { file: "shared/generated/apiSignatures.generated.ts", check: process.argv.includes("--check") });
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
+ * });
126
133
  * console.log(result.lines.join("\n"));
127
134
  * process.exit(result.ok ? 0 : 1);
128
135
  * ```
129
136
  *
130
137
  * Signatures are compared as the map the file holds, so a checkout that
131
138
  * rewrote its line endings or a formatter that re-indented it or unquoted
132
- * its keys is neither stale nor rewritten. `verifyInFreshProcess` also
133
- * checks the file, written or found current, against the instance's module
134
- * loaded in a fresh process.
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.
135
143
  */
136
- export const writeApiSignatures = async (source, options) => {
144
+ export const writeApiSignatures = async (options) => {
137
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
+ }
138
159
  const entries = await source.apiSignatureEntries();
139
160
  const previous = existsSync(file) ? readFileSync(file, "utf8") : null;
140
161
  const { changed, added, removedKeys, movedLines, summary } = describeSignatureChanges(entries, previous === null ? {} : readSignatureMap(previous));
@@ -142,7 +163,7 @@ export const writeApiSignatures = async (source, options) => {
142
163
  const result = (ok, written, lines) => ({ ok, file, count: entries.length, written, changed, added, removedKeys, lines });
143
164
  // A stale file is the answer by itself: a fresh process could only find
144
165
  // it stale again. A current one goes on to the verification, as a write
145
- // does, so a check that names verifyInFreshProcess verifies.
166
+ // does.
146
167
  let written = false;
147
168
  let lines;
148
169
  if (options.check) {
@@ -155,44 +176,18 @@ export const writeApiSignatures = async (source, options) => {
155
176
  // formatted, so a watcher or an incremental build sees no change
156
177
  // where there is none, and a formatter's or a checkout's version of
157
178
  // the file is not rewritten back on every run. A change of header,
158
- // quotes or semicolons shows the next time the map changes. The write
159
- // goes to a file beside the target and is renamed over it, so a build
160
- // reading the file meanwhile sees the old map or the new one, never
161
- // half of one. A symlink is followed to the file it names: renamed
162
- // over, the link itself would become the new file and its target
163
- // would keep the old map.
179
+ // quotes or semicolons shows the next time the map changes.
164
180
  written = !unchanged;
165
- if (written) {
166
- const target = previous === null ? file : realpathSync(file);
167
- mkdirSync(dirname(target), { recursive: true });
168
- const partial = `${target}.${process.pid}.tmp`;
169
- try {
170
- writeFileSync(partial, renderSignatureFile(entries, options));
171
- renameSync(partial, target);
172
- }
173
- catch (err) {
174
- rmSync(partial, { force: true });
175
- throw err;
176
- }
177
- }
181
+ if (written)
182
+ writeFileAtomically(file, renderSignatureFile(entries, options), previous !== null);
178
183
  lines = [
179
184
  written ? `✓ Wrote ${options.file} (${entries.length} APIs)` : `✓ ${options.file} is up to date (${entries.length} APIs)`,
180
185
  ...(movedLines.length ? [` ${summary}`, ...movedLines] : [" no signatures changed: this build forces no reloads"]),
181
186
  ];
182
187
  }
183
- if (!options.verifyInFreshProcess)
188
+ if (options.verifyInFreshProcess === false)
184
189
  return result(true, written, lines);
185
- const { module: instanceModule, exportName = "default" } = options.verifyInFreshProcess;
186
- const request = {
187
- // A string that is already a file URL (what import.meta.resolve()
188
- // answers) is taken as one: resolved as a path, it would name a
189
- // directory called "file:" under the working directory.
190
- moduleUrl: typeof instanceModule === "string" && !/^file:/i.test(instanceModule)
191
- ? pathToFileURL(resolve(instanceModule)).href
192
- : new URL(instanceModule).href,
193
- exportName,
194
- file,
195
- };
190
+ const request = { moduleUrl, exportName, file };
196
191
  const child = spawnSync(process.execPath, [...freshProcessNodeFlags(process.execArgv), fileURLToPath(FRESH_PROCESS_ENTRY), JSON.stringify(request)], {
197
192
  encoding: "utf8",
198
193
  timeout: VERIFY_TIMEOUT_MS,
@@ -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
+ };
package/dist/build.d.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * Build entry point (`import ... from "lambder/build"`).
3
3
  *
4
- * What a generator script runs at build time over the app's own instance to
5
- * write the signature file both sides ship. Node-only and imported by nothing
6
- * else in the package, so no deployment or bundle carries it.
4
+ * What a generator script runs at build time to write the files a deployment
5
+ * ships: the signature file both sides read, from the app's own instance, and
6
+ * the contract a client compiles against, from the server's sources. Node-only
7
+ * and imported by nothing else in the package, so no deployment or bundle
8
+ * carries it.
7
9
  */
8
10
  export { writeApiSignatures } from "./build/writeApiSignatures.js";
9
11
  export type { LambderApiSignatureSource, LambderApiSignatureFileOptions, LambderApiSignatureFileResult, } from "./build/writeApiSignatures.js";
12
+ export { writeApiContract } from "./build/writeApiContract.js";
13
+ export type { LambderApiContractFileOptions, LambderApiContractFileResult, } from "./build/writeApiContract.js";
14
+ export type { LambderModuleLocation } from "./build/moduleLocation.js";
package/dist/build.js CHANGED
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Build entry point (`import ... from "lambder/build"`).
3
3
  *
4
- * What a generator script runs at build time over the app's own instance to
5
- * write the signature file both sides ship. Node-only and imported by nothing
6
- * else in the package, so no deployment or bundle carries it.
4
+ * What a generator script runs at build time to write the files a deployment
5
+ * ships: the signature file both sides read, from the app's own instance, and
6
+ * the contract a client compiles against, from the server's sources. Node-only
7
+ * and imported by nothing else in the package, so no deployment or bundle
8
+ * carries it.
7
9
  */
8
10
  export { writeApiSignatures } from "./build/writeApiSignatures.js";
11
+ export { writeApiContract } from "./build/writeApiContract.js";