@textmode/runner-client 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,6 +104,7 @@ 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;
@@ -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,18 +104,13 @@ 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
  }
126
116
  this.disposeFrame();
@@ -177,7 +167,8 @@ export class IframeTextmodeRuntime {
177
167
  }
178
168
  const code = this.lastRequestedCode;
179
169
  this.setStatus('recovering');
180
- const initialized = await this.init(this.container, this.settings ?? undefined);
170
+ this.forceDisposeFrameForReconnect();
171
+ const initialized = await this.init(this.container);
181
172
  if (initialized && code) {
182
173
  void this.runCode(code);
183
174
  }
@@ -202,42 +193,6 @@ export class IframeTextmodeRuntime {
202
193
  // Element focus still helps browsers that gate iframe animation cadence.
203
194
  }
204
195
  }
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
196
  /**
242
197
  * Executes code in the runner.
243
198
  *
@@ -252,95 +207,6 @@ export class IframeTextmodeRuntime {
252
207
  await this.request(message);
253
208
  return true;
254
209
  }
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
210
  connectPort() {
345
211
  if (!this.iframe?.contentWindow) {
346
212
  this.handleUnavailable('runner frame is unavailable');
@@ -361,8 +227,10 @@ export class IframeTextmodeRuntime {
361
227
  routeRunnerMessage(message, {
362
228
  onReady: (readyMessage) => this.handleReady(readyMessage),
363
229
  onRunOk: (runOkMessage) => {
364
- this.pending.resolve(runOkMessage.requestId, runOkMessage);
365
- this.options.onRunOk?.(runOkMessage);
230
+ const resolved = this.pending.resolve(runOkMessage.requestId, runOkMessage);
231
+ if (!runOkMessage.requestId || resolved) {
232
+ this.options.onRunOk?.(runOkMessage);
233
+ }
366
234
  },
367
235
  onRunError: (runErrorMessage) => this.handleRunError(runErrorMessage),
368
236
  onSynthError: (synthErrorMessage) => {
@@ -374,24 +242,6 @@ export class IframeTextmodeRuntime {
374
242
  onUserInteraction: () => {
375
243
  this.options.onUserInteraction?.();
376
244
  },
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
245
  onPong: () => {
396
246
  this.heartbeat.markPong();
397
247
  },
@@ -409,23 +259,7 @@ export class IframeTextmodeRuntime {
409
259
  this.readyTimeoutId = null;
410
260
  }
411
261
  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
- }
262
+ this.markReady();
429
263
  }
430
264
  markReady() {
431
265
  this.ready = true;
@@ -449,37 +283,36 @@ export class IframeTextmodeRuntime {
449
283
  column: message.column,
450
284
  });
451
285
  }
452
- request(message, timeoutMs = this.requestTimeoutMs, transfer) {
453
- if (!this.port || (!this.ready && message.type !== 'CONFIGURE_RUNTIME')) {
286
+ request(message, timeoutMs = this.requestTimeoutMs) {
287
+ if (!this.port || !this.ready) {
454
288
  return Promise.reject(new Error('runner is not ready'));
455
289
  }
456
290
  const requestId = 'requestId' in message ? message.requestId : undefined;
457
291
  if (!requestId) {
458
- this.postMessage(message, transfer);
292
+ this.postMessage(message);
459
293
  return Promise.resolve(undefined);
460
294
  }
295
+ const kind = requestKindForMessage(message.type);
461
296
  const promise = this.pending.register({
462
297
  requestId,
463
- kind: requestKindForMessage(message.type),
298
+ kind,
464
299
  messageType: message.type,
465
300
  timeoutMs,
466
301
  onTimeout: (error) => {
302
+ if (kind === 'run') {
303
+ return;
304
+ }
467
305
  this.handleUnavailable(error.message);
468
306
  },
469
307
  });
470
- this.postMessage(message, transfer);
308
+ this.postMessage(message);
471
309
  return promise;
472
310
  }
473
- postMessage(message, transfer) {
311
+ postMessage(message) {
474
312
  if (!this.port) {
475
313
  throw new Error('runner port is not connected');
476
314
  }
477
- if (transfer && transfer.length > 0) {
478
- this.port.postMessage(message, transfer);
479
- }
480
- else {
481
- this.port.postMessage(message);
482
- }
315
+ this.port.postMessage(message);
483
316
  }
484
317
  handleUnavailable(reason) {
485
318
  const error = new Error(reason);
@@ -490,17 +323,31 @@ export class IframeTextmodeRuntime {
490
323
  this.readyResolver = null;
491
324
  this.readyRejecter = null;
492
325
  this.disposeFrame();
493
- const status = reason.includes('heartbeat') ? 'hung' : 'unavailable';
326
+ const status = reason === 'runner heartbeat timed out' ? 'hung' : 'unavailable';
494
327
  this.setStatus(status, reason);
495
328
  this.options.onUnavailable?.(reason, status);
496
329
  }
330
+ forceDisposeFrameForReconnect() {
331
+ if (this.port) {
332
+ try {
333
+ this.postMessage({ type: 'DISPOSE' });
334
+ }
335
+ catch {
336
+ // The existing connection may already be unavailable.
337
+ }
338
+ }
339
+ this.heartbeat.stop();
340
+ this.pending.rejectAll(new Error('runner reconnecting'));
341
+ this.ready = false;
342
+ this.disposeFrame();
343
+ }
497
344
  validateReadyMessage(message) {
498
345
  const capabilities = message.capabilities;
499
346
  if (!isRunnerCapabilities(capabilities)) {
500
347
  return 'runner did not advertise a valid current capability set';
501
348
  }
502
- if (!capabilities.runtimeConfig) {
503
- return 'runner does not support runtime configuration';
349
+ if (!capabilities.heartbeat) {
350
+ return 'runner does not support heartbeat monitoring';
504
351
  }
505
352
  return null;
506
353
  }
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.0",
4
4
  "description": "Browser iframe runtime client for the hosted textmode runner.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -30,6 +30,6 @@
30
30
  "prepare": "npm run build"
31
31
  },
32
32
  "dependencies": {
33
- "@textmode/runner-protocol": "^0.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 {};