@floegence/flowersec-core 0.10.1 → 0.11.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.
@@ -1,4 +1,9 @@
1
1
  import { type ConnectOptionsBase } from "../client-connect/connectCore.js";
2
2
  import type { ClientInternal } from "../client.js";
3
- export type DirectConnectOptions = ConnectOptionsBase;
3
+ export type DirectConnectOptions = ConnectOptionsBase & Readonly<{
4
+ /** Type-only marker to prevent mixing direct and tunnel option types. */
5
+ __mode?: "direct";
6
+ /** Reserved for tunnel connects; forbidden for direct connects. */
7
+ endpointInstanceId?: never;
8
+ }>;
4
9
  export declare function connectDirect(info: unknown, opts: DirectConnectOptions): Promise<ClientInternal>;
@@ -1,8 +1,10 @@
1
1
  export * from "./constants.js";
2
2
  export * from "./types.js";
3
3
  export * from "./cookieJar.js";
4
+ export * from "./profiles.js";
4
5
  export * from "./runtime.js";
5
6
  export * from "./serviceWorker.js";
6
7
  export { registerServiceWorkerAndEnsureControl } from "./registerServiceWorker.js";
8
+ export * from "./integration.js";
7
9
  export * from "./wsPatch.js";
8
10
  export * from "./disableUpstreamServiceWorkerRegister.js";
@@ -1,8 +1,10 @@
1
1
  export * from "./constants.js";
2
2
  export * from "./types.js";
3
3
  export * from "./cookieJar.js";
4
+ export * from "./profiles.js";
4
5
  export * from "./runtime.js";
5
6
  export * from "./serviceWorker.js";
6
7
  export { registerServiceWorkerAndEnsureControl } from "./registerServiceWorker.js";
8
+ export * from "./integration.js";
7
9
  export * from "./wsPatch.js";
8
10
  export * from "./disableUpstreamServiceWorkerRegister.js";
@@ -0,0 +1,69 @@
1
+ import type { Client } from "../client.js";
2
+ import { type ProxyProfile, type ProxyProfileName } from "./profiles.js";
3
+ import { type ProxyRuntime } from "./runtime.js";
4
+ import { type ProxyServiceWorkerScriptOptions } from "./serviceWorker.js";
5
+ export type ProxyIntegrationMonitorOptions = Readonly<{
6
+ enabled?: boolean;
7
+ throttleMs?: number;
8
+ }>;
9
+ export type ProxyIntegrationRepairOptions = Readonly<{
10
+ queryKey?: string;
11
+ maxAttempts?: number;
12
+ controllerTimeoutMs?: number;
13
+ strategy?: "replace" | "reload";
14
+ }>;
15
+ export type ProxyServiceWorkerConflictPolicy = Readonly<{
16
+ keepScriptPathSuffixes?: readonly string[];
17
+ uninstallOnMismatch?: boolean;
18
+ }>;
19
+ export type ProxyIntegrationServiceWorkerOptions = Readonly<{
20
+ scriptUrl: string;
21
+ scope?: string;
22
+ expectedScriptPathSuffix?: string;
23
+ repair?: ProxyIntegrationRepairOptions;
24
+ monitor?: ProxyIntegrationMonitorOptions;
25
+ conflicts?: ProxyServiceWorkerConflictPolicy;
26
+ }>;
27
+ export type RegisterProxyIntegrationOptions = Readonly<{
28
+ client: Client;
29
+ profile?: ProxyProfileName | Partial<ProxyProfile>;
30
+ runtime?: Readonly<{
31
+ maxJsonFrameBytes?: number;
32
+ maxChunkBytes?: number;
33
+ maxBodyBytes?: number;
34
+ maxWsFrameBytes?: number;
35
+ timeoutMs?: number;
36
+ }>;
37
+ serviceWorker: ProxyIntegrationServiceWorkerOptions;
38
+ plugins?: readonly ProxyIntegrationPlugin[];
39
+ }>;
40
+ export type ProxyIntegrationContext = Readonly<{
41
+ runtime: ProxyRuntime;
42
+ options: RegisterProxyIntegrationOptions;
43
+ profile: ProxyProfile;
44
+ }>;
45
+ export type ControllerMismatchContext = Readonly<{
46
+ expectedScriptPathSuffix: string;
47
+ actualScriptURL: string;
48
+ stage: "register" | "monitor";
49
+ }>;
50
+ export type ProxyIntegrationPlugin = Readonly<{
51
+ name: string;
52
+ mutateOptions?: (opts: RegisterProxyIntegrationOptions) => RegisterProxyIntegrationOptions;
53
+ extendServiceWorkerScriptOptions?: (opts: ProxyServiceWorkerScriptOptions) => ProxyServiceWorkerScriptOptions;
54
+ serviceWorkerConflictPolicy?: ProxyServiceWorkerConflictPolicy;
55
+ forwardFetchMessageTypes?: readonly string[];
56
+ onRegistered?: (ctx: ProxyIntegrationContext) => void | Promise<void>;
57
+ onControllerMismatch?: (ctx: ControllerMismatchContext) => "repair" | "ignore" | void;
58
+ onDisposed?: () => void | Promise<void>;
59
+ }>;
60
+ export type ProxyIntegrationHandle = Readonly<{
61
+ runtime: ProxyRuntime;
62
+ dispose: () => Promise<void>;
63
+ }>;
64
+ export type CreateProxyIntegrationServiceWorkerScriptOptions = Readonly<{
65
+ baseOptions?: ProxyServiceWorkerScriptOptions;
66
+ plugins?: readonly ProxyIntegrationPlugin[];
67
+ }>;
68
+ export declare function createProxyIntegrationServiceWorkerScript(opts?: CreateProxyIntegrationServiceWorkerScriptOptions): string;
69
+ export declare function registerProxyIntegration(input: RegisterProxyIntegrationOptions): Promise<ProxyIntegrationHandle>;
@@ -0,0 +1,292 @@
1
+ import { resolveProxyProfile } from "./profiles.js";
2
+ import { registerServiceWorkerAndEnsureControl } from "./registerServiceWorker.js";
3
+ import { createProxyRuntime } from "./runtime.js";
4
+ import { createProxyServiceWorkerScript } from "./serviceWorker.js";
5
+ function dedupeStrings(values) {
6
+ const out = [];
7
+ const seen = new Set();
8
+ for (const v of values) {
9
+ const n = String(v ?? "").trim();
10
+ if (n === "" || seen.has(n))
11
+ continue;
12
+ seen.add(n);
13
+ out.push(n);
14
+ }
15
+ return out;
16
+ }
17
+ function parseRepairAttemptFromHref(href, queryKey) {
18
+ try {
19
+ const u = new URL(href);
20
+ const raw = String(u.searchParams.get(queryKey) ?? "").trim();
21
+ const n = raw === "" ? 0 : Number(raw);
22
+ if (!Number.isFinite(n) || n < 0)
23
+ return 0;
24
+ return Math.min(9, Math.floor(n));
25
+ }
26
+ catch {
27
+ return 0;
28
+ }
29
+ }
30
+ function buildHrefWithRepairAttempt(href, queryKey, attempt) {
31
+ const u = new URL(href);
32
+ u.searchParams.set(queryKey, String(Math.max(0, Math.floor(attempt))));
33
+ return u.toString();
34
+ }
35
+ function isServiceWorkerScriptPathSuffix(raw, suffix) {
36
+ const script = String(raw ?? "").trim();
37
+ if (script === "")
38
+ return false;
39
+ const wanted = String(suffix ?? "").trim();
40
+ if (wanted === "")
41
+ return false;
42
+ try {
43
+ const u = new URL(script);
44
+ return u.pathname.endsWith(wanted);
45
+ }
46
+ catch {
47
+ return script.endsWith(wanted);
48
+ }
49
+ }
50
+ async function waitForControllerSuffix(suffix, timeoutMs) {
51
+ const sw = globalThis.navigator?.serviceWorker;
52
+ if (!sw)
53
+ return false;
54
+ const isMatch = () => isServiceWorkerScriptPathSuffix(String(sw.controller?.scriptURL ?? ""), suffix);
55
+ if (isMatch())
56
+ return true;
57
+ const ms = Math.max(0, Math.floor(timeoutMs));
58
+ return await new Promise((resolve) => {
59
+ let done = false;
60
+ let timer = null;
61
+ const finish = (ok) => {
62
+ if (done)
63
+ return;
64
+ done = true;
65
+ if (timer != null)
66
+ window.clearTimeout(timer);
67
+ sw.removeEventListener("controllerchange", onChange);
68
+ resolve(ok);
69
+ };
70
+ const onChange = () => {
71
+ if (isMatch())
72
+ finish(true);
73
+ };
74
+ sw.addEventListener("controllerchange", onChange);
75
+ if (isMatch()) {
76
+ finish(true);
77
+ return;
78
+ }
79
+ if (ms > 0)
80
+ timer = window.setTimeout(() => finish(isMatch()), ms);
81
+ });
82
+ }
83
+ async function uninstallConflictingServiceWorkers(conflicts) {
84
+ if (conflicts?.uninstallOnMismatch === false)
85
+ return;
86
+ const sw = globalThis.navigator?.serviceWorker;
87
+ if (!sw || typeof sw.getRegistrations !== "function")
88
+ return;
89
+ const keep = dedupeStrings(conflicts?.keepScriptPathSuffixes ?? []);
90
+ const regs = await sw.getRegistrations();
91
+ for (const reg of regs) {
92
+ const script = String(reg?.active?.scriptURL ?? reg?.waiting?.scriptURL ?? reg?.installing?.scriptURL ?? "").trim();
93
+ if (script === "")
94
+ continue;
95
+ let shouldKeep = false;
96
+ for (const suffix of keep) {
97
+ if (isServiceWorkerScriptPathSuffix(script, suffix)) {
98
+ shouldKeep = true;
99
+ break;
100
+ }
101
+ }
102
+ if (shouldKeep)
103
+ continue;
104
+ try {
105
+ await reg.unregister();
106
+ }
107
+ catch {
108
+ // Best effort cleanup.
109
+ }
110
+ }
111
+ }
112
+ async function maybeRepairNavigation(opts) {
113
+ const attempt = parseRepairAttemptFromHref(window.location.href, opts.queryKey);
114
+ if (attempt >= opts.maxAttempts)
115
+ return;
116
+ if (opts.strategy === "reload") {
117
+ window.location.reload();
118
+ await new Promise(() => {
119
+ // keep pending on navigation
120
+ });
121
+ return;
122
+ }
123
+ const next = buildHrefWithRepairAttempt(window.location.href, opts.queryKey, attempt + 1);
124
+ window.location.replace(next);
125
+ await new Promise(() => {
126
+ // keep pending on navigation
127
+ });
128
+ }
129
+ function applyPluginMutations(opts, plugins) {
130
+ let out = opts;
131
+ for (const plugin of plugins) {
132
+ if (!plugin.mutateOptions)
133
+ continue;
134
+ out = plugin.mutateOptions(out);
135
+ }
136
+ return out;
137
+ }
138
+ function mergeConflictPolicy(base, plugins) {
139
+ const keep = dedupeStrings([
140
+ ...(base?.keepScriptPathSuffixes ?? []),
141
+ ...plugins.flatMap((p) => p.serviceWorkerConflictPolicy?.keepScriptPathSuffixes ?? []),
142
+ ]);
143
+ let uninstallOnMismatch = base?.uninstallOnMismatch;
144
+ for (const p of plugins) {
145
+ if (p.serviceWorkerConflictPolicy?.uninstallOnMismatch != null) {
146
+ uninstallOnMismatch = p.serviceWorkerConflictPolicy.uninstallOnMismatch;
147
+ }
148
+ }
149
+ if (keep.length === 0 && uninstallOnMismatch == null)
150
+ return base;
151
+ const out = {};
152
+ if (keep.length > 0)
153
+ out.keepScriptPathSuffixes = keep;
154
+ if (uninstallOnMismatch != null)
155
+ out.uninstallOnMismatch = uninstallOnMismatch;
156
+ return out;
157
+ }
158
+ function shouldIgnoreMismatch(plugins, ctx) {
159
+ for (const plugin of plugins) {
160
+ const action = plugin.onControllerMismatch?.(ctx);
161
+ if (action === "ignore")
162
+ return true;
163
+ }
164
+ return false;
165
+ }
166
+ function buildRuntimeOptions(profile, runtime) {
167
+ return {
168
+ maxJsonFrameBytes: runtime?.maxJsonFrameBytes ?? profile.maxJsonFrameBytes,
169
+ maxChunkBytes: runtime?.maxChunkBytes ?? profile.maxChunkBytes,
170
+ maxBodyBytes: runtime?.maxBodyBytes ?? profile.maxBodyBytes,
171
+ maxWsFrameBytes: runtime?.maxWsFrameBytes ?? profile.maxWsFrameBytes,
172
+ timeoutMs: runtime?.timeoutMs ?? profile.timeoutMs,
173
+ };
174
+ }
175
+ export function createProxyIntegrationServiceWorkerScript(opts = {}) {
176
+ const plugins = opts.plugins ?? [];
177
+ let scriptOpts = opts.baseOptions ?? {};
178
+ for (const plugin of plugins) {
179
+ if (!plugin.extendServiceWorkerScriptOptions)
180
+ continue;
181
+ scriptOpts = plugin.extendServiceWorkerScriptOptions(scriptOpts);
182
+ }
183
+ const forwarded = dedupeStrings([
184
+ ...(scriptOpts.forwardFetchMessageTypes ?? []),
185
+ ...plugins.flatMap((p) => p.forwardFetchMessageTypes ?? []),
186
+ ]);
187
+ const keepSuffixes = dedupeStrings([
188
+ ...(scriptOpts.conflictHints?.keepScriptPathSuffixes ?? []),
189
+ ...plugins.flatMap((p) => p.serviceWorkerConflictPolicy?.keepScriptPathSuffixes ?? []),
190
+ ]);
191
+ const finalOpts = {
192
+ ...scriptOpts,
193
+ forwardFetchMessageTypes: forwarded,
194
+ };
195
+ if (keepSuffixes.length > 0) {
196
+ return createProxyServiceWorkerScript({
197
+ ...finalOpts,
198
+ conflictHints: { keepScriptPathSuffixes: keepSuffixes },
199
+ });
200
+ }
201
+ if (scriptOpts.conflictHints != null) {
202
+ return createProxyServiceWorkerScript({
203
+ ...finalOpts,
204
+ conflictHints: scriptOpts.conflictHints,
205
+ });
206
+ }
207
+ return createProxyServiceWorkerScript(finalOpts);
208
+ }
209
+ export async function registerProxyIntegration(input) {
210
+ const plugins = input.plugins ?? [];
211
+ const opts = applyPluginMutations(input, plugins);
212
+ const profile = resolveProxyProfile(opts.profile);
213
+ const runtimeOpts = buildRuntimeOptions(profile, opts.runtime);
214
+ const runtime = createProxyRuntime({ ...runtimeOpts, client: opts.client });
215
+ const swCfg = opts.serviceWorker;
216
+ const repairQueryKey = String(swCfg.repair?.queryKey ?? "_flowersec_sw_repair").trim() || "_flowersec_sw_repair";
217
+ const maxRepairAttempts = Math.max(0, Math.floor(swCfg.repair?.maxAttempts ?? 2));
218
+ const controllerTimeoutMs = Math.max(0, Math.floor(swCfg.repair?.controllerTimeoutMs ?? 8_000));
219
+ const strategy = swCfg.repair?.strategy ?? "replace";
220
+ const conflicts = mergeConflictPolicy(swCfg.conflicts, plugins);
221
+ const finalScope = String(swCfg.scope ?? "/").trim() || "/";
222
+ const expectedScriptPathSuffix = String(swCfg.expectedScriptPathSuffix ?? "").trim();
223
+ await uninstallConflictingServiceWorkers(conflicts);
224
+ await registerServiceWorkerAndEnsureControl({
225
+ scriptUrl: swCfg.scriptUrl,
226
+ scope: finalScope,
227
+ repairQueryKey,
228
+ maxRepairAttempts,
229
+ controllerTimeoutMs,
230
+ });
231
+ if (expectedScriptPathSuffix !== "") {
232
+ const ok = await waitForControllerSuffix(expectedScriptPathSuffix, controllerTimeoutMs);
233
+ if (!ok) {
234
+ const actual = String(globalThis.navigator?.serviceWorker?.controller?.scriptURL ?? "").trim();
235
+ const ctx = {
236
+ expectedScriptPathSuffix,
237
+ actualScriptURL: actual,
238
+ stage: "register",
239
+ };
240
+ if (!shouldIgnoreMismatch(plugins, ctx)) {
241
+ await uninstallConflictingServiceWorkers(conflicts);
242
+ await maybeRepairNavigation({ queryKey: repairQueryKey, maxAttempts: maxRepairAttempts, strategy });
243
+ throw new Error("Proxy Service Worker is installed but not controlling this page");
244
+ }
245
+ }
246
+ }
247
+ const monitorEnabled = swCfg.monitor?.enabled ?? true;
248
+ const monitorThrottleMs = Math.max(0, Math.floor(swCfg.monitor?.throttleMs ?? 10_000));
249
+ const sw = globalThis.navigator?.serviceWorker;
250
+ let monitorHandler = null;
251
+ let lastMonitorRepairAt = 0;
252
+ if (monitorEnabled && sw && expectedScriptPathSuffix !== "") {
253
+ monitorHandler = () => {
254
+ const actual = String(sw.controller?.scriptURL ?? "").trim();
255
+ if (isServiceWorkerScriptPathSuffix(actual, expectedScriptPathSuffix))
256
+ return;
257
+ const ctx = {
258
+ expectedScriptPathSuffix,
259
+ actualScriptURL: actual,
260
+ stage: "monitor",
261
+ };
262
+ if (shouldIgnoreMismatch(plugins, ctx))
263
+ return;
264
+ const now = Date.now();
265
+ if (monitorThrottleMs > 0 && now - lastMonitorRepairAt <= monitorThrottleMs)
266
+ return;
267
+ lastMonitorRepairAt = now;
268
+ void maybeRepairNavigation({ queryKey: repairQueryKey, maxAttempts: maxRepairAttempts, strategy });
269
+ };
270
+ sw.addEventListener("controllerchange", monitorHandler);
271
+ }
272
+ const ctx = {
273
+ runtime,
274
+ options: opts,
275
+ profile,
276
+ };
277
+ for (const plugin of plugins) {
278
+ await plugin.onRegistered?.(ctx);
279
+ }
280
+ return {
281
+ runtime,
282
+ dispose: async () => {
283
+ if (monitorHandler && sw) {
284
+ sw.removeEventListener("controllerchange", monitorHandler);
285
+ }
286
+ runtime.dispose();
287
+ for (const plugin of plugins) {
288
+ await plugin.onDisposed?.();
289
+ }
290
+ },
291
+ };
292
+ }
@@ -0,0 +1,23 @@
1
+ export type ProxyProfile = Readonly<{
2
+ maxJsonFrameBytes: number;
3
+ maxChunkBytes: number;
4
+ maxBodyBytes: number;
5
+ maxWsFrameBytes: number;
6
+ timeoutMs: number;
7
+ }>;
8
+ export type ProxyProfileName = "default" | "codeserver";
9
+ export declare const PROXY_PROFILE_DEFAULT: Readonly<{
10
+ maxJsonFrameBytes: number;
11
+ maxChunkBytes: number;
12
+ maxBodyBytes: number;
13
+ maxWsFrameBytes: number;
14
+ timeoutMs: number;
15
+ }>;
16
+ export declare const PROXY_PROFILE_CODESERVER: Readonly<{
17
+ maxJsonFrameBytes: number;
18
+ maxChunkBytes: number;
19
+ maxBodyBytes: number;
20
+ maxWsFrameBytes: number;
21
+ timeoutMs: number;
22
+ }>;
23
+ export declare function resolveProxyProfile(profile?: ProxyProfileName | Partial<ProxyProfile>): ProxyProfile;
@@ -0,0 +1,54 @@
1
+ import { DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_CHUNK_BYTES, DEFAULT_MAX_WS_FRAME_BYTES } from "./constants.js";
2
+ const DEFAULT_PROFILE = Object.freeze({
3
+ // Keep aligned with runtime defaults.
4
+ maxJsonFrameBytes: 0,
5
+ maxChunkBytes: DEFAULT_MAX_CHUNK_BYTES,
6
+ maxBodyBytes: DEFAULT_MAX_BODY_BYTES,
7
+ maxWsFrameBytes: DEFAULT_MAX_WS_FRAME_BYTES,
8
+ timeoutMs: 0,
9
+ });
10
+ const CODESERVER_PROFILE = Object.freeze({
11
+ maxJsonFrameBytes: 0,
12
+ maxChunkBytes: DEFAULT_MAX_CHUNK_BYTES,
13
+ maxBodyBytes: DEFAULT_MAX_BODY_BYTES,
14
+ // Keep aligned with redeven/redeven-agent production profile.
15
+ maxWsFrameBytes: 32 * 1024 * 1024,
16
+ timeoutMs: 0,
17
+ });
18
+ export const PROXY_PROFILE_DEFAULT = DEFAULT_PROFILE;
19
+ export const PROXY_PROFILE_CODESERVER = CODESERVER_PROFILE;
20
+ function normalizeSafeInt(name, value) {
21
+ if (!Number.isFinite(value))
22
+ throw new Error(`${name} must be a finite number`);
23
+ const n = Math.floor(value);
24
+ if (!Number.isSafeInteger(n))
25
+ throw new Error(`${name} must be a safe integer`);
26
+ if (n < 0)
27
+ throw new Error(`${name} must be >= 0`);
28
+ return n;
29
+ }
30
+ function resolveNamedProfile(name) {
31
+ switch (name) {
32
+ case "default":
33
+ return DEFAULT_PROFILE;
34
+ case "codeserver":
35
+ return CODESERVER_PROFILE;
36
+ default:
37
+ throw new Error(`unknown proxy profile: ${name}`);
38
+ }
39
+ }
40
+ export function resolveProxyProfile(profile) {
41
+ if (profile == null)
42
+ return DEFAULT_PROFILE;
43
+ if (typeof profile === "string") {
44
+ return resolveNamedProfile(profile);
45
+ }
46
+ const base = DEFAULT_PROFILE;
47
+ return Object.freeze({
48
+ maxJsonFrameBytes: normalizeSafeInt("maxJsonFrameBytes", profile.maxJsonFrameBytes ?? base.maxJsonFrameBytes),
49
+ maxChunkBytes: normalizeSafeInt("maxChunkBytes", profile.maxChunkBytes ?? base.maxChunkBytes),
50
+ maxBodyBytes: normalizeSafeInt("maxBodyBytes", profile.maxBodyBytes ?? base.maxBodyBytes),
51
+ maxWsFrameBytes: normalizeSafeInt("maxWsFrameBytes", profile.maxWsFrameBytes ?? base.maxWsFrameBytes),
52
+ timeoutMs: normalizeSafeInt("timeoutMs", profile.timeoutMs ?? base.timeoutMs),
53
+ });
54
+ }
@@ -30,5 +30,9 @@ export type ProxyServiceWorkerScriptOptions = Readonly<{
30
30
  proxyPathPrefix?: string;
31
31
  stripProxyPathPrefix?: boolean;
32
32
  injectHTML?: ProxyServiceWorkerInjectHTMLOptions;
33
+ forwardFetchMessageTypes?: readonly string[];
34
+ conflictHints?: Readonly<{
35
+ keepScriptPathSuffixes?: readonly string[];
36
+ }>;
33
37
  }>;
34
38
  export declare function createProxyServiceWorkerScript(opts?: ProxyServiceWorkerScriptOptions): string;
@@ -25,6 +25,20 @@ function normalizePathList(name, input) {
25
25
  }
26
26
  return Array.from(new Set(out));
27
27
  }
28
+ function normalizeMessageTypeList(name, input) {
29
+ const out = [];
30
+ if (input == null || input.length === 0)
31
+ return out;
32
+ for (const raw of input) {
33
+ const s = typeof raw === "string" ? raw.trim() : "";
34
+ if (s === "")
35
+ continue;
36
+ if (/[\r\n]/.test(s))
37
+ throw new Error(`${name} must not contain newline`);
38
+ out.push(s);
39
+ }
40
+ return Array.from(new Set(out));
41
+ }
28
42
  const defaultMaxInjectHTMLBytes = 2 * 1024 * 1024;
29
43
  function normalizeMaxBytes(name, v, defaultValue) {
30
44
  if (v == null)
@@ -58,6 +72,8 @@ export function createProxyServiceWorkerScript(opts = {}) {
58
72
  }
59
73
  const passthroughPaths = normalizePathList("passthrough.paths", opts.passthrough?.paths);
60
74
  const passthroughPrefixes = normalizePathList("passthrough.prefixes", opts.passthrough?.prefixes);
75
+ const forwardFetchMessageTypes = normalizeMessageTypeList("forwardFetchMessageTypes", opts.forwardFetchMessageTypes);
76
+ const keepScriptPathSuffixes = normalizePathList("conflictHints.keepScriptPathSuffixes", opts.conflictHints?.keepScriptPathSuffixes);
61
77
  const injectHTML = opts.injectHTML ?? null;
62
78
  // Injection mode defaults to inline_module when injectHTML is provided.
63
79
  const injectMode = injectHTML?.mode ?? "inline_module";
@@ -116,6 +132,8 @@ const INJECT_SET_NO_STORE = ${JSON.stringify(setNoStore)};
116
132
 
117
133
  const MAX_REQUEST_BODY_BYTES = ${JSON.stringify(maxRequestBodyBytes)};
118
134
  const MAX_INJECT_HTML_BYTES = ${JSON.stringify(maxInjectHTMLBytes)};
135
+ const FORWARD_FETCH_MESSAGE_TYPES = new Set(${JSON.stringify(forwardFetchMessageTypes)});
136
+ const CONFLICT_HINT_KEEP_SCRIPT_SUFFIXES = ${JSON.stringify(keepScriptPathSuffixes)};
119
137
 
120
138
  const INJECT_STRIP_HEADER_NAMES = new Set(["content-length", "etag", "last-modified", "content-md5"]);
121
139
 
@@ -133,8 +151,32 @@ self.addEventListener("activate", (event) => {
133
151
  self.addEventListener("message", (event) => {
134
152
  const data = event.data;
135
153
  if (!data || typeof data !== "object") return;
136
- if (data.type !== "flowersec-proxy:register-runtime") return;
137
- if (event.source && typeof event.source.id === "string") runtimeClientId = event.source.id;
154
+ const msgType = typeof data.type === "string" ? data.type : "";
155
+ if (msgType === "flowersec-proxy:register-runtime") {
156
+ if (event.source && typeof event.source.id === "string") runtimeClientId = event.source.id;
157
+ return;
158
+ }
159
+ if (!FORWARD_FETCH_MESSAGE_TYPES.has(msgType)) return;
160
+
161
+ const port = event.ports && event.ports[0];
162
+ if (!port) return;
163
+
164
+ 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 {}
168
+ try { port.close(); } catch {}
169
+ return;
170
+ }
171
+
172
+ try {
173
+ runtime.postMessage({ type: "flowersec-proxy:fetch", req: data.req }, [port]);
174
+ } catch (e) {
175
+ const msg = e instanceof Error ? e.message : String(e);
176
+ try { port.postMessage({ type: "flowersec-proxy:response_error", status: 502, message: msg }); } catch {}
177
+ try { port.close(); } catch {}
178
+ }
179
+ })());
138
180
  });
