@floegence/flowersec-core 0.15.0 → 0.16.0

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.
@@ -0,0 +1,22 @@
1
+ import type { YamuxStream } from "../yamux/stream.js";
2
+ import type { ProxyRuntimeLimits } from "./runtime.js";
3
+ export type RegisterProxyAppWindowOptions = Readonly<{
4
+ controllerOrigin: string;
5
+ controllerWindow?: Window | null;
6
+ targetWindow?: Window;
7
+ maxWsFrameBytes?: number;
8
+ }>;
9
+ export type ProxyAppWindowHandle = Readonly<{
10
+ runtime: Readonly<{
11
+ limits: Partial<ProxyRuntimeLimits>;
12
+ openWebSocketStream: (path: string, opts?: Readonly<{
13
+ protocols?: readonly string[];
14
+ signal?: AbortSignal;
15
+ }>) => Promise<Readonly<{
16
+ stream: YamuxStream;
17
+ protocol: string;
18
+ }>>;
19
+ }>;
20
+ dispose: () => void;
21
+ }>;
22
+ export declare function registerProxyAppWindow(opts: RegisterProxyAppWindowOptions): ProxyAppWindowHandle;
@@ -0,0 +1,133 @@
1
+ import { createMessagePortBackedStream } from "./portStream.js";
2
+ import { PROXY_WINDOW_FETCH_FORWARD_MSG_TYPE, PROXY_WINDOW_FETCH_MSG_TYPE, PROXY_WINDOW_WS_ERROR_MSG_TYPE, PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE, PROXY_WINDOW_WS_OPEN_MSG_TYPE, } from "./windowBridgeProtocol.js";
3
+ function resolveTargetWindow(raw) {
4
+ const target = raw ?? globalThis.window;
5
+ if (target == null)
6
+ throw new Error("targetWindow is not available");
7
+ return target;
8
+ }
9
+ function resolveControllerWindow(targetWindow, raw) {
10
+ if (raw != null)
11
+ return raw;
12
+ try {
13
+ if (targetWindow.top && targetWindow.top !== targetWindow)
14
+ return targetWindow.top;
15
+ }
16
+ catch {
17
+ // ignore
18
+ }
19
+ try {
20
+ if (targetWindow.parent && targetWindow.parent !== targetWindow)
21
+ return targetWindow.parent;
22
+ }
23
+ catch {
24
+ // ignore
25
+ }
26
+ throw new Error("controllerWindow is not available");
27
+ }
28
+ function postFetchError(port, message) {
29
+ try {
30
+ port.postMessage({ type: "flowersec-proxy:response_error", status: 502, message });
31
+ }
32
+ catch {
33
+ // Best-effort.
34
+ }
35
+ try {
36
+ port.close();
37
+ }
38
+ catch {
39
+ // Best-effort.
40
+ }
41
+ }
42
+ export function registerProxyAppWindow(opts) {
43
+ const controllerOrigin = String(opts.controllerOrigin ?? "").trim();
44
+ if (controllerOrigin === "") {
45
+ throw new Error("controllerOrigin is required");
46
+ }
47
+ const targetWindow = resolveTargetWindow(opts.targetWindow);
48
+ const controllerWindow = resolveControllerWindow(targetWindow, opts.controllerWindow);
49
+ const sw = targetWindow.navigator?.serviceWorker;
50
+ const onServiceWorkerMessage = (ev) => {
51
+ const data = ev.data;
52
+ if (data == null || typeof data !== "object")
53
+ return;
54
+ if (data.type !== PROXY_WINDOW_FETCH_FORWARD_MSG_TYPE)
55
+ return;
56
+ const port = ev.ports?.[0];
57
+ if (!port)
58
+ return;
59
+ try {
60
+ controllerWindow.postMessage({ type: PROXY_WINDOW_FETCH_MSG_TYPE, req: data.req }, controllerOrigin, [port]);
61
+ }
62
+ catch (error) {
63
+ const message = error instanceof Error ? error.message : String(error);
64
+ postFetchError(port, message);
65
+ }
66
+ };
67
+ sw?.addEventListener("message", onServiceWorkerMessage);
68
+ const runtime = {
69
+ limits: opts.maxWsFrameBytes === undefined ? {} : { maxWsFrameBytes: opts.maxWsFrameBytes },
70
+ openWebSocketStream: async (path, wsOpts = {}) => {
71
+ const channel = new MessageChannel();
72
+ const port = channel.port1;
73
+ port.start?.();
74
+ return await new Promise((resolve, reject) => {
75
+ let settled = false;
76
+ const finishReject = (error) => {
77
+ if (settled)
78
+ return;
79
+ settled = true;
80
+ try {
81
+ port.close();
82
+ }
83
+ catch {
84
+ // Best-effort.
85
+ }
86
+ reject(error instanceof Error ? error : new Error(String(error)));
87
+ };
88
+ const finishResolve = (protocol) => {
89
+ if (settled)
90
+ return;
91
+ settled = true;
92
+ resolve({ stream: createMessagePortBackedStream(port), protocol });
93
+ };
94
+ port.onmessage = (ev) => {
95
+ const data = ev.data;
96
+ if (data == null || typeof data !== "object")
97
+ return;
98
+ const type = typeof data.type === "string" ? data.type : "";
99
+ if (type === PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE) {
100
+ finishResolve(String(data.protocol ?? ""));
101
+ return;
102
+ }
103
+ if (type === PROXY_WINDOW_WS_ERROR_MSG_TYPE) {
104
+ finishReject(new Error(String(data.message ?? "upstream ws open failed")));
105
+ }
106
+ };
107
+ if (wsOpts.signal != null) {
108
+ if (wsOpts.signal.aborted) {
109
+ finishReject(wsOpts.signal.reason ?? new Error("aborted"));
110
+ return;
111
+ }
112
+ wsOpts.signal.addEventListener("abort", () => finishReject(wsOpts.signal?.reason ?? new Error("aborted")), { once: true });
113
+ }
114
+ try {
115
+ controllerWindow.postMessage({
116
+ type: PROXY_WINDOW_WS_OPEN_MSG_TYPE,
117
+ path,
118
+ ...(wsOpts.protocols === undefined ? {} : { protocols: wsOpts.protocols }),
119
+ }, controllerOrigin, [channel.port2]);
120
+ }
121
+ catch (error) {
122
+ finishReject(error);
123
+ }
124
+ });
125
+ },
126
+ };
127
+ return {
128
+ runtime,
129
+ dispose: () => {
130
+ sw?.removeEventListener("message", onServiceWorkerMessage);
131
+ },
132
+ };
133
+ }
@@ -0,0 +1,11 @@
1
+ import type { ProxyRuntime } from "./runtime.js";
2
+ export type RegisterProxyControllerWindowOptions = Readonly<{
3
+ runtime: ProxyRuntime;
4
+ allowedOrigins: readonly string[];
5
+ targetWindow?: Window;
6
+ expectedSource?: Window | null;
7
+ }>;
8
+ export type ProxyControllerWindowHandle = Readonly<{
9
+ dispose: () => void;
10
+ }>;
11
+ export declare function registerProxyControllerWindow(opts: RegisterProxyControllerWindowOptions): ProxyControllerWindowHandle;
@@ -0,0 +1,124 @@
1
+ import { PROXY_WINDOW_FETCH_MSG_TYPE, PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE, PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE, PROXY_WINDOW_STREAM_END_MSG_TYPE, PROXY_WINDOW_STREAM_RESET_MSG_TYPE, PROXY_WINDOW_WS_ERROR_MSG_TYPE, PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE, PROXY_WINDOW_WS_OPEN_MSG_TYPE, } from "./windowBridgeProtocol.js";
2
+ function normalizeOrigins(origins) {
3
+ const out = [];
4
+ const seen = new Set();
5
+ for (const origin of origins) {
6
+ const normalized = String(origin ?? "").trim();
7
+ if (normalized === "" || seen.has(normalized))
8
+ continue;
9
+ seen.add(normalized);
10
+ out.push(normalized);
11
+ }
12
+ return out;
13
+ }
14
+ function cloneChunk(chunk) {
15
+ const out = new Uint8Array(chunk.byteLength);
16
+ out.set(chunk);
17
+ return out.buffer;
18
+ }
19
+ function bridgeWebSocket(runtime, msg, port) {
20
+ const ac = new AbortController();
21
+ let streamClosed = false;
22
+ const closePort = () => {
23
+ try {
24
+ port.close();
25
+ }
26
+ catch {
27
+ // Best-effort.
28
+ }
29
+ };
30
+ void (async () => {
31
+ try {
32
+ const wsOpts = {
33
+ signal: ac.signal,
34
+ ...(msg.protocols === undefined ? {} : { protocols: msg.protocols }),
35
+ };
36
+ const { stream, protocol } = await runtime.openWebSocketStream(msg.path, wsOpts);
37
+ port.onmessage = (ev) => {
38
+ const data = ev.data;
39
+ if (data == null || typeof data !== "object")
40
+ return;
41
+ const type = typeof data.type === "string" ? data.type : "";
42
+ switch (type) {
43
+ case PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE: {
44
+ const raw = data.data;
45
+ if (!(raw instanceof ArrayBuffer))
46
+ return;
47
+ void stream.write(new Uint8Array(raw));
48
+ return;
49
+ }
50
+ case PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE:
51
+ void stream.close();
52
+ return;
53
+ case PROXY_WINDOW_STREAM_RESET_MSG_TYPE: {
54
+ const message = String(data.message ?? "stream reset");
55
+ stream.reset(new Error(message));
56
+ streamClosed = true;
57
+ ac.abort(message);
58
+ closePort();
59
+ return;
60
+ }
61
+ default:
62
+ return;
63
+ }
64
+ };
65
+ port.start?.();
66
+ port.postMessage({ type: PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE, protocol });
67
+ while (!streamClosed) {
68
+ const chunk = await stream.read();
69
+ if (chunk == null) {
70
+ streamClosed = true;
71
+ port.postMessage({ type: PROXY_WINDOW_STREAM_END_MSG_TYPE });
72
+ closePort();
73
+ return;
74
+ }
75
+ const ab = cloneChunk(chunk);
76
+ port.postMessage({ type: PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE, data: ab }, [ab]);
77
+ }
78
+ }
79
+ catch (error) {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ port.postMessage({ type: PROXY_WINDOW_WS_ERROR_MSG_TYPE, message });
82
+ closePort();
83
+ }
84
+ })();
85
+ }
86
+ export function registerProxyControllerWindow(opts) {
87
+ const allowedOrigins = normalizeOrigins(opts.allowedOrigins);
88
+ if (allowedOrigins.length === 0) {
89
+ throw new Error("allowedOrigins is required");
90
+ }
91
+ const targetWindow = opts.targetWindow ?? globalThis.window;
92
+ if (targetWindow == null) {
93
+ throw new Error("targetWindow is not available");
94
+ }
95
+ const onMessage = (ev) => {
96
+ if (!allowedOrigins.includes(String(ev.origin ?? "").trim()))
97
+ return;
98
+ if (opts.expectedSource != null && ev.source !== opts.expectedSource)
99
+ return;
100
+ const data = ev.data;
101
+ if (data == null || typeof data !== "object")
102
+ return;
103
+ const type = typeof data.type === "string" ? data.type : "";
104
+ const port = ev.ports?.[0];
105
+ if (!port)
106
+ return;
107
+ switch (type) {
108
+ case PROXY_WINDOW_FETCH_MSG_TYPE:
109
+ opts.runtime.dispatchFetch(data.req, port);
110
+ return;
111
+ case PROXY_WINDOW_WS_OPEN_MSG_TYPE:
112
+ bridgeWebSocket(opts.runtime, data, port);
113
+ return;
114
+ default:
115
+ return;
116
+ }
117
+ };
118
+ targetWindow.addEventListener("message", onMessage);
119
+ return {
120
+ dispose: () => {
121
+ targetWindow.removeEventListener("message", onMessage);
122
+ },
123
+ };
124
+ }
@@ -8,5 +8,7 @@ export { registerServiceWorkerAndEnsureControl } from "./registerServiceWorker.j
8
8
  export * from "./integration.js";
