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,113 @@
1
+ // Deciding *which* Dart to use, separately from finding or fetching one.
2
+ //
3
+ // Kept apart from dart-sdk.js because the questions are different: this file
4
+ // answers "what did the user ask for", that one answers "where is it". Mixing
5
+ // them is how the pin came to be consulted only on the download path.
6
+
7
+ const CHANNELS = ['stable', 'beta', 'dev'];
8
+
9
+ /** The version this package installs when nobody has asked for one. */
10
+ const DEFAULT_DART_VERSION = '3.13.1';
11
+
12
+ const ARCHIVE = 'https://storage.googleapis.com/dart-archive/channels';
13
+
14
+ /**
15
+ * What the user asked for, and how firmly.
16
+ *
17
+ * `explicit` is the important part. A pin someone typed has to be honoured even
18
+ * when a different Dart is already on PATH, or it is not a pin. The built-in
19
+ * default has no such claim: it exists so a first build on Render has something
20
+ * to fetch, and deferring to a local toolchain there is a courtesy, not a bug.
21
+ *
22
+ * Precedence, highest first: the command line, the environment, package.json.
23
+ * The flag is for trying something once, the environment for varying a build
24
+ * without a commit — Render's dashboard sets those — and package.json for the
25
+ * answer that should travel with the project.
26
+ */
27
+ function requestedVersion({ flag, env, config }) {
28
+ if (flag) return { version: flag, explicit: true, from: '--dart-version' };
29
+ if (env) return { version: env, explicit: true, from: 'RENDER_DART_VERSION' };
30
+ if (config) return { version: config, explicit: true, from: 'package.json' };
31
+ return { version: DEFAULT_DART_VERSION, explicit: false, from: 'default' };
32
+ }
33
+
34
+ /** Whether a request names a moving target rather than one release. */
35
+ function isAlias(version) {
36
+ return version === 'latest' || CHANNELS.includes(version);
37
+ }
38
+
39
+ /**
40
+ * Turns `latest` or a channel name into the version it currently means.
41
+ *
42
+ * An exact version resolves to itself without touching the network, so a
43
+ * pinned project keeps building when the archive is unreachable — which is the
44
+ * main practical argument for pinning one.
45
+ */
46
+ async function resolveVersion(version, { log = () => {} } = {}) {
47
+ if (!isAlias(version)) return version;
48
+
49
+ const channel = version === 'latest' ? 'stable' : version;
50
+ const url = `${ARCHIVE}/${channel}/release/latest/VERSION`;
51
+ const res = await fetch(url);
52
+ if (!res.ok) {
53
+ throw new Error(
54
+ `Could not resolve Dart "${version}": ${res.status} from ${url}`,
55
+ );
56
+ }
57
+ const { version: resolved } = await res.json();
58
+ log(`"${version}" is Dart ${resolved} today`);
59
+ return resolved;
60
+ }
61
+
62
+ /**
63
+ * Every version the archive offers on a channel, newest first.
64
+ *
65
+ * The bucket lists build-number directories alongside version ones — 28 against
66
+ * 176 on stable — so anything without a dot is dropped.
67
+ */
68
+ async function listVersions(channel = 'stable') {
69
+ if (!CHANNELS.includes(channel)) {
70
+ throw new Error(
71
+ `Unknown channel "${channel}". Choose one of: ${CHANNELS.join(', ')}.`,
72
+ );
73
+ }
74
+ const url =
75
+ 'https://storage.googleapis.com/storage/v1/b/dart-archive/o' +
76
+ `?delimiter=/&prefix=channels/${channel}/release/&fields=prefixes`;
77
+
78
+ const res = await fetch(url);
79
+ if (!res.ok) throw new Error(`Could not list Dart versions: ${res.status}`);
80
+ const { prefixes = [] } = await res.json();
81
+
82
+ return prefixes
83
+ .map((p) => p.split('/').filter(Boolean).pop())
84
+ .filter((name) => name.includes('.'))
85
+ .sort(compareVersions)
86
+ .reverse();
87
+ }
88
+
89
+ /** Numeric where both are numeric, so 3.9.0 sorts below 3.10.0. */
90
+ function compareVersions(a, b) {
91
+ const parts = (v) => v.split(/[.\-+]/).map((n) => (/^\d+$/.test(n) ? +n : n));
92
+ const [x, y] = [parts(a), parts(b)];
93
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
94
+ const [p, q] = [x[i], y[i]];
95
+ if (p === q) continue;
96
+ if (p === undefined) return -1;
97
+ if (q === undefined) return 1;
98
+ if (typeof p === typeof q) return p < q ? -1 : 1;
99
+ // A pre-release suffix sorts below the plain release it belongs to.
100
+ return typeof p === 'number' ? 1 : -1;
101
+ }
102
+ return 0;
103
+ }
104
+
105
+ module.exports = {
106
+ CHANNELS,
107
+ DEFAULT_DART_VERSION,
108
+ requestedVersion,
109
+ isAlias,
110
+ resolveVersion,
111
+ listVersions,
112
+ compareVersions,
113
+ };
@@ -0,0 +1,112 @@
1
+ // Driving the Dart generator that turns @nativeTask functions into a
2
+ // dispatcher and typed stubs.
3
+ //
4
+ // The generator is a Dart program with its own pubspec, shipped inside this
5
+ // package and resolved into the project's node_modules pub cache. Keeping it
6
+ // self-contained means depending on package:analyzer never touches the user's
7
+ // own pubspec.
8
+ const { execFileSync, spawnSync } = require('node:child_process');
9
+ const { existsSync, readFileSync, writeFileSync, mkdirSync } = require('node:fs');
10
+ const path = require('node:path');
11
+
12
+ const GENERATOR_DIR = path.join(__dirname, '..', '..', 'dart', 'generator');
13
+
14
+ /** Resolves the generator's own dependencies, once. */
15
+ function ensureGenerator(dart, pubCache, log) {
16
+ const marker = path.join(GENERATOR_DIR, '.dart_tool', 'package_config.json');
17
+ if (existsSync(marker)) return;
18
+
19
+ log('resolving generator dependencies (first native build)');
20
+ const result = spawnSync(dart, ['pub', 'get'], {
21
+ cwd: GENERATOR_DIR,
22
+ stdio: 'pipe',
23
+ env: { ...process.env, PUB_CACHE: pubCache ?? process.env.PUB_CACHE },
24
+ });
25
+ if (result.status !== 0) {
26
+ throw new Error(
27
+ `could not resolve the render-dart generator:\n${(result.stderr ?? '').toString().trim()}`,
28
+ );
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Makes sure a runtime file the generated code imports is present.
34
+ *
35
+ * Written when missing. Never overwritten — an author may have edited it — but
36
+ * a difference is reported, because a stale copy alongside a newer render-dart
37
+ * is exactly how the `^0.1.0` template pin went unnoticed for three releases.
38
+ */
39
+ function ensureRuntimeFile(root, name, log) {
40
+ const shipped = path.join(__dirname, '..', '..', 'runtime', name);
41
+ const local = path.join(root, name);
42
+
43
+ if (!existsSync(local)) {
44
+ writeFileSync(local, readFileSync(shipped));
45
+ log(`added ${name}`);
46
+ return;
47
+ }
48
+ if (readFileSync(local, 'utf8') !== readFileSync(shipped, 'utf8')) {
49
+ // Worth being loud about: the failure mode is a compile error against a
50
+ // signature that changed, which reads as a bug in the generated code
51
+ // rather than as a stale file.
52
+ log(
53
+ `note: ${name} differs from the copy shipped with this render-dart. ` +
54
+ `If the build fails on a signature it does not recognise, refresh it: ` +
55
+ `cp node_modules/render-workflows-dart/runtime/${name} ${name}`,
56
+ );
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Generates the dispatcher and stubs for one native task entry.
62
+ *
63
+ * Returns the dispatcher path, which is what actually gets compiled — the
64
+ * author's file is imported by it rather than compiled directly.
65
+ */
66
+ function generate({ dart, root, entry, pubCache, log }) {
67
+ ensureGenerator(dart, pubCache, log);
68
+ ensureRuntimeFile(root, 'native_task.dart', log);
69
+
70
+ const main = path.join(root, '.dart_tool', 'render_dart', `${entry.name}.main.dart`);
71
+ mkdirSync(path.dirname(main), { recursive: true });
72
+
73
+ // Only pass an override when package.json actually set one; otherwise the
74
+ // annotation decides.
75
+ const overrides = [];
76
+ if (entry.worker !== undefined) overrides.push('--worker', String(entry.worker));
77
+ if (entry.idleTimeoutMs !== undefined) overrides.push('--idle', String(entry.idleTimeoutMs));
78
+ if (entry.timeoutMs !== undefined) overrides.push('--timeout', String(entry.timeoutMs));
79
+
80
+ const result = spawnSync(
81
+ dart,
82
+ [
83
+ 'run',
84
+ path.join('bin', 'generate.dart'),
85
+ '--project', root,
86
+ '--entry', entry.entry,
87
+ '--name', entry.name,
88
+ '--stub', entry.stub,
89
+ '--facade', entry.facade,
90
+ '--main', main,
91
+ ...overrides,
92
+ ],
93
+ {
94
+ cwd: GENERATOR_DIR,
95
+ stdio: 'pipe',
96
+ env: { ...process.env, PUB_CACHE: pubCache ?? process.env.PUB_CACHE },
97
+ },
98
+ );
99
+
100
+ if (result.status !== 0) {
101
+ throw new Error(
102
+ `native task ${entry.rel} could not be generated:\n` +
103
+ `${(result.stderr ?? '').toString().trim()}`,
104
+ );
105
+ }
106
+
107
+ const { methods } = JSON.parse((result.stdout ?? '{}').toString().trim() || '{}');
108
+ log(`native ${entry.name}: ${(methods ?? []).length} task(s) — ${(methods ?? []).join(', ')}`);
109
+ return main;
110
+ }
111
+
112
+ module.exports = { generate, ensureRuntimeFile, GENERATOR_DIR };
@@ -0,0 +1,8 @@
1
+ // The Dart -> JavaScript toolchain, free of Render specifics.
2
+ //
3
+ // Kept separate so it can be extracted into a general Dart/Node bridge if one
4
+ // is ever wanted; nothing in here knows what a workflow is.
5
+ module.exports = {
6
+ ...require('./dart-sdk'),
7
+ ...require('./compile'),
8
+ };
@@ -0,0 +1,217 @@
1
+ // Compiling declared Dart files to native executables.
2
+ //
3
+ // Render's own container can do this: the SDK render-dart vendors is the full
4
+ // SDK, carrying gen_snapshot and dartaotruntime, and the build host is already
5
+ // linux/x64. So nothing is cross-compiled and no binary is committed — the
6
+ // executable is produced from the source in the commit that deploys it.
7
+ const { execFileSync } = require('node:child_process');
8
+ const { createHash } = require('node:crypto');
9
+ const { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, chmodSync } = require('node:fs');
10
+ const { readdir } = require('node:fs/promises');
11
+ const path = require('node:path');
12
+
13
+ const CACHE_DIR = path.join('node_modules', '.native-cache');
14
+ const OUT_DIR = path.join('build', 'native');
15
+ const SKIP_DIRS = new Set(['build', 'node_modules', '.dart_tool', '.git']);
16
+
17
+ /**
18
+ * Normalises `renderDart.native` into full entries.
19
+ *
20
+ * Accepts a bare path as shorthand for a wrapped native task, or an object for
21
+ * anything else:
22
+ *
23
+ * "native": [
24
+ * "native/image_tools.dart",
25
+ * { "entry": "native/raw.dart", "mode": "exe" },
26
+ * { "entry": "native/hot.dart", "worker": true, "idleTimeoutMs": 30000 }
27
+ * ]
28
+ */
29
+ function nativeEntries(root, declared) {
30
+ const seen = new Map();
31
+
32
+ return (declared ?? []).map((raw) => {
33
+ const spec = typeof raw === 'string' ? { entry: raw } : { ...raw };
34
+ if (!spec.entry) {
35
+ throw new Error('every renderDart.native entry needs an "entry" path');
36
+ }
37
+
38
+ const mode = spec.mode ?? 'task';
39
+ if (mode !== 'task' && mode !== 'exe') {
40
+ throw new Error(`unknown native mode "${mode}" for ${spec.entry} (expected "task" or "exe")`);
41
+ }
42
+
43
+ // Left undefined when package.json says nothing, so the annotation on the
44
+ // function decides. Only an explicit value here overrides it.
45
+ const worker = spec.worker;
46
+ // An "exe" owns its own main and has no dispatch loop to keep alive, so
47
+ // there is nothing for a worker to talk to.
48
+ if (worker === true && mode !== 'task') {
49
+ throw new Error(`"worker" needs mode "task", but ${spec.entry} is mode "exe"`);
50
+ }
51
+
52
+ const entry = path.resolve(root, spec.entry);
53
+ // `native/probe_impl.dart` is the implementation; the generated facade
54
+ // takes the plain name, so callers write `import 'native/probe.dart'` and
55
+ // never see a generated filename.
56
+ const base = path.basename(spec.entry, '.dart');
57
+ const name = base.endsWith('_impl') ? base.slice(0, -'_impl'.length) : base;
58
+
59
+ const dir = path.dirname(entry);
60
+ const facade = path.join(dir, `${name}.dart`);
61
+ if (facade === entry) {
62
+ throw new Error(
63
+ `${spec.entry} would be overwritten by its own generated facade. ` +
64
+ `Name the implementation ${name}_impl.dart, so the facade can take ` +
65
+ `${name}.dart and callers import the plain name.`,
66
+ );
67
+ }
68
+
69
+ if (seen.has(name)) {
70
+ throw new Error(
71
+ `two native entries are both named "${name}" (${seen.get(name)} and ${spec.entry}); ` +
72
+ `they would compile to the same executable`,
73
+ );
74
+ }
75
+ seen.set(name, spec.entry);
76
+
77
+ return {
78
+ name,
79
+ entry,
80
+ rel: spec.entry,
81
+ dir,
82
+ facade,
83
+ stub: path.join(dir, `${name}.stub.dart`),
84
+ mode,
85
+ worker,
86
+ idleTimeoutMs: spec.idleTimeoutMs,
87
+ timeoutMs: spec.timeoutMs,
88
+ out: path.join(root, OUT_DIR, name),
89
+ };
90
+ });
91
+ }
92
+
93
+ /** Every .dart file under `dir`, sorted, so a fingerprint is stable. */
94
+ async function dartFilesUnder(dir) {
95
+ const found = [];
96
+ async function walk(d) {
97
+ let entries;
98
+ try {
99
+ entries = await readdir(d, { withFileTypes: true });
100
+ } catch {
101
+ return;
102
+ }
103
+ for (const e of entries) {
104
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith('.')) continue;
105
+ const full = path.join(d, e.name);
106
+ if (e.isDirectory()) await walk(full);
107
+ else if (e.name.endsWith('.dart')) found.push(full);
108
+ }
109
+ }
110
+ await walk(dir);
111
+ return found.sort();
112
+ }
113
+
114
+ /**
115
+ * Content fingerprint of everything that feeds one native executable.
116
+ *
117
+ * Keyed on content, never mtime. Every Render deploy is a fresh git checkout,
118
+ * which stamps the current time on every file — so an mtime comparison can
119
+ * never hit, and the cache silently does nothing. That was measured against
120
+ * Render's build log before this was written.
121
+ */
122
+ async function fingerprint(root, entry) {
123
+ const files = await dartFilesUnder(entry.dir);
124
+ const h = createHash('sha256');
125
+ h.update(entry.mode).update('\0').update(String(entry.worker)).update('\0');
126
+ for (const f of files) {
127
+ h.update(path.relative(root, f)).update('\0').update(readFileSync(f));
128
+ }
129
+ for (const meta of ['pubspec.yaml', 'pubspec.lock']) {
130
+ const p = path.join(root, meta);
131
+ if (existsSync(p)) h.update(meta).update('\0').update(readFileSync(p));
132
+ }
133
+ return h.digest('hex');
134
+ }
135
+
136
+ /**
137
+ * Compiles each entry, reusing a cached binary when its sources are unchanged.
138
+ *
139
+ * Output lands in node_modules/.native-cache first and is copied to
140
+ * build/native/. That is deliberate: Render's build cache preserves
141
+ * node_modules and nothing else, so a deploy touching only tasks.dart reuses
142
+ * the executable instead of paying for another AOT compile.
143
+ */
144
+ async function buildNative({ dart, root, entries, pubCache, log }) {
145
+ const cacheDir = path.join(root, CACHE_DIR);
146
+ mkdirSync(cacheDir, { recursive: true });
147
+ mkdirSync(path.join(root, OUT_DIR), { recursive: true });
148
+
149
+ const env = pubCache ? { ...process.env, PUB_CACHE: pubCache } : process.env;
150
+
151
+ for (const entry of entries) {
152
+ // A "task" entry compiles the generated dispatcher, which imports the
153
+ // author's file; an "exe" entry compiles the file itself.
154
+ const source = entry.mode === 'task' ? entry.main : entry.entry;
155
+ const cached = path.join(cacheDir, entry.name);
156
+ const stamp = `${cached}.sha256`;
157
+ const want = await fingerprint(root, entry);
158
+
159
+ const hit =
160
+ existsSync(cached) &&
161
+ existsSync(stamp) &&
162
+ readFileSync(stamp, 'utf8').trim() === want;
163
+
164
+ if (hit) {
165
+ log(`native ${entry.name}: cache hit (${want.slice(0, 12)})`);
166
+ } else {
167
+ log(`native ${entry.name}: compiling ${path.relative(root, source)}`);
168
+ execFileSync(dart, ['compile', 'exe', source, '-o', cached], {
169
+ cwd: root,
170
+ stdio: 'inherit',
171
+ env,
172
+ });
173
+ writeFileSync(stamp, want);
174
+ }
175
+
176
+ copyFileSync(cached, entry.out);
177
+ chmodSync(entry.out, 0o755);
178
+ const { size } = statSync(entry.out);
179
+ log(`native ${entry.name}: ${path.relative(root, entry.out)} (${(size / 1e6).toFixed(1)} MB)`);
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Writes a .gitignore listing what the build generated in each native dir.
185
+ *
186
+ * The facade takes a natural name like `probe.dart`, so without this it reads
187
+ * as hand-written source. Having the build maintain the list keeps it right as
188
+ * entries are added or renamed.
189
+ */
190
+ function writeGeneratedIgnores(root, entries) {
191
+ const byDir = new Map();
192
+ for (const entry of entries) {
193
+ if (entry.mode !== 'task') continue;
194
+ const names = byDir.get(entry.dir) ?? [];
195
+ names.push(path.basename(entry.facade), path.basename(entry.stub));
196
+ byDir.set(entry.dir, names);
197
+ }
198
+
199
+ for (const [dir, names] of byDir) {
200
+ writeFileSync(
201
+ path.join(dir, '.gitignore'),
202
+ '# Written by `render-dart build`. These are generated from the\n' +
203
+ '# @nativeTask sources beside them and would only ever go stale in git.\n' +
204
+ `${names.sort().join('\n')}\n`,
205
+ );
206
+ }
207
+ }
208
+
209
+ module.exports = {
210
+ nativeEntries,
211
+ buildNative,
212
+ fingerprint,
213
+ dartFilesUnder,
214
+ writeGeneratedIgnores,
215
+ CACHE_DIR,
216
+ OUT_DIR,
217
+ };