@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.
@@ -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
+ }
@@ -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 {};
@@ -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
+ }