oxidejs 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,17 @@ 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.
204
+ - StackBlitz WebContainers do not keep `AsyncLocalStorage` across `async/await`. Oxide detects `process.versions.webcontainer` and falls back to a sync request store, capturing context before Effect schedules work and serializing handler entry so concurrent requests do not stomp that store. Stream pulls re-enter the captured store. This is a demo/dev workaround, not a concurrency model for production.
188
205
 
189
206
  ### Host header
190
207
 
@@ -1,3 +1,4 @@
1
+ import { n as inWebcontainer } from "./context-DQDDwFYi.mjs";
1
2
  import { Effect, Layer, Scope, Stream } from "effect";
2
3
  import { RpcClient, RpcSchema, RpcSerialization } from "effect/unstable/rpc";
3
4
  import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
@@ -6,20 +7,37 @@ import { Socket } from "effect/unstable/socket";
6
7
  function asyncGenToStream(gen) {
7
8
  return Stream.fromAsyncIterable(gen, (error) => error instanceof Error ? error : new Error(String(error)));
8
9
  }
10
+ /** Serialize WebContainer stream pulls so the shared syncStore is not stomped. */
11
+ let pullTail = Promise.resolve();
9
12
  /**
10
- * Re-enter `run` for every generator pull so AsyncLocalStorage request context
11
- * stays available across yields (Effect may drain the stream after the outer ALS scope ends).
13
+ * Re-enter `run` for every generator pull so request context stays available
14
+ * across yields (Effect may drain the stream after the outer ALS scope ends;
15
+ * WebContainer also loses ALS across awaits, so `run` must reinstall the store).
16
+ * On WebContainer, pulls are serialized through settlement so concurrent streams
17
+ * cannot replace the sync fallback mid-pull. `withRequestEntry` is unchanged.
12
18
  */
13
19
  function bindAsyncGenContext(gen, run) {
20
+ const runPull = (fn) => {
21
+ if (!inWebcontainer()) return run(fn);
22
+ let release;
23
+ const gate = new Promise((resolve) => {
24
+ release = resolve;
25
+ });
26
+ const prev = pullTail;
27
+ pullTail = gate;
28
+ return prev.then(() => Promise.resolve(run(fn))).finally(() => {
29
+ release();
30
+ });
31
+ };
14
32
  return {
15
- next: (value) => run(() => gen.next(value)),
16
- return: (value) => run(() => gen.return(value)),
17
- throw: (error) => run(() => gen.throw(error)),
33
+ next: (value) => runPull(() => gen.next(value)),
34
+ return: (value) => runPull(() => gen.return(value)),
35
+ throw: (error) => runPull(() => gen.throw(error)),
18
36
  [Symbol.asyncIterator]() {
19
37
  return this;
20
38
  },
21
39
  async [Symbol.asyncDispose]() {
22
- await run(() => gen.return(void 0));
40
+ await runPull(() => gen.return(void 0));
23
41
  }
24
42
  };
25
43
  }
