@floegence/flowersec-core 0.6.0 → 0.7.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,8 @@
1
+ export declare const PROXY_PROTOCOL_VERSION: 1;
2
+ export declare const PROXY_KIND_HTTP1: "flowersec-proxy/http1";
3
+ export declare const PROXY_KIND_WS: "flowersec-proxy/ws";
4
+ export declare const DEFAULT_MAX_CHUNK_BYTES: number;
5
+ export declare const DEFAULT_MAX_BODY_BYTES: number;
6
+ export declare const DEFAULT_MAX_WS_FRAME_BYTES: number;
7
+ export declare const DEFAULT_DEFAULT_TIMEOUT_MS = 30000;
8
+ export declare const DEFAULT_MAX_TIMEOUT_MS: number;
@@ -0,0 +1,8 @@
1
+ export const PROXY_PROTOCOL_VERSION = 1;
2
+ export const PROXY_KIND_HTTP1 = "flowersec-proxy/http1";
3
+ export const PROXY_KIND_WS = "flowersec-proxy/ws";
4
+ export const DEFAULT_MAX_CHUNK_BYTES = 256 * 1024;
5
+ export const DEFAULT_MAX_BODY_BYTES = 64 * 1024 * 1024;
6
+ export const DEFAULT_MAX_WS_FRAME_BYTES = 1024 * 1024;
7
+ export const DEFAULT_DEFAULT_TIMEOUT_MS = 30_000;
8
+ export const DEFAULT_MAX_TIMEOUT_MS = 5 * 60_000;
@@ -0,0 +1,6 @@
1
+ export declare class CookieJar {
2
+ private readonly cookies;
3
+ setCookie(setCookieHeader: string): void;
4
+ updateFromSetCookieHeaders(headers: readonly string[]): void;
5
+ getCookieHeader(path: string): string;
6
+ }
@@ -0,0 +1,88 @@
1
+ function nowMs() {
2
+ return Date.now();
3
+ }
4
+ function parseCookieNameValue(s) {
5
+ const idx = s.indexOf("=");
6
+ if (idx <= 0)
7
+ return null;
8
+ const name = s.slice(0, idx).trim();
9
+ const value = s.slice(idx + 1).trim();
10
+ if (name === "")
11
+ return null;
12
+ return { name, value };
13
+ }
14
+ export class CookieJar {
15
+ cookies = new Map();
16
+ setCookie(setCookieHeader) {
17
+ const raw = setCookieHeader.trim();
18
+ if (raw === "")
19
+ return;
20
+ const parts = raw.split(";").map((p) => p.trim()).filter((p) => p !== "");
21
+ if (parts.length === 0)
22
+ return;
23
+ const nv = parseCookieNameValue(parts[0] ?? "");
24
+ if (nv == null)
25
+ return;
26
+ let path = "/";
27
+ let expiresAtMs;
28
+ for (let i = 1; i < parts.length; i++) {
29
+ const p = parts[i];
30
+ const lower = p.toLowerCase();
31
+ if (lower.startsWith("path=")) {
32
+ const v = p.slice("path=".length).trim();
33
+ if (v !== "")
34
+ path = v;
35
+ continue;
36
+ }
37
+ if (lower.startsWith("max-age=")) {
38
+ const v = p.slice("max-age=".length).trim();
39
+ const n = Number.parseInt(v, 10);
40
+ if (!Number.isFinite(n))
41
+ continue;
42
+ if (n <= 0) {
43
+ this.cookies.delete(nv.name);
44
+ return;
45
+ }
46
+ expiresAtMs = nowMs() + n * 1000;
47
+ continue;
48
+ }
49
+ if (lower.startsWith("expires=")) {
50
+ const v = p.slice("expires=".length).trim();
51
+ const t = Date.parse(v);
52
+ if (!Number.isFinite(t))
53
+ continue;
54
+ expiresAtMs = t;
55
+ continue;
56
+ }
57
+ }
58
+ // Expired via Expires.
59
+ if (expiresAtMs != null && expiresAtMs <= nowMs()) {
60
+ this.cookies.delete(nv.name);
61
+ return;
62
+ }
63
+ if (expiresAtMs == null) {
64
+ this.cookies.set(nv.name, { name: nv.name, value: nv.value, path });
65
+ }
66
+ else {
67
+ this.cookies.set(nv.name, { name: nv.name, value: nv.value, path, expiresAtMs });
68
+ }
69
+ }
70
+ updateFromSetCookieHeaders(headers) {
71
+ for (const h of headers)
72
+ this.setCookie(h);
73
+ }
74
+ getCookieHeader(path) {
75
+ const now = nowMs();
76
+ const pairs = [];
77
+ for (const c of this.cookies.values()) {
78
+ if (c.expiresAtMs != null && c.expiresAtMs <= now) {
79
+ this.cookies.delete(c.name);
80
+ continue;
81
+ }
82
+ if (c.path !== "/" && !path.startsWith(c.path))
83
+ continue;
84
+ pairs.push(`${c.name}=${c.value}`);
85
+ }
86
+ return pairs.join("; ");
87
+ }
88
+ }
@@ -0,0 +1 @@
1
+ export declare function disableUpstreamServiceWorkerRegister(): void;
@@ -0,0 +1,23 @@
1
+ // disableUpstreamServiceWorkerRegister blocks upstream apps from registering their own Service Worker.
2
+ //
3
+ // This is required for runtime-mode proxying where a proxy SW must control the app scope.
4
+ export function disableUpstreamServiceWorkerRegister() {
5
+ try {
6
+ const sw = globalThis.navigator?.serviceWorker;
7
+ if (!sw || typeof sw.register !== "function")
8
+ return;
9
+ const blocked = () => {
10
+ throw new Error("service worker register is disabled by flowersec-proxy runtime");
11
+ };
12
+ // Best-effort. Some environments may not allow overriding the property.
13
+ try {
14
+ sw.register = blocked;
15
+ }
16
+ catch {
17
+ // Ignore.
18
+ }
19
+ }
20
+ catch {
21
+ // Ignore.
22
+ }
23
+ }
@@ -0,0 +1,14 @@
1
+ import type { Header } from "./types.js";
2
+ export declare function normalizeHeaderName(name: string): string;
3
+ export declare function isValidHeaderName(name: string): boolean;
4
+ export declare function isSafeHeaderValue(value: string): boolean;
5
+ export type FilterHeadersOptions = Readonly<{
6
+ extraAllowed?: readonly string[];
7
+ }>;
8
+ export declare function filterRequestHeaders(input: readonly Header[], opts?: FilterHeadersOptions): Header[];
9
+ export type FilterResponseResult = Readonly<{
10
+ passthrough: Header[];
11
+ setCookie: string[];
12
+ }>;
13
+ export declare function filterResponseHeaders(input: readonly Header[], opts?: FilterHeadersOptions): FilterResponseResult;
14
+ export declare function filterWsOpenHeaders(input: readonly Header[], opts?: FilterHeadersOptions): Header[];
@@ -0,0 +1,124 @@
1
+ const DEFAULT_REQUEST_HEADER_ALLOWLIST = new Set([
2
+ "accept",
3
+ "accept-language",
4
+ "cache-control",
5
+ "content-type",
6
+ "if-match",
7
+ "if-modified-since",
8
+ "if-none-match",
9
+ "if-unmodified-since",
10
+ "pragma",
11
+ "range",
12
+ "x-requested-with"
13
+ ]);
14
+ const DEFAULT_RESPONSE_HEADER_ALLOWLIST = new Set([
15
+ "cache-control",
16
+ "content-disposition",
17
+ "content-encoding",
18
+ "content-language",
19
+ "content-type",
20
+ "etag",
21
+ "expires",
22
+ "last-modified",
23
+ "location",
24
+ "pragma",
25
+ "vary",
26
+ "www-authenticate",
27
+ "set-cookie"
28
+ ]);
29
+ const DEFAULT_WS_HEADER_ALLOWLIST = new Set(["sec-websocket-protocol", "cookie"]);
30
+ export function normalizeHeaderName(name) {
31
+ return name.trim().toLowerCase();
32
+ }
33
+ export function isValidHeaderName(name) {
34
+ // Minimal RFC 7230 token validation; keep strict to avoid smuggling bugs.
35
+ for (let i = 0; i < name.length; i++) {
36
+ const c = name.charCodeAt(i);
37
+ const isAlpha = c >= 0x61 && c <= 0x7a;
38
+ const isDigit = c >= 0x30 && c <= 0x39;
39
+ if (isAlpha || isDigit)
40
+ continue;
41
+ // ! # $ % & ' * + - . ^ _ ` | ~
42
+ if (c === 0x21 || c === 0x23 || c === 0x24 || c === 0x25 || c === 0x26 || c === 0x27)
43
+ continue;
44
+ if (c === 0x2a || c === 0x2b || c === 0x2d || c === 0x2e)
45
+ continue;
46
+ if (c === 0x5e || c === 0x5f || c === 0x60 || c === 0x7c || c === 0x7e)
47
+ continue;
48
+ return false;
49
+ }
50
+ return name.length > 0;
51
+ }
52
+ export function isSafeHeaderValue(value) {
53
+ return !value.includes("\r") && !value.includes("\n");
54
+ }
55
+ function buildExtraAllowedSet(extraAllowed) {
56
+ if (extraAllowed == null || extraAllowed.length === 0)
57
+ return null;
58
+ const s = new Set();
59
+ for (const n of extraAllowed) {
60
+ const nn = normalizeHeaderName(n);
61
+ if (nn !== "" && isValidHeaderName(nn))
62
+ s.add(nn);
63
+ }
64
+ return s;
65
+ }
66
+ function isAllowed(allow, extra, name) {
67
+ return allow.has(name) || (extra != null && extra.has(name));
68
+ }
69
+ export function filterRequestHeaders(input, opts = {}) {
70
+ const extra = buildExtraAllowedSet(opts.extraAllowed);
71
+ const out = [];
72
+ for (const h of input) {
73
+ const name = normalizeHeaderName(h.name);
74
+ if (name === "" || !isValidHeaderName(name))
75
+ continue;
76
+ if (!isSafeHeaderValue(h.value))
77
+ continue;
78
+ // Never forward these from the client runtime.
79
+ if (name === "host" || name === "authorization")
80
+ continue;
81
+ // Cookie is injected by the runtime CookieJar (not copied from browser cookie store).
82
+ if (name === "cookie")
83
+ continue;
84
+ if (!isAllowed(DEFAULT_REQUEST_HEADER_ALLOWLIST, extra, name))
85
+ continue;
86
+ out.push({ name, value: h.value });
87
+ }
88
+ return out;
89
+ }
90
+ export function filterResponseHeaders(input, opts = {}) {
91
+ const extra = buildExtraAllowedSet(opts.extraAllowed);
92
+ const passthrough = [];
93
+ const setCookie = [];
94
+ for (const h of input) {
95
+ const name = normalizeHeaderName(h.name);
96
+ if (name === "" || !isValidHeaderName(name))
97
+ continue;
98
+ if (!isSafeHeaderValue(h.value))
99
+ continue;
100
+ if (!isAllowed(DEFAULT_RESPONSE_HEADER_ALLOWLIST, extra, name))
101
+ continue;
102
+ if (name === "set-cookie") {
103
+ setCookie.push(h.value);
104
+ continue;
105
+ }
106
+ passthrough.push({ name, value: h.value });
107
+ }
108
+ return { passthrough, setCookie };
109
+ }
110
+ export function filterWsOpenHeaders(input, opts = {}) {
111
+ const extra = buildExtraAllowedSet(opts.extraAllowed);
112
+ const out = [];
113
+ for (const h of input) {
114
+ const name = normalizeHeaderName(h.name);
115
+ if (name === "" || !isValidHeaderName(name))
116
+ continue;
117
+ if (!isSafeHeaderValue(h.value))
118
+ continue;
119
+ if (!isAllowed(DEFAULT_WS_HEADER_ALLOWLIST, extra, name))
120
+ continue;
121
+ out.push({ name, value: h.value });
122
+ }
123
+ return out;
124
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./constants.js";
2
+ export * from "./types.js";
3
+ export * from "./cookieJar.js";
4
+ export * from "./runtime.js";
5
+ export * from "./serviceWorker.js";
6
+ export * from "./wsPatch.js";
7
+ export * from "./disableUpstreamServiceWorkerRegister.js";
@@ -0,0 +1,7 @@
1
+ export * from "./constants.js";
2
+ export * from "./types.js";
3
+ export * from "./cookieJar.js";
4
+ export * from "./runtime.js";
5
+ export * from "./serviceWorker.js";
6
+ export * from "./wsPatch.js";
7
+ export * from "./disableUpstreamServiceWorkerRegister.js";
@@ -0,0 +1,33 @@
1
+ import type { Client } from "../client.js";
2
+ import type { YamuxStream } from "../yamux/stream.js";
3
+ import { CookieJar } from "./cookieJar.js";
4
+ export type ProxyRuntimeLimits = Readonly<{
5
+ maxJsonFrameBytes: number;
6
+ maxChunkBytes: number;
7
+ maxBodyBytes: number;
8
+ maxWsFrameBytes: number;
9
+ }>;
10
+ export type ProxyRuntime = Readonly<{
11
+ cookieJar: CookieJar;
12
+ limits: ProxyRuntimeLimits;
13
+ dispose: () => void;
14
+ openWebSocketStream: (path: string, opts?: Readonly<{
15
+ protocols?: readonly string[];
16
+ signal?: AbortSignal;
17
+ }>) => Promise<Readonly<{
18
+ stream: YamuxStream;
19
+ protocol: string;
20
+ }>>;
21
+ }>;
22
+ export type ProxyRuntimeOptions = Readonly<{
23
+ client: Client;
24
+ maxJsonFrameBytes?: number;
25
+ maxChunkBytes?: number;
26
+ maxBodyBytes?: number;
27
+ maxWsFrameBytes?: number;
28
+ timeoutMs?: number;
29
+ extraRequestHeaders?: readonly string[];
30
+ extraResponseHeaders?: readonly string[];
31
+ extraWsHeaders?: readonly string[];
32
+ }>;
33
+ export declare function createProxyRuntime(opts: ProxyRuntimeOptions): ProxyRuntime;
@@ -0,0 +1,237 @@
1
+ import { DEFAULT_MAX_JSON_FRAME_BYTES, readJsonFrame, writeJsonFrame } from "../framing/jsonframe.js";
2
+ import { createByteReader } from "../streamio/index.js";
3
+ import { base64urlEncode } from "../utils/base64url.js";
4
+ import { readU32be, u32be } from "../utils/bin.js";
5
+ import { CookieJar } from "./cookieJar.js";
6
+ import { DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_CHUNK_BYTES, DEFAULT_MAX_WS_FRAME_BYTES, PROXY_KIND_HTTP1, PROXY_KIND_WS, PROXY_PROTOCOL_VERSION } from "./constants.js";
7
+ import { filterRequestHeaders, filterResponseHeaders, filterWsOpenHeaders } from "./headerPolicy.js";
8
+ function randomB64u(bytes) {
9
+ const b = new Uint8Array(bytes);
10
+ if (globalThis.crypto?.getRandomValues) {
11
+ globalThis.crypto.getRandomValues(b);
12
+ }
13
+ else {
14
+ for (let i = 0; i < b.length; i++)
15
+ b[i] = Math.floor(Math.random() * 256);
16
+ }
17
+ return base64urlEncode(b);
18
+ }
19
+ function pathOnly(path) {
20
+ const p = path.trim();
21
+ if (!p.startsWith("/"))
22
+ throw new Error("path must start with /");
23
+ if (p.startsWith("//"))
24
+ throw new Error("path must not start with //");
25
+ if (/[ \t\r\n]/.test(p))
26
+ throw new Error("path contains whitespace");
27
+ if (p.includes("://"))
28
+ throw new Error("path must not include scheme/host");
29
+ return p;
30
+ }
31
+ function cookiePathFromRequestPath(path) {
32
+ const q = path.indexOf("?");
33
+ return q >= 0 ? path.slice(0, q) : path;
34
+ }
35
+ function normalizeTimeoutMs(timeoutMs) {
36
+ const v = Math.floor(timeoutMs ?? 0);
37
+ if (v < 0)
38
+ throw new Error("timeout_ms must be >= 0");
39
+ return v;
40
+ }
41
+ function normalizeMaxBytes(name, v, defaultValue) {
42
+ if (v == null)
43
+ return defaultValue;
44
+ if (!Number.isFinite(v))
45
+ throw new Error(`${name} must be a finite number`);
46
+ const n = Math.floor(v);
47
+ if (!Number.isSafeInteger(n))
48
+ throw new Error(`${name} must be a safe integer`);
49
+ if (n < 0)
50
+ throw new Error(`${name} must be >= 0`);
51
+ if (n === 0)
52
+ return defaultValue;
53
+ return n;
54
+ }
55
+ async function writeChunkFrames(stream, body, chunkSize, maxBodyBytes) {
56
+ if (maxBodyBytes > 0 && body.length > maxBodyBytes)
57
+ throw new Error("request body too large");
58
+ const n = Math.max(1, Math.floor(chunkSize));
59
+ let off = 0;
60
+ while (off < body.length) {
61
+ const end = Math.min(body.length, off + n);
62
+ const chunk = body.subarray(off, end);
63
+ await stream.write(u32be(chunk.length));
64
+ await stream.write(chunk);
65
+ off = end;
66
+ }
67
+ await stream.write(u32be(0));
68
+ }
69
+ async function readChunkFrames(reader, maxChunkBytes, maxBodyBytes) {
70
+ async function* gen() {
71
+ let total = 0;
72
+ while (true) {
73
+ const lenBuf = await reader.readExactly(4);
74
+ const n = readU32be(lenBuf, 0);
75
+ if (n === 0)
76
+ return;
77
+ if (maxChunkBytes > 0 && n > maxChunkBytes)
78
+ throw new Error("response chunk too large");
79
+ total += n;
80
+ if (maxBodyBytes > 0 && total > maxBodyBytes)
81
+ throw new Error("response body too large");
82
+ const payload = await reader.readExactly(n);
83
+ yield payload;
84
+ }
85
+ }
86
+ return gen();
87
+ }
88
+ export function createProxyRuntime(opts) {
89
+ const client = opts.client;
90
+ const cookieJar = new CookieJar();
91
+ const maxJsonFrameBytes = normalizeMaxBytes("maxJsonFrameBytes", opts.maxJsonFrameBytes, DEFAULT_MAX_JSON_FRAME_BYTES);
92
+ const maxChunkBytes = normalizeMaxBytes("maxChunkBytes", opts.maxChunkBytes, DEFAULT_MAX_CHUNK_BYTES);
93
+ const maxBodyBytes = normalizeMaxBytes("maxBodyBytes", opts.maxBodyBytes, DEFAULT_MAX_BODY_BYTES);
94
+ const maxWsFrameBytes = normalizeMaxBytes("maxWsFrameBytes", opts.maxWsFrameBytes, DEFAULT_MAX_WS_FRAME_BYTES);
95
+ const timeoutMs = normalizeTimeoutMs(opts.timeoutMs);
96
+ const extraRequestHeaders = opts.extraRequestHeaders ?? [];
97
+ const extraResponseHeaders = opts.extraResponseHeaders ?? [];
98
+ const extraWsHeaders = opts.extraWsHeaders ?? [];
99
+ const registerRuntime = () => {
100
+ try {
101
+ const ctl = globalThis.navigator?.serviceWorker?.controller;
102
+ ctl?.postMessage({ type: "flowersec-proxy:register-runtime" });
103
+ }
104
+ catch {
105
+ // Best-effort: runtime can still work if SW picks it via matchAll().
106
+ }
107
+ };
108
+ const onMessage = (ev) => {
109
+ const data = ev.data;
110
+ if (data == null || typeof data !== "object")
111
+ return;
112
+ if (data.type !== "flowersec-proxy:fetch")
113
+ return;
114
+ const msg = data;
115
+ const port = ev.ports?.[0];
116
+ if (!port)
117
+ return;
118
+ void handleFetch(msg.req, port);
119
+ };
120
+ const sw = globalThis.navigator?.serviceWorker;
121
+ sw?.addEventListener("message", onMessage);
122
+ sw?.addEventListener("controllerchange", registerRuntime);
123
+ registerRuntime();
124
+ async function handleFetch(req, port) {
125
+ const ac = new AbortController();
126
+ let stream = null;
127
+ port.onmessage = (ev) => {
128
+ const m = ev.data;
129
+ if (m && typeof m === "object" && m.type === "flowersec-proxy:abort") {
130
+ ac.abort("aborted");
131
+ }
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");
154
+ }
155
+ if (!respMeta.ok) {
156
+ const msg = respMeta.error?.message ?? "upstream error";
157
+ port.postMessage({ type: "flowersec-proxy:response_error", status: 502, message: msg });
158
+ try {
159
+ stream.reset(new Error(msg));
160
+ }
161
+ catch {
162
+ // Best-effort.
163
+ }
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
+ }
196
+ catch {
197
+ // Best-effort.
198
+ }
199
+ }
200
+ }
201
+ async function openWebSocketStream(pathRaw, wsOpts = {}) {
202
+ const path = pathOnly(pathRaw);
203
+ const openOpts = wsOpts.signal ? { signal: wsOpts.signal } : undefined;
204
+ const stream = await client.openStream(PROXY_KIND_WS, openOpts);
205
+ const reader = createByteReader(stream, openOpts);
206
+ const cookie = cookieJar.getCookieHeader(cookiePathFromRequestPath(path));
207
+ const headers = [];
208
+ const protos = wsOpts.protocols?.filter((p) => p.trim() !== "") ?? [];
209
+ if (protos.length > 0)
210
+ headers.push({ name: "sec-websocket-protocol", value: protos.join(", ") });
211
+ if (cookie !== "")
212
+ headers.push({ name: "cookie", value: cookie });
213
+ const filtered = filterWsOpenHeaders(headers, { extraAllowed: extraWsHeaders });
214
+ await writeJsonFrame(stream, { v: PROXY_PROTOCOL_VERSION, conn_id: randomB64u(18), path, headers: filtered });
215
+ const resp = (await readJsonFrame(reader, maxJsonFrameBytes));
216
+ if (resp.v !== PROXY_PROTOCOL_VERSION || resp.ok !== true) {
217
+ const msg = resp.error?.message ?? "upstream ws open failed";
218
+ try {
219
+ stream.reset(new Error(msg));
220
+ }
221
+ catch {
222
+ // Best-effort.
223
+ }
224
+ throw new Error(msg);
225
+ }
226
+ return { stream, protocol: resp.protocol ?? "" };
227
+ }
228
+ return {
229
+ cookieJar,
230
+ limits: { maxJsonFrameBytes, maxChunkBytes, maxBodyBytes, maxWsFrameBytes },
231
+ openWebSocketStream,
232
+ dispose: () => {
233
+ sw?.removeEventListener("message", onMessage);
234
+ sw?.removeEventListener("controllerchange", registerRuntime);
235
+ }
236
+ };
237
+ }
@@ -0,0 +1,9 @@
1
+ export type ProxyServiceWorkerScriptOptions = Readonly<{
2
+ proxyPathPrefix?: string;
3
+ stripProxyPathPrefix?: boolean;
4
+ injectHTML?: Readonly<{
5
+ proxyModuleUrl: string;
6
+ runtimeGlobal?: string;
7
+ }>;
8
+ }>;
9
+ export declare function createProxyServiceWorkerScript(opts?: ProxyServiceWorkerScriptOptions): string;
@@ -0,0 +1,197 @@
1
+ // createProxyServiceWorkerScript returns a Service Worker script that forwards fetches to a runtime
2
+ // in a controlled window via postMessage + MessageChannel.
3
+ //
4
+ // The runtime side is implemented by createProxyRuntime(...) in this package.
5
+ export function createProxyServiceWorkerScript(opts = {}) {
6
+ const proxyPathPrefix = opts.proxyPathPrefix ?? "";
7
+ const stripProxyPathPrefix = opts.stripProxyPathPrefix ?? false;
8
+ const injectHTML = opts.injectHTML ?? null;
9
+ const proxyModuleUrl = injectHTML?.proxyModuleUrl?.trim() ?? "";
10
+ const runtimeGlobal = injectHTML?.runtimeGlobal?.trim() ?? "__flowersecProxyRuntime";
11
+ if (injectHTML != null && proxyModuleUrl === "") {
12
+ throw new Error("injectHTML.proxyModuleUrl must be non-empty");
13
+ }
14
+ if (injectHTML != null && runtimeGlobal === "") {
15
+ throw new Error("injectHTML.runtimeGlobal must be non-empty");
16
+ }
17
+ return `// Generated by @floegence/flowersec-core/proxy
18
+ const PROXY_PATH_PREFIX = ${JSON.stringify(proxyPathPrefix)};
19
+ const STRIP_PROXY_PATH_PREFIX = ${JSON.stringify(stripProxyPathPrefix)};
20
+ const INJECT_HTML = ${JSON.stringify(injectHTML != null)};
21
+ const PROXY_MODULE_URL = ${JSON.stringify(proxyModuleUrl)};
22
+ const RUNTIME_GLOBAL = ${JSON.stringify(runtimeGlobal)};
23
+ let runtimeClientId = null;
24
+
25
+ self.addEventListener("install", () => {
26
+ // Take over as soon as possible.
27
+ void self.skipWaiting();
28
+ });
29
+
30
+ self.addEventListener("activate", (event) => {
31
+ event.waitUntil(self.clients.claim());
32
+ });
33
+
34
+ self.addEventListener("message", (event) => {
35
+ const data = event.data;
36
+ if (!data || typeof data !== "object") return;
37
+ if (data.type !== "flowersec-proxy:register-runtime") return;
38
+ if (event.source && typeof event.source.id === "string") runtimeClientId = event.source.id;
39
+ });
40
+
41
+ async function getRuntimeClient() {
42
+ if (runtimeClientId) {
43
+ const c = await self.clients.get(runtimeClientId);
44
+ if (c) return c;
45
+ runtimeClientId = null;
46
+ }
47
+ const cs = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
48
+ if (cs.length > 0) {
49
+ runtimeClientId = cs[0].id;
50
+ return cs[0];
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function headersToPairs(headers) {
56
+ const out = [];
57
+ headers.forEach((value, name) => out.push({ name, value }));
58
+ return out;
59
+ }
60
+
61
+ function concatChunks(chunks) {
62
+ let total = 0;
63
+ for (const c of chunks) total += c.length;
64
+ const out = new Uint8Array(total);
65
+ let off = 0;
66
+ for (const c of chunks) {
67
+ out.set(c, off);
68
+ off += c.length;
69
+ }
70
+ return out;
71
+ }
72
+
73
+ function injectBootstrap(html) {
74
+ const snippet =
75
+ '<script type="module">' +
76
+ 'import { installWebSocketPatch, disableUpstreamServiceWorkerRegister } from ' + JSON.stringify(PROXY_MODULE_URL) + ';' +
77
+ 'const rt = window.top && window.top[' + JSON.stringify(RUNTIME_GLOBAL) + '];' +
78
+ 'if (rt) { disableUpstreamServiceWorkerRegister(); installWebSocketPatch({ runtime: rt }); }' +
79
+ '</script>';
80
+ const lower = html.toLowerCase();
81
+ const idx = lower.indexOf("<head");
82
+ if (idx >= 0) {
83
+ const end = html.indexOf(">", idx);
84
+ if (end >= 0) return html.slice(0, end + 1) + snippet + html.slice(end + 1);
85
+ }
86
+ return snippet + html;
87
+ }
88
+
89
+ self.addEventListener("fetch", (event) => {
90
+ const url = new URL(event.request.url);
91
+ if (PROXY_PATH_PREFIX && !url.pathname.startsWith(PROXY_PATH_PREFIX)) return;
92
+ event.respondWith(handleFetch(event));
93
+ });
94
+
95
+ async function handleFetch(event) {
96
+ const runtime = await getRuntimeClient();
97
+ if (!runtime) return new Response("flowersec-proxy runtime not available", { status: 503 });
98
+
99
+ const req = event.request;
100
+ const url = new URL(req.url);
101
+ const id = Math.random().toString(16).slice(2) + Date.now().toString(16);
102
+ const body = (req.method === "GET" || req.method === "HEAD") ? undefined : await req.arrayBuffer();
103
+
104
+ const ch = new MessageChannel();
105
+ const port = ch.port1;
106
+ const port2 = ch.port2;
107
+
108
+ let metaResolve;
109
+ let metaReject;
110
+ const metaPromise = new Promise((resolve, reject) => { metaResolve = resolve; metaReject = reject; });
111
+
112
+ const queued = [];
113
+ const htmlChunks = [];
114
+ let shouldInjectHTML = false;
115
+
116
+ let doneResolve;
117
+ let doneReject;
118
+ const donePromise = new Promise((resolve, reject) => { doneResolve = resolve; doneReject = reject; });
119
+
120
+ let controller = null;
121
+
122
+ const stream = new ReadableStream({
123
+ start(c) { controller = c; },
124
+ cancel() {
125
+ try { port.postMessage({ type: "flowersec-proxy:abort" }); } catch {}
126
+ try { port.close(); } catch {}
127
+ }
128
+ });
129
+
130
+ port.onmessage = (ev) => {
131
+ const m = ev.data;
132
+ if (!m || typeof m.type !== "string") return;
133
+ if (m.type === "flowersec-proxy:response_meta") {
134
+ if (INJECT_HTML) {
135
+ const ct = String((m.headers || []).find((h) => (h.name || "").toLowerCase() === "content-type")?.value || "");
136
+ shouldInjectHTML = ct.toLowerCase().includes("text/html");
137
+ }
138
+ metaResolve(m);
139
+ if (controller && !shouldInjectHTML) for (const q of queued) controller.enqueue(q);
140
+ if (shouldInjectHTML) for (const q of queued) htmlChunks.push(q);
141
+ queued.length = 0;
142
+ return;
143
+ }
144
+ if (m.type === "flowersec-proxy:response_chunk") {
145
+ const b = new Uint8Array(m.data);
146
+ if (shouldInjectHTML) {
147
+ htmlChunks.push(b);
148
+ return;
149
+ }
150
+ if (controller) controller.enqueue(b); else queued.push(b);
151
+ return;
152
+ }
153
+ if (m.type === "flowersec-proxy:response_end") {
154
+ if (shouldInjectHTML) {
155
+ doneResolve(htmlChunks);
156
+ return;
157
+ }
158
+ controller?.close();
159
+ return;
160
+ }
161
+ if (m.type === "flowersec-proxy:response_error") {
162
+ const err = new Error(m.message || "proxy error");
163
+ metaReject(err);
164
+ doneReject(err);
165
+ controller?.error(err);
166
+ try { port.close(); } catch {}
167
+ return;
168
+ }
169
+ };
170
+
171
+ let path = url.pathname + url.search;
172
+ if (PROXY_PATH_PREFIX && STRIP_PROXY_PATH_PREFIX) {
173
+ let rest = url.pathname.slice(PROXY_PATH_PREFIX.length);
174
+ if (rest.startsWith("/")) rest = rest.slice(1);
175
+ path = "/" + rest + url.search;
176
+ }
177
+
178
+ runtime.postMessage({
179
+ type: "flowersec-proxy:fetch",
180
+ req: { id, method: req.method, path, headers: headersToPairs(req.headers), body }
181
+ }, [port2]);
182
+
183
+ const meta = await metaPromise;
184
+ const headers = new Headers();
185
+ for (const h of (meta.headers || [])) headers.append(h.name, h.value);
186
+ if (shouldInjectHTML) {
187
+ const chunks = await donePromise;
188
+ const raw = concatChunks(chunks);
189
+ const html = new TextDecoder().decode(raw);
190
+ const injected = injectBootstrap(html);
191
+ return new Response(new TextEncoder().encode(injected), { status: meta.status || 502, headers });
192
+ }
193
+
194
+ return new Response(stream, { status: meta.status || 502, headers });
195
+ }
196
+ `;
197
+ }
@@ -0,0 +1,37 @@
1
+ export type Header = Readonly<{
2
+ name: string;
3
+ value: string;
4
+ }>;
5
+ export type ProxyError = Readonly<{
6
+ code: string;
7
+ message: string;
8
+ }>;
9
+ export type HttpRequestMetaV1 = Readonly<{
10
+ v: 1;
11
+ request_id: string;
12
+ method: string;
13
+ path: string;
14
+ headers: Header[];
15
+ timeout_ms?: number;
16
+ }>;
17
+ export type HttpResponseMetaV1 = Readonly<{
18
+ v: 1;
19
+ request_id: string;
20
+ ok: boolean;
21
+ status?: number;
22
+ headers?: Header[];
23
+ error?: ProxyError;
24
+ }>;
25
+ export type WsOpenMetaV1 = Readonly<{
26
+ v: 1;
27
+ conn_id: string;
28
+ path: string;
29
+ headers: Header[];
30
+ }>;
31
+ export type WsOpenRespV1 = Readonly<{
32
+ v: 1;
33
+ conn_id: string;
34
+ ok: boolean;
35
+ protocol?: string;
36
+ error?: ProxyError;
37
+ }>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { ProxyRuntime } from "./runtime.js";
2
+ export type WebSocketPatchOptions = Readonly<{
3
+ runtime: ProxyRuntime;
4
+ shouldProxy?: (url: URL) => boolean;
5
+ maxWsFrameBytes?: number;
6
+ }>;
7
+ export declare function installWebSocketPatch(opts: WebSocketPatchOptions): Readonly<{
8
+ uninstall: () => void;
9
+ }>;
@@ -0,0 +1,272 @@
1
+ import { createByteReader } from "../streamio/index.js";
2
+ import { readU32be, u16be, u32be } from "../utils/bin.js";
3
+ import { DEFAULT_MAX_WS_FRAME_BYTES } from "./constants.js";
4
+ function readU16be(buf, off) {
5
+ return ((buf[off] << 8) | buf[off + 1]) >>> 0;
6
+ }
7
+ const te = new TextEncoder();
8
+ const td = new TextDecoder();
9
+ function defaultPortForProtocol(protocol) {
10
+ // Protocol strings include the trailing ':' in URL (e.g. 'http:', 'ws:').
11
+ if (protocol === "http:" || protocol === "ws:")
12
+ return "80";
13
+ if (protocol === "https:" || protocol === "wss:")
14
+ return "443";
15
+ return "";
16
+ }
17
+ class ListenerMap {
18
+ map = new Map();
19
+ on(type, cb) {
20
+ let s = this.map.get(type);
21
+ if (!s) {
22
+ s = new Set();
23
+ this.map.set(type, s);
24
+ }
25
+ s.add(cb);
26
+ }
27
+ off(type, cb) {
28
+ this.map.get(type)?.delete(cb);
29
+ }
30
+ emit(type, ev) {
31
+ for (const cb of this.map.get(type) ?? []) {
32
+ try {
33
+ cb.call(null, ev);
34
+ }
35
+ catch {
36
+ // Best-effort event fanout.
37
+ }
38
+ }
39
+ }
40
+ }
41
+ async function writeWSFrame(stream, op, payload, maxPayload) {
42
+ if (maxPayload > 0 && payload.length > maxPayload)
43
+ throw new Error("ws payload too large");
44
+ const hdr = new Uint8Array(5);
45
+ hdr[0] = op & 0xff;
46
+ hdr.set(u32be(payload.length), 1);
47
+ await stream.write(hdr);
48
+ if (payload.length > 0)
49
+ await stream.write(payload);
50
+ }
51
+ async function readWSFrame(reader, maxPayload) {
52
+ const hdr = await reader.readExactly(5);
53
+ const op = hdr[0];
54
+ const n = readU32be(hdr, 1);
55
+ if (maxPayload > 0 && n > maxPayload)
56
+ throw new Error("ws payload too large");
57
+ const payload = n === 0 ? new Uint8Array() : await reader.readExactly(n);
58
+ return { op, payload };
59
+ }
60
+ export function installWebSocketPatch(opts) {
61
+ const Original = globalThis.WebSocket;
62
+ if (Original == null) {
63
+ return { uninstall: () => { } };
64
+ }
65
+ const shouldProxy = opts.shouldProxy ??
66
+ ((u) => {
67
+ const loc = globalThis.location;
68
+ const hostname = typeof loc?.hostname === "string" ? loc.hostname : "";
69
+ if (hostname === "")
70
+ return false;
71
+ const locProto = typeof loc?.protocol === "string" ? loc.protocol : "";
72
+ const locPortRaw = typeof loc?.port === "string" ? loc.port : "";
73
+ const locPort = locPortRaw !== "" ? locPortRaw : defaultPortForProtocol(locProto);
74
+ const uPort = u.port !== "" ? u.port : defaultPortForProtocol(u.protocol);
75
+ return u.hostname === hostname && uPort === locPort;
76
+ });
77
+ const runtime = opts.runtime;
78
+ const runtimeMaxWsFrameBytes = typeof runtime.limits?.maxWsFrameBytes === "number" && Number.isFinite(runtime.limits.maxWsFrameBytes)
79
+ ? runtime.limits.maxWsFrameBytes
80
+ : DEFAULT_MAX_WS_FRAME_BYTES;
81
+ const maxWsFrameBytesRaw = opts.maxWsFrameBytes ?? runtimeMaxWsFrameBytes;
82
+ if (!Number.isFinite(maxWsFrameBytesRaw))
83
+ throw new Error("maxWsFrameBytes must be a finite number");
84
+ const maxWsFrameBytesFloor = Math.floor(maxWsFrameBytesRaw);
85
+ if (maxWsFrameBytesFloor < 0)
86
+ throw new Error("maxWsFrameBytes must be >= 0");
87
+ const maxWsFrameBytes = maxWsFrameBytesFloor === 0 ? runtimeMaxWsFrameBytes : maxWsFrameBytesFloor;
88
+ class PatchedWebSocket {
89
+ static CONNECTING = 0;
90
+ static OPEN = 1;
91
+ static CLOSING = 2;
92
+ static CLOSED = 3;
93
+ url = "";
94
+ readyState = PatchedWebSocket.CONNECTING;
95
+ bufferedAmount = 0;
96
+ extensions = "";
97
+ protocol = "";
98
+ binaryType = "blob";
99
+ onopen = null;
100
+ onmessage = null;
101
+ onerror = null;
102
+ onclose = null;
103
+ listeners = new ListenerMap();
104
+ ac = new AbortController();
105
+ stream = null;
106
+ readLoopPromise = null;
107
+ writeChain = Promise.resolve();
108
+ constructor(url, protocols) {
109
+ const u = new URL(String(url), globalThis.location?.href);
110
+ if (!shouldProxy(u)) {
111
+ return new Original(String(url), protocols);
112
+ }
113
+ this.url = u.toString();
114
+ void this.init(u, protocols);
115
+ }
116
+ addEventListener(type, listener) {
117
+ this.listeners.on(type, listener);
118
+ }
119
+ removeEventListener(type, listener) {
120
+ this.listeners.off(type, listener);
121
+ }
122
+ send(data) {
123
+ if (this.readyState !== PatchedWebSocket.OPEN || this.stream == null) {
124
+ throw new Error("WebSocket is not open");
125
+ }
126
+ const sendBytes = (op, payload) => {
127
+ this.writeChain = this.writeChain
128
+ .then(() => writeWSFrame(this.stream, op, payload, maxWsFrameBytes))
129
+ .catch((e) => this.fail(e));
130
+ };
131
+ if (typeof data === "string") {
132
+ sendBytes(1, te.encode(data));
133
+ return;
134
+ }
135
+ if (data instanceof ArrayBuffer) {
136
+ sendBytes(2, new Uint8Array(data));
137
+ return;
138
+ }
139
+ if (ArrayBuffer.isView(data)) {
140
+ sendBytes(2, new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
141
+ return;
142
+ }
143
+ if (typeof Blob !== "undefined" && data instanceof Blob) {
144
+ void data
145
+ .arrayBuffer()
146
+ .then((ab) => sendBytes(2, new Uint8Array(ab)))
147
+ .catch((e) => this.fail(e));
148
+ return;
149
+ }
150
+ throw new Error("unsupported WebSocket send payload");
151
+ }
152
+ close(code, reason) {
153
+ if (this.readyState === PatchedWebSocket.CLOSED)
154
+ return;
155
+ this.readyState = PatchedWebSocket.CLOSING;
156
+ const payloadParts = [];
157
+ if (code != null)
158
+ payloadParts.push(u16be(code));
159
+ if (reason != null && reason !== "")
160
+ payloadParts.push(te.encode(reason));
161
+ const payload = payloadParts.length === 0 ? new Uint8Array() : payloadParts.reduce((a, b) => {
162
+ const out = new Uint8Array(a.length + b.length);
163
+ out.set(a, 0);
164
+ out.set(b, a.length);
165
+ return out;
166
+ });
167
+ this.writeChain = this.writeChain
168
+ .then(() => (this.stream ? writeWSFrame(this.stream, 8, payload, maxWsFrameBytes) : undefined))
169
+ .catch(() => undefined)
170
+ .finally(() => {
171
+ try {
172
+ this.ac.abort("closed");
173
+ }
174
+ catch {
175
+ // Best-effort abort.
176
+ }
177
+ });
178
+ }
179
+ emit(type, ev) {
180
+ const prop = this["on" + type];
181
+ if (typeof prop === "function") {
182
+ try {
183
+ prop.call(this, ev);
184
+ }
185
+ catch {
186
+ // Best-effort.
187
+ }
188
+ }
189
+ this.listeners.emit(type, ev);
190
+ }
191
+ async init(u, protocols) {
192
+ try {
193
+ const list = typeof protocols === "string"
194
+ ? [protocols]
195
+ : Array.isArray(protocols)
196
+ ? protocols
197
+ : [];
198
+ const { stream, protocol } = await runtime.openWebSocketStream(u.pathname + u.search, {
199
+ protocols: list,
200
+ signal: this.ac.signal
201
+ });
202
+ this.stream = stream;
203
+ this.protocol = protocol;
204
+ this.readyState = PatchedWebSocket.OPEN;
205
+ this.emit("open", { type: "open" });
206
+ this.readLoopPromise = this.readLoop(stream, this.ac.signal);
207
+ }
208
+ catch (e) {
209
+ this.fail(e);
210
+ }
211
+ }
212
+ async readLoop(stream, signal) {
213
+ const reader = createByteReader(stream, { signal });
214
+ try {
215
+ while (true) {
216
+ const { op, payload } = await readWSFrame(reader, maxWsFrameBytes);
217
+ if (op === 9) {
218
+ // Ping -> Pong (not exposed to WebSocket JS API).
219
+ await writeWSFrame(stream, 10, payload, maxWsFrameBytes);
220
+ continue;
221
+ }
222
+ if (op === 10)
223
+ continue;
224
+ if (op === 8) {
225
+ this.readyState = PatchedWebSocket.CLOSED;
226
+ const code = payload.length >= 2 ? readU16be(payload, 0) : 1000;
227
+ const reason = payload.length > 2 ? td.decode(payload.subarray(2)) : "";
228
+ this.emit("close", { type: "close", code, reason, wasClean: true });
229
+ return;
230
+ }
231
+ if (op === 1) {
232
+ this.emit("message", { type: "message", data: td.decode(payload) });
233
+ continue;
234
+ }
235
+ if (op === 2) {
236
+ if (this.binaryType === "arraybuffer") {
237
+ const ab = payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength);
238
+ this.emit("message", { type: "message", data: ab });
239
+ continue;
240
+ }
241
+ if (typeof Blob !== "undefined") {
242
+ this.emit("message", { type: "message", data: new Blob([new Uint8Array(payload)]) });
243
+ continue;
244
+ }
245
+ // Fallback for non-browser contexts.
246
+ const ab = payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength);
247
+ this.emit("message", { type: "message", data: ab });
248
+ continue;
249
+ }
250
+ }
251
+ }
252
+ catch (e) {
253
+ if (this.readyState !== PatchedWebSocket.CLOSED)
254
+ this.fail(e);
255
+ }
256
+ }
257
+ fail(e) {
258
+ this.readyState = PatchedWebSocket.CLOSED;
259
+ const msg = e instanceof Error ? e.message : String(e);
260
+ this.emit("error", { type: "error", message: msg });
261
+ this.emit("close", { type: "close", code: 1006, reason: msg, wasClean: false });
262
+ try {
263
+ this.ac.abort(msg);
264
+ }
265
+ catch {
266
+ // Best-effort.
267
+ }
268
+ }
269
+ }
270
+ globalThis.WebSocket = PatchedWebSocket;
271
+ return { uninstall: () => (globalThis.WebSocket = Original) };
272
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -44,6 +44,10 @@
44
44
  "types": "./dist/streamio/index.d.ts",
45
45
  "default": "./dist/streamio/index.js"
46
46
  },
47
+ "./proxy": {
48
+ "types": "./dist/proxy/index.d.ts",
49
+ "default": "./dist/proxy/index.js"
50
+ },
47
51
  "./rpc": {
48
52
  "types": "./dist/rpc/index.d.ts",
49
53
  "default": "./dist/rpc/index.js"