@genation/bridge-peer 0.1.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/GenationBridgePeer.podspec +17 -0
- package/LICENSE +190 -0
- package/README.md +66 -0
- package/package.json +59 -0
- package/react-native/android/build.gradle +24 -0
- package/react-native/android/src/main/java/com/genation/bridge/GenationBridgePeerModule.kt +182 -0
- package/react-native/android/src/main/java/com/genation/bridge/GenationBridgePeerPackage.kt +15 -0
- package/react-native/android/src/main/jniLibs/arm64-v8a/libgenation_bridge_crypto_uniffi.so +0 -0
- package/react-native/android/src/main/jniLibs/armeabi-v7a/libgenation_bridge_crypto_uniffi.so +0 -0
- package/react-native/android/src/main/jniLibs/x86/libgenation_bridge_crypto_uniffi.so +0 -0
- package/react-native/android/src/main/jniLibs/x86_64/libgenation_bridge_crypto_uniffi.so +0 -0
- package/react-native/generated/kotlin/uniffi/genation_bridge_crypto_uniffi/genation_bridge_crypto_uniffi.kt +2006 -0
- package/react-native/generated/swift/GenationBridgeCryptoMobile.swift +1072 -0
- package/react-native/generated/swift/GenationBridgeCryptoMobileFFI.h +703 -0
- package/react-native/generated/swift/GenationBridgeCryptoMobileFFI.modulemap +7 -0
- package/react-native/ios/GenationBridgePeer.m +14 -0
- package/react-native/ios/GenationBridgePeer.swift +234 -0
- package/react-native/native/GenationBridgeCryptoMobile.xcframework/Info.plist +48 -0
- package/react-native/native/GenationBridgeCryptoMobile.xcframework/ios-arm64/Headers/GenationBridgeCryptoMobileFFI.h +703 -0
- package/react-native/native/GenationBridgeCryptoMobile.xcframework/ios-arm64/Headers/module.modulemap +7 -0
- package/react-native/native/GenationBridgeCryptoMobile.xcframework/ios-arm64/libgenation_bridge_crypto_uniffi.a +0 -0
- package/react-native/native/GenationBridgeCryptoMobile.xcframework/ios-arm64_x86_64-simulator/Headers/GenationBridgeCryptoMobileFFI.h +703 -0
- package/react-native/native/GenationBridgeCryptoMobile.xcframework/ios-arm64_x86_64-simulator/Headers/module.modulemap +7 -0
- package/react-native/native/GenationBridgeCryptoMobile.xcframework/ios-arm64_x86_64-simulator/libgenation_bridge_crypto_uniffi.a +0 -0
- package/react-native.config.js +8 -0
- package/src/core/codec.d.ts +5 -0
- package/src/core/codec.js +46 -0
- package/src/core/errors.d.ts +11 -0
- package/src/core/errors.js +27 -0
- package/src/core/identity.d.ts +16 -0
- package/src/core/identity.js +1 -0
- package/src/core/lifecycle.d.ts +4 -0
- package/src/core/lifecycle.js +1 -0
- package/src/core/mod.d.ts +6 -0
- package/src/core/mod.js +3 -0
- package/src/core/peer.d.ts +67 -0
- package/src/core/peer.js +373 -0
- package/src/core/runtime.d.ts +78 -0
- package/src/core/runtime.js +0 -0
- package/src/node/crypto-core/javascript/mod.d.ts +8 -0
- package/src/node/crypto-core/javascript/mod.js +24 -0
- package/src/node/crypto-core/javascript/provider.d.ts +2 -0
- package/src/node/crypto-core/javascript/provider.js +43 -0
- package/src/node/crypto-core/pkg/wasm/genation_bridge_crypto_wasm.d.ts +122 -0
- package/src/node/crypto-core/pkg/wasm/genation_bridge_crypto_wasm.js +433 -0
- package/src/node/crypto-core/pkg/wasm/genation_bridge_crypto_wasm_bg.wasm +0 -0
- package/src/node/crypto-core/pkg/wasm/genation_bridge_crypto_wasm_bg.wasm.d.ts +25 -0
- package/src/node/encoding.js +1 -0
- package/src/node/file-key-store.d.ts +6 -0
- package/src/node/file-key-store.js +111 -0
- package/src/node/identity-provider.d.ts +8 -0
- package/src/node/identity-provider.js +57 -0
- package/src/node/identity.js +45 -0
- package/src/node/mod.d.ts +16 -0
- package/src/node/mod.js +17 -0
- package/src/node/relay-runtime.d.ts +11 -0
- package/src/node/relay-runtime.js +11 -0
- package/src/react-native/identity-provider.d.ts +4 -0
- package/src/react-native/identity-provider.js +125 -0
- package/src/react-native/lifecycle.d.ts +8 -0
- package/src/react-native/lifecycle.js +9 -0
- package/src/react-native/mod.d.ts +20 -0
- package/src/react-native/mod.js +36 -0
- package/src/react-native/native-module.d.ts +17 -0
- package/src/react-native/native-module.js +9 -0
- package/src/relay/encoding.js +32 -0
- package/src/relay/frame-channel.d.ts +1 -0
- package/src/relay/frame-channel.js +110 -0
- package/src/relay/identity.d.ts +27 -0
- package/src/relay/identity.js +0 -0
- package/src/relay/runtime.d.ts +8 -0
- package/src/relay/runtime.js +900 -0
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
import { buildInvocationAad, buildSessionPrologue, permitsCapability } from "@genation/bridge-contracts";
|
|
2
|
+
import { BridgePeerError } from "../core/mod.js";
|
|
3
|
+
import { decodeBase64url, encodeBase64url, parseRelayGrantClaims } from "./encoding.js";
|
|
4
|
+
import { RelayFrameChannel } from "./frame-channel.js";
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
const bridgeCodes = new Set([
|
|
7
|
+
"OK",
|
|
8
|
+
"TARGET_OFFLINE",
|
|
9
|
+
"CAPABILITY_NOT_EXPOSED",
|
|
10
|
+
"PERMISSION_DENIED",
|
|
11
|
+
"DEADLINE_EXCEEDED",
|
|
12
|
+
"CANCELLED",
|
|
13
|
+
"CONNECTION_LOST",
|
|
14
|
+
"EXPOSER_ERROR",
|
|
15
|
+
"PROTOCOL_ERROR",
|
|
16
|
+
"NO_COMPATIBLE_EXPOSER",
|
|
17
|
+
"CAPABILITY_MODE_MISMATCH",
|
|
18
|
+
"FANOUT_LIMIT_EXCEEDED",
|
|
19
|
+
"PAYLOAD_TOO_LARGE",
|
|
20
|
+
"RESPONSE_LIMIT_EXCEEDED",
|
|
21
|
+
"AUTHORIZATION_EXPIRED"
|
|
22
|
+
]);
|
|
23
|
+
const newId = (prefix)=>`${prefix}_${crypto.randomUUID()}`;
|
|
24
|
+
const remaining = (deadlineAt)=>Math.max(1, deadlineAt - Date.now());
|
|
25
|
+
const maxStreamPlaintextChunkBytes = 48 * 1_024;
|
|
26
|
+
const encodeResponseMetadata = (response)=>{
|
|
27
|
+
const value = Object.freeze({
|
|
28
|
+
status: response.status,
|
|
29
|
+
statusText: response.statusText,
|
|
30
|
+
headers: Object.freeze([
|
|
31
|
+
...response.headers.entries()
|
|
32
|
+
].map(([name, value])=>Object.freeze([
|
|
33
|
+
name,
|
|
34
|
+
value
|
|
35
|
+
])))
|
|
36
|
+
});
|
|
37
|
+
const encoded = encoder.encode(JSON.stringify(value));
|
|
38
|
+
if (encoded.length > maxStreamPlaintextChunkBytes) {
|
|
39
|
+
throw new BridgePeerError("RESPONSE_LIMIT_EXCEEDED");
|
|
40
|
+
}
|
|
41
|
+
return encoded;
|
|
42
|
+
};
|
|
43
|
+
const decodeResponseMetadata = (value)=>{
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", {
|
|
46
|
+
fatal: true
|
|
47
|
+
}).decode(value));
|
|
48
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
49
|
+
throw new Error("invalid_metadata");
|
|
50
|
+
}
|
|
51
|
+
const candidate = parsed;
|
|
52
|
+
if (!Number.isInteger(candidate.status) || candidate.status < 200 || candidate.status > 599 || typeof candidate.statusText !== "string" || !Array.isArray(candidate.headers) || candidate.headers.some((entry)=>!Array.isArray(entry) || entry.length !== 2 || entry.some((part)=>typeof part !== "string"))) throw new Error("invalid_metadata");
|
|
53
|
+
return Object.freeze({
|
|
54
|
+
status: candidate.status,
|
|
55
|
+
statusText: candidate.statusText,
|
|
56
|
+
headers: Object.freeze(candidate.headers.map(([name, headerValue])=>Object.freeze([
|
|
57
|
+
name,
|
|
58
|
+
headerValue
|
|
59
|
+
])))
|
|
60
|
+
});
|
|
61
|
+
} catch {
|
|
62
|
+
throw new BridgePeerError("PROTOCOL_ERROR");
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const streamChunks = (value)=>{
|
|
66
|
+
const chunks = [];
|
|
67
|
+
for(let offset = 0; offset < value.length; offset += maxStreamPlaintextChunkBytes){
|
|
68
|
+
chunks.push(value.slice(offset, offset + maxStreamPlaintextChunkBytes));
|
|
69
|
+
}
|
|
70
|
+
return chunks;
|
|
71
|
+
};
|
|
72
|
+
const abortableConnect = async (promise, signal)=>{
|
|
73
|
+
if (signal.aborted) throw new BridgePeerError("PEER_CLOSED");
|
|
74
|
+
return await new Promise((resolve, reject)=>{
|
|
75
|
+
const abort = ()=>reject(new BridgePeerError("PEER_CLOSED"));
|
|
76
|
+
signal.addEventListener("abort", abort, {
|
|
77
|
+
once: true
|
|
78
|
+
});
|
|
79
|
+
promise.then((value)=>{
|
|
80
|
+
signal.removeEventListener("abort", abort);
|
|
81
|
+
resolve(value);
|
|
82
|
+
}, (error)=>{
|
|
83
|
+
signal.removeEventListener("abort", abort);
|
|
84
|
+
reject(error);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
const assertNotAborted = (signal)=>{
|
|
89
|
+
if (signal?.aborted) throw new BridgePeerError("CANCELLED");
|
|
90
|
+
};
|
|
91
|
+
const relayFailure = (frame)=>new BridgePeerError(frame.error.bridgeCode, frame.error.retryable);
|
|
92
|
+
const assertCompatibleGrant = (grant, input, identity)=>{
|
|
93
|
+
const claims = parseRelayGrantClaims(grant);
|
|
94
|
+
const expose = input.desiredExposures().map((value)=>value.capability);
|
|
95
|
+
if (claims.projectId !== input.projectId || claims.peerId !== input.peerId || claims.keys.connection.x !== identity.connectionPublicKey.x || claims.keys.noiseStatic.x !== identity.noiseStaticPublicKey.x || claims.expiresAt <= Math.floor(Date.now() / 1_000) || expose.some((capability)=>!permitsCapability(claims, "expose", capability)) || input.requestedInvokeCapabilities.some((capability)=>!permitsCapability(claims, "invoke", capability))) throw new BridgePeerError("GRANT_FAILED");
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
compact: grant,
|
|
98
|
+
claims
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
const obtainGrant = async (input, identity)=>{
|
|
102
|
+
try {
|
|
103
|
+
const result = await input.grantProvider(Object.freeze({
|
|
104
|
+
projectId: input.projectId,
|
|
105
|
+
peerId: input.peerId,
|
|
106
|
+
connectionPublicKey: identity.connectionPublicKey,
|
|
107
|
+
noiseStaticPublicKey: identity.noiseStaticPublicKey,
|
|
108
|
+
requestedPermissions: Object.freeze({
|
|
109
|
+
expose: Object.freeze(input.desiredExposures().map((value)=>value.capability)),
|
|
110
|
+
invoke: Object.freeze([
|
|
111
|
+
...input.requestedInvokeCapabilities
|
|
112
|
+
])
|
|
113
|
+
})
|
|
114
|
+
}));
|
|
115
|
+
return assertCompatibleGrant(result.grant, input, identity);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof BridgePeerError) throw error;
|
|
118
|
+
throw new BridgePeerError("GRANT_FAILED");
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
class RelayConnection {
|
|
122
|
+
#input;
|
|
123
|
+
#channel;
|
|
124
|
+
#connected;
|
|
125
|
+
#identity;
|
|
126
|
+
#timeoutMs;
|
|
127
|
+
#grant;
|
|
128
|
+
#exposures = new Map();
|
|
129
|
+
#heartbeatTimer;
|
|
130
|
+
#renewalTimer;
|
|
131
|
+
#renewalInFlight;
|
|
132
|
+
#closed = false;
|
|
133
|
+
#disconnectNotified = false;
|
|
134
|
+
#heartbeatSequence = 0;
|
|
135
|
+
#activeIncomingControllers = new Set();
|
|
136
|
+
#activeIncomingReaders = new Set();
|
|
137
|
+
#activeResponders = new Set();
|
|
138
|
+
#exposureReplacements = new Map();
|
|
139
|
+
constructor(input){
|
|
140
|
+
this.#input = input.request;
|
|
141
|
+
this.#channel = input.channel;
|
|
142
|
+
this.#connected = input.connected;
|
|
143
|
+
this.#identity = input.identity;
|
|
144
|
+
this.#grant = input.grant;
|
|
145
|
+
this.#timeoutMs = input.timeoutMs;
|
|
146
|
+
this.#channel.setUnhandled((frame)=>this.#handleUnhandled(frame));
|
|
147
|
+
this.#channel.setUnexpectedClose(()=>this.#notifyDisconnect());
|
|
148
|
+
this.#scheduleHeartbeat();
|
|
149
|
+
this.#scheduleRenewal();
|
|
150
|
+
}
|
|
151
|
+
async replaceExposures(exposures) {
|
|
152
|
+
if (this.#closed) throw new BridgePeerError("CONNECTION_LOST");
|
|
153
|
+
if (exposures.some((value)=>!permitsCapability(this.#grant.claims, "expose", value.capability))) await this.#renewGrant();
|
|
154
|
+
if (exposures.some((value)=>!permitsCapability(this.#grant.claims, "expose", value.capability))) throw new BridgePeerError("GRANT_FAILED");
|
|
155
|
+
const frameId = newId("frm");
|
|
156
|
+
this.#exposureReplacements.set(frameId, setTimeout(()=>{
|
|
157
|
+
this.#exposureReplacements.delete(frameId);
|
|
158
|
+
}, this.#timeoutMs));
|
|
159
|
+
try {
|
|
160
|
+
this.#channel.send({
|
|
161
|
+
version: "json.v1",
|
|
162
|
+
type: "exposure.replace",
|
|
163
|
+
frameId,
|
|
164
|
+
sentAt: Date.now(),
|
|
165
|
+
projectId: this.#input.projectId,
|
|
166
|
+
peerId: this.#input.peerId,
|
|
167
|
+
connectionEpoch: this.#connected.connectionEpoch,
|
|
168
|
+
capabilities: exposures.map(({ capability, stream })=>({
|
|
169
|
+
capability,
|
|
170
|
+
stream
|
|
171
|
+
}))
|
|
172
|
+
});
|
|
173
|
+
} catch (error) {
|
|
174
|
+
clearTimeout(this.#exposureReplacements.get(frameId));
|
|
175
|
+
this.#exposureReplacements.delete(frameId);
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
this.#exposures = new Map(exposures.map((value)=>[
|
|
179
|
+
value.capability,
|
|
180
|
+
value
|
|
181
|
+
]));
|
|
182
|
+
}
|
|
183
|
+
async invokeUnary(input) {
|
|
184
|
+
const session = await this.#openSession(input, false, undefined, input.signal);
|
|
185
|
+
try {
|
|
186
|
+
const invocationId = newId("inv");
|
|
187
|
+
const header = this.#startHeader(session.context, invocationId, input, false);
|
|
188
|
+
const ciphertext = encodeBase64url(session.machine.encrypt(buildInvocationAad(header), input.payload));
|
|
189
|
+
let dispatched = false;
|
|
190
|
+
const cancel = ()=>{
|
|
191
|
+
if (dispatched) void this.#sendCancel(session, header).catch(()=>undefined);
|
|
192
|
+
};
|
|
193
|
+
input.signal?.addEventListener("abort", cancel, {
|
|
194
|
+
once: true
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
assertNotAborted(input.signal);
|
|
198
|
+
this.#channel.send({
|
|
199
|
+
...header,
|
|
200
|
+
ciphertext
|
|
201
|
+
});
|
|
202
|
+
dispatched = true;
|
|
203
|
+
if (input.signal?.aborted) cancel();
|
|
204
|
+
const terminal = await this.#channel.waitFor((frame)=>frame.type === "invoke.result" && frame.invocationId === invocationId || frame.type === "relay.error" && frame.error.correlationId === invocationId, remaining(input.deadlineAt), input.signal);
|
|
205
|
+
if (terminal.type === "relay.error") throw relayFailure(terminal);
|
|
206
|
+
const plaintext = session.machine.decrypt(buildInvocationAad(terminal), decodeBase64url(terminal.ciphertext));
|
|
207
|
+
if (terminal.outcome !== "succeeded") {
|
|
208
|
+
throw new BridgePeerError(terminal.bridgeCode);
|
|
209
|
+
}
|
|
210
|
+
return plaintext;
|
|
211
|
+
} finally{
|
|
212
|
+
input.signal?.removeEventListener("abort", cancel);
|
|
213
|
+
}
|
|
214
|
+
} finally{
|
|
215
|
+
session.machine.close();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async invokeStream(input) {
|
|
219
|
+
const session = await this.#openSession(input, true, undefined, input.signal);
|
|
220
|
+
const invocationId = newId("inv");
|
|
221
|
+
const start = this.#startHeader(session.context, invocationId, input, true);
|
|
222
|
+
const cancellation = new AbortController();
|
|
223
|
+
let dispatched = false;
|
|
224
|
+
let cancelSent = false;
|
|
225
|
+
const cancel = ()=>{
|
|
226
|
+
if (!cancellation.signal.aborted) cancellation.abort();
|
|
227
|
+
if (dispatched && !cancelSent) {
|
|
228
|
+
cancelSent = true;
|
|
229
|
+
void this.#sendCancel(session, start).catch(()=>undefined);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
input.signal?.addEventListener("abort", cancel, {
|
|
233
|
+
once: true
|
|
234
|
+
});
|
|
235
|
+
let released = false;
|
|
236
|
+
const release = ()=>{
|
|
237
|
+
if (released) return;
|
|
238
|
+
released = true;
|
|
239
|
+
input.signal?.removeEventListener("abort", cancel);
|
|
240
|
+
session.machine.close();
|
|
241
|
+
};
|
|
242
|
+
try {
|
|
243
|
+
assertNotAborted(input.signal);
|
|
244
|
+
this.#channel.send({
|
|
245
|
+
...start,
|
|
246
|
+
ciphertext: encodeBase64url(session.machine.encrypt(buildInvocationAad(start), input.payload))
|
|
247
|
+
});
|
|
248
|
+
dispatched = true;
|
|
249
|
+
if (input.signal?.aborted) cancel();
|
|
250
|
+
const first = await this.#channel.waitFor((frame)=>(frame.type === "invoke.stream.open" || frame.type === "invoke.stream.end") && frame.invocationId === invocationId || frame.type === "relay.error" && frame.error.correlationId === invocationId, remaining(input.deadlineAt), cancellation.signal);
|
|
251
|
+
if (first.type === "relay.error") throw relayFailure(first);
|
|
252
|
+
const firstPlaintext = session.machine.decrypt(buildInvocationAad(first), decodeBase64url(first.ciphertext));
|
|
253
|
+
if (first.type === "invoke.stream.end") {
|
|
254
|
+
throw new BridgePeerError(first.bridgeCode);
|
|
255
|
+
}
|
|
256
|
+
if (first.sequence !== 1) throw new BridgePeerError("PROTOCOL_ERROR");
|
|
257
|
+
const metadata = decodeResponseMetadata(firstPlaintext);
|
|
258
|
+
this.#sendStreamAck(session.machine, first, first.sequence);
|
|
259
|
+
let expectedSequence = 2;
|
|
260
|
+
const body = new ReadableStream({
|
|
261
|
+
pull: async (controller)=>{
|
|
262
|
+
try {
|
|
263
|
+
const frame = await this.#channel.waitFor((candidate)=>(candidate.type === "invoke.stream.chunk" || candidate.type === "invoke.stream.end") && candidate.invocationId === invocationId || candidate.type === "relay.error" && candidate.error.correlationId === invocationId, this.#timeoutMs, cancellation.signal);
|
|
264
|
+
if (frame.type === "relay.error") throw relayFailure(frame);
|
|
265
|
+
if (frame.sequence !== expectedSequence) {
|
|
266
|
+
throw new BridgePeerError("PROTOCOL_ERROR");
|
|
267
|
+
}
|
|
268
|
+
const plaintext = session.machine.decrypt(buildInvocationAad(frame), decodeBase64url(frame.ciphertext));
|
|
269
|
+
expectedSequence += 1;
|
|
270
|
+
if (frame.type === "invoke.stream.chunk") {
|
|
271
|
+
if (plaintext.length > 0) controller.enqueue(plaintext);
|
|
272
|
+
this.#sendStreamAck(session.machine, frame, frame.sequence);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (frame.outcome !== "succeeded") {
|
|
276
|
+
throw new BridgePeerError(frame.bridgeCode);
|
|
277
|
+
}
|
|
278
|
+
if (plaintext.length > 0) controller.enqueue(plaintext);
|
|
279
|
+
controller.close();
|
|
280
|
+
release();
|
|
281
|
+
} catch (error) {
|
|
282
|
+
controller.error(error instanceof BridgePeerError ? error : new BridgePeerError("PROTOCOL_ERROR"));
|
|
283
|
+
release();
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
cancel: ()=>{
|
|
287
|
+
cancel();
|
|
288
|
+
release();
|
|
289
|
+
}
|
|
290
|
+
}, {
|
|
291
|
+
highWaterMark: 0
|
|
292
|
+
});
|
|
293
|
+
return new Response(body, {
|
|
294
|
+
status: metadata.status,
|
|
295
|
+
statusText: metadata.statusText,
|
|
296
|
+
headers: metadata.headers
|
|
297
|
+
});
|
|
298
|
+
} catch (error) {
|
|
299
|
+
release();
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async invokeFanout(input) {
|
|
304
|
+
const fanoutId = newId("fan");
|
|
305
|
+
this.#channel.send({
|
|
306
|
+
version: "json.v1",
|
|
307
|
+
type: "invoke.fanout.prepare",
|
|
308
|
+
frameId: newId("frm"),
|
|
309
|
+
sentAt: Date.now(),
|
|
310
|
+
projectId: this.#input.projectId,
|
|
311
|
+
fanoutId,
|
|
312
|
+
sourcePeerId: this.#input.peerId,
|
|
313
|
+
sourceConnectionEpoch: this.#connected.connectionEpoch,
|
|
314
|
+
capability: input.capability,
|
|
315
|
+
deadlineAt: input.deadlineAt,
|
|
316
|
+
stream: false
|
|
317
|
+
});
|
|
318
|
+
const prepared = await this.#channel.waitFor((frame)=>frame.type === "invoke.fanout.targets" && frame.fanoutId === fanoutId || frame.type === "relay.error" && frame.error.correlationId === fanoutId, remaining(input.deadlineAt), input.signal);
|
|
319
|
+
if (prepared.type === "relay.error") throw relayFailure(prepared);
|
|
320
|
+
const setup = await Promise.allSettled(prepared.targets.map(async (target)=>({
|
|
321
|
+
target,
|
|
322
|
+
session: await this.#openSession({
|
|
323
|
+
capability: input.capability,
|
|
324
|
+
targetPeer: target.peerId,
|
|
325
|
+
deadlineAt: input.deadlineAt
|
|
326
|
+
}, false, target.connectionEpoch, input.signal),
|
|
327
|
+
invocationId: newId("inv")
|
|
328
|
+
})));
|
|
329
|
+
const sessions = setup.flatMap((result)=>result.status === "fulfilled" ? [
|
|
330
|
+
result.value
|
|
331
|
+
] : []);
|
|
332
|
+
const failedSetup = setup.find((result)=>result.status === "rejected");
|
|
333
|
+
if (failedSetup?.status === "rejected") {
|
|
334
|
+
for (const { session } of sessions)session.machine.close();
|
|
335
|
+
throw failedSetup.reason;
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
assertNotAborted(input.signal);
|
|
339
|
+
const descriptors = sessions.map(({ target, session, invocationId })=>{
|
|
340
|
+
const header = this.#startHeader(session.context, invocationId, input, false, fanoutId);
|
|
341
|
+
return Object.freeze({
|
|
342
|
+
peerId: target.peerId,
|
|
343
|
+
connectionEpoch: target.connectionEpoch,
|
|
344
|
+
sessionId: session.context.sessionId,
|
|
345
|
+
invocationId,
|
|
346
|
+
ciphertext: encodeBase64url(session.machine.encrypt(buildInvocationAad(header), input.payload)),
|
|
347
|
+
header
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
let dispatched = false;
|
|
351
|
+
let cancelSent = false;
|
|
352
|
+
const cancel = ()=>{
|
|
353
|
+
if (!dispatched || cancelSent) return;
|
|
354
|
+
cancelSent = true;
|
|
355
|
+
for(let index = 0; index < descriptors.length; index += 1){
|
|
356
|
+
void this.#sendCancel(sessions[index].session, descriptors[index].header).catch(()=>undefined);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
input.signal?.addEventListener("abort", cancel, {
|
|
360
|
+
once: true
|
|
361
|
+
});
|
|
362
|
+
try {
|
|
363
|
+
assertNotAborted(input.signal);
|
|
364
|
+
this.#channel.send({
|
|
365
|
+
version: "json.v1",
|
|
366
|
+
type: "invoke.fanout.start",
|
|
367
|
+
frameId: newId("frm"),
|
|
368
|
+
sentAt: Date.now(),
|
|
369
|
+
projectId: this.#input.projectId,
|
|
370
|
+
fanoutId,
|
|
371
|
+
sourcePeerId: this.#input.peerId,
|
|
372
|
+
sourceConnectionEpoch: this.#connected.connectionEpoch,
|
|
373
|
+
capability: input.capability,
|
|
374
|
+
deadlineAt: input.deadlineAt,
|
|
375
|
+
stream: false,
|
|
376
|
+
targets: descriptors.map(({ header: _header, ...descriptor })=>descriptor)
|
|
377
|
+
});
|
|
378
|
+
dispatched = true;
|
|
379
|
+
if (input.signal?.aborted) cancel();
|
|
380
|
+
const terminal = await this.#channel.waitFor((frame)=>frame.type === "invoke.fanout.result" && frame.fanoutId === fanoutId || frame.type === "relay.error" && frame.error.correlationId === fanoutId, remaining(input.deadlineAt), input.signal);
|
|
381
|
+
if (terminal.type === "relay.error") throw relayFailure(terminal);
|
|
382
|
+
if (terminal.snapshotCount !== descriptors.length || terminal.outcomes.some((outcome, index)=>outcome.peerId !== descriptors[index]?.peerId || outcome.connectionEpoch !== descriptors[index]?.connectionEpoch)) throw new BridgePeerError("PROTOCOL_ERROR");
|
|
383
|
+
return terminal.outcomes.map((outcome, index)=>{
|
|
384
|
+
if (outcome.outcome !== "succeeded" || outcome.ciphertext === undefined) {
|
|
385
|
+
return Object.freeze({
|
|
386
|
+
peerId: outcome.peerId,
|
|
387
|
+
status: "rejected",
|
|
388
|
+
error: new BridgePeerError(outcome.bridgeCode)
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
const descriptor = descriptors[index];
|
|
392
|
+
const result = {
|
|
393
|
+
...descriptor.header,
|
|
394
|
+
type: "invoke.result",
|
|
395
|
+
frameId: terminal.frameId,
|
|
396
|
+
sentAt: terminal.sentAt,
|
|
397
|
+
fromPeerId: descriptor.header.toPeerId,
|
|
398
|
+
toPeerId: descriptor.header.fromPeerId,
|
|
399
|
+
fromConnectionEpoch: descriptor.header.toConnectionEpoch,
|
|
400
|
+
toConnectionEpoch: descriptor.header.fromConnectionEpoch,
|
|
401
|
+
outcome: outcome.outcome,
|
|
402
|
+
bridgeCode: outcome.bridgeCode,
|
|
403
|
+
...outcome.applicationStatus === undefined ? {} : {
|
|
404
|
+
applicationStatus: outcome.applicationStatus
|
|
405
|
+
},
|
|
406
|
+
ciphertext: outcome.ciphertext
|
|
407
|
+
};
|
|
408
|
+
try {
|
|
409
|
+
return Object.freeze({
|
|
410
|
+
peerId: outcome.peerId,
|
|
411
|
+
status: "fulfilled",
|
|
412
|
+
payload: sessions[index].session.machine.decrypt(buildInvocationAad(result), decodeBase64url(outcome.ciphertext))
|
|
413
|
+
});
|
|
414
|
+
} catch {
|
|
415
|
+
return Object.freeze({
|
|
416
|
+
peerId: outcome.peerId,
|
|
417
|
+
status: "rejected",
|
|
418
|
+
error: new BridgePeerError("PROTOCOL_ERROR")
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
} finally{
|
|
423
|
+
input.signal?.removeEventListener("abort", cancel);
|
|
424
|
+
}
|
|
425
|
+
} finally{
|
|
426
|
+
for (const { session } of sessions)session.machine.close();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
close() {
|
|
430
|
+
if (this.#closed) return Promise.resolve();
|
|
431
|
+
this.#closed = true;
|
|
432
|
+
if (this.#heartbeatTimer !== undefined) clearTimeout(this.#heartbeatTimer);
|
|
433
|
+
if (this.#renewalTimer !== undefined) clearTimeout(this.#renewalTimer);
|
|
434
|
+
for (const timer of this.#exposureReplacements.values())clearTimeout(timer);
|
|
435
|
+
this.#exposureReplacements.clear();
|
|
436
|
+
this.#releaseIncoming();
|
|
437
|
+
this.#exposures.clear();
|
|
438
|
+
this.#channel.close();
|
|
439
|
+
return Promise.resolve();
|
|
440
|
+
}
|
|
441
|
+
async #openSession(input, stream, expectedTargetEpoch, signal) {
|
|
442
|
+
assertNotAborted(signal);
|
|
443
|
+
const sessionId = newId("ses");
|
|
444
|
+
this.#channel.send({
|
|
445
|
+
version: "json.v1",
|
|
446
|
+
type: "session.open",
|
|
447
|
+
frameId: newId("frm"),
|
|
448
|
+
sentAt: Date.now(),
|
|
449
|
+
projectId: this.#input.projectId,
|
|
450
|
+
sessionId,
|
|
451
|
+
fromPeerId: this.#input.peerId,
|
|
452
|
+
toPeerId: input.targetPeer,
|
|
453
|
+
fromConnectionEpoch: this.#connected.connectionEpoch,
|
|
454
|
+
capability: input.capability,
|
|
455
|
+
stream
|
|
456
|
+
});
|
|
457
|
+
const ready = await this.#channel.waitFor((frame)=>frame.type === "session.ready" && frame.context.sessionId === sessionId || frame.type === "relay.error" && frame.error.correlationId === sessionId, remaining(input.deadlineAt), signal);
|
|
458
|
+
if (ready.type === "relay.error") throw relayFailure(ready);
|
|
459
|
+
const remote = parseRelayGrantClaims(ready.remoteGrant);
|
|
460
|
+
if (remote.projectId !== this.#input.projectId || remote.peerId !== input.targetPeer || remote.expiresAt <= Math.floor(Date.now() / 1_000) || !permitsCapability(remote, "expose", input.capability) || ready.context.projectId !== this.#input.projectId || ready.context.initiatorPeerId !== this.#input.peerId || ready.context.initiatorConnectionEpoch !== this.#connected.connectionEpoch || ready.capability !== input.capability || ready.stream !== stream || expectedTargetEpoch !== undefined && ready.context.responderConnectionEpoch !== expectedTargetEpoch) throw new BridgePeerError("PROTOCOL_ERROR");
|
|
461
|
+
const machine = this.#identity.createInitiator({
|
|
462
|
+
prologue: buildSessionPrologue(ready.context),
|
|
463
|
+
responderPublicKey: decodeBase64url(remote.keys.noiseStatic.x)
|
|
464
|
+
});
|
|
465
|
+
try {
|
|
466
|
+
assertNotAborted(signal);
|
|
467
|
+
this.#channel.send({
|
|
468
|
+
version: "json.v1",
|
|
469
|
+
type: "session.handshake",
|
|
470
|
+
frameId: newId("frm"),
|
|
471
|
+
sentAt: Date.now(),
|
|
472
|
+
context: ready.context,
|
|
473
|
+
step: 1,
|
|
474
|
+
message: encodeBase64url(machine.beginHandshake())
|
|
475
|
+
});
|
|
476
|
+
const second = await this.#channel.waitFor((frame)=>frame.type === "session.handshake" && frame.step === 2 && frame.context.sessionId === sessionId, remaining(input.deadlineAt), signal);
|
|
477
|
+
machine.finishHandshake(decodeBase64url(second.message));
|
|
478
|
+
assertNotAborted(signal);
|
|
479
|
+
return Object.freeze({
|
|
480
|
+
context: ready.context,
|
|
481
|
+
machine
|
|
482
|
+
});
|
|
483
|
+
} catch (error) {
|
|
484
|
+
machine.close();
|
|
485
|
+
throw error;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
#startHeader(context, invocationId, input, stream, fanoutId) {
|
|
489
|
+
return {
|
|
490
|
+
version: "json.v1",
|
|
491
|
+
type: "invoke.start",
|
|
492
|
+
frameId: newId("frm"),
|
|
493
|
+
sentAt: Date.now(),
|
|
494
|
+
projectId: this.#input.projectId,
|
|
495
|
+
sessionId: context.sessionId,
|
|
496
|
+
invocationId,
|
|
497
|
+
...fanoutId === undefined ? {} : {
|
|
498
|
+
fanoutId
|
|
499
|
+
},
|
|
500
|
+
fromPeerId: this.#input.peerId,
|
|
501
|
+
toPeerId: context.responderPeerId,
|
|
502
|
+
fromConnectionEpoch: context.initiatorConnectionEpoch,
|
|
503
|
+
toConnectionEpoch: context.responderConnectionEpoch,
|
|
504
|
+
capability: input.capability,
|
|
505
|
+
deadlineAt: input.deadlineAt,
|
|
506
|
+
stream,
|
|
507
|
+
ciphertext: "YQ"
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
#sendCancel(session, start) {
|
|
511
|
+
const header = {
|
|
512
|
+
...start,
|
|
513
|
+
type: "invoke.cancel",
|
|
514
|
+
frameId: newId("frm"),
|
|
515
|
+
sentAt: Date.now(),
|
|
516
|
+
ciphertext: "YQ"
|
|
517
|
+
};
|
|
518
|
+
const ciphertext = encodeBase64url(session.machine.encrypt(buildInvocationAad(header), new Uint8Array()));
|
|
519
|
+
this.#channel.send({
|
|
520
|
+
...header,
|
|
521
|
+
ciphertext
|
|
522
|
+
});
|
|
523
|
+
return Promise.resolve();
|
|
524
|
+
}
|
|
525
|
+
#sendStreamAck(machine, frame, ackSequence) {
|
|
526
|
+
const header = {
|
|
527
|
+
version: "json.v1",
|
|
528
|
+
type: "invoke.stream.ack",
|
|
529
|
+
frameId: newId("frm"),
|
|
530
|
+
sentAt: Date.now(),
|
|
531
|
+
projectId: frame.projectId,
|
|
532
|
+
sessionId: frame.sessionId,
|
|
533
|
+
invocationId: frame.invocationId,
|
|
534
|
+
fromPeerId: frame.toPeerId,
|
|
535
|
+
toPeerId: frame.fromPeerId,
|
|
536
|
+
fromConnectionEpoch: frame.toConnectionEpoch,
|
|
537
|
+
toConnectionEpoch: frame.fromConnectionEpoch,
|
|
538
|
+
capability: frame.capability,
|
|
539
|
+
deadlineAt: frame.deadlineAt,
|
|
540
|
+
stream: true,
|
|
541
|
+
ackSequence,
|
|
542
|
+
ciphertext: "YQ"
|
|
543
|
+
};
|
|
544
|
+
this.#channel.send({
|
|
545
|
+
...header,
|
|
546
|
+
ciphertext: encodeBase64url(machine.encrypt(buildInvocationAad(header), new Uint8Array()))
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
#handleUnhandled(frame) {
|
|
550
|
+
if (frame.type === "session.incoming") {
|
|
551
|
+
void this.#handleIncoming(frame).catch(()=>undefined);
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
if (frame.type === "relay.error" && this.#exposureReplacements.has(frame.error.correlationId)) {
|
|
555
|
+
const timer = this.#exposureReplacements.get(frame.error.correlationId);
|
|
556
|
+
clearTimeout(timer);
|
|
557
|
+
this.#exposureReplacements.delete(frame.error.correlationId);
|
|
558
|
+
this.#channel.close();
|
|
559
|
+
this.#notifyDisconnect();
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
async #handleIncoming(incoming) {
|
|
565
|
+
const exposure = this.#exposures.get(incoming.capability);
|
|
566
|
+
if (exposure === undefined || exposure.stream !== incoming.stream) return;
|
|
567
|
+
const remote = parseRelayGrantClaims(incoming.remoteGrant);
|
|
568
|
+
if (remote.projectId !== this.#input.projectId || remote.peerId !== incoming.context.initiatorPeerId || remote.expiresAt <= Math.floor(Date.now() / 1_000) || incoming.context.projectId !== this.#input.projectId || incoming.context.responderPeerId !== this.#input.peerId || incoming.context.responderConnectionEpoch !== this.#connected.connectionEpoch || !permitsCapability(remote, "invoke", incoming.capability)) throw new BridgePeerError("PROTOCOL_ERROR");
|
|
569
|
+
const machine = this.#identity.createResponder({
|
|
570
|
+
prologue: buildSessionPrologue(incoming.context),
|
|
571
|
+
initiatorPublicKey: decodeBase64url(remote.keys.noiseStatic.x)
|
|
572
|
+
});
|
|
573
|
+
this.#activeResponders.add(machine);
|
|
574
|
+
try {
|
|
575
|
+
const first = await this.#channel.waitFor((frame)=>frame.type === "session.handshake" && frame.step === 1 && frame.context.sessionId === incoming.context.sessionId, this.#timeoutMs);
|
|
576
|
+
const second = machine.acceptHandshake(decodeBase64url(first.message));
|
|
577
|
+
this.#channel.send({
|
|
578
|
+
version: "json.v1",
|
|
579
|
+
type: "session.handshake",
|
|
580
|
+
frameId: newId("frm"),
|
|
581
|
+
sentAt: Date.now(),
|
|
582
|
+
context: incoming.context,
|
|
583
|
+
step: 2,
|
|
584
|
+
message: encodeBase64url(second)
|
|
585
|
+
});
|
|
586
|
+
const start = await this.#channel.waitFor((frame)=>frame.type === "invoke.start" && frame.sessionId === incoming.context.sessionId, this.#timeoutMs);
|
|
587
|
+
if (start.stream) {
|
|
588
|
+
await this.#handleIncomingStream(machine, exposure, start);
|
|
589
|
+
} else await this.#handleIncomingUnary(machine, exposure, start);
|
|
590
|
+
} finally{
|
|
591
|
+
this.#activeResponders.delete(machine);
|
|
592
|
+
machine.close();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
async #handleIncomingUnary(machine, exposure, start) {
|
|
596
|
+
const request = machine.decrypt(buildInvocationAad(start), decodeBase64url(start.ciphertext));
|
|
597
|
+
const controller = new AbortController();
|
|
598
|
+
this.#activeIncomingControllers.add(controller);
|
|
599
|
+
const cancelWait = this.#watchCancellation(machine, start, controller);
|
|
600
|
+
let outcome = "succeeded";
|
|
601
|
+
let bridgeCode = "OK";
|
|
602
|
+
let response = new Uint8Array();
|
|
603
|
+
try {
|
|
604
|
+
const handled = await exposure.handle(request, controller.signal);
|
|
605
|
+
if (!(handled instanceof Uint8Array)) throw new BridgePeerError("EXPOSER_ERROR");
|
|
606
|
+
response = handled;
|
|
607
|
+
} catch (error) {
|
|
608
|
+
if (controller.signal.aborted) {
|
|
609
|
+
outcome = "cancelled";
|
|
610
|
+
bridgeCode = "CANCELLED";
|
|
611
|
+
} else {
|
|
612
|
+
outcome = "failed";
|
|
613
|
+
bridgeCode = error instanceof BridgePeerError && bridgeCodes.has(error.code) ? error.code : "EXPOSER_ERROR";
|
|
614
|
+
}
|
|
615
|
+
} finally{
|
|
616
|
+
cancelWait.abort();
|
|
617
|
+
this.#activeIncomingControllers.delete(controller);
|
|
618
|
+
}
|
|
619
|
+
const header = {
|
|
620
|
+
...start,
|
|
621
|
+
type: "invoke.result",
|
|
622
|
+
frameId: newId("frm"),
|
|
623
|
+
sentAt: Date.now(),
|
|
624
|
+
fromPeerId: start.toPeerId,
|
|
625
|
+
toPeerId: start.fromPeerId,
|
|
626
|
+
fromConnectionEpoch: start.toConnectionEpoch,
|
|
627
|
+
toConnectionEpoch: start.fromConnectionEpoch,
|
|
628
|
+
outcome,
|
|
629
|
+
bridgeCode,
|
|
630
|
+
ciphertext: "YQ"
|
|
631
|
+
};
|
|
632
|
+
const ciphertext = encodeBase64url(machine.encrypt(buildInvocationAad(header), response));
|
|
633
|
+
this.#channel.send({
|
|
634
|
+
...header,
|
|
635
|
+
ciphertext
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
async #handleIncomingStream(machine, exposure, start) {
|
|
639
|
+
const request = machine.decrypt(buildInvocationAad(start), decodeBase64url(start.ciphertext));
|
|
640
|
+
const controller = new AbortController();
|
|
641
|
+
this.#activeIncomingControllers.add(controller);
|
|
642
|
+
const cancelWait = this.#watchCancellation(machine, start, controller);
|
|
643
|
+
let sequence = 0;
|
|
644
|
+
let reader;
|
|
645
|
+
let outcome = "succeeded";
|
|
646
|
+
let bridgeCode = "OK";
|
|
647
|
+
try {
|
|
648
|
+
const handled = await exposure.handle(request, controller.signal);
|
|
649
|
+
if (!(handled instanceof Response)) throw new BridgePeerError("EXPOSER_ERROR");
|
|
650
|
+
sequence += 1;
|
|
651
|
+
const open = this.#streamResponseFrame(start, "invoke.stream.open", sequence);
|
|
652
|
+
this.#channel.send({
|
|
653
|
+
...open,
|
|
654
|
+
ciphertext: encodeBase64url(machine.encrypt(buildInvocationAad(open), encodeResponseMetadata(handled)))
|
|
655
|
+
});
|
|
656
|
+
await this.#waitForStreamAck(machine, start.invocationId, sequence, controller.signal);
|
|
657
|
+
if (handled.body !== null) {
|
|
658
|
+
reader = handled.body.getReader();
|
|
659
|
+
this.#activeIncomingReaders.add(reader);
|
|
660
|
+
while(true){
|
|
661
|
+
const read = await reader.read();
|
|
662
|
+
if (read.done) break;
|
|
663
|
+
for (const chunk of streamChunks(read.value)){
|
|
664
|
+
if (controller.signal.aborted) throw new BridgePeerError("CANCELLED");
|
|
665
|
+
sequence += 1;
|
|
666
|
+
const frame = this.#streamResponseFrame(start, "invoke.stream.chunk", sequence);
|
|
667
|
+
this.#channel.send({
|
|
668
|
+
...frame,
|
|
669
|
+
ciphertext: encodeBase64url(machine.encrypt(buildInvocationAad(frame), chunk))
|
|
670
|
+
});
|
|
671
|
+
await this.#waitForStreamAck(machine, start.invocationId, sequence, controller.signal);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
} catch (error) {
|
|
676
|
+
if (controller.signal.aborted) {
|
|
677
|
+
outcome = "cancelled";
|
|
678
|
+
bridgeCode = "CANCELLED";
|
|
679
|
+
} else {
|
|
680
|
+
outcome = "failed";
|
|
681
|
+
bridgeCode = error instanceof BridgePeerError && bridgeCodes.has(error.code) ? error.code : "EXPOSER_ERROR";
|
|
682
|
+
}
|
|
683
|
+
await reader?.cancel().catch(()=>undefined);
|
|
684
|
+
} finally{
|
|
685
|
+
cancelWait.abort();
|
|
686
|
+
this.#activeIncomingControllers.delete(controller);
|
|
687
|
+
if (reader !== undefined) this.#activeIncomingReaders.delete(reader);
|
|
688
|
+
}
|
|
689
|
+
sequence += 1;
|
|
690
|
+
const end = this.#streamEndFrame(start, sequence, outcome, bridgeCode);
|
|
691
|
+
this.#channel.send({
|
|
692
|
+
...end,
|
|
693
|
+
ciphertext: encodeBase64url(machine.encrypt(buildInvocationAad(end), new Uint8Array()))
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
#streamResponseFrame(start, type, sequence) {
|
|
697
|
+
return {
|
|
698
|
+
version: "json.v1",
|
|
699
|
+
type,
|
|
700
|
+
frameId: newId("frm"),
|
|
701
|
+
sentAt: Date.now(),
|
|
702
|
+
projectId: start.projectId,
|
|
703
|
+
sessionId: start.sessionId,
|
|
704
|
+
invocationId: start.invocationId,
|
|
705
|
+
fromPeerId: start.toPeerId,
|
|
706
|
+
toPeerId: start.fromPeerId,
|
|
707
|
+
fromConnectionEpoch: start.toConnectionEpoch,
|
|
708
|
+
toConnectionEpoch: start.fromConnectionEpoch,
|
|
709
|
+
capability: start.capability,
|
|
710
|
+
deadlineAt: start.deadlineAt,
|
|
711
|
+
stream: true,
|
|
712
|
+
sequence,
|
|
713
|
+
ciphertext: "YQ"
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
#streamEndFrame(start, sequence, outcome, bridgeCode) {
|
|
717
|
+
return {
|
|
718
|
+
version: "json.v1",
|
|
719
|
+
type: "invoke.stream.end",
|
|
720
|
+
frameId: newId("frm"),
|
|
721
|
+
sentAt: Date.now(),
|
|
722
|
+
projectId: start.projectId,
|
|
723
|
+
sessionId: start.sessionId,
|
|
724
|
+
invocationId: start.invocationId,
|
|
725
|
+
fromPeerId: start.toPeerId,
|
|
726
|
+
toPeerId: start.fromPeerId,
|
|
727
|
+
fromConnectionEpoch: start.toConnectionEpoch,
|
|
728
|
+
toConnectionEpoch: start.fromConnectionEpoch,
|
|
729
|
+
capability: start.capability,
|
|
730
|
+
deadlineAt: start.deadlineAt,
|
|
731
|
+
stream: true,
|
|
732
|
+
sequence,
|
|
733
|
+
outcome,
|
|
734
|
+
bridgeCode,
|
|
735
|
+
ciphertext: "YQ"
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
async #waitForStreamAck(machine, invocationId, sequence, signal) {
|
|
739
|
+
const ack = await this.#channel.waitFor((frame)=>frame.type === "invoke.stream.ack" && frame.invocationId === invocationId && frame.ackSequence === sequence, this.#timeoutMs, signal);
|
|
740
|
+
const plaintext = machine.decrypt(buildInvocationAad(ack), decodeBase64url(ack.ciphertext));
|
|
741
|
+
if (plaintext.length !== 0) throw new BridgePeerError("PROTOCOL_ERROR");
|
|
742
|
+
}
|
|
743
|
+
#watchCancellation(machine, start, controller) {
|
|
744
|
+
const stop = new AbortController();
|
|
745
|
+
void this.#channel.waitFor((frame)=>frame.type === "invoke.cancel" && frame.invocationId === start.invocationId, remaining(start.deadlineAt), stop.signal).then((frame)=>{
|
|
746
|
+
machine.decrypt(buildInvocationAad(frame), decodeBase64url(frame.ciphertext));
|
|
747
|
+
controller.abort();
|
|
748
|
+
}).catch(()=>undefined);
|
|
749
|
+
return stop;
|
|
750
|
+
}
|
|
751
|
+
#scheduleHeartbeat() {
|
|
752
|
+
const run = async ()=>{
|
|
753
|
+
if (this.#closed) return;
|
|
754
|
+
const sequence = ++this.#heartbeatSequence;
|
|
755
|
+
try {
|
|
756
|
+
this.#channel.send({
|
|
757
|
+
version: "json.v1",
|
|
758
|
+
type: "heartbeat.request",
|
|
759
|
+
frameId: newId("frm"),
|
|
760
|
+
sentAt: Date.now(),
|
|
761
|
+
connectionId: this.#connected.connectionId,
|
|
762
|
+
connectionEpoch: this.#connected.connectionEpoch,
|
|
763
|
+
sequence
|
|
764
|
+
});
|
|
765
|
+
await this.#channel.waitFor((frame)=>frame.type === "heartbeat.response" && frame.sequence === sequence && frame.connectionId === this.#connected.connectionId, this.#connected.heartbeatIntervalMs);
|
|
766
|
+
} catch {
|
|
767
|
+
this.#channel.close();
|
|
768
|
+
this.#notifyDisconnect();
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
this.#heartbeatTimer = setTimeout(run, this.#connected.heartbeatIntervalMs);
|
|
772
|
+
};
|
|
773
|
+
this.#heartbeatTimer = setTimeout(run, this.#connected.heartbeatIntervalMs);
|
|
774
|
+
}
|
|
775
|
+
#scheduleRenewal() {
|
|
776
|
+
if (this.#renewalTimer !== undefined) clearTimeout(this.#renewalTimer);
|
|
777
|
+
for (const timer of this.#exposureReplacements.values())clearTimeout(timer);
|
|
778
|
+
this.#exposureReplacements.clear();
|
|
779
|
+
const now = Math.floor(Date.now() / 1_000);
|
|
780
|
+
const lifetime = Math.max(1, this.#grant.claims.expiresAt - now);
|
|
781
|
+
const advance = Math.min(15, Math.max(1, Math.floor(lifetime * 0.25)));
|
|
782
|
+
const delay = Math.max(1, (this.#grant.claims.expiresAt - advance - now) * 1_000);
|
|
783
|
+
this.#renewalTimer = setTimeout(()=>void this.#renew(), delay);
|
|
784
|
+
}
|
|
785
|
+
async #renew() {
|
|
786
|
+
if (this.#closed) return;
|
|
787
|
+
try {
|
|
788
|
+
await this.#renewGrant();
|
|
789
|
+
} catch {
|
|
790
|
+
const remainingMs = this.#grant.claims.expiresAt * 1_000 - Date.now();
|
|
791
|
+
if (remainingMs <= 1_000) {
|
|
792
|
+
this.#channel.close();
|
|
793
|
+
this.#notifyDisconnect();
|
|
794
|
+
} else {
|
|
795
|
+
this.#renewalTimer = setTimeout(()=>void this.#renew(), Math.min(1_000, remainingMs - 500));
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
async #renewGrant() {
|
|
800
|
+
this.#renewalInFlight ??= this.#performRenewal().finally(()=>{
|
|
801
|
+
this.#renewalInFlight = undefined;
|
|
802
|
+
});
|
|
803
|
+
return await this.#renewalInFlight;
|
|
804
|
+
}
|
|
805
|
+
async #performRenewal() {
|
|
806
|
+
if (this.#closed) throw new BridgePeerError("CONNECTION_LOST");
|
|
807
|
+
const next = await obtainGrant(this.#input, this.#identity.publicIdentity);
|
|
808
|
+
const frameId = newId("frm");
|
|
809
|
+
this.#channel.send({
|
|
810
|
+
version: "json.v1",
|
|
811
|
+
type: "peer.renew",
|
|
812
|
+
frameId,
|
|
813
|
+
sentAt: Date.now(),
|
|
814
|
+
connectionId: this.#connected.connectionId,
|
|
815
|
+
projectId: this.#input.projectId,
|
|
816
|
+
peerId: this.#input.peerId,
|
|
817
|
+
connectionEpoch: this.#connected.connectionEpoch,
|
|
818
|
+
grant: next.compact
|
|
819
|
+
});
|
|
820
|
+
const result = await this.#channel.waitFor((frame)=>frame.type === "peer.renewed" && frame.connectionId === this.#connected.connectionId || frame.type === "relay.error" && frame.error.correlationId === frameId, this.#timeoutMs);
|
|
821
|
+
if (result.type === "relay.error") throw relayFailure(result);
|
|
822
|
+
if (result.grantExpiresAt !== next.claims.expiresAt) {
|
|
823
|
+
throw new BridgePeerError("GRANT_FAILED");
|
|
824
|
+
}
|
|
825
|
+
this.#grant = next;
|
|
826
|
+
this.#scheduleRenewal();
|
|
827
|
+
}
|
|
828
|
+
#notifyDisconnect() {
|
|
829
|
+
if (this.#closed || this.#disconnectNotified) return;
|
|
830
|
+
this.#disconnectNotified = true;
|
|
831
|
+
if (this.#heartbeatTimer !== undefined) clearTimeout(this.#heartbeatTimer);
|
|
832
|
+
if (this.#renewalTimer !== undefined) clearTimeout(this.#renewalTimer);
|
|
833
|
+
this.#releaseIncoming();
|
|
834
|
+
this.#input.onDisconnect(new BridgePeerError("CONNECTION_LOST", true));
|
|
835
|
+
}
|
|
836
|
+
#releaseIncoming() {
|
|
837
|
+
for (const controller of this.#activeIncomingControllers)controller.abort();
|
|
838
|
+
this.#activeIncomingControllers.clear();
|
|
839
|
+
for (const reader of this.#activeIncomingReaders){
|
|
840
|
+
void reader.cancel().catch(()=>undefined);
|
|
841
|
+
}
|
|
842
|
+
this.#activeIncomingReaders.clear();
|
|
843
|
+
for (const machine of this.#activeResponders)machine.close();
|
|
844
|
+
this.#activeResponders.clear();
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
/** Creates the shared direct-WebSocket relay runtime around one platform identity. */ export const createRelayRuntime = (options)=>{
|
|
848
|
+
const timeoutMs = options.operationTimeoutMs ?? 15_000;
|
|
849
|
+
if (!/^wss?:\/\//.test(options.relayUrl) || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new BridgePeerError("INVALID_CONFIGURATION");
|
|
850
|
+
const factory = options.webSocketFactory ?? ((url)=>new WebSocket(url));
|
|
851
|
+
return Object.freeze({
|
|
852
|
+
connect: async (input)=>{
|
|
853
|
+
const identity = await abortableConnect(options.identityProvider.load(input.projectId, input.peerId), input.signal);
|
|
854
|
+
const publicIdentity = identity.publicIdentity;
|
|
855
|
+
const grant = await abortableConnect(obtainGrant(input, publicIdentity), input.signal);
|
|
856
|
+
const channel = new RelayFrameChannel(options.relayUrl, factory);
|
|
857
|
+
try {
|
|
858
|
+
const challenge = await channel.waitFor((frame)=>frame.type === "relay.challenge", timeoutMs, input.signal);
|
|
859
|
+
const proof = await identity.signConnectionProof({
|
|
860
|
+
projectId: input.projectId,
|
|
861
|
+
peerId: input.peerId,
|
|
862
|
+
connectionId: challenge.connectionId,
|
|
863
|
+
challenge: challenge.challenge,
|
|
864
|
+
challengeExpiresAt: challenge.expiresAt,
|
|
865
|
+
grant: grant.compact
|
|
866
|
+
});
|
|
867
|
+
const frameId = newId("frm");
|
|
868
|
+
channel.send({
|
|
869
|
+
version: "json.v1",
|
|
870
|
+
type: "peer.connect",
|
|
871
|
+
frameId,
|
|
872
|
+
sentAt: Date.now(),
|
|
873
|
+
connectionId: challenge.connectionId,
|
|
874
|
+
projectId: input.projectId,
|
|
875
|
+
peerId: input.peerId,
|
|
876
|
+
grant: grant.compact,
|
|
877
|
+
proof,
|
|
878
|
+
supportedCodecs: [
|
|
879
|
+
"json.v1"
|
|
880
|
+
]
|
|
881
|
+
});
|
|
882
|
+
const accepted = await channel.waitFor((frame)=>frame.type === "peer.connected" || frame.type === "relay.error", timeoutMs, input.signal);
|
|
883
|
+
if (accepted.type === "relay.error") throw relayFailure(accepted);
|
|
884
|
+
if (accepted.connectionId !== challenge.connectionId || accepted.projectId !== input.projectId || accepted.peerId !== input.peerId) throw new BridgePeerError("PROTOCOL_ERROR");
|
|
885
|
+
return new RelayConnection({
|
|
886
|
+
request: input,
|
|
887
|
+
channel,
|
|
888
|
+
connected: accepted,
|
|
889
|
+
identity,
|
|
890
|
+
grant,
|
|
891
|
+
timeoutMs
|
|
892
|
+
});
|
|
893
|
+
} catch (error) {
|
|
894
|
+
channel.close();
|
|
895
|
+
if (error instanceof BridgePeerError) throw error;
|
|
896
|
+
throw new BridgePeerError("TRANSPORT_ERROR");
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
};
|