chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { afterEach, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
clearBifrostFailure,
|
||||
getActiveBifrostCooldown,
|
||||
getBifrostFailureCooldownMs,
|
||||
recordBifrostFailure,
|
||||
resetBifrostCooldowns,
|
||||
} from "../../../../src/app/api/v1/relay/chat/completions/bifrostCooldown.ts";
|
||||
|
||||
afterEach(() => {
|
||||
resetBifrostCooldowns();
|
||||
});
|
||||
|
||||
test("bifrost cooldown defaults to a short retry suppression window", () => {
|
||||
assert.equal(getBifrostFailureCooldownMs({}), 5000);
|
||||
assert.equal(
|
||||
getBifrostFailureCooldownMs({ OMNIROUTE_BIFROST_FAILURE_COOLDOWN_MS: "250" }),
|
||||
250
|
||||
);
|
||||
assert.equal(
|
||||
getBifrostFailureCooldownMs({ OMNIROUTE_BIFROST_FAILURE_COOLDOWN_MS: "bad" }),
|
||||
5000
|
||||
);
|
||||
});
|
||||
|
||||
test("bifrost cooldown reports remaining time and expires", () => {
|
||||
recordBifrostFailure("http://bifrost.local", "timeout", 1000, 500);
|
||||
|
||||
assert.deepEqual(getActiveBifrostCooldown("http://bifrost.local", 1100), {
|
||||
remainingMs: 400,
|
||||
reason: "timeout",
|
||||
});
|
||||
assert.equal(getActiveBifrostCooldown("http://bifrost.local", 1501), null);
|
||||
});
|
||||
|
||||
test("bifrost cooldown can be disabled or cleared", () => {
|
||||
recordBifrostFailure("http://bifrost.local", "timeout", 1000, 0);
|
||||
assert.equal(getActiveBifrostCooldown("http://bifrost.local", 1001), null);
|
||||
|
||||
recordBifrostFailure("http://bifrost.local", "timeout", 1000, 500);
|
||||
clearBifrostFailure("http://bifrost.local");
|
||||
|
||||
assert.equal(getActiveBifrostCooldown("http://bifrost.local", 1001), null);
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
checkIpRateLimit,
|
||||
getClientIp,
|
||||
sanitizeForensicHeader,
|
||||
} from "../../../../src/app/api/v1/relay/chat/completions/relaySecurity.ts";
|
||||
import { getDbInstance } from "../../../../src/lib/db/core.ts";
|
||||
import { getRelayLogs } from "../../../../src/lib/db/relayProxies.ts";
|
||||
|
||||
// ─── T-12 (#3932 PR-4): bifrost sidecar proxy route ──────────────────────
|
||||
//
|
||||
// We test the *contract* of the route by setting env before import and
|
||||
// calling the exported POST handler. The handler is module-scope configured
|
||||
// (BIFROST_BASE_URL is read at import time), so env must be set BEFORE the
|
||||
// dynamic import below.
|
||||
|
||||
const ORIGINAL_BIFROST_BASE_URL = process.env.BIFROST_BASE_URL;
|
||||
const ORIGINAL_BIFROST_API_KEY = process.env.BIFROST_API_KEY;
|
||||
const ORIGINAL_BIFROST_OMNI_KEY = process.env.OMNIROUTE_BIFROST_KEY;
|
||||
const ORIGINAL_BIFROST_TIMEOUT = process.env.BIFROST_TIMEOUT_MS;
|
||||
const ORIGINAL_BIFROST_STREAMING = process.env.BIFROST_STREAMING_ENABLED;
|
||||
const ORIGINAL_FETCH = globalThis.fetch;
|
||||
|
||||
function seedRelayToken(rawToken: string) {
|
||||
const id = `rl_test_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
getDbInstance()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO relay_tokens (id, name, token_hash, token_prefix, description, combo_id,
|
||||
allowed_models, max_tokens_per_request, max_requests_per_minute, max_requests_per_day,
|
||||
max_cost_per_day, enabled, created_at, updated_at, expires_at, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
|
||||
`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
"bifrost-sse-lifecycle",
|
||||
createHash("sha256").update(rawToken).digest("hex"),
|
||||
"rl_test",
|
||||
"",
|
||||
null,
|
||||
JSON.stringify(["*"]),
|
||||
128000,
|
||||
60,
|
||||
10000,
|
||||
0,
|
||||
now,
|
||||
now,
|
||||
null,
|
||||
"{}"
|
||||
);
|
||||
return { id, rawToken };
|
||||
}
|
||||
|
||||
function restoreEnv() {
|
||||
if (ORIGINAL_BIFROST_BASE_URL === undefined) delete process.env.BIFROST_BASE_URL;
|
||||
else process.env.BIFROST_BASE_URL = ORIGINAL_BIFROST_BASE_URL;
|
||||
if (ORIGINAL_BIFROST_API_KEY === undefined) delete process.env.BIFROST_API_KEY;
|
||||
else process.env.BIFROST_API_KEY = ORIGINAL_BIFROST_API_KEY;
|
||||
if (ORIGINAL_BIFROST_OMNI_KEY === undefined) delete process.env.OMNIROUTE_BIFROST_KEY;
|
||||
else process.env.OMNIROUTE_BIFROST_KEY = ORIGINAL_BIFROST_OMNI_KEY;
|
||||
if (ORIGINAL_BIFROST_TIMEOUT === undefined) delete process.env.BIFROST_TIMEOUT_MS;
|
||||
else process.env.BIFROST_TIMEOUT_MS = ORIGINAL_BIFROST_TIMEOUT;
|
||||
if (ORIGINAL_BIFROST_STREAMING === undefined) delete process.env.BIFROST_STREAMING_ENABLED;
|
||||
else process.env.BIFROST_STREAMING_ENABLED = ORIGINAL_BIFROST_STREAMING;
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
}
|
||||
|
||||
// Case 1: BIFROST_BASE_URL unset. We test this first because the route's
|
||||
// module-scope `BIFROST_BASE_URL` would be empty for the entire test file.
|
||||
test("bifrost route: returns 503 + fallback header when BIFROST_BASE_URL is unset", async () => {
|
||||
delete process.env.BIFROST_BASE_URL;
|
||||
delete process.env.BIFROST_API_KEY;
|
||||
delete process.env.OMNIROUTE_BIFROST_KEY;
|
||||
delete process.env.BIFROST_TIMEOUT_MS;
|
||||
delete process.env.BIFROST_STREAMING_ENABLED;
|
||||
|
||||
// Dynamic import after env is set so the module reads the empty value.
|
||||
const { POST } = await import(
|
||||
"../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts"
|
||||
);
|
||||
|
||||
const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: "gpt-4", messages: [] }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
assert.equal(res.status, 503);
|
||||
assert.equal(res.headers.get("X-Bifrost-Fallback"), "/api/v1/relay/chat/completions");
|
||||
const body = await res.json();
|
||||
assert.match(String(body?.error?.message ?? ""), /Bifrost sidecar not configured/);
|
||||
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
// Case 2: BIFROST_BASE_URL set but no auth token in request. The route should
|
||||
// return 401 *before* trying to reach the gateway, so we don't need a mock fetch.
|
||||
test("bifrost route: returns 401 when BIFROST_BASE_URL is set but no auth token is provided", async () => {
|
||||
process.env.BIFROST_BASE_URL = "http://bifrost.test.local:8080";
|
||||
delete process.env.BIFROST_API_KEY;
|
||||
delete process.env.OMNIROUTE_BIFROST_KEY;
|
||||
delete process.env.BIFROST_TIMEOUT_MS;
|
||||
delete process.env.BIFROST_STREAMING_ENABLED;
|
||||
|
||||
// Use a fresh module instance by appending a cache-busting query string.
|
||||
// (Node's ESM cache is keyed by resolved URL, so a unique query bypasses it.)
|
||||
const { POST } = await import(
|
||||
`../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}`
|
||||
);
|
||||
|
||||
const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: "hi" }] }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
assert.equal(res.status, 401);
|
||||
const body = await res.json();
|
||||
assert.match(String(body?.error?.message ?? ""), /Missing relay token/);
|
||||
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
// Case 3: hashToken() is used internally. Verify the SHA-256 output shape so
|
||||
// downstream code that compares hashes doesn't silently break if the impl
|
||||
// changes. (This is a contract test, not a black-box test of the function.)
|
||||
test("bifrost route: relay token hashing matches the SHA-256 hex contract", () => {
|
||||
const token = "test-token-abc-123";
|
||||
const hash = createHash("sha256").update(token).digest("hex");
|
||||
assert.equal(hash.length, 64); // SHA-256 hex = 64 chars
|
||||
assert.match(hash, /^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
// Case 4: Validate the CORS preflight handler exists and responds with 204/200
|
||||
test("bifrost route: OPTIONS responds with CORS headers", async () => {
|
||||
const { OPTIONS } = await import(
|
||||
`../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}`
|
||||
);
|
||||
const res = await OPTIONS();
|
||||
// handleCorsOptions() returns 204 No Content with the standard CORS
|
||||
// methods/headers. Access-Control-Allow-Origin is intentionally NOT set on
|
||||
// the route's response — src/middleware.ts (applyCorsHeaders) is the single
|
||||
// source of truth for which origin to echo, based on the allowlist in
|
||||
// src/server/cors/origins.ts. We assert the route ships its end of the
|
||||
// contract: status + methods/headers. Origin overlay is exercised by the
|
||||
// middleware tests.
|
||||
assert.ok(res.status === 200 || res.status === 204, `expected 200/204, got ${res.status}`);
|
||||
assert.ok(
|
||||
res.headers.get("Access-Control-Allow-Methods"),
|
||||
"missing Access-Control-Allow-Methods header"
|
||||
);
|
||||
assert.ok(
|
||||
res.headers.get("Access-Control-Allow-Headers"),
|
||||
"missing Access-Control-Allow-Headers header"
|
||||
);
|
||||
});
|
||||
|
||||
test("bifrost route: shared relay IP limiter blocks a repeated token from one IP", () => {
|
||||
const tokenId = `bifrost-ip-limit-${Date.now()}-${Math.random()}`;
|
||||
const ip = "203.0.113.77";
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
assert.equal(checkIpRateLimit(tokenId, ip).allowed, true);
|
||||
}
|
||||
|
||||
const blocked = checkIpRateLimit(tokenId, ip);
|
||||
assert.equal(blocked.allowed, false);
|
||||
assert.ok(blocked.resetIn >= 0 && blocked.resetIn <= 60);
|
||||
});
|
||||
|
||||
test("bifrost route: shared relay security helpers sanitize forwarded client metadata", () => {
|
||||
const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", {
|
||||
headers: {
|
||||
"x-forwarded-for": "198.51.100.8, 10.0.0.2",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(getClientIp(req), "198.51.100.8");
|
||||
assert.equal(sanitizeForensicHeader("ua\r\nforged"), "ua forged");
|
||||
});
|
||||
|
||||
test("bifrost route: records relay usage after SSE stream completion", async () => {
|
||||
process.env.BIFROST_BASE_URL = "http://bifrost.test.local:8080";
|
||||
process.env.BIFROST_TIMEOUT_MS = "5000";
|
||||
delete process.env.BIFROST_API_KEY;
|
||||
delete process.env.OMNIROUTE_BIFROST_KEY;
|
||||
delete process.env.BIFROST_STREAMING_ENABLED;
|
||||
|
||||
const relayToken = seedRelayToken(`relay_bifrost_sse_${Date.now()}`);
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("data: {\"delta\":\"hi\"}\n\n"));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}
|
||||
);
|
||||
|
||||
const { POST } = await import(
|
||||
`../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}`
|
||||
);
|
||||
|
||||
const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${relayToken.rawToken}`,
|
||||
"content-type": "application/json",
|
||||
"x-request-id": "bifrost-sse-lifecycle-test",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "gpt-4",
|
||||
stream: true,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("X-Routed-By"), "bifrost");
|
||||
assert.equal(getRelayLogs(relayToken.id, 10).length, 0);
|
||||
|
||||
assert.match(await res.text(), /delta/);
|
||||
|
||||
const logs = getRelayLogs(relayToken.id, 10);
|
||||
assert.equal(logs.length, 1);
|
||||
assert.equal(logs[0].status, "success");
|
||||
assert.equal(logs[0].status_code, 200);
|
||||
assert.equal(logs[0].request_id, "bifrost-sse-lifecycle-test");
|
||||
|
||||
restoreEnv();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
GET,
|
||||
OPTIONS,
|
||||
} from "../../../../src/app/api/v1/provider-plugin-manifest/route.ts";
|
||||
|
||||
test("provider plugin manifest route returns JSON-safe manifest", async () => {
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("Content-Type"), "application/json");
|
||||
assert.equal(body.schemaVersion, 1);
|
||||
assert.equal(body.generatedFrom, "open-sse/config/providers");
|
||||
assert.ok(body.providers.length > 100);
|
||||
assert.ok(body.providers.some((provider: { id: string }) => provider.id === "openai"));
|
||||
|
||||
const serialized = JSON.stringify(body);
|
||||
assert.equal(serialized.includes("clientSecret"), false);
|
||||
});
|
||||
|
||||
test("provider plugin manifest route handles CORS preflight", async () => {
|
||||
const response = await OPTIONS();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Methods"), "GET, OPTIONS");
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Headers"), "*");
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
getBifrostRoutingConfig,
|
||||
getRoutingFallbackHeader,
|
||||
resolveRelayRoutingBackend,
|
||||
shouldTryBifrost,
|
||||
shouldTryBifrostForRequest,
|
||||
} from "../../../../src/app/api/v1/relay/chat/completions/routingBackend.ts";
|
||||
|
||||
test("relay routing backend defaults to TypeScript without bifrost", () => {
|
||||
const env = {};
|
||||
|
||||
assert.equal(resolveRelayRoutingBackend(env), "ts");
|
||||
assert.equal(getBifrostRoutingConfig(env), null);
|
||||
});
|
||||
|
||||
test("relay routing backend auto-enables bifrost when base URL is configured", () => {
|
||||
const env = {
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080/",
|
||||
OMNIROUTE_BIFROST_KEY: "sidecar-key",
|
||||
BIFROST_TIMEOUT_MS: "250",
|
||||
};
|
||||
|
||||
const config = getBifrostRoutingConfig(env);
|
||||
|
||||
assert.equal(resolveRelayRoutingBackend(env), "auto");
|
||||
assert.equal(config?.baseUrl, "http://127.0.0.1:8080");
|
||||
assert.equal(config?.apiKey, "sidecar-key");
|
||||
assert.equal(config?.timeoutMs, 250);
|
||||
assert.equal(config?.streamingEnabled, true);
|
||||
assert.equal(config?.enabled, true);
|
||||
assert.equal(shouldTryBifrost("auto", config), true);
|
||||
});
|
||||
|
||||
test("relay routing backend honors explicit TS and strict bifrost modes", () => {
|
||||
const env = {
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
OMNIROUTE_RELAY_BACKEND: "ts",
|
||||
};
|
||||
const config = getBifrostRoutingConfig(env);
|
||||
|
||||
assert.equal(resolveRelayRoutingBackend(env), "ts");
|
||||
assert.equal(shouldTryBifrost("ts", config), false);
|
||||
|
||||
env.OMNIROUTE_RELAY_BACKEND = "bifrost";
|
||||
assert.equal(resolveRelayRoutingBackend(env), "bifrost");
|
||||
assert.equal(shouldTryBifrost("bifrost", config), true);
|
||||
});
|
||||
|
||||
test("relay routing backend respects bifrost killswitch", () => {
|
||||
const env = {
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
BIFROST_ENABLED: "0",
|
||||
};
|
||||
const config = getBifrostRoutingConfig(env);
|
||||
|
||||
assert.equal(resolveRelayRoutingBackend(env), "ts");
|
||||
assert.equal(config?.enabled, false);
|
||||
assert.equal(shouldTryBifrost("auto", config), false);
|
||||
});
|
||||
|
||||
test("relay routing backend falls back on invalid timeout values", () => {
|
||||
const env = {
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
BIFROST_TIMEOUT_MS: "not-a-number",
|
||||
};
|
||||
|
||||
assert.equal(getBifrostRoutingConfig(env)?.timeoutMs, 30000);
|
||||
});
|
||||
|
||||
test("relay routing backend exposes TS fallback header only for enabled auto bifrost fallback", () => {
|
||||
const config = getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
});
|
||||
|
||||
assert.equal(getRoutingFallbackHeader("auto", config), "bifrost");
|
||||
assert.equal(getRoutingFallbackHeader("ts", config), undefined);
|
||||
assert.equal(getRoutingFallbackHeader("bifrost", config), undefined);
|
||||
assert.equal(
|
||||
getRoutingFallbackHeader(
|
||||
"auto",
|
||||
getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
BIFROST_ENABLED: "0",
|
||||
})
|
||||
),
|
||||
undefined
|
||||
);
|
||||
assert.equal(getRoutingFallbackHeader("auto", null), undefined);
|
||||
});
|
||||
|
||||
test("relay routing backend keeps strict bifrost failures out of auto fallback accounting", () => {
|
||||
const config = getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
});
|
||||
|
||||
assert.equal(shouldTryBifrost("bifrost", config), true);
|
||||
assert.equal(getRoutingFallbackHeader("bifrost", config), undefined);
|
||||
assert.equal(resolveRelayRoutingBackend({ OMNIROUTE_RELAY_BACKEND: "bifrost" }), "bifrost");
|
||||
});
|
||||
|
||||
test("relay routing backend auto mode tries bifrost for manifest-eligible providers", () => {
|
||||
const config = getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
shouldTryBifrostForRequest("auto", config, { model: "openai/gpt-4.1" }, () => ({
|
||||
eligible: true,
|
||||
reasons: [],
|
||||
})),
|
||||
{ tryBifrost: true }
|
||||
);
|
||||
});
|
||||
|
||||
test("relay routing backend auto mode skips bifrost for manifest-ineligible providers", () => {
|
||||
const config = getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
shouldTryBifrostForRequest("auto", config, { model: "cw/claude-sonnet-4.6" }, () => ({
|
||||
eligible: false,
|
||||
reasons: ["custom executor: claude-web"],
|
||||
})),
|
||||
{ tryBifrost: false, fallbackReason: "bifrost-ineligible" }
|
||||
);
|
||||
});
|
||||
|
||||
test("relay routing backend auto mode keeps unknown providers on TS fallback", () => {
|
||||
const config = getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
shouldTryBifrostForRequest("auto", config, { model: "unknown/model" }, () => null),
|
||||
{ tryBifrost: false, fallbackReason: "bifrost-provider-unknown" }
|
||||
);
|
||||
});
|
||||
|
||||
test("relay routing backend strict bifrost bypasses manifest eligibility", () => {
|
||||
const config = getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
shouldTryBifrostForRequest("bifrost", config, { model: "cw/claude-sonnet-4.6" }, () => ({
|
||||
eligible: false,
|
||||
reasons: ["custom executor: claude-web"],
|
||||
})),
|
||||
{ tryBifrost: true }
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { finalizeReadableStream } from "../../../../src/app/api/v1/relay/chat/completions/streamFinalizer.ts";
|
||||
|
||||
test("finalizeReadableStream finalizes once after the wrapped stream completes", async () => {
|
||||
const finalized: unknown[] = [];
|
||||
const stream = finalizeReadableStream(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("hello"));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
(error) => finalized.push(error)
|
||||
);
|
||||
|
||||
assert.equal(await new Response(stream).text(), "hello");
|
||||
assert.deepEqual(finalized, [undefined]);
|
||||
});
|
||||
|
||||
test("finalizeReadableStream finalizes once when the consumer cancels", async () => {
|
||||
const finalized: unknown[] = [];
|
||||
let cancelReason: unknown;
|
||||
const stream = finalizeReadableStream(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("chunk"));
|
||||
},
|
||||
cancel(reason) {
|
||||
cancelReason = reason;
|
||||
},
|
||||
}),
|
||||
(error) => finalized.push(error)
|
||||
);
|
||||
|
||||
const reader = stream.getReader();
|
||||
const first = await reader.read();
|
||||
assert.equal(new TextDecoder().decode(first.value), "chunk");
|
||||
|
||||
await reader.cancel("client disconnected");
|
||||
await reader.cancel("second cancel");
|
||||
|
||||
assert.equal(cancelReason, "client disconnected");
|
||||
assert.deepEqual(finalized, ["client disconnected"]);
|
||||
});
|
||||
Reference in New Issue
Block a user