@floegence/flowersec-core 0.12.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.
@@ -8,6 +8,7 @@ import { base64urlDecode } from "../utils/base64url.js";
8
8
  import { AbortError, FlowersecError, throwIfAborted } from "../utils/errors.js";
9
9
  import { WebSocketBinaryTransport, WsCloseError } from "../ws-client/binaryTransport.js";
10
10
  import { OriginMismatchError, WsFactoryRequiredError, classifyConnectError, classifyHandshakeError, createWebSocket, waitOpen, withAbortAndTimeout, } from "./common.js";
11
+ import { prepareChannelId } from "./contract.js";
11
12
  import { isTunnelAttachCloseReason } from "./tunnelAttachCloseReason.js";
12
13
  export async function connectCore(args) {
13
14
  const observer = normalizeObserver(args.opts.observer);
@@ -65,10 +66,7 @@ export async function connectCore(args) {
65
66
  if (!Number.isSafeInteger(maxWsQueuedBytes) || maxWsQueuedBytes < 0) {
66
67
  invalidOption("maxWsQueuedBytes must be a non-negative integer");
67
68
  }
68
- const channelId = typeof args.channelId === "string" ? args.channelId.trim() : "";
69
- if (channelId === "") {
70
- throw new FlowersecError({ path: args.path, stage: "validate", code: "missing_channel_id", message: "missing channel_id" });
71
- }
69
+ const channelId = prepareChannelId(args.channelId, args.path);
72
70
  let psk;
73
71
  try {
74
72
  const pskB64u = typeof args.e2eePskB64u === "string" ? args.e2eePskB64u.trim() : "";
@@ -0,0 +1,8 @@
1
+ import type { ChannelInitGrant, Role as ControlRole } from "../gen/flowersec/controlplane/v1.gen.js";
2
+ import type { DirectConnectInfo } from "../gen/flowersec/direct/v1.gen.js";
3
+ import { type FlowersecPath } from "../utils/errors.js";
4
+ export declare const CHANNEL_ID_MAX_BYTES = 256;
5
+ export declare function prepareChannelId(raw: string, path: Exclude<FlowersecPath, "auto">): string;
6
+ export declare function assertTunnelGrantContract(grant: ChannelInitGrant, expectedRole: ControlRole): void;
7
+ export declare function assertDirectConnectContract(info: DirectConnectInfo): void;
8
+ export declare function assertValidPSK(raw: string, path: Exclude<FlowersecPath, "auto">): string;
@@ -0,0 +1,51 @@
1
+ import { base64urlDecode } from "../utils/base64url.js";
2
+ import { FlowersecError } from "../utils/errors.js";
3
+ const textEncoder = new TextEncoder();
4
+ export const CHANNEL_ID_MAX_BYTES = 256;
5
+ export function prepareChannelId(raw, path) {
6
+ const channelId = typeof raw === "string" ? raw.trim() : "";
7
+ if (channelId === "") {
8
+ throw new FlowersecError({ path, stage: "validate", code: "missing_channel_id", message: "missing channel_id" });
9
+ }
10
+ if (textEncoder.encode(channelId).length > CHANNEL_ID_MAX_BYTES) {
11
+ throw new FlowersecError({ path, stage: "validate", code: "invalid_input", message: "channel_id too long" });
12
+ }
13
+ return channelId;
14
+ }
15
+ export function assertTunnelGrantContract(grant, expectedRole) {
16
+ if (grant.role !== expectedRole) {
17
+ throw new FlowersecError({ stage: "validate", code: "role_mismatch", path: "tunnel", message: `expected role=${expectedRole === 1 ? "client" : "server"}` });
18
+ }
19
+ const allowedSuites = Array.isArray(grant.allowed_suites) ? grant.allowed_suites : [];
20
+ if (allowedSuites.length === 0) {
21
+ throw new FlowersecError({ stage: "validate", code: "invalid_suite", path: "tunnel", message: "allowed_suites must be non-empty" });
22
+ }
23
+ for (const suite of allowedSuites) {
24
+ assertSupportedSuite(suite, "tunnel");
25
+ }
26
+ assertSupportedSuite(grant.default_suite, "tunnel");
27
+ if (!allowedSuites.includes(grant.default_suite)) {
28
+ throw new FlowersecError({ stage: "validate", code: "invalid_suite", path: "tunnel", message: "default_suite must be included in allowed_suites" });
29
+ }
30
+ }
31
+ export function assertDirectConnectContract(info) {
32
+ assertSupportedSuite(info.default_suite, "direct");
33
+ }
34
+ export function assertValidPSK(raw, path) {
35
+ const pskB64u = typeof raw === "string" ? raw.trim() : "";
36
+ try {
37
+ const psk = base64urlDecode(pskB64u);
38
+ if (psk.length !== 32) {
39
+ throw new Error("psk must be 32 bytes");
40
+ }
41
+ }
42
+ catch (e) {
43
+ throw new FlowersecError({ stage: "validate", code: "invalid_psk", path, message: "invalid e2ee_psk_b64u", cause: e });
44
+ }
45
+ return pskB64u;
46
+ }
47
+ function assertSupportedSuite(suite, path) {
48
+ if (suite !== 1 && suite !== 2) {
49
+ throw new FlowersecError({ stage: "validate", code: "invalid_suite", path, message: "invalid suite" });
50
+ }
51
+ }
@@ -1,7 +1,7 @@
1
1
  import { assertDirectConnectInfo } from "../gen/flowersec/direct/v1.gen.js";
2
- import { base64urlDecode } from "../utils/base64url.js";
3
2
  import { FlowersecError } from "../utils/errors.js";
4
3
  import { connectCore } from "../client-connect/connectCore.js";
4
+ import { assertDirectConnectContract, assertValidPSK, prepareChannelId } from "../client-connect/contract.js";
5
5
  function isRecord(v) {
6
6
  return typeof v === "object" && v != null && !Array.isArray(v);
7
7
  }
@@ -68,9 +68,7 @@ export async function connectDirect(info, opts) {
68
68
  if (ready.ws_url === "") {
69
69
  throw new FlowersecError({ stage: "validate", code: "missing_ws_url", path: "direct", message: "missing ws_url" });
70
70
  }
71
- if (ready.channel_id === "") {
72
- throw new FlowersecError({ stage: "validate", code: "missing_channel_id", path: "direct", message: "missing channel_id" });
73
- }
71
+ const channelId = prepareChannelId(ready.channel_id, "direct");
74
72
  if (ready.channel_init_expire_at_unix_s <= 0) {
75
73
  throw new FlowersecError({
76
74
  stage: "validate",
@@ -79,19 +77,12 @@ export async function connectDirect(info, opts) {
79
77
  message: "missing channel_init_expire_at_unix_s",
80
78
  });
81
79
  }
82
- try {
83
- const psk = base64urlDecode(ready.e2ee_psk_b64u);
84
- if (psk.length !== 32) {
85
- throw new Error("psk must be 32 bytes");
86
- }
87
- }
88
- catch (e) {
89
- throw new FlowersecError({ stage: "validate", code: "invalid_psk", path: "direct", message: "invalid e2ee_psk_b64u", cause: e });
90
- }
80
+ assertDirectConnectContract(ready);
81
+ assertValidPSK(ready.e2ee_psk_b64u, "direct");
91
82
  return await connectCore({
92
83
  path: "direct",
93
84
  wsUrl: ready.ws_url,
94
- channelId: ready.channel_id,
85
+ channelId,
95
86
  e2eePskB64u: ready.e2ee_psk_b64u,
96
87
  defaultSuite: ready.default_suite,
97
88
  opts,
@@ -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";
@@ -4,6 +4,7 @@ import { base64urlDecode, base64urlEncode } from "../utils/base64url.js";
4
4
  import { FlowersecError } from "../utils/errors.js";
5
5
  import { randomBytes } from "../client-connect/common.js";
6
6
  import { connectCore } from "../client-connect/connectCore.js";
7
+ import { assertTunnelGrantContract, assertValidPSK, prepareChannelId } from "../client-connect/contract.js";
7
8
  function isRecord(v) {
8
9
  return typeof v === "object" && v != null && !Array.isArray(v);
9
10
  }
@@ -84,10 +85,7 @@ export async function connectTunnel(grant, opts) {
84
85
  if (tunnelUrl === "") {
85
86
  throw new FlowersecError({ stage: "validate", code: "missing_tunnel_url", path: "tunnel", message: "missing tunnel_url" });
86
87
  }
87
- const channelId = checkedGrant.channel_id.trim();
88
- if (channelId === "") {
89
- throw new FlowersecError({ stage: "validate", code: "missing_channel_id", path: "tunnel", message: "missing channel_id" });
90
- }
88
+ const channelId = prepareChannelId(checkedGrant.channel_id, "tunnel");
91
89
  const token = checkedGrant.token.trim();
92
90
  if (token === "") {
93
91
  throw new FlowersecError({ stage: "validate", code: "missing_token", path: "tunnel", message: "missing token" });
@@ -100,20 +98,9 @@ export async function connectTunnel(grant, opts) {
100
98
  message: "missing channel_init_expire_at_unix_s",
101
99
  });
102
100
  }
103
- const e2eePskB64u = checkedGrant.e2ee_psk_b64u.trim();
104
- try {
105
- const psk = base64urlDecode(e2eePskB64u);
106
- if (psk.length !== 32) {
107
- throw new Error("psk must be 32 bytes");
108
- }
109
- }
110
- catch (e) {
111
- throw new FlowersecError({ stage: "validate", code: "invalid_psk", path: "tunnel", message: "invalid e2ee_psk_b64u", cause: e });
112
- }
101
+ assertTunnelGrantContract(checkedGrant, ControlRole.Role_client);
102
+ const e2eePskB64u = assertValidPSK(checkedGrant.e2ee_psk_b64u, "tunnel");
113
103
  const idleTimeoutSeconds = checkedGrant.idle_timeout_seconds;
114
- if (checkedGrant.role !== ControlRole.Role_client) {
115
- throw new FlowersecError({ stage: "validate", code: "role_mismatch", path: "tunnel", message: "expected role=client" });
116
- }
117
104
  const endpointInstanceId = opts.endpointInstanceId ?? base64urlEncode(randomBytes(24));
118
105
  let eidBytes;
119
106
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.12.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": {