lambder 8.0.2 → 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 +115 -1
- package/README.md +7 -2
- package/dist/build/ContractTypePrinter.d.ts +85 -0
- package/dist/build/ContractTypePrinter.js +402 -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 +32 -27
- package/dist/build/writeApiSignatures.js +37 -42
- package/dist/build/writeFileAtomically.d.ts +8 -0
- package/dist/build/writeFileAtomically.js +22 -0
- package/dist/build.d.ts +8 -3
- package/dist/build.js +6 -3
- package/dist/client/LambderUploadRunner.d.ts +96 -0
- package/dist/client/LambderUploadRunner.js +234 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +4 -0
- package/dist/core/Lambder.d.ts +9 -10
- package/dist/core/Lambder.js +9 -10
- package/dist/index.d.ts +11 -1
- package/dist/index.js +8 -0
- package/dist/mock/lambderMockMswHandler.d.ts +10 -4
- package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
- package/dist/mock/lambderMockUploadMswHandler.js +28 -0
- package/dist/mock.d.ts +3 -0
- package/dist/mock.js +4 -0
- package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
- package/dist/shared/contracts/LambderUploadBucket.js +74 -0
- package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
- package/dist/shared/util/LambderContentDisposition.js +13 -0
- package/dist/shared/util/LambderTextDigest.d.ts +7 -5
- package/dist/shared/util/LambderTextDigest.js +11 -5
- package/dist/shared/wire/LambderApiContract.d.ts +10 -40
- package/dist/shared/wire/LambderApiRefusal.d.ts +6 -0
- package/dist/shared/wire/LambderApiRefusal.js +6 -0
- 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/LambderDdbSdk.js +1 -5
- package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
- package/dist/stores/LambderMemoryUploadBucket.js +219 -0
- 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 +4 -4
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +1 -0
- package/package.json +15 -1
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { resolve } from "path";
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
3
|
+
/** The module as a file URL. A string that already is one is taken as one: resolved as a path, it would name a directory called "file:". */
|
|
4
|
+
export const moduleUrlOf = (module) => typeof module === "string" && !/^file:/i.test(module) ? pathToFileURL(resolve(module)).href : new URL(module).href;
|
|
5
|
+
/** The module as a path on disk. */
|
|
6
|
+
export const modulePathOf = (module) => fileURLToPath(moduleUrlOf(module));
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type LambderModuleLocation } from "./moduleLocation.js";
|
|
2
|
+
export type LambderApiContractFileOptions = {
|
|
3
|
+
/** The module that exports the Lambder instance, usually the server's entry: a path relative to the working directory, or a file URL. */
|
|
4
|
+
module: LambderModuleLocation;
|
|
5
|
+
/** The export that holds the instance, whose ApiContract is written out. Default: "default", the module's default export. */
|
|
6
|
+
exportName?: string;
|
|
7
|
+
/** The name the written module exports the contract type under. Default: "ApiContractType". */
|
|
8
|
+
typeName?: string;
|
|
9
|
+
/** The tsconfig.json the module compiles under, whose compiler options and path aliases resolve its imports. Relative to the working directory. Default: the nearest tsconfig.json at or above the module's directory. */
|
|
10
|
+
tsconfig?: string;
|
|
11
|
+
/** The TypeScript module to write. Relative to the working directory. */
|
|
12
|
+
file: string;
|
|
13
|
+
/** Write nothing, and answer whether the file on disk is what the contract prints now. Default: false. */
|
|
14
|
+
check?: boolean;
|
|
15
|
+
/** The comment the file opens with, one `//` line per line. It should name what generates the file. Default: a note naming writeApiContract(). */
|
|
16
|
+
header?: string;
|
|
17
|
+
/** Quotes in the generated module. Default: "double". */
|
|
18
|
+
quotes?: "single" | "double";
|
|
19
|
+
/** End the generated declarations and members with semicolons. Default: true. */
|
|
20
|
+
semicolons?: boolean;
|
|
21
|
+
};
|
|
22
|
+
export type LambderApiContractFileResult = {
|
|
23
|
+
/** False when the contract could not be read or printed, a check found the file stale, or the printed type was not the contract's. */
|
|
24
|
+
ok: boolean;
|
|
25
|
+
/** The absolute path. */
|
|
26
|
+
file: string;
|
|
27
|
+
/** How many APIs the contract holds (0 when it could not be read). */
|
|
28
|
+
count: number;
|
|
29
|
+
/** True when the file was (re)written; a file that already holds this text is left untouched. */
|
|
30
|
+
written: boolean;
|
|
31
|
+
/** APIs whose printed types differ from the file that was on disk, by name, counting the declarations each refers to. */
|
|
32
|
+
changed: string[];
|
|
33
|
+
added: string[];
|
|
34
|
+
removed: string[];
|
|
35
|
+
/** What happened, as lines to print: a summary, then one line per API that moved or per member that could not be printed. */
|
|
36
|
+
lines: string[];
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Writes an app's API contract type to a TypeScript module as plain types,
|
|
40
|
+
* or checks the one on disk, and names the APIs whose types moved.
|
|
41
|
+
*
|
|
42
|
+
* The contract is the ApiContract property of the instance the module
|
|
43
|
+
* exports, read through the TypeScript compiler under the server's own
|
|
44
|
+
* tsconfig; nothing of the server runs. Every type in it is printed as the
|
|
45
|
+
* structure it resolves to: zod's inferences, mapped and conditional types and
|
|
46
|
+
* the server's own types become plain object types, unions and literals. The
|
|
47
|
+
* written module imports nothing, not even lambder, and exports one type
|
|
48
|
+
* alias, `typeName`. Only the default library's interfaces (Date) are printed
|
|
49
|
+
* by name. A non-generic named type is printed once, as a declaration of its
|
|
50
|
+
* own that the entries refer to.
|
|
51
|
+
*
|
|
52
|
+
* Anything with no plain form fails the call and names where it sits: a
|
|
53
|
+
* function, a symbol-keyed property, an enum, a class's private member, or a
|
|
54
|
+
* type parameter the contract leaves open. Property `readonly` modifiers are
|
|
55
|
+
* not carried over (they never decide assignability); readonly arrays and
|
|
56
|
+
* tuples are.
|
|
57
|
+
*
|
|
58
|
+
* ```ts
|
|
59
|
+
* import { writeApiContract } from "lambder/build";
|
|
60
|
+
*
|
|
61
|
+
* const result = await writeApiContract({
|
|
62
|
+
* module: "server/src/index.ts", // export const lambder = initLambder()...
|
|
63
|
+
* exportName: "lambder",
|
|
64
|
+
* file: "shared/generated/apiContract.generated.ts",
|
|
65
|
+
* check: process.argv.includes("--check"),
|
|
66
|
+
* });
|
|
67
|
+
* console.log(result.lines.join("\n"));
|
|
68
|
+
* process.exit(result.ok ? 0 : 1);
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* A check compares the text, so the file should be left out of formatters;
|
|
72
|
+
* each declaration carries a `// prettier-ignore` line for Prettier. A write
|
|
73
|
+
* that changes the file first compiles the new text beside the server's
|
|
74
|
+
* sources and checks every entry against the contract both ways, and writes
|
|
75
|
+
* nothing when one differs. The file is written to a temporary file renamed
|
|
76
|
+
* over the old one, so a build reading it meanwhile never sees half of it.
|
|
77
|
+
*/
|
|
78
|
+
export declare const writeApiContract: (options: LambderApiContractFileOptions) => Promise<LambderApiContractFileResult>;
|
|
@@ -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
|
+
};
|
|
@@ -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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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:
|
|
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
|
|
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(
|
|
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.
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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: (
|
|
114
|
+
export declare const writeApiSignatures: (options: LambderApiSignatureFileOptions) => Promise<LambderApiSignatureFileResult>;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from "child_process";
|
|
2
|
-
import { existsSync,
|
|
3
|
-
import {
|
|
4
|
-
import { fileURLToPath
|
|
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
|
|
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(
|
|
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.
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
188
|
+
if (options.verifyInFreshProcess === false)
|
|
184
189
|
return result(true, written, lines);
|
|
185
|
-
const {
|
|
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
|
+
};
|