@quiesco/agent 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +23 -2
- package/metro.cjs +144 -0
- package/package.json +54 -3
- package/src/actions/hit-test.ts +42 -0
- package/src/actions/host.ts +194 -0
- package/src/actions/js.ts +245 -0
- package/src/actions/multitouch.ts +179 -0
- package/src/actions/native.ts +500 -0
- package/src/actions/performer.ts +87 -0
- package/src/actions/value.ts +460 -0
- package/src/actions/wheel.ts +260 -0
- package/src/api-types.ts +32 -0
- package/src/api.ts +11 -0
- package/src/assertions/index.ts +160 -0
- package/src/backdoor.ts +54 -0
- package/src/build-config.ts +6 -0
- package/src/connection/client.ts +196 -0
- package/src/connection/params.ts +63 -0
- package/src/early.ts +10 -0
- package/src/errors.ts +23 -0
- package/src/events.ts +136 -0
- package/src/expo-fetch.js +13 -0
- package/src/fiber-adapter/default-deps.native.ts +36 -0
- package/src/fiber-adapter/default-deps.ts +38 -0
- package/src/fiber-adapter/fiber.ts +81 -0
- package/src/fiber-adapter/hook.ts +154 -0
- package/src/fiber-adapter/host.ts +210 -0
- package/src/fiber-adapter/index.ts +221 -0
- package/src/fiber-adapter/scheduler.ts +70 -0
- package/src/fiber-adapter/traverse.ts +260 -0
- package/src/fiber-adapter/types.ts +142 -0
- package/src/index.ts +7 -0
- package/src/install.ts +6 -0
- package/src/json-check.ts +43 -0
- package/src/platform.ts +155 -0
- package/src/query/index.ts +5 -0
- package/src/query/matcher.ts +121 -0
- package/src/query/semantic.ts +266 -0
- package/src/query/tree.ts +116 -0
- package/src/query/visibility.ts +60 -0
- package/src/reset/index.ts +59 -0
- package/src/reset/storage-defaults.native.ts +26 -0
- package/src/reset/storage-defaults.ts +39 -0
- package/src/root/QuiescoRoot.tsx +28 -0
- package/src/runtime.ts +1003 -0
- package/src/singleton.ts +18 -0
- package/src/stub.ts +28 -0
- package/src/sync/frame-stable.ts +142 -0
- package/src/sync/idle-callback.ts +142 -0
- package/src/sync/index.ts +17 -0
- package/src/sync/native-settle.ts +41 -0
- package/src/sync/network.ts +299 -0
- package/src/sync/offline.ts +121 -0
- package/src/sync/react.ts +28 -0
- package/src/sync/timer.ts +124 -0
- package/src/sync/tracker.ts +51 -0
- package/src/sync/wait-for-idle.ts +141 -0
- package/src/version.ts +2 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket JSON-RPC client of the agent (requirements.md §7).
|
|
3
|
+
* - On open: send `hello`; the host answers with the session id and the agent config, or closes the
|
|
4
|
+
* socket with a 4xxx code (session / protocol mismatch, replaced by a newer connection).
|
|
5
|
+
* - Host → agent requests are dispatched to `handle` and answered with `result` or `error`.
|
|
6
|
+
* - Agent → host events are notifications; they are buffered until the handshake completes.
|
|
7
|
+
* - Reconnects with backoff after unexpected closes (Metro reload, host restart, network blip).
|
|
8
|
+
*/
|
|
9
|
+
import { CloseCode, JsonRpcErrorCode, PROTOCOL_VERSION } from '@quiesco/protocol';
|
|
10
|
+
import type {
|
|
11
|
+
AgentConfig,
|
|
12
|
+
AgentEventName,
|
|
13
|
+
AgentEvents,
|
|
14
|
+
HostMethods,
|
|
15
|
+
JsonRpcFailure,
|
|
16
|
+
JsonRpcRequest,
|
|
17
|
+
JsonRpcSuccess,
|
|
18
|
+
ParamsOf,
|
|
19
|
+
ResultOf,
|
|
20
|
+
} from '@quiesco/protocol';
|
|
21
|
+
|
|
22
|
+
import { QuiescoError } from '../errors';
|
|
23
|
+
import type { SocketLike } from '../platform';
|
|
24
|
+
import type { Clock } from '../sync';
|
|
25
|
+
import type { ConnectionParams } from './params';
|
|
26
|
+
|
|
27
|
+
export type HelloInfo = Omit<ParamsOf<HostMethods['hello']>, 'sessionId' | 'protocolVersion'>;
|
|
28
|
+
export type RequestHandler = (method: string, params: unknown) => Promise<unknown>;
|
|
29
|
+
export type ClientState = 'idle' | 'connecting' | 'handshaking' | 'ready' | 'rejected' | 'stopped';
|
|
30
|
+
|
|
31
|
+
export interface AgentClientOptions {
|
|
32
|
+
params: ConnectionParams;
|
|
33
|
+
hello: HelloInfo;
|
|
34
|
+
createSocket: (url: string) => SocketLike;
|
|
35
|
+
handle: RequestHandler;
|
|
36
|
+
onReady?: (config: AgentConfig) => void;
|
|
37
|
+
clock: Clock;
|
|
38
|
+
/** Diagnostics sink (defaults to nothing; the runtime wires it to the original console). */
|
|
39
|
+
log?: (message: string) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const HELLO_ID = 'hello';
|
|
43
|
+
const BACKOFF_MS = [250, 500, 1000, 2000];
|
|
44
|
+
const MAX_BUFFERED_EVENTS = 100;
|
|
45
|
+
/** Close codes after which reconnecting is pointless. */
|
|
46
|
+
const FINAL_CODES: ReadonlySet<number> = new Set([CloseCode.SESSION_MISMATCH, CloseCode.PROTOCOL_MISMATCH, CloseCode.REPLACED]);
|
|
47
|
+
|
|
48
|
+
type Incoming = Partial<JsonRpcRequest & JsonRpcSuccess & JsonRpcFailure>;
|
|
49
|
+
|
|
50
|
+
export class AgentClient {
|
|
51
|
+
private socket: SocketLike | null = null;
|
|
52
|
+
private _state: ClientState = 'idle';
|
|
53
|
+
private attempt = 0;
|
|
54
|
+
private retryTimer: unknown = null;
|
|
55
|
+
private readonly buffer: string[] = [];
|
|
56
|
+
|
|
57
|
+
constructor(private readonly options: AgentClientOptions) {}
|
|
58
|
+
|
|
59
|
+
get state(): ClientState {
|
|
60
|
+
return this._state;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get url(): string {
|
|
64
|
+
return `ws://localhost:${this.options.params.port}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
start(): void {
|
|
68
|
+
if (this._state !== 'idle' && this._state !== 'stopped') return;
|
|
69
|
+
this.connect();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
stop(): void {
|
|
73
|
+
this._state = 'stopped';
|
|
74
|
+
if (this.retryTimer !== null) this.options.clock.clearTimeout(this.retryTimer);
|
|
75
|
+
this.retryTimer = null;
|
|
76
|
+
const s = this.socket;
|
|
77
|
+
this.socket = null;
|
|
78
|
+
s?.close(CloseCode.NORMAL, 'agent stopped');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
notify<E extends AgentEventName>(event: E, params: AgentEvents[E]): void {
|
|
82
|
+
const msg = JSON.stringify({ jsonrpc: '2.0', method: event, params });
|
|
83
|
+
if (this._state === 'ready' && this.socket !== null) {
|
|
84
|
+
this.socket.send(msg);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
this.buffer.push(msg);
|
|
88
|
+
if (this.buffer.length > MAX_BUFFERED_EVENTS) this.buffer.shift();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private connect(): void {
|
|
92
|
+
this._state = 'connecting';
|
|
93
|
+
let socket: SocketLike;
|
|
94
|
+
try {
|
|
95
|
+
socket = this.options.createSocket(this.url);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
this.options.log?.(`cannot create WebSocket to ${this.url}: ${String(e)}`);
|
|
98
|
+
this.scheduleReconnect();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.socket = socket;
|
|
102
|
+
socket.onopen = () => {
|
|
103
|
+
if (this.socket !== socket) return;
|
|
104
|
+
this._state = 'handshaking';
|
|
105
|
+
const params: ParamsOf<HostMethods['hello']> = {
|
|
106
|
+
...this.options.hello,
|
|
107
|
+
sessionId: this.options.params.session,
|
|
108
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
109
|
+
};
|
|
110
|
+
socket.send(JSON.stringify({ jsonrpc: '2.0', id: HELLO_ID, method: 'hello', params }));
|
|
111
|
+
};
|
|
112
|
+
socket.onmessage = (ev) => {
|
|
113
|
+
if (this.socket === socket) this.onMessage(socket, ev.data);
|
|
114
|
+
};
|
|
115
|
+
socket.onerror = () => {
|
|
116
|
+
// onclose follows; nothing to do here.
|
|
117
|
+
};
|
|
118
|
+
socket.onclose = (ev) => {
|
|
119
|
+
if (this.socket !== socket) return;
|
|
120
|
+
this.socket = null;
|
|
121
|
+
if (this._state === 'stopped') return;
|
|
122
|
+
if (FINAL_CODES.has(ev.code)) {
|
|
123
|
+
this._state = 'rejected';
|
|
124
|
+
this.options.log?.(`host closed the connection (${ev.code}${ev.reason ? `: ${ev.reason}` : ''}); not reconnecting`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
this.scheduleReconnect();
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private scheduleReconnect(): void {
|
|
132
|
+
this._state = 'connecting';
|
|
133
|
+
const delay = BACKOFF_MS[Math.min(this.attempt, BACKOFF_MS.length - 1)] as number;
|
|
134
|
+
this.attempt++;
|
|
135
|
+
this.retryTimer = this.options.clock.setTimeout(() => {
|
|
136
|
+
this.retryTimer = null;
|
|
137
|
+
if (this._state !== 'stopped') this.connect();
|
|
138
|
+
}, delay);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private onMessage(socket: SocketLike, data: unknown): void {
|
|
142
|
+
let msg: Incoming;
|
|
143
|
+
try {
|
|
144
|
+
msg = JSON.parse(String(data)) as Incoming;
|
|
145
|
+
} catch {
|
|
146
|
+
this.send(socket, { jsonrpc: '2.0', id: null, error: { code: JsonRpcErrorCode.PARSE_ERROR, message: 'invalid JSON' } });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (msg.id === HELLO_ID && msg.method === undefined) {
|
|
150
|
+
this.onHelloResponse(socket, msg);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (typeof msg.method === 'string' && msg.id !== undefined) {
|
|
154
|
+
void this.onRequest(socket, msg.id, msg.method, msg.params);
|
|
155
|
+
}
|
|
156
|
+
// Notifications from the host are not used yet.
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private onHelloResponse(socket: SocketLike, msg: Incoming): void {
|
|
160
|
+
if (msg.error !== undefined) {
|
|
161
|
+
this.options.log?.(`hello rejected: ${msg.error.message}`);
|
|
162
|
+
return; // the host closes the socket with a final code
|
|
163
|
+
}
|
|
164
|
+
const result = msg.result as ResultOf<HostMethods['hello']>;
|
|
165
|
+
this._state = 'ready';
|
|
166
|
+
this.attempt = 0;
|
|
167
|
+
this.options.onReady?.(result.config);
|
|
168
|
+
for (const m of this.buffer.splice(0)) socket.send(m);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async onRequest(socket: SocketLike, id: string | number, method: string, params: unknown): Promise<void> {
|
|
172
|
+
try {
|
|
173
|
+
const result = await this.options.handle(method, params);
|
|
174
|
+
this.send(socket, { jsonrpc: '2.0', id, result: result ?? null });
|
|
175
|
+
} catch (e) {
|
|
176
|
+
this.send(socket, { jsonrpc: '2.0', id, error: toErrorObject(e) });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private send(socket: SocketLike, msg: object): void {
|
|
181
|
+
if (this.socket === socket) socket.send(JSON.stringify(msg));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export class MethodNotFound extends Error {
|
|
186
|
+
constructor(readonly method: string) {
|
|
187
|
+
super(`unknown method ${method}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function toErrorObject(e: unknown): JsonRpcFailure['error'] {
|
|
192
|
+
if (e instanceof QuiescoError) return e.toJSON();
|
|
193
|
+
if (e instanceof MethodNotFound) return { code: JsonRpcErrorCode.METHOD_NOT_FOUND, message: e.message };
|
|
194
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
195
|
+
return { code: JsonRpcErrorCode.INTERNAL_ERROR, message: `agent error: ${err.message}`, data: { stack: err.stack ?? '' } };
|
|
196
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where to connect (docs/spikes/05-connection-params.md). Priority:
|
|
3
|
+
* 1. Native launch parameters (iOS process args / Android intent extras), via @quiesco/native.
|
|
4
|
+
* 2. Web: `window.__QUIESCO__` injected by Playwright's addInitScript.
|
|
5
|
+
* 3. Build-time fallback generated by withQuiesco (`EXPO_PUBLIC_QUIESCO_PORT`).
|
|
6
|
+
* 4. DEFAULT_PORT with no session (JS-only mode, single device).
|
|
7
|
+
*/
|
|
8
|
+
import { DEFAULT_PORT, WEB_PARAMS_GLOBAL } from '@quiesco/protocol';
|
|
9
|
+
import type { JsonValue } from '@quiesco/protocol';
|
|
10
|
+
|
|
11
|
+
export interface ConnectionParams {
|
|
12
|
+
port: number;
|
|
13
|
+
session: string | null;
|
|
14
|
+
source: 'native' | 'web-global' | 'build' | 'default';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ParamSources {
|
|
18
|
+
native: { port?: string; session?: string } | null;
|
|
19
|
+
global: Record<string, unknown>;
|
|
20
|
+
build: { port: number | null; session: string | null };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toPort(v: unknown): number | null {
|
|
24
|
+
const n = typeof v === 'number' ? v : typeof v === 'string' ? Number.parseInt(v, 10) : Number.NaN;
|
|
25
|
+
return Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveParams(src: ParamSources): ConnectionParams {
|
|
29
|
+
const nativePort = toPort(src.native?.port);
|
|
30
|
+
if (nativePort !== null) return { port: nativePort, session: src.native?.session ?? null, source: 'native' };
|
|
31
|
+
|
|
32
|
+
const web = src.global[WEB_PARAMS_GLOBAL] as { port?: unknown; session?: unknown } | undefined;
|
|
33
|
+
const webPort = toPort(web?.port);
|
|
34
|
+
if (webPort !== null) return { port: webPort, session: typeof web?.session === 'string' ? web.session : null, source: 'web-global' };
|
|
35
|
+
|
|
36
|
+
const buildPort = toPort(src.build.port);
|
|
37
|
+
if (buildPort !== null) return { port: buildPort, session: src.build.session, source: 'build' };
|
|
38
|
+
|
|
39
|
+
return { port: DEFAULT_PORT, session: null, source: 'default' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Test launch arguments (Phase 6.5). Native: base64 of an ASCII-only JSON object (LAUNCH_PARAM_ARGS);
|
|
44
|
+
* web: the object in `window.__QUIESCO__.args`. Anything else → no arguments, with the reason.
|
|
45
|
+
*/
|
|
46
|
+
export function decodeLaunchArgs(
|
|
47
|
+
native: string | null | undefined,
|
|
48
|
+
global: Record<string, unknown>,
|
|
49
|
+
decodeBase64: (b64: string) => string,
|
|
50
|
+
): { args: Record<string, JsonValue>; problem: string | null } {
|
|
51
|
+
const isObject = (v: unknown): v is Record<string, JsonValue> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
52
|
+
if (typeof native === 'string' && native !== '') {
|
|
53
|
+
try {
|
|
54
|
+
const v: unknown = JSON.parse(decodeBase64(native));
|
|
55
|
+
return isObject(v) ? { args: v, problem: null } : { args: {}, problem: 'launch arguments are not a JSON object' };
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return { args: {}, problem: `cannot decode launch arguments (${(e as Error).message})` };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const web = global[WEB_PARAMS_GLOBAL] as { args?: unknown } | undefined;
|
|
61
|
+
if (web?.args !== undefined) return isObject(web.args) ? { args: web.args, problem: null } : { args: {}, problem: 'launch arguments are not a JSON object' };
|
|
62
|
+
return { args: {}, problem: null };
|
|
63
|
+
}
|
package/src/early.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Earliest agent code (docs/spikes/01 §1, 02 §2): installs the React DevTools hook and the scheduler
|
|
3
|
+
* patch. React Native 0.88 loads its renderer from `setup-env`, which Metro runs before the app entry,
|
|
4
|
+
* so `withQuiesco()` (metro.cjs) puts this module first in `getModulesRunBeforeMainModule`.
|
|
5
|
+
* `install.ts` imports it too, which keeps it in the bundle (and covers setups without that hook).
|
|
6
|
+
* It must not import react-native or react.
|
|
7
|
+
*/
|
|
8
|
+
import { installEarly } from './fiber-adapter';
|
|
9
|
+
|
|
10
|
+
installEarly();
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ErrorCode } from '@quiesco/protocol';
|
|
2
|
+
import type { ErrorDataMap, ErrorName, QuiescoErrorObject } from '@quiesco/protocol';
|
|
3
|
+
|
|
4
|
+
/** Error thrown inside the agent; serialized to a JSON-RPC error object by the connection layer. */
|
|
5
|
+
export class QuiescoError<N extends ErrorName = ErrorName> extends Error {
|
|
6
|
+
readonly errorName: N;
|
|
7
|
+
readonly data: ErrorDataMap[N];
|
|
8
|
+
|
|
9
|
+
constructor(name: N, message: string, data: ErrorDataMap[N]) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'QuiescoError';
|
|
12
|
+
this.errorName = name;
|
|
13
|
+
this.data = data;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get code(): (typeof ErrorCode)[N] {
|
|
17
|
+
return ErrorCode[this.errorName];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
toJSON(): QuiescoErrorObject<N> {
|
|
21
|
+
return { code: this.code, message: this.message, data: { name: this.errorName, ...this.data } };
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App → host events (requirements.md §7): console output as `log`, uncaught errors as `error`
|
|
3
|
+
* (the current step fails immediately on the host).
|
|
4
|
+
* - React Native: wraps `ErrorUtils.setGlobalHandler` (fatal → `redbox`, otherwise `uncaught`).
|
|
5
|
+
* - Browsers: `error` / `unhandledrejection` window events.
|
|
6
|
+
* Both are feature-detected; nothing here branches on the platform name.
|
|
7
|
+
* Native unhandled promise rejections are only reported by RN in __DEV__ (as LogBox warnings) and are
|
|
8
|
+
* not forwarded yet.
|
|
9
|
+
*/
|
|
10
|
+
import type { AgentEvents, LogLevel } from '@quiesco/protocol';
|
|
11
|
+
|
|
12
|
+
type Notify = <E extends keyof AgentEvents>(event: E, params: AgentEvents[E]) => void;
|
|
13
|
+
|
|
14
|
+
const LEVELS: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
15
|
+
const CONSOLE_METHODS: Record<string, LogLevel> = { debug: 'debug', log: 'info', info: 'info', warn: 'warn', error: 'error' };
|
|
16
|
+
|
|
17
|
+
interface ErrorUtilsLike {
|
|
18
|
+
getGlobalHandler(): (error: unknown, isFatal?: boolean) => void;
|
|
19
|
+
setGlobalHandler(handler: (error: unknown, isFatal?: boolean) => void): void;
|
|
20
|
+
}
|
|
21
|
+
interface EventTargetLike {
|
|
22
|
+
addEventListener(type: string, listener: (ev: unknown) => void): void;
|
|
23
|
+
removeEventListener(type: string, listener: (ev: unknown) => void): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface EventForwarderOptions {
|
|
27
|
+
notify: Notify;
|
|
28
|
+
target?: Record<string, unknown>;
|
|
29
|
+
now?: () => number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatArg(a: unknown): string {
|
|
33
|
+
if (typeof a === 'string') return a;
|
|
34
|
+
if (a instanceof Error) return a.stack ?? `${a.name}: ${a.message}`;
|
|
35
|
+
try {
|
|
36
|
+
return JSON.stringify(a) ?? String(a);
|
|
37
|
+
} catch {
|
|
38
|
+
return String(a);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function errorParts(e: unknown): { message: string; stack?: string } {
|
|
43
|
+
if (e instanceof Error) return e.stack === undefined ? { message: e.message } : { message: e.message, stack: e.stack };
|
|
44
|
+
return { message: formatArg(e) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class EventForwarder {
|
|
48
|
+
private minLevel = LEVELS.warn;
|
|
49
|
+
private readonly restores: (() => void)[] = [];
|
|
50
|
+
private readonly target: Record<string, unknown>;
|
|
51
|
+
private readonly now: () => number;
|
|
52
|
+
private forwarding = false;
|
|
53
|
+
|
|
54
|
+
constructor(private readonly options: EventForwarderOptions) {
|
|
55
|
+
this.target = options.target ?? (globalThis as unknown as Record<string, unknown>);
|
|
56
|
+
this.now = options.now ?? Date.now;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setLevel(level: LogLevel | 'off'): void {
|
|
60
|
+
this.minLevel = level === 'off' ? Number.POSITIVE_INFINITY : LEVELS[level];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
install(): void {
|
|
64
|
+
if (this.restores.length > 0) return;
|
|
65
|
+
this.installConsole();
|
|
66
|
+
this.installErrorUtils();
|
|
67
|
+
this.installWindowEvents();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
uninstall(): void {
|
|
71
|
+
while (this.restores.length > 0) this.restores.pop()?.();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private send: Notify = (event, params) => {
|
|
75
|
+
// Guard against recursion when sending itself logs (e.g. a failing socket warns).
|
|
76
|
+
if (this.forwarding) return;
|
|
77
|
+
this.forwarding = true;
|
|
78
|
+
try {
|
|
79
|
+
this.options.notify(event, params);
|
|
80
|
+
} catch {
|
|
81
|
+
// never break the app's console
|
|
82
|
+
} finally {
|
|
83
|
+
this.forwarding = false;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
private installConsole(): void {
|
|
88
|
+
const c = this.target['console'] as Record<string, unknown> | undefined;
|
|
89
|
+
if (c === undefined) return;
|
|
90
|
+
for (const [method, level] of Object.entries(CONSOLE_METHODS)) {
|
|
91
|
+
const original = c[method];
|
|
92
|
+
if (typeof original !== 'function') continue;
|
|
93
|
+
const self = this;
|
|
94
|
+
const wrapped = function (this: unknown, ...args: unknown[]) {
|
|
95
|
+
if (LEVELS[level] >= self.minLevel) self.send('log', { level, message: args.map(formatArg).join(' '), timestamp: self.now() });
|
|
96
|
+
return (original as (...a: unknown[]) => unknown).apply(this, args);
|
|
97
|
+
};
|
|
98
|
+
c[method] = wrapped;
|
|
99
|
+
this.restores.push(() => {
|
|
100
|
+
if (c[method] === wrapped) c[method] = original;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private installErrorUtils(): void {
|
|
106
|
+
const eu = this.target['ErrorUtils'] as ErrorUtilsLike | undefined;
|
|
107
|
+
if (eu === undefined || typeof eu.setGlobalHandler !== 'function') return;
|
|
108
|
+
const previous = eu.getGlobalHandler();
|
|
109
|
+
const handler = (error: unknown, isFatal?: boolean): void => {
|
|
110
|
+
this.send('error', { kind: isFatal === true ? 'redbox' : 'uncaught', ...errorParts(error), timestamp: this.now() });
|
|
111
|
+
previous(error, isFatal);
|
|
112
|
+
};
|
|
113
|
+
eu.setGlobalHandler(handler);
|
|
114
|
+
this.restores.push(() => {
|
|
115
|
+
if (eu.getGlobalHandler() === handler) eu.setGlobalHandler(previous);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private installWindowEvents(): void {
|
|
120
|
+
const w = this.target['window'] as EventTargetLike | undefined;
|
|
121
|
+
if (w === undefined || typeof w.addEventListener !== 'function') return;
|
|
122
|
+
const onError = (ev: unknown): void => {
|
|
123
|
+
const e = ev as { error?: unknown; message?: string };
|
|
124
|
+
this.send('error', { kind: 'uncaught', ...errorParts(e.error ?? e.message), timestamp: this.now() });
|
|
125
|
+
};
|
|
126
|
+
const onRejection = (ev: unknown): void => {
|
|
127
|
+
this.send('error', { kind: 'unhandledRejection', ...errorParts((ev as { reason?: unknown }).reason), timestamp: this.now() });
|
|
128
|
+
};
|
|
129
|
+
w.addEventListener('error', onError);
|
|
130
|
+
w.addEventListener('unhandledrejection', onRejection);
|
|
131
|
+
this.restores.push(() => {
|
|
132
|
+
w.removeEventListener('error', onError);
|
|
133
|
+
w.removeEventListener('unhandledrejection', onRejection);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// `expo/fetch` as the app sees it in e2e builds (Phase 6.10). withQuiesco() (metro.cjs) resolves the
|
|
2
|
+
// app's and libraries' `expo/fetch` imports here; this module's own import goes to the real one. The
|
|
3
|
+
// real module's exports are non-configurable getters, so they cannot be patched at runtime.
|
|
4
|
+
//
|
|
5
|
+
// The exported `fetch` is tracked like the global one (busy until the response and its body are read)
|
|
6
|
+
// and honors the simulated offline mode. Everything else is re-exported unchanged.
|
|
7
|
+
import { fetch as expoFetch } from 'expo/fetch';
|
|
8
|
+
|
|
9
|
+
import { getRuntime } from './singleton';
|
|
10
|
+
|
|
11
|
+
export * from 'expo/fetch';
|
|
12
|
+
|
|
13
|
+
export const fetch = getRuntime().wrapFetch(expoFetch);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform modules for iOS / Android (Metro picks this file over default-deps.ts for native platforms).
|
|
3
|
+
* Everything is loaded lazily: requiring the renderer shim at agent install time would load the
|
|
4
|
+
* renderer BEFORE the DevTools hook and scheduler patch are in place.
|
|
5
|
+
*/
|
|
6
|
+
import type { PlatformDeps } from './host';
|
|
7
|
+
import type { Fiber } from './fiber';
|
|
8
|
+
import type { Viewport } from './types';
|
|
9
|
+
|
|
10
|
+
declare const require: (id: string) => unknown;
|
|
11
|
+
|
|
12
|
+
interface FabricShim {
|
|
13
|
+
getPublicInstanceFromInternalInstanceHandle?: (fiber: Fiber) => unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const defaultPlatformDeps: PlatformDeps = {
|
|
17
|
+
getPublicInstance() {
|
|
18
|
+
// Private RN path (docs/spikes/01 §3). RN 0.88 warns about deep imports; isolated here on purpose.
|
|
19
|
+
const mod = require('react-native/Libraries/Renderer/shims/ReactFabric') as { default?: FabricShim } & FabricShim;
|
|
20
|
+
const shim = mod.default ?? mod;
|
|
21
|
+
return shim.getPublicInstanceFromInternalInstanceHandle ?? null;
|
|
22
|
+
},
|
|
23
|
+
focusedElement() {
|
|
24
|
+
const rn = require('react-native') as { TextInput?: { State?: { currentlyFocusedInput?: () => unknown } } };
|
|
25
|
+
return rn.TextInput?.State?.currentlyFocusedInput?.() ?? null;
|
|
26
|
+
},
|
|
27
|
+
viewport() {
|
|
28
|
+
const rn = require('react-native') as { Dimensions: { get(dim: 'window'): Viewport } };
|
|
29
|
+
const { width, height } = rn.Dimensions.get('window');
|
|
30
|
+
return { width, height };
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function loadScheduler(): unknown {
|
|
35
|
+
return require('scheduler');
|
|
36
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform modules for web (react-dom + react-native-web). Metro uses default-deps.native.ts on iOS / Android.
|
|
3
|
+
*/
|
|
4
|
+
import type { PlatformDeps } from './host';
|
|
5
|
+
|
|
6
|
+
declare const require: (id: string) => unknown;
|
|
7
|
+
|
|
8
|
+
interface WindowLike {
|
|
9
|
+
innerWidth: number;
|
|
10
|
+
innerHeight: number;
|
|
11
|
+
getComputedStyle(el: Element): CSSStyleDeclaration;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function win(): WindowLike | null {
|
|
15
|
+
const w = (globalThis as { window?: WindowLike }).window;
|
|
16
|
+
return w !== undefined && typeof w.getComputedStyle === 'function' ? w : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const defaultPlatformDeps: PlatformDeps = {
|
|
20
|
+
viewport() {
|
|
21
|
+
const w = win();
|
|
22
|
+
return w === null ? null : { width: w.innerWidth, height: w.innerHeight };
|
|
23
|
+
},
|
|
24
|
+
focusedElement() {
|
|
25
|
+
const doc = (globalThis as { document?: { activeElement?: unknown } }).document;
|
|
26
|
+
return doc?.activeElement ?? null;
|
|
27
|
+
},
|
|
28
|
+
getComputedStyle(el) {
|
|
29
|
+
const w = win();
|
|
30
|
+
if (w === null || typeof Element === 'undefined' || !(el instanceof Element)) return null;
|
|
31
|
+
const s = w.getComputedStyle(el);
|
|
32
|
+
return { display: s.display, opacity: s.opacity, overflowX: s.overflowX, overflowY: s.overflowY };
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function loadScheduler(): unknown {
|
|
37
|
+
return require('scheduler');
|
|
38
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal shapes of React's internal objects. Private to fiber-adapter.
|
|
3
|
+
* Field names follow React 19 (ReactInternalTypes.js / ReactFiberRoot.js); verified on React 19.3.0
|
|
4
|
+
* with react-native-renderer (Fabric) and react-dom (docs/spikes/01, 02, 07).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** WorkTag values from ReactWorkTags.js. */
|
|
8
|
+
export const WorkTag = {
|
|
9
|
+
FunctionComponent: 0,
|
|
10
|
+
ClassComponent: 1,
|
|
11
|
+
HostRoot: 3,
|
|
12
|
+
HostPortal: 4,
|
|
13
|
+
HostComponent: 5,
|
|
14
|
+
HostText: 6,
|
|
15
|
+
ForwardRef: 11,
|
|
16
|
+
MemoComponent: 14,
|
|
17
|
+
SimpleMemoComponent: 15,
|
|
18
|
+
/** react-dom only: <title>, <link>, ... hoisted into <head>. Not queryable; children are walked. */
|
|
19
|
+
HostHoistable: 26,
|
|
20
|
+
/** react-dom only: <html>, <head>, <body>. Not queryable; children are walked. */
|
|
21
|
+
HostSingleton: 27,
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export interface Fiber {
|
|
25
|
+
tag: number;
|
|
26
|
+
type: unknown;
|
|
27
|
+
key: string | null;
|
|
28
|
+
memoizedProps: unknown;
|
|
29
|
+
stateNode: unknown;
|
|
30
|
+
child: Fiber | null;
|
|
31
|
+
sibling: Fiber | null;
|
|
32
|
+
return: Fiber | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface FiberRoot {
|
|
36
|
+
current: Fiber;
|
|
37
|
+
pendingLanes: number;
|
|
38
|
+
callbackNode: unknown;
|
|
39
|
+
cancelPendingCommit: unknown;
|
|
40
|
+
timeoutHandle: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Object passed by a renderer to `hook.inject()`. */
|
|
44
|
+
export interface RendererInternals {
|
|
45
|
+
bundleType?: number;
|
|
46
|
+
version?: string;
|
|
47
|
+
rendererPackageName?: string;
|
|
48
|
+
reconcilerVersion?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The subset of `__REACT_DEVTOOLS_GLOBAL_HOOK__` that renderers call. */
|
|
52
|
+
export interface DevToolsHook {
|
|
53
|
+
supportsFiber?: boolean;
|
|
54
|
+
isDisabled?: boolean;
|
|
55
|
+
renderers?: Map<number, RendererInternals>;
|
|
56
|
+
inject: (internals: RendererInternals) => number;
|
|
57
|
+
onScheduleFiberRoot?: (rendererID: number, root: FiberRoot, children: unknown) => void;
|
|
58
|
+
onCommitFiberRoot?: (rendererID: number, root: FiberRoot, priority: unknown, didError: unknown) => void;
|
|
59
|
+
onPostCommitFiberRoot?: (rendererID: number, root: FiberRoot) => void;
|
|
60
|
+
onCommitFiberUnmount?: (rendererID: number, fiber: Fiber) => void;
|
|
61
|
+
/** react-devtools-core: the roots it has seen for a renderer. */
|
|
62
|
+
getFiberRoots?: (rendererID: number) => Set<FiberRoot>;
|
|
63
|
+
[key: string]: unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isObject(v: unknown): v is Record<string, unknown> {
|
|
67
|
+
return typeof v === 'object' && v !== null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Depth-first walk over `fiber` and its siblings (pre-order). Iterative: fiber trees can be deep. */
|
|
71
|
+
export function* walkSiblings(fiber: Fiber | null): Generator<Fiber> {
|
|
72
|
+
const stack: Fiber[] = [];
|
|
73
|
+
let node = fiber;
|
|
74
|
+
while (node !== null || stack.length > 0) {
|
|
75
|
+
if (node === null) node = stack.pop() ?? null;
|
|
76
|
+
if (node === null) break;
|
|
77
|
+
yield node;
|
|
78
|
+
if (node.sibling !== null) stack.push(node.sibling);
|
|
79
|
+
node = node.child;
|
|
80
|
+
}
|
|
81
|
+
}
|