@floegence/flowersec-core 0.19.0 → 0.19.2

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.
@@ -13,6 +13,13 @@ export class ControlplaneRequestError extends Error {
13
13
  }
14
14
  export const DEFAULT_CONNECT_ARTIFACT_PATH = "/v1/connect/artifact";
15
15
  export const DEFAULT_ENTRY_CONNECT_ARTIFACT_PATH = "/v1/connect/artifact/entry";
16
+ const DEFAULT_MAX_CONTROLPLANE_RESPONSE_BYTES = 1 << 20;
17
+ class ControlplaneResponseTooLargeError extends Error {
18
+ constructor(maxBytes) {
19
+ super(`controlplane response exceeded ${maxBytes} bytes`);
20
+ this.name = "ControlplaneResponseTooLargeError";
21
+ }
22
+ }
16
23
  function resolveFetch(fetchImpl) {
17
24
  if (fetchImpl)
18
25
  return fetchImpl;
@@ -37,6 +44,46 @@ function parseMaybeJSON(bodyText) {
37
44
  return trimmed;
38
45
  }
39
46
  }
47
+ function parseContentLength(headerValue) {
48
+ const raw = String(headerValue ?? "").trim();
49
+ if (raw === "")
50
+ return null;
51
+ if (!/^[0-9]+$/.test(raw))
52
+ return null;
53
+ const parsed = Number(raw);
54
+ if (!Number.isSafeInteger(parsed) || parsed < 0)
55
+ return null;
56
+ return parsed;
57
+ }
58
+ async function readControlplaneText(response, maxBytes) {
59
+ const contentLength = parseContentLength(response.headers.get("Content-Length"));
60
+ if (contentLength !== null && contentLength > maxBytes) {
61
+ throw new ControlplaneResponseTooLargeError(maxBytes);
62
+ }
63
+ if (!response.body) {
64
+ return "";
65
+ }
66
+ const reader = response.body.getReader();
67
+ const decoder = new TextDecoder();
68
+ let totalBytes = 0;
69
+ let text = "";
70
+ while (true) {
71
+ const chunk = await reader.read();
72
+ if (chunk.done)
73
+ break;
74
+ totalBytes += chunk.value.byteLength;
75
+ if (totalBytes > maxBytes) {
76
+ try {
77
+ await reader.cancel();
78
+ }
79
+ catch { }
80
+ throw new ControlplaneResponseTooLargeError(maxBytes);
81
+ }
82
+ text += decoder.decode(chunk.value, { stream: true });
83
+ }
84
+ text += decoder.decode();
85
+ return text;
86
+ }
40
87
  function decodeErrorMessage(status, responseBody) {
41
88
  let message = `controlplane request failed: ${status}`;
42
89
  let code = "";
@@ -61,7 +108,19 @@ export async function requestControlplaneJSON(url, init) {
61
108
  ...init,
62
109
  cache: "no-store",
63
110
  });
64
- const rawBody = await response.text();
111
+ let rawBody = "";
112
+ try {
113
+ rawBody = await readControlplaneText(response, DEFAULT_MAX_CONTROLPLANE_RESPONSE_BYTES);
114
+ }
115
+ catch (err) {
116
+ if (err instanceof ControlplaneResponseTooLargeError && !response.ok) {
117
+ throw new ControlplaneRequestError({
118
+ status: response.status,
119
+ message: err.message,
120
+ });
121
+ }
122
+ throw err;
123
+ }
65
124
  const responseBody = parseMaybeJSON(rawBody);
