@textmode/runner-client 0.2.0 → 0.5.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/README.md +18 -6
- package/dist/IframeTextmodeRuntime.d.ts +32 -7
- package/dist/IframeTextmodeRuntime.js +89 -10
- package/dist/index.d.ts +1 -1
- package/dist/internal/messageRouter.d.ts +2 -0
- package/dist/internal/messageRouter.js +6 -0
- package/dist/internal/requestRegistry.d.ts +0 -2
- package/dist/internal/requestRegistry.js +3 -5
- package/dist/options.d.ts +20 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Browser iframe runtime client for the hosted textmode runner.
|
|
|
5
5
|
This package gives any browser host app a typed runtime API for mounting the
|
|
6
6
|
runner iframe, performing the current generic protocol handshake, routing
|
|
7
7
|
request/response messages, monitoring heartbeat status, running code,
|
|
8
|
-
|
|
8
|
+
reconnecting, and disposing the transport.
|
|
9
9
|
|
|
10
10
|
It does not execute textmode.js sketches directly. It controls a runner app at
|
|
11
11
|
the `runnerUrl` you provide.
|
|
@@ -36,6 +36,12 @@ if (!container) {
|
|
|
36
36
|
|
|
37
37
|
const runtime = new IframeTextmodeRuntime({
|
|
38
38
|
runnerUrl: 'https://runner.textmode.art/',
|
|
39
|
+
onUserActivationRequired() {
|
|
40
|
+
container.dataset.userActivation = 'required';
|
|
41
|
+
},
|
|
42
|
+
onUserInteraction() {
|
|
43
|
+
delete container.dataset.userActivation;
|
|
44
|
+
},
|
|
39
45
|
onStatusChange(status: RunnerRuntimeStatus, reason) {
|
|
40
46
|
console.info('runner status changed', status, reason);
|
|
41
47
|
},
|
|
@@ -55,7 +61,6 @@ try {
|
|
|
55
61
|
}
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
await runtime.runCode('t.frameCount = 0;', { softReset: true });
|
|
59
64
|
runtime.dispose();
|
|
60
65
|
```
|
|
61
66
|
|
|
@@ -88,10 +93,17 @@ Typical host apps follow this lifecycle:
|
|
|
88
93
|
|
|
89
94
|
1. Create an `IframeTextmodeRuntime` with a trusted `runnerUrl`.
|
|
90
95
|
2. Call `init(container)` from a browser context.
|
|
91
|
-
3. Use `runCode`
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
96
|
+
3. Use `runCode` to replace the current sketch execution while preserving its timeline.
|
|
97
|
+
4. Use `resetRuntime` to rebuild textmode while preserving the current iframe document and its browser interaction state.
|
|
98
|
+
5. Call `reconnect` only when the iframe document or transport must be replaced.
|
|
99
|
+
6. Call `dispose` when the host view is unmounted.
|
|
100
|
+
|
|
101
|
+
`resetRuntime` falls back to reconnecting when an older runner does not advertise the optional `runtimeReset` capability.
|
|
102
|
+
|
|
103
|
+
Cross-origin WebKit runners may request one trusted child-frame interaction
|
|
104
|
+
through `onUserActivationRequired`. A host can temporarily expose or elevate
|
|
105
|
+
the existing iframe until `onUserInteraction` fires. Programmatic focus or a
|
|
106
|
+
synthetic parent-page click is not an equivalent substitute.
|
|
95
107
|
|
|
96
108
|
The runtime exposes `status`, `isReady`, and `frame` getters for host UI state.
|
|
97
109
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { type RunnerCapabilities } from '@textmode/runner-protocol';
|
|
2
|
-
import { type IframeTextmodeRuntimeOptions } from './options';
|
|
1
|
+
import { type AudioDataMessage, type RunnerCapabilities } from '@textmode/runner-protocol';
|
|
2
|
+
import { type IframeTextmodeRuntimeOptions, type RunnerProbeOptions, type RunnerReconnectOptions } from './options';
|
|
3
3
|
import type { RunnerRuntimeStatus } from './status';
|
|
4
4
|
/**
|
|
5
5
|
* Browser iframe runtime for communicating with the hosted textmode runner.
|
|
@@ -74,12 +74,13 @@ export declare class IframeTextmodeRuntime {
|
|
|
74
74
|
*/
|
|
75
75
|
dispose(): void;
|
|
76
76
|
/**
|
|
77
|
-
* Recreates the iframe and reruns the last requested code
|
|
77
|
+
* Recreates the iframe and optionally reruns the last requested code.
|
|
78
78
|
*
|
|
79
|
+
* @param options - Reconnect behavior. The last code is rerun by default.
|
|
79
80
|
* @returns `true` when reconnection succeeds.
|
|
80
81
|
* @category Runtime
|
|
81
82
|
*/
|
|
82
|
-
reconnect(): Promise<boolean>;
|
|
83
|
+
reconnect(options?: RunnerReconnectOptions): Promise<boolean>;
|
|
83
84
|
/**
|
|
84
85
|
* Focuses the iframe from a host user gesture.
|
|
85
86
|
*
|
|
@@ -93,9 +94,32 @@ export declare class IframeTextmodeRuntime {
|
|
|
93
94
|
*
|
|
94
95
|
* @category Runtime
|
|
95
96
|
*/
|
|
96
|
-
runCode(code: string
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
runCode(code: string): Promise<boolean>;
|
|
98
|
+
/**
|
|
99
|
+
* Executes code as a transactional candidate.
|
|
100
|
+
*
|
|
101
|
+
* Failed and timed-out probes do not replace the code used by reconnect.
|
|
102
|
+
*
|
|
103
|
+
* @category Runtime
|
|
104
|
+
*/
|
|
105
|
+
probeCode(code: string, options?: RunnerProbeOptions): Promise<boolean>;
|
|
106
|
+
/**
|
|
107
|
+
* Rebuilds the textmode runtime while preserving the current iframe document.
|
|
108
|
+
*
|
|
109
|
+
* Older runners fall back to a full reconnect followed by one code execution.
|
|
110
|
+
*
|
|
111
|
+
* @category Runtime
|
|
112
|
+
*/
|
|
113
|
+
resetRuntime(code: string): Promise<boolean>;
|
|
114
|
+
/**
|
|
115
|
+
* Sends a fire-and-forget audio analysis frame to the runner.
|
|
116
|
+
*
|
|
117
|
+
* Audio frames are intentionally not request-tracked: hosts may send them at
|
|
118
|
+
* animation-frame cadence, and stale frames can be safely dropped.
|
|
119
|
+
*
|
|
120
|
+
* @category Runtime
|
|
121
|
+
*/
|
|
122
|
+
sendAudioData(data: Omit<AudioDataMessage, 'type'>): boolean;
|
|
99
123
|
private connectPort;
|
|
100
124
|
private handlePortMessage;
|
|
101
125
|
private handleReady;
|
|
@@ -109,5 +133,6 @@ export declare class IframeTextmodeRuntime {
|
|
|
109
133
|
private assertSandboxOriginPolicy;
|
|
110
134
|
private setStatus;
|
|
111
135
|
private disposeFrame;
|
|
136
|
+
private rejectPendingHandshake;
|
|
112
137
|
private createRequestId;
|
|
113
138
|
}
|
|
@@ -109,10 +109,18 @@ export class IframeTextmodeRuntime {
|
|
|
109
109
|
*/
|
|
110
110
|
async init(container) {
|
|
111
111
|
this.container = container;
|
|
112
|
-
|
|
112
|
+
try {
|
|
113
|
+
this.assertSandboxOriginPolicy();
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
117
|
+
this.handleUnavailable(reason);
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
113
120
|
if (this.isReady && this.iframe?.isConnected) {
|
|
114
121
|
return true;
|
|
115
122
|
}
|
|
123
|
+
this.rejectPendingHandshake('runner initialization superseded');
|
|
116
124
|
this.disposeFrame();
|
|
117
125
|
this.ready = false;
|
|
118
126
|
this.setStatus('connecting');
|
|
@@ -129,6 +137,8 @@ export class IframeTextmodeRuntime {
|
|
|
129
137
|
}, this.handshakeTimeoutMs);
|
|
130
138
|
});
|
|
131
139
|
iframe.addEventListener('load', () => {
|
|
140
|
+
if (this.iframe !== iframe)
|
|
141
|
+
return;
|
|
132
142
|
this.connectPort();
|
|
133
143
|
}, { once: true });
|
|
134
144
|
return readyPromise;
|
|
@@ -149,6 +159,7 @@ export class IframeTextmodeRuntime {
|
|
|
149
159
|
}
|
|
150
160
|
this.pending.rejectAll(new Error('runner disposed'));
|
|
151
161
|
this.heartbeat.stop();
|
|
162
|
+
this.rejectPendingHandshake('runner disposed');
|
|
152
163
|
this.disposeFrame();
|
|
153
164
|
this.ready = false;
|
|
154
165
|
this.setStatus('idle');
|
|
@@ -156,16 +167,17 @@ export class IframeTextmodeRuntime {
|
|
|
156
167
|
this.readyRejecter = null;
|
|
157
168
|
}
|
|
158
169
|
/**
|
|
159
|
-
* Recreates the iframe and reruns the last requested code
|
|
170
|
+
* Recreates the iframe and optionally reruns the last requested code.
|
|
160
171
|
*
|
|
172
|
+
* @param options - Reconnect behavior. The last code is rerun by default.
|
|
161
173
|
* @returns `true` when reconnection succeeds.
|
|
162
174
|
* @category Runtime
|
|
163
175
|
*/
|
|
164
|
-
async reconnect() {
|
|
176
|
+
async reconnect(options = {}) {
|
|
165
177
|
if (!this.container) {
|
|
166
178
|
return false;
|
|
167
179
|
}
|
|
168
|
-
const code = this.lastRequestedCode;
|
|
180
|
+
const code = options.rerun === false ? null : this.lastRequestedCode;
|
|
169
181
|
this.setStatus('recovering');
|
|
170
182
|
this.forceDisposeFrameForReconnect();
|
|
171
183
|
const initialized = await this.init(this.container);
|
|
@@ -198,13 +210,62 @@ export class IframeTextmodeRuntime {
|
|
|
198
210
|
*
|
|
199
211
|
* @category Runtime
|
|
200
212
|
*/
|
|
201
|
-
async runCode(code
|
|
202
|
-
this.lastRequestedCode = code;
|
|
213
|
+
async runCode(code) {
|
|
203
214
|
const requestId = this.createRequestId('run');
|
|
204
|
-
const message =
|
|
205
|
-
? { type: 'SOFT_RESET', requestId, code }
|
|
206
|
-
: { type: 'RUN_CODE', requestId, code };
|
|
215
|
+
const message = { type: 'RUN_CODE', requestId, code };
|
|
207
216
|
await this.request(message);
|
|
217
|
+
this.lastRequestedCode = code;
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Executes code as a transactional candidate.
|
|
222
|
+
*
|
|
223
|
+
* Failed and timed-out probes do not replace the code used by reconnect.
|
|
224
|
+
*
|
|
225
|
+
* @category Runtime
|
|
226
|
+
*/
|
|
227
|
+
async probeCode(code, options = {}) {
|
|
228
|
+
const requestId = this.createRequestId('probe');
|
|
229
|
+
const message = { type: 'RUN_CODE', requestId, code };
|
|
230
|
+
await this.request(message, options.timeoutMs ?? this.requestTimeoutMs);
|
|
231
|
+
this.lastRequestedCode = code;
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Rebuilds the textmode runtime while preserving the current iframe document.
|
|
236
|
+
*
|
|
237
|
+
* Older runners fall back to a full reconnect followed by one code execution.
|
|
238
|
+
*
|
|
239
|
+
* @category Runtime
|
|
240
|
+
*/
|
|
241
|
+
async resetRuntime(code) {
|
|
242
|
+
if (this.capabilities?.runtimeReset !== true) {
|
|
243
|
+
const reconnected = await this.reconnect({ rerun: false });
|
|
244
|
+
return reconnected ? this.runCode(code) : false;
|
|
245
|
+
}
|
|
246
|
+
const requestId = this.createRequestId('reset');
|
|
247
|
+
await this.request({ type: 'RESET_RUNTIME', requestId, code });
|
|
248
|
+
this.lastRequestedCode = code;
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Sends a fire-and-forget audio analysis frame to the runner.
|
|
253
|
+
*
|
|
254
|
+
* Audio frames are intentionally not request-tracked: hosts may send them at
|
|
255
|
+
* animation-frame cadence, and stale frames can be safely dropped.
|
|
256
|
+
*
|
|
257
|
+
* @category Runtime
|
|
258
|
+
*/
|
|
259
|
+
sendAudioData(data) {
|
|
260
|
+
if (!this.port || !this.ready) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
this.postMessage({
|
|
264
|
+
type: 'AUDIO_DATA',
|
|
265
|
+
fft: data.fft,
|
|
266
|
+
waveform: data.waveform,
|
|
267
|
+
timestamp: data.timestamp,
|
|
268
|
+
});
|
|
208
269
|
return true;
|
|
209
270
|
}
|
|
210
271
|
connectPort() {
|
|
@@ -236,9 +297,15 @@ export class IframeTextmodeRuntime {
|
|
|
236
297
|
onSynthError: (synthErrorMessage) => {
|
|
237
298
|
this.options.onSynthError?.(synthErrorMessage.message);
|
|
238
299
|
},
|
|
300
|
+
onHardReset: () => {
|
|
301
|
+
this.options.onHardReset?.();
|
|
302
|
+
},
|
|
239
303
|
onToggleUI: () => {
|
|
240
304
|
this.options.onToggleUI?.();
|
|
241
305
|
},
|
|
306
|
+
onUserActivationRequired: () => {
|
|
307
|
+
this.options.onUserActivationRequired?.();
|
|
308
|
+
},
|
|
242
309
|
onUserInteraction: () => {
|
|
243
310
|
this.options.onUserInteraction?.();
|
|
244
311
|
},
|
|
@@ -295,7 +362,6 @@ export class IframeTextmodeRuntime {
|
|
|
295
362
|
const kind = requestKindForMessage(message.type);
|
|
296
363
|
const promise = this.pending.register({
|
|
297
364
|
requestId,
|
|
298
|
-
kind,
|
|
299
365
|
messageType: message.type,
|
|
300
366
|
timeoutMs,
|
|
301
367
|
onTimeout: (error) => {
|
|
@@ -338,6 +404,7 @@ export class IframeTextmodeRuntime {
|
|
|
338
404
|
}
|
|
339
405
|
this.heartbeat.stop();
|
|
340
406
|
this.pending.rejectAll(new Error('runner reconnecting'));
|
|
407
|
+
this.rejectPendingHandshake('runner reconnecting');
|
|
341
408
|
this.ready = false;
|
|
342
409
|
this.disposeFrame();
|
|
343
410
|
}
|
|
@@ -380,6 +447,18 @@ export class IframeTextmodeRuntime {
|
|
|
380
447
|
this.iframe = null;
|
|
381
448
|
}
|
|
382
449
|
}
|
|
450
|
+
rejectPendingHandshake(reason) {
|
|
451
|
+
if (!this.readyRejecter && !this.readyResolver) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (this.readyTimeoutId !== null) {
|
|
455
|
+
window.clearTimeout(this.readyTimeoutId);
|
|
456
|
+
this.readyTimeoutId = null;
|
|
457
|
+
}
|
|
458
|
+
this.readyRejecter?.(new Error(reason));
|
|
459
|
+
this.readyResolver = null;
|
|
460
|
+
this.readyRejecter = null;
|
|
461
|
+
}
|
|
383
462
|
createRequestId(prefix) {
|
|
384
463
|
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
385
464
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,5 +11,5 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export { IframeTextmodeRuntime } from './IframeTextmodeRuntime';
|
|
13
13
|
export { RunnerRequestError, type RunnerExecutionError } from './errors';
|
|
14
|
-
export { DEFAULT_IFRAME_SANDBOX_TOKENS, type IframeMountMode, type IframeSandboxToken, type IframeTextmodeRuntimeOptions, } from './options';
|
|
14
|
+
export { DEFAULT_IFRAME_SANDBOX_TOKENS, type IframeMountMode, type IframeSandboxToken, type IframeTextmodeRuntimeOptions, type RunnerProbeOptions, type RunnerReconnectOptions, } from './options';
|
|
15
15
|
export { type RunnerRuntimeStatus } from './status';
|
|
@@ -4,7 +4,9 @@ export interface RunnerMessageHandlers {
|
|
|
4
4
|
onRunOk: (message: RunOkMessage) => void;
|
|
5
5
|
onRunError: (message: RunErrorMessage) => void;
|
|
6
6
|
onSynthError: (message: SynthErrorMessage) => void;
|
|
7
|
+
onHardReset: () => void;
|
|
7
8
|
onToggleUI: () => void;
|
|
9
|
+
onUserActivationRequired: () => void;
|
|
8
10
|
onUserInteraction: () => void;
|
|
9
11
|
onPong: (message: PongMessage) => void;
|
|
10
12
|
}
|
|
@@ -16,9 +16,15 @@ export function routeRunnerMessage(message, handlers) {
|
|
|
16
16
|
case 'SYNTH_ERROR':
|
|
17
17
|
handlers.onSynthError(message);
|
|
18
18
|
break;
|
|
19
|
+
case 'HARD_RESET':
|
|
20
|
+
handlers.onHardReset();
|
|
21
|
+
break;
|
|
19
22
|
case 'TOGGLE_UI':
|
|
20
23
|
handlers.onToggleUI();
|
|
21
24
|
break;
|
|
25
|
+
case 'USER_ACTIVATION_REQUIRED':
|
|
26
|
+
handlers.onUserActivationRequired();
|
|
27
|
+
break;
|
|
22
28
|
case 'USER_INTERACTION':
|
|
23
29
|
handlers.onUserInteraction();
|
|
24
30
|
break;
|
|
@@ -8,7 +8,6 @@ export interface RequestTimerApi {
|
|
|
8
8
|
}
|
|
9
9
|
interface RegisterRequestOptions {
|
|
10
10
|
requestId: string;
|
|
11
|
-
kind: RequestKind;
|
|
12
11
|
messageType: ParentToRunnerMessage['type'];
|
|
13
12
|
timeoutMs: number;
|
|
14
13
|
onTimeout: (error: Error) => void;
|
|
@@ -18,7 +17,6 @@ export declare class RequestRegistry {
|
|
|
18
17
|
private readonly timerApi;
|
|
19
18
|
private readonly visibilityApi;
|
|
20
19
|
constructor(timerApi?: RequestTimerApi, visibilityApi?: PageVisibilityApi);
|
|
21
|
-
get size(): number;
|
|
22
20
|
register<T>(options: RegisterRequestOptions): Promise<T>;
|
|
23
21
|
resolve(requestId: string | undefined, value: unknown): boolean;
|
|
24
22
|
reject(requestId: string, error: Error): boolean;
|
|
@@ -7,9 +7,6 @@ export class RequestRegistry {
|
|
|
7
7
|
this.timerApi = timerApi;
|
|
8
8
|
this.visibilityApi = visibilityApi;
|
|
9
9
|
}
|
|
10
|
-
get size() {
|
|
11
|
-
return this.pending.size;
|
|
12
|
-
}
|
|
13
10
|
register(options) {
|
|
14
11
|
return new Promise((resolve, reject) => {
|
|
15
12
|
let remainingMs = options.timeoutMs;
|
|
@@ -66,7 +63,6 @@ export class RequestRegistry {
|
|
|
66
63
|
scheduleTimer();
|
|
67
64
|
}
|
|
68
65
|
this.pending.set(options.requestId, {
|
|
69
|
-
kind: options.kind,
|
|
70
66
|
resolve: resolve,
|
|
71
67
|
reject,
|
|
72
68
|
cleanup,
|
|
@@ -104,10 +100,12 @@ export class RequestRegistry {
|
|
|
104
100
|
export function requestKindForMessage(type) {
|
|
105
101
|
switch (type) {
|
|
106
102
|
case 'RUN_CODE':
|
|
107
|
-
case 'SOFT_RESET':
|
|
108
103
|
return 'run';
|
|
104
|
+
case 'RESET_RUNTIME':
|
|
105
|
+
return 'lifecycle';
|
|
109
106
|
case 'PING':
|
|
110
107
|
case 'DISPOSE':
|
|
108
|
+
case 'AUDIO_DATA':
|
|
111
109
|
return 'lifecycle';
|
|
112
110
|
}
|
|
113
111
|
}
|
package/dist/options.d.ts
CHANGED
|
@@ -13,6 +13,22 @@ export type IframeSandboxToken = 'allow-downloads' | 'allow-same-origin' | 'allo
|
|
|
13
13
|
* @category Options
|
|
14
14
|
*/
|
|
15
15
|
export type IframeMountMode = 'append' | 'replace';
|
|
16
|
+
/**
|
|
17
|
+
* Controls how a runner reconnect restores previously requested code.
|
|
18
|
+
*
|
|
19
|
+
* @category Options
|
|
20
|
+
*/
|
|
21
|
+
export interface RunnerReconnectOptions {
|
|
22
|
+
/** Rerun the last requested code after reconnecting. Defaults to `true`. */
|
|
23
|
+
rerun?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Controls a transactional code probe.
|
|
27
|
+
*/
|
|
28
|
+
export interface RunnerProbeOptions {
|
|
29
|
+
/** Request timeout for the probe. Defaults to the runtime request timeout. */
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
}
|
|
16
32
|
/**
|
|
17
33
|
* Default sandbox tokens used by the runner iframe.
|
|
18
34
|
*
|
|
@@ -50,8 +66,12 @@ export interface IframeTextmodeRuntimeOptions {
|
|
|
50
66
|
onRunError?: (error: RunnerExecutionError) => void;
|
|
51
67
|
/** Called when the runner reports a synth parameter error. */
|
|
52
68
|
onSynthError?: (message: string) => void;
|
|
69
|
+
/** Called when the runner requests a fresh host runtime. */
|
|
70
|
+
onHardReset?: () => void;
|
|
53
71
|
/** Called when the runner requests host UI visibility changes. */
|
|
54
72
|
onToggleUI?: () => void;
|
|
73
|
+
/** Called when the runner needs a trusted interaction inside its iframe document. */
|
|
74
|
+
onUserActivationRequired?: () => void;
|
|
55
75
|
/** Called when the runner reports user interaction. */
|
|
56
76
|
onUserInteraction?: () => void;
|
|
57
77
|
/** Called when the runner becomes unavailable or hung. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@textmode/runner-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Browser iframe runtime client for the hosted textmode runner.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -30,6 +30,6 @@
|
|
|
30
30
|
"prepare": "npm run build"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@textmode/runner-protocol": "^0.
|
|
33
|
+
"@textmode/runner-protocol": "^0.5.0"
|
|
34
34
|
}
|
|
35
35
|
}
|