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
19 changes: 19 additions & 0 deletions packages/functions-compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,25 @@ enforced without making the manifest user-importable.
Release this package before bumping apper's compiler pin. Existing compiled
Workers need rebuilding; the workspace toggle does not install new code.

## Workers compatibility for npm clients

Two gaps in workerd break common Node-targeted npm packages, Stagehand among
them, and the compiler closes both for every function and actor:

- **`ws`.** Its exports map sends the `browser` condition to a stub that throws
on use. `src/esbuild/ws-native-virtual.ts` resolves bare `ws` and
`npm:ws[@version]` to a `ws`-shaped client over workerd's native WebSocket
(`on`/`once`/`off`, `send`, `close`, `terminate`, `readyState`). It covers
what Stagehand uses, not the whole `ws` API. When the options carry headers
beyond `User-Agent` (Stagehand's `cdpHeaders`), the socket is opened with a
`fetch` upgrade so they reach the server, and that connection follows the
function's egress routing. Other connection options are ignored, and
`WebSocketServer` throws: a function can open sockets but not listen.
- **`AsyncLocalStorage.enterWith()`.** workerd's version throws. The Deno shim
replaces it with a no-op, so the store is never seeded. That suits libraries
that also pass their context explicitly and re-enter it with `run()`. The
shim probes first and keeps a working native implementation.

## Layout

| Path | What it is |
Expand Down
3 changes: 3 additions & 0 deletions packages/functions-compiler/src/deno-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { privateDataSourcesVirtualPlugin } from "./esbuild/private-data-sources-
import { runtimeContextVirtualPlugin } from "./esbuild/runtime-context-virtual.js";
import { runtimeVirtualPlugin } from "./esbuild/runtime-virtual.js";
import { USER_NAMESPACE, userFilesPlugin } from "./esbuild/user-files.js";
import { wsNativeVirtualPlugin } from "./esbuild/ws-native-virtual.js";
import { lazyEsbuild } from "./lazy-deps.js";
import type { PreparedWorker } from "./worker-entry.js";

Expand Down Expand Up @@ -82,6 +83,8 @@ export async function bundleToModule(
// esbuild from dumping diagnostics to the service's stderr.
logLevel: "silent",
plugins: [
// `ws` → native-WebSocket client; claims the import before any resolver.
wsNativeVirtualPlugin(),
// CJS require() of a node builtin → static re-export (workerd throws
// on esbuild's lowered __require). Runs before the Deno resolver.
nodeBuiltinRequirePlugin(),
Expand Down
183 changes: 183 additions & 0 deletions packages/functions-compiler/src/esbuild/ws-native-virtual.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import type { Plugin } from "esbuild";

const NAMESPACE = "base44-ws-native";

// Bare `ws` from a dependency, or `npm:ws[@version]` from user code. Subpaths
// (`ws/lib/...`) are internals no client code reaches for, so they resolve as-is.
const WS_SPECIFIER = /^(npm:)?ws(@[^/]+)?$/;

// The `ws` package's exports map sends the "browser" condition to a stub that
// throws on use, and its Node build needs node:net. workerd has a native
// WebSocket client, so serve a `ws`-shaped client over it. CommonJS so both
// `import WebSocket from "ws"` and `require("ws")` get the class.
//
// The native constructor cannot send request headers, so when the caller passes
// `options.headers` (Stagehand's `cdpHeaders`, e.g. a remote browser's auth
// token) the socket is opened with a fetch upgrade instead, which can. A lone
// User-Agent (Stagehand always sends one) is not worth leaving the native path.
const ADAPTER = `"use strict";
const toBuffer = (value) => (globalThis.Buffer ? globalThis.Buffer.from(value) : value);
const READY_STATES = ["CONNECTING", "OPEN", "CLOSING", "CLOSED"];
const later = (fn, ...args) => queueMicrotask(() => fn(...args));
const upgradeHeaders = (options) => {
const headers = new Headers(options?.headers);
for (const name of headers.keys()) if (name !== "user-agent") return headers;
return null;
};
class WebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
#socket = null;
#state = 0;
#url;
#closeRequested = null;
#terminated = false;
#listeners = new Map();
constructor(address, protocols, options) {
if (protocols && typeof protocols === "object" && !Array.isArray(protocols)) {
options = protocols;
protocols = undefined;
}
this.#url = String(address);
const headers = upgradeHeaders(options);
if (headers) {
this.#upgrade(headers, protocols);
} else {
this.#attach(new globalThis.WebSocket(this.#url, protocols));
}
}
get readyState() {
if (this.#terminated) return 3;
return this.#socket ? this.#socket.readyState : this.#state;
}
get url() { return this.#url; }
get protocol() { return this.#socket ? this.#socket.protocol ?? "" : ""; }
on(name, listener) { return this.#add(name, listener, false); }
addListener(name, listener) { return this.#add(name, listener, false); }
once(name, listener) { return this.#add(name, listener, true); }
off(name, listener) { return this.removeListener(name, listener); }
removeListener(name, listener) {
const listeners = this.#listeners.get(name);
if (listeners) this.#listeners.set(name, listeners.filter((entry) => entry.listener !== listener));
return this;
}
removeAllListeners(name) {
if (name === undefined) this.#listeners.clear();
else this.#listeners.delete(name);
return this;
}
emit(name, ...args) {
const listeners = this.#listeners.get(name) ?? [];
this.#listeners.set(name, listeners.filter((entry) => !entry.once));
for (const entry of listeners) entry.listener.apply(this, args);
return listeners.length > 0;
}
send(data, options, callback) {
const done = typeof options === "function" ? options : callback;
try {
if (!this.#socket) {
throw new Error("WebSocket is not open: readyState " + this.#state + " (" + READY_STATES[this.#state] + ")");
}
this.#socket.send(data);
done?.();
} catch (error) {
if (!done) throw error;
done(error);
}
}
close(code, reason) {
if (this.#socket) this.#socket.close(code, reason);
else if (this.#state === 0) {
// Upgrade still in flight: close as soon as it lands.
this.#closeRequested = [code, reason];
this.#state = 2;
}
}
// \`ws\` tears the connection down at once. workerd has no abrupt close, so the
// socket is closed normally and "close" is reported now, not on the reply.
terminate() {
if (this.#terminated || this.readyState === 3) return;
this.#terminated = true;
if (this.#socket) {
try { this.#socket.close(); } catch {}
} else {
// Upgrade still in flight: close it on arrival, without an "open".
this.#closeRequested = [];
this.#state = 3;
}
later(() => this.emit("close", 1006, toBuffer("")));
}
#attach(socket) {
this.#socket = socket;
try { socket.binaryType = "arraybuffer"; } catch {}
socket.addEventListener("open", () => this.emit("open"));
socket.addEventListener("message", (event) => {
const isBinary = typeof event.data !== "string";
this.emit("message", toBuffer(isBinary ? new Uint8Array(event.data) : event.data), isBinary);
});
socket.addEventListener("error", (event) => this.emit("error", event.error ?? new Error(event.message || "WebSocket error")));
socket.addEventListener("close", (event) => {
if (!this.#terminated) this.emit("close", event.code, toBuffer(event.reason ?? ""));
});
}
#upgrade(headers, protocols) {
headers.set("Upgrade", "websocket");
if (protocols) headers.set("Sec-WebSocket-Protocol", [].concat(protocols).join(", "));
const httpUrl = this.#url.replace(/^ws(s?):/i, "http$1:");
const fail = (error) => {
this.#state = 3;
if (this.#terminated) return;
this.emit("error", error);
this.emit("close", 1006, toBuffer(""));
};
// Two-argument then(): an exception from an "open" listener must not be
// reported as a failed upgrade.
fetch(httpUrl, { headers }).then((response) => {
const socket = response.webSocket;
if (!socket) {
response.body?.cancel();
return fail(new Error("Unexpected server response: " + response.status));
}
socket.accept();
this.#attach(socket);
if (this.#closeRequested) return socket.close(...this.#closeRequested);
// accept() opens the socket without dispatching an "open" event.
this.emit("open");
}, fail);
}
#add(name, listener, once) {
const listeners = this.#listeners.get(name) ?? [];
listeners.push({ listener, once });
this.#listeners.set(name, listeners);
return this;
}
}
class WebSocketServer {
constructor() {
throw new Error("ws WebSocketServer is not available in Base44 functions: a function can open WebSocket connections but cannot listen for them.");
}
}
module.exports = WebSocket;
module.exports.WebSocket = WebSocket;
module.exports.WebSocketServer = WebSocketServer;
module.exports.default = WebSocket;
`;

export function wsNativeVirtualPlugin(): Plugin {
return {
name: "base44-ws-native",
setup(build) {
// Every namespace (user files and dependencies), one shared module path.
build.onResolve({ filter: WS_SPECIFIER }, () => ({
path: "ws",
namespace: NAMESPACE,
}));
build.onLoad({ filter: /.*/, namespace: NAMESPACE }, () => ({
contents: ADAPTER,
loader: "js",
}));
},
};
}
9 changes: 9 additions & 0 deletions packages/functions-compiler/src/shim/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ const deno = Object.freeze({

(globalThis as unknown as { Deno: unknown }).Deno = deno;
(globalThis as unknown as { Buffer: typeof Buffer }).Buffer = Buffer;
// workerd's enterWith() is a stub that throws; libraries such as Stagehand
// call it to seed a context they also pass explicitly, so a no-op keeps them
// working. Probed on a throwaway instance so a future native implementation is
// left in place.
try {
new AsyncLocalStorage().enterWith(undefined);
} catch {
AsyncLocalStorage.prototype.enterWith = () => {};
}

export function getRegisteredHandler(): DenoServeHandler | null {
return registeredHandler;
Expand Down
5 changes: 5 additions & 0 deletions packages/functions-compiler/test/package-matrix-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,9 @@ export const PACKAGES: PkgCase[] = [
{ name: "testJimp", pkg: "jimp", version: "0.16.13", expected: "pass", source: fn("jimp", "0.16.13",
"import Jimp from 'npm:jimp@0.16.13';",
" const img = new Jimp(1, 1, 0xFF0000FF); const base64 = await img.getBase64Async(Jimp.MIME_PNG); const output = { hasData: base64.length > 0 };") },
// Constructing calls AsyncLocalStorage.enterWith() (no-op'd by the shim) and
// loading imports `ws` (served as the native-WebSocket adapter).
{ name: "testStagehand", pkg: "@browserbasehq/stagehand", version: "3.7.3", expected: "pass", source: fn("@browserbasehq/stagehand", "3.7.3",
"import { Stagehand } from 'npm:@browserbasehq/stagehand@3.7.3';",
" const stagehand = new Stagehand({ env: 'LOCAL', localBrowserLaunchOptions: { cdpUrl: 'ws://127.0.0.1:9/devtools/browser/x' }, model: { modelName: 'openai/gpt-4.1-mini', apiKey: 'x', openaiEndpointFormat: 'chat' }, disablePino: true }); const output = { hasInit: typeof stagehand.init === 'function', hasAct: typeof stagehand.act === 'function' };") },
];
Loading
Loading