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 +23 -6
- package/dist/{client-Bc4g9AEw.mjs → client-BgBfkZDx.mjs} +29 -8
- package/dist/{context-C1UFQ0Zc.d.mts → context-Ct8u5XUC.d.mts} +10 -1
- package/dist/context-DQDDwFYi.mjs +100 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{plugin-IQa_lKkT.mjs → plugin-CurpVnGn.mjs} +7 -6
- package/dist/plugin.mjs +1 -1
- package/dist/rpc/client.mjs +1 -1
- package/dist/{actions-DE6p5Cyp.mjs → rpc-tYqxQToi.mjs} +422 -14
- package/dist/rpc.d.mts +6 -3
- package/dist/rpc.mjs +2 -2
- package/dist/rsbuild.mjs +1 -1
- package/dist/vite.mjs +1 -1
- package/package.json +1 -1
- package/dist/context-zrTZyYpF.mjs +0 -42
- package/dist/rpc-DYFEdah8.mjs +0 -382
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
|
|
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
|
-
|
|
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
|
|
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
|
|
11
|
-
*
|
|
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) =>
|
|
16
|
-
return: (value) =>
|
|
17
|
-
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
|
|
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
|
-
|
|
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) =>
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 : [], ...
|
|
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("./
|
|
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-
|
|
1
|
+
import { n as unpluginFactory, r as vite, t as oxidejs } from "./plugin-CurpVnGn.mjs";
|
|
2
2
|
export { oxidejs as default, oxidejs, unpluginFactory, vite };
|
package/dist/rpc/client.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as createClient } from "../client-
|
|
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 {
|
|
191
|
+
`import { getRequestStore, withRequestStore } from "oxidejs";`,
|
|
188
192
|
`import { asyncGenToStreamInContext } from "oxidejs/rpc";`,
|
|
189
|
-
`const
|
|
190
|
-
`const
|
|
191
|
-
`
|
|
192
|
-
`
|
|
193
|
-
`
|
|
194
|
-
`
|
|
195
|
-
`
|
|
196
|
-
`
|
|
197
|
-
`
|
|
198
|
-
`
|
|
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
|
-
`
|
|
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 =
|
|
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
|
-
|
|
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-
|
|
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
|
|
42
|
-
*
|
|
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
|
|
2
|
-
import { a as
|
|
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
package/dist/vite.mjs
CHANGED
package/package.json
CHANGED
|
@@ -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 };
|
package/dist/rpc-DYFEdah8.mjs
DELETED
|
@@ -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 };
|