Skip to content

Commit 7056b25

Browse files
committed
RDBC-1083: Cloudflare workers enabled
1 parent 12df664 commit 7056b25

29 files changed

Lines changed: 680 additions & 4 deletions

.github/workflows/Cloudflare.yml

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
name: tests/cloudflare
2+
3+
permissions:
4+
contents: read
5+
6+
on:
7+
push:
8+
branches: [ v7.2 ]
9+
pull_request:
10+
branches: [ v7.2 ]
11+
schedule:
12+
- cron: '0 10 * * *'
13+
workflow_dispatch:
14+
15+
jobs:
16+
# Boots the client in real workerd across the bundlers used to deploy to
17+
# Cloudflare, and asserts it loads (guards the RDBC-1083 load/interop family).
18+
# No RavenDB server needed.
19+
load:
20+
name: load (${{ matrix.dir }}, node ${{ matrix.node-version }})
21+
runs-on: ubuntu-latest
22+
strategy:
23+
fail-fast: false
24+
matrix:
25+
node-version: [20.x, 22.x]
26+
dir:
27+
- test/cloudflare-worker # wrangler / esbuild
28+
- test/cloudflare-vite # Vite / Rollup
29+
- test/cloudflare-nitro # Nitro / Rollup + unenv (TanStack Start-style)
30+
31+
steps:
32+
- uses: actions/checkout@v4
33+
34+
- name: Setup Node.js ${{ matrix.node-version }}
35+
uses: actions/setup-node@v4
36+
with:
37+
node-version: ${{ matrix.node-version }}
38+
39+
# `npm ci` runs `prepare` (tshy build + addWorkerCondition.mjs): builds dist/
40+
# and injects the workerd/worker export conditions the bundlers resolve.
41+
- name: Build the client
42+
run: npm ci
43+
44+
- name: Install example deps (resolves ravendb from repo root)
45+
working-directory: ${{ matrix.dir }}
46+
run: npm install
47+
48+
- name: Build the worker (if the bundler needs a build step)
49+
working-directory: ${{ matrix.dir }}
50+
run: npm run build --if-present
51+
52+
- name: Load smoke test in workerd
53+
working-directory: ${{ matrix.dir }}
54+
env:
55+
WRANGLER_SEND_METRICS: "false"
56+
run: npm run smoke
57+
58+
# Full data-path end-to-end: a Nitro (TanStack Start-style) worker running in
59+
# workerd performs a real store + load against an (insecure) RavenDB server.
60+
# Exercises the request + response-stream pipeline that node:stream provides.
61+
e2e:
62+
name: e2e (nitro -> RavenDB, node ${{ matrix.node-version }})
63+
runs-on: ubuntu-latest
64+
strategy:
65+
fail-fast: false
66+
matrix:
67+
node-version: [22.x]
68+
serverVersion: ["7.2"]
69+
env:
70+
RAVEN_License: ${{ secrets.RAVEN_LICENSE }}
71+
RAVENDB_URL: http://127.0.0.1:8080
72+
73+
steps:
74+
- uses: actions/checkout@v4
75+
76+
- name: Setup Node.js ${{ matrix.node-version }}
77+
uses: actions/setup-node@v4
78+
with:
79+
node-version: ${{ matrix.node-version }}
80+
81+
- name: Download RavenDB Server
82+
run: wget -O RavenDB.tar.bz2 "https://hibernatingrhinos.com/downloads/RavenDB%20for%20Linux%20x64/latest?buildType=nightly&version=${{ matrix.serverVersion }}"
83+
84+
- name: Extract RavenDB Server
85+
run: tar xjf RavenDB.tar.bz2
86+
87+
- name: Start RavenDB (insecure, in-memory)
88+
run: |
89+
chmod +x ./RavenDB/Server/Raven.Server
90+
./RavenDB/Server/Raven.Server \
91+
--non-interactive \
92+
--ServerUrl=http://127.0.0.1:8080 \
93+
--Setup.Mode=None \
94+
--Security.UnsecuredAccessAllowed=PublicNetwork \
95+
--License.Eula.Accepted=true \
96+
--RunInMemory=true &
97+
for i in $(seq 1 60); do
98+
if curl -sf http://127.0.0.1:8080/build/version > /dev/null; then echo "RavenDB up"; exit 0; fi
99+
sleep 1
100+
done
101+
echo "RavenDB did not start in time" && exit 1
102+
103+
- name: Build the client
104+
run: npm ci
105+
106+
- name: Install example deps
107+
working-directory: test/cloudflare-nitro
108+
run: npm install
109+
110+
- name: Build the Nitro worker
111+
working-directory: test/cloudflare-nitro
112+
run: npm run build
113+
114+
- name: End-to-end store/load in workerd against RavenDB
115+
working-directory: test/cloudflare-nitro
116+
env:
117+
WRANGLER_SEND_METRICS: "false"
118+
run: npm run e2e

