Skip to content

Commit ca8335f

Browse files
SCHY-148 Use miniflare to test changes in a cloudflare-esque environment to catch issues (#92)
* use miniflare to test builds in a cloudflare environment ( to smoke test cloud flare issues early) * add replicator/datastream readme * use webassembly.compile over webassembly.module * add mroe cloudflare tests cases * remove console.log * move cloudflare-compat job to a different workflow to avoid fern regeneration issues * saving this here for later * remove cloudflare check from ci for now * Add cloudflare test script to CI (restoring from removing it in earlier PR) --------- Co-authored-by: Christopher Brady <chris@schematichq.com>
1 parent 4236b25 commit ca8335f

File tree

10 files changed

+205
-19
lines changed

10 files changed

+205
-19
lines changed

.fernignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
.claude/
44
.github/CODEOWNERS
5+
.github/workflows/cloudflare.yml
56
CLAUDE.md
67
LICENSE
78
README.md
@@ -19,6 +20,7 @@ src/version.ts
1920
src/wasm/
2021
src/webhooks.ts
2122
src/wrapper.ts
23+
tests/cloudflare/
2224
tests/unit/cache/local.test.ts
2325
tests/unit/datastream/datastream-client.test.ts
2426
tests/unit/datastream/websocket-client.test.ts

.github/workflows/cloudflare.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: cloudflare-compat
2+
3+
on: [push]
4+
5+
jobs:
6+
cloudflare-compat:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- name: Checkout repo
10+
uses: actions/checkout@v6
11+
12+
- name: Set up node
13+
uses: actions/setup-node@v6
14+
15+
- name: Install dependencies
16+
run: yarn install --frozen-lockfile
17+
18+
- name: Build
19+
run: yarn build
20+
21+
- name: Test Cloudflare compatibility
22+
run: yarn test:cloudflare

README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,97 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
601601
}
602602
```
603603

604+
## DataStream
605+
606+
DataStream enables local flag evaluation by maintaining a WebSocket connection to Schematic and caching flag rules, company, and user data locally.
607+
608+
> **Runtime compatibility:** DataStream requires Node.js APIs (`WebSocket`, `EventEmitter`) and is not supported in edge runtimes such as Cloudflare Workers, Vercel Edge Functions, or Deno Deploy. For these runtimes, use [Replicator Mode](#replicator-mode) instead.
609+
610+
### Setup
611+
612+
```ts
613+
import { SchematicClient } from "@schematichq/schematic-typescript-node";
614+
615+
const client = new SchematicClient({
616+
apiKey: process.env.SCHEMATIC_API_KEY,
617+
useDataStream: true,
618+
});
619+
620+
// Flag checks are now evaluated locally
621+
const flagValue = await client.checkFlag(
622+
{ company: { id: "your-company-id" } },
623+
"some-flag-key",
624+
);
625+
626+
client.close();
627+
```
628+
629+
### Configuration options
630+
631+
| Option | Type | Default | Description |
632+
|---|---|---|---|
633+
| `cacheTTL` | `number` | 24 hours | Cache TTL in milliseconds |
634+
| `redisClient` | `RedisClient` || Redis client for shared caching (uses in-memory cache if not provided) |
635+
| `redisKeyPrefix` | `string` | `schematic:` | Key prefix for Redis cache entries |
636+
637+
```ts
638+
import { createClient } from "redis";
639+
import { SchematicClient } from "@schematichq/schematic-typescript-node";
640+
641+
const redisClient = createClient({ url: "redis://localhost:6379" });
642+
await redisClient.connect();
643+
644+
const client = new SchematicClient({
645+
apiKey: process.env.SCHEMATIC_API_KEY,
646+
useDataStream: true,
647+
dataStream: {
648+
redisClient,
649+
redisKeyPrefix: "schematic:",
650+
cacheTTL: 60 * 60 * 1000, // 1 hour
651+
},
652+
});
653+
```
654+
655+
## Replicator Mode
656+
657+
Replicator mode is designed for environments where a separate process (the replicator) manages the WebSocket connection and populates a shared cache. The SDK reads from that cache and evaluates flags locally without establishing its own WebSocket connection.
658+
659+
### Requirements
660+
661+
Replicator mode requires a shared cache (Redis or custom cache providers) so the SDK can read data written by the external replicator process.
662+
663+
### Setup
664+
665+
```ts
666+
import { createClient } from "redis";
667+
import { SchematicClient } from "@schematichq/schematic-typescript-node";
668+
669+
const redisClient = createClient({ url: "redis://localhost:6379" });
670+
await redisClient.connect();
671+
672+
const client = new SchematicClient({
673+
apiKey: process.env.SCHEMATIC_API_KEY,
674+
useDataStream: true,
675+
dataStream: {
676+
replicatorMode: true,
677+
redisClient,
678+
replicatorHealthURL: "http://localhost:8080/health",
679+
replicatorHealthCheck: 30000, // 30 seconds
680+
},
681+
});
682+
```
683+
684+
### Configuration options
685+
686+
| Option | Type | Default | Description |
687+
|---|---|---|---|
688+
| `replicatorMode` | `boolean` | `false` | Enable replicator mode |
689+
| `redisClient` | `RedisClient` || **Required.** Redis client for reading from the shared cache |
690+
| `redisKeyPrefix` | `string` | `schematic:` | Key prefix for Redis cache entries |
691+
| `replicatorHealthURL` | `string` || URL to poll for replicator health status |
692+
| `replicatorHealthCheck` | `number` | 30000 | Health check polling interval in milliseconds |
693+
| `cacheTTL` | `number` | 24 hours | Cache TTL in milliseconds |
694+
604695
## Testing
605696

606697
### Offline mode

package.json

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,29 +25,29 @@
2525
"test:cloudflare": "node scripts/test-cloudflare.mjs"
2626
},
2727
"dependencies": {
28+
"@types/ws": "^8.18.1",
2829
"form-data": "^4.0.4",
2930
"formdata-node": "^6.0.3",
3031
"node-fetch": "^2.7.0",
3132
"readable-stream": "^4.5.2",
32-
"ws": "^8.18.1",
33-
"@types/ws": "^8.18.1"
33+
"ws": "^8.18.1"
3434
},
3535
"devDependencies": {
36+
"@biomejs/biome": "2.3.1",
37+
"@jest/globals": "^29.7.0",
38+
"@types/jest": "^29.5.14",
39+
"@types/node": "^18.19.70",
3640
"@types/node-fetch": "^2.6.12",
3741
"@types/readable-stream": "^4.0.18",
38-
"webpack": "^5.97.1",
39-
"ts-loader": "^9.5.1",
42+
"esbuild": "^0.25.9",
4043
"jest": "^29.7.0",
41-
"@jest/globals": "^29.7.0",
42-
"@types/jest": "^29.5.14",
43-
"ts-jest": "^29.3.4",
4444
"jest-environment-jsdom": "^29.7.0",
45+
"miniflare": "^4.20260305.0",
4546
"msw": "2.11.2",
46-
"@types/node": "^18.19.70",
47+
"ts-jest": "^29.3.4",
48+
"ts-loader": "^9.5.1",
4749
"typescript": "~5.7.2",
48-
"@biomejs/biome": "2.3.1",
49-
"esbuild": "^0.25.9",
50-
"miniflare": "^4.20260305.0"
50+
"webpack": "^5.97.1"
5151
},
5252
"browser": {
5353
"fs": false,

scripts/test-cloudflare.mjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { build } from "esbuild";
2+
import { Miniflare } from "miniflare";
3+
4+
// Bundle the worker entry + all external deps into a single ESM file.
5+
// platform: 'browser' mirrors how Wrangler bundles for Workers (no Node.js globals assumed).
6+
// nodejs_compat in Miniflare provides runtime polyfills for Node built-ins.
7+
const result = await build({
8+
entryPoints: ["tests/cloudflare/worker.mjs"],
9+
bundle: true,
10+
format: "esm",
11+
target: "esnext",
12+
platform: "browser",
13+
write: false,
14+
});
15+
16+
const mf = new Miniflare({
17+
modules: true,
18+
script: result.outputFiles[0].text,
19+
compatibilityFlags: ["nodejs_compat"],
20+
compatibilityDate: "2024-09-23",
21+
});
22+
23+
try {
24+
const res = await mf.dispatchFetch("http://localhost/");
25+
const text = await res.text();
26+
if (!res.ok) {
27+
console.error("❌ Cloudflare compatibility test failed:", text);
28+
process.exit(1);
29+
}
30+
console.log("✅ Cloudflare compatibility test passed!");
31+
} finally {
32+
await mf.dispose();
33+
}

src/rules-engine.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as Schematic from './api/types';
2-
import { RulesEngineJS } from './wasm/rulesengine.js';
2+
import { RulesEngineJS, initWasm } from './wasm/rulesengine.js';
33

44
/** Entitlement details returned by the WASM rules engine */
55
export interface WasmFeatureEntitlement {
@@ -50,6 +50,7 @@ export class RulesEngineClient {
5050
}
5151

5252
try {
53+
await initWasm();
5354
this.wasmInstance = new RulesEngineJS();
5455
this.initialized = true;
5556
} catch (error) {

src/wasm/rulesengine.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* tslint:disable */
22
/* eslint-disable */
3+
export function initWasm(): Promise<void>;
34
export class RulesEngineJS {
45
free(): void;
56
[Symbol.dispose](): void;

src/wasm/rulesengine.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -192,10 +192,14 @@ exports.__wbindgen_init_externref_table = function() {
192192
;
193193
};
194194

195-
const wasmBase64 = require('./rulesengine_bg_wasm_base64.js');
196-
const wasmBytes = Uint8Array.from(atob(wasmBase64), c => c.charCodeAt(0));
197-
const wasmModule = new WebAssembly.Module(wasmBytes);
198-
const wasm = exports.__wasm = new WebAssembly.Instance(wasmModule, imports).exports;
199-
200-
wasm.__wbindgen_start();
195+
let wasm;
196+
197+
exports.initWasm = async function() {
198+
if (wasm) return;
199+
const wasmBase64 = require('./rulesengine_bg_wasm_base64.js');
200+
const wasmBytes = Uint8Array.from(atob(wasmBase64), c => c.charCodeAt(0));
201+
const wasmModule = await WebAssembly.compile(wasmBytes);
202+
wasm = exports.__wasm = (await WebAssembly.instantiate(wasmModule, imports)).exports;
203+
wasm.__wbindgen_start();
204+
};
201205

src/wrapper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,4 +549,4 @@ export class SchematicClient extends BaseClient {
549549
}
550550
}
551551

552-
export class Schematic extends SchematicClient {}
552+
export class Schematic extends SchematicClient {}

tests/cloudflare/worker.mjs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { SchematicClient } from "../../dist/index.js";
2+
3+
export default {
4+
async fetch(request, env, ctx) {
5+
const results = [];
6+
7+
// Test 1: Offline mode — basic import and instantiation
8+
try {
9+
const client = new SchematicClient({ offline: true });
10+
await client.close();
11+
results.push("offline-mode: ok");
12+
} catch (e) {
13+
return new Response("offline-mode: " + e.message, { status: 500 });
14+
}
15+
16+
// Test 2: DataStream in non-replicator mode should be blocked in edge runtime
17+
try {
18+
const client = new SchematicClient({
19+
apiKey: "test_key",
20+
useDataStream: true,
21+
});
22+
// DataStream should have been disabled (no datastreamClient created)
23+
// but the client should still be functional
24+
await client.close();
25+
results.push("datastream-blocked: ok");
26+
} catch (e) {
27+
return new Response("datastream-blocked: " + e.message, { status: 500 });
28+
}
29+
30+
return new Response(results.join("\n"));
31+
},
32+
};

0 commit comments

Comments
 (0)