@floegence/flowersec-core 0.14.0 → 0.15.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.
@@ -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({
@@ -7,6 +7,7 @@ import type { ProxyRuntime } from "./runtime.js";
7
7
  export type ConnectTunnelProxyBrowserOptions = Readonly<{
8
8
  connect?: TunnelConnectBrowserOptions;
9
9
  profile?: ProxyProfileName | Partial<ProxyProfile>;
10
+ runtimeGlobalKey?: string;
10
11
  runtime?: RegisterProxyIntegrationOptions["runtime"];
11
12
  serviceWorker: ProxyIntegrationServiceWorkerOptions;
12
13
  plugins?: readonly ProxyIntegrationPlugin[];
@@ -6,6 +6,7 @@ export async function connectTunnelProxyBrowser(grant, opts) {
6
6
  client,
7
7
  serviceWorker: opts.serviceWorker,
8
8
  ...(opts.profile === undefined ? {} : { profile: opts.profile }),
9
+ ...(opts.runtimeGlobalKey === undefined ? {} : { runtimeGlobalKey: opts.runtimeGlobalKey }),
9
10
  ...(opts.runtime === undefined ? {} : { runtime: opts.runtime }),
10
11
  ...(opts.plugins === undefined ? {} : { plugins: opts.plugins }),
11
12
  };
@@ -27,6 +27,7 @@ export type ProxyIntegrationServiceWorkerOptions = Readonly<{
27
27
  export type RegisterProxyIntegrationOptions = Readonly<{
28
28
  client: Client;
29
29
  profile?: ProxyProfileName | Partial<ProxyProfile>;
30
+ runtimeGlobalKey?: string;
30
31
  runtime?: Readonly<{
31
32
  maxJsonFrameBytes?: number;
32
33
  maxChunkBytes?: number;
@@ -172,6 +172,18 @@ function buildRuntimeOptions(profile, runtime) {
172
172
  timeoutMs: runtime?.timeoutMs ?? profile.timeoutMs,
173
173
  };
174
174
  }
175
+ function bindRuntimeGlobal(runtime, key) {
176
+ const runtimeGlobalKey = String(key ?? "").trim();
177
+ if (runtimeGlobalKey === "")
178
+ return null;
179
+ const target = globalThis;
180
+ target[runtimeGlobalKey] = runtime;
181
+ return () => {
182
+ if (target[runtimeGlobalKey] === runtime) {
183
+ delete target[runtimeGlobalKey];
184
+ }
185
+ };
186
+ }
175
187
  export function createProxyIntegrationServiceWorkerScript(opts = {}) {
176
188
  const plugins = opts.plugins ?? [];
177
189
  let scriptOpts = opts.baseOptions ?? {};
@@ -212,6 +224,7 @@ export async function registerProxyIntegration(input) {
212
224
  const profile = resolveProxyProfile(opts.profile);
213
225
  const runtimeOpts = buildRuntimeOptions(profile, opts.runtime);
214
226
  const runtime = createProxyRuntime({ ...runtimeOpts, client: opts.client });
227
+ const releaseRuntimeGlobal = bindRuntimeGlobal(runtime, opts.runtimeGlobalKey);
215
228
  const swCfg = opts.serviceWorker;
216
229
  const repairQueryKey = String(swCfg.repair?.queryKey ?? "_flowersec_sw_repair").trim() || "_flowersec_sw_repair";
217
230
  const maxRepairAttempts = Math.max(0, Math.floor(swCfg.repair?.maxAttempts ?? 2));
@@ -280,13 +293,34 @@ export async function registerProxyIntegration(input) {
280
293
  return {
281
294
  runtime,
282
295
  dispose: async () => {
296
+ let firstError = null;
283
297
  if (monitorHandler && sw) {
284
298
  sw.removeEventListener("controllerchange", monitorHandler);
285
299
  }
286
- runtime.dispose();
300
+ try {
301
+ runtime.dispose();
302
+ }
303
+ catch (error) {
304
+ firstError = error;
305
+ }
287
306
  for (const plugin of plugins) {
288
- await plugin.onDisposed?.();
307
+ try {
308
+ await plugin.onDisposed?.();
309
+ }
310
+ catch (error) {
311
+ if (firstError == null)
312
+ firstError = error;
313
+ }
314
+ }
315
+ try {
316
+ releaseRuntimeGlobal?.();
317
+ }
318
+ catch (error) {
319
+ if (firstError == null)
320
+ firstError = error;
289
321
  }
322
+ if (firstError != null)
323
+ throw firstError;
290
324
  },
291
325
  };
292
326
  }
@@ -2,4 +2,5 @@ export * from "./caller.js";
2
2
  export * from "./client.js";
3
3
  export * from "./server.js";
4
4
  export * from "./callError.js";
5
+ export * from "./rpcProxy.js";
5
6
  export * from "./typed.js";
package/dist/rpc/index.js CHANGED
@@ -2,4 +2,5 @@ export * from "./caller.js";
2
2
  export * from "./client.js";
3
3
  export * from "./server.js";
4
4
  export * from "./callError.js";
5
+ export * from "./rpcProxy.js";
5
6
  export * from "./typed.js";
@@ -0,0 +1,16 @@
1
+ import type { RpcClient } from "./client.js";
2
+ export declare class RpcProxyDetachedError extends Error {
3
+ constructor();
4
+ }
5
+ export declare class RpcProxy {
6
+ private client;
7
+ private readonly notifyHandlers;
8
+ attach(client: RpcClient): void;
9
+ detach(): void;
10
+ onNotify(typeId: number, handler: (payload: unknown) => void): () => void;
11
+ call(typeId: number, payload: unknown, signal?: AbortSignal): Promise<{
12
+ payload: unknown;
13
+ error?: import("../gen/flowersec/rpc/v1.gen.js").RpcError;
14
+ }>;
15
+ notify(typeId: number, payload: unknown): Promise<void>;
16
+ }
@@ -0,0 +1,58 @@
1
+ export class RpcProxyDetachedError extends Error {
2
+ constructor() {
3
+ super("rpc proxy is not attached");
4
+ this.name = "RpcProxyDetachedError";
5
+ }
6
+ }
7
+ // RpcProxy keeps notify subscriptions stable across client reattachment.
8
+ export class RpcProxy {
9
+ client = null;
10
+ notifyHandlers = new Map();
11
+ attach(client) {
12
+ this.detach();
13
+ this.client = client;
14
+ for (const [typeId, handlers] of this.notifyHandlers) {
15
+ for (const [handler, state] of handlers) {
16
+ state.unsub = client.onNotify(typeId, handler);
17
+ }
18
+ }
19
+ }
20
+ detach() {
21
+ for (const [, handlers] of this.notifyHandlers) {
22
+ for (const [, state] of handlers) {
23
+ state.unsub?.();
24
+ delete state.unsub;
25
+ }
26
+ }
27
+ this.client = null;
28
+ }
29
+ onNotify(typeId, handler) {
30
+ const tid = typeId >>> 0;
31
+ const handlers = this.notifyHandlers.get(tid) ?? new Map();
32
+ if (!this.notifyHandlers.has(tid))
33
+ this.notifyHandlers.set(tid, handlers);
34
+ if (!handlers.has(handler)) {
35
+ const state = {};
36
+ if (this.client != null)
37
+ state.unsub = this.client.onNotify(tid, handler);
38
+ handlers.set(handler, state);
39
+ }
40
+ return () => {
41
+ const state = handlers.get(handler);
42
+ state?.unsub?.();
43
+ handlers.delete(handler);
44
+ if (handlers.size === 0)
45
+ this.notifyHandlers.delete(tid);
46
+ };
47
+ }
48
+ async call(typeId, payload, signal) {
49
+ if (this.client == null)
50
+ throw new RpcProxyDetachedError();
51
+ return await this.client.call(typeId, payload, signal);
52
+ }
53
+ async notify(typeId, payload) {
54
+ if (this.client == null)
55
+ throw new RpcProxyDetachedError();
56
+ await this.client.notify(typeId, payload);
57
+ }
58
+ }
@@ -1,13 +1 @@
1
- import type { RpcClient } from "../rpc/client.js";
2
- export declare class RpcProxy {
3
- private client;
4
- private readonly notifyHandlers;
5
- attach(client: RpcClient): void;
6
- detach(): void;
7
- onNotify(typeId: number, handler: (payload: unknown) => void): () => void;
8
- call(typeId: number, payload: unknown, signal?: AbortSignal): Promise<{
9
- payload: unknown;
10
- error?: import("../gen/flowersec/rpc/v1.gen.js").RpcError;
11
- }>;
12
- notify(typeId: number, payload: unknown): Promise<void>;
13
- }
1
+ export { RpcProxy, RpcProxyDetachedError } from "../rpc/rpcProxy.js";
@@ -1,59 +1 @@
1
- // RpcProxy allows handlers to survive client reattachment.
2
- export class RpcProxy {
3
- // Active client connection (null when detached).
4
- client = null;
5
- // Persisted notification handlers across reattachments.
6
- notifyHandlers = new Map();
7
- // attach wires existing notification handlers to a new client.
8
- attach(client) {
9
- this.detach();
10
- this.client = client;
11
- for (const [typeId, handlers] of this.notifyHandlers) {
12
- for (const [h, state] of handlers) {
13
- state.unsub = client.onNotify(typeId, h);
14
- }
15
- }
16
- }
17
- // detach unwires handlers from the current client.
18
- detach() {
19
- for (const [, handlers] of this.notifyHandlers) {
20
- for (const [, state] of handlers) {
21
- state.unsub?.();
22
- delete state.unsub;
23
- }
24
- }
25
- this.client = null;
26
- }
27
- // onNotify registers handlers that will be rebound on reattach.
28
- onNotify(typeId, handler) {
29
- const tid = typeId >>> 0;
30
- const handlers = this.notifyHandlers.get(tid) ?? new Map();
31
- if (!this.notifyHandlers.has(tid))
32
- this.notifyHandlers.set(tid, handlers);
33
- if (!handlers.has(handler)) {
34
- const state = {};
35
- if (this.client != null)
36
- state.unsub = this.client.onNotify(tid, handler);
37
- handlers.set(handler, state);
38
- }
39
- return () => {
40
- const state = handlers.get(handler);
41
- state?.unsub?.();
42
- handlers.delete(handler);
43
- if (handlers.size === 0)
44
- this.notifyHandlers.delete(tid);
45
- };
46
- }
47
- // call forwards the RPC call to the attached client.
48
- async call(typeId, payload, signal) {
49
- if (this.client == null)
50
- throw new Error("rpc proxy is not attached");
51
- return await this.client.call(typeId, payload, signal);
52
- }
53
- // notify forwards a one-way notification to the attached client.
54
- async notify(typeId, payload) {
55
- if (this.client == null)
56
- throw new Error("rpc proxy is not attached");
57
- await this.client.notify(typeId, payload);
58
- }
59
- }
1
+ export { RpcProxy, RpcProxyDetachedError } from "../rpc/rpcProxy.js";
@@ -1,3 +1,4 @@
1
+ import type { Client } from "../client.js";
1
2
  import { ByteReader } from "../yamux/byteReader.js";
2
3
  import type { YamuxStream } from "../yamux/stream.js";
3
4
  export declare function readMaybe(stream: YamuxStream): Promise<Uint8Array | null>;
@@ -13,3 +14,19 @@ export type ReadNBytesOptions = Readonly<{
13
14
  onProgress?: (read: number) => void;
14
15
  }>;
15
16
  export declare function readNBytes(reader: ByteReader, n: number, opts?: ReadNBytesOptions): Promise<Uint8Array>;
17
+ export type JsonFrameChannel = Readonly<{
18
+ stream: YamuxStream;
19
+ reader: ByteReader;
20
+ writeFrame: (value: unknown) => Promise<void>;
21
+ readFrame: <T = unknown>(opts?: Readonly<{
22
+ maxBytes?: number;
23
+ assert?: (value: unknown) => T;
24
+ }>) => Promise<T>;
25
+ close: () => Promise<void>;
26
+ }>;
27
+ export type JsonFrameChannelOptions = Readonly<{
28
+ signal?: AbortSignal;
29
+ maxJsonFrameBytes?: number;
30
+ }>;
31
+ export declare function createJsonFrameChannel(stream: YamuxStream, opts?: JsonFrameChannelOptions): JsonFrameChannel;
32
+ export declare function openJsonFrameChannel(client: Pick<Client, "openStream">, kind: string, opts?: JsonFrameChannelOptions): Promise<JsonFrameChannel>;
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_MAX_JSON_FRAME_BYTES, readJsonFrame, writeJsonFrame } from "../framing/jsonframe.js";
1
2
  import { AbortError, throwIfAborted } from "../utils/errors.js";
2
3
  import { ByteReader } from "../yamux/byteReader.js";
3
4
  function abortReasonToError(signal) {
@@ -56,3 +57,34 @@ export async function readNBytes(reader, n, opts = {}) {
56
57
  }
57
58
  return out;
58
59
  }
60
+ function normalizeMaxJsonFrameBytes(maxBytes) {
61
+ const value = maxBytes ?? DEFAULT_MAX_JSON_FRAME_BYTES;
62
+ if (!Number.isFinite(value) || value < 0) {
63
+ throw new Error("maxJsonFrameBytes must be a non-negative finite number");
64
+ }
65
+ return Math.floor(value);
66
+ }
67
+ export function createJsonFrameChannel(stream, opts = {}) {
68
+ const reader = createByteReader(stream, opts.signal == null ? {} : { signal: opts.signal });
69
+ const defaultMaxBytes = normalizeMaxJsonFrameBytes(opts.maxJsonFrameBytes);
70
+ return {
71
+ stream,
72
+ reader,
73
+ writeFrame: async (value) => {
74
+ await writeJsonFrame(stream, value);
75
+ },
76
+ readFrame: async (readOpts = {}) => {
77
+ const raw = await readJsonFrame(reader, normalizeMaxJsonFrameBytes(readOpts.maxBytes ?? defaultMaxBytes));
78
+ if (readOpts.assert != null)
79
+ return readOpts.assert(raw);
80
+ return raw;
81
+ },
82
+ close: async () => {
83
+ await stream.close();
84
+ },
85
+ };
86
+ }
87
+ export async function openJsonFrameChannel(client, kind, opts = {}) {
88
+ const stream = await client.openStream(kind, opts.signal == null ? {} : { signal: opts.signal });
89
+ return createJsonFrameChannel(stream, opts);
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
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",