@@ -62,7 +80,8 @@ function clientLayer(options) {
62
80
  function loadClient(group, options) {
63
81
  return Effect.gen(function* () {
64
82
  const scope = yield* Scope.make();
65
- return yield* Scope.provide(scope)(RpcClient.make(group).pipe(Effect.provide(clientLayer(options))));
83
+ const context = yield* Scope.provide(scope)(Layer.build(clientLayer(options)));
84
+ return yield* Scope.provide(scope)(RpcClient.make(group).pipe(Effect.provide(context)));
66
85
  });
67
86
  }
68
87
  function isStreamResult(value) {
@@ -122,7 +141,9 @@ function createClient(group, options) {
122
141
  const key = cacheKey(group, options);
123
142
  let entry = clientCache.get(key);
124
143
  if (!entry) {
125
- entry = { pending: Effect.runPromise(loadClient(group, options)).then((flat) => nestClient(group, flat)).catch((error) => {
144
+ entry = { pending: Effect.runPromise(loadClient(group, options)).then((flat) => {
145
+ return nestClient(group, flat);
146
+ }).catch((error) => {
126
147
  clientCache.delete(key);
127
148
  throw error;
128
149
  }) };
@@ -52,6 +52,15 @@ type ActionContext = {
52
52
  fetchCtx?: ExecutionContext;
53
53
  [key: string]: unknown;
54
54
  };
55
+ /** Current request context: ALS first, then the WebContainer sync fallback. */
56
+ declare function getRequestStore(): ActionContext;
57
+ /**
58
+ * Run `fn` with `store` on ALS and the sync fallback. On WebContainer the sync
59
+ * slot is restored only after an async `fn` settles (streams capture the store
60
+ * at invoke time and re-enter via this helper on each pull). Sync returns and
61
+ * throws restore immediately so a completed request is not left visible.
62
+ */
63
+ declare function withRequestStore<T>(ctx: ActionContext, fn: () => T): T;
55
64
  /** Current RPC or host request context. Throws outside request handling. */
56
65
  declare function useCtx<C extends ActionContext = ActionContext>(): C;
57
66
  /** Current server `Request`. Available in actions, SSR, and frame renders. */
@@ -61,4 +70,4 @@ declare function useEnv<E = unknown>(): E | undefined;
61
70
  /** Worker `ctx` from `fetch(request, env, ctx)` (`waitUntil`). `undefined` on Node. */
62
71
  declare function useFetchCtx(): ExecutionContext | undefined;
63
72
  //#endregion
64
- export { useFetchCtx as a, ServerActionHandle as c, brandServerAction as d, wrapClientRpc as f, useEnv as i, StreamActionHandle as l, ExecutionContext as n, useRequest as o, wrapClientStreamRpc as p, useCtx as r, ACTION_CALL as s, ActionContext as t, action as u };
73
+ export { useEnv as a, withRequestStore as c, StreamActionHandle as d, action as f, wrapClientStreamRpc as h, useCtx as i, ACTION_CALL as l, wrapClientRpc as m, ExecutionContext as n, useFetchCtx as o, brandServerAction as p, getRequestStore as r, useRequest as s, ActionContext as t, ServerActionHandle as u };
@@ -0,0 +1,100 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import process from "node:process";
3
+ //#region src/context.ts
4
+ const ALS_KEY = Symbol.for("oxidejs.requestContext");
5
+ const FETCH_KEY = Symbol.for("oxidejs.fetch");
6
+ /** StackBlitz WebContainers lose AsyncLocalStorage across `async/await`. */
7
+ const inWebcontainer = () => {
8
+ if (typeof process === "undefined") return false;
9
+ const versions = process.versions;
10
+ return Boolean(versions.webcontainer);
11
+ };
12
+ /** Module fallback when ALS does not survive awaits (WebContainer). */
13
+ let syncStore = null;
14
+ /** Serialize handler *entry* on WebContainer so syncStore is not stomped. */
15
+ let entryTail = Promise.resolve();
16
+ function als() {
17
+ const g = globalThis;
18
+ return g[ALS_KEY] ??= new AsyncLocalStorage();
19
+ }
20
+ const isPromiseLike = (value) => value !== null && (typeof value === "object" || typeof value === "function") && typeof value.then === "function";
21
+ /** Current request context: ALS first, then the WebContainer sync fallback. */
22
+ function getRequestStore() {
23
+ const current = als().getStore() ?? syncStore;
24
+ if (!current) throw new Error("oxidejs: request context is unavailable");
25
+ return current;
26
+ }
27
+ function store() {
28
+ return getRequestStore();
29
+ }
30
+ /**
31
+ * Run `fn` with `store` on ALS and the sync fallback. On WebContainer the sync
32
+ * slot is restored only after an async `fn` settles (streams capture the store
33
+ * at invoke time and re-enter via this helper on each pull). Sync returns and
34
+ * throws restore immediately so a completed request is not left visible.
35
+ */
36
+ function withRequestStore(ctx, fn) {
37
+ const previous = syncStore;
38
+ syncStore = ctx;
39
+ let deferRestore = false;
40
+ try {
41
+ const result = als().run(ctx, fn);
42
+ if (inWebcontainer() && isPromiseLike(result)) {
43
+ deferRestore = true;
44
+ return Promise.resolve(result).finally(() => {
45
+ if (syncStore === ctx) syncStore = previous;
46
+ });
47
+ }
48
+ return result;
49
+ } finally {
50
+ if (!deferRestore) syncStore = previous;
51
+ }
52
+ }
53
+ /**
54
+ * Serialize async work that installs request context on WebContainer.
55
+ * Release as soon as `fn` settles — do not wait for streamed response bodies.
56
+ */
57
+ async function withRequestEntry(fn) {
58
+ if (!inWebcontainer()) return fn();
59
+ let release;
60
+ const gate = new Promise((resolve) => {
61
+ release = resolve;
62
+ });
63
+ const previous = entryTail;
64
+ entryTail = gate;
65
+ await previous;
66
+ try {
67
+ return await fn();
68
+ } finally {
69
+ release();
70
+ }
71
+ }
72
+ /** Current RPC or host request context. Throws outside request handling. */
73
+ function useCtx() {
74
+ return store();
75
+ }
76
+ /** Current server `Request`. Available in actions, SSR, and frame renders. */
77
+ function useRequest() {
78
+ return store().req;
79
+ }
80
+ /** Worker `env` from `fetch(request, env, ctx)`. `undefined` on the Node fetch preset. */
81
+ function useEnv() {
82
+ return store().env;
83
+ }
84
+ /** Worker `ctx` from `fetch(request, env, ctx)` (`waitUntil`). `undefined` on Node. */
85
+ function useFetchCtx() {
86
+ return store().fetchCtx;
87
+ }
88
+ function runWithRequest(req, fn, extra) {
89
+ return withRequestStore({
90
+ ...extra,
91
+ req
92
+ }, fn);
93
+ }
94
+ const HOOK_KEY = Symbol.for("oxidejs.runWithRequest");
95
+ globalThis[HOOK_KEY] ??= (req, fn) => {
96
+ const extra = req[FETCH_KEY];
97
+ return runWithRequest(req, fn, extra);
98
+ };
99
+ //#endregion
100
+ export { useEnv as a, withRequestEntry as c, useCtx as i, withRequestStore as l, inWebcontainer as n, useFetchCtx as o, runWithRequest as r, useRequest as s, getRequestStore as t };
package/dist/index.d.mts CHANGED
@@ -1,3 +1,3 @@
1
- import { a as useFetchCtx, c as ServerActionHandle, d as brandServerAction, f as wrapClientRpc, i as useEnv, l as StreamActionHandle, n as ExecutionContext, o as useRequest, p as wrapClientStreamRpc, r as useCtx, s as ACTION_CALL, t as ActionContext, u as action } from "./context-C1UFQ0Zc.mjs";
1
+ import { a as useEnv, c as withRequestStore, d as StreamActionHandle, f as action, h as wrapClientStreamRpc, i as useCtx, l as ACTION_CALL, m as wrapClientRpc, n as ExecutionContext, o as useFetchCtx, p as brandServerAction, r as getRequestStore, s as useRequest, t as ActionContext, u as ServerActionHandle } from "./context-Ct8u5XUC.mjs";
2
2
  import { a as OxidejsWranglerOptions, i as OxidejsPreset, n as OxidejsActionTransport, o as ResolvedOptions, r as OxidejsOptions, t as OxidejsActionHeaders } from "./types-BM4NAnzy.mjs";
3
- export { ACTION_CALL, type ActionContext, type ExecutionContext, type OxidejsActionHeaders, type OxidejsActionTransport, type OxidejsOptions, type OxidejsPreset, type OxidejsWranglerOptions, type ResolvedOptions, type ServerActionHandle, type StreamActionHandle, action, brandServerAction, useCtx, useEnv, useFetchCtx, useRequest, wrapClientRpc, wrapClientStreamRpc };
3
+ export { ACTION_CALL, type ActionContext, type ExecutionContext, type OxidejsActionHeaders, type OxidejsActionTransport, type OxidejsOptions, type OxidejsPreset, type OxidejsWranglerOptions, type ResolvedOptions, type ServerActionHandle, type StreamActionHandle, action, brandServerAction, getRequestStore, useCtx, useEnv, useFetchCtx, useRequest, withRequestStore, wrapClientRpc, wrapClientStreamRpc };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as useRequest, i as useFetchCtx, n as useCtx, r as useEnv } from "./context-zrTZyYpF.mjs";
1
+ import { a as useEnv, i as useCtx, l as withRequestStore, o as useFetchCtx, s as useRequest, t as getRequestStore } from "./context-DQDDwFYi.mjs";
2
2
  import * as Effect from "effect/Effect";
3
3
  import * as Atom from "effect/unstable/reactivity/Atom";
4
4
  import * as Registry from "effect/unstable/reactivity/AtomRegistry";
@@ -105,4 +105,4 @@ function action(fn) {
105
105
  }))), invoke);
106
106
  }
107
107
  //#endregion
108
- export { ACTION_CALL, action, brandServerAction, useCtx, useEnv, useFetchCtx, useRequest, wrapClientRpc, wrapClientStreamRpc };
108
+ export { ACTION_CALL, action, brandServerAction, getRequestStore, useCtx, useEnv, useFetchCtx, useRequest, withRequestStore, wrapClientRpc, wrapClientStreamRpc };
@@ -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-tYqxQToi.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-tYqxQToi.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-CurpVnGn.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-BgBfkZDx.mjs";
2
2
  export { createClient };
@@ -1,5 +1,9 @@
1
+ import { c as withRequestEntry, r as runWithRequest } from "./context-DQDDwFYi.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) => {
@@ -184,20 +188,21 @@ function generateActionsModule(modules, opts) {
184
188
  `import { Effect } from "effect";`,
185
189
  `import { Schema } from "effect";`,
186
190
  `import { Rpc, RpcGroup } from "effect/unstable/rpc";`,
187
- `import { AsyncLocalStorage } from "node:async_hooks";`,
191
+ `import { getRequestStore, withRequestStore } from "oxidejs";`,
188
192
  `import { asyncGenToStreamInContext } from "oxidejs/rpc";`,
189
- `const __alsKey = Symbol.for("oxidejs.requestContext");`,
190
- `const __als = globalThis[__alsKey] ??= new AsyncLocalStorage();`,
191
- `const __store = () => {`,
192
- ` const ctx = __als.getStore();`,
193
- ` if (!ctx) throw new Error("oxidejs: request context is unavailable");`,
194
- ` return ctx;`,
195
- `};`,
196
- `const __run = (fn) =>`,
197
- ` Effect.promise(() => __als.run(__store(), fn)).pipe(`,
198
- ` Effect.map((value) => (value === undefined ? null : value)),`,
193
+ `const __run = (fn) => {`,
194
+ ` const __s = getRequestStore();`,
195
+ ` return Effect.promise(() => withRequestStore(__s, fn)).pipe(`,
196
+ ` Effect.map((value) => {`,
197
+ ` if (value instanceof Response) {`,
198
+ ` console.error("oxidejs: action() returned a Response; actions must return serializable data. Return a Response from src/server.ts for raw HTTP responses.");`,
199
+ ` throw new Error("action() returned a Response; return it from src/server.ts instead");`,
200
+ ` }`,
201
+ ` return value === undefined ? null : value;`,
202
+ ` }),`,
199
203
  ` );`,
200
- `const __withStore = (store, fn) => __als.run(store, fn);`
204
+ `};`,
205
+ `const __withStore = (store, fn) => withRequestStore(store, fn);`
201
206
  ];
202
207
  const rpcNames = [];
203
208
  const aliases = modules.map((mod, i) => {
@@ -221,7 +226,7 @@ function generateActionsModule(modules, opts) {
221
226
  for (const { alias, mod } of aliases) for (const name of mod.exports) {
222
227
  const tag = `${mod.key}.${name}`;
223
228
  const stream = mod.streams?.includes(name) ?? false;
224
- lines.push(stream ? ` ${JSON.stringify(tag)}: ({ args }) => { const __s = __store(); return asyncGenToStreamInContext(() => ${alias}[${JSON.stringify(name)}].apply(null, args), (fn) => __withStore(__s, fn)); },` : ` ${JSON.stringify(tag)}: ({ args }) => __run(() => ${alias}[${JSON.stringify(name)}].apply(null, args)),`);
229
+ lines.push(stream ? ` ${JSON.stringify(tag)}: ({ args }) => { const __s = getRequestStore(); return asyncGenToStreamInContext(() => ${alias}[${JSON.stringify(name)}].apply(null, args), (fn) => __withStore(__s, fn)); },` : ` ${JSON.stringify(tag)}: ({ args }) => __run(() => ${alias}[${JSON.stringify(name)}].apply(null, args)),`);
225
230
  }
226
231
  lines.push(`});`);
227
232
  lines.push(`export default actionsGroup;`);
@@ -501,4 +506,407 @@ async function sendWebResponseFrom(req, res, response) {
501
506
  return pipeResponse(req, res, response);
502
507
  }
503
508
  //#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 };
509
+ //#region src/rpc/same-origin.ts
510
+ function isSameOrigin(request) {
511
+ const site = request.headers.get("sec-fetch-site");
512
+ const origin = request.headers.get("origin");
513
+ if (!origin && !site) return false;
514
+ if (site && site !== "same-origin" && site !== "none") return false;
515
+ if (!origin) return site === "same-origin" || site === "none";
516
+ try {
517
+ return new URL(origin).host === (request.headers.get("host") ?? new URL(request.url).host);
518
+ } catch {
519
+ return false;
520
+ }
521
+ }
522
+ //#endregion
523
+ //#region src/rpc/scrub.ts
524
+ /** Strip Effect RPC `Defect` / `Cause` payloads down to plain JSON-RPC errors. */
525
+ const INTERNAL = {
526
+ code: -32603,
527
+ message: "Internal error"
528
+ };
529
+ const NDJSON_CONTENT = "application/json-rpc";
530
+ function isRecord(value) {
531
+ return value !== null && typeof value === "object";
532
+ }
533
+ function classifyCause(error) {
534
+ const blob = `${String(error["message"] ?? "")}${JSON.stringify(error["data"] ?? "")}`;
535
+ if (/Unknown request tag/i.test(blob)) return {
536
+ code: -32601,
537
+ message: "Method not found"
538
+ };
539
+ if (/Missing key/i.test(blob) || /Expected/i.test(blob) && /\["args"\]|\[\\"args\\"\]/.test(blob)) return {
540
+ code: -32602,
541
+ message: "Invalid params"
542
+ };
543
+ return { ...INTERNAL };
544
+ }
545
+ function scrubError(error) {
546
+ if (!isRecord(error)) return { ...INTERNAL };
547
+ if (error["_tag"] === "Defect") return { ...INTERNAL };
548
+ if (error["_tag"] === "Cause") return classifyCause(error);
549
+ if (typeof error["code"] === "number" && typeof error["message"] === "string") return {
550
+ code: error["code"],
551
+ message: error["message"]
552
+ };
553
+ return { ...INTERNAL };
554
+ }
555
+ function createIdRepairState(requestIds = []) {
556
+ return { remaining: new Set(requestIds) };
557
+ }
558
+ /**
559
+ * Scrub one JSON-RPC response object.
560
+ * Effect encodes Defects with `id: -32603`; reclaim the originating request id from `state.remaining`.
561
+ */
562
+ function scrubRpcMessage(msg, requestIds = [], state = createIdRepairState(requestIds)) {
563
+ if (!isRecord(msg) || !("error" in msg) || msg["error"] == null) {
564
+ if (isRecord(msg) && msg["chunk"] !== true && msg["id"] !== -32603 && "id" in msg) state.remaining.delete(msg["id"]);
565
+ return msg;
566
+ }
567
+ const error = scrubError(msg["error"]);
568
+ let id = msg["id"];
569
+ if (id === -32603) {
570
+ const next = state.remaining.values().next();
571
+ if (!next.done) {
572
+ id = next.value;
573
+ state.remaining.delete(next.value);
574
+ } else id = null;
575
+ } else if (id !== void 0 && id !== null) state.remaining.delete(id);
576
+ if (id === void 0) id = null;
577
+ return {
578
+ jsonrpc: "2.0",
579
+ id,
580
+ error
581
+ };
582
+ }
583
+ /**
584
+ * Rewrite a JSON / NDJSON body so clients never see Effect `_tag` / `data` trees.
585
+ * Accepts a single object, a JSON array, or newline-delimited frames.
586
+ */
587
+ function scrubRpcJson(body, requestIds = []) {
588
+ const trimmed = body.replace(/^\uFEFF/, "");
589
+ if (!trimmed) return body;
590
+ const state = createIdRepairState(requestIds);
591
+ if (trimmed.includes("\n")) {
592
+ const lines = trimmed.split("\n");
593
+ const out = [];
594
+ for (const line of lines) {
595
+ if (line === "") continue;
596
+ out.push(scrubRpcLine(line, state));
597
+ }
598
+ return trimmed.endsWith("\n") ? `${out.join("\n")}\n` : out.join("\n");
599
+ }
600
+ try {
601
+ const parsed = JSON.parse(trimmed);
602
+ if (Array.isArray(parsed)) return JSON.stringify(parsed.map((msg) => scrubRpcMessage(msg, requestIds, state)));
603
+ return JSON.stringify(scrubRpcMessage(parsed, requestIds, state));
604
+ } catch {
605
+ return body;
606
+ }
607
+ }
608
+ function scrubRpcLine(line, state) {
609
+ try {
610
+ return JSON.stringify(scrubRpcMessage(JSON.parse(line), [], state));
611
+ } catch {
612
+ return line;
613
+ }
614
+ }
615
+ /** Collect JSON-RPC request ids from a unary object or batch array body. */
616
+ function extractJsonRpcRequestIds(body) {
617
+ try {
618
+ let text = typeof body === "string" ? body : new TextDecoder().decode(body instanceof Uint8Array ? body : new Uint8Array(body));
619
+ text = text.replace(/^\uFEFF/, "").trimEnd();
620
+ if (text.includes("\n")) {
621
+ const ids = [];
622
+ for (const line of text.split("\n")) {
623
+ if (!line) continue;
624
+ const parsed = JSON.parse(line);
625
+ if (isRecord(parsed) && "id" in parsed) ids.push(parsed["id"]);
626
+ }
627
+ return ids;
628
+ }
629
+ const parsed = JSON.parse(text);
630
+ if (Array.isArray(parsed)) return parsed.filter(isRecord).filter((item) => "id" in item).map((item) => item["id"]);
631
+ if (isRecord(parsed) && "id" in parsed) return [parsed["id"]];
632
+ } catch {}
633
+ return [];
634
+ }
635
+ /** Ensure a body is a valid NDJSON frame (Effect's ndJsonRpc decode requires a trailing newline). */
636
+ function ensureNdjsonBody(buf) {
637
+ const bytes = new Uint8Array(buf);
638
+ if (bytes.length > 0 && bytes[bytes.length - 1] === 10) return bytes;
639
+ const out = new Uint8Array(bytes.length + 1);
640
+ out.set(bytes);
641
+ out[bytes.length] = 10;
642
+ return out;
643
+ }
644
+ /**
645
+ * TransformStream that scrubs Effect defect payloads one NDJSON line at a time,
646
+ * so long-running stream actions stay incremental.
647
+ */
648
+ function scrubNdjsonTransform(requestIds = []) {
649
+ const decoder = new TextDecoder();
650
+ const encoder = new TextEncoder();
651
+ const state = createIdRepairState(requestIds);
652
+ let pending = "";
653
+ return new TransformStream({
654
+ transform(chunk, controller) {
655
+ pending += decoder.decode(chunk, { stream: true });
656
+ let nl = pending.indexOf("\n");
657
+ while (nl !== -1) {
658
+ const line = pending.slice(0, nl);
659
+ pending = pending.slice(nl + 1);
660
+ if (line.length > 0) controller.enqueue(encoder.encode(`${scrubRpcLine(line, state)}\n`));
661
+ nl = pending.indexOf("\n");
662
+ }
663
+ },
664
+ flush(controller) {
665
+ pending += decoder.decode();
666
+ if (pending.length > 0) {
667
+ controller.enqueue(encoder.encode(`${scrubRpcLine(pending, state)}\n`));
668
+ pending = "";
669
+ }
670
+ }
671
+ });
672
+ }
673
+ //#endregion
674
+ //#region src/rpc/server.ts
675
+ const JSON_RPC_FORBIDDEN = {
676
+ jsonrpc: "2.0",
677
+ error: {
678
+ code: -32600,
679
+ message: "Forbidden"
680
+ },
681
+ id: null
682
+ };
683
+ const bundles = /* @__PURE__ */ new Map();
684
+ const groupIds = /* @__PURE__ */ new WeakMap();
685
+ let nextGroupId = 0;
686
+ const serialization = RpcSerialization.layerNdJsonRpc();
687
+ function bundleKey(group, path, transport) {
688
+ let id = groupIds.get(group);
689
+ if (id === void 0) {
690
+ id = nextGroupId++;
691
+ groupIds.set(group, id);
692
+ }
693
+ return `${id}:${path}:${transport}`;
694
+ }
695
+ function forbidden() {
696
+ return new Response(JSON.stringify(JSON_RPC_FORBIDDEN), {
697
+ status: 403,
698
+ headers: { "content-type": "application/json" }
699
+ });
700
+ }
701
+ function methodNotAllowed() {
702
+ return new Response("Method Not Allowed", {
703
+ status: 405,
704
+ headers: { Allow: "POST" }
705
+ });
706
+ }
707
+ function buildBundle(group, handlers, path, transport) {
708
+ const app = RpcServer.layerHttp({
709
+ group,
710
+ path,
711
+ protocol: transport === "ws" ? "websocket" : "http"
712
+ }).pipe(Layer.provide(handlers), Layer.provide(serialization));
713
+ return HttpRouter.toWebHandler(app, { disableLogger: true });
714
+ }
715
+ function bundleFor(group, handlers, path, transport) {
716
+ const key = bundleKey(group, path, transport);
717
+ const cached = bundles.get(key);
718
+ if (cached) return cached;
719
+ const built = buildBundle(group, handlers, path, transport);
720
+ bundles.set(key, built);
721
+ return built;
722
+ }
723
+ function scrubJsonResponse(response, requestIds) {
724
+ const contentType = response.headers.get("content-type") ?? "";
725
+ if (!contentType.includes("json")) return response;
726
+ if (response.body && (contentType.includes("application/json-rpc") || contentType.includes("ndjson"))) {
727
+ const headers = new Headers(response.headers);
728
+ headers.delete("content-length");
729
+ return new Response(response.body.pipeThrough(scrubNdjsonTransform(requestIds)), {
730
+ status: response.status,
731
+ statusText: response.statusText,
732
+ headers
733
+ });
734
+ }
735
+ return response;
736
+ }
737
+ async function scrubBufferedJson(response, requestIds) {
738
+ const text = await response.text();
739
+ const headers = new Headers(response.headers);
740
+ headers.delete("content-length");
741
+ return new Response(scrubRpcJson(text, requestIds), {
742
+ status: response.status,
743
+ statusText: response.statusText,
744
+ headers
745
+ });
746
+ }
747
+ function createActionHandler(group, handlers, options = {}) {
748
+ const path = options.path ?? "/__oxide/action";
749
+ const transport = options.transport ?? "http";
750
+ const sameOrigin = options.sameOrigin ?? true;
751
+ return async (request) => {
752
+ if (!matchesActionPath(new URL(request.url).pathname, path)) return new Response("Not Found", { status: 404 });
753
+ if (transport === "http" && request.method !== "POST") return methodNotAllowed();
754
+ if (sameOrigin && !isSameOrigin(request)) return forbidden();
755
+ return withRequestEntry(async () => {
756
+ const rawBody = ensureNdjsonBody(await request.arrayBuffer());
757
+ const requestIds = extractJsonRpcRequestIds(rawBody);
758
+ const headers = new Headers(request.headers);
759
+ headers.set("content-type", NDJSON_CONTENT);
760
+ const forwarded = new Request(request.url, {
761
+ method: request.method,
762
+ headers,
763
+ body: rawBody,
764
+ signal: request.signal
765
+ });
766
+ const extra = await options.createContext?.(forwarded) ?? {};
767
+ const { handler } = bundleFor(group, handlers, path, transport);
768
+ const response = await runWithRequest(forwarded, () => handler(forwarded), extra);
769
+ const contentType = response.headers.get("content-type") ?? "";
770
+ if (contentType.includes("application/json-rpc") || contentType.includes("ndjson")) return scrubJsonResponse(response, requestIds);
771
+ if (contentType.includes("json")) return scrubBufferedJson(response, requestIds);
772
+ return response;
773
+ });
774
+ };
775
+ }
776
+ function disposeActionHandler(group, path = ACTION_PATH, transport = "http") {
777
+ const key = bundleKey(group, path, transport);
778
+ const bundle = bundles.get(key);
779
+ bundles.delete(key);
780
+ return bundle?.dispose() ?? Promise.resolve();
781
+ }
782
+ //#endregion
783
+ //#region src/rpc/ws.ts
784
+ function parseMessage(message, maxBytes) {
785
+ try {
786
+ const raw = message.text();
787
+ if (new TextEncoder().encode(raw).byteLength > maxBytes) return {
788
+ ok: false,
789
+ tooLarge: true
790
+ };
791
+ return {
792
+ ok: true,
793
+ value: raw
794
+ };
795
+ } catch {
796
+ return { ok: false };
797
+ }
798
+ }
799
+ /**
800
+ * Effect's socket client sends `@effect/rpc/Ping` keepalives (no id) and hangs
801
+ * up unless the server answers `@effect/rpc/Pong`. Handle control messages here
802
+ * so they never reach the action handler.
803
+ */
804
+ function controlReply(raw) {
805
+ try {
806
+ const parsed = JSON.parse(raw);
807
+ if (parsed && typeof parsed === "object" && parsed.method === "@effect/rpc/Ping") return JSON.stringify({
808
+ jsonrpc: "2.0",
809
+ method: "@effect/rpc/Pong"
810
+ });
811
+ } catch {}
812
+ }
813
+ /** Forward each complete NDJSON line as its own WS message (keeps streams incremental). */
814
+ async function sendNdjsonFrames(peer, response, signal) {
815
+ if (signal.aborted) {
816
+ await response.body?.cancel();
817
+ return;
818
+ }
819
+ if (!response.body) {
820
+ const text = await response.text();
821
+ if (text && !signal.aborted) peer.send(text);
822
+ return;
823
+ }
824
+ const reader = response.body.getReader();
825
+ const decoder = new TextDecoder();
826
+ let pending = "";
827
+ const onAbort = () => {
828
+ reader.cancel();
829
+ };
830
+ signal.addEventListener("abort", onAbort, { once: true });
831
+ try {
832
+ while (!signal.aborted) {
833
+ const { done, value } = await reader.read();
834
+ if (done) break;
835
+ pending += decoder.decode(value, { stream: true });
836
+ let nl = pending.indexOf("\n");
837
+ while (nl !== -1) {
838
+ const line = pending.slice(0, nl);
839
+ pending = pending.slice(nl + 1);
840
+ if (line.length > 0 && !signal.aborted) peer.send(`${line}\n`);
841
+ nl = pending.indexOf("\n");
842
+ }
843
+ }
844
+ if (!signal.aborted) {
845
+ pending += decoder.decode();
846
+ if (pending.length > 0) peer.send(pending.endsWith("\n") ? pending : `${pending}\n`);
847
+ }
848
+ } finally {
849
+ signal.removeEventListener("abort", onAbort);
850
+ }
851
+ }
852
+ function createWsHooks(group, handlers, options = {}) {
853
+ const path = options.path ?? "/__oxide/action";
854
+ const maxBytes = options.maxMessageSize ?? 1048576;
855
+ const sameOrigin = options.sameOrigin ?? true;
856
+ const baseOptions = {
857
+ path,
858
+ transport: "http",
859
+ sameOrigin
860
+ };
861
+ return {
862
+ upgrade(req) {
863
+ let pathname;
864
+ try {
865
+ pathname = new URL(req.url).pathname;
866
+ } catch {
867
+ return new Response("Bad Request", { status: 400 });
868
+ }
869
+ if (!matchesActionPath(pathname, path)) return new Response("Not Found", { status: 404 });
870
+ if (sameOrigin && !isSameOrigin(req)) return new Response("Forbidden", { status: 403 });
871
+ },
872
+ async message(peer, message) {
873
+ const parsed = parseMessage(message, maxBytes);
874
+ if (!parsed.ok) {
875
+ peer.send(JSON.stringify({
876
+ jsonrpc: "2.0",
877
+ error: {
878
+ code: -32600,
879
+ message: parsed.tooLarge ? "Payload too large" : "Parse error"
880
+ },
881
+ id: null
882
+ }));
883
+ return;
884
+ }
885
+ const pingReply = controlReply(parsed.value);
886
+ if (pingReply !== void 0) {
887
+ peer.send(pingReply);
888
+ return;
889
+ }
890
+ const abort = new AbortController();
891
+ peer.onClose?.(() => abort.abort());
892
+ const host = peer.request?.headers.get("host") ?? "localhost";
893
+ const headers = new Headers(peer.request?.headers);
894
+ headers.set("content-type", NDJSON_CONTENT);
895
+ const peerCtx = await options.createContext?.(peer) ?? peer.context;
896
+ await sendNdjsonFrames(peer, await createActionHandler(group, handlers, {
897
+ ...baseOptions,
898
+ createContext: (req) => ({
899
+ ...peerCtx,
900
+ req
901
+ })
902
+ })(new Request(`http://${host}${path}`, {
903
+ method: "POST",
904
+ headers,
905
+ body: parsed.value,
906
+ signal: abort.signal
907
+ })), abort.signal);
908
+ }
909
+ };
910
+ }
911
+ //#endregion
912
+ 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.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as ActionContext } from "./context-C1UFQ0Zc.mjs";
1
+ import { t as ActionContext } from "./context-Ct8u5XUC.mjs";
2
2
  import { RpcClientOptions, createClient } from "./rpc/client.mjs";
3
3
  import { Layer, Stream } from "effect";
4
4
  import { Rpc, RpcGroup } from "effect/unstable/rpc";
@@ -38,8 +38,11 @@ declare function createWsHooks(group: RpcGroup.RpcGroup<Rpc.Any>, handlers: Laye
38
38
  //#region src/rpc/stream.d.ts
39
39
  declare function asyncGenToStream<T>(gen: AsyncGenerator<T, unknown, unknown>): Stream.Stream<T, Error, never>;
40
40
  /**
41
- * Re-enter `run` for every generator pull so AsyncLocalStorage request context
42
- * stays available across yields (Effect may drain the stream after the outer ALS scope ends).
41
+ * Re-enter `run` for every generator pull so request context stays available
42
+ * across yields (Effect may drain the stream after the outer ALS scope ends;
43
+ * WebContainer also loses ALS across awaits, so `run` must reinstall the store).
44
+ * On WebContainer, pulls are serialized through settlement so concurrent streams
45
+ * cannot replace the sync fallback mid-pull. `withRequestEntry` is unchanged.
43
46
  */
44
47
  declare function bindAsyncGenContext<T>(gen: AsyncGenerator<T, unknown, unknown>, run: <R>(fn: () => R) => R): AsyncGenerator<T, unknown, unknown>;
45
48
  /** Create a generator inside `run`, then keep every subsequent pull inside `run`. */
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-tYqxQToi.mjs";
2
+ import { a as streamToAsyncGen, i as bindAsyncGenContext, n as asyncGenToStream, r as asyncGenToStreamInContext, t as createClient } from "./client-BgBfkZDx.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-CurpVnGn.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-CurpVnGn.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.2",
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,42 +0,0 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
2
- //#region src/context.ts
3
- const ALS_KEY = Symbol.for("oxidejs.requestContext");
4
- const FETCH_KEY = Symbol.for("oxidejs.fetch");
5
- function als() {
6
- const g = globalThis;
7
- return g[ALS_KEY] ??= new AsyncLocalStorage();
8
- }
9
- function store() {
10
- const current = als().getStore();
11
- if (!current) throw new Error("oxidejs: request context is unavailable");
12
- return current;
13
- }
14
- /** Current RPC or host request context. Throws outside request handling. */
15
- function useCtx() {
16
- return store();
17
- }
18
- /** Current server `Request`. Available in actions, SSR, and frame renders. */
19
- function useRequest() {
20
- return store().req;
21
- }
22
- /** Worker `env` from `fetch(request, env, ctx)`. `undefined` on the Node fetch preset. */
23
- function useEnv() {
24
- return store().env;
25
- }
26
- /** Worker `ctx` from `fetch(request, env, ctx)` (`waitUntil`). `undefined` on Node. */
27
- function useFetchCtx() {
28
- return store().fetchCtx;
29
- }
30
- function runWithRequest(req, fn, extra) {
31
- return als().run({
32
- ...extra,
33
- req
34
- }, fn);
35
- }
36
- const HOOK_KEY = Symbol.for("oxidejs.runWithRequest");
37
- globalThis[HOOK_KEY] ??= (req, fn) => {
38
- const extra = req[FETCH_KEY];
39
- return runWithRequest(req, fn, extra);
40
- };
41
- //#endregion
42
- export { useRequest as a, useFetchCtx as i, useCtx as n, useEnv as r, runWithRequest as t };
@@ -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 };