@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,779 @@
1
+ //! JsPluginProxy — a `Pluginable` implementation that ships hook
2
+ //! invocations through a crossbeam channel for a JavaScript-side
3
+ //! handler (running on the GJS main loop) to fulfill.
4
+ //!
5
+ //! Phase B.2 scope: all 12 hooks routed through the channel. JS-side
6
+ //! filter handling (a plugin declared as `{filter, handler}` short-
7
+ //! circuits before reaching us by virtue of register_hook_usage). The
8
+ //! actual per-call regex-against-id matching for `transform({filter})`
9
+ //! style plugins is performed JS-side for now; pushing it into Rust to
10
+ //! avoid the JSON roundtrip on no-match is the Phase B.4 optimization.
11
+ //!
12
+ //! Architecture mirrors `@gjsify/webrtc-native`'s signal-bridge
13
+ //! pattern (refs `packages/web/webrtc-native/src/vala/promise-bridge.vala`):
14
+ //! - tokio-task running `Bundler::generate()` on the worker pool
15
+ //! - calls into JsPluginProxy from various worker threads
16
+ //! - JsPluginProxy serializes a `HookRequest`, pushes to channel
17
+ //! - eventfd-write wakes the GLib main loop
18
+ //! - Vala source pulls requests, emits GObject signal on main thread
19
+ //! - JS handler runs, calls `session.respond(req_id, json)`
20
+ //! - Vala forwards to `gjsify_rolldown_session_respond` extern
21
+ //! - Rust pulls the matching oneshot::Sender from `pending`, sends response
22
+ //! - tokio-task on the original thread wakes up from `oneshot::recv()`
23
+ //! and continues with the parsed result.
24
+
25
+ use std::borrow::Cow;
26
+ use std::fmt;
27
+ use std::sync::atomic::{AtomicU64, Ordering};
28
+ use std::time::Duration;
29
+
30
+ use anyhow::anyhow;
31
+ use crossbeam_channel::Sender;
32
+ use regex::Regex;
33
+ use rolldown_plugin::{
34
+ HookAddonArgs, HookBuildEndArgs, HookBuildStartArgs, HookCloseBundleArgs,
35
+ HookGenerateBundleArgs, HookInjectionOutputReturn, HookLoadArgs, HookLoadOutput,
36
+ HookLoadReturn, HookNoopReturn, HookRenderChunkArgs, HookRenderChunkOutput,
37
+ HookRenderChunkReturn, HookResolveIdArgs, HookResolveIdOutput, HookResolveIdReturn,
38
+ HookTransformArgs, HookTransformOutput, HookTransformReturn, HookUsage, HookWriteBundleArgs,
39
+ Plugin, PluginContext, SharedLoadPluginContext, SharedTransformPluginContext,
40
+ };
41
+ use serde::{Deserialize, Serialize};
42
+ use tokio::sync::oneshot;
43
+
44
+ /// Per-plugin metadata we send with each request so the JS adapter
45
+ /// knows which user plugin to dispatch to.
46
+ ///
47
+ /// `id_filter` lets a plugin pre-declare per-hook regex filters
48
+ /// (load/transform/resolveId only — the only hooks where a single
49
+ /// `id` string is the matchable input). When set, the proxy compiles
50
+ /// the regex once and skips the JS round-trip whenever the id doesn't
51
+ /// match. Mirrors rolldown's own `HookFilter.value` short-circuit
52
+ /// path; full token-tree boolean expressions stay JS-side for now.
53
+ #[derive(Debug, Clone, Serialize, Deserialize)]
54
+ #[serde(rename_all = "camelCase")]
55
+ pub struct PluginMeta {
56
+ pub name: String,
57
+ pub hooks: Vec<String>,
58
+ #[serde(default)]
59
+ pub id_filter: PluginIdFilter,
60
+ }
61
+
62
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
63
+ #[serde(rename_all = "camelCase")]
64
+ pub struct PluginIdFilter {
65
+ #[serde(default)]
66
+ pub load: Option<String>,
67
+ #[serde(default)]
68
+ pub transform: Option<String>,
69
+ #[serde(default)]
70
+ pub resolve_id: Option<String>,
71
+ }
72
+
73
+ /// Hook invocation flowing Rust → JS. Tagged so the Vala bridge can
74
+ /// route to the matching GObject signal.
75
+ ///
76
+ /// Per-hook payloads are intentionally small. Phase B.4 split heavy
77
+ /// data (`transform.code`) out of the JSON envelope and into a
78
+ /// parallel bytes-payload slot keyed by req_id, so the JS adapter
79
+ /// can fetch it as a `GLib.Bytes` (zero-copy Uint8Array view) via
80
+ /// `take_request_payload(reqId)`. The Transform variant therefore
81
+ /// no longer carries the code string — only `payloadKind:'code'`
82
+ /// telling JS to go fetch the bytes.
83
+ #[derive(Debug, Serialize)]
84
+ #[serde(tag = "hook", rename_all = "camelCase")]
85
+ pub enum HookRequestPayload {
86
+ #[serde(rename_all = "camelCase")]
87
+ Load { id: String },
88
+ #[serde(rename_all = "camelCase")]
89
+ ResolveId { specifier: String, importer: Option<String>, is_entry: bool },
90
+ /// `code` is delivered as bytes via the request-payload side-channel
91
+ /// (`take_request_payload(reqId)`). The `payload_kind` marker
92
+ /// flags this for the JS adapter.
93
+ #[serde(rename_all = "camelCase")]
94
+ Transform { id: String, module_type: String, payload_kind: &'static str },
95
+ #[serde(rename_all = "camelCase")]
96
+ RenderChunk { code: String, file_name: String, name: String, is_entry: bool },
97
+ #[serde(rename_all = "camelCase")]
98
+ Banner { file_name: String, name: String, is_entry: bool },
99
+ #[serde(rename_all = "camelCase")]
100
+ Footer { file_name: String, name: String, is_entry: bool },
101
+ #[serde(rename_all = "camelCase")]
102
+ Intro { file_name: String, name: String, is_entry: bool },
103
+ #[serde(rename_all = "camelCase")]
104
+ Outro { file_name: String, name: String, is_entry: bool },
105
+ BuildStart {},
106
+ #[serde(rename_all = "camelCase")]
107
+ BuildEnd { error: Option<String> },
108
+ GenerateBundle {},
109
+ WriteBundle {},
110
+ CloseBundle {},
111
+ }
112
+
113
+ /// Wire-format envelope for one hook invocation. Sent over
114
+ /// crossbeam channel; eventfd written after each push.
115
+ pub struct HookRequest {
116
+ pub req_id: u64,
117
+ pub plugin_index: usize,
118
+ pub payload: HookRequestPayload,
119
+ /// Filled in by JS via `session.respond()`.
120
+ pub reply: oneshot::Sender<HookResponse>,
121
+ }
122
+
123
+ impl fmt::Debug for HookRequest {
124
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125
+ f.debug_struct("HookRequest")
126
+ .field("req_id", &self.req_id)
127
+ .field("plugin_index", &self.plugin_index)
128
+ .field("payload", &self.payload)
129
+ .finish()
130
+ }
131
+ }
132
+
133
+ /// Tagged response from JS handler. Three states match rolldown's
134
+ /// `Result<Option<T>>` convention:
135
+ /// - `Skip` → `Ok(None)` → next plugin in chain runs
136
+ /// - `Ok` → `Ok(Some(value))` → this plugin's result wins
137
+ /// - `Error` → `Err(BuildDiagnostic)` → fail the build
138
+ #[derive(Debug, Deserialize)]
139
+ #[serde(tag = "kind", rename_all = "camelCase")]
140
+ pub enum HookResponse {
141
+ Skip,
142
+ Ok { value: serde_json::Value },
143
+ Error { message: String, stack: Option<String> },
144
+ }
145
+
146
+ /// Wire shape for a load-hook response. Mirrors the small subset of
147
+ /// `HookLoadOutput` we expose to JS plugins (no source-map yet — that
148
+ /// comes in Phase B.4 alongside the zero-copy GBytes path).
149
+ #[derive(Debug, Deserialize)]
150
+ #[serde(rename_all = "camelCase")]
151
+ pub struct LoadHookValue {
152
+ pub code: String,
153
+ #[serde(default)]
154
+ pub module_type: Option<String>,
155
+ }
156
+
157
+ #[derive(Debug, Deserialize)]
158
+ #[serde(rename_all = "camelCase")]
159
+ pub struct ResolveIdHookValue {
160
+ pub id: String,
161
+ #[serde(default)]
162
+ pub external: Option<bool>,
163
+ #[serde(default)]
164
+ pub side_effects: Option<bool>,
165
+ }
166
+
167
+ #[derive(Debug, Deserialize)]
168
+ #[serde(rename_all = "camelCase")]
169
+ pub struct TransformHookValue {
170
+ #[serde(default)]
171
+ pub code: Option<String>,
172
+ #[serde(default)]
173
+ pub module_type: Option<String>,
174
+ /// Phase B.4 — when true, the new code bytes were stashed via
175
+ /// `set_response_payload(reqId, bytes)` before `respond()`.
176
+ /// `code` is then expected to be absent and the Rust side picks
177
+ /// the bytes up from `SessionShared.response_payloads`.
178
+ #[serde(default)]
179
+ pub has_code_bytes: bool,
180
+ }
181
+
182
+ #[derive(Debug, Deserialize)]
183
+ #[serde(rename_all = "camelCase")]
184
+ pub struct RenderChunkHookValue {
185
+ pub code: String,
186
+ // Source map deferred to Phase B.4 (zero-copy GBytes path).
187
+ }
188
+
189
+ #[derive(Debug, Deserialize)]
190
+ #[serde(rename_all = "camelCase")]
191
+ pub struct StringHookValue {
192
+ pub text: String,
193
+ }
194
+
195
+ fn parse_module_type(s: Option<&str>) -> anyhow::Result<Option<rolldown_common::ModuleType>> {
196
+ match s {
197
+ None => Ok(None),
198
+ Some("js") | Some("ecmascript") => Ok(Some(rolldown_common::ModuleType::Js)),
199
+ Some("json") => Ok(Some(rolldown_common::ModuleType::Json)),
200
+ Some("text") => Ok(Some(rolldown_common::ModuleType::Text)),
201
+ Some(other) => Err(anyhow!("rolldown: unsupported moduleType '{}'", other)),
202
+ }
203
+ }
204
+
205
+ impl HookResponse {
206
+ pub fn into_load_return(self) -> HookLoadReturn {
207
+ match self {
208
+ HookResponse::Skip => Ok(None),
209
+ HookResponse::Ok { value } => {
210
+ let v: LoadHookValue = serde_json::from_value(value)
211
+ .map_err(|e| anyhow!("rolldown: malformed load response: {e}"))?;
212
+ let module_type = parse_module_type(v.module_type.as_deref())?;
213
+ Ok(Some(HookLoadOutput {
214
+ code: v.code.into(),
215
+ map: None,
216
+ side_effects: None,
217
+ module_type,
218
+ }))
219
+ }
220
+ HookResponse::Error { message, stack } => Err(combine(message, stack)),
221
+ }
222
+ }
223
+
224
+ pub fn into_resolve_id_return(self) -> HookResolveIdReturn {
225
+ match self {
226
+ HookResponse::Skip => Ok(None),
227
+ HookResponse::Ok { value } => {
228
+ let v: ResolveIdHookValue = serde_json::from_value(value)
229
+ .map_err(|e| anyhow!("rolldown: malformed resolveId response: {e}"))?;
230
+ let external = match v.external {
231
+ Some(true) => Some(rolldown_common::ResolvedExternal::Absolute),
232
+ _ => None,
233
+ };
234
+ let side_effects = match v.side_effects {
235
+ Some(true) => Some(rolldown_common::side_effects::HookSideEffects::True),
236
+ Some(false) => Some(rolldown_common::side_effects::HookSideEffects::False),
237
+ None => None,
238
+ };
239
+ Ok(Some(HookResolveIdOutput {
240
+ id: v.id.into(),
241
+ external,
242
+ normalize_external_id: None,
243
+ side_effects,
244
+ package_json_path: None,
245
+ }))
246
+ }
247
+ HookResponse::Error { message, stack } => Err(combine(message, stack)),
248
+ }
249
+ }
250
+
251
+ /// Phase B.4 variant — like `into_transform_return` but consumes
252
+ /// pre-fetched `response_bytes` (taken from the response-payload
253
+ /// slot at the dispatch site) when the response envelope
254
+ /// advertises `hasCodeBytes: true`. Falls back to `value.code`
255
+ /// (JSON-embedded string) otherwise so callers not yet using the
256
+ /// zero-copy path keep working.
257
+ pub fn into_transform_return_with_bytes(
258
+ self,
259
+ response_bytes: Option<Vec<u8>>,
260
+ ) -> HookTransformReturn {
261
+ match self {
262
+ HookResponse::Skip => Ok(None),
263
+ HookResponse::Ok { value } => {
264
+ let v: TransformHookValue = serde_json::from_value(value)
265
+ .map_err(|e| anyhow!("rolldown: malformed transform response: {e}"))?;
266
+ let module_type = parse_module_type(v.module_type.as_deref())?;
267
+ let code = if v.has_code_bytes {
268
+ let bytes = response_bytes.ok_or_else(|| {
269
+ anyhow!("rolldown: transform response advertised hasCodeBytes but no payload was stashed")
270
+ })?;
271
+ match String::from_utf8(bytes) {
272
+ Ok(s) => Some(s),
273
+ Err(e) => return Err(anyhow!("rolldown: transform response payload was not valid UTF-8: {e}")),
274
+ }
275
+ } else {
276
+ v.code
277
+ };
278
+ Ok(Some(HookTransformOutput {
279
+ code,
280
+ map: None,
281
+ side_effects: None,
282
+ module_type,
283
+ }))
284
+ }
285
+ HookResponse::Error { message, stack } => Err(combine(message, stack)),
286
+ }
287
+ }
288
+
289
+ pub fn into_transform_return(self) -> HookTransformReturn {
290
+ match self {
291
+ HookResponse::Skip => Ok(None),
292
+ HookResponse::Ok { value } => {
293
+ let v: TransformHookValue = serde_json::from_value(value)
294
+ .map_err(|e| anyhow!("rolldown: malformed transform response: {e}"))?;
295
+ let module_type = parse_module_type(v.module_type.as_deref())?;
296
+ Ok(Some(HookTransformOutput {
297
+ code: v.code,
298
+ map: None,
299
+ side_effects: None,
300
+ module_type,
301
+ }))
302
+ }
303
+ HookResponse::Error { message, stack } => Err(combine(message, stack)),
304
+ }
305
+ }
306
+
307
+ pub fn into_render_chunk_return(self) -> HookRenderChunkReturn {
308
+ match self {
309
+ HookResponse::Skip => Ok(None),
310
+ HookResponse::Ok { value } => {
311
+ let v: RenderChunkHookValue = serde_json::from_value(value)
312
+ .map_err(|e| anyhow!("rolldown: malformed renderChunk response: {e}"))?;
313
+ Ok(Some(HookRenderChunkOutput {
314
+ code: v.code,
315
+ map: None,
316
+ }))
317
+ }
318
+ HookResponse::Error { message, stack } => Err(combine(message, stack)),
319
+ }
320
+ }
321
+
322
+ /// banner/footer/intro/outro all return `Result<Option<String>>`.
323
+ pub fn into_injection_return(self) -> HookInjectionOutputReturn {
324
+ match self {
325
+ HookResponse::Skip => Ok(None),
326
+ HookResponse::Ok { value } => {
327
+ // Accept either {"text": "..."} or a raw string.
328
+ if let serde_json::Value::String(s) = &value {
329
+ return Ok(Some(s.clone()));
330
+ }
331
+ let v: StringHookValue = serde_json::from_value(value)
332
+ .map_err(|e| anyhow!("rolldown: malformed injection response: {e}"))?;
333
+ Ok(Some(v.text))
334
+ }
335
+ HookResponse::Error { message, stack } => Err(combine(message, stack)),
336
+ }
337
+ }
338
+
339
+ /// build_start/build_end/generate_bundle/write_bundle/close_bundle
340
+ /// all return `Result<()>`.
341
+ pub fn into_noop_return(self) -> HookNoopReturn {
342
+ match self {
343
+ HookResponse::Skip | HookResponse::Ok { .. } => Ok(()),
344
+ HookResponse::Error { message, stack } => Err(combine(message, stack)),
345
+ }
346
+ }
347
+ }
348
+
349
+ fn combine(message: String, stack: Option<String>) -> anyhow::Error {
350
+ let mut msg = message;
351
+ if let Some(s) = stack {
352
+ msg.push('\n');
353
+ msg.push_str(&s);
354
+ }
355
+ anyhow!(msg)
356
+ }
357
+
358
+ /// `Pluginable` implementation that proxies a single user plugin.
359
+ /// Each plugin in the user's `plugins[]` array gets its own
360
+ /// `JsPluginProxy` so rolldown's per-hook ordering / first-non-null
361
+ /// chaining works unmodified.
362
+ pub struct JsPluginProxy {
363
+ pub name: String,
364
+ pub plugin_index: usize,
365
+ pub hooks: Vec<String>,
366
+ pub request_tx: Sender<HookRequest>,
367
+ /// Atomic monotonic ID dispenser; shared across all proxies in
368
+ /// the same session via Arc clone in the session constructor.
369
+ pub next_request_id: std::sync::Arc<AtomicU64>,
370
+ /// Wakeup pipe written after each request.send() so the Vala
371
+ /// source on the main loop can react.
372
+ pub request_eventfd: i32,
373
+ /// Maximum time we wait for a JS response before failing the
374
+ /// build with a timeout error. Defaults to 60s.
375
+ pub response_timeout: Duration,
376
+ /// Compiled per-hook id regex. None means "always dispatch".
377
+ pub load_id_filter: Option<Regex>,
378
+ pub transform_id_filter: Option<Regex>,
379
+ pub resolve_id_filter: Option<Regex>,
380
+ /// Shared session state for nested-protocol callbacks
381
+ /// (`this.resolve()` / `this.warn()`). The proxy registers each
382
+ /// load/transform hook's `PluginContext` here so the JS handler
383
+ /// can re-enter Rust during its `await`.
384
+ pub shared: std::sync::Arc<crate::session::SessionShared>,
385
+ }
386
+
387
+ impl fmt::Debug for JsPluginProxy {
388
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
389
+ f.debug_struct("JsPluginProxy")
390
+ .field("name", &self.name)
391
+ .field("plugin_index", &self.plugin_index)
392
+ .field("hooks", &self.hooks)
393
+ .finish()
394
+ }
395
+ }
396
+
397
+ impl JsPluginProxy {
398
+ fn new_request_id(&self) -> u64 {
399
+ self.next_request_id.fetch_add(1, Ordering::SeqCst)
400
+ }
401
+
402
+ fn proxies(&self, hook: &str) -> bool {
403
+ self.hooks.iter().any(|h| h == hook)
404
+ }
405
+
406
+ /// Push a hook request onto the channel + wake the main loop via
407
+ /// eventfd-write, then await the JS-side response.
408
+ async fn dispatch(&self, payload: HookRequestPayload) -> anyhow::Result<HookResponse> {
409
+ let (resp, _bytes) = self.dispatch_inner(payload, None, None).await?;
410
+ Ok(resp)
411
+ }
412
+
413
+ /// Variant of `dispatch` that registers `ctx` for the duration of
414
+ /// the JS handler's await. Used by load/transform hooks so the
415
+ /// JS handler may call `this.resolve()` / `this.warn()` mid-await
416
+ /// via `gjsify_rolldown_session_context_resolve`.
417
+ async fn dispatch_with_ctx(
418
+ &self,
419
+ payload: HookRequestPayload,
420
+ ctx: rolldown_plugin::PluginContext,
421
+ ) -> anyhow::Result<HookResponse> {
422
+ let (resp, _bytes) = self.dispatch_inner(payload, Some(ctx), None).await?;
423
+ Ok(resp)
424
+ }
425
+
426
+ /// Variant that pre-stashes a bytes payload alongside the JSON
427
+ /// envelope. The JS side fetches it via
428
+ /// `take_request_payload(reqId)` instead of seeing it embedded in
429
+ /// the envelope. Used by `transform` to avoid JSON-escaping
430
+ /// arbitrarily large source code. Returns the response paired
431
+ /// with any response bytes the JS side stashed before replying.
432
+ async fn dispatch_with_payload(
433
+ &self,
434
+ payload: HookRequestPayload,
435
+ ctx: rolldown_plugin::PluginContext,
436
+ request_bytes: Vec<u8>,
437
+ ) -> anyhow::Result<(HookResponse, Option<Vec<u8>>)> {
438
+ self.dispatch_inner(payload, Some(ctx), Some(request_bytes)).await
439
+ }
440
+
441
+ async fn dispatch_inner(
442
+ &self,
443
+ payload: HookRequestPayload,
444
+ ctx: Option<rolldown_plugin::PluginContext>,
445
+ request_bytes: Option<Vec<u8>>,
446
+ ) -> anyhow::Result<(HookResponse, Option<Vec<u8>>)> {
447
+ let (reply_tx, reply_rx) = oneshot::channel();
448
+ let req_id = self.new_request_id();
449
+
450
+ if let Some(c) = ctx {
451
+ self.shared.contexts.lock().unwrap().insert(req_id, c);
452
+ }
453
+ if let Some(bytes) = request_bytes {
454
+ self.shared.request_payloads.lock().unwrap().insert(req_id, bytes);
455
+ }
456
+
457
+ let req = HookRequest {
458
+ req_id,
459
+ plugin_index: self.plugin_index,
460
+ payload,
461
+ reply: reply_tx,
462
+ };
463
+
464
+ if self.request_tx.send(req).is_err() {
465
+ self.shared.contexts.lock().unwrap().remove(&req_id);
466
+ self.shared.request_payloads.lock().unwrap().remove(&req_id);
467
+ return Err(anyhow!("rolldown: plugin channel closed (session aborted)"));
468
+ }
469
+
470
+ let one: u64 = 1;
471
+ unsafe {
472
+ libc::write(
473
+ self.request_eventfd,
474
+ &one as *const u64 as *const libc::c_void,
475
+ 8,
476
+ );
477
+ }
478
+
479
+ let result = tokio::time::timeout(self.response_timeout, reply_rx).await;
480
+ // Clear the context registration regardless of outcome; the
481
+ // JS handler is finished (or timed out) and any further
482
+ // `this.resolve()` calls keyed on this req_id would race.
483
+ self.shared.contexts.lock().unwrap().remove(&req_id);
484
+ // Clean up any unconsumed request payload bytes. If the JS
485
+ // adapter took ownership via `take_request_payload`, this is
486
+ // a no-op; if it never did, we don't want the bytes to leak.
487
+ self.shared.request_payloads.lock().unwrap().remove(&req_id);
488
+ // Pop any response bytes the JS side stashed via
489
+ // `set_response_payload(req_id, bytes)`. Returned to the
490
+ // caller so it can decode + use without going through JSON.
491
+ let response_bytes = self.shared.response_payloads.lock().unwrap().remove(&req_id);
492
+
493
+ match result {
494
+ Ok(Ok(resp)) => Ok((resp, response_bytes)),
495
+ Ok(Err(_)) => Err(anyhow!(
496
+ "rolldown: plugin {} dropped reply for request",
497
+ self.name
498
+ )),
499
+ Err(_) => Err(anyhow!(
500
+ "rolldown: plugin {} timed out after {}s waiting for JS response",
501
+ self.name,
502
+ self.response_timeout.as_secs()
503
+ )),
504
+ }
505
+ }
506
+ }
507
+
508
+ impl Plugin for JsPluginProxy {
509
+ fn name(&self) -> Cow<'static, str> {
510
+ Cow::Owned(self.name.clone())
511
+ }
512
+
513
+ fn register_hook_usage(&self) -> HookUsage {
514
+ let mut usage = HookUsage::empty();
515
+ for h in &self.hooks {
516
+ match h.as_str() {
517
+ "load" => usage |= HookUsage::Load,
518
+ "transform" => usage |= HookUsage::Transform,
519
+ "resolveId" => usage |= HookUsage::ResolveId,
520
+ "renderChunk" => usage |= HookUsage::RenderChunk,
521
+ "buildStart" => usage |= HookUsage::BuildStart,
522
+ "buildEnd" => usage |= HookUsage::BuildEnd,
523
+ "generateBundle" => usage |= HookUsage::GenerateBundle,
524
+ "writeBundle" => usage |= HookUsage::WriteBundle,
525
+ "closeBundle" => usage |= HookUsage::CloseBundle,
526
+ "banner" => usage |= HookUsage::Banner,
527
+ "footer" => usage |= HookUsage::Footer,
528
+ "intro" => usage |= HookUsage::Intro,
529
+ "outro" => usage |= HookUsage::Outro,
530
+ _ => {}
531
+ }
532
+ }
533
+ usage
534
+ }
535
+
536
+ fn load(
537
+ &self,
538
+ ctx: SharedLoadPluginContext,
539
+ args: &HookLoadArgs<'_>,
540
+ ) -> impl std::future::Future<Output = HookLoadReturn> + Send {
541
+ let proxies = self.proxies("load");
542
+ let id = args.id.to_string();
543
+ let filtered = self
544
+ .load_id_filter
545
+ .as_ref()
546
+ .map(|re| !re.is_match(&id))
547
+ .unwrap_or(false);
548
+ let ctx_clone = ctx.inner.clone();
549
+ async move {
550
+ if !proxies || filtered { return Ok(None); }
551
+ let resp = self
552
+ .dispatch_with_ctx(HookRequestPayload::Load { id }, ctx_clone)
553
+ .await?;
554
+ resp.into_load_return()
555
+ }
556
+ }
557
+
558
+ fn resolve_id(
559
+ &self,
560
+ ctx: &PluginContext,
561
+ args: &HookResolveIdArgs<'_>,
562
+ ) -> impl std::future::Future<Output = HookResolveIdReturn> + Send {
563
+ let proxies = self.proxies("resolveId");
564
+ let specifier = args.specifier.to_string();
565
+ let importer = args.importer.map(|s| s.to_string());
566
+ let is_entry = args.is_entry;
567
+ let filtered = self
568
+ .resolve_id_filter
569
+ .as_ref()
570
+ .map(|re| !re.is_match(&specifier))
571
+ .unwrap_or(false);
572
+ let ctx_clone = ctx.clone();
573
+ async move {
574
+ if !proxies || filtered { return Ok(None); }
575
+ let resp = self
576
+ .dispatch_with_ctx(
577
+ HookRequestPayload::ResolveId { specifier, importer, is_entry },
578
+ ctx_clone,
579
+ )
580
+ .await?;
581
+ resp.into_resolve_id_return()
582
+ }
583
+ }
584
+
585
+ fn transform(
586
+ &self,
587
+ ctx: SharedTransformPluginContext,
588
+ args: &HookTransformArgs<'_>,
589
+ ) -> impl std::future::Future<Output = HookTransformReturn> + Send {
590
+ let proxies = self.proxies("transform");
591
+ let id = args.id.to_string();
592
+ let code_bytes = args.code.as_bytes().to_vec();
593
+ let module_type = format!("{:?}", args.module_type).to_lowercase();
594
+ let filtered = self
595
+ .transform_id_filter
596
+ .as_ref()
597
+ .map(|re| !re.is_match(&id))
598
+ .unwrap_or(false);
599
+ let ctx_clone = ctx.inner.clone();
600
+ async move {
601
+ if !proxies || filtered { return Ok(None); }
602
+ // Phase B.4 — the JS adapter fetches `code` bytes via
603
+ // `take_request_payload(reqId)`; the JSON envelope only
604
+ // carries metadata + the `payloadKind:'code'` marker.
605
+ // Response side: if JS stashed new code as response
606
+ // payload bytes (and set `hasCodeBytes: true` on the
607
+ // value), `dispatch_with_payload` returns them too.
608
+ let (resp, response_bytes) = self
609
+ .dispatch_with_payload(
610
+ HookRequestPayload::Transform {
611
+ id,
612
+ module_type,
613
+ payload_kind: "code",
614
+ },
615
+ ctx_clone,
616
+ code_bytes,
617
+ )
618
+ .await?;
619
+ resp.into_transform_return_with_bytes(response_bytes)
620
+ }
621
+ }
622
+
623
+ fn render_chunk(
624
+ &self,
625
+ _ctx: &PluginContext,
626
+ args: &HookRenderChunkArgs<'_>,
627
+ ) -> impl std::future::Future<Output = HookRenderChunkReturn> + Send {
628
+ let proxies = self.proxies("renderChunk");
629
+ let code = args.code.clone();
630
+ let file_name = args.chunk.filename.to_string();
631
+ let name = args.chunk.name.to_string();
632
+ let is_entry = args.chunk.is_entry;
633
+ async move {
634
+ if !proxies { return Ok(None); }
635
+ let resp = self
636
+ .dispatch(HookRequestPayload::RenderChunk { code, file_name, name, is_entry })
637
+ .await?;
638
+ resp.into_render_chunk_return()
639
+ }
640
+ }
641
+
642
+ fn banner(
643
+ &self,
644
+ _ctx: &PluginContext,
645
+ args: &HookAddonArgs,
646
+ ) -> impl std::future::Future<Output = HookInjectionOutputReturn> + Send {
647
+ let proxies = self.proxies("banner");
648
+ let file_name = args.chunk.filename.to_string();
649
+ let name = args.chunk.name.to_string();
650
+ let is_entry = args.chunk.is_entry;
651
+ async move {
652
+ if !proxies { return Ok(None); }
653
+ let resp = self
654
+ .dispatch(HookRequestPayload::Banner { file_name, name, is_entry })
655
+ .await?;
656
+ resp.into_injection_return()
657
+ }
658
+ }
659
+
660
+ fn footer(
661
+ &self,
662
+ _ctx: &PluginContext,
663
+ args: &HookAddonArgs,
664
+ ) -> impl std::future::Future<Output = HookInjectionOutputReturn> + Send {
665
+ let proxies = self.proxies("footer");
666
+ let file_name = args.chunk.filename.to_string();
667
+ let name = args.chunk.name.to_string();
668
+ let is_entry = args.chunk.is_entry;
669
+ async move {
670
+ if !proxies { return Ok(None); }
671
+ let resp = self
672
+ .dispatch(HookRequestPayload::Footer { file_name, name, is_entry })
673
+ .await?;
674
+ resp.into_injection_return()
675
+ }
676
+ }
677
+
678
+ fn intro(
679
+ &self,
680
+ _ctx: &PluginContext,
681
+ args: &HookAddonArgs,
682
+ ) -> impl std::future::Future<Output = HookInjectionOutputReturn> + Send {
683
+ let proxies = self.proxies("intro");
684
+ let file_name = args.chunk.filename.to_string();
685
+ let name = args.chunk.name.to_string();
686
+ let is_entry = args.chunk.is_entry;
687
+ async move {
688
+ if !proxies { return Ok(None); }
689
+ let resp = self
690
+ .dispatch(HookRequestPayload::Intro { file_name, name, is_entry })
691
+ .await?;
692
+ resp.into_injection_return()
693
+ }
694
+ }
695
+
696
+ fn outro(
697
+ &self,
698
+ _ctx: &PluginContext,
699
+ args: &HookAddonArgs,
700
+ ) -> impl std::future::Future<Output = HookInjectionOutputReturn> + Send {
701
+ let proxies = self.proxies("outro");
702
+ let file_name = args.chunk.filename.to_string();
703
+ let name = args.chunk.name.to_string();
704
+ let is_entry = args.chunk.is_entry;
705
+ async move {
706
+ if !proxies { return Ok(None); }
707
+ let resp = self
708
+ .dispatch(HookRequestPayload::Outro { file_name, name, is_entry })
709
+ .await?;
710
+ resp.into_injection_return()
711
+ }
712
+ }
713
+
714
+ fn build_start(
715
+ &self,
716
+ _ctx: &PluginContext,
717
+ _args: &HookBuildStartArgs<'_>,
718
+ ) -> impl std::future::Future<Output = HookNoopReturn> + Send {
719
+ let proxies = self.proxies("buildStart");
720
+ async move {
721
+ if !proxies { return Ok(()); }
722
+ let resp = self.dispatch(HookRequestPayload::BuildStart {}).await?;
723
+ resp.into_noop_return()
724
+ }
725
+ }
726
+
727
+ fn build_end(
728
+ &self,
729
+ _ctx: &PluginContext,
730
+ args: Option<&HookBuildEndArgs>,
731
+ ) -> impl std::future::Future<Output = HookNoopReturn> + Send {
732
+ let proxies = self.proxies("buildEnd");
733
+ let error = args.map(|a| format!("{:?}", a.errors));
734
+ async move {
735
+ if !proxies { return Ok(()); }
736
+ let resp = self.dispatch(HookRequestPayload::BuildEnd { error }).await?;
737
+ resp.into_noop_return()
738
+ }
739
+ }
740
+
741
+ fn generate_bundle(
742
+ &self,
743
+ _ctx: &PluginContext,
744
+ _args: &mut HookGenerateBundleArgs<'_>,
745
+ ) -> impl std::future::Future<Output = HookNoopReturn> + Send {
746
+ let proxies = self.proxies("generateBundle");
747
+ async move {
748
+ if !proxies { return Ok(()); }
749
+ let resp = self.dispatch(HookRequestPayload::GenerateBundle {}).await?;
750
+ resp.into_noop_return()
751
+ }
752
+ }
753
+
754
+ fn write_bundle(
755
+ &self,
756
+ _ctx: &PluginContext,
757
+ _args: &mut HookWriteBundleArgs,
758
+ ) -> impl std::future::Future<Output = HookNoopReturn> + Send {
759
+ let proxies = self.proxies("writeBundle");
760
+ async move {
761
+ if !proxies { return Ok(()); }
762
+ let resp = self.dispatch(HookRequestPayload::WriteBundle {}).await?;
763
+ resp.into_noop_return()
764
+ }
765
+ }
766
+
767
+ fn close_bundle(
768
+ &self,
769
+ _ctx: &PluginContext,
770
+ _args: Option<&HookCloseBundleArgs>,
771
+ ) -> impl std::future::Future<Output = HookNoopReturn> + Send {
772
+ let proxies = self.proxies("closeBundle");
773
+ async move {
774
+ if !proxies { return Ok(()); }
775
+ let resp = self.dispatch(HookRequestPayload::CloseBundle {}).await?;
776
+ resp.into_noop_return()
777
+ }
778
+ }
779
+ }