@botiverse/porthole 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/LICENSE +217 -0
  2. package/dist/bridge/bridge.d.ts +39 -0
  3. package/dist/bridge/bridge.js +198 -0
  4. package/dist/bridge/types.d.ts +83 -0
  5. package/dist/bridge/types.js +1 -0
  6. package/dist/compile/compile.d.ts +35 -0
  7. package/dist/compile/compile.js +30 -0
  8. package/dist/compile/policy.d.ts +55 -0
  9. package/dist/compile/policy.js +103 -0
  10. package/dist/config/capabilities.d.ts +154 -0
  11. package/dist/config/capabilities.js +63 -0
  12. package/dist/config/schema.d.ts +152 -0
  13. package/dist/config/schema.js +11 -0
  14. package/dist/config/validate.d.ts +22 -0
  15. package/dist/config/validate.js +261 -0
  16. package/dist/conformance/behavior.d.ts +2 -0
  17. package/dist/conformance/behavior.js +426 -0
  18. package/dist/conformance/index.d.ts +101 -0
  19. package/dist/conformance/index.js +4 -0
  20. package/dist/conformance/kit.d.ts +42 -0
  21. package/dist/conformance/kit.js +24 -0
  22. package/dist/conformance/oracles.d.ts +35 -0
  23. package/dist/conformance/oracles.js +77 -0
  24. package/dist/conformance/probes.d.ts +33 -0
  25. package/dist/conformance/probes.js +876 -0
  26. package/dist/conformance/served.d.ts +75 -0
  27. package/dist/conformance/served.js +335 -0
  28. package/dist/guide/examples.d.ts +70 -0
  29. package/dist/guide/examples.js +69 -0
  30. package/dist/guide/guide.d.ts +16 -0
  31. package/dist/guide/guide.js +265 -0
  32. package/dist/host/create-host.d.ts +8 -0
  33. package/dist/host/create-host.js +292 -0
  34. package/dist/host/locate.d.ts +8 -0
  35. package/dist/host/locate.js +16 -0
  36. package/dist/host/outcome.d.ts +24 -0
  37. package/dist/host/outcome.js +134 -0
  38. package/dist/host/types.d.ts +127 -0
  39. package/dist/host/types.js +1 -0
  40. package/dist/index.d.ts +25 -0
  41. package/dist/index.js +21 -0
  42. package/dist/isolation/served.d.ts +35 -0
  43. package/dist/isolation/served.js +117 -0
  44. package/dist/isolation/types.d.ts +96 -0
  45. package/dist/isolation/types.js +2 -0
  46. package/dist/mcp/adapter.d.ts +43 -0
  47. package/dist/mcp/adapter.js +327 -0
  48. package/dist/mcp/index.d.ts +27 -0
  49. package/dist/mcp/index.js +25 -0
  50. package/dist/mcp/proxy.d.ts +25 -0
  51. package/dist/mcp/proxy.js +106 -0
  52. package/dist/mcp/view.d.ts +31 -0
  53. package/dist/mcp/view.js +72 -0
  54. package/dist/platform/types.d.ts +51 -0
  55. package/dist/platform/types.js +1 -0
  56. package/dist/platform/web.d.ts +8 -0
  57. package/dist/platform/web.js +85 -0
  58. package/dist/presets/presets.d.ts +71 -0
  59. package/dist/presets/presets.js +140 -0
  60. package/dist/protocol/adapter.d.ts +73 -0
  61. package/dist/protocol/adapter.js +1 -0
  62. package/dist/protocol/jsonrpc.d.ts +43 -0
  63. package/dist/protocol/jsonrpc.js +50 -0
  64. package/dist/protocol/mcp-apps.d.ts +79 -0
  65. package/dist/protocol/mcp-apps.js +99 -0
  66. package/dist/protocol/shell-adapter.d.ts +5 -0
  67. package/dist/protocol/shell-adapter.js +228 -0
  68. package/dist/react/dialog.d.ts +20 -0
  69. package/dist/react/dialog.js +98 -0
  70. package/dist/react/frame.d.ts +91 -0
  71. package/dist/react/frame.js +255 -0
  72. package/dist/react/index.d.ts +13 -0
  73. package/dist/react/index.js +13 -0
  74. package/dist/react/live.d.ts +24 -0
  75. package/dist/react/live.js +57 -0
  76. package/dist/server/index.d.ts +27 -0
  77. package/dist/server/index.js +26 -0
  78. package/dist/server/keys.d.ts +19 -0
  79. package/dist/server/keys.js +78 -0
  80. package/dist/server/pages.d.ts +11 -0
  81. package/dist/server/pages.js +86 -0
  82. package/dist/server/proxy.d.ts +21 -0
  83. package/dist/server/proxy.js +107 -0
  84. package/dist/server/server.d.ts +7 -0
  85. package/dist/server/server.js +542 -0
  86. package/dist/server/tickets.d.ts +8 -0
  87. package/dist/server/tickets.js +28 -0
  88. package/dist/server/types.d.ts +97 -0
  89. package/dist/server/types.js +1 -0
  90. package/dist/shell/document.d.ts +29 -0
  91. package/dist/shell/document.js +85 -0
  92. package/dist/shell/plan.d.ts +37 -0
  93. package/dist/shell/plan.js +35 -0
  94. package/dist/shell/runtime.d.ts +28 -0
  95. package/dist/shell/runtime.js +848 -0
  96. package/dist/theme/variables.d.ts +73 -0
  97. package/dist/theme/variables.js +171 -0
  98. package/dist/transport/post-message.d.ts +9 -0
  99. package/dist/transport/post-message.js +38 -0
  100. package/dist/transport/types.d.ts +15 -0
  101. package/dist/transport/types.js +1 -0
  102. package/package.json +63 -1
  103. package/src/bridge/bridge.ts +278 -0
  104. package/src/bridge/types.ts +95 -0
  105. package/src/compile/compile.ts +68 -0
  106. package/src/compile/policy.ts +130 -0
  107. package/src/config/capabilities.ts +138 -0
  108. package/src/config/schema.ts +168 -0
  109. package/src/config/validate.ts +318 -0
  110. package/src/conformance/behavior.ts +485 -0
  111. package/src/conformance/index.ts +116 -0
  112. package/src/conformance/kit.ts +63 -0
  113. package/src/conformance/oracles.ts +126 -0
  114. package/src/conformance/probes.ts +1129 -0
  115. package/src/conformance/served.ts +501 -0
  116. package/src/guide/examples.ts +74 -0
  117. package/src/guide/guide.ts +430 -0
  118. package/src/host/create-host.ts +335 -0
  119. package/src/host/locate.ts +25 -0
  120. package/src/host/outcome.ts +159 -0
  121. package/src/host/types.ts +126 -0
  122. package/src/index.ts +128 -0
  123. package/src/isolation/served.ts +151 -0
  124. package/src/isolation/types.ts +91 -0
  125. package/src/mcp/adapter.ts +441 -0
  126. package/src/mcp/index.ts +50 -0
  127. package/src/mcp/proxy.ts +117 -0
  128. package/src/mcp/view.ts +97 -0
  129. package/src/platform/types.ts +55 -0
  130. package/src/platform/web.ts +91 -0
  131. package/src/presets/presets.ts +217 -0
  132. package/src/protocol/adapter.ts +67 -0
  133. package/src/protocol/jsonrpc.ts +83 -0
  134. package/src/protocol/mcp-apps.ts +138 -0
  135. package/src/protocol/shell-adapter.ts +261 -0
  136. package/src/react/dialog.tsx +158 -0
  137. package/src/react/frame.tsx +486 -0
  138. package/src/react/index.ts +20 -0
  139. package/src/react/live.ts +76 -0
  140. package/src/server/index.ts +37 -0
  141. package/src/server/keys.ts +100 -0
  142. package/src/server/pages.ts +117 -0
  143. package/src/server/proxy.ts +131 -0
  144. package/src/server/server.ts +657 -0
  145. package/src/server/tickets.ts +30 -0
  146. package/src/server/types.ts +104 -0
  147. package/src/shell/document.ts +121 -0
  148. package/src/shell/plan.ts +69 -0
  149. package/src/shell/runtime.ts +848 -0
  150. package/src/theme/variables.ts +190 -0
  151. package/src/transport/post-message.ts +45 -0
  152. package/src/transport/types.ts +16 -0
