miniflare
Version:
Fun, full-featured, fully-local simulator for Cloudflare Workers
418 lines (416 loc) • 18.7 kB
JavaScript
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
for (var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target, i = decorators.length - 1, decorator; i >= 0; i--)
(decorator = decorators[i]) && (result = (kind ? decorator(target, key, result) : decorator(result)) || result);
return kind && result && __defProp(target, key, result), result;
};
// src/workers/browser-rendering/binding.worker.ts
import assert from "node:assert";
import {
DELETE,
GET,
HttpError,
MiniflareDurableObject,
POST,
PUT,
Router,
SharedBindings
} from "miniflare:shared";
function isClosed(ws) {
return !ws || ws.readyState === WebSocket.CLOSED;
}
function chromeBaseUrl(wsEndpoint) {
return `http://${new URL(wsEndpoint.replace("ws://", "http://")).host}`;
}
var RETRYABLE_FETCH_ERROR_SUBSTRINGS = [
// kj/async-io-win32.c++ ConnectEx (#1225) — the remote socket refused us.
// Surfaces on Windows when Chrome announced the DevTools URL but isn't
// quite accepting connections yet.
"connection refused",
"remote computer refused",
// kj/async-io-win32.c++ WSARecv (#64) — the connection went away mid-read.
"network name is no longer available",
// Generic workerd disconnect classifications.
"network connection lost",
"disconnected"
];
function isRetryableFetchError(error) {
let message = error?.message;
if (typeof message != "string")
return !1;
let lower = message.toLowerCase();
return RETRYABLE_FETCH_ERROR_SUBSTRINGS.some(
(needle) => lower.includes(needle)
);
}
var MAX_BODY_PREVIEW = 2e3;
function truncateBody(text) {
return text.length <= MAX_BODY_PREVIEW ? text : `${text.slice(0, MAX_BODY_PREVIEW)}... (truncated, ${text.length} bytes total)`;
}
async function parseJsonResponse(resp, context) {
let text = await resp.text();
if (!resp.ok)
throw new Error(
`${context}: upstream returned ${resp.status} ${resp.statusText}
${truncateBody(text)}`
);
try {
return JSON.parse(text);
} catch (cause) {
throw new Error(
`${context}: expected JSON, got non-JSON response (${resp.status} ${resp.statusText})
${truncateBody(text)}`,
{ cause }
);
}
}
async function fetchWithConnectRetry(url, init, {
maxAttempts = 5,
baseDelayMs = 25,
maxDelayMs = 250
} = {}) {
let lastError;
for (let attempt = 0; attempt < maxAttempts; attempt++)
try {
return await fetch(url, init);
} catch (e) {
if (lastError = e, !isRetryableFetchError(e) || attempt === maxAttempts - 1)
break;
let delay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
throw lastError;
}
function forwardClose(target, e) {
!target || target.readyState === WebSocket.CLOSED || (!e?.code || e?.code === 1005 || e?.code === 1006 ? target.close() : target.close(e.code, e.reason));
}
var BrowserSession = class extends MiniflareDurableObject {
sessionInfo;
chromeWs;
legacyServerWs;
wss = [];
#statusTimeout;
setSessionInfoRoute = async (req) => {
this.sessionInfo = await req.json();
let wsUrl = this.sessionInfo.wsEndpoint.replace("ws://", "http://"), resp = await fetchWithConnectRetry(wsUrl, {
headers: { Upgrade: "websocket" }
});
return assert(resp.webSocket !== null, "Expected a WebSocket response"), this.chromeWs = resp.webSocket, this.chromeWs.accept(), this.chromeWs.addEventListener("message", (m) => {
if (!this.legacyServerWs)
return;
let string = new TextEncoder().encode(m.data), data = new Uint8Array(string.length + 4);
new DataView(data.buffer).setUint32(0, string.length, !0), data.set(string, 4), this.legacyServerWs.send(data);
}), this.chromeWs.addEventListener("close", (e) => {
this.closeSession(e);
}), this.#scheduleStatusCheck(), new Response(null, { status: 204 });
};
getSessionInfoRoute = async () => (isClosed(this.chromeWs) && this.closeSession(), this.sessionInfo ? Response.json(this.sessionInfo) : new Response(null, { status: 204 }));
connectDevtools = async () => {
if (assert(
this.sessionInfo !== void 0,
"sessionInfo must be set before connecting"
), assert(
this.chromeWs !== void 0,
"chromeWs must be established before connecting"
), this.legacyServerWs !== void 0)
throw new HttpError(409, "WebSocket already initialized");
let webSocketPair = new WebSocketPair(), [client, server] = Object.values(webSocketPair);
return server.accept(), server.addEventListener("message", (m) => {
m.data !== "ping" && this.chromeWs?.send(
new TextDecoder().decode(m.data.slice(4))
);
}), server.addEventListener("close", (e) => {
this.closeWebSockets(e);
}), this.legacyServerWs = server, this.sessionInfo.connectionId = crypto.randomUUID(), this.sessionInfo.connectionStartTime = Date.now(), new Response(null, {
status: 101,
webSocket: client,
headers: { "cf-browser-session-id": this.name }
});
};
jsonVersion = async () => this.#proxyJsonRequest("/json/version");
jsonList = async () => this.#proxyJsonRequest("/json/list");
jsonAlias = async () => this.#proxyJsonRequest("/json/list");
jsonProtocol = async () => this.#proxyJsonRequest("/json/protocol");
jsonNew = async (_req, _params, url) => this.#proxyJsonRequest(
`/json/new?${new URLSearchParams({ url: url.searchParams.get("url") ?? "" })}`,
"PUT"
);
jsonActivate = async (_req, params) => this.#proxyJsonRequest(`/json/activate/${params?.targetId}`);
jsonClose = async (_req, params) => this.#proxyJsonRequest(`/json/close/${params?.targetId}`);
pageWebSocket = async (_req, params) => this.sessionInfo ? this.#proxyRawWebSocket(
`${chromeBaseUrl(this.sessionInfo.wsEndpoint).replace("http://", "ws://")}/devtools/page/${params?.pageId}`
) : Response.json({ error: "Browser not found" }, { status: 404 });
closeBrowser = async () => {
if (this.sessionInfo) {
let closeUrl = new URL("http://localhost/browser/close");
closeUrl.searchParams.set("sessionId", this.sessionInfo.sessionId), this.env[SharedBindings.MAYBE_SERVICE_LOOPBACK].fetch(closeUrl, {
method: "POST"
});
}
return Response.json({ status: "closed" });
};
sessionDetail = async () => this.sessionInfo ? Response.json({
sessionId: this.sessionInfo.sessionId,
startTime: this.sessionInfo.startTime,
connectionId: this.sessionInfo.connectionId,
connectionStartTime: this.sessionInfo.connectionStartTime
}) : Response.json({ error: "Session not found" }, { status: 404 });
connect = async (_req) => {
assert(
this.sessionInfo !== void 0,
"sessionInfo must be set before connecting"
);
let wsUrl = this.sessionInfo.wsEndpoint.replace("ws://", "http://"), resp = await this.#proxyRawWebSocket(wsUrl);
return this.sessionInfo.connectionId = crypto.randomUUID(), this.sessionInfo.connectionStartTime = Date.now(), new Response(null, {
status: resp.status,
webSocket: resp.webSocket,
headers: { "cf-browser-session-id": this.name }
});
};
closeWebSockets(e) {
forwardClose(this.legacyServerWs, e);
for (let { chrome, server } of this.wss)
forwardClose(chrome, e), forwardClose(server, e);
this.legacyServerWs = void 0, this.wss = [], this.sessionInfo && (this.sessionInfo.connectionId = void 0, this.sessionInfo.connectionStartTime = void 0);
}
closeSession(e) {
this.#statusTimeout !== void 0 && (this.timers.clearTimeout(this.#statusTimeout), this.#statusTimeout = void 0), this.closeWebSockets(e), forwardClose(this.chromeWs, e), this.chromeWs = void 0, this.sessionInfo = void 0;
}
async #proxyRawWebSocket(targetWsUrl) {
let response = await fetchWithConnectRetry(
targetWsUrl.replace("ws://", "http://"),
{
headers: { Upgrade: "websocket" }
}
);
assert(response.webSocket !== null, "Expected a WebSocket response");
let chrome = response.webSocket;
chrome.accept();
let webSocketPair = new WebSocketPair(), [client, server] = Object.values(webSocketPair);
server.accept();
let pair = { chrome, server };
return this.wss.push(pair), chrome.addEventListener("message", (m) => server.send(m.data)), server.addEventListener("message", (m) => chrome.send(m.data)), server.addEventListener("close", (e) => {
forwardClose(chrome, e), forwardClose(server, e), this.wss = this.wss.filter((p) => p !== pair);
}), chrome.addEventListener("close", (e) => {
forwardClose(server, e), forwardClose(chrome, e), this.wss = this.wss.filter((p) => p !== pair);
}), new Response(null, { status: 101, webSocket: client });
}
async #proxyJsonRequest(chromePath, method = "GET") {
if (!this.sessionInfo)
return Response.json({ error: "Browser not found" }, { status: 404 });
let resp = await fetchWithConnectRetry(
`${chromeBaseUrl(this.sessionInfo.wsEndpoint)}${chromePath}`,
{ method }
);
return new Response(await resp.text(), {
status: resp.status,
headers: { "Content-Type": "application/json" }
});
}
async #checkStatus() {
if (this.sessionInfo) {
let url = new URL("http://localhost/browser/status");
url.searchParams.set("sessionId", this.sessionInfo.sessionId), (await this.env[SharedBindings.MAYBE_SERVICE_LOOPBACK].fetch(url)).ok || this.closeSession();
}
}
#scheduleStatusCheck() {
this.#statusTimeout === void 0 && (this.#statusTimeout = this.timers.setTimeout(async () => {
this.#statusTimeout = void 0, await this.#checkStatus(), this.chromeWs && this.#scheduleStatusCheck();
}, 1e3));
}
};
__decorateClass([
POST("/session-info")
], BrowserSession.prototype, "setSessionInfoRoute", 2), __decorateClass([
GET("/session-info")
], BrowserSession.prototype, "getSessionInfoRoute", 2), __decorateClass([
GET("/v1/connectDevtools")
], BrowserSession.prototype, "connectDevtools", 2), __decorateClass([
GET("/v1/devtools/browser/:sessionId/json/version")
], BrowserSession.prototype, "jsonVersion", 2), __decorateClass([
GET("/v1/devtools/browser/:sessionId/json/list")
], BrowserSession.prototype, "jsonList", 2), __decorateClass([
GET("/v1/devtools/browser/:sessionId/json")
], BrowserSession.prototype, "jsonAlias", 2), __decorateClass([
GET("/v1/devtools/browser/:sessionId/json/protocol")
], BrowserSession.prototype, "jsonProtocol", 2), __decorateClass([
PUT("/v1/devtools/browser/:sessionId/json/new")
], BrowserSession.prototype, "jsonNew", 2), __decorateClass([
GET("/v1/devtools/browser/:sessionId/json/activate/:targetId")
], BrowserSession.prototype, "jsonActivate", 2), __decorateClass([
GET("/v1/devtools/browser/:sessionId/json/close/:targetId")
], BrowserSession.prototype, "jsonClose", 2), __decorateClass([
GET("/v1/devtools/browser/:sessionId/page/:pageId")
], BrowserSession.prototype, "pageWebSocket", 2), __decorateClass([
DELETE("/v1/devtools/browser/:sessionId")
], BrowserSession.prototype, "closeBrowser", 2), __decorateClass([
GET("/v1/devtools/session/:sessionId")
], BrowserSession.prototype, "sessionDetail", 2), __decorateClass([
GET("/v1/devtools/browser/:sessionId")
], BrowserSession.prototype, "connect", 2);
var BrowserRenderingRouter = class extends Router {
constructor(env) {
super();
this.env = env;
}
env;
#callSession(sessionId, request) {
let cf = { miniflare: { name: sessionId } };
return this.env.BrowserSession.get(
this.env.BrowserSession.idFromName(sessionId)
).fetch(request, {
cf
});
}
#fetchSession(sessionId, path, init) {
let cf = { miniflare: { name: sessionId } };
return this.env.BrowserSession.get(
this.env.BrowserSession.idFromName(sessionId)
).fetch(`http://placeholder${path}`, {
...init,
cf
});
}
async #acquireSession() {
let resp = await this.env[SharedBindings.MAYBE_SERVICE_LOOPBACK].fetch(
"http://localhost/browser/launch"
), sessionInfo = await parseJsonResponse(
resp,
"Failed to launch local browser via miniflare loopback (/browser/launch)"
);
return await this.#fetchSession(sessionInfo.sessionId, "/session-info", {
method: "POST",
body: JSON.stringify(sessionInfo)
}), sessionInfo;
}
async #getActiveSessions() {
let sessionIdsResp = await this.env[SharedBindings.MAYBE_SERVICE_LOOPBACK].fetch("http://localhost/browser/sessionIds"), sessionIds = await parseJsonResponse(
sessionIdsResp,
"Failed to list active browser sessions via miniflare loopback (/browser/sessionIds)"
);
return (await Promise.all(
sessionIds.map(async (sessionId) => {
let resp = await this.#fetchSession(sessionId, "/session-info");
if (resp.status === 204)
return null;
let sessionInfo = await parseJsonResponse(
resp,
`Failed to read session-info for browser session ${sessionId}`
);
return {
sessionId: sessionInfo.sessionId,
startTime: sessionInfo.startTime,
connectionId: sessionInfo.connectionId,
connectionStartTime: sessionInfo.connectionStartTime
};
})
)).filter(Boolean);
}
acquireRoute = async () => {
let sessionInfo = await this.#acquireSession();
return Response.json({ sessionId: sessionInfo.sessionId });
};
sessionsRoute = async () => Response.json({ sessions: await this.#getActiveSessions() });
limitsRoute = async () => Response.json({
maxConcurrentSessions: 6,
allowedBrowserAcquisitions: 6,
timeUntilNextAllowedBrowserAcquisition: 0
});
historyRoute = async () => Response.json([]);
sessionListRoute = async () => Response.json(await this.#getActiveSessions());
connectDevtoolsRoute = async (req, _params, url) => {
let sessionId = url.searchParams.get("browser_session");
return sessionId ? this.#callSession(sessionId, req) : new Response("browser_session must be set", { status: 400 });
};
acquireBrowserRoute = async () => {
let sessionInfo = await this.#acquireSession();
return Response.json({ sessionId: sessionInfo.sessionId });
};
connectBrowserRoute = async (req) => {
let sessionInfo = await this.#acquireSession(), doUrl = new URL(req.url);
return doUrl.pathname = `/v1/devtools/browser/${sessionInfo.sessionId}`, this.#callSession(
sessionInfo.sessionId,
new Request(doUrl, {
method: req.method,
headers: {
...Object.fromEntries(req.headers),
"x-session-id": sessionInfo.sessionId
}
})
);
};
sessionDetailRoute = async (req, params) => this.#callSession(params.sessionId, req);
closeBrowserRoute = async (req, params) => {
let { sessionId } = params;
await this.#callSession(sessionId, req);
for (let i = 0; i < 50; i++) {
let statusUrl = new URL("http://localhost/browser/status");
if (statusUrl.searchParams.set("sessionId", sessionId), (await this.env[SharedBindings.MAYBE_SERVICE_LOOPBACK].fetch(statusUrl)).status === 410)
break;
await new Promise((r) => setTimeout(r, 100));
}
return Response.json({ status: "closed" });
};
connectBrowserSessionRoute = async (req, params, _url) => {
let doUrl = new URL(req.url);
return this.#callSession(params.sessionId, new Request(doUrl, req));
};
jsonVersionRoute = async (req, params) => this.#callSession(params.sessionId, req);
jsonListRoute = async (req, params) => this.#callSession(params.sessionId, req);
jsonAliasRoute = async (req, params) => this.#callSession(params.sessionId, req);
jsonProtocolRoute = async (req, params) => this.#callSession(params.sessionId, req);
jsonNewRoute = async (req, params) => this.#callSession(params.sessionId, req);
jsonActivateRoute = async (req, params) => this.#callSession(params.sessionId, req);
jsonCloseRoute = async (req, params) => this.#callSession(params.sessionId, req);
pageWebSocketRoute = async (req, params) => this.#callSession(params.sessionId, req);
};
__decorateClass([
GET("/v1/acquire")
], BrowserRenderingRouter.prototype, "acquireRoute", 2), __decorateClass([
GET("/v1/sessions")
], BrowserRenderingRouter.prototype, "sessionsRoute", 2), __decorateClass([
GET("/v1/limits")
], BrowserRenderingRouter.prototype, "limitsRoute", 2), __decorateClass([
GET("/v1/history")
], BrowserRenderingRouter.prototype, "historyRoute", 2), __decorateClass([
GET("/v1/devtools/session")
], BrowserRenderingRouter.prototype, "sessionListRoute", 2), __decorateClass([
GET("/v1/connectDevtools")
], BrowserRenderingRouter.prototype, "connectDevtoolsRoute", 2), __decorateClass([
POST("/v1/devtools/browser")
], BrowserRenderingRouter.prototype, "acquireBrowserRoute", 2), __decorateClass([
GET("/v1/devtools/browser")
], BrowserRenderingRouter.prototype, "connectBrowserRoute", 2), __decorateClass([
GET("/v1/devtools/session/:sessionId")
], BrowserRenderingRouter.prototype, "sessionDetailRoute", 2), __decorateClass([
DELETE("/v1/devtools/browser/:sessionId")
], BrowserRenderingRouter.prototype, "closeBrowserRoute", 2), __decorateClass([
GET("/v1/devtools/browser/:sessionId")
], BrowserRenderingRouter.prototype, "connectBrowserSessionRoute", 2), __decorateClass([
GET("/v1/devtools/browser/:sessionId/json/version")
], BrowserRenderingRouter.prototype, "jsonVersionRoute", 2), __decorateClass([
GET("/v1/devtools/browser/:sessionId/json/list")
], BrowserRenderingRouter.prototype, "jsonListRoute", 2), __decorateClass([
GET("/v1/devtools/browser/:sessionId/json")
], BrowserRenderingRouter.prototype, "jsonAliasRoute", 2), __decorateClass([
GET("/v1/devtools/browser/:sessionId/json/protocol")
], BrowserRenderingRouter.prototype, "jsonProtocolRoute", 2), __decorateClass([
PUT("/v1/devtools/browser/:sessionId/json/new")
], BrowserRenderingRouter.prototype, "jsonNewRoute", 2), __decorateClass([
GET("/v1/devtools/browser/:sessionId/json/activate/:targetId")
], BrowserRenderingRouter.prototype, "jsonActivateRoute", 2), __decorateClass([
GET("/v1/devtools/browser/:sessionId/json/close/:targetId")
], BrowserRenderingRouter.prototype, "jsonCloseRoute", 2), __decorateClass([
GET("/v1/devtools/browser/:sessionId/page/:pageId")
], BrowserRenderingRouter.prototype, "pageWebSocketRoute", 2);
var binding_worker_default = {
fetch(request, env) {
return new BrowserRenderingRouter(env).fetch(request);
}
};
export {
BrowserSession,
binding_worker_default as default
};
//# sourceMappingURL=binding.worker.js.map