oxidejs 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,11 +45,11 @@ oxide({
45
45
  });
46
46
  ```
47
47
 
48
- `"celld"` writes `dist/wrangler.jsonc` for celld, a self-hosted alternative to Cloudflare Workers, and skips asset serving (`ASSETS` does that).
48
+ `"celld"` writes `dist/wrangler.jsonc` for celld, a self-hosted alternative to Cloudflare Workers, and skips asset serving (`ASSETS` does that). The generated worker imports `oxidejs/worker-dom/install` so Ilha SSR has a DOM before your entry evaluates. Oxide merges `nodejs_compat` into `compatibility_flags` when you do not set it.
49
49
 
50
50
  ## Server actions
51
51
 
52
- Files named `*.server.ts`, `*.server.tsx`, `*.server.js`, or `*.server.jsx` are server-only. A client import is replaced with an RPC stub that POSTs `/__oxide/action`. The original module never enters the client graph. **Only exports wrapped in `action()` become remote actions** — any other export stays server-local and is not callable over the wire. Server and Vite SSR (`import.meta.env.SSR === true`) keep the real functions. Methods are `<file>.<fn>` (`test.ping`). Call `useRequest()` inside an action for the inbound `Request`. `useCtx()` is the request context (`{ req }` plus anything middleware or `createContext` added). On `preset: "celld"`, `useEnv()` and `useFetchCtx()` are the Worker `env` and `ctx` from `fetch(request, env, ctx)` — same values as `useCtx().env` / `useCtx().fetchCtx`. Return `undefined` from `src/server.ts` to fall through to static files. No server action files → the bundle does not import `oxidejs/rpc`.
52
+ Files named `*.server.ts`, `*.server.tsx`, `*.server.js`, or `*.server.jsx` are server-only. A client import is replaced with an Effect RPC stub that POSTs `/__oxide/action` as newline-delimited JSON-RPC (`application/json-rpc`). The original module never enters the client graph. **Only exports wrapped in `action()` become remote actions** — any other export stays server-local and is not callable over the wire. Server and Vite SSR (`import.meta.env.SSR === true`) keep the real functions. Methods are `<file>.<fn>` (`test.ping`). Call `useRequest()` inside an action for the inbound `Request`. `useCtx()` is the request context (`{ req }` plus anything middleware or `createContext` added). On `preset: "celld"`, `useEnv()` and `useFetchCtx()` are the Worker `env` and `ctx` from `fetch(request, env, ctx)` — same values as `useCtx().env` / `useCtx().fetchCtx`. Return `undefined` from `src/server.ts` to fall through to static files. No server action files → the bundle does not import `oxidejs/rpc`. `action()` results are JSON-RPC data — returning a `Response` from an action is an error; return a raw `Response` from `src/server.ts` for raw HTTP responses.
53
53
 
54
54
  ```ts
55
55
  // src/test.server.ts
@@ -78,7 +78,18 @@ export default {
78
78
  };
79
79
  ```
80
80
 
81
- `action()` is runtime identity — it marks the export and adds a typed transport-only `{ signal }` argument. Wrap `async function*` in it to stream over Effect RPC as newline-delimited JSON-RPC (`application/json-rpc` frames, not SSE). On the client, await the call to get the async generator. Inside server code, always read the non-optional signal from `useRequest().signal`:
81
+ ### Call shape
82
+
83
+ Unary actions return a Promise and expose helpers for UI wiring:
84
+
85
+ | Call | What it does |
86
+ | ------------------------------------------- | ------------------------------------------------ |
87
+ | `await ping()` | Run the action (always invokes RPC on client) |
88
+ | `ping.set(...args)` | Same as calling with args; also writes the atom |
89
+ | `ping.bind(...args)` / `ping.with(...args)` | Return an event handler that invokes the action |
90
+ | `ping.result` | Read the last `AsyncResult` from the client atom |
91
+
92
+ `action()` marks the export and adds a typed transport-only `{ signal }` argument. Wrap `async function*` in it to stream over Effect RPC as newline-delimited JSON-RPC (not SSE). On the client the stub returns an async generator — iterate it directly. Inside server code, always read the non-optional signal from `useRequest().signal`:
82
93
 
83
94
  ```ts
84
95
  // src/test.server.ts
