Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<file>.<fn>` (`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.
Expand Down
5 changes: 3 additions & 2 deletions apps/website/docs/(introduction)/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</Card>
</CardGroup>

Expand All @@ -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
30 changes: 15 additions & 15 deletions apps/website/docs/oxide/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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";
Expand All @@ -86,13 +91,15 @@ oxide({
transport: "http",
path: "/__oxide/action",
sameOrigin: true,
openrpc: true,
},
});
```

- `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). 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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions apps/website/docs/oxide/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions apps/website/docs/oxide/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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

Expand All @@ -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
2 changes: 2 additions & 0 deletions apps/website/docs/oxide/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions apps/website/docs/oxide/server-actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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). 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.
16 changes: 10 additions & 6 deletions apps/website/docs/oxide/worker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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:

Expand All @@ -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:
Expand Down Expand Up @@ -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).
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 28 additions & 2 deletions packages/oxidejs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
# 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`)
- 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

- 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
Expand Down Expand Up @@ -32,11 +58,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

Expand Down
Loading
Loading