@floegence/flowersec-core 0.13.0 → 0.14.1

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.
@@ -14,6 +14,19 @@ export async function connectCore(args) {
14
14
  const observer = normalizeObserver(args.opts.observer);
15
15
  const signal = args.opts.signal;
16
16
  const connectStart = nowSeconds();
17
+ let attachState = args.path === "tunnel" ? "not_started" : "accepted";
18
+ const reportAttachSuccess = () => {
19
+ if (args.path !== "tunnel" || attachState !== "sent")
20
+ return;
21
+ observer.onAttach("ok", undefined);
22
+ attachState = "accepted";
23
+ };
24
+ const reportAttachFailure = (reason) => {
25
+ if (args.path !== "tunnel" || attachState !== "sent")
26
+ return;
27
+ observer.onAttach("fail", reason);
28
+ attachState = "failed";
29
+ };
17
30
  const origin = typeof args.opts.origin === "string" ? args.opts.origin.trim() : "";
18
31
  if (origin === "") {
19
32
  throw new FlowersecError({ path: args.path, stage: "validate", code: "missing_origin", message: "missing origin" });
@@ -131,9 +144,11 @@ export async function connectCore(args) {
131
144
  if (args.path === "tunnel") {
132
145
  try {
133
146
  ws.send(args.attach.attachJson);
147
+ attachState = "sent";
134
148
  }
135
149
  catch (err) {
136
150
  observer.onAttach("fail", "send_failed");
151
+ attachState = "failed";
137
152
  try {
138
153
  transport.close();
139
154
  }
@@ -161,9 +176,7 @@ export async function connectCore(args) {
161
176
  ...(signal !== undefined ? { signal } : {}),
162
177
  onCancel: () => transport.close(),
163
178
  });
164
- if (args.path === "tunnel") {
165
- observer.onAttach("ok", undefined);
166
- }
179
+ reportAttachSuccess();
167
180
  observer.onHandshake(args.path, "ok", undefined, nowSeconds() - handshakeStart);
168
181
  }
169
182
  catch (err) {
@@ -173,12 +186,12 @@ export async function connectCore(args) {
173
186
  if (args.path === "tunnel" && err instanceof WsCloseError) {
174
187
  const reason = err.reason;
175
188
  if (isTunnelAttachCloseReason(reason)) {
176
- observer.onAttach("fail", reason);
189
+ reportAttachFailure(reason);
177
190
  throw new FlowersecError({ path: args.path, stage: "attach", code: reason, message: "tunnel rejected attach", cause: err });
178
191
  }
179
192
  }
180
193
  if (args.path === "tunnel") {
181
- observer.onAttach("ok", undefined);
194
+ reportAttachFailure(handshakeCode === "timeout" ? "timeout" : handshakeCode === "canceled" ? "canceled" : "attach_failed");
182
195
  }
183
196
  observer.onHandshake(args.path, "fail", handshakeCode, handshakeElapsedSeconds);
184
197
  throw new FlowersecError({
@@ -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.1",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -108,7 +108,10 @@
108
108
  "build": "tsc -p tsconfig.build.json",
109
109
  "bench": "vitest bench --run",
110
110
  "test": "vitest run",
111
- "lint": "eslint ."
111
+ "lint": "eslint .",
112
+ "verify:package": "node ./scripts/verify-package-exports.mjs",
113
+ "prepack": "npm run build",
114
+ "prepublishOnly": "npm run build && npm run verify:package"
112
115
  },
113
116
  "dependencies": {
114
117
  "@noble/ciphers": "^0.6.0",