@ic-reactor/vite-plugin 0.15.1 → 4.0.0-beta.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/README.md +141 -173
- package/dist/index.cjs +534 -207
- package/dist/index.d.cts +88 -30
- package/dist/index.d.ts +88 -30
- package/dist/index.js +534 -211
- package/package.json +6 -8
- package/src/generate.ts +594 -0
- package/src/index.ts +456 -430
- package/llms.txt +0 -156
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ic-reactor/vite-plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Vite plugin
|
|
3
|
+
"version": "4.0.0-beta.1",
|
|
4
|
+
"description": "Vite plugin that generates candid-core modules from .did files with candid-core-cli and injects the local IC environment (ic_env cookie and /api proxy) in development",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
"src",
|
|
25
25
|
"!src/**/*.test.ts",
|
|
26
26
|
"!src/**/__snapshots__",
|
|
27
|
-
"README.md"
|
|
28
|
-
"llms.txt"
|
|
27
|
+
"README.md"
|
|
29
28
|
],
|
|
30
29
|
"keywords": [
|
|
31
30
|
"vite",
|
|
@@ -48,14 +47,13 @@
|
|
|
48
47
|
"bugs": {
|
|
49
48
|
"url": "https://github.com/B3Pay/ic-reactor/issues"
|
|
50
49
|
},
|
|
51
|
-
"homepage": "https://ic-reactor.b3pay.net/
|
|
52
|
-
"dependencies": {
|
|
53
|
-
"@ic-reactor/codegen": "0.15.1"
|
|
54
|
-
},
|
|
50
|
+
"homepage": "https://ic-reactor.b3pay.net/v4/",
|
|
55
51
|
"peerDependencies": {
|
|
52
|
+
"@candid-core/cli": "0.2.0-beta.1",
|
|
56
53
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
|
57
54
|
},
|
|
58
55
|
"devDependencies": {
|
|
56
|
+
"@candid-core/cli": "0.2.0-beta.1",
|
|
59
57
|
"@types/node": "^26.5.1",
|
|
60
58
|
"tsup": "^8.5.1",
|
|
61
59
|
"typescript": "^6.0.3",
|
package/src/generate.ts
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generation: `candid-core-cli gen` in a child process.
|
|
3
|
+
*
|
|
4
|
+
* candid-core's generator is WebAssembly. Running it in the dev server's own
|
|
5
|
+
* process would let one trap, one runaway loop or one memory blow-up on a bad
|
|
6
|
+
* `.did` take the dev server down with it. A child process takes only itself
|
|
7
|
+
* down: the plugin reads what it printed (or why it did not print) and reports
|
|
8
|
+
* that for the canister it was generating.
|
|
9
|
+
*
|
|
10
|
+
* This module imports nothing from Vite and nothing from the CLI. It resolves
|
|
11
|
+
* the CLI's bin script from the app's own `node_modules` and runs it with the
|
|
12
|
+
* running Node binary, never through a shell.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from "node:child_process"
|
|
16
|
+
import fs from "node:fs"
|
|
17
|
+
import { createRequire } from "node:module"
|
|
18
|
+
import path from "node:path"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The `@candid-core/cli` release this plugin is built against. The package
|
|
22
|
+
* declares it as an exact peer, and a test keeps the two equal: the app has to
|
|
23
|
+
* install the generator that pairs with the `@candid-core/schema` runtime its
|
|
24
|
+
* generated modules import.
|
|
25
|
+
*/
|
|
26
|
+
export const CANDID_CORE_CLI_VERSION = "0.2.0-beta.1"
|
|
27
|
+
|
|
28
|
+
/** The `schemaVersion` of the `--json` document this module understands. */
|
|
29
|
+
const REPORT_SCHEMA_VERSION = 1
|
|
30
|
+
|
|
31
|
+
/** How long one generator process may run before it is killed. */
|
|
32
|
+
export const GENERATE_TIMEOUT_MS = 60_000
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The most stderr lines of one process passed on to the logger. The generator
|
|
36
|
+
* prints nothing there when all is well, so more than this is a runaway
|
|
37
|
+
* process, which would flood the terminal for as long as its timeout.
|
|
38
|
+
*/
|
|
39
|
+
const MAX_STREAMED_LINES = 200
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The most characters kept of one stderr line. A longer line is logged cut at
|
|
43
|
+
* this length, with a note, and the rest of it is dropped as it arrives. It
|
|
44
|
+
* bounds the line being assembled, which the cap on the whole stream does not:
|
|
45
|
+
* a process that writes without a newline would otherwise grow that line
|
|
46
|
+
* until it exited or was killed at the timeout.
|
|
47
|
+
*/
|
|
48
|
+
const MAX_LINE_CHARS = 4096
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The most output kept from one stream of the generator. A report is a few
|
|
52
|
+
* lines per canister; the cap only stops a runaway process from filling memory
|
|
53
|
+
* before the timeout kills it.
|
|
54
|
+
*/
|
|
55
|
+
const MAX_OUTPUT_BYTES = 4 * 1024 * 1024
|
|
56
|
+
|
|
57
|
+
/** What the generator left out of a module, as it reports it. */
|
|
58
|
+
export interface Omission {
|
|
59
|
+
kind: string
|
|
60
|
+
name: string
|
|
61
|
+
reason: string
|
|
62
|
+
via?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A canister to generate, with absolute paths. */
|
|
66
|
+
export interface GenerateCanister {
|
|
67
|
+
/** The canister's name in the plugin options, for messages. */
|
|
68
|
+
name: string
|
|
69
|
+
didFile: string
|
|
70
|
+
outDir: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What happened to one `.did` written into one output directory: a module, or
|
|
75
|
+
* the reason there is none. It is the fate of every canister that names that
|
|
76
|
+
* pair, which is why it lists names. Canisters with one interface (an ICP
|
|
77
|
+
* ledger and a ckBTC ledger on `icrc1.did`) are one generation and one module.
|
|
78
|
+
*/
|
|
79
|
+
export interface Outcome {
|
|
80
|
+
/** The canisters this applies to, by their names in the plugin options. */
|
|
81
|
+
names: string[]
|
|
82
|
+
/** The module the generator wrote or checked, when it said. */
|
|
83
|
+
module?: string
|
|
84
|
+
/** `written` when a file was created or changed, `unchanged` otherwise. */
|
|
85
|
+
status: "written" | "unchanged" | "failed"
|
|
86
|
+
omitted: Omission[]
|
|
87
|
+
/** Why the generation failed, in words fit for an error message. */
|
|
88
|
+
failure?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface GenerateResult {
|
|
92
|
+
outcomes: Outcome[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The absolute path of the `candid-core-cli` bin script installed for the app
|
|
97
|
+
* rooted at `root`.
|
|
98
|
+
*
|
|
99
|
+
* Resolved from the app and not from the plugin: the CLI that pairs with the
|
|
100
|
+
* app's `@candid-core/schema` is the one the app installed, wherever the
|
|
101
|
+
* package manager put the plugin. Throws an `Error` that says what to install.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveCliBin(root: string): string {
|
|
104
|
+
const requireFromApp = createRequire(path.join(root, "package.json"))
|
|
105
|
+
let manifestPath: string
|
|
106
|
+
try {
|
|
107
|
+
manifestPath = requireFromApp.resolve("@candid-core/cli/package.json")
|
|
108
|
+
} catch {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`cannot find @candid-core/cli from ${root}. Install the generator that pairs with ` +
|
|
111
|
+
`this plugin next to @candid-core/schema: npm install --save-dev --save-exact ` +
|
|
112
|
+
`@candid-core/cli@${CANDID_CORE_CLI_VERSION}`
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as {
|
|
116
|
+
bin?: string | Record<string, string>
|
|
117
|
+
}
|
|
118
|
+
const bin =
|
|
119
|
+
typeof manifest.bin === "string"
|
|
120
|
+
? manifest.bin
|
|
121
|
+
: manifest.bin?.["candid-core-cli"]
|
|
122
|
+
if (!bin) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`${manifestPath} declares no candid-core-cli bin, so it is not the generator this plugin runs`
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
return path.resolve(path.dirname(manifestPath), bin)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface ProcessResult {
|
|
131
|
+
stdout: string
|
|
132
|
+
stderr: string
|
|
133
|
+
code: number | null
|
|
134
|
+
signal: NodeJS.Signals | null
|
|
135
|
+
/** Set when the process never ran to its end: it did not start, or was killed at the timeout or by the caller. */
|
|
136
|
+
problem?: string
|
|
137
|
+
/** Whether Node could not start the process at all, so another try would fail the same way. */
|
|
138
|
+
unstartable?: boolean
|
|
139
|
+
/** Whether the caller stopped the process, so nothing is left to try. */
|
|
140
|
+
stopped?: boolean
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const keep = (text: string, chunk: string) =>
|
|
144
|
+
text.length >= MAX_OUTPUT_BYTES ? text : text + chunk
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Run `node <bin> ...args` in `cwd` and settle with what it did. Never
|
|
148
|
+
* rejects. Aborting `signal` kills the process, and a signal that is already
|
|
149
|
+
* aborted starts none.
|
|
150
|
+
*/
|
|
151
|
+
function runProcess(
|
|
152
|
+
bin: string,
|
|
153
|
+
args: string[],
|
|
154
|
+
cwd: string,
|
|
155
|
+
timeoutMs: number,
|
|
156
|
+
signal?: AbortSignal,
|
|
157
|
+
onStderrLine?: (line: string) => void
|
|
158
|
+
): Promise<ProcessResult> {
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
let stdout = ""
|
|
161
|
+
let stderr = ""
|
|
162
|
+
let timedOut = false
|
|
163
|
+
let settled = false
|
|
164
|
+
|
|
165
|
+
const stopped: Omit<ProcessResult, "stdout" | "stderr"> = {
|
|
166
|
+
code: null,
|
|
167
|
+
signal: null,
|
|
168
|
+
problem: "was stopped",
|
|
169
|
+
stopped: true,
|
|
170
|
+
}
|
|
171
|
+
if (signal?.aborted) {
|
|
172
|
+
resolve({ stdout, stderr, ...stopped })
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// A line is passed on when it is complete, not when its first bytes
|
|
177
|
+
// arrive, and what follows the last newline is passed on at the end. The
|
|
178
|
+
// line being assembled keeps at most MAX_LINE_CHARS characters: `cut` says
|
|
179
|
+
// that it had more.
|
|
180
|
+
let unfinished = ""
|
|
181
|
+
let cut = false
|
|
182
|
+
let passed = 0
|
|
183
|
+
const pass = (text: string) => {
|
|
184
|
+
if (!text.trim() || passed > MAX_STREAMED_LINES) return
|
|
185
|
+
passed++
|
|
186
|
+
onStderrLine?.(
|
|
187
|
+
passed > MAX_STREAMED_LINES
|
|
188
|
+
? `more than ${MAX_STREAMED_LINES} lines on stderr: the rest is not logged`
|
|
189
|
+
: text.trimEnd()
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
const passUnfinished = () => {
|
|
193
|
+
pass(
|
|
194
|
+
cut
|
|
195
|
+
? `${unfinished} [line cut at ${MAX_LINE_CHARS} characters]`
|
|
196
|
+
: unfinished
|
|
197
|
+
)
|
|
198
|
+
unfinished = ""
|
|
199
|
+
cut = false
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const settle = (result: Omit<ProcessResult, "stdout" | "stderr">) => {
|
|
203
|
+
if (settled) return
|
|
204
|
+
settled = true
|
|
205
|
+
passUnfinished()
|
|
206
|
+
clearTimeout(timer)
|
|
207
|
+
signal?.removeEventListener("abort", stop)
|
|
208
|
+
resolve({ stdout, stderr, ...result })
|
|
209
|
+
}
|
|
210
|
+
const stop = () => {
|
|
211
|
+
// Nothing of a run that was stopped is passed on, not even a last line.
|
|
212
|
+
unfinished = ""
|
|
213
|
+
cut = false
|
|
214
|
+
child.kill("SIGKILL")
|
|
215
|
+
settle(stopped)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// No shell, and stdin closed: the CLI never reads it, and a child that
|
|
219
|
+
// inherited the dev server's terminal could not be told apart from it.
|
|
220
|
+
const child = spawn(process.execPath, [bin, ...args], {
|
|
221
|
+
cwd,
|
|
222
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
223
|
+
windowsHide: true,
|
|
224
|
+
})
|
|
225
|
+
const timer = setTimeout(() => {
|
|
226
|
+
timedOut = true
|
|
227
|
+
child.kill("SIGKILL")
|
|
228
|
+
}, timeoutMs)
|
|
229
|
+
signal?.addEventListener("abort", stop, { once: true })
|
|
230
|
+
|
|
231
|
+
child.stdout.setEncoding("utf-8")
|
|
232
|
+
child.stderr.setEncoding("utf-8")
|
|
233
|
+
child.stdout.on("data", (chunk: string) => (stdout = keep(stdout, chunk)))
|
|
234
|
+
child.stderr.on("data", (chunk: string) => {
|
|
235
|
+
stderr = keep(stderr, chunk)
|
|
236
|
+
// Past the last line that is logged there is nothing left to assemble.
|
|
237
|
+
if (settled || !onStderrLine || passed > MAX_STREAMED_LINES) return
|
|
238
|
+
const pieces = chunk.split(/\r?\n/)
|
|
239
|
+
pieces.forEach((piece, index) => {
|
|
240
|
+
const room = MAX_LINE_CHARS - unfinished.length
|
|
241
|
+
if (piece.length > room) cut = true
|
|
242
|
+
unfinished += piece.slice(0, room)
|
|
243
|
+
if (index < pieces.length - 1) passUnfinished()
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
child.on("error", (error) =>
|
|
247
|
+
settle({
|
|
248
|
+
code: null,
|
|
249
|
+
signal: null,
|
|
250
|
+
problem: `could not start: ${error.message}`,
|
|
251
|
+
unstartable: true,
|
|
252
|
+
})
|
|
253
|
+
)
|
|
254
|
+
child.on("close", (code, signal) =>
|
|
255
|
+
settle({
|
|
256
|
+
code,
|
|
257
|
+
signal,
|
|
258
|
+
problem: timedOut
|
|
259
|
+
? `did not finish within ${timeoutMs / 1000}s and was killed`
|
|
260
|
+
: undefined,
|
|
261
|
+
})
|
|
262
|
+
)
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** How a process that produced no usable report ended, with its stderr. */
|
|
267
|
+
function describeProcess(run: ProcessResult, reason?: string): string {
|
|
268
|
+
const ending =
|
|
269
|
+
run.problem ??
|
|
270
|
+
(run.signal
|
|
271
|
+
? `was killed by ${run.signal}`
|
|
272
|
+
: `exited with code ${run.code}`)
|
|
273
|
+
const lines = [`candid-core-cli ${ending}`]
|
|
274
|
+
if (reason) lines.push(reason)
|
|
275
|
+
const stderr = run.stderr.trim()
|
|
276
|
+
if (stderr) lines.push(stderr)
|
|
277
|
+
return lines.join("\n")
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
interface ReportEntry {
|
|
281
|
+
status: string
|
|
282
|
+
module?: string
|
|
283
|
+
omitted: Omission[]
|
|
284
|
+
diagnostics: unknown[]
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The entries of a `--json` document, in command-line order: `undefined` when
|
|
289
|
+
* stdout is not JSON at all, and an `Error` for a document this module does
|
|
290
|
+
* not know how to read.
|
|
291
|
+
*/
|
|
292
|
+
function parseReport(stdout: string, count: number): ReportEntry[] | undefined {
|
|
293
|
+
let report: unknown
|
|
294
|
+
try {
|
|
295
|
+
report = JSON.parse(stdout)
|
|
296
|
+
} catch {
|
|
297
|
+
return undefined
|
|
298
|
+
}
|
|
299
|
+
const { schemaVersion, entries } = (report ?? {}) as Record<string, unknown>
|
|
300
|
+
if (schemaVersion !== REPORT_SCHEMA_VERSION) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`its report has schemaVersion ${String(schemaVersion)}, and this plugin reads ${REPORT_SCHEMA_VERSION}; ` +
|
|
303
|
+
`install @candid-core/cli@${CANDID_CORE_CLI_VERSION}`
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
if (!Array.isArray(entries) || entries.length !== count) {
|
|
307
|
+
throw new Error(`its report does not have one entry per .did (${count})`)
|
|
308
|
+
}
|
|
309
|
+
return entries.map((entry): ReportEntry => {
|
|
310
|
+
const { status, module, omitted, diagnostics } = entry as Record<
|
|
311
|
+
string,
|
|
312
|
+
unknown
|
|
313
|
+
>
|
|
314
|
+
return {
|
|
315
|
+
status: String(status),
|
|
316
|
+
module: typeof module === "string" ? module : undefined,
|
|
317
|
+
omitted: Array.isArray(omitted) ? (omitted as Omission[]) : [],
|
|
318
|
+
diagnostics: Array.isArray(diagnostics) ? diagnostics : [],
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** One line (plus its notes) for a compiler diagnostic. */
|
|
324
|
+
function formatDiagnostic(diagnostic: unknown): string {
|
|
325
|
+
const { code, message, notes } = (diagnostic ?? {}) as {
|
|
326
|
+
code?: unknown
|
|
327
|
+
message?: unknown
|
|
328
|
+
notes?: unknown
|
|
329
|
+
}
|
|
330
|
+
if (typeof message !== "string") return JSON.stringify(diagnostic)
|
|
331
|
+
const lead = typeof code === "string" ? `${code}: ${message}` : message
|
|
332
|
+
const extra = Array.isArray(notes) ? notes.map((note) => ` ${note}`) : []
|
|
333
|
+
return [lead, ...extra].join("\n")
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** What every generator process of one `generate` call shares. */
|
|
337
|
+
interface Context {
|
|
338
|
+
cli: string
|
|
339
|
+
root: string
|
|
340
|
+
timeoutMs: number
|
|
341
|
+
signal?: AbortSignal
|
|
342
|
+
/**
|
|
343
|
+
* Called with each line a generator process writes to stderr, as it writes
|
|
344
|
+
* it, and the `.did` files that process is generating. The generator prints
|
|
345
|
+
* nothing there with `--json` when all is well, so a line is worth showing.
|
|
346
|
+
* What a process that failed wrote is also part of its failure message.
|
|
347
|
+
*/
|
|
348
|
+
onStderr?: (line: string, didFiles: string[]) => void
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** One `.did` to write into one output directory, for each canister that names the pair. */
|
|
352
|
+
interface Unit {
|
|
353
|
+
names: string[]
|
|
354
|
+
didFile: string
|
|
355
|
+
outDir: string
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* One generator process for `units`, which all write into `outDir`. The report
|
|
360
|
+
* names each unit's fate. A process that ends without a usable report (it
|
|
361
|
+
* crashed, or was killed at the timeout) fails every unit it was given, unless
|
|
362
|
+
* there is more than one: it could have been any of them, so each is run alone
|
|
363
|
+
* to find out, side by side. A unit that is the cause fails alone, and the
|
|
364
|
+
* others are generated. A hang costs one more wait for that unit, and no more.
|
|
365
|
+
*/
|
|
366
|
+
async function runGroup(
|
|
367
|
+
context: Context,
|
|
368
|
+
outDir: string,
|
|
369
|
+
units: Unit[]
|
|
370
|
+
): Promise<GenerateResult> {
|
|
371
|
+
const run = await runProcess(
|
|
372
|
+
context.cli,
|
|
373
|
+
["gen", ...units.map((unit) => unit.didFile), "-o", outDir, "--json"],
|
|
374
|
+
context.root,
|
|
375
|
+
context.timeoutMs,
|
|
376
|
+
context.signal,
|
|
377
|
+
(line) =>
|
|
378
|
+
context.onStderr?.(
|
|
379
|
+
line,
|
|
380
|
+
units.map((unit) => unit.didFile)
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
let entries: ReportEntry[] | undefined
|
|
385
|
+
let reason: string | undefined
|
|
386
|
+
if (!run.problem) {
|
|
387
|
+
try {
|
|
388
|
+
entries = parseReport(run.stdout, units.length)
|
|
389
|
+
// A process that crashed says so by its exit and its stderr.
|
|
390
|
+
if (!entries && run.code === 0) reason = "it printed no report"
|
|
391
|
+
} catch (error) {
|
|
392
|
+
reason = error instanceof Error ? error.message : String(error)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (!entries) {
|
|
397
|
+
if (units.length > 1 && !run.unstartable && !run.stopped) {
|
|
398
|
+
const results = await Promise.all(
|
|
399
|
+
units.map((unit) => runGroup(context, outDir, [unit]))
|
|
400
|
+
)
|
|
401
|
+
return { outcomes: results.flatMap((result) => result.outcomes) }
|
|
402
|
+
}
|
|
403
|
+
const failure = describeProcess(run, reason)
|
|
404
|
+
return {
|
|
405
|
+
outcomes: units.map(({ names }) => ({
|
|
406
|
+
names,
|
|
407
|
+
status: "failed",
|
|
408
|
+
omitted: [],
|
|
409
|
+
failure,
|
|
410
|
+
})),
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
outcomes: units.map((unit, index): Outcome => {
|
|
416
|
+
const entry = entries[index]
|
|
417
|
+
if (entry.status === "failed") {
|
|
418
|
+
return {
|
|
419
|
+
names: unit.names,
|
|
420
|
+
status: "failed",
|
|
421
|
+
omitted: [],
|
|
422
|
+
failure:
|
|
423
|
+
entry.diagnostics.map(formatDiagnostic).join("\n") ||
|
|
424
|
+
"candid-core-cli reported the entry as failed",
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
names: unit.names,
|
|
429
|
+
module: entry.module,
|
|
430
|
+
status: entry.status === "unchanged" ? "unchanged" : "written",
|
|
431
|
+
omitted: entry.omitted,
|
|
432
|
+
}
|
|
433
|
+
}),
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** `"a"`, or `"a", "b"`: canister names as messages quote them. */
|
|
438
|
+
function quoteNames(names: string[]): string {
|
|
439
|
+
return names.map((name) => `"${name}"`).join(", ")
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Whether the filesystem that holds `dir` takes `A.ts` and `a.ts` for one
|
|
444
|
+
* file, `undefined` when that cannot be told.
|
|
445
|
+
*
|
|
446
|
+
* It looks a name up in a spelling with its case swapped and asks whether that
|
|
447
|
+
* finds the same entry. The name is one inside `dir`, or inside the nearest
|
|
448
|
+
* directory above it that exists (`dir` may be made by the generator), or that
|
|
449
|
+
* directory's own, so that the lookup is made where the modules will be. Only
|
|
450
|
+
* ASCII letters are swapped: other scripts fold by rules a probe cannot know.
|
|
451
|
+
*/
|
|
452
|
+
export function foldsCase(dir: string): boolean | undefined {
|
|
453
|
+
const swapCase = (name: string) =>
|
|
454
|
+
name.replace(/[a-z]/gi, (letter) =>
|
|
455
|
+
letter === letter.toLowerCase()
|
|
456
|
+
? letter.toUpperCase()
|
|
457
|
+
: letter.toLowerCase()
|
|
458
|
+
)
|
|
459
|
+
const probe = (entry: string): boolean | undefined => {
|
|
460
|
+
const swapped = path.join(
|
|
461
|
+
path.dirname(entry),
|
|
462
|
+
swapCase(path.basename(entry))
|
|
463
|
+
)
|
|
464
|
+
if (swapped === entry) return undefined
|
|
465
|
+
try {
|
|
466
|
+
const found = fs.lstatSync(entry, { bigint: true })
|
|
467
|
+
// A filesystem that reports no file index cannot say that two names
|
|
468
|
+
// are one file.
|
|
469
|
+
if (found.ino === 0n) return undefined
|
|
470
|
+
try {
|
|
471
|
+
const other = fs.lstatSync(swapped, { bigint: true })
|
|
472
|
+
return other.dev === found.dev && other.ino === found.ino
|
|
473
|
+
} catch (error) {
|
|
474
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT"
|
|
475
|
+
? false
|
|
476
|
+
: undefined
|
|
477
|
+
}
|
|
478
|
+
} catch {
|
|
479
|
+
return undefined
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
for (let at = dir; ; at = path.dirname(at)) {
|
|
484
|
+
let names: string[] = []
|
|
485
|
+
try {
|
|
486
|
+
names = fs.readdirSync(at)
|
|
487
|
+
} catch {
|
|
488
|
+
// Not made yet, or not a directory.
|
|
489
|
+
}
|
|
490
|
+
const named = names.find((name) => swapCase(name) !== name)
|
|
491
|
+
const verdict =
|
|
492
|
+
(named === undefined ? undefined : probe(path.join(at, named))) ??
|
|
493
|
+
probe(at)
|
|
494
|
+
if (verdict !== undefined) return verdict
|
|
495
|
+
if (path.dirname(at) === at) return undefined
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Generate `canisters` with the CLI at `cli`: one process for each output
|
|
501
|
+
* directory, run side by side. Never rejects. Aborting `signal` kills the
|
|
502
|
+
* processes that are running, and their canisters fail as stopped.
|
|
503
|
+
*
|
|
504
|
+
* Canisters that name the same `.did` and the same `outDir` are one
|
|
505
|
+
* generation: the CLI gets the file once, and its outcome belongs to each of
|
|
506
|
+
* them. That is the case of many canisters with one interface (two ICRC-1
|
|
507
|
+
* ledgers on `icrc1.did`), which share one module.
|
|
508
|
+
*
|
|
509
|
+
* Different `.did` files that would write the same module (the CLI names an
|
|
510
|
+
* output after its `.did`, so `a/ledger.did` and `b/ledger.did` in one
|
|
511
|
+
* `outDir` collide) are refused here, naming both. The CLI would otherwise
|
|
512
|
+
* refuse the whole run with a usage error. Whether `A.did` and `a.did` collide
|
|
513
|
+
* depends on the filesystem of the `outDir`: they do where it ignores case
|
|
514
|
+
* (macOS and Windows by default) and write two modules where it does not.
|
|
515
|
+
*/
|
|
516
|
+
export async function generate(
|
|
517
|
+
request: Context & {
|
|
518
|
+
canisters: GenerateCanister[]
|
|
519
|
+
/**
|
|
520
|
+
* Whether the filesystem that holds an `outDir` ignores case, `undefined`
|
|
521
|
+
* when unknown, which is taken as a filesystem that does not. For tests,
|
|
522
|
+
* which cannot rely on the filesystem of the machine they run on.
|
|
523
|
+
*/
|
|
524
|
+
foldsCase?: (outDir: string) => boolean | undefined
|
|
525
|
+
}
|
|
526
|
+
): Promise<GenerateResult> {
|
|
527
|
+
const { root } = request
|
|
528
|
+
// Each pair of .did and outDir once, and the module each would write.
|
|
529
|
+
const units = new Map<string, Unit>()
|
|
530
|
+
const claimed = new Map<string, Unit>()
|
|
531
|
+
const refused = new Map<Unit, Unit>()
|
|
532
|
+
|
|
533
|
+
// The probe runs once for each outDir.
|
|
534
|
+
const folding = new Map<string, boolean>()
|
|
535
|
+
const targetOf = (unit: Unit): string => {
|
|
536
|
+
let folds = folding.get(unit.outDir)
|
|
537
|
+
if (folds === undefined) {
|
|
538
|
+
folds = (request.foldsCase ?? foldsCase)(unit.outDir) ?? false
|
|
539
|
+
folding.set(unit.outDir, folds)
|
|
540
|
+
}
|
|
541
|
+
return folds ? moduleOf(unit).toLowerCase() : moduleOf(unit)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
for (const canister of request.canisters) {
|
|
545
|
+
const key = JSON.stringify([canister.didFile, canister.outDir])
|
|
546
|
+
const same = units.get(key)
|
|
547
|
+
if (same) {
|
|
548
|
+
same.names.push(canister.name)
|
|
549
|
+
continue
|
|
550
|
+
}
|
|
551
|
+
const unit: Unit = {
|
|
552
|
+
names: [canister.name],
|
|
553
|
+
didFile: canister.didFile,
|
|
554
|
+
outDir: canister.outDir,
|
|
555
|
+
}
|
|
556
|
+
units.set(key, unit)
|
|
557
|
+
const target = targetOf(unit)
|
|
558
|
+
const owner = claimed.get(target)
|
|
559
|
+
if (owner) refused.set(unit, owner)
|
|
560
|
+
else claimed.set(target, unit)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const outcomes: Outcome[] = [...refused].map(([unit, owner]) => ({
|
|
564
|
+
names: unit.names,
|
|
565
|
+
status: "failed",
|
|
566
|
+
omitted: [],
|
|
567
|
+
failure:
|
|
568
|
+
`would write ${path.relative(root, moduleOf(unit))}, which ${quoteNames(owner.names)} already writes` +
|
|
569
|
+
(moduleOf(unit) === moduleOf(owner)
|
|
570
|
+
? ". "
|
|
571
|
+
: ` as ${path.relative(root, moduleOf(owner))}: the filesystem does not tell the two names apart. `) +
|
|
572
|
+
`The generator names a module after its .did file: rename one file or give one canister its own outDir`,
|
|
573
|
+
}))
|
|
574
|
+
|
|
575
|
+
const groups = new Map<string, Unit[]>()
|
|
576
|
+
for (const unit of units.values()) {
|
|
577
|
+
if (refused.has(unit)) continue
|
|
578
|
+
groups.set(unit.outDir, [...(groups.get(unit.outDir) ?? []), unit])
|
|
579
|
+
}
|
|
580
|
+
const results = await Promise.all(
|
|
581
|
+
[...groups].map(([outDir, members]) => runGroup(request, outDir, members))
|
|
582
|
+
)
|
|
583
|
+
return {
|
|
584
|
+
outcomes: [...outcomes, ...results.flatMap((result) => result.outcomes)],
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** The module the CLI writes for `unit`: it names it after the `.did`. */
|
|
589
|
+
function moduleOf(unit: Unit): string {
|
|
590
|
+
return path.join(
|
|
591
|
+
unit.outDir,
|
|
592
|
+
path.basename(unit.didFile, path.extname(unit.didFile)) + ".ts"
|
|
593
|
+
)
|
|
594
|
+
}
|