Skip to content
Draft
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
21 changes: 15 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,25 @@ jobs:
echo "ERROR: dist/index.js not found"
exit 1
fi
if [ ! -f dist/wasm/rulesengine_bg.wasm ]; then
echo "ERROR: dist/wasm/rulesengine_bg.wasm not found"
echo "The build may have failed to copy WASM artifacts"
exit 1
fi
if [ ! -f dist/wasm/rulesengine.js ]; then
echo "ERROR: dist/wasm/rulesengine.js not found"
exit 1
fi
echo "Verified: WASM artifacts present in dist/wasm/"
# The .wasm binary is base64-inlined into rulesengine.js at build
# time (see build.js inlineWasmBinary), so the standalone .wasm
# is intentionally absent from dist/. Verify the inlining sentinel
# is present instead — guards against a future build change that
# silently reverts to the disk-loaded path.
if ! grep -q "WASM binary inlined at build time" dist/wasm/rulesengine.js; then
echo "ERROR: dist/wasm/rulesengine.js does not contain the inlined WASM sentinel"
echo "The build may have failed to run inlineWasmBinary()"
exit 1
fi
if [ -f dist/wasm/rulesengine_bg.wasm ]; then
echo "ERROR: dist/wasm/rulesengine_bg.wasm should have been removed by inlineWasmBinary()"
exit 1
fi
echo "Verified: WASM inlined into dist/wasm/rulesengine.js"

publish:
needs: [ compile, test, verify-package ]
Expand Down
55 changes: 54 additions & 1 deletion build.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// build.js
const esbuild = require('esbuild');
const { execSync } = require('child_process');
const { cpSync, mkdirSync, existsSync } = require('fs');
const { cpSync, mkdirSync, existsSync, readFileSync, writeFileSync, rmSync } = require('fs');

const sharedConfig = {
entryPoints: ['src/index.ts'],
Expand Down Expand Up @@ -52,6 +52,8 @@ async function build() {
}
console.log('✅ WASM artifacts copied to dist/wasm/');

inlineWasmBinary();

// Generate TypeScript declarations with tsc
console.log('🔧 Generating TypeScript declarations...');
execSync('tsc --emitDeclarationOnly --outDir dist', { stdio: 'inherit' });
Expand All @@ -65,4 +67,55 @@ async function build() {
}
}

// Inline the WASM binary into dist/wasm/rulesengine.js so the runtime no
// longer reads it off disk via `fs.readFileSync(${__dirname}/...)`.
//
// The wasm-bindgen-generated loader resolves the .wasm file relative to
// its own `__dirname`, which breaks the moment a downstream bundler
// (webpack, Next.js, Vite, etc.) follows the require chain and rewrites
// `__dirname` to point inside the bundle output — there's no `.wasm`
// sibling there, so initialization fails with a misleading ENOENT.
// See the linked Next.js failure mode at
// .next/dev/server/vendor-chunks/rulesengine_bg.wasm → ENOENT.
//
// Inlining the bytes as a base64 string sidesteps the whole class of
// `__dirname`-aware loaders. Costs ~+138 KB on the tarball (550 KB
// base64 string in JS replaces a 414 KB .wasm + the loader stub) but
// makes the SDK bundler-agnostic for free. We delete the standalone
// `.wasm` after rewriting since nothing reads it at runtime anymore.
function inlineWasmBinary() {
const loaderPath = 'dist/wasm/rulesengine.js';
const binaryPath = 'dist/wasm/rulesengine_bg.wasm';
if (!existsSync(loaderPath) || !existsSync(binaryPath)) {
console.warn('⚠️ Skipping WASM inlining — files not found:', { loaderPath, binaryPath });
return;
}

const wasmBase64 = readFileSync(binaryPath).toString('base64');
const loaderSource = readFileSync(loaderPath, 'utf8');

// Match the wasm-bindgen-emitted block that reads the binary off disk.
// Captures any whitespace/comments wasm-bindgen emits between the two
// statements so future loader updates don't silently bypass this step.
const loaderPattern = /const wasmPath = `\$\{__dirname\}\/rulesengine_bg\.wasm`;\s*\nconst wasmBytes = require\('fs'\)\.readFileSync\(wasmPath\);/;
if (!loaderPattern.test(loaderSource)) {
throw new Error(
'WASM inlining failed: expected `const wasmPath = `${__dirname}/...`; const wasmBytes = require(\'fs\').readFileSync(wasmPath);` in ' +
loaderPath +
'. wasm-bindgen output shape changed — update inlineWasmBinary() to match.'
);
}

const inlined = loaderSource.replace(
loaderPattern,
`// WASM binary inlined at build time (see build.js inlineWasmBinary).\nconst wasmBytes = Buffer.from('${wasmBase64}', 'base64');`
);
writeFileSync(loaderPath, inlined);

// Standalone .wasm is dead weight once the bytes are embedded. Drop
// it so we don't ship two copies of the binary.
rmSync(binaryPath);
console.log(`✅ WASM inlined into ${loaderPath} (${wasmBase64.length} base64 chars, .wasm removed)`);
}

build();
34 changes: 32 additions & 2 deletions src/cache/redis.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,45 @@
import { CacheProvider, CacheOptions } from "./types";

/**
* Minimal interface describing the redis client methods used by RedisCacheProvider.
* Compatible with the 'redis' package's RedisClientType.
* Minimal interface describing the redis client methods used by the SDK.
* Compatible with the 'redis' package's RedisClientType (node-redis v4).
*
* Includes hash + sorted-set + eval surface used by the credit-lease and
* reservation stores to coordinate state across multiple SDK pods.
*/
export interface RedisClient {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<unknown>;
setEx(key: string, seconds: number, value: string): Promise<unknown>;
del(key: string | string[]): Promise<unknown>;
scanIterator(options: { MATCH: string; COUNT: number }): AsyncIterable<string>;
// Hash ops — used to store lease + reservation state as a single field-set
// so partial updates (e.g. atomic decrement on localRemainingCredits) don't
// step on neighboring fields.
hSet(key: string, field: string | Record<string, string | number>, value?: string | number): Promise<unknown>;
hGet(key: string, field: string): Promise<string | null | undefined>;
hGetAll(key: string): Promise<Record<string, string>>;
hDel(key: string, field: string | string[]): Promise<unknown>;
// Sorted-set ops — used to index reservations by expiry timestamp so the
// sweeper can pop expired entries in O(log n).
zAdd(key: string, members: { score: number; value: string } | { score: number; value: string }[]): Promise<unknown>;
zRangeByScore(key: string, min: number | string, max: number | string): Promise<string[]>;
zRem(key: string, member: string | string[]): Promise<unknown>;
zCard(key: string): Promise<number>;
// Set ops — used by `RedisLeaseStore` as an index of outstanding lease slots
// so `snapshot()` (driving `releaseAll` on close) can enumerate them without
// scanning Redis.
sMembers(key: string): Promise<string[]>;
sRem(key: string, member: string): Promise<unknown>;
// Lua scripts — required for atomic check-and-decrement on lease balance
// and atomic consume-and-refund on reservations.
eval(
script: string,
options: { keys: string[]; arguments: string[] },
): Promise<unknown>;
// Expiry on a millisecond-precision absolute timestamp — used to auto-clean
// lease + reservation rows shortly after their declared expiry.
pExpireAt(key: string, timestamp: number): Promise<unknown>;
}

export interface RedisOptions extends CacheOptions {
Expand Down
Loading