@floegence/flowersec-core 0.7.0 → 0.8.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.
@@ -3,5 +3,6 @@ export * from "./types.js";
3
3
  export * from "./cookieJar.js";
4
4
  export * from "./runtime.js";
5
5
  export * from "./serviceWorker.js";
6
+ export { registerServiceWorkerAndEnsureControl } from "./registerServiceWorker.js";
6
7
  export * from "./wsPatch.js";
7
8
  export * from "./disableUpstreamServiceWorkerRegister.js";
@@ -3,5 +3,6 @@ export * from "./types.js";
3
3
  export * from "./cookieJar.js";
4
4
  export * from "./runtime.js";
5
5
  export * from "./serviceWorker.js";
6
+ export { registerServiceWorkerAndEnsureControl } from "./registerServiceWorker.js";
6
7
  export * from "./wsPatch.js";
7
8
  export * from "./disableUpstreamServiceWorkerRegister.js";
@@ -0,0 +1,17 @@
1
+ export type RegisterServiceWorkerOptions = Readonly<{
2
+ scriptUrl: string;
3
+ scope?: string;
4
+ repairQueryKey?: string;
5
+ maxRepairAttempts?: number;
6
+ controllerTimeoutMs?: number;
7
+ }>;
8
+ declare function parseRepairAttemptFromHref(href: string, queryKey: string): number;
9
+ declare function buildHrefWithRepairAttempt(href: string, queryKey: string, attempt: number): string;
10
+ declare function buildHrefWithoutRepairQueryParam(href: string, queryKey: string): string;
11
+ export declare function registerServiceWorkerAndEnsureControl(opts: RegisterServiceWorkerOptions): Promise<void>;
12
+ export declare const __testOnly: {
13
+ parseRepairAttemptFromHref: typeof parseRepairAttemptFromHref;
14
+ buildHrefWithRepairAttempt: typeof buildHrefWithRepairAttempt;
15
+ buildHrefWithoutRepairQueryParam: typeof buildHrefWithoutRepairQueryParam;
16
+ };
17
+ export {};
@@ -0,0 +1,116 @@
1
+ function parseRepairAttemptFromHref(href, queryKey) {
2
+ try {
3
+ const u = new URL(href);
4
+ const raw = String(u.searchParams.get(queryKey) ?? "").trim();
5
+ const n = raw ? Number(raw) : 0;
6
+ if (!Number.isFinite(n) || n < 0)
7
+ return 0;
8
+ return Math.min(9, Math.floor(n));
9
+ }
10
+ catch {
11
+ return 0;
12
+ }
13
+ }
14
+ function buildHrefWithRepairAttempt(href, queryKey, attempt) {
15
+ const u = new URL(href);
16
+ u.searchParams.set(queryKey, String(Math.max(0, Math.floor(attempt))));
17
+ return u.toString();
18
+ }
19
+ function buildHrefWithoutRepairQueryParam(href, queryKey) {
20
+ const u = new URL(href);
21
+ u.searchParams.delete(queryKey);
22
+ const search = u.searchParams.toString();
23
+ return u.pathname + (search ? `?${search}` : "") + u.hash;
24
+ }
25
+ function waitForController(timeoutMs) {
26
+ if (navigator.serviceWorker.controller)
27
+ return Promise.resolve(true);
28
+ const ms = Math.max(0, Math.floor(timeoutMs));
29
+ return new Promise((resolve) => {
30
+ let done = false;
31
+ let t = null;
32
+ function finish(ok) {
33
+ if (done)
34
+ return;
35
+ done = true;
36
+ if (t != null)
37
+ window.clearTimeout(t);
38
+ navigator.serviceWorker.removeEventListener("controllerchange", onChange);
39
+ resolve(ok);
40
+ }
41
+ function onChange() {
42
+ finish(true);
43
+ }
44
+ navigator.serviceWorker.addEventListener("controllerchange", onChange);
45
+ // Avoid race: controller can become available before/after the listener registration.
46
+ if (navigator.serviceWorker.controller) {
47
+ finish(true);
48
+ return;
49
+ }
50
+ if (ms > 0) {
51
+ t = window.setTimeout(() => finish(Boolean(navigator.serviceWorker.controller)), ms);
52
+ }
53
+ });
54
+ }
55
+ // registerServiceWorkerAndEnsureControl registers a Service Worker and ensures the current page load is controlled.
56
+ //
57
+ // In DevTools hard reload flows, the SW may be installed but not control the current page load.
58
+ // When this happens, we perform a limited "soft navigation" repair to recover control.
59
+ export async function registerServiceWorkerAndEnsureControl(opts) {
60
+ const scriptUrl = String(opts.scriptUrl ?? "").trim();
61
+ if (!scriptUrl)
62
+ throw new Error("scriptUrl is required");
63
+ if (globalThis.navigator?.serviceWorker == null) {
64
+ throw new Error("service worker is not available in this environment");
65
+ }
66
+ const scope = String(opts.scope ?? "/").trim() || "/";
67
+ const queryKey = String(opts.repairQueryKey ?? "_flowersec_sw_repair").trim() || "_flowersec_sw_repair";
68
+ const maxRepairAttempts = Math.max(0, Math.floor(opts.maxRepairAttempts ?? 2));
69
+ const controllerTimeoutMs = Math.max(0, Math.floor(opts.controllerTimeoutMs ?? 2_000));
70
+ await navigator.serviceWorker.register(scriptUrl, { scope });
71
+ await navigator.serviceWorker.ready;
72
+ const attempt = parseRepairAttemptFromHref(window.location.href, queryKey);
73
+ if (navigator.serviceWorker.controller) {
74
+ if (attempt > 0) {
75
+ try {
76
+ const next = buildHrefWithoutRepairQueryParam(window.location.href, queryKey);
77
+ history.replaceState(null, document.title, next);
78
+ }
79
+ catch {
80
+ // ignore
81
+ }
82
+ }
83
+ return;
84
+ }
85
+ const controlled = await waitForController(controllerTimeoutMs);
86
+ if (controlled) {
87
+ if (attempt > 0) {
88
+ try {
89
+ const next = buildHrefWithoutRepairQueryParam(window.location.href, queryKey);
90
+ history.replaceState(null, document.title, next);
91
+ }
92
+ catch {
93
+ // ignore
94
+ }
95
+ }
96
+ return;
97
+ }
98
+ if (attempt < maxRepairAttempts) {
99
+ try {
100
+ const next = buildHrefWithRepairAttempt(window.location.href, queryKey, attempt + 1);
101
+ window.location.replace(next);
102
+ }
103
+ catch {
104
+ window.location.reload();
105
+ }
106
+ // Navigation will interrupt JS; keep pending so callers don't proceed with dependent work.
107
+ await new Promise(() => { });
108
+ }
109
+ throw new Error("Service Worker is installed but not controlling this page");
110
+ }
111
+ // Test-only exports (not re-exported from the public proxy entrypoint).
112
+ export const __testOnly = {
113
+ parseRepairAttemptFromHref,
114
+ buildHrefWithRepairAttempt,
115
+ buildHrefWithoutRepairQueryParam,
116
+ };
@@ -1,9 +1,32 @@
1
+ export type ProxyServiceWorkerPassthroughOptions = Readonly<{
2
+ paths?: readonly string[];
3
+ prefixes?: readonly string[];
4
+ }>;
5
+ export type ProxyServiceWorkerInjectHTMLInlineModule = Readonly<{
6
+ mode?: "inline_module";
7
+ proxyModuleUrl: string;
8
+ runtimeGlobal?: string;
9
+ }>;
10
+ export type ProxyServiceWorkerInjectHTMLExternalScript = Readonly<{
11
+ mode: "external_script";
12
+ scriptUrl: string;
13
+ runtimeGlobal?: string;
14
+ }>;
15
+ export type ProxyServiceWorkerInjectHTMLExternalModule = Readonly<{
16
+ mode: "external_module";
17
+ scriptUrl: string;
18
+ runtimeGlobal?: string;
19
+ }>;
20
+ export type ProxyServiceWorkerInjectHTMLOptions = (ProxyServiceWorkerInjectHTMLInlineModule | ProxyServiceWorkerInjectHTMLExternalScript | ProxyServiceWorkerInjectHTMLExternalModule) & Readonly<{
21
+ excludePathPrefixes?: readonly string[];
22
+ stripValidatorHeaders?: boolean;
23
+ setNoStore?: boolean;
24
+ }>;
1
25
  export type ProxyServiceWorkerScriptOptions = Readonly<{
26
+ sameOriginOnly?: boolean;
27
+ passthrough?: ProxyServiceWorkerPassthroughOptions;
2
28
  proxyPathPrefix?: string;
3
29
  stripProxyPathPrefix?: boolean;
4
- injectHTML?: Readonly<{
5
- proxyModuleUrl: string;
6
- runtimeGlobal?: string;
7
- }>;
30
+ injectHTML?: ProxyServiceWorkerInjectHTMLOptions;
8
31
  }>;
9
32
  export declare function createProxyServiceWorkerScript(opts?: ProxyServiceWorkerScriptOptions): string;
@@ -1,25 +1,103 @@
1
+ function normalizePathPrefix(name, v) {
2
+ const s = typeof v === "string" ? v.trim() : "";
3
+ if (s === "")
4
+ return "";
5
+ if (!s.startsWith("/"))
6
+ throw new Error(`${name} must start with "/"`);
7
+ if (s.startsWith("//"))
8
+ throw new Error(`${name} must not start with "//"`);
9
+ if (/[ \t\r\n]/.test(s))
10
+ throw new Error(`${name} must not contain whitespace`);
11
+ if (s.includes("://"))
12
+ throw new Error(`${name} must not include scheme/host`);
13
+ return s;
14
+ }
15
+ function normalizePathList(name, input) {
16
+ const out = [];
17
+ if (input == null || input.length === 0)
18
+ return out;
19
+ for (const raw of input) {
20
+ const s = normalizePathPrefix(name, raw);
21
+ if (s === "")
22
+ continue;
23
+ out.push(s);
24
+ }
25
+ return Array.from(new Set(out));
26
+ }
1
27
  // createProxyServiceWorkerScript returns a Service Worker script that forwards fetches to a runtime
2
28
  // in a controlled window via postMessage + MessageChannel.
3
29
  //
4
30
  // The runtime side is implemented by createProxyRuntime(...) in this package.
5
31
  export function createProxyServiceWorkerScript(opts = {}) {
6
- const proxyPathPrefix = opts.proxyPathPrefix ?? "";
32
+ const sameOriginOnly = opts.sameOriginOnly ?? true;
33
+ if (typeof sameOriginOnly !== "boolean") {
34
+ throw new Error("sameOriginOnly must be a boolean");
35
+ }
36
+ const proxyPathPrefix = normalizePathPrefix("proxyPathPrefix", opts.proxyPathPrefix);
7
37
  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");
38
+ if (typeof stripProxyPathPrefix !== "boolean") {
39
+ throw new Error("stripProxyPathPrefix must be a boolean");
13
40
  }
41
+ const passthroughPaths = normalizePathList("passthrough.paths", opts.passthrough?.paths);
42
+ const passthroughPrefixes = normalizePathList("passthrough.prefixes", opts.passthrough?.prefixes);
43
+ const injectHTML = opts.injectHTML ?? null;
44
+ // Injection mode defaults to inline_module when injectHTML is provided.
45
+ const injectMode = injectHTML?.mode ?? "inline_module";
46
+ const runtimeGlobal = injectHTML != null ? (injectHTML.runtimeGlobal?.trim() ?? "__flowersecProxyRuntime") : "__flowersecProxyRuntime";
14
47
  if (injectHTML != null && runtimeGlobal === "") {
15
48
  throw new Error("injectHTML.runtimeGlobal must be non-empty");
16
49
  }
50
+ const excludeInjectPrefixes = normalizePathList("injectHTML.excludePathPrefixes", injectHTML?.excludePathPrefixes);
51
+ const stripValidatorHeaders = injectHTML?.stripValidatorHeaders ?? true;
52
+ if (typeof stripValidatorHeaders !== "boolean") {
53
+ throw new Error("injectHTML.stripValidatorHeaders must be a boolean");
54
+ }
55
+ const setNoStore = injectHTML?.setNoStore ?? true;
56
+ if (typeof setNoStore !== "boolean") {
57
+ throw new Error("injectHTML.setNoStore must be a boolean");
58
+ }
59
+ let proxyModuleUrl = "";
60
+ let injectScriptUrl = "";
61
+ if (injectHTML != null) {
62
+ if (injectMode === "inline_module") {
63
+ // Note: union type guarantees proxyModuleUrl exists, but keep a runtime check for JS callers.
64
+ proxyModuleUrl =
65
+ "proxyModuleUrl" in injectHTML && typeof injectHTML.proxyModuleUrl === "string"
66
+ ? injectHTML.proxyModuleUrl.trim()
67
+ : "";
68
+ if (proxyModuleUrl === "") {
69
+ throw new Error("injectHTML.proxyModuleUrl must be non-empty");
70
+ }
71
+ }
72
+ else {
73
+ injectScriptUrl =
74
+ "scriptUrl" in injectHTML && typeof injectHTML.scriptUrl === "string"
75
+ ? injectHTML.scriptUrl.trim()
76
+ : "";
77
+ if (injectScriptUrl === "") {
78
+ throw new Error("injectHTML.scriptUrl must be non-empty");
79
+ }
80
+ }
81
+ }
17
82
  return `// Generated by @floegence/flowersec-core/proxy
83
+ const SAME_ORIGIN_ONLY = ${JSON.stringify(sameOriginOnly)};
18
84
  const PROXY_PATH_PREFIX = ${JSON.stringify(proxyPathPrefix)};
19
85
  const STRIP_PROXY_PATH_PREFIX = ${JSON.stringify(stripProxyPathPrefix)};
86
+
87
+ const PASSTHROUGH_PATHS = new Set(${JSON.stringify(passthroughPaths)});
88
+ const PASSTHROUGH_PREFIXES = ${JSON.stringify(passthroughPrefixes)};
89
+
20
90
  const INJECT_HTML = ${JSON.stringify(injectHTML != null)};
91
+ const INJECT_MODE = ${JSON.stringify(injectMode)};
21
92
  const PROXY_MODULE_URL = ${JSON.stringify(proxyModuleUrl)};
93
+ const INJECT_SCRIPT_URL = ${JSON.stringify(injectScriptUrl)};
22
94
  const RUNTIME_GLOBAL = ${JSON.stringify(runtimeGlobal)};
95
+ const INJECT_EXCLUDE_PREFIXES = ${JSON.stringify(excludeInjectPrefixes)};
96
+ const INJECT_STRIP_VALIDATOR_HEADERS = ${JSON.stringify(stripValidatorHeaders)};
97
+ const INJECT_SET_NO_STORE = ${JSON.stringify(setNoStore)};
98
+
99
+ const INJECT_STRIP_HEADER_NAMES = new Set(["content-length", "etag", "last-modified", "content-md5"]);
100
+
23
101
  let runtimeClientId = null;
24
102
 
25
103
  self.addEventListener("install", () => {
@@ -52,6 +130,21 @@ async function getRuntimeClient() {
52
130
  return null;
53
131
  }
54
132
 
133
+ function shouldPassthrough(pathname) {
134
+ if (PASSTHROUGH_PATHS.has(pathname)) return true;
135
+ for (const p of PASSTHROUGH_PREFIXES) {
136
+ if (pathname.startsWith(p)) return true;
137
+ }
138
+ return false;
139
+ }
140
+
141
+ function shouldSkipInject(pathname) {
142
+ for (const p of INJECT_EXCLUDE_PREFIXES) {
143
+ if (pathname.startsWith(p)) return true;
144
+ }
145
+ return false;
146
+ }
147
+
55
148
  function headersToPairs(headers) {
56
149
  const out = [];
57
150
  headers.forEach((value, name) => out.push({ name, value }));
@@ -71,12 +164,31 @@ function concatChunks(chunks) {
71
164
  }
72
165
 
73
166
  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>';
167
+ let snippet = "";
168
+
169
+ if (INJECT_MODE === "inline_module") {
170
+ snippet =
171
+ '<script type="module">' +
172
+ 'import { installWebSocketPatch, disableUpstreamServiceWorkerRegister } from ' + JSON.stringify(PROXY_MODULE_URL) + ';' +
173
+ 'const rt = window.top && window.top[' + JSON.stringify(RUNTIME_GLOBAL) + '];' +
174
+ 'if (rt) { disableUpstreamServiceWorkerRegister(); installWebSocketPatch({ runtime: rt }); }' +
175
+ '</script>';
176
+ } else if (INJECT_MODE === "external_module") {
177
+ snippet =
178
+ '<script type="module" src="' +
179
+ INJECT_SCRIPT_URL +
180
+ '"' +
181
+ (RUNTIME_GLOBAL ? ' data-flowersec-runtime-global="' + RUNTIME_GLOBAL + '"' : "") +
182
+ "></script>";
183
+ } else if (INJECT_MODE === "external_script") {
184
+ snippet =
185
+ '<script src="' +
186
+ INJECT_SCRIPT_URL +
187
+ '"' +
188
+ (RUNTIME_GLOBAL ? ' data-flowersec-runtime-global="' + RUNTIME_GLOBAL + '"' : "") +
189
+ "></script>";
190
+ }
191
+
80
192
  const lower = html.toLowerCase();
81
193
  const idx = lower.indexOf("<head");
82
194
  if (idx >= 0) {
@@ -88,7 +200,13 @@ function injectBootstrap(html) {
88
200
 
89
201
  self.addEventListener("fetch", (event) => {
90
202
  const url = new URL(event.request.url);
203
+
204
+ // Only proxy same-origin requests by default. The runtime proxy protocol forwards path+query only.
205
+ if (SAME_ORIGIN_ONLY && url.origin !== self.location.origin) return;
206
+
91
207
  if (PROXY_PATH_PREFIX && !url.pathname.startsWith(PROXY_PATH_PREFIX)) return;
208
+ if (shouldPassthrough(url.pathname)) return;
209
+
92
210
  event.respondWith(handleFetch(event));
93
211
  });
94
212
 
@@ -112,6 +230,7 @@ async function handleFetch(event) {
112
230
  const queued = [];
113
231
  const htmlChunks = [];
114
232
  let shouldInjectHTML = false;
233
+ const injectAllowed = INJECT_HTML && !shouldSkipInject(url.pathname);
115
234
 
116
235
  let doneResolve;
117
236
  let doneReject;
@@ -131,7 +250,7 @@ async function handleFetch(event) {
131
250
  const m = ev.data;
132
251
  if (!m || typeof m.type !== "string") return;
133
252
  if (m.type === "flowersec-proxy:response_meta") {
134
- if (INJECT_HTML) {
253
+ if (injectAllowed) {
135
254
  const ct = String((m.headers || []).find((h) => (h.name || "").toLowerCase() === "content-type")?.value || "");
136
255
  shouldInjectHTML = ct.toLowerCase().includes("text/html");
137
256
  }
@@ -182,7 +301,17 @@ async function handleFetch(event) {
182
301
 
183
302
  const meta = await metaPromise;
184
303
  const headers = new Headers();
185
- for (const h of (meta.headers || [])) headers.append(h.name, h.value);
304
+ for (const h of (meta.headers || [])) {
305
+ const name = String(h.name || "");
306
+ const lower = name.toLowerCase();
307
+ if (shouldInjectHTML && INJECT_STRIP_VALIDATOR_HEADERS && INJECT_STRIP_HEADER_NAMES.has(lower)) continue;
308
+ headers.append(name, String(h.value || ""));
309
+ }
310
+
311
+ if (shouldInjectHTML && INJECT_SET_NO_STORE && !headers.has("Cache-Control")) {
312
+ headers.set("Cache-Control", "no-store");
313
+ }
314
+
186
315
  if (shouldInjectHTML) {
187
316
  const chunks = await donePromise;
188
317
  const raw = concatChunks(chunks);
@@ -0,0 +1,33 @@
1
+ import type { Client } from "../client.js";
2
+ import type { ClientObserverLike } from "../observability/observer.js";
3
+ export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
4
+ export type AutoReconnectConfig = Readonly<{
5
+ enabled?: boolean;
6
+ maxAttempts?: number;
7
+ initialDelayMs?: number;
8
+ maxDelayMs?: number;
9
+ factor?: number;
10
+ jitterRatio?: number;
11
+ }>;
12
+ export type ReconnectState = Readonly<{
13
+ status: ConnectionStatus;
14
+ error: Error | null;
15
+ client: Client | null;
16
+ }>;
17
+ export type ReconnectListener = (state: ReconnectState) => void;
18
+ export type ConnectOnce = (args: Readonly<{
19
+ signal: AbortSignal;
20
+ observer: ClientObserverLike;
21
+ }>) => Promise<Client>;
22
+ export type ConnectConfig = Readonly<{
23
+ connectOnce: ConnectOnce;
24
+ observer?: ClientObserverLike;
25
+ autoReconnect?: AutoReconnectConfig;
26
+ }>;
27
+ export type ReconnectManager = Readonly<{
28
+ state: () => ReconnectState;
29
+ subscribe: (listener: ReconnectListener) => () => void;
30
+ connect: (config: ConnectConfig) => Promise<void>;
31
+ disconnect: () => void;
32
+ }>;
33
+ export declare function createReconnectManager(): ReconnectManager;
@@ -0,0 +1,235 @@
1
+ function normalizeAutoReconnect(cfg) {
2
+ if (!cfg?.enabled) {
3
+ return {
4
+ enabled: false,
5
+ maxAttempts: 1,
6
+ initialDelayMs: 500,
7
+ maxDelayMs: 10_000,
8
+ factor: 1.8,
9
+ jitterRatio: 0.2,
10
+ };
11
+ }
12
+ return {
13
+ enabled: true,
14
+ maxAttempts: Math.max(1, cfg.maxAttempts ?? 5),
15
+ initialDelayMs: Math.max(0, cfg.initialDelayMs ?? 500),
16
+ maxDelayMs: Math.max(0, cfg.maxDelayMs ?? 10_000),
17
+ factor: Math.max(1, cfg.factor ?? 1.8),
18
+ jitterRatio: Math.max(0, cfg.jitterRatio ?? 0.2),
19
+ };
20
+ }
21
+ function backoffDelayMs(attemptIndex, cfg) {
22
+ const base = Math.min(cfg.maxDelayMs, cfg.initialDelayMs * Math.pow(cfg.factor, attemptIndex));
23
+ const jitter = cfg.jitterRatio <= 0 ? 0 : base * cfg.jitterRatio * (Math.random() * 2 - 1);
24
+ return Math.max(0, Math.round(base + jitter));
25
+ }
26
+ export function createReconnectManager() {
27
+ let s = { status: "disconnected", error: null, client: null };
28
+ const listeners = new Set();
29
+ const setState = (next) => {
30
+ s = next;
31
+ for (const fn of listeners) {
32
+ try {
33
+ fn(s);
34
+ }
35
+ catch {
36
+ // ignore listener errors
37
+ }
38
+ }
39
+ };
40
+ let token = 0;
41
+ let active = null;
42
+ let retryTimer = null;
43
+ let retryResolve = null;
44
+ let attemptAbort = null;
45
+ const cancelRetrySleep = () => {
46
+ if (retryTimer) {
47
+ clearTimeout(retryTimer);
48
+ retryTimer = null;
49
+ }
50
+ retryResolve?.();
51
+ retryResolve = null;
52
+ };
53
+ const abortActiveAttempt = () => {
54
+ try {
55
+ attemptAbort?.abort("canceled");
56
+ }
57
+ catch {
58
+ // ignore
59
+ }
60
+ attemptAbort = null;
61
+ };
62
+ const sleep = (ms) => new Promise((resolve) => {
63
+ retryResolve = resolve;
64
+ retryTimer = setTimeout(() => {
65
+ retryTimer = null;
66
+ retryResolve = null;
67
+ resolve();
68
+ }, ms);
69
+ });
70
+ const disconnectInternal = () => {
71
+ cancelRetrySleep();
72
+ abortActiveAttempt();
73
+ active = null;
74
+ token += 1;
75
+ if (s.client) {
76
+ try {
77
+ s.client.close();
78
+ }
79
+ catch {
80
+ // ignore
81
+ }
82
+ }
83
+ setState({ status: "disconnected", error: null, client: null });
84
+ };
85
+ const startReconnect = (t, cfg, error) => {
86
+ if (t !== token)
87
+ return;
88
+ if (active !== cfg)
89
+ return;
90
+ if (s.status !== "connected")
91
+ return;
92
+ const ar = normalizeAutoReconnect(cfg.autoReconnect);
93
+ if (!ar.enabled) {
94
+ if (s.client) {
95
+ try {
96
+ s.client.close();
97
+ }
98
+ catch {
99
+ // ignore
100
+ }
101
+ }
102
+ setState({ status: "error", error, client: null });
103
+ return;
104
+ }
105
+ // Restart the connection loop in the background.
106
+ cancelRetrySleep();
107
+ abortActiveAttempt();
108
+ if (s.client) {
109
+ try {
110
+ s.client.close();
111
+ }
112
+ catch {
113
+ // ignore
114
+ }
115
+ }
116
+ token += 1;
117
+ const nextToken = token;
118
+ setState({ status: "connecting", error, client: null });
119
+ void connectWithRetry(nextToken, cfg).catch(() => {
120
+ // connectWithRetry updates state; keep errors observable via state().
121
+ });
122
+ };
123
+ const createObserver = (t, cfg) => {
124
+ const user = cfg.observer;
125
+ return {
126
+ onConnect: (...args) => user?.onConnect?.(...args),
127
+ onAttach: (...args) => user?.onAttach?.(...args),
128
+ onHandshake: (...args) => user?.onHandshake?.(...args),
129
+ onWsClose: (kind, code) => {
130
+ user?.onWsClose?.(kind, code);
131
+ if (kind === "peer_or_error") {
132
+ startReconnect(t, cfg, new Error(`WebSocket closed (${code ?? "unknown"})`));
133
+ }
134
+ },
135
+ onWsError: (reason) => {
136
+ user?.onWsError?.(reason);
137
+ startReconnect(t, cfg, new Error(`WebSocket error: ${reason}`));
138
+ },
139
+ onRpcCall: (...args) => user?.onRpcCall?.(...args),
140
+ onRpcNotify: (...args) => user?.onRpcNotify?.(...args),
141
+ };
142
+ };
143
+ const connectOnce = async (t, cfg) => {
144
+ abortActiveAttempt();
145
+ attemptAbort = new AbortController();
146
+ return await cfg.connectOnce({ signal: attemptAbort.signal, observer: createObserver(t, cfg) ?? {} });
147
+ };
148
+ const connectWithRetry = async (t, cfg) => {
149
+ const ar = normalizeAutoReconnect(cfg.autoReconnect);
150
+ let attempts = 0;
151
+ for (;;) {
152
+ if (t !== token)
153
+ return;
154
+ if (active !== cfg)
155
+ return;
156
+ attempts += 1;
157
+ try {
158
+ const client = await connectOnce(t, cfg);
159
+ if (t !== token) {
160
+ try {
161
+ client.close();
162
+ }
163
+ catch {
164
+ // ignore
165
+ }
166
+ return;
167
+ }
168
+ if (active !== cfg) {
169
+ try {
170
+ client.close();
171
+ }
172
+ catch {
173
+ // ignore
174
+ }
175
+ return;
176
+ }
177
+ setState({ status: "connected", client, error: null });
178
+ return;
179
+ }
180
+ catch (err) {
181
+ const e = err instanceof Error ? err : new Error(String(err));
182
+ if (t !== token)
183
+ return;
184
+ if (active !== cfg)
185
+ return;
186
+ const canRetry = ar.enabled && attempts < ar.maxAttempts;
187
+ if (!canRetry) {
188
+ setState({ status: "error", error: e, client: null });
189
+ throw e;
190
+ }
191
+ setState({ status: "connecting", error: e, client: null });
192
+ const delay = backoffDelayMs(attempts - 1, ar);
193
+ await sleep(delay);
194
+ }
195
+ }
196
+ };
197
+ const connect = async (cfg) => {
198
+ cancelRetrySleep();
199
+ abortActiveAttempt();
200
+ token += 1;
201
+ const t = token;
202
+ active = cfg;
203
+ if (s.client) {
204
+ try {
205
+ s.client.close();
206
+ }
207
+ catch {
208
+ // ignore
209
+ }
210
+ }
211
+ setState({ status: "connecting", error: null, client: null });
212
+ await connectWithRetry(t, cfg);
213
+ };
214
+ const disconnect = () => {
215
+ disconnectInternal();
216
+ };
217
+ return {
218
+ state: () => s,
219
+ subscribe: (listener) => {
220
+ listeners.add(listener);
221
+ // Push the current state immediately for convenience.
222
+ try {
223
+ listener(s);
224
+ }
225
+ catch {
226
+ // ignore
227
+ }
228
+ return () => {
229
+ listeners.delete(listener);
230
+ };
231
+ },
232
+ connect,
233
+ disconnect,
234
+ };
235
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -48,6 +48,10 @@
48
48
  "types": "./dist/proxy/index.d.ts",
49
49
  "default": "./dist/proxy/index.js"
50
50
  },
51
+ "./reconnect": {
52
+ "types": "./dist/reconnect/index.d.ts",
53
+ "default": "./dist/reconnect/index.js"
54
+ },
51
55
  "./rpc": {
52
56
  "types": "./dist/rpc/index.d.ts",
53
57
  "default": "./dist/rpc/index.js"