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,428 @@
1
+ /// The Dart side of the render-dart bridge.
2
+ ///
3
+ /// This file is boilerplate — you shouldn't need to edit it. It will become a
4
+ /// pub package (`render_workflows_node`); for now it ships with the template
5
+ /// so a project stays self-contained.
6
+ library;
7
+
8
+ import 'dart:async';
9
+ import 'dart:convert';
10
+ import 'dart:js_interop';
11
+ import 'dart:js_interop_unsafe';
12
+
13
+ @JS('__registerTask')
14
+ external void _registerTask(String name, JSFunction fn, JSObject? options);
15
+
16
+ @JS('__callTask')
17
+ external JSPromise<JSAny?> _callTask(String name, JSArray args);
18
+
19
+ @JS('__start')
20
+ external JSPromise<JSAny?> _start();
21
+
22
+ @JS('__nativeCall')
23
+ external JSPromise<JSArray<JSString>> _nativeCall(
24
+ String binary,
25
+ String request,
26
+ JSObject options,
27
+ );
28
+
29
+ @JS('__fileUri')
30
+ external String _fileUri(String relativePath);
31
+
32
+ /// Resolves a project-relative path to a `file:` URI.
33
+ ///
34
+ /// Useful for packages that load bundled assets through `fetch`. Node's fetch
35
+ /// has no `file:` support of its own; the render-dart runtime adds it, so a
36
+ /// URI from here can be handed straight to such a package:
37
+ ///
38
+ /// ```dart
39
+ /// await initializeForge2D(wasmUri: Uri.parse(fileUri('web/box2d.wasm')));
40
+ /// ```
41
+ String fileUri(String relativePath) => _fileUri(relativePath);
42
+
43
+ @JS('__require')
44
+ external JSObject _require(String id);
45
+
46
+ @JS('__run')
47
+ external JSPromise<JSObject> _run(String command, JSArray args, JSObject options);
48
+
49
+ /// Loads an npm package or Node built-in, by name.
50
+ ///
51
+ /// This is how a Dart task reaches the npm ecosystem. Bind what you need with
52
+ /// an extension type:
53
+ ///
54
+ /// ```dart
55
+ /// @JS()
56
+ /// extension type _Crypto(JSObject _) implements JSObject {
57
+ /// external String randomUUID();
58
+ /// }
59
+ ///
60
+ /// final crypto = _Crypto(requireModule('node:crypto'));
61
+ /// print(crypto.randomUUID());
62
+ /// ```
63
+ ///
64
+ /// Dart cannot call `require` directly — in CommonJS it is module-scoped, and
65
+ /// `globalThis.require` is undefined — so the runtime hoists it for us.
66
+ JSObject requireModule(String id) => _require(id);
67
+
68
+ /// What a finished process left behind.
69
+ class ProcessResult {
70
+ const ProcessResult({
71
+ required this.exitCode,
72
+ required this.stdout,
73
+ required this.stderr,
74
+ this.signal,
75
+ });
76
+
77
+ /// The process's exit code, or -1 if it was killed before exiting.
78
+ final int exitCode;
79
+ final String stdout;
80
+ final String stderr;
81
+
82
+ /// The signal that killed the process, if one did.
83
+ final String? signal;
84
+
85
+ bool get ok => exitCode == 0;
86
+
87
+ @override
88
+ String toString() => 'ProcessResult(exitCode: $exitCode'
89
+ '${signal == null ? '' : ', signal: $signal'})';
90
+ }
91
+
92
+ /// Runs [command] to completion and captures its output.
93
+ ///
94
+ /// `dart:io` compiles under dart2js and then fails at runtime, so `Process` is
95
+ /// unavailable here. This is the way to shell out — to a CLI tool, or to a
96
+ /// natively compiled Dart binary shipped alongside the workflow.
97
+ ///
98
+ /// A non-zero exit is returned, not thrown: an exit code is a result, and the
99
+ /// caller usually wants [ProcessResult.stderr] with it. It throws only when the
100
+ /// process could not be started, or when [timeout] elapses.
101
+ ///
102
+ /// ```dart
103
+ /// final result = await runProcess('git', args: ['rev-parse', 'HEAD']);
104
+ /// if (result.ok) print(result.stdout.trim());
105
+ /// ```
106
+ Future<ProcessResult> runProcess(
107
+ String command, {
108
+ List<String> args = const [],
109
+ String? workingDirectory,
110
+ Map<String, String>? environment,
111
+ String? stdin,
112
+ Duration? timeout,
113
+ bool runInShell = false,
114
+ }) async {
115
+ final options = JSObject();
116
+ if (workingDirectory != null) options['cwd'] = workingDirectory.toJS;
117
+ if (stdin != null) options['stdin'] = stdin.toJS;
118
+ if (timeout != null) options['timeoutMs'] = timeout.inMilliseconds.toJS;
119
+ if (runInShell) options['shell'] = true.toJS;
120
+ if (environment != null) {
121
+ final env = JSObject();
122
+ environment.forEach((key, value) => env[key] = value.toJS);
123
+ options['env'] = env;
124
+ }
125
+
126
+ final raw = await _run(command, args.map((a) => a.toJS).toList().toJS, options)
127
+ .toDart;
128
+
129
+ return ProcessResult(
130
+ exitCode: (raw['code']! as JSNumber).toDartInt,
131
+ stdout: (raw['stdout']! as JSString).toDart,
132
+ stderr: (raw['stderr']! as JSString).toDart,
133
+ signal: raw['signal'].isUndefinedOrNull
134
+ ? null
135
+ : (raw['signal']! as JSString).toDart,
136
+ );
137
+ }
138
+
139
+ /// Thrown when a native task reports a failure.
140
+ ///
141
+ /// Carries the message and stack trace from the native side rather than the
142
+ /// spawn's exit code, so a `throw` inside AOT-compiled Dart reads the same way
143
+ /// it would if the call had been local.
144
+ class NativeTaskException implements Exception {
145
+ NativeTaskException(this.message, {this.nativeStackTrace, this.binary, this.method});
146
+
147
+ final String message;
148
+ final String? nativeStackTrace;
149
+ final String? binary;
150
+ final String? method;
151
+
152
+ @override
153
+ String toString() {
154
+ final where = binary == null ? '' : ' in $binary/$method';
155
+ return 'NativeTaskException$where: $message'
156
+ '${nativeStackTrace == null ? '' : '\n$nativeStackTrace'}';
157
+ }
158
+ }
159
+
160
+ /// Calls a `@nativeTask` function in an AOT-compiled executable.
161
+ ///
162
+ /// Generated stubs call this; you rarely call it directly. The executable is
163
+ /// produced by `render-dart build` from the file declared in
164
+ /// `renderDart.native`, and lives at `build/native/<binary>` relative to the
165
+ /// project root — which is the working directory both on Render and under
166
+ /// `render workflows dev`.
167
+ ///
168
+ /// One JSONL line goes in and the reply lines come back. `print()` on the
169
+ /// native side arrives as a `\$log` line and is forwarded here, so native
170
+ /// output still reaches the task log instead of corrupting the framing.
171
+ /// Per-call overrides for native tasks, scoped to a block.
172
+ ///
173
+ /// Settings normally travel with the `@NativeTask` declaration, so nothing at
174
+ /// a call site needs to know a function is native. This is the escape hatch
175
+ /// for the times one caller wants something different — it changes no
176
+ /// signatures, which is what keeps the same source compiling both natively and
177
+ /// under dart2js.
178
+ ///
179
+ /// ```dart
180
+ /// await NativeCall.scope(worker: false, () async => probe(30));
181
+ /// ```
182
+ class NativeCall {
183
+ const NativeCall._();
184
+
185
+ static const _key = #renderDartNativeCall;
186
+
187
+ /// Runs [body] with these settings applied to every native call inside it,
188
+ /// including nested ones.
189
+ static Future<T> scope<T>(
190
+ Future<T> Function() body, {
191
+ bool? worker,
192
+ Duration? idleTimeout,
193
+ Duration? timeout,
194
+ }) {
195
+ final outer = Zone.current[_key] as _NativeOverrides?;
196
+ return runZoned(
197
+ body,
198
+ zoneValues: {
199
+ // An inner scope refines the outer one rather than replacing it.
200
+ _key: _NativeOverrides(
201
+ worker: worker ?? outer?.worker,
202
+ idleTimeoutMs: idleTimeout?.inMilliseconds ?? outer?.idleTimeoutMs,
203
+ timeoutMs: timeout?.inMilliseconds ?? outer?.timeoutMs,
204
+ ),
205
+ },
206
+ );
207
+ }
208
+ }
209
+
210
+ class _NativeOverrides {
211
+ const _NativeOverrides({this.worker, this.idleTimeoutMs, this.timeoutMs});
212
+
213
+ final bool? worker;
214
+ final int? idleTimeoutMs;
215
+ final int? timeoutMs;
216
+ }
217
+
218
+ Future<Object?> callNativeTask(
219
+ String binary,
220
+ String method,
221
+ List<Object?> args, [
222
+ Map<String, Object?> named = const {},
223
+ bool worker = false,
224
+ int idleTimeoutMs = 30000,
225
+ int timeoutMs = 0,
226
+ ]) async {
227
+ // A surrounding NativeCall.scope wins over what the declaration asked for.
228
+ final overrides = Zone.current[NativeCall._key] as _NativeOverrides?;
229
+ final useWorker = overrides?.worker ?? worker;
230
+ final idle = overrides?.idleTimeoutMs ?? idleTimeoutMs;
231
+ final limit = overrides?.timeoutMs ?? timeoutMs;
232
+ final request = jsonEncode({
233
+ 'id': 1,
234
+ 'method': method,
235
+ 'args': args,
236
+ 'named': named,
237
+ });
238
+
239
+ // Both paths speak the same JSONL, so the reply handling below is shared.
240
+ // A worker keeps the executable alive and assigns its own ids, since it can
241
+ // have several calls in flight; a one-shot spawn writes a line and reads the
242
+ // answer back.
243
+ final List<String> lines;
244
+ ProcessResult? result;
245
+
246
+ if (useWorker) {
247
+ final options = JSObject()
248
+ ..['idleTimeoutMs'] = idle.toJS
249
+ ..['timeoutMs'] = limit.toJS;
250
+ final replies =
251
+ await _nativeCall('build/native/$binary', request, options).toDart;
252
+ lines = replies.toDart.map((line) => line.toDart).toList();
253
+ } else {
254
+ result = await runProcess(
255
+ 'build/native/$binary',
256
+ stdin: '$request\n',
257
+ timeout: limit > 0 ? Duration(milliseconds: limit) : null,
258
+ );
259
+ lines = const LineSplitter().convert(result.stdout);
260
+ }
261
+
262
+ for (final line in lines) {
263
+ if (line.trim().isEmpty) continue;
264
+
265
+ final Map<String, Object?> message;
266
+ try {
267
+ message = (jsonDecode(line) as Map).cast<String, Object?>();
268
+ } catch (_) {
269
+ // Anything that is not JSON came from the program writing to stdout
270
+ // directly, which the generated wrapper avoids. Surface it rather than
271
+ // failing on a parse error nobody can act on.
272
+ print('[native $binary] $line');
273
+ continue;
274
+ }
275
+
276
+ if (message.containsKey(r'$log')) {
277
+ print('[native $binary] ${message[r'$log']}');
278
+ continue;
279
+ }
280
+ if (message.containsKey(r'$err')) {
281
+ throw NativeTaskException(
282
+ message[r'$err'] as String,
283
+ nativeStackTrace: message[r'$stack'] as String?,
284
+ binary: binary,
285
+ method: method,
286
+ );
287
+ }
288
+ if (message.containsKey(r'$ok')) return message[r'$ok'];
289
+ }
290
+
291
+ // No reply at all: the process died before it could answer. A worker
292
+ // rejects on its own with the child's exit status, so this only covers the
293
+ // one-shot path.
294
+ throw NativeTaskException(
295
+ result == null || result.exitCode == 0
296
+ ? 'no response from build/native/$binary'
297
+ : 'build/native/$binary exited ${result.exitCode}'
298
+ '${result.stderr.trim().isEmpty ? '' : ': ${result.stderr.trim()}'}',
299
+ binary: binary,
300
+ method: method,
301
+ );
302
+ }
303
+
304
+ /// How Render should retry a failing task.
305
+ class Retry {
306
+ const Retry({required this.maxRetries, this.waitDurationMs, this.backoffScaling});
307
+
308
+ final int maxRetries;
309
+ final int? waitDurationMs;
310
+ final double? backoffScaling;
311
+ }
312
+
313
+ /// Instance size a task runs on. Larger tiers need access from Render.
314
+ enum TaskPlan { starter, standard, pro, proPlus, proMax, proUltra }
315
+
316
+ const _planWire = {
317
+ TaskPlan.starter: 'starter',
318
+ TaskPlan.standard: 'standard',
319
+ TaskPlan.pro: 'pro',
320
+ TaskPlan.proPlus: 'pro_plus',
321
+ TaskPlan.proMax: 'pro_max',
322
+ TaskPlan.proUltra: 'pro_ultra',
323
+ };
324
+
325
+ /// Registers [body] as a Render task named [name].
326
+ ///
327
+ /// Call this at top level, before [start]. Arguments arrive as a positional
328
+ /// list matching whatever the caller passed; the return value must be
329
+ /// JSON-serialisable, and Render caps input at 4 MB.
330
+ ///
331
+ /// [timeoutSeconds] accepts 30–86,400 and defaults to two hours.
332
+ void task(
333
+ String name,
334
+ Future<Object?> Function(List<Object?> args) body, {
335
+ Retry? retry,
336
+ int? timeoutSeconds,
337
+ TaskPlan? plan,
338
+ }) {
339
+ if (timeoutSeconds != null &&
340
+ (timeoutSeconds < 30 || timeoutSeconds > 86400)) {
341
+ throw ArgumentError.value(
342
+ timeoutSeconds,
343
+ 'timeoutSeconds',
344
+ 'Render allows 30 to 86,400 seconds.',
345
+ );
346
+ }
347
+
348
+ JSPromise<JSAny?> wrapper(JSArray args) => _guard(() async {
349
+ final dartArgs = args.toDart.map(_toDart).toList();
350
+ return _toJs(await body(dartArgs));
351
+ });
352
+
353
+ _registerTask(name, wrapper.toJS, _options(retry, timeoutSeconds, plan));
354
+ }
355
+
356
+ /// Runs [name] as a child task and waits for its result.
357
+ ///
358
+ /// Each call becomes a separate task run on its own Render instance, so this
359
+ /// is how you fan work out. Safe to call after an `await`.
360
+ Future<Object?> callTask(String name, List<Object?> args) async {
361
+ final result = await _callTask(name, args.map(_toJs).toList().toJS).toDart;
362
+ return _toDart(result);
363
+ }
364
+
365
+ /// Starts the task server. Call once, at the end of `main`.
366
+ void start() => _start();
367
+
368
+ JSObject? _options(Retry? retry, int? timeoutSeconds, TaskPlan? plan) {
369
+ if (retry == null && timeoutSeconds == null && plan == null) return null;
370
+
371
+ final options = JSObject();
372
+ if (retry != null) {
373
+ final r = JSObject();
374
+ r['maxRetries'] = retry.maxRetries.toJS;
375
+ if (retry.waitDurationMs != null) {
376
+ r['waitDurationMs'] = retry.waitDurationMs!.toJS;
377
+ }
378
+ if (retry.backoffScaling != null) {
379
+ r['backoffScaling'] = retry.backoffScaling!.toJS;
380
+ }
381
+ options['retry'] = r;
382
+ }
383
+ if (timeoutSeconds != null) options['timeoutSeconds'] = timeoutSeconds.toJS;
384
+ if (plan != null) options['plan'] = _planWire[plan]!.toJS;
385
+ return options;
386
+ }
387
+
388
+ /// Dart must never throw across the JS boundary.
389
+ ///
390
+ /// A Dart exception converted by `Future.toJS` reaches Render as the opaque
391
+ /// "Dart exception thrown from converted Future...", with the real message
392
+ /// boxed where the SDK's `error.message` cannot see it. Returning an envelope
393
+ /// lets the JS side rethrow a genuine Error carrying the real text.
394
+ JSPromise<JSAny?> _guard(Future<JSAny?> Function() body) {
395
+ Future<JSAny?> wrapped() async {
396
+ final out = JSObject();
397
+ try {
398
+ out[r'$ok'] = await body();
399
+ } catch (e, stackTrace) {
400
+ out[r'$err'] = '$e\n$stackTrace'.toJS;
401
+ }
402
+ return out;
403
+ }
404
+
405
+ return wrapped().toJS;
406
+ }
407
+
408
+ JSAny? _toJs(Object? value) => switch (value) {
409
+ null => null,
410
+ final String v => v.toJS,
411
+ final bool v => v.toJS,
412
+ final int v => v.toJS,
413
+ final double v => v.toJS,
414
+ final List<Object?> v => v.map(_toJs).toList().toJS,
415
+ final Map<String, Object?> v => (() {
416
+ final o = JSObject();
417
+ v.forEach((key, val) => o[key] = _toJs(val));
418
+ return o;
419
+ })(),
420
+ _ => throw ArgumentError.value(
421
+ value,
422
+ 'value',
423
+ 'Task arguments and results must be JSON-serialisable. '
424
+ '${value.runtimeType} is not.',
425
+ ),
426
+ };
427
+
428
+ Object? _toDart(JSAny? value) => value?.dartify();