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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import 'render_dart.dart';
|
|
2
|
+
// The natural name. Nothing here says these run in another process, or that
|
|
3
|
+
// they touch dart:io — the facade resolves to a stub under dart2js.
|
|
4
|
+
import 'native/db.dart';
|
|
5
|
+
|
|
6
|
+
/// Render Workflows tasks backed by a Render Postgres database.
|
|
7
|
+
///
|
|
8
|
+
/// package:postgres cannot run under dart2js, so every one of these reaches a
|
|
9
|
+
/// natively compiled binary. Nothing at the call site says so.
|
|
10
|
+
///
|
|
11
|
+
/// The table is created and filled by `seed/`, which runs on your machine over
|
|
12
|
+
/// the database's *external* connection string. These tasks read the same rows
|
|
13
|
+
/// from inside Render over the *internal* one — so a change made by a task and
|
|
14
|
+
/// seen by `seed/bin/show.dart` has crossed two machines and two connections.
|
|
15
|
+
void main() {
|
|
16
|
+
/// Query, optionally filtered.
|
|
17
|
+
task('listWidgets', (args) async => await listWidgets(
|
|
18
|
+
sku: args.isEmpty ? null : args[0] as String?,
|
|
19
|
+
limit: args.length < 2 ? 20 : args[1]! as int,
|
|
20
|
+
));
|
|
21
|
+
|
|
22
|
+
/// A single aggregate row.
|
|
23
|
+
task('widgetStats', (args) async => await widgetStats());
|
|
24
|
+
|
|
25
|
+
/// Insert or update.
|
|
26
|
+
task('upsertWidget', (args) async => await upsertWidget(
|
|
27
|
+
args[0]! as String,
|
|
28
|
+
args[1]! as String,
|
|
29
|
+
args[2]! as int,
|
|
30
|
+
args[3]! as int,
|
|
31
|
+
));
|
|
32
|
+
|
|
33
|
+
/// Update one row and return it.
|
|
34
|
+
task('restock', (args) async => await restock(
|
|
35
|
+
args[0]! as String,
|
|
36
|
+
args[1]! as int,
|
|
37
|
+
));
|
|
38
|
+
|
|
39
|
+
/// Calls dbConnection repeatedly to show that a worker holds one Postgres
|
|
40
|
+
/// session: the backend pid stays put while the call counter climbs.
|
|
41
|
+
///
|
|
42
|
+
/// Reconnecting per call would show a different pid every time — and pay for
|
|
43
|
+
/// a TCP handshake, TLS negotiation and authentication each round.
|
|
44
|
+
task('connectionReuse', (args) async {
|
|
45
|
+
final n = args.isEmpty ? 5 : args[0]! as int;
|
|
46
|
+
final started = DateTime.now();
|
|
47
|
+
|
|
48
|
+
final backendPids = <Object?>{};
|
|
49
|
+
Map<String, Object?> last = const {};
|
|
50
|
+
for (var i = 0; i < n; i++) {
|
|
51
|
+
last = await dbConnection();
|
|
52
|
+
backendPids.add(last['backend_pid']);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
'calls': n,
|
|
57
|
+
'ms': DateTime.now().difference(started).inMilliseconds,
|
|
58
|
+
'distinctBackendPids': backendPids.length,
|
|
59
|
+
'callsThisProcess': last['callsThisProcess'],
|
|
60
|
+
'connectionAgeMs': last['connectionAgeMs'],
|
|
61
|
+
'database': last['database'],
|
|
62
|
+
};
|
|
63
|
+
}, timeoutSeconds: 120);
|
|
64
|
+
|
|
65
|
+
start();
|
|
66
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "render-workflows-dart",
|
|
3
|
+
"version": "0.8.2",
|
|
4
|
+
"description": "Unofficial: write Render Workflows tasks in Dart, compiled to JavaScript. Not affiliated with or endorsed by Render.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/timmaffett/render_workflows_dart.git"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"render",
|
|
12
|
+
"workflows",
|
|
13
|
+
"dart",
|
|
14
|
+
"dart2js"
|
|
15
|
+
],
|
|
16
|
+
"bin": {
|
|
17
|
+
"render-workflows-dart": "src/cli.js",
|
|
18
|
+
"render-dart": "src/cli.js"
|
|
19
|
+
},
|
|
20
|
+
"main": "src/runtime.js",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./src/runtime.js",
|
|
23
|
+
"./runtime": "./src/runtime.js",
|
|
24
|
+
"./toolchain": "./src/toolchain/index.js",
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"runtime",
|
|
30
|
+
"examples",
|
|
31
|
+
"README.md",
|
|
32
|
+
"CHANGELOG.md",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"dart"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "node --test \"test/**/*.test.js\""
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@renderinc/sdk": ">=0.5.0",
|
|
41
|
+
"@bjorn3/browser_wasi_shim": ">=0.2.9"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"@bjorn3/browser_wasi_shim": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Working in this project
|
|
2
|
+
|
|
3
|
+
Render Workflows tasks written in Dart, built by
|
|
4
|
+
[`render-dart`](https://github.com/timmaffett/render_workflows_dart).
|
|
5
|
+
|
|
6
|
+
Task bodies are compiled to JavaScript with `dart compile js` and registered
|
|
7
|
+
through Render's official `@renderinc/sdk` on the `node` runtime. Where
|
|
8
|
+
JavaScript is not enough, a task calls into Dart compiled natively.
|
|
9
|
+
|
|
10
|
+
## Commands
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx render-dart build # compile; --force skips the freshness check
|
|
14
|
+
npx render-dart dev # build, then start Render's local task server
|
|
15
|
+
npx render-dart dart # which Dart this project uses, and why
|
|
16
|
+
render workflows tasks list --local
|
|
17
|
+
render workflows start <task> --local --input='[1, 2]'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
No Dart installation is needed. If one is on `PATH` the build uses it;
|
|
21
|
+
otherwise an SDK is fetched into `node_modules`. Either way the build prints
|
|
22
|
+
which version it used and where it came from, so `render-dart dart` is only
|
|
23
|
+
needed before building.
|
|
24
|
+
|
|
25
|
+
To pin a version, put it in `package.json` — that is the form that travels
|
|
26
|
+
with the project:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
"renderDart": { "dartVersion": "3.13.1" }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`--dart-version` and `RENDER_DART_VERSION` override it, in that order, for
|
|
33
|
+
trying one without a commit. **A version asked for explicitly beats a Dart on
|
|
34
|
+
`PATH`** and is downloaded if it has to be, so a pin means the same version
|
|
35
|
+
here and on Render. `latest` and the channel names work too, but an exact
|
|
36
|
+
version is the one that reproduces.
|
|
37
|
+
|
|
38
|
+
## Writing a task
|
|
39
|
+
|
|
40
|
+
`tasks.dart` registers functions by name and must end with `start()`:
|
|
41
|
+
|
|
42
|
+
```dart
|
|
43
|
+
task('doThing', (args) async => args[0]! as int * 2);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Arguments and return values are JSON. Render caps one invocation's input at
|
|
47
|
+
4 MB. `callTask('other', [...])` starts a **subtask of this workflow**, each on
|
|
48
|
+
its own instance — that is how work fans out.
|
|
49
|
+
|
|
50
|
+
## The rule that catches people
|
|
51
|
+
|
|
52
|
+
**`dart:io` does not work in task code.** It compiles under dart2js and then
|
|
53
|
+
throws `Unsupported operation` on first use, so the build refuses a direct
|
|
54
|
+
import. That rules out files, sockets, subprocesses and any package needing
|
|
55
|
+
them.
|
|
56
|
+
|
|
57
|
+
Three ways forward:
|
|
58
|
+
|
|
59
|
+
| Need | Use |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| HTTP | `package:http` — works, it goes through the platform's networking |
|
|
62
|
+
| A package shipping a `.wasm` | just use it; modules resolve automatically |
|
|
63
|
+
| Files, FFI, a database, real parallelism | a **native task** |
|
|
64
|
+
|
|
65
|
+
## Native tasks
|
|
66
|
+
|
|
67
|
+
Write the implementation in `native/<name>_impl.dart`, annotate top-level
|
|
68
|
+
functions, and list the file in `renderDart.native` in `package.json`:
|
|
69
|
+
|
|
70
|
+
```dart
|
|
71
|
+
// native/tools_impl.dart
|
|
72
|
+
import 'dart:io';
|
|
73
|
+
import '../native_task.dart';
|
|
74
|
+
|
|
75
|
+
@nativeTask
|
|
76
|
+
Map<String, Object?> inspect(String path) => {'bytes': File(path).lengthSync()};
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```dart
|
|
80
|
+
// tasks.dart — nothing here says it is native
|
|
81
|
+
import 'native/tools.dart';
|
|
82
|
+
|
|
83
|
+
task('inspect', (args) async => await inspect(args[0]! as String));
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The build generates `native/tools.dart` and `native/tools.stub.dart`. Do not
|
|
87
|
+
edit or commit them.
|
|
88
|
+
|
|
89
|
+
- **Always `await`** a native task; the stub returns a `Future` where the
|
|
90
|
+
implementation may not.
|
|
91
|
+
- **Return JSON-representable types only.** `DateTime` is not — convert to an
|
|
92
|
+
ISO-8601 string. Anything unsupported is rejected at build time, naming the
|
|
93
|
+
parameter.
|
|
94
|
+
- **`@NativeTask(worker: true)`** keeps the process alive between calls, which
|
|
95
|
+
matters for anything holding a connection. It also keeps top-level state, so
|
|
96
|
+
one call can observe what the last left behind.
|
|
97
|
+
|
|
98
|
+
**Native is not faster at arithmetic.** Measured on Render, V8 matches Dart AOT
|
|
99
|
+
on integer work and beats it at small n. Choose native for *capability* — or
|
|
100
|
+
for parallelism, which is the one real speed win, since dart2js is
|
|
101
|
+
single-threaded.
|
|
102
|
+
|
|
103
|
+
## Reaching a database
|
|
104
|
+
|
|
105
|
+
`package:postgres` cannot run under dart2js at all — it needs a raw socket, and
|
|
106
|
+
pub.dev marks it `runtime:native-aot` with no `runtime:web`. So a database
|
|
107
|
+
needs a native task, holding the connection open with `worker: true`.
|
|
108
|
+
|
|
109
|
+
`render-dart init db --template postgres` scaffolds a working version, seeder
|
|
110
|
+
included.
|
|
111
|
+
|
|
112
|
+
The connection string belongs in `DATABASE_URL` on the service, set to the
|
|
113
|
+
database's **internal** string. A workflow's environment can only be set when
|
|
114
|
+
the service is created; afterwards, the Dashboard is the only route.
|
|
115
|
+
|
|
116
|
+
## Deploying
|
|
117
|
+
|
|
118
|
+
| Field | Value |
|
|
119
|
+
| --- | --- |
|
|
120
|
+
| Runtime | Node |
|
|
121
|
+
| Build Command | `npm install && npm run build` |
|
|
122
|
+
| Start Command | `node index.js` |
|
|
123
|
+
| Root Directory | where this `package.json` lives |
|
|
124
|
+
|
|
125
|
+
**Autodeploy fires only for commits touching the root directory.** A commit
|
|
126
|
+
elsewhere being ignored is correct, not a broken webhook.
|
|
127
|
+
|
|
128
|
+
## Do not commit
|
|
129
|
+
|
|
130
|
+
`build/`, `node_modules/`, and the generated `native/*.dart` files. The build
|
|
131
|
+
regenerates all of them and maintains a `native/.gitignore`.
|
|
132
|
+
|
|
133
|
+
`render_dart.dart` and `native_task.dart` are render-dart's bridge files. They
|
|
134
|
+
are boilerplate — the build refreshes them when missing and warns when a local
|
|
135
|
+
copy is older than the installed package.
|
|
136
|
+
|
|
137
|
+
## More
|
|
138
|
+
|
|
139
|
+
Runnable examples covering HTTP, native tasks, Postgres, WebAssembly and
|
|
140
|
+
calling Render's own API from a task:
|
|
141
|
+
<https://github.com/timmaffett/render_workflows_dart/tree/main/examples>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/// The native side of a render-dart native task.
|
|
2
|
+
///
|
|
3
|
+
/// This file is boilerplate — you shouldn't need to edit it. A file compiled
|
|
4
|
+
/// AOT by `render-dart build` runs a generated `main()` that calls
|
|
5
|
+
/// [nativeTaskMain] with its `@nativeTask` functions.
|
|
6
|
+
///
|
|
7
|
+
/// The protocol is JSONL: one JSON object per line, in and out. That framing
|
|
8
|
+
/// is what lets the same binary serve a one-shot call and, later, a persistent
|
|
9
|
+
/// worker — a one-shot writes a line and reads a line, a worker just keeps
|
|
10
|
+
/// looping.
|
|
11
|
+
///
|
|
12
|
+
/// → {"id":1,"method":"inspect","args":["a.png"],"named":{"sha":true}}
|
|
13
|
+
/// ← {"id":1,"$log":"reading a.png"}
|
|
14
|
+
/// ← {"id":1,"$ok":{"bytes":8192}}
|
|
15
|
+
///
|
|
16
|
+
/// Every message carries an `id` even though a one-shot call has only one
|
|
17
|
+
/// request in flight, so nothing about the wire format changes when a worker
|
|
18
|
+
/// starts multiplexing several.
|
|
19
|
+
library;
|
|
20
|
+
|
|
21
|
+
import 'dart:async';
|
|
22
|
+
import 'dart:convert';
|
|
23
|
+
import 'dart:io';
|
|
24
|
+
|
|
25
|
+
/// Marks a top-level function as callable from a dart2js task.
|
|
26
|
+
///
|
|
27
|
+
/// Parameters and the return value cross a JSON boundary, so they must be
|
|
28
|
+
/// JSON-representable: bool, int, double, num, String, List<T>, Map<String, T>,
|
|
29
|
+
/// Object?, dynamic, or Future<T> of those. `render-dart build` rejects
|
|
30
|
+
/// anything else by name rather than letting it fail as a decode error at run
|
|
31
|
+
/// time.
|
|
32
|
+
class NativeTask {
|
|
33
|
+
const NativeTask({this.worker, this.idleTimeout, this.timeout});
|
|
34
|
+
|
|
35
|
+
/// Keep the executable alive between calls.
|
|
36
|
+
///
|
|
37
|
+
/// Much faster in a loop — 10 ms against 132 ms over 20 calls on Render —
|
|
38
|
+
/// but the process keeps its top-level state, so call N can observe what
|
|
39
|
+
/// call N-1 left behind and a leak is never cleaned up by process exit.
|
|
40
|
+
/// Defaults to false.
|
|
41
|
+
final bool? worker;
|
|
42
|
+
|
|
43
|
+
/// How long an idle worker lingers before it is reaped. Defaults to 30 s.
|
|
44
|
+
final Duration? idleTimeout;
|
|
45
|
+
|
|
46
|
+
/// How long one call may take before it is abandoned. Unset means no limit.
|
|
47
|
+
final Duration? timeout;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// The annotation itself, for the common case with no options: `@nativeTask`.
|
|
51
|
+
const nativeTask = NativeTask();
|
|
52
|
+
|
|
53
|
+
/// What a generated dispatcher entry looks like.
|
|
54
|
+
typedef NativeHandler = Future<Object?> Function(
|
|
55
|
+
List<Object?> args,
|
|
56
|
+
Map<String, Object?> named,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
/// Serves JSONL requests on stdin until it closes.
|
|
60
|
+
///
|
|
61
|
+
/// Handlers run one at a time. A native task therefore never has to be
|
|
62
|
+
/// reentrant, which is the reason worker mode can reuse this unchanged.
|
|
63
|
+
Future<void> nativeTaskMain(
|
|
64
|
+
List<String> argv,
|
|
65
|
+
Map<String, NativeHandler> handlers,
|
|
66
|
+
) async {
|
|
67
|
+
final out = stdout;
|
|
68
|
+
|
|
69
|
+
final lines = stdin.transform(utf8.decoder).transform(const LineSplitter());
|
|
70
|
+
|
|
71
|
+
await for (final line in lines) {
|
|
72
|
+
if (line.trim().isEmpty) continue;
|
|
73
|
+
|
|
74
|
+
Object? id;
|
|
75
|
+
try {
|
|
76
|
+
final request = jsonDecode(line) as Map<String, Object?>;
|
|
77
|
+
id = request['id'];
|
|
78
|
+
final method = request['method'] as String?;
|
|
79
|
+
final args = (request['args'] as List?)?.cast<Object?>() ?? const [];
|
|
80
|
+
final named = (request['named'] as Map?)?.cast<String, Object?>() ?? const {};
|
|
81
|
+
|
|
82
|
+
final handler = handlers[method];
|
|
83
|
+
if (handler == null) {
|
|
84
|
+
_write(out, {
|
|
85
|
+
'id': id,
|
|
86
|
+
r'$err': 'no @nativeTask named "$method" in this executable',
|
|
87
|
+
r'$known': handlers.keys.toList(),
|
|
88
|
+
});
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// print() from a task body would otherwise land on stdout and corrupt
|
|
93
|
+
// the framing, so it is rerouted to a $log line instead of becoming a
|
|
94
|
+
// baffling parse error on the Node side.
|
|
95
|
+
final result = await runZoned(
|
|
96
|
+
() => handler(args, named),
|
|
97
|
+
zoneSpecification: ZoneSpecification(
|
|
98
|
+
print: (_, __, ___, String message) =>
|
|
99
|
+
_write(out, {'id': id, r'$log': message}),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
_write(out, {'id': id, r'$ok': result});
|
|
104
|
+
} catch (e, stackTrace) {
|
|
105
|
+
// Mirrors the {$ok}/{$err} envelope the JS boundary uses, for the same
|
|
106
|
+
// reason: a real message is worth more than a generic failure.
|
|
107
|
+
_write(out, {'id': id, r'$err': '$e', r'$stack': '$stackTrace'});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
void _write(IOSink out, Map<String, Object?> message) {
|
|
113
|
+
// jsonEncode escapes newlines, so one message is always exactly one line.
|
|
114
|
+
out.writeln(jsonEncode(message));
|
|
115
|
+
}
|