cloudflare--workers-sdk
70bf21e064
Prerelease / build (push) Has been skipped
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
4220 行
115 KiB
TypeScript
4220 行
115 KiB
TypeScript
// noinspection TypeScriptValidateJSTypes
|
|
|
|
import assert from "node:assert";
|
|
import childProcess from "node:child_process";
|
|
import { once } from "node:events";
|
|
import { existsSync } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import http from "node:http";
|
|
import net from "node:net";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { json, text } from "node:stream/consumers";
|
|
import url from "node:url";
|
|
import util from "node:util";
|
|
import {
|
|
_forceColour,
|
|
_transformsForContentEncodingAndContentType,
|
|
createFetchMock,
|
|
DeferredPromise,
|
|
fetch,
|
|
kCurrentWorker,
|
|
Miniflare,
|
|
MiniflareCoreError,
|
|
parseWithRootPath,
|
|
PLUGINS,
|
|
Response,
|
|
viewToBuffer,
|
|
} from "miniflare";
|
|
import { afterEach, test, vi } from "vitest";
|
|
import { WebSocketServer } from "ws";
|
|
import { assertIsV2ModuleFallbackProtocol } from "../src/plugins/core/module-fallback";
|
|
import {
|
|
FIXTURES_PATH,
|
|
TestLog,
|
|
useCwd,
|
|
useDispose,
|
|
useServer,
|
|
useTmp,
|
|
utf8Encode,
|
|
} from "./test-shared";
|
|
import type {
|
|
D1Database,
|
|
DurableObjectNamespace,
|
|
Fetcher,
|
|
KVNamespace,
|
|
Queue,
|
|
R2Bucket,
|
|
} from "@cloudflare/workers-types/experimental";
|
|
import type {
|
|
MessageEvent,
|
|
MiniflareOptions,
|
|
ReplaceWorkersTypes,
|
|
Worker_Module,
|
|
WorkerOptions,
|
|
} from "miniflare";
|
|
import type { AddressInfo } from "node:net";
|
|
import type { Writable } from "node:stream";
|
|
import type {
|
|
CloseEvent as StandardCloseEvent,
|
|
MessageEvent as StandardMessageEvent,
|
|
} from "ws";
|
|
|
|
// (base64 encoded module containing a single `add(i32, i32): i32` export)
|
|
const ADD_WASM_MODULE = Buffer.from(
|
|
"AGFzbQEAAAABBwFgAn9/AX8DAgEABwcBA2FkZAAACgkBBwAgACABagsACgRuYW1lAgMBAAA=",
|
|
"base64"
|
|
);
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
test("Miniflare: validates options", async ({ expect, onTestFinished }) => {
|
|
// Check empty workers array rejected
|
|
expect(() => new Miniflare({ workers: [] })).toThrow(
|
|
new MiniflareCoreError("ERR_NO_WORKERS", "No workers defined")
|
|
);
|
|
|
|
// Check workers with the same name rejected
|
|
expect(
|
|
() =>
|
|
new Miniflare({
|
|
workers: [{ script: "" }, { script: "" }],
|
|
})
|
|
).toThrow(
|
|
new MiniflareCoreError(
|
|
"ERR_DUPLICATE_NAME",
|
|
"Multiple workers defined without a `name`"
|
|
)
|
|
);
|
|
expect(
|
|
() =>
|
|
new Miniflare({
|
|
workers: [
|
|
{ script: "" },
|
|
{ script: "", name: "a" },
|
|
{ script: "", name: "b" },
|
|
{ script: "", name: "a" },
|
|
],
|
|
})
|
|
).toThrow(
|
|
new MiniflareCoreError(
|
|
"ERR_DUPLICATE_NAME",
|
|
'Multiple workers defined with the same `name`: "a"'
|
|
)
|
|
);
|
|
|
|
// Disable colours for easier to read expectations
|
|
_forceColour(false);
|
|
onTestFinished(() => _forceColour());
|
|
|
|
// Check throws validation error with incorrect options
|
|
let error: MiniflareCoreError | undefined = undefined;
|
|
try {
|
|
// @ts-expect-error intentionally testing incorrect types
|
|
new Miniflare({ name: 42, script: "" });
|
|
} catch (e) {
|
|
error = e as MiniflareCoreError;
|
|
}
|
|
expect(error).toBeInstanceOf(MiniflareCoreError);
|
|
expect(error?.code).toEqual("ERR_VALIDATION");
|
|
expect(error?.message).toEqual(
|
|
`Unexpected options passed to \`new Miniflare()\` constructor:
|
|
{
|
|
name: 42,
|
|
^ Expected string, received number
|
|
...,
|
|
}`
|
|
);
|
|
|
|
// Check throws validation error with primitive option
|
|
error = undefined;
|
|
try {
|
|
// @ts-expect-error intentionally testing incorrect types
|
|
new Miniflare("addEventListener(...)");
|
|
} catch (e) {
|
|
error = e as MiniflareCoreError;
|
|
}
|
|
expect(error).toBeInstanceOf(MiniflareCoreError);
|
|
expect(error?.code).toEqual("ERR_VALIDATION");
|
|
expect(error?.message).toEqual(
|
|
`Unexpected options passed to \`new Miniflare()\` constructor:
|
|
'addEventListener(...)'
|
|
^ Expected object, received string`
|
|
);
|
|
});
|
|
|
|
test("Miniflare: accepts mixed r2Buckets record", () => {
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: "",
|
|
r2Buckets: {
|
|
LOCAL_BUCKET: "local-bucket",
|
|
REMOTE_BUCKET: { id: "remote-bucket" },
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
});
|
|
|
|
test("Miniflare: accepts mixed kvNamespaces record", () => {
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: "",
|
|
kvNamespaces: {
|
|
LOCAL_NS: "local-ns",
|
|
REMOTE_NS: { id: "remote-ns" },
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
});
|
|
|
|
test("Miniflare: accepts mixed d1Databases record", () => {
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: "",
|
|
d1Databases: {
|
|
LOCAL_DB: "local-db",
|
|
REMOTE_DB: { id: "remote-db" },
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
});
|
|
|
|
test("Miniflare: accepts mixed pipelines record", () => {
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: "",
|
|
pipelines: {
|
|
LOCAL_PIPELINE: "local-pipeline",
|
|
REMOTE_PIPELINE: { pipeline: "remote-pipeline" },
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
});
|
|
test("Miniflare: ready returns copy of entry URL", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
port: 0,
|
|
modules: true,
|
|
script: "",
|
|
});
|
|
useDispose(mf);
|
|
|
|
const url1 = await mf.ready;
|
|
url1.protocol = "ws:";
|
|
const url2 = await mf.ready;
|
|
expect(url1).not.toBe(url2);
|
|
expect(url2.protocol).toBe("http:");
|
|
});
|
|
|
|
test("Miniflare: setOptions: can update host/port", async ({ expect }) => {
|
|
// Extract loopback port from injected live reload script
|
|
const loopbackPortRegexp = /\/\/ Miniflare Live Reload.+url\.port = (\d+)/s;
|
|
|
|
const opts: MiniflareOptions = {
|
|
port: 0,
|
|
inspectorPort: 0,
|
|
liveReload: true,
|
|
script: `addEventListener("fetch", (event) => {
|
|
event.respondWith(new Response("<p>👋</p>", {
|
|
headers: { "Content-Type": "text/html;charset=utf-8" }
|
|
}));
|
|
})`,
|
|
};
|
|
const mf = new Miniflare(opts);
|
|
useDispose(mf);
|
|
|
|
async function getState() {
|
|
const url = await mf.ready;
|
|
const inspectorUrl = await mf.getInspectorURL();
|
|
const res = await mf.dispatchFetch("http://localhost");
|
|
const loopbackPort = loopbackPortRegexp.exec(await res.text())?.[1];
|
|
return { url, inspectorUrl, loopbackPort };
|
|
}
|
|
|
|
const state1 = await getState();
|
|
opts.host = "0.0.0.0";
|
|
await mf.setOptions(opts);
|
|
const state2 = await getState();
|
|
|
|
// Make sure ports were reused when `port: 0` passed to `setOptions()`
|
|
expect(state1.url.port).not.toBe("0");
|
|
expect(state1.url.port).toBe(state2.url.port);
|
|
expect(state1.inspectorUrl.port).not.toBe("0");
|
|
expect(state1.inspectorUrl.port).toBe(state2.inspectorUrl.port);
|
|
|
|
// Make sure updating the host restarted the loopback server
|
|
expect(state1.loopbackPort).toBeDefined();
|
|
expect(state2.loopbackPort).toBeDefined();
|
|
expect(state1.loopbackPort).not.toBe(state2.loopbackPort);
|
|
|
|
// Make sure setting port to `undefined` always gives a new port, but keeps
|
|
// existing loopback server
|
|
opts.port = undefined;
|
|
await mf.setOptions(opts);
|
|
const state3 = await getState();
|
|
expect(state3.url.port).not.toBe("0");
|
|
expect(state1.url.port).not.toBe(state3.url.port);
|
|
expect(state2.loopbackPort).toBe(state3.loopbackPort);
|
|
});
|
|
|
|
const interfaces = os.networkInterfaces();
|
|
const localInterface = (interfaces["en0"] ?? interfaces["eth0"])?.find(
|
|
({ family }) => family === "IPv4"
|
|
);
|
|
(localInterface === undefined ? test.skip : test)(
|
|
"Miniflare: can use local network address as host",
|
|
async ({ expect }) => {
|
|
assert(localInterface !== undefined);
|
|
const mf = new Miniflare({
|
|
host: localInterface.address,
|
|
modules: true,
|
|
script: `export default { fetch(request, env) { return env.SERVICE.fetch(request); } }`,
|
|
serviceBindings: {
|
|
SERVICE() {
|
|
return new Response("body");
|
|
},
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
|
|
let res = await mf.dispatchFetch("https://example.com");
|
|
expect(await res.text()).toBe("body");
|
|
|
|
const worker = await mf.getWorker();
|
|
res = await worker.fetch("https://example.com");
|
|
expect(await res.text()).toBe("body");
|
|
}
|
|
);
|
|
|
|
test("Miniflare: can use localhost as host", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
host: "localhost",
|
|
modules: true,
|
|
script: `export default { fetch(request, env) { return env.SERVICE.fetch(request); } }`,
|
|
serviceBindings: {
|
|
SERVICE() {
|
|
return new Response("body");
|
|
},
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
|
|
const url = await mf.ready;
|
|
expect(url.hostname).toBe("localhost");
|
|
|
|
let res = await mf.dispatchFetch("https://example.com");
|
|
expect(await res.text()).toBe("body");
|
|
|
|
const worker = await mf.getWorker();
|
|
res = await worker.fetch("https://example.com");
|
|
expect(await res.text()).toBe("body");
|
|
});
|
|
|
|
test("Miniflare: can use IPv6 loopback as host", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
host: "::1",
|
|
modules: true,
|
|
script: `export default { fetch(request, env) { return env.SERVICE.fetch(request); } }`,
|
|
serviceBindings: {
|
|
SERVICE() {
|
|
return new Response("body");
|
|
},
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
|
|
let res = await mf.dispatchFetch("https://example.com");
|
|
expect(await res.text()).toBe("body");
|
|
|
|
const worker = await mf.getWorker();
|
|
res = await worker.fetch("https://example.com");
|
|
expect(await res.text()).toBe("body");
|
|
});
|
|
|
|
test("Miniflare: routes to multiple workers with fallback", async ({
|
|
expect,
|
|
}) => {
|
|
const opts: MiniflareOptions = {
|
|
workers: [
|
|
{
|
|
name: "a",
|
|
routes: ["*/api"],
|
|
script: `addEventListener("fetch", (event) => {
|
|
event.respondWith(new Response("a"));
|
|
})`,
|
|
},
|
|
{
|
|
name: "b",
|
|
routes: ["*/api/*"], // Less specific than "a"'s
|
|
script: `addEventListener("fetch", (event) => {
|
|
event.respondWith(new Response("b"));
|
|
})`,
|
|
},
|
|
],
|
|
};
|
|
const mf = new Miniflare(opts);
|
|
useDispose(mf);
|
|
|
|
// Check "a"'s more specific route checked first
|
|
let res = await mf.dispatchFetch("http://localhost/api");
|
|
expect(await res.text()).toBe("a");
|
|
|
|
// Check "b" still accessible
|
|
res = await mf.dispatchFetch("http://localhost/api/2");
|
|
expect(await res.text()).toBe("b");
|
|
|
|
// Check fallback to first
|
|
res = await mf.dispatchFetch("http://localhost/notapi");
|
|
expect(await res.text()).toBe("a");
|
|
});
|
|
|
|
test("Miniflare: custom service using Content-Encoding header", async ({
|
|
expect,
|
|
}) => {
|
|
const testBody = "x".repeat(100);
|
|
const { http } = await useServer((req, res) => {
|
|
const testEncoding = req.headers["x-test-encoding"]?.toString();
|
|
const contentType = "text/html"; // known content-type that will always be compressed
|
|
const encoders = _transformsForContentEncodingAndContentType(
|
|
testEncoding,
|
|
contentType
|
|
);
|
|
let initialStream: Writable = res;
|
|
for (let i = encoders.length - 1; i >= 0; i--) {
|
|
encoders[i].pipe(initialStream);
|
|
initialStream = encoders[i];
|
|
}
|
|
res.writeHead(200, {
|
|
"Content-Encoding": testEncoding,
|
|
"Content-Type": contentType,
|
|
});
|
|
initialStream.write(testBody);
|
|
initialStream.end();
|
|
});
|
|
const mf = new Miniflare({
|
|
compatibilityFlags: ["brotli_content_encoding"],
|
|
script: `addEventListener("fetch", (event) => {
|
|
event.respondWith(CUSTOM.fetch(event.request));
|
|
})`,
|
|
serviceBindings: {
|
|
CUSTOM(request) {
|
|
return fetch(http, request);
|
|
},
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
|
|
const testEncoding = async (encoding: string) => {
|
|
const res = await mf.dispatchFetch("http://localhost", {
|
|
headers: {
|
|
"Accept-Encoding": encoding,
|
|
"X-Test-Encoding": encoding,
|
|
},
|
|
});
|
|
expect(await res.text()).toBe(testBody);
|
|
// This header is mostly just for this test -- but is an indication for anyone who wants to know if the response _was_ compressed
|
|
// Expected the response, before decoding, to be encoded as ${encoding}
|
|
expect(res.headers.get("MF-Content-Encoding")).toBe(encoding);
|
|
// Ensure this header has been removed -- undici.fetch has already decoded (decompressed) the response
|
|
// Expected Content-Encoding header to be removed
|
|
expect(res.headers.get("Content-Encoding")).toBe(null);
|
|
};
|
|
|
|
await testEncoding("gzip");
|
|
await testEncoding("deflate");
|
|
await testEncoding("br");
|
|
// `undici`'s `fetch()` is currently broken when `Content-Encoding` specifies
|
|
// multiple encodings. Once https://github.com/nodejs/undici/pull/2159 is
|
|
// released, we can re-enable this test.
|
|
// TODO(soon): re-enable this test
|
|
// await test("deflate, gzip");
|
|
});
|
|
|
|
test("Miniflare: negotiates acceptable encoding", async ({ expect }) => {
|
|
const testBody = "x".repeat(100);
|
|
const mf = new Miniflare({
|
|
bindings: { TEST_BODY: testBody },
|
|
compatibilityFlags: ["brotli_content_encoding"],
|
|
modules: true,
|
|
script: `
|
|
export default {
|
|
async fetch(request, env, ctx) {
|
|
const url = new URL(request.url);
|
|
|
|
switch (url.pathname) {
|
|
case "/": {
|
|
return Response.json({
|
|
AcceptEncoding: request.headers.get("Accept-Encoding"),
|
|
clientAcceptEncoding: request.cf.clientAcceptEncoding,
|
|
});
|
|
}
|
|
|
|
// Content-Encoding routes
|
|
case "/gzip": {
|
|
return new Response(env.TEST_BODY, {
|
|
headers: {
|
|
"Content-Encoding": "gzip",
|
|
}
|
|
});
|
|
}
|
|
case "/br": {
|
|
return new Response(env.TEST_BODY, {
|
|
headers: {
|
|
"Content-Encoding": "br",
|
|
}
|
|
});
|
|
}
|
|
case "/deflate": {
|
|
// workerd doesn't automatically encode "deflate"
|
|
const response = new Response(env.TEST_BODY);
|
|
const compressionStream = new CompressionStream("deflate");
|
|
const compressedBody = response.body.pipeThrough(compressionStream);
|
|
return new Response(compressedBody, {
|
|
encodeBody: "manual",
|
|
headers: {
|
|
"Content-Encoding": "deflate",
|
|
},
|
|
});
|
|
}
|
|
|
|
// Content-Type routes
|
|
case "/default-compressed": {
|
|
return new Response(env.TEST_BODY, {
|
|
headers: {
|
|
"Content-Type": "text/html",
|
|
}
|
|
});
|
|
}
|
|
case "/default-uncompressed": {
|
|
return new Response(env.TEST_BODY, {
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
}
|
|
});
|
|
}
|
|
|
|
default: {
|
|
return new Response(null, { status: 404 });
|
|
}
|
|
}
|
|
},
|
|
};
|
|
`,
|
|
});
|
|
useDispose(mf);
|
|
|
|
// Using `fetch()` directly to simulate eyeball
|
|
const url = await mf.ready;
|
|
const gzipUrl = new URL("/gzip", url);
|
|
const brUrl = new URL("/br", url);
|
|
const deflateUrl = new URL("/deflate", url);
|
|
const defaultCompressedUrl = new URL("/default-compressed", url);
|
|
const defaultUncompressedUrl = new URL("/default-uncompressed", url);
|
|
|
|
// https://github.com/cloudflare/workers-sdk/issues/5246
|
|
let res = await fetch(url, {
|
|
headers: { "Accept-Encoding": "hello" },
|
|
});
|
|
expect(await res.json()).toEqual({
|
|
AcceptEncoding: "br, gzip",
|
|
clientAcceptEncoding: "hello",
|
|
});
|
|
|
|
// Check all encodings supported
|
|
res = await fetch(gzipUrl);
|
|
expect(await res.text()).toBe(testBody);
|
|
res = await fetch(brUrl);
|
|
expect(await res.text()).toBe(testBody);
|
|
res = await fetch(deflateUrl);
|
|
expect(await res.text()).toBe(testBody);
|
|
|
|
// Check with `Accept-Encoding: gzip`
|
|
res = await fetch(gzipUrl, { headers: { "Accept-Encoding": "gzip" } });
|
|
expect(res.headers.get("Content-Encoding")).toBe("gzip");
|
|
expect(await res.text()).toBe(testBody);
|
|
res = await fetch(brUrl, { headers: { "Accept-Encoding": "gzip" } });
|
|
expect(res.headers.get("Content-Encoding")).toBe("gzip");
|
|
expect(await res.text()).toBe(testBody);
|
|
// "deflate" isn't an accepted encoding inside Workers, so returned as is
|
|
res = await fetch(deflateUrl, { headers: { "Accept-Encoding": "gzip" } });
|
|
expect(res.headers.get("Content-Encoding")).toBe("deflate");
|
|
expect(await res.text()).toBe(testBody);
|
|
|
|
// Check with `Accept-Encoding: br`
|
|
res = await fetch(gzipUrl, { headers: { "Accept-Encoding": "br" } });
|
|
expect(res.headers.get("Content-Encoding")).toBe("br");
|
|
expect(await res.text()).toBe(testBody);
|
|
res = await fetch(brUrl, { headers: { "Accept-Encoding": "br" } });
|
|
expect(res.headers.get("Content-Encoding")).toBe("br");
|
|
expect(await res.text()).toBe(testBody);
|
|
// "deflate" isn't an accepted encoding inside Workers, so returned as is
|
|
res = await fetch(deflateUrl, { headers: { "Accept-Encoding": "br" } });
|
|
expect(res.headers.get("Content-Encoding")).toBe("deflate");
|
|
expect(await res.text()).toBe(testBody);
|
|
|
|
// Check with mixed `Accept-Encoding`
|
|
res = await fetch(gzipUrl, { headers: { "Accept-Encoding": "gzip, br" } });
|
|
expect(res.headers.get("Content-Encoding")).toBe("gzip");
|
|
expect(await res.text()).toBe(testBody);
|
|
res = await fetch(gzipUrl, { headers: { "Accept-Encoding": "br, gzip" } });
|
|
expect(res.headers.get("Content-Encoding")).toBe("br");
|
|
expect(await res.text()).toBe(testBody);
|
|
res = await fetch(gzipUrl, {
|
|
headers: { "Accept-Encoding": "br;q=0.5, gzip" },
|
|
});
|
|
expect(res.headers.get("Content-Encoding")).toBe("gzip");
|
|
expect(await res.text()).toBe(testBody);
|
|
|
|
// Check empty `Accept-Encoding`
|
|
res = await fetch(gzipUrl, { headers: { "Accept-Encoding": "" } });
|
|
expect(res.headers.get("Content-Encoding")).toBe("gzip");
|
|
expect(await res.text()).toBe(testBody);
|
|
|
|
// Check identity encoding
|
|
res = await fetch(gzipUrl, { headers: { "Accept-Encoding": "identity" } });
|
|
expect(res.headers.get("Content-Encoding")).toBe(null);
|
|
expect(await res.text()).toBe(testBody);
|
|
res = await fetch(gzipUrl, { headers: { "Accept-Encoding": "*" } });
|
|
expect(res.headers.get("Content-Encoding")).toBe(null);
|
|
expect(await res.text()).toBe(testBody);
|
|
res = await fetch(gzipUrl, { headers: { "Accept-Encoding": "zstd, *" } });
|
|
expect(res.headers.get("Content-Encoding")).toBe(null);
|
|
expect(await res.text()).toBe(testBody);
|
|
res = await fetch(gzipUrl, {
|
|
headers: { "Accept-Encoding": "zstd, identity;q=0" },
|
|
});
|
|
expect(res.status).toBe(415);
|
|
expect(res.headers.get("Accept-Encoding")).toBe("br, gzip");
|
|
expect(await res.text()).toBe("Unsupported Media Type");
|
|
res = await fetch(gzipUrl, { headers: { "Accept-Encoding": "zstd, *;q=0" } });
|
|
expect(res.status).toBe(415);
|
|
expect(res.headers.get("Accept-Encoding")).toBe("br, gzip");
|
|
expect(await res.text()).toBe("Unsupported Media Type");
|
|
|
|
// Check malformed `Accept-Encoding`
|
|
res = await fetch(gzipUrl, { headers: { "Accept-Encoding": ",(,br,,,q=," } });
|
|
expect(res.headers.get("Content-Encoding")).toBe("br");
|
|
expect(await res.text()).toBe(testBody);
|
|
|
|
// Check `Content-Type: text/html` is compressed (FL always compresses html)
|
|
res = await fetch(defaultCompressedUrl);
|
|
expect(res.headers.get("Content-Type")).toBe("text/html");
|
|
expect(res.headers.get("Content-Encoding")).toBe("gzip");
|
|
expect(await res.text()).toBe(testBody);
|
|
|
|
// Check `Content-Type: text/event-stream` is not compressed (FL does not compress this mime type)
|
|
res = await fetch(defaultUncompressedUrl);
|
|
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
|
expect(res.headers.get("Content-Encoding")).toBe(null);
|
|
expect(await res.text()).toBe(testBody);
|
|
});
|
|
|
|
test("Miniflare: custom service using Set-Cookie header", async ({
|
|
expect,
|
|
}) => {
|
|
const testCookies = [
|
|
"key1=value1; Max-Age=3600",
|
|
"key2=value2; Domain=example.com; Secure",
|
|
];
|
|
const { http } = await useServer((req, res) => {
|
|
res.writeHead(200, { "Set-Cookie": testCookies });
|
|
res.end();
|
|
});
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request, env, ctx) {
|
|
const res = await env.CUSTOM.fetch(request);
|
|
return Response.json(res.headers.getSetCookie());
|
|
}
|
|
}`,
|
|
serviceBindings: {
|
|
CUSTOM(request) {
|
|
return fetch(http, request);
|
|
},
|
|
},
|
|
// Enable `Headers#getSetCookie()`:
|
|
// https://github.com/cloudflare/workerd/blob/14b54764609c263ea36ab862bb8bf512f9b1387b/src/workerd/io/compatibility-date.capnp#L273-L278
|
|
compatibilityDate: "2023-03-01",
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.json()).toEqual(testCookies);
|
|
});
|
|
|
|
test("Miniflare: web socket kitchen sink", async ({
|
|
expect,
|
|
onTestFinished,
|
|
}) => {
|
|
// Create deferred promises for asserting asynchronous event results
|
|
const clientEventPromise = new DeferredPromise<MessageEvent>();
|
|
const serverMessageEventPromise = new DeferredPromise<StandardMessageEvent>();
|
|
const serverCloseEventPromise = new DeferredPromise<StandardCloseEvent>();
|
|
|
|
// Create WebSocket origin server
|
|
const server = http.createServer();
|
|
const wss = new WebSocketServer({
|
|
server,
|
|
handleProtocols(protocols) {
|
|
expect(protocols).toEqual(new Set(["protocol1", "protocol2"]));
|
|
return "protocol2";
|
|
},
|
|
});
|
|
wss.on("connection", (ws, req) => {
|
|
// Testing receiving additional headers sent from upgrade request
|
|
expect(req.headers["user-agent"]).toBe("Test");
|
|
|
|
ws.send("hello from server");
|
|
ws.addEventListener("message", serverMessageEventPromise.resolve);
|
|
ws.addEventListener("close", serverCloseEventPromise.resolve);
|
|
});
|
|
wss.on("headers", (headers) => {
|
|
headers.push("Set-Cookie: key=value");
|
|
});
|
|
const port = await new Promise<number>((resolve) => {
|
|
server.listen(0, () => {
|
|
onTestFinished(() => {
|
|
server.close();
|
|
});
|
|
resolve((server.address() as AddressInfo).port);
|
|
});
|
|
});
|
|
|
|
// Create Miniflare instance with WebSocket worker and custom service binding
|
|
// fetching from WebSocket origin server
|
|
const mf = new Miniflare({
|
|
script: `addEventListener("fetch", (event) => {
|
|
event.respondWith(CUSTOM.fetch(event.request));
|
|
})`,
|
|
serviceBindings: {
|
|
// Testing loopback server WebSocket coupling
|
|
CUSTOM(request) {
|
|
// Testing dispatchFetch custom cf injection
|
|
expect(request.cf?.country).toBe("MF");
|
|
// Testing dispatchFetch injects default cf values
|
|
expect(request.cf?.regionCode).toBe("TX");
|
|
expect(request.headers.get("MF-Custom-Service")).toBe(null);
|
|
// Testing WebSocket-upgrading fetch
|
|
return fetch(`http://localhost:${port}`, request);
|
|
},
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
|
|
// Testing dispatchFetch WebSocket coupling
|
|
const res = await mf.dispatchFetch("http://localhost", {
|
|
headers: {
|
|
Upgrade: "websocket",
|
|
"User-Agent": "Test",
|
|
"Sec-WebSocket-Protocol": "protocol1, protocol2",
|
|
},
|
|
cf: { country: "MF" },
|
|
});
|
|
|
|
assert(res.webSocket);
|
|
res.webSocket.addEventListener("message", clientEventPromise.resolve);
|
|
res.webSocket.accept();
|
|
res.webSocket.send("hello from client");
|
|
res.webSocket.close(1000, "Test Closure");
|
|
// Test receiving additional headers from upgrade response
|
|
expect(res.headers.get("Set-Cookie")).toBe("key=value");
|
|
expect(res.headers.get("Sec-WebSocket-Protocol")).toBe("protocol2");
|
|
|
|
// Check event results
|
|
const clientEvent = await clientEventPromise;
|
|
const serverMessageEvent = await serverMessageEventPromise;
|
|
const serverCloseEvent = await serverCloseEventPromise;
|
|
expect(clientEvent.data).toBe("hello from server");
|
|
expect(serverMessageEvent.data).toBe("hello from client");
|
|
expect(serverCloseEvent.code).toBe(1000);
|
|
expect(serverCloseEvent.reason).toBe("Test Closure");
|
|
});
|
|
|
|
test("Miniflare: custom service binding to another Miniflare instance", async ({
|
|
expect,
|
|
}) => {
|
|
const mfOther = new Miniflare({
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request) {
|
|
const { method, url } = request;
|
|
const body = request.body && await request.text();
|
|
return Response.json({ method, url, body });
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mfOther);
|
|
|
|
const mf = new Miniflare({
|
|
script: `addEventListener("fetch", (event) => {
|
|
event.respondWith(CUSTOM.fetch(event.request));
|
|
})`,
|
|
serviceBindings: {
|
|
async CUSTOM(request) {
|
|
// Check internal keys removed (e.g. `MF-Custom-Service`, `MF-Original-URL`)
|
|
// https://github.com/cloudflare/miniflare/issues/475
|
|
const keys = [...request.headers.keys()];
|
|
expect(
|
|
keys.filter((key) => key.toLowerCase().startsWith("mf-"))
|
|
).toEqual([]);
|
|
|
|
return await mfOther.dispatchFetch(request);
|
|
},
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
|
|
// Checking URL (including protocol/host) and body preserved through
|
|
// `dispatchFetch()` and custom service bindings
|
|
let res = await mf.dispatchFetch("https://custom1.mf/a?key=value");
|
|
expect(await res.json()).toEqual({
|
|
method: "GET",
|
|
url: "https://custom1.mf/a?key=value",
|
|
body: null,
|
|
});
|
|
|
|
res = await mf.dispatchFetch("https://custom2.mf/b", {
|
|
method: "POST",
|
|
body: "body",
|
|
});
|
|
expect(await res.json()).toEqual({
|
|
method: "POST",
|
|
url: "https://custom2.mf/b",
|
|
body: "body",
|
|
});
|
|
|
|
// https://github.com/cloudflare/miniflare/issues/476
|
|
res = await mf.dispatchFetch("https://custom3.mf/c", { method: "DELETE" });
|
|
expect(await res.json()).toEqual({
|
|
method: "DELETE",
|
|
url: "https://custom3.mf/c",
|
|
body: null,
|
|
});
|
|
});
|
|
test("Miniflare: service binding to current worker", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
serviceBindings: { SELF: kCurrentWorker },
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request, env) {
|
|
const { pathname } = new URL(request.url);
|
|
if (pathname === "/callback") return new Response("callback");
|
|
const response = await env.SELF.fetch("http://placeholder/callback");
|
|
const text = await response.text();
|
|
return new Response("body:" + text);
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("body:callback");
|
|
});
|
|
test("Miniflare: service binding to network", async ({ expect }) => {
|
|
const { http } = await useServer((req, res) => res.end("network"));
|
|
const mf = new Miniflare({
|
|
serviceBindings: { NETWORK: { network: { allow: ["private"] } } },
|
|
modules: true,
|
|
script: `export default {
|
|
fetch(request, env) { return env.NETWORK.fetch(request); }
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch(http);
|
|
expect(await res.text()).toBe("network");
|
|
});
|
|
test("Miniflare: service binding to external server", async ({ expect }) => {
|
|
const { http } = await useServer((req, res) => res.end("external"));
|
|
const mf = new Miniflare({
|
|
serviceBindings: {
|
|
EXTERNAL: { external: { address: http.host, http: {} } },
|
|
},
|
|
modules: true,
|
|
script: `export default {
|
|
fetch(request, env) { return env.EXTERNAL.fetch(request); }
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("https://example.com");
|
|
expect(await res.text()).toBe("external");
|
|
});
|
|
test("Miniflare: service binding to disk", async ({ expect }) => {
|
|
const tmp = await useTmp();
|
|
const testPath = path.join(tmp, "test.txt");
|
|
await fs.writeFile(testPath, "👋");
|
|
const mf = new Miniflare({
|
|
serviceBindings: {
|
|
DISK: { disk: { path: tmp, writable: true } },
|
|
},
|
|
modules: true,
|
|
script: `export default {
|
|
fetch(request, env) { return env.DISK.fetch(request); }
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
|
|
let res = await mf.dispatchFetch("https://example.com/test.txt");
|
|
expect(await res.text()).toBe("👋");
|
|
|
|
res = await mf.dispatchFetch("https://example.com/test.txt", {
|
|
method: "PUT",
|
|
body: "✏️",
|
|
});
|
|
expect(res.status).toBe(204);
|
|
expect(await fs.readFile(testPath, "utf8")).toBe("✏️");
|
|
});
|
|
test("Miniflare: service binding to named entrypoint", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
workers: [
|
|
{
|
|
name: "a",
|
|
serviceBindings: {
|
|
A_RPC_SERVICE: { name: kCurrentWorker, entrypoint: "RpcEntrypoint" },
|
|
A_NAMED_SERVICE: { name: "a", entrypoint: "namedEntrypoint" },
|
|
B_NAMED_SERVICE: { name: "b", entrypoint: "anotherNamedEntrypoint" },
|
|
},
|
|
compatibilityFlags: ["rpc"],
|
|
modules: true,
|
|
script: `
|
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
export class RpcEntrypoint extends WorkerEntrypoint {
|
|
ping() { return "a:rpc:pong"; }
|
|
}
|
|
|
|
export const namedEntrypoint = {
|
|
fetch(request, env, ctx) { return new Response("a:named:pong"); }
|
|
};
|
|
|
|
export default {
|
|
async fetch(request, env) {
|
|
const aRpc = await env.A_RPC_SERVICE.ping();
|
|
const aNamed = await (await env.A_NAMED_SERVICE.fetch("http://placeholder")).text();
|
|
const bNamed = await (await env.B_NAMED_SERVICE.fetch("http://placeholder")).text();
|
|
return Response.json({ aRpc, aNamed, bNamed });
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
{
|
|
name: "b",
|
|
modules: true,
|
|
script: `
|
|
export const anotherNamedEntrypoint = {
|
|
fetch(request, env, ctx) { return new Response("b:named:pong"); }
|
|
};
|
|
`,
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("http://placeholder");
|
|
expect(await res.json()).toEqual({
|
|
aRpc: "a:rpc:pong",
|
|
aNamed: "a:named:pong",
|
|
bNamed: "b:named:pong",
|
|
});
|
|
});
|
|
|
|
test("Miniflare: service binding to named entrypoint that implements a method returning a plain object", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
workers: [
|
|
{
|
|
name: "a",
|
|
serviceBindings: {
|
|
RPC_SERVICE: { name: "b", entrypoint: "RpcEntrypoint" },
|
|
},
|
|
compatibilityFlags: ["rpc"],
|
|
modules: true,
|
|
script: `
|
|
export default {
|
|
async fetch(request, env) {
|
|
const obj = await env.RPC_SERVICE.getObject();
|
|
return Response.json({ obj });
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
{
|
|
name: "b",
|
|
modules: true,
|
|
script: `
|
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
export class RpcEntrypoint extends WorkerEntrypoint {
|
|
getObject() {
|
|
return {
|
|
isPlainObject: true,
|
|
value: 123,
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
const bindings = await mf.getBindings<{ RPC_SERVICE: any }>();
|
|
const o = await bindings.RPC_SERVICE.getObject();
|
|
expect(o.isPlainObject).toEqual(true);
|
|
expect(o.value).toEqual(123);
|
|
});
|
|
|
|
test("Miniflare: service binding to named entrypoint that implements a method returning an RpcTarget instance", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
workers: [
|
|
{
|
|
name: "a",
|
|
serviceBindings: {
|
|
RPC_SERVICE: { name: "b", entrypoint: "RpcEntrypoint" },
|
|
},
|
|
compatibilityFlags: ["rpc"],
|
|
modules: true,
|
|
script: `
|
|
export default {
|
|
async fetch(request, env) {
|
|
const rpcTarget = await env.RPC_SERVICE.getRpcTarget();
|
|
return Response.json(rpcTarget.id);
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
{
|
|
name: "b",
|
|
modules: true,
|
|
script: `
|
|
import { WorkerEntrypoint, RpcTarget } from "cloudflare:workers";
|
|
|
|
export class RpcEntrypoint extends WorkerEntrypoint {
|
|
getRpcTarget() {
|
|
return new SubService("test-id");
|
|
}
|
|
}
|
|
|
|
class SubService extends RpcTarget {
|
|
#id
|
|
|
|
constructor(id) {
|
|
super()
|
|
this.#id = id
|
|
}
|
|
|
|
get id() {
|
|
return this.#id
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
const bindings = await mf.getBindings<{ RPC_SERVICE: any }>();
|
|
const rpcTarget = await bindings.RPC_SERVICE.getRpcTarget();
|
|
expect(rpcTarget.id).toEqual("test-id");
|
|
});
|
|
|
|
test("Miniflare: tail consumer called", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
handleRuntimeStdio: () => {},
|
|
workers: [
|
|
{
|
|
name: "a",
|
|
tails: ["b"],
|
|
compatibilityDate: "2025-04-28",
|
|
modules: true,
|
|
script: `
|
|
|
|
export default {
|
|
async fetch(request, env) {
|
|
if(request.url.includes("b")) { return env.B.fetch(request)}
|
|
console.log("Tail: log event")
|
|
|
|
return new Response("hello from a");
|
|
}
|
|
}
|
|
`,
|
|
serviceBindings: {
|
|
B: "b",
|
|
},
|
|
},
|
|
{
|
|
name: "b",
|
|
modules: true,
|
|
compatibilityDate: "2025-04-28",
|
|
|
|
script: `
|
|
let event;
|
|
export default {
|
|
fetch() {return Response.json(event)},
|
|
tail(e) {event = e }
|
|
};
|
|
`,
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("http://placeholder");
|
|
expect(await res.text()).toEqual("hello from a");
|
|
expect(
|
|
(
|
|
(await (await mf.dispatchFetch("http://placeholder/b")).json()) as {
|
|
logs: { message: string[] }[];
|
|
}[]
|
|
)[0].logs[0].message
|
|
).toEqual(["Tail: log event"]);
|
|
});
|
|
|
|
test("Miniflare: custom outbound service", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
workers: [
|
|
{
|
|
name: "a",
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch() {
|
|
const res1 = await (await fetch("https://example.com/1")).text();
|
|
const res2 = await (await fetch("https://example.com/2")).text();
|
|
return Response.json({ res1, res2 });
|
|
}
|
|
}`,
|
|
outboundService: "b",
|
|
},
|
|
{
|
|
name: "b",
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request, env) {
|
|
if (request.url === "https://example.com/1") {
|
|
return new Response("one");
|
|
} else {
|
|
return fetch(request);
|
|
}
|
|
}
|
|
}`,
|
|
outboundService(request) {
|
|
return new Response(`fallback:${request.url}`);
|
|
},
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
const res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.json()).toEqual({
|
|
res1: "one",
|
|
res2: "fallback:https://example.com/2",
|
|
});
|
|
});
|
|
|
|
test("Miniflare: custom outbound service passes through TCP sockets", async ({
|
|
expect,
|
|
}) => {
|
|
const tcpServer = net.createServer((socket) => {
|
|
socket.on("data", () => {
|
|
socket.write("pong");
|
|
});
|
|
});
|
|
const listeningPromise = once(tcpServer, "listening");
|
|
tcpServer.listen(0, "127.0.0.1");
|
|
|
|
await listeningPromise;
|
|
|
|
const address = tcpServer.address();
|
|
assert(typeof address === "object" && address !== null);
|
|
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
compatibilityDate: "2026-05-20",
|
|
compatibilityFlags: ["nodejs_compat"],
|
|
script: `
|
|
import { connect } from "cloudflare:sockets";
|
|
|
|
export default {
|
|
async fetch() {
|
|
const response = await fetch("https://example.com/path?query=1");
|
|
const fetchBody = await response.text();
|
|
const socket = connect({ hostname: "127.0.0.1", port: ${address.port} });
|
|
const writer = socket.writable.getWriter();
|
|
await writer.write(new TextEncoder().encode("ping"));
|
|
const reader = socket.readable.getReader();
|
|
const { value } = await reader.read();
|
|
writer.releaseLock();
|
|
reader.releaseLock();
|
|
socket.close();
|
|
|
|
return Response.json({
|
|
fetchBody,
|
|
socketBody: new TextDecoder().decode(value),
|
|
});
|
|
}
|
|
};
|
|
`,
|
|
outboundService(request) {
|
|
return new Response(`intercepted:${request.url}`);
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
|
|
try {
|
|
const response = await mf.dispatchFetch("http://localhost");
|
|
const body = await response.json();
|
|
expect({ status: response.status, body }).toEqual({
|
|
status: 200,
|
|
body: {
|
|
fetchBody: "intercepted:https://example.com/path?query=1",
|
|
socketBody: "pong",
|
|
},
|
|
});
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => {
|
|
tcpServer.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
|
|
test("Miniflare: can send GET request with body", async ({ expect }) => {
|
|
// https://github.com/cloudflare/workerd/issues/1122
|
|
const mf = new Miniflare({
|
|
compatibilityDate: "2023-08-01",
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request) {
|
|
return Response.json({
|
|
cf: request.cf,
|
|
contentLength: request.headers.get("Content-Length"),
|
|
hasBody: request.body !== null,
|
|
});
|
|
}
|
|
}`,
|
|
cf: { key: "value" },
|
|
});
|
|
useDispose(mf);
|
|
|
|
// Can't use `dispatchFetch()` here as `fetch()` prohibits `GET` requests
|
|
// with bodies/`Content-Length: 0` headers
|
|
const url = await mf.ready;
|
|
function get(opts: http.RequestOptions = {}): Promise<http.IncomingMessage> {
|
|
return new Promise((resolve, reject) => {
|
|
http.get(url, opts, resolve).on("error", reject);
|
|
});
|
|
}
|
|
|
|
let res = await get();
|
|
expect(await json(res)).toEqual({
|
|
cf: { key: "value", clientAcceptEncoding: "" },
|
|
contentLength: null,
|
|
hasBody: false,
|
|
});
|
|
|
|
res = await get({ headers: { "content-length": "0" } });
|
|
expect(await json(res)).toEqual({
|
|
cf: { key: "value", clientAcceptEncoding: "" },
|
|
contentLength: "0",
|
|
hasBody: true,
|
|
});
|
|
});
|
|
|
|
test("Miniflare: handles redirect responses", async ({ expect }) => {
|
|
// https://github.com/cloudflare/workers-sdk/issues/5018
|
|
|
|
const { http } = await useServer((req, res) => {
|
|
// Check no special headers set
|
|
const headerKeys = Object.keys(req.headers);
|
|
expect(
|
|
headerKeys.filter((key) => key.toLowerCase().startsWith("mf-"))
|
|
).toEqual([]);
|
|
|
|
const { pathname } = new URL(req.url ?? "", "http://placeholder");
|
|
if (pathname === "/ping") {
|
|
res.end("pong");
|
|
} else if (pathname === "/redirect-back") {
|
|
res.writeHead(302, { Location: "https://custom.mf/external-redirected" });
|
|
res.end();
|
|
} else {
|
|
res.writeHead(404);
|
|
res.end("Not Found");
|
|
}
|
|
});
|
|
|
|
const mf = new Miniflare({
|
|
bindings: { EXTERNAL_URL: http.href },
|
|
compatibilityDate: "2024-01-01",
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request, env) {
|
|
const url = new URL(request.url);
|
|
const externalUrl = new URL(env.EXTERNAL_URL);
|
|
if (url.pathname === "/redirect-relative") {
|
|
return new Response(null, { status: 302, headers: { Location: "/relative-redirected" } });
|
|
} else if (url.pathname === "/redirect-absolute") {
|
|
url.pathname = "/absolute-redirected";
|
|
return Response.redirect(url, 302);
|
|
} else if (url.pathname === "/redirect-external") {
|
|
externalUrl.pathname = "/ping";
|
|
return Response.redirect(externalUrl, 302);
|
|
} else if (url.pathname === "/redirect-external-and-back") {
|
|
externalUrl.pathname = "/redirect-back";
|
|
return Response.redirect(externalUrl, 302);
|
|
} else {
|
|
return new Response("end:" + url.href);
|
|
}
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
|
|
// Check relative redirect
|
|
let res = await mf.dispatchFetch("https://custom.mf/redirect-relative", {
|
|
redirect: "manual",
|
|
});
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.get("Location")).toBe("/relative-redirected");
|
|
await res.arrayBuffer(); // (drain)
|
|
|
|
res = await mf.dispatchFetch("https://custom.mf/redirect-relative");
|
|
expect(res.status).toBe(200);
|
|
expect(await res.text()).toBe("end:https://custom.mf/relative-redirected");
|
|
|
|
// Check absolute redirect to same origin
|
|
res = await mf.dispatchFetch("https://custom.mf/redirect-absolute", {
|
|
redirect: "manual",
|
|
});
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.get("Location")).toBe(
|
|
"https://custom.mf/absolute-redirected"
|
|
);
|
|
await res.arrayBuffer(); // (drain)
|
|
|
|
res = await mf.dispatchFetch("https://custom.mf/redirect-absolute");
|
|
expect(res.status).toBe(200);
|
|
expect(await res.text()).toBe("end:https://custom.mf/absolute-redirected");
|
|
|
|
// Check absolute redirect to external origin
|
|
res = await mf.dispatchFetch("https://custom.mf/redirect-external", {
|
|
redirect: "manual",
|
|
});
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.get("Location")).toBe(new URL("/ping", http).href);
|
|
await res.arrayBuffer(); // (drain)
|
|
|
|
res = await mf.dispatchFetch("https://custom.mf/redirect-external");
|
|
expect(res.status).toBe(200);
|
|
expect(await res.text()).toBe("pong");
|
|
|
|
// Check absolute redirect to external origin, then redirect back to initial
|
|
res = await mf.dispatchFetch("https://custom.mf/redirect-external-and-back", {
|
|
redirect: "manual",
|
|
});
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.get("Location")).toBe(
|
|
new URL("/redirect-back", http).href
|
|
);
|
|
await res.arrayBuffer(); // (drain)
|
|
|
|
res = await mf.dispatchFetch("https://custom.mf/redirect-external-and-back");
|
|
expect(res.status).toBe(200);
|
|
// External server redirects back to worker running in `workerd`
|
|
expect(await res.text()).toBe("end:https://custom.mf/external-redirected");
|
|
});
|
|
|
|
test("Miniflare: fetch mocking", async ({ expect }) => {
|
|
const fetchMock = createFetchMock();
|
|
fetchMock.disableNetConnect();
|
|
const origin = fetchMock.get("https://example.com");
|
|
origin.intercept({ method: "GET", path: "/" }).reply(200, "Mocked response!");
|
|
|
|
const mfOptions: MiniflareOptions = {
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch() {
|
|
return fetch("https://example.com/");
|
|
}
|
|
}`,
|
|
fetchMock,
|
|
};
|
|
const resultOptions = {} as MiniflareOptions;
|
|
|
|
// Verify that options with `fetchMock` can be parsed first before passing to Miniflare
|
|
// Regression test for https://github.com/cloudflare/workers-sdk/issues/5486
|
|
for (const plugin of Object.values(PLUGINS)) {
|
|
Object.assign(
|
|
resultOptions,
|
|
parseWithRootPath("", plugin.options, mfOptions)
|
|
);
|
|
}
|
|
|
|
const mf = new Miniflare(resultOptions);
|
|
useDispose(mf);
|
|
const res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("Mocked response!");
|
|
|
|
// Check `outboundService`and `fetchMock` mutually exclusive
|
|
await expect(
|
|
mf.setOptions({
|
|
script: "",
|
|
fetchMock,
|
|
outboundService: "",
|
|
})
|
|
).rejects.toThrow(
|
|
new MiniflareCoreError(
|
|
"ERR_MULTIPLE_OUTBOUNDS",
|
|
"Only one of `outboundService` or `fetchMock` may be specified per worker"
|
|
)
|
|
);
|
|
});
|
|
test("Miniflare: custom upstream as origin (with colons)", async ({
|
|
expect,
|
|
}) => {
|
|
const upstream = await useServer((req, res) => {
|
|
res.end(`upstream: ${new URL(req.url ?? "", "http://upstream")}`);
|
|
});
|
|
const mf = new Miniflare({
|
|
upstream: new URL("/extra:extra/", upstream.http.toString()).toString(),
|
|
modules: true,
|
|
script: `export default {
|
|
fetch(request) {
|
|
return fetch(request);
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
// Check rewrites protocol, hostname, and port, but keeps pathname and query
|
|
const res = await mf.dispatchFetch("https://random:0/path:path?a=1");
|
|
expect(await res.text()).toBe(
|
|
"upstream: http://upstream/extra:extra/path:path?a=1"
|
|
);
|
|
});
|
|
test("Miniflare: custom upstream as origin", async ({ expect }) => {
|
|
const upstream = await useServer((req, res) => {
|
|
res.end(`upstream: ${new URL(req.url ?? "", "http://upstream")}`);
|
|
});
|
|
const mf = new Miniflare({
|
|
upstream: new URL("/extra/", upstream.http.toString()).toString(),
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request) {
|
|
const resp = await (await fetch(request)).text();
|
|
return Response.json({
|
|
resp,
|
|
host: request.headers.get("Host")
|
|
});
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
// Check rewrites protocol, hostname, and port, but keeps pathname and query
|
|
const res = await mf.dispatchFetch("https://random:0/path?a=1");
|
|
expect(await res.json()).toEqual({
|
|
resp: "upstream: http://upstream/extra/path?a=1",
|
|
host: upstream.http.host,
|
|
});
|
|
});
|
|
test("Miniflare: custom upstream sets MF-Original-Hostname header", async ({
|
|
expect,
|
|
}) => {
|
|
const upstream = await useServer((req, res) => {
|
|
res.end(`upstream`);
|
|
});
|
|
const mf = new Miniflare({
|
|
upstream: upstream.http.toString(),
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request) {
|
|
return Response.json({
|
|
host: request.headers.get("Host"),
|
|
originalHostname: request.headers.get("MF-Original-Hostname")
|
|
});
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
// Check that original hostname is preserved when using upstream
|
|
const res = await mf.dispatchFetch(
|
|
"https://my-original-host.example.com:8080/path?a=1"
|
|
);
|
|
expect(await res.json()).toEqual({
|
|
host: upstream.http.host,
|
|
originalHostname: "my-original-host.example.com:8080",
|
|
});
|
|
});
|
|
test("Miniflare: MF-Original-Hostname header not set without upstream", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request) {
|
|
return Response.json({
|
|
originalHostname: request.headers.get("MF-Original-Hostname")
|
|
});
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
// Check that original hostname header is not set when not using upstream
|
|
const res = await mf.dispatchFetch("https://random:0/path?a=1");
|
|
expect(await res.json()).toEqual({
|
|
originalHostname: null,
|
|
});
|
|
});
|
|
test("Miniflare: set origin to original URL if proxy shared secret matches", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
unsafeProxySharedSecret: "SOME_PROXY_SHARED_SECRET_VALUE",
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request) {
|
|
return Response.json({
|
|
host: request.headers.get("Host")
|
|
});
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("https://random:0/path?a=1", {
|
|
headers: { "MF-Proxy-Shared-Secret": "SOME_PROXY_SHARED_SECRET_VALUE" },
|
|
});
|
|
expect(await res.json()).toEqual({
|
|
host: "random:0",
|
|
});
|
|
});
|
|
test("Miniflare: keep origin as listening host if proxy shared secret not provided", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request) {
|
|
return Response.json({
|
|
host: request.headers.get("Host")
|
|
});
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("https://random:0/path?a=1");
|
|
expect(await res.json()).toEqual({
|
|
host: (await mf.ready).host,
|
|
});
|
|
});
|
|
test("Miniflare: 400 error on proxy shared secret header when not configured", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request) {
|
|
return Response.json({
|
|
host: request.headers.get("Host")
|
|
});
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("https://random:0/path?a=1", {
|
|
headers: { "MF-Proxy-Shared-Secret": "SOME_PROXY_SHARED_SECRET_VALUE" },
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(await res.text()).toBe(
|
|
"Disallowed header in request: MF-Proxy-Shared-Secret=SOME_PROXY_SHARED_SECRET_VALUE"
|
|
);
|
|
});
|
|
test("Miniflare: 400 error on proxy shared secret header mismatch with configuration", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
unsafeProxySharedSecret: "SOME_PROXY_SHARED_SECRET_VALUE",
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request) {
|
|
return Response.json({
|
|
host: request.headers.get("Host")
|
|
});
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("https://random:0/path?a=1", {
|
|
headers: { "MF-Proxy-Shared-Secret": "BAD_PROXY_SHARED_SECRET" },
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(await res.text()).toBe(
|
|
"Disallowed header in request: MF-Proxy-Shared-Secret=BAD_PROXY_SHARED_SECRET"
|
|
);
|
|
});
|
|
|
|
test("Miniflare: `node:`, `cloudflare:` and `workerd:` modules", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
compatibilityFlags: ["nodejs_compat", "rtti_api"],
|
|
scriptPath: "index.mjs",
|
|
script: `
|
|
import assert from "node:assert";
|
|
import { Buffer } from "node:buffer";
|
|
import { connect } from "cloudflare:sockets";
|
|
import rtti from "workerd:rtti";
|
|
export default {
|
|
fetch() {
|
|
assert.strictEqual(typeof connect, "function");
|
|
assert.strictEqual(typeof rtti, "object");
|
|
return new Response(Buffer.from("test").toString("base64"))
|
|
}
|
|
}
|
|
`,
|
|
});
|
|
useDispose(mf);
|
|
const res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("dGVzdA==");
|
|
});
|
|
|
|
test("Miniflare: modules in sub-directories", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
modules: [
|
|
{
|
|
type: "ESModule",
|
|
path: "index.js",
|
|
contents: `import { b } from "./sub1/index.js"; export default { fetch() { return new Response(String(b + 3)); } }`,
|
|
},
|
|
{
|
|
type: "ESModule",
|
|
path: "sub1/index.js",
|
|
contents: `import { c } from "./sub2/index.js"; export const b = c + 20;`,
|
|
},
|
|
{
|
|
type: "ESModule",
|
|
path: "sub1/sub2/index.js",
|
|
contents: `export const c = 100;`,
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
const res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("123");
|
|
});
|
|
|
|
test("Miniflare: python modules", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
modules: [
|
|
{
|
|
type: "PythonModule",
|
|
path: "index.py",
|
|
contents:
|
|
"from test_module import add; from workers import Response, WorkerEntrypoint;\nclass Default(WorkerEntrypoint):\n def fetch(self, request):\n return Response(str(add(2,2)))",
|
|
},
|
|
{
|
|
type: "PythonModule",
|
|
path: "test_module.py",
|
|
contents: `def add(a, b):\n return a + b`,
|
|
},
|
|
],
|
|
compatibilityFlags: ["python_workers", "python_no_global_handlers"],
|
|
});
|
|
useDispose(mf);
|
|
const res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("4");
|
|
});
|
|
|
|
test("Miniflare: HTTPS fetches using browser CA certificates", async ({
|
|
expect,
|
|
}) => {
|
|
// example.com is intentionally maintained as a stable test endpoint by IANA.
|
|
// We previously fetched https://workers.cloudflare.com/cf.json but that URL
|
|
// now 301s to https://www.cloudflare.com/cf.json which 404s, so the test
|
|
// was effectively asserting on an unintended 404 path rather than HTTPS CA
|
|
// trust.
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: `export default {
|
|
fetch() {
|
|
return fetch("https://example.com/");
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
const res = await mf.dispatchFetch("http://localhost");
|
|
expect(res.ok).toBe(true);
|
|
await res.arrayBuffer(); // (drain)
|
|
});
|
|
|
|
test("Miniflare: accepts https requests", async ({ expect }) => {
|
|
const log = new TestLog();
|
|
|
|
const mf = new Miniflare({
|
|
log,
|
|
modules: true,
|
|
https: true,
|
|
script: `export default {
|
|
fetch() {
|
|
return new Response("Hello world");
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("https://localhost");
|
|
expect(res.ok).toBe(true);
|
|
await res.arrayBuffer(); // (drain)
|
|
|
|
expect(log.logs[0][1].startsWith("Ready on https://"));
|
|
});
|
|
|
|
// Regression test for https://github.com/cloudflare/workers-sdk/issues/9357
|
|
test("Miniflare: throws error messages that reflect the actual issue", async ({
|
|
expect,
|
|
}) => {
|
|
const log = new TestLog();
|
|
|
|
const mf = new Miniflare({
|
|
log,
|
|
modules: true,
|
|
https: true,
|
|
script: `export default {
|
|
async fetch(request, env, ctx) {
|
|
Object.defineProperty("not an object", "node", "");
|
|
|
|
return new Response('Hello World!');
|
|
},
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("https://localhost");
|
|
expect(await res.text()).toMatch(
|
|
/TypeError: Object\.defineProperty called on non-object/
|
|
);
|
|
});
|
|
|
|
test("Miniflare: manually triggered scheduled events", async ({ expect }) => {
|
|
const log = new TestLog();
|
|
|
|
const mf = new Miniflare({
|
|
log,
|
|
modules: true,
|
|
script: `
|
|
let scheduledRun = false;
|
|
export default {
|
|
fetch() {
|
|
return new Response(scheduledRun);
|
|
},
|
|
scheduled(controller) {
|
|
scheduledRun = true;
|
|
controller.noRetry();
|
|
}
|
|
}`,
|
|
unsafeTriggerHandlers: true,
|
|
});
|
|
useDispose(mf);
|
|
|
|
let res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("false");
|
|
|
|
res = await mf.dispatchFetch("http://localhost/cdn-cgi/handler/scheduled");
|
|
expect(await res.text()).toBe("ok");
|
|
|
|
res = await mf.dispatchFetch(
|
|
"http://localhost/cdn-cgi/handler/scheduled?format=json"
|
|
);
|
|
expect(await res.json()).toEqual({ outcome: "ok", noRetry: true });
|
|
|
|
res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("true");
|
|
});
|
|
|
|
test("Miniflare: manually triggered scheduled events with assets", async ({
|
|
expect,
|
|
}) => {
|
|
const log = new TestLog();
|
|
const tmp = await useTmp();
|
|
await fs.writeFile(
|
|
path.join(tmp, "foo.html"),
|
|
"<!doctype html><p>asset</p>",
|
|
"utf8"
|
|
);
|
|
await fs.writeFile(path.join(tmp, "foo.md"), "asset", "utf8");
|
|
const mf = new Miniflare({
|
|
log,
|
|
modules: true,
|
|
script: `
|
|
let scheduledRun = false;
|
|
let cron;
|
|
let scheduledTime;
|
|
export default {
|
|
fetch() {
|
|
return Response.json({ scheduledRun, cron, scheduledTime });
|
|
},
|
|
scheduled(controller, env, ctx) {
|
|
scheduledRun = true;
|
|
cron = controller.cron;
|
|
scheduledTime = Number(controller.scheduledTime);
|
|
controller.noRetry();
|
|
}
|
|
}`,
|
|
assets: {
|
|
directory: tmp,
|
|
routerConfig: {
|
|
has_user_worker: true,
|
|
},
|
|
},
|
|
unsafeTriggerHandlers: true,
|
|
});
|
|
useDispose(mf);
|
|
|
|
type ScheduledResult = {
|
|
scheduledRun: boolean;
|
|
cron?: string;
|
|
scheduledTime?: number;
|
|
};
|
|
|
|
let res = await mf.dispatchFetch("http://localhost");
|
|
let json = (await res.json()) as ScheduledResult;
|
|
expect(json.scheduledRun).toBe(false);
|
|
expect(json.cron).toBe(undefined);
|
|
expect(json.scheduledTime).toBe(undefined);
|
|
|
|
res = await mf.dispatchFetch("http://localhost/foo");
|
|
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
expect(await res.text()).toBe("<!doctype html><p>asset</p>");
|
|
|
|
res = await mf.dispatchFetch("http://localhost/foo.md");
|
|
expect(res.headers.get("content-type")).toBe("text/markdown; charset=utf-8");
|
|
expect(await res.text()).toBe("asset");
|
|
|
|
res = await mf.dispatchFetch("http://localhost/cdn-cgi/handler/scheduled");
|
|
expect(await res.text()).toBe("ok");
|
|
|
|
res = await mf.dispatchFetch("http://localhost");
|
|
json = (await res.json()) as ScheduledResult;
|
|
expect(json.scheduledRun).toBe(true);
|
|
expect(json.cron).toBe("");
|
|
expect(json.scheduledTime).toBeDefined();
|
|
|
|
res = await mf.dispatchFetch(
|
|
"http://localhost/cdn-cgi/handler/scheduled?format=json&cron=0+0+0+0+0&time=1234567890987"
|
|
);
|
|
expect(await res.json()).toEqual({
|
|
outcome: "ok",
|
|
noRetry: true,
|
|
});
|
|
|
|
res = await mf.dispatchFetch("http://localhost");
|
|
json = (await res.json()) as ScheduledResult;
|
|
expect(json.scheduledRun).toBe(true);
|
|
expect(json.cron).toBe("0 0 0 0 0");
|
|
expect(json.scheduledTime).toBe(1234567890987);
|
|
});
|
|
|
|
test("Miniflare: manually triggered email handler - valid email", async ({
|
|
expect,
|
|
}) => {
|
|
const log = new TestLog();
|
|
|
|
const mf = new Miniflare({
|
|
log,
|
|
modules: true,
|
|
script: `
|
|
let receivedEmail = false;
|
|
export default {
|
|
fetch() {
|
|
return new Response(receivedEmail);
|
|
},
|
|
email(emailMessage) {
|
|
receivedEmail = true;
|
|
}
|
|
}`,
|
|
unsafeTriggerHandlers: true,
|
|
});
|
|
useDispose(mf);
|
|
|
|
let res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("false");
|
|
|
|
res = await mf.dispatchFetch(
|
|
"http://localhost/cdn-cgi/handler/email?from=someone@example.com&to=someone-else@example.com",
|
|
{
|
|
body: `From: someone <someone@example.com>
|
|
To: someone else <someone-else@example.com>
|
|
Message-ID: <im-a-random-message-id@example.com>
|
|
MIME-Version: 1.0
|
|
Content-Type: text/plain
|
|
|
|
This is a random email body.
|
|
`,
|
|
method: "POST",
|
|
}
|
|
);
|
|
expect(await res.text()).toBe("Worker successfully processed email");
|
|
expect(res.status).toBe(200);
|
|
|
|
res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("true");
|
|
});
|
|
|
|
test("Miniflare: manually triggered email handler - setReject does not throw", async ({
|
|
expect,
|
|
}) => {
|
|
const log = new TestLog();
|
|
|
|
const mf = new Miniflare({
|
|
log,
|
|
modules: true,
|
|
script: `
|
|
let receivedEmail = false;
|
|
export default {
|
|
fetch() {
|
|
return new Response(receivedEmail);
|
|
},
|
|
async email(emailMessage) {
|
|
await emailMessage.setReject("I just don't like this email :(")
|
|
receivedEmail = true;
|
|
}
|
|
}`,
|
|
unsafeTriggerHandlers: true,
|
|
});
|
|
useDispose(mf);
|
|
|
|
let res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("false");
|
|
|
|
res = await mf.dispatchFetch(
|
|
"http://localhost/cdn-cgi/handler/email?from=someone@example.com&to=someone-else@example.com",
|
|
{
|
|
body: `From: someone <someone@example.com>
|
|
To: someone else <someone-else@example.com>
|
|
Message-ID: <im-a-random-message-id@example.com>
|
|
MIME-Version: 1.0
|
|
Content-Type: text/plain
|
|
|
|
This is a random email body.
|
|
`,
|
|
method: "POST",
|
|
}
|
|
);
|
|
expect(await res.text()).toBe(
|
|
"Worker rejected email with the following reason: I just don't like this email :("
|
|
);
|
|
expect(res.status).toBe(400);
|
|
|
|
res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("true");
|
|
});
|
|
|
|
test("Miniflare: manually triggered email handler - forward does not throw", async ({
|
|
expect,
|
|
}) => {
|
|
const log = new TestLog();
|
|
|
|
const mf = new Miniflare({
|
|
log,
|
|
modules: true,
|
|
script: `
|
|
let receivedEmail = false;
|
|
export default {
|
|
fetch() {
|
|
return new Response(receivedEmail);
|
|
},
|
|
async email(emailMessage) {
|
|
await emailMessage.forward("mark.s@example.com")
|
|
receivedEmail = true;
|
|
}
|
|
}`,
|
|
unsafeTriggerHandlers: true,
|
|
});
|
|
useDispose(mf);
|
|
|
|
let res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("false");
|
|
|
|
res = await mf.dispatchFetch(
|
|
"http://localhost/cdn-cgi/handler/email?from=someone@example.com&to=someone-else@example.com",
|
|
{
|
|
body: `From: someone <someone@example.com>
|
|
To: someone else <someone-else@example.com>
|
|
Message-ID: <im-a-random-message-id@example.com>
|
|
MIME-Version: 1.0
|
|
Content-Type: text/plain
|
|
|
|
This is a random email body.
|
|
`,
|
|
method: "POST",
|
|
}
|
|
);
|
|
expect(await res.text()).toBe("Worker successfully processed email");
|
|
expect(res.status).toBe(200);
|
|
|
|
res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("true");
|
|
});
|
|
|
|
test("Miniflare: manually triggered email handler - invalid email, no message id", async ({
|
|
expect,
|
|
}) => {
|
|
const log = new TestLog();
|
|
|
|
const mf = new Miniflare({
|
|
log,
|
|
modules: true,
|
|
script: `
|
|
let receivedEmail = false;
|
|
export default {
|
|
fetch() {
|
|
return new Response(receivedEmail);
|
|
},
|
|
email(emailMessage) {
|
|
receivedEmail = true;
|
|
}
|
|
}`,
|
|
unsafeTriggerHandlers: true,
|
|
});
|
|
useDispose(mf);
|
|
|
|
let res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("false");
|
|
|
|
res = await mf.dispatchFetch(
|
|
"http://localhost/cdn-cgi/handler/email?from=someone@example.com&to=someone-else@example.com",
|
|
{
|
|
body: `From: someone <someone@example.com>
|
|
To: someone else <someone-else@example.com>
|
|
MIME-Version: 1.0
|
|
Content-Type: text/plain
|
|
|
|
This is a random email body.
|
|
`,
|
|
method: "POST",
|
|
}
|
|
);
|
|
expect(await res.text()).toBe(
|
|
"Email could not be parsed: invalid or no message id provided"
|
|
);
|
|
expect(res.status).toBe(400);
|
|
|
|
res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("false");
|
|
});
|
|
|
|
test("Miniflare: manually triggered email handler - reply handler works", async ({
|
|
expect,
|
|
}) => {
|
|
const log = new TestLog();
|
|
|
|
const mf = new Miniflare({
|
|
log,
|
|
modules: true,
|
|
script: `
|
|
import {EmailMessage} from "cloudflare:email"
|
|
let receivedEmail = false;
|
|
export default {
|
|
fetch() {
|
|
return new Response(receivedEmail);
|
|
},
|
|
async email(emailMessage) {
|
|
await emailMessage.reply(new EmailMessage(
|
|
"someone-else@example.com",
|
|
"someone@example.com",
|
|
(new Response(
|
|
\`From: someone else <someone-else@example.com>
|
|
To: someone <someone@example.com>
|
|
In-Reply-To: <im-a-random-message-id@example.com>
|
|
Message-ID: <im-another-random-message-id@example.com>
|
|
MIME-Version: 1.0
|
|
Content-Type: text/plain
|
|
|
|
This is a random email body.
|
|
\`)).body
|
|
));
|
|
|
|
receivedEmail = true;
|
|
}
|
|
}`,
|
|
unsafeTriggerHandlers: true,
|
|
});
|
|
useDispose(mf);
|
|
|
|
let res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("false");
|
|
|
|
res = await mf.dispatchFetch(
|
|
"http://localhost/cdn-cgi/handler/email?from=someone@example.com&to=someone-else@example.com",
|
|
{
|
|
body: `From: someone <someone@example.com>
|
|
To: someone else <someone-else@example.com>
|
|
MIME-Version: 1.0
|
|
Message-ID: <im-a-random-message-id@example.com>
|
|
Content-Type: text/plain
|
|
|
|
This is a random email body.
|
|
`,
|
|
method: "POST",
|
|
}
|
|
);
|
|
expect(await res.text()).toBe("Worker successfully processed email");
|
|
expect(res.status).toBe(200);
|
|
|
|
res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("true");
|
|
});
|
|
|
|
test("Miniflare: unimplemented /cdn-cgi/handler/ routes", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: `
|
|
export default {
|
|
fetch() {
|
|
return new Response("Hello world");
|
|
}
|
|
}
|
|
`,
|
|
unsafeTriggerHandlers: true,
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("http://localhost/cdn-cgi/handler/foo");
|
|
expect(await res.text()).toBe(
|
|
`"/cdn-cgi/handler/foo" is not a valid handler. Did you mean to use "/cdn-cgi/handler/scheduled" or "/cdn-cgi/handler/email"?`
|
|
);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
test("Miniflare: other /cdn-cgi/ routes", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: `
|
|
export default {
|
|
fetch() {
|
|
return new Response("Hello world");
|
|
}
|
|
}
|
|
`,
|
|
unsafeTriggerHandlers: true,
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("http://localhost/cdn-cgi/foo");
|
|
expect(await res.text()).toBe("Hello world");
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
test("Miniflare: blocks non-local Host headers from reaching /cdn-cgi/ routes", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: `
|
|
export default {
|
|
fetch() {
|
|
return new Response("Hello world");
|
|
}
|
|
}
|
|
`,
|
|
unsafeTriggerHandlers: true,
|
|
});
|
|
useDispose(mf);
|
|
|
|
const url = await mf.ready;
|
|
const directUrlStatus = await new Promise<number>((resolve, reject) => {
|
|
const req = http.get(
|
|
`${url.origin}/cdn-cgi/foo`,
|
|
{ setHost: false, headers: { Host: "example.trycloudflare.com" } },
|
|
(res) => {
|
|
res.resume();
|
|
resolve(res.statusCode ?? 0);
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
});
|
|
|
|
expect(directUrlStatus).toBe(403);
|
|
|
|
const originalUrlStatus = await new Promise<number>((resolve, reject) => {
|
|
const req = http.get(
|
|
`${url.origin}/foo`,
|
|
{
|
|
setHost: false,
|
|
headers: {
|
|
Host: "example.trycloudflare.com",
|
|
"MF-Original-URL": "http://localhost/cdn-cgi/handler/scheduled",
|
|
},
|
|
},
|
|
(res) => {
|
|
res.resume();
|
|
resolve(res.statusCode ?? 0);
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
});
|
|
|
|
expect(originalUrlStatus).toBe(403);
|
|
});
|
|
|
|
test("Miniflare: listens on ipv6", async ({ expect }) => {
|
|
const log = new TestLog();
|
|
|
|
const mf = new Miniflare({
|
|
log,
|
|
modules: true,
|
|
host: "*",
|
|
script: `export default {
|
|
fetch() {
|
|
return new Response("Hello world");
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
|
|
const url = await mf.ready;
|
|
|
|
let response = await fetch(`http://localhost:${url.port}`);
|
|
expect(response.ok).toBe(true);
|
|
|
|
response = await fetch(`http://[::1]:${url.port}`);
|
|
expect(response.ok).toBe(true);
|
|
|
|
response = await fetch(`http://127.0.0.1:${url.port}`);
|
|
expect(response.ok).toBe(true);
|
|
});
|
|
|
|
test("Miniflare: dispose() immediately after construction", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({ script: "", modules: true });
|
|
const readyPromise = mf.ready;
|
|
// Attach rejection handler BEFORE dispose() to prevent unhandled rejection
|
|
const readyAssertion = expect(readyPromise).rejects.toThrow(
|
|
new MiniflareCoreError("ERR_DISPOSED", "Cannot use disposed instance")
|
|
);
|
|
await mf.dispose();
|
|
await readyAssertion;
|
|
});
|
|
|
|
test("Miniflare: getBindings() returns all bindings", async ({
|
|
expect,
|
|
onTestFinished,
|
|
}) => {
|
|
const tmp = await useTmp();
|
|
const blobPath = path.join(tmp, "blob.txt");
|
|
await fs.writeFile(blobPath, "blob");
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: `
|
|
export class DurableObject {}
|
|
export default { fetch() { return new Response(null, { status: 404 }); } }
|
|
`,
|
|
bindings: { STRING: "hello", OBJECT: { a: 1, b: { c: 2 } } },
|
|
textBlobBindings: { TEXT: blobPath },
|
|
dataBlobBindings: { DATA: blobPath },
|
|
serviceBindings: { SELF: "" },
|
|
d1Databases: ["DB"],
|
|
durableObjects: { DO: "DurableObject" },
|
|
kvNamespaces: ["KV"],
|
|
queueProducers: ["QUEUE"],
|
|
r2Buckets: ["BUCKET"],
|
|
});
|
|
let disposed = false;
|
|
onTestFinished(() => {
|
|
if (!disposed) return mf.dispose();
|
|
});
|
|
|
|
interface Env {
|
|
STRING: string;
|
|
OBJECT: unknown;
|
|
TEXT: string;
|
|
DATA: ArrayBuffer;
|
|
SELF: ReplaceWorkersTypes<Fetcher>;
|
|
DB: D1Database;
|
|
DO: ReplaceWorkersTypes<DurableObjectNamespace>;
|
|
KV: ReplaceWorkersTypes<KVNamespace>;
|
|
QUEUE: Queue<unknown>;
|
|
BUCKET: ReplaceWorkersTypes<R2Bucket>;
|
|
}
|
|
const bindings = await mf.getBindings<Env>();
|
|
|
|
expect(bindings).toMatchObject({
|
|
STRING: "hello",
|
|
OBJECT: { a: 1, b: { c: 2 } },
|
|
TEXT: "blob",
|
|
});
|
|
expect(bindings.DATA).toEqual(viewToBuffer(utf8Encode("blob")));
|
|
|
|
const opts: util.InspectOptions = { colors: false };
|
|
expect(util.inspect(bindings.SELF, opts)).toMatch(/name: 'Fetcher'/);
|
|
expect(util.inspect(bindings.DB, opts)).toMatch(/name: 'D1Database'/);
|
|
expect(util.inspect(bindings.DO, opts)).toMatch(
|
|
/name: 'DurableObjectNamespace'/
|
|
);
|
|
expect(util.inspect(bindings.KV, opts)).toMatch(/name: 'KvNamespace'/);
|
|
expect(util.inspect(bindings.QUEUE, opts)).toMatch(/name: 'WorkerQueue'/);
|
|
expect(util.inspect(bindings.BUCKET, opts)).toMatch(/name: 'R2Bucket'/);
|
|
|
|
// Check with WebAssembly binding (aren't supported by modules workers)
|
|
const addWasmPath = path.join(tmp, "add.wasm");
|
|
await fs.writeFile(addWasmPath, ADD_WASM_MODULE);
|
|
await mf.setOptions({
|
|
script:
|
|
'addEventListener("fetch", (event) => event.respondWith(new Response(null, { status: 404 })));',
|
|
wasmBindings: { ADD: addWasmPath },
|
|
});
|
|
const { ADD } = await mf.getBindings<{ ADD: WebAssembly.Module }>();
|
|
const instance = new WebAssembly.Instance(ADD);
|
|
assert(typeof instance.exports.add === "function");
|
|
expect((instance.exports.add as (a: number, b: number) => number)(1, 2)).toBe(
|
|
3
|
|
);
|
|
|
|
// Check bindings poisoned after dispose
|
|
await mf.dispose();
|
|
disposed = true;
|
|
expect(() => bindings.KV.get("key")).toThrow(
|
|
new Error(
|
|
"Attempted to use poisoned stub. Stubs to runtime objects must be re-created after calling `Miniflare#setOptions()` or `Miniflare#dispose()`."
|
|
)
|
|
);
|
|
});
|
|
test("Miniflare: getBindings() returns wrapped bindings", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
workers: [
|
|
{
|
|
wrappedBindings: {
|
|
Greeter: {
|
|
scriptName: "greeter-implementation",
|
|
},
|
|
},
|
|
modules: true,
|
|
script: "",
|
|
},
|
|
{
|
|
modules: true,
|
|
name: "greeter-implementation",
|
|
script: `
|
|
class Greeter {
|
|
sayHello(name) {
|
|
return "Hello " + name;
|
|
}
|
|
}
|
|
|
|
export default function (env) {
|
|
return new Greeter();
|
|
}
|
|
`,
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
interface Env {
|
|
Greeter: {
|
|
sayHello: (str: string) => string;
|
|
};
|
|
}
|
|
const { Greeter } = await mf.getBindings<Env>();
|
|
|
|
const helloWorld = Greeter.sayHello("World");
|
|
|
|
expect(helloWorld).toBe("Hello World");
|
|
});
|
|
test("Miniflare: getBindings() handles wrapped bindings returning objects containing functions", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
workers: [
|
|
{
|
|
wrappedBindings: {
|
|
Greeter: {
|
|
scriptName: "greeter-obj-implementation",
|
|
},
|
|
},
|
|
modules: true,
|
|
script: "",
|
|
},
|
|
{
|
|
modules: true,
|
|
name: "greeter-obj-implementation",
|
|
script: `
|
|
export default function (env) {
|
|
const objWithFunction = {
|
|
greeting: "Hello",
|
|
sayHello(name) {
|
|
return this.greeting + ' ' + name;
|
|
}
|
|
};
|
|
return objWithFunction;
|
|
}
|
|
`,
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
interface Env {
|
|
Greeter: {
|
|
greeting: string;
|
|
sayHello: (str: string) => string;
|
|
};
|
|
}
|
|
const { Greeter } = await mf.getBindings<Env>();
|
|
|
|
const helloWorld = Greeter.sayHello("World");
|
|
|
|
expect(helloWorld).toBe("Hello World");
|
|
expect(Greeter.greeting).toBe("Hello");
|
|
});
|
|
test("Miniflare: getBindings() handles wrapped bindings returning objects containing nested functions", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
workers: [
|
|
{
|
|
wrappedBindings: {
|
|
Greeter: {
|
|
scriptName: "greeter-obj-implementation",
|
|
},
|
|
},
|
|
modules: true,
|
|
script: "",
|
|
},
|
|
{
|
|
modules: true,
|
|
name: "greeter-obj-implementation",
|
|
script: `
|
|
export default function (env) {
|
|
const objWithFunction = {
|
|
obj: {
|
|
obj1: {
|
|
obj2: {
|
|
sayHello: (name) => "Hello " + name + " from a nested function"
|
|
}
|
|
}
|
|
}
|
|
};
|
|
return objWithFunction;
|
|
}
|
|
`,
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
interface Env {
|
|
Greeter: {
|
|
obj: {
|
|
obj1: {
|
|
obj2: {
|
|
sayHello: (str: string) => string;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|
|
const { Greeter } = await mf.getBindings<Env>();
|
|
|
|
const helloWorld = Greeter.obj.obj1.obj2.sayHello("World");
|
|
|
|
expect(helloWorld).toBe("Hello World from a nested function");
|
|
});
|
|
test("Miniflare: getBindings() handles wrapped bindings returning functions returning functions", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
workers: [
|
|
{
|
|
wrappedBindings: {
|
|
GreetFactory: {
|
|
scriptName: "greet-factory-obj-implementation",
|
|
},
|
|
},
|
|
modules: true,
|
|
script: "",
|
|
},
|
|
{
|
|
modules: true,
|
|
name: "greet-factory-obj-implementation",
|
|
script: `
|
|
export default function (env) {
|
|
const factory = {
|
|
getGreetFunction(name) {
|
|
return (name) => {
|
|
return this.greeting + ' ' + name;
|
|
}
|
|
},
|
|
greeting: "Salutations",
|
|
};
|
|
return factory;
|
|
}
|
|
`,
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
interface Env {
|
|
GreetFactory: {
|
|
greeting: string;
|
|
getGreetFunction: () => (str: string) => string;
|
|
};
|
|
}
|
|
const { GreetFactory } = await mf.getBindings<Env>();
|
|
|
|
const greetFunction = GreetFactory.getGreetFunction();
|
|
|
|
expect(greetFunction("Esteemed World")).toBe("Salutations Esteemed World");
|
|
expect(GreetFactory.greeting).toBe("Salutations");
|
|
});
|
|
test("Miniflare: getWorker() allows dispatching events directly", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: `
|
|
let lastScheduledController;
|
|
let lastQueueBatch;
|
|
export default {
|
|
async fetch(request, env, ctx) {
|
|
const { pathname } = new URL(request.url);
|
|
if (pathname === "/scheduled") {
|
|
return Response.json({
|
|
scheduledTime: lastScheduledController?.scheduledTime,
|
|
cron: lastScheduledController?.cron,
|
|
});
|
|
} else if (pathname === "/queue") {
|
|
return Response.json({
|
|
queue: lastQueueBatch.queue,
|
|
messages: lastQueueBatch.messages.map((message) => ({
|
|
id: message.id,
|
|
timestamp: message.timestamp.getTime(),
|
|
body: message.body,
|
|
bodyType: message.body.constructor.name,
|
|
})),
|
|
});
|
|
} else if (pathname === "/get-url") {
|
|
return new Response(request.url);
|
|
} else {
|
|
return new Response(null, { status: 404 });
|
|
}
|
|
},
|
|
async scheduled(controller, env, ctx) {
|
|
lastScheduledController = controller;
|
|
if (controller.cron === "* * * * *") controller.noRetry();
|
|
},
|
|
async queue(batch, env, ctx) {
|
|
lastQueueBatch = batch;
|
|
if (batch.queue === "needy") batch.retryAll();
|
|
for (const message of batch.messages) {
|
|
if (message.id === "perfect") message.ack();
|
|
}
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
const fetcher = await mf.getWorker();
|
|
|
|
// Check `Fetcher#scheduled()` (implicitly testing `Fetcher#fetch()`)
|
|
let scheduledResult = await fetcher.scheduled({
|
|
cron: "* * * * *",
|
|
});
|
|
expect(scheduledResult).toEqual({ outcome: "ok", noRetry: true });
|
|
scheduledResult = await fetcher.scheduled({
|
|
scheduledTime: new Date(1000),
|
|
cron: "30 * * * *",
|
|
});
|
|
expect(scheduledResult).toEqual({ outcome: "ok", noRetry: false });
|
|
|
|
let res = await fetcher.fetch("http://localhost/scheduled");
|
|
const scheduledController = await res.json();
|
|
expect(scheduledController).toEqual({
|
|
scheduledTime: 1000,
|
|
cron: "30 * * * *",
|
|
});
|
|
|
|
// Check `Fetcher#queue()`
|
|
let queueResult = await fetcher.queue("needy", [
|
|
{ id: "a", timestamp: new Date(1000), body: "a", attempts: 1 },
|
|
{ id: "b", timestamp: new Date(2000), body: { b: 1 }, attempts: 1 },
|
|
]);
|
|
expect(queueResult).toEqual({
|
|
outcome: "ok",
|
|
ackAll: false,
|
|
retryBatch: {
|
|
retry: true,
|
|
},
|
|
explicitAcks: [],
|
|
retryMessages: [],
|
|
});
|
|
queueResult = await fetcher.queue("queue", [
|
|
{
|
|
id: "c",
|
|
timestamp: new Date(3000),
|
|
body: new Uint8Array([1, 2, 3]),
|
|
attempts: 1,
|
|
},
|
|
{
|
|
id: "perfect",
|
|
timestamp: new Date(4000),
|
|
body: new Date(5000),
|
|
attempts: 1,
|
|
},
|
|
]);
|
|
expect(queueResult).toEqual({
|
|
outcome: "ok",
|
|
ackAll: false,
|
|
retryBatch: {
|
|
retry: false,
|
|
},
|
|
explicitAcks: ["perfect"],
|
|
retryMessages: [],
|
|
});
|
|
|
|
res = await fetcher.fetch("http://localhost/queue");
|
|
const queueBatch = await res.json();
|
|
expect(queueBatch).toEqual({
|
|
queue: "queue",
|
|
messages: [
|
|
{
|
|
id: "c",
|
|
timestamp: 3000,
|
|
body: { 0: 1, 1: 2, 2: 3 },
|
|
bodyType: "Uint8Array",
|
|
},
|
|
{
|
|
id: "perfect",
|
|
timestamp: 4000,
|
|
body: "1970-01-01T00:00:05.000Z",
|
|
bodyType: "Date",
|
|
},
|
|
],
|
|
});
|
|
|
|
// Check `Fetcher#fetch()`
|
|
res = await fetcher.fetch("https://dummy:1234/get-url");
|
|
expect(await res.text()).toBe("https://dummy:1234/get-url");
|
|
});
|
|
test("Miniflare: getBindings() and friends return bindings for different workers", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
workers: [
|
|
{
|
|
name: "a",
|
|
modules: true,
|
|
script: `
|
|
export class DurableObject {}
|
|
export default { fetch() { return new Response("a"); } }
|
|
`,
|
|
d1Databases: ["DB"],
|
|
durableObjects: { DO: "DurableObject" },
|
|
},
|
|
{
|
|
// 2nd worker unnamed, to validate that not specifying a name when
|
|
// getting bindings gives the entrypoint, not the unnamed worker
|
|
script:
|
|
'addEventListener("fetch", (event) => event.respondWith(new Response("unnamed")));',
|
|
kvNamespaces: ["KV"],
|
|
queueProducers: ["QUEUE"],
|
|
},
|
|
{
|
|
name: "b",
|
|
script:
|
|
'addEventListener("fetch", (event) => event.respondWith(new Response("b")));',
|
|
r2Buckets: ["BUCKET"],
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
// Check `getBindings()`
|
|
let bindings = await mf.getBindings();
|
|
expect(Object.keys(bindings)).toEqual(["DB", "DO"]);
|
|
bindings = await mf.getBindings("");
|
|
expect(Object.keys(bindings)).toEqual(["KV", "QUEUE"]);
|
|
bindings = await mf.getBindings("b");
|
|
expect(Object.keys(bindings)).toEqual(["BUCKET"]);
|
|
await expect(() => mf.getBindings("c")).rejects.toThrow(
|
|
new TypeError('"c" worker not found')
|
|
);
|
|
|
|
// Check `getWorker()`
|
|
let fetcher = await mf.getWorker();
|
|
expect(await (await fetcher.fetch("http://localhost")).text()).toBe("a");
|
|
fetcher = await mf.getWorker("");
|
|
expect(await (await fetcher.fetch("http://localhost")).text()).toBe(
|
|
"unnamed"
|
|
);
|
|
fetcher = await mf.getWorker("b");
|
|
expect(await (await fetcher.fetch("http://localhost")).text()).toBe("b");
|
|
await expect(() => mf.getWorker("c")).rejects.toThrow(
|
|
new TypeError('"c" worker not found')
|
|
);
|
|
|
|
// Check `getD1Database()`
|
|
let binding: unknown = await mf.getD1Database("DB");
|
|
expect(binding).toBeDefined();
|
|
await expect(() => mf.getD1Database("DO")).rejects.toThrow(
|
|
new TypeError(`No D1 database binding named "DO" found in "a" worker.`)
|
|
);
|
|
await expect(() => mf.getD1Database("DB", "c")).rejects.toThrow(
|
|
new TypeError(`No D1 database binding named "DB" found in "c" worker.`)
|
|
);
|
|
|
|
// Check `getDurableObjectNamespace()`
|
|
binding = await mf.getDurableObjectNamespace("DO");
|
|
expect(binding).toBeDefined();
|
|
await expect(() => mf.getDurableObjectNamespace("DO", "c")).rejects.toThrow(
|
|
new TypeError(
|
|
`No Durable Object namespace binding named "DO" found in "c" worker.`
|
|
)
|
|
);
|
|
|
|
// Check `getKVNamespace()`
|
|
binding = await mf.getKVNamespace("KV", "");
|
|
expect(binding).toBeDefined();
|
|
await expect(() => mf.getKVNamespace("KV", "c")).rejects.toThrow(
|
|
new TypeError(`No KV namespace binding named "KV" found in "c" worker.`)
|
|
);
|
|
|
|
// Check `getQueueProducer()`
|
|
binding = await mf.getQueueProducer("QUEUE", "");
|
|
expect(binding).toBeDefined();
|
|
await expect(() => mf.getQueueProducer("QUEUE", "c")).rejects.toThrow(
|
|
new TypeError(
|
|
`No Queue producer binding named "QUEUE" found in "c" worker.`
|
|
)
|
|
);
|
|
|
|
// Check `getR2Bucket()`
|
|
binding = await mf.getR2Bucket("BUCKET", "b");
|
|
expect(binding).toBeDefined();
|
|
await expect(() => mf.getR2Bucket("BUCKET", "c")).rejects.toThrow(
|
|
new TypeError(`No R2 bucket binding named "BUCKET" found in "c" worker.`)
|
|
);
|
|
});
|
|
|
|
test("Miniflare: unsafeEvictDurableObject() resets in-memory state and preserves durable storage", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
name: "do-worker",
|
|
modules: true,
|
|
script: `
|
|
export class Counter {
|
|
constructor(state) {
|
|
this.state = state;
|
|
this.memoryCount = 0;
|
|
}
|
|
|
|
async fetch() {
|
|
this.memoryCount += 1;
|
|
const storageCount = ((await this.state.storage.get("count")) ?? 0) + 1;
|
|
await this.state.storage.put("count", storageCount);
|
|
return Response.json({
|
|
memoryCount: this.memoryCount,
|
|
storageCount,
|
|
});
|
|
}
|
|
}
|
|
|
|
export default {
|
|
fetch(_request, env) {
|
|
const id = env.COUNTER.idFromName("user-123");
|
|
return env.COUNTER.get(id).fetch("http://counter");
|
|
}
|
|
};
|
|
`,
|
|
durableObjects: { COUNTER: "Counter" },
|
|
});
|
|
useDispose(mf);
|
|
|
|
let response = await mf.dispatchFetch("http://localhost");
|
|
expect(await response.json()).toEqual({
|
|
memoryCount: 1,
|
|
storageCount: 1,
|
|
});
|
|
|
|
await mf.unsafeEvictDurableObject("do-worker", "Counter", {
|
|
name: "user-123",
|
|
});
|
|
|
|
response = await mf.dispatchFetch("http://localhost");
|
|
expect(await response.json()).toEqual({
|
|
memoryCount: 1,
|
|
storageCount: 2,
|
|
});
|
|
});
|
|
|
|
test("Miniflare: allows direct access to workers", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
workers: [
|
|
{
|
|
name: "a",
|
|
script: `addEventListener("fetch", (e) => e.respondWith(new Response("a")))`,
|
|
unsafeDirectSockets: [{ port: 0 }],
|
|
},
|
|
{
|
|
routes: ["*/*"],
|
|
script: `addEventListener("fetch", (e) => e.respondWith(new Response("b")))`,
|
|
},
|
|
{
|
|
name: "c",
|
|
script: `addEventListener("fetch", (e) => e.respondWith(new Response("c")))`,
|
|
unsafeDirectSockets: [{ host: "127.0.0.1" }],
|
|
},
|
|
{
|
|
name: "d",
|
|
compatibilityFlags: ["experimental"],
|
|
modules: true,
|
|
script: `
|
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
export class One extends WorkerEntrypoint {
|
|
fetch() { return new Response("d:1"); }
|
|
}
|
|
export const two = {
|
|
fetch() { return new Response("d:2"); }
|
|
};
|
|
export const three = {
|
|
fetch() { return new Response("d:2"); }
|
|
};
|
|
`,
|
|
unsafeDirectSockets: [{ entrypoint: "One" }, { entrypoint: "two" }],
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
// Check can access workers as usual
|
|
let res = await mf.dispatchFetch("http://localhost/");
|
|
expect(await res.text()).toBe("b");
|
|
|
|
// Check can access workers directly
|
|
// (`undefined` worker name should default to entrypoint, not unnamed worker)
|
|
const aURL = await mf.unsafeGetDirectURL();
|
|
const cURL = await mf.unsafeGetDirectURL("c");
|
|
res = await fetch(aURL);
|
|
expect(await res.text()).toBe("a");
|
|
res = await fetch(cURL);
|
|
expect(await res.text()).toBe("c");
|
|
|
|
// Check can access workers directly with different entrypoints
|
|
const d1URL = await mf.unsafeGetDirectURL("d", "One");
|
|
const d2URL = await mf.unsafeGetDirectURL("d", "two");
|
|
res = await fetch(d1URL);
|
|
expect(await res.text()).toBe("d:1");
|
|
res = await fetch(d2URL);
|
|
expect(await res.text()).toBe("d:2");
|
|
|
|
// Can can only access configured for direct access
|
|
await expect(mf.unsafeGetDirectURL("z")).rejects.toThrow(
|
|
new TypeError('"z" worker not found')
|
|
);
|
|
await expect(mf.unsafeGetDirectURL("")).rejects.toThrow(
|
|
new TypeError('Direct access disabled in "" worker for default entrypoint')
|
|
);
|
|
await expect(mf.unsafeGetDirectURL("d", "three")).rejects.toThrow(
|
|
new TypeError('Direct access disabled in "d" worker for "three" entrypoint')
|
|
);
|
|
});
|
|
test("Miniflare: allows RPC between multiple instances", async ({ expect }) => {
|
|
const mf1 = new Miniflare({
|
|
unsafeDirectSockets: [{ entrypoint: "TestEntrypoint" }],
|
|
compatibilityFlags: ["experimental"],
|
|
modules: true,
|
|
script: `
|
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
export class TestEntrypoint extends WorkerEntrypoint {
|
|
ping() { return "pong"; }
|
|
}
|
|
`,
|
|
});
|
|
useDispose(mf1);
|
|
|
|
const testEntrypointUrl = await mf1.unsafeGetDirectURL("", "TestEntrypoint");
|
|
|
|
const mf2 = new Miniflare({
|
|
serviceBindings: {
|
|
SERVICE: { external: { address: testEntrypointUrl.host, http: {} } },
|
|
},
|
|
compatibilityFlags: ["experimental"],
|
|
modules: true,
|
|
script: `
|
|
export default {
|
|
async fetch(request, env, ctx) {
|
|
const result = await env.SERVICE.ping();
|
|
return new Response(result);
|
|
}
|
|
}
|
|
`,
|
|
});
|
|
useDispose(mf2);
|
|
|
|
const res = await mf2.dispatchFetch("http://placeholder");
|
|
expect(await res.text()).toBe("pong");
|
|
});
|
|
|
|
// Only test `MINIFLARE_WORKERD_PATH` on Unix. The test uses a Node.js script
|
|
// with a shebang, directly as the replacement `workerd` binary, which won't
|
|
// work on Windows.
|
|
const isWindows = process.platform === "win32";
|
|
const unixSerialTest = isWindows ? test.skip : test.sequential;
|
|
unixSerialTest(
|
|
"Miniflare: MINIFLARE_WORKERD_PATH overrides workerd path",
|
|
async ({ expect, onTestFinished }) => {
|
|
const workerdPath = path.join(FIXTURES_PATH, "little-workerd.mjs");
|
|
|
|
const original = process.env.MINIFLARE_WORKERD_PATH;
|
|
process.env.MINIFLARE_WORKERD_PATH = workerdPath;
|
|
onTestFinished(() => {
|
|
// Setting key/values pairs on `process.env` coerces values to strings
|
|
if (original === undefined) delete process.env.MINIFLARE_WORKERD_PATH;
|
|
else process.env.MINIFLARE_WORKERD_PATH = original;
|
|
});
|
|
|
|
const mf = new Miniflare({ script: "" });
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe(
|
|
"When I grow up, I want to be a big workerd!"
|
|
);
|
|
}
|
|
);
|
|
|
|
// When workerd exits before sending all listen events on FD3, Miniflare should
|
|
// detect this and throw ERR_RUNTIME_FAILURE instead of hanging indefinitely.
|
|
// See https://github.com/cloudflare/workers-sdk/issues/14077
|
|
unixSerialTest(
|
|
"Miniflare: throws ERR_RUNTIME_FAILURE when workerd exits before all sockets are ready",
|
|
async ({ expect, onTestFinished }) => {
|
|
const workerdPath = path.join(FIXTURES_PATH, "crashing-workerd.mjs");
|
|
|
|
const original = process.env.MINIFLARE_WORKERD_PATH;
|
|
process.env.MINIFLARE_WORKERD_PATH = workerdPath;
|
|
onTestFinished(() => {
|
|
if (original === undefined) {
|
|
delete process.env.MINIFLARE_WORKERD_PATH;
|
|
} else {
|
|
process.env.MINIFLARE_WORKERD_PATH = original;
|
|
}
|
|
});
|
|
|
|
const mf = new Miniflare({ script: "" });
|
|
onTestFinished(() => mf.dispose().catch(() => {}));
|
|
|
|
await expect(mf.ready).rejects.toThrow(MiniflareCoreError);
|
|
await expect(mf.ready).rejects.toThrow(/Workers runtime failed to start/);
|
|
}
|
|
);
|
|
|
|
const TIMEZONE_WORKER = `
|
|
export default {
|
|
fetch() {
|
|
return Response.json({
|
|
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
});
|
|
}
|
|
}
|
|
`;
|
|
|
|
test.sequential("Miniflare: workerd subprocess defaults to TZ=UTC to match production", async ({
|
|
expect,
|
|
}) => {
|
|
// Deliberately set the host TZ to a non-UTC value, otherwise the test
|
|
// would falsely pass: the test-runner's vitest globalSetup already sets
|
|
// `process.env.TZ = "UTC"`, which would propagate to workerd via the
|
|
// inherited `process.env`.
|
|
vi.stubEnv("TZ", "America/Chicago");
|
|
const mf = new Miniflare({ modules: true, script: TIMEZONE_WORKER });
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.json()).toEqual({ tz: "UTC" });
|
|
});
|
|
|
|
// Skipped on Windows because workerd-on-Windows always reports `UTC` from
|
|
// `Intl.DateTimeFormat().resolvedOptions().timeZone` regardless of the `TZ`
|
|
// env var. A diagnostic probe on Windows CI showed that every value tried
|
|
// (`America/Chicago`, `Asia/Tokyo`, `Europe/London`, `Etc/GMT+5`, `Etc/UTC`,
|
|
// `UTC`, `PST8PDT`) all resolved to `UTC`. This is a workerd limitation on
|
|
// Windows; the override mechanism itself (passing the env var through) is
|
|
// platform-independent and exercised on macOS/Linux.
|
|
unixSerialTest(
|
|
"Miniflare: unsafeRuntimeEnv overrides the default workerd subprocess env",
|
|
async ({ expect }) => {
|
|
vi.stubEnv("TZ", "UTC");
|
|
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: TIMEZONE_WORKER,
|
|
unsafeRuntimeEnv: { TZ: "America/Chicago" },
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.json()).toEqual({ tz: "America/Chicago" });
|
|
}
|
|
);
|
|
|
|
test("Miniflare: exits cleanly", async ({ expect }) => {
|
|
const miniflarePath = require.resolve("miniflare");
|
|
const result = childProcess.spawn(
|
|
process.execPath,
|
|
[
|
|
"--no-warnings", // Hide experimental warnings
|
|
"-e",
|
|
`
|
|
const { Miniflare, Log, LogLevel } = require(${JSON.stringify(miniflarePath)});
|
|
const mf = new Miniflare({
|
|
verbose: true,
|
|
modules: true,
|
|
script: \`export default {
|
|
fetch() {
|
|
return new Response("body");
|
|
}
|
|
}\`
|
|
});
|
|
(async () => {
|
|
const res = await mf.dispatchFetch("http://placeholder/");
|
|
const text = await res.text();
|
|
process.send(text);
|
|
process.disconnect();
|
|
})();
|
|
`,
|
|
],
|
|
{
|
|
stdio: [/* in */ "ignore", /* out */ "pipe", /* error */ "pipe", "ipc"],
|
|
}
|
|
);
|
|
|
|
// Make sure workerd started
|
|
const [message] = await once(result, "message");
|
|
expect(message).toBe("body");
|
|
|
|
// Check exit doesn't output anything
|
|
const closePromise = once(result, "close");
|
|
result.kill("SIGINT");
|
|
assert(result.stdout !== null && result.stderr !== null);
|
|
const stdout = await text(result.stdout);
|
|
const stderr = await text(result.stderr);
|
|
await closePromise;
|
|
expect(stdout).toBe("");
|
|
expect(stderr).toBe("");
|
|
});
|
|
|
|
test("Miniflare: supports unsafe eval bindings", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: `export default {
|
|
fetch(req, env, ctx) {
|
|
const three = env.UNSAFE_EVAL.eval("2 + 1");
|
|
const fn = env.UNSAFE_EVAL.newFunction(
|
|
"return \`the computed value is \${n}\`", "", "n"
|
|
);
|
|
return new Response(fn(three));
|
|
}
|
|
}`,
|
|
unsafeEvalBinding: "UNSAFE_EVAL",
|
|
});
|
|
useDispose(mf);
|
|
|
|
const response = await mf.dispatchFetch("http://localhost");
|
|
expect(response.ok).toBe(true);
|
|
expect(await response.text()).toBe("the computed value is 3");
|
|
});
|
|
|
|
test("Miniflare: supports wrapped bindings", async ({ expect }) => {
|
|
const store = new Map<string, string>();
|
|
const mf = new Miniflare({
|
|
workers: [
|
|
{
|
|
wrappedBindings: {
|
|
MINI_KV: {
|
|
scriptName: "mini-kv",
|
|
bindings: { NAMESPACE: "ns" },
|
|
},
|
|
},
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request, env, ctx) {
|
|
await env.MINI_KV.set("key", "value");
|
|
const value = await env.MINI_KV.get("key");
|
|
await env.MINI_KV.delete("key");
|
|
const emptyValue = await env.MINI_KV.get("key");
|
|
await env.MINI_KV.set("key", "another value");
|
|
return Response.json({ value, emptyValue });
|
|
}
|
|
}`,
|
|
},
|
|
{
|
|
name: "mini-kv",
|
|
serviceBindings: {
|
|
async STORE(request) {
|
|
const { pathname } = new URL(request.url);
|
|
const key = pathname.substring(1);
|
|
if (request.method === "GET") {
|
|
const value = store.get(key);
|
|
const status = value === undefined ? 404 : 200;
|
|
return new Response(value ?? null, { status });
|
|
} else if (request.method === "PUT") {
|
|
const value = await request.text();
|
|
store.set(key, value);
|
|
return new Response(null, { status: 204 });
|
|
} else if (request.method === "DELETE") {
|
|
store.delete(key);
|
|
return new Response(null, { status: 204 });
|
|
} else {
|
|
return new Response(null, { status: 405 });
|
|
}
|
|
},
|
|
},
|
|
modules: true,
|
|
script: `
|
|
class MiniKV {
|
|
constructor(env) {
|
|
this.STORE = env.STORE;
|
|
this.baseURL = "http://x/" + (env.NAMESPACE ?? "") + ":";
|
|
}
|
|
async get(key) {
|
|
const res = await this.STORE.fetch(this.baseURL + key);
|
|
return res.status === 404 ? null : await res.text();
|
|
}
|
|
async set(key, body) {
|
|
await this.STORE.fetch(this.baseURL + key, { method: "PUT", body });
|
|
}
|
|
async delete(key) {
|
|
await this.STORE.fetch(this.baseURL + key, { method: "DELETE" });
|
|
}
|
|
}
|
|
|
|
export default function (env) {
|
|
return new MiniKV(env);
|
|
}
|
|
`,
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("http://localhost/");
|
|
expect(await res.json()).toEqual({ value: "value", emptyValue: null });
|
|
expect(store).toEqual(new Map([["ns:key", "another value"]]));
|
|
});
|
|
test("Miniflare: check overrides default bindings with bindings from wrapped binding designator", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
workers: [
|
|
{
|
|
wrappedBindings: {
|
|
WRAPPED: {
|
|
scriptName: "binding",
|
|
entrypoint: "wrapped",
|
|
bindings: { B: "overridden b" },
|
|
},
|
|
},
|
|
modules: true,
|
|
script: `export default {
|
|
fetch(request, env, ctx) {
|
|
return env.WRAPPED();
|
|
}
|
|
}`,
|
|
},
|
|
{
|
|
name: "binding",
|
|
modules: true,
|
|
bindings: { A: "default a", B: "default b" },
|
|
script: `export function wrapped(env) {
|
|
return () => Response.json(env);
|
|
}`,
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("http://localhost/");
|
|
expect(await res.json()).toEqual({ A: "default a", B: "overridden b" });
|
|
});
|
|
test("Miniflare: checks uses compatibility and outbound configuration of binder", async ({
|
|
expect,
|
|
}) => {
|
|
const workers: WorkerOptions[] = [
|
|
{
|
|
compatibilityDate: "2022-03-21", // Default-on date for `global_navigator`
|
|
compatibilityFlags: ["nodejs_compat"],
|
|
wrappedBindings: { WRAPPED: "binding" },
|
|
modules: true,
|
|
script: `export default {
|
|
fetch(request, env, ctx) {
|
|
return env.WRAPPED();
|
|
}
|
|
}`,
|
|
outboundService(request) {
|
|
return new Response(`outbound:${request.url}`);
|
|
},
|
|
},
|
|
{
|
|
name: "binding",
|
|
modules: [
|
|
{
|
|
type: "ESModule",
|
|
path: "index.mjs",
|
|
contents: `export default function () {
|
|
return async () => {
|
|
const typeofNavigator = typeof navigator;
|
|
let importedNode = false;
|
|
try {
|
|
await import("node:util");
|
|
importedNode = true;
|
|
} catch {}
|
|
const outboundRes = await fetch("http://placeholder/");
|
|
const outboundText = await outboundRes.text();
|
|
return Response.json({ typeofNavigator, importedNode, outboundText });
|
|
}
|
|
}`,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
const mf = new Miniflare({ workers });
|
|
useDispose(mf);
|
|
|
|
let res = await mf.dispatchFetch("http://localhost/");
|
|
expect(await res.json()).toEqual({
|
|
typeofNavigator: "object",
|
|
importedNode: true,
|
|
outboundText: "outbound:http://placeholder/",
|
|
});
|
|
|
|
const fetchMock = createFetchMock();
|
|
fetchMock.disableNetConnect();
|
|
fetchMock
|
|
.get("http://placeholder")
|
|
.intercept({ path: "/" })
|
|
.reply(200, "mocked");
|
|
workers[0].compatibilityDate = "2022-03-20";
|
|
workers[0].compatibilityFlags = [];
|
|
workers[0].outboundService = undefined;
|
|
workers[0].fetchMock = fetchMock;
|
|
await mf.setOptions({ workers });
|
|
res = await mf.dispatchFetch("http://localhost/");
|
|
expect(await res.json()).toEqual({
|
|
typeofNavigator: "undefined",
|
|
importedNode: false,
|
|
outboundText: "mocked",
|
|
});
|
|
});
|
|
test("Miniflare: cannot call getWorker() on wrapped binding worker", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
workers: [
|
|
{
|
|
wrappedBindings: { WRAPPED: "binding" },
|
|
modules: true,
|
|
script: `export default {
|
|
fetch(request, env, ctx) {
|
|
return env.WRAPPED;
|
|
}
|
|
}`,
|
|
},
|
|
{
|
|
name: "binding",
|
|
modules: true,
|
|
script: `export default function () {
|
|
return "🎁";
|
|
}`,
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
await expect(mf.getWorker("binding")).rejects.toThrow(
|
|
new TypeError(
|
|
'"binding" is being used as a wrapped binding, and cannot be accessed as a worker'
|
|
)
|
|
);
|
|
});
|
|
test("Miniflare: prohibits invalid wrapped bindings", async ({ expect }) => {
|
|
const mf = new Miniflare({ modules: true, script: "" });
|
|
useDispose(mf);
|
|
|
|
// Check prohibits using entrypoint worker
|
|
await expect(
|
|
mf.setOptions({
|
|
name: "a",
|
|
modules: true,
|
|
script: "",
|
|
wrappedBindings: {
|
|
WRAPPED: { scriptName: "a", entrypoint: "wrapped" },
|
|
},
|
|
})
|
|
).rejects.toThrow(
|
|
new MiniflareCoreError(
|
|
"ERR_INVALID_WRAPPED",
|
|
'Cannot use "a" for wrapped binding because it\'s the entrypoint.\n' +
|
|
'Ensure "a" isn\'t the first entry in the `workers` array.'
|
|
)
|
|
);
|
|
|
|
// Check prohibits using service worker
|
|
await expect(
|
|
mf.setOptions({
|
|
workers: [
|
|
{ modules: true, script: "", wrappedBindings: { WRAPPED: "binding" } },
|
|
{ name: "binding", script: "" },
|
|
],
|
|
})
|
|
).rejects.toThrow(
|
|
new MiniflareCoreError(
|
|
"ERR_INVALID_WRAPPED",
|
|
'Cannot use "binding" for wrapped binding because it\'s a service worker.\n' +
|
|
'Ensure "binding" sets `modules` to `true` or an array of modules'
|
|
)
|
|
);
|
|
|
|
// Check prohibits multiple modules
|
|
await expect(
|
|
mf.setOptions({
|
|
workers: [
|
|
{ modules: true, script: "", wrappedBindings: { WRAPPED: "binding" } },
|
|
{
|
|
name: "binding",
|
|
modules: [
|
|
{ type: "ESModule", path: "index.mjs", contents: "" },
|
|
{ type: "ESModule", path: "dep.mjs", contents: "" },
|
|
],
|
|
},
|
|
],
|
|
})
|
|
).rejects.toThrow(
|
|
new MiniflareCoreError(
|
|
"ERR_INVALID_WRAPPED",
|
|
'Cannot use "binding" for wrapped binding because it isn\'t a single module.\n' +
|
|
'Ensure "binding" doesn\'t include unbundled `import`s.'
|
|
)
|
|
);
|
|
|
|
// Check prohibits non-ES-modules
|
|
await expect(
|
|
mf.setOptions({
|
|
workers: [
|
|
{ modules: true, script: "", wrappedBindings: { WRAPPED: "binding" } },
|
|
{
|
|
name: "binding",
|
|
modules: [{ type: "CommonJS", path: "index.cjs", contents: "" }],
|
|
},
|
|
],
|
|
})
|
|
).rejects.toThrow(
|
|
new MiniflareCoreError(
|
|
"ERR_INVALID_WRAPPED",
|
|
'Cannot use "binding" for wrapped binding because it isn\'t a single ES module'
|
|
)
|
|
);
|
|
|
|
// Check prohibits Durable Object bindings
|
|
await expect(
|
|
mf.setOptions({
|
|
workers: [
|
|
{
|
|
modules: true,
|
|
script: "",
|
|
wrappedBindings: { WRAPPED: "binding" },
|
|
durableObjects: {
|
|
OBJECT: { scriptName: "binding", className: "TestObject" },
|
|
},
|
|
},
|
|
{
|
|
name: "binding",
|
|
modules: [{ type: "ESModule", path: "index.mjs", contents: "" }],
|
|
},
|
|
],
|
|
})
|
|
).rejects.toThrow(
|
|
new MiniflareCoreError(
|
|
"ERR_INVALID_WRAPPED",
|
|
'Cannot use "binding" for wrapped binding because it is bound to with Durable Object bindings.\n' +
|
|
'Ensure other workers don\'t define Durable Object bindings to "binding".'
|
|
)
|
|
);
|
|
|
|
// Check prohibits service bindings
|
|
await expect(
|
|
mf.setOptions({
|
|
workers: [
|
|
{
|
|
modules: true,
|
|
script: "",
|
|
wrappedBindings: {
|
|
WRAPPED: { scriptName: "binding", entrypoint: "wrapped" },
|
|
},
|
|
serviceBindings: { SERVICE: "binding" },
|
|
},
|
|
{
|
|
name: "binding",
|
|
modules: [{ type: "ESModule", path: "index.mjs", contents: "" }],
|
|
},
|
|
],
|
|
})
|
|
).rejects.toThrow(
|
|
new MiniflareCoreError(
|
|
"ERR_INVALID_WRAPPED",
|
|
'Cannot use "binding" for wrapped binding because it is bound to with service bindings.\n' +
|
|
'Ensure other workers don\'t define service bindings to "binding".'
|
|
)
|
|
);
|
|
|
|
// Check prohibits compatibility date and flags
|
|
await expect(
|
|
mf.setOptions({
|
|
workers: [
|
|
{ modules: true, script: "", wrappedBindings: { WRAPPED: "binding" } },
|
|
{
|
|
name: "binding",
|
|
compatibilityDate: "2023-11-01",
|
|
modules: [{ type: "ESModule", path: "index.mjs", contents: "" }],
|
|
},
|
|
],
|
|
})
|
|
).rejects.toThrow(
|
|
new MiniflareCoreError(
|
|
"ERR_INVALID_WRAPPED",
|
|
'Cannot use "binding" for wrapped binding because it defines a compatibility date.\n' +
|
|
"Wrapped bindings use the compatibility date of the worker with the binding."
|
|
)
|
|
);
|
|
await expect(
|
|
mf.setOptions({
|
|
workers: [
|
|
{ modules: true, script: "", wrappedBindings: { WRAPPED: "binding" } },
|
|
{
|
|
name: "binding",
|
|
compatibilityFlags: ["nodejs_compat"],
|
|
modules: [{ type: "ESModule", path: "index.mjs", contents: "" }],
|
|
},
|
|
],
|
|
})
|
|
).rejects.toThrow(
|
|
new MiniflareCoreError(
|
|
"ERR_INVALID_WRAPPED",
|
|
'Cannot use "binding" for wrapped binding because it defines compatibility flags.\n' +
|
|
"Wrapped bindings use the compatibility flags of the worker with the binding."
|
|
)
|
|
);
|
|
|
|
// Check prohibits outbound service
|
|
await expect(
|
|
mf.setOptions({
|
|
workers: [
|
|
{ modules: true, script: "", wrappedBindings: { WRAPPED: "binding" } },
|
|
{
|
|
name: "binding",
|
|
outboundService() {
|
|
assert.fail();
|
|
},
|
|
modules: [{ type: "ESModule", path: "index.mjs", contents: "" }],
|
|
},
|
|
],
|
|
})
|
|
).rejects.toThrow(
|
|
new MiniflareCoreError(
|
|
"ERR_INVALID_WRAPPED",
|
|
'Cannot use "binding" for wrapped binding because it defines an outbound service.\n' +
|
|
"Wrapped bindings use the outbound service of the worker with the binding."
|
|
)
|
|
);
|
|
|
|
// Check prohibits cyclic wrapped bindings
|
|
await expect(
|
|
mf.setOptions({
|
|
workers: [
|
|
{ modules: true, script: "", wrappedBindings: { WRAPPED: "binding" } },
|
|
{
|
|
name: "binding",
|
|
wrappedBindings: { WRAPPED: "binding" }, // Simple cycle
|
|
modules: [{ type: "ESModule", path: "index.mjs", contents: "" }],
|
|
},
|
|
],
|
|
})
|
|
).rejects.toThrow(
|
|
new MiniflareCoreError(
|
|
"ERR_CYCLIC",
|
|
"Generated workerd config contains cycles. Ensure wrapped bindings don't have bindings to themselves."
|
|
)
|
|
);
|
|
await expect(
|
|
mf.setOptions({
|
|
workers: [
|
|
{
|
|
modules: true,
|
|
script: "",
|
|
wrappedBindings: { WRAPPED1: "binding-1" },
|
|
},
|
|
{
|
|
name: "binding-1",
|
|
wrappedBindings: { WRAPPED2: "binding-2" },
|
|
modules: [{ type: "ESModule", path: "index.mjs", contents: "" }],
|
|
},
|
|
{
|
|
name: "binding-2",
|
|
wrappedBindings: { WRAPPED3: "binding-3" },
|
|
modules: [{ type: "ESModule", path: "index.mjs", contents: "" }],
|
|
},
|
|
{
|
|
name: "binding-3",
|
|
wrappedBindings: { WRAPPED1: "binding-1" }, // Multi-step cycle
|
|
modules: [{ type: "ESModule", path: "index.mjs", contents: "" }],
|
|
},
|
|
],
|
|
})
|
|
).rejects.toThrow(
|
|
new MiniflareCoreError(
|
|
"ERR_CYCLIC",
|
|
"Generated workerd config contains cycles. Ensure wrapped bindings don't have bindings to themselves."
|
|
)
|
|
);
|
|
});
|
|
|
|
test("Miniflare: getCf() returns a standard cf object", async ({ expect }) => {
|
|
const mf = new Miniflare({ script: "", modules: true });
|
|
useDispose(mf);
|
|
|
|
const cf = await mf.getCf();
|
|
expect(cf).toMatchObject({
|
|
colo: "DFW",
|
|
city: "Austin",
|
|
regionCode: "TX",
|
|
});
|
|
});
|
|
|
|
test("Miniflare: getCf() returns a user provided cf object", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
script: "",
|
|
modules: true,
|
|
cf: {
|
|
myFakeField: "test",
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
|
|
const cf = await mf.getCf();
|
|
expect(cf).toEqual({ myFakeField: "test" });
|
|
});
|
|
|
|
test("Miniflare: dispatchFetch() can override cf", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
script:
|
|
"export default { fetch(request) { return Response.json(request.cf) } }",
|
|
modules: true,
|
|
cf: {
|
|
myFakeField: "test",
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
|
|
const cf = await mf.dispatchFetch("http://example.com/", {
|
|
cf: { myFakeField: "test2" },
|
|
});
|
|
const cfJson = (await cf.json()) as { myFakeField: string };
|
|
expect(cfJson.myFakeField).toEqual("test2");
|
|
});
|
|
|
|
test("Miniflare: CF-Connecting-IP is injected", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
script:
|
|
"export default { fetch(request) { return new Response(request.headers.get('CF-Connecting-IP')) } }",
|
|
modules: true,
|
|
cf: {
|
|
myFakeField: "test",
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
|
|
const ip = await mf.dispatchFetch("http://example.com/");
|
|
// Tracked in https://github.com/cloudflare/workerd/issues/3310
|
|
if (!isWindows) {
|
|
expect(await ip.text()).toEqual("127.0.0.1");
|
|
} else {
|
|
expect(await ip.text()).toEqual("");
|
|
}
|
|
});
|
|
|
|
test("Miniflare: CF-Connecting-IP is injected (ipv6)", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
script:
|
|
"export default { fetch(request) { return new Response(request.headers.get('CF-Connecting-IP')) } }",
|
|
modules: true,
|
|
cf: {
|
|
myFakeField: "test",
|
|
},
|
|
host: "::1",
|
|
});
|
|
useDispose(mf);
|
|
|
|
const ip = await mf.dispatchFetch("http://example.com/");
|
|
|
|
// Tracked in https://github.com/cloudflare/workerd/issues/3310
|
|
if (!isWindows) {
|
|
expect(await ip.text()).toEqual("::1");
|
|
} else {
|
|
expect(await ip.text()).toEqual("");
|
|
}
|
|
});
|
|
|
|
test("Miniflare: CF-Connecting-IP is preserved when present", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
script:
|
|
"export default { fetch(request) { return new Response(request.headers.get('CF-Connecting-IP')) } }",
|
|
modules: true,
|
|
cf: {
|
|
myFakeField: "test",
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
|
|
const ip = await mf.dispatchFetch("http://example.com/", {
|
|
headers: {
|
|
"CF-Connecting-IP": "128.0.0.1",
|
|
},
|
|
});
|
|
expect(await ip.text()).toEqual("128.0.0.1");
|
|
});
|
|
|
|
// regression test for https://github.com/cloudflare/workers-sdk/issues/7924
|
|
// The "server" service just returns the value of the CF-Connecting-IP header which would normally be added by Miniflare. If you send a request to with no such header, Miniflare will add one.
|
|
// The "client" service makes an outbound request with a fake CF-Connecting-IP header to the "server" service. If the outbound stripping happens then this header will not make it to the "server" service
|
|
// so its response will contain the header added by Miniflare. If the stripping is turned off then the response from the "server" service will contain the fake header.
|
|
test("Miniflare: strips CF-Connecting-IP", async ({ expect }) => {
|
|
const server = new Miniflare({
|
|
script:
|
|
"export default { fetch(request) { return new Response(request.headers.get(`CF-Connecting-IP`)) } }",
|
|
modules: true,
|
|
});
|
|
const serverUrl = await server.ready;
|
|
|
|
const client = new Miniflare({
|
|
script: `export default { fetch(request) { return fetch('${serverUrl.href}', {headers: {"CF-Connecting-IP":"fake-value"}}) } }`,
|
|
modules: true,
|
|
});
|
|
useDispose(client);
|
|
useDispose(server);
|
|
|
|
const landingPage = await client.dispatchFetch("http://example.com/");
|
|
// The CF-Connecting-IP header value of "fake-value" should be stripped by Miniflare, and should be replaced with a generic 127.0.0.1
|
|
expect(await landingPage.text()).not.toEqual("fake-value");
|
|
});
|
|
|
|
test("Miniflare: does not strip CF-Connecting-IP when configured", async ({
|
|
expect,
|
|
}) => {
|
|
const server = new Miniflare({
|
|
script:
|
|
"export default { fetch(request) { return new Response(request.headers.get(`CF-Connecting-IP`)) } }",
|
|
modules: true,
|
|
});
|
|
const serverUrl = await server.ready;
|
|
|
|
const client = new Miniflare({
|
|
script: `export default { fetch(request) { return fetch('${serverUrl.href}', {headers: {"CF-Connecting-IP":"fake-value"}}) } }`,
|
|
modules: true,
|
|
stripCfConnectingIp: false,
|
|
});
|
|
useDispose(client);
|
|
useDispose(server);
|
|
|
|
const landingPage = await client.dispatchFetch("http://example.com/");
|
|
expect(await landingPage.text()).toEqual("fake-value");
|
|
});
|
|
|
|
// Test for https://github.com/cloudflare/workers-sdk/issues/4367
|
|
// The CF-Worker header should be added to outbound fetch requests to match production behavior
|
|
test("Miniflare: adds CF-Worker header to outbound requests with zone option", async ({
|
|
expect,
|
|
}) => {
|
|
const server = new Miniflare({
|
|
script:
|
|
"export default { fetch(request) { return new Response(request.headers.get(`CF-Worker`)) } }",
|
|
modules: true,
|
|
});
|
|
const serverUrl = await server.ready;
|
|
|
|
const client = new Miniflare({
|
|
name: "my-worker",
|
|
zone: "my-zone.example.com",
|
|
script: `export default { fetch(request) { return fetch('${serverUrl.href}') } }`,
|
|
modules: true,
|
|
});
|
|
useDispose(client);
|
|
useDispose(server);
|
|
|
|
const response = await client.dispatchFetch("http://example.com/");
|
|
// The CF-Worker header should be set to the zone value when provided
|
|
expect(await response.text()).toEqual("my-zone.example.com");
|
|
});
|
|
|
|
test("Miniflare: CF-Worker header defaults to worker-name.example.com when zone not set", async ({
|
|
expect,
|
|
}) => {
|
|
const server = new Miniflare({
|
|
script:
|
|
"export default { fetch(request) { return new Response(request.headers.get(`CF-Worker`)) } }",
|
|
modules: true,
|
|
});
|
|
const serverUrl = await server.ready;
|
|
|
|
const client = new Miniflare({
|
|
name: "my-worker",
|
|
// No zone set, should default to `${worker-name}.example.com`
|
|
script: `export default { fetch(request) { return fetch('${serverUrl.href}') } }`,
|
|
modules: true,
|
|
});
|
|
useDispose(client);
|
|
useDispose(server);
|
|
|
|
const response = await client.dispatchFetch("http://example.com/");
|
|
// The CF-Worker header should default to `${worker-name}.example.com` when no zone is specified
|
|
expect(await response.text()).toEqual("my-worker.example.com");
|
|
});
|
|
|
|
test("Miniflare: CF-Worker header defaults to worker.example.com when neither zone nor name set", async ({
|
|
expect,
|
|
}) => {
|
|
const server = new Miniflare({
|
|
script:
|
|
"export default { fetch(request) { return new Response(request.headers.get(`CF-Worker`)) } }",
|
|
modules: true,
|
|
});
|
|
const serverUrl = await server.ready;
|
|
|
|
const client = new Miniflare({
|
|
// No name or zone set, should default to "worker.example.com"
|
|
script: `export default { fetch(request) { return fetch('${serverUrl.href}') } }`,
|
|
modules: true,
|
|
});
|
|
useDispose(client);
|
|
useDispose(server);
|
|
|
|
const response = await client.dispatchFetch("http://example.com/");
|
|
// The CF-Worker header should default to "worker.example.com" when neither zone nor name is specified
|
|
expect(await response.text()).toEqual("worker.example.com");
|
|
});
|
|
|
|
test("Miniflare: can use module fallback service", async ({ expect }) => {
|
|
const modulesRoot = "/";
|
|
const modules: Record<string, Omit<Worker_Module, "name">> = {
|
|
"/virtual/a.mjs": {
|
|
esModule: `
|
|
import { b } from "./dir/b.mjs";
|
|
export default "a" + b;
|
|
`,
|
|
},
|
|
"/virtual/dir/b.mjs": {
|
|
esModule: 'export { default as b } from "./c.cjs";',
|
|
},
|
|
"/virtual/dir/c.cjs": {
|
|
commonJsModule: 'module.exports = "c" + require("./sub/d.cjs");',
|
|
},
|
|
"/virtual/dir/sub/d.cjs": {
|
|
commonJsModule: 'module.exports = "d";',
|
|
},
|
|
};
|
|
|
|
const mf = new Miniflare({
|
|
host: "localhost",
|
|
unsafeModuleFallbackService(request) {
|
|
const resolveMethod = request.headers.get("X-Resolve-Method");
|
|
assert(resolveMethod === "import" || resolveMethod === "require");
|
|
const url = new URL(request.url);
|
|
const specifier = url.searchParams.get("specifier");
|
|
assert(specifier !== null);
|
|
const maybeModule = modules[specifier];
|
|
if (maybeModule === undefined) return new Response(null, { status: 404 });
|
|
const name = path.posix.relative(modulesRoot, specifier);
|
|
return new Response(JSON.stringify({ name, ...maybeModule }));
|
|
},
|
|
workers: [
|
|
{
|
|
name: "a",
|
|
routes: ["*/a"],
|
|
compatibilityFlags: ["export_commonjs_default"],
|
|
modulesRoot,
|
|
modules: [
|
|
{
|
|
type: "ESModule",
|
|
path: "/virtual/index.mjs",
|
|
contents: `
|
|
import a from "./a.mjs";
|
|
export default {
|
|
async fetch() {
|
|
return new Response(a);
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
],
|
|
unsafeUseModuleFallbackService: true,
|
|
},
|
|
{
|
|
name: "b",
|
|
routes: ["*/b"],
|
|
compatibilityFlags: ["export_commonjs_default"],
|
|
modulesRoot,
|
|
modules: [
|
|
{
|
|
type: "ESModule",
|
|
path: "/virtual/index.mjs",
|
|
contents: `
|
|
export default {
|
|
async fetch() {
|
|
try {
|
|
await import("./a.mjs");
|
|
return new Response(null, { status: 204 });
|
|
} catch (e) {
|
|
return new Response(String(e), { status: 500 });
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
let res = await mf.dispatchFetch("http://localhost/a");
|
|
expect(await res.text()).toBe("acd");
|
|
|
|
// Check fallback service ignored if not explicitly enabled
|
|
res = await mf.dispatchFetch("http://localhost/b");
|
|
expect(res.status).toBe(500);
|
|
expect(await res.text()).toBe('Error: No such module "virtual/a.mjs".');
|
|
});
|
|
|
|
test("Miniflare: can use module fallback service with V2 protocol", async ({
|
|
expect,
|
|
}) => {
|
|
// V2 protocol uses file:// URLs instead of paths
|
|
// Module keys are paths (without the /bundle prefix that workerd adds)
|
|
const modules: Record<string, Omit<Worker_Module, "name">> = {
|
|
"/virtual/a.mjs": {
|
|
esModule: `
|
|
import { b } from "./dir/b.mjs";
|
|
export default "a" + b;
|
|
`,
|
|
},
|
|
"/virtual/dir/b.mjs": {
|
|
esModule: 'export { default as b } from "./c.cjs";',
|
|
},
|
|
"/virtual/dir/c.cjs": {
|
|
commonJsModule: 'module.exports = "c" + require("./sub/d.cjs");',
|
|
},
|
|
"/virtual/dir/sub/d.cjs": {
|
|
commonJsModule: 'module.exports = "d";',
|
|
},
|
|
};
|
|
|
|
const mf = new Miniflare({
|
|
async unsafeModuleFallbackService(request) {
|
|
// V2 protocol uses POST with JSON body instead of GET with query params
|
|
assert(request.method === "POST", "V2 protocol should use POST method");
|
|
|
|
const body = await request.json();
|
|
assertIsV2ModuleFallbackProtocol(body);
|
|
|
|
assert(
|
|
body.type === "import" || body.type === "require",
|
|
`Expected type to be "import" or "require", got "${body.type}"`
|
|
);
|
|
assert(
|
|
body.specifier.startsWith("file://"),
|
|
`V2 specifier should be a file:// URL, got "${body.specifier}"`
|
|
);
|
|
|
|
// V2 specifier is a file:// URL like "file:///bundle/virtual/a.mjs"
|
|
// Extract the path and strip the "/bundle" prefix to match our modules map
|
|
const specifierUrl = new URL(body.specifier);
|
|
const modulePath = specifierUrl.pathname.replace(/^\/bundle/, "");
|
|
const maybeModule = modules[modulePath];
|
|
if (maybeModule === undefined) {
|
|
return new Response(null, { status: 404 });
|
|
}
|
|
|
|
// V2 protocol expects name to match the full URL specifier
|
|
return Response.json({ name: body.specifier, ...maybeModule });
|
|
},
|
|
workers: [
|
|
{
|
|
name: "a",
|
|
routes: ["*/a"],
|
|
compatibilityFlags: ["export_commonjs_default", "new_module_registry"],
|
|
modulesRoot: "/",
|
|
modules: [
|
|
{
|
|
type: "ESModule",
|
|
path: "/virtual/index.mjs",
|
|
contents: `
|
|
import a from "./a.mjs";
|
|
export default {
|
|
async fetch() {
|
|
return new Response(a);
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
],
|
|
unsafeUseModuleFallbackService: true,
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("http://localhost/a");
|
|
expect(await res.text()).toBe("acd");
|
|
});
|
|
|
|
test("Miniflare: respects rootPath for path-valued options", async ({
|
|
expect,
|
|
}) => {
|
|
const tmp = await useTmp();
|
|
const aPath = path.join(tmp, "a");
|
|
const bPath = path.join(tmp, "b");
|
|
await fs.mkdir(aPath);
|
|
await fs.mkdir(bPath);
|
|
await fs.writeFile(path.join(aPath, "1.txt"), "one text");
|
|
await fs.writeFile(path.join(aPath, "1.bin"), "one data");
|
|
await fs.writeFile(path.join(aPath, "add.wasm"), ADD_WASM_MODULE);
|
|
await fs.writeFile(path.join(bPath, "2.txt"), "two text");
|
|
await fs.writeFile(path.join(tmp, "3.txt"), "three text");
|
|
const mf = new Miniflare({
|
|
rootPath: tmp,
|
|
kvPersist: "kv",
|
|
workers: [
|
|
{
|
|
name: "a",
|
|
rootPath: "a",
|
|
routes: ["*/a"],
|
|
textBlobBindings: { TEXT: "1.txt" },
|
|
dataBlobBindings: { DATA: "1.bin" },
|
|
wasmBindings: { ADD: "add.wasm" },
|
|
// WASM bindings aren't supported by modules workers
|
|
script: `addEventListener("fetch", (event) => {
|
|
event.respondWith(Response.json({
|
|
text: TEXT,
|
|
data: new TextDecoder().decode(DATA),
|
|
result: new WebAssembly.Instance(ADD).exports.add(1, 2)
|
|
}));
|
|
});`,
|
|
},
|
|
{
|
|
name: "b",
|
|
rootPath: "b",
|
|
routes: ["*/b"],
|
|
textBlobBindings: { TEXT: "2.txt" },
|
|
sitePath: ".",
|
|
script: `addEventListener("fetch", (event) => {
|
|
event.respondWith(Response.json({
|
|
text: TEXT,
|
|
manifest: Object.keys(__STATIC_CONTENT_MANIFEST)
|
|
}));
|
|
});`,
|
|
},
|
|
{
|
|
name: "c",
|
|
routes: ["*/c"],
|
|
textBlobBindings: { TEXT: "3.txt" },
|
|
kvNamespaces: { NAMESPACE: "namespace" },
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request, env, ctx) {
|
|
await env.NAMESPACE.put("key", "value");
|
|
return Response.json({ text: env.TEXT });
|
|
}
|
|
}`,
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
let res = await mf.dispatchFetch("http://localhost/a");
|
|
expect(await res.json()).toEqual({
|
|
text: "one text",
|
|
data: "one data",
|
|
result: 3,
|
|
});
|
|
res = await mf.dispatchFetch("http://localhost/b");
|
|
expect(await res.json()).toEqual({
|
|
text: "two text",
|
|
manifest: ["2.txt"],
|
|
});
|
|
res = await mf.dispatchFetch("http://localhost/c");
|
|
expect(await res.json()).toEqual({
|
|
text: "three text",
|
|
});
|
|
expect(existsSync(path.join(tmp, "kv", "namespace"))).toBe(true);
|
|
|
|
// Check persistence URLs not resolved relative to root path
|
|
await mf.setOptions({
|
|
rootPath: tmp,
|
|
kvPersist: url.pathToFileURL(path.join(tmp, "kv")).href,
|
|
kvNamespaces: { NAMESPACE: "namespace" },
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request, env, ctx) {
|
|
return new Response(await env.NAMESPACE.get("key"));
|
|
}
|
|
}`,
|
|
});
|
|
res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("value");
|
|
|
|
// Check only resolves root path once for single worker options (with relative
|
|
// root path)
|
|
useCwd(tmp);
|
|
await mf.setOptions({
|
|
rootPath: "a",
|
|
textBlobBindings: { TEXT: "1.txt" },
|
|
script:
|
|
'addEventListener("fetch", (event) => event.respondWith(new Response(TEXT)));',
|
|
});
|
|
res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("one text");
|
|
});
|
|
|
|
test("Miniflare: custom Node service binding", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: `
|
|
export default {
|
|
fetch(request, env) {
|
|
return env.CUSTOM.fetch(request, {
|
|
headers: {
|
|
"custom-header": "foo"
|
|
}
|
|
});
|
|
}
|
|
}`,
|
|
serviceBindings: {
|
|
CUSTOM: {
|
|
node: (req, res) => {
|
|
res.end(
|
|
`Response from custom Node service binding. The value of "custom-header" is "${req.headers["custom-header"]}".`
|
|
);
|
|
},
|
|
},
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
|
|
const response = await mf.dispatchFetch("http://localhost");
|
|
const text = await response.text();
|
|
expect(text).toBe(
|
|
`Response from custom Node service binding. The value of "custom-header" is "foo".`
|
|
);
|
|
});
|
|
|
|
test("Miniflare: custom Node outbound service", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: `
|
|
export default {
|
|
fetch(request, env) {
|
|
return fetch(request, {
|
|
headers: {
|
|
"custom-header": "foo"
|
|
}
|
|
});
|
|
}
|
|
}`,
|
|
outboundService: {
|
|
node: (req, res) => {
|
|
res.end(
|
|
`Response from custom Node outbound service. The value of "custom-header" is "foo".`
|
|
);
|
|
},
|
|
},
|
|
});
|
|
useDispose(mf);
|
|
|
|
const response = await mf.dispatchFetch("http://localhost");
|
|
const text = await response.text();
|
|
expect(text).toBe(
|
|
`Response from custom Node outbound service. The value of "custom-header" is "foo".`
|
|
);
|
|
});
|
|
|
|
test("Miniflare: setOptions: can restart workerd multiple times in succession", async ({
|
|
expect,
|
|
}) => {
|
|
// Regression test for https://github.com/cloudflare/workers-sdk/issues/11675
|
|
// EBADF errors can occur when spawning a new workerd process if the stdio
|
|
// pipes from the previous process are not properly cleaned up. This is
|
|
// especially relevant when other parts of the system (like worker_threads
|
|
// used by Miniflare or other Vite plugins) are also managing file descriptors.
|
|
// While this test doesn't reliably reproduce the race condition, it validates
|
|
// that the restart mechanism works correctly after the fix.
|
|
const mf = new Miniflare({
|
|
port: 0,
|
|
modules: true,
|
|
script: `export default {
|
|
fetch() {
|
|
return new Response("version 1");
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
|
|
// First request to ensure initial startup is complete
|
|
let res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe("version 1");
|
|
|
|
// Perform multiple rapid setOptions calls to trigger workerd restarts.
|
|
// This tests that the stdio pipe cleanup in dispose() works correctly.
|
|
for (let i = 2; i <= 5; i++) {
|
|
await mf.setOptions({
|
|
port: 0,
|
|
modules: true,
|
|
script: `export default {
|
|
fetch() {
|
|
return new Response("version ${i}");
|
|
}
|
|
}`,
|
|
});
|
|
res = await mf.dispatchFetch("http://localhost");
|
|
expect(await res.text()).toBe(`version ${i}`);
|
|
}
|
|
});
|
|
|
|
test("Miniflare: MINIFLARE_WORKERD_CONFIG_DEBUG controls workerd config file creation", async ({
|
|
expect,
|
|
onTestFinished,
|
|
}) => {
|
|
const originalEnv = process.env.MINIFLARE_WORKERD_CONFIG_DEBUG;
|
|
const configFilePath = "workerd-config.json";
|
|
|
|
// Clean up any existing config file
|
|
if (existsSync(configFilePath)) {
|
|
await fs.unlink(configFilePath);
|
|
}
|
|
|
|
onTestFinished(async () => {
|
|
if (originalEnv === undefined) {
|
|
delete process.env.MINIFLARE_WORKERD_CONFIG_DEBUG;
|
|
} else {
|
|
process.env.MINIFLARE_WORKERD_CONFIG_DEBUG = originalEnv;
|
|
}
|
|
if (existsSync(configFilePath)) {
|
|
await fs.unlink(configFilePath);
|
|
}
|
|
});
|
|
|
|
// ensure the config file is not created without the flag
|
|
delete process.env.MINIFLARE_WORKERD_CONFIG_DEBUG;
|
|
let mf = new Miniflare({
|
|
modules: true,
|
|
script: `export default {
|
|
fetch() {
|
|
return new Response("Hello World");
|
|
}
|
|
}`,
|
|
});
|
|
// Trigger workerd config serialization by dispatching a request
|
|
let response = await mf.dispatchFetch("http://localhost");
|
|
// seems like miniflare doesn't like it if you don't read the response
|
|
await response.text();
|
|
// config file should not be created when MINIFLARE_WORKERD_CONFIG_DEBUG is not set
|
|
expect(existsSync(configFilePath)).toBe(false);
|
|
await mf.dispose();
|
|
|
|
// ensure the config file is created with the flag
|
|
process.env.MINIFLARE_WORKERD_CONFIG_DEBUG = configFilePath;
|
|
mf = new Miniflare({
|
|
modules: true,
|
|
script: `export default {
|
|
fetch() {
|
|
return new Response("Hello World");
|
|
}
|
|
}`,
|
|
});
|
|
response = await mf.dispatchFetch("http://localhost");
|
|
await response.text();
|
|
// workerd-config.json should be created when MINIFLARE_WORKERD_CONFIG_DEBUG=true
|
|
expect(existsSync(configFilePath)).toBe(true);
|
|
await mf.dispose();
|
|
});
|
|
|
|
// https://github.com/cloudflare/workers-sdk/issues/13013
|
|
test("Miniflare: dispatchFetch handles POST/PUT with non-2xx status", async ({
|
|
expect,
|
|
}) => {
|
|
const mf = new Miniflare({
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request) {
|
|
const url = new URL(request.url);
|
|
const status = parseInt(url.searchParams.get("status") ?? "200");
|
|
return Response.json(
|
|
{ method: request.method, status },
|
|
{ status }
|
|
);
|
|
}
|
|
}`,
|
|
});
|
|
useDispose(mf);
|
|
|
|
// GET + non-2xx (worked before the fix too)
|
|
for (const status of [400, 401, 403, 404, 500]) {
|
|
const res = await mf.dispatchFetch(`http://localhost/?status=${status}`);
|
|
expect(res.status).toBe(status);
|
|
expect(await res.json()).toEqual({ method: "GET", status });
|
|
}
|
|
|
|
// POST + 2xx
|
|
for (const status of [200, 201]) {
|
|
const res = await mf.dispatchFetch(`http://localhost/?status=${status}`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ data: "test" }),
|
|
});
|
|
expect(res.status).toBe(status);
|
|
expect(await res.json()).toEqual({ method: "POST", status });
|
|
}
|
|
|
|
// POST + non-2xx (previously threw "fetch failed" due to undici bug)
|
|
for (const status of [400, 401, 403, 404, 500]) {
|
|
const res = await mf.dispatchFetch(`http://localhost/?status=${status}`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ data: "test" }),
|
|
});
|
|
expect(res.status).toBe(status);
|
|
expect(await res.json()).toEqual({ method: "POST", status });
|
|
}
|
|
|
|
// PUT + non-2xx (also affected by the same bug)
|
|
for (const status of [400, 401, 403, 404, 500]) {
|
|
const res = await mf.dispatchFetch(`http://localhost/?status=${status}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ data: "test" }),
|
|
});
|
|
expect(res.status).toBe(status);
|
|
expect(await res.json()).toEqual({ method: "PUT", status });
|
|
}
|
|
});
|