@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 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, loading fonts,
8
- controlling playback, running code, exporting output, reconnecting, and
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
- const image = await runtime.export('image', {
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, settings)` from a browser context.
106
- 3. Use `runCode`, `configure`, `setSettings`, `export`, `loadFont`, and
107
- `playback` as needed.
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. Export downloads should be
122
- initiated by the host app after it receives an export result from the runner.
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 ExportMessage, type ExportResultMessage, type GifExportOptions, type ImageExportOptions, type PlaybackAction, type PlaybackState, type RunnerCapabilities, type RuntimeSettings, type SvgExportOptions, type TxtExportOptions, type WebmExportOptions } from '@textmode/runner-protocol';
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, settings?: RuntimeSettings): Promise<boolean>;
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
- 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
- };
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 = new RequestRegistry();
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, settings) {
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
- const initialized = await this.init(this.container, this.settings ?? undefined);
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
- this.options.onRunOk?.(runOkMessage);
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
- 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
- }
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, transfer) {
453
- if (!this.port || (!this.ready && message.type !== 'CONFIGURE_RUNTIME')) {
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, transfer);
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: requestKindForMessage(message.type),
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, transfer);
312
+ this.postMessage(message);
471
313
  return promise;
472
314
  }
473
- postMessage(message, transfer) {
315
+ postMessage(message) {
474
316
  if (!this.port) {
475
317
  throw new Error('runner port is not connected');
476
318
  }
477
- if (transfer && transfer.length > 0) {
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.includes('heartbeat') ? 'hung' : 'unavailable';
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.runtimeConfig) {
503
- return 'runner does not support runtime configuration';
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, export
8
- * helpers, font loading, playback control, reconnect, and disposal for host
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, export
8
- * helpers, font loading, playback control, reconnect, and disposal for host
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
- stop() {
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 ExportProgressMessage, type ExportResultMessage, type FontErrorMessage, type FontLoadedMessage, type PlaybackStateMessage, type PongMessage, type ReadyMessage, type RunErrorMessage, type RunOkMessage, type SynthErrorMessage } from '@textmode/runner-protocol';
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
- export type RequestKind = 'run' | 'export' | 'font' | 'playback' | 'settings';
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
- constructor(timerApi?: RequestTimerApi);
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
- constructor(timerApi = createWindowTimerApi()) {
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
- const timeoutId = this.timerApi.setTimeout(() => {
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
- }, options.timeoutMs);
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
- timeoutId,
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
- this.timerApi.clearTimeout(pending.timeoutId);
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
- this.timerApi.clearTimeout(pending.timeoutId);
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
- this.timerApi.clearTimeout(pending.timeoutId);
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 'settings';
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 { ExportProgress, PlaybackState, RunnerCapabilities, RunOkMessage } from '@textmode/runner-protocol';
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 and optional runtime configuration succeeds. */
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.0",
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.1.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 {};