mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
e9063411b8
On Cloudflare Workers the isolate can terminate after the Response is sent, killing in-flight error reports (e.g. Sentry HTTP requests). Fix by having reportRequestError() self-register with ctx.waitUntil() via getRequestExecutionContext(). This is the same pattern used by KVCacheHandler, ISR regeneration, and fetch cache revalidation. On Node.js (dev or vinext start) the context is null so it's a no-op — fire-and-forget is fine because the process stays alive. Since reportRequestError never rejects (internal try/catch), the redundant .catch() at all 5 call sites is removed.
212 lines
7.3 KiB
TypeScript
212 lines
7.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { findInstrumentationFile } from "../packages/vinext/src/server/instrumentation.js";
|
|
|
|
// The runInstrumentation/reportRequestError describe blocks re-import via
|
|
// vi.resetModules() to get fresh module-level state (_onRequestError).
|
|
// findInstrumentationFile is a pure function — no reset needed.
|
|
|
|
describe("findInstrumentationFile", () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-instr-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("returns the path when a file exists at root", () => {
|
|
fs.writeFileSync(path.join(tmpDir, "instrumentation.ts"), "");
|
|
|
|
const result = findInstrumentationFile(tmpDir);
|
|
|
|
expect(result).toBe(path.join(tmpDir, "instrumentation.ts"));
|
|
});
|
|
|
|
it("prefers root over src/ directory (priority order)", () => {
|
|
// Create both root and src/ variants
|
|
fs.writeFileSync(path.join(tmpDir, "instrumentation.ts"), "");
|
|
fs.mkdirSync(path.join(tmpDir, "src"));
|
|
fs.writeFileSync(path.join(tmpDir, "src", "instrumentation.ts"), "");
|
|
|
|
const result = findInstrumentationFile(tmpDir);
|
|
|
|
// Root files come first in INSTRUMENTATION_FILES, so root wins
|
|
expect(result).toBe(path.join(tmpDir, "instrumentation.ts"));
|
|
});
|
|
|
|
it("falls back to src/ directory", () => {
|
|
fs.mkdirSync(path.join(tmpDir, "src"));
|
|
fs.writeFileSync(path.join(tmpDir, "src", "instrumentation.ts"), "");
|
|
|
|
const result = findInstrumentationFile(tmpDir);
|
|
|
|
expect(result).toBe(path.join(tmpDir, "src", "instrumentation.ts"));
|
|
});
|
|
|
|
it("returns null when no instrumentation file exists", () => {
|
|
const result = findInstrumentationFile(tmpDir);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("runInstrumentation", () => {
|
|
let runInstrumentation: typeof import("../packages/vinext/src/server/instrumentation.js").runInstrumentation;
|
|
let getOnRequestErrorHandler: typeof import("../packages/vinext/src/server/instrumentation.js").getOnRequestErrorHandler;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
const mod = await import("../packages/vinext/src/server/instrumentation.js");
|
|
runInstrumentation = mod.runInstrumentation;
|
|
getOnRequestErrorHandler = mod.getOnRequestErrorHandler;
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete globalThis.__VINEXT_onRequestErrorHandler__;
|
|
});
|
|
|
|
it("calls register() when exported", async () => {
|
|
const register = vi.fn();
|
|
const runner = {
|
|
import: vi.fn().mockResolvedValue({ register }),
|
|
};
|
|
|
|
await runInstrumentation(runner, "/fake/instrumentation.ts");
|
|
|
|
expect(register).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("stores onRequestError handler for later retrieval", async () => {
|
|
const onRequestError = vi.fn();
|
|
const runner = {
|
|
import: vi.fn().mockResolvedValue({ onRequestError }),
|
|
};
|
|
|
|
await runInstrumentation(runner, "/fake/instrumentation.ts");
|
|
|
|
expect(getOnRequestErrorHandler()).toBe(onRequestError);
|
|
});
|
|
|
|
it("handles modules with no register or onRequestError gracefully", async () => {
|
|
const runner = {
|
|
import: vi.fn().mockResolvedValue({}),
|
|
};
|
|
|
|
// Should not throw
|
|
await runInstrumentation(runner, "/fake/instrumentation.ts");
|
|
|
|
expect(getOnRequestErrorHandler()).toBeNull();
|
|
});
|
|
|
|
it("logs error and continues when import fails", async () => {
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const runner = {
|
|
import: vi.fn().mockRejectedValue(new Error("Module not found")),
|
|
};
|
|
|
|
// Should not throw
|
|
await runInstrumentation(runner, "/fake/instrumentation.ts");
|
|
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
"[vinext] Failed to load instrumentation:",
|
|
"Module not found",
|
|
);
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("reportRequestError", () => {
|
|
let runInstrumentation: typeof import("../packages/vinext/src/server/instrumentation.js").runInstrumentation;
|
|
let reportRequestError: typeof import("../packages/vinext/src/server/instrumentation.js").reportRequestError;
|
|
let runWithExecutionContext: typeof import("../packages/vinext/src/shims/request-context.js").runWithExecutionContext;
|
|
|
|
const sampleRequest = { path: "/test", method: "GET", headers: {} };
|
|
const sampleContext = {
|
|
routerKind: "App Router" as const,
|
|
routePath: "/test",
|
|
routeType: "render" as const,
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
const mod = await import("../packages/vinext/src/server/instrumentation.js");
|
|
runInstrumentation = mod.runInstrumentation;
|
|
reportRequestError = mod.reportRequestError;
|
|
const ctxMod = await import("../packages/vinext/src/shims/request-context.js");
|
|
runWithExecutionContext = ctxMod.runWithExecutionContext;
|
|
});
|
|
|
|
it("calls the registered handler with correct args", async () => {
|
|
const onRequestError = vi.fn();
|
|
const runner = {
|
|
import: vi.fn().mockResolvedValue({ onRequestError }),
|
|
};
|
|
await runInstrumentation(runner, "/fake/instrumentation.ts");
|
|
|
|
const error = new Error("boom");
|
|
await reportRequestError(error, sampleRequest, sampleContext);
|
|
|
|
expect(onRequestError).toHaveBeenCalledWith(error, sampleRequest, sampleContext);
|
|
});
|
|
|
|
it("no-ops when no handler is registered", async () => {
|
|
// No runInstrumentation called, so _onRequestError is null.
|
|
// Should not throw.
|
|
await reportRequestError(new Error("boom"), sampleRequest, sampleContext);
|
|
});
|
|
|
|
it("catches and logs errors thrown by the handler", async () => {
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const onRequestError = vi.fn().mockRejectedValue(new Error("handler broke"));
|
|
const runner = {
|
|
import: vi.fn().mockResolvedValue({ onRequestError }),
|
|
};
|
|
await runInstrumentation(runner, "/fake/instrumentation.ts");
|
|
|
|
await reportRequestError(new Error("boom"), sampleRequest, sampleContext);
|
|
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
"[vinext] onRequestError handler threw:",
|
|
"handler broke",
|
|
);
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it("registers the report promise with ctx.waitUntil on Workers", async () => {
|
|
const onRequestError = vi.fn().mockResolvedValue(undefined);
|
|
const runner = {
|
|
import: vi.fn().mockResolvedValue({ onRequestError }),
|
|
};
|
|
await runInstrumentation(runner, "/fake/instrumentation.ts");
|
|
|
|
const waitUntil = vi.fn();
|
|
const ctx = { waitUntil };
|
|
|
|
await runWithExecutionContext(ctx, () =>
|
|
reportRequestError(new Error("boom"), sampleRequest, sampleContext),
|
|
);
|
|
|
|
expect(waitUntil).toHaveBeenCalledOnce();
|
|
// The promise passed to waitUntil should resolve (reportRequestError never rejects)
|
|
await expect(waitUntil.mock.calls[0][0]).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("does not call waitUntil when no execution context is available", async () => {
|
|
const onRequestError = vi.fn().mockResolvedValue(undefined);
|
|
const runner = {
|
|
import: vi.fn().mockResolvedValue({ onRequestError }),
|
|
};
|
|
await runInstrumentation(runner, "/fake/instrumentation.ts");
|
|
|
|
// Call outside runWithExecutionContext — should not throw
|
|
await reportRequestError(new Error("boom"), sampleRequest, sampleContext);
|
|
|
|
expect(onRequestError).toHaveBeenCalledOnce();
|
|
});
|
|
});
|