9
9
  export * from "./controllerGuard.js";
10
10
  export * from "./bootstrap.js";
11
+ export * from "./controllerWindow.js";
12
+ export * from "./appWindow.js";
11
13
  export * from "./wsPatch.js";
12
14
  export * from "./disableUpstreamServiceWorkerRegister.js";
@@ -8,5 +8,7 @@ export { registerServiceWorkerAndEnsureControl } from "./registerServiceWorker.j
8
8
  export * from "./integration.js";
9
9
  export * from "./controllerGuard.js";
10
10
  export * from "./bootstrap.js";
11
+ export * from "./controllerWindow.js";
12
+ export * from "./appWindow.js";
11
13
  export * from "./wsPatch.js";
12
14
  export * from "./disableUpstreamServiceWorkerRegister.js";
@@ -0,0 +1,2 @@
1
+ import type { YamuxStream } from "../yamux/stream.js";
2
+ export declare function createMessagePortBackedStream(port: MessagePort): YamuxStream;
@@ -0,0 +1,120 @@
1
+ import { PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE, PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE, PROXY_WINDOW_STREAM_END_MSG_TYPE, PROXY_WINDOW_STREAM_RESET_MSG_TYPE, } from "./windowBridgeProtocol.js";
2
+ function cloneChunk(chunk) {
3
+ const out = new Uint8Array(chunk.byteLength);
4
+ out.set(chunk);
5
+ return out.buffer;
6
+ }
7
+ export function createMessagePortBackedStream(port) {
8
+ let closed = false;
9
+ let error = null;
10
+ const queue = [];
11
+ const waiters = [];
12
+ const resolveWaiter = (value) => {
13
+ const waiter = waiters.shift();
14
+ if (waiter) {
15
+ waiter(value);
16
+ return true;
17
+ }
18
+ return false;
19
+ };
20
+ const pushValue = (value) => {
21
+ if (resolveWaiter(value))
22
+ return;
23
+ queue.push(value);
24
+ };
25
+ const fail = (err) => {
26
+ if (error != null)
27
+ return;
28
+ error = err;
29
+ while (resolveWaiter(err)) {
30
+ // Drain waiters.
31
+ }
32
+ };
33
+ port.onmessage = (ev) => {
34
+ const data = ev.data;
35
+ if (data == null || typeof data !== "object")
36
+ return;
37
+ const type = typeof data.type === "string" ? data.type : "";
38
+ switch (type) {
39
+ case PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE: {
40
+ const raw = data.data;
41
+ if (!(raw instanceof ArrayBuffer))
42
+ return;
43
+ pushValue(new Uint8Array(raw));
44
+ return;
45
+ }
46
+ case PROXY_WINDOW_STREAM_END_MSG_TYPE:
47
+ case PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE:
48
+ closed = true;
49
+ pushValue(null);
50
+ return;
51
+ case PROXY_WINDOW_STREAM_RESET_MSG_TYPE: {
52
+ closed = true;
53
+ const message = String(data.message ?? "stream reset");
54
+ fail(new Error(message));
55
+ try {
56
+ port.close();
57
+ }
58
+ catch {
59
+ // Best-effort.
60
+ }
61
+ return;
62
+ }
63
+ default:
64
+ return;
65
+ }
66
+ };
67
+ port.start?.();
68
+ return {
69
+ async read() {
70
+ if (error != null)
71
+ throw error;
72
+ const next = queue.shift();
73
+ if (next !== undefined)
74
+ return next;
75
+ if (closed)
76
+ return null;
77
+ const value = await new Promise((resolve) => {
78
+ waiters.push(resolve);
79
+ });
80
+ if (value instanceof Error)
81
+ throw value;
82
+ return value;
83
+ },
84
+ async write(chunk) {
85
+ if (error != null)
86
+ throw error;
87
+ if (closed)
88
+ throw new Error("stream is closed");
89
+ const ab = cloneChunk(chunk);
90
+ port.postMessage({ type: PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE, data: ab }, [ab]);
91
+ },
92
+ async close() {
93
+ if (closed)
94
+ return;
95
+ closed = true;
96
+ try {
97
+ port.postMessage({ type: PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE });
98
+ }
99
+ finally {
100
+ pushValue(null);
101
+ }
102
+ },
103
+ reset(err) {
104
+ const message = err instanceof Error ? err.message : String(err);
105
+ closed = true;
106
+ fail(err instanceof Error ? err : new Error(message));
107
+ try {
108
+ port.postMessage({ type: PROXY_WINDOW_STREAM_RESET_MSG_TYPE, message });
109
+ }
110
+ finally {
111
+ try {
112
+ port.close();
113
+ }
114
+ catch {
115
+ // Best-effort.
116
+ }
117
+ }
118
+ },
119
+ };
120
+ }
@@ -1,6 +1,14 @@
1
1
  import type { Client } from "../client.js";
