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/README.md ADDED
@@ -0,0 +1,678 @@
1
+ # render-workflows-dart
2
+
3
+ > **Renamed.** This package was `render-dart` up to 0.8.1 and is
4
+ > `render-workflows-dart` from 0.8.2. The `render-dart` command still works, so
5
+ > an existing project only changes one line:
6
+ >
7
+ > ```diff
8
+ > - "render-dart": "^0.8.1"
9
+ > + "render-workflows-dart": "^0.8.2"
10
+ > ```
11
+
12
+ > **This is an _unofficial_, independent, community-built package.**
13
+ > Not affiliated with, endorsed by, or supported by
14
+ > [Render](https://render.com).
15
+ >
16
+ > Render's own SDKs and documentation are at
17
+ > [render.com/docs](https://render.com/docs).
18
+
19
+ [![Render](https://raw.githubusercontent.com/timmaffett/render_workflows_dart/main/doc/render-logo.png)](https://render.com) [\*](#note)
20
+
21
+ Write [Render Workflows](https://render.com/docs/workflows) tasks in **Dart**,
22
+ on [Render](https://render.com).
23
+
24
+ [Render Workflows](https://render.com/docs/workflows) is in public beta and has
25
+ no built-in way to use Dart: tasks are defined with Render's own SDK, which is
26
+ available for **TypeScript and Python** only. Their docs say SDKs for more
27
+ languages are planned.
28
+
29
+ `render-dart` provides first class dart language support, without reaching for Docker (which
30
+ would cost API provisioning and local development). It can compile Dart **two
31
+ ways**:
32
+
33
+ - **Task bodies to JavaScript**, with `dart compile js`, registered through
34
+ Render's `@renderinc/sdk`. Render sees an ordinary Node workflow.
35
+ - **Anything needing the real platform to a native executable**, with
36
+ `dart compile exe`. The JavaScript side becomes a generated shim that calls
37
+ it, so the task code still reads as a plain Dart call. That is what reaches
38
+ `dart:io`, `dart:ffi`, isolates, and packages like `postgres` that have no
39
+ web build at all.
40
+
41
+ Both are compiled during the deploy, on Render's own hardware — nothing binary
42
+ is committed. You get real static analysis over the whole thing.
43
+
44
+ ## The Dart side of Render
45
+
46
+ This package covers **writing** workflow tasks. Two companion Dart packages optionally provide
47
+ access to render's apis (neither are required to use this one):
48
+
49
+ | | |
50
+ | --- | --- |
51
+ | `render-dart` (this) | **Writing** tasks in Dart |
52
+ | [`render_workflows`](https://pub.dev/packages/render_workflows) | **Running** workflows/tasks — start, watch, cancel, fan out |
53
+ | [`render_api`](https://pub.dev/packages/render_api) | **Managing** the services — create, deploy, inspect |
54
+
55
+ So a Flutter app can trigger a task with `render_workflows` that a Dart
56
+ workflow, built here, then executes.
57
+
58
+ ## Quick start
59
+
60
+ ```bash
61
+ npx render-workflows-dart init my-workflow
62
+ cd my-workflow
63
+ npm install
64
+ npm run dev
65
+ ```
66
+
67
+ Then, in another terminal:
68
+
69
+ ```bash
70
+ render workflows tasks list --local
71
+ render workflows start sumSquares --local --input='[[2,3,4]]'
72
+ ```
73
+
74
+ ## Examples, which are also templates
75
+
76
+ ```bash
77
+ npx render-workflows-dart init my-app --template postgres
78
+ ```
79
+
80
+ | | Answers |
81
+ | --- | --- |
82
+ | [`default`](examples/default) | Writing a task, fanning out, retrying |
83
+ | [`http`](examples/http) | Calling an external API |
84
+ | [`native`](examples/native) | Files, subprocesses, FFI, more than one core |
85
+ | [`postgres`](examples/postgres) | Reaching a database |
86
+ | [`introspect`](examples/introspect) | Inspecting Render, or running a task in another workflow |
87
+
88
+ Each is a complete service you can run, and `init` scaffolds from it — so a
89
+ template that does not work is a test failure rather than a surprise.
90
+
91
+ ## Coding agents
92
+
93
+ Agents never read `node_modules`, so nothing shipped inside this package
94
+ reaches one helping in your project. `render-dart init` therefore writes an
95
+ `AGENTS.md` (and a `CLAUDE.md` pointing at it) **into the project it creates**,
96
+ covering the traps, when to reach for a native task, and how to get at a
97
+ database.
98
+
99
+ If you are an agent reading this page: the short version is that task bodies
100
+ are dart2js, `dart:io` does not work there, `package:http` does, and anything
101
+ needing files, FFI, a database or a second core goes through a native task —
102
+ see [Two ways past dart2js](#two-ways-past-dart2js). Native is **not** faster
103
+ at arithmetic.
104
+
105
+ There is also a Claude skill in the repository at
106
+ `.claude/skills/render-dart/`. It is not in the npm tarball, since skills are
107
+ not loaded from `node_modules`:
108
+
109
+ ```bash
110
+ cp -r render-dart/.claude/skills/render-dart ~/.claude/skills/
111
+ ```
112
+
113
+ `AGENTS.md` in the repository root is guidance for working *on* this package,
114
+ which is a different thing.
115
+
116
+ ## Writing tasks
117
+
118
+ ```dart
119
+ import 'render_dart.dart';
120
+
121
+ void main() {
122
+ task('calculateSquare', (args) async {
123
+ final n = args[0]! as int;
124
+ return n * n;
125
+ });
126
+
127
+ // Each callTask becomes its own task run on its own Render instance.
128
+ task('sumSquares', (args) async {
129
+ var total = 0;
130
+ for (final v in args[0]! as List<Object?>) {
131
+ total += (await callTask('calculateSquare', [v]))! as int;
132
+ }
133
+ return total;
134
+ });
135
+
136
+ start();
137
+ }
138
+ ```
139
+
140
+ ## Deploying
141
+
142
+ ```bash
143
+ render workflows create --name my-workflow --repo <url> \
144
+ --runtime node --root-directory my-workflow \
145
+ --build-command "npm install && npm run build" \
146
+ --run-command "node index.js"
147
+ ```
148
+
149
+ No Dart is needed on Render's builder — `render-dart build` fetches a pinned
150
+ SDK when none is present.
151
+
152
+ > Blueprints (`render.yaml`) do not support Workflows, so provisioning is via
153
+ > the CLI, the API, or the Dashboard.
154
+
155
+ ## Commands
156
+
157
+ | | |
158
+ | --- | --- |
159
+ | `render-dart build` | Compile `tasks.dart` to `build/tasks.js`, skipping if fresh |
160
+ | `render-dart dev` | Build, then start Render's local task server |
161
+ | `render-dart init [dir] [--template <name>]` | Scaffold a new project from an [example](#examples-which-are-also-templates) |
162
+ | `render-dart dart` | Which Dart this project will use, and why |
163
+ | `render-dart dart --list` | Every version the archive offers |
164
+
165
+ `--template` takes any directory name under
166
+ [`examples/`](examples) — `default` (the default), `http`, `native`,
167
+ `postgres` or `introspect`. An unknown name fails listing the real ones.
168
+
169
+ A scaffold also gets an `AGENTS.md`, and a `CLAUDE.md` pointing at it, so a
170
+ coding agent working in that project has the guidance it would otherwise never
171
+ see — see [Coding agents](#coding-agents).
172
+
173
+ Configure through `renderDart` in `package.json`:
174
+
175
+ ```json
176
+ {
177
+ "renderDart": {
178
+ "entry": "tasks.dart",
179
+ "out": "build/tasks.js",
180
+ "dartVersion": "3.13.1",
181
+ "optimize": "O2",
182
+ "sourceMaps": false,
183
+ "allowDartIo": false,
184
+ "allowDartIoIn": [],
185
+ "native": []
186
+ }
187
+ }
188
+ ```
189
+
190
+ `native` lists Dart files to compile to native executables — see
191
+ [Native tasks](#native-tasks). Each entry is a path, or an object that can
192
+ override what the source declared:
193
+
194
+ ```json
195
+ "native": [
196
+ "native/tools_impl.dart",
197
+ { "entry": "native/raw_impl.dart", "mode": "exe" },
198
+ { "entry": "native/hot_impl.dart", "worker": false }
199
+ ]
200
+ ```
201
+
202
+ `allowDartIoIn` exempts named directories from the `dart:io` guard — for a
203
+ local tool sitting beside the workflow rather than running on it, like the
204
+ seeder in the `postgres` example. Narrower than `allowDartIo: true`, which
205
+ switches the check off for task code too.
206
+
207
+ ## Choosing a Dart version
208
+
209
+ Three places, highest first. The flag is for trying one once, the environment
210
+ for varying a build without a commit — Render's dashboard sets those — and
211
+ `package.json` for the answer that should travel with the project:
212
+
213
+ ```bash
214
+ npx render-dart build --dart-version 3.12.2
215
+ RENDER_DART_VERSION=3.12.2 npx render-dart build
216
+ ```
217
+
218
+ ```json
219
+ "renderDart": { "dartVersion": "3.12.2" }
220
+ ```
221
+
222
+ A version can be exact, `latest`, or a channel name — `stable`, `beta` or
223
+ `dev`. An exact version needs no network to interpret, so a pinned project
224
+ keeps building when the archive is unreachable; an alias is resolved against
225
+ the archive on each build, which is the point of asking for one.
226
+
227
+ ```bash
228
+ npx render-dart dart # what this project will use, and why
229
+ npx render-dart dart --list # 176 stable releases, newest first
230
+ ```
231
+
232
+ **A pin set explicitly wins over a Dart already on `PATH`.** If they differ, the
233
+ requested version is downloaded and used. Only the built-in default defers to a
234
+ local toolchain — it exists so a first build on Render has something to fetch,
235
+ not to override a Dart you installed deliberately.
236
+
237
+ Before 0.8.0 that was not true: the pin was consulted only when downloading, so
238
+ it worked on a first Render build and was silently ignored everywhere else. On a
239
+ laptop `PATH` always won, and on later Render builds whatever had been vendored
240
+ first won for ever, because the cache key was "does the directory exist". If
241
+ setting `dartVersion` ever appeared to do nothing, that is why. The vendored SDK
242
+ now records its version and is replaced when the pin changes.
243
+
244
+ Every build says which Dart it used and where it came from:
245
+
246
+ ```
247
+ [render-dart] Dart 3.12.2 requested by RENDER_DART_VERSION
248
+ [render-dart] using Dart 3.12.2 (downloaded)
249
+ ```
250
+
251
+ Downloads are checked against the archive's published SHA-256 before being
252
+ unpacked. The hash is computed while the archive streams to disk, so it costs no
253
+ extra I/O — 0.18s of CPU for a 228 MB file, against roughly 30s to fetch it.
254
+ Releases old enough to predate the published sums say so rather than implying a
255
+ check happened.
256
+
257
+ ## Using pub.dev packages
258
+
259
+ Add them to `pubspec.yaml` as normal; `render-dart build` runs `dart pub get`
260
+ for you. What works is determined by dart2js, not by Render:
261
+
262
+ | | Works? | |
263
+ | --- | --- | --- |
264
+ | Pure Dart (`collection`, `crypto`, `intl`, `path`) | yes | Nothing to think about |
265
+ | `package:http` | yes | Goes through `fetch`, which Node 18+ provides |
266
+ | Anything importing `dart:io` | **no** | `File`, `Process`, `Socket`, `HttpClient` |
267
+
268
+ **`dart:io` is the trap.** dart2js compiles it without complaint and then
269
+ throws `Unsupported operation` at runtime — so a task using it deploys cleanly
270
+ and fails on its first real run, potentially burning up to the task timeout
271
+ first. `render-dart build` therefore refuses to build a project that imports
272
+ `dart:io` directly, and tells you what to use instead. Conditional imports
273
+ (`if (dart.library.io)`) are left alone, and `allowDartIo` opts out.
274
+
275
+ For Node APIs beyond HTTP, use `dart:js_interop` directly.
276
+
277
+ ### WebAssembly
278
+
279
+ Packages that ship a `.wasm` module work, and they are often the **simpler**
280
+ choice: the module runs inside the Node process, so there is no subprocess, no
281
+ second binary, and no build step beyond the one you already have.
282
+
283
+ When a package has no wasm build — or the work needs files, sockets or more
284
+ than one core — [native tasks](#two-ways-past-dart2js) are the other route.
285
+
286
+ Two shapes, both verified on Render, differing in how much the runtime has to
287
+ supply:
288
+
289
+ | | Needs | Verified |
290
+ | --- | --- | --- |
291
+ | Uses the platform's `WebAssembly` API (`forge2d`) | asset resolution only | Box2D v3, zero config |
292
+ | Carries a JS wasm runtime (`rust_crypto` → `wasm_run`) | asset resolution, `XMLHttpRequest`, and two pre-seeded globals | SHA/MD5/HMAC, cross-checked against pure-Dart `crypto` |
293
+
294
+ `wasm_run` looks browser-only at first: it loads its WASI shim by injecting a
295
+ `<script>` tag into an HTML document. But its setup checks whether the global
296
+ is *already* present and skips injection if so. The runtime seeds both:
297
+ `wasmFeatureDetect` comes from a UMD bundle shipped inside the pub package, and
298
+ `browser_wasi_shim` from npm. It then loads its module over `XMLHttpRequest`,
299
+ which the runtime also provides, on top of `fetch`.
300
+
301
+ For `wasm_run`-based packages, add the shim to your project — it is an
302
+ *optional* peer dependency, so nothing else pays for it:
303
+
304
+ ```bash
305
+ npm install @bjorn3/browser_wasi_shim
306
+ ```
307
+
308
+ Then the package works unmodified, with no `loadModule` callback and no other
309
+ setup. Its **native** path stays unavailable, needing `dart:ffi` and a wasmtime
310
+ binary; the web executor is what runs here, on the host's own `WebAssembly`.
311
+
312
+ One caveat worth knowing before combining packages: `rust_crypto` and
313
+ `forge2d 0.15` cannot share a pubspec, because `wasm_run` pulls
314
+ `build_rust_binaries` → `hooks ^1.0.0` while forge2d needs `hooks ^2.0.0`. Put
315
+ them in separate workflows.
316
+
317
+ `forge2d` — a `dart:ffi` binding to Box2D v3 — selects a bundled 227 KB
318
+ WebAssembly build under dart2js, and runs on Render unchanged:
319
+
320
+ ```dart
321
+ await initializeForge2D(wasmUri: Uri.parse(fileUri('web/box2d.wasm')));
322
+ ```
323
+
324
+ No configuration, no staging step, no `wasmUri`.
325
+
326
+ A Dart web app serves each package's `lib/` at `packages/<name>/`, and packages
327
+ that ship assets ask for them at exactly that path. Nothing serves it under
328
+ Node, so the request fails. The runtime resolves those paths from
329
+ `.dart_tool/package_config.json` — written by `dart pub get`, so the mapping is
330
+ exact rather than guessed — and reads the file directly. Node's `fetch` also
331
+ has no `file:` scheme, which the runtime adds for the same reason.
332
+
333
+ For assets of your own rather than a package's, `fileUri()` resolves a
334
+ project-relative path:
335
+
336
+ ```dart
337
+ final data = await http.get(Uri.parse(fileUri('data/table.json')));
338
+ ```
339
+
340
+ `node:wasi` is not required: forge2d supplies its own WASI shims. It is
341
+ available in Node if a module ever needs the real thing.
342
+
343
+ ## Reaching Node from a task
344
+
345
+ `dart:io` compiles under dart2js and then throws at runtime, so a task cannot
346
+ open a file, spawn a process, or reach the npm ecosystem on its own. Two
347
+ helpers close that gap.
348
+
349
+ **Any npm package or Node built-in:**
350
+
351
+ ```dart
352
+ @JS()
353
+ extension type _Crypto(JSObject _) implements JSObject {
354
+ external String randomUUID();
355
+ }
356
+
357
+ final crypto = _Crypto(requireModule('node:crypto'));
358
+ print(crypto.randomUUID());
359
+ ```
360
+
361
+ Dart cannot call `require` itself — in CommonJS it is module-scoped, and
362
+ `globalThis.require` is undefined in both CommonJS and ESM — so the runtime
363
+ hoists it. Resolution is rooted at your project directory, so
364
+ `requireModule('lodash')` means whatever *your* package.json depends on.
365
+
366
+ **Shelling out** to a CLI tool. (For calling *Dart* compiled natively, use
367
+ [native tasks](#native-tasks) rather than driving a process by hand.)
368
+
369
+ ```dart
370
+ final result = await runProcess('git', args: ['rev-parse', 'HEAD']);
371
+ if (result.ok) print(result.stdout.trim());
372
+ ```
373
+
374
+ `runProcess` takes `args`, `workingDirectory`, `environment`, `stdin`,
375
+ `timeout` and `runInShell`. A non-zero exit is **returned, not thrown** — an
376
+ exit code is a result, and the caller usually wants `stderr` with it. It throws
377
+ only when the process could not be started, or when `timeout` elapses (SIGKILL,
378
+ since a task run is already bounded by Render's own timeout).
379
+
380
+ ## Two ways past dart2js
381
+
382
+ dart2js cannot open a file, use a second core, or run a package that needs
383
+ `dart:io`. There are two escapes, and they are complementary rather than
384
+ ranked.
385
+
386
+ | | WebAssembly | Native task |
387
+ | --- | --- | --- |
388
+ | Runs in | the Node process | a subprocess |
389
+ | Needs | the package to ship a `.wasm` | nothing — any Dart compiles |
390
+ | `dart:io`, sockets, files | no | **yes** |
391
+ | `dart:ffi` | no | **yes** |
392
+ | More than one core | no | **yes**, isolates |
393
+ | Per-call cost | none | ~0.5 ms with a worker |
394
+ | Extra artefact | none | a binary, built during the deploy |
395
+
396
+ **Reach for wasm when the package already has one.** `forge2d` and
397
+ `rust_crypto` both do, and `render-dart` resolves their modules without
398
+ configuration. Nothing is spawned and nothing is compiled.
399
+
400
+ **Reach for native when there is no wasm build, when the work needs I/O or
401
+ FFI, or when it needs to use more than one core.** `package:postgres` is the
402
+ clearest case: it speaks the wire protocol over a raw socket, and pub.dev marks
403
+ it `runtime:native-aot` with no `runtime:web`. There is no wasm alternative and
404
+ no dart2js path — native or nothing.
405
+
406
+ ### What native is *not*
407
+
408
+ It is not a way to make computation faster. The same recursive fib, compiled
409
+ both ways and run on Render:
410
+
411
+ | n | dart2js | native |
412
+ | --- | --- | --- |
413
+ | 30 | 8 ms | 23 ms |
414
+ | 34 | 60 ms | 50 ms |
415
+ | 36 | 146 ms | 131 ms |
416
+
417
+ V8 matches Dart AOT on pure integer work, and beats it at small n. If your task
418
+ is arithmetic, dart2js is already fine.
419
+
420
+ The speed win that *is* real is **parallelism**, because dart2js inherits
421
+ JavaScript's single thread. The same batch of fib(32), run sequentially in
422
+ JavaScript and across isolates natively:
423
+
424
+ | jobs | dart2js seq | native parallel | speedup |
425
+ | ---: | ---: | ---: | ---: |
426
+ | 2 | 46 ms | 31 ms | 1.5x |
427
+ | 4 | 88 ms | 48 ms | 1.8x |
428
+ | 8 | 177 ms | 84 ms | 2.1x |
429
+ | 16 | 367 ms | 219 ms | 1.7x |
430
+ | 32 | 706 ms | 533 ms | 1.3x |
431
+
432
+ **Treat that as anecdote.** It is one workload on one Render instance, on the
433
+ default `starter` task plan in a free workspace — the smallest there is. A
434
+ different plan, or different work, would produce a different curve.
435
+
436
+ What it does illustrate is a shape worth expecting: the benefit is real, it
437
+ does not grow indefinitely, and past some point more isolates cost more than
438
+ they return. The dart2js column stays flat at ~22 ms per job throughout, which
439
+ is the control confirming the native side's rise is not noise.
440
+
441
+ `Platform.numberOfProcessors` reported 32 the whole time, which was not a
442
+ useful guide to any of this. Measure the workload on the plan it will run on.
443
+
444
+ ## Native tasks
445
+
446
+ Write the function once, compile it AOT, and call it from task code as if it
447
+ were local — no process handling, no serialisation, nothing at the call site
448
+ that says it is native.
449
+
450
+ Write the implementation in `<name>_impl.dart`:
451
+
452
+ ```dart
453
+ // native/tools_impl.dart
454
+ import 'dart:io';
455
+
456
+ import '../native_task.dart';
457
+
458
+ @nativeTask
459
+ Map<String, Object?> inspect(String path) => {
460
+ 'bytes': File(path).lengthSync(),
461
+ 'lines': File(path).readAsLinesSync().length,
462
+ };
463
+ ```
464
+
465
+ Declare it, and call it by its plain name:
466
+
467
+ ```json
468
+ "renderDart": { "native": ["native/tools_impl.dart"] }
469
+ ```
470
+
471
+ ```dart
472
+ // tasks.dart — nothing here says "native"
473
+ import 'native/tools.dart';
474
+
475
+ task('inspect', (args) async => await inspect(args[0]! as String));
476
+ ```
477
+
478
+ `render-dart build` generates `native/tools.dart` as a conditional export:
479
+
480
+ ```dart
481
+ export 'tools.stub.dart' if (dart.library.io) 'tools_impl.dart';
482
+ ```
483
+
484
+ so the **same source** compiles to a process call under dart2js and a direct
485
+ call natively. That also means native code can be unit-tested on the Dart VM,
486
+ and a native function calling a sibling skips the process hop entirely.
487
+
488
+ Always `await` a native task — the stub returns a `Future` where the
489
+ implementation may return a plain value, and awaiting is what makes one piece of
490
+ code valid on both sides.
491
+
492
+ ### What can cross
493
+
494
+ Parameters and return values are JSON, so: `bool`, `int`, `double`, `num`,
495
+ `String`, `List<T>`, `Map<String, T>`, `Object?`, `dynamic`, and `Future<T>` of
496
+ those, nullable included. Required, optional and named parameters all work,
497
+ with their defaults.
498
+
499
+ Anything else — a custom class, `Uint8List`, `Set`, a record — is **rejected at
500
+ build time**, naming the parameter, rather than failing as a decode error on a
501
+ live run.
502
+
503
+ ### Options ride with the declaration
504
+
505
+ So a call site never has to know, and never has to be updated when you change
506
+ your mind:
507
+
508
+ ```dart
509
+ @NativeTask(worker: true, idleTimeout: Duration(seconds: 30))
510
+ Future<int> hot(int a) async => a;
511
+ ```
512
+
513
+ | | |
514
+ | --- | --- |
515
+ | `worker` | Keep the executable alive between calls. Default `false` |
516
+ | `idleTimeout` | How long an idle worker lingers. Default 30 s |
517
+ | `timeout` | How long one call may take. Default none |
518
+
519
+ `renderDart.native` can override any of them per entry, so a deployment can
520
+ change behaviour without editing code. To vary them for one caller — without
521
+ changing any signature, which is what keeps the one-source property:
522
+
523
+ ```dart
524
+ await NativeCall.scope(worker: false, () async => hot(1));
525
+ ```
526
+
527
+ ### Worker mode
528
+
529
+ Measured on Render, 20 calls:
530
+
531
+ | | processes | time |
532
+ | --- | --- | --- |
533
+ | spawn per call | 20 | 112 ms |
534
+ | worker | 1 | **9 ms** |
535
+
536
+ It is opt-in because a worker keeps top-level state between calls. That is what
537
+ makes it fast, and it also means a leak accumulates instead of being cleaned up
538
+ by process exit, and one call can observe what the last one left behind. A call
539
+ that throws does *not* kill the worker; a process that dies rejects everything
540
+ in flight with its exit code and stderr, then respawns on the next call.
541
+
542
+ ### The wire, and errors
543
+
544
+ One JSON object per line (JSONL) over stdin/stdout. `print()` on the native side
545
+ arrives as a `$log` line and is forwarded to the task log — on stdout it would
546
+ corrupt the framing, so it is rerouted rather than left to break things. A
547
+ native `throw` arrives as a `NativeTaskException` carrying the real message and
548
+ the native stack trace.
549
+
550
+ ### A worked example: Postgres
551
+
552
+ `package:postgres` speaks the wire protocol over a raw socket. pub.dev marks it
553
+ `runtime:native-aot` and `runtime:native-jit`, with **no** `runtime:web` — it
554
+ cannot run under dart2js at all, and there is no wasm build to fall back on.
555
+ Native is the only route to a database from a Dart workflow.
556
+
557
+ ```dart
558
+ // native/db_impl.dart
559
+ @NativeTask(worker: true, idleTimeout: Duration(minutes: 2))
560
+ Future<List<Map<String, Object?>>> listWidgets({int limit = 20}) async {
561
+ final db = await _db(); // held open between calls
562
+ final rows = await db.execute(
563
+ Sql.named('select * from widgets limit @limit'),
564
+ parameters: {'limit': limit},
565
+ );
566
+ return rows.map(_jsonRow).toList();
567
+ }
568
+ ```
569
+
570
+ ```dart
571
+ // tasks.dart
572
+ import 'native/db.dart';
573
+
574
+ task('listWidgets', (args) async => await listWidgets(limit: 20));
575
+ ```
576
+
577
+ Worker mode earns its keep here: the process stays alive, so the TCP handshake,
578
+ TLS negotiation and Postgres authentication happen once rather than per call.
579
+ `pg_backend_pid()` proves it from the server's side — it stays constant across
580
+ calls while a counter climbs.
581
+
582
+ Two things this example ran into, both worth knowing before you hit them:
583
+
584
+ - **`timestamptz` arrives as a `DateTime`, which is not JSON.** Convert before
585
+ returning, or the build rejects the signature — the right failure, but a
586
+ puzzling one if unexpected.
587
+ - **A held connection can be dropped** by the server, a deploy, or idling. Check
588
+ and reconnect rather than surfacing a broken socket; that is the honest cost
589
+ of keeping state in a worker.
590
+
591
+ A full version, with a local seeding program that creates the table over the
592
+ *external* connection string while the tasks read it over the *internal* one,
593
+ is in [`examples/postgres`](examples/postgres) — which is also what
594
+ `init --template postgres` scaffolds.
595
+
596
+ ### `mode: "exe"`
597
+
598
+ For a program that owns its own `main()` and wants no wrapper. It is compiled
599
+ to `build/native/<name>` and left alone; call it with `runProcess`.
600
+
601
+ ### Building
602
+
603
+ The vendored SDK carries `gen_snapshot`, so this needs nothing extra — and
604
+ **nothing is cross-compiled and no binary is committed**. Render's build host is
605
+ already `linux/x64`, so the executable is produced from the source in the commit
606
+ that deploys it.
607
+
608
+ Generated files (`tools.dart`, `tools.stub.dart`) are listed in a
609
+ `native/.gitignore` the build maintains, because the facade takes a plain name
610
+ and would otherwise read as hand-written source.
611
+
612
+ Native sources need `dart:io`, so declared native directories — and
613
+ `native_task.dart` — are exempt from the `dart:io` guard. Everything else stays
614
+ strict.
615
+
616
+ ## Two things this package exists to get right
617
+
618
+ **`RENDER_SDK_AUTO_START` must be `false` before the SDK loads.** The SDK's
619
+ `task()` schedules its own `startTaskServer()` via `setImmediate`. Combined
620
+ with an explicit start, that produces two task servers and runs **every task
621
+ body twice** — doubled side effects and doubled billing. Neither
622
+ `render workflows dev` nor Render sets this for you.
623
+
624
+ **Dart must never throw across the JS boundary.** A Dart exception converted
625
+ by `Future.toJS` reaches Render as the opaque *"Dart exception thrown from
626
+ converted Future…"*, with the real message boxed out of reach. Task bodies
627
+ return a `{$ok}`/`{$err}` envelope instead, and the runtime rethrows a genuine
628
+ `Error`, so the actual message lands in the run record.
629
+
630
+ Both are handled for you. They are documented because they cost real debugging
631
+ time to find.
632
+
633
+ ## Build caching on Render
634
+
635
+ The Dart SDK is unpacked into `node_modules/.dart-sdk`, and the pub cache into
636
+ `node_modules/.pub-cache`. Render preserves `node_modules` between builds but
637
+ not arbitrary top-level directories — measured, with the SDK elsewhere it was
638
+ re-downloaded on every deploy, 33s of a 52s build. Cached, the build step is
639
+ about a second.
640
+
641
+ Native executables are cached the same way, in `node_modules/.native-cache`,
642
+ keyed on the **content** of their sources rather than mtime — every deploy is a
643
+ fresh git checkout that restamps mtimes, so an mtime-keyed cache could never
644
+ hit. A deploy that changes only `tasks.dart` reuses the executable instead of
645
+ paying for another AOT compile.
646
+
647
+ Use *Clear build cache & deploy* in the Dashboard to force a clean fetch.
648
+
649
+ ## Layout
650
+
651
+ src/runtime.js Loaded by your workflow; bridges Dart to the SDK
652
+ src/web-shims.js Browser-shaped APIs Node lacks: self, file: fetch,
653
+ Dart package assets, XMLHttpRequest
654
+ src/node-bridge.js Node access Dart lacks: require, subprocesses
655
+ src/native-worker.js Keeping native executables alive between calls
656
+ src/cli.js build / dev / init
657
+ src/toolchain/ SDK resolution and compilation, free of Render
658
+ specifics so it can be extracted later
659
+ dart/generator/ Reads @nativeTask with package:analyzer and writes
660
+ the dispatcher, stubs and facade. Its own pubspec,
661
+ so your project never depends on the analyzer
662
+ examples/ Five runnable services, which are also the `init`
663
+ templates — so a template cannot drift from a
664
+ working example
665
+ runtime/ Copied into a scaffold: the two Dart bridge files
666
+ (render_dart.dart, native_task.dart) and the
667
+ AGENTS.md that lands in your project
668
+
669
+ ## Licence
670
+
671
+ MIT
672
+
673
+ ## Note
674
+
675
+ \* The Render name and logo are trademarks of Render Services, Inc. The mark
676
+ itself is unmodified, shown on white with the clear space Render's brand kit
677
+ specifies, referentially — to identify the service these packages work with,
678
+ not to suggest any endorsement.