render-workflows-dart 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/LICENSE +21 -0
  3. package/README.md +678 -0
  4. package/dart/generator/bin/generate.dart +419 -0
  5. package/dart/generator/pubspec.lock +149 -0
  6. package/dart/generator/pubspec.yaml +9 -0
  7. package/examples/README.md +56 -0
  8. package/examples/default/README.md +18 -0
  9. package/examples/default/gitignore +7 -0
  10. package/examples/default/index.js +2 -0
  11. package/examples/default/package.json +17 -0
  12. package/examples/default/pubspec.yaml +7 -0
  13. package/examples/default/tasks.dart +39 -0
  14. package/examples/http/README.md +26 -0
  15. package/examples/http/gitignore +7 -0
  16. package/examples/http/index.js +2 -0
  17. package/examples/http/package.json +18 -0
  18. package/examples/http/pubspec.yaml +13 -0
  19. package/examples/http/tasks.dart +100 -0
  20. package/examples/introspect/README.md +50 -0
  21. package/examples/introspect/gitignore +7 -0
  22. package/examples/introspect/index.js +2 -0
  23. package/examples/introspect/node_env.dart +35 -0
  24. package/examples/introspect/package.json +18 -0
  25. package/examples/introspect/pubspec.yaml +23 -0
  26. package/examples/introspect/tasks.dart +144 -0
  27. package/examples/native/README.md +32 -0
  28. package/examples/native/gitignore +7 -0
  29. package/examples/native/index.js +2 -0
  30. package/examples/native/native/tools_impl.dart +105 -0
  31. package/examples/native/package.json +23 -0
  32. package/examples/native/pubspec.yaml +7 -0
  33. package/examples/native/tasks.dart +35 -0
  34. package/examples/postgres/README.md +73 -0
  35. package/examples/postgres/gitignore +7 -0
  36. package/examples/postgres/index.js +2 -0
  37. package/examples/postgres/native/db_impl.dart +205 -0
  38. package/examples/postgres/package.json +26 -0
  39. package/examples/postgres/pubspec.yaml +14 -0
  40. package/examples/postgres/seed/bin/seed.dart +56 -0
  41. package/examples/postgres/seed/bin/show.dart +64 -0
  42. package/examples/postgres/seed/lib/src/connect.dart +93 -0
  43. package/examples/postgres/seed/lib/src/schema.dart +46 -0
  44. package/examples/postgres/seed/pubspec.yaml +17 -0
  45. package/examples/postgres/tasks.dart +66 -0
  46. package/package.json +51 -0
  47. package/runtime/AGENTS.md +141 -0
  48. package/runtime/CLAUDE.md +2 -0
  49. package/runtime/native_task.dart +115 -0
  50. package/runtime/render_dart.dart +428 -0
  51. package/src/cli.js +396 -0
  52. package/src/native-worker.js +196 -0
  53. package/src/node-bridge.js +118 -0
  54. package/src/runtime.js +108 -0
  55. package/src/toolchain/compile.js +153 -0
  56. package/src/toolchain/dart-sdk.js +216 -0
  57. package/src/toolchain/dart-version.js +113 -0
  58. package/src/toolchain/generate.js +112 -0
  59. package/src/toolchain/index.js +8 -0
  60. package/src/toolchain/native.js +217 -0
  61. package/src/web-shims.js +281 -0
package/src/cli.js ADDED
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env node
2
+ // render-dart CLI: build, dev, init.
3
+
4
+ const { spawn } = require('node:child_process');
5
+ const { cp, mkdir, readFile, rename, writeFile } = require('node:fs/promises');
6
+ const { existsSync, readdirSync, readFileSync } = require('node:fs');
7
+ const path = require('node:path');
8
+
9
+ const { version } = require('../package.json');
10
+ const { resolveDart } = require('./toolchain/dart-sdk');
11
+ const {
12
+ CHANNELS,
13
+ DEFAULT_DART_VERSION,
14
+ listVersions,
15
+ requestedVersion,
16
+ } = require('./toolchain/dart-version');
17
+ const { compile, findDartIoImports, isFresh, pubGet } = require('./toolchain/compile');
18
+ const { nativeEntries, buildNative, writeGeneratedIgnores } = require('./toolchain/native');
19
+ const { generate, ensureRuntimeFile } = require('./toolchain/generate');
20
+
21
+ const log = (m) => console.log(`[render-dart] ${m}`);
22
+ const fail = (m) => {
23
+ console.error(`[render-dart] ${m}`);
24
+ process.exit(1);
25
+ };
26
+
27
+ /** Reads render-dart settings from the project's package.json. */
28
+ async function config(root) {
29
+ let pkg = {};
30
+ try {
31
+ pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
32
+ } catch {
33
+ // A project without package.json is fine; defaults apply.
34
+ }
35
+ const c = pkg.renderDart ?? {};
36
+ return {
37
+ entry: path.resolve(root, c.entry ?? 'tasks.dart'),
38
+ out: path.resolve(root, c.out ?? 'build/tasks.js'),
39
+ // Left undefaulted on purpose: the flag and the environment outrank this,
40
+ // and requestedVersion() cannot tell "unset" from "set to the default" if
41
+ // the default is applied here.
42
+ dartVersion: c.dartVersion,
43
+ optimize: c.optimize ?? 'O2',
44
+ sourceMaps: c.sourceMaps ?? false,
45
+ allowDartIo: c.allowDartIo ?? false,
46
+ // Directories whose dart:io use is legitimate — a local tool sitting
47
+ // beside the workflow, say. Narrower than allowDartIo, which switches the
48
+ // check off for task code too.
49
+ allowDartIoIn: c.allowDartIoIn ?? [],
50
+ native: c.native ?? [],
51
+ };
52
+ }
53
+
54
+ async function build(root, { force = false, dartVersion } = {}) {
55
+ const c = await config(root);
56
+
57
+ if (!existsSync(c.entry)) {
58
+ fail(
59
+ `No Dart entrypoint at ${path.relative(root, c.entry)}. ` +
60
+ `Create one, or set renderDart.entry in package.json.`,
61
+ );
62
+ }
63
+
64
+ let native;
65
+ try {
66
+ native = nativeEntries(root, c.native);
67
+ } catch (e) {
68
+ fail(e.message);
69
+ }
70
+ for (const entry of native) {
71
+ if (!existsSync(entry.entry)) {
72
+ fail(`renderDart.native lists ${entry.rel}, which does not exist.`);
73
+ }
74
+ }
75
+
76
+ // Native entries have their own content-hash cache, so the mtime check only
77
+ // decides whether dart2js needs to run again.
78
+ const jsFresh = !force && (await isFresh(root, c.out));
79
+ if (native.length === 0 && jsFresh) {
80
+ log('output is up to date, skipping compile');
81
+ return c.out;
82
+ }
83
+
84
+ if (!c.allowDartIo) {
85
+ // Declared native sources are compiled AOT, where dart:io works — as is
86
+ // native_task.dart, which is our own runtime for that side and is never
87
+ // reachable from the dart2js entrypoint.
88
+ const io = await findDartIoImports(root, [
89
+ ...native.map((n) => n.dir),
90
+ ...c.allowDartIoIn.map((d) => path.resolve(root, d)),
91
+ path.join(root, 'native_task.dart'),
92
+ ]);
93
+ if (io.length > 0) {
94
+ const where = io.map((h) => ` ${h.file}:${h.line}`).join('\n');
95
+ fail(
96
+ `dart:io is imported, but tasks are compiled with dart2js and dart:io ` +
97
+ `does not work there.\n\n${where}\n\n` +
98
+ `It compiles without error and then throws "Unsupported operation" ` +
99
+ `on the first run, so this would fail in production rather than here.\n\n` +
100
+ `Use package:http (which works — it goes through fetch) instead of ` +
101
+ `HttpClient, and dart:js_interop for anything else Node provides.\n` +
102
+ `Set "renderDart": { "allowDartIo": true } in package.json to skip ` +
103
+ `this check.`,
104
+ );
105
+ }
106
+ }
107
+
108
+ const asked = requestedVersion({
109
+ flag: dartVersion,
110
+ env: process.env.RENDER_DART_VERSION,
111
+ config: c.dartVersion,
112
+ });
113
+ if (asked.explicit) log(`Dart ${asked.version} requested by ${asked.from}`);
114
+
115
+ const { dart } = await resolveDart({
116
+ root,
117
+ version: asked.version,
118
+ explicit: asked.explicit,
119
+ log,
120
+ });
121
+
122
+ // Only needed when the project declares pub dependencies, but harmless
123
+ // otherwise and far cheaper than diagnosing a missing package_config.
124
+ let pubCache;
125
+ if (existsSync(path.join(root, 'pubspec.yaml'))) {
126
+ pubCache = pubGet(dart, root, log);
127
+ }
128
+
129
+
130
+ // Flag a render_dart.dart left behind by an older render-dart before
131
+ // anything can fail against a signature it does not have.
132
+ ensureRuntimeFile(root, 'render_dart.dart', log);
133
+
134
+ // Generation comes first: the stubs it writes are what tasks.dart imports,
135
+ // so dart2js must not run before they exist.
136
+ if (native.length > 0) {
137
+ try {
138
+ for (const entry of native) {
139
+ if (entry.mode === 'task') {
140
+ entry.main = generate({ dart, root, entry, pubCache, log });
141
+ }
142
+ }
143
+ writeGeneratedIgnores(root, native);
144
+ await buildNative({ dart, root, entries: native, pubCache, log });
145
+ } catch (e) {
146
+ fail(e.message);
147
+ }
148
+ }
149
+
150
+ if (jsFresh) {
151
+ log('JavaScript output is up to date, skipping compile');
152
+ return c.out;
153
+ }
154
+
155
+ await compile({
156
+ dart,
157
+ root,
158
+ entry: c.entry,
159
+ out: c.out,
160
+ optimize: c.optimize,
161
+ sourceMaps: c.sourceMaps,
162
+ pubCache,
163
+ log,
164
+ });
165
+ return c.out;
166
+ }
167
+
168
+ /** Builds, then hands off to the Render CLI's local task server. */
169
+ async function dev(root, args) {
170
+ await build(root, { dartVersion: flagValue(args, '--dart-version') });
171
+
172
+ const startCommand = args.length > 0 ? args : ['node', 'index.js'];
173
+ log(`starting local task server: render workflows dev -- ${startCommand.join(' ')}`);
174
+
175
+ const child = spawn(
176
+ 'render',
177
+ ['workflows', 'dev', '--', ...startCommand],
178
+ { cwd: root, stdio: 'inherit' },
179
+ );
180
+ child.on('error', (e) => {
181
+ if (e.code === 'ENOENT') {
182
+ fail(
183
+ 'The Render CLI is not installed. Install it with `brew install render`, ' +
184
+ 'or see https://render.com/docs/cli',
185
+ );
186
+ }
187
+ fail(e.message);
188
+ });
189
+ child.on('exit', (code) => process.exit(code ?? 0));
190
+ }
191
+
192
+ const EXAMPLES_DIR = path.join(__dirname, '..', 'examples');
193
+ const RUNTIME_DIR = path.join(__dirname, '..', 'runtime');
194
+
195
+ /** Template names, read from the directory rather than a hardcoded list. */
196
+ function templates() {
197
+ return readdirSync(EXAMPLES_DIR, { withFileTypes: true })
198
+ .filter((e) => e.isDirectory())
199
+ .map((e) => e.name)
200
+ .sort();
201
+ }
202
+
203
+ /** Copies an example into a new directory as a starting point. */
204
+ async function init(root, args) {
205
+ // Templates are the examples, so every one of them is provably runnable —
206
+ // there is no second copy to drift.
207
+ const template = args.find((a) => a.startsWith('--template='))?.split('=')[1] ??
208
+ (args.includes('--template') ? args[args.indexOf('--template') + 1] : null) ??
209
+ 'default';
210
+
211
+ const available = templates();
212
+ if (!available.includes(template)) {
213
+ fail(`No template named "${template}". Available: ${available.join(', ')}`);
214
+ }
215
+
216
+ const positional = args.filter((a, i) =>
217
+ !a.startsWith('--') && args[i - 1] !== '--template');
218
+ const target = path.resolve(root, positional[0] ?? 'dart-workflow');
219
+ if (existsSync(target)) fail(`${target} already exists.`);
220
+
221
+ await mkdir(target, { recursive: true });
222
+ // An example is a working project, so its directory also holds build output
223
+ // and resolved dependencies. None of that belongs in a fresh scaffold, and
224
+ // copying a lockfile would pin someone to whatever was resolved here.
225
+ const skip = new Set([
226
+ 'README.md',
227
+ 'node_modules',
228
+ 'build',
229
+ '.dart_tool',
230
+ 'pubspec.lock',
231
+ 'package-lock.json',
232
+ ]);
233
+
234
+ // The build writes a native/.gitignore naming what it generated. Reusing
235
+ // that list means a scaffold never carries a stale facade or stub, without
236
+ // this having to guess at their names.
237
+ const generatedList = path.join(EXAMPLES_DIR, template, 'native', '.gitignore');
238
+ if (existsSync(generatedList)) {
239
+ for (const line of readFileSync(generatedList, 'utf8').split('\n')) {
240
+ const name = line.trim();
241
+ if (name && !name.startsWith('#')) skip.add(name);
242
+ }
243
+ }
244
+
245
+ await cp(path.join(EXAMPLES_DIR, template), target, {
246
+ recursive: true,
247
+ filter: (src) => !skip.has(path.basename(src)),
248
+ });
249
+
250
+ // The Dart bridge files are not kept in the examples — `build` writes them
251
+ // when missing, which is also how they stay current on upgrade. A scaffold
252
+ // gets them up front so the project analyses before its first build.
253
+ for (const name of ['render_dart.dart', 'native_task.dart']) {
254
+ await cp(path.join(RUNTIME_DIR, name), path.join(target, name));
255
+ }
256
+
257
+ // Guidance for coding agents, in the one place they will reliably look.
258
+ // An agent helping in this project never opens node_modules, so nothing we
259
+ // ship inside the package reaches it.
260
+ for (const name of ['AGENTS.md', 'CLAUDE.md']) {
261
+ await cp(path.join(RUNTIME_DIR, name), path.join(target, name));
262
+ }
263
+
264
+ // npm strips .gitignore from published packages, so the template ships it
265
+ // as `gitignore` and it gets its real name back here.
266
+ const shipped = path.join(target, 'gitignore');
267
+ if (existsSync(shipped)) await rename(shipped, path.join(target, '.gitignore'));
268
+
269
+ // Name the project after its directory, and pin render-dart to whatever
270
+ // version is doing the scaffolding.
271
+ //
272
+ // The template used to carry a hardcoded range, which silently went stale:
273
+ // `^0.1.0` means `<0.2.0` for a 0.x package, so every project scaffolded
274
+ // after 0.2.0 quietly installed 0.1.1 and missed everything since. Deriving
275
+ // it here means it cannot drift again.
276
+ const pkgPath = path.join(target, 'package.json');
277
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
278
+ pkg.name = path.basename(target);
279
+ // Drop the pre-0.8.2 name if the template still carries it, so a scaffold
280
+ // never ends up depending on both.
281
+ delete pkg.dependencies['render-dart'];
282
+ pkg.dependencies['render-workflows-dart'] = `^${version}`;
283
+ await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
284
+
285
+ log(`created ${path.relative(root, target) || target}`);
286
+ console.log(`
287
+ Next:
288
+ cd ${path.relative(root, target) || target}
289
+ npm install
290
+ npx render-dart dev
291
+
292
+ Then deploy with runtime "node", root directory "${path.basename(target)}",
293
+ build command "npm install && npm run build", start command "npm start".
294
+ `);
295
+ }
296
+
297
+ /** The value after `--name`, or after `--name=`. */
298
+ function flagValue(args, name) {
299
+ const i = args.indexOf(name);
300
+ if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-')) return args[i + 1];
301
+ const inline = args.find((a) => a.startsWith(`${name}=`));
302
+ return inline ? inline.slice(name.length + 1) : undefined;
303
+ }
304
+
305
+ /** `render-dart dart` — what is available, and what this project will use. */
306
+ async function dartInfo(root, args) {
307
+ if (args.includes('--list')) {
308
+ const channel = flagValue(args, '--channel') ?? 'stable';
309
+ const versions = await listVersions(channel);
310
+ const limit = Number(flagValue(args, '--limit') ?? 20);
311
+ console.log(`Dart ${channel} releases (${versions.length} total):\n`);
312
+ for (const v of versions.slice(0, limit)) console.log(` ${v}`);
313
+ if (versions.length > limit) {
314
+ console.log(`\n ... ${versions.length - limit} older, --limit N for more`);
315
+ }
316
+ console.log(
317
+ `\nPin one with --dart-version, RENDER_DART_VERSION, or\n` +
318
+ `"renderDart": { "dartVersion": "..." } in package.json.\n` +
319
+ `"latest" and the channel names ${CHANNELS.join(', ')} also work.`,
320
+ );
321
+ return;
322
+ }
323
+
324
+ // No flag: report what this project would actually use, which is the
325
+ // question people are usually asking.
326
+ const c = await config(root);
327
+ const asked = requestedVersion({
328
+ flag: flagValue(args, '--dart-version'),
329
+ env: process.env.RENDER_DART_VERSION,
330
+ config: c.dartVersion,
331
+ });
332
+ console.log(
333
+ `requested: ${asked.version}` +
334
+ (asked.explicit ? ` (from ${asked.from})` : ' (built-in default)'),
335
+ );
336
+ const resolved = await resolveDart({
337
+ root,
338
+ version: asked.version,
339
+ explicit: asked.explicit,
340
+ fetch: false,
341
+ log: () => {},
342
+ });
343
+ console.log(`resolved: ${resolved.version ?? 'unknown'} (${resolved.source})`);
344
+ console.log(`default: ${DEFAULT_DART_VERSION}`);
345
+ console.log('\nrender-dart dart --list [--channel beta] [--limit N]');
346
+ }
347
+
348
+ async function main() {
349
+ const [command, ...args] = process.argv.slice(2);
350
+ const root = process.cwd();
351
+
352
+ switch (command) {
353
+ case 'build':
354
+ await build(root, {
355
+ force: args.includes('--force'),
356
+ dartVersion: flagValue(args, '--dart-version'),
357
+ });
358
+ break;
359
+ case 'dev':
360
+ await dev(root, args);
361
+ break;
362
+ case 'init':
363
+ await init(root, args);
364
+ break;
365
+ case 'dart':
366
+ await dartInfo(root, args);
367
+ break;
368
+ default:
369
+ console.log(`render-dart — write Render Workflows tasks in Dart
370
+
371
+ Usage:
372
+ render-dart build [--force] [--dart-version <v>]
373
+ Compile tasks.dart to build/tasks.js
374
+ render-dart dev [-- cmd...] Build, then run the local task server
375
+ render-dart init [dir] [--template <name>]
376
+ Scaffold a new Dart workflow project
377
+ render-dart dart [--list] [--channel <c>] [--limit <n>]
378
+ Which Dart this project uses, or what exists
379
+
380
+ Dart version, highest precedence first:
381
+ --dart-version <v> also accepted by the dev command
382
+ RENDER_DART_VERSION=<v>
383
+ "renderDart": { "dartVersion": "<v>" } in package.json
384
+ A version may be exact, "latest", or a channel: stable, beta, dev.
385
+ An explicit one is used even if a different Dart is on PATH.
386
+
387
+ Configure via "renderDart" in package.json:
388
+ entry, out, dartVersion, optimize, sourceMaps, allowDartIo, allowDartIoIn,
389
+ native
390
+ `);
391
+ // Asking for help is not an error; an unknown command is.
392
+ process.exit(command && !['--help', '-h', 'help'].includes(command) ? 1 : 0);
393
+ }
394
+ }
395
+
396
+ main().catch((e) => fail(e.message));
@@ -0,0 +1,196 @@
1
+ // Keeping a native task executable alive between calls.
2
+ //
3
+ // Spawn-per-call costs a process start every time — small against Render's
4
+ // per-run overhead, but real in a loop. A worker pays it once and then serves
5
+ // requests over the same JSONL protocol, which is why the binary needs no
6
+ // changes: its dispatch loop already reads until stdin closes.
7
+ //
8
+ // The trade-off is state. Top-level variables, caches and open handles persist
9
+ // between calls, which is exactly what makes it fast and also means a leak
10
+ // accumulates instead of being cleaned up by process exit. Opt-in per entry.
11
+ const { spawn } = require('node:child_process');
12
+ const path = require('node:path');
13
+
14
+ /** binary path -> live worker */
15
+ const workers = new Map();
16
+ let nextId = 1;
17
+
18
+ function startWorker(binary, idleTimeoutMs) {
19
+ const child = spawn(binary, [], {
20
+ cwd: process.cwd(),
21
+ stdio: ['pipe', 'pipe', 'pipe'],
22
+ });
23
+
24
+ const worker = {
25
+ child,
26
+ binary,
27
+ idleTimeoutMs,
28
+ pending: new Map(),
29
+ buffer: '',
30
+ stderr: '',
31
+ idleTimer: null,
32
+ dead: false,
33
+ };
34
+
35
+ child.stdout.setEncoding('utf8');
36
+ child.stdout.on('data', (chunk) => {
37
+ worker.buffer += chunk;
38
+ let nl;
39
+ while ((nl = worker.buffer.indexOf('\n')) !== -1) {
40
+ const line = worker.buffer.slice(0, nl);
41
+ worker.buffer = worker.buffer.slice(nl + 1);
42
+ routeLine(worker, line);
43
+ }
44
+ });
45
+
46
+ // Native code writing to stderr is diagnostics, not protocol. Keep it
47
+ // visible rather than swallowing it, and keep the tail for a crash report.
48
+ child.stderr.setEncoding('utf8');
49
+ child.stderr.on('data', (chunk) => {
50
+ worker.stderr = (worker.stderr + chunk).slice(-4096);
51
+ process.stderr.write(`[native ${path.basename(binary)}] ${chunk}`);
52
+ });
53
+
54
+ const die = (reason) => {
55
+ worker.dead = true;
56
+ clearTimeout(worker.idleTimer);
57
+ workers.delete(binary);
58
+ // A hung promise is the worst possible failure here — every waiting call
59
+ // gets a real error instead.
60
+ for (const { reject } of worker.pending.values()) {
61
+ reject(new Error(reason));
62
+ }
63
+ worker.pending.clear();
64
+ };
65
+
66
+ child.on('exit', (code, signal) => {
67
+ if (worker.pending.size === 0 && !worker.stderr) return void workers.delete(binary);
68
+ die(
69
+ `native worker ${path.basename(binary)} exited ` +
70
+ `${signal ? `on ${signal}` : `with code ${code}`}` +
71
+ `${worker.stderr.trim() ? `: ${worker.stderr.trim()}` : ''}`,
72
+ );
73
+ });
74
+ child.on('error', (e) => die(`native worker ${path.basename(binary)} failed to start: ${e.message}`));
75
+
76
+ return worker;
77
+ }
78
+
79
+ function routeLine(worker, line) {
80
+ if (!line.trim()) return;
81
+
82
+ let message;
83
+ try {
84
+ message = JSON.parse(line);
85
+ } catch {
86
+ // Not protocol — surface it rather than dropping it silently.
87
+ process.stderr.write(`[native ${path.basename(worker.binary)}] ${line}\n`);
88
+ return;
89
+ }
90
+
91
+ const call = worker.pending.get(message.id);
92
+ if (!call) return;
93
+
94
+ call.lines.push(line);
95
+ if ('$ok' in message || '$err' in message) {
96
+ worker.pending.delete(message.id);
97
+ call.resolve(call.lines);
98
+ armIdleTimer(worker);
99
+ }
100
+ }
101
+
102
+ function armIdleTimer(worker) {
103
+ clearTimeout(worker.idleTimer);
104
+ if (worker.idleTimeoutMs <= 0 || worker.pending.size > 0) return;
105
+
106
+ worker.idleTimer = setTimeout(() => {
107
+ if (worker.pending.size > 0) return;
108
+ workers.delete(worker.binary);
109
+ worker.child.stdin.end();
110
+ worker.child.kill();
111
+ }, worker.idleTimeoutMs);
112
+ // Never hold the process open just to wait for a reap.
113
+ worker.idleTimer.unref?.();
114
+ }
115
+
116
+ /**
117
+ * Sends one request line and resolves with every reply line for it.
118
+ *
119
+ * Returning raw lines keeps the Dart side identical for both process models —
120
+ * it parses `$log`, `$ok` and `$err` the same way whether they came from a
121
+ * worker or a one-shot spawn.
122
+ */
123
+ function nativeCall(binary, requestLine, { idleTimeoutMs = 30000, timeoutMs = 0 } = {}) {
124
+ const resolved = path.resolve(process.cwd(), binary);
125
+
126
+ let worker = workers.get(resolved);
127
+ if (!worker || worker.dead || worker.child.exitCode !== null) {
128
+ worker = startWorker(resolved, idleTimeoutMs);
129
+ workers.set(resolved, worker);
130
+ }
131
+
132
+ // The id is assigned here, not in Dart: a worker multiplexes calls, so it
133
+ // has to be unique across everything in flight.
134
+ const id = nextId++;
135
+ const request = { ...JSON.parse(requestLine), id };
136
+
137
+ clearTimeout(worker.idleTimer);
138
+
139
+ return new Promise((resolve, reject) => {
140
+ // A worker handles requests one at a time, so a call that never returns
141
+ // would block every later one. The whole process goes, rather than leaving
142
+ // a worker wedged behind a hung handler.
143
+ let timer = null;
144
+ const settle = (fn) => (value) => {
145
+ if (timer) clearTimeout(timer);
146
+ fn(value);
147
+ };
148
+
149
+ worker.pending.set(id, { resolve: settle(resolve), reject: settle(reject), lines: [] });
150
+
151
+ if (timeoutMs > 0) {
152
+ timer = setTimeout(() => {
153
+ if (!worker.pending.has(id)) return;
154
+ worker.pending.delete(id);
155
+ worker.child.kill('SIGKILL');
156
+ reject(new Error(
157
+ `native worker ${path.basename(resolved)} exceeded ${timeoutMs} ms`,
158
+ ));
159
+ }, timeoutMs);
160
+ timer.unref?.();
161
+ }
162
+
163
+ worker.child.stdin.write(`${JSON.stringify(request)}\n`, (e) => {
164
+ if (!e) return;
165
+ const call = worker.pending.get(id);
166
+ worker.pending.delete(id);
167
+ call?.reject(new Error(
168
+ `could not write to native worker ${path.basename(resolved)}: ${e.message}`,
169
+ ));
170
+ });
171
+ });
172
+ }
173
+
174
+ /** Closes every worker's stdin, which its dispatch loop sees as EOF. */
175
+ function shutdownWorkers() {
176
+ for (const worker of workers.values()) {
177
+ clearTimeout(worker.idleTimer);
178
+ try {
179
+ worker.child.stdin.end();
180
+ } catch {
181
+ // Already gone.
182
+ }
183
+ }
184
+ workers.clear();
185
+ }
186
+
187
+ function installNativeWorker() {
188
+ if (globalThis.__nativeCall) return;
189
+
190
+ globalThis.__nativeCall = (binary, requestLine, options) =>
191
+ nativeCall(binary, requestLine, options ?? {});
192
+
193
+ process.once('exit', shutdownWorkers);
194
+ }
195
+
196
+ module.exports = { installNativeWorker, nativeCall, shutdownWorkers, workers };