139
181
 
140
182
  async function getRuntimeClient() {
@@ -28,6 +28,7 @@ export type ReconnectManager = Readonly<{
28
28
  state: () => ReconnectState;
29
29
  subscribe: (listener: ReconnectListener) => () => void;
30
30
  connect: (config: ConnectConfig) => Promise<void>;
31
+ connectIfNeeded: (config: ConnectConfig) => Promise<void>;
31
32
  disconnect: () => void;
32
33
  }>;
33
34
  export declare function createReconnectManager(): ReconnectManager;
@@ -23,6 +23,22 @@ function backoffDelayMs(attemptIndex, cfg) {
23
23
  const jitter = cfg.jitterRatio <= 0 ? 0 : base * cfg.jitterRatio * (Math.random() * 2 - 1);
24
24
  return Math.max(0, Math.round(base + jitter));
25
25
  }
26
+ function isSameConfig(a, b) {
27
+ if (a == null)
28
+ return false;
29
+ if (a.connectOnce !== b.connectOnce)
30
+ return false;
31
+ if (a.observer !== b.observer)
32
+ return false;
33
+ const aa = normalizeAutoReconnect(a.autoReconnect);
34
+ const bb = normalizeAutoReconnect(b.autoReconnect);
35
+ return (aa.enabled === bb.enabled &&
36
+ aa.maxAttempts === bb.maxAttempts &&
37
+ aa.initialDelayMs === bb.initialDelayMs &&
38
+ aa.maxDelayMs === bb.maxDelayMs &&
39
+ aa.factor === bb.factor &&
40
+ aa.jitterRatio === bb.jitterRatio);
41
+ }
26
42
  export function createReconnectManager() {
27
43
  let s = { status: "disconnected", error: null, client: null };
28
44
  const listeners = new Set();
@@ -39,6 +55,7 @@ export function createReconnectManager() {
39
55
  };
40
56
  let token = 0;
41
57
  let active = null;
58
+ let activeConnectPromise = null;
42
59
  let retryTimer = null;
43
60
  let retryResolve = null;
44
61
  let attemptAbort = null;
@@ -71,6 +88,7 @@ export function createReconnectManager() {
71
88
  cancelRetrySleep();
72
89
  abortActiveAttempt();
73
90
  active = null;
91
+ activeConnectPromise = null;
74
92
  token += 1;
75
93
  if (s.client) {
76
94
  try {
@@ -209,7 +227,26 @@ export function createReconnectManager() {
209
227
  }
210
228
  }
211
229
  setState({ status: "connecting", error: null, client: null });
212
- await connectWithRetry(t, cfg);
230
+ const p = connectWithRetry(t, cfg);
231
+ activeConnectPromise = p;
232
+ try {
233
+ await p;
234
+ }
235
+ finally {
236
+ if (activeConnectPromise === p)
237
+ activeConnectPromise = null;
238
+ }
239
+ };
240
+ const connectIfNeeded = async (cfg) => {
241
+ if (isSameConfig(active, cfg)) {
242
+ if (s.status === "connected" && s.client)
243
+ return;
244
+ if (s.status === "connecting" && activeConnectPromise) {
245
+ await activeConnectPromise;
246
+ return;
247
+ }
248
+ }
249
+ await connect(cfg);
213
250
  };
214
251
  const disconnect = () => {
215
252
  disconnectInternal();
@@ -230,6 +267,7 @@ export function createReconnectManager() {
230
267
  };
231
268
  },
232
269
  connect,
270
+ connectIfNeeded,
233
271
  disconnect,
234
272
  };
235
273
  }
@@ -1,6 +1,8 @@
1
1
  import { type ConnectOptionsBase } from "../client-connect/connectCore.js";
2
2
  import type { ClientInternal } from "../client.js";
3
3
  export type TunnelConnectOptions = ConnectOptionsBase & Readonly<{
4
+ /** Type-only marker to prevent mixing direct and tunnel option types. */
5
+ __mode?: "tunnel";
4
6
  /** Optional caller-provided endpoint instance ID (base64url). */
5
7
  endpointInstanceId?: string;
6
8
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {