From deeac6ececf7e92865a747f5ec578f1156b0c452 Mon Sep 17 00:00:00 2001 From: Ryuz Date: Fri, 11 Sep 2026 11:42:37 +0200 Subject: [PATCH 1/3] feat(oxide): add action() openrpc generation --- AGENTS.md | 2 +- .../docs/(introduction)/getting-started.mdx | 5 +- apps/website/docs/oxide/configuration.mdx | 30 +-- apps/website/docs/oxide/overview.mdx | 7 +- apps/website/docs/oxide/quickstart.mdx | 7 +- apps/website/docs/oxide/security.mdx | 2 + apps/website/docs/oxide/server-actions.mdx | 2 + apps/website/docs/oxide/worker.mdx | 16 +- bun.lock | 2 +- packages/oxidejs/CHANGELOG.md | 26 +- packages/oxidejs/README.md | 16 +- packages/oxidejs/package.json | 6 +- packages/oxidejs/src/actions.test.ts | 14 ++ packages/oxidejs/src/actions.ts | 47 +++- packages/oxidejs/src/context.ts | 11 + packages/oxidejs/src/core.test.ts | 11 + packages/oxidejs/src/core.ts | 24 +- packages/oxidejs/src/index.ts | 7 + packages/oxidejs/src/openrpc.test.ts | 77 ++++++ packages/oxidejs/src/openrpc.ts | 238 ++++++++++++++++++ packages/oxidejs/src/oxide-plugins.test.ts | 82 +----- packages/oxidejs/src/plugin.ts | 82 +++++- packages/oxidejs/src/plugins/celld.ts | 38 --- packages/oxidejs/src/types.ts | 12 +- packages/oxidejs/src/wrangler.test.ts | 89 ++++++- packages/oxidejs/src/wrangler.ts | 80 +++++- packages/oxidejs/tsdown.config.ts | 1 - templates/kit/README.md | 8 +- templates/kit/package.json | 6 +- templates/kit/vite.config.ts | 5 +- templates/kit/wrangler.jsonc | 6 +- templates/simple/package.json | 2 +- 32 files changed, 757 insertions(+), 204 deletions(-) create mode 100644 packages/oxidejs/src/openrpc.test.ts create mode 100644 packages/oxidejs/src/openrpc.ts delete mode 100644 packages/oxidejs/src/plugins/celld.ts diff --git a/AGENTS.md b/AGENTS.md index 05e2969..7ff4726 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ ## oxidejs conventions -- Default is `"fetch"` when no wrangler config is at the project root; `"worker"` when `wrangler.jsonc` / `wrangler.toml` / `wrangler.json` exists (overridable). Workers apps use `@cloudflare/vite-plugin` + root wrangler (oxide supplies `virtual:oxide/worker`; wrap Cloudflare options with `withOxide` from `oxidejs/wrangler`). For celld, add `plugins: ["oxidejs/plugins/celld"]` and set `OXIDE_CELLD=1` on celld-only scripts so prepare does not pollute Cloudflare deploys. +- Default is `"fetch"` when no wrangler config is at the project root; `"worker"` when `wrangler.jsonc` / `wrangler.toml` / `wrangler.json` exists (overridable). Workers apps use `@cloudflare/vite-plugin` + root wrangler (oxide supplies `virtual:oxide/worker`; wrap Cloudflare options with `withOxide` from `oxidejs/wrangler`). Production builds with a Cloudflare Vite `dist/ssr/wrangler.json` snapshot also write a celld-ready `dist/wrangler.json` + `dist/celld/entry.js` via `prepareCelldDeploy` (outside `ssr/` so Cloudflare deploy does not upload a second Worker). - `*.server.ts` / `*.server.js` are server-only. Client imports become Effect RPC stubs on `/__oxide/action` (HTTP or WebSocket). Method names are `.` (`test.ping`). - `workflow()` in `*.server.ts` (`preset: "worker"`): Cloudflare Workflows driver — class export, wrangler `workflows` via `withOxide` / `mergeDurableBindings`, RPC `name.start` / `name.status` / `name.send`. `run(event, { step, env? })` receives a context bag (not a bare `step`). - `queue({ name, workflow })` in `*.server.ts` (`preset: "worker"`): Cloudflare Queues — wrangler `queues` via merge, same-worker `queue` handler starts the workflow per message (envelope id from `send` → `{ id }`), RPC `name.send` / `name.sendBatch`. Optional `producerStart: true` also starts the workflow from the producer for celld (same-worker consumers do not run with `fetch()`); default off so Cloudflare queue semantics control execution. diff --git a/apps/website/docs/(introduction)/getting-started.mdx b/apps/website/docs/(introduction)/getting-started.mdx index 83c296e..723b739 100644 --- a/apps/website/docs/(introduction)/getting-started.mdx +++ b/apps/website/docs/(introduction)/getting-started.mdx @@ -13,7 +13,7 @@ description: Oxide turns Vite or Rsbuild into a typed server — and an optional href="/oxide/configuration" icon="settings" > - Presets, action transport, paths, and deploy keys. + Presets, action transport, paths, plugins, and celld prepare. @@ -26,5 +26,6 @@ description: Oxide turns Vite or Rsbuild into a typed server — and an optional - [Server entry](/oxide/server-entry) — handle a request, or fall through to static files - [Server actions](/oxide/server-actions) — typed RPC, streams, live queries, Schema, Effect -- [Configuration](/oxide/configuration) — `preset`, paths, and actions +- [Worker](/oxide/worker) — workflows, queues, and cron schedules +- [Configuration](/oxide/configuration) — `preset`, paths, actions, and celld - [Security](/oxide/security) — what Oxide guards by default diff --git a/apps/website/docs/oxide/configuration.mdx b/apps/website/docs/oxide/configuration.mdx index b972c7d..1333448 100644 --- a/apps/website/docs/oxide/configuration.mdx +++ b/apps/website/docs/oxide/configuration.mdx @@ -13,9 +13,10 @@ Pass options to `oxide()` in your Vite or Rsbuild config. Start with `oxide()` | [`workerEntry`](#workerentry) | `src/server.ts` | Server entry file (optional when default is missing) | | [`outDir`](#outdir) | `dist` | Build output folder | | [`clientDir`](#clientdir) | `client` | Client folder inside `outDir` | -| [`actions`](#actions) | `"http"` | Action transport, endpoint path, and same-origin protection | +| [`actions`](#actions) | `"http"` | Action transport, endpoint path, same-origin, OpenRPC | | [`actionHeaders`](#actionheaders) | - | Public static headers added by the browser action client | -| [`middleware`](#middleware) | `[]` | Production request middleware, in order | +| [`middleware`](#middleware) | `[]` | Request middleware, in order | +| [`plugins`](#plugins) | `[]` | Production `beforeBuild` / `afterBuild` hooks | | [`imports`](#imports) | `[]` | Side-effect modules loaded at server startup | | [`bodyLimit`](#bodylimit) | `1048576` | Max Node request body size (413 when exceeded) | | [`notFound`](#notfound) | - | Custom HTML 404 body on Node | @@ -46,7 +47,11 @@ export default defineConfig({ }); ``` -Point root `wrangler.jsonc` `main` at a thin entry that re-exports `virtual:oxide/worker`. Wrap Cloudflare options with `withOxide` from `oxidejs/wrangler` so scanned `workflow()` / `queue()` / `schedule()` bindings merge and the Worker uses Vite's `ssr` environment (or call `mergeDurableBindings` yourself inside `config`). +Point root `wrangler.jsonc` `main` at a thin entry that re-exports `virtual:oxide/worker`. Wrap Cloudflare options with `withOxide` from `oxidejs/wrangler` so scanned `workflow()` / `queue()` / `schedule()` bindings merge and the Worker uses Vite's `ssr` environment (or call `mergeDurableBindings` yourself inside `config`). Durable bindings are not oxide options — they merge through `withOxide`. + +### celld + +When Cloudflare Vite writes `dist/ssr/wrangler.json`, every production oxide build also runs `prepareCelldDeploy`: it writes `dist/wrangler.json` with celld-safe paths (`main: "celld/entry.js"`, `assets.directory: "client"`), strips `.assetsignore` and Cloudflare-only keys (`no_bundle`, `workers_dev`, …), writes a stripped entry under `dist/celld/` (outside `ssr/`, so Cloudflare deploy does not upload a second Worker module), and merges project `.dev.vars` into `vars`. Cloudflare `wrangler deploy` still uses the Vite SSR snapshot; `celld deploy dist` uses the rewritten root config. Call `prepareCelldDeploy("dist")` yourself only outside a normal oxide build. Helpers `toCelldWrangler` / `writeCelldWrangler` live on `oxidejs/wrangler`. ## workerEntry @@ -76,7 +81,7 @@ import oxide from "oxidejs/vite"; oxide({ actions: "http" }); // default ``` -The object form also configures the URL path and same-origin protection: +The object form also configures the URL path, same-origin protection, and OpenRPC discovery: ```ts twoslash import oxide from "oxidejs/vite"; @@ -86,6 +91,7 @@ oxide({ transport: "http", path: "/__oxide/action", sameOrigin: true, + openrpc: true, }, }); ``` @@ -93,6 +99,7 @@ oxide({ - `transport` is `"http"` by default. `"ws"` uses WebSocket (`crossws` on Node, `WebSocketPair` with [`preset: "worker"`](#preset)). - `path` defaults to `/__oxide/action`. It must start with `/` and cannot contain a query string. - `sameOrigin` defaults to `true` for HTTP and WebSocket. This blocks browser calls from another site, including sibling subdomains. +- `openrpc` defaults to `false`. When `true`, Oxide serves `GET /__oxide/openrpc` with an [OpenRPC](https://spec.open-rpc.org/) 1.3 document for `action()` handlers only (payload / success / error from `ACTION_META`; workflows, queues, and schedules are omitted). With `"worker"`, the generated Worker calls `server.accept()` for upgrades. Durable Object apps that need hibernation can pass a custom `accept` into `createWsHooks` (for example `ctx.acceptWebSocket(ws)`). The generated wrapper does not create a DO for you. @@ -140,6 +147,8 @@ export default defineConfig({ Modules run in array order, before the action gate. The second argument contains the runtime's `env` and fetch `ctx`. The same handlers are loaded through the SSR graph in development, so dev and production behave identically. +On WebSocket upgrades, a middleware `Response` short-circuits (auth `302` / `401` / …). Oxide ignores only `@ilha/router/ssr` document Responses so upgrades still reach `WebSocketPair` / celld. On other requests, return a `Response` to stop the chain or `undefined` to continue. + Entries can also be objects that carry their own side-effect imports — modules loaded at startup before the handler runs: ```ts twoslash @@ -173,22 +182,13 @@ import oxide from "oxidejs/vite"; export default defineConfig({ plugins: [ oxide({ - plugins: ["oxidejs/plugins/celld"], + plugins: ["./build-plugin.ts"], }), ], }); ``` -`oxidejs/plugins/celld` runs `prepareCelldDeploy` after a production build when `OXIDE_CELLD=1` and `dist/ssr/wrangler.json` exists (Cloudflare Vite layout). Set the env on celld-only scripts so Cloudflare `wrangler deploy` does not upload `celld-entry.js`. - -```json -{ - "scripts": { - "deploy": "vite build && wrangler deploy", - "deploy:celld": "OXIDE_CELLD=1 vite build && celld deploy dist" - } -} -``` +Hooks run once per production build (not during `vite` / `vite dev`). Specifiers are dynamic-imported and must default-export an `OxidePlugin`. Celld prepare is built-in when a Cloudflare Vite snapshot exists — see [celld](#celld) — not a plugin. ## imports diff --git a/apps/website/docs/oxide/overview.mdx b/apps/website/docs/oxide/overview.mdx index ce66941..90dacd5 100644 --- a/apps/website/docs/oxide/overview.mdx +++ b/apps/website/docs/oxide/overview.mdx @@ -19,15 +19,16 @@ Use Oxide when you want a small typed full-stack app: a server entry, optional s - **One output tree** — `dist/server.js`, and a client only when you have a page (Node). Workers apps use `@cloudflare/vite-plugin` for the build layout. - **Server actions** — `export const x = action(...)` in `*.server.ts`. Import `x` from the browser; Oxide swaps in an Effect RPC stub. Types stay shared. Secrets and helpers that are not wrapped in `action()` never cross the wire. See [Server actions](/oxide/server-actions). - **Streams and live data** — `async function*` over newline-delimited JSON-RPC; `liveQuery` for topic snapshots on WebSocket. -- **Node or Cloudflare** — default Node when no wrangler file. [`preset`](/oxide/configuration#preset) is `"worker"` when `wrangler.jsonc` / `.toml` is present (or set it yourself). +- **Node or Cloudflare** — default Node when no wrangler file. [`preset`](/oxide/configuration#preset) is `"worker"` when `wrangler.jsonc` / `.toml` / `.json` is present (or set it yourself). +- **Workflows, queues, schedules** — Cloudflare / celld durable work from `*.server.ts`. See [Worker](/oxide/worker). - **Vite and Rsbuild** — same options, same Node layout. -You can ship a server-only worker (no `index.html`) or a UI that calls actions with no hand-written `/api` routes. +You can ship a server-only worker (no `index.html`) or a UI that calls actions with no hand-written `/api` routes. Worker production builds also prepare a celld-ready `dist/wrangler.json` when Cloudflare Vite leaves an SSR snapshot. ## What it does not do - No oxide-owned workerd emulation (use `@cloudflare/vite-plugin`) -- No automatic celld / wrangler deploy +- No automatic `celld deploy` / `wrangler deploy` (prepare only) - No Node-builtin polyfills — a stray Node import fails the build ## Next steps diff --git a/apps/website/docs/oxide/quickstart.mdx b/apps/website/docs/oxide/quickstart.mdx index 08a8571..dfb8756 100644 --- a/apps/website/docs/oxide/quickstart.mdx +++ b/apps/website/docs/oxide/quickstart.mdx @@ -127,7 +127,7 @@ No `index.html` → only `dist/server.js`. With `index.html` → client assets i ### Cloudflare Workers -Add a root `wrangler.jsonc` (preset defaults to `"worker"`) and `@cloudflare/vite-plugin`. +Add a root `wrangler.jsonc` (preset defaults to `"worker"`) and `@cloudflare/vite-plugin`. Wrap Cloudflare options with `withOxide` so durable bindings merge. ```ts twoslash import { cloudflare } from "@cloudflare/vite-plugin"; @@ -140,7 +140,7 @@ export default defineConfig({ }); ``` -See [`preset`](/oxide/configuration#preset). +Production builds that emit `dist/ssr/wrangler.json` also write a celld-ready `dist/wrangler.json`. See [`preset`](/oxide/configuration#preset) and [Worker](/oxide/worker). ### Server only @@ -150,4 +150,5 @@ Leave out `index.html`. The plugin emits `dist/server.js` and does not build a c - [Server entry](/oxide/server-entry) — `src/server.ts` and fallthrough - [Server actions](/oxide/server-actions) — Schema, streams, live queries, WebSocket -- [Configuration](/oxide/configuration) — `preset`, paths, actions +- [Worker](/oxide/worker) — workflows, queues, schedules +- [Configuration](/oxide/configuration) — `preset`, paths, actions, celld diff --git a/apps/website/docs/oxide/security.mdx b/apps/website/docs/oxide/security.mdx index c19bc36..f5269ee 100644 --- a/apps/website/docs/oxide/security.mdx +++ b/apps/website/docs/oxide/security.mdx @@ -36,6 +36,8 @@ Generated `__asset` uses `path.join` — not `path.resolve` — so a leading `/` Set `sameOrigin: false` only when you accept cross-origin calls and provide CORS, CSRF, and authentication. See [`middleware`](/oxide/configuration#middleware). +`actions.sameOrigin` defaults to `true`. HTTP actions require `Origin` and/or `Sec-Fetch-Site` and reject cross-site callers. WebSocket upgrades also allow requests that omit both headers when `Host` matches the request URL host (celld and some proxies strip those headers; `localhost` / `127.0.0.1` / `::1` count as the same host). + ## Host header The generated Node server builds `request.url` from `req.headers.host` (same pattern as Express, Hono, Koa). If your entry uses that URL for redirects, validate the host yourself. In production your reverse proxy usually owns this. diff --git a/apps/website/docs/oxide/server-actions.mdx b/apps/website/docs/oxide/server-actions.mdx index 3050943..09f36f6 100644 --- a/apps/website/docs/oxide/server-actions.mdx +++ b/apps/website/docs/oxide/server-actions.mdx @@ -409,4 +409,6 @@ oxide({ }); ``` +Set `actions.openrpc: true` to serve `GET /__oxide/openrpc` — an [OpenRPC](https://spec.open-rpc.org/) 1.3 document for `action()` handlers only (Effect Schema → JSON Schema; workflows, queues, and schedules are omitted). See [`actions`](/oxide/configuration#actions). + Set `sameOrigin: false` only when you accept another origin and own CORS, CSRF, and authentication. For static client headers, see [`actionHeaders`](/oxide/configuration#actionheaders) — never put a private secret there. diff --git a/apps/website/docs/oxide/worker.mdx b/apps/website/docs/oxide/worker.mdx index b4328b8..1279c0b 100644 --- a/apps/website/docs/oxide/worker.mdx +++ b/apps/website/docs/oxide/worker.mdx @@ -7,11 +7,11 @@ description: Workflows, queues, and cron schedules for Cloudflare Workers mode. When you use `preset: "worker"` (default when a root wrangler config exists), Oxide targets Cloudflare Workers semantics (and the same-worker runtime shape on celld). -This page documents the `*.server.ts` exports that wire into Cloudflare Workflows, Queues, and Cron triggers. +This page documents the `*.server.ts` exports that wire into Cloudflare Workflows, Queues, and Cron triggers. Merge durable bindings with `withOxide` (or `mergeDurableBindings`) from `oxidejs/wrangler` — see [`preset`](/oxide/configuration#preset). ## Workflows -Durable multi-step jobs on Cloudflare Workflows. Export `workflow()` from a `*.server.ts` file: +Durable multi-step jobs on Cloudflare Workflows / celld. Export `workflow()` from a `*.server.ts` file: ```ts twoslash src/invoice.server.ts import { Schema } from "effect"; @@ -34,7 +34,9 @@ export const invoice = workflow({ }); ``` -Defaults: binding `INVOICE`, class `InvoiceWorkflow`. Override with `binding` / `className`. +Defaults: binding `INVOICE`, class `InvoiceWorkflow`. Override with `binding` / `className` (string literals — the build scanner does not evaluate variables). + +The second `run` argument is a context bag: `{ step, env?, signal? }`. Oxide also stamps a request store so `useEnv()` / `useCtx()` work inside `run`. Keep side effects inside `step.do` — the runtime replays `run()` from the start for deterministic recovery. Pass `{ idempotencyKey }` on `start` for a stable instance id. Retries with the same id reuse the existing instance. `status` returns `{ status: "not_found" }` when the instance does not exist yet. From the client or server: @@ -60,8 +62,6 @@ const { id } = await invoice.start({ orderId: "ord_1" }); const status = await invoice.status(id); ``` -Keep side effects inside `step.do`. The runtime replays `run()` from the start for deterministic recovery. Pass `{ idempotencyKey }` on `start` for a stable instance id. - ## Queues Buffer work, then start a workflow per message (durable / resumable). Export `queue()` from a `*.server.ts` file next to the workflow it drives: @@ -130,6 +130,10 @@ export const nightly = schedule({ }); ``` -Exactly one of `workflow` / `queue` / `handle`. Oxide merges unique cron expressions into `triggers.crons` and attaches a same-worker `scheduled` handler. Each tick starts the workflow with id `` `${name}-${scheduledTime}` `` (idempotent retries; letters/digits/`-`/`_` only). +Exactly one of `workflow` / `queue` / `handle`. Oxide merges unique cron expressions into `triggers.crons` and attaches a same-worker `scheduled` handler. Each tick starts the workflow with id `` `${name}-${scheduledTime}` `` (idempotent retries; letters/digits/`-`/`_` only — celld and Cloudflare reject `:`). `params` may be a value or `(event) => value`; payload schema comes from the workflow/queue handle. + +## Deploy (Cloudflare and celld) + +Cloudflare owns `wrangler deploy` from the Vite SSR snapshot (`dist/ssr`). The same production `vite build` also writes a celld-ready `dist/wrangler.json` when that snapshot exists — no extra plugin or env flag. See [celld](/oxide/configuration#celld). diff --git a/bun.lock b/bun.lock index fdb508d..6b96b4f 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/oxidejs": { "name": "oxidejs", - "version": "0.5.2", + "version": "0.5.3", "dependencies": { "effect": "~4.0.0-rc.112", "linkedom": "^0.18.13", diff --git a/packages/oxidejs/CHANGELOG.md b/packages/oxidejs/CHANGELOG.md index e1328cb..6c499fb 100644 --- a/packages/oxidejs/CHANGELOG.md +++ b/packages/oxidejs/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.5.3 + +### Added + +- `actions: { openrpc: true }` serves `GET /__oxide/openrpc` — OpenRPC 1.3 document for `action()` handlers (Effect Schema → JSON Schema; workflows/queues/schedules omitted) + +### Changed + +- Production builds with a Cloudflare Vite `dist/ssr/wrangler.json` snapshot always run `prepareCelldDeploy` (removed `oxidejs/plugins/celld` and the earlier `OXIDE_CELLD=1` gate) +- `prepareCelldDeploy` writes the stripped Worker entry to `dist/celld/entry.js` (not beside Cloudflare's `ssr/` main) so `wrangler deploy` does not upload a second module +- Celld prepare also runs when the Vite `ssr` / `server` environment closes, so it still sees the snapshot when `client` finishes first +- Dev OpenRPC 503 responses use a generic error string (no SSR load message leak) +- `codecToJsonSchema` no longer swallows `toJsonSchemaDocument` failures + +### Fixed + +- `rewriteRelativeModuleSpecifiers` rewrites minified `from "…"` imports that use `$` bindings (celld esbuild could not resolve `./assets/…` from `dist/celld/entry.js`) + +### Breaking + +- Removed the `oxidejs/plugins/celld` export — celld prepare is built into production builds when `dist/ssr/wrangler.json` exists; drop `plugins: ["oxidejs/plugins/celld"]` from your config + ## 0.5.2 ### Breaking @@ -32,11 +54,11 @@ ### Added -- `plugins` option — `beforeBuild` / `afterBuild` once per production build; `"oxidejs/plugins/celld"` runs `prepareCelldDeploy` after build when `OXIDE_CELLD=1` and `dist/ssr/wrangler.json` exists (keeps Cloudflare deploys clean) +- `plugins` option — `beforeBuild` / `afterBuild` once per production build - `oxidejs/wrangler` → `withOxide` HOF for `cloudflare(withOxide())` (defaults `viteEnvironment.name` to `"ssr"`, merges durable bindings; optional `root` when Vite root ≠ cwd), plus `mergeDurableBindings` for hand-rolled `config` customizers - Relative `plugins` module IDs resolve against the project root - `toCelldWrangler` / `writeCelldWrangler` — strip Cloudflare Vite wrangler snapshot keys that `celld deploy` rejects (including `no_bundle`, so celld esbuilds the multi-chunk Vite Worker into one module) -- `prepareCelldDeploy` — write `dist/wrangler.json` with in-project paths for Cloudflare Vite (`ssr/` + `client/`) layouts; strip `.assetsignore`; rewrite `main` to `ssr/celld-entry.js` without bare `node:fs` / `node:path` side-effect imports (celld 0.4 has no `node:fs` stub) +- `prepareCelldDeploy` — write `dist/wrangler.json` with in-project paths for Cloudflare Vite (`ssr/` + `client/`) layouts; strip `.assetsignore`; rewrite `main` to `celld/entry.js` without bare `node:fs` / `node:path` side-effect imports (celld 0.4 has no `node:fs` stub) ## 0.5.0 diff --git a/packages/oxidejs/README.md b/packages/oxidejs/README.md index 44c6a55..17aaba5 100644 --- a/packages/oxidejs/README.md +++ b/packages/oxidejs/README.md @@ -47,7 +47,7 @@ import oxide from "oxidejs/vite"; export default defineConfig({ plugins: [ - oxide({ actions: "ws", middleware: [...], plugins: ["oxidejs/plugins/celld"] }), + oxide({ actions: "ws", middleware: [...] }), cloudflare(withOxide()), ], }); @@ -343,7 +343,7 @@ export class ActionRoom { The generated worker wrapper does not create a DO — hibernation is opt-in when you own the object. -`vite dev` and `rsbuild dev` serve the endpoint via middleware. `actions: "http"` (default) serves `/__oxide/action`; `actions: "ws"` uses a WebSocket instead (`crossws` on Node/`fetch`, `WebSocketPair` on `preset: "worker"`). `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. +`vite dev` and `rsbuild dev` serve the endpoint via middleware. `actions: "http"` (default) serves `/__oxide/action`; `actions: "ws"` uses a WebSocket instead (`crossws` on Node/`fetch`, `WebSocketPair` on `preset: "worker"`). `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. Set `actions.openrpc: true` to serve `GET /__oxide/openrpc` — an [OpenRPC](https://spec.open-rpc.org/) 1.3 document for `action()` handlers only (Effect Schema → JSON Schema; workflows/queues/schedules are omitted). `actionHeaders` are static headers on the shared HTTP client and are ignored for WebSocket actions. ## Rsbuild @@ -367,10 +367,10 @@ Same factory as Vite: client stubs, `/__oxide/action`, and `dist/server.js`. | `workerEntry` | `src/server.ts` | Relative to project root. Default path is skipped when missing (actions-only). Explicit path must exist. | | `outDir` | `dist` | Output root (Node / `"fetch"`) | | `clientDir` | `client` | Must stay inside `outDir` | -| `actions` | `"http"` | `"ws"` uses WebSocket (`crossws` on Node, `WebSocketPair` with `"worker"`); object form: `{ transport, path, sameOrigin }` (`sameOrigin: true`) | +| `actions` | `"http"` | `"ws"` uses WebSocket (`crossws` on Node, `WebSocketPair` with `"worker"`); object form: `{ transport, path, sameOrigin, openrpc }` (`sameOrigin: true`, `openrpc: false`) | | `actionHeaders` | — | Static headers on the HTTP client | | `middleware` | `[]` | Fetch middleware. On WS: honor Responses (auth 302/401/…); ignore only `@ilha/router/ssr` document Responses. Otherwise Response short-circuits before actions / server entry. | -| `plugins` | `[]` | Build plugins (`beforeBuild` / `afterBuild`). Pass objects or module IDs such as `"oxidejs/plugins/celld"`. | +| `plugins` | `[]` | Build plugins (`beforeBuild` / `afterBuild`). Pass objects or module IDs. | | `imports` | `[]` | Modules imported for side effects at server startup | | `bodyLimit` | `1048576` | Max Node request body size; larger requests get 413 | | `notFound` | — | Custom HTML 404 body when no route or asset matches | @@ -380,19 +380,17 @@ Durable bindings (`workflow` / `queue` / `schedule`) merge via `withOxide` (or ` `writeCelldWrangler(path)` / `toCelldWrangler(config)` strip Cloudflare Vite snapshot keys that `celld deploy` rejects (`workers_dev`, `dev`, `jsx_*`, `no_bundle`, …). Dropping `no_bundle` matters: the Vite Worker graph is multi-chunk, and celld only stubs `node:*` / `cloudflare:*` — relative `./assets/…` imports fail at load unless celld runs esbuild. -For the Cloudflare Vite layout (`dist/ssr` + `dist/client`), add `plugins: ["oxidejs/plugins/celld"]` and set `OXIDE_CELLD=1` on celld-only scripts (`dev:celld` / `deploy:celld`) so prepare does not run during `wrangler deploy` — or call `prepareCelldDeploy("dist")` yourself. That writes `dist/wrangler.json` with relocated paths (`main: "ssr/celld-entry.js"`, `assets.directory: "client"`), removes Cloudflare-only asset files celld rejects (`.assetsignore`), strips bare unused `import "node:fs"` / `import "node:path"` side-effects (celld 0.4 has no `node:fs` stub), and merges project `.dev.vars` into `vars` (celld does not load `.dev.vars` like `wrangler dev`). Keep the allowlist aligned with celld's `SUPPORTED_KEYS`. +For the Cloudflare Vite layout (`dist/ssr` + `dist/client`), production builds automatically run `prepareCelldDeploy` when `dist/ssr/wrangler.json` exists — writing `dist/wrangler.json` with relocated paths (`main: "celld/entry.js"`, `assets.directory: "client"`), removing Cloudflare-only asset files celld rejects (`.assetsignore`), stripping bare unused `import "node:fs"` / `import "node:path"` side-effects into `dist/celld/entry.js` (outside `ssr/`, so Cloudflare deploy does not upload a second Worker module), and merging project `.dev.vars` into `vars` (celld does not load `.dev.vars` like `wrangler dev`). Keep the allowlist aligned with celld's `SUPPORTED_KEYS`. Call `prepareCelldDeploy("dist")` yourself only if you need the rewrite outside a normal oxide build. Cloudflare `wrangler deploy` still uses the Vite SSR snapshot; the extra `dist/wrangler.json` / `dist/celld/` are for celld. ### `plugins` ```ts oxide({ - plugins: ["oxidejs/plugins/celld"], + plugins: ["./build-plugin.ts"], }); -// package.json -// "deploy:celld": "OXIDE_CELLD=1 vite build && celld deploy dist" ``` -Each plugin may define `beforeBuild` and/or `afterBuild` `(ctx) => void | Promise` where `ctx` is `{ root, outDir, preset }`. Hooks run once per production build (not during `vite` / `vite dev`). Specifiers are dynamic-imported (relative paths against the project root) and must default-export an `OxidePlugin`. The bundled `oxidejs/plugins/celld` plugin runs `prepareCelldDeploy` after build when `OXIDE_CELLD=1` and `dist/ssr/wrangler.json` exists. +Each plugin may define `beforeBuild` and/or `afterBuild` `(ctx) => void | Promise` where `ctx` is `{ root, outDir, preset }`. Hooks run once per production build (not during `vite` / `vite dev`). Specifiers are dynamic-imported (relative paths against the project root) and must default-export an `OxidePlugin`. ### `middleware` and `imports` diff --git a/packages/oxidejs/package.json b/packages/oxidejs/package.json index 964fc3f..a8f2205 100644 --- a/packages/oxidejs/package.json +++ b/packages/oxidejs/package.json @@ -1,6 +1,6 @@ { "name": "oxidejs", - "version": "0.5.2", + "version": "0.5.3", "description": "Vite/Rsbuild plugin. One build → dist/server.js + optional client. Server actions via *.server.ts.", "keywords": [ "cloudflare", @@ -41,10 +41,6 @@ "types": "./dist/wrangler.d.mts", "import": "./dist/wrangler.mjs" }, - "./plugins/celld": { - "types": "./dist/plugins/celld.d.mts", - "import": "./dist/plugins/celld.mjs" - }, "./rsbuild": { "types": "./dist/rsbuild.d.mts", "import": "./dist/rsbuild.mjs" diff --git a/packages/oxidejs/src/actions.test.ts b/packages/oxidejs/src/actions.test.ts index a222b68..d6e27a6 100644 --- a/packages/oxidejs/src/actions.test.ts +++ b/packages/oxidejs/src/actions.test.ts @@ -472,6 +472,8 @@ ${stubSource}` expect(code).toContain('"test.ping": ({ args }) =>'); expect(code).toContain("const __s = getRequestStore()"); expect(code).toContain(".apply(null, args)"); + expect(code).toContain("export const actionsOpenRpcEntries = ["); + expect(code).toContain('{ name: "test.ping", meta: __meta_0_ping }'); expect(code).not.toContain("AsyncLocalStorage"); expect(code).not.toContain('"_action": {'); expect(code).not.toContain("__args"); @@ -549,6 +551,18 @@ ${stubSource}` expect(code).not.toContain(": __nf()"); expect(code).toContain("export * from"); expect(code).toContain("...(user ?? {})"); + expect(code).not.toContain("createOpenRpcResponse"); + }); + + test("wrapper serves OpenRPC when actions.openrpc is enabled", () => { + const code = generateWorkerWrapper("/app/src/server.ts", { + actionOpenRpc: true, + preset: "fetch", + }); + expect(code).toContain("createOpenRpcResponse"); + expect(code).toContain("matchesOpenRpcPath"); + expect(code).toContain("actionsOpenRpcEntries"); + expect(code).not.toContain("OPENRPC_PATH"); }); test("worker wrapper SPA-falls back to / when client exists", () => { diff --git a/packages/oxidejs/src/actions.ts b/packages/oxidejs/src/actions.ts index b59c74d..a6b99d5 100644 --- a/packages/oxidejs/src/actions.ts +++ b/packages/oxidejs/src/actions.ts @@ -555,6 +555,14 @@ export const generateActionsModule = function generateActionsModule( } lines.push( `});`, + `export const actionsOpenRpcEntries = [`, + ...aliases.flatMap(({ i, mod }) => + mod.exports.map((name) => { + const tag = `${mod.key}.${name}`; + return ` { name: ${JSON.stringify(tag)}, meta: __meta_${i}_${name} },`; + }) + ), + `];`, `export default actionsGroup;`, `export { actionsGroup as actions };` ); @@ -598,6 +606,7 @@ const pipeResponse = async function pipeResponse( }; interface WorkerWrapperOpts { + actionOpenRpc?: boolean; actionPath?: string; actionSameOrigin?: boolean; actions?: OxidejsActionTransport; @@ -826,23 +835,46 @@ const buildActionImports = function buildActionImports( hasActions: boolean, ws: boolean, actionPath: string, - sameOrigin: boolean + sameOrigin: boolean, + openrpc: boolean ): string { if (!hasActions) { - return ""; + return openrpc + ? `import { createOpenRpcResponse, matchesOpenRpcPath } from "oxidejs"; +const __openRpcEntries = []; +` + : ""; } + const openRpcImport = openrpc + ? `import { createOpenRpcResponse, matchesOpenRpcPath } from "oxidejs"; +import { actionsOpenRpcEntries as __openRpcEntries } from ${JSON.stringify(VIRTUAL_ACTIONS_ID)}; +` + : ""; if (ws) { - return `import { createWsHooks } from "oxidejs/rpc"; + return `${openRpcImport}import { createWsHooks } from "oxidejs/rpc"; import { actionsGroup, actionsHandlers } from ${JSON.stringify(VIRTUAL_ACTIONS_ID)}; const __ws = createWsHooks(actionsGroup, actionsHandlers, { path: ${JSON.stringify(actionPath)}, sameOrigin: ${sameOrigin} }); `; } - return `import { createActionHandler } from "oxidejs/rpc"; + return `${openRpcImport}import { createActionHandler } from "oxidejs/rpc"; import { actionsGroup, actionsHandlers } from ${JSON.stringify(VIRTUAL_ACTIONS_ID)}; const __rpc = createActionHandler(actionsGroup, actionsHandlers, { path: ${JSON.stringify(actionPath)}, sameOrigin: ${sameOrigin}, createContext: (req) => req[__fetch] ?? {} }); `; }; +const buildOpenRpcGate = function buildOpenRpcGate( + openrpc: boolean, + actionPath: string +): string { + if (!openrpc) { + return ""; + } + return `if (matchesOpenRpcPath(new URL(request.url).pathname)) { + return createOpenRpcResponse(__openRpcEntries, request, { actionPath: ${JSON.stringify(actionPath)} }); + } + `; +}; + const isIlhaSsrMiddleware = function isIlhaSsrMiddleware( entry: string | MiddlewareModuleConfig ): boolean { @@ -1013,6 +1045,7 @@ export const generateWorkerWrapper = function generateWorkerWrapper( const clientDir = opts.clientDir ?? "client"; const actionPath = opts.actionPath ?? ACTION_PATH; const sameOrigin = opts.actionSameOrigin ?? false; + const openrpc = opts.actionOpenRpc === true; const serveAssets = preset === "fetch" && (opts.hasClient === true || opts.hasPublic === true); const hasActions = opts.hasActions !== false; @@ -1036,9 +1069,11 @@ export const generateWorkerWrapper = function generateWorkerWrapper( hasActions, ws, actionPath, - sameOrigin + sameOrigin, + openrpc ); const actionMatchFn = `const __actionMatch = (p) => p === ${JSON.stringify(actionPath)} || p === ${JSON.stringify(`${actionPath}/`)};`; + const openRpcGate = buildOpenRpcGate(openrpc, actionPath); const actionGate = buildActionGate(hasActions, ws); const wsUpgradeGate = buildWsUpgradeGate(hasActions, ws, opts.middleware); const mwEntries = normalizeMiddlewareEntries(opts.middleware ?? []); @@ -1062,7 +1097,7 @@ ${middlewareImports}${actionImports}${hasActions ? `${actionMatchFn}\n` : ""}${a ...(user ?? {}), async fetch(request, env, ctx) { request[__fetch] = { env, fetchCtx: ctx }; - ${wsUpgradeGate}${middlewareGate}${actionGate}${afterAction} + ${wsUpgradeGate}${openRpcGate}${middlewareGate}${actionGate}${afterAction} }${queueBits.method}${scheduleBits.method}, }; ${workflowExport}export default app; diff --git a/packages/oxidejs/src/context.ts b/packages/oxidejs/src/context.ts index 82490c5..2c8d2ec 100644 --- a/packages/oxidejs/src/context.ts +++ b/packages/oxidejs/src/context.ts @@ -172,3 +172,14 @@ export type { WorkflowStep, WorkflowStepConfig, } from "./workflow"; +export { + buildOpenRpcDocument, + codecToJsonSchema, + createOpenRpcResponse, + matchesOpenRpcPath, + OPENRPC_PATH, +} from "./openrpc"; +export type { + BuildOpenRpcDocumentOptions, + OpenRpcActionEntry, +} from "./openrpc"; diff --git a/packages/oxidejs/src/core.test.ts b/packages/oxidejs/src/core.test.ts index 7eff9d7..f509354 100644 --- a/packages/oxidejs/src/core.test.ts +++ b/packages/oxidejs/src/core.test.ts @@ -41,6 +41,7 @@ describe("resolveOptions", () => { expect(resolved.actions).toBe("http"); expect(resolved.actionPath).toBe("/__oxide/action"); expect(resolved.actionSameOrigin).toBe(true); + expect(resolved.actionOpenRpc).toBe(false); }); test("defaults to worker when wrangler.jsonc exists", () => { @@ -84,6 +85,16 @@ describe("resolveOptions", () => { expect(resolved.actionSameOrigin).toBe(false); }); + test("resolves actions.openrpc", () => { + expect( + resolveOptions({ actions: { openrpc: true } }, process.cwd()) + .actionOpenRpc + ).toBe(true); + expect(resolveOptions({ actions: "ws" }, process.cwd()).actionOpenRpc).toBe( + false + ); + }); + test("rejects actions.path with a query string", () => { expect(() => resolveOptions({ actions: { path: "rpc?bad" } }, process.cwd()) diff --git a/packages/oxidejs/src/core.ts b/packages/oxidejs/src/core.ts index fa9d265..8174505 100644 --- a/packages/oxidejs/src/core.ts +++ b/packages/oxidejs/src/core.ts @@ -131,6 +131,7 @@ export const hasHtmlEntry = function hasHtmlEntry( }; interface ResolvedActions { + openrpc: boolean; path: string; sameOrigin: boolean; transport: OxidejsActionTransport; @@ -140,10 +141,20 @@ const resolveActions = function resolveActions( raw: OxidejsActions | undefined ): ResolvedActions { if (raw === undefined) { - return { path: ACTION_PATH, sameOrigin: true, transport: "http" }; + return { + openrpc: false, + path: ACTION_PATH, + sameOrigin: true, + transport: "http", + }; } if (raw === "http" || raw === "ws") { - return { path: ACTION_PATH, sameOrigin: true, transport: raw }; + return { + openrpc: false, + path: ACTION_PATH, + sameOrigin: true, + transport: raw, + }; } if (Object.prototype.toString.call(raw) !== "[object Object]") { throw new Error(`oxidejs: unknown actions transport "${String(raw)}"`); @@ -160,7 +171,12 @@ const resolveActions = function resolveActions( `oxidejs: actions.path must start with "/" and contain no query string (got "${actionsPath}")` ); } - return { path: actionsPath, sameOrigin: raw.sameOrigin ?? true, transport }; + return { + openrpc: raw.openrpc === true, + path: actionsPath, + sameOrigin: raw.sameOrigin ?? true, + transport, + }; }; interface ResolvedPaths { @@ -253,6 +269,7 @@ export const resolveOptions = function resolveOptions( transport: actions, path: actionPath, sameOrigin: actionSameOrigin, + openrpc: actionOpenRpc, } = resolveActions(raw?.actions); const hasClient = hasHtmlEntry(rootAbs, config); const hasPublic = fs.existsSync(path.join(rootAbs, "public")); @@ -260,6 +277,7 @@ export const resolveOptions = function resolveOptions( return { actionHeaders: raw?.actionHeaders, + actionOpenRpc, actionPath, actionSameOrigin, actions, diff --git a/packages/oxidejs/src/index.ts b/packages/oxidejs/src/index.ts index 3cc8b7d..a89b116 100644 --- a/packages/oxidejs/src/index.ts +++ b/packages/oxidejs/src/index.ts @@ -47,6 +47,11 @@ export { toWorkflowClassName, WORKFLOW_META, workflow, + buildOpenRpcDocument, + codecToJsonSchema, + createOpenRpcResponse, + matchesOpenRpcPath, + OPENRPC_PATH, } from "./context"; export type { ActionMeta, @@ -89,6 +94,8 @@ export type { WorkflowStartResult, WorkflowStep, WorkflowStepConfig, + BuildOpenRpcDocumentOptions, + OpenRpcActionEntry, } from "./context"; export type { OxideBuildContext, diff --git a/packages/oxidejs/src/openrpc.test.ts b/packages/oxidejs/src/openrpc.test.ts new file mode 100644 index 0000000..6d4384e --- /dev/null +++ b/packages/oxidejs/src/openrpc.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; + +import * as Schema from "effect/Schema"; + +import type { ActionMeta } from "./action"; +import { + buildOpenRpcDocument, + codecToJsonSchema, + createOpenRpcResponse, + matchesOpenRpcPath, + OPENRPC_PATH, +} from "./openrpc"; + +describe("openrpc", () => { + test("matchesOpenRpcPath", () => { + expect(matchesOpenRpcPath(OPENRPC_PATH)).toBe(true); + expect(matchesOpenRpcPath(`${OPENRPC_PATH}/`)).toBe(true); + expect(matchesOpenRpcPath("/__oxide/action")).toBe(false); + }); + + test("buildOpenRpcDocument lists stamped action schemas", () => { + // eslint-disable-next-line unicorn/throw-new-error -- Schema.TaggedError factory, not a thrown Error + class NotFound extends Schema.TaggedError()("NotFound", { + id: Schema.String, + }) {} + const payload = Schema.Struct({ orderId: Schema.String }); + const meta: ActionMeta = { + error: NotFound, + payload, + success: Schema.Struct({ ok: Schema.Boolean }), + }; + const doc = buildOpenRpcDocument([{ meta, name: "invoice.charge" }], { + actionUrl: "http://localhost/__oxide/action", + }); + expect(doc.openrpc).toBe("1.3.2"); + expect(doc.servers[0]?.url).toBe("http://localhost/__oxide/action"); + expect(doc.methods).toHaveLength(1); + expect(doc.methods[0]?.name).toBe("invoice.charge"); + expect(doc.methods[0]?.paramStructure).toBe("by-name"); + expect(doc.methods[0]?.params[0]?.name).toBe("args"); + expect(doc.methods[0]?.errors?.[0]?.code).toBe(-32_000); + expect(doc.components.schemas["NotFoundEncoded"]).toBeDefined(); + }); + + test("codecToJsonSchema falls back for missing codecs", () => { + expect(codecToJsonSchema()).toEqual({ + definitions: {}, + schema: {}, + }); + }); + + test("createOpenRpcResponse serves GET JSON", async () => { + const response = createOpenRpcResponse( + [{ meta: { success: Schema.String }, name: "test.ping" }], + new Request("http://localhost/__oxide/openrpc"), + { actionPath: "/__oxide/action" } + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("application/json"); + // SAFETY: OpenRPC document is JSON. + const body = (await response.json()) as { + methods: { name: string }[]; + openrpc: string; + }; + expect(body.openrpc).toBe("1.3.2"); + expect(body.methods[0]?.name).toBe("test.ping"); + }); + + test("createOpenRpcResponse rejects non-GET", () => { + const response = createOpenRpcResponse( + [], + new Request("http://localhost/__oxide/openrpc", { method: "POST" }), + { actionPath: "/__oxide/action" } + ); + expect(response.status).toBe(405); + }); +}); diff --git a/packages/oxidejs/src/openrpc.ts b/packages/oxidejs/src/openrpc.ts new file mode 100644 index 0000000..3615a51 --- /dev/null +++ b/packages/oxidejs/src/openrpc.ts @@ -0,0 +1,238 @@ +/* eslint-disable anti-slop/no-runtime-typeof, anti-slop/no-unsafe-dictionary-type, anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns, anti-slop/no-known-value-widening -- OpenRPC / JSON Schema bags are untyped at the discovery boundary */ +import * as Schema from "effect/Schema"; + +import type { ActionMeta } from "./action"; +import type { OxidejsJson } from "./types"; + +export const OPENRPC_PATH = "/__oxide/openrpc"; + +export interface OpenRpcActionEntry { + meta: ActionMeta; + name: string; +} + +export interface BuildOpenRpcDocumentOptions { + /** Absolute or path-only URL of the JSON-RPC action endpoint. */ + actionUrl: string; + title?: string; + version?: string; +} + +interface JsonSchemaObject { + [key: string]: OxidejsJson | undefined; +} + +interface CodecJsonSchema { + definitions: { [key: string]: JsonSchemaObject }; + schema: JsonSchemaObject; +} + +const emptySchema: JsonSchemaObject = {}; + +const rewriteSchemaRefs = function rewriteSchemaRefs( + value: OxidejsJson +): OxidejsJson { + if (Array.isArray(value)) { + return value.map((item) => rewriteSchemaRefs(item)); + } + if (value === null || typeof value !== "object") { + return value; + } + const out: { [key: string]: OxidejsJson } = {}; + for (const [key, child] of Object.entries(value)) { + if (key === "$ref" && typeof child === "string") { + out[key] = child + .replace(/^#\/\$defs\//u, "#/components/schemas/") + .replace(/^#\/definitions\//u, "#/components/schemas/"); + continue; + } + // SAFETY: JSON Schema child values stay JSON-compatible after rewrite. + out[key] = rewriteSchemaRefs(child as OxidejsJson); + } + return out; +}; + +/** Convert an Effect Schema codec to a JSON Schema object (+ optional $defs). */ +export const codecToJsonSchema = function codecToJsonSchema( + codec?: Schema.Codec +): CodecJsonSchema { + if (codec === undefined) { + return { definitions: {}, schema: emptySchema }; + } + const document = Schema.toJsonSchemaDocument(codec); + // SAFETY: Effect returns a JSON-Schema-shaped bag; rewrite $defs refs for OpenRPC components. + const rawSchema = document.schema as OxidejsJson; + // SAFETY: definitions is a string-keyed JSON Schema map when present. + const rawDefs = (document.definitions ?? {}) as { + [key: string]: OxidejsJson; + }; + const definitions: { [key: string]: JsonSchemaObject } = {}; + for (const [name, def] of Object.entries(rawDefs)) { + // SAFETY: each definition is a JSON Schema object after rewrite. + definitions[name] = rewriteSchemaRefs(def) as JsonSchemaObject; + } + return { + definitions, + // SAFETY: rewritten schema remains a JSON Schema object. + schema: rewriteSchemaRefs(rawSchema) as JsonSchemaObject, + }; +}; + +const mergeDefinitions = function mergeDefinitions( + target: { [key: string]: JsonSchemaObject }, + source: { [key: string]: JsonSchemaObject } +) { + for (const [name, schema] of Object.entries(source)) { + target[name] ??= schema; + } +}; + +interface OpenRpcError { + code: number; + data?: JsonSchemaObject; + message: string; +} + +interface OpenRpcMethod { + description?: string; + errors?: OpenRpcError[]; + name: string; + paramStructure: "by-name"; + params: { + name: string; + required: boolean; + schema: JsonSchemaObject; + }[]; + result: { name: string; schema: JsonSchemaObject }; + summary?: string; + ["x-oxide-stream"]?: boolean; +} + +/** + * Build an OpenRPC 1.3 document for oxide `action()` RPC methods. + * Payload shape matches Effect Rpc: by-name `{ args: [...] }`. + */ +export const buildOpenRpcDocument = function buildOpenRpcDocument( + entries: OpenRpcActionEntry[], + opts: BuildOpenRpcDocumentOptions +) { + const componentsSchemas: { [key: string]: JsonSchemaObject } = {}; + const methods = entries.map((entry) => { + const payloadCodec = entry.meta.payload + ? Schema.Struct({ args: Schema.Tuple([entry.meta.payload]) }) + : Schema.Struct({ args: Schema.Array(Schema.Unknown) }); + // SAFETY: Struct/Tuple schemas are accepted by toJsonSchemaDocument. + const paramsSchema = codecToJsonSchema( + payloadCodec as Schema.Codec + ); + mergeDefinitions(componentsSchemas, paramsSchema.definitions); + + const resultSchema = codecToJsonSchema(entry.meta.success); + mergeDefinitions(componentsSchemas, resultSchema.definitions); + + const errors: OpenRpcError[] = []; + if (entry.meta.error) { + const errorSchema = codecToJsonSchema(entry.meta.error); + mergeDefinitions(componentsSchemas, errorSchema.definitions); + errors.push({ + code: -32_000, + data: errorSchema.schema, + message: "Application error", + }); + } + + const { properties } = paramsSchema.schema; + let argsSchema = emptySchema; + if ( + properties && + typeof properties === "object" && + !Array.isArray(properties) + ) { + // SAFETY: Effect Struct emit puts `args` under properties. + const props = properties as { [key: string]: JsonSchemaObject }; + argsSchema = props["args"] ?? emptySchema; + } + + const method: OpenRpcMethod = { + name: entry.name, + paramStructure: "by-name", + params: [ + { + name: "args", + required: true, + schema: argsSchema, + }, + ], + result: { + name: "result", + schema: resultSchema.schema, + }, + }; + if (entry.meta.stream) { + method.description = + "Streaming action — results are NDJSON JSON-RPC notifications/chunks over the action endpoint."; + method.summary = "stream"; + method["x-oxide-stream"] = true; + } + if (errors.length > 0) { + method.errors = errors; + } + return method; + }); + + return { + components: { + schemas: componentsSchemas, + }, + info: { + title: opts.title ?? "Oxide Actions", + version: opts.version ?? "1.0.0", + }, + methods, + openrpc: "1.3.2", + servers: [ + { + name: "actions", + url: opts.actionUrl, + }, + ], + }; +}; + +export const matchesOpenRpcPath = function matchesOpenRpcPath( + pathname: string, + openRpcPath: string = OPENRPC_PATH +) { + return pathname === openRpcPath || pathname === `${openRpcPath}/`; +}; + +/** `GET`/`HEAD` OpenRPC document for stamped `action()` handlers. */ +export const createOpenRpcResponse = function createOpenRpcResponse( + entries: OpenRpcActionEntry[], + request: Request, + opts: { actionPath: string; title?: string; version?: string } +) { + if (request.method !== "GET" && request.method !== "HEAD") { + return new Response("Method Not Allowed", { + headers: { Allow: "GET, HEAD" }, + status: 405, + }); + } + const actionUrl = new URL(opts.actionPath, request.url).href; + const documentOpts: BuildOpenRpcDocumentOptions = { actionUrl }; + if (opts.title !== undefined) { + documentOpts.title = opts.title; + } + if (opts.version !== undefined) { + documentOpts.version = opts.version; + } + const document = buildOpenRpcDocument(entries, documentOpts); + const body = request.method === "HEAD" ? null : JSON.stringify(document); + return new Response(body, { + headers: { + "cache-control": "no-store", + "content-type": "application/json; charset=utf-8", + }, + status: 200, + }); +}; diff --git a/packages/oxidejs/src/oxide-plugins.test.ts b/packages/oxidejs/src/oxide-plugins.test.ts index 2864b24..1870526 100644 --- a/packages/oxidejs/src/oxide-plugins.test.ts +++ b/packages/oxidejs/src/oxide-plugins.test.ts @@ -11,7 +11,6 @@ import { shouldRunAfterBuild, toOxideBuildContext, } from "./oxide-plugins"; -import celldPlugin, { isCelldPrepareEnabled } from "./plugins/celld"; import type { OxidePlugin, ResolvedOptions } from "./types"; describe("oxide plugins", () => { @@ -26,9 +25,7 @@ describe("oxide plugins", () => { expect(resolvePluginModuleId("./plugins/mine.ts", root)).toBe( pathToFileURL(path.resolve(root, "./plugins/mine.ts")).href ); - expect(resolvePluginModuleId("oxidejs/plugins/celld", root)).toBe( - "oxidejs/plugins/celld" - ); + expect(resolvePluginModuleId("my-pkg/plugin", root)).toBe("my-pkg/plugin"); }); test("resolveOxidePlugins loads a relative plugin from the app root", async () => { @@ -88,81 +85,4 @@ describe("oxide plugins", () => { root: "/app", }); }); - - test("isCelldPrepareEnabled requires OXIDE_CELLD", () => { - expect(isCelldPrepareEnabled({})).toBe(false); - expect(isCelldPrepareEnabled({ OXIDE_CELLD: "0" })).toBe(false); - expect(isCelldPrepareEnabled({ OXIDE_CELLD: "1" })).toBe(true); - expect(isCelldPrepareEnabled({ OXIDE_CELLD: "true" })).toBe(true); - }); - - test("celld plugin prepares when enabled and ssr/wrangler.json exists", () => { - const dist = fs.mkdtempSync(path.join(os.tmpdir(), "oxide-celld-plugin-")); - const prev = process.env["OXIDE_CELLD"]; - process.env["OXIDE_CELLD"] = "1"; - try { - fs.mkdirSync(path.join(dist, "ssr"), { recursive: true }); - fs.mkdirSync(path.join(dist, "client"), { recursive: true }); - fs.writeFileSync( - path.join(dist, "ssr", "index.js"), - `export default {}\n` - ); - fs.writeFileSync( - path.join(dist, "ssr", "wrangler.json"), - `${JSON.stringify({ - assets: { binding: "ASSETS", directory: "../client" }, - main: "index.js", - name: "kit", - workers_dev: true, - })}\n` - ); - celldPlugin.afterBuild?.({ - outDir: dist, - preset: "worker", - root: path.dirname(dist), - }); - expect(fs.existsSync(path.join(dist, "wrangler.json"))).toBe(true); - // SAFETY: prepareCelldDeploy writes JSON. - const written = JSON.parse( - fs.readFileSync(path.join(dist, "wrangler.json"), "utf-8") - ) as { main?: string }; - expect(written.main).toBe("ssr/celld-entry.js"); - } finally { - if (prev === undefined) { - delete process.env["OXIDE_CELLD"]; - } else { - process.env["OXIDE_CELLD"] = prev; - } - fs.rmSync(dist, { force: true, recursive: true }); - } - }); - - test("celld plugin no-ops without OXIDE_CELLD even when snapshot exists", () => { - const dist = fs.mkdtempSync(path.join(os.tmpdir(), "oxide-celld-skip-")); - const prev = process.env["OXIDE_CELLD"]; - delete process.env["OXIDE_CELLD"]; - try { - fs.mkdirSync(path.join(dist, "ssr"), { recursive: true }); - fs.writeFileSync( - path.join(dist, "ssr", "wrangler.json"), - `${JSON.stringify({ main: "index.js", name: "kit" })}\n` - ); - celldPlugin.afterBuild?.({ - outDir: dist, - preset: "worker", - root: path.dirname(dist), - }); - expect(fs.existsSync(path.join(dist, "wrangler.json"))).toBe(false); - expect(fs.existsSync(path.join(dist, "ssr", "celld-entry.js"))).toBe( - false - ); - } finally { - if (prev === undefined) { - delete process.env["OXIDE_CELLD"]; - } else { - process.env["OXIDE_CELLD"] = prev; - } - fs.rmSync(dist, { force: true, recursive: true }); - } - }); }); diff --git a/packages/oxidejs/src/plugin.ts b/packages/oxidejs/src/plugin.ts index 92339ce..2b9d1e5 100644 --- a/packages/oxidejs/src/plugin.ts +++ b/packages/oxidejs/src/plugin.ts @@ -37,6 +37,12 @@ import { VIRTUAL_WORKFLOWS_ID, } from "./actions"; import { copyPublicDir, resolveOptions } from "./core"; +import { + createOpenRpcResponse, + matchesOpenRpcPath, + OPENRPC_PATH, +} from "./openrpc"; +import type { OpenRpcActionEntry } from "./openrpc"; import { resolveOxidePlugins, runOxidePluginHook, @@ -70,6 +76,7 @@ import { parseWorkflowExports, scanWorkflowFiles, } from "./workflow-build"; +import { maybePrepareCelldDeploy } from "./wrangler"; const scanDurableModules = function scanDurableModules(root: string) { const modules = scanServerFiles(root); @@ -224,6 +231,40 @@ const actionMiddleware = function actionMiddleware( }; }; +const openRpcMiddleware = function openRpcMiddleware( + loadRouter: () => Promise<{ + actionsOpenRpcEntries?: OpenRpcActionEntry[]; + }>, + actionPath: string, + bodyLimit: number +) { + return (req: ConnectReq, res: ConnectRes, next: ConnectNext) => { + if ( + !matchesOpenRpcPath((req.url ?? "").split("?")[0] ?? "", OPENRPC_PATH) + ) { + return next(); + } + void (async () => { + try { + const mod = await loadRouter(); + const response = createOpenRpcResponse( + mod.actionsOpenRpcEntries ?? [], + await nodeToWebRequest(req, bodyLimit), + { actionPath } + ); + await sendWebResponseFrom(req, res, response); + } catch { + if (res.headersSent) { + return; + } + res.statusCode = 503; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ error: "oxide openrpc handler failed" })); + } + })(); + }; +}; + const attachActionUpgrade = function attachActionUpgrade( httpServer: HttpUpgradeServer | null | undefined, loadRouter: () => Promise<{ actionsHandlers: unknown; default: unknown }>, @@ -325,6 +366,7 @@ const loadActions = function loadActions(root: string) { // SAFETY: generated actions.mjs exports RpcGroup default + actionsHandlers; dynamic import has no static module type. return (await import(pathToFileURL(file).href)) as { actionsHandlers: unknown; + actionsOpenRpcEntries?: OpenRpcActionEntry[]; default: unknown; }; } finally { @@ -432,6 +474,7 @@ const loadVirtualWorker = function loadVirtualWorker( ctx.addWatchFile(mod.abs); } return generateWorkerWrapper(hasEntry ? resolved.workerEntryAbs : null, { + actionOpenRpc: resolved.actionOpenRpc, actionPath: resolved.actionPath, actionSameOrigin: resolved.actionSameOrigin, actions: resolved.actions, @@ -579,7 +622,11 @@ const attachDevMiddlewareBridge = function attachDevMiddlewareBridge( // Don't touch /__oxide/action — reading the body here would empty the // Node stream before the action middleware runs. if ( - matchesActionPath((creq.url ?? "").split("?")[0] ?? "", opts.actionPath) + matchesActionPath( + (creq.url ?? "").split("?")[0] ?? "", + opts.actionPath + ) || + matchesOpenRpcPath((creq.url ?? "").split("?")[0] ?? "", OPENRPC_PATH) ) { return next(); } @@ -659,6 +706,20 @@ export const unpluginFactory: UnpluginFactory = ( "afterBuild", toOxideBuildContext(resolved) ); + maybePrepareCelldDeploy(resolved.outDir); + }; + + const maybePrepareOnSsrClose = function maybePrepareOnSsrClose( + environmentName: string | undefined + ) { + if (!resolved) { + return; + } + // Cloudflare Vite writes `dist/ssr/wrangler.json` when the ssr env closes. + // Client can finish first; do not wait for afterBuild to see the snapshot. + if (environmentName === "ssr" || environmentName === "server") { + maybePrepareCelldDeploy(resolved.outDir); + } }; return { @@ -682,6 +743,7 @@ export const unpluginFactory: UnpluginFactory = ( // SAFETY: Vite Environment API — each env closes separately; run once on last. const environmentName = (this as { environment?: { name?: string } }) .environment?.name; + maybePrepareOnSsrClose(environmentName); if (!shouldRunAfterBuild(environmentName, resolved.hasClient)) { return; } @@ -766,6 +828,11 @@ export const unpluginFactory: UnpluginFactory = ( ) ); } + if (opts.actionOpenRpc) { + server.middlewares.use( + openRpcMiddleware(loadRouter, opts.actionPath, opts.bodyLimit) + ); + } }); api.onBeforeStartPreviewServer?.(({ server }) => { if (resolved === undefined || resolved.preset === "worker") { @@ -834,6 +901,7 @@ export const unpluginFactory: UnpluginFactory = ( // SAFETY: virtual:oxide/actions SSR module exports RpcGroup default + actionsHandlers. const mod = (await server.ssrLoadModule(VIRTUAL_ACTIONS_ID)) as { actionsHandlers: unknown; + actionsOpenRpcEntries?: OpenRpcActionEntry[]; default: unknown; }; return mod; @@ -841,7 +909,11 @@ export const unpluginFactory: UnpluginFactory = ( // Lazy + clear-on-reject: never leave a rejected promise cached (sticky 503) // and never start a follow-up fetch nobody awaits (uncaught rejection → crash). let routerReady: - | Promise<{ actionsHandlers: unknown; default: unknown }> + | Promise<{ + actionsHandlers: unknown; + actionsOpenRpcEntries?: OpenRpcActionEntry[]; + default: unknown; + }> | undefined; const loadRouter = async () => { if (routerReady === undefined) { @@ -914,6 +986,12 @@ export const unpluginFactory: UnpluginFactory = ( ) : Promise.resolve([]); + if (opts?.actionOpenRpc) { + server.middlewares.use( + openRpcMiddleware(loadRouter, opts.actionPath, opts.bodyLimit) + ); + } + if (opts?.actions !== "ws") { wireActions(); } diff --git a/packages/oxidejs/src/plugins/celld.ts b/packages/oxidejs/src/plugins/celld.ts deleted file mode 100644 index 47a06b9..0000000 --- a/packages/oxidejs/src/plugins/celld.ts +++ /dev/null @@ -1,38 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -import type { OxidePlugin } from "../types"; -import { prepareCelldDeploy } from "../wrangler"; - -/** Opt-in gate so Cloudflare `wrangler deploy` is not polluted by celld artifacts. */ -export const isCelldPrepareEnabled = function isCelldPrepareEnabled( - env: { [key: string]: string | undefined } = process.env -) { - const value = env["OXIDE_CELLD"]; - return value === "1" || value === "true"; -}; - -/** - * After a production build with `OXIDE_CELLD=1`, rewrite the Cloudflare Vite - * `dist/ssr` snapshot into a celld-ready `dist/wrangler.json`. - * - * ```ts - * oxide({ plugins: ["oxidejs/plugins/celld"] }) - * // package.json: "deploy:celld": "OXIDE_CELLD=1 vite build && celld deploy dist" - * ``` - */ -const celldPlugin: OxidePlugin = { - afterBuild(ctx) { - if (!isCelldPrepareEnabled()) { - return; - } - const snapshot = path.join(ctx.outDir, "ssr", "wrangler.json"); - if (!fs.existsSync(snapshot)) { - return; - } - prepareCelldDeploy(ctx.outDir); - }, - name: "oxidejs/celld", -}; - -export default celldPlugin; diff --git a/packages/oxidejs/src/types.ts b/packages/oxidejs/src/types.ts index ec98c75..7765e40 100644 --- a/packages/oxidejs/src/types.ts +++ b/packages/oxidejs/src/types.ts @@ -11,7 +11,7 @@ export type OxidejsJson = export type OxidejsActionTransport = "http" | "ws"; -/** `actions` config: transport string, or an object with `transport`, `path`, `sameOrigin`. */ +/** `actions` config: transport string, or an object with `transport`, `path`, `sameOrigin`, `openrpc`. */ export type OxidejsActions = | OxidejsActionTransport | { @@ -20,6 +20,11 @@ export type OxidejsActions = path?: string; /** Reject cross-origin action requests (CSRF defense). Default: true. */ sameOrigin?: boolean; + /** + * Serve `GET /__oxide/openrpc` with an OpenRPC 1.3 document for + * `action()` handlers only. Default: false. + */ + openrpc?: boolean; }; /** Static headers inlined into the shared action client. Functions cannot ship to the browser. */ @@ -98,8 +103,7 @@ export interface OxidejsOptions { /** * Build plugins with `beforeBuild` / `afterBuild` hooks (production builds * only; once per build). Pass an object or a module specifier (default - * export), e.g. `["oxidejs/plugins/celld"]`. Relative specifiers resolve - * against the Vite/Rsbuild project root. + * export). Relative specifiers resolve against the Vite/Rsbuild project root. */ plugins?: OxidePluginInput[]; } @@ -124,6 +128,8 @@ export interface ResolvedOptions { actionPath: string; /** Reject cross-origin action requests (CSRF defense). Default: true. */ actionSameOrigin: boolean; + /** Serve OpenRPC discovery for `action()` handlers at `/__oxide/openrpc`. */ + actionOpenRpc: boolean; actionHeaders: OxidejsActionHeaders | undefined; middleware: (string | { module: string; imports?: string[] })[]; imports: string[]; diff --git a/packages/oxidejs/src/wrangler.test.ts b/packages/oxidejs/src/wrangler.test.ts index 10c9814..ad9b64b 100644 --- a/packages/oxidejs/src/wrangler.test.ts +++ b/packages/oxidejs/src/wrangler.test.ts @@ -7,9 +7,11 @@ import path from "node:path"; import { isCelldUnsupportedKey, mergeDevVarsIntoConfig, + maybePrepareCelldDeploy, parseDevVars, prepareCelldDeploy, relocateCloudflareViteWrangler, + rewriteRelativeModuleSpecifiers, stripCelldBareNodeImports, toCelldWrangler, withOxide, @@ -306,7 +308,7 @@ describe("relocateCloudflareViteWrangler", () => { fs.mkdirSync(path.join(dist, "client"), { recursive: true }); fs.writeFileSync( path.join(dist, "ssr", "index.js"), - `import "node:fs";\nimport "node:path";\nimport { EventEmitter } from "node:events";\nexport default {}\n` + `import "node:fs";\nimport "node:path";\nimport { EventEmitter } from "node:events";\nimport "./chunk.js";\nexport { x } from "./util.js";\nexport default {}\n` ); fs.writeFileSync( path.join(dist, "client", ".assetsignore"), @@ -328,19 +330,24 @@ describe("relocateCloudflareViteWrangler", () => { ) as CelldWranglerConfig; expect(written).toEqual({ assets: { binding: "ASSETS", directory: "client" }, - main: "ssr/celld-entry.js", + main: "celld/entry.js", name: "kit", }); expect(fs.existsSync(path.join(dist, "client", ".assetsignore"))).toBe( false ); + expect(fs.existsSync(path.join(dist, "ssr", "celld-entry.js"))).toBe( + false + ); const celldEntry = fs.readFileSync( - path.join(dist, "ssr", "celld-entry.js"), + path.join(dist, "celld", "entry.js"), "utf-8" ); expect(celldEntry).not.toContain('import "node:fs"'); expect(celldEntry).not.toContain('import "node:path"'); expect(celldEntry).toContain('from "node:events"'); + expect(celldEntry).toContain('import "../ssr/chunk.js"'); + expect(celldEntry).toContain('from "../ssr/util.js"'); expect( fs.readFileSync(path.join(dist, "ssr", "index.js"), "utf-8") ).toContain('import "node:fs"'); @@ -362,6 +369,48 @@ describe("relocateCloudflareViteWrangler", () => { fs.rmSync(dist, { force: true, recursive: true }); } }); + + test("maybePrepareCelldDeploy no-ops without ssr snapshot", () => { + const dist = fs.mkdtempSync(path.join(os.tmpdir(), "oxide-celld-maybe-")); + try { + expect(maybePrepareCelldDeploy(dist)).toBeUndefined(); + expect(fs.existsSync(path.join(dist, "wrangler.json"))).toBe(false); + } finally { + fs.rmSync(dist, { force: true, recursive: true }); + } + }); + + test("maybePrepareCelldDeploy prepares when snapshot exists", () => { + const dist = fs.mkdtempSync( + path.join(os.tmpdir(), "oxide-celld-maybe-ok-") + ); + try { + fs.mkdirSync(path.join(dist, "ssr"), { recursive: true }); + fs.mkdirSync(path.join(dist, "client"), { recursive: true }); + fs.writeFileSync( + path.join(dist, "ssr", "index.js"), + `export default {}\n` + ); + fs.writeFileSync( + path.join(dist, "ssr", "wrangler.json"), + `${JSON.stringify({ + assets: { binding: "ASSETS", directory: "../client" }, + main: "index.js", + name: "kit", + workers_dev: true, + })}\n` + ); + const written = maybePrepareCelldDeploy(dist); + expect(written?.["main"]).toBe("celld/entry.js"); + expect(fs.existsSync(path.join(dist, "wrangler.json"))).toBe(true); + expect(fs.existsSync(path.join(dist, "celld", "entry.js"))).toBe(true); + expect(fs.existsSync(path.join(dist, "ssr", "celld-entry.js"))).toBe( + false + ); + } finally { + fs.rmSync(dist, { force: true, recursive: true }); + } + }); }); describe("stripCelldBareNodeImports", () => { @@ -373,3 +422,37 @@ describe("stripCelldBareNodeImports", () => { ).toBe(`import fs from "node:fs";\n`); }); }); + +describe("rewriteRelativeModuleSpecifiers", () => { + test("rewrites relative imports for a moved entry", () => { + const source = [ + `import "node:fs";`, + `import "./chunk.js";`, + `import { x } from "./util.js";`, + `export { y } from "./y.js";`, + `export * from "./z.js";`, + `const m = import("./dyn.js");`, + ].join("\n"); + expect( + rewriteRelativeModuleSpecifiers(source, "/dist/ssr", "/dist/celld") + ).toBe( + [ + `import "node:fs";`, + `import "../ssr/chunk.js";`, + `import { x } from "../ssr/util.js";`, + `export { y } from "../ssr/y.js";`, + `export * from "../ssr/z.js";`, + `const m = import("../ssr/dyn.js");`, + ].join("\n") + ); + }); + + test("rewrites from-specifiers with minified $ bindings", () => { + const source = `import { $ as a, B as array$1, z as object } from "./assets/factory.js";\n`; + expect( + rewriteRelativeModuleSpecifiers(source, "/dist/ssr", "/dist/celld") + ).toBe( + `import { $ as a, B as array$1, z as object } from "../ssr/assets/factory.js";\n` + ); + }); +}); diff --git a/packages/oxidejs/src/wrangler.ts b/packages/oxidejs/src/wrangler.ts index 46ac49b..0d5d654 100644 --- a/packages/oxidejs/src/wrangler.ts +++ b/packages/oxidejs/src/wrangler.ts @@ -351,8 +351,55 @@ export const stripCelldBareNodeImports = function stripCelldBareNodeImports( }; /** - * Write `celld-entry.js` next to the Worker main with bare unsupported node - * imports stripped, and return a project-relative main path. + * Rewrite relative `import` / `export` specifiers so a file moved to `toDir` + * still resolves modules that lived next to the original under `fromDir`. + * + * Matches `from "…"`, `import("…")`, and side-effect `import "…"` only — + * do not parse binding lists (minified names use `$`, digits, …). + */ +export const rewriteRelativeModuleSpecifiers = + function rewriteRelativeModuleSpecifiers( + source: string, + fromDir: string, + toDir: string + ): string { + const fromResolved = path.resolve(fromDir); + const toResolved = path.resolve(toDir); + if (fromResolved === toResolved) { + return source; + } + const relocate = function relocate(spec: string) { + const absolute = path.resolve(fromResolved, spec); + let relocated = path + .relative(toResolved, absolute) + .split(path.sep) + .join("/"); + if (!(relocated.startsWith("./") || relocated.startsWith("../"))) { + relocated = `./${relocated}`; + } + return relocated; + }; + let out = source.replaceAll( + /\bfrom\s+(?["'])(?\.[^"']+)\k/gu, + (_full, quote: string, spec: string) => + `from ${quote}${relocate(spec)}${quote}` + ); + out = out.replaceAll( + /\bimport\s*\(\s*(?["'])(?\.[^"']+)\k/gu, + (_full, quote: string, spec: string) => + `import(${quote}${relocate(spec)}${quote}` + ); + out = out.replaceAll( + /\bimport\s+(?["'])(?\.[^"']+)\k/gu, + (_full, quote: string, spec: string) => + `import ${quote}${relocate(spec)}${quote}` + ); + return out; + }; + +/** + * Write a stripped Worker entry under `celld/` (not beside Cloudflare's + * `ssr/` main) and return a project-relative main path. */ const writeCelldWorkerEntry = function writeCelldWorkerEntry( projectRoot: string, @@ -374,18 +421,26 @@ const writeCelldWorkerEntry = function writeCelldWorkerEntry( `oxidejs: celld main escapes the deploy root (got ${JSON.stringify(mainRelative)})` ); } - const celldEntryAbs = path.join(path.dirname(mainAbs), "celld-entry.js"); + const celldDir = path.join(projectRoot, "celld"); + fs.mkdirSync(celldDir, { recursive: true }); + const celldEntryAbs = path.join(celldDir, "entry.js"); const entrySource = fs.readFileSync(mainAbs, "utf-8"); - fs.writeFileSync(celldEntryAbs, stripCelldBareNodeImports(entrySource)); - return path.relative(projectRoot, celldEntryAbs).split(path.sep).join("/"); + const stripped = stripCelldBareNodeImports(entrySource); + const rewritten = rewriteRelativeModuleSpecifiers( + stripped, + path.dirname(mainAbs), + celldDir + ); + fs.writeFileSync(celldEntryAbs, rewritten); + return "celld/entry.js"; }; /** * Read the Cloudflare Vite wrangler snapshot under `dist/ssr`, strip unsupported * keys, relocate paths for a `dist/` deploy root, and write `dist/wrangler.json`. * Also removes Cloudflare-only asset files celld rejects (e.g. `.assetsignore`) - * and rewrites `main` to a celld entry without bare `node:fs` / `node:path` - * side-effect imports. + * and rewrites `main` to `celld/entry.js` without bare `node:fs` / `node:path` + * side-effect imports (kept outside `ssr/` so Cloudflare deploy does not upload it). */ export const prepareCelldDeploy = function prepareCelldDeploy( distDir = "dist" @@ -425,6 +480,17 @@ export const prepareCelldDeploy = function prepareCelldDeploy( return cleaned; }; +/** Run {@link prepareCelldDeploy} when a Cloudflare Vite `ssr/wrangler.json` snapshot exists. */ +export const maybePrepareCelldDeploy = function maybePrepareCelldDeploy( + distDir = "dist" +): CelldWranglerConfig | undefined { + const snapshot = path.join(path.resolve(distDir), "ssr", "wrangler.json"); + if (!fs.existsSync(snapshot)) { + return; + } + return prepareCelldDeploy(distDir); +}; + /** * @deprecated Prefer {@link prepareCelldDeploy} for Cloudflare Vite layouts. * Read a wrangler JSON snapshot, strip celld-unsupported keys, write it back. diff --git a/packages/oxidejs/tsdown.config.ts b/packages/oxidejs/tsdown.config.ts index 7fe83df..01bd3a4 100644 --- a/packages/oxidejs/tsdown.config.ts +++ b/packages/oxidejs/tsdown.config.ts @@ -16,7 +16,6 @@ export default defineConfig({ index: "src/index.ts", "mutation-queue": "src/mutation-queue.ts", plugin: "src/plugin.ts", - "plugins/celld": "src/plugins/celld.ts", rpc: "src/rpc/index.ts", "rpc/client": "src/rpc/client.ts", rsbuild: "src/rsbuild.ts", diff --git a/templates/kit/README.md b/templates/kit/README.md index a34e911..8d836a1 100644 --- a/templates/kit/README.md +++ b/templates/kit/README.md @@ -15,7 +15,7 @@ Local and Cloudflare deploy use the same Vite path (workerd). `preset` defaults - `tasks.userId` references `user.id`. Actions require a session and only read/write that user's rows (`liveQuery` topic `tasks:`). - `/api/auth/*` is handled in `src/server.ts` with a per-request Better Auth instance bound to `env.DB`. - Registration is passkey-first (`requireSession: false`): name + email → WebAuthn → user row + session. Sign-in uses `signIn.passkey()`. -- Set `BETTER_AUTH_SECRET` (and optionally `BETTER_AUTH_URL`) via `.dev.vars` locally (`cp .dev.vars.example .dev.vars`). The `oxidejs/plugins/celld` build plugin merges those into `dist/wrangler.json` `vars` for celld. For Cloudflare, after the first deploy run `bunx wrangler secret put BETTER_AUTH_SECRET` (and usually `BETTER_AUTH_URL` to your `*.workers.dev` origin). Do not commit real secrets in `wrangler.jsonc`. +- Set `BETTER_AUTH_SECRET` (and optionally `BETTER_AUTH_URL`) via `.dev.vars` locally (`cp .dev.vars.example .dev.vars`). Production builds merge those into `dist/wrangler.json` `vars` for celld. For Cloudflare, after the first deploy run `bunx wrangler secret put BETTER_AUTH_SECRET` (and usually `BETTER_AUTH_URL` to your `*.workers.dev` origin). Do not commit real secrets in `wrangler.jsonc`. - `advanced.database.validateSchema` is off: Better Auth's check uses `pragma_table_info(?)`, which D1 rejects (`SQLITE_AUTH`). ParanORM migrations are the source of truth. ## Live list @@ -68,11 +68,11 @@ Optional: set `BETTER_AUTH_URL` to your `*.workers.dev` (or custom) origin after ```sh bun run build -bun run dev:celld # OXIDE_CELLD=1 vite build → celld dev dist -bun run deploy:celld # OXIDE_CELLD=1 vite build → celld deploy dist +bun run dev:celld # vite build → celld dev dist +bun run deploy:celld # vite build → celld deploy dist ``` -`@cloudflare/vite-plugin` writes `dist/ssr/wrangler.json` with CF-only keys (`no_bundle`, `workers_dev`, …), paths like `assets.directory: "../client"` (illegal for celld), `dist/client/.assetsignore`, and bare `import "node:fs"` leftovers. Enable `plugins: ["oxidejs/plugins/celld"]` and set `OXIDE_CELLD=1` on celld scripts so `vite build` runs `prepareCelldDeploy` and writes `dist/wrangler.json` with celld-safe keys/paths (`main: "ssr/celld-entry.js"`, `assets.directory: "client"`), strips `.assetsignore`, drops `no_bundle` so `celld deploy` esbuilds one module, and strips bare unused `node:fs` / `node:path` imports (celld 0.4 has no `node:fs` stub). Plain `bun run deploy` / `vite build` skip prepare so Wrangler does not upload `celld-entry.js`. Needs `esbuild` on `PATH`. +`@cloudflare/vite-plugin` writes `dist/ssr/wrangler.json` with CF-only keys (`no_bundle`, `workers_dev`, …), paths like `assets.directory: "../client"` (illegal for celld), `dist/client/.assetsignore`, and bare `import "node:fs"` leftovers. Every production `vite build` with that snapshot also runs `prepareCelldDeploy` and writes `dist/wrangler.json` with celld-safe keys/paths (`main: "celld/entry.js"`, `assets.directory: "client"`), strips `.assetsignore`, drops `no_bundle` so `celld deploy` esbuilds one module, and writes a stripped entry under `dist/celld/` (not beside Cloudflare's `ssr/` main). Cloudflare `wrangler deploy` still uses the Vite SSR snapshot. Needs `esbuild` on `PATH`. A **running** celld node does not restart on deploy. It polls `deploy/current.json` every **30s** (`CELLD_DEPLOY_POLL_S`) and only then builds/adopts the new Worker + assets. diff --git a/templates/kit/package.json b/templates/kit/package.json index c73f7ee..727882a 100644 --- a/templates/kit/package.json +++ b/templates/kit/package.json @@ -4,11 +4,11 @@ "type": "module", "scripts": { "dev": "vite", - "dev:celld": "OXIDE_CELLD=1 vite build && celld dev dist", + "dev:celld": "vite build && celld dev dist", "build": "vite build", "preview": "vite preview", "deploy": "vite build && wrangler deploy", - "deploy:celld": "OXIDE_CELLD=1 vite build && celld deploy dist", + "deploy:celld": "vite build && celld deploy dist", "cf:login": "wrangler login", "celld": "celld" }, @@ -27,7 +27,7 @@ "@cloudflare/workers-types": "^5.20260818.1", "@tailwindcss/vite": "^4.3.3", "daisyui": "^5.7.22", - "oxidejs": "^0.5.2", + "oxidejs": "^0.5.3", "tailwindcss": "^4.3.3", "typescript": "^7", "vite": "^8.2.2", diff --git a/templates/kit/vite.config.ts b/templates/kit/vite.config.ts index 2e05a2b..3b9c015 100644 --- a/templates/kit/vite.config.ts +++ b/templates/kit/vite.config.ts @@ -8,9 +8,10 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [ oxide({ - actions: "ws", + actions: { + transport: "ws", + }, middleware: ["./src/middleware/db.ts", "@ilha/router/ssr"], - plugins: ["oxidejs/plugins/celld"], }), cloudflare(withOxide()), pages(), diff --git a/templates/kit/wrangler.jsonc b/templates/kit/wrangler.jsonc index 8578dfd..aed7f13 100644 --- a/templates/kit/wrangler.jsonc +++ b/templates/kit/wrangler.jsonc @@ -18,9 +18,9 @@ "bucket_name": "files", }, ], - // Secrets: copy `.dev.vars.example` → `.dev.vars` locally. The celld - // oxide plugin merges `.dev.vars` into deploy `vars`. Use - // `wrangler secret put BETTER_AUTH_SECRET` for Cloudflare. + // Secrets: copy `.dev.vars.example` → `.dev.vars` locally. + // `prepareCelldDeploy` merges `.dev.vars` into deploy `vars` for celld. + // Use `wrangler secret put BETTER_AUTH_SECRET` for Cloudflare. "assets": { "binding": "ASSETS", // Oxide's worker SPA-falls-back document navigations (fetches `/` — celld diff --git a/templates/simple/package.json b/templates/simple/package.json index 3eb92e0..8e254ba 100644 --- a/templates/simple/package.json +++ b/templates/simple/package.json @@ -15,7 +15,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.3.3", "daisyui": "^5.7.22", - "oxidejs": "^0.5.2", + "oxidejs": "^0.5.3", "tailwindcss": "^4.3.3", "typescript": "^6", "vite": "^8.2.1" From 169eceb45c4738f6382eab419c307b06bb3b6846 Mon Sep 17 00:00:00 2001 From: Ryuz Date: Fri, 11 Sep 2026 12:04:02 +0200 Subject: [PATCH 2/3] fix(pr): improvements --- apps/website/docs/oxide/configuration.mdx | 2 +- apps/website/docs/oxide/server-actions.mdx | 2 +- packages/oxidejs/CHANGELOG.md | 4 + packages/oxidejs/README.md | 7 +- packages/oxidejs/src/actions.test.ts | 28 ++++ packages/oxidejs/src/actions.ts | 13 +- packages/oxidejs/src/core.test.ts | 6 + packages/oxidejs/src/core.ts | 2 +- packages/oxidejs/src/openrpc.test.ts | 28 ++++ packages/oxidejs/src/openrpc.ts | 11 +- packages/oxidejs/src/plugin.ts | 9 +- packages/oxidejs/src/types.ts | 3 +- packages/oxidejs/src/wrangler.test.ts | 19 +++ packages/oxidejs/src/wrangler.ts | 184 +++++++++++++++++---- 14 files changed, 275 insertions(+), 43 deletions(-) diff --git a/apps/website/docs/oxide/configuration.mdx b/apps/website/docs/oxide/configuration.mdx index 1333448..c236024 100644 --- a/apps/website/docs/oxide/configuration.mdx +++ b/apps/website/docs/oxide/configuration.mdx @@ -99,7 +99,7 @@ oxide({ - `transport` is `"http"` by default. `"ws"` uses WebSocket (`crossws` on Node, `WebSocketPair` with [`preset: "worker"`](#preset)). - `path` defaults to `/__oxide/action`. It must start with `/` and cannot contain a query string. - `sameOrigin` defaults to `true` for HTTP and WebSocket. This blocks browser calls from another site, including sibling subdomains. -- `openrpc` defaults to `false`. When `true`, Oxide serves `GET /__oxide/openrpc` with an [OpenRPC](https://spec.open-rpc.org/) 1.3 document for `action()` handlers only (payload / success / error from `ACTION_META`; workflows, queues, and schedules are omitted). +- `openrpc` defaults to `false`. When `true`, Oxide serves `GET /__oxide/openrpc` with an [OpenRPC](https://spec.open-rpc.org/) 1.3 document for `action()` handlers only (payload / success / error from `ACTION_META`; workflows, queues, and schedules are omitted). Ignored when `transport` is `"ws"`. With `"worker"`, the generated Worker calls `server.accept()` for upgrades. Durable Object apps that need hibernation can pass a custom `accept` into `createWsHooks` (for example `ctx.acceptWebSocket(ws)`). The generated wrapper does not create a DO for you. diff --git a/apps/website/docs/oxide/server-actions.mdx b/apps/website/docs/oxide/server-actions.mdx index 09f36f6..dbed3a8 100644 --- a/apps/website/docs/oxide/server-actions.mdx +++ b/apps/website/docs/oxide/server-actions.mdx @@ -409,6 +409,6 @@ oxide({ }); ``` -Set `actions.openrpc: true` to serve `GET /__oxide/openrpc` — an [OpenRPC](https://spec.open-rpc.org/) 1.3 document for `action()` handlers only (Effect Schema → JSON Schema; workflows, queues, and schedules are omitted). See [`actions`](/oxide/configuration#actions). +Set `actions.openrpc: true` to serve `GET /__oxide/openrpc` — an [OpenRPC](https://spec.open-rpc.org/) 1.3 document for `action()` handlers only (Effect Schema → JSON Schema; workflows, queues, and schedules are omitted). OpenRPC is HTTP-only and stays off when `transport` is `"ws"`. See [`actions`](/oxide/configuration#actions). Set `sameOrigin: false` only when you accept another origin and own CORS, CSRF, and authentication. For static client headers, see [`actionHeaders`](/oxide/configuration#actionheaders) — never put a private secret there. diff --git a/packages/oxidejs/CHANGELOG.md b/packages/oxidejs/CHANGELOG.md index 6c499fb..bd62823 100644 --- a/packages/oxidejs/CHANGELOG.md +++ b/packages/oxidejs/CHANGELOG.md @@ -17,6 +17,10 @@ ### Fixed - `rewriteRelativeModuleSpecifiers` rewrites minified `from "…"` imports that use `$` bindings (celld esbuild could not resolve `./assets/…` from `dist/celld/entry.js`) +- OpenRPC discovery runs after middleware (production wrapper + Vite Connect bridge) so auth can reject `GET /__oxide/openrpc` +- `actions.openrpc: true` is ignored when `transport` is `"ws"` (discovery is HTTP-only) +- OpenRPC `components.schemas` merge throws on conflicting definitions for the same name +- Relative celld entry rewrite skips import-shaped text inside strings and comments ### Breaking diff --git a/packages/oxidejs/README.md b/packages/oxidejs/README.md index 17aaba5..2256c2c 100644 --- a/packages/oxidejs/README.md +++ b/packages/oxidejs/README.md @@ -46,10 +46,7 @@ import { withOxide } from "oxidejs/wrangler"; import oxide from "oxidejs/vite"; export default defineConfig({ - plugins: [ - oxide({ actions: "ws", middleware: [...] }), - cloudflare(withOxide()), - ], + plugins: [oxide({ actions: "ws", middleware: [] }), cloudflare(withOxide())], }); ``` @@ -343,7 +340,7 @@ export class ActionRoom { The generated worker wrapper does not create a DO — hibernation is opt-in when you own the object. -`vite dev` and `rsbuild dev` serve the endpoint via middleware. `actions: "http"` (default) serves `/__oxide/action`; `actions: "ws"` uses a WebSocket instead (`crossws` on Node/`fetch`, `WebSocketPair` on `preset: "worker"`). `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. Set `actions.openrpc: true` to serve `GET /__oxide/openrpc` — an [OpenRPC](https://spec.open-rpc.org/) 1.3 document for `action()` handlers only (Effect Schema → JSON Schema; workflows/queues/schedules are omitted). `actionHeaders` are static headers on the shared HTTP client and are ignored for WebSocket actions. +`vite dev` and `rsbuild dev` serve the endpoint via middleware. `actions: "http"` (default) serves `/__oxide/action`; `actions: "ws"` uses a WebSocket instead (`crossws` on Node/`fetch`, `WebSocketPair` on `preset: "worker"`). `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. Set `actions.openrpc: true` to serve `GET /__oxide/openrpc` — an [OpenRPC](https://spec.open-rpc.org/) 1.3 document for `action()` handlers only (Effect Schema → JSON Schema; workflows/queues/schedules are omitted). OpenRPC is HTTP-only and stays off when `transport` is `"ws"`. `actionHeaders` are static headers on the shared HTTP client and are ignored for WebSocket actions. ## Rsbuild diff --git a/packages/oxidejs/src/actions.test.ts b/packages/oxidejs/src/actions.test.ts index d6e27a6..e13484d 100644 --- a/packages/oxidejs/src/actions.test.ts +++ b/packages/oxidejs/src/actions.test.ts @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; import { assetRelPath, + bypassesDevMiddlewareBridge, generateActionsClientModule, generateActionsModule, generateClientModule, @@ -35,6 +36,7 @@ import { withRequestEntry, withRequestStore, } from "./context"; +import { OPENRPC_PATH } from "./openrpc"; import { createActionHandler } from "./rpc/server"; import { writeGeneratedActions } from "./rpc/test-harness"; import { runActionInContext } from "./run-action"; @@ -181,6 +183,17 @@ const readStreamUntilNewline = async function readStreamUntilNewline( ); }; +describe("bypassesDevMiddlewareBridge", () => { + test("skips action path but not OpenRPC discovery", () => { + expect( + bypassesDevMiddlewareBridge("/__oxide/action", "/__oxide/action") + ).toBe(true); + expect(bypassesDevMiddlewareBridge(OPENRPC_PATH, "/__oxide/action")).toBe( + false + ); + }); +}); + describe("parseExportedNames", () => { test("finds only action-marked exports (functions, generators, consts)", () => { expect( @@ -565,6 +578,21 @@ ${stubSource}` expect(code).not.toContain("OPENRPC_PATH"); }); + test("wrapper runs middleware before OpenRPC discovery", () => { + const code = generateWorkerWrapper("/app/src/server.ts", { + actionOpenRpc: true, + middleware: ["./auth.ts"], + preset: "fetch", + }); + const fetchIdx = code.indexOf("async fetch(request, env, ctx)"); + const body = code.slice(fetchIdx); + const mwIdx = body.indexOf("for (const __mw of"); + const openRpcIdx = body.indexOf("if (matchesOpenRpcPath"); + expect(mwIdx).toBeGreaterThan(-1); + expect(openRpcIdx).toBeGreaterThan(-1); + expect(mwIdx).toBeLessThan(openRpcIdx); + }); + test("worker wrapper SPA-falls back to / when client exists", () => { const code = generateWorkerWrapper("/app/src/server.ts", { hasClient: true, diff --git a/packages/oxidejs/src/actions.ts b/packages/oxidejs/src/actions.ts index a6b99d5..75b68dd 100644 --- a/packages/oxidejs/src/actions.ts +++ b/packages/oxidejs/src/actions.ts @@ -53,6 +53,17 @@ export const matchesActionPath = function matchesActionPath( return pathname === actionPath || pathname === `${actionPath}/`; }; +/** + * Vite Connect bridge skips only the action endpoint (body stream). OpenRPC + * stays in the bridge so middleware can reject discovery like production. + */ +export const bypassesDevMiddlewareBridge = function bypassesDevMiddlewareBridge( + pathname: string, + actionPath: string +) { + return matchesActionPath(pathname, actionPath); +}; + const IGNORE_DIRS = new Set(["node_modules", "dist", ".git", ".wrangler"]); /** Only `export const name = action(...)` become remote RPC actions. Everything else stays server-local. */ const EXPORT_RE = @@ -1097,7 +1108,7 @@ ${middlewareImports}${actionImports}${hasActions ? `${actionMatchFn}\n` : ""}${a ...(user ?? {}), async fetch(request, env, ctx) { request[__fetch] = { env, fetchCtx: ctx }; - ${wsUpgradeGate}${openRpcGate}${middlewareGate}${actionGate}${afterAction} + ${wsUpgradeGate}${middlewareGate}${openRpcGate}${actionGate}${afterAction} }${queueBits.method}${scheduleBits.method}, }; ${workflowExport}export default app; diff --git a/packages/oxidejs/src/core.test.ts b/packages/oxidejs/src/core.test.ts index f509354..2c1f905 100644 --- a/packages/oxidejs/src/core.test.ts +++ b/packages/oxidejs/src/core.test.ts @@ -93,6 +93,12 @@ describe("resolveOptions", () => { expect(resolveOptions({ actions: "ws" }, process.cwd()).actionOpenRpc).toBe( false ); + expect( + resolveOptions( + { actions: { openrpc: true, transport: "ws" } }, + process.cwd() + ).actionOpenRpc + ).toBe(false); }); test("rejects actions.path with a query string", () => { diff --git a/packages/oxidejs/src/core.ts b/packages/oxidejs/src/core.ts index 8174505..ede01f2 100644 --- a/packages/oxidejs/src/core.ts +++ b/packages/oxidejs/src/core.ts @@ -172,7 +172,7 @@ const resolveActions = function resolveActions( ); } return { - openrpc: raw.openrpc === true, + openrpc: transport !== "ws" && raw.openrpc === true, path: actionsPath, sameOrigin: raw.sameOrigin ?? true, transport, diff --git a/packages/oxidejs/src/openrpc.test.ts b/packages/oxidejs/src/openrpc.test.ts index 6d4384e..296001c 100644 --- a/packages/oxidejs/src/openrpc.test.ts +++ b/packages/oxidejs/src/openrpc.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file -- Schema.TaggedError fixtures per test */ import { describe, expect, test } from "bun:test"; import * as Schema from "effect/Schema"; @@ -74,4 +75,31 @@ describe("openrpc", () => { ); expect(response.status).toBe(405); }); + + test("buildOpenRpcDocument throws on conflicting component schemas", () => { + // Same TaggedError tag → same Encoded def name; different fields → conflict. + // eslint-disable-next-line unicorn/throw-new-error -- Schema.TaggedError factory + class ConflictA extends Schema.TaggedError()("Conflict", { + a: Schema.String, + }) {} + // eslint-disable-next-line unicorn/throw-new-error -- Schema.TaggedError factory + class ConflictB extends Schema.TaggedError()("Conflict", { + b: Schema.Number, + }) {} + expect(() => + buildOpenRpcDocument( + [ + { + meta: { error: ConflictA, success: Schema.String }, + name: "a.run", + }, + { + meta: { error: ConflictB, success: Schema.String }, + name: "b.run", + }, + ], + { actionUrl: "http://localhost/__oxide/action" } + ) + ).toThrow(/components\.schemas\[/u); + }); }); diff --git a/packages/oxidejs/src/openrpc.ts b/packages/oxidejs/src/openrpc.ts index 3615a51..24c7f1c 100644 --- a/packages/oxidejs/src/openrpc.ts +++ b/packages/oxidejs/src/openrpc.ts @@ -83,7 +83,16 @@ const mergeDefinitions = function mergeDefinitions( source: { [key: string]: JsonSchemaObject } ) { for (const [name, schema] of Object.entries(source)) { - target[name] ??= schema; + const existing = target[name]; + if (existing === undefined) { + target[name] = schema; + continue; + } + if (JSON.stringify(existing) !== JSON.stringify(schema)) { + throw new Error( + `oxidejs: OpenRPC components.schemas[${JSON.stringify(name)}] conflict — codecs produced different definitions for the same name` + ); + } } }; diff --git a/packages/oxidejs/src/plugin.ts b/packages/oxidejs/src/plugin.ts index 2b9d1e5..5a54c40 100644 --- a/packages/oxidejs/src/plugin.ts +++ b/packages/oxidejs/src/plugin.ts @@ -14,6 +14,7 @@ import { generateWorkerWrapper, isServerFileId, loadClientStub, + bypassesDevMiddlewareBridge, matchesActionPath, moduleKey, nodeToWebRequest, @@ -620,13 +621,13 @@ const attachDevMiddlewareBridge = function attachDevMiddlewareBridge( const handlersPromise = loadDevMiddlewareHandlers(server, opts); server.middlewares.use((creq, cres, next) => { // Don't touch /__oxide/action — reading the body here would empty the - // Node stream before the action middleware runs. + // Node stream before the action middleware runs. OpenRPC stays in this + // bridge so auth middleware can reject discovery the same as production. if ( - matchesActionPath( + bypassesDevMiddlewareBridge( (creq.url ?? "").split("?")[0] ?? "", opts.actionPath - ) || - matchesOpenRpcPath((creq.url ?? "").split("?")[0] ?? "", OPENRPC_PATH) + ) ) { return next(); } diff --git a/packages/oxidejs/src/types.ts b/packages/oxidejs/src/types.ts index 7765e40..01a8f65 100644 --- a/packages/oxidejs/src/types.ts +++ b/packages/oxidejs/src/types.ts @@ -22,7 +22,8 @@ export type OxidejsActions = sameOrigin?: boolean; /** * Serve `GET /__oxide/openrpc` with an OpenRPC 1.3 document for - * `action()` handlers only. Default: false. + * `action()` handlers only. Ignored when `transport` is `"ws"` (discovery + * is HTTP). Default: false. */ openrpc?: boolean; }; diff --git a/packages/oxidejs/src/wrangler.test.ts b/packages/oxidejs/src/wrangler.test.ts index ad9b64b..77cbcec 100644 --- a/packages/oxidejs/src/wrangler.test.ts +++ b/packages/oxidejs/src/wrangler.test.ts @@ -455,4 +455,23 @@ describe("rewriteRelativeModuleSpecifiers", () => { `import { $ as a, B as array$1, z as object } from "../ssr/assets/factory.js";\n` ); }); + + test("leaves import-shaped text inside strings and comments alone", () => { + const source = [ + `const hint = 'from "./assets/secret.js"';`, + `// from "./assets/comment.js"`, + `/* import("./assets/block.js") */`, + `import "./assets/real.js";`, + ].join("\n"); + expect( + rewriteRelativeModuleSpecifiers(source, "/dist/ssr", "/dist/celld") + ).toBe( + [ + `const hint = 'from "./assets/secret.js"';`, + `// from "./assets/comment.js"`, + `/* import("./assets/block.js") */`, + `import "../ssr/assets/real.js";`, + ].join("\n") + ); + }); }); diff --git a/packages/oxidejs/src/wrangler.ts b/packages/oxidejs/src/wrangler.ts index 0d5d654..36d6345 100644 --- a/packages/oxidejs/src/wrangler.ts +++ b/packages/oxidejs/src/wrangler.ts @@ -350,12 +350,129 @@ export const stripCelldBareNodeImports = function stripCelldBareNodeImports( return out; }; +const relocateRelativeSpecifier = function relocateRelativeSpecifier( + spec: string, + fromDir: string, + toDir: string +) { + const absolute = path.resolve(fromDir, spec); + let relocated = path.relative(toDir, absolute).split(path.sep).join("/"); + if (!(relocated.startsWith("./") || relocated.startsWith("../"))) { + relocated = `./${relocated}`; + } + return relocated; +}; + +const skipLineComment = function skipLineComment( + source: string, + start: number +) { + let i = start + 2; + while (i < source.length && source[i] !== "\n") { + i += 1; + } + return i; +}; + +const skipBlockComment = function skipBlockComment( + source: string, + start: number +) { + let i = start + 2; + while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) { + i += 1; + } + return Math.min(i + 2, source.length); +}; + +const skipStringLiteral = function skipStringLiteral( + source: string, + start: number +) { + const quote = source[start]; + let i = start + 1; + while (i < source.length) { + if (source[i] === "\\") { + i += 2; + continue; + } + if (source[i] === quote) { + return i + 1; + } + if (quote === "`" && source[i] === "$" && source[i + 1] === "{") { + i += 2; + let depth = 1; + while (i < source.length && depth > 0) { + if (source[i] === "\\") { + i += 2; + continue; + } + if (source[i] === "{") { + depth += 1; + } else if (source[i] === "}") { + depth -= 1; + } + i += 1; + } + continue; + } + i += 1; + } + return i; +}; + +const FROM_SPEC_RE = /^from\s+(?["'])(?\.[^"']+)\k/u; +const IMPORT_CALL_SPEC_RE = + /^import\s*\(\s*(?["'])(?\.[^"']+)\k/u; +const IMPORT_SIDE_SPEC_RE = + /^import\s+(?["'])(?\.[^"']+)\k/u; + +const tryRewriteModuleSpecifierAt = function tryRewriteModuleSpecifierAt( + source: string, + index: number, + fromDir: string, + toDir: string +): { length: number; text: string } | undefined { + if (index > 0 && /[\w$]/u.test(source[index - 1] ?? "")) { + return; + } + const rest = source.slice(index); + const fromMatch = FROM_SPEC_RE.exec(rest); + if (fromMatch?.groups) { + const quote = fromMatch.groups["quote"] ?? '"'; + const spec = fromMatch.groups["spec"] ?? ""; + return { + length: fromMatch[0].length, + text: `from ${quote}${relocateRelativeSpecifier(spec, fromDir, toDir)}${quote}`, + }; + } + const callMatch = IMPORT_CALL_SPEC_RE.exec(rest); + if (callMatch?.groups) { + const quote = callMatch.groups["quote"] ?? '"'; + const spec = callMatch.groups["spec"] ?? ""; + return { + length: callMatch[0].length, + text: `import(${quote}${relocateRelativeSpecifier(spec, fromDir, toDir)}${quote}`, + }; + } + const sideMatch = IMPORT_SIDE_SPEC_RE.exec(rest); + if (sideMatch?.groups) { + const quote = sideMatch.groups["quote"] ?? '"'; + const spec = sideMatch.groups["spec"] ?? ""; + return { + length: sideMatch[0].length, + text: `import ${quote}${relocateRelativeSpecifier(spec, fromDir, toDir)}${quote}`, + }; + } +}; + /** * Rewrite relative `import` / `export` specifiers so a file moved to `toDir` * still resolves modules that lived next to the original under `fromDir`. * - * Matches `from "…"`, `import("…")`, and side-effect `import "…"` only — - * do not parse binding lists (minified names use `$`, digits, …). + * Only rewrites real module dependency string literals (`from "…"`, + * `import("…")`, side-effect `import "…"`). Strings and comments are skipped + * so import-shaped text inside a string literal stays unchanged. */ export const rewriteRelativeModuleSpecifiers = function rewriteRelativeModuleSpecifiers( @@ -368,33 +485,44 @@ export const rewriteRelativeModuleSpecifiers = if (fromResolved === toResolved) { return source; } - const relocate = function relocate(spec: string) { - const absolute = path.resolve(fromResolved, spec); - let relocated = path - .relative(toResolved, absolute) - .split(path.sep) - .join("/"); - if (!(relocated.startsWith("./") || relocated.startsWith("../"))) { - relocated = `./${relocated}`; + let result = ""; + let i = 0; + while (i < source.length) { + const c0 = source[i]; + const c1 = source[i + 1]; + if (c0 === "/" && c1 === "/") { + const end = skipLineComment(source, i); + result += source.slice(i, end); + i = end; + continue; } - return relocated; - }; - let out = source.replaceAll( - /\bfrom\s+(?["'])(?\.[^"']+)\k/gu, - (_full, quote: string, spec: string) => - `from ${quote}${relocate(spec)}${quote}` - ); - out = out.replaceAll( - /\bimport\s*\(\s*(?["'])(?\.[^"']+)\k/gu, - (_full, quote: string, spec: string) => - `import(${quote}${relocate(spec)}${quote}` - ); - out = out.replaceAll( - /\bimport\s+(?["'])(?\.[^"']+)\k/gu, - (_full, quote: string, spec: string) => - `import ${quote}${relocate(spec)}${quote}` - ); - return out; + if (c0 === "/" && c1 === "*") { + const end = skipBlockComment(source, i); + result += source.slice(i, end); + i = end; + continue; + } + const rewritten = tryRewriteModuleSpecifierAt( + source, + i, + fromResolved, + toResolved + ); + if (rewritten) { + result += rewritten.text; + i += rewritten.length; + continue; + } + if (c0 === '"' || c0 === "'" || c0 === "`") { + const end = skipStringLiteral(source, i); + result += source.slice(i, end); + i = end; + continue; + } + result += c0; + i += 1; + } + return result; }; /** From 67a0dae67b15fa10f88b88ee2edfbf1b81b59b1c Mon Sep 17 00:00:00 2001 From: Ryuz Date: Fri, 11 Sep 2026 12:08:58 +0200 Subject: [PATCH 3/3] fix(pr): improvements --- packages/oxidejs/src/wrangler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/oxidejs/src/wrangler.ts b/packages/oxidejs/src/wrangler.ts index 36d6345..cbbaee2 100644 --- a/packages/oxidejs/src/wrangler.ts +++ b/packages/oxidejs/src/wrangler.ts @@ -434,7 +434,7 @@ const tryRewriteModuleSpecifierAt = function tryRewriteModuleSpecifierAt( toDir: string ): { length: number; text: string } | undefined { if (index > 0 && /[\w$]/u.test(source[index - 1] ?? "")) { - return; + return undefined; } const rest = source.slice(index); const fromMatch = FROM_SPEC_RE.exec(rest); @@ -464,6 +464,7 @@ const tryRewriteModuleSpecifierAt = function tryRewriteModuleSpecifierAt( text: `import ${quote}${relocateRelativeSpecifier(spec, fromDir, toDir)}${quote}`, }; } + return undefined; }; /**