@atolljs/devtools 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +408 -71
- package/app/api.js +68 -0
- package/app/index.html +161 -2
- package/app/main.js +877 -127
- package/app/panels/actions.js +324 -0
- package/app/panels/audit-rules.js +728 -0
- package/app/panels/audits.js +438 -0
- package/app/panels/inspect-island.js +351 -0
- package/app/panels/memory-values.js +287 -0
- package/app/panels/performance.js +304 -0
- package/app/panels/reactivity.js +271 -0
- package/app/panels/recorder.js +344 -0
- package/app/panels/recording-format.js +166 -0
- package/app/panels/route.d.ts +44 -0
- package/app/panels/route.js +264 -0
- package/app/panels/shell.js +789 -0
- package/app/panels/toast.js +38 -0
- package/dist/bin.js +704 -5
- package/dist/chunk-57NGHQUL.js +344 -0
- package/dist/{chunk-4N3NBFL3.js → chunk-AGT6VNZI.js} +179 -217
- package/dist/chunk-WIL2P7HG.js +661 -0
- package/dist/client.d.ts +7 -0
- package/dist/control.d.ts +4 -0
- package/dist/index.js +2 -1
- package/dist/node.js +2 -1
- package/dist/nodeWs.d.ts +1 -0
- package/dist/otel.d.ts +35 -0
- package/dist/otel.js +87 -0
- package/dist/otelMap.d.ts +232 -0
- package/dist/otelTransport.d.ts +69 -0
- package/dist/overlay.d.ts +16 -2
- package/dist/probes.d.ts +7 -0
- package/dist/protocol.d.ts +46 -4
- package/dist/server.d.ts +19 -0
- package/dist/server.js +61 -3
- package/package.json +6 -2
- package/src/bin.ts +27 -2
- package/src/broadcast.ts +6 -0
- package/src/client.ts +26 -2
- package/src/control.ts +51 -0
- package/src/nodeWs.ts +7 -1
- package/src/otel.ts +140 -0
- package/src/otelMap.ts +661 -0
- package/src/otelTransport.ts +286 -0
- package/src/overlay.ts +153 -27
- package/src/probes.ts +155 -0
- package/src/protocol.ts +52 -4
- package/src/server.ts +83 -3
package/src/probes.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main-thread responsiveness probes, installed by the transports next to
|
|
3
|
+
* `installMemoryProbe`:
|
|
4
|
+
*
|
|
5
|
+
* - `runtime:longframe`: Long Animation Frame entries (blocking time plus
|
|
6
|
+
* the longest scripts) where the engine supports them, `longtask`
|
|
7
|
+
* entries otherwise. While the page is hidden, only frames with at least
|
|
8
|
+
* `HIDDEN_SCRIPT_MS` of script are reported, flagged `hidden: true`.
|
|
9
|
+
* - `runtime:frames`: a rAF sampler, one event per second with fps and the
|
|
10
|
+
* frames that took over ~2x the display interval.
|
|
11
|
+
*
|
|
12
|
+
* Browser main thread only: a no-op in Node and in workers. Idempotent and
|
|
13
|
+
* reference-counted, so two connections share one observer + one rAF loop
|
|
14
|
+
* and the loop stops when the last one closes.
|
|
15
|
+
*/
|
|
16
|
+
import { emitDevtools } from '@atolljs/core';
|
|
17
|
+
|
|
18
|
+
const MAX_SCRIPTS = 5;
|
|
19
|
+
/** Long tasks past this block input; LoAF reports `blockingDuration` directly. */
|
|
20
|
+
const BUDGET_MS = 50;
|
|
21
|
+
/** While hidden, a frame is reported only with at least this much script time. */
|
|
22
|
+
export const HIDDEN_SCRIPT_MS = 100;
|
|
23
|
+
/** A gap this long means the tab was hidden/throttled, not a dropped frame. */
|
|
24
|
+
const STALL_MS = 1000;
|
|
25
|
+
|
|
26
|
+
interface LoafScript {
|
|
27
|
+
duration: number;
|
|
28
|
+
sourceURL?: string;
|
|
29
|
+
sourceFunctionName?: string;
|
|
30
|
+
invoker?: string;
|
|
31
|
+
}
|
|
32
|
+
interface LoafEntry extends PerformanceEntry {
|
|
33
|
+
blockingDuration?: number;
|
|
34
|
+
scripts?: LoafScript[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let refs = 0;
|
|
38
|
+
let teardown: (() => void) | null = null;
|
|
39
|
+
|
|
40
|
+
const isBrowserMain = (): boolean =>
|
|
41
|
+
typeof window !== 'undefined' &&
|
|
42
|
+
typeof window.document !== 'undefined' &&
|
|
43
|
+
typeof requestAnimationFrame === 'function' &&
|
|
44
|
+
!(typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope);
|
|
45
|
+
|
|
46
|
+
const observeLongFrames = (): (() => void) => {
|
|
47
|
+
if (typeof PerformanceObserver === 'undefined') return () => {};
|
|
48
|
+
const supported = PerformanceObserver.supportedEntryTypes ?? [];
|
|
49
|
+
const type = supported.includes('long-animation-frame')
|
|
50
|
+
? 'long-animation-frame'
|
|
51
|
+
: supported.includes('longtask')
|
|
52
|
+
? 'longtask'
|
|
53
|
+
: null;
|
|
54
|
+
if (!type) return () => {};
|
|
55
|
+
const obs = new PerformanceObserver((list) => {
|
|
56
|
+
const hidden = document.visibilityState === 'hidden';
|
|
57
|
+
for (const e of list.getEntries() as LoafEntry[]) {
|
|
58
|
+
const ms = Math.round(e.duration);
|
|
59
|
+
const blockingMs = Math.round(e.blockingDuration ?? Math.max(0, e.duration - BUDGET_MS));
|
|
60
|
+
// Throttled renders (background or occluded view) stretch frames with
|
|
61
|
+
// no work in them, so judge by script time, not frame duration.
|
|
62
|
+
const scriptMs = e.scripts ? e.scripts.reduce((n, s) => n + s.duration, 0) : undefined;
|
|
63
|
+
if (hidden) {
|
|
64
|
+
// longtask entries have no script attribution: their duration is the task itself.
|
|
65
|
+
if ((scriptMs ?? e.duration) < HIDDEN_SCRIPT_MS) continue;
|
|
66
|
+
} else if (e.blockingDuration === 0 && (scriptMs ?? 0) < BUDGET_MS) continue;
|
|
67
|
+
const scripts = e.scripts?.length
|
|
68
|
+
? [...e.scripts]
|
|
69
|
+
.sort((a, b) => b.duration - a.duration)
|
|
70
|
+
.slice(0, MAX_SCRIPTS)
|
|
71
|
+
.map((s) => ({
|
|
72
|
+
src: s.sourceURL ? s.sourceURL.slice(0, 300) : undefined,
|
|
73
|
+
fn: s.sourceFunctionName || s.invoker || undefined,
|
|
74
|
+
ms: Math.round(s.duration),
|
|
75
|
+
}))
|
|
76
|
+
: undefined;
|
|
77
|
+
emitDevtools({
|
|
78
|
+
type: 'runtime:longframe', ms, blockingMs,
|
|
79
|
+
...(scripts ? { scripts } : {}),
|
|
80
|
+
...(hidden ? { hidden: true } : {}),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
obs.observe({ type, buffered: true });
|
|
86
|
+
} catch {
|
|
87
|
+
return () => {};
|
|
88
|
+
}
|
|
89
|
+
return () => obs.disconnect();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const sampleFrames = (): (() => void) => {
|
|
93
|
+
let raf = 0;
|
|
94
|
+
let last = 0;
|
|
95
|
+
let windowStart = 0;
|
|
96
|
+
let frames = 0;
|
|
97
|
+
let dropped = 0;
|
|
98
|
+
/** Display interval estimate: the shortest frame seen (≥4ms), 60Hz until known. */
|
|
99
|
+
let interval = 1000 / 60;
|
|
100
|
+
let shortest = Infinity;
|
|
101
|
+
const tick = (now: number) => {
|
|
102
|
+
raf = requestAnimationFrame(tick);
|
|
103
|
+
if (!last) {
|
|
104
|
+
last = windowStart = now;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const dt = now - last;
|
|
108
|
+
last = now;
|
|
109
|
+
if (dt > STALL_MS) {
|
|
110
|
+
// hidden tab / debugger pause: restart the window, report nothing
|
|
111
|
+
windowStart = now;
|
|
112
|
+
frames = dropped = 0;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
frames++;
|
|
116
|
+
if (dt >= 4 && dt < shortest) shortest = dt;
|
|
117
|
+
if (dt > interval * 2) dropped++;
|
|
118
|
+
const elapsed = now - windowStart;
|
|
119
|
+
if (elapsed >= 1000) {
|
|
120
|
+
emitDevtools({ type: 'runtime:frames', fps: Math.round((frames * 1000) / elapsed), dropped });
|
|
121
|
+
if (shortest !== Infinity) interval = shortest;
|
|
122
|
+
shortest = Infinity;
|
|
123
|
+
windowStart = now;
|
|
124
|
+
frames = dropped = 0;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
raf = requestAnimationFrame(tick);
|
|
128
|
+
return () => cancelAnimationFrame(raf);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Start the long-frame observer and the frame sampler; returns a release
|
|
133
|
+
* function (the probes stop when every installer has released).
|
|
134
|
+
*/
|
|
135
|
+
export function installJankProbe(): () => void {
|
|
136
|
+
if (!isBrowserMain()) return () => {};
|
|
137
|
+
refs++;
|
|
138
|
+
if (!teardown) {
|
|
139
|
+
const stopObs = observeLongFrames();
|
|
140
|
+
const stopRaf = sampleFrames();
|
|
141
|
+
teardown = () => {
|
|
142
|
+
stopObs();
|
|
143
|
+
stopRaf();
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
let released = false;
|
|
147
|
+
return () => {
|
|
148
|
+
if (released) return;
|
|
149
|
+
released = true;
|
|
150
|
+
if (--refs === 0 && teardown) {
|
|
151
|
+
teardown();
|
|
152
|
+
teardown = null;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -13,6 +13,11 @@ export interface SessionInfo {
|
|
|
13
13
|
id: string;
|
|
14
14
|
/** Human label — config `name`, app title, process argv, etc. */
|
|
15
15
|
name?: string;
|
|
16
|
+
/**
|
|
17
|
+
* The shell's own framework ('react', 'vue', 'svelte', 'solid', 'angular',
|
|
18
|
+
* 'vanilla', …): drawn as the main-thread hub's mark on the app map.
|
|
19
|
+
*/
|
|
20
|
+
framework?: string;
|
|
16
21
|
runtime: 'browser' | 'node';
|
|
17
22
|
/** Browser location.href / process title — whatever helps identify it. */
|
|
18
23
|
hint?: string;
|
|
@@ -26,6 +31,18 @@ export interface SessionInfo {
|
|
|
26
31
|
closedAt?: number;
|
|
27
32
|
/** Viewer-pinned closed sessions are exempt from the retention sweep. */
|
|
28
33
|
pinned?: boolean;
|
|
34
|
+
/** Runtime facts captured at connect, for the dashboard's audits. */
|
|
35
|
+
env?: SessionEnv;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SessionEnv {
|
|
39
|
+
/** `crossOriginIsolated` — false means no SharedArrayBuffer doorbell (islands poll). */
|
|
40
|
+
crossOriginIsolated?: boolean;
|
|
41
|
+
sharedArrayBuffer?: boolean;
|
|
42
|
+
hardwareConcurrency?: number;
|
|
43
|
+
userAgent?: string;
|
|
44
|
+
/** Node `process.version` on the Node path. */
|
|
45
|
+
node?: string;
|
|
29
46
|
}
|
|
30
47
|
|
|
31
48
|
export interface HelloMessage {
|
|
@@ -38,20 +55,48 @@ export interface BatchMessage {
|
|
|
38
55
|
events: EmittedDevtoolsEvent[];
|
|
39
56
|
}
|
|
40
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Dashboard → app command (`runDevtoolsCommand` on the app side). `id` is
|
|
60
|
+
* viewer-generated and echoed on the result.
|
|
61
|
+
*/
|
|
62
|
+
export interface ControlRequest {
|
|
63
|
+
type: 'control';
|
|
64
|
+
sessionId: string;
|
|
65
|
+
id: string;
|
|
66
|
+
cmd: string;
|
|
67
|
+
args?: Record<string, unknown>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** App → dashboard reply to a ControlRequest. */
|
|
71
|
+
export interface ControlResult {
|
|
72
|
+
type: 'control-result';
|
|
73
|
+
sessionId: string;
|
|
74
|
+
id: string;
|
|
75
|
+
ok: boolean;
|
|
76
|
+
result?: unknown;
|
|
77
|
+
error?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
41
80
|
/** What an instrumented app sends on /events. */
|
|
42
|
-
export type ClientMessage = HelloMessage | BatchMessage;
|
|
81
|
+
export type ClientMessage = HelloMessage | BatchMessage | ControlResult;
|
|
82
|
+
|
|
83
|
+
/** What the server sends an instrumented app on /events. */
|
|
84
|
+
export type AppMessage = ControlRequest;
|
|
43
85
|
|
|
44
86
|
/** What a dashboard viewer receives on /view. */
|
|
45
87
|
export type ViewerMessage =
|
|
46
88
|
| { type: 'sessions'; sessions: SessionInfo[] }
|
|
47
|
-
| { type: 'batch'; session: SessionInfo; events: EmittedDevtoolsEvent[] }
|
|
89
|
+
| { type: 'batch'; session: SessionInfo; events: EmittedDevtoolsEvent[] }
|
|
90
|
+
| ControlResult;
|
|
48
91
|
|
|
49
92
|
/** What a dashboard viewer sends on /view. */
|
|
50
93
|
export type ViewerRequest =
|
|
51
94
|
/** Drop a closed session and its replayed history. Live sessions are ignored. */
|
|
52
95
|
| { type: 'dismiss'; sessionId: string }
|
|
53
96
|
/** Exempt a closed session from the retention sweep (or release it). */
|
|
54
|
-
| { type: 'pin'; sessionId: string; pinned: boolean }
|
|
97
|
+
| { type: 'pin'; sessionId: string; pinned: boolean }
|
|
98
|
+
/** Relay a command to the live app with this session id. */
|
|
99
|
+
| ControlRequest;
|
|
55
100
|
|
|
56
101
|
/**
|
|
57
102
|
* Pure-client transport: frames on a same-origin BroadcastChannel named
|
|
@@ -66,4 +111,7 @@ export type BroadcastMessage =
|
|
|
66
111
|
/** Posted on close/pagehide so dashboards can mark the session ended. */
|
|
67
112
|
| { type: 'bye'; sessionId: string }
|
|
68
113
|
/** Dashboard announcing itself — apps re-hello and replay their tail. */
|
|
69
|
-
| { type: 'view' }
|
|
114
|
+
| { type: 'view' }
|
|
115
|
+
/** Dashboard → app command; only the app whose session id matches answers. */
|
|
116
|
+
| ControlRequest
|
|
117
|
+
| ControlResult;
|
package/src/server.ts
CHANGED
|
@@ -11,6 +11,28 @@ import { join, normalize, extname } from 'node:path';
|
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import type { ClientMessage, SessionInfo, ViewerMessage, ViewerRequest } from './protocol';
|
|
13
13
|
import { acceptWebSocket, type WsConnection } from './ws';
|
|
14
|
+
import { OtelMapper, type OtelSignals } from './otelMap';
|
|
15
|
+
import {
|
|
16
|
+
createOtelPipeline,
|
|
17
|
+
createOtlpSender,
|
|
18
|
+
sessionResource,
|
|
19
|
+
type OtelPipeline,
|
|
20
|
+
type OtlpSenderOptions,
|
|
21
|
+
type ResourceAttributes,
|
|
22
|
+
} from './otelTransport';
|
|
23
|
+
|
|
24
|
+
/** Forward every ingested session's events to an OTLP/HTTP endpoint. */
|
|
25
|
+
export interface DevtoolsServerOtlpOptions extends OtlpSenderOptions {
|
|
26
|
+
/** Fixed `service.name` for every session (default: each session's name). */
|
|
27
|
+
serviceName?: string;
|
|
28
|
+
/** Extra resource attributes on every session. */
|
|
29
|
+
resource?: ResourceAttributes;
|
|
30
|
+
signals?: OtelSignals;
|
|
31
|
+
/** Default 5000ms. */
|
|
32
|
+
flushIntervalMs?: number;
|
|
33
|
+
/** Default 512. */
|
|
34
|
+
maxBatch?: number;
|
|
35
|
+
}
|
|
14
36
|
|
|
15
37
|
export interface DevtoolsServerOptions {
|
|
16
38
|
/** Default 4780. 0 picks an ephemeral port (tests). */
|
|
@@ -25,6 +47,11 @@ export interface DevtoolsServerOptions {
|
|
|
25
47
|
closedTtlMs?: number;
|
|
26
48
|
/** How often the retention sweep runs (default 60s — tests lower it). */
|
|
27
49
|
sweepMs?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Also export everything ingested to OpenTelemetry (one OTLP resource per
|
|
52
|
+
* app session). Off by default.
|
|
53
|
+
*/
|
|
54
|
+
otlp?: DevtoolsServerOtlpOptions;
|
|
28
55
|
onListen?: (url: string) => void;
|
|
29
56
|
}
|
|
30
57
|
|
|
@@ -77,6 +104,26 @@ export function createDevtoolsServer(opts: DevtoolsServerOptions = {}): Promise<
|
|
|
77
104
|
const closedTtlMs = opts.closedTtlMs ?? 30 * 60 * 1000;
|
|
78
105
|
/** Batches kept for replay — sessions updates aren't replayed (stale). */
|
|
79
106
|
const tail: ViewerMessage[] = [];
|
|
107
|
+
/** In-flight control requests: request id → the viewer awaiting the result. */
|
|
108
|
+
const pendingControl = new Map<string, WsConnection>();
|
|
109
|
+
|
|
110
|
+
/** OTLP forwarding: one mapper (resource) per session id. */
|
|
111
|
+
let otlp: OtelPipeline | null = null;
|
|
112
|
+
const otlpSessions = new Map<string, SessionInfo>();
|
|
113
|
+
if (opts.otlp) {
|
|
114
|
+
const o = opts.otlp;
|
|
115
|
+
otlp = createOtelPipeline({
|
|
116
|
+
sender: createOtlpSender(o),
|
|
117
|
+
signals: o.signals,
|
|
118
|
+
flushIntervalMs: o.flushIntervalMs,
|
|
119
|
+
maxBatch: o.maxBatch,
|
|
120
|
+
newMapper: (id) =>
|
|
121
|
+
new OtelMapper({
|
|
122
|
+
resource: sessionResource(otlpSessions.get(id) ?? { id, runtime: 'browser' }, o.serviceName, o.resource),
|
|
123
|
+
signals: o.signals,
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
80
127
|
|
|
81
128
|
const sessionList = (): SessionInfo[] => [...sessions.values(), ...closedSessions];
|
|
82
129
|
|
|
@@ -121,7 +168,10 @@ export function createDevtoolsServer(opts: DevtoolsServerOptions = {}): Promise<
|
|
|
121
168
|
|
|
122
169
|
if (url.pathname === '/view') {
|
|
123
170
|
viewers.add(conn);
|
|
124
|
-
conn.onClose = () =>
|
|
171
|
+
conn.onClose = () => {
|
|
172
|
+
viewers.delete(conn);
|
|
173
|
+
for (const [id, v] of pendingControl) if (v === conn) pendingControl.delete(id);
|
|
174
|
+
};
|
|
125
175
|
conn.onMessage = (text) => {
|
|
126
176
|
let msg: ViewerRequest;
|
|
127
177
|
try {
|
|
@@ -139,6 +189,17 @@ export function createDevtoolsServer(opts: DevtoolsServerOptions = {}): Promise<
|
|
|
139
189
|
if (!s || s.pinned === msg.pinned) return;
|
|
140
190
|
s.pinned = msg.pinned;
|
|
141
191
|
broadcast({ type: 'sessions', sessions: sessionList() });
|
|
192
|
+
} else if (msg.type === 'control') {
|
|
193
|
+
const app = [...sessions].find(([, s]) => s.id === msg.sessionId)?.[0];
|
|
194
|
+
if (!app) {
|
|
195
|
+
conn.send(JSON.stringify({
|
|
196
|
+
type: 'control-result', sessionId: msg.sessionId, id: msg.id, ok: false,
|
|
197
|
+
error: 'session is not live',
|
|
198
|
+
} satisfies ViewerMessage));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
pendingControl.set(msg.id, conn);
|
|
202
|
+
app.send(JSON.stringify(msg));
|
|
142
203
|
}
|
|
143
204
|
};
|
|
144
205
|
conn.send(JSON.stringify({ type: 'sessions', sessions: sessionList() }));
|
|
@@ -162,11 +223,27 @@ export function createDevtoolsServer(opts: DevtoolsServerOptions = {}): Promise<
|
|
|
162
223
|
return;
|
|
163
224
|
}
|
|
164
225
|
if (msg.type === 'batch' && greeted) {
|
|
165
|
-
|
|
226
|
+
const session = sessions.get(conn)!;
|
|
227
|
+
broadcast({ type: 'batch', session, events: msg.events });
|
|
228
|
+
if (otlp && Array.isArray(msg.events)) {
|
|
229
|
+
otlpSessions.set(session.id, session);
|
|
230
|
+
// Remote clocks are unknown: the mapper rebases on receive time.
|
|
231
|
+
const recv = Date.now();
|
|
232
|
+
for (const ev of msg.events) otlp.ingest(ev, recv, session.id);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (msg.type === 'control-result') {
|
|
236
|
+
const viewer = pendingControl.get(msg.id);
|
|
237
|
+
pendingControl.delete(msg.id);
|
|
238
|
+
if (viewer && viewers.has(viewer)) viewer.send(JSON.stringify(msg));
|
|
166
239
|
}
|
|
167
240
|
};
|
|
168
241
|
conn.onClose = () => {
|
|
169
242
|
const info = sessions.get(conn);
|
|
243
|
+
if (info && otlp) {
|
|
244
|
+
otlp.release(info.id);
|
|
245
|
+
otlpSessions.delete(info.id);
|
|
246
|
+
}
|
|
170
247
|
if (sessions.delete(conn) && info) {
|
|
171
248
|
info.closed = true;
|
|
172
249
|
info.closedAt = Date.now();
|
|
@@ -210,7 +287,10 @@ export function createDevtoolsServer(opts: DevtoolsServerOptions = {}): Promise<
|
|
|
210
287
|
clearInterval(sweep);
|
|
211
288
|
for (const v of viewers) v.close();
|
|
212
289
|
for (const c of sessions.keys()) c.close();
|
|
213
|
-
server.close(() =>
|
|
290
|
+
server.close(() => {
|
|
291
|
+
if (otlp) void otlp.close().then(() => done());
|
|
292
|
+
else done();
|
|
293
|
+
});
|
|
214
294
|
}),
|
|
215
295
|
});
|
|
216
296
|
});
|