@@ -0,0 +1,100 @@
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * The server's secrets, derived from the one it is given: one key per use, so a value made for
5
+ * one (a label) can never stand in for another (a cookie's signature).
6
+ */
7
+
8
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
9
+
10
+ /** Lowercase base32 (RFC 4648 alphabet, no padding): DNS-safe, case-insensitive. */
11
+ export function base32(bytes: Uint8Array): string {
12
+ let out = "";
13
+ let buffer = 0;
14
+ let bits = 0;
15
+ for (const byte of bytes) {
16
+ buffer = (buffer << 8) | byte;
17
+ bits += 8;
18
+ while (bits >= 5) {
19
+ out += ALPHABET[(buffer >>> (bits - 5)) & 31];
20
+ bits -= 5;
21
+ }
22
+ buffer &= (1 << bits) - 1;
23
+ }
24
+ if (bits > 0) {
25
+ out += ALPHABET[(buffer << (5 - bits)) & 31];
26
+ }
27
+ return out;
28
+ }
29
+
30
+ const base64url = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url");
31
+
32
+ /** JSON with object keys in a fixed order, so equal values always serialize alike. */
33
+ export function canonicalJson(value: unknown): string {
34
+ if (Array.isArray(value)) {
35
+ return `[${value.map(canonicalJson).join(",")}]`;
36
+ }
37
+ if (value !== null && typeof value === "object") {
38
+ const entries = Object.entries(value as Record<string, unknown>)
39
+ .filter(([, each]) => each !== undefined)
40
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
41
+ return `{${entries.map(([key, each]) => `${JSON.stringify(key)}:${canonicalJson(each)}`).join(",")}}`;
42
+ }
43
+ return JSON.stringify(value);
44
+ }
45
+
46
+ export interface Keys {
47
+ /** The DNS label for a storage scope: 24 base32 characters (120 bits), stable, not guessable. */
48
+ label(scope: string): string;
49
+ /** A short name for an instance (16 base32 characters): its document's path and cookie name. */
50
+ instanceKey(instance: unknown): string;
51
+ /** `value` signed: `<payload>.<signature>`, both base64url. */
52
+ sign(value: unknown): string;
53
+ /** The signed value, or null when the signature doesn't hold. */
54
+ verify(token: string): unknown;
55
+ }
56
+
57
+ export function createKeys(secret: Uint8Array): Keys {
58
+ if (secret.byteLength < 32) {
59
+ throw new TypeError("The server's secret must be at least 32 random bytes");
60
+ }
61
+ const derive = (use: string): Buffer =>
62
+ createHmac("sha256", secret).update(`porthole/${use}`).digest();
63
+ const labelKey = derive("label");
64
+ const instanceKey = derive("instance");
65
+ const signKey = derive("session");
66
+ const mac = (key: Buffer, text: string): Buffer =>
67
+ createHmac("sha256", key).update(text).digest();
68
+
69
+ return {
70
+ label: (scope) => base32(mac(labelKey, scope).subarray(0, 15)),
71
+ instanceKey: (instance) => base32(mac(instanceKey, canonicalJson(instance)).subarray(0, 10)),
72
+ sign: (value) => {
73
+ const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
74
+ return `${payload}.${base64url(mac(signKey, payload))}`;
75
+ },
76
+ verify: (token) => {
77
+ const [payload, signature, ...rest] = token.split(".");
78
+ if (payload === undefined || signature === undefined || rest.length > 0) {
79
+ return null;
80
+ }
81
+ const expected = mac(signKey, payload);
82
+ const given = Buffer.from(signature, "base64url");
83
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
84
+ return null;
85
+ }
86
+ try {
87
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as unknown;
88
+ } catch {
89
+ return null;
90
+ }
91
+ },
92
+ };
93
+ }
94
+
95
+ /** A fresh one-time ticket: 160 random bits. */
96
+ export const newTicket = (): string => `pt_${base32(randomBytes(20))}`;
97
+
98
+ /** What a ticket is stored under: its hash, so a leaked store holds no usable ticket. */
99
+ export const ticketId = (ticket: string): string =>
100
+ createHash("sha256").update(ticket).digest("base64url");
@@ -0,0 +1,117 @@
1
+ import { FRAME_STATUS_METHOD } from "../isolation/types.ts";
2
+ import type { FrameBlock } from "../host/types.ts";
3
+
4
+ /**
5
+ * The server's own pages on a content origin: why it shows nothing of the content, and the page a
6
+ * framed app is entered through. Plain, framable only by the host origins, never cached. Each tells
7
+ * the page that frames it how loading went (`porthole/notifications/frame-status`, P040), posted to
8
+ * the host origins only, so a host can show its own fallback ("Open in a new tab"). Their words are
9
+ * the host-agnostic mechanism's; a host that wants its own wording serves its own page in front.
10
+ */
11
+
12
+ const escape = (text: string): string =>
13
+ text.replace(/[&<>"']/gu, (char) => `&#${char.charCodeAt(0)};`);
14
+
15
+ const STYLE =
16
+ ":root{color-scheme:light dark;font-family:system-ui,sans-serif}body{margin:0;min-height:100vh;display:grid;place-items:center;background:Canvas;color:CanvasText}main{max-width:420px;padding:24px}h1{font-size:17px;margin:0 0 8px}p{margin:0;opacity:.75;line-height:1.5}";
17
+
18
+ /** Where the pages' scripts are served, on every content origin. */
19
+ export const STATUS_SCRIPT_PATH = "/__porthole/status.js";
20
+ export const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
21
+ /** Answers 204 when the request carries a good session for its origin's app, 401 otherwise. */
22
+ export const SESSION_PATH = "/__porthole/session";
23
+
24
+ const say = (): string =>
25
+ `const say = (params) => { if (window.parent === window) return; for (const origin of hosts) window.parent.postMessage({ jsonrpc: "2.0", method: ${JSON.stringify(FRAME_STATUS_METHOD)}, params }, origin); };`;
26
+
27
+ /** Tells the host the page is blocked, and why (from the page's data attributes). */
28
+ const STATUS_SCRIPT = `(() => {
29
+ const main = document.querySelector("[data-porthole-status]");
30
+ if (main === null) return;
31
+ const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
32
+ ${say()}
33
+ say({ state: "blocked", reason: main.dataset.portholeReason || "error", status: Number(main.dataset.portholeStatus) });
34
+ })();`;
35
+
36
+ /**
37
+ * Enters a framed app: says so, checks that the browser kept the session cookie this page's own
38
+ * answer set (WebKit keeps none in a cross-site frame, P025), then goes on to the app, or says it
39
+ * is blocked and why.
40
+ */
41
+ const ENTER_SCRIPT = `(async () => {
42
+ const main = document.querySelector("[data-porthole-enter]");
43
+ if (main === null) return;
44
+ const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
45
+ ${say()}
46
+ say({ state: "entering" });
47
+ let kept = false;
48
+ try {
49
+ kept = (await fetch(${JSON.stringify(SESSION_PATH)}, { credentials: "same-origin", cache: "no-store" })).ok;
50
+ } catch (_) {}
51
+ if (kept) {
52
+ location.replace(main.dataset.portholeTarget || "/");
53
+ return;
54
+ }
55
+ say({ state: "blocked", reason: "cookies", status: 401 });
56
+ main.querySelector("h1").textContent = "Open this app in a new tab";
57
+ main.querySelector("p").textContent = "This browser keeps no sign-in for it inside a frame.";
58
+ })();`;
59
+
60
+ /** The pages' scripts, by path: served with the pages' own policy in mind (`script-src 'self'`). */
61
+ export function pageScript(pathname: string): Response | null {
62
+ const body =
63
+ pathname === STATUS_SCRIPT_PATH
64
+ ? STATUS_SCRIPT
65
+ : pathname === ENTER_SCRIPT_PATH
66
+ ? ENTER_SCRIPT
67
+ : null;
68
+ return body === null
69
+ ? null
70
+ : new Response(body, {
71
+ headers: {
72
+ "content-type": "text/javascript; charset=utf-8",
73
+ "cache-control": "no-store",
74
+ "x-content-type-options": "nosniff",
75
+ },
76
+ });
77
+ }
78
+
79
+ const policy = (frameAncestors: string, connect: boolean): string =>
80
+ `default-src 'none'; style-src 'unsafe-inline'; script-src 'self'; ${connect ? "connect-src 'self'; " : ""}base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`;
81
+
82
+ const pageHeaders = (frameAncestors: string, connect: boolean) => ({
83
+ "content-type": "text/html; charset=utf-8",
84
+ "cache-control": "no-store",
85
+ "content-security-policy": policy(frameAncestors, connect),
86
+ "referrer-policy": "no-referrer",
87
+ "x-content-type-options": "nosniff",
88
+ });
89
+
90
+ export function statusPage(
91
+ status: number,
92
+ title: string,
93
+ detail: string,
94
+ frameAncestors: string,
95
+ method = "GET",
96
+ reason: FrameBlock = "error",
97
+ hostOrigins: readonly string[] = [],
98
+ ): Response {
99
+ const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${STYLE}</style></head><body><main data-porthole-status="${status}" data-porthole-reason="${escape(reason)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main><script src="${STATUS_SCRIPT_PATH}"></script></body></html>`;
100
+ return new Response(method === "HEAD" ? null : body, {
101
+ status,
102
+ headers: pageHeaders(frameAncestors, false),
103
+ });
104
+ }
105
+
106
+ /** The page a framed app is entered through (its answer also sets the session cookie). */
107
+ export function enterPage(
108
+ target: string,
109
+ frameAncestors: string,
110
+ hostOrigins: readonly string[],
111
+ cookie: string,
112
+ ): Response {
113
+ const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Opening the app</title><style>${STYLE}</style></head><body><main data-porthole-enter="" data-porthole-target="${escape(target)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>Opening the app…</h1><p></p></main><script src="${ENTER_SCRIPT_PATH}"></script></body></html>`;
114
+ const headers = new Headers(pageHeaders(frameAncestors, true));
115
+ headers.set("set-cookie", cookie);
116
+ return new Response(body, { status: 200, headers });
117
+ }
@@ -0,0 +1,131 @@
1
+ import type { Access, Upstream } from "./types.ts";
2
+
3
+ /**
4
+ * A url-mode upstream that relays each request to the running app by HTTP: the app believes it is
5
+ * on its own origin (Host, Origin and Referer name it; dev servers check Host), learns nothing of
6
+ * the proxy (no forwarding headers), and its answer comes back on the content origin (its
7
+ * redirects to itself, and its cookies, stay there). WebSockets (a dev server's hot reload) don't
8
+ * go through a fetch-style handler: the host relays those on its own upgrade path, after
9
+ * `PortholeServer.access` lets the request in.
10
+ */
11
+
12
+ export interface ProxyOptions {
13
+ /** Where the app runs. Default: the external instance's own `url` (its origin; paths map 1:1). */
14
+ readonly target?: (access: Access) => string;
15
+ /** The fetch to relay with. Default: the global one. */
16
+ readonly fetch?: typeof fetch;
17
+ }
18
+
19
+ const HOP_BY_HOP = new Set([
20
+ "connection",
21
+ "keep-alive",
22
+ "proxy-authenticate",
23
+ "proxy-authorization",
24
+ "proxy-connection",
25
+ "te",
26
+ "trailer",
27
+ "transfer-encoding",
28
+ "upgrade",
29
+ ]);
30
+ /** What a proxy in front of the host's server added: the app is told nothing of it. */
31
+ const FORWARDING = /^(?:x-forwarded-.*|forwarded|x-real-ip)$/u;
32
+
33
+ function connectionTokens(headers: Headers): Set<string> {
34
+ return new Set(
35
+ (headers.get("connection") ?? "")
36
+ .split(",")
37
+ .map((token) => token.trim().toLowerCase())
38
+ .filter((token) => token !== ""),
39
+ );
40
+ }
41
+
42
+ /** The browser's request headers as the app at `target` gets them. */
43
+ export function upstreamRequestHeaders(headers: Headers, origin: string, target: string): Headers {
44
+ const named = connectionTokens(headers);
45
+ const out = new Headers();
46
+ for (const [name, value] of headers) {
47
+ const key = name.toLowerCase();
48
+ if (key === "host" || HOP_BY_HOP.has(key) || named.has(key) || FORWARDING.test(key)) {
49
+ continue;
50
+ }
51
+ if (key === "origin" && value === origin) {
52
+ out.set(name, target);
53
+ } else if (key === "referer" && (value === origin || value.startsWith(`${origin}/`))) {
54
+ out.set(name, `${target}${value.slice(origin.length)}`);
55
+ } else {
56
+ out.append(name, value);
57
+ }
58
+ }
59
+ // The answer comes back decoded by fetch: ask for it plain rather than pay to decode it.
60
+ out.set("accept-encoding", "identity");
61
+ return out;
62
+ }
63
+
64
+ /** The app's answer headers as the browser on `origin` gets them. */
65
+ export function upstreamResponseHeaders(headers: Headers, origin: string, target: string): Headers {
66
+ const named = connectionTokens(headers);
67
+ const out = new Headers();
68
+ for (const [name, value] of headers) {
69
+ const key = name.toLowerCase();
70
+ if (
71
+ HOP_BY_HOP.has(key) ||
72
+ named.has(key) ||
73
+ key === "set-cookie" ||
74
+ // fetch decoded the body, whatever the app said it encoded.
75
+ key === "content-encoding" ||
76
+ key === "content-length"
77
+ ) {
78
+ continue;
79
+ }
80
+ if (key === "location") {
81
+ out.set(name, onOrigin(value, origin, target));
82
+ } else {
83
+ out.append(name, value);
84
+ }
85
+ }
86
+ for (const cookie of headers.getSetCookie()) {
87
+ // Host-only on the content origin: an app's cookie never reaches the content domain's others.
88
+ const [pair = "", ...attributes] = cookie.split(";");
89
+ const kept = attributes.filter((attribute) => !/^\s*domain\s*=/iu.test(attribute));
90
+ out.append("set-cookie", [pair, ...kept].join(";"));
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /** `<target>/x` → `<origin>/x`; any other URL as it is. */
96
+ function onOrigin(location: string, origin: string, target: string): string {
97
+ try {
98
+ const url = new URL(location, target);
99
+ return url.origin === target ? `${origin}${url.pathname}${url.search}${url.hash}` : location;
100
+ } catch {
101
+ return location;
102
+ }
103
+ }
104
+
105
+ /** An `Upstream` that relays to the app by HTTP. */
106
+ export function proxyTo(options: ProxyOptions = {}): Upstream {
107
+ const relay = options.fetch ?? fetch;
108
+ return async (request, access) => {
109
+ const where =
110
+ options.target?.(access) ??
111
+ (access.instance.document.kind === "external" ? access.instance.document.url : null);
112
+ if (where === null) {
113
+ return new Response("Not an app", { status: 502 });
114
+ }
115
+ const target = new URL(where).origin;
116
+ const url = new URL(request.url);
117
+ const method = request.method.toUpperCase();
118
+ const hasBody = method !== "GET" && method !== "HEAD";
119
+ const answer = await relay(`${target}${url.pathname}${url.search}`, {
120
+ method,
121
+ headers: upstreamRequestHeaders(request.headers, access.origin, target),
122
+ redirect: "manual",
123
+ ...(hasBody ? { body: request.body, duplex: "half" } : {}),
124
+ });
125
+ return new Response(method === "HEAD" ? null : answer.body, {
126
+ status: answer.status,
127
+ statusText: answer.statusText,
128
+ headers: upstreamResponseHeaders(answer.headers, access.origin, target),
129
+ });
130
+ };
131
+ }