@textmode/runner-client 0.1.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/LICENSE +661 -0
- package/README.md +133 -0
- package/dist/IframeTextmodeRuntime.d.ts +177 -0
- package/dist/IframeTextmodeRuntime.js +539 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +21 -0
- package/dist/font.d.ts +11 -0
- package/dist/font.js +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +15 -0
- package/dist/internal/heartbeat.d.ts +26 -0
- package/dist/internal/heartbeat.js +44 -0
- package/dist/internal/iframeMount.d.ts +4 -0
- package/dist/internal/iframeMount.js +29 -0
- package/dist/internal/messageRouter.d.ts +16 -0
- package/dist/internal/messageRouter.js +45 -0
- package/dist/internal/requestRegistry.d.ts +25 -0
- package/dist/internal/requestRegistry.js +77 -0
- package/dist/internal/sandboxPolicy.d.ts +7 -0
- package/dist/internal/sandboxPolicy.js +6 -0
- package/dist/options.d.ts +67 -0
- package/dist/options.js +15 -0
- package/dist/status.d.ts +6 -0
- package/dist/status.js +1 -0
- package/package.json +35 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import { isRunnerCapabilities, } from '@textmode/runner-protocol';
|
|
2
|
+
import { RunnerRequestError } from './errors';
|
|
3
|
+
import { DEFAULT_IFRAME_SANDBOX_TOKENS, } from './options';
|
|
4
|
+
import { HeartbeatController } from './internal/heartbeat';
|
|
5
|
+
import { createRunnerIframe, focusElement, mountRunnerIframe } from './internal/iframeMount';
|
|
6
|
+
import { routeRunnerMessage } from './internal/messageRouter';
|
|
7
|
+
import { RequestRegistry, requestKindForMessage } from './internal/requestRegistry';
|
|
8
|
+
import { assertSandboxOriginPolicy } from './internal/sandboxPolicy';
|
|
9
|
+
const getRuntimeErrorMessage = (error, fallback) => {
|
|
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
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Browser iframe runtime for communicating with the hosted textmode runner.
|
|
20
|
+
*
|
|
21
|
+
* @category Runtime
|
|
22
|
+
*/
|
|
23
|
+
export class IframeTextmodeRuntime {
|
|
24
|
+
runnerHref;
|
|
25
|
+
runnerOrigin;
|
|
26
|
+
sandboxTokens;
|
|
27
|
+
handshakeTimeoutMs;
|
|
28
|
+
requestTimeoutMs;
|
|
29
|
+
options;
|
|
30
|
+
mountMode;
|
|
31
|
+
pending = new RequestRegistry();
|
|
32
|
+
heartbeat;
|
|
33
|
+
iframe = null;
|
|
34
|
+
channel = null;
|
|
35
|
+
port = null;
|
|
36
|
+
container = null;
|
|
37
|
+
settings = null;
|
|
38
|
+
capabilities = null;
|
|
39
|
+
ready = false;
|
|
40
|
+
currentStatus = 'idle';
|
|
41
|
+
readyResolver = null;
|
|
42
|
+
readyRejecter = null;
|
|
43
|
+
readyTimeoutId = null;
|
|
44
|
+
lastRequestedCode = null;
|
|
45
|
+
constructor(options) {
|
|
46
|
+
this.options = options;
|
|
47
|
+
this.mountMode = options.mountMode ?? 'replace';
|
|
48
|
+
const runnerLocation = new URL(options.runnerUrl, window.location.href);
|
|
49
|
+
this.runnerHref = runnerLocation.href;
|
|
50
|
+
this.runnerOrigin = runnerLocation.origin;
|
|
51
|
+
this.sandboxTokens = [...(options.sandboxTokens ?? DEFAULT_IFRAME_SANDBOX_TOKENS)];
|
|
52
|
+
this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? 5000;
|
|
53
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 12000;
|
|
54
|
+
this.heartbeat = new HeartbeatController({
|
|
55
|
+
intervalMs: options.heartbeatIntervalMs ?? 2000,
|
|
56
|
+
timeoutMs: options.heartbeatTimeoutMs ?? 10000,
|
|
57
|
+
onPing: () => {
|
|
58
|
+
this.postMessage({
|
|
59
|
+
type: 'PING',
|
|
60
|
+
nonce: this.createRequestId('ping'),
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
onTimeout: () => {
|
|
64
|
+
this.handleUnavailable('runner heartbeat timed out');
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Whether the runner iframe is ready to accept requests.
|
|
70
|
+
*
|
|
71
|
+
* @category Runtime
|
|
72
|
+
*/
|
|
73
|
+
get isReady() {
|
|
74
|
+
return this.ready && this.currentStatus === 'ready';
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Current runner iframe element, when mounted.
|
|
78
|
+
*
|
|
79
|
+
* @category Runtime
|
|
80
|
+
*/
|
|
81
|
+
get frame() {
|
|
82
|
+
return this.iframe;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Current runner lifecycle status.
|
|
86
|
+
*
|
|
87
|
+
* @category Runtime
|
|
88
|
+
*/
|
|
89
|
+
get status() {
|
|
90
|
+
return this.currentStatus;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Alias for {@link IframeTextmodeRuntime.status}.
|
|
94
|
+
*
|
|
95
|
+
* @category Runtime
|
|
96
|
+
*/
|
|
97
|
+
get runnerStatus() {
|
|
98
|
+
return this.currentStatus;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Capabilities advertised by the connected runner.
|
|
102
|
+
*
|
|
103
|
+
* @category Runtime
|
|
104
|
+
*/
|
|
105
|
+
get advertisedCapabilities() {
|
|
106
|
+
return this.capabilities;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Mounts the runner iframe and performs the current protocol handshake.
|
|
110
|
+
*
|
|
111
|
+
* @param container - DOM element that should contain the runner iframe.
|
|
112
|
+
* @param settings - Optional fixed runtime settings to configure after ready.
|
|
113
|
+
* @returns `true` when the runner is ready.
|
|
114
|
+
* @category Runtime
|
|
115
|
+
*/
|
|
116
|
+
async init(container, settings) {
|
|
117
|
+
this.container = container;
|
|
118
|
+
this.settings = settings ? { ...settings } : null;
|
|
119
|
+
this.assertSandboxOriginPolicy();
|
|
120
|
+
if (this.isReady && this.iframe?.isConnected) {
|
|
121
|
+
if (settings) {
|
|
122
|
+
await this.configure(settings);
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
this.disposeFrame();
|
|
127
|
+
this.ready = false;
|
|
128
|
+
this.setStatus('connecting');
|
|
129
|
+
const iframe = createRunnerIframe(this.runnerHref, this.sandboxTokens);
|
|
130
|
+
this.iframe = iframe;
|
|
131
|
+
mountRunnerIframe(container, iframe, this.mountMode);
|
|
132
|
+
const readyPromise = new Promise((resolve, reject) => {
|
|
133
|
+
this.readyResolver = resolve;
|
|
134
|
+
this.readyRejecter = reject;
|
|
135
|
+
this.readyTimeoutId = window.setTimeout(() => {
|
|
136
|
+
const error = new Error('runner handshake timed out');
|
|
137
|
+
this.handleUnavailable(error.message);
|
|
138
|
+
reject(error);
|
|
139
|
+
}, this.handshakeTimeoutMs);
|
|
140
|
+
});
|
|
141
|
+
iframe.addEventListener('load', () => {
|
|
142
|
+
this.connectPort();
|
|
143
|
+
}, { once: true });
|
|
144
|
+
return readyPromise;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Disposes the iframe connection and rejects pending requests.
|
|
148
|
+
*
|
|
149
|
+
* @category Runtime
|
|
150
|
+
*/
|
|
151
|
+
dispose() {
|
|
152
|
+
if (this.port) {
|
|
153
|
+
try {
|
|
154
|
+
this.postMessage({ type: 'DISPOSE' });
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// The connection may already be gone during page teardown.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
this.pending.rejectAll(new Error('runner disposed'));
|
|
161
|
+
this.heartbeat.stop();
|
|
162
|
+
this.disposeFrame();
|
|
163
|
+
this.ready = false;
|
|
164
|
+
this.setStatus('idle');
|
|
165
|
+
this.readyResolver = null;
|
|
166
|
+
this.readyRejecter = null;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Recreates the iframe and reruns the last requested code when available.
|
|
170
|
+
*
|
|
171
|
+
* @returns `true` when reconnection succeeds.
|
|
172
|
+
* @category Runtime
|
|
173
|
+
*/
|
|
174
|
+
async reconnect() {
|
|
175
|
+
if (!this.container) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
const code = this.lastRequestedCode;
|
|
179
|
+
this.setStatus('recovering');
|
|
180
|
+
const initialized = await this.init(this.container, this.settings ?? undefined);
|
|
181
|
+
if (initialized && code) {
|
|
182
|
+
void this.runCode(code);
|
|
183
|
+
}
|
|
184
|
+
return initialized;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Focuses the iframe from a host user gesture.
|
|
188
|
+
*
|
|
189
|
+
* Some browsers use this to unlock normal iframe animation cadence.
|
|
190
|
+
*
|
|
191
|
+
* @category Runtime
|
|
192
|
+
*/
|
|
193
|
+
activateFromUserGesture() {
|
|
194
|
+
if (!this.iframe)
|
|
195
|
+
return;
|
|
196
|
+
this.iframe.tabIndex = -1;
|
|
197
|
+
focusElement(this.iframe);
|
|
198
|
+
try {
|
|
199
|
+
this.iframe.contentWindow?.focus();
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// Element focus still helps browsers that gate iframe animation cadence.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
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
|
+
/**
|
|
242
|
+
* Executes code in the runner.
|
|
243
|
+
*
|
|
244
|
+
* @category Runtime
|
|
245
|
+
*/
|
|
246
|
+
async runCode(code, options = {}) {
|
|
247
|
+
this.lastRequestedCode = code;
|
|
248
|
+
const requestId = this.createRequestId('run');
|
|
249
|
+
const message = options.softReset
|
|
250
|
+
? { type: 'SOFT_RESET', requestId, code }
|
|
251
|
+
: { type: 'RUN_CODE', requestId, code };
|
|
252
|
+
await this.request(message);
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
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
|
+
connectPort() {
|
|
345
|
+
if (!this.iframe?.contentWindow) {
|
|
346
|
+
this.handleUnavailable('runner frame is unavailable');
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
this.channel = new MessageChannel();
|
|
350
|
+
this.port = this.channel.port1;
|
|
351
|
+
this.port.onmessage = (event) => {
|
|
352
|
+
this.handlePortMessage(event.data);
|
|
353
|
+
};
|
|
354
|
+
this.port.start();
|
|
355
|
+
const initMessage = {
|
|
356
|
+
type: 'INIT',
|
|
357
|
+
};
|
|
358
|
+
this.iframe.contentWindow.postMessage(initMessage, this.runnerOrigin, [this.channel.port2]);
|
|
359
|
+
}
|
|
360
|
+
handlePortMessage(message) {
|
|
361
|
+
routeRunnerMessage(message, {
|
|
362
|
+
onReady: (readyMessage) => this.handleReady(readyMessage),
|
|
363
|
+
onRunOk: (runOkMessage) => {
|
|
364
|
+
this.pending.resolve(runOkMessage.requestId, runOkMessage);
|
|
365
|
+
this.options.onRunOk?.(runOkMessage);
|
|
366
|
+
},
|
|
367
|
+
onRunError: (runErrorMessage) => this.handleRunError(runErrorMessage),
|
|
368
|
+
onSynthError: (synthErrorMessage) => {
|
|
369
|
+
this.options.onSynthError?.(synthErrorMessage.message);
|
|
370
|
+
},
|
|
371
|
+
onToggleUI: () => {
|
|
372
|
+
this.options.onToggleUI?.();
|
|
373
|
+
},
|
|
374
|
+
onUserInteraction: () => {
|
|
375
|
+
this.options.onUserInteraction?.();
|
|
376
|
+
},
|
|
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
|
+
onPong: () => {
|
|
396
|
+
this.heartbeat.markPong();
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
handleReady(message) {
|
|
401
|
+
const protocolError = this.validateReadyMessage(message);
|
|
402
|
+
if (protocolError) {
|
|
403
|
+
this.handleUnavailable(protocolError);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
this.capabilities = message.capabilities;
|
|
407
|
+
if (this.readyTimeoutId !== null) {
|
|
408
|
+
window.clearTimeout(this.readyTimeoutId);
|
|
409
|
+
this.readyTimeoutId = null;
|
|
410
|
+
}
|
|
411
|
+
this.options.onConnected?.();
|
|
412
|
+
const settings = this.settings;
|
|
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
|
+
}
|
|
429
|
+
}
|
|
430
|
+
markReady() {
|
|
431
|
+
this.ready = true;
|
|
432
|
+
this.setStatus('ready');
|
|
433
|
+
this.options.onReady?.(this.capabilities);
|
|
434
|
+
this.heartbeat.start();
|
|
435
|
+
this.readyResolver?.(true);
|
|
436
|
+
this.readyResolver = null;
|
|
437
|
+
this.readyRejecter = null;
|
|
438
|
+
}
|
|
439
|
+
handleRunError(message) {
|
|
440
|
+
const error = new RunnerRequestError(message);
|
|
441
|
+
if (message.requestId) {
|
|
442
|
+
this.pending.reject(message.requestId, error);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
this.options.onRunError?.({
|
|
446
|
+
message: message.message,
|
|
447
|
+
stack: message.stack,
|
|
448
|
+
line: message.line,
|
|
449
|
+
column: message.column,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
request(message, timeoutMs = this.requestTimeoutMs, transfer) {
|
|
453
|
+
if (!this.port || (!this.ready && message.type !== 'CONFIGURE_RUNTIME')) {
|
|
454
|
+
return Promise.reject(new Error('runner is not ready'));
|
|
455
|
+
}
|
|
456
|
+
const requestId = 'requestId' in message ? message.requestId : undefined;
|
|
457
|
+
if (!requestId) {
|
|
458
|
+
this.postMessage(message, transfer);
|
|
459
|
+
return Promise.resolve(undefined);
|
|
460
|
+
}
|
|
461
|
+
const promise = this.pending.register({
|
|
462
|
+
requestId,
|
|
463
|
+
kind: requestKindForMessage(message.type),
|
|
464
|
+
messageType: message.type,
|
|
465
|
+
timeoutMs,
|
|
466
|
+
onTimeout: (error) => {
|
|
467
|
+
this.handleUnavailable(error.message);
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
this.postMessage(message, transfer);
|
|
471
|
+
return promise;
|
|
472
|
+
}
|
|
473
|
+
postMessage(message, transfer) {
|
|
474
|
+
if (!this.port) {
|
|
475
|
+
throw new Error('runner port is not connected');
|
|
476
|
+
}
|
|
477
|
+
if (transfer && transfer.length > 0) {
|
|
478
|
+
this.port.postMessage(message, transfer);
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
this.port.postMessage(message);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
handleUnavailable(reason) {
|
|
485
|
+
const error = new Error(reason);
|
|
486
|
+
this.heartbeat.stop();
|
|
487
|
+
this.ready = false;
|
|
488
|
+
this.pending.rejectAll(error);
|
|
489
|
+
this.readyRejecter?.(error);
|
|
490
|
+
this.readyResolver = null;
|
|
491
|
+
this.readyRejecter = null;
|
|
492
|
+
this.disposeFrame();
|
|
493
|
+
const status = reason.includes('heartbeat') ? 'hung' : 'unavailable';
|
|
494
|
+
this.setStatus(status, reason);
|
|
495
|
+
this.options.onUnavailable?.(reason, status);
|
|
496
|
+
}
|
|
497
|
+
validateReadyMessage(message) {
|
|
498
|
+
const capabilities = message.capabilities;
|
|
499
|
+
if (!isRunnerCapabilities(capabilities)) {
|
|
500
|
+
return 'runner did not advertise a valid current capability set';
|
|
501
|
+
}
|
|
502
|
+
if (!capabilities.runtimeConfig) {
|
|
503
|
+
return 'runner does not support runtime configuration';
|
|
504
|
+
}
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
assertSandboxOriginPolicy() {
|
|
508
|
+
assertSandboxOriginPolicy({
|
|
509
|
+
sandboxTokens: this.sandboxTokens,
|
|
510
|
+
runnerOrigin: this.runnerOrigin,
|
|
511
|
+
parentOrigin: window.location.origin,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
setStatus(status, reason = null) {
|
|
515
|
+
if (this.currentStatus === status && !reason) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
this.currentStatus = status;
|
|
519
|
+
this.options.onStatusChange?.(status, reason);
|
|
520
|
+
}
|
|
521
|
+
disposeFrame() {
|
|
522
|
+
if (this.readyTimeoutId !== null) {
|
|
523
|
+
window.clearTimeout(this.readyTimeoutId);
|
|
524
|
+
this.readyTimeoutId = null;
|
|
525
|
+
}
|
|
526
|
+
this.port?.close();
|
|
527
|
+
this.channel?.port1.close();
|
|
528
|
+
this.channel?.port2.close();
|
|
529
|
+
this.port = null;
|
|
530
|
+
this.channel = null;
|
|
531
|
+
if (this.iframe) {
|
|
532
|
+
this.iframe.remove();
|
|
533
|
+
this.iframe = null;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
createRequestId(prefix) {
|
|
537
|
+
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
538
|
+
}
|
|
539
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { RunErrorMessage } from '@textmode/runner-protocol';
|
|
2
|
+
/**
|
|
3
|
+
* Error shape surfaced by runner execution callbacks and rejected run requests.
|
|
4
|
+
*
|
|
5
|
+
* @category Errors
|
|
6
|
+
*/
|
|
7
|
+
export interface RunnerExecutionError {
|
|
8
|
+
/** Human-readable error message. */
|
|
9
|
+
message: string;
|
|
10
|
+
/** Optional stack trace reported by the runner. */
|
|
11
|
+
stack?: string;
|
|
12
|
+
/** Optional 1-based source line. */
|
|
13
|
+
line?: number;
|
|
14
|
+
/** Optional 1-based source column. */
|
|
15
|
+
column?: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Error used when a request-scoped runner execution fails.
|
|
19
|
+
*
|
|
20
|
+
* @category Errors
|
|
21
|
+
*/
|
|
22
|
+
export declare class RunnerRequestError extends Error implements RunnerExecutionError {
|
|
23
|
+
/** Optional 1-based source line. */
|
|
24
|
+
readonly line?: number;
|
|
25
|
+
/** Optional 1-based source column. */
|
|
26
|
+
readonly column?: number;
|
|
27
|
+
/** Request identifier that produced the error. */
|
|
28
|
+
readonly requestId?: string;
|
|
29
|
+
constructor(message: RunErrorMessage);
|
|
30
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error used when a request-scoped runner execution fails.
|
|
3
|
+
*
|
|
4
|
+
* @category Errors
|
|
5
|
+
*/
|
|
6
|
+
export class RunnerRequestError extends Error {
|
|
7
|
+
/** Optional 1-based source line. */
|
|
8
|
+
line;
|
|
9
|
+
/** Optional 1-based source column. */
|
|
10
|
+
column;
|
|
11
|
+
/** Request identifier that produced the error. */
|
|
12
|
+
requestId;
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message.message);
|
|
15
|
+
this.name = 'RunnerRequestError';
|
|
16
|
+
this.stack = message.stack;
|
|
17
|
+
this.line = message.line;
|
|
18
|
+
this.column = message.column;
|
|
19
|
+
this.requestId = message.requestId;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/dist/font.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
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
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
*
|
|
4
|
+
* Browser iframe runtime client for the hosted textmode runner.
|
|
5
|
+
*
|
|
6
|
+
* `@textmode/runner-client` manages the runner iframe lifecycle, current
|
|
7
|
+
* protocol handshake, request/response routing, heartbeat monitoring, export
|
|
8
|
+
* helpers, font loading, playback control, reconnect, and disposal for host
|
|
9
|
+
* apps.
|
|
10
|
+
*
|
|
11
|
+
* @module @textmode/runner-client
|
|
12
|
+
*/
|
|
13
|
+
export { IframeTextmodeRuntime } from './IframeTextmodeRuntime';
|
|
14
|
+
export { RunnerRequestError, type RunnerExecutionError } from './errors';
|
|
15
|
+
export { type FontLoadResult } from './font';
|
|
16
|
+
export { DEFAULT_IFRAME_SANDBOX_TOKENS, type IframeMountMode, type IframeSandboxToken, type IframeTextmodeRuntimeOptions, } from './options';
|
|
17
|
+
export { type RunnerRuntimeStatus } from './status';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
*
|
|
4
|
+
* Browser iframe runtime client for the hosted textmode runner.
|
|
5
|
+
*
|
|
6
|
+
* `@textmode/runner-client` manages the runner iframe lifecycle, current
|
|
7
|
+
* protocol handshake, request/response routing, heartbeat monitoring, export
|
|
8
|
+
* helpers, font loading, playback control, reconnect, and disposal for host
|
|
9
|
+
* apps.
|
|
10
|
+
*
|
|
11
|
+
* @module @textmode/runner-client
|
|
12
|
+
*/
|
|
13
|
+
export { IframeTextmodeRuntime } from './IframeTextmodeRuntime';
|
|
14
|
+
export { RunnerRequestError } from './errors';
|
|
15
|
+
export { DEFAULT_IFRAME_SANDBOX_TOKENS, } from './options';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface HeartbeatTimerApi {
|
|
2
|
+
setInterval: (handler: () => void, intervalMs: number) => number;
|
|
3
|
+
clearInterval: (intervalId: number) => void;
|
|
4
|
+
}
|
|
5
|
+
export interface HeartbeatControllerOptions {
|
|
6
|
+
intervalMs: number;
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
now?: () => number;
|
|
9
|
+
timerApi?: HeartbeatTimerApi;
|
|
10
|
+
onPing: () => void;
|
|
11
|
+
onTimeout: () => void;
|
|
12
|
+
}
|
|
13
|
+
export declare class HeartbeatController {
|
|
14
|
+
private readonly intervalMs;
|
|
15
|
+
private readonly timeoutMs;
|
|
16
|
+
private readonly now;
|
|
17
|
+
private readonly timerApi;
|
|
18
|
+
private readonly onPing;
|
|
19
|
+
private readonly onTimeout;
|
|
20
|
+
private intervalId;
|
|
21
|
+
private lastPongAt;
|
|
22
|
+
constructor(options: HeartbeatControllerOptions);
|
|
23
|
+
start(): void;
|
|
24
|
+
stop(): void;
|
|
25
|
+
markPong(): void;
|
|
26
|
+
}
|