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
@@ -0,0 +1,118 @@
1
+ // Access to the Node platform that Dart cannot reach on its own.
2
+ //
3
+ // Two gaps, both structural rather than incidental:
4
+ //
5
+ // `require` is module-scoped in CommonJS, and `globalThis.require` is
6
+ // undefined in both CommonJS and ESM -- verified, not assumed. A compiled
7
+ // Dart task therefore cannot load an npm package without a hoist.
8
+ //
9
+ // `dart:io` compiles under dart2js and throws at runtime, so `Process` is
10
+ // unavailable. Shelling out has to go through Node.
11
+
12
+ const { createRequire } = require('node:module');
13
+ const path = require('node:path');
14
+
15
+ /**
16
+ * Loads an npm package or Node built-in, by name.
17
+ *
18
+ * Dart cannot reach `require` on its own: in CommonJS it is module-scoped, and
19
+ * `globalThis.require` is undefined in both CommonJS and ESM. Hoisting it here
20
+ * is the only way a compiled Dart task can use the npm ecosystem.
21
+ *
22
+ * final crypto = requireModule('node:crypto');
23
+ */
24
+ function nodeRequire(id) {
25
+ return projectRequire()(id);
26
+ }
27
+
28
+ /**
29
+ * A `require` rooted at the project directory rather than at this file.
30
+ *
31
+ * Resolving from inside `node_modules/render-workflows-dart/src/` happens to reach the
32
+ * project's dependencies by walking up, but that is an accident of layout that
33
+ * pnpm and workspaces can break. Rooting at the working directory makes
34
+ * `requireModule('lodash')` mean what the task author expects: whatever their
35
+ * own package.json depends on.
36
+ */
37
+ let cachedRequire;
38
+ function projectRequire() {
39
+ cachedRequire ??= createRequire(path.join(process.cwd(), 'noop.js'));
40
+ return cachedRequire;
41
+ }
42
+
43
+ /**
44
+ * Runs a command to completion and captures its output.
45
+ *
46
+ * `dart:io` compiles under dart2js and then fails at runtime, so `Process` is
47
+ * unavailable to a task. This is the sanctioned way to shell out — to a CLI
48
+ * tool, or to a natively compiled Dart binary shipped alongside the workflow.
49
+ *
50
+ * Resolves with `{code, stdout, stderr}` even when the command exits non-zero:
51
+ * an exit code is a result, not an exception. It rejects only when the process
52
+ * could not be started at all.
53
+ */
54
+ function runProcess(command, args, options) {
55
+ const { spawn } = require('node:child_process');
56
+ const opts = options ?? {};
57
+
58
+ return new Promise((resolve, reject) => {
59
+ const child = spawn(command, args ?? [], {
60
+ cwd: opts.cwd ?? process.cwd(),
61
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
62
+ shell: opts.shell ?? false,
63
+ });
64
+
65
+ let stdout = '';
66
+ let stderr = '';
67
+ let settled = false;
68
+
69
+ child.stdout?.setEncoding('utf8');
70
+ child.stderr?.setEncoding('utf8');
71
+ child.stdout?.on('data', (chunk) => (stdout += chunk));
72
+ child.stderr?.on('data', (chunk) => (stderr += chunk));
73
+
74
+ let timer;
75
+ if (opts.timeoutMs > 0) {
76
+ timer = setTimeout(() => {
77
+ // SIGKILL rather than SIGTERM: a task run is capped by Render's own
78
+ // timeout, and a process ignoring SIGTERM would burn the whole budget.
79
+ child.kill('SIGKILL');
80
+ if (!settled) {
81
+ settled = true;
82
+ reject(new Error(
83
+ `\`${command}\` exceeded its ${opts.timeoutMs}ms timeout`,
84
+ ));
85
+ }
86
+ }, opts.timeoutMs);
87
+ }
88
+
89
+ child.on('error', (e) => {
90
+ clearTimeout(timer);
91
+ if (!settled) {
92
+ settled = true;
93
+ reject(e);
94
+ }
95
+ });
96
+
97
+ child.on('close', (code, signal) => {
98
+ clearTimeout(timer);
99
+ if (settled) return;
100
+ settled = true;
101
+ resolve({ code: code ?? -1, signal: signal ?? null, stdout, stderr });
102
+ });
103
+
104
+ if (opts.stdin !== undefined && opts.stdin !== null) {
105
+ child.stdin?.end(opts.stdin);
106
+ } else {
107
+ child.stdin?.end();
108
+ }
109
+ });
110
+ }
111
+
112
+ /** Installs both globals. Safe to call more than once. */
113
+ function installNodeBridge() {
114
+ if (globalThis.__require === undefined) globalThis.__require = nodeRequire;
115
+ if (globalThis.__run === undefined) globalThis.__run = runProcess;
116
+ }
117
+
118
+ module.exports = { installNodeBridge, nodeRequire, runProcess };
package/src/runtime.js ADDED
@@ -0,0 +1,108 @@
1
+ // The Node-side runtime for Dart-authored Render Workflows tasks.
2
+ //
3
+ // Render runs a workflow's start command; that command loads this module,
4
+ // which bridges dart2js output to the official @renderinc/sdk.
5
+
6
+ // MUST run before @renderinc/sdk is required, and is why this module exists
7
+ // rather than being inlined into user code.
8
+ //
9
+ // The SDK's task() schedules its own startTaskServer() via setImmediate as
10
+ // soon as it sees RENDER_SDK_SOCKET_PATH. Since we also start the server
11
+ // explicitly, leaving auto-start on produces TWO task servers and executes
12
+ // every task body TWICE -- doubled side effects and doubled billing. Neither
13
+ // `render workflows dev` nor Render in production sets this for us.
14
+ process.env.RENDER_SDK_AUTO_START = 'false';
15
+
16
+ const path = require('node:path');
17
+ const { pathToFileURL } = require('node:url');
18
+ const { task, startTaskServer } = require('@renderinc/sdk/workflows');
19
+ const { installWebShims, ensureWasmRunGlobals } = require('./web-shims');
20
+ const { installNodeBridge } = require('./node-bridge');
21
+ const { installNativeWorker } = require('./native-worker');
22
+
23
+ // Node lacks several APIs Dart packages assume: `self`, a `file:` scheme for
24
+ // fetch, Dart's packages/<name>/ asset paths, and XMLHttpRequest.
25
+ installWebShims();
26
+
27
+ // Reaching npm packages and shelling out. Dart can do neither on its own:
28
+ // `require` is module-scoped, and dart:io's Process fails under dart2js.
29
+ installNodeBridge();
30
+ installNativeWorker();
31
+
32
+ /**
33
+ * Turns a project-relative path into a file: URL the patched fetch can read.
34
+ *
35
+ * Exposed to Dart so a task can point a package at a bundled asset, e.g.
36
+ * `initializeForge2D(wasmUri: Uri.parse(fileUri('web/box2d.wasm')))`.
37
+ */
38
+ globalThis.__fileUri = (relativePath) =>
39
+ pathToFileURL(path.resolve(process.cwd(), relativePath)).href;
40
+
41
+ /** Registered tasks, by name, as returned by the SDK's task(). */
42
+ const wrapped = Object.create(null);
43
+
44
+ /**
45
+ * Registers a Dart task. Called by the compiled Dart bundle, not by hand.
46
+ *
47
+ * Two adaptations happen here:
48
+ * 1. Varargs collapse into a single array, so the Dart side can expose
49
+ * fixed-arity closures instead of guessing each task's parameter count.
50
+ * 2. The Dart result envelope is unwrapped. Dart must never throw across the
51
+ * boundary: a converted Dart exception reaches Render as the opaque
52
+ * "Dart exception thrown from converted Future...", with the real message
53
+ * boxed out of reach. Task bodies return {$ok} or {$err} instead, and the
54
+ * failure becomes a genuine Error here.
55
+ */
56
+ globalThis.__registerTask = (name, fn, options) => {
57
+ wrapped[name] = task({ name, ...(options ?? {}) }, async (...args) => {
58
+ // Cached, so this is a no-op after the first task and costs nothing at all
59
+ // for projects that do not depend on wasm_run.
60
+ await ensureWasmRunGlobals();
61
+ const env = await fn(args);
62
+ if (env && env.$err !== undefined) throw new Error(env.$err);
63
+ return env ? env.$ok : undefined;
64
+ });
65
+ };
66
+
67
+ /**
68
+ * Invokes another task as a child run. Calling an SDK-wrapped function from
69
+ * inside an executing task is what makes Render spawn a subtask.
70
+ */
71
+ globalThis.__callTask = (name, args) => {
72
+ const fn = wrapped[name];
73
+ if (!fn) {
74
+ throw new Error(
75
+ `Task '${name}' is not registered. Tasks must be registered at module ` +
76
+ `level, before the task server starts.`,
77
+ );
78
+ }
79
+ return fn(...args);
80
+ };
81
+
82
+ /**
83
+ * Starts the task server.
84
+ *
85
+ * The SDK reports a failure over /callback and then rethrows, which would
86
+ * otherwise surface as an unhandled rejection and a wall of compiled-JS
87
+ * stack. The run is already recorded failed by that point, so exit cleanly
88
+ * with the message.
89
+ */
90
+ globalThis.__start = () =>
91
+ startTaskServer().catch((e) => {
92
+ console.error('[render-dart] task failed:', e && e.message ? e.message : e);
93
+ process.exit(1);
94
+ });
95
+
96
+ /**
97
+ * Loads a compiled Dart bundle, which registers its tasks and starts serving.
98
+ *
99
+ * @param {string} bundlePath Path to dart2js output, relative to the caller.
100
+ */
101
+ function runTasks(bundlePath = './build/tasks.js') {
102
+ const resolved = path.isAbsolute(bundlePath)
103
+ ? bundlePath
104
+ : path.resolve(process.cwd(), bundlePath);
105
+ require(resolved);
106
+ }
107
+
108
+ module.exports = { runTasks };
@@ -0,0 +1,153 @@
1
+ // Compiling Dart to JavaScript, with a freshness check so the work is skipped
2
+ // when nothing has changed.
3
+
4
+ const { execFileSync, spawnSync } = require('node:child_process');
5
+ const { mkdir, readdir, readFile, stat } = require('node:fs/promises');
6
+ const path = require('node:path');
7
+
8
+ const SKIP_DIRS = new Set(['build', 'node_modules', '.dart_tool', '.git']);
9
+
10
+ /** Newest mtime across every .dart file under `dir`, recursively. */
11
+ async function newestDartSource(dir) {
12
+ let newest = 0;
13
+ let entries;
14
+ try {
15
+ entries = await readdir(dir, { withFileTypes: true });
16
+ } catch {
17
+ return 0;
18
+ }
19
+
20
+ for (const entry of entries) {
21
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
22
+ const full = path.join(dir, entry.name);
23
+ if (entry.isDirectory()) {
24
+ newest = Math.max(newest, await newestDartSource(full));
25
+ } else if (entry.name.endsWith('.dart') || entry.name === 'pubspec.yaml') {
26
+ newest = Math.max(newest, (await stat(full)).mtimeMs);
27
+ }
28
+ }
29
+ return newest;
30
+ }
31
+
32
+ /** Whether `out` is newer than every Dart source under `root`. */
33
+ async function isFresh(root, out) {
34
+ try {
35
+ const built = await stat(out);
36
+ return built.mtimeMs >= (await newestDartSource(root));
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Resolves pub dependencies, if the project declares any.
44
+ *
45
+ * dart2js needs a package resolution file before it can compile a project
46
+ * that imports anything from pub.dev, and Render's builder has no pub cache,
47
+ * so this has to run there too.
48
+ */
49
+ function pubGet(dart, root, log) {
50
+ // Relocate the pub cache into node_modules for the same reason the SDK
51
+ // lives there: Render preserves node_modules between builds, but ~/.pub-cache
52
+ // sits in a fresh filesystem every time and would re-download every
53
+ // dependency on every deploy.
54
+ const pubCache = process.env.PUB_CACHE ?? path.join(root, 'node_modules', '.pub-cache');
55
+ const result = spawnSync(dart, ['pub', 'get'], {
56
+ cwd: root,
57
+ stdio: 'pipe',
58
+ env: { ...process.env, PUB_CACHE: pubCache },
59
+ });
60
+ if (result.status !== 0) {
61
+ const stderr = (result.stderr ?? '').toString().trim();
62
+ throw new Error(`dart pub get failed:\n${stderr}`);
63
+ }
64
+ log(`pub dependencies resolved (cache: ${path.relative(root, pubCache)})`);
65
+ return pubCache;
66
+ }
67
+
68
+ /**
69
+ * Compiles `entry` to `out` with dart2js.
70
+ *
71
+ * @param {object} opts
72
+ * @param {string} opts.dart Path to the dart executable.
73
+ * @param {string} opts.root Project root (holds pubspec.yaml).
74
+ * @param {string} opts.entry Dart entrypoint.
75
+ * @param {string} opts.out Output JavaScript file.
76
+ * @param {string} [opts.optimize] dart2js optimisation level, default O2.
77
+ * @param {boolean} [opts.sourceMaps] Emit a source map alongside the output.
78
+ * @param {(m: string) => void} opts.log
79
+ */
80
+ async function compile({ dart, root, entry, out, optimize = 'O2', sourceMaps = false, pubCache, log }) {
81
+ await mkdir(path.dirname(out), { recursive: true });
82
+
83
+ const args = ['compile', 'js', `-${optimize}`, '-o', out];
84
+ // dart2js emits source maps by default; suppressing them keeps the bundle
85
+ // small, at the cost of production stack traces pointing into compiled JS.
86
+ if (!sourceMaps) args.push('--no-source-maps');
87
+ args.push(entry);
88
+
89
+ log(`compiling ${path.relative(root, entry)} -> ${path.relative(root, out)}`);
90
+ execFileSync(dart, args, {
91
+ cwd: root,
92
+ stdio: 'inherit',
93
+ env: pubCache ? { ...process.env, PUB_CACHE: pubCache } : process.env,
94
+ });
95
+
96
+ const { size } = await stat(out);
97
+ log(`done (${(size / 1024).toFixed(1)} KB)`);
98
+ return size;
99
+ }
100
+
101
+ /**
102
+ * Finds direct `dart:io` imports in the project's own Dart sources.
103
+ *
104
+ * This matters because dart2js **compiles dart:io without complaint** and then
105
+ * throws `Unsupported operation` at runtime — verified. A task using File,
106
+ * Process, Socket or HttpClient therefore deploys cleanly and fails on its
107
+ * first real run, which on Render means burning up to the task timeout before
108
+ * anyone finds out. Catching it at build time turns a production failure into
109
+ * a build error.
110
+ *
111
+ * Conditional imports (`if (dart.library.io)`) are legitimate and skipped.
112
+ * Only the project's own files are scanned, never pub dependencies.
113
+ */
114
+ async function findDartIoImports(root, exemptDirs = []) {
115
+ const hits = [];
116
+ // Declared native sources are compiled AOT, where dart:io is the whole
117
+ // point. Their directories are exempt; everything else stays strict.
118
+ const exempt = exemptDirs.map((d) => path.resolve(d));
119
+ const isExempt = (full) =>
120
+ exempt.some((d) => full === d || full.startsWith(d + path.sep));
121
+
122
+ async function walk(dir) {
123
+ let entries;
124
+ try {
125
+ entries = await readdir(dir, { withFileTypes: true });
126
+ } catch {
127
+ return;
128
+ }
129
+ for (const entry of entries) {
130
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
131
+ const full = path.join(dir, entry.name);
132
+ if (entry.isDirectory()) {
133
+ if (isExempt(full)) continue;
134
+ await walk(full);
135
+ } else if (entry.name.endsWith('.dart')) {
136
+ if (isExempt(full)) continue;
137
+ const source = await readFile(full, 'utf8');
138
+ source.split('\n').forEach((line, i) => {
139
+ const trimmed = line.trim();
140
+ if (!trimmed.startsWith('import') && !trimmed.startsWith('export')) return;
141
+ if (!/['"]dart:io['"]/.test(trimmed)) return;
142
+ if (trimmed.includes('if (')) return; // conditional import, fine
143
+ hits.push({ file: path.relative(root, full), line: i + 1 });
144
+ });
145
+ }
146
+ }
147
+ }
148
+
149
+ await walk(root);
150
+ return hits;
151
+ }
152
+
153
+ module.exports = { compile, isFresh, newestDartSource, pubGet, findDartIoImports };
@@ -0,0 +1,216 @@
1
+ // Locating or fetching a Dart SDK.
2
+ //
3
+ // Deliberately free of Render specifics -- this is the piece worth extracting
4
+ // into a general Dart/Node bridge if one is ever wanted.
5
+
6
+ const { createHash } = require('node:crypto');
7
+ const { execFileSync, spawnSync } = require('node:child_process');
8
+ const { createWriteStream } = require('node:fs');
9
+ const { mkdir, readFile, rm, stat, writeFile } = require('node:fs/promises');
10
+ const { Readable } = require('node:stream');
11
+ const { pipeline } = require('node:stream/promises');
12
+ const path = require('node:path');
13
+
14
+ const { resolveVersion } = require('./dart-version');
15
+
16
+ const ARCHIVE_BASE =
17
+ 'https://storage.googleapis.com/dart-archive/channels/stable/release';
18
+
19
+ /**
20
+ * Where a vendored SDK is unpacked, relative to the project root.
21
+ *
22
+ * Inside node_modules on purpose. Render's build cache preserves node_modules
23
+ * between builds but not an arbitrary top-level directory -- measured: with
24
+ * the SDK at `<root>/.dart-sdk` it was re-downloaded on every build, 33s of a
25
+ * 52s build. Moved here, later builds reuse it and the build step drops to
26
+ * about a second. A dot-prefixed directory survives `npm install`.
27
+ */
28
+ const VENDOR_DIR = path.join('node_modules', '.dart-sdk');
29
+
30
+ async function exists(p) {
31
+ try {
32
+ await stat(p);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function onPath(bin) {
40
+ return spawnSync(bin, ['--version'], { stdio: 'ignore' }).status === 0;
41
+ }
42
+
43
+ /**
44
+ * The version of a `dart` executable, or null if it will not say.
45
+ *
46
+ * `dart --version` writes to stderr on some releases and stdout on others, so
47
+ * both are searched rather than assuming either.
48
+ */
49
+ function versionOf(bin) {
50
+ const r = spawnSync(bin, ['--version'], { encoding: 'utf8' });
51
+ if (r.status !== 0) return null;
52
+ const m = `${r.stdout ?? ''}${r.stderr ?? ''}`.match(/Dart SDK version:\s*(\S+)/);
53
+ return m ? m[1] : null;
54
+ }
55
+
56
+ /** The version recorded beside a vendored SDK, if one was written. */
57
+ async function vendoredVersion(root) {
58
+ try {
59
+ return (await readFile(path.join(root, VENDOR_DIR, 'VERSION'), 'utf8')).trim();
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /** The published checksum for an archive, or null if none is served. */
66
+ async function publishedChecksum(url) {
67
+ const res = await fetch(`${url}.sha256sum`);
68
+ if (!res.ok) return null;
69
+ // The file is "<hex> *<filename>".
70
+ const [hex] = (await res.text()).trim().split(/\s+/);
71
+ return /^[0-9a-f]{64}$/.test(hex) ? hex : null;
72
+ }
73
+
74
+ /** The archive URL for a version on this platform. */
75
+ function archiveUrl(version) {
76
+ const os = { linux: 'linux', darwin: 'macos', win32: 'windows' }[
77
+ process.platform
78
+ ];
79
+ const arch = { x64: 'x64', arm64: 'arm64' }[process.arch];
80
+ if (!os || !arch) {
81
+ throw new Error(
82
+ `No Dart SDK build for ${process.platform}/${process.arch}.`,
83
+ );
84
+ }
85
+ return `${ARCHIVE_BASE}/${version}/sdk/dartsdk-${os}-${arch}-release.zip`;
86
+ }
87
+
88
+ /** Downloads and unpacks a pinned SDK, returning the path to its `dart`. */
89
+ async function fetchSdk({ root, version, log }) {
90
+ const dir = path.join(root, VENDOR_DIR);
91
+ const zip = path.join(root, 'node_modules', '.dart-sdk.zip');
92
+ const url = archiveUrl(version);
93
+
94
+ log(`fetching Dart ${version} (${url.split('/').pop()})`);
95
+ const [res, expected] = await Promise.all([
96
+ fetch(url),
97
+ publishedChecksum(url),
98
+ ]);
99
+ if (!res.ok) throw new Error(`Dart SDK download failed: ${res.status} ${url}`);
100
+
101
+ await mkdir(path.dirname(zip), { recursive: true });
102
+
103
+ // Hashed while it streams to disk rather than in a second pass: the bytes are
104
+ // already going through this process, so verifying costs no extra I/O and
105
+ // overlaps the download. Measured at 0.18s of CPU for a 228 MB archive,
106
+ // against roughly 30s to fetch it.
107
+ const hash = createHash('sha256');
108
+ await pipeline(
109
+ Readable.fromWeb(res.body),
110
+ async function* (source) {
111
+ for await (const chunk of source) {
112
+ hash.update(chunk);
113
+ yield chunk;
114
+ }
115
+ },
116
+ createWriteStream(zip),
117
+ );
118
+
119
+ if (expected) {
120
+ const actual = hash.digest('hex');
121
+ if (actual !== expected) {
122
+ await rm(zip, { force: true });
123
+ throw new Error(
124
+ `Dart SDK checksum mismatch for ${version}.\n` +
125
+ ` expected ${expected}\n received ${actual}\n` +
126
+ 'The archive was not unpacked. This is worth reporting rather than ' +
127
+ 'retrying: the SDK is downloaded over the network and then executed.',
128
+ );
129
+ }
130
+ log('checksum verified');
131
+ } else {
132
+ // Older releases predate the published sums; say so rather than implying a
133
+ // check happened.
134
+ log(`no published checksum for ${version}; skipping verification`);
135
+ }
136
+
137
+ log('unpacking');
138
+ await rm(dir, { recursive: true, force: true });
139
+ await mkdir(dir, { recursive: true });
140
+ // The archive has a top-level `dart-sdk/`; unpack into a parent and point
141
+ // at the inner directory rather than trying to strip it.
142
+ execFileSync('unzip', ['-q', zip, '-d', dir], { stdio: 'inherit' });
143
+ await rm(zip, { force: true });
144
+
145
+ // Recorded so the next build can tell *which* SDK is cached. Without this the
146
+ // cache key is "does the directory exist", and changing the pin has no effect
147
+ // on any machine that has already built once — including every Render build
148
+ // after the first.
149
+ await writeFile(path.join(dir, 'VERSION'), `${version}\n`);
150
+
151
+ return path.join(dir, 'dart-sdk', 'bin', 'dart');
152
+ }
153
+
154
+ /**
155
+ * Finds a `dart` that satisfies the request, and says where it came from.
156
+ *
157
+ * The order still keeps each environment fast, but a pin now decides rather
158
+ * than merely suggesting:
159
+ *
160
+ * 1. a vendored SDK, if it is the version asked for
161
+ * 2. `dart` on PATH, if it is the version asked for — or if nothing was asked
162
+ * 3. download, unless `fetch` is false — see below
163
+ *
164
+ * The difference from before is the phrase "the version asked for". Previously
165
+ * both caches were consulted by existence alone, so a pinned version was
166
+ * honoured on a first Render build and silently ignored everywhere else: on a
167
+ * laptop PATH always won, and on later Render builds whatever had been vendored
168
+ * first won for ever. That is why setting `dartVersion` appeared to do nothing.
169
+ *
170
+ * A request that is not explicit — the built-in default — still defers to a
171
+ * local toolchain, because that default exists to give a first build something
172
+ * to fetch, not to override a Dart the developer installed deliberately.
173
+ */
174
+ async function resolveDart({ root, version, explicit = false, fetch: mayFetch = true, log }) {
175
+ const wanted = await resolveVersion(version, { log });
176
+
177
+ const vendored = path.join(root, VENDOR_DIR, 'dart-sdk', 'bin', 'dart');
178
+ if (await exists(vendored)) {
179
+ const have = await vendoredVersion(root);
180
+ if (!explicit || have === wanted) {
181
+ log(`using Dart ${have ?? 'unknown'} (vendored)`);
182
+ return { dart: vendored, version: have, source: 'vendored' };
183
+ }
184
+ log(`vendored Dart is ${have ?? 'unknown'}, ${wanted} was asked for`);
185
+ }
186
+
187
+ if (onPath('dart')) {
188
+ const have = versionOf('dart');
189
+ if (!explicit || have === wanted) {
190
+ log(`using Dart ${have ?? 'unknown'} from PATH`);
191
+ return { dart: 'dart', version: have, source: 'path' };
192
+ }
193
+ log(`Dart on PATH is ${have ?? 'unknown'}, ${wanted} was asked for`);
194
+ }
195
+
196
+ if (!mayFetch) {
197
+ // Asking which Dart would be used must not install one. `render-dart dart`
198
+ // is a question, and a question that pulls 228 MB and unpacks 624 MB is a
199
+ // surprise nobody asked for.
200
+ return { dart: null, version: wanted, source: 'would download' };
201
+ }
202
+
203
+ const dart = await fetchSdk({ root, version: wanted, log });
204
+ log(`using Dart ${wanted} (downloaded)`);
205
+ return { dart, version: wanted, source: 'downloaded' };
206
+ }
207
+
208
+ module.exports = {
209
+ resolveDart,
210
+ fetchSdk,
211
+ archiveUrl,
212
+ versionOf,
213
+ vendoredVersion,
214
+ publishedChecksum,
215
+ VENDOR_DIR,
216
+ };