2
2
  import type { YamuxStream } from "../yamux/stream.js";
3
3
  import { CookieJar } from "./cookieJar.js";
4
+ import type { Header } from "./types.js";
5
+ type ProxyFetchReq = Readonly<{
6
+ id: string;
7
+ method: string;
8
+ path: string;
9
+ headers: readonly Header[];
10
+ body?: ArrayBuffer;
11
+ }>;
4
12
  export type ProxyRuntimeLimits = Readonly<{
5
13
  maxJsonFrameBytes: number;
6
14
  maxChunkBytes: number;
@@ -8,9 +16,9 @@ export type ProxyRuntimeLimits = Readonly<{
8
16
  maxWsFrameBytes: number;
9
17
  }>;
10
18
  export type ProxyRuntime = Readonly<{
11
- cookieJar: CookieJar;
12
19
  limits: ProxyRuntimeLimits;
13
20
  dispose: () => void;
21
+ dispatchFetch: (req: ProxyFetchReq, port: MessagePort) => void;
14
22
  openWebSocketStream: (path: string, opts?: Readonly<{
15
23
  protocols?: readonly string[];
16
24
  signal?: AbortSignal;
@@ -29,5 +37,7 @@ export type ProxyRuntimeOptions = Readonly<{
29
37
  extraRequestHeaders?: readonly string[];
30
38
  extraResponseHeaders?: readonly string[];
31
39
  extraWsHeaders?: readonly string[];
40
+ cookieJar?: CookieJar;
32
41
  }>;
33
42
  export declare function createProxyRuntime(opts: ProxyRuntimeOptions): ProxyRuntime;
43
+ export {};
@@ -87,7 +87,7 @@ async function readChunkFrames(reader, maxChunkBytes, maxBodyBytes) {
87
87
  }
88
88
  export function createProxyRuntime(opts) {
89
89
  const client = opts.client;
90
- const cookieJar = new CookieJar();
90
+ const cookieJar = opts.cookieJar ?? new CookieJar();
91
91
  const maxJsonFrameBytes = normalizeMaxBytes("maxJsonFrameBytes", opts.maxJsonFrameBytes, DEFAULT_MAX_JSON_FRAME_BYTES);
92
92
  const maxChunkBytes = normalizeMaxBytes("maxChunkBytes", opts.maxChunkBytes, DEFAULT_MAX_CHUNK_BYTES);
93
93
  const maxBodyBytes = normalizeMaxBytes("maxBodyBytes", opts.maxBodyBytes, DEFAULT_MAX_BODY_BYTES);
@@ -115,13 +115,13 @@ export function createProxyRuntime(opts) {
115
115
  const port = ev.ports?.[0];
116
116
  if (!port)
117
117
  return;
118
- void handleFetch(msg.req, port);
118
+ dispatchFetch(msg.req, port);
119
119
  };
120
120
  const sw = globalThis.navigator?.serviceWorker;
121
121
  sw?.addEventListener("message", onMessage);
122
122
  sw?.addEventListener("controllerchange", registerRuntime);
123
123
  registerRuntime();
124
- async function handleFetch(req, port) {
124
+ const dispatchFetch = (req, port) => {
125
125
  const ac = new AbortController();
126
126
  let stream = null;
127
127
  port.onmessage = (ev) => {
@@ -130,74 +130,76 @@ export function createProxyRuntime(opts) {
130
130
  ac.abort("aborted");
131
131
  }
132
132
  };
133
- try {
134
- const path = pathOnly(req.path);
135
- const requestID = req.id.trim() !== "" ? req.id : randomB64u(18);
136
- stream = await client.openStream(PROXY_KIND_HTTP1, { signal: ac.signal });
137
- const reader = createByteReader(stream, { signal: ac.signal });
138
- const filteredReqHeaders = filterRequestHeaders(req.headers, { extraAllowed: extraRequestHeaders });
139
- const cookieHeader = cookieJar.getCookieHeader(cookiePathFromRequestPath(path));
140
- const reqHeaders = cookieHeader === "" ? filteredReqHeaders : [...filteredReqHeaders, { name: "cookie", value: cookieHeader }];
141
- await writeJsonFrame(stream, {
142
- v: PROXY_PROTOCOL_VERSION,
143
- request_id: requestID,
144
- method: req.method,
145
- path,
146
- headers: reqHeaders,
147
- timeout_ms: timeoutMs
148
- });
149
- const body = req.body != null ? new Uint8Array(req.body) : new Uint8Array();
150
- await writeChunkFrames(stream, body, Math.min(64 * 1024, maxChunkBytes), maxBodyBytes);
151
- const respMeta = (await readJsonFrame(reader, maxJsonFrameBytes));
152
- if (respMeta.v !== PROXY_PROTOCOL_VERSION || respMeta.request_id !== requestID) {
153
- throw new Error("invalid upstream response meta");
133
+ void (async () => {
134
+ try {
135
+ const path = pathOnly(req.path);
136
+ const requestID = req.id.trim() !== "" ? req.id : randomB64u(18);
137
+ stream = await client.openStream(PROXY_KIND_HTTP1, { signal: ac.signal });
138
+ const reader = createByteReader(stream, { signal: ac.signal });
139
+ const filteredReqHeaders = filterRequestHeaders(req.headers, { extraAllowed: extraRequestHeaders });
140
+ const cookieHeader = cookieJar.getCookieHeader(cookiePathFromRequestPath(path));
141
+ const reqHeaders = cookieHeader === "" ? filteredReqHeaders : [...filteredReqHeaders, { name: "cookie", value: cookieHeader }];
142
+ await writeJsonFrame(stream, {
143
+ v: PROXY_PROTOCOL_VERSION,
144
+ request_id: requestID,
145
+ method: req.method,
146
+ path,
147
+ headers: reqHeaders,
148
+ timeout_ms: timeoutMs
149
+ });
150
+ const body = req.body != null ? new Uint8Array(req.body) : new Uint8Array();
151
+ await writeChunkFrames(stream, body, Math.min(64 * 1024, maxChunkBytes), maxBodyBytes);
152
+ const respMeta = (await readJsonFrame(reader, maxJsonFrameBytes));
153
+ if (respMeta.v !== PROXY_PROTOCOL_VERSION || respMeta.request_id !== requestID) {
154
+ throw new Error("invalid upstream response meta");
155
+ }
156
+ if (!respMeta.ok) {
157
+ const msg = respMeta.error?.message ?? "upstream error";
158
+ port.postMessage({ type: "flowersec-proxy:response_error", status: 502, message: msg });
159
+ try {
160
+ stream.reset(new Error(msg));
161
+ }
162
+ catch {
163
+ // Best-effort.
164
+ }
165
+ stream = null;
166
+ return;
167
+ }
168
+ const status = Math.max(0, Math.floor(respMeta.status ?? 502));
169
+ const rawHeaders = Array.isArray(respMeta.headers) ? respMeta.headers : [];
170
+ const { passthrough, setCookie } = filterResponseHeaders(rawHeaders, { extraAllowed: extraResponseHeaders });
171
+ cookieJar.updateFromSetCookieHeaders(setCookie);
172
+ port.postMessage({ type: "flowersec-proxy:response_meta", status, headers: passthrough });
173
+ const chunks = await readChunkFrames(reader, maxChunkBytes, maxBodyBytes);
174
+ for await (const chunk of chunks) {
175
+ // Always transfer an ArrayBuffer (SharedArrayBuffer is not transferable).
176
+ const ab = chunk.slice().buffer;
177
+ port.postMessage({ type: "flowersec-proxy:response_chunk", data: ab }, [ab]);
178
+ }
179
+ port.postMessage({ type: "flowersec-proxy:response_end" });
180
+ await stream.close();
181
+ stream = null;
154
182
  }
155
- if (!respMeta.ok) {
156
- const msg = respMeta.error?.message ?? "upstream error";
183
+ catch (e) {
184
+ const msg = e instanceof Error ? e.message : String(e);
157
185
  port.postMessage({ type: "flowersec-proxy:response_error", status: 502, message: msg });
158
186
  try {
159
- stream.reset(new Error(msg));
187
+ stream?.reset(new Error(msg));
160
188
  }
161
189
  catch {
162
190
  // Best-effort.
163
191
  }
164
- stream = null;
165
- return;
166
- }
167
- const status = Math.max(0, Math.floor(respMeta.status ?? 502));
168
- const rawHeaders = Array.isArray(respMeta.headers) ? respMeta.headers : [];
169
- const { passthrough, setCookie } = filterResponseHeaders(rawHeaders, { extraAllowed: extraResponseHeaders });
170
- cookieJar.updateFromSetCookieHeaders(setCookie);
171
- port.postMessage({ type: "flowersec-proxy:response_meta", status, headers: passthrough });
172
- const chunks = await readChunkFrames(reader, maxChunkBytes, maxBodyBytes);
173
- for await (const chunk of chunks) {
174
- // Always transfer an ArrayBuffer (SharedArrayBuffer is not transferable).
175
- const ab = chunk.slice().buffer;
176
- port.postMessage({ type: "flowersec-proxy:response_chunk", data: ab }, [ab]);
177
- }
178
- port.postMessage({ type: "flowersec-proxy:response_end" });
179
- await stream.close();
180
- stream = null;
181
- }
182
- catch (e) {
183
- const msg = e instanceof Error ? e.message : String(e);
184
- port.postMessage({ type: "flowersec-proxy:response_error", status: 502, message: msg });
185
- try {
186
- stream?.reset(new Error(msg));
187
- }
188
- catch {
189
- // Best-effort.
190
- }
191
- }
192
- finally {
193
- try {
194
- port.close();
195
192
  }
196
- catch {
197
- // Best-effort.
193
+ finally {
194
+ try {
195
+ port.close();
196
+ }
197
+ catch {
198
+ // Best-effort.
199
+ }
198
200
  }
199
- }
200
- }
201
+ })();
202
+ };
201
203
  async function openWebSocketStream(pathRaw, wsOpts = {}) {
202
204
  const path = pathOnly(pathRaw);
203
205
  const openOpts = wsOpts.signal ? { signal: wsOpts.signal } : undefined;
@@ -226,8 +228,8 @@ export function createProxyRuntime(opts) {
226
228
  return { stream, protocol: resp.protocol ?? "" };
227
229
  }
228
230
  return {
229
- cookieJar,
230
231
  limits: { maxJsonFrameBytes, maxChunkBytes, maxBodyBytes, maxWsFrameBytes },
232
+ dispatchFetch,
231
233
  openWebSocketStream,
232
234
  dispose: () => {
233
235
  sw?.removeEventListener("message", onMessage);
@@ -31,6 +31,8 @@ export type ProxyServiceWorkerScriptOptions = Readonly<{
31
31
  stripProxyPathPrefix?: boolean;
32
32
  injectHTML?: ProxyServiceWorkerInjectHTMLOptions;
33
33
  forwardFetchMessageTypes?: readonly string[];
34
+ windowTarget?: "registered_runtime" | "request_client";
35
+ windowClientMessageType?: string;
34
36
  conflictHints?: Readonly<{
35
37
  keepScriptPathSuffixes?: readonly string[];
36
38
  }>;
@@ -74,6 +74,18 @@ export function createProxyServiceWorkerScript(opts = {}) {
74
74
  const passthroughPrefixes = normalizePathList("passthrough.prefixes", opts.passthrough?.prefixes);
75
75
  const forwardFetchMessageTypes = normalizeMessageTypeList("forwardFetchMessageTypes", opts.forwardFetchMessageTypes);
76
76
  const keepScriptPathSuffixes = normalizePathList("conflictHints.keepScriptPathSuffixes", opts.conflictHints?.keepScriptPathSuffixes);
77
+ const windowTarget = opts.windowTarget ?? "registered_runtime";
78
+ if (windowTarget !== "registered_runtime" && windowTarget !== "request_client") {
79
+ throw new Error('windowTarget must be either "registered_runtime" or "request_client"');
80
+ }
81
+ const windowClientMessageType = (() => {
82
+ const normalized = typeof opts.windowClientMessageType === "string" ? opts.windowClientMessageType.trim() : "flowersec-proxy:fetch";
83
+ if (normalized === "")
84
+ throw new Error("windowClientMessageType must be non-empty");
85
+ if (/[\r\n]/.test(normalized))
86
+ throw new Error("windowClientMessageType must not contain newline");
87
+ return normalized;
88
+ })();
77
89
  const injectHTML = opts.injectHTML ?? null;
78
90
  // Injection mode defaults to inline_module when injectHTML is provided.
79
91
  const injectMode = injectHTML?.mode ?? "inline_module";
@@ -133,6 +145,8 @@ const INJECT_SET_NO_STORE = ${JSON.stringify(setNoStore)};
133
145
  const MAX_REQUEST_BODY_BYTES = ${JSON.stringify(maxRequestBodyBytes)};
134
146
  const MAX_INJECT_HTML_BYTES = ${JSON.stringify(maxInjectHTMLBytes)};
135
147
  const FORWARD_FETCH_MESSAGE_TYPES = new Set(${JSON.stringify(forwardFetchMessageTypes)});
148
+ const WINDOW_TARGET = ${JSON.stringify(windowTarget)};
149
+ const WINDOW_CLIENT_MESSAGE_TYPE = ${JSON.stringify(windowClientMessageType)};
136
150
  const CONFLICT_HINT_KEEP_SCRIPT_SUFFIXES = ${JSON.stringify(keepScriptPathSuffixes)};
137
151
 
138
152
  const INJECT_STRIP_HEADER_NAMES = new Set(["content-length", "etag", "last-modified", "content-md5"]);
@@ -162,15 +176,19 @@ self.addEventListener("message", (event) => {
162
176
  if (!port) return;
163
177
 
164
178
  event.waitUntil((async () => {
165
- const runtime = await getRuntimeClient();
166
- if (!runtime) {
167
- try { port.postMessage({ type: "flowersec-proxy:response_error", status: 503, message: "flowersec-proxy runtime not available" }); } catch {}
179
+ const preferredClientId =
180
+ WINDOW_TARGET === "request_client" && event.source && typeof event.source.id === "string"
181
+ ? event.source.id
182
+ : "";
183
+ const target = await getWindowClient(preferredClientId);
184
+ if (!target) {
185
+ try { port.postMessage({ type: "flowersec-proxy:response_error", status: 503, message: "flowersec-proxy target window not available" }); } catch {}
168
186
  try { port.close(); } catch {}
169
187
  return;
170
188
  }
171
189
 
172
190
  try {
173
- runtime.postMessage({ type: "flowersec-proxy:fetch", req: data.req }, [port]);
191
+ target.postMessage({ type: WINDOW_CLIENT_MESSAGE_TYPE, req: data.req }, [port]);
174
192
  } catch (e) {
175
193
  const msg = e instanceof Error ? e.message : String(e);
176
194
  try { port.postMessage({ type: "flowersec-proxy:response_error", status: 502, message: msg }); } catch {}
@@ -179,7 +197,16 @@ self.addEventListener("message", (event) => {
179
197
  })());
180
198
  });
181
199
 
182
- async function getRuntimeClient() {
200
+ async function getWindowClient(preferredClientId) {
201
+ if (WINDOW_TARGET === "request_client") {
202
+ if (preferredClientId) {
203
+ const c = await self.clients.get(preferredClientId);
204
+ if (c) return c;
205
+ }
206
+ const cs = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
207
+ return cs.length > 0 ? cs[0] : null;
208
+ }
209
+
183
210
  if (runtimeClientId) {
184
211
  const c = await self.clients.get(runtimeClientId);
185
212
  if (c) return c;
@@ -330,8 +357,12 @@ async function handleFetch(event) {
330
357
  let lastErrorMessage = "proxy error";
331
358
 
332
359
  try {
333
- const runtime = await getRuntimeClient();
334
- if (!runtime) return new Response("flowersec-proxy runtime not available", { status: 503 });
360
+ const preferredClientId =
361
+ WINDOW_TARGET === "request_client"
362
+ ? String(event.clientId || event.resultingClientId || "")
363
+ : "";
364
+ const target = await getWindowClient(preferredClientId);
365
+ if (!target) return new Response("flowersec-proxy target window not available", { status: 503 });
335
366
 
336
367
  const req = event.request;
337
368
  const url = new URL(req.url);
@@ -455,8 +486,8 @@ async function handleFetch(event) {
455
486
  path = "/" + rest + url.search;
456
487
  }
457
488
 
458
- runtime.postMessage({
459
- type: "flowersec-proxy:fetch",
489
+ target.postMessage({
490
+ type: WINDOW_CLIENT_MESSAGE_TYPE,
460
491
  req: { id, method: req.method, path, headers: headersToPairs(req.headers), body }
461
492
  }, [port2]);
462
493
 
@@ -0,0 +1,52 @@
1
+ import type { Header } from "./types.js";
2
+ export declare const PROXY_WINDOW_FETCH_FORWARD_MSG_TYPE = "flowersec-proxy:window_fetch";
3
+ export declare const PROXY_WINDOW_FETCH_MSG_TYPE = "flowersec-proxy:fetch";
4
+ export declare const PROXY_WINDOW_WS_OPEN_MSG_TYPE = "flowersec-proxy:ws_open";
5
+ export declare const PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE = "flowersec-proxy:ws_open_ack";
6
+ export declare const PROXY_WINDOW_WS_ERROR_MSG_TYPE = "flowersec-proxy:ws_error";
7
+ export declare const PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE = "flowersec-proxy:stream_chunk";
8
+ export declare const PROXY_WINDOW_STREAM_END_MSG_TYPE = "flowersec-proxy:stream_end";
9
+ export declare const PROXY_WINDOW_STREAM_RESET_MSG_TYPE = "flowersec-proxy:stream_reset";
10
+ export declare const PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE = "flowersec-proxy:stream_close";
11
+ export type ProxyWindowFetchRequest = Readonly<{
12
+ id: string;
13
+ method: string;
14
+ path: string;
15
+ headers: readonly Header[];
16
+ body?: ArrayBuffer;
17
+ }>;
18
+ export type ProxyWindowFetchForwardMsg = Readonly<{
19
+ type: typeof PROXY_WINDOW_FETCH_FORWARD_MSG_TYPE;
20
+ req: ProxyWindowFetchRequest;
21
+ }>;
22
+ export type ProxyWindowFetchMsg = Readonly<{
23
+ type: typeof PROXY_WINDOW_FETCH_MSG_TYPE;
24
+ req: ProxyWindowFetchRequest;
25
+ }>;
26
+ export type ProxyWindowWsOpenMsg = Readonly<{
27
+ type: typeof PROXY_WINDOW_WS_OPEN_MSG_TYPE;
28
+ path: string;
29
+ protocols?: readonly string[];
30
+ }>;
31
+ export type ProxyWindowWsOpenAckMsg = Readonly<{
32
+ type: typeof PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE;
33
+ protocol: string;
34
+ }>;
35
+ export type ProxyWindowWsErrorMsg = Readonly<{
36
+ type: typeof PROXY_WINDOW_WS_ERROR_MSG_TYPE;
37
+ message: string;
38
+ }>;
39
+ export type ProxyWindowStreamChunkMsg = Readonly<{
40
+ type: typeof PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE;
41
+ data: ArrayBuffer;
42
+ }>;
43
+ export type ProxyWindowStreamEndMsg = Readonly<{
44
+ type: typeof PROXY_WINDOW_STREAM_END_MSG_TYPE;
45
+ }>;
46
+ export type ProxyWindowStreamResetMsg = Readonly<{
47
+ type: typeof PROXY_WINDOW_STREAM_RESET_MSG_TYPE;
48
+ message: string;
49
+ }>;
50
+ export type ProxyWindowStreamCloseMsg = Readonly<{
51
+ type: typeof PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE;
52
+ }>;
@@ -0,0 +1,9 @@
1
+ export const PROXY_WINDOW_FETCH_FORWARD_MSG_TYPE = "flowersec-proxy:window_fetch";
2
+ export const PROXY_WINDOW_FETCH_MSG_TYPE = "flowersec-proxy:fetch";
3
+ export const PROXY_WINDOW_WS_OPEN_MSG_TYPE = "flowersec-proxy:ws_open";
4
+ export const PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE = "flowersec-proxy:ws_open_ack";
5
+ export const PROXY_WINDOW_WS_ERROR_MSG_TYPE = "flowersec-proxy:ws_error";
6
+ export const PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE = "flowersec-proxy:stream_chunk";
7
+ export const PROXY_WINDOW_STREAM_END_MSG_TYPE = "flowersec-proxy:stream_end";
8
+ export const PROXY_WINDOW_STREAM_RESET_MSG_TYPE = "flowersec-proxy:stream_reset";
9
+ export const PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE = "flowersec-proxy:stream_close";
@@ -1,6 +1,16 @@
1
- import type { ProxyRuntime } from "./runtime.js";
1
+ import type { YamuxStream } from "../yamux/stream.js";
2
+ import type { ProxyRuntimeLimits } from "./runtime.js";
2
3
  export type WebSocketPatchOptions = Readonly<{
3
- runtime: ProxyRuntime;
4
+ runtime: Readonly<{
5
+ limits: Partial<ProxyRuntimeLimits>;
6
+ openWebSocketStream: (path: string, opts?: Readonly<{
7
+ protocols?: readonly string[];
8
+ signal?: AbortSignal;
9
+ }>) => Promise<Readonly<{
10
+ stream: YamuxStream;
11
+ protocol: string;
12
+ }>>;
13
+ }>;
4
14
  shouldProxy?: (url: URL) => boolean;
5
15
  maxWsFrameBytes?: number;
6
16
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {