@gjsify/rolldown-native 0.4.0 → 0.4.3

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.
@@ -0,0 +1,437 @@
1
+ /*
2
+ * GjsifyRolldown — Vala wrapper around the Rust rolldown cdylib.
3
+ *
4
+ * Mirrors the @gjsify/lightningcss-native pattern. The real bundler
5
+ * lives in src/rust/ (compiled by meson via cargo to libgjsify_rolldown.so);
6
+ * a tiny C glue file translates the Rust result struct into GBytes +
7
+ * GError; this Vala layer just exposes a GObject method emitting to
8
+ * GIR/typelib so JS can do:
9
+ *
10
+ * import GjsifyRolldown from "gi://GjsifyRolldown?version=1.0";
11
+ * const bundler = new GjsifyRolldown.Bundler();
12
+ * const optsBytes = GLib.Bytes.new(new TextEncoder().encode(JSON.stringify({...})));
13
+ * const out = bundler.bundle(optsBytes);
14
+ * const result = JSON.parse(new TextDecoder().decode(out.get_data()));
15
+ *
16
+ * POC scope: no JS plugins, no watch mode. Each call constructs a
17
+ * fresh rolldown Bundler and runs generate() on a current-thread
18
+ * tokio runtime — sync from the JS view, no thread leaks on exit.
19
+ */
20
+
21
+ namespace GjsifyRolldown {
22
+
23
+ [CCode (cname = "gjsify_rolldown_glue_bundle",
24
+ cheader_filename = "gjsify-rolldown-glue.h")]
25
+ private extern GLib.Bytes? _glue_bundle (GLib.Bytes options_json) throws GLib.Error;
26
+
27
+ /**
28
+ * Bundler — stateless one-shot bundle pipeline.
29
+ *
30
+ * Each `bundle()` call constructs a fresh `rolldown::Bundler` on
31
+ * the Rust side, drives `Bundler::generate()` to completion on a
32
+ * per-call current-thread tokio runtime, and returns the chunk +
33
+ * asset list as JSON (matching rolldown's serde shape).
34
+ */
35
+ public class Bundler : GLib.Object {
36
+
37
+ /**
38
+ * bundle:
39
+ * @options_json: UTF-8 JSON document matching rolldown's
40
+ * BundlerOptions serde shape (camelCase).
41
+ *
42
+ * Returns: (transfer full): output JSON as GLib.Bytes. The JSON
43
+ * matches the BundleOutputJson shape declared in
44
+ * src/rust/src/lib.rs: `{ warnings: string[], output:
45
+ * (Chunk|Asset)[] }`.
46
+ *
47
+ * Throws GjsifyRolldownError.FAILED on any pipeline error.
48
+ */
49
+ public GLib.Bytes bundle (GLib.Bytes options_json) throws GLib.Error {
50
+ var bytes = _glue_bundle (options_json);
51
+ if (bytes == null)
52
+ throw new GLib.Error (GLib.Quark.from_string ("gjsify-rolldown-error-quark"),
53
+ 0, "rolldown: unknown error (NULL result without GError)");
54
+ return bytes;
55
+ }
56
+ }
57
+
58
+ /* ------------------------------------------------------------- */
59
+ /* Phase B.1+: plugin-callback bridge. */
60
+ /* ------------------------------------------------------------- */
61
+
62
+ [Compact]
63
+ [CCode (cname = "BundleSession", free_function = "gjsify_rolldown_session_free", has_type_id = false)]
64
+ private class BundleSessionHandle { }
65
+
66
+ [CCode (cname = "gjsify_rolldown_glue_session_start",
67
+ cheader_filename = "gjsify-rolldown-glue.h")]
68
+ private extern BundleSessionHandle? _glue_session_start (GLib.Bytes args_json) throws GLib.Error;
69
+
70
+ [CCode (cname = "gjsify_rolldown_session_request_fd",
71
+ cheader_filename = "gjsify-rolldown.h")]
72
+ private extern int _session_request_fd (BundleSessionHandle session);
73
+
74
+ [CCode (cname = "gjsify_rolldown_session_complete_fd",
75
+ cheader_filename = "gjsify-rolldown.h")]
76
+ private extern int _session_complete_fd (BundleSessionHandle session);
77
+
78
+ [CCode (cname = "gjsify_rolldown_glue_session_next_request",
79
+ cheader_filename = "gjsify-rolldown-glue.h")]
80
+ private extern GLib.Bytes? _glue_session_next_request (BundleSessionHandle session);
81
+
82
+ [CCode (cname = "gjsify_rolldown_glue_session_respond",
83
+ cheader_filename = "gjsify-rolldown-glue.h")]
84
+ private extern bool _glue_session_respond (BundleSessionHandle session,
85
+ uint64 req_id,
86
+ GLib.Bytes response_json);
87
+
88
+ [CCode (cname = "gjsify_rolldown_glue_session_try_result",
89
+ cheader_filename = "gjsify-rolldown-glue.h")]
90
+ private extern GLib.Bytes? _glue_session_try_result (BundleSessionHandle session) throws GLib.Error;
91
+
92
+ [CCode (cname = "gjsify_rolldown_session_cancel",
93
+ cheader_filename = "gjsify-rolldown.h")]
94
+ private extern void _session_cancel (BundleSessionHandle session);
95
+
96
+ /* Phase B.3 — nested-protocol externs. */
97
+
98
+ [CCode (cname = "gjsify_rolldown_glue_session_context_resolve",
99
+ cheader_filename = "gjsify-rolldown-glue.h")]
100
+ private extern uint64 _glue_session_context_resolve (BundleSessionHandle session,
101
+ uint64 parent_req_id,
102
+ GLib.Bytes args_json);
103
+
104
+ [CCode (cname = "gjsify_rolldown_glue_session_context_warn",
105
+ cheader_filename = "gjsify-rolldown-glue.h")]
106
+ private extern void _glue_session_context_warn (BundleSessionHandle session,
107
+ GLib.Bytes message);
108
+
109
+ [CCode (cname = "gjsify_rolldown_session_context_response_fd",
110
+ cheader_filename = "gjsify-rolldown.h")]
111
+ private extern int _session_context_response_fd (BundleSessionHandle session);
112
+
113
+ [CCode (cname = "gjsify_rolldown_glue_session_next_context_response",
114
+ cheader_filename = "gjsify-rolldown-glue.h")]
115
+ private extern GLib.Bytes? _glue_session_next_context_response (BundleSessionHandle session);
116
+
117
+ /* Phase B.4 — bytes-payload side-channel externs. */
118
+
119
+ [CCode (cname = "gjsify_rolldown_glue_session_take_request_payload",
120
+ cheader_filename = "gjsify-rolldown-glue.h")]
121
+ private extern GLib.Bytes? _glue_session_take_request_payload (BundleSessionHandle session,
122
+ uint64 req_id);
123
+
124
+ [CCode (cname = "gjsify_rolldown_glue_session_set_response_payload",
125
+ cheader_filename = "gjsify-rolldown-glue.h")]
126
+ private extern bool _glue_session_set_response_payload (BundleSessionHandle session,
127
+ uint64 req_id,
128
+ GLib.Bytes bytes);
129
+
130
+ /**
131
+ * BundlerSession — long-lived bundle session that drives a
132
+ * rolldown build asynchronously, emitting a GObject signal
133
+ * each time a JS plugin hook needs to fire.
134
+ *
135
+ * Typical JS-side usage:
136
+ *
137
+ * const session = new GjsifyRolldown.BundlerSession();
138
+ * session.connect('load_requested', (_, reqId, idx, args) => {
139
+ * const result = userPlugins[idx].load(decode(args).id);
140
+ * session.respond(reqId, encode(JSON.stringify(...)));
141
+ * });
142
+ * session.connect('completed', (_, output) => resolvePromise(output));
143
+ * session.connect('error_occurred', (_, msg) => rejectPromise(msg));
144
+ * session.start(argsJsonBytes);
145
+ */
146
+ public class BundlerSession : GLib.Object {
147
+ private BundleSessionHandle? _handle = null;
148
+ private uint _request_source_id = 0;
149
+ private uint _complete_source_id = 0;
150
+ private uint _ctx_response_source_id = 0;
151
+
152
+ /**
153
+ * Emitted whenever rolldown invokes a plugin hook.
154
+ *
155
+ * @hook_name: one of `load`, `transform`, `resolveId`,
156
+ * `renderChunk`, `banner`, `footer`, `intro`,
157
+ * `outro`, `buildStart`, `buildEnd`,
158
+ * `generateBundle`, `writeBundle`, `closeBundle`
159
+ * @req_id: opaque ID; pass back to `respond()` exactly once
160
+ * @plugin_index: position in the user's `plugins[]` array
161
+ * that originally registered this hook
162
+ * @args_json: full request envelope as JSON. Shape depends on
163
+ * the hook — see `HookRequestPayload` in
164
+ * `src/rust/src/plugin_proxy.rs`.
165
+ *
166
+ * JS handler MUST eventually call `respond(req_id, json)`
167
+ * exactly once with one of:
168
+ * `{"kind":"skip"}` — chain to next plugin
169
+ * `{"kind":"ok", "value": ...}` — handler succeeded
170
+ * `{"kind":"error", "message": "...", "stack": "..."}`
171
+ *
172
+ * Failure to respond within 60s aborts the build with a
173
+ * timeout error.
174
+ */
175
+ public signal void hook_requested (string hook_name,
176
+ uint64 req_id,
177
+ uint plugin_index,
178
+ GLib.Bytes args_json);
179
+
180
+ /** Emitted when the bundle completes successfully. */
181
+ public signal void completed (GLib.Bytes output_json);
182
+
183
+ /** Emitted on any pipeline failure. */
184
+ public signal void error_occurred (string message);
185
+
186
+ /**
187
+ * Phase B.3 — emitted whenever a context-resolve sub-result
188
+ * (initiated via `context_resolve()`) is ready. JS handler
189
+ * matches `child_id` against the value previously returned
190
+ * from `context_resolve()`, parses `response_json` as
191
+ * `{childId, id?, external?, error?}`.
192
+ */
193
+ public signal void context_response (uint64 child_id, GLib.Bytes response_json);
194
+
195
+ /**
196
+ * Start the bundle session. @args_json must be a UTF-8 JSON
197
+ * document of shape `{"options": <BundlerOptions>, "plugins":
198
+ * [{"name": "...", "hooks": ["load", ...]}]}`.
199
+ */
200
+ public void start (GLib.Bytes args_json) throws GLib.Error {
201
+ if (_handle != null)
202
+ throw new GLib.Error (GLib.Quark.from_string ("gjsify-rolldown-error-quark"),
203
+ 0, "rolldown: BundlerSession.start() called twice");
204
+
205
+ _handle = _glue_session_start (args_json);
206
+ if (_handle == null)
207
+ throw new GLib.Error (GLib.Quark.from_string ("gjsify-rolldown-error-quark"),
208
+ 0, "rolldown: session_start returned NULL without GError");
209
+
210
+ int req_fd = _session_request_fd (_handle);
211
+ int comp_fd = _session_complete_fd (_handle);
212
+
213
+ // Watch the eventfds on the GLib main loop. add_full hands
214
+ // the source priority + condition; we own the fd lifetime
215
+ // via Rust, so don't pass close_fd:true.
216
+ var req_chan = new GLib.IOChannel.unix_new (req_fd);
217
+ req_chan.set_close_on_unref (false);
218
+ req_chan.set_encoding (null);
219
+ req_chan.set_buffered (false);
220
+ _request_source_id = req_chan.add_watch (GLib.IOCondition.IN, on_request_ready);
221
+
222
+ var comp_chan = new GLib.IOChannel.unix_new (comp_fd);
223
+ comp_chan.set_close_on_unref (false);
224
+ comp_chan.set_encoding (null);
225
+ comp_chan.set_buffered (false);
226
+ _complete_source_id = comp_chan.add_watch (GLib.IOCondition.IN, on_complete_ready);
227
+
228
+ int ctx_fd = _session_context_response_fd (_handle);
229
+ var ctx_chan = new GLib.IOChannel.unix_new (ctx_fd);
230
+ ctx_chan.set_close_on_unref (false);
231
+ ctx_chan.set_encoding (null);
232
+ ctx_chan.set_buffered (false);
233
+ _ctx_response_source_id = ctx_chan.add_watch (GLib.IOCondition.IN, on_ctx_response_ready);
234
+ }
235
+
236
+ private bool on_ctx_response_ready (GLib.IOChannel source, GLib.IOCondition cond) {
237
+ char[] sink = new char[8];
238
+ try {
239
+ size_t got;
240
+ source.read_chars (sink, out got);
241
+ } catch (Error e) {
242
+ // ignore — eventfd had nothing left
243
+ }
244
+
245
+ while (_handle != null) {
246
+ var resp_bytes = _glue_session_next_context_response (_handle);
247
+ if (resp_bytes == null) break;
248
+
249
+ // Peek at childId so the C signal handler can route by it
250
+ // without re-parsing the JSON in JS.
251
+ unowned uint8[]? data = resp_bytes.get_data ();
252
+ ssize_t len = (ssize_t) resp_bytes.get_size ();
253
+ uint64 child_id = 0;
254
+ try {
255
+ var parser = new Json.Parser ();
256
+ parser.load_from_data ((string) data, len);
257
+ child_id = (uint64) parser.get_root ().get_object ().get_int_member ("childId");
258
+ } catch (Error e) {
259
+ error_occurred ("rolldown: malformed context_response from Rust: %s".printf (e.message));
260
+ continue;
261
+ }
262
+ context_response (child_id, resp_bytes);
263
+ }
264
+ return true;
265
+ }
266
+
267
+ private bool on_request_ready (GLib.IOChannel source, GLib.IOCondition cond) {
268
+ // Drain the eventfd counter (best-effort; ignore errors).
269
+ // Drain via posix read() — IOChannel.read_chars wants
270
+ // `char[]` and Vala can't auto-convert from `uint8[]`.
271
+ // The eventfd content is an opaque 8-byte counter; we
272
+ // discard it (just need the wake-up).
273
+ char[] sink = new char[8];
274
+ try {
275
+ size_t got;
276
+ source.read_chars (sink, out got);
277
+ } catch (Error e) {
278
+ // EAGAIN-equivalent — eventfd had nothing left, fine.
279
+ }
280
+
281
+ // Pull all queued requests in this wake-up cycle.
282
+ while (_handle != null) {
283
+ var req_bytes = _glue_session_next_request (_handle);
284
+ if (req_bytes == null) break;
285
+ dispatch_request (req_bytes);
286
+ }
287
+ return true; // keep source alive
288
+ }
289
+
290
+ private void dispatch_request (GLib.Bytes req_bytes) {
291
+ // Parse just enough to route. We don't want to fully
292
+ // deserialize on the Vala side — JS does that. But we
293
+ // need to peek at "hook" and "reqId" + "pluginIndex" to
294
+ // emit the right signal.
295
+ // GBytes is NOT NUL-terminated; pass the explicit length
296
+ // to Json.Parser so it doesn't read past the buffer.
297
+ unowned uint8[]? data = req_bytes.get_data ();
298
+ ssize_t len = (ssize_t) req_bytes.get_size ();
299
+ uint64 req_id = 0;
300
+ uint plugin_index = 0;
301
+ string hook = "";
302
+ try {
303
+ var parser = new Json.Parser ();
304
+ parser.load_from_data ((string) data, len);
305
+ var root = parser.get_root ().get_object ();
306
+ req_id = (uint64) root.get_int_member ("reqId");
307
+ plugin_index = (uint) root.get_int_member ("pluginIndex");
308
+ hook = root.get_string_member ("hook");
309
+ } catch (Error e) {
310
+ error_occurred ("rolldown: malformed request from Rust: %s".printf (e.message));
311
+ return;
312
+ }
313
+
314
+ // Generic dispatch — JS-side adapter routes by hook name.
315
+ // Validates that hook is one of the known names; unknown
316
+ // names indicate a Rust/Vala desync and are surfaced as
317
+ // build errors rather than silently accepted.
318
+ switch (hook) {
319
+ case "load":
320
+ case "transform":
321
+ case "resolveId":
322
+ case "renderChunk":
323
+ case "banner":
324
+ case "footer":
325
+ case "intro":
326
+ case "outro":
327
+ case "buildStart":
328
+ case "buildEnd":
329
+ case "generateBundle":
330
+ case "writeBundle":
331
+ case "closeBundle":
332
+ hook_requested (hook, req_id, plugin_index, req_bytes);
333
+ break;
334
+ default:
335
+ error_occurred ("rolldown: unknown hook '%s' from Rust side".printf (hook));
336
+ break;
337
+ }
338
+ }
339
+
340
+ private bool on_complete_ready (GLib.IOChannel source, GLib.IOCondition cond) {
341
+ char[] sink = new char[8];
342
+ try {
343
+ size_t got;
344
+ source.read_chars (sink, out got);
345
+ } catch (Error e) {
346
+ // ignore
347
+ }
348
+
349
+ if (_handle == null) return false;
350
+ try {
351
+ var bytes = _glue_session_try_result (_handle);
352
+ if (bytes != null) {
353
+ completed (bytes);
354
+ teardown_sources ();
355
+ return false;
356
+ }
357
+ } catch (Error e) {
358
+ error_occurred (e.message);
359
+ teardown_sources ();
360
+ return false;
361
+ }
362
+ return true;
363
+ }
364
+
365
+ private void teardown_sources () {
366
+ if (_request_source_id != 0) {
367
+ GLib.Source.remove (_request_source_id);
368
+ _request_source_id = 0;
369
+ }
370
+ if (_complete_source_id != 0) {
371
+ GLib.Source.remove (_complete_source_id);
372
+ _complete_source_id = 0;
373
+ }
374
+ if (_ctx_response_source_id != 0) {
375
+ GLib.Source.remove (_ctx_response_source_id);
376
+ _ctx_response_source_id = 0;
377
+ }
378
+ }
379
+
380
+ public void respond (uint64 req_id, GLib.Bytes response_json) {
381
+ if (_handle == null) return;
382
+ _glue_session_respond (_handle, req_id, response_json);
383
+ }
384
+
385
+ /**
386
+ * Phase B.3 — trigger `ctx.resolve()` on behalf of the JS
387
+ * plugin handler currently running for `parent_req_id`.
388
+ * Returns a child request ID that JS uses to match the
389
+ * eventual `context_response(child_id, ...)` signal.
390
+ * Returns 0 if `parent_req_id` is unknown (parent already
391
+ * completed, or JS called outside a handler).
392
+ */
393
+ public uint64 context_resolve (uint64 parent_req_id, GLib.Bytes args_json) {
394
+ if (_handle == null) return 0;
395
+ return _glue_session_context_resolve (_handle, parent_req_id, args_json);
396
+ }
397
+
398
+ /** Phase B.3 — append a `this.warn(msg)` string to the build's
399
+ * warnings list. */
400
+ public void context_warn (GLib.Bytes message) {
401
+ if (_handle == null) return;
402
+ _glue_session_context_warn (_handle, message);
403
+ }
404
+
405
+ /**
406
+ * Phase B.4 — drain the request-payload bytes Rust stashed for
407
+ * `req_id` (currently only the transform hook's source code).
408
+ * Returns null when there's no stashed payload (e.g. the hook
409
+ * is not transform, or the slot was already taken).
410
+ */
411
+ public GLib.Bytes? take_request_payload (uint64 req_id) {
412
+ if (_handle == null) return null;
413
+ return _glue_session_take_request_payload (_handle, req_id);
414
+ }
415
+
416
+ /**
417
+ * Phase B.4 — stash bytes for Rust to consume after `respond()`.
418
+ * Used by the transform hook to return the new code without
419
+ * round-tripping through a JSON-encoded string.
420
+ */
421
+ public bool set_response_payload (uint64 req_id, GLib.Bytes bytes) {
422
+ if (_handle == null) return false;
423
+ return _glue_session_set_response_payload (_handle, req_id, bytes);
424
+ }
425
+
426
+ public void cancel () {
427
+ if (_handle != null) _session_cancel (_handle);
428
+ }
429
+
430
+ public override void dispose () {
431
+ teardown_sources ();
432
+ cancel ();
433
+ _handle = null; // Compact class auto-frees via free_function
434
+ base.dispose ();
435
+ }
436
+ }
437
+ }