README.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2321,6 +2321,91 @@ let store = new DocumentStore('url', 'databaseName', authOptions);
23212321
store.initialize();
23222322
```
23232323

2324+
## Cloudflare Workers
2325+
2326+
The client runs on [Cloudflare Workers](https://developers.cloudflare.com/workers/).
2327+
Three Workers-specific things to know:
2328+
2329+
**1. Enable the Node.js compatibility flag.** The client uses a few Node built-ins
2330+
(`node:stream`, `node:events`, …). Add `nodejs_compat` to your `wrangler.toml`:
2331+
2332+
```toml
2333+
compatibility_flags = ["nodejs_compat"]
2334+
compatibility_date = "2024-09-23" # or newer
2335+
```
2336+
2337+
The package ships `workerd` and `worker` export conditions, so wrangler/esbuild and
2338+
framework bundlers (OpenNext, Vite, …) automatically resolve the Workers-compatible
2339+
ESM build — you do not need any bundler aliases or `serverExternalPackages` tweaks.
2340+
2341+
**2. On frameworks that use Nitro (TanStack Start, Nuxt, Nitro), make sure Node
2342+
built-ins resolve to the runtime — not to `unenv` stubs.** Nitro polyfills Node with
2343+
[`unenv`](https://github.com/unjs/unenv), whose `node:stream` is incomplete. If Node.js
2344+
compatibility is not enabled, the first request fails and the client raises a descriptive
2345+
error (the underlying `[unenv] PassThrough is not implemented yet!` is kept as its cause):
2346+
2347+
```
2348+
InvalidOperationException: The RavenDB client requires a working node:stream on Cloudflare
2349+
Workers, but this runtime provided an incomplete polyfill. Enable Node.js compatibility by
2350+
adding compatibility_flags = ["nodejs_compat"] (and a recent compatibility_date) to your
2351+
wrangler configuration. ...
2352+
```
2353+
2354+
The fix is to enable Cloudflare's real Node.js compatibility so Nitro defers `node:`
2355+
built-ins to `workerd` instead of stubbing them. Provide a `wrangler.toml` (that Nitro
2356+
reads at build time) with the flag, and set a recent `compatibilityDate` in your Nitro
2357+
/ TanStack Start config:
2358+
2359+
```toml
2360+
# wrangler.toml (read by Nitro at build time)
2361+
compatibility_date = "2024-09-23" # or newer
2362+
compatibility_flags = ["nodejs_compat"]
2363+
```
2364+
2365+
You can confirm it worked: the built bundle no longer inlines `unenv` `node:*` stubs,
2366+
and requests stream responses successfully.
2367+
2368+
**3. mTLS is done with a Cloudflare `mtls_certificate` binding, not `authOptions`.**
2369+
Workers has no Node `https` agent, so X.509 client-certificate authentication cannot
2370+
use the usual `authOptions` certificate. Instead, upload the client certificate to
2371+
Cloudflare and hand the client a `fetch` bound to that certificate via
2372+
`conventions.customFetch`:
2373+
2374+
```bash
2375+
# Upload the RavenDB client certificate (PEM: cert + key) once:
2376+
npx wrangler mtls-certificate upload --cert client.crt --key client.key --name ravendb
2377+
```
2378+
2379+
```toml
2380+
# wrangler.toml — bind the uploaded certificate
2381+
mtls_certificates = [
2382+
{ binding = "RAVENDB_CERT", certificate_id = "<id printed by the upload command>" }
2383+
]
2384+
```
2385+
2386+
```javascript
2387+
import { DocumentStore } from "ravendb";
2388+
2389+
export default {
2390+
async fetch(request, env) {
2391+
// `env` (and therefore the binding) is only available inside the handler.
2392+
const store = new DocumentStore("https://a.free.example.ravendb.cloud", "MyDatabase");
2393+
2394+
// Route every request through the mTLS binding's fetch. Because the binding
2395+
// presents the client certificate at the TLS layer, do NOT also pass authOptions.
2396+
store.conventions.customFetch = env.RAVENDB_CERT.fetch.bind(env.RAVENDB_CERT);
2397+
store.initialize();
2398+
2399+
const session = store.openSession();
2400+
const doc = await session.load("users/1");
2401+
return Response.json(doc ?? null);
2402+
}
2403+
};
2404+
```
2405+
2406+
A minimal, runnable load check lives in [`test/cloudflare-worker`](./test/cloudflare-worker)
2407+
and runs in CI.
2408+
23242409
## Building
23252410
23262411
```bash

package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@
1313
"exports": {
1414
"./package.json": "./package.json",
1515
".": {
16+
"workerd": {
17+
"types": "./dist/esm/index.d.ts",
18+
"default": "./dist/esm/index.js"
19+
},
20+
"worker": {
21+
"types": "./dist/esm/index.d.ts",
22+
"default": "./dist/esm/index.js"
23+
},
1624
"import": {
1725
"types": "./dist/esm/index.d.ts",
1826
"default": "./dist/esm/index.js"
@@ -25,7 +33,7 @@
2533
},
2634
"scripts": {
2735
"test": "mocha \"test/**/*.ts\"",
28-
"prepare": "tshy",
36+
"prepare": "tshy && node scripts/addWorkerCondition.mjs",
2937
"watch": "tsc --watch",
3038
"lint": "eslint {src,test}/**/*.ts",
3139
"check-exports": "node ./scripts/reportMissingTopLevelExports.js",

scripts/addWorkerCondition.mjs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Post-build step for `prepare` (runs after tshy).
2+
//
3+
// tshy regenerates package.json "exports" on every build with only the
4+
// "import" (ESM) and "require" (CommonJS) conditions. Cloudflare Workers and
5+
// other edge bundlers (wrangler/esbuild, OpenNext, Vite) resolve the "workerd"
6+
// and "worker" export conditions. Without them a bundler may fall back to the
7+
// CommonJS `require()` chain, which is exactly the resolution path that breaks
8+
// under an ESM bundler (RDBC-1083: "Class extends value [object Module]").
9+
//
10+
// We therefore inject "workerd" and "worker" conditions that always point at
11+
// the ESM build, ordered BEFORE "import"/"require" so they win on Workers.
12+
// The script is idempotent: it strips any conditions it previously added and
13+
// re-inserts them, so repeated `prepare` runs converge to the same result.
14+
import { readFileSync, writeFileSync } from "node:fs";
15+
16+
const PKG_PATH = new URL("../package.json", import.meta.url);
17+
const WORKER_CONDITIONS = ["workerd", "worker"];
18+
19+
const pkg = JSON.parse(readFileSync(PKG_PATH, "utf8"));
20+
21+
if (!pkg.exports || typeof pkg.exports !== "object") {
22+
throw new Error("addWorkerCondition: package.json has no 'exports' map (did tshy run first?)");
23+
}
24+
25+
let patched = 0;
26+
27+
for (const [subpath, entry] of Object.entries(pkg.exports)) {
28+
// Only conditional-object entries (e.g. "." -> { import, require }); skip
29+
// plain string entries like "./package.json".
30+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
31+
continue;
32+
}
33+
34+
// The ESM target is whatever the "import" condition resolves to.
35+
const esmTarget = entry.import;
36+
if (!esmTarget) {
37+
continue;
38+
}
39+
40+
// Rebuild the entry so the worker conditions come first. Drop any previously
41+
// injected worker conditions to stay idempotent.
42+
const rest = {};
43+
for (const [condition, value] of Object.entries(entry)) {
44+
if (!WORKER_CONDITIONS.includes(condition)) {
45+
rest[condition] = value;
46+
}
47+
}
48+
49+
const rebuilt = {};
50+
for (const condition of WORKER_CONDITIONS) {
51+
// Deep-clone the ESM target so each condition is an independent value.
52+
rebuilt[condition] = JSON.parse(JSON.stringify(esmTarget));
53+
}
54+
Object.assign(rebuilt, rest);
55+
56+
pkg.exports[subpath] = rebuilt;
57+
patched++;
58+
}
59+
60+
writeFileSync(PKG_PATH, JSON.stringify(pkg, null, 2) + "\n");
61+
// Log to stderr so it never pollutes stdout of `npm pack --silent` (which is
62+
// parsed to obtain the tarball filename in CI).
63+
console.error(`addWorkerCondition: injected ${WORKER_CONDITIONS.join("/")} into ${patched} export(s).`);

src/Http/RavenCommand.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { StatusCodes } from "./StatusCode.js";
44
import { Stream, Readable, PassThrough } from "node:stream";
55
import { HttpRequestParameters, HttpResponse } from "../Primitives/Http.js";
66
import { getLogger } from "../Utility/LogUtil.js";
7-
import { throwError } from "../Exceptions/index.js";
7+
import { getError, throwError } from "../Exceptions/index.js";
88
import { IRavenObject } from "../Types/IRavenObject.js";
99
import { getEtagHeader, HeadersBuilder, closeHttpResponse } from "../Utility/HttpUtil.js";
1010
import { TypeInfo } from "../Mapping/ObjectMapper.js";
@@ -161,14 +161,24 @@ export abstract class RavenCommand<TResult> {
161161

162162
if (RuntimeUtil.isBun()) {
163163
optionsToUse = { body: bodyToUse, ...restOptions } as BunFetchRequestInit;
164+
} else if (RuntimeUtil.isWorkerd()) {
165+
// Cloudflare Workers' fetch has no undici `dispatcher` concept; mTLS is
166+
// handled by the custom fetch (an mtls_certificate binding). Passing a
167+
// dispatcher here is meaningless and can confuse the runtime.
168+
optionsToUse = { body: bodyToUse, ...restOptions } as RequestInit;
164169
} else {
165170
if (requestOptions.dispatcher) { // support for fiddler
166171
agent = requestOptions.dispatcher;
167172
}
168173
optionsToUse = { body: bodyToUse, ...restOptions, dispatcher: agent } as RequestInit;
169174
}
170175

171-
const passthrough = new PassThrough();
176+
let passthrough: PassThrough;
177+
try {
178+
passthrough = new PassThrough();
179+
} catch (err) {
180+
throw RavenCommand._maybeNodeStreamUnavailableError(err as Error);
181+
}
172182
passthrough.pause();
173183

174184
const fetchFn = fetcher ?? fetch; // support for custom fetcher
@@ -188,6 +198,23 @@ export abstract class RavenCommand<TResult> {
188198
};
189199
}
190200

201+
private static _maybeNodeStreamUnavailableError(err: Error): Error {
202+
// Some edge bundlers ship an incomplete `node:stream` where PassThrough throws
203+
// "not implemented" (e.g. Nitro/unenv, used by TanStack Start & Nuxt). Turn that
204+
// cryptic stub error into an actionable one pointing at the real fix.
205+
if (RuntimeUtil.isWorkerd()) {
206+
return getError(
207+
"InvalidOperationException",
208+
"The RavenDB client requires a working node:stream on Cloudflare Workers, but this "
209+
+ "runtime provided an incomplete polyfill. Enable Node.js compatibility by adding "
210+
+ `compatibility_flags = ["nodejs_compat"] (and a recent compatibility_date) to your `
211+
+ "wrangler configuration. For framework bundlers such as Nitro / TanStack Start / Nuxt "
212+
+ "this makes node: built-ins resolve to the workerd runtime instead of unenv stubs.",
213+
err);
214+
}
215+
return err;
216+
}
217+
191218
private static maybeWrapBody(body: any) {
192219
if (body instanceof Readable) {
193220
throw new Error("Requests using stream.Readable as payload are not yet supported!");

src/Http/RequestExecutor.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,12 @@ export class RequestExecutor implements IDisposable {
344344
return null;
345345
}
346346

347+
if (RuntimeUtil.isWorkerd()) {
348+
// Cloudflare Workers has no undici Agent / Node https agent.
349+
// mTLS is provided via conventions.customFetch (an mtls_certificate binding).
350+
return null;
351+
}
352+
347353
if (this._httpAgent) {
348354
return this._httpAgent;
349355
}

0 commit comments

Comments
 (0)