Skip to content

Commit 7b833b0

Browse files
authored
Merge pull request #559 from Scooletz/RDBC-1083-cloudflare-workers
RDBC-1083: Cloudflare workers enabled
2 parents c6bcca6 + 5b31a56 commit 7b833b0

30 files changed

Lines changed: 770 additions & 18 deletions

.github/workflows/Cloudflare.yml

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

.mocharc.jsonc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
"slow": 1000,
33
"timeout": 40000,
44

5+
// The test/cloudflare-* dirs are self-contained Cloudflare example projects
6+
// (with their own deps); they must not be picked up by the "test/**/*.ts"
7+
// runner or it fails to resolve their bundler deps (e.g. nitropack).
8+
"ignore": [
9+
"test/cloudflare-*/**"
10+
],
11+
512
"node-option": [
613
"import=./register.js"
714
]

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: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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+
// Fail the build loudly if nothing was patched. tshy rewrites the whole "exports"
61+
// map on every build, so if its output shape ever changes (e.g. the "import"
62+
// condition is renamed or nested differently) this script would silently produce a
63+
// package.json without the workerd/worker conditions and ship a broken package.
64+
if (patched === 0) {
65+
throw new Error(
66+
"addWorkerCondition: patched 0 export entries -- no entry exposed an 'import' condition to mirror. "
67+
+ "tshy's 'exports' shape likely changed; the workerd/worker conditions were NOT injected. "
68+
+ "Refusing to leave package.json without them.");
69+
}
70+
71+
writeFileSync(PKG_PATH, JSON.stringify(pkg, null, 2) + "\n");
72+
// Log to stderr so it never pollutes stdout of `npm pack --silent` (which is
73+
// parsed to obtain the tarball filename in CI).
74+
console.error(`addWorkerCondition: injected ${WORKER_CONDITIONS.join("/")} into ${patched} export(s).`);

0 commit comments

Comments
 (0)