@@ -93,12 +104,14 @@ export const ticks = action(async function* (n: number) {
93
104
  import { ticks } from "./test.server";
94
105
 
95
106
  const ac = new AbortController();
96
- for await (const value of await ticks(10, { signal: ac.signal })) {
107
+ for await (const value of ticks(10, { signal: ac.signal })) {
97
108
  console.log(value);
98
109
  }
99
110
  ac.abort();
100
111
  ```
101
112
 
113
+ Stream actions do not support `bind` / `with`. Breaking the `for await` loop or calling `return()` on the generator cleans up the server generator.
114
+
102
115
  `vite dev` and `rsbuild dev` serve the endpoint via middleware. `actions: "http"` (default) serves `/__oxide/action`; `actions: "ws"` uses a WebSocket instead (needs `crossws`; not with `preset: "celld"`). `actions.sameOrigin` defaults to `true` for both transports; set it to `false` only when you intentionally accept cross-origin requests. Set `actions.path` to move the endpoint. `actionHeaders` are static headers on the shared HTTP client and are ignored for WebSocket actions.
103
116
 
104
117
  ## Rsbuild
@@ -125,7 +138,7 @@ Same factory as Vite: client stubs, `/__oxide/action`, and `dist/server.js`.
125
138
  | `clientDir` | `client` | Must stay inside `outDir` |
126
139
  | `wrangler.name` | required if `emitConfig` | |
127
140
  | `wrangler.compatibility_date` | required if `emitConfig` | |
128
- | `wrangler.compatibility_flags` | — | optional |
141
+ | `wrangler.compatibility_flags` | — | optional; `nodejs_compat` is merged in automatically on `celld` |
129
142
  | `wrangler.durable_objects` | — | optional |
130
143
  | `wrangler.migrations` | — | optional |
131
144
  | `wrangler.services` | — | optional |
@@ -178,13 +191,16 @@ The generated `__asset` function uses `path.join` — not `path.resolve` — so
178
191
 
179
192
  ### Server actions (`*.server.{ts,tsx,js,jsx}`)
180
193
 
181
- - Server action code is **never bundled into the client**. Client imports are replaced with RPC stubs that POST the action endpoint (default `/__oxide/action`). The original source stays server-only.
194
+ - Server action code is **never bundled into the client**. Client imports are replaced with Effect RPC stubs that POST the action endpoint (default `/__oxide/action`). The original source stays server-only.
182
195
  - Only `action()`-wrapped exports are exposed as RPC; other exports stay server-local.
196
+ - Stream actions use newline-delimited JSON-RPC (`application/json-rpc`) over that same endpoint — not Server-Sent Events. Frames are scrubbed as they flush on HTTP and WebSocket.
183
197
  - The endpoint is POST-only. Non-POST requests return `405`.
184
198
  - Method dispatch uses `Object.hasOwn`, blocking `__proto__` / `constructor` walks.
185
199
  - Unknown or missing content-types → `415`.
186
200
  - Body size capped at 1 MB by default (enforced on the actual body, not just `Content-Length`).
187
201
  - Batch requests capped at 20 items (both HTTP and WebSocket transports).
202
+ - Effect `Defect` / `Cause` payloads are scrubbed before they leave the endpoint. Clients see plain JSON-RPC errors (`code` + `message` only). Thrown messages become `Internal error` (`-32603`). Unknown methods → `-32601`; invalid params → `-32602`.
203
+ - `actions.sameOrigin` defaults to `true`. Requests without both `Origin` and `Sec-Fetch-Site` are rejected when that check is on.
188
204
 
189
205
  ### Host header
190
206
 
@@ -62,7 +62,8 @@ function clientLayer(options) {
62
62
  function loadClient(group, options) {
63
63
  return Effect.gen(function* () {
64
64
  const scope = yield* Scope.make();
65
- return yield* Scope.provide(scope)(RpcClient.make(group).pipe(Effect.provide(clientLayer(options))));
65
+ const context = yield* Scope.provide(scope)(Layer.build(clientLayer(options)));
66
+ return yield* Scope.provide(scope)(RpcClient.make(group).pipe(Effect.provide(context)));
66
67
  });
67
68
  }
68
69
  function isStreamResult(value) {
@@ -122,7 +123,9 @@ function createClient(group, options) {
122
123
  const key = cacheKey(group, options);
123
124
  let entry = clientCache.get(key);
124
125
  if (!entry) {
125
- entry = { pending: Effect.runPromise(loadClient(group, options)).then((flat) => nestClient(group, flat)).catch((error) => {
126
+ entry = { pending: Effect.runPromise(loadClient(group, options)).then((flat) => {
127
+ return nestClient(group, flat);
128
+ }).catch((error) => {
126
129
  clientCache.delete(key);
127
130
  throw error;
128
131
  }) };
@@ -1,5 +1,4 @@
1
- import { C as scanServerFiles, S as pluginShouldStub, _ as matchesActionPath, a as RequestBodyTooLargeError, b as parseExportedNames, c as VIRTUAL_WORKER_ID, d as generateActionsModule, f as generateClientModule, g as loadClientStub, h as isServerFileId, i as RESOLVED_VIRTUAL_WORKER_ID, m as generateWorkerWrapper, n as RESOLVED_VIRTUAL_ACTIONS_ID, o as VIRTUAL_ACTIONS_ID, p as generateClientStub, r as RESOLVED_VIRTUAL_CLIENT_ID, t as ACTION_PATH, u as generateActionsClientModule, v as moduleKey, w as sendWebResponseFrom, x as parseStreamExports, y as nodeToWebRequest } from "./actions-DE6p5Cyp.mjs";
2
- import { n as createActionHandler, t as createWsHooks } from "./rpc-DYFEdah8.mjs";
1
+ import { A as pluginShouldStub, C as isServerFileId, D as nodeToWebRequest, E as moduleKey, M as sendWebResponseFrom, O as parseExportedNames, S as generateWorkerWrapper, T as matchesActionPath, b as generateClientModule, d as RESOLVED_VIRTUAL_CLIENT_ID, f as RESOLVED_VIRTUAL_WORKER_ID, g as VIRTUAL_WORKER_ID, j as scanServerFiles, k as parseStreamExports, l as ACTION_PATH, m as VIRTUAL_ACTIONS_ID, n as createActionHandler, p as RequestBodyTooLargeError, t as createWsHooks, u as RESOLVED_VIRTUAL_ACTIONS_ID, v as generateActionsClientModule, w as loadClientStub, x as generateClientStub, y as generateActionsModule } from "./rpc-DzUkWWgQ.mjs";
3
2
  import { ensureWorkerDom } from "./worker-dom.mjs";
4
3
  import { createUnplugin } from "unplugin";
5
4
  import fs from "node:fs";
@@ -182,11 +181,13 @@ function mergeAliases(config, extra) {
182
181
  replacement
183
182
  })), ...extra];
184
183
  }
185
- const EFFECT_DEPS = [
184
+ const OPTIMIZE_DEPS = [
186
185
  "effect",
187
186
  "effect/unstable/rpc",
188
187
  "effect/unstable/http",
189
- "effect/unstable/socket"
188
+ "effect/unstable/socket",
189
+ "oxidejs",
190
+ "oxidejs/rpc/client"
190
191
  ];
191
192
  function applyViteEnvironments(config, opts) {
192
193
  config.builder ??= {};
@@ -198,7 +199,7 @@ function applyViteEnvironments(config, opts) {
198
199
  ]);
199
200
  config.resolve.dedupe = [...dedupe];
200
201
  config.optimizeDeps ??= {};
201
- const optimizeInclude = /* @__PURE__ */ new Set([...Array.isArray(config.optimizeDeps.include) ? config.optimizeDeps.include : [], ...EFFECT_DEPS]);
202
+ const optimizeInclude = /* @__PURE__ */ new Set([...Array.isArray(config.optimizeDeps.include) ? config.optimizeDeps.include : [], ...OPTIMIZE_DEPS]);
202
203
  config.optimizeDeps.include = [...optimizeInclude];
203
204
  const celld = opts.preset === "celld";
204
205
  mergeAliases(config, oxideRpcAliases());
@@ -530,7 +531,7 @@ const unpluginFactory = (options) => {
530
531
  next();
531
532
  return;
532
533
  }
533
- const { nodeToWebRequest, sendWebResponseFrom } = await import("./actions-DE6p5Cyp.mjs").then((n) => n.l);
534
+ const { nodeToWebRequest, sendWebResponseFrom } = await import("./rpc-DzUkWWgQ.mjs").then((n) => n._);
534
535
  const request = await nodeToWebRequest(creq, resolved.bodyLimit);
535
536
  const context = {
536
537
  env: resolved.env,
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as unpluginFactory, r as vite, t as oxidejs } from "./plugin-IQa_lKkT.mjs";
1
+ import { n as unpluginFactory, r as vite, t as oxidejs } from "./plugin-HZKRDuCS.mjs";
2
2
  export { oxidejs as default, oxidejs, unpluginFactory, vite };
@@ -1,2 +1,2 @@
1
- import { t as createClient } from "../client-Bc4g9AEw.mjs";
1
+ import { t as createClient } from "../client-C-s6XXpS.mjs";
2
2
  export { createClient };
@@ -1,5 +1,9 @@
1
+ import { t as runWithRequest } from "./context-zrTZyYpF.mjs";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
4
+ import { Layer } from "effect";
5
+ import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
6
+ import { HttpRouter } from "effect/unstable/http";
3
7
  //#region \0rolldown/runtime.js
4
8
  var __defProp = Object.defineProperty;
5
9
  var __exportAll = (all, no_symbols) => {
@@ -195,7 +199,13 @@ function generateActionsModule(modules, opts) {
195
199
  `};`,
196
200
  `const __run = (fn) =>`,
197
201
  ` Effect.promise(() => __als.run(__store(), fn)).pipe(`,
198
- ` Effect.map((value) => (value === undefined ? null : value)),`,
202
+ ` Effect.map((value) => {`,
203
+ ` if (value instanceof Response) {`,
204
+ ` console.error("oxidejs: action() returned a Response; actions must return serializable data. Return a Response from src/server.ts for raw HTTP responses.");`,
205
+ ` throw new Error("action() returned a Response; return it from src/server.ts instead");`,
206
+ ` }`,
207
+ ` return value === undefined ? null : value;`,
208
+ ` }),`,
199
209
  ` );`,
200
210
  `const __withStore = (store, fn) => __als.run(store, fn);`
201
211
  ];
@@ -501,4 +511,405 @@ async function sendWebResponseFrom(req, res, response) {
501
511
  return pipeResponse(req, res, response);
502
512
  }
503
513
  //#endregion
504
- export { scanServerFiles as C, pluginShouldStub as S, matchesActionPath as _, RequestBodyTooLargeError as a, parseExportedNames as b, VIRTUAL_WORKER_ID as c, generateActionsModule as d, generateClientModule as f, loadClientStub as g, isServerFileId as h, RESOLVED_VIRTUAL_WORKER_ID as i, actions_exports as l, generateWorkerWrapper as m, RESOLVED_VIRTUAL_ACTIONS_ID as n, VIRTUAL_ACTIONS_ID as o, generateClientStub as p, RESOLVED_VIRTUAL_CLIENT_ID as r, VIRTUAL_CLIENT_ID as s, ACTION_PATH as t, generateActionsClientModule as u, moduleKey as v, sendWebResponseFrom as w, parseStreamExports as x, nodeToWebRequest as y };
514
+ //#region src/rpc/same-origin.ts
515
+ function isSameOrigin(request) {
516
+ const site = request.headers.get("sec-fetch-site");
517
+ const origin = request.headers.get("origin");
518
+ if (!origin && !site) return false;
519
+ if (site && site !== "same-origin" && site !== "none") return false;
520
+ if (!origin) return site === "same-origin" || site === "none";
521
+ try {
522
+ return new URL(origin).host === (request.headers.get("host") ?? new URL(request.url).host);
523
+ } catch {
524
+ return false;
525
+ }
526
+ }
527
+ //#endregion
528
+ //#region src/rpc/scrub.ts
529
+ /** Strip Effect RPC `Defect` / `Cause` payloads down to plain JSON-RPC errors. */
530
+ const INTERNAL = {
531
+ code: -32603,
532
+ message: "Internal error"
533
+ };
534
+ const NDJSON_CONTENT = "application/json-rpc";
535
+ function isRecord(value) {
536
+ return value !== null && typeof value === "object";
537
+ }
538
+ function classifyCause(error) {
539
+ const blob = `${String(error["message"] ?? "")}${JSON.stringify(error["data"] ?? "")}`;
540
+ if (/Unknown request tag/i.test(blob)) return {
541
+ code: -32601,
542
+ message: "Method not found"
543
+ };
544
+ if (/Missing key/i.test(blob) || /Expected/i.test(blob) && /\["args"\]|\[\\"args\\"\]/.test(blob)) return {
545
+ code: -32602,
546
+ message: "Invalid params"
547
+ };
548
+ return { ...INTERNAL };
549
+ }
550
+ function scrubError(error) {
551
+ if (!isRecord(error)) return { ...INTERNAL };
552
+ if (error["_tag"] === "Defect") return { ...INTERNAL };
553
+ if (error["_tag"] === "Cause") return classifyCause(error);
554
+ if (typeof error["code"] === "number" && typeof error["message"] === "string") return {
555
+ code: error["code"],
556
+ message: error["message"]
557
+ };
558
+ return { ...INTERNAL };
559
+ }
560
+ function createIdRepairState(requestIds = []) {
561
+ return { remaining: new Set(requestIds) };
562
+ }
563
+ /**
564
+ * Scrub one JSON-RPC response object.
565
+ * Effect encodes Defects with `id: -32603`; reclaim the originating request id from `state.remaining`.
566
+ */
567
+ function scrubRpcMessage(msg, requestIds = [], state = createIdRepairState(requestIds)) {
568
+ if (!isRecord(msg) || !("error" in msg) || msg["error"] == null) {
569
+ if (isRecord(msg) && msg["chunk"] !== true && msg["id"] !== -32603 && "id" in msg) state.remaining.delete(msg["id"]);
570
+ return msg;
571
+ }
572
+ const error = scrubError(msg["error"]);
573
+ let id = msg["id"];
574
+ if (id === -32603) {
575
+ const next = state.remaining.values().next();
576
+ if (!next.done) {
577
+ id = next.value;
578
+ state.remaining.delete(next.value);
579
+ } else id = null;
580
+ } else if (id !== void 0 && id !== null) state.remaining.delete(id);
581
+ if (id === void 0) id = null;
582
+ return {
583
+ jsonrpc: "2.0",
584
+ id,
585
+ error
586
+ };
587
+ }
588
+ /**
589
+ * Rewrite a JSON / NDJSON body so clients never see Effect `_tag` / `data` trees.
590
+ * Accepts a single object, a JSON array, or newline-delimited frames.
591
+ */
592
+ function scrubRpcJson(body, requestIds = []) {
593
+ const trimmed = body.replace(/^\uFEFF/, "");
594
+ if (!trimmed) return body;
595
+ const state = createIdRepairState(requestIds);
596
+ if (trimmed.includes("\n")) {
597
+ const lines = trimmed.split("\n");
598
+ const out = [];
599
+ for (const line of lines) {
600
+ if (line === "") continue;
601
+ out.push(scrubRpcLine(line, state));
602
+ }
603
+ return trimmed.endsWith("\n") ? `${out.join("\n")}\n` : out.join("\n");
604
+ }
605
+ try {
606
+ const parsed = JSON.parse(trimmed);
607
+ if (Array.isArray(parsed)) return JSON.stringify(parsed.map((msg) => scrubRpcMessage(msg, requestIds, state)));
608
+ return JSON.stringify(scrubRpcMessage(parsed, requestIds, state));
609
+ } catch {
610
+ return body;
611
+ }
612
+ }
613
+ function scrubRpcLine(line, state) {
614
+ try {
615
+ return JSON.stringify(scrubRpcMessage(JSON.parse(line), [], state));
616
+ } catch {
617
+ return line;
618
+ }
619
+ }
620
+ /** Collect JSON-RPC request ids from a unary object or batch array body. */
621
+ function extractJsonRpcRequestIds(body) {
622
+ try {
623
+ let text = typeof body === "string" ? body : new TextDecoder().decode(body instanceof Uint8Array ? body : new Uint8Array(body));
624
+ text = text.replace(/^\uFEFF/, "").trimEnd();
625
+ if (text.includes("\n")) {
626
+ const ids = [];
627
+ for (const line of text.split("\n")) {
628
+ if (!line) continue;
629
+ const parsed = JSON.parse(line);
630
+ if (isRecord(parsed) && "id" in parsed) ids.push(parsed["id"]);
631
+ }
632
+ return ids;
633
+ }
634
+ const parsed = JSON.parse(text);
635
+ if (Array.isArray(parsed)) return parsed.filter(isRecord).filter((item) => "id" in item).map((item) => item["id"]);
636
+ if (isRecord(parsed) && "id" in parsed) return [parsed["id"]];
637
+ } catch {}
638
+ return [];
639
+ }
640
+ /** Ensure a body is a valid NDJSON frame (Effect's ndJsonRpc decode requires a trailing newline). */
641
+ function ensureNdjsonBody(buf) {
642
+ const bytes = new Uint8Array(buf);
643
+ if (bytes.length > 0 && bytes[bytes.length - 1] === 10) return bytes;
644
+ const out = new Uint8Array(bytes.length + 1);
645
+ out.set(bytes);
646
+ out[bytes.length] = 10;
647
+ return out;
648
+ }
649
+ /**
650
+ * TransformStream that scrubs Effect defect payloads one NDJSON line at a time,
651
+ * so long-running stream actions stay incremental.
652
+ */
653
+ function scrubNdjsonTransform(requestIds = []) {
654
+ const decoder = new TextDecoder();
655
+ const encoder = new TextEncoder();
656
+ const state = createIdRepairState(requestIds);
657
+ let pending = "";
658
+ return new TransformStream({
659
+ transform(chunk, controller) {
660
+ pending += decoder.decode(chunk, { stream: true });
661
+ let nl = pending.indexOf("\n");
662
+ while (nl !== -1) {
663
+ const line = pending.slice(0, nl);
664
+ pending = pending.slice(nl + 1);
665
+ if (line.length > 0) controller.enqueue(encoder.encode(`${scrubRpcLine(line, state)}\n`));
666
+ nl = pending.indexOf("\n");
667
+ }
668
+ },
669
+ flush(controller) {
670
+ pending += decoder.decode();
671
+ if (pending.length > 0) {
672
+ controller.enqueue(encoder.encode(`${scrubRpcLine(pending, state)}\n`));
673
+ pending = "";
674
+ }
675
+ }
676
+ });
677
+ }
678
+ //#endregion
679
+ //#region src/rpc/server.ts
680
+ const JSON_RPC_FORBIDDEN = {
681
+ jsonrpc: "2.0",
682
+ error: {
683
+ code: -32600,
684
+ message: "Forbidden"
685
+ },
686
+ id: null
687
+ };
688
+ const bundles = /* @__PURE__ */ new Map();
689
+ const groupIds = /* @__PURE__ */ new WeakMap();
690
+ let nextGroupId = 0;
691
+ const serialization = RpcSerialization.layerNdJsonRpc();
692
+ function bundleKey(group, path, transport) {
693
+ let id = groupIds.get(group);
694
+ if (id === void 0) {
695
+ id = nextGroupId++;
696
+ groupIds.set(group, id);
697
+ }
698
+ return `${id}:${path}:${transport}`;
699
+ }
700
+ function forbidden() {
701
+ return new Response(JSON.stringify(JSON_RPC_FORBIDDEN), {
702
+ status: 403,
703
+ headers: { "content-type": "application/json" }
704
+ });
705
+ }
706
+ function methodNotAllowed() {
707
+ return new Response("Method Not Allowed", {
708
+ status: 405,
709
+ headers: { Allow: "POST" }
710
+ });
711
+ }
712
+ function buildBundle(group, handlers, path, transport) {
713
+ const app = RpcServer.layerHttp({
714
+ group,
715
+ path,
716
+ protocol: transport === "ws" ? "websocket" : "http"
717
+ }).pipe(Layer.provide(handlers), Layer.provide(serialization));
718
+ return HttpRouter.toWebHandler(app, { disableLogger: true });
719
+ }
720
+ function bundleFor(group, handlers, path, transport) {
721
+ const key = bundleKey(group, path, transport);
722
+ const cached = bundles.get(key);
723
+ if (cached) return cached;
724
+ const built = buildBundle(group, handlers, path, transport);
725
+ bundles.set(key, built);
726
+ return built;
727
+ }
728
+ function scrubJsonResponse(response, requestIds) {
729
+ const contentType = response.headers.get("content-type") ?? "";
730
+ if (!contentType.includes("json")) return response;
731
+ if (response.body && (contentType.includes("application/json-rpc") || contentType.includes("ndjson"))) {
732
+ const headers = new Headers(response.headers);
733
+ headers.delete("content-length");
734
+ return new Response(response.body.pipeThrough(scrubNdjsonTransform(requestIds)), {
735
+ status: response.status,
736
+ statusText: response.statusText,
737
+ headers
738
+ });
739
+ }
740
+ return response;
741
+ }
742
+ async function scrubBufferedJson(response, requestIds) {
743
+ const text = await response.text();
744
+ const headers = new Headers(response.headers);
745
+ headers.delete("content-length");
746
+ return new Response(scrubRpcJson(text, requestIds), {
747
+ status: response.status,
748
+ statusText: response.statusText,
749
+ headers
750
+ });
751
+ }
752
+ function createActionHandler(group, handlers, options = {}) {
753
+ const path = options.path ?? "/__oxide/action";
754
+ const transport = options.transport ?? "http";
755
+ const sameOrigin = options.sameOrigin ?? true;
756
+ return async (request) => {
757
+ if (!matchesActionPath(new URL(request.url).pathname, path)) return new Response("Not Found", { status: 404 });
758
+ if (transport === "http" && request.method !== "POST") return methodNotAllowed();
759
+ if (sameOrigin && !isSameOrigin(request)) return forbidden();
760
+ const rawBody = ensureNdjsonBody(await request.arrayBuffer());
761
+ const requestIds = extractJsonRpcRequestIds(rawBody);
762
+ const headers = new Headers(request.headers);
763
+ headers.set("content-type", NDJSON_CONTENT);
764
+ const forwarded = new Request(request.url, {
765
+ method: request.method,
766
+ headers,
767
+ body: rawBody,
768
+ signal: request.signal
769
+ });
770
+ const extra = await options.createContext?.(forwarded) ?? {};
771
+ const { handler } = bundleFor(group, handlers, path, transport);
772
+ const response = await runWithRequest(forwarded, () => handler(forwarded), extra);
773
+ const contentType = response.headers.get("content-type") ?? "";
774
+ if (contentType.includes("application/json-rpc") || contentType.includes("ndjson")) return scrubJsonResponse(response, requestIds);
775
+ if (contentType.includes("json")) return scrubBufferedJson(response, requestIds);
776
+ return response;
777
+ };
778
+ }
779
+ function disposeActionHandler(group, path = ACTION_PATH, transport = "http") {
780
+ const key = bundleKey(group, path, transport);
781
+ const bundle = bundles.get(key);
782
+ bundles.delete(key);
783
+ return bundle?.dispose() ?? Promise.resolve();
784
+ }
785
+ //#endregion
786
+ //#region src/rpc/ws.ts
787
+ function parseMessage(message, maxBytes) {
788
+ try {
789
+ const raw = message.text();
790
+ if (new TextEncoder().encode(raw).byteLength > maxBytes) return {
791
+ ok: false,
792
+ tooLarge: true
793
+ };
794
+ return {
795
+ ok: true,
796
+ value: raw
797
+ };
798
+ } catch {
799
+ return { ok: false };
800
+ }
801
+ }
802
+ /**
803
+ * Effect's socket client sends `@effect/rpc/Ping` keepalives (no id) and hangs
804
+ * up unless the server answers `@effect/rpc/Pong`. Handle control messages here
805
+ * so they never reach the action handler.
806
+ */
807
+ function controlReply(raw) {
808
+ try {
809
+ const parsed = JSON.parse(raw);
810
+ if (parsed && typeof parsed === "object" && parsed.method === "@effect/rpc/Ping") return JSON.stringify({
811
+ jsonrpc: "2.0",
812
+ method: "@effect/rpc/Pong"
813
+ });
814
+ } catch {}
815
+ }
816
+ /** Forward each complete NDJSON line as its own WS message (keeps streams incremental). */
817
+ async function sendNdjsonFrames(peer, response, signal) {
818
+ if (signal.aborted) {
819
+ await response.body?.cancel();
820
+ return;
821
+ }
822
+ if (!response.body) {
823
+ const text = await response.text();
824
+ if (text && !signal.aborted) peer.send(text);
825
+ return;
826
+ }
827
+ const reader = response.body.getReader();
828
+ const decoder = new TextDecoder();
829
+ let pending = "";
830
+ const onAbort = () => {
831
+ reader.cancel();
832
+ };
833
+ signal.addEventListener("abort", onAbort, { once: true });
834
+ try {
835
+ while (!signal.aborted) {
836
+ const { done, value } = await reader.read();
837
+ if (done) break;
838
+ pending += decoder.decode(value, { stream: true });
839
+ let nl = pending.indexOf("\n");
840
+ while (nl !== -1) {
841
+ const line = pending.slice(0, nl);
842
+ pending = pending.slice(nl + 1);
843
+ if (line.length > 0 && !signal.aborted) peer.send(`${line}\n`);
844
+ nl = pending.indexOf("\n");
845
+ }
846
+ }
847
+ if (!signal.aborted) {
848
+ pending += decoder.decode();
849
+ if (pending.length > 0) peer.send(pending.endsWith("\n") ? pending : `${pending}\n`);
850
+ }
851
+ } finally {
852
+ signal.removeEventListener("abort", onAbort);
853
+ }
854
+ }
855
+ function createWsHooks(group, handlers, options = {}) {
856
+ const path = options.path ?? "/__oxide/action";
857
+ const maxBytes = options.maxMessageSize ?? 1048576;
858
+ const sameOrigin = options.sameOrigin ?? true;
859
+ const baseOptions = {
860
+ path,
861
+ transport: "http",
862
+ sameOrigin
863
+ };
864
+ return {
865
+ upgrade(req) {
866
+ let pathname;
867
+ try {
868
+ pathname = new URL(req.url).pathname;
869
+ } catch {
870
+ return new Response("Bad Request", { status: 400 });
871
+ }
872
+ if (!matchesActionPath(pathname, path)) return new Response("Not Found", { status: 404 });
873
+ if (sameOrigin && !isSameOrigin(req)) return new Response("Forbidden", { status: 403 });
874
+ },
875
+ async message(peer, message) {
876
+ const parsed = parseMessage(message, maxBytes);
877
+ if (!parsed.ok) {
878
+ peer.send(JSON.stringify({
879
+ jsonrpc: "2.0",
880
+ error: {
881
+ code: -32600,
882
+ message: parsed.tooLarge ? "Payload too large" : "Parse error"
883
+ },
884
+ id: null
885
+ }));
886
+ return;
887
+ }
888
+ const pingReply = controlReply(parsed.value);
889
+ if (pingReply !== void 0) {
890
+ peer.send(pingReply);
891
+ return;
892
+ }
893
+ const abort = new AbortController();
894
+ peer.onClose?.(() => abort.abort());
895
+ const host = peer.request?.headers.get("host") ?? "localhost";
896
+ const headers = new Headers(peer.request?.headers);
897
+ headers.set("content-type", NDJSON_CONTENT);
898
+ const peerCtx = await options.createContext?.(peer) ?? peer.context;
899
+ await sendNdjsonFrames(peer, await createActionHandler(group, handlers, {
900
+ ...baseOptions,
901
+ createContext: (req) => ({
902
+ ...peerCtx,
903
+ req
904
+ })
905
+ })(new Request(`http://${host}${path}`, {
906
+ method: "POST",
907
+ headers,
908
+ body: parsed.value,
909
+ signal: abort.signal
910
+ })), abort.signal);
911
+ }
912
+ };
913
+ }
914
+ //#endregion
915
+ export { pluginShouldStub as A, isServerFileId as C, nodeToWebRequest as D, moduleKey as E, sendWebResponseFrom as M, parseExportedNames as O, generateWorkerWrapper as S, matchesActionPath as T, actions_exports as _, extractJsonRpcRequestIds as a, generateClientModule as b, scrubRpcMessage as c, RESOLVED_VIRTUAL_CLIENT_ID as d, RESOLVED_VIRTUAL_WORKER_ID as f, VIRTUAL_WORKER_ID as g, VIRTUAL_CLIENT_ID as h, ensureNdjsonBody as i, scanServerFiles as j, parseStreamExports as k, ACTION_PATH as l, VIRTUAL_ACTIONS_ID as m, createActionHandler as n, scrubNdjsonTransform as o, RequestBodyTooLargeError as p, disposeActionHandler as r, scrubRpcJson as s, createWsHooks as t, RESOLVED_VIRTUAL_ACTIONS_ID as u, generateActionsClientModule as v, loadClientStub as w, generateClientStub as x, generateActionsModule as y };
package/dist/rpc.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { a as streamToAsyncGen, i as bindAsyncGenContext, n as asyncGenToStream, r as asyncGenToStreamInContext, t as createClient } from "./client-Bc4g9AEw.mjs";
2
- import { a as extractJsonRpcRequestIds, c as scrubRpcMessage, i as ensureNdjsonBody, n as createActionHandler, o as scrubNdjsonTransform, r as disposeActionHandler, s as scrubRpcJson, t as createWsHooks } from "./rpc-DYFEdah8.mjs";
1
+ import { a as extractJsonRpcRequestIds, c as scrubRpcMessage, i as ensureNdjsonBody, n as createActionHandler, o as scrubNdjsonTransform, r as disposeActionHandler, s as scrubRpcJson, t as createWsHooks } from "./rpc-DzUkWWgQ.mjs";
2
+ import { a as streamToAsyncGen, i as bindAsyncGenContext, n as asyncGenToStream, r as asyncGenToStreamInContext, t as createClient } from "./client-C-s6XXpS.mjs";
3
3
  export { asyncGenToStream, asyncGenToStreamInContext, bindAsyncGenContext, createActionHandler, createClient, createWsHooks, disposeActionHandler, ensureNdjsonBody, extractJsonRpcRequestIds, scrubNdjsonTransform, scrubRpcJson, scrubRpcMessage, streamToAsyncGen };