66
125
  if (!response.ok) {
67
126
  const error = decodeErrorMessage(response.status, responseBody);
@@ -1,6 +1,7 @@
1
1
  export declare class CookieJar {
2
2
  private readonly cookies;
3
- setCookie(setCookieHeader: string): void;
4
- updateFromSetCookieHeaders(headers: readonly string[]): void;
3
+ private nextCreatedAtSeq;
4
+ setCookie(setCookieHeader: string, requestPath?: string): void;
5
+ updateFromSetCookieHeaders(headers: readonly string[], requestPath?: string): void;
5
6
  getCookieHeader(path: string): string;
6
7
  }
@@ -11,9 +11,54 @@ function parseCookieNameValue(s) {
11
11
  return null;
12
12
  return { name, value };
13
13
  }
14
+ function cookieStorageKey(name, path) {
15
+ return `${name}\u0000${path}`;
16
+ }
17
+ function requestPathOnly(path) {
18
+ const raw = path.trim();
19
+ if (!raw.startsWith("/"))
20
+ return "/";
21
+ const q = raw.indexOf("?");
22
+ const out = q >= 0 ? raw.slice(0, q) : raw;
23
+ return out === "" ? "/" : out;
24
+ }
25
+ function defaultCookiePathFromRequestPath(requestPath) {
26
+ const path = requestPathOnly(requestPath);
27
+ if (path === "/")
28
+ return "/";
29
+ const lastSlash = path.lastIndexOf("/");
30
+ if (lastSlash <= 0)
31
+ return "/";
32
+ return path.slice(0, lastSlash);
33
+ }
34
+ function normalizeCookiePath(pathAttr, requestPath) {
35
+ const path = pathAttr?.trim() ?? "";
36
+ if (path === "" || !path.startsWith("/"))
37
+ return defaultCookiePathFromRequestPath(requestPath);
38
+ return path;
39
+ }
40
+ function pathMatchesCookiePath(requestPath, cookiePath) {
41
+ const path = requestPathOnly(requestPath);
42
+ if (cookiePath === "/")
43
+ return true;
44
+ if (path === cookiePath)
45
+ return true;
46
+ if (!path.startsWith(cookiePath))
47
+ return false;
48
+ if (cookiePath.endsWith("/"))
49
+ return true;
50
+ return path.charAt(cookiePath.length) === "/";
51
+ }
52
+ function compareCookiesForHeader(a, b) {
53
+ const pathLenDiff = b.path.length - a.path.length;
54
+ if (pathLenDiff !== 0)
55
+ return pathLenDiff;
56
+ return a.createdAtSeq - b.createdAtSeq;
57
+ }
14
58
  export class CookieJar {
15
59
  cookies = new Map();
16
- setCookie(setCookieHeader) {
60
+ nextCreatedAtSeq = 0;
61
+ setCookie(setCookieHeader, requestPath = "/") {
17
62
  const raw = setCookieHeader.trim();
18
63
  if (raw === "")
19
64
  return;
@@ -23,15 +68,16 @@ export class CookieJar {
23
68
  const nv = parseCookieNameValue(parts[0] ?? "");
24
69
  if (nv == null)
25
70
  return;
26
- let path = "/";
71
+ let pathAttr;
27
72
  let expiresAtMs;
73
+ let maxAgeSeen = false;
28
74
  for (let i = 1; i < parts.length; i++) {
29
75
  const p = parts[i];
30
76
  const lower = p.toLowerCase();
31
77
  if (lower.startsWith("path=")) {
32
78
  const v = p.slice("path=".length).trim();
33
79
  if (v !== "")
34
- path = v;
80
+ pathAttr = v;
35
81
  continue;
36
82
  }
37
83
  if (lower.startsWith("max-age=")) {
@@ -39,14 +85,13 @@ export class CookieJar {
39
85
  const n = Number.parseInt(v, 10);
40
86
  if (!Number.isFinite(n))
41
87
  continue;
42
- if (n <= 0) {
43
- this.cookies.delete(nv.name);
44
- return;
45
- }
46
- expiresAtMs = nowMs() + n * 1000;
88
+ maxAgeSeen = true;
89
+ expiresAtMs = n <= 0 ? 0 : nowMs() + n * 1000;
47
90
  continue;
48
91
  }
49
92
  if (lower.startsWith("expires=")) {
93
+ if (maxAgeSeen)
94
+ continue;
50
95
  const v = p.slice("expires=".length).trim();
51
96
  const t = Date.parse(v);
52
97
  if (!Number.isFinite(t))
@@ -55,34 +100,37 @@ export class CookieJar {
55
100
  continue;
56
101
  }
57
102
  }
58
- // Expired via Expires.
103
+ const path = normalizeCookiePath(pathAttr, requestPath);
104
+ const key = cookieStorageKey(nv.name, path);
59
105
  if (expiresAtMs != null && expiresAtMs <= nowMs()) {
60
- this.cookies.delete(nv.name);
106
+ this.cookies.delete(key);
61
107
  return;
62
108
  }
109
+ const createdAtSeq = this.cookies.get(key)?.createdAtSeq ?? this.nextCreatedAtSeq++;
63
110
  if (expiresAtMs == null) {
64
- this.cookies.set(nv.name, { name: nv.name, value: nv.value, path });
111
+ this.cookies.set(key, { name: nv.name, value: nv.value, path, createdAtSeq });
65
112
  }
66
113
  else {
67
- this.cookies.set(nv.name, { name: nv.name, value: nv.value, path, expiresAtMs });
114
+ this.cookies.set(key, { name: nv.name, value: nv.value, path, expiresAtMs, createdAtSeq });
68
115
  }
69
116
  }
70
- updateFromSetCookieHeaders(headers) {
117
+ updateFromSetCookieHeaders(headers, requestPath = "/") {
71
118
  for (const h of headers)
72
- this.setCookie(h);
119
+ this.setCookie(h, requestPath);
73
120
  }
74
121
  getCookieHeader(path) {
75
122
  const now = nowMs();
76
- const pairs = [];
77
- for (const c of this.cookies.values()) {
123
+ const matched = [];
124
+ for (const [key, c] of this.cookies.entries()) {
78
125
  if (c.expiresAtMs != null && c.expiresAtMs <= now) {
79
- this.cookies.delete(c.name);
126
+ this.cookies.delete(key);
80
127
  continue;
81
128
  }
82
- if (c.path !== "/" && !path.startsWith(c.path))
129
+ if (!pathMatchesCookiePath(path, c.path))
83
130
  continue;
84
- pairs.push(`${c.name}=${c.value}`);
131
+ matched.push(c);
85
132
  }
86
- return pairs.join("; ");
133
+ matched.sort(compareCookiesForHeader);
134
+ return matched.map((c) => `${c.name}=${c.value}`).join("; ");
87
135
  }
88
136
  }
@@ -1,7 +1,7 @@
1
1
  import { profileToPresetManifest } from "./profiles.js";
2
2
  import { resolveProxyPreset } from "./preset.js";
3
3
  import { registerServiceWorkerAndEnsureControl } from "./registerServiceWorker.js";
4
- import { createProxyRuntime } from "./runtime.js";
4
+ import { createProxyRuntime, ensureServiceWorkerRuntimeRegistered } from "./runtime.js";
5
5
  import { createProxyServiceWorkerScript } from "./serviceWorker.js";
6
6
  function dedupeStrings(values) {
7
7
  const out = [];
@@ -252,6 +252,7 @@ export async function registerProxyIntegration(input) {
252
252
  maxRepairAttempts,
253
253
  controllerTimeoutMs,
254
254
  });
255
+ await ensureServiceWorkerRuntimeRegistered({ timeoutMs: controllerTimeoutMs });
255
256
  if (expectedScriptPathSuffix !== "") {
256
257
  const ok = await waitForControllerSuffix(expectedScriptPathSuffix, controllerTimeoutMs);
257
258
  if (!ok) {
@@ -7,6 +7,7 @@ type ProxyFetchReq = Readonly<{
7
7
  method: string;
8
8
  path: string;
9
9
  headers: readonly Header[];
10
+ external_origin?: string;
10
11
  body?: ArrayBuffer;
11
12
  }>;
12
13
  export type ProxyRuntimeLimits = Readonly<{
@@ -39,5 +40,9 @@ export type ProxyRuntimeOptions = Readonly<{
39
40
  extraWsHeaders?: readonly string[];
40
41
  cookieJar?: CookieJar;
41
42
  }>;
43
+ export type EnsureServiceWorkerRuntimeRegisteredOptions = Readonly<{
44
+ timeoutMs?: number;
45
+ }>;
42
46
  export declare function createProxyRuntime(opts: ProxyRuntimeOptions): ProxyRuntime;
47
+ export declare function ensureServiceWorkerRuntimeRegistered(opts?: EnsureServiceWorkerRuntimeRegisteredOptions): Promise<void>;
43
48
  export {};
@@ -38,6 +38,27 @@ function normalizeTimeoutMs(timeoutMs) {
38
38
  throw new Error("timeout_ms must be >= 0");
39
39
  return v;
40
40
  }
41
+ function normalizeExternalOrigin(externalOriginRaw) {
42
+ if (typeof externalOriginRaw !== "string")
43
+ return undefined;
44
+ const externalOrigin = externalOriginRaw.trim();
45
+ if (externalOrigin === "")
46
+ return undefined;
47
+ let parsed;
48
+ try {
49
+ parsed = new URL(externalOrigin);
50
+ }
51
+ catch {
52
+ throw new Error("external_origin must be an http(s) origin");
53
+ }
54
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || parsed.host === "") {
55
+ throw new Error("external_origin must be an http(s) origin");
56
+ }
57
+ if (parsed.username !== "" || parsed.password !== "" || (parsed.pathname !== "" && parsed.pathname !== "/") || parsed.search !== "" || parsed.hash !== "") {
58
+ throw new Error("external_origin must be an origin without credentials, path, query, or fragment");
59
+ }
60
+ return parsed.origin;
61
+ }
41
62
  function normalizeMaxBytes(name, v, defaultValue) {
42
63
  if (v == null)
43
64
  return defaultValue;
@@ -97,13 +118,9 @@ export function createProxyRuntime(opts) {
97
118
  const extraResponseHeaders = opts.extraResponseHeaders ?? [];
98
119
  const extraWsHeaders = opts.extraWsHeaders ?? [];
99
120
  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
- }
121
+ void ensureServiceWorkerRuntimeRegistered({ timeoutMs: 2_000 }).catch(() => {
122
+ // Best-effort: controllerchange will retry once the active Service Worker is ready.
123
+ });
107
124
  };
108
125
  const onMessage = (ev) => {
109
126
  const data = ev.data;
@@ -134,6 +151,7 @@ export function createProxyRuntime(opts) {
134
151
  try {
135
152
  const path = pathOnly(req.path);
136
153
  const requestID = req.id.trim() !== "" ? req.id : randomB64u(18);
154
+ const externalOrigin = normalizeExternalOrigin(req.external_origin);
137
155
  stream = await client.openStream(PROXY_KIND_HTTP1, { signal: ac.signal });
138
156
  const reader = createByteReader(stream, { signal: ac.signal });
139
157
  const filteredReqHeaders = filterRequestHeaders(req.headers, { extraAllowed: extraRequestHeaders });
@@ -145,6 +163,7 @@ export function createProxyRuntime(opts) {
145
163
  method: req.method,
146
164
  path,
147
165
  headers: reqHeaders,
166
+ ...(externalOrigin === undefined ? {} : { external_origin: externalOrigin }),
148
167
  timeout_ms: timeoutMs
149
168
  });
150
169
  const body = req.body != null ? new Uint8Array(req.body) : new Uint8Array();
@@ -168,7 +187,7 @@ export function createProxyRuntime(opts) {
168
187
  const status = Math.max(0, Math.floor(respMeta.status ?? 502));
169
188
  const rawHeaders = Array.isArray(respMeta.headers) ? respMeta.headers : [];
170
189
  const { passthrough, setCookie } = filterResponseHeaders(rawHeaders, { extraAllowed: extraResponseHeaders });
171
- cookieJar.updateFromSetCookieHeaders(setCookie);
190
+ cookieJar.updateFromSetCookieHeaders(setCookie, path);
172
191
  port.postMessage({ type: "flowersec-proxy:response_meta", status, headers: passthrough });
173
192
  const chunks = await readChunkFrames(reader, maxChunkBytes, maxBodyBytes);
174
193
  for await (const chunk of chunks) {
@@ -237,3 +256,60 @@ export function createProxyRuntime(opts) {
237
256
  }
238
257
  };
239
258
  }
259
+ export async function ensureServiceWorkerRuntimeRegistered(opts = {}) {
260
+ const ctl = globalThis.navigator?.serviceWorker?.controller;
261
+ if (!ctl || typeof ctl.postMessage !== "function")
262
+ return;
263
+ const timeoutMs = Math.max(0, Math.floor(opts.timeoutMs ?? 2_000));
264
+ const ch = new MessageChannel();
265
+ await new Promise((resolve, reject) => {
266
+ let done = false;
267
+ let timer = null;
268
+ const finish = (error) => {
269
+ if (done)
270
+ return;
271
+ done = true;
272
+ if (timer != null)
273
+ clearTimeout(timer);
274
+ ch.port1.onmessage = null;
275
+ ch.port1.onmessageerror = null;
276
+ try {
277
+ ch.port1.close();
278
+ }
279
+ catch {
280
+ // Best-effort.
281
+ }
282
+ if (error == null) {
283
+ resolve();
284
+ return;
285
+ }
286
+ reject(error instanceof Error ? error : new Error(String(error)));
287
+ };
288
+ ch.port1.onmessage = (ev) => {
289
+ const data = ev.data;
290
+ if (data == null || typeof data !== "object")
291
+ return;
292
+ if (data.type !== "flowersec-proxy:register-runtime-ack")
293
+ return;
294
+ if (data.ok !== true) {
295
+ finish(new Error("service worker runtime registration was rejected"));
296
+ return;
297
+ }
298
+ finish();
299
+ };
300
+ ch.port1.onmessageerror = () => {
301
+ finish(new Error("service worker runtime registration failed"));
302
+ };
303
+ if (timeoutMs > 0) {
304
+ timer = setTimeout(() => {
305
+ finish(new Error("service worker runtime registration timed out"));
306
+ }, timeoutMs);
307
+ }
308
+ try {
309
+ ctl.postMessage({ type: "flowersec-proxy:register-runtime" }, [ch.port2]);
310
+ }
311
+ catch (error) {
312
+ finish(error);
313
+ }
314
+ });
315
+ }
@@ -159,6 +159,7 @@ self.addEventListener("install", (event) => {
159
159
  });
160
160
 
161
161
  self.addEventListener("activate", (event) => {
162
+ runtimeClientId = null;
162
163
  event.waitUntil(self.clients.claim());
163
164
  });
164
165
 
@@ -167,7 +168,13 @@ self.addEventListener("message", (event) => {
167
168
  if (!data || typeof data !== "object") return;
168
169
  const msgType = typeof data.type === "string" ? data.type : "";
169
170
  if (msgType === "flowersec-proxy:register-runtime") {
170
- if (event.source && typeof event.source.id === "string") runtimeClientId = event.source.id;
171
+ const ok = Boolean(event.source && typeof event.source.id === "string");
172
+ if (ok) runtimeClientId = event.source.id;
173
+ const port = event.ports && event.ports[0];
174
+ if (port) {
175
+ try { port.postMessage({ type: "flowersec-proxy:register-runtime-ack", ok }); } catch {}
176
+ try { port.close(); } catch {}
177
+ }
171
178
  return;
172
179
  }
173
180
  if (!FORWARD_FETCH_MESSAGE_TYPES.has(msgType)) return;
@@ -207,16 +214,10 @@ async function getWindowClient(preferredClientId) {
207
214
  return cs.length > 0 ? cs[0] : null;
208
215
  }
209
216
 
210
- if (runtimeClientId) {
211
- const c = await self.clients.get(runtimeClientId);
212
- if (c) return c;
213
- runtimeClientId = null;
214
- }
215
- const cs = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
216
- if (cs.length > 0) {
217
- runtimeClientId = cs[0].id;
218
- return cs[0];
219
- }
217
+ if (!runtimeClientId) return null;
218
+ const c = await self.clients.get(runtimeClientId);
219
+ if (c) return c;
220
+ runtimeClientId = null;
220
221
  return null;
221
222
  }
222
223
 
@@ -367,6 +368,7 @@ async function handleFetch(event) {
367
368
  const req = event.request;
368
369
  const url = new URL(req.url);
369
370
  const id = Math.random().toString(16).slice(2) + Date.now().toString(16);
371
+ const externalOrigin = url.origin === self.location.origin ? url.origin : "";
370
372
 
371
373
  let body;
372
374
  if (req.method === "GET" || req.method === "HEAD") {
@@ -488,7 +490,14 @@ async function handleFetch(event) {
488
490
 
489
491
  target.postMessage({
490
492
  type: WINDOW_CLIENT_MESSAGE_TYPE,
491
- req: { id, method: req.method, path, headers: headersToPairs(req.headers), body }
493
+ req: {
494
+ id,
495
+ method: req.method,
496
+ path,
497
+ headers: headersToPairs(req.headers),
498
+ ...(externalOrigin ? { external_origin: externalOrigin } : {}),
499
+ body
500
+ }
492
501
  }, [port2]);
493
502
 
494
503
  const meta = await metaPromise;
@@ -12,6 +12,7 @@ export type HttpRequestMetaV1 = Readonly<{
12
12
  method: string;
13
13
  path: string;
14
14
  headers: Header[];
15
+ external_origin?: string;
15
16
  timeout_ms?: number;
16
17
  }>;
17
18
  export type HttpResponseMetaV1 = Readonly<{
@@ -13,6 +13,7 @@ export type ProxyWindowFetchRequest = Readonly<{
13
13
  method: string;
14
14
  path: string;
15
15
  headers: readonly Header[];
16
+ external_origin?: string;
16
17
  body?: ArrayBuffer;
17
18
  }>;
18
19
  export type ProxyWindowFetchForwardMsg = Readonly<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {