@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.
- package/dist/proxy/index.d.ts +1 -0
- package/dist/proxy/index.js +1 -0
- package/dist/proxy/registerServiceWorker.d.ts +17 -0
- package/dist/proxy/registerServiceWorker.js +116 -0
- package/dist/proxy/serviceWorker.d.ts +27 -4
- package/dist/proxy/serviceWorker.js +143 -14
- package/dist/reconnect/index.d.ts +33 -0
- package/dist/reconnect/index.js +235 -0
- package/package.json +5 -1
package/dist/proxy/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/proxy/index.js
CHANGED
|
@@ -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?:
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 (
|
|
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 || []))
|
|
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.
|
|
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"
|