@floegence/flowersec-core 0.13.0 → 0.14.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,19 @@
1
+ import type { Client } from "../client.js";
2
+ import type { TunnelConnectBrowserOptions } from "../browser/connect.js";
3
+ import type { ChannelInitGrant } from "../gen/flowersec/controlplane/v1.gen.js";
4
+ import { type ProxyIntegrationPlugin, type ProxyIntegrationServiceWorkerOptions, type RegisterProxyIntegrationOptions } from "./integration.js";
5
+ import type { ProxyProfile, ProxyProfileName } from "./profiles.js";
6
+ import type { ProxyRuntime } from "./runtime.js";
7
+ export type ConnectTunnelProxyBrowserOptions = Readonly<{
8
+ connect?: TunnelConnectBrowserOptions;
9
+ profile?: ProxyProfileName | Partial<ProxyProfile>;
10
+ runtime?: RegisterProxyIntegrationOptions["runtime"];
11
+ serviceWorker: ProxyIntegrationServiceWorkerOptions;
12
+ plugins?: readonly ProxyIntegrationPlugin[];
13
+ }>;
14
+ export type ConnectTunnelProxyBrowserHandle = Readonly<{
15
+ client: Client;
16
+ runtime: ProxyRuntime;
17
+ dispose: () => Promise<void>;
18
+ }>;
19
+ export declare function connectTunnelProxyBrowser(grant: ChannelInitGrant, opts: ConnectTunnelProxyBrowserOptions): Promise<ConnectTunnelProxyBrowserHandle>;
@@ -0,0 +1,45 @@
1
+ import { connectTunnelBrowser } from "../browser/connect.js";
2
+ import { registerProxyIntegration, } from "./integration.js";
3
+ export async function connectTunnelProxyBrowser(grant, opts) {
4
+ const client = await connectTunnelBrowser(grant, opts.connect ?? {});
5
+ const integrationInput = {
6
+ client,
7
+ serviceWorker: opts.serviceWorker,
8
+ ...(opts.profile === undefined ? {} : { profile: opts.profile }),
9
+ ...(opts.runtime === undefined ? {} : { runtime: opts.runtime }),
10
+ ...(opts.plugins === undefined ? {} : { plugins: opts.plugins }),
11
+ };
12
+ let registered = false;
13
+ let integration = null;
14
+ try {
15
+ integration = await registerProxyIntegration(integrationInput);
16
+ registered = true;
17
+ }
18
+ finally {
19
+ if (!registered) {
20
+ client.close();
21
+ }
22
+ }
23
+ return {
24
+ client,
25
+ runtime: integration.runtime,
26
+ dispose: async () => {
27
+ let firstError = null;
28
+ try {
29
+ await integration.dispose();
30
+ }
31
+ catch (error) {
32
+ firstError = error;
33
+ }
34
+ try {
35
+ client.close();
36
+ }
37
+ catch (error) {
38
+ if (firstError == null)
39
+ firstError = error;
40
+ }
41
+ if (firstError != null)
42
+ throw firstError;
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,33 @@
1
+ export type ServiceWorkerControllerGuardMonitorOptions = Readonly<{
2
+ enabled?: boolean;
3
+ throttleMs?: number;
4
+ }>;
5
+ export type ServiceWorkerControllerGuardRepairOptions = Readonly<{
6
+ queryKey?: string;
7
+ maxAttempts?: number;
8
+ controllerTimeoutMs?: number;
9
+ strategy?: "replace" | "reload";
10
+ }>;
11
+ export type ServiceWorkerControllerGuardConflictPolicy = Readonly<{
12
+ keepScriptPathSuffixes?: readonly string[];
13
+ uninstallOnMismatch?: boolean;
14
+ }>;
15
+ export type ServiceWorkerControllerGuardMismatchContext = Readonly<{
16
+ expectedScriptPathSuffix: string;
17
+ actualScriptURL: string;
18
+ stage: "ensure" | "monitor";
19
+ }>;
20
+ export type ServiceWorkerControllerGuardOptions = Readonly<{
21
+ targetWindow?: Window;
22
+ navigationWindow?: Window;
23
+ expectedScriptPathSuffix: string;
24
+ repair?: ServiceWorkerControllerGuardRepairOptions;
25
+ monitor?: ServiceWorkerControllerGuardMonitorOptions;
26
+ conflicts?: ServiceWorkerControllerGuardConflictPolicy;
27
+ onControllerMismatch?: (ctx: ServiceWorkerControllerGuardMismatchContext) => "repair" | "ignore" | void;
28
+ }>;
29
+ export type ServiceWorkerControllerGuardHandle = Readonly<{
30
+ ensure: () => Promise<void>;
31
+ dispose: () => void;
32
+ }>;
33
+ export declare function createServiceWorkerControllerGuard(opts: ServiceWorkerControllerGuardOptions): ServiceWorkerControllerGuardHandle;
@@ -0,0 +1,205 @@
1
+ function dedupeStrings(values) {
2
+ const out = [];
3
+ const seen = new Set();
4
+ for (const value of values) {
5
+ const normalized = String(value ?? "").trim();
6
+ if (normalized === "" || seen.has(normalized))
7
+ continue;
8
+ seen.add(normalized);
9
+ out.push(normalized);
10
+ }
11
+ return out;
12
+ }
13
+ function resolveWindow(raw, label) {
14
+ const candidate = (raw ?? globalThis.window);
15
+ if (candidate == null) {
16
+ throw new Error(`${label} is not available in this environment`);
17
+ }
18
+ return candidate;
19
+ }
20
+ function getServiceWorker(targetWindow) {
21
+ return targetWindow.navigator?.serviceWorker ?? null;
22
+ }
23
+ function parseRepairAttemptFromHref(href, queryKey) {
24
+ try {
25
+ const url = new URL(href);
26
+ const raw = String(url.searchParams.get(queryKey) ?? "").trim();
27
+ const n = raw === "" ? 0 : Number(raw);
28
+ if (!Number.isFinite(n) || n < 0)
29
+ return 0;
30
+ return Math.min(9, Math.floor(n));
31
+ }
32
+ catch {
33
+ return 0;
34
+ }
35
+ }
36
+ function buildHrefWithRepairAttempt(href, queryKey, attempt) {
37
+ const url = new URL(href);
38
+ url.searchParams.set(queryKey, String(Math.max(0, Math.floor(attempt))));
39
+ return url.toString();
40
+ }
41
+ function isServiceWorkerScriptPathSuffix(raw, suffix) {
42
+ const script = String(raw ?? "").trim();
43
+ const wanted = String(suffix ?? "").trim();
44
+ if (script === "" || wanted === "")
45
+ return false;
46
+ try {
47
+ return new URL(script).pathname.endsWith(wanted);
48
+ }
49
+ catch {
50
+ return script.endsWith(wanted);
51
+ }
52
+ }
53
+ async function waitForControllerSuffix(targetWindow, suffix, timeoutMs) {
54
+ const sw = getServiceWorker(targetWindow);
55
+ if (sw == null)
56
+ return false;
57
+ const isMatch = () => isServiceWorkerScriptPathSuffix(String(sw.controller?.scriptURL ?? ""), suffix);
58
+ if (isMatch())
59
+ return true;
60
+ const ms = Math.max(0, Math.floor(timeoutMs));
61
+ return await new Promise((resolve) => {
62
+ let done = false;
63
+ let timer = null;
64
+ const finish = (ok) => {
65
+ if (done)
66
+ return;
67
+ done = true;
68
+ if (timer != null)
69
+ targetWindow.clearTimeout(timer);
70
+ sw.removeEventListener?.("controllerchange", onChange);
71
+ resolve(ok);
72
+ };
73
+ const onChange = () => {
74
+ if (isMatch())
75
+ finish(true);
76
+ };
77
+ sw.addEventListener?.("controllerchange", onChange);
78
+ if (isMatch()) {
79
+ finish(true);
80
+ return;
81
+ }
82
+ if (ms > 0) {
83
+ timer = targetWindow.setTimeout(() => finish(isMatch()), ms);
84
+ }
85
+ else {
86
+ finish(isMatch());
87
+ }
88
+ });
89
+ }
90
+ async function uninstallConflictingServiceWorkers(targetWindow, conflicts) {
91
+ if (conflicts?.uninstallOnMismatch === false)
92
+ return;
93
+ const sw = getServiceWorker(targetWindow);
94
+ if (sw == null || typeof sw.getRegistrations !== "function")
95
+ return;
96
+ const keepSuffixes = dedupeStrings(conflicts?.keepScriptPathSuffixes ?? []);
97
+ const regs = await sw.getRegistrations();
98
+ for (const reg of regs) {
99
+ const script = String(reg?.active?.scriptURL ?? reg?.waiting?.scriptURL ?? reg?.installing?.scriptURL ?? "").trim();
100
+ if (script === "")
101
+ continue;
102
+ let shouldKeep = false;
103
+ for (const suffix of keepSuffixes) {
104
+ if (isServiceWorkerScriptPathSuffix(script, suffix)) {
105
+ shouldKeep = true;
106
+ break;
107
+ }
108
+ }
109
+ if (shouldKeep)
110
+ continue;
111
+ try {
112
+ await reg.unregister?.();
113
+ }
114
+ catch {
115
+ // Best effort cleanup.
116
+ }
117
+ }
118
+ }
119
+ function triggerRepairNavigation(navigationWindow, repair) {
120
+ const queryKey = String(repair?.queryKey ?? "_flowersec_sw_repair").trim() || "_flowersec_sw_repair";
121
+ const maxAttempts = Math.max(0, Math.floor(repair?.maxAttempts ?? 2));
122
+ const strategy = repair?.strategy ?? "replace";
123
+ const attempt = parseRepairAttemptFromHref(navigationWindow.location.href, queryKey);
124
+ if (attempt >= maxAttempts)
125
+ return false;
126
+ if (strategy === "reload") {
127
+ navigationWindow.location.reload();
128
+ return true;
129
+ }
130
+ const next = buildHrefWithRepairAttempt(navigationWindow.location.href, queryKey, attempt + 1);
131
+ navigationWindow.location.replace(next);
132
+ return true;
133
+ }
134
+ export function createServiceWorkerControllerGuard(opts) {
135
+ const targetWindow = resolveWindow(opts.targetWindow, "targetWindow");
136
+ const navigationWindow = resolveWindow(opts.navigationWindow ?? opts.targetWindow, "navigationWindow");
137
+ const expectedScriptPathSuffix = String(opts.expectedScriptPathSuffix ?? "").trim();
138
+ if (expectedScriptPathSuffix === "") {
139
+ throw new Error("expectedScriptPathSuffix must be non-empty");
140
+ }
141
+ const controllerTimeoutMs = Math.max(0, Math.floor(opts.repair?.controllerTimeoutMs ?? 8_000));
142
+ const monitorEnabled = opts.monitor?.enabled ?? true;
143
+ const monitorThrottleMs = Math.max(0, Math.floor(opts.monitor?.throttleMs ?? 10_000));
144
+ let disposed = false;
145
+ let monitorHandler = null;
146
+ let lastMonitorRepairAt = 0;
147
+ const handleMismatch = async (stage) => {
148
+ const actualScriptURL = String(getServiceWorker(targetWindow)?.controller?.scriptURL ?? "").trim();
149
+ const ctx = {
150
+ expectedScriptPathSuffix,
151
+ actualScriptURL,
152
+ stage,
153
+ };
154
+ const action = opts.onControllerMismatch?.(ctx);
155
+ if (action === "ignore")
156
+ return "ignored";
157
+ await uninstallConflictingServiceWorkers(targetWindow, opts.conflicts);
158
+ return triggerRepairNavigation(navigationWindow, opts.repair) ? "repaired" : "skipped";
159
+ };
160
+ const attachMonitor = () => {
161
+ if (!monitorEnabled || monitorHandler != null)
162
+ return;
163
+ const currentSW = getServiceWorker(targetWindow);
164
+ if (currentSW == null)
165
+ return;
166
+ monitorHandler = () => {
167
+ const actualScriptURL = String(currentSW.controller?.scriptURL ?? "").trim();
168
+ if (isServiceWorkerScriptPathSuffix(actualScriptURL, expectedScriptPathSuffix))
169
+ return;
170
+ const now = Date.now();
171
+ if (monitorThrottleMs > 0 && now - lastMonitorRepairAt <= monitorThrottleMs)
172
+ return;
173
+ lastMonitorRepairAt = now;
174
+ void handleMismatch("monitor");
175
+ };
176
+ currentSW.addEventListener?.("controllerchange", monitorHandler);
177
+ };
178
+ return {
179
+ ensure: async () => {
180
+ if (disposed)
181
+ throw new Error("controller guard is already disposed");
182
+ if (getServiceWorker(targetWindow) == null) {
183
+ throw new Error("service worker is not available in the target window");
184
+ }
185
+ const ok = await waitForControllerSuffix(targetWindow, expectedScriptPathSuffix, controllerTimeoutMs);
186
+ if (!ok) {
187
+ const outcome = await handleMismatch("ensure");
188
+ if (outcome !== "ignored") {
189
+ throw new Error("Proxy Service Worker is installed but not controlling the target window");
190
+ }
191
+ return;
192
+ }
193
+ attachMonitor();
194
+ },
195
+ dispose: () => {
196
+ if (disposed)
197
+ return;
198
+ disposed = true;
199
+ if (monitorHandler != null) {
200
+ getServiceWorker(targetWindow)?.removeEventListener?.("controllerchange", monitorHandler);
201
+ }
202
+ monitorHandler = null;
203
+ },
204
+ };
205
+ }
@@ -6,5 +6,7 @@ export * from "./runtime.js";
6
6
  export * from "./serviceWorker.js";
7
7
  export { registerServiceWorkerAndEnsureControl } from "./registerServiceWorker.js";
8
8
  export * from "./integration.js";
9
+ export * from "./controllerGuard.js";
10
+ export * from "./bootstrap.js";
9
11
  export * from "./wsPatch.js";
10
12
  export * from "./disableUpstreamServiceWorkerRegister.js";
@@ -6,5 +6,7 @@ export * from "./runtime.js";
6
6
  export * from "./serviceWorker.js";
7
7
  export { registerServiceWorkerAndEnsureControl } from "./registerServiceWorker.js";
8
8
  export * from "./integration.js";
9
+ export * from "./controllerGuard.js";
10
+ export * from "./bootstrap.js";
9
11
  export * from "./wsPatch.js";
10
12
  export * from "./disableUpstreamServiceWorkerRegister.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {