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.
- package/CHANGELOG.md +193 -0
- package/LICENSE +21 -0
- package/README.md +678 -0
- package/dart/generator/bin/generate.dart +419 -0
- package/dart/generator/pubspec.lock +149 -0
- package/dart/generator/pubspec.yaml +9 -0
- package/examples/README.md +56 -0
- package/examples/default/README.md +18 -0
- package/examples/default/gitignore +7 -0
- package/examples/default/index.js +2 -0
- package/examples/default/package.json +17 -0
- package/examples/default/pubspec.yaml +7 -0
- package/examples/default/tasks.dart +39 -0
- package/examples/http/README.md +26 -0
- package/examples/http/gitignore +7 -0
- package/examples/http/index.js +2 -0
- package/examples/http/package.json +18 -0
- package/examples/http/pubspec.yaml +13 -0
- package/examples/http/tasks.dart +100 -0
- package/examples/introspect/README.md +50 -0
- package/examples/introspect/gitignore +7 -0
- package/examples/introspect/index.js +2 -0
- package/examples/introspect/node_env.dart +35 -0
- package/examples/introspect/package.json +18 -0
- package/examples/introspect/pubspec.yaml +23 -0
- package/examples/introspect/tasks.dart +144 -0
- package/examples/native/README.md +32 -0
- package/examples/native/gitignore +7 -0
- package/examples/native/index.js +2 -0
- package/examples/native/native/tools_impl.dart +105 -0
- package/examples/native/package.json +23 -0
- package/examples/native/pubspec.yaml +7 -0
- package/examples/native/tasks.dart +35 -0
- package/examples/postgres/README.md +73 -0
- package/examples/postgres/gitignore +7 -0
- package/examples/postgres/index.js +2 -0
- package/examples/postgres/native/db_impl.dart +205 -0
- package/examples/postgres/package.json +26 -0
- package/examples/postgres/pubspec.yaml +14 -0
- package/examples/postgres/seed/bin/seed.dart +56 -0
- package/examples/postgres/seed/bin/show.dart +64 -0
- package/examples/postgres/seed/lib/src/connect.dart +93 -0
- package/examples/postgres/seed/lib/src/schema.dart +46 -0
- package/examples/postgres/seed/pubspec.yaml +17 -0
- package/examples/postgres/tasks.dart +66 -0
- package/package.json +51 -0
- package/runtime/AGENTS.md +141 -0
- package/runtime/CLAUDE.md +2 -0
- package/runtime/native_task.dart +115 -0
- package/runtime/render_dart.dart +428 -0
- package/src/cli.js +396 -0
- package/src/native-worker.js +196 -0
- package/src/node-bridge.js +118 -0
- package/src/runtime.js +108 -0
- package/src/toolchain/compile.js +153 -0
- package/src/toolchain/dart-sdk.js +216 -0
- package/src/toolchain/dart-version.js +113 -0
- package/src/toolchain/generate.js +112 -0
- package/src/toolchain/index.js +8 -0
- package/src/toolchain/native.js +217 -0
- 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 };
|