package/dist/rsbuild.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as oxidejs } from "./plugin-IQa_lKkT.mjs";
1
+ import { t as oxidejs } from "./plugin-HZKRDuCS.mjs";
2
2
  //#region src/rsbuild.ts
3
3
  var rsbuild_default = oxidejs.rsbuild;
4
4
  //#endregion
package/dist/vite.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as oxidejs } from "./plugin-IQa_lKkT.mjs";
1
+ import { t as oxidejs } from "./plugin-HZKRDuCS.mjs";
2
2
  //#region src/vite.ts
3
3
  var vite_default = oxidejs.vite;
4
4
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxidejs",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Vite/Rsbuild plugin. One build → dist/server.js + optional client. Server actions via *.server.ts.",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -1,382 +0,0 @@
1
- import { _ as matchesActionPath, t as ACTION_PATH } from "./actions-DE6p5Cyp.mjs";
2
- import { t as runWithRequest } from "./context-zrTZyYpF.mjs";
3
- import { Layer } from "effect";
4
- import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
5
- import { HttpRouter } from "effect/unstable/http";
6
- //#region src/rpc/same-origin.ts
7
- function isSameOrigin(request) {
8
- const site = request.headers.get("sec-fetch-site");
9
- const origin = request.headers.get("origin");
10
- if (!origin && !site) return false;
11
- if (site && site !== "same-origin" && site !== "none") return false;
12
- if (!origin) return site === "same-origin" || site === "none";
13
- try {
14
- return new URL(origin).host === (request.headers.get("host") ?? new URL(request.url).host);
15
- } catch {
16
- return false;
17
- }
18
- }
19
- //#endregion
20
- //#region src/rpc/scrub.ts
21
- /** Strip Effect RPC `Defect` / `Cause` payloads down to plain JSON-RPC errors. */
22
- const INTERNAL = {
23
- code: -32603,
24
- message: "Internal error"
25
- };
26
- const NDJSON_CONTENT = "application/json-rpc";
27
- function isRecord(value) {
28
- return value !== null && typeof value === "object";
29
- }
30
- function classifyCause(error) {
31
- const blob = `${String(error["message"] ?? "")}${JSON.stringify(error["data"] ?? "")}`;
32
- if (/Unknown request tag/i.test(blob)) return {
33
- code: -32601,
34
- message: "Method not found"
35
- };
36
- if (/Missing key/i.test(blob) || /Expected/i.test(blob) && /\["args"\]|\[\\"args\\"\]/.test(blob)) return {
37
- code: -32602,
38
- message: "Invalid params"
39
- };
40
- return { ...INTERNAL };
41
- }
42
- function scrubError(error) {
43
- if (!isRecord(error)) return { ...INTERNAL };
44
- if (error["_tag"] === "Defect") return { ...INTERNAL };
45
- if (error["_tag"] === "Cause") return classifyCause(error);
46
- if (typeof error["code"] === "number" && typeof error["message"] === "string") return {
47
- code: error["code"],
48
- message: error["message"]
49
- };
50
- return { ...INTERNAL };
51
- }
52
- function createIdRepairState(requestIds = []) {
53
- return { remaining: new Set(requestIds) };
54
- }
55
- /**
56
- * Scrub one JSON-RPC response object.
57
- * Effect encodes Defects with `id: -32603`; reclaim the originating request id from `state.remaining`.
58
- */
59
- function scrubRpcMessage(msg, requestIds = [], state = createIdRepairState(requestIds)) {
60
- if (!isRecord(msg) || !("error" in msg) || msg["error"] == null) {
61
- if (isRecord(msg) && msg["chunk"] !== true && msg["id"] !== -32603 && "id" in msg) state.remaining.delete(msg["id"]);
62
- return msg;
63
- }
64
- const error = scrubError(msg["error"]);
65
- let id = msg["id"];
66
- if (id === -32603) {
67
- const next = state.remaining.values().next();
68
- if (!next.done) {
69
- id = next.value;
70
- state.remaining.delete(next.value);
71
- } else id = null;
72
- } else if (id !== void 0 && id !== null) state.remaining.delete(id);
73
- if (id === void 0) id = null;
74
- return {
75
- jsonrpc: "2.0",
76
- id,
77
- error
78
- };
79
- }
80
- /**
81
- * Rewrite a JSON / NDJSON body so clients never see Effect `_tag` / `data` trees.
82
- * Accepts a single object, a JSON array, or newline-delimited frames.
83
- */
84
- function scrubRpcJson(body, requestIds = []) {
85
- const trimmed = body.replace(/^\uFEFF/, "");
86
- if (!trimmed) return body;
87
- const state = createIdRepairState(requestIds);
88
- if (trimmed.includes("\n")) {
89
- const lines = trimmed.split("\n");
90
- const out = [];
91
- for (const line of lines) {
92
- if (line === "") continue;
93
- out.push(scrubRpcLine(line, state));
94
- }
95
- return trimmed.endsWith("\n") ? `${out.join("\n")}\n` : out.join("\n");
96
- }
97
- try {
98
- const parsed = JSON.parse(trimmed);
99
- if (Array.isArray(parsed)) return JSON.stringify(parsed.map((msg) => scrubRpcMessage(msg, requestIds, state)));
100
- return JSON.stringify(scrubRpcMessage(parsed, requestIds, state));
101
- } catch {
102
- return body;
103
- }
104
- }
105
- function scrubRpcLine(line, state) {
106
- try {
107
- return JSON.stringify(scrubRpcMessage(JSON.parse(line), [], state));
108
- } catch {
109
- return line;
110
- }
111
- }
112
- /** Collect JSON-RPC request ids from a unary object or batch array body. */
113
- function extractJsonRpcRequestIds(body) {
114
- try {
115
- let text = typeof body === "string" ? body : new TextDecoder().decode(body instanceof Uint8Array ? body : new Uint8Array(body));
116
- text = text.replace(/^\uFEFF/, "").trimEnd();
117
- if (text.includes("\n")) {
118
- const ids = [];
119
- for (const line of text.split("\n")) {
120
- if (!line) continue;
121
- const parsed = JSON.parse(line);
122
- if (isRecord(parsed) && "id" in parsed) ids.push(parsed["id"]);
123
- }
124
- return ids;
125
- }
126
- const parsed = JSON.parse(text);
127
- if (Array.isArray(parsed)) return parsed.filter(isRecord).filter((item) => "id" in item).map((item) => item["id"]);
128
- if (isRecord(parsed) && "id" in parsed) return [parsed["id"]];
129
- } catch {}
130
- return [];
131
- }
132
- /** Ensure a body is a valid NDJSON frame (Effect's ndJsonRpc decode requires a trailing newline). */
133
- function ensureNdjsonBody(buf) {
134
- const bytes = new Uint8Array(buf);
135
- if (bytes.length > 0 && bytes[bytes.length - 1] === 10) return bytes;
136
- const out = new Uint8Array(bytes.length + 1);
137
- out.set(bytes);
138
- out[bytes.length] = 10;
139
- return out;
140
- }
141
- /**
142
- * TransformStream that scrubs Effect defect payloads one NDJSON line at a time,
143
- * so long-running stream actions stay incremental.
144
- */
145
- function scrubNdjsonTransform(requestIds = []) {
146
- const decoder = new TextDecoder();
147
- const encoder = new TextEncoder();
148
- const state = createIdRepairState(requestIds);
149
- let pending = "";
150
- return new TransformStream({
151
- transform(chunk, controller) {
152
- pending += decoder.decode(chunk, { stream: true });
153
- let nl = pending.indexOf("\n");
154
- while (nl !== -1) {
155
- const line = pending.slice(0, nl);
156
- pending = pending.slice(nl + 1);
157
- if (line.length > 0) controller.enqueue(encoder.encode(`${scrubRpcLine(line, state)}\n`));
158
- nl = pending.indexOf("\n");
159
- }
160
- },
161
- flush(controller) {
162
- pending += decoder.decode();
163
- if (pending.length > 0) {
164
- controller.enqueue(encoder.encode(`${scrubRpcLine(pending, state)}\n`));
165
- pending = "";
166
- }
167
- }
168
- });
169
- }
170
- //#endregion
171
- //#region src/rpc/server.ts
172
- const JSON_RPC_FORBIDDEN = {
173
- jsonrpc: "2.0",
174
- error: {
175
- code: -32600,
176
- message: "Forbidden"
177
- },
178
- id: null
179
- };
180
- const bundles = /* @__PURE__ */ new Map();
181
- const groupIds = /* @__PURE__ */ new WeakMap();
182
- let nextGroupId = 0;
183
- const serialization = RpcSerialization.layerNdJsonRpc();
184
- function bundleKey(group, path, transport) {
185
- let id = groupIds.get(group);
186
- if (id === void 0) {
187
- id = nextGroupId++;
188
- groupIds.set(group, id);
189
- }
190
- return `${id}:${path}:${transport}`;
191
- }
192
- function forbidden() {
193
- return new Response(JSON.stringify(JSON_RPC_FORBIDDEN), {
194
- status: 403,
195
- headers: { "content-type": "application/json" }
196
- });
197
- }
198
- function methodNotAllowed() {
199
- return new Response("Method Not Allowed", {
200
- status: 405,
201
- headers: { Allow: "POST" }
202
- });
203
- }
204
- function buildBundle(group, handlers, path, transport) {
205
- const app = RpcServer.layerHttp({
206
- group,
207
- path,
208
- protocol: transport === "ws" ? "websocket" : "http"
209
- }).pipe(Layer.provide(handlers), Layer.provide(serialization));
210
- return HttpRouter.toWebHandler(app, { disableLogger: true });
211
- }
212
- function bundleFor(group, handlers, path, transport) {
213
- const key = bundleKey(group, path, transport);
214
- const cached = bundles.get(key);
215
- if (cached) return cached;
216
- const built = buildBundle(group, handlers, path, transport);
217
- bundles.set(key, built);
218
- return built;
219
- }
220
- function scrubJsonResponse(response, requestIds) {
221
- const contentType = response.headers.get("content-type") ?? "";
222
- if (!contentType.includes("json")) return response;
223
- if (response.body && (contentType.includes("application/json-rpc") || contentType.includes("ndjson"))) {
224
- const headers = new Headers(response.headers);
225
- headers.delete("content-length");
226
- return new Response(response.body.pipeThrough(scrubNdjsonTransform(requestIds)), {
227
- status: response.status,
228
- statusText: response.statusText,
229
- headers
230
- });
231
- }
232
- return response;
233
- }
234
- async function scrubBufferedJson(response, requestIds) {
235
- const text = await response.text();
236
- const headers = new Headers(response.headers);
237
- headers.delete("content-length");
238
- return new Response(scrubRpcJson(text, requestIds), {
239
- status: response.status,
240
- statusText: response.statusText,
241
- headers
242
- });
243
- }
244
- function createActionHandler(group, handlers, options = {}) {
245
- const path = options.path ?? "/__oxide/action";
246
- const transport = options.transport ?? "http";
247
- const sameOrigin = options.sameOrigin ?? true;
248
- return async (request) => {
249
- if (!matchesActionPath(new URL(request.url).pathname, path)) return new Response("Not Found", { status: 404 });
250
- if (transport === "http" && request.method !== "POST") return methodNotAllowed();
251
- if (sameOrigin && !isSameOrigin(request)) return forbidden();
252
- const rawBody = ensureNdjsonBody(await request.arrayBuffer());
253
- const requestIds = extractJsonRpcRequestIds(rawBody);
254
- const headers = new Headers(request.headers);
255
- headers.set("content-type", NDJSON_CONTENT);
256
- const forwarded = new Request(request.url, {
257
- method: request.method,
258
- headers,
259
- body: rawBody,
260
- signal: request.signal
261
- });
262
- const extra = await options.createContext?.(forwarded) ?? {};
263
- const { handler } = bundleFor(group, handlers, path, transport);
264
- const response = await runWithRequest(forwarded, () => handler(forwarded), extra);
265
- const contentType = response.headers.get("content-type") ?? "";
266
- if (contentType.includes("application/json-rpc") || contentType.includes("ndjson")) return scrubJsonResponse(response, requestIds);
267
- if (contentType.includes("json")) return scrubBufferedJson(response, requestIds);
268
- return response;
269
- };
270
- }
271
- function disposeActionHandler(group, path = ACTION_PATH, transport = "http") {
272
- const key = bundleKey(group, path, transport);
273
- const bundle = bundles.get(key);
274
- bundles.delete(key);
275
- return bundle?.dispose() ?? Promise.resolve();
276
- }
277
- //#endregion
278
- //#region src/rpc/ws.ts
279
- function parseMessage(message, maxBytes) {
280
- try {
281
- const raw = message.text();
282
- if (new TextEncoder().encode(raw).byteLength > maxBytes) return {
283
- ok: false,
284
- tooLarge: true
285
- };
286
- return {
287
- ok: true,
288
- value: raw
289
- };
290
- } catch {
291
- return { ok: false };
292
- }
293
- }
294
- /** Forward each complete NDJSON line as its own WS message (keeps streams incremental). */
295
- async function sendNdjsonFrames(peer, response, signal) {
296
- if (signal.aborted) {
297
- await response.body?.cancel();
298
- return;
299
- }
300
- if (!response.body) {
301
- const text = await response.text();
302
- if (text && !signal.aborted) peer.send(text);
303
- return;
304
- }
305
- const reader = response.body.getReader();
306
- const decoder = new TextDecoder();
307
- let pending = "";
308
- const onAbort = () => {
309
- reader.cancel();
310
- };
311
- signal.addEventListener("abort", onAbort, { once: true });
312
- try {
313
- while (!signal.aborted) {
314
- const { done, value } = await reader.read();
315
- if (done) break;
316
- pending += decoder.decode(value, { stream: true });
317
- let nl = pending.indexOf("\n");
318
- while (nl !== -1) {
319
- const line = pending.slice(0, nl);
320
- pending = pending.slice(nl + 1);
321
- if (line.length > 0 && !signal.aborted) peer.send(`${line}\n`);
322
- nl = pending.indexOf("\n");
323
- }
324
- }
325
- if (!signal.aborted) {
326
- pending += decoder.decode();
327
- if (pending.length > 0) peer.send(pending.endsWith("\n") ? pending : `${pending}\n`);
328
- }
329
- } finally {
330
- signal.removeEventListener("abort", onAbort);
331
- }
332
- }
333
- function createWsHooks(group, handlers, options = {}) {
334
- const path = options.path ?? "/__oxide/action";
335
- const maxBytes = options.maxMessageSize ?? 1048576;
336
- const sameOrigin = options.sameOrigin ?? true;
337
- const baseOptions = {
338
- path,
339
- transport: "http",
340
- sameOrigin
341
- };
342
- return {
343
- upgrade(req) {
344
- if (!matchesActionPath(new URL(req.url).pathname, path)) return new Response("Not Found", { status: 404 });
345
- if (sameOrigin && !isSameOrigin(req)) return new Response("Forbidden", { status: 403 });
346
- },
347
- async message(peer, message) {
348
- const parsed = parseMessage(message, maxBytes);
349
- if (!parsed.ok) {
350
- peer.send(JSON.stringify({
351
- jsonrpc: "2.0",
352
- error: {
353
- code: -32600,
354
- message: parsed.tooLarge ? "Payload too large" : "Parse error"
355
- },
356
- id: null
357
- }));
358
- return;
359
- }
360
- const abort = new AbortController();
361
- peer.onClose?.(() => abort.abort());
362
- const host = peer.request?.headers.get("host") ?? "localhost";
363
- const headers = new Headers(peer.request?.headers);
364
- headers.set("content-type", NDJSON_CONTENT);
365
- const peerCtx = await options.createContext?.(peer) ?? peer.context;
366
- await sendNdjsonFrames(peer, await createActionHandler(group, handlers, {
367
- ...baseOptions,
368
- createContext: (req) => ({
369
- ...peerCtx,
370
- req
371
- })
372
- })(new Request(`http://${host}${path}`, {
373
- method: "POST",
374
- headers,
375
- body: parsed.value,
376
- signal: abort.signal
377
- })), abort.signal);
378
- }
379
- };
380
- }
381
- //#endregion
382
- export { extractJsonRpcRequestIds as a, scrubRpcMessage as c, ensureNdjsonBody as i, createActionHandler as n, scrubNdjsonTransform as o, disposeActionHandler as r, scrubRpcJson as s, createWsHooks as t };