@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ic-reactor/vite-plugin",
3
- "version": "0.15.1",
4
- "description": "Vite plugin for zero-config IC reactor generation from Candid files",
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/v3/packages/vite-plugin",
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",
@@ -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
+ }