@react-native-harness/bridge 1.3.0 → 1.4.0-rc.1
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/dist/__tests__/client.test.d.ts +2 -0
- package/dist/__tests__/client.test.d.ts.map +1 -0
- package/dist/__tests__/client.test.js +81 -0
- package/dist/__tests__/heartbeat.test.d.ts +2 -0
- package/dist/__tests__/heartbeat.test.d.ts.map +1 -0
- package/dist/__tests__/heartbeat.test.js +37 -0
- package/dist/__tests__/protocol.test.d.ts +2 -0
- package/dist/__tests__/protocol.test.d.ts.map +1 -0
- package/dist/__tests__/protocol.test.js +37 -0
- package/dist/__tests__/rpc-peer.test.d.ts +2 -0
- package/dist/__tests__/rpc-peer.test.d.ts.map +1 -0
- package/dist/__tests__/rpc-peer.test.js +127 -0
- package/dist/client.d.ts +7 -14
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +96 -42
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +23 -0
- package/dist/heartbeat.d.ts +13 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/heartbeat.js +49 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/protocol.d.ts +51 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +132 -0
- package/dist/rpc-peer.d.ts +28 -0
- package/dist/rpc-peer.d.ts.map +1 -0
- package/dist/rpc-peer.js +146 -0
- package/dist/server.d.ts +1 -20
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +122 -71
- package/dist/shared/test-collector.d.ts +2 -0
- package/dist/shared/test-collector.d.ts.map +1 -1
- package/dist/shared/test-runner.d.ts +13 -0
- package/dist/shared/test-runner.d.ts.map +1 -1
- package/dist/shared.d.ts +0 -2
- package/dist/shared.d.ts.map +1 -1
- package/dist/transport.d.ts +22 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +22 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/dist/websocket-client-transport.d.ts +3 -0
- package/dist/websocket-client-transport.d.ts.map +1 -0
- package/dist/websocket-client-transport.js +45 -0
- package/dist/websocket-server-transport.d.ts +4 -0
- package/dist/websocket-server-transport.d.ts.map +1 -0
- package/dist/websocket-server-transport.js +43 -0
- package/eslint.config.mjs +5 -1
- package/package.json +6 -5
- package/src/__tests__/client.test.ts +99 -0
- package/src/__tests__/heartbeat.test.ts +47 -0
- package/src/__tests__/protocol.test.ts +51 -0
- package/src/__tests__/rpc-peer.test.ts +181 -0
- package/src/client.ts +146 -57
- package/src/errors.ts +32 -0
- package/src/heartbeat.ts +67 -0
- package/src/index.ts +1 -0
- package/src/protocol.ts +233 -0
- package/src/rpc-peer.ts +222 -0
- package/src/server.ts +179 -114
- package/src/shared/test-collector.ts +3 -0
- package/src/shared/test-runner.ts +14 -0
- package/src/shared.ts +0 -2
- package/src/transport.ts +47 -0
- package/src/websocket-client-transport.ts +85 -0
- package/src/websocket-server-transport.ts +54 -0
- package/vite.config.ts +18 -0
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import type { BridgeEvents, DeviceDescriptor } from './shared.js';
|
|
2
|
+
|
|
3
|
+
export type SerializedBridgeError = {
|
|
4
|
+
name: string;
|
|
5
|
+
message: string;
|
|
6
|
+
stack?: string;
|
|
7
|
+
cause?: unknown;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type BridgeInvokeMessage = {
|
|
11
|
+
type: 'invoke';
|
|
12
|
+
id: number;
|
|
13
|
+
method: string;
|
|
14
|
+
args: unknown[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type BridgeReturnMessage =
|
|
18
|
+
| {
|
|
19
|
+
type: 'return';
|
|
20
|
+
id: number;
|
|
21
|
+
ok: true;
|
|
22
|
+
value?: unknown;
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
type: 'return';
|
|
26
|
+
id: number;
|
|
27
|
+
ok: false;
|
|
28
|
+
error: SerializedBridgeError;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type BridgeEventMessage<Event extends { type: string } = BridgeEvents> = {
|
|
32
|
+
type: 'event';
|
|
33
|
+
event: Event;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type BridgeReadyMessage = {
|
|
37
|
+
type: 'ready';
|
|
38
|
+
device: DeviceDescriptor;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type BridgePingMessage = {
|
|
42
|
+
type: 'ping';
|
|
43
|
+
id: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type BridgePongMessage = {
|
|
47
|
+
type: 'pong';
|
|
48
|
+
id: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type BridgeControlMessage =
|
|
52
|
+
| BridgeReadyMessage
|
|
53
|
+
| BridgePingMessage
|
|
54
|
+
| BridgePongMessage;
|
|
55
|
+
|
|
56
|
+
export type BridgeMessage<Event extends { type: string } = BridgeEvents> =
|
|
57
|
+
| BridgeInvokeMessage
|
|
58
|
+
| BridgeReturnMessage
|
|
59
|
+
| BridgeEventMessage<Event>
|
|
60
|
+
| BridgeControlMessage;
|
|
61
|
+
|
|
62
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
|
63
|
+
return typeof value === 'object' && value !== null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const readNumber = (value: unknown, fieldName: string): number => {
|
|
67
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
68
|
+
throw new Error(`Invalid bridge message: ${fieldName} must be a number`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return value;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const readString = (value: unknown, fieldName: string): string => {
|
|
75
|
+
if (typeof value !== 'string') {
|
|
76
|
+
throw new Error(`Invalid bridge message: ${fieldName} must be a string`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return value;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const readSerializedBridgeError = (value: unknown): SerializedBridgeError => {
|
|
83
|
+
if (!isRecord(value)) {
|
|
84
|
+
throw new Error('Invalid bridge message: error must be an object');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const name = readString(value.name, 'error.name');
|
|
88
|
+
const message = readString(value.message, 'error.message');
|
|
89
|
+
|
|
90
|
+
if (value.stack !== undefined) {
|
|
91
|
+
readString(value.stack, 'error.stack');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const stack = value.stack as string | undefined;
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
name,
|
|
98
|
+
message,
|
|
99
|
+
stack,
|
|
100
|
+
cause: value.cause,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const readDeviceDescriptor = (value: unknown): DeviceDescriptor => {
|
|
105
|
+
if (!isRecord(value)) {
|
|
106
|
+
throw new Error('Invalid bridge message: device must be an object');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
platform: readString(value.platform, 'device.platform') as DeviceDescriptor['platform'],
|
|
111
|
+
manufacturer: readString(value.manufacturer, 'device.manufacturer'),
|
|
112
|
+
model: readString(value.model, 'device.model'),
|
|
113
|
+
osVersion: readString(value.osVersion, 'device.osVersion'),
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const readBridgeEvent = (value: unknown): BridgeEvents => {
|
|
118
|
+
if (!isRecord(value)) {
|
|
119
|
+
throw new Error('Invalid bridge message: event must be an object');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
readString(value.type, 'event.type');
|
|
123
|
+
|
|
124
|
+
return value as BridgeEvents;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const serializeBridgeMessage = (
|
|
128
|
+
message: BridgeMessage,
|
|
129
|
+
): string => {
|
|
130
|
+
return JSON.stringify(message);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const parseBridgeMessage = (raw: string): BridgeMessage => {
|
|
134
|
+
const parsed: unknown = JSON.parse(raw);
|
|
135
|
+
|
|
136
|
+
if (!isRecord(parsed)) {
|
|
137
|
+
throw new Error('Invalid bridge message: expected an object');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const messageType = readString(parsed.type, 'type');
|
|
141
|
+
|
|
142
|
+
switch (messageType) {
|
|
143
|
+
case 'invoke': {
|
|
144
|
+
readNumber(parsed.id, 'id');
|
|
145
|
+
readString(parsed.method, 'method');
|
|
146
|
+
|
|
147
|
+
if (!Array.isArray(parsed.args)) {
|
|
148
|
+
throw new Error('Invalid bridge message: args must be an array');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return parsed as BridgeInvokeMessage;
|
|
152
|
+
}
|
|
153
|
+
case 'return': {
|
|
154
|
+
readNumber(parsed.id, 'id');
|
|
155
|
+
|
|
156
|
+
if (typeof parsed.ok !== 'boolean') {
|
|
157
|
+
throw new Error('Invalid bridge message: ok must be a boolean');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!parsed.ok) {
|
|
161
|
+
readSerializedBridgeError(parsed.error);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return parsed as BridgeReturnMessage;
|
|
165
|
+
}
|
|
166
|
+
case 'event': {
|
|
167
|
+
readBridgeEvent(parsed.event);
|
|
168
|
+
return parsed as BridgeEventMessage;
|
|
169
|
+
}
|
|
170
|
+
case 'ready': {
|
|
171
|
+
readDeviceDescriptor(parsed.device);
|
|
172
|
+
return parsed as BridgeReadyMessage;
|
|
173
|
+
}
|
|
174
|
+
case 'ping':
|
|
175
|
+
case 'pong': {
|
|
176
|
+
readNumber(parsed.id, 'id');
|
|
177
|
+
return parsed as BridgePingMessage | BridgePongMessage;
|
|
178
|
+
}
|
|
179
|
+
default:
|
|
180
|
+
throw new Error(`Invalid bridge message: unknown type ${messageType}`);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const serializeBridgeError = (
|
|
185
|
+
error: unknown,
|
|
186
|
+
): SerializedBridgeError => {
|
|
187
|
+
if (error instanceof Error) {
|
|
188
|
+
return {
|
|
189
|
+
name: error.name,
|
|
190
|
+
message: error.message,
|
|
191
|
+
stack: error.stack,
|
|
192
|
+
cause: error.cause,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (isRecord(error)) {
|
|
197
|
+
const message =
|
|
198
|
+
typeof error.message === 'string'
|
|
199
|
+
? error.message
|
|
200
|
+
: `Non-Error thrown value: ${String(error)}`;
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
name: typeof error.name === 'string' ? error.name : 'NonErrorThrown',
|
|
204
|
+
message,
|
|
205
|
+
stack: typeof error.stack === 'string' ? error.stack : undefined,
|
|
206
|
+
cause: 'cause' in error ? error.cause : undefined,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
name: 'NonErrorThrown',
|
|
212
|
+
message:
|
|
213
|
+
typeof error === 'string'
|
|
214
|
+
? error
|
|
215
|
+
: `Non-Error thrown value: ${String(error)}`,
|
|
216
|
+
};
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export const deserializeBridgeError = (
|
|
220
|
+
serialized: SerializedBridgeError,
|
|
221
|
+
): Error => {
|
|
222
|
+
const error = new Error(serialized.message, {
|
|
223
|
+
cause: serialized.cause,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
error.name = serialized.name;
|
|
227
|
+
|
|
228
|
+
if (serialized.stack) {
|
|
229
|
+
error.stack = serialized.stack;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return error;
|
|
233
|
+
};
|
package/src/rpc-peer.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deserializeBridgeError,
|
|
3
|
+
parseBridgeMessage,
|
|
4
|
+
serializeBridgeError,
|
|
5
|
+
serializeBridgeMessage,
|
|
6
|
+
type BridgeControlMessage,
|
|
7
|
+
} from './protocol.js';
|
|
8
|
+
import type { RpcTransport } from './transport.js';
|
|
9
|
+
|
|
10
|
+
type RpcMethod = {
|
|
11
|
+
bivarianceHack(...args: unknown[]): unknown;
|
|
12
|
+
}['bivarianceHack'];
|
|
13
|
+
type RpcMethods = Record<string, RpcMethod>;
|
|
14
|
+
|
|
15
|
+
type PendingInvocation = {
|
|
16
|
+
args: unknown[];
|
|
17
|
+
method: string;
|
|
18
|
+
reject: (reason: unknown) => void;
|
|
19
|
+
resolve: (value: unknown) => void;
|
|
20
|
+
timeout: ReturnType<typeof setTimeout> | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type RpcPeer<
|
|
24
|
+
Remote extends RpcMethods,
|
|
25
|
+
Event extends { type: string },
|
|
26
|
+
> = {
|
|
27
|
+
invoke: <K extends keyof Remote>(
|
|
28
|
+
method: K,
|
|
29
|
+
...args: Parameters<Remote[K]>
|
|
30
|
+
) => Promise<Awaited<ReturnType<Remote[K]>>>;
|
|
31
|
+
sendEvent: (event: Event) => void;
|
|
32
|
+
handleMessage: (raw: string) => Promise<BridgeControlMessage | null>;
|
|
33
|
+
close: (reason?: Error) => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type CreateRpcPeerOptions<
|
|
37
|
+
Local extends RpcMethods,
|
|
38
|
+
Event extends { type: string },
|
|
39
|
+
> = {
|
|
40
|
+
localMethods: Local;
|
|
41
|
+
transport: RpcTransport;
|
|
42
|
+
onEvent?: (event: Event) => void;
|
|
43
|
+
callTimeoutMs?: number;
|
|
44
|
+
createTimeoutError?: (method: string, args: unknown[]) => Error;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const createClosedPeerError = (): Error => {
|
|
48
|
+
return new Error('Bridge RPC peer closed');
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const createRpcPeer = <
|
|
52
|
+
Local extends RpcMethods,
|
|
53
|
+
Remote extends RpcMethods,
|
|
54
|
+
Event extends { type: string },
|
|
55
|
+
>(
|
|
56
|
+
options: CreateRpcPeerOptions<Local, Event>,
|
|
57
|
+
): RpcPeer<Remote, Event> => {
|
|
58
|
+
const pendingInvocations = new Map<number, PendingInvocation>();
|
|
59
|
+
let nextMessageId = 1;
|
|
60
|
+
let closedReason: Error | null = null;
|
|
61
|
+
|
|
62
|
+
const rejectPendingInvocations = (reason: Error) => {
|
|
63
|
+
for (const [id, invocation] of pendingInvocations) {
|
|
64
|
+
if (invocation.timeout) {
|
|
65
|
+
clearTimeout(invocation.timeout);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
pendingInvocations.delete(id);
|
|
69
|
+
invocation.reject(reason);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const close = (reason = createClosedPeerError()) => {
|
|
74
|
+
if (closedReason) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
closedReason = reason;
|
|
79
|
+
rejectPendingInvocations(reason);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const sendMessage = (message: object) => {
|
|
83
|
+
if (closedReason) {
|
|
84
|
+
throw closedReason;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
options.transport.send(serializeBridgeMessage(message as never));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
invoke: (method, ...args) => {
|
|
92
|
+
if (closedReason) {
|
|
93
|
+
return Promise.reject(closedReason);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const id = nextMessageId++;
|
|
97
|
+
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const methodName = String(method);
|
|
100
|
+
const invocation: PendingInvocation = {
|
|
101
|
+
args,
|
|
102
|
+
method: methodName,
|
|
103
|
+
reject,
|
|
104
|
+
resolve: (value) => {
|
|
105
|
+
resolve(value as Awaited<ReturnType<Remote[typeof method]>>);
|
|
106
|
+
},
|
|
107
|
+
timeout: null,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (options.callTimeoutMs !== undefined) {
|
|
111
|
+
invocation.timeout = setTimeout(() => {
|
|
112
|
+
pendingInvocations.delete(id);
|
|
113
|
+
reject(
|
|
114
|
+
options.createTimeoutError?.(methodName, args) ??
|
|
115
|
+
new Error(`RPC call timed out: ${methodName}`),
|
|
116
|
+
);
|
|
117
|
+
}, options.callTimeoutMs);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
pendingInvocations.set(id, invocation);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
sendMessage({
|
|
124
|
+
type: 'invoke',
|
|
125
|
+
id,
|
|
126
|
+
method: methodName,
|
|
127
|
+
args,
|
|
128
|
+
});
|
|
129
|
+
} catch (error) {
|
|
130
|
+
pendingInvocations.delete(id);
|
|
131
|
+
|
|
132
|
+
if (invocation.timeout) {
|
|
133
|
+
clearTimeout(invocation.timeout);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
reject(error);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
sendEvent: (event) => {
|
|
141
|
+
if (closedReason) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
sendMessage({ type: 'event', event });
|
|
147
|
+
} catch (error) {
|
|
148
|
+
close(error instanceof Error ? error : createClosedPeerError());
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
handleMessage: async (raw) => {
|
|
152
|
+
const message = parseBridgeMessage(raw);
|
|
153
|
+
|
|
154
|
+
switch (message.type) {
|
|
155
|
+
case 'invoke': {
|
|
156
|
+
const localMethod = options.localMethods[message.method];
|
|
157
|
+
|
|
158
|
+
if (!localMethod) {
|
|
159
|
+
sendMessage({
|
|
160
|
+
type: 'return',
|
|
161
|
+
id: message.id,
|
|
162
|
+
ok: false,
|
|
163
|
+
error: serializeBridgeError(
|
|
164
|
+
new Error(`Unknown bridge RPC method: ${message.method}`),
|
|
165
|
+
),
|
|
166
|
+
});
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const value = await localMethod(...message.args);
|
|
172
|
+
sendMessage({
|
|
173
|
+
type: 'return',
|
|
174
|
+
id: message.id,
|
|
175
|
+
ok: true,
|
|
176
|
+
value,
|
|
177
|
+
});
|
|
178
|
+
} catch (error) {
|
|
179
|
+
sendMessage({
|
|
180
|
+
type: 'return',
|
|
181
|
+
id: message.id,
|
|
182
|
+
ok: false,
|
|
183
|
+
error: serializeBridgeError(error),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
case 'return': {
|
|
190
|
+
const invocation = pendingInvocations.get(message.id);
|
|
191
|
+
|
|
192
|
+
if (!invocation) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
pendingInvocations.delete(message.id);
|
|
197
|
+
|
|
198
|
+
if (invocation.timeout) {
|
|
199
|
+
clearTimeout(invocation.timeout);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (message.ok) {
|
|
203
|
+
invocation.resolve(message.value);
|
|
204
|
+
} else {
|
|
205
|
+
invocation.reject(deserializeBridgeError(message.error));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
case 'event': {
|
|
211
|
+
options.onEvent?.(message.event as unknown as Event);
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
case 'ready':
|
|
215
|
+
case 'ping':
|
|
216
|
+
case 'pong':
|
|
217
|
+
return message;
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
close,
|
|
221
|
+
};
|
|
222
|
+
};
|