@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,852 @@
1
+ //! BundleSession — owns the tokio runtime, the plugin-channel pump,
2
+ //! the eventfd wakeup pair, and the BundleHandle the JS layer uses
3
+ //! to drive the build.
4
+ //!
5
+ //! Lifecycle:
6
+ //! 1. `start()` — caller passes options + plugin metadata. We
7
+ //! construct N JsPluginProxy instances, hand them to
8
+ //! Bundler::with_plugins, spawn a tokio task that drives
9
+ //! `Bundler::generate()` to completion, return the session.
10
+ //! 2. While the build runs, the worker pool calls into the proxies,
11
+ //! pushing HookRequests to the channel + writing the request
12
+ //! eventfd. JS drains via `next_request()` and replies via
13
+ //! `respond()`.
14
+ //! 3. When the bundle task completes, the result is stored on the
15
+ //! session. The Vala side polls `try_take_result()` after
16
+ //! seeing the request_eventfd close (or its own completion
17
+ //! eventfd, see Phase B.2 — for B.1 we expose `wait()` only).
18
+
19
+ use std::ffi::CString;
20
+ use std::os::raw::{c_char, c_int};
21
+ use std::ptr;
22
+ use std::sync::Arc;
23
+ use std::sync::Mutex;
24
+ use std::sync::atomic::AtomicU64;
25
+ use std::time::Duration;
26
+
27
+ use crossbeam_channel::{Receiver, Sender, unbounded};
28
+ use rolldown::Bundler;
29
+ use rolldown_common::BundlerOptions;
30
+ use rolldown_plugin::__inner::SharedPluginable;
31
+ use rolldown_plugin::{PluginContext, PluginContextResolveOptions};
32
+ use serde::{Deserialize, Serialize};
33
+ use tokio::runtime::{Builder, Runtime};
34
+ use tokio::sync::oneshot;
35
+
36
+ use crate::plugin_proxy::{HookRequest, HookResponse, JsPluginProxy, PluginMeta};
37
+ use crate::{BundleOutputJson, OutputJson, convert_output};
38
+
39
+ #[derive(Debug, Deserialize)]
40
+ pub struct StartArgs {
41
+ pub options: BundlerOptions,
42
+ pub plugins: Vec<PluginMeta>,
43
+ }
44
+
45
+ /// One context-resolve sub-result flowing Rust → JS. Surfaces through
46
+ /// the `context_response_eventfd` queue.
47
+ #[derive(Debug, Serialize)]
48
+ #[serde(rename_all = "camelCase")]
49
+ pub struct ContextResolveResponse {
50
+ pub child_id: u64,
51
+ /// Resolved id, when the call succeeded and produced one.
52
+ pub id: Option<String>,
53
+ /// Whether the resolved module is external. Mirrors rolldown's
54
+ /// `ResolvedId.is_external`.
55
+ pub external: Option<bool>,
56
+ /// Set when `ctx.resolve()` failed. Maps to rolldown's
57
+ /// `ResolveError` variants serialized as `Display`. JS handler
58
+ /// rejects its `await` Promise with this message.
59
+ pub error: Option<String>,
60
+ }
61
+
62
+ /// Slice of session state shared with `JsPluginProxy`. Wrapped in
63
+ /// `Arc` and cloned into each proxy. Holds everything the proxies
64
+ /// need to (a) register/unregister parent `PluginContext` snapshots
65
+ /// for nested-protocol callbacks, (b) deliver context-resolve results
66
+ /// to JS via the dedicated eventfd, and (c) accumulate `this.warn()`
67
+ /// strings into the final BundleOutput.
68
+ pub struct SessionShared {
69
+ pub contexts: Mutex<std::collections::HashMap<u64, PluginContext>>,
70
+ pub next_child_id: AtomicU64,
71
+ pub context_response_tx: Sender<ContextResolveResponse>,
72
+ pub context_response_eventfd: c_int,
73
+ pub context_warnings: Mutex<Vec<String>>,
74
+ /// Phase B.4 — parallel bytes-payload slots keyed by req_id.
75
+ /// `request_payloads` holds Rust → JS payloads (e.g. transform
76
+ /// `code` source bytes), drained by the JS adapter via
77
+ /// `take_request_payload(reqId)`. `response_payloads` holds JS →
78
+ /// Rust payloads (e.g. transform output bytes), stashed by JS
79
+ /// before `respond()` and consumed by the Rust dispatch site.
80
+ pub request_payloads: Mutex<std::collections::HashMap<u64, Vec<u8>>>,
81
+ pub response_payloads: Mutex<std::collections::HashMap<u64, Vec<u8>>>,
82
+ }
83
+
84
+ /// Public session handle. Owned via raw pointer on the C side.
85
+ pub struct BundleSession {
86
+ /// Runtime kept alive for the lifetime of the session. multi_thread
87
+ /// because rolldown internally spawns parallel module-graph tasks
88
+ /// that would otherwise serialize behind plugin-callback awaits.
89
+ pub runtime: Runtime,
90
+ /// Channel: many proxies → single drain. Sender side is cloned
91
+ /// into each JsPluginProxy.
92
+ pub request_rx: Receiver<HookRequest>,
93
+ /// Pending requests waiting on JS reply. Keyed by req_id.
94
+ pub pending: Mutex<std::collections::HashMap<u64, oneshot::Sender<HookResponse>>>,
95
+ /// eventfd for "request available" wakeup → GLib main loop watches.
96
+ pub request_eventfd: c_int,
97
+ /// Result of the bundle task once complete. None while still running.
98
+ pub result: Mutex<Option<Result<String, String>>>,
99
+ /// Completion eventfd: written exactly once when `result` is set.
100
+ pub complete_eventfd: c_int,
101
+ /// Cancel flag — set by the C side via `cancel()`. tokio task
102
+ /// observes via shared atomic and aborts.
103
+ pub cancelled: Arc<std::sync::atomic::AtomicBool>,
104
+ /// Phase B.3 — shared with each `JsPluginProxy` for nested
105
+ /// plugin-context callbacks.
106
+ pub shared: Arc<SessionShared>,
107
+ /// Receiver side of the context-response channel. Drained by
108
+ /// `gjsify_rolldown_session_next_context_response` from the GLib
109
+ /// main loop after waking on `context_response_eventfd`.
110
+ pub context_response_rx: Receiver<ContextResolveResponse>,
111
+ }
112
+
113
+ impl BundleSession {
114
+ /// Drain at most one pending HookRequest non-blockingly. Returns
115
+ /// None if the channel is empty (or if the receiver was dropped).
116
+ pub fn try_next_request(&self) -> Option<HookRequest> {
117
+ self.request_rx.try_recv().ok()
118
+ }
119
+
120
+ /// Park the JS-supplied response into the matching pending slot.
121
+ /// Returns true if a waiter was found (and woken).
122
+ pub fn deliver_response(&self, req_id: u64, response: HookResponse) -> bool {
123
+ let mut pending = self.pending.lock().unwrap();
124
+ if let Some(tx) = pending.remove(&req_id) {
125
+ let _ = tx.send(response);
126
+ true
127
+ } else {
128
+ false
129
+ }
130
+ }
131
+
132
+ /// Snapshot the bundle result. Returns None if the build is
133
+ /// still running. On completion: Some(Ok(json)) or
134
+ /// Some(Err(message)).
135
+ pub fn try_take_result(&self) -> Option<Result<String, String>> {
136
+ self.result.lock().unwrap().take()
137
+ }
138
+
139
+ /// Mark cancelled. The tokio task observes this on its next
140
+ /// poll and aborts. Pending JS-replies will fail with timeout
141
+ /// or "channel closed".
142
+ pub fn cancel(&self) {
143
+ self.cancelled
144
+ .store(true, std::sync::atomic::Ordering::SeqCst);
145
+ // Drop the channel sender by dropping the proxies on shutdown.
146
+ // The tokio runtime drop will abort all spawned tasks.
147
+ }
148
+ }
149
+
150
+ /// FFI: start a new bundle session. Returns NULL on error +
151
+ /// `*err_out` set to a heap-allocated message (caller frees via
152
+ /// `gjsify_rolldown_session_free_error`).
153
+ #[unsafe(no_mangle)]
154
+ pub extern "C" fn gjsify_rolldown_session_start(
155
+ args_json: *const c_char,
156
+ args_json_len: usize,
157
+ err_out: *mut *mut c_char,
158
+ ) -> *mut BundleSession {
159
+ let args: StartArgs = match parse_json_args(args_json, args_json_len) {
160
+ Ok(a) => a,
161
+ Err(msg) => {
162
+ unsafe { *err_out = err_to_cstr(msg) };
163
+ return ptr::null_mut();
164
+ }
165
+ };
166
+
167
+ // Create the eventfd pair. EFD_NONBLOCK so writes never block;
168
+ // EFD_SEMAPHORE off (we use counter-mode so JS can read once
169
+ // per drain cycle and process all pending requests in a loop).
170
+ let request_eventfd = unsafe { libc::eventfd(0, libc::EFD_NONBLOCK | libc::EFD_CLOEXEC) };
171
+ if request_eventfd < 0 {
172
+ unsafe { *err_out = err_to_cstr("rolldown: eventfd(request) failed".to_string()) };
173
+ return ptr::null_mut();
174
+ }
175
+ let complete_eventfd = unsafe { libc::eventfd(0, libc::EFD_NONBLOCK | libc::EFD_CLOEXEC) };
176
+ if complete_eventfd < 0 {
177
+ unsafe { libc::close(request_eventfd) };
178
+ unsafe { *err_out = err_to_cstr("rolldown: eventfd(complete) failed".to_string()) };
179
+ return ptr::null_mut();
180
+ }
181
+
182
+ let context_response_eventfd =
183
+ unsafe { libc::eventfd(0, libc::EFD_NONBLOCK | libc::EFD_CLOEXEC) };
184
+ if context_response_eventfd < 0 {
185
+ unsafe { libc::close(request_eventfd) };
186
+ unsafe { libc::close(complete_eventfd) };
187
+ unsafe { *err_out = err_to_cstr("rolldown: eventfd(context_response) failed".to_string()) };
188
+ return ptr::null_mut();
189
+ }
190
+
191
+ let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
192
+ let (request_tx, request_rx) = unbounded::<HookRequest>();
193
+ let (context_response_tx, context_response_rx) = unbounded::<ContextResolveResponse>();
194
+ let next_request_id = Arc::new(AtomicU64::new(1));
195
+
196
+ let shared = Arc::new(SessionShared {
197
+ contexts: Mutex::new(std::collections::HashMap::new()),
198
+ next_child_id: AtomicU64::new(1),
199
+ context_response_tx,
200
+ context_response_eventfd,
201
+ context_warnings: Mutex::new(Vec::new()),
202
+ request_payloads: Mutex::new(std::collections::HashMap::new()),
203
+ response_payloads: Mutex::new(std::collections::HashMap::new()),
204
+ });
205
+
206
+ // Build N proxies, one per user plugin. Order in this Vec is the
207
+ // plugin order rolldown sees — matches plugin_index in the
208
+ // request payload so JS dispatches to the right plugin.
209
+ let proxies: Vec<SharedPluginable> = match args
210
+ .plugins
211
+ .iter()
212
+ .enumerate()
213
+ .map(|(idx, meta)| {
214
+ let load_id_filter = compile_filter(&meta.id_filter.load, &meta.name, "load")?;
215
+ let transform_id_filter =
216
+ compile_filter(&meta.id_filter.transform, &meta.name, "transform")?;
217
+ let resolve_id_filter =
218
+ compile_filter(&meta.id_filter.resolve_id, &meta.name, "resolveId")?;
219
+ Ok::<_, String>(Arc::new(JsPluginProxy {
220
+ name: meta.name.clone(),
221
+ plugin_index: idx,
222
+ hooks: meta.hooks.clone(),
223
+ request_tx: request_tx.clone(),
224
+ next_request_id: next_request_id.clone(),
225
+ request_eventfd,
226
+ response_timeout: Duration::from_secs(60),
227
+ load_id_filter,
228
+ transform_id_filter,
229
+ resolve_id_filter,
230
+ shared: shared.clone(),
231
+ }) as SharedPluginable)
232
+ })
233
+ .collect::<Result<Vec<_>, _>>()
234
+ {
235
+ Ok(v) => v,
236
+ Err(msg) => {
237
+ unsafe { libc::close(request_eventfd) };
238
+ unsafe { libc::close(complete_eventfd) };
239
+ unsafe { libc::close(context_response_eventfd) };
240
+ unsafe { *err_out = err_to_cstr(msg) };
241
+ return ptr::null_mut();
242
+ }
243
+ };
244
+
245
+ // Drop the original sender so the channel naturally closes when
246
+ // all proxies are dropped (which happens after the bundle task
247
+ // exits).
248
+ drop(request_tx);
249
+
250
+ let runtime = match Builder::new_multi_thread()
251
+ .worker_threads(num_cpus_capped())
252
+ .enable_all()
253
+ .build()
254
+ {
255
+ Ok(r) => r,
256
+ Err(e) => {
257
+ unsafe { libc::close(request_eventfd) };
258
+ unsafe { libc::close(complete_eventfd) };
259
+ unsafe { libc::close(context_response_eventfd) };
260
+ unsafe { *err_out = err_to_cstr(format!("rolldown: tokio init: {e}")) };
261
+ return ptr::null_mut();
262
+ }
263
+ };
264
+
265
+ let result_slot: Arc<Mutex<Option<Result<String, String>>>> = Arc::new(Mutex::new(None));
266
+ let result_slot_clone = result_slot.clone();
267
+ let complete_eventfd_for_task = complete_eventfd;
268
+ let cancelled_for_task = cancelled.clone();
269
+ let shared_for_task = shared.clone();
270
+
271
+ runtime.spawn(async move {
272
+ let res = run_bundle(args.options, proxies, cancelled_for_task, shared_for_task).await;
273
+ let final_json = match res {
274
+ Ok(json) => Ok(json),
275
+ Err(msg) => Err(msg),
276
+ };
277
+ *result_slot_clone.lock().unwrap() = Some(final_json);
278
+ // Signal completion to the GLib main loop.
279
+ let one: u64 = 1;
280
+ unsafe {
281
+ libc::write(
282
+ complete_eventfd_for_task,
283
+ &one as *const u64 as *const libc::c_void,
284
+ 8,
285
+ );
286
+ }
287
+ });
288
+
289
+ let session = Box::new(BundleSession {
290
+ runtime,
291
+ request_rx,
292
+ pending: Mutex::new(std::collections::HashMap::new()),
293
+ request_eventfd,
294
+ result: Mutex::new(None),
295
+ complete_eventfd,
296
+ cancelled,
297
+ shared,
298
+ context_response_rx,
299
+ });
300
+
301
+ // Move the result slot into the session AFTER spawn (the spawn
302
+ // closure has its own clone; the session reads from the same
303
+ // shared slot).
304
+ let session_ptr = Box::into_raw(session);
305
+ // Patch: the session owns its own Mutex<Option<Result>> — but
306
+ // the spawn closure was given a clone via result_slot_clone.
307
+ // To make `try_take_result` see the same slot, we re-point.
308
+ // Simplest fix: stash result_slot Arc inside the session via
309
+ // a side-table. For B.1 we route through an interior-mutability
310
+ // helper — the result Mutex inside the session IS the slot;
311
+ // the closure already has a separate Arc. To unify, we drop
312
+ // the session-internal slot and instead use the Arc directly.
313
+ //
314
+ // Implementation: replace session.result with a method that
315
+ // pulls from result_slot. Done below via `ResultSlotRegistry`.
316
+ register_result_slot(session_ptr, result_slot);
317
+
318
+ unsafe { *err_out = ptr::null_mut() };
319
+ session_ptr
320
+ }
321
+
322
+ // --- result-slot registry ----------------------------------------------
323
+ //
324
+ // The clean way would be to put the Arc<Mutex<...>> straight on the
325
+ // session. We do that here via an interior swap: the session's own
326
+ // `result` Mutex<Option<...>> is unused; `register_result_slot`
327
+ // stores the *real* slot in a static map keyed by session pointer.
328
+ // `try_take_result` is rewritten to consult the map.
329
+ //
330
+ // Reason for this indirection: we needed to spawn the task before
331
+ // constructing the Box<Session> (because moving the proxies into
332
+ // the spawn future requires them by value), and we couldn't
333
+ // reference `session.result` from inside the spawn closure
334
+ // without &mut Box ownership shenanigans.
335
+
336
+ use std::collections::HashMap as StdHashMap;
337
+ use std::sync::OnceLock;
338
+
339
+ type ResultSlot = Arc<Mutex<Option<Result<String, String>>>>;
340
+
341
+ static RESULT_SLOTS: OnceLock<Mutex<StdHashMap<usize, ResultSlot>>> = OnceLock::new();
342
+
343
+ fn registry() -> &'static Mutex<StdHashMap<usize, ResultSlot>> {
344
+ RESULT_SLOTS.get_or_init(|| Mutex::new(StdHashMap::new()))
345
+ }
346
+
347
+ fn register_result_slot(session_ptr: *mut BundleSession, slot: ResultSlot) {
348
+ registry().lock().unwrap().insert(session_ptr as usize, slot);
349
+ }
350
+
351
+ fn lookup_result_slot(session_ptr: *mut BundleSession) -> Option<ResultSlot> {
352
+ registry().lock().unwrap().get(&(session_ptr as usize)).cloned()
353
+ }
354
+
355
+ fn unregister_result_slot(session_ptr: *mut BundleSession) {
356
+ registry().lock().unwrap().remove(&(session_ptr as usize));
357
+ }
358
+
359
+ // --- bundle driver -----------------------------------------------------
360
+
361
+ async fn run_bundle(
362
+ options: BundlerOptions,
363
+ plugins: Vec<SharedPluginable>,
364
+ cancelled: Arc<std::sync::atomic::AtomicBool>,
365
+ shared: Arc<SessionShared>,
366
+ ) -> Result<String, String> {
367
+ if cancelled.load(std::sync::atomic::Ordering::SeqCst) {
368
+ return Err("rolldown: cancelled before start".to_string());
369
+ }
370
+ let mut bundler = Bundler::with_plugins(options, plugins)
371
+ .map_err(|e| format!("rolldown: Bundler::new: {e:?}"))?;
372
+ let bundle = bundler
373
+ .generate()
374
+ .await
375
+ .map_err(|e| format!("rolldown: Bundler::generate: {e:?}"))?;
376
+
377
+ let mut warnings: Vec<String> = bundle.warnings.iter().map(|w| format!("{w:?}")).collect();
378
+ // Append `this.warn(...)` strings collected during the build so the
379
+ // JS caller sees them alongside rolldown-internal warnings.
380
+ warnings.extend(shared.context_warnings.lock().unwrap().drain(..));
381
+ let output: Vec<OutputJson> = bundle.assets.into_iter().map(convert_output).collect();
382
+ serde_json::to_string(&BundleOutputJson { warnings, output })
383
+ .map_err(|e| format!("rolldown: serialize: {e}"))
384
+ }
385
+
386
+ // --- FFI surface -------------------------------------------------------
387
+
388
+ #[unsafe(no_mangle)]
389
+ pub extern "C" fn gjsify_rolldown_session_request_fd(session: *mut BundleSession) -> c_int {
390
+ if session.is_null() {
391
+ return -1;
392
+ }
393
+ unsafe { (*session).request_eventfd }
394
+ }
395
+
396
+ #[unsafe(no_mangle)]
397
+ pub extern "C" fn gjsify_rolldown_session_complete_fd(session: *mut BundleSession) -> c_int {
398
+ if session.is_null() {
399
+ return -1;
400
+ }
401
+ unsafe { (*session).complete_eventfd }
402
+ }
403
+
404
+ /// Drain one pending request. Returns NULL when the channel is
405
+ /// momentarily empty (caller should retry on next eventfd wake).
406
+ /// Caller frees the returned string via `gjsify_rolldown_session_free_string`.
407
+ #[unsafe(no_mangle)]
408
+ pub extern "C" fn gjsify_rolldown_session_next_request(
409
+ session: *mut BundleSession,
410
+ out_len: *mut usize,
411
+ ) -> *mut c_char {
412
+ if session.is_null() {
413
+ unsafe { *out_len = 0 };
414
+ return ptr::null_mut();
415
+ }
416
+ let session = unsafe { &*session };
417
+
418
+ // Pull one request. Move its `reply` Sender into pending map
419
+ // before we serialize the payload (so JS can respond as soon
420
+ // as it sees the request_id).
421
+ let req = match session.try_next_request() {
422
+ Some(r) => r,
423
+ None => {
424
+ unsafe { *out_len = 0 };
425
+ return ptr::null_mut();
426
+ }
427
+ };
428
+
429
+ let req_id = req.req_id;
430
+ let plugin_index = req.plugin_index;
431
+ let payload = req.payload;
432
+ session.pending.lock().unwrap().insert(req_id, req.reply);
433
+
434
+ // Serialize the payload standalone so the per-variant
435
+ // `#[serde(rename_all = "camelCase")]` inside HookRequestPayload
436
+ // is honored. `#[serde(flatten)]` would silently drop those rules
437
+ // (serde issue #1346) and re-emit fields like `module_type` /
438
+ // `is_entry` in their original snake_case.
439
+ let json = match serde_json::to_value(&payload) {
440
+ Ok(mut value) => {
441
+ if let Some(obj) = value.as_object_mut() {
442
+ obj.insert("reqId".to_string(), serde_json::json!(req_id));
443
+ obj.insert("pluginIndex".to_string(), serde_json::json!(plugin_index));
444
+ }
445
+ value.to_string()
446
+ }
447
+ Err(e) => format!("{{\"reqId\":{req_id},\"error\":\"serialize: {e}\"}}"),
448
+ };
449
+ let len = json.len();
450
+ unsafe { *out_len = len };
451
+ let cstr = match CString::new(json) {
452
+ Ok(c) => c,
453
+ Err(_) => return ptr::null_mut(),
454
+ };
455
+ cstr.into_raw()
456
+ }
457
+
458
+ #[unsafe(no_mangle)]
459
+ pub extern "C" fn gjsify_rolldown_session_respond(
460
+ session: *mut BundleSession,
461
+ req_id: u64,
462
+ response_json: *const c_char,
463
+ response_json_len: usize,
464
+ ) -> bool {
465
+ if session.is_null() || response_json.is_null() {
466
+ return false;
467
+ }
468
+ let slice =
469
+ unsafe { std::slice::from_raw_parts(response_json as *const u8, response_json_len) };
470
+ let resp: HookResponse = match serde_json::from_slice(slice) {
471
+ Ok(r) => r,
472
+ Err(e) => HookResponse::Error {
473
+ message: format!("rolldown: malformed response JSON: {e}"),
474
+ stack: None,
475
+ },
476
+ };
477
+ let session = unsafe { &*session };
478
+ session.deliver_response(req_id, resp)
479
+ }
480
+
481
+ /// Returns NULL while still building. On completion: returns a
482
+ /// JSON document (caller frees via `gjsify_rolldown_session_free_string`).
483
+ /// `*is_error` is set to true when the response carries an error
484
+ /// message; in that case the JSON is the error text wrapped as
485
+ /// `{"error": "..."}`.
486
+ #[unsafe(no_mangle)]
487
+ pub extern "C" fn gjsify_rolldown_session_try_result(
488
+ session: *mut BundleSession,
489
+ out_len: *mut usize,
490
+ is_error: *mut bool,
491
+ ) -> *mut c_char {
492
+ if session.is_null() {
493
+ unsafe { *out_len = 0 };
494
+ unsafe { *is_error = true };
495
+ return ptr::null_mut();
496
+ }
497
+ let slot = match lookup_result_slot(session) {
498
+ Some(s) => s,
499
+ None => {
500
+ unsafe { *out_len = 0 };
501
+ unsafe { *is_error = false };
502
+ return ptr::null_mut();
503
+ }
504
+ };
505
+ let result = match slot.lock().unwrap().take() {
506
+ Some(r) => r,
507
+ None => {
508
+ unsafe { *out_len = 0 };
509
+ unsafe { *is_error = false };
510
+ return ptr::null_mut();
511
+ }
512
+ };
513
+ match result {
514
+ Ok(json) => {
515
+ unsafe { *is_error = false };
516
+ unsafe { *out_len = json.len() };
517
+ CString::new(json).ok().map(|c| c.into_raw()).unwrap_or(ptr::null_mut())
518
+ }
519
+ Err(msg) => {
520
+ unsafe { *is_error = true };
521
+ let wrapped = format!("{{\"error\":{}}}", serde_json::to_string(&msg).unwrap_or_else(|_| "\"<unprintable>\"".to_string()));
522
+ unsafe { *out_len = wrapped.len() };
523
+ CString::new(wrapped).ok().map(|c| c.into_raw()).unwrap_or(ptr::null_mut())
524
+ }
525
+ }
526
+ }
527
+
528
+ #[unsafe(no_mangle)]
529
+ pub extern "C" fn gjsify_rolldown_session_cancel(session: *mut BundleSession) {
530
+ if session.is_null() {
531
+ return;
532
+ }
533
+ let session = unsafe { &*session };
534
+ session.cancel();
535
+ }
536
+
537
+ #[unsafe(no_mangle)]
538
+ pub extern "C" fn gjsify_rolldown_session_free(session: *mut BundleSession) {
539
+ if session.is_null() {
540
+ return;
541
+ }
542
+ unregister_result_slot(session);
543
+ let boxed = unsafe { Box::from_raw(session) };
544
+ unsafe {
545
+ libc::close(boxed.request_eventfd);
546
+ libc::close(boxed.complete_eventfd);
547
+ libc::close(boxed.shared.context_response_eventfd);
548
+ }
549
+ // Dropping the runtime aborts all in-flight tasks. shutdown_timeout
550
+ // gives them up to 500ms to drain; any remaining workers are
551
+ // terminated. Prevents the GJS process exit from hanging.
552
+ boxed.runtime.shutdown_timeout(Duration::from_millis(500));
553
+ }
554
+
555
+ // ---------------------------------------------------------------------
556
+ // Phase B.3 — nested-protocol FFI surface
557
+ // ---------------------------------------------------------------------
558
+
559
+ /// JS-side request shape for `this.resolve()`. `args_json` is the
560
+ /// payload of `gjsify_rolldown_session_context_resolve`. We deserialize
561
+ /// it once, hand the values to `PluginContext.resolve()`, and surface
562
+ /// the result on the context-response channel.
563
+ #[derive(Debug, Deserialize)]
564
+ #[serde(rename_all = "camelCase")]
565
+ pub struct ContextResolveRequest {
566
+ pub specifier: String,
567
+ #[serde(default)]
568
+ pub importer: Option<String>,
569
+ #[serde(default)]
570
+ pub skip_self: Option<bool>,
571
+ #[serde(default)]
572
+ pub is_entry: Option<bool>,
573
+ }
574
+
575
+ /// FFI: trigger a `ctx.resolve()` callback on behalf of a JS plugin
576
+ /// hook handler. Returns a child request ID immediately; the actual
577
+ /// result lands on the context-response queue (drained via
578
+ /// `next_context_response()`).
579
+ ///
580
+ /// Returns 0 on error (parent_req_id unknown / args malformed).
581
+ #[unsafe(no_mangle)]
582
+ pub extern "C" fn gjsify_rolldown_session_context_resolve(
583
+ session: *mut BundleSession,
584
+ parent_req_id: u64,
585
+ args_json: *const c_char,
586
+ args_json_len: usize,
587
+ ) -> u64 {
588
+ if session.is_null() || args_json.is_null() {
589
+ return 0;
590
+ }
591
+ let session = unsafe { &*session };
592
+
593
+ // Snapshot the parent context. Both load and transform hooks
594
+ // register a `PluginContext::clone()` keyed by their req_id
595
+ // before sending the JS request.
596
+ let ctx = match session.shared.contexts.lock().unwrap().get(&parent_req_id).cloned() {
597
+ Some(c) => c,
598
+ None => return 0,
599
+ };
600
+
601
+ let slice = unsafe { std::slice::from_raw_parts(args_json as *const u8, args_json_len) };
602
+ let req: ContextResolveRequest = match serde_json::from_slice(slice) {
603
+ Ok(r) => r,
604
+ Err(e) => {
605
+ // Best effort: drop a synthetic child reply so JS doesn't
606
+ // hang waiting on a Promise that will never resolve.
607
+ let child_id = session.shared.next_child_id.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
608
+ let resp = ContextResolveResponse {
609
+ child_id,
610
+ id: None,
611
+ external: None,
612
+ error: Some(format!("rolldown: malformed context_resolve args JSON: {e}")),
613
+ };
614
+ let _ = session.shared.context_response_tx.send(resp);
615
+ wake_eventfd(session.shared.context_response_eventfd);
616
+ return child_id;
617
+ }
618
+ };
619
+
620
+ let child_id = session
621
+ .shared
622
+ .next_child_id
623
+ .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
624
+ let shared = session.shared.clone();
625
+
626
+ session.runtime.spawn(async move {
627
+ let opts = PluginContextResolveOptions {
628
+ skip_self: req.skip_self.unwrap_or(true),
629
+ is_entry: req.is_entry.unwrap_or(false),
630
+ ..Default::default()
631
+ };
632
+ let resp = match ctx
633
+ .resolve(&req.specifier, req.importer.as_deref(), Some(opts))
634
+ .await
635
+ {
636
+ Ok(Ok(resolved)) => ContextResolveResponse {
637
+ child_id,
638
+ id: Some(resolved.id.to_string()),
639
+ external: Some(matches!(
640
+ resolved.external,
641
+ rolldown_common::ResolvedExternal::Absolute
642
+ | rolldown_common::ResolvedExternal::Relative
643
+ )),
644
+ error: None,
645
+ },
646
+ Ok(Err(resolve_err)) => ContextResolveResponse {
647
+ child_id,
648
+ id: None,
649
+ external: None,
650
+ error: Some(format!("{resolve_err:?}")),
651
+ },
652
+ Err(e) => ContextResolveResponse {
653
+ child_id,
654
+ id: None,
655
+ external: None,
656
+ error: Some(format!("{e}")),
657
+ },
658
+ };
659
+ let _ = shared.context_response_tx.send(resp);
660
+ wake_eventfd(shared.context_response_eventfd);
661
+ });
662
+
663
+ child_id
664
+ }
665
+
666
+ /// JS-side `this.warn(msg)` — accumulate the message; it surfaces in
667
+ /// the final `BundleOutputJson.warnings` array.
668
+ #[unsafe(no_mangle)]
669
+ pub extern "C" fn gjsify_rolldown_session_context_warn(
670
+ session: *mut BundleSession,
671
+ message: *const c_char,
672
+ message_len: usize,
673
+ ) {
674
+ if session.is_null() || message.is_null() {
675
+ return;
676
+ }
677
+ let slice = unsafe { std::slice::from_raw_parts(message as *const u8, message_len) };
678
+ let msg = match std::str::from_utf8(slice) {
679
+ Ok(s) => s.to_string(),
680
+ Err(_) => "rolldown: this.warn() called with non-UTF-8 message".to_string(),
681
+ };
682
+ let session = unsafe { &*session };
683
+ session.shared.context_warnings.lock().unwrap().push(msg);
684
+ }
685
+
686
+ /// FFI: get the eventfd that signals "a context-resolve sub-result is
687
+ /// available". GLib main loop watches this with `G_IO_IN`.
688
+ #[unsafe(no_mangle)]
689
+ pub extern "C" fn gjsify_rolldown_session_context_response_fd(
690
+ session: *mut BundleSession,
691
+ ) -> c_int {
692
+ if session.is_null() {
693
+ return -1;
694
+ }
695
+ let session = unsafe { &*session };
696
+ session.shared.context_response_eventfd
697
+ }
698
+
699
+ /// FFI: drain one pending context-resolve response. Returns NULL when
700
+ /// the channel is empty.
701
+ #[unsafe(no_mangle)]
702
+ pub extern "C" fn gjsify_rolldown_session_next_context_response(
703
+ session: *mut BundleSession,
704
+ out_len: *mut usize,
705
+ ) -> *mut c_char {
706
+ if session.is_null() {
707
+ unsafe { *out_len = 0 };
708
+ return ptr::null_mut();
709
+ }
710
+ let session = unsafe { &*session };
711
+ let resp = match session.context_response_rx.try_recv() {
712
+ Ok(r) => r,
713
+ Err(_) => {
714
+ unsafe { *out_len = 0 };
715
+ return ptr::null_mut();
716
+ }
717
+ };
718
+ let json = match serde_json::to_string(&resp) {
719
+ Ok(s) => s,
720
+ Err(e) => format!("{{\"childId\":{},\"error\":\"serialize: {e}\"}}", resp.child_id),
721
+ };
722
+ unsafe { *out_len = json.len() };
723
+ CString::new(json).ok().map(|c| c.into_raw()).unwrap_or(ptr::null_mut())
724
+ }
725
+
726
+ fn wake_eventfd(fd: c_int) {
727
+ let one: u64 = 1;
728
+ unsafe {
729
+ libc::write(fd, &one as *const u64 as *const libc::c_void, 8);
730
+ }
731
+ }
732
+
733
+ // ---------------------------------------------------------------------
734
+ // Phase B.4 — bytes-payload side-channel for the transform hook.
735
+ // ---------------------------------------------------------------------
736
+
737
+ /// FFI: drain the request-payload bytes the Rust side stashed for
738
+ /// `req_id` (transform's source code). Returns NULL if the slot is
739
+ /// empty or already consumed. Caller frees the returned buffer via
740
+ /// `gjsify_rolldown_session_free_payload(buf, len)`.
741
+ #[unsafe(no_mangle)]
742
+ pub extern "C" fn gjsify_rolldown_session_take_request_payload(
743
+ session: *mut BundleSession,
744
+ req_id: u64,
745
+ out_len: *mut usize,
746
+ ) -> *mut u8 {
747
+ if session.is_null() {
748
+ unsafe { *out_len = 0 };
749
+ return ptr::null_mut();
750
+ }
751
+ let session = unsafe { &*session };
752
+ let bytes = match session.shared.request_payloads.lock().unwrap().remove(&req_id) {
753
+ Some(b) => b,
754
+ None => {
755
+ unsafe { *out_len = 0 };
756
+ return ptr::null_mut();
757
+ }
758
+ };
759
+ let mut boxed = bytes.into_boxed_slice();
760
+ let ptr = boxed.as_mut_ptr();
761
+ let len = boxed.len();
762
+ std::mem::forget(boxed);
763
+ unsafe { *out_len = len };
764
+ ptr
765
+ }
766
+
767
+ /// FFI: JS-side stashes response payload bytes (the transform hook's
768
+ /// output code) for the Rust dispatch site to pick up after
769
+ /// `respond()`. Returns true on success.
770
+ #[unsafe(no_mangle)]
771
+ pub extern "C" fn gjsify_rolldown_session_set_response_payload(
772
+ session: *mut BundleSession,
773
+ req_id: u64,
774
+ bytes: *const u8,
775
+ bytes_len: usize,
776
+ ) -> bool {
777
+ if session.is_null() || bytes.is_null() {
778
+ return false;
779
+ }
780
+ let slice = unsafe { std::slice::from_raw_parts(bytes, bytes_len) };
781
+ let owned = slice.to_vec();
782
+ let session = unsafe { &*session };
783
+ session
784
+ .shared
785
+ .response_payloads
786
+ .lock()
787
+ .unwrap()
788
+ .insert(req_id, owned);
789
+ true
790
+ }
791
+
792
+ /// Free a payload buffer returned by
793
+ /// `gjsify_rolldown_session_take_request_payload`. The caller MUST
794
+ /// pass back the same length they received.
795
+ #[unsafe(no_mangle)]
796
+ pub extern "C" fn gjsify_rolldown_session_free_payload(buf: *mut u8, len: usize) {
797
+ if buf.is_null() {
798
+ return;
799
+ }
800
+ let slice = unsafe { std::slice::from_raw_parts_mut(buf, len) };
801
+ let _boxed = unsafe { Box::from_raw(slice as *mut [u8]) };
802
+ }
803
+
804
+ #[unsafe(no_mangle)]
805
+ pub extern "C" fn gjsify_rolldown_session_free_string(s: *mut c_char) {
806
+ if !s.is_null() {
807
+ unsafe { drop(CString::from_raw(s)) };
808
+ }
809
+ }
810
+
811
+ #[unsafe(no_mangle)]
812
+ pub extern "C" fn gjsify_rolldown_session_free_error(s: *mut c_char) {
813
+ if !s.is_null() {
814
+ unsafe { drop(CString::from_raw(s)) };
815
+ }
816
+ }
817
+
818
+ // --- helpers -----------------------------------------------------------
819
+
820
+ fn parse_json_args(p: *const c_char, len: usize) -> Result<StartArgs, String> {
821
+ if p.is_null() || len == 0 {
822
+ return Err("rolldown: empty args JSON".to_string());
823
+ }
824
+ let slice = unsafe { std::slice::from_raw_parts(p as *const u8, len) };
825
+ let s = std::str::from_utf8(slice).map_err(|_| "rolldown: invalid UTF-8 in args".to_string())?;
826
+ serde_json::from_str(s).map_err(|e| format!("rolldown: args parse: {e}"))
827
+ }
828
+
829
+ fn err_to_cstr(s: String) -> *mut c_char {
830
+ CString::new(s)
831
+ .unwrap_or_else(|_| CString::new("rolldown: <unprintable error>").unwrap())
832
+ .into_raw()
833
+ }
834
+
835
+ fn num_cpus_capped() -> usize {
836
+ std::thread::available_parallelism()
837
+ .map(|n| n.get().min(4))
838
+ .unwrap_or(2)
839
+ }
840
+
841
+ fn compile_filter(
842
+ src: &Option<String>,
843
+ plugin_name: &str,
844
+ hook_name: &str,
845
+ ) -> Result<Option<regex::Regex>, String> {
846
+ match src {
847
+ None => Ok(None),
848
+ Some(pat) => regex::Regex::new(pat).map(Some).map_err(|e| {
849
+ format!("rolldown: plugin '{plugin_name}' {hook_name} filter '{pat}' invalid: {e}")
850
+ }),
851
+ }
852
+ }