@textmode/runner-client 0.1.0 → 0.2.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/README.md +10 -24
- package/dist/IframeTextmodeRuntime.d.ts +5 -68
- package/dist/IframeTextmodeRuntime.js +59 -195
- package/dist/index.d.ts +2 -4
- package/dist/index.js +2 -3
- package/dist/internal/heartbeat.d.ts +8 -0
- package/dist/internal/heartbeat.js +38 -4
- package/dist/internal/messageRouter.d.ts +1 -6
- package/dist/internal/messageRouter.js +0 -15
- package/dist/internal/requestRegistry.d.ts +5 -2
- package/dist/internal/requestRegistry.js +59 -16
- package/dist/internal/visibility.d.ts +6 -0
- package/dist/internal/visibility.js +65 -0
- package/dist/options.d.ts +2 -6
- package/package.json +2 -2
- package/dist/font.d.ts +0 -11
- package/dist/font.js +0 -1
package/README.md
CHANGED
|
@@ -4,9 +4,8 @@ Browser iframe runtime client for the hosted textmode runner.
|
|
|
4
4
|
|
|
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
|
-
request/response messages, monitoring heartbeat status,
|
|
8
|
-
|
|
9
|
-
disposing the transport.
|
|
7
|
+
request/response messages, monitoring heartbeat status, running code,
|
|
8
|
+
soft-resetting sketches, reconnecting, and disposing the transport.
|
|
10
9
|
|
|
11
10
|
It does not execute textmode.js sketches directly. It controls a runner app at
|
|
12
11
|
the `runnerUrl` you provide.
|
|
@@ -40,17 +39,9 @@ const runtime = new IframeTextmodeRuntime({
|
|
|
40
39
|
onStatusChange(status: RunnerRuntimeStatus, reason) {
|
|
41
40
|
console.info('runner status changed', status, reason);
|
|
42
41
|
},
|
|
43
|
-
onExportProgress(requestId, format, progress) {
|
|
44
|
-
console.info('export progress', requestId, format, progress);
|
|
45
|
-
},
|
|
46
42
|
});
|
|
47
43
|
|
|
48
|
-
await runtime.init(container
|
|
49
|
-
width: 640,
|
|
50
|
-
height: 640,
|
|
51
|
-
fontSize: 16,
|
|
52
|
-
frameRate: 60,
|
|
53
|
-
});
|
|
44
|
+
await runtime.init(container);
|
|
54
45
|
|
|
55
46
|
try {
|
|
56
47
|
await runtime.runCode(`
|
|
@@ -64,12 +55,7 @@ try {
|
|
|
64
55
|
}
|
|
65
56
|
}
|
|
66
57
|
|
|
67
|
-
|
|
68
|
-
format: 'png',
|
|
69
|
-
scale: 2,
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
console.info(image.filename, image.mimeType, image.blob);
|
|
58
|
+
await runtime.runCode('t.frameCount = 0;', { softReset: true });
|
|
73
59
|
runtime.dispose();
|
|
74
60
|
```
|
|
75
61
|
|
|
@@ -91,7 +77,6 @@ The main exports are:
|
|
|
91
77
|
- `RunnerRuntimeStatus`
|
|
92
78
|
- `RunnerExecutionError`
|
|
93
79
|
- `RunnerRequestError`
|
|
94
|
-
- `FontLoadResult`
|
|
95
80
|
- `IframeTextmodeRuntimeOptions`
|
|
96
81
|
- `IframeMountMode`
|
|
97
82
|
- `IframeSandboxToken`
|
|
@@ -102,9 +87,9 @@ The main exports are:
|
|
|
102
87
|
Typical host apps follow this lifecycle:
|
|
103
88
|
|
|
104
89
|
1. Create an `IframeTextmodeRuntime` with a trusted `runnerUrl`.
|
|
105
|
-
2. Call `init(container
|
|
106
|
-
3. Use `runCode
|
|
107
|
-
|
|
90
|
+
2. Call `init(container)` from a browser context.
|
|
91
|
+
3. Use `runCode` for normal execution and `runCode(code, { softReset: true })`
|
|
92
|
+
when the host should reset sketch time before rerunning code.
|
|
108
93
|
4. Call `reconnect` after a recoverable runner failure.
|
|
109
94
|
5. Call `dispose` when the host view is unmounted.
|
|
110
95
|
|
|
@@ -118,8 +103,9 @@ The default iframe sandbox tokens are:
|
|
|
118
103
|
['allow-scripts', 'allow-same-origin']
|
|
119
104
|
```
|
|
120
105
|
|
|
121
|
-
`allow-downloads` is not included by default.
|
|
122
|
-
|
|
106
|
+
`allow-downloads` is not included by default. Sketches can use the installed
|
|
107
|
+
`textmode.export.js` helpers inside the sandboxed runtime, but the host client
|
|
108
|
+
does not expose a parent-controlled export channel.
|
|
123
109
|
|
|
124
110
|
The runtime refuses to start a runner that combines `allow-scripts` and
|
|
125
111
|
`allow-same-origin` on the same origin as the parent page.
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
import type { FontLoadResult } from './font';
|
|
1
|
+
import { type RunnerCapabilities } from '@textmode/runner-protocol';
|
|
3
2
|
import { type IframeTextmodeRuntimeOptions } from './options';
|
|
4
3
|
import type { RunnerRuntimeStatus } from './status';
|
|
5
4
|
/**
|
|
@@ -17,11 +16,11 @@ export declare class IframeTextmodeRuntime {
|
|
|
17
16
|
private readonly mountMode;
|
|
18
17
|
private readonly pending;
|
|
19
18
|
private readonly heartbeat;
|
|
19
|
+
private readonly visibility;
|
|
20
20
|
private iframe;
|
|
21
21
|
private channel;
|
|
22
22
|
private port;
|
|
23
23
|
private container;
|
|
24
|
-
private settings;
|
|
25
24
|
private capabilities;
|
|
26
25
|
private ready;
|
|
27
26
|
private currentStatus;
|
|
@@ -64,11 +63,10 @@ export declare class IframeTextmodeRuntime {
|
|
|
64
63
|
* Mounts the runner iframe and performs the current protocol handshake.
|
|
65
64
|
*
|
|
66
65
|
* @param container - DOM element that should contain the runner iframe.
|
|
67
|
-
* @param settings - Optional fixed runtime settings to configure after ready.
|
|
68
66
|
* @returns `true` when the runner is ready.
|
|
69
67
|
* @category Runtime
|
|
70
68
|
*/
|
|
71
|
-
init(container: HTMLElement
|
|
69
|
+
init(container: HTMLElement): Promise<boolean>;
|
|
72
70
|
/**
|
|
73
71
|
* Disposes the iframe connection and rejects pending requests.
|
|
74
72
|
*
|
|
@@ -90,18 +88,6 @@ export declare class IframeTextmodeRuntime {
|
|
|
90
88
|
* @category Runtime
|
|
91
89
|
*/
|
|
92
90
|
activateFromUserGesture(): void;
|
|
93
|
-
/**
|
|
94
|
-
* Configures complete fixed runtime settings.
|
|
95
|
-
*
|
|
96
|
-
* @category Runtime
|
|
97
|
-
*/
|
|
98
|
-
configure(settings: RuntimeSettings): Promise<PlaybackState | null>;
|
|
99
|
-
/**
|
|
100
|
-
* Applies a partial runtime settings update.
|
|
101
|
-
*
|
|
102
|
-
* @category Runtime
|
|
103
|
-
*/
|
|
104
|
-
setSettings(settings: Partial<RuntimeSettings>): Promise<PlaybackState | null>;
|
|
105
91
|
/**
|
|
106
92
|
* Executes code in the runner.
|
|
107
93
|
*
|
|
@@ -110,57 +96,6 @@ export declare class IframeTextmodeRuntime {
|
|
|
110
96
|
runCode(code: string, options?: {
|
|
111
97
|
softReset?: boolean;
|
|
112
98
|
}): Promise<boolean>;
|
|
113
|
-
/**
|
|
114
|
-
* Exports the current runner output in any supported format.
|
|
115
|
-
*
|
|
116
|
-
* @category Exports
|
|
117
|
-
*/
|
|
118
|
-
export(format: ExportMessage['format'], options?: ImageExportOptions | SvgExportOptions | TxtExportOptions | GifExportOptions | WebmExportOptions, timeoutMs?: number): Promise<ExportResultMessage>;
|
|
119
|
-
/**
|
|
120
|
-
* Exports the current runner output as a raster image.
|
|
121
|
-
*
|
|
122
|
-
* @category Exports
|
|
123
|
-
*/
|
|
124
|
-
exportImage(options: ImageExportOptions): Promise<ExportResultMessage>;
|
|
125
|
-
/**
|
|
126
|
-
* Exports the current runner output as SVG.
|
|
127
|
-
*
|
|
128
|
-
* @category Exports
|
|
129
|
-
*/
|
|
130
|
-
exportSvg(options: SvgExportOptions): Promise<ExportResultMessage>;
|
|
131
|
-
/**
|
|
132
|
-
* Exports the current runner output as plain text.
|
|
133
|
-
*
|
|
134
|
-
* @category Exports
|
|
135
|
-
*/
|
|
136
|
-
exportTxt(options: TxtExportOptions): Promise<ExportResultMessage>;
|
|
137
|
-
/**
|
|
138
|
-
* Records an animated GIF export.
|
|
139
|
-
*
|
|
140
|
-
* @category Exports
|
|
141
|
-
*/
|
|
142
|
-
exportGif(options: GifExportOptions): Promise<ExportResultMessage>;
|
|
143
|
-
/**
|
|
144
|
-
* Records a WebM export.
|
|
145
|
-
*
|
|
146
|
-
* @category Exports
|
|
147
|
-
*/
|
|
148
|
-
exportWebm(options: WebmExportOptions): Promise<ExportResultMessage>;
|
|
149
|
-
/**
|
|
150
|
-
* Loads a font file into the runner.
|
|
151
|
-
*
|
|
152
|
-
* @category Fonts
|
|
153
|
-
*/
|
|
154
|
-
loadFont(file: File): Promise<FontLoadResult>;
|
|
155
|
-
/**
|
|
156
|
-
* Sends a playback command and resolves with the resulting playback state.
|
|
157
|
-
*
|
|
158
|
-
* @category Playback
|
|
159
|
-
*/
|
|
160
|
-
playback(action: PlaybackAction, options?: {
|
|
161
|
-
frame?: number;
|
|
162
|
-
maxFrames?: number;
|
|
163
|
-
}): Promise<PlaybackState>;
|
|
164
99
|
private connectPort;
|
|
165
100
|
private handlePortMessage;
|
|
166
101
|
private handleReady;
|
|
@@ -169,9 +104,11 @@ export declare class IframeTextmodeRuntime {
|
|
|
169
104
|
private request;
|
|
170
105
|
private postMessage;
|
|
171
106
|
private handleUnavailable;
|
|
107
|
+
private forceDisposeFrameForReconnect;
|
|
172
108
|
private validateReadyMessage;
|
|
173
109
|
private assertSandboxOriginPolicy;
|
|
174
110
|
private setStatus;
|
|
175
111
|
private disposeFrame;
|
|
112
|
+
private rejectPendingHandshake;
|
|
176
113
|
private createRequestId;
|
|
177
114
|
}
|
|
@@ -6,15 +6,7 @@ import { createRunnerIframe, focusElement, mountRunnerIframe } from './internal/
|
|
|
6
6
|
import { routeRunnerMessage } from './internal/messageRouter';
|
|
7
7
|
import { RequestRegistry, requestKindForMessage } from './internal/requestRegistry';
|
|
8
8
|
import { assertSandboxOriginPolicy } from './internal/sandboxPolicy';
|
|
9
|
-
|
|
10
|
-
if (error instanceof Error && error.message) {
|
|
11
|
-
return error.message;
|
|
12
|
-
}
|
|
13
|
-
if (typeof error === 'string' && error.trim()) {
|
|
14
|
-
return error;
|
|
15
|
-
}
|
|
16
|
-
return fallback;
|
|
17
|
-
};
|
|
9
|
+
import { createDocumentVisibilityApi } from './internal/visibility';
|
|
18
10
|
/**
|
|
19
11
|
* Browser iframe runtime for communicating with the hosted textmode runner.
|
|
20
12
|
*
|
|
@@ -28,13 +20,13 @@ export class IframeTextmodeRuntime {
|
|
|
28
20
|
requestTimeoutMs;
|
|
29
21
|
options;
|
|
30
22
|
mountMode;
|
|
31
|
-
pending
|
|
23
|
+
pending;
|
|
32
24
|
heartbeat;
|
|
25
|
+
visibility;
|
|
33
26
|
iframe = null;
|
|
34
27
|
channel = null;
|
|
35
28
|
port = null;
|
|
36
29
|
container = null;
|
|
37
|
-
settings = null;
|
|
38
30
|
capabilities = null;
|
|
39
31
|
ready = false;
|
|
40
32
|
currentStatus = 'idle';
|
|
@@ -51,9 +43,12 @@ export class IframeTextmodeRuntime {
|
|
|
51
43
|
this.sandboxTokens = [...(options.sandboxTokens ?? DEFAULT_IFRAME_SANDBOX_TOKENS)];
|
|
52
44
|
this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? 5000;
|
|
53
45
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 12000;
|
|
46
|
+
this.visibility = createDocumentVisibilityApi();
|
|
47
|
+
this.pending = new RequestRegistry(undefined, this.visibility);
|
|
54
48
|
this.heartbeat = new HeartbeatController({
|
|
55
49
|
intervalMs: options.heartbeatIntervalMs ?? 2000,
|
|
56
50
|
timeoutMs: options.heartbeatTimeoutMs ?? 10000,
|
|
51
|
+
visibilityApi: this.visibility,
|
|
57
52
|
onPing: () => {
|
|
58
53
|
this.postMessage({
|
|
59
54
|
type: 'PING',
|
|
@@ -109,20 +104,16 @@ export class IframeTextmodeRuntime {
|
|
|
109
104
|
* Mounts the runner iframe and performs the current protocol handshake.
|
|
110
105
|
*
|
|
111
106
|
* @param container - DOM element that should contain the runner iframe.
|
|
112
|
-
* @param settings - Optional fixed runtime settings to configure after ready.
|
|
113
107
|
* @returns `true` when the runner is ready.
|
|
114
108
|
* @category Runtime
|
|
115
109
|
*/
|
|
116
|
-
async init(container
|
|
110
|
+
async init(container) {
|
|
117
111
|
this.container = container;
|
|
118
|
-
this.settings = settings ? { ...settings } : null;
|
|
119
112
|
this.assertSandboxOriginPolicy();
|
|
120
113
|
if (this.isReady && this.iframe?.isConnected) {
|
|
121
|
-
if (settings) {
|
|
122
|
-
await this.configure(settings);
|
|
123
|
-
}
|
|
124
114
|
return true;
|
|
125
115
|
}
|
|
116
|
+
this.rejectPendingHandshake('runner initialization superseded');
|
|
126
117
|
this.disposeFrame();
|
|
127
118
|
this.ready = false;
|
|
128
119
|
this.setStatus('connecting');
|
|
@@ -139,6 +130,8 @@ export class IframeTextmodeRuntime {
|
|
|
139
130
|
}, this.handshakeTimeoutMs);
|
|
140
131
|
});
|
|
141
132
|
iframe.addEventListener('load', () => {
|
|
133
|
+
if (this.iframe !== iframe)
|
|
134
|
+
return;
|
|
142
135
|
this.connectPort();
|
|
143
136
|
}, { once: true });
|
|
144
137
|
return readyPromise;
|
|
@@ -159,6 +152,7 @@ export class IframeTextmodeRuntime {
|
|
|
159
152
|
}
|
|
160
153
|
this.pending.rejectAll(new Error('runner disposed'));
|
|
161
154
|
this.heartbeat.stop();
|
|
155
|
+
this.rejectPendingHandshake('runner disposed');
|
|
162
156
|
this.disposeFrame();
|
|
163
157
|
this.ready = false;
|
|
164
158
|
this.setStatus('idle');
|
|
@@ -177,7 +171,8 @@ export class IframeTextmodeRuntime {
|
|
|
177
171
|
}
|
|
178
172
|
const code = this.lastRequestedCode;
|
|
179
173
|
this.setStatus('recovering');
|
|
180
|
-
|
|
174
|
+
this.forceDisposeFrameForReconnect();
|
|
175
|
+
const initialized = await this.init(this.container);
|
|
181
176
|
if (initialized && code) {
|
|
182
177
|
void this.runCode(code);
|
|
183
178
|
}
|
|
@@ -202,42 +197,6 @@ export class IframeTextmodeRuntime {
|
|
|
202
197
|
// Element focus still helps browsers that gate iframe animation cadence.
|
|
203
198
|
}
|
|
204
199
|
}
|
|
205
|
-
/**
|
|
206
|
-
* Configures complete fixed runtime settings.
|
|
207
|
-
*
|
|
208
|
-
* @category Runtime
|
|
209
|
-
*/
|
|
210
|
-
async configure(settings) {
|
|
211
|
-
this.settings = { ...settings };
|
|
212
|
-
const wasReady = this.ready;
|
|
213
|
-
this.setStatus('configuring');
|
|
214
|
-
return this.request({
|
|
215
|
-
type: 'CONFIGURE_RUNTIME',
|
|
216
|
-
requestId: this.createRequestId('settings'),
|
|
217
|
-
settings,
|
|
218
|
-
}).then((message) => {
|
|
219
|
-
if (wasReady) {
|
|
220
|
-
this.setStatus('ready');
|
|
221
|
-
}
|
|
222
|
-
return message.state;
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
/**
|
|
226
|
-
* Applies a partial runtime settings update.
|
|
227
|
-
*
|
|
228
|
-
* @category Runtime
|
|
229
|
-
*/
|
|
230
|
-
async setSettings(settings) {
|
|
231
|
-
this.settings = {
|
|
232
|
-
...(this.settings ?? settings),
|
|
233
|
-
...settings,
|
|
234
|
-
};
|
|
235
|
-
return this.request({
|
|
236
|
-
type: 'SET_SETTINGS',
|
|
237
|
-
requestId: this.createRequestId('settings'),
|
|
238
|
-
settings,
|
|
239
|
-
}).then((message) => message.state);
|
|
240
|
-
}
|
|
241
200
|
/**
|
|
242
201
|
* Executes code in the runner.
|
|
243
202
|
*
|
|
@@ -252,95 +211,6 @@ export class IframeTextmodeRuntime {
|
|
|
252
211
|
await this.request(message);
|
|
253
212
|
return true;
|
|
254
213
|
}
|
|
255
|
-
/**
|
|
256
|
-
* Exports the current runner output in any supported format.
|
|
257
|
-
*
|
|
258
|
-
* @category Exports
|
|
259
|
-
*/
|
|
260
|
-
async export(format, options, timeoutMs) {
|
|
261
|
-
const message = {
|
|
262
|
-
type: 'EXPORT',
|
|
263
|
-
requestId: this.createRequestId('export'),
|
|
264
|
-
format,
|
|
265
|
-
options,
|
|
266
|
-
};
|
|
267
|
-
return this.request(message, timeoutMs);
|
|
268
|
-
}
|
|
269
|
-
/**
|
|
270
|
-
* Exports the current runner output as a raster image.
|
|
271
|
-
*
|
|
272
|
-
* @category Exports
|
|
273
|
-
*/
|
|
274
|
-
async exportImage(options) {
|
|
275
|
-
return this.export('image', options);
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* Exports the current runner output as SVG.
|
|
279
|
-
*
|
|
280
|
-
* @category Exports
|
|
281
|
-
*/
|
|
282
|
-
async exportSvg(options) {
|
|
283
|
-
return this.export('svg', options);
|
|
284
|
-
}
|
|
285
|
-
/**
|
|
286
|
-
* Exports the current runner output as plain text.
|
|
287
|
-
*
|
|
288
|
-
* @category Exports
|
|
289
|
-
*/
|
|
290
|
-
async exportTxt(options) {
|
|
291
|
-
return this.export('txt', options);
|
|
292
|
-
}
|
|
293
|
-
/**
|
|
294
|
-
* Records an animated GIF export.
|
|
295
|
-
*
|
|
296
|
-
* @category Exports
|
|
297
|
-
*/
|
|
298
|
-
async exportGif(options) {
|
|
299
|
-
return this.export('gif', options, Math.max(this.requestTimeoutMs, 120000));
|
|
300
|
-
}
|
|
301
|
-
/**
|
|
302
|
-
* Records a WebM export.
|
|
303
|
-
*
|
|
304
|
-
* @category Exports
|
|
305
|
-
*/
|
|
306
|
-
async exportWebm(options) {
|
|
307
|
-
return this.export('webm', options, Math.max(this.requestTimeoutMs, 120000));
|
|
308
|
-
}
|
|
309
|
-
/**
|
|
310
|
-
* Loads a font file into the runner.
|
|
311
|
-
*
|
|
312
|
-
* @category Fonts
|
|
313
|
-
*/
|
|
314
|
-
async loadFont(file) {
|
|
315
|
-
const requestId = this.createRequestId('font');
|
|
316
|
-
const buffer = await file.arrayBuffer();
|
|
317
|
-
const message = {
|
|
318
|
-
type: 'LOAD_FONT',
|
|
319
|
-
requestId,
|
|
320
|
-
fileName: file.name,
|
|
321
|
-
mimeType: file.type || undefined,
|
|
322
|
-
buffer,
|
|
323
|
-
};
|
|
324
|
-
return this.request(message, undefined, [buffer]).then((result) => ({
|
|
325
|
-
familyName: result.familyName,
|
|
326
|
-
characters: result.characters,
|
|
327
|
-
}));
|
|
328
|
-
}
|
|
329
|
-
/**
|
|
330
|
-
* Sends a playback command and resolves with the resulting playback state.
|
|
331
|
-
*
|
|
332
|
-
* @category Playback
|
|
333
|
-
*/
|
|
334
|
-
async playback(action, options = {}) {
|
|
335
|
-
const message = {
|
|
336
|
-
type: 'PLAYBACK',
|
|
337
|
-
requestId: this.createRequestId('playback'),
|
|
338
|
-
action,
|
|
339
|
-
frame: options.frame,
|
|
340
|
-
maxFrames: options.maxFrames,
|
|
341
|
-
};
|
|
342
|
-
return this.request(message).then((result) => result.state);
|
|
343
|
-
}
|
|
344
214
|
connectPort() {
|
|
345
215
|
if (!this.iframe?.contentWindow) {
|
|
346
216
|
this.handleUnavailable('runner frame is unavailable');
|
|
@@ -361,8 +231,10 @@ export class IframeTextmodeRuntime {
|
|
|
361
231
|
routeRunnerMessage(message, {
|
|
362
232
|
onReady: (readyMessage) => this.handleReady(readyMessage),
|
|
363
233
|
onRunOk: (runOkMessage) => {
|
|
364
|
-
this.pending.resolve(runOkMessage.requestId, runOkMessage);
|
|
365
|
-
|
|
234
|
+
const resolved = this.pending.resolve(runOkMessage.requestId, runOkMessage);
|
|
235
|
+
if (!runOkMessage.requestId || resolved) {
|
|
236
|
+
this.options.onRunOk?.(runOkMessage);
|
|
237
|
+
}
|
|
366
238
|
},
|
|
367
239
|
onRunError: (runErrorMessage) => this.handleRunError(runErrorMessage),
|
|
368
240
|
onSynthError: (synthErrorMessage) => {
|
|
@@ -374,24 +246,6 @@ export class IframeTextmodeRuntime {
|
|
|
374
246
|
onUserInteraction: () => {
|
|
375
247
|
this.options.onUserInteraction?.();
|
|
376
248
|
},
|
|
377
|
-
onExportProgress: (exportProgressMessage) => {
|
|
378
|
-
this.options.onExportProgress?.(exportProgressMessage.requestId, exportProgressMessage.format, exportProgressMessage.progress);
|
|
379
|
-
},
|
|
380
|
-
onExportResult: (exportResultMessage) => {
|
|
381
|
-
this.pending.resolve(exportResultMessage.requestId, exportResultMessage);
|
|
382
|
-
},
|
|
383
|
-
onFontLoaded: (fontLoadedMessage) => {
|
|
384
|
-
this.pending.resolve(fontLoadedMessage.requestId, fontLoadedMessage);
|
|
385
|
-
},
|
|
386
|
-
onFontError: (fontErrorMessage) => {
|
|
387
|
-
this.pending.reject(fontErrorMessage.requestId, new Error(fontErrorMessage.message));
|
|
388
|
-
},
|
|
389
|
-
onPlaybackState: (playbackStateMessage) => {
|
|
390
|
-
this.options.onPlaybackState?.(playbackStateMessage.state);
|
|
391
|
-
if (playbackStateMessage.requestId) {
|
|
392
|
-
this.pending.resolve(playbackStateMessage.requestId, playbackStateMessage);
|
|
393
|
-
}
|
|
394
|
-
},
|
|
395
249
|
onPong: () => {
|
|
396
250
|
this.heartbeat.markPong();
|
|
397
251
|
},
|
|
@@ -409,23 +263,7 @@ export class IframeTextmodeRuntime {
|
|
|
409
263
|
this.readyTimeoutId = null;
|
|
410
264
|
}
|
|
411
265
|
this.options.onConnected?.();
|
|
412
|
-
|
|
413
|
-
if (settings) {
|
|
414
|
-
void this.configure(settings)
|
|
415
|
-
.then(() => {
|
|
416
|
-
this.markReady();
|
|
417
|
-
})
|
|
418
|
-
.catch((error) => {
|
|
419
|
-
this.ready = false;
|
|
420
|
-
this.setStatus('unavailable', getRuntimeErrorMessage(error, 'runner configuration failed'));
|
|
421
|
-
this.readyRejecter?.(error);
|
|
422
|
-
this.readyResolver = null;
|
|
423
|
-
this.readyRejecter = null;
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
else {
|
|
427
|
-
this.markReady();
|
|
428
|
-
}
|
|
266
|
+
this.markReady();
|
|
429
267
|
}
|
|
430
268
|
markReady() {
|
|
431
269
|
this.ready = true;
|
|
@@ -449,37 +287,36 @@ export class IframeTextmodeRuntime {
|
|
|
449
287
|
column: message.column,
|
|
450
288
|
});
|
|
451
289
|
}
|
|
452
|
-
request(message, timeoutMs = this.requestTimeoutMs
|
|
453
|
-
if (!this.port ||
|
|
290
|
+
request(message, timeoutMs = this.requestTimeoutMs) {
|
|
291
|
+
if (!this.port || !this.ready) {
|
|
454
292
|
return Promise.reject(new Error('runner is not ready'));
|
|
455
293
|
}
|
|
456
294
|
const requestId = 'requestId' in message ? message.requestId : undefined;
|
|
457
295
|
if (!requestId) {
|
|
458
|
-
this.postMessage(message
|
|
296
|
+
this.postMessage(message);
|
|
459
297
|
return Promise.resolve(undefined);
|
|
460
298
|
}
|
|
299
|
+
const kind = requestKindForMessage(message.type);
|
|
461
300
|
const promise = this.pending.register({
|
|
462
301
|
requestId,
|
|
463
|
-
kind
|
|
302
|
+
kind,
|
|
464
303
|
messageType: message.type,
|
|
465
304
|
timeoutMs,
|
|
466
305
|
onTimeout: (error) => {
|
|
306
|
+
if (kind === 'run') {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
467
309
|
this.handleUnavailable(error.message);
|
|
468
310
|
},
|
|
469
311
|
});
|
|
470
|
-
this.postMessage(message
|
|
312
|
+
this.postMessage(message);
|
|
471
313
|
return promise;
|
|
472
314
|
}
|
|
473
|
-
postMessage(message
|
|
315
|
+
postMessage(message) {
|
|
474
316
|
if (!this.port) {
|
|
475
317
|
throw new Error('runner port is not connected');
|
|
476
318
|
}
|
|
477
|
-
|
|
478
|
-
this.port.postMessage(message, transfer);
|
|
479
|
-
}
|
|
480
|
-
else {
|
|
481
|
-
this.port.postMessage(message);
|
|
482
|
-
}
|
|
319
|
+
this.port.postMessage(message);
|
|
483
320
|
}
|
|
484
321
|
handleUnavailable(reason) {
|
|
485
322
|
const error = new Error(reason);
|
|
@@ -490,17 +327,32 @@ export class IframeTextmodeRuntime {
|
|
|
490
327
|
this.readyResolver = null;
|
|
491
328
|
this.readyRejecter = null;
|
|
492
329
|
this.disposeFrame();
|
|
493
|
-
const status = reason
|
|
330
|
+
const status = reason === 'runner heartbeat timed out' ? 'hung' : 'unavailable';
|
|
494
331
|
this.setStatus(status, reason);
|
|
495
332
|
this.options.onUnavailable?.(reason, status);
|
|
496
333
|
}
|
|
334
|
+
forceDisposeFrameForReconnect() {
|
|
335
|
+
if (this.port) {
|
|
336
|
+
try {
|
|
337
|
+
this.postMessage({ type: 'DISPOSE' });
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// The existing connection may already be unavailable.
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
this.heartbeat.stop();
|
|
344
|
+
this.pending.rejectAll(new Error('runner reconnecting'));
|
|
345
|
+
this.rejectPendingHandshake('runner reconnecting');
|
|
346
|
+
this.ready = false;
|
|
347
|
+
this.disposeFrame();
|
|
348
|
+
}
|
|
497
349
|
validateReadyMessage(message) {
|
|
498
350
|
const capabilities = message.capabilities;
|
|
499
351
|
if (!isRunnerCapabilities(capabilities)) {
|
|
500
352
|
return 'runner did not advertise a valid current capability set';
|
|
501
353
|
}
|
|
502
|
-
if (!capabilities.
|
|
503
|
-
return 'runner does not support
|
|
354
|
+
if (!capabilities.heartbeat) {
|
|
355
|
+
return 'runner does not support heartbeat monitoring';
|
|
504
356
|
}
|
|
505
357
|
return null;
|
|
506
358
|
}
|
|
@@ -533,6 +385,18 @@ export class IframeTextmodeRuntime {
|
|
|
533
385
|
this.iframe = null;
|
|
534
386
|
}
|
|
535
387
|
}
|
|
388
|
+
rejectPendingHandshake(reason) {
|
|
389
|
+
if (!this.readyRejecter && !this.readyResolver) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (this.readyTimeoutId !== null) {
|
|
393
|
+
window.clearTimeout(this.readyTimeoutId);
|
|
394
|
+
this.readyTimeoutId = null;
|
|
395
|
+
}
|
|
396
|
+
this.readyRejecter?.(new Error(reason));
|
|
397
|
+
this.readyResolver = null;
|
|
398
|
+
this.readyRejecter = null;
|
|
399
|
+
}
|
|
536
400
|
createRequestId(prefix) {
|
|
537
401
|
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
538
402
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,14 +4,12 @@
|
|
|
4
4
|
* Browser iframe runtime client for the hosted textmode runner.
|
|
5
5
|
*
|
|
6
6
|
* `@textmode/runner-client` manages the runner iframe lifecycle, current
|
|
7
|
-
* protocol handshake, request/response routing, heartbeat monitoring,
|
|
8
|
-
*
|
|
9
|
-
* apps.
|
|
7
|
+
* protocol handshake, request/response routing, heartbeat monitoring, reconnect,
|
|
8
|
+
* and disposal for host apps.
|
|
10
9
|
*
|
|
11
10
|
* @module @textmode/runner-client
|
|
12
11
|
*/
|
|
13
12
|
export { IframeTextmodeRuntime } from './IframeTextmodeRuntime';
|
|
14
13
|
export { RunnerRequestError, type RunnerExecutionError } from './errors';
|
|
15
|
-
export { type FontLoadResult } from './font';
|
|
16
14
|
export { DEFAULT_IFRAME_SANDBOX_TOKENS, type IframeMountMode, type IframeSandboxToken, type IframeTextmodeRuntimeOptions, } from './options';
|
|
17
15
|
export { type RunnerRuntimeStatus } from './status';
|
package/dist/index.js
CHANGED
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
* Browser iframe runtime client for the hosted textmode runner.
|
|
5
5
|
*
|
|
6
6
|
* `@textmode/runner-client` manages the runner iframe lifecycle, current
|
|
7
|
-
* protocol handshake, request/response routing, heartbeat monitoring,
|
|
8
|
-
*
|
|
9
|
-
* apps.
|
|
7
|
+
* protocol handshake, request/response routing, heartbeat monitoring, reconnect,
|
|
8
|
+
* and disposal for host apps.
|
|
10
9
|
*
|
|
11
10
|
* @module @textmode/runner-client
|
|
12
11
|
*/
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type PageVisibilityApi } from './visibility';
|
|
1
2
|
export interface HeartbeatTimerApi {
|
|
2
3
|
setInterval: (handler: () => void, intervalMs: number) => number;
|
|
3
4
|
clearInterval: (intervalId: number) => void;
|
|
@@ -7,6 +8,7 @@ export interface HeartbeatControllerOptions {
|
|
|
7
8
|
timeoutMs: number;
|
|
8
9
|
now?: () => number;
|
|
9
10
|
timerApi?: HeartbeatTimerApi;
|
|
11
|
+
visibilityApi?: PageVisibilityApi;
|
|
10
12
|
onPing: () => void;
|
|
11
13
|
onTimeout: () => void;
|
|
12
14
|
}
|
|
@@ -15,12 +17,18 @@ export declare class HeartbeatController {
|
|
|
15
17
|
private readonly timeoutMs;
|
|
16
18
|
private readonly now;
|
|
17
19
|
private readonly timerApi;
|
|
20
|
+
private readonly visibilityApi;
|
|
18
21
|
private readonly onPing;
|
|
19
22
|
private readonly onTimeout;
|
|
20
23
|
private intervalId;
|
|
24
|
+
private cleanupVisibility;
|
|
25
|
+
private isActive;
|
|
21
26
|
private lastPongAt;
|
|
22
27
|
constructor(options: HeartbeatControllerOptions);
|
|
23
28
|
start(): void;
|
|
24
29
|
stop(): void;
|
|
25
30
|
markPong(): void;
|
|
31
|
+
private readonly handleVisibilityChange;
|
|
32
|
+
private startInterval;
|
|
33
|
+
private stopInterval;
|
|
26
34
|
}
|
|
@@ -1,24 +1,61 @@
|
|
|
1
|
+
import { createDocumentVisibilityApi, isPageVisible } from './visibility';
|
|
1
2
|
export class HeartbeatController {
|
|
2
3
|
intervalMs;
|
|
3
4
|
timeoutMs;
|
|
4
5
|
now;
|
|
5
6
|
timerApi;
|
|
7
|
+
visibilityApi;
|
|
6
8
|
onPing;
|
|
7
9
|
onTimeout;
|
|
8
10
|
intervalId = null;
|
|
11
|
+
cleanupVisibility = null;
|
|
12
|
+
isActive = false;
|
|
9
13
|
lastPongAt = 0;
|
|
10
14
|
constructor(options) {
|
|
11
15
|
this.intervalMs = options.intervalMs;
|
|
12
16
|
this.timeoutMs = options.timeoutMs;
|
|
13
17
|
this.now = options.now ?? Date.now;
|
|
14
18
|
this.timerApi = options.timerApi ?? createWindowTimerApi();
|
|
19
|
+
this.visibilityApi = options.visibilityApi ?? createDocumentVisibilityApi();
|
|
15
20
|
this.onPing = options.onPing;
|
|
16
21
|
this.onTimeout = options.onTimeout;
|
|
17
22
|
}
|
|
18
23
|
start() {
|
|
19
24
|
this.stop();
|
|
25
|
+
this.isActive = true;
|
|
20
26
|
this.lastPongAt = this.now();
|
|
27
|
+
this.cleanupVisibility = this.visibilityApi.addChangeListener(this.handleVisibilityChange);
|
|
28
|
+
if (isPageVisible(this.visibilityApi)) {
|
|
29
|
+
this.startInterval();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
stop() {
|
|
33
|
+
this.isActive = false;
|
|
34
|
+
this.stopInterval();
|
|
35
|
+
this.cleanupVisibility?.();
|
|
36
|
+
this.cleanupVisibility = null;
|
|
37
|
+
}
|
|
38
|
+
markPong() {
|
|
39
|
+
this.lastPongAt = this.now();
|
|
40
|
+
}
|
|
41
|
+
handleVisibilityChange = () => {
|
|
42
|
+
if (!this.isActive)
|
|
43
|
+
return;
|
|
44
|
+
if (!isPageVisible(this.visibilityApi)) {
|
|
45
|
+
this.stopInterval();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
this.lastPongAt = this.now();
|
|
49
|
+
this.startInterval();
|
|
50
|
+
};
|
|
51
|
+
startInterval() {
|
|
52
|
+
if (this.intervalId !== null)
|
|
53
|
+
return;
|
|
21
54
|
this.intervalId = this.timerApi.setInterval(() => {
|
|
55
|
+
if (!isPageVisible(this.visibilityApi)) {
|
|
56
|
+
this.stopInterval();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
22
59
|
if (this.now() - this.lastPongAt > this.timeoutMs) {
|
|
23
60
|
this.onTimeout();
|
|
24
61
|
return;
|
|
@@ -26,15 +63,12 @@ export class HeartbeatController {
|
|
|
26
63
|
this.onPing();
|
|
27
64
|
}, this.intervalMs);
|
|
28
65
|
}
|
|
29
|
-
|
|
66
|
+
stopInterval() {
|
|
30
67
|
if (this.intervalId !== null) {
|
|
31
68
|
this.timerApi.clearInterval(this.intervalId);
|
|
32
69
|
this.intervalId = null;
|
|
33
70
|
}
|
|
34
71
|
}
|
|
35
|
-
markPong() {
|
|
36
|
-
this.lastPongAt = this.now();
|
|
37
|
-
}
|
|
38
72
|
}
|
|
39
73
|
function createWindowTimerApi() {
|
|
40
74
|
return {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type PongMessage, type ReadyMessage, type RunErrorMessage, type RunOkMessage, type SynthErrorMessage } from '@textmode/runner-protocol';
|
|
2
2
|
export interface RunnerMessageHandlers {
|
|
3
3
|
onReady: (message: ReadyMessage) => void;
|
|
4
4
|
onRunOk: (message: RunOkMessage) => void;
|
|
@@ -6,11 +6,6 @@ export interface RunnerMessageHandlers {
|
|
|
6
6
|
onSynthError: (message: SynthErrorMessage) => void;
|
|
7
7
|
onToggleUI: () => void;
|
|
8
8
|
onUserInteraction: () => void;
|
|
9
|
-
onExportProgress: (message: ExportProgressMessage) => void;
|
|
10
|
-
onExportResult: (message: ExportResultMessage) => void;
|
|
11
|
-
onFontLoaded: (message: FontLoadedMessage) => void;
|
|
12
|
-
onFontError: (message: FontErrorMessage) => void;
|
|
13
|
-
onPlaybackState: (message: PlaybackStateMessage) => void;
|
|
14
9
|
onPong: (message: PongMessage) => void;
|
|
15
10
|
}
|
|
16
11
|
export declare function routeRunnerMessage(message: unknown, handlers: RunnerMessageHandlers): boolean;
|
|
@@ -22,21 +22,6 @@ export function routeRunnerMessage(message, handlers) {
|
|
|
22
22
|
case 'USER_INTERACTION':
|
|
23
23
|
handlers.onUserInteraction();
|
|
24
24
|
break;
|
|
25
|
-
case 'EXPORT_PROGRESS':
|
|
26
|
-
handlers.onExportProgress(message);
|
|
27
|
-
break;
|
|
28
|
-
case 'EXPORT_RESULT':
|
|
29
|
-
handlers.onExportResult(message);
|
|
30
|
-
break;
|
|
31
|
-
case 'FONT_LOADED':
|
|
32
|
-
handlers.onFontLoaded(message);
|
|
33
|
-
break;
|
|
34
|
-
case 'FONT_ERROR':
|
|
35
|
-
handlers.onFontError(message);
|
|
36
|
-
break;
|
|
37
|
-
case 'PLAYBACK_STATE':
|
|
38
|
-
handlers.onPlaybackState(message);
|
|
39
|
-
break;
|
|
40
25
|
case 'PONG':
|
|
41
26
|
handlers.onPong(message);
|
|
42
27
|
break;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { ParentToRunnerMessage } from '@textmode/runner-protocol';
|
|
2
|
-
|
|
2
|
+
import { type PageVisibilityApi } from './visibility';
|
|
3
|
+
export type RequestKind = 'run' | 'lifecycle';
|
|
3
4
|
export interface RequestTimerApi {
|
|
4
5
|
setTimeout: (handler: () => void, timeoutMs: number) => number;
|
|
5
6
|
clearTimeout: (timeoutId: number) => void;
|
|
7
|
+
now: () => number;
|
|
6
8
|
}
|
|
7
9
|
interface RegisterRequestOptions {
|
|
8
10
|
requestId: string;
|
|
@@ -14,7 +16,8 @@ interface RegisterRequestOptions {
|
|
|
14
16
|
export declare class RequestRegistry {
|
|
15
17
|
private readonly pending;
|
|
16
18
|
private readonly timerApi;
|
|
17
|
-
|
|
19
|
+
private readonly visibilityApi;
|
|
20
|
+
constructor(timerApi?: RequestTimerApi, visibilityApi?: PageVisibilityApi);
|
|
18
21
|
get size(): number;
|
|
19
22
|
register<T>(options: RegisterRequestOptions): Promise<T>;
|
|
20
23
|
resolve(requestId: string | undefined, value: unknown): boolean;
|
|
@@ -1,25 +1,75 @@
|
|
|
1
|
+
import { createDocumentVisibilityApi, isPageVisible } from './visibility';
|
|
1
2
|
export class RequestRegistry {
|
|
2
3
|
pending = new Map();
|
|
3
4
|
timerApi;
|
|
4
|
-
|
|
5
|
+
visibilityApi;
|
|
6
|
+
constructor(timerApi = createWindowTimerApi(), visibilityApi = createDocumentVisibilityApi()) {
|
|
5
7
|
this.timerApi = timerApi;
|
|
8
|
+
this.visibilityApi = visibilityApi;
|
|
6
9
|
}
|
|
7
10
|
get size() {
|
|
8
11
|
return this.pending.size;
|
|
9
12
|
}
|
|
10
13
|
register(options) {
|
|
11
14
|
return new Promise((resolve, reject) => {
|
|
12
|
-
|
|
15
|
+
let remainingMs = options.timeoutMs;
|
|
16
|
+
let timeoutId = null;
|
|
17
|
+
let startedAt = this.timerApi.now();
|
|
18
|
+
const timerApi = this.timerApi;
|
|
19
|
+
let cleanupVisibility = () => { };
|
|
20
|
+
function pauseTimer() {
|
|
21
|
+
if (timeoutId === null)
|
|
22
|
+
return;
|
|
23
|
+
const elapsedMs = Math.max(0, timerApi.now() - startedAt);
|
|
24
|
+
remainingMs = Math.max(0, remainingMs - elapsedMs);
|
|
25
|
+
timerApi.clearTimeout(timeoutId);
|
|
26
|
+
timeoutId = null;
|
|
27
|
+
}
|
|
28
|
+
const cleanup = () => {
|
|
29
|
+
if (timeoutId !== null) {
|
|
30
|
+
timerApi.clearTimeout(timeoutId);
|
|
31
|
+
timeoutId = null;
|
|
32
|
+
}
|
|
33
|
+
cleanupVisibility();
|
|
34
|
+
};
|
|
35
|
+
const rejectTimedOut = () => {
|
|
36
|
+
cleanup();
|
|
13
37
|
this.pending.delete(options.requestId);
|
|
14
38
|
const error = new Error(`runner request timed out: ${options.messageType}`);
|
|
15
39
|
reject(error);
|
|
16
40
|
options.onTimeout(error);
|
|
17
|
-
}
|
|
41
|
+
};
|
|
42
|
+
const scheduleTimer = () => {
|
|
43
|
+
startedAt = timerApi.now();
|
|
44
|
+
timeoutId = timerApi.setTimeout(() => {
|
|
45
|
+
timeoutId = null;
|
|
46
|
+
if (!isPageVisible(this.visibilityApi)) {
|
|
47
|
+
remainingMs = 0;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
rejectTimedOut();
|
|
51
|
+
}, Math.max(0, remainingMs));
|
|
52
|
+
};
|
|
53
|
+
function resumeTimer() {
|
|
54
|
+
if (timeoutId !== null)
|
|
55
|
+
return;
|
|
56
|
+
scheduleTimer();
|
|
57
|
+
}
|
|
58
|
+
cleanupVisibility = this.visibilityApi.addChangeListener(() => {
|
|
59
|
+
if (isPageVisible(this.visibilityApi)) {
|
|
60
|
+
resumeTimer();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
pauseTimer();
|
|
64
|
+
});
|
|
65
|
+
if (isPageVisible(this.visibilityApi)) {
|
|
66
|
+
scheduleTimer();
|
|
67
|
+
}
|
|
18
68
|
this.pending.set(options.requestId, {
|
|
19
69
|
kind: options.kind,
|
|
20
70
|
resolve: resolve,
|
|
21
71
|
reject,
|
|
22
|
-
|
|
72
|
+
cleanup,
|
|
23
73
|
});
|
|
24
74
|
});
|
|
25
75
|
}
|
|
@@ -29,7 +79,7 @@ export class RequestRegistry {
|
|
|
29
79
|
const pending = this.pending.get(requestId);
|
|
30
80
|
if (!pending)
|
|
31
81
|
return false;
|
|
32
|
-
|
|
82
|
+
pending.cleanup();
|
|
33
83
|
this.pending.delete(requestId);
|
|
34
84
|
pending.resolve(value);
|
|
35
85
|
return true;
|
|
@@ -38,14 +88,14 @@ export class RequestRegistry {
|
|
|
38
88
|
const pending = this.pending.get(requestId);
|
|
39
89
|
if (!pending)
|
|
40
90
|
return false;
|
|
41
|
-
|
|
91
|
+
pending.cleanup();
|
|
42
92
|
this.pending.delete(requestId);
|
|
43
93
|
pending.reject(error);
|
|
44
94
|
return true;
|
|
45
95
|
}
|
|
46
96
|
rejectAll(error) {
|
|
47
97
|
for (const [requestId, pending] of this.pending) {
|
|
48
|
-
|
|
98
|
+
pending.cleanup();
|
|
49
99
|
pending.reject(error);
|
|
50
100
|
this.pending.delete(requestId);
|
|
51
101
|
}
|
|
@@ -56,22 +106,15 @@ export function requestKindForMessage(type) {
|
|
|
56
106
|
case 'RUN_CODE':
|
|
57
107
|
case 'SOFT_RESET':
|
|
58
108
|
return 'run';
|
|
59
|
-
case 'EXPORT':
|
|
60
|
-
return 'export';
|
|
61
|
-
case 'LOAD_FONT':
|
|
62
|
-
return 'font';
|
|
63
|
-
case 'PLAYBACK':
|
|
64
|
-
return 'playback';
|
|
65
|
-
case 'CONFIGURE_RUNTIME':
|
|
66
|
-
case 'SET_SETTINGS':
|
|
67
109
|
case 'PING':
|
|
68
110
|
case 'DISPOSE':
|
|
69
|
-
return '
|
|
111
|
+
return 'lifecycle';
|
|
70
112
|
}
|
|
71
113
|
}
|
|
72
114
|
function createWindowTimerApi() {
|
|
73
115
|
return {
|
|
74
116
|
setTimeout: (handler, timeoutMs) => window.setTimeout(handler, timeoutMs),
|
|
75
117
|
clearTimeout: (timeoutId) => window.clearTimeout(timeoutId),
|
|
118
|
+
now: () => Date.now(),
|
|
76
119
|
};
|
|
77
120
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface PageVisibilityApi {
|
|
2
|
+
getState: () => string;
|
|
3
|
+
addChangeListener: (listener: () => void) => () => void;
|
|
4
|
+
}
|
|
5
|
+
export declare function isPageVisible(visibility: PageVisibilityApi): boolean;
|
|
6
|
+
export declare function createDocumentVisibilityApi(): PageVisibilityApi;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export function isPageVisible(visibility) {
|
|
2
|
+
return visibility.getState() === 'visible';
|
|
3
|
+
}
|
|
4
|
+
export function createDocumentVisibilityApi() {
|
|
5
|
+
const listeners = new Set();
|
|
6
|
+
let forcedHidden = false;
|
|
7
|
+
const notify = () => {
|
|
8
|
+
for (const listener of listeners) {
|
|
9
|
+
listener();
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
const getState = () => {
|
|
13
|
+
if (forcedHidden)
|
|
14
|
+
return 'hidden';
|
|
15
|
+
const documentState = getDocument()?.visibilityState;
|
|
16
|
+
return typeof documentState === 'string' ? documentState : 'visible';
|
|
17
|
+
};
|
|
18
|
+
const addChangeListener = (listener) => {
|
|
19
|
+
listeners.add(listener);
|
|
20
|
+
if (listeners.size === 1) {
|
|
21
|
+
attachGlobalListeners();
|
|
22
|
+
}
|
|
23
|
+
return () => {
|
|
24
|
+
listeners.delete(listener);
|
|
25
|
+
if (listeners.size === 0) {
|
|
26
|
+
detachGlobalListeners();
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
const handleVisibilityChange = () => {
|
|
31
|
+
notify();
|
|
32
|
+
};
|
|
33
|
+
const handlePageHide = () => {
|
|
34
|
+
forcedHidden = true;
|
|
35
|
+
notify();
|
|
36
|
+
};
|
|
37
|
+
const handlePageShow = () => {
|
|
38
|
+
forcedHidden = false;
|
|
39
|
+
notify();
|
|
40
|
+
};
|
|
41
|
+
const attachGlobalListeners = () => {
|
|
42
|
+
getDocument()?.addEventListener?.('visibilitychange', handleVisibilityChange);
|
|
43
|
+
getDocument()?.addEventListener?.('freeze', handlePageHide);
|
|
44
|
+
getDocument()?.addEventListener?.('resume', handlePageShow);
|
|
45
|
+
getWindow()?.addEventListener?.('pagehide', handlePageHide);
|
|
46
|
+
getWindow()?.addEventListener?.('pageshow', handlePageShow);
|
|
47
|
+
};
|
|
48
|
+
const detachGlobalListeners = () => {
|
|
49
|
+
getDocument()?.removeEventListener?.('visibilitychange', handleVisibilityChange);
|
|
50
|
+
getDocument()?.removeEventListener?.('freeze', handlePageHide);
|
|
51
|
+
getDocument()?.removeEventListener?.('resume', handlePageShow);
|
|
52
|
+
getWindow()?.removeEventListener?.('pagehide', handlePageHide);
|
|
53
|
+
getWindow()?.removeEventListener?.('pageshow', handlePageShow);
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
getState,
|
|
57
|
+
addChangeListener,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function getDocument() {
|
|
61
|
+
return typeof document === 'undefined' ? null : document;
|
|
62
|
+
}
|
|
63
|
+
function getWindow() {
|
|
64
|
+
return typeof window === 'undefined' ? null : window;
|
|
65
|
+
}
|
package/dist/options.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { RunnerCapabilities, RunOkMessage } from '@textmode/runner-protocol';
|
|
2
2
|
import type { RunnerExecutionError } from './errors';
|
|
3
3
|
import type { RunnerRuntimeStatus } from './status';
|
|
4
4
|
/**
|
|
@@ -42,7 +42,7 @@ export interface IframeTextmodeRuntimeOptions {
|
|
|
42
42
|
heartbeatIntervalMs?: number;
|
|
43
43
|
/** Maximum time without a heartbeat pong before the runner is marked hung. */
|
|
44
44
|
heartbeatTimeoutMs?: number;
|
|
45
|
-
/** Called after the runner is ready
|
|
45
|
+
/** Called after the runner is ready. */
|
|
46
46
|
onReady?: (capabilities: RunnerCapabilities) => void;
|
|
47
47
|
/** Called when code execution succeeds. */
|
|
48
48
|
onRunOk?: (message: RunOkMessage) => void;
|
|
@@ -54,10 +54,6 @@ export interface IframeTextmodeRuntimeOptions {
|
|
|
54
54
|
onToggleUI?: () => void;
|
|
55
55
|
/** Called when the runner reports user interaction. */
|
|
56
56
|
onUserInteraction?: () => void;
|
|
57
|
-
/** Called as multi-frame exports report progress. */
|
|
58
|
-
onExportProgress?: (requestId: string, format: 'gif' | 'webm', progress: ExportProgress) => void;
|
|
59
|
-
/** Called whenever the runner reports playback state. */
|
|
60
|
-
onPlaybackState?: (state: PlaybackState) => void;
|
|
61
57
|
/** Called when the runner becomes unavailable or hung. */
|
|
62
58
|
onUnavailable?: (reason: string, status: RunnerRuntimeStatus) => void;
|
|
63
59
|
/** Called after the MessagePort connection is established. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@textmode/runner-client",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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.2.0"
|
|
34
34
|
}
|
|
35
35
|
}
|
package/dist/font.d.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Metadata returned after the runner successfully loads a font file.
|
|
3
|
-
*
|
|
4
|
-
* @category Fonts
|
|
5
|
-
*/
|
|
6
|
-
export interface FontLoadResult {
|
|
7
|
-
/** Font family name detected by the runner. */
|
|
8
|
-
familyName: string | null;
|
|
9
|
-
/** Characters available in the loaded font. */
|
|
10
|
-
characters: string[];
|
|
11
|
-
}
|
package/dist/font.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|