@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
|
Binary file
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Application codec kept above the byte-only E2EE provider. */ export type BridgeCodec<T = unknown> = Readonly<{
|
|
2
|
+
encode(value: T): Uint8Array;
|
|
3
|
+
decode(value: Uint8Array): T;
|
|
4
|
+
}>;
|
|
5
|
+
/** Strict default JSON codec with sanitized encode and decode failures. */ export declare const bridgeJsonCodec: BridgeCodec;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { BridgePeerError } from "./errors.js";
|
|
2
|
+
const assertJsonNative = (value, seen)=>{
|
|
3
|
+
if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return;
|
|
4
|
+
if (typeof value !== "object") throw new TypeError("unsupported_json_value");
|
|
5
|
+
if (seen.has(value)) throw new TypeError("cyclic_json_value");
|
|
6
|
+
seen.add(value);
|
|
7
|
+
try {
|
|
8
|
+
if (Array.isArray(value)) {
|
|
9
|
+
if (Object.keys(value).length !== value.length) {
|
|
10
|
+
throw new TypeError("sparse_json_array");
|
|
11
|
+
}
|
|
12
|
+
for (const entry of value)assertJsonNative(entry, seen);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const prototype = Object.getPrototypeOf(value);
|
|
16
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
17
|
+
throw new TypeError("non_plain_json_object");
|
|
18
|
+
}
|
|
19
|
+
for (const entry of Object.values(value)){
|
|
20
|
+
assertJsonNative(entry, seen);
|
|
21
|
+
}
|
|
22
|
+
} finally{
|
|
23
|
+
seen.delete(value);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/** Strict default JSON codec with sanitized encode and decode failures. */ export const bridgeJsonCodec = Object.freeze({
|
|
27
|
+
encode (value) {
|
|
28
|
+
try {
|
|
29
|
+
assertJsonNative(value, new WeakSet());
|
|
30
|
+
const json = JSON.stringify(value);
|
|
31
|
+
if (json === undefined) throw new TypeError("unsupported_json_value");
|
|
32
|
+
return new TextEncoder().encode(json);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new BridgePeerError("CODEC_ERROR");
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
decode (value) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(new TextDecoder("utf-8", {
|
|
40
|
+
fatal: true
|
|
41
|
+
}).decode(value));
|
|
42
|
+
} catch {
|
|
43
|
+
throw new BridgePeerError("CODEC_ERROR");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { BridgeCode } from "@genation/bridge-contracts";
|
|
2
|
+
/** Stable peer-local or relay-owned error categories exposed by the SDK facade. */ export type BridgePeerErrorCode = BridgeCode | "INVALID_CONFIGURATION" | "PEER_OFFLINE" | "PEER_CLOSED" | "DUPLICATE_CAPABILITY" | "CODEC_ERROR" | "KEYSTORE_ERROR" | "GRANT_FAILED" | "TRANSPORT_ERROR";
|
|
3
|
+
/** Sanitized application-facing error without customer content or provider details. */ export declare class BridgePeerError extends Error {
|
|
4
|
+
/** Stable machine-readable SDK or Bridge code. */ readonly code: BridgePeerErrorCode;
|
|
5
|
+
/** Whether an application may consider an explicit retry. */ readonly retryable: boolean;
|
|
6
|
+
/** Creates one sanitized peer error. */ constructor(code: BridgePeerErrorCode, retryable?: boolean);
|
|
7
|
+
/** Returns the only fields intended for public diagnostics. */ toJSON(): Readonly<{
|
|
8
|
+
code: BridgePeerErrorCode;
|
|
9
|
+
retryable: boolean;
|
|
10
|
+
}>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const localMessages = Object.freeze({
|
|
2
|
+
INVALID_CONFIGURATION: "Invalid peer configuration",
|
|
3
|
+
PEER_OFFLINE: "Peer is not connected",
|
|
4
|
+
PEER_CLOSED: "Peer is closed",
|
|
5
|
+
DUPLICATE_CAPABILITY: "Capability is already exposed",
|
|
6
|
+
CODEC_ERROR: "Codec failed",
|
|
7
|
+
KEYSTORE_ERROR: "Peer identity storage failed",
|
|
8
|
+
GRANT_FAILED: "Grant acquisition failed",
|
|
9
|
+
TRANSPORT_ERROR: "Peer transport failed"
|
|
10
|
+
});
|
|
11
|
+
const bridgeMessage = (code)=>code.split("_").map((part)=>part[0] + part.slice(1).toLowerCase()).join(" ");
|
|
12
|
+
/** Sanitized application-facing error without customer content or provider details. */ export class BridgePeerError extends Error {
|
|
13
|
+
/** Stable machine-readable SDK or Bridge code. */ code;
|
|
14
|
+
/** Whether an application may consider an explicit retry. */ retryable;
|
|
15
|
+
/** Creates one sanitized peer error. */ constructor(code, retryable = false){
|
|
16
|
+
super(code in localMessages ? localMessages[code] : bridgeMessage(code));
|
|
17
|
+
this.name = "BridgePeerError";
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.retryable = retryable;
|
|
20
|
+
}
|
|
21
|
+
/** Returns the only fields intended for public diagnostics. */ toJSON() {
|
|
22
|
+
return Object.freeze({
|
|
23
|
+
code: this.code,
|
|
24
|
+
retryable: this.retryable
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** SDK-owned long-term private identity for one exact project and peer pair. */ export type BridgePeerIdentity = Readonly<{
|
|
2
|
+
connectionPrivateKey: Readonly<{
|
|
3
|
+
kty: "OKP";
|
|
4
|
+
crv: "Ed25519";
|
|
5
|
+
x: string;
|
|
6
|
+
d: string;
|
|
7
|
+
}>;
|
|
8
|
+
noiseStaticPrivateKey: string;
|
|
9
|
+
}>;
|
|
10
|
+
/** Atomic platform-neutral identity storage contract used by peer adapters. */ export type BridgePeerKeyStore = Readonly<{
|
|
11
|
+
loadOrCreate(input: Readonly<{
|
|
12
|
+
projectId: string;
|
|
13
|
+
peerId: string;
|
|
14
|
+
create(): Promise<BridgePeerIdentity>;
|
|
15
|
+
}>): Promise<BridgePeerIdentity>;
|
|
16
|
+
}>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/** SDK-owned long-term private identity for one exact project and peer pair. */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/** Internal platform activity source used to suspend live mobile resources. */
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { type BridgeCodec, bridgeJsonCodec } from "./codec.js";
|
|
2
|
+
export { BridgePeerError, type BridgePeerErrorCode } from "./errors.js";
|
|
3
|
+
export { type BridgePeerIdentity, type BridgePeerKeyStore } from "./identity.js";
|
|
4
|
+
export { type BridgePeerPlatformLifecycle } from "./lifecycle.js";
|
|
5
|
+
export { type BridgeExposureContext, type BridgeExposureOptions, type BridgeExposureRegistration, type BridgeFanoutInvocationOptions, type BridgeFanoutResult, type BridgePeer, type BridgePeerConnectResult, type BridgePeerCoreOptions, type BridgePeerReconnectOptions, type BridgePeerState, type BridgeTargetInvocationOptions, createBridgePeerCore } from "./peer.js";
|
|
6
|
+
export { type BridgeGrantProvider, type BridgeGrantProviderInput, type BridgeGrantProviderResult, type BridgePeerPublicIdentity, type BridgePeerRuntime, type BridgePeerRuntimeConnectInput, type BridgePeerRuntimeConnection, type RuntimeExposure, type RuntimeFanoutInput, type RuntimeFanoutResult, type RuntimeInvocationInput, type RuntimeRelayFailure } from "./runtime.js";
|
package/src/core/mod.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type BridgeCodec } from "./codec.js";
|
|
2
|
+
import { BridgePeerError } from "./errors.js";
|
|
3
|
+
import type { BridgePeerPlatformLifecycle } from "./lifecycle.js";
|
|
4
|
+
import type { BridgeGrantProvider, BridgePeerRuntime } from "./runtime.js";
|
|
5
|
+
/** Stable observable state of one logical peer object. */ export type BridgePeerState = "idle" | "connecting" | "connected" | "reconnecting" | "suspended" | "closed";
|
|
6
|
+
/** Simple result returned by idempotent connection intent. */ export type BridgePeerConnectResult = Readonly<{
|
|
7
|
+
status: "connected" | "connecting" | "reconnecting";
|
|
8
|
+
}>;
|
|
9
|
+
/** Tunable bounded reconnect behavior, primarily for deterministic adapters and tests. */ export type BridgePeerReconnectOptions = Readonly<{
|
|
10
|
+
maxAttempts?: number;
|
|
11
|
+
delay?: (attempt: number) => Promise<void>;
|
|
12
|
+
}>;
|
|
13
|
+
/** Shared peer options independent of Node.js or React Native adapters. */ export type BridgePeerCoreOptions = Readonly<{
|
|
14
|
+
projectId: string;
|
|
15
|
+
peerId: string;
|
|
16
|
+
grantProvider: BridgeGrantProvider;
|
|
17
|
+
requestedInvokeCapabilities?: readonly string[];
|
|
18
|
+
codec?: BridgeCodec;
|
|
19
|
+
reconnect?: BridgePeerReconnectOptions;
|
|
20
|
+
lifecycle?: BridgePeerPlatformLifecycle;
|
|
21
|
+
}>;
|
|
22
|
+
/** One capability handler registration retained as desired state until disposal. */ export type BridgeExposureRegistration = Readonly<{
|
|
23
|
+
dispose(): Promise<void>;
|
|
24
|
+
}>;
|
|
25
|
+
/** Context provided to one application exposure handler. */ export type BridgeExposureContext = Readonly<{
|
|
26
|
+
signal: AbortSignal;
|
|
27
|
+
}>;
|
|
28
|
+
/** Exposure options selecting response mode and an optional application codec. */ export type BridgeExposureOptions<TInput = unknown, TOutput = unknown> = Readonly<{
|
|
29
|
+
stream?: boolean;
|
|
30
|
+
codec?: BridgeCodec<TInput | TOutput>;
|
|
31
|
+
}>;
|
|
32
|
+
/** Invocation options for one explicit target and optional response stream. */ export type BridgeTargetInvocationOptions = Readonly<{
|
|
33
|
+
targetPeer: string;
|
|
34
|
+
stream?: boolean;
|
|
35
|
+
deadlineAt?: number;
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
codec?: BridgeCodec;
|
|
38
|
+
}>;
|
|
39
|
+
/** Invocation options for one target-less unary fanout. */ export type BridgeFanoutInvocationOptions = Readonly<{
|
|
40
|
+
deadlineAt?: number;
|
|
41
|
+
signal?: AbortSignal;
|
|
42
|
+
codec?: BridgeCodec;
|
|
43
|
+
}>;
|
|
44
|
+
/** One application-facing all-settled fanout entry. */ export type BridgeFanoutResult<T = unknown> = Readonly<{
|
|
45
|
+
peerId: string;
|
|
46
|
+
status: "fulfilled";
|
|
47
|
+
value: T;
|
|
48
|
+
}> | Readonly<{
|
|
49
|
+
peerId: string;
|
|
50
|
+
status: "rejected";
|
|
51
|
+
error: BridgePeerError;
|
|
52
|
+
}>;
|
|
53
|
+
/** Public symmetric peer facade for exposure, invocation, state, and lifecycle. */ export type BridgePeer = Readonly<{
|
|
54
|
+
projectId: string;
|
|
55
|
+
peerId: string;
|
|
56
|
+
readonly state: BridgePeerState;
|
|
57
|
+
connect(): Promise<BridgePeerConnectResult>;
|
|
58
|
+
expose<TInput = unknown, TOutput = unknown>(capability: string, handler: (input: TInput, context: BridgeExposureContext) => TOutput | Response | Promise<TOutput | Response>, options?: BridgeExposureOptions<TInput, TOutput>): BridgeExposureRegistration;
|
|
59
|
+
invoke<TInput = unknown, TOutput = unknown>(capability: string, input: TInput, options: BridgeTargetInvocationOptions & Readonly<{
|
|
60
|
+
stream: true;
|
|
61
|
+
}>): Promise<Response>;
|
|
62
|
+
invoke<TInput = unknown, TOutput = unknown>(capability: string, input: TInput, options: BridgeTargetInvocationOptions): Promise<TOutput>;
|
|
63
|
+
invoke<TInput = unknown, TOutput = unknown>(capability: string, input: TInput, options?: BridgeFanoutInvocationOptions): Promise<readonly BridgeFanoutResult<TOutput>[]>;
|
|
64
|
+
subscribeState(listener: (state: BridgePeerState) => void): () => void;
|
|
65
|
+
close(): Promise<void>;
|
|
66
|
+
}>;
|
|
67
|
+
/** Creates the shared peer state machine around one selected platform runtime. */ export declare const createBridgePeerCore: (options: BridgePeerCoreOptions, runtime: BridgePeerRuntime) => BridgePeer;
|
package/src/core/peer.js
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { capabilityNameSchema, opaqueIdSchema } from "@genation/bridge-contracts";
|
|
2
|
+
import { bridgeJsonCodec } from "./codec.js";
|
|
3
|
+
import { BridgePeerError } from "./errors.js";
|
|
4
|
+
const defaultReconnectDelay = async (attempt)=>{
|
|
5
|
+
const base = Math.min(3_000, 100 * 2 ** Math.max(0, attempt - 1));
|
|
6
|
+
const jittered = Math.round(base * (0.8 + Math.random() * 0.4));
|
|
7
|
+
await new Promise((resolve)=>setTimeout(resolve, jittered));
|
|
8
|
+
};
|
|
9
|
+
const validateDeadline = (value)=>{
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
const deadline = value ?? now + 30_000;
|
|
12
|
+
if (!Number.isSafeInteger(deadline) || deadline <= now || deadline > now + 120_000) throw new BridgePeerError("DEADLINE_EXCEEDED");
|
|
13
|
+
return deadline;
|
|
14
|
+
};
|
|
15
|
+
const mapRuntimeError = (error)=>error instanceof BridgePeerError ? error : new BridgePeerError("TRANSPORT_ERROR");
|
|
16
|
+
/** Creates the shared peer state machine around one selected platform runtime. */ export const createBridgePeerCore = (options, runtime)=>{
|
|
17
|
+
if (!opaqueIdSchema.safeParse(options.projectId).success || !opaqueIdSchema.safeParse(options.peerId).success || typeof options.grantProvider !== "function") throw new BridgePeerError("INVALID_CONFIGURATION");
|
|
18
|
+
const invokeCapabilities = [
|
|
19
|
+
...options.requestedInvokeCapabilities ?? []
|
|
20
|
+
];
|
|
21
|
+
if (invokeCapabilities.some((value)=>!capabilityNameSchema.safeParse(value).success) || new Set(invokeCapabilities).size !== invokeCapabilities.length) throw new BridgePeerError("INVALID_CONFIGURATION");
|
|
22
|
+
const defaultCodec = options.codec ?? bridgeJsonCodec;
|
|
23
|
+
const exposures = new Map();
|
|
24
|
+
const listeners = new Set();
|
|
25
|
+
const maxReconnectAttempts = options.reconnect?.maxAttempts ?? 5;
|
|
26
|
+
if (!Number.isSafeInteger(maxReconnectAttempts) || maxReconnectAttempts < 0) {
|
|
27
|
+
throw new BridgePeerError("INVALID_CONFIGURATION");
|
|
28
|
+
}
|
|
29
|
+
const reconnectDelay = options.reconnect?.delay ?? defaultReconnectDelay;
|
|
30
|
+
let state = "idle";
|
|
31
|
+
let desiredConnection = false;
|
|
32
|
+
let connection;
|
|
33
|
+
let generation = 0;
|
|
34
|
+
let reconnectPromise;
|
|
35
|
+
let closePromise;
|
|
36
|
+
let openingController;
|
|
37
|
+
let platformActive = options.lifecycle?.active ?? true;
|
|
38
|
+
let unsubscribeLifecycle;
|
|
39
|
+
const isClosed = ()=>state === "closed";
|
|
40
|
+
const transition = (next)=>{
|
|
41
|
+
if (state === next) return;
|
|
42
|
+
state = next;
|
|
43
|
+
for (const listener of [
|
|
44
|
+
...listeners
|
|
45
|
+
]){
|
|
46
|
+
try {
|
|
47
|
+
listener(next);
|
|
48
|
+
} catch {
|
|
49
|
+
// Customer observers cannot own or interrupt SDK lifecycle progress.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const snapshot = ()=>Object.freeze([
|
|
54
|
+
...exposures.values()
|
|
55
|
+
]);
|
|
56
|
+
const open = async (reconnecting)=>{
|
|
57
|
+
const currentGeneration = ++generation;
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
openingController = controller;
|
|
60
|
+
let candidate;
|
|
61
|
+
try {
|
|
62
|
+
candidate = await runtime.connect({
|
|
63
|
+
projectId: options.projectId,
|
|
64
|
+
peerId: options.peerId,
|
|
65
|
+
grantProvider: options.grantProvider,
|
|
66
|
+
requestedInvokeCapabilities: Object.freeze([
|
|
67
|
+
...invokeCapabilities
|
|
68
|
+
]),
|
|
69
|
+
desiredExposures: snapshot,
|
|
70
|
+
signal: controller.signal,
|
|
71
|
+
onDisconnect: (error)=>{
|
|
72
|
+
if (currentGeneration !== generation || state === "closed") return;
|
|
73
|
+
connection = undefined;
|
|
74
|
+
if (!desiredConnection) {
|
|
75
|
+
transition("idle");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!platformActive) {
|
|
79
|
+
transition("suspended");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
transition("reconnecting");
|
|
83
|
+
reconnectPromise ??= reconnect().finally(()=>{
|
|
84
|
+
reconnectPromise = undefined;
|
|
85
|
+
});
|
|
86
|
+
void error;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
if (currentGeneration !== generation || state === "closed" || !platformActive) {
|
|
90
|
+
throw new BridgePeerError("PEER_CLOSED");
|
|
91
|
+
}
|
|
92
|
+
connection = candidate;
|
|
93
|
+
await candidate.replaceExposures(snapshot());
|
|
94
|
+
transition("connected");
|
|
95
|
+
void reconnecting;
|
|
96
|
+
return candidate;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (candidate !== undefined) {
|
|
99
|
+
if (connection === candidate) connection = undefined;
|
|
100
|
+
await candidate.close().catch(()=>undefined);
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
} finally{
|
|
104
|
+
if (openingController === controller) openingController = undefined;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const reconnect = async ()=>{
|
|
108
|
+
for(let attempt = 1; attempt <= maxReconnectAttempts; attempt += 1){
|
|
109
|
+
if (!desiredConnection || isClosed() || !platformActive) return;
|
|
110
|
+
await reconnectDelay(attempt);
|
|
111
|
+
if (!desiredConnection || isClosed() || !platformActive) return;
|
|
112
|
+
try {
|
|
113
|
+
await open(true);
|
|
114
|
+
return;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error instanceof BridgePeerError && error.code === "PEER_CLOSED") return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (state !== "closed") {
|
|
120
|
+
desiredConnection = false;
|
|
121
|
+
transition("idle");
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const connect = async ()=>{
|
|
125
|
+
if (state === "closed") throw new BridgePeerError("PEER_CLOSED");
|
|
126
|
+
if (state === "connected") return Object.freeze({
|
|
127
|
+
status: "connected"
|
|
128
|
+
});
|
|
129
|
+
if (state === "connecting") return Object.freeze({
|
|
130
|
+
status: "connecting"
|
|
131
|
+
});
|
|
132
|
+
if (state === "reconnecting") {
|
|
133
|
+
return Object.freeze({
|
|
134
|
+
status: "reconnecting"
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (state === "suspended") {
|
|
138
|
+
desiredConnection = true;
|
|
139
|
+
return Object.freeze({
|
|
140
|
+
status: "reconnecting"
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
desiredConnection = true;
|
|
144
|
+
if (!platformActive) {
|
|
145
|
+
transition("suspended");
|
|
146
|
+
return Object.freeze({
|
|
147
|
+
status: "reconnecting"
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
transition("connecting");
|
|
151
|
+
const attemptGeneration = generation + 1;
|
|
152
|
+
try {
|
|
153
|
+
await open(false);
|
|
154
|
+
return Object.freeze({
|
|
155
|
+
status: "connected"
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (desiredConnection && !isClosed() && (!platformActive || generation !== attemptGeneration)) {
|
|
159
|
+
return Object.freeze({
|
|
160
|
+
status: "reconnecting"
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
desiredConnection = false;
|
|
164
|
+
if (!isClosed()) transition("idle");
|
|
165
|
+
throw mapRuntimeError(error);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const assertConnected = ()=>{
|
|
169
|
+
if (state === "closed") throw new BridgePeerError("PEER_CLOSED");
|
|
170
|
+
if (state !== "connected" || connection === undefined) {
|
|
171
|
+
throw new BridgePeerError("PEER_OFFLINE");
|
|
172
|
+
}
|
|
173
|
+
return connection;
|
|
174
|
+
};
|
|
175
|
+
const recoverExposureReplacement = async (active)=>{
|
|
176
|
+
if (connection !== active || state !== "connected") return;
|
|
177
|
+
connection = undefined;
|
|
178
|
+
generation += 1;
|
|
179
|
+
await active.close().catch(()=>undefined);
|
|
180
|
+
if (!desiredConnection || isClosed()) return;
|
|
181
|
+
transition("reconnecting");
|
|
182
|
+
reconnectPromise ??= reconnect().finally(()=>{
|
|
183
|
+
reconnectPromise = undefined;
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
const expose = (capability, handler, exposureOptions)=>{
|
|
187
|
+
if (state === "closed") throw new BridgePeerError("PEER_CLOSED");
|
|
188
|
+
if (!capabilityNameSchema.safeParse(capability).success) {
|
|
189
|
+
throw new BridgePeerError("INVALID_CONFIGURATION");
|
|
190
|
+
}
|
|
191
|
+
if (exposures.has(capability)) {
|
|
192
|
+
throw new BridgePeerError("DUPLICATE_CAPABILITY");
|
|
193
|
+
}
|
|
194
|
+
const codec = exposureOptions?.codec ?? defaultCodec;
|
|
195
|
+
const stream = exposureOptions?.stream ?? false;
|
|
196
|
+
const exposure = Object.freeze({
|
|
197
|
+
capability,
|
|
198
|
+
stream,
|
|
199
|
+
handle: async (payload, signal)=>{
|
|
200
|
+
let input;
|
|
201
|
+
try {
|
|
202
|
+
input = codec.decode(payload);
|
|
203
|
+
} catch {
|
|
204
|
+
throw new BridgePeerError("CODEC_ERROR");
|
|
205
|
+
}
|
|
206
|
+
let output;
|
|
207
|
+
try {
|
|
208
|
+
output = await handler(input, Object.freeze({
|
|
209
|
+
signal
|
|
210
|
+
}));
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error instanceof BridgePeerError) throw error;
|
|
213
|
+
throw new BridgePeerError("EXPOSER_ERROR");
|
|
214
|
+
}
|
|
215
|
+
if (stream) {
|
|
216
|
+
if (!(output instanceof Response)) {
|
|
217
|
+
throw new BridgePeerError("EXPOSER_ERROR");
|
|
218
|
+
}
|
|
219
|
+
return output;
|
|
220
|
+
}
|
|
221
|
+
if (output instanceof Response) throw new BridgePeerError("EXPOSER_ERROR");
|
|
222
|
+
try {
|
|
223
|
+
return codec.encode(output);
|
|
224
|
+
} catch {
|
|
225
|
+
throw new BridgePeerError("CODEC_ERROR");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
exposures.set(capability, exposure);
|
|
230
|
+
if (connection !== undefined && state === "connected") {
|
|
231
|
+
const active = connection;
|
|
232
|
+
void active.replaceExposures(snapshot()).catch(()=>recoverExposureReplacement(active));
|
|
233
|
+
}
|
|
234
|
+
let disposed = false;
|
|
235
|
+
return Object.freeze({
|
|
236
|
+
dispose: async ()=>{
|
|
237
|
+
if (disposed) return;
|
|
238
|
+
disposed = true;
|
|
239
|
+
exposures.delete(capability);
|
|
240
|
+
if (connection !== undefined && state === "connected") {
|
|
241
|
+
const active = connection;
|
|
242
|
+
await active.replaceExposures(snapshot()).catch(()=>recoverExposureReplacement(active));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
const invoke = async (capability, input, invokeOptions)=>{
|
|
248
|
+
const active = assertConnected();
|
|
249
|
+
if (!capabilityNameSchema.safeParse(capability).success) {
|
|
250
|
+
throw new BridgePeerError("INVALID_CONFIGURATION");
|
|
251
|
+
}
|
|
252
|
+
const deadlineAt = validateDeadline(invokeOptions?.deadlineAt);
|
|
253
|
+
if (invokeOptions?.signal?.aborted) throw new BridgePeerError("CANCELLED");
|
|
254
|
+
const codec = invokeOptions?.codec ?? defaultCodec;
|
|
255
|
+
let payload;
|
|
256
|
+
try {
|
|
257
|
+
payload = codec.encode(input);
|
|
258
|
+
} catch {
|
|
259
|
+
throw new BridgePeerError("CODEC_ERROR");
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
if ("targetPeer" in (invokeOptions ?? {})) {
|
|
263
|
+
const target = invokeOptions.targetPeer;
|
|
264
|
+
if (!opaqueIdSchema.safeParse(target).success) {
|
|
265
|
+
throw new BridgePeerError("INVALID_CONFIGURATION");
|
|
266
|
+
}
|
|
267
|
+
const direct = {
|
|
268
|
+
capability,
|
|
269
|
+
payload,
|
|
270
|
+
targetPeer: target,
|
|
271
|
+
deadlineAt,
|
|
272
|
+
signal: invokeOptions?.signal
|
|
273
|
+
};
|
|
274
|
+
if (invokeOptions.stream === true) {
|
|
275
|
+
return await active.invokeStream(direct);
|
|
276
|
+
}
|
|
277
|
+
return codec.decode(await active.invokeUnary(direct));
|
|
278
|
+
}
|
|
279
|
+
if (invokeOptions?.stream === true) {
|
|
280
|
+
throw new BridgePeerError("INVALID_CONFIGURATION");
|
|
281
|
+
}
|
|
282
|
+
const fanout = await active.invokeFanout({
|
|
283
|
+
capability,
|
|
284
|
+
payload,
|
|
285
|
+
deadlineAt,
|
|
286
|
+
signal: invokeOptions?.signal
|
|
287
|
+
});
|
|
288
|
+
return fanout.map((result)=>{
|
|
289
|
+
if (result.status === "rejected") return Object.freeze({
|
|
290
|
+
...result
|
|
291
|
+
});
|
|
292
|
+
try {
|
|
293
|
+
return Object.freeze({
|
|
294
|
+
peerId: result.peerId,
|
|
295
|
+
status: "fulfilled",
|
|
296
|
+
value: codec.decode(result.payload)
|
|
297
|
+
});
|
|
298
|
+
} catch {
|
|
299
|
+
return Object.freeze({
|
|
300
|
+
peerId: result.peerId,
|
|
301
|
+
status: "rejected",
|
|
302
|
+
error: new BridgePeerError("CODEC_ERROR")
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
} catch (error) {
|
|
307
|
+
throw mapRuntimeError(error);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
const close = ()=>{
|
|
311
|
+
closePromise ??= (async ()=>{
|
|
312
|
+
if (state === "closed") return;
|
|
313
|
+
desiredConnection = false;
|
|
314
|
+
generation += 1;
|
|
315
|
+
openingController?.abort();
|
|
316
|
+
openingController = undefined;
|
|
317
|
+
unsubscribeLifecycle?.();
|
|
318
|
+
unsubscribeLifecycle = undefined;
|
|
319
|
+
const active = connection;
|
|
320
|
+
connection = undefined;
|
|
321
|
+
transition("closed");
|
|
322
|
+
await active?.close().catch(()=>undefined);
|
|
323
|
+
listeners.clear();
|
|
324
|
+
})();
|
|
325
|
+
return closePromise;
|
|
326
|
+
};
|
|
327
|
+
if (options.lifecycle !== undefined) {
|
|
328
|
+
unsubscribeLifecycle = options.lifecycle.subscribe((active)=>{
|
|
329
|
+
if (platformActive === active || isClosed()) return;
|
|
330
|
+
platformActive = active;
|
|
331
|
+
if (!active) {
|
|
332
|
+
generation += 1;
|
|
333
|
+
openingController?.abort();
|
|
334
|
+
openingController = undefined;
|
|
335
|
+
const live = connection;
|
|
336
|
+
connection = undefined;
|
|
337
|
+
transition("suspended");
|
|
338
|
+
void live?.close().catch(()=>undefined);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (!desiredConnection) {
|
|
342
|
+
transition("idle");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
transition("reconnecting");
|
|
346
|
+
reconnectPromise ??= reconnect().finally(()=>{
|
|
347
|
+
reconnectPromise = undefined;
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
if (!platformActive) transition("suspended");
|
|
351
|
+
}
|
|
352
|
+
const peer = {
|
|
353
|
+
projectId: options.projectId,
|
|
354
|
+
peerId: options.peerId,
|
|
355
|
+
get state () {
|
|
356
|
+
return state;
|
|
357
|
+
},
|
|
358
|
+
connect,
|
|
359
|
+
expose,
|
|
360
|
+
invoke: invoke,
|
|
361
|
+
subscribeState: (listener)=>{
|
|
362
|
+
listeners.add(listener);
|
|
363
|
+
try {
|
|
364
|
+
listener(state);
|
|
365
|
+
} catch {
|
|
366
|
+
// Initial observation follows the same isolation rule as later transitions.
|
|
367
|
+
}
|
|
368
|
+
return ()=>listeners.delete(listener);
|
|
369
|
+
},
|
|
370
|
+
close
|
|
371
|
+
};
|
|
372
|
+
return Object.freeze(peer);
|
|
373
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { BridgeCode } from "@genation/bridge-contracts";
|
|
2
|
+
import type { BridgePeerError } from "./errors.js";
|
|
3
|
+
/** Public identity supplied to the customer grant provider without private keys. */ export type BridgePeerPublicIdentity = Readonly<{
|
|
4
|
+
connectionPublicKey: Readonly<{
|
|
5
|
+
kty: "OKP";
|
|
6
|
+
crv: "Ed25519";
|
|
7
|
+
x: string;
|
|
8
|
+
}>;
|
|
9
|
+
noiseStaticPublicKey: Readonly<{
|
|
10
|
+
kty: "OKP";
|
|
11
|
+
crv: "X25519";
|
|
12
|
+
x: string;
|
|
13
|
+
}>;
|
|
14
|
+
}>;
|
|
15
|
+
/** Immutable input used by customer code to obtain one fresh signed peer grant. */ export type BridgeGrantProviderInput = Readonly<{
|
|
16
|
+
projectId: string;
|
|
17
|
+
peerId: string;
|
|
18
|
+
connectionPublicKey: BridgePeerPublicIdentity["connectionPublicKey"];
|
|
19
|
+
noiseStaticPublicKey: BridgePeerPublicIdentity["noiseStaticPublicKey"];
|
|
20
|
+
requestedPermissions: Readonly<{
|
|
21
|
+
expose: readonly string[];
|
|
22
|
+
invoke: readonly string[];
|
|
23
|
+
}>;
|
|
24
|
+
}>;
|
|
25
|
+
/** Fresh compact grant returned by a customer-owned backend callback. */ export type BridgeGrantProviderResult = Readonly<{
|
|
26
|
+
grant: string;
|
|
27
|
+
}>;
|
|
28
|
+
/** Customer callback that authenticates and authorizes peer intent outside Bridge. */ export type BridgeGrantProvider = (input: BridgeGrantProviderInput) => Promise<BridgeGrantProviderResult>;
|
|
29
|
+
/** One desired capability translated to the platform runtime's byte boundary. */ export type RuntimeExposure = Readonly<{
|
|
30
|
+
capability: string;
|
|
31
|
+
stream: boolean;
|
|
32
|
+
handle(payload: Uint8Array, signal: AbortSignal): Promise<Uint8Array | Response>;
|
|
33
|
+
}>;
|
|
34
|
+
/** Direct invocation routed by one connected platform runtime. */ export type RuntimeInvocationInput = Readonly<{
|
|
35
|
+
capability: string;
|
|
36
|
+
payload: Uint8Array;
|
|
37
|
+
targetPeer: string;
|
|
38
|
+
deadlineAt: number;
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
}>;
|
|
41
|
+
/** Target-less unary invocation routed as one bounded immutable fanout snapshot. */ export type RuntimeFanoutInput = Readonly<{
|
|
42
|
+
capability: string;
|
|
43
|
+
payload: Uint8Array;
|
|
44
|
+
deadlineAt: number;
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
}>;
|
|
47
|
+
/** One terminal runtime fanout entry before application decoding. */ export type RuntimeFanoutResult = Readonly<{
|
|
48
|
+
peerId: string;
|
|
49
|
+
status: "fulfilled";
|
|
50
|
+
payload: Uint8Array;
|
|
51
|
+
}> | Readonly<{
|
|
52
|
+
peerId: string;
|
|
53
|
+
status: "rejected";
|
|
54
|
+
error: BridgePeerError;
|
|
55
|
+
}>;
|
|
56
|
+
/** One live platform connection used only through the shared peer state machine. */ export type BridgePeerRuntimeConnection = Readonly<{
|
|
57
|
+
replaceExposures(exposures: readonly RuntimeExposure[]): Promise<void>;
|
|
58
|
+
invokeUnary(input: RuntimeInvocationInput): Promise<Uint8Array>;
|
|
59
|
+
invokeStream(input: RuntimeInvocationInput): Promise<Response>;
|
|
60
|
+
invokeFanout(input: RuntimeFanoutInput): Promise<readonly RuntimeFanoutResult[]>;
|
|
61
|
+
close(): Promise<void>;
|
|
62
|
+
}>;
|
|
63
|
+
/** Connection request passed from shared lifecycle state into a platform adapter. */ export type BridgePeerRuntimeConnectInput = Readonly<{
|
|
64
|
+
projectId: string;
|
|
65
|
+
peerId: string;
|
|
66
|
+
grantProvider: BridgeGrantProvider;
|
|
67
|
+
requestedInvokeCapabilities: readonly string[];
|
|
68
|
+
desiredExposures(): readonly RuntimeExposure[];
|
|
69
|
+
signal: AbortSignal;
|
|
70
|
+
onDisconnect(error: BridgePeerError): void;
|
|
71
|
+
}>;
|
|
72
|
+
/** Platform adapter capable of creating one fresh fenced physical connection. */ export type BridgePeerRuntime = Readonly<{
|
|
73
|
+
connect(input: BridgePeerRuntimeConnectInput): Promise<BridgePeerRuntimeConnection>;
|
|
74
|
+
}>;
|
|
75
|
+
/** Relay error metadata retained by a platform adapter before SDK error mapping. */ export type RuntimeRelayFailure = Readonly<{
|
|
76
|
+
bridgeCode: BridgeCode;
|
|
77
|
+
retryable: boolean;
|
|
78
|
+
}>;
|
|
File without changes
|