@floegence/flowersec-core 0.23.0 → 0.24.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/README.md +17 -65
- package/dist/client-connect/connectCore.d.ts +3 -3
- package/dist/client-connect/connectCore.js +7 -5
- package/dist/controlplane/issuer.d.ts +5 -2
- package/dist/controlplane/issuer.js +76 -16
- package/dist/defaults.d.ts +2 -0
- package/dist/defaults.js +2 -0
- package/dist/e2ee/handshake.d.ts +2 -2
- package/dist/e2ee/secureChannel.d.ts +2 -2
- package/dist/e2ee/secureChannel.js +22 -10
- package/dist/endpoint/index.js +18 -5
- package/dist/reconnect/index.js +62 -25
- package/dist/yamux/byteReader.d.ts +2 -0
- package/dist/yamux/byteReader.js +28 -24
- package/dist/yamux/session.js +1 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# @floegence/flowersec-core
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Status: experimental; not audited.
|
|
3
|
+
The TypeScript SDK for Flowersec end-to-end encrypted direct and tunneled sessions. It implements the portable Node.js client and endpoint contract plus the browser and Service Worker runtime owned by TypeScript.
|
|
6
4
|
|
|
7
5
|
## Install
|
|
8
6
|
|
|
@@ -10,73 +8,27 @@ Status: experimental; not audited.
|
|
|
10
8
|
npm install @floegence/flowersec-core
|
|
11
9
|
```
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Browser:
|
|
16
|
-
|
|
17
|
-
```ts
|
|
18
|
-
import { connectBrowser } from "@floegence/flowersec-core/browser";
|
|
19
|
-
import { requestConnectArtifact } from "@floegence/flowersec-core/controlplane";
|
|
20
|
-
|
|
21
|
-
const artifact = await requestConnectArtifact({
|
|
22
|
-
endpointId: "env_demo",
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
const client = await connectBrowser(artifact);
|
|
26
|
-
await client.ping();
|
|
27
|
-
client.close();
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
Node.js:
|
|
31
|
-
|
|
32
|
-
```ts
|
|
33
|
-
import { connectNode, createNodeReconnectConfig } from "@floegence/flowersec-core/node";
|
|
34
|
-
import { requestConnectArtifact } from "@floegence/flowersec-core/controlplane";
|
|
35
|
-
import { createControlplaneArtifactSource } from "@floegence/flowersec-core/reconnect";
|
|
36
|
-
|
|
37
|
-
const artifact = await requestConnectArtifact({
|
|
38
|
-
baseUrl: "https://your-app.example/api/flowersec",
|
|
39
|
-
endpointId: "env_demo",
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
const client = await connectNode(artifact, {
|
|
43
|
-
origin: "https://your-app.example",
|
|
44
|
-
});
|
|
45
|
-
await client.ping();
|
|
46
|
-
const rttMs = await client.probeLiveness();
|
|
47
|
-
client.close();
|
|
48
|
-
|
|
49
|
-
const reconnectConfig = createNodeReconnectConfig({
|
|
50
|
-
source: createControlplaneArtifactSource({
|
|
51
|
-
baseUrl: "https://your-app.example/api/flowersec",
|
|
52
|
-
endpointId: "env_demo",
|
|
53
|
-
}),
|
|
54
|
-
connect: {
|
|
55
|
-
origin: "https://your-app.example",
|
|
56
|
-
},
|
|
57
|
-
});
|
|
58
|
-
```
|
|
11
|
+
The package is ESM-only and exposes environment-specific entrypoints for browser and Node.js applications.
|
|
59
12
|
|
|
60
|
-
|
|
13
|
+
## Cookbook
|
|
61
14
|
|
|
62
|
-
|
|
63
|
-
targets without DNS resolution. Deliberate non-loopback `ws://` connections must use
|
|
64
|
-
`createNetworkPlaintextPolicy(...)` with exact canonical IP literals and
|
|
65
|
-
`PlaintextRiskAcceptance.acceptPreE2ECredentialExposure`. The unrestricted `AllowPlaintext` preset is deprecated.
|
|
15
|
+
Start with the [TypeScript cookbook](https://github.com/floegence/flowersec/tree/main/examples/ts). It contains runnable browser direct/tunnel pages, Node.js clients, the shared demo server, the Service Worker proxy runtime, and manual protocol-stack references.
|
|
66
16
|
|
|
67
|
-
##
|
|
17
|
+
## Entrypoints
|
|
68
18
|
|
|
69
|
-
|
|
19
|
+
- Browser client: `@floegence/flowersec-core/browser`
|
|
20
|
+
- Node.js client and endpoint transport: `@floegence/flowersec-core/node`
|
|
21
|
+
- Controlplane artifact helpers: `@floegence/flowersec-core/controlplane`
|
|
22
|
+
- Endpoint session serving: `@floegence/flowersec-core/endpoint`
|
|
23
|
+
- RPC: `@floegence/flowersec-core/rpc`
|
|
24
|
+
- Reconnect: `@floegence/flowersec-core/reconnect`
|
|
25
|
+
- HTTP/WebSocket proxy and browser runtime: `@floegence/flowersec-core/proxy`
|
|
26
|
+
- Observability: `@floegence/flowersec-core/observability`
|
|
70
27
|
|
|
71
|
-
|
|
28
|
+
High-level WebSocket connections require TLS by default. Use `AllowPlaintextForLoopback` only for literal local development targets.
|
|
72
29
|
|
|
73
|
-
|
|
30
|
+
## Runtime Boundaries
|
|
74
31
|
|
|
75
|
-
|
|
32
|
+
TypeScript owns browser and Service Worker integration. Shared tunnel, proxy gateway, and helper binaries remain Go-owned.
|
|
76
33
|
|
|
77
|
-
|
|
78
|
-
- Integration guide: `docs/INTEGRATION_GUIDE.md`
|
|
79
|
-
- API surface contract: `docs/API_SURFACE.md`
|
|
80
|
-
- Controlplane artifact fetch: `docs/CONTROLPLANE_ARTIFACT_FETCH.md`
|
|
81
|
-
- Error model: `docs/ERROR_MODEL.md`
|
|
82
|
-
- Migration guide: `docs/V0_20_MIGRATION.md`
|
|
34
|
+
Review the shared [API contract](../docs/API_CONTRACT.md), [protocol](../docs/PROTOCOL.md), [threat model](../docs/THREAT_MODEL.md), and [error model](../docs/ERROR_MODEL.md).
|
|
@@ -23,7 +23,7 @@ export type ConnectOptionsBase = Readonly<{
|
|
|
23
23
|
maxHandshakePayload?: number;
|
|
24
24
|
/** Maximum encrypted record size on the wire (0 uses default). */
|
|
25
25
|
maxRecordBytes?: number;
|
|
26
|
-
/** Maximum
|
|
26
|
+
/** Maximum queued inbound plaintext bytes in the secure channel (0 uses default). */
|
|
27
27
|
maxBufferedBytes?: number;
|
|
28
28
|
/** Maximum queued outbound plaintext bytes in the secure channel (default 4 MiB; 0 uses default). */
|
|
29
29
|
maxOutboundBufferedBytes?: number;
|
|
@@ -39,9 +39,9 @@ export type ConnectOptionsBase = Readonly<{
|
|
|
39
39
|
observer?: ClientObserverLike;
|
|
40
40
|
/** Policy evaluated before any WebSocket network activity. */
|
|
41
41
|
transportSecurityPolicy?: TransportSecurityPolicy;
|
|
42
|
-
/**
|
|
42
|
+
/** Scope validators keyed by scope name. */
|
|
43
43
|
scopeResolvers?: ConnectScopeResolverMap;
|
|
44
|
-
/**
|
|
44
|
+
/** Explicit compatibility switch for optional scope failures. */
|
|
45
45
|
relaxedOptionalScopeValidation?: boolean;
|
|
46
46
|
/** Acknowledged Yamux liveness checks, or false to disable automatic checks. */
|
|
47
47
|
liveness?: false | LivenessOptions;
|
|
@@ -303,10 +303,11 @@ export async function connectCore(args) {
|
|
|
303
303
|
return await mux.probeLiveness(liveness.timeoutMs);
|
|
304
304
|
}
|
|
305
305
|
catch (e) {
|
|
306
|
-
|
|
306
|
+
const timedOut = isYamuxPingTimeoutError(e);
|
|
307
|
+
if (timedOut) {
|
|
307
308
|
emitObserverDiagnostic(args.opts.observer, { path: args.path, stage: "yamux", code_domain: "event", code: "liveness_timeout", result: "fail" });
|
|
308
309
|
}
|
|
309
|
-
const error = new FlowersecError({ path: args.path, stage: "yamux", code: "ping_failed", message: "liveness probe failed", cause: e });
|
|
310
|
+
const error = new FlowersecError({ path: args.path, stage: "yamux", code: timedOut ? "timeout" : "ping_failed", message: "liveness probe failed", cause: e });
|
|
310
311
|
reportTermination(error);
|
|
311
312
|
throw error;
|
|
312
313
|
}
|
|
@@ -355,8 +356,9 @@ export async function connectCore(args) {
|
|
|
355
356
|
},
|
|
356
357
|
probeLiveness,
|
|
357
358
|
openStream: async (kind, opts = {}) => {
|
|
358
|
-
|
|
359
|
-
|
|
359
|
+
const streamKind = kind?.trim() ?? "";
|
|
360
|
+
if (streamKind === "")
|
|
361
|
+
throw new FlowersecError({ path: args.path, stage: "rpc", code: "missing_stream_kind", message: "missing stream kind" });
|
|
360
362
|
if (opts.signal?.aborted) {
|
|
361
363
|
throw new FlowersecError({
|
|
362
364
|
path: args.path,
|
|
@@ -415,7 +417,7 @@ export async function connectCore(args) {
|
|
|
415
417
|
});
|
|
416
418
|
}
|
|
417
419
|
try {
|
|
418
|
-
await writeStreamHello((b) => s.write(b),
|
|
420
|
+
await writeStreamHello((b) => s.write(b), streamKind);
|
|
419
421
|
}
|
|
420
422
|
catch (err) {
|
|
421
423
|
if (signal?.aborted) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type TokenPayload } from "./token.js";
|
|
2
2
|
export declare class IssuerKeyset {
|
|
3
|
-
private
|
|
4
|
-
private
|
|
3
|
+
private active;
|
|
4
|
+
private readonly verificationKeys;
|
|
5
5
|
constructor(kid: string, signingSeed: Uint8Array);
|
|
6
6
|
static random(kid: string): IssuerKeyset;
|
|
7
7
|
currentKID(): string;
|
|
@@ -9,7 +9,10 @@ export declare class IssuerKeyset {
|
|
|
9
9
|
sign(payload: Omit<TokenPayload, "kid"> & Readonly<{
|
|
10
10
|
kid?: string;
|
|
11
11
|
}>): string;
|
|
12
|
+
addVerificationKey(kid: string, publicKey: Uint8Array): void;
|
|
12
13
|
rotate(kid: string, signingSeed: Uint8Array): void;
|
|
14
|
+
retireVerificationKey(kid: string): void;
|
|
13
15
|
exportTunnelKeyset(): Uint8Array;
|
|
14
16
|
dispose(): void;
|
|
17
|
+
private requireActive;
|
|
15
18
|
}
|
|
@@ -2,13 +2,13 @@ import { ed25519 } from "@noble/curves/ed25519";
|
|
|
2
2
|
import { base64urlEncode } from "../utils/base64url.js";
|
|
3
3
|
import { signToken } from "./token.js";
|
|
4
4
|
export class IssuerKeyset {
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
active;
|
|
6
|
+
verificationKeys = new Map();
|
|
7
7
|
constructor(kid, signingSeed) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
this.
|
|
8
|
+
const normalizedKID = normalizeKid(kid);
|
|
9
|
+
const retainedSeed = copySigningSeed(signingSeed);
|
|
10
|
+
this.active = { kid: normalizedKID, signingSeed: retainedSeed };
|
|
11
|
+
this.verificationKeys.set(normalizedKID, ed25519.getPublicKey(retainedSeed));
|
|
12
12
|
}
|
|
13
13
|
static random(kid) {
|
|
14
14
|
const seed = crypto.getRandomValues(new Uint8Array(32));
|
|
@@ -20,30 +20,90 @@ export class IssuerKeyset {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
currentKID() {
|
|
23
|
-
return this.kid;
|
|
23
|
+
return this.requireActive().kid;
|
|
24
24
|
}
|
|
25
25
|
publicKeys() {
|
|
26
|
-
|
|
26
|
+
this.requireActive();
|
|
27
|
+
return new Map([...this.verificationKeys.entries()]
|
|
28
|
+
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
|
29
|
+
.map(([kid, publicKey]) => [kid, publicKey.slice()]));
|
|
27
30
|
}
|
|
28
31
|
sign(payload) {
|
|
29
|
-
|
|
32
|
+
const active = this.requireActive();
|
|
33
|
+
return signToken(active.signingSeed, { ...payload, kid: active.kid });
|
|
34
|
+
}
|
|
35
|
+
addVerificationKey(kid, publicKey) {
|
|
36
|
+
this.requireActive();
|
|
37
|
+
const normalizedKID = normalizeKid(kid);
|
|
38
|
+
const retainedKey = copyPublicKey(publicKey);
|
|
39
|
+
const existing = this.verificationKeys.get(normalizedKID);
|
|
40
|
+
if (existing != null) {
|
|
41
|
+
if (!equalBytes(existing, retainedKey))
|
|
42
|
+
throw new Error(`key ID ${normalizedKID} is already bound to a different public key`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
this.verificationKeys.set(normalizedKID, retainedKey);
|
|
30
46
|
}
|
|
31
47
|
rotate(kid, signingSeed) {
|
|
48
|
+
const active = this.requireActive();
|
|
32
49
|
const nextKID = normalizeKid(kid);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
const nextSeed = copySigningSeed(signingSeed);
|
|
51
|
+
const nextPublicKey = ed25519.getPublicKey(nextSeed);
|
|
52
|
+
const prepublishedKey = this.verificationKeys.get(nextKID);
|
|
53
|
+
if (prepublishedKey == null) {
|
|
54
|
+
nextSeed.fill(0);
|
|
55
|
+
throw new Error(`key ID ${nextKID} must be prepublished before rotation`);
|
|
56
|
+
}
|
|
57
|
+
if (!equalBytes(prepublishedKey, nextPublicKey)) {
|
|
58
|
+
nextSeed.fill(0);
|
|
59
|
+
throw new Error(`key ID ${nextKID} is bound to a different public key`);
|
|
60
|
+
}
|
|
61
|
+
this.active = { kid: nextKID, signingSeed: nextSeed };
|
|
62
|
+
active.signingSeed.fill(0);
|
|
63
|
+
}
|
|
64
|
+
retireVerificationKey(kid) {
|
|
65
|
+
const active = this.requireActive();
|
|
66
|
+
const normalizedKID = normalizeKid(kid);
|
|
67
|
+
if (normalizedKID === active.kid)
|
|
68
|
+
throw new Error("the active signing key cannot be retired");
|
|
69
|
+
if (!this.verificationKeys.delete(normalizedKID))
|
|
70
|
+
throw new Error(`key ID ${normalizedKID} is not published`);
|
|
39
71
|
}
|
|
40
72
|
exportTunnelKeyset() {
|
|
41
73
|
const keys = [...this.publicKeys()].map(([kid, publicKey]) => ({ kid, pubkey_b64u: base64urlEncode(publicKey) }));
|
|
42
74
|
return new TextEncoder().encode(JSON.stringify({ keys }, null, 2));
|
|
43
75
|
}
|
|
44
76
|
dispose() {
|
|
45
|
-
this.
|
|
77
|
+
const active = this.active;
|
|
78
|
+
if (active == null)
|
|
79
|
+
return;
|
|
80
|
+
active.signingSeed.fill(0);
|
|
81
|
+
this.active = undefined;
|
|
82
|
+
this.verificationKeys.clear();
|
|
46
83
|
}
|
|
84
|
+
requireActive() {
|
|
85
|
+
if (this.active == null)
|
|
86
|
+
throw new Error("issuer keyset is disposed");
|
|
87
|
+
return this.active;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function copySigningSeed(signingSeed) {
|
|
91
|
+
if (signingSeed.length !== 32)
|
|
92
|
+
throw new TypeError("Ed25519 signing seed must be 32 bytes");
|
|
93
|
+
return signingSeed.slice();
|
|
94
|
+
}
|
|
95
|
+
function copyPublicKey(publicKey) {
|
|
96
|
+
if (publicKey.length !== 32)
|
|
97
|
+
throw new TypeError("Ed25519 public key must be 32 bytes");
|
|
98
|
+
return publicKey.slice();
|
|
99
|
+
}
|
|
100
|
+
function equalBytes(left, right) {
|
|
101
|
+
if (left.length !== right.length)
|
|
102
|
+
return false;
|
|
103
|
+
let diff = 0;
|
|
104
|
+
for (let index = 0; index < left.length; index++)
|
|
105
|
+
diff |= left[index] ^ right[index];
|
|
106
|
+
return diff === 0;
|
|
47
107
|
}
|
|
48
108
|
function normalizeKid(kid) {
|
|
49
109
|
const value = kid.trim();
|
package/dist/defaults.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export declare const SDK_DEFAULTS: Readonly<{
|
|
|
8
8
|
maxHandshakePayloadBytes: number;
|
|
9
9
|
maxRecordBytes: number;
|
|
10
10
|
outboundRecordChunkBytes: number;
|
|
11
|
+
maxInboundBufferedBytes: number;
|
|
11
12
|
maxOutboundBufferedBytes: number;
|
|
12
13
|
}>;
|
|
13
14
|
yamux: Readonly<{
|
|
@@ -15,6 +16,7 @@ export declare const SDK_DEFAULTS: Readonly<{
|
|
|
15
16
|
maxInboundStreams: 32;
|
|
16
17
|
maxFrameBytes: number;
|
|
17
18
|
preferredOutboundFrameBytes: number;
|
|
19
|
+
maxStreamWriteQueueBytes: number;
|
|
18
20
|
maxStreamReceiveBytes: number;
|
|
19
21
|
maxSessionReceiveBytes: number;
|
|
20
22
|
}>;
|
package/dist/defaults.js
CHANGED
|
@@ -8,6 +8,7 @@ export const SDK_DEFAULTS = Object.freeze({
|
|
|
8
8
|
maxHandshakePayloadBytes: 8 * 1024,
|
|
9
9
|
maxRecordBytes: 1024 * 1024,
|
|
10
10
|
outboundRecordChunkBytes: 64 * 1024,
|
|
11
|
+
maxInboundBufferedBytes: 4 * 1024 * 1024,
|
|
11
12
|
maxOutboundBufferedBytes: 4 * 1024 * 1024,
|
|
12
13
|
}),
|
|
13
14
|
yamux: Object.freeze({
|
|
@@ -15,6 +16,7 @@ export const SDK_DEFAULTS = Object.freeze({
|
|
|
15
16
|
maxInboundStreams: 32,
|
|
16
17
|
maxFrameBytes: 256 * 1024,
|
|
17
18
|
preferredOutboundFrameBytes: 64 * 1024,
|
|
19
|
+
maxStreamWriteQueueBytes: 4 * 1024 * 1024,
|
|
18
20
|
maxStreamReceiveBytes: 256 * 1024,
|
|
19
21
|
maxSessionReceiveBytes: 16 * 1024 * 1024,
|
|
20
22
|
}),
|
package/dist/e2ee/handshake.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type HandshakeClientOptions = Readonly<{
|
|
|
16
16
|
maxRecordBytes: number;
|
|
17
17
|
/** Preferred plaintext bytes per outbound record. */
|
|
18
18
|
outboundRecordChunkBytes?: number;
|
|
19
|
-
/** Maximum
|
|
19
|
+
/** Maximum queued inbound plaintext bytes for the secure channel. */
|
|
20
20
|
maxBufferedBytes?: number;
|
|
21
21
|
/** Maximum queued outbound plaintext bytes for the secure channel. */
|
|
22
22
|
maxOutboundBufferedBytes?: number;
|
|
@@ -44,7 +44,7 @@ export type HandshakeServerOptions = Readonly<{
|
|
|
44
44
|
maxRecordBytes: number;
|
|
45
45
|
/** Preferred plaintext bytes per outbound record. */
|
|
46
46
|
outboundRecordChunkBytes?: number;
|
|
47
|
-
/** Maximum
|
|
47
|
+
/** Maximum queued inbound plaintext bytes for the secure channel. */
|
|
48
48
|
maxBufferedBytes?: number;
|
|
49
49
|
/** Maximum queued outbound plaintext bytes for the secure channel. */
|
|
50
50
|
maxOutboundBufferedBytes?: number;
|
|
@@ -31,7 +31,7 @@ export declare class SecureChannel {
|
|
|
31
31
|
private readonly transport;
|
|
32
32
|
private readonly maxRecordBytes;
|
|
33
33
|
private readonly outboundRecordChunkBytes;
|
|
34
|
-
private readonly
|
|
34
|
+
private readonly maxInboundBufferedBytes;
|
|
35
35
|
private readonly maxOutboundBufferedBytes;
|
|
36
36
|
private sendKey;
|
|
37
37
|
private recvKey;
|
|
@@ -50,7 +50,7 @@ export declare class SecureChannel {
|
|
|
50
50
|
private sendQueueBytes;
|
|
51
51
|
private sendClosed;
|
|
52
52
|
private sendErr;
|
|
53
|
-
private
|
|
53
|
+
private recvQueue;
|
|
54
54
|
private recvQueueHead;
|
|
55
55
|
private recvQueueBytes;
|
|
56
56
|
private recvWaiters;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { RECORD_FLAG_APP, RECORD_FLAG_PING, RECORD_FLAG_REKEY } from "./constants.js";
|
|
2
2
|
import { decryptRecord, encryptRecord, maxPlaintextBytes } from "./record.js";
|
|
3
3
|
import { deriveRekeyKey } from "./kdf.js";
|
|
4
|
+
import { SDK_DEFAULTS } from "../defaults.js";
|
|
4
5
|
const maxRecordSeq = (1n << 64n) - 1n;
|
|
5
6
|
class RecordSeqExhaustedError extends Error {
|
|
6
7
|
constructor() {
|
|
@@ -15,8 +16,7 @@ export class SecureChannel {
|
|
|
15
16
|
// Maximum allowed bytes per record frame.
|
|
16
17
|
maxRecordBytes;
|
|
17
18
|
outboundRecordChunkBytes;
|
|
18
|
-
|
|
19
|
-
maxBufferedBytes;
|
|
19
|
+
maxInboundBufferedBytes;
|
|
20
20
|
maxOutboundBufferedBytes;
|
|
21
21
|
// Active encryption keys and nonce prefixes for the current epoch.
|
|
22
22
|
sendKey;
|
|
@@ -52,16 +52,16 @@ export class SecureChannel {
|
|
|
52
52
|
this.transport = args.transport;
|
|
53
53
|
this.maxRecordBytes = args.maxRecordBytes;
|
|
54
54
|
const maxPlain = Math.max(1, maxPlaintextBytes(this.maxRecordBytes));
|
|
55
|
-
this.outboundRecordChunkBytes = args.outboundRecordChunkBytes ?? Math.min(
|
|
55
|
+
this.outboundRecordChunkBytes = args.outboundRecordChunkBytes ?? Math.min(SDK_DEFAULTS.e2ee.outboundRecordChunkBytes, maxPlain);
|
|
56
56
|
if (!Number.isSafeInteger(this.outboundRecordChunkBytes) || this.outboundRecordChunkBytes <= 0 || this.outboundRecordChunkBytes > maxPlain) {
|
|
57
57
|
throw new RangeError("outboundRecordChunkBytes must be a positive integer within the record plaintext limit");
|
|
58
58
|
}
|
|
59
|
-
this.
|
|
60
|
-
const maxOutboundBufferedBytes = args.maxOutboundBufferedBytes ??
|
|
59
|
+
this.maxInboundBufferedBytes = Math.max(0, args.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.maxInboundBufferedBytes);
|
|
60
|
+
const maxOutboundBufferedBytes = args.maxOutboundBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes;
|
|
61
61
|
if (!Number.isSafeInteger(maxOutboundBufferedBytes) || maxOutboundBufferedBytes < 0) {
|
|
62
62
|
throw new RangeError("maxOutboundBufferedBytes must be a non-negative safe integer");
|
|
63
63
|
}
|
|
64
|
-
this.maxOutboundBufferedBytes = maxOutboundBufferedBytes === 0 ?
|
|
64
|
+
this.maxOutboundBufferedBytes = maxOutboundBufferedBytes === 0 ? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes : maxOutboundBufferedBytes;
|
|
65
65
|
this.sendKey = args.sendKey;
|
|
66
66
|
this.recvKey = args.recvKey;
|
|
67
67
|
this.sendNoncePrefix = args.sendNoncePrefix;
|
|
@@ -87,7 +87,11 @@ export class SecureChannel {
|
|
|
87
87
|
if (this.recvQueueHead < this.recvQueue.length) {
|
|
88
88
|
const b = this.recvQueue[this.recvQueueHead];
|
|
89
89
|
this.recvQueueHead++;
|
|
90
|
-
if (this.recvQueueHead
|
|
90
|
+
if (this.recvQueueHead === this.recvQueue.length) {
|
|
91
|
+
this.recvQueue = [];
|
|
92
|
+
this.recvQueueHead = 0;
|
|
93
|
+
}
|
|
94
|
+
else if (this.recvQueueHead > 1024 && this.recvQueueHead * 2 > this.recvQueue.length) {
|
|
91
95
|
this.recvQueue.splice(0, this.recvQueueHead);
|
|
92
96
|
this.recvQueueHead = 0;
|
|
93
97
|
}
|
|
@@ -171,7 +175,11 @@ export class SecureChannel {
|
|
|
171
175
|
return null;
|
|
172
176
|
const req = this.sendQueue[this.sendQueueHead];
|
|
173
177
|
this.sendQueueHead++;
|
|
174
|
-
if (this.sendQueueHead
|
|
178
|
+
if (this.sendQueueHead === this.sendQueue.length) {
|
|
179
|
+
this.sendQueue = [];
|
|
180
|
+
this.sendQueueHead = 0;
|
|
181
|
+
}
|
|
182
|
+
else if (this.sendQueueHead > 1024 && this.sendQueueHead * 2 > this.sendQueue.length) {
|
|
175
183
|
this.sendQueue.splice(0, this.sendQueueHead);
|
|
176
184
|
this.sendQueueHead = 0;
|
|
177
185
|
}
|
|
@@ -182,7 +190,11 @@ export class SecureChannel {
|
|
|
182
190
|
return undefined;
|
|
183
191
|
const w = this.sendWaiters[this.sendWaitersHead];
|
|
184
192
|
this.sendWaitersHead++;
|
|
185
|
-
if (this.sendWaitersHead
|
|
193
|
+
if (this.sendWaitersHead === this.sendWaiters.length) {
|
|
194
|
+
this.sendWaiters = [];
|
|
195
|
+
this.sendWaitersHead = 0;
|
|
196
|
+
}
|
|
197
|
+
else if (this.sendWaitersHead > 1024 && this.sendWaitersHead * 2 > this.sendWaiters.length) {
|
|
186
198
|
this.sendWaiters.splice(0, this.sendWaitersHead);
|
|
187
199
|
this.sendWaitersHead = 0;
|
|
188
200
|
}
|
|
@@ -284,7 +296,7 @@ export class SecureChannel {
|
|
|
284
296
|
}
|
|
285
297
|
this.recvSeq = seq + 1n;
|
|
286
298
|
if (flags === RECORD_FLAG_APP) {
|
|
287
|
-
if (this.
|
|
299
|
+
if (this.maxInboundBufferedBytes > 0 && this.recvQueueBytes + plaintext.length > this.maxInboundBufferedBytes) {
|
|
288
300
|
throw new Error("recv buffer exceeded");
|
|
289
301
|
}
|
|
290
302
|
this.recvQueue.push(plaintext);
|
package/dist/endpoint/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { decodeHandshakeFrame } from "../e2ee/framing.js";
|
|
|
8
8
|
import { HANDSHAKE_TYPE_INIT, PROTOCOL_VERSION } from "../e2ee/constants.js";
|
|
9
9
|
import { readStreamHello, writeStreamHello } from "../streamhello/streamHello.js";
|
|
10
10
|
import { ByteReader } from "../yamux/byteReader.js";
|
|
11
|
+
import { isYamuxPingTimeoutError } from "../yamux/errors.js";
|
|
11
12
|
import { YamuxSession } from "../yamux/session.js";
|
|
12
13
|
import { RpcServer } from "../rpc/server.js";
|
|
13
14
|
import { base64urlDecode, base64urlEncode } from "../utils/base64url.js";
|
|
@@ -43,9 +44,10 @@ export class Session {
|
|
|
43
44
|
return session;
|
|
44
45
|
}
|
|
45
46
|
async openStream(kind, options = {}) {
|
|
47
|
+
const streamKind = normalizeStreamKind(kind, this.path);
|
|
46
48
|
const stream = await this.mux.openStream(options);
|
|
47
49
|
try {
|
|
48
|
-
await writeStreamHello((bytes) => stream.write(bytes),
|
|
50
|
+
await writeStreamHello((bytes) => stream.write(bytes), streamKind);
|
|
49
51
|
return stream;
|
|
50
52
|
}
|
|
51
53
|
catch (error) {
|
|
@@ -82,8 +84,19 @@ export class Session {
|
|
|
82
84
|
return;
|
|
83
85
|
}
|
|
84
86
|
}
|
|
85
|
-
probeLiveness(timeoutMs = SDK_DEFAULTS.transport.handshakeTimeoutMs) {
|
|
86
|
-
|
|
87
|
+
async probeLiveness(timeoutMs = SDK_DEFAULTS.transport.handshakeTimeoutMs) {
|
|
88
|
+
try {
|
|
89
|
+
return await this.mux.probeLiveness(timeoutMs);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
throw new FlowersecError({
|
|
93
|
+
path: this.path,
|
|
94
|
+
stage: "yamux",
|
|
95
|
+
code: isYamuxPingTimeoutError(error) ? "timeout" : "ping_failed",
|
|
96
|
+
message: "endpoint liveness probe failed",
|
|
97
|
+
cause: error,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
87
100
|
}
|
|
88
101
|
async rekey() {
|
|
89
102
|
try {
|
|
@@ -247,7 +260,7 @@ async function establishSession(path, transport, handshake, options, endpointIns
|
|
|
247
260
|
maxHandshakePayload: options.maxHandshakePayload ?? SDK_DEFAULTS.e2ee.maxHandshakePayloadBytes,
|
|
248
261
|
maxRecordBytes: options.maxRecordBytes ?? SDK_DEFAULTS.e2ee.maxRecordBytes,
|
|
249
262
|
outboundRecordChunkBytes: options.outboundRecordChunkBytes ?? SDK_DEFAULTS.e2ee.outboundRecordChunkBytes,
|
|
250
|
-
maxBufferedBytes: options.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.
|
|
263
|
+
maxBufferedBytes: options.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.maxInboundBufferedBytes,
|
|
251
264
|
maxOutboundBufferedBytes: options.maxOutboundBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes,
|
|
252
265
|
timeoutMs: options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs,
|
|
253
266
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
@@ -327,7 +340,7 @@ function randomEndpointInstanceId() {
|
|
|
327
340
|
function normalizeStreamKind(kind, path) {
|
|
328
341
|
const value = kind.trim();
|
|
329
342
|
if (value === "")
|
|
330
|
-
throw new FlowersecError({ path, stage: "
|
|
343
|
+
throw new FlowersecError({ path, stage: "rpc", code: "missing_stream_kind", message: "missing stream kind" });
|
|
331
344
|
return value;
|
|
332
345
|
}
|
|
333
346
|
function unwrapServerGrant(input) {
|
package/dist/reconnect/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getClientTermination } from "../client-connect/termination.js";
|
|
2
2
|
import { emitObserverDiagnostic, withObserverContext } from "../observability/observer.js";
|
|
3
3
|
import { SDK_DEFAULTS } from "../defaults.js";
|
|
4
|
+
import { AbortError, FlowersecError } from "../utils/errors.js";
|
|
4
5
|
export { createArtifactResolver, createControlplaneArtifactSource } from "./artifactControlplane.js";
|
|
5
6
|
function normalizeAutoReconnect(cfg) {
|
|
6
7
|
const settings = {
|
|
@@ -33,6 +34,26 @@ function backoffDelayMs(attemptIndex, cfg) {
|
|
|
33
34
|
const jitter = cfg.jitterRatio <= 0 ? 0 : base * cfg.jitterRatio * (Math.random() * 2 - 1);
|
|
34
35
|
return Math.max(0, Math.round(base + jitter));
|
|
35
36
|
}
|
|
37
|
+
const TERMINAL_RECONNECT_CODES = new Set([
|
|
38
|
+
"invalid_input",
|
|
39
|
+
"invalid_option",
|
|
40
|
+
"role_mismatch",
|
|
41
|
+
"transport_policy_denied",
|
|
42
|
+
"invalid_psk",
|
|
43
|
+
"invalid_suite",
|
|
44
|
+
"missing_grant",
|
|
45
|
+
"missing_connect_info",
|
|
46
|
+
"missing_tunnel_url",
|
|
47
|
+
"missing_ws_url",
|
|
48
|
+
"missing_channel_id",
|
|
49
|
+
"missing_token",
|
|
50
|
+
"missing_init_exp",
|
|
51
|
+
]);
|
|
52
|
+
function isTerminalConnectError(error) {
|
|
53
|
+
if (error instanceof AbortError || error.name === "AbortError")
|
|
54
|
+
return true;
|
|
55
|
+
return error instanceof FlowersecError && (error.code === "canceled" || TERMINAL_RECONNECT_CODES.has(error.code));
|
|
56
|
+
}
|
|
36
57
|
function isSameConfig(a, b) {
|
|
37
58
|
if (a == null)
|
|
38
59
|
return false;
|
|
@@ -101,6 +122,30 @@ export function createReconnectManager() {
|
|
|
101
122
|
resolve();
|
|
102
123
|
}, ms);
|
|
103
124
|
});
|
|
125
|
+
const finishWithError = (cfg, error, currentAttemptSeq) => {
|
|
126
|
+
setState({ status: "error", error, client: null });
|
|
127
|
+
emitObserverDiagnostic(withObserverContext(cfg.observer, { attemptSeq: currentAttemptSeq }), {
|
|
128
|
+
path: "auto",
|
|
129
|
+
stage: "reconnect",
|
|
130
|
+
code_domain: "event",
|
|
131
|
+
code: "reconnect_exhausted",
|
|
132
|
+
result: "fail",
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
const scheduleRetry = async (t, cfg, error, settings, failedAttemptIndex, currentAttemptSeq) => {
|
|
136
|
+
if (t !== token || active !== cfg)
|
|
137
|
+
return false;
|
|
138
|
+
setState({ status: "connecting", error, client: null });
|
|
139
|
+
emitObserverDiagnostic(withObserverContext(cfg.observer, { attemptSeq: currentAttemptSeq }), {
|
|
140
|
+
path: "auto",
|
|
141
|
+
stage: "reconnect",
|
|
142
|
+
code_domain: "event",
|
|
143
|
+
code: "reconnect_scheduled",
|
|
144
|
+
result: "retry",
|
|
145
|
+
});
|
|
146
|
+
await sleep(backoffDelayMs(failedAttemptIndex, settings));
|
|
147
|
+
return t === token && active === cfg;
|
|
148
|
+
};
|
|
104
149
|
const disconnectInternal = () => {
|
|
105
150
|
cancelRetrySleep();
|
|
106
151
|
abortActiveAttempt();
|
|
@@ -145,8 +190,7 @@ export function createReconnectManager() {
|
|
|
145
190
|
}
|
|
146
191
|
token += 1;
|
|
147
192
|
const nextToken = token;
|
|
148
|
-
const reconnectPromise = startConnectLoop(nextToken, cfg);
|
|
149
|
-
setState({ status: "connecting", error, client: null });
|
|
193
|
+
const reconnectPromise = startConnectLoop(nextToken, cfg, error);
|
|
150
194
|
void reconnectPromise.catch(() => {
|
|
151
195
|
// connectWithRetry updates state; keep errors observable via state().
|
|
152
196
|
});
|
|
@@ -184,9 +228,17 @@ export function createReconnectManager() {
|
|
|
184
228
|
attemptAbort = new AbortController();
|
|
185
229
|
return await cfg.connectOnce({ signal: attemptAbort.signal, observer: createObserver(t, cfg, currentAttemptSeq) ?? {} });
|
|
186
230
|
};
|
|
187
|
-
const connectWithRetry = async (t, cfg) => {
|
|
231
|
+
const connectWithRetry = async (t, cfg, initialFailure) => {
|
|
188
232
|
const ar = normalizeAutoReconnect(cfg.autoReconnect);
|
|
189
233
|
let attempts = 0;
|
|
234
|
+
if (initialFailure != null) {
|
|
235
|
+
if (isTerminalConnectError(initialFailure)) {
|
|
236
|
+
finishWithError(cfg, initialFailure, attemptSeq);
|
|
237
|
+
throw initialFailure;
|
|
238
|
+
}
|
|
239
|
+
if (!await scheduleRetry(t, cfg, initialFailure, ar, 0, attemptSeq))
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
190
242
|
for (;;) {
|
|
191
243
|
if (t !== token)
|
|
192
244
|
return;
|
|
@@ -245,32 +297,17 @@ export function createReconnectManager() {
|
|
|
245
297
|
return;
|
|
246
298
|
if (active !== cfg)
|
|
247
299
|
return;
|
|
248
|
-
const canRetry = ar.enabled && attempts < ar.maxAttempts;
|
|
300
|
+
const canRetry = ar.enabled && attempts < ar.maxAttempts && !isTerminalConnectError(e);
|
|
249
301
|
if (!canRetry) {
|
|
250
|
-
|
|
251
|
-
emitObserverDiagnostic(withObserverContext(cfg.observer, { attemptSeq }), {
|
|
252
|
-
path: "auto",
|
|
253
|
-
stage: "reconnect",
|
|
254
|
-
code_domain: "event",
|
|
255
|
-
code: "reconnect_exhausted",
|
|
256
|
-
result: "fail",
|
|
257
|
-
});
|
|
302
|
+
finishWithError(cfg, e, attemptSeq);
|
|
258
303
|
throw e;
|
|
259
304
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
emitObserverDiagnostic(withObserverContext(cfg.observer, { attemptSeq }), {
|
|
263
|
-
path: "auto",
|
|
264
|
-
stage: "reconnect",
|
|
265
|
-
code_domain: "event",
|
|
266
|
-
code: "reconnect_scheduled",
|
|
267
|
-
result: "retry",
|
|
268
|
-
});
|
|
269
|
-
await sleep(delay);
|
|
305
|
+
if (!await scheduleRetry(t, cfg, e, ar, attempts - 1, attemptSeq))
|
|
306
|
+
return;
|
|
270
307
|
}
|
|
271
308
|
}
|
|
272
309
|
};
|
|
273
|
-
const startConnectLoop = (t, cfg) => {
|
|
310
|
+
const startConnectLoop = (t, cfg, initialFailure) => {
|
|
274
311
|
let resolveLoop;
|
|
275
312
|
let rejectLoop;
|
|
276
313
|
const loop = new Promise((resolve, reject) => {
|
|
@@ -283,7 +320,7 @@ export function createReconnectManager() {
|
|
|
283
320
|
activeConnectPromise = null;
|
|
284
321
|
});
|
|
285
322
|
activeConnectPromise = promise;
|
|
286
|
-
void connectWithRetry(t, cfg).then(resolveLoop, rejectLoop);
|
|
323
|
+
void connectWithRetry(t, cfg, initialFailure).then(resolveLoop, rejectLoop);
|
|
287
324
|
return promise;
|
|
288
325
|
};
|
|
289
326
|
const connect = async (cfg) => {
|
|
@@ -300,7 +337,7 @@ export function createReconnectManager() {
|
|
|
300
337
|
setState({ status: "error", error: closeError, client: null });
|
|
301
338
|
throw closeError;
|
|
302
339
|
}
|
|
303
|
-
const connectPromise = startConnectLoop(t, cfg);
|
|
340
|
+
const connectPromise = startConnectLoop(t, cfg, null);
|
|
304
341
|
setState({ status: "connecting", error: null, client: null });
|
|
305
342
|
await connectPromise;
|
|
306
343
|
};
|
package/dist/yamux/byteReader.js
CHANGED
|
@@ -23,25 +23,7 @@ export class ByteReader {
|
|
|
23
23
|
this.buffered += chunk.length;
|
|
24
24
|
}
|
|
25
25
|
const out = new Uint8Array(n);
|
|
26
|
-
|
|
27
|
-
while (outOff < n) {
|
|
28
|
-
const head = this.chunks[this.chunkHead];
|
|
29
|
-
const avail = head.length - this.headOff;
|
|
30
|
-
const need = n - outOff;
|
|
31
|
-
const take = Math.min(avail, need);
|
|
32
|
-
out.set(head.subarray(this.headOff, this.headOff + take), outOff);
|
|
33
|
-
outOff += take;
|
|
34
|
-
this.headOff += take;
|
|
35
|
-
this.buffered -= take;
|
|
36
|
-
if (this.headOff === head.length) {
|
|
37
|
-
this.chunkHead++;
|
|
38
|
-
this.headOff = 0;
|
|
39
|
-
if (this.chunkHead > 1024 && this.chunkHead * 2 > this.chunks.length) {
|
|
40
|
-
this.chunks.splice(0, this.chunkHead);
|
|
41
|
-
this.chunkHead = 0;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
26
|
+
this.consumeAvailable(n, out);
|
|
45
27
|
return out;
|
|
46
28
|
}
|
|
47
29
|
// discardExactly consumes bytes without allocating a contiguous output buffer.
|
|
@@ -59,19 +41,41 @@ export class ByteReader {
|
|
|
59
41
|
this.chunks.push(chunk);
|
|
60
42
|
this.buffered += chunk.length;
|
|
61
43
|
}
|
|
44
|
+
remaining -= this.consumeAvailable(remaining);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// bufferedBytes returns the number of bytes currently buffered.
|
|
48
|
+
bufferedBytes() {
|
|
49
|
+
return this.buffered;
|
|
50
|
+
}
|
|
51
|
+
consumeAvailable(maxBytes, output) {
|
|
52
|
+
let consumed = 0;
|
|
53
|
+
while (consumed < maxBytes && this.buffered > 0) {
|
|
62
54
|
const head = this.chunks[this.chunkHead];
|
|
63
|
-
const take = Math.min(
|
|
55
|
+
const take = Math.min(maxBytes - consumed, head.length - this.headOff);
|
|
56
|
+
if (output != null)
|
|
57
|
+
output.set(head.subarray(this.headOff, this.headOff + take), consumed);
|
|
64
58
|
this.headOff += take;
|
|
65
59
|
this.buffered -= take;
|
|
66
|
-
|
|
60
|
+
consumed += take;
|
|
67
61
|
if (this.headOff === head.length) {
|
|
68
62
|
this.chunkHead++;
|
|
69
63
|
this.headOff = 0;
|
|
70
64
|
}
|
|
71
65
|
}
|
|
66
|
+
this.compactConsumedChunks();
|
|
67
|
+
return consumed;
|
|
72
68
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
69
|
+
compactConsumedChunks() {
|
|
70
|
+
if (this.buffered === 0) {
|
|
71
|
+
this.chunks.length = 0;
|
|
72
|
+
this.chunkHead = 0;
|
|
73
|
+
this.headOff = 0;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (this.chunkHead > 1024 && this.chunkHead * 2 > this.chunks.length) {
|
|
77
|
+
this.chunks.splice(0, this.chunkHead);
|
|
78
|
+
this.chunkHead = 0;
|
|
79
|
+
}
|
|
76
80
|
}
|
|
77
81
|
}
|
package/dist/yamux/session.js
CHANGED
|
@@ -11,7 +11,7 @@ export const DEFAULT_YAMUX_LIMITS = Object.freeze({
|
|
|
11
11
|
preferredOutboundFrameBytes: SDK_DEFAULTS.yamux.preferredOutboundFrameBytes,
|
|
12
12
|
maxStreamReceiveBytes: SDK_DEFAULTS.yamux.maxStreamReceiveBytes,
|
|
13
13
|
maxSessionReceiveBytes: SDK_DEFAULTS.yamux.maxSessionReceiveBytes,
|
|
14
|
-
maxStreamWriteQueueBytes: SDK_DEFAULTS.
|
|
14
|
+
maxStreamWriteQueueBytes: SDK_DEFAULTS.yamux.maxStreamWriteQueueBytes,
|
|
15
15
|
});
|
|
16
16
|
// YamuxSession multiplexes multiple streams over a single byte stream.
|
|
17
17
|
export class YamuxSession {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@floegence/flowersec-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -121,6 +121,8 @@
|
|
|
121
121
|
"build": "tsc -p tsconfig.build.json",
|
|
122
122
|
"bench": "vitest bench --run",
|
|
123
123
|
"test": "npm run build && vitest run",
|
|
124
|
+
"test:browser": "npm run build && playwright test",
|
|
125
|
+
"ensure:browser": "node ./scripts/ensure-playwright-chromium.mjs",
|
|
124
126
|
"test:coverage": "npm run build && vitest run --coverage",
|
|
125
127
|
"lint": "eslint .",
|
|
126
128
|
"verify:package": "node ./scripts/verify-package-exports.mjs",
|
|
@@ -136,6 +138,7 @@
|
|
|
136
138
|
"devDependencies": {
|
|
137
139
|
"@types/node": "^24.0.0",
|
|
138
140
|
"@types/ws": "^8.5.12",
|
|
141
|
+
"@playwright/test": "1.58.2",
|
|
139
142
|
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
|
140
143
|
"@typescript-eslint/parser": "^8.18.0",
|
|
141
144
|
"@vitest/coverage-v8": "4.1.0",
|