@textmode/runner-client 0.2.0 → 0.5.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
@@ -5,7 +5,7 @@ Browser iframe runtime client for the hosted textmode runner.
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
7
  request/response messages, monitoring heartbeat status, running code,
8
- soft-resetting sketches, reconnecting, and disposing the transport.
8
+ reconnecting, and disposing the transport.
9
9
 
10
10
  It does not execute textmode.js sketches directly. It controls a runner app at
11
11
  the `runnerUrl` you provide.
@@ -36,6 +36,12 @@ if (!container) {
36
36
 
37
37
  const runtime = new IframeTextmodeRuntime({
38
38
  runnerUrl: 'https://runner.textmode.art/',
39
+ onUserActivationRequired() {
40
+ container.dataset.userActivation = 'required';
41
+ },
42
+ onUserInteraction() {
43
+ delete container.dataset.userActivation;
44
+ },
39
45
  onStatusChange(status: RunnerRuntimeStatus, reason) {
40
46
  console.info('runner status changed', status, reason);
41
47
  },
@@ -55,7 +61,6 @@ try {
55
61
  }
56
62
  }
57
63
 
58
- await runtime.runCode('t.frameCount = 0;', { softReset: true });
59
64
  runtime.dispose();
60
65
  ```
61
66
 
@@ -88,10 +93,17 @@ Typical host apps follow this lifecycle:
88
93
 
89
94
  1. Create an `IframeTextmodeRuntime` with a trusted `runnerUrl`.
90
95
  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.
93
- 4. Call `reconnect` after a recoverable runner failure.
94
- 5. Call `dispose` when the host view is unmounted.
96
+ 3. Use `runCode` to replace the current sketch execution while preserving its timeline.
97
+ 4. Use `resetRuntime` to rebuild textmode while preserving the current iframe document and its browser interaction state.
98
+ 5. Call `reconnect` only when the iframe document or transport must be replaced.
99
+ 6. Call `dispose` when the host view is unmounted.
100
+
101
+ `resetRuntime` falls back to reconnecting when an older runner does not advertise the optional `runtimeReset` capability.
102
+
103
+ Cross-origin WebKit runners may request one trusted child-frame interaction
104
+ through `onUserActivationRequired`. A host can temporarily expose or elevate
105
+ the existing iframe until `onUserInteraction` fires. Programmatic focus or a
106
+ synthetic parent-page click is not an equivalent substitute.
95
107
 
96
108
  The runtime exposes `status`, `isReady`, and `frame` getters for host UI state.
97
109
 
@@ -1,5 +1,5 @@
1
- import { type RunnerCapabilities } from '@textmode/runner-protocol';
2
- import { type IframeTextmodeRuntimeOptions } from './options';
1
+ import { type AudioDataMessage, type RunnerCapabilities } from '@textmode/runner-protocol';
2
+ import { type IframeTextmodeRuntimeOptions, type RunnerProbeOptions, type RunnerReconnectOptions } from './options';
3
3
  import type { RunnerRuntimeStatus } from './status';
4
4
  /**
5
5
  * Browser iframe runtime for communicating with the hosted textmode runner.
@@ -74,12 +74,13 @@ export declare class IframeTextmodeRuntime {
74
74
  */
75
75
  dispose(): void;
76
76
  /**
77
- * Recreates the iframe and reruns the last requested code when available.
77
+ * Recreates the iframe and optionally reruns the last requested code.
78
78
  *
79
+ * @param options - Reconnect behavior. The last code is rerun by default.
79
80
  * @returns `true` when reconnection succeeds.
80
81
  * @category Runtime
81
82
  */
82
- reconnect(): Promise<boolean>;
83
+ reconnect(options?: RunnerReconnectOptions): Promise<boolean>;
83
84
  /**
84
85
  * Focuses the iframe from a host user gesture.
85
86
  *
@@ -93,9 +94,32 @@ export declare class IframeTextmodeRuntime {
93
94
  *
94
95
  * @category Runtime
95
96
  */
96
- runCode(code: string, options?: {
97
- softReset?: boolean;
98
- }): Promise<boolean>;
97
+ runCode(code: string): Promise<boolean>;
98
+ /**
99
+ * Executes code as a transactional candidate.
100
+ *
101
+ * Failed and timed-out probes do not replace the code used by reconnect.
102
+ *
103
+ * @category Runtime
104
+ */
105
+ probeCode(code: string, options?: RunnerProbeOptions): Promise<boolean>;
106
+ /**
107
+ * Rebuilds the textmode runtime while preserving the current iframe document.
108
+ *
109
+ * Older runners fall back to a full reconnect followed by one code execution.
110
+ *
111
+ * @category Runtime
112
+ */
113
+ resetRuntime(code: string): Promise<boolean>;
114
+ /**
115
+ * Sends a fire-and-forget audio analysis frame to the runner.
116
+ *
117
+ * Audio frames are intentionally not request-tracked: hosts may send them at
118
+ * animation-frame cadence, and stale frames can be safely dropped.
119
+ *
120
+ * @category Runtime
121
+ */
122
+ sendAudioData(data: Omit<AudioDataMessage, 'type'>): boolean;
99
123
  private connectPort;
100
124
  private handlePortMessage;
101
125
  private handleReady;
@@ -109,5 +133,6 @@ export declare class IframeTextmodeRuntime {
109
133
  private assertSandboxOriginPolicy;
110
134
  private setStatus;
111
135
  private disposeFrame;
136
+ private rejectPendingHandshake;
112
137
  private createRequestId;
113
138
  }
@@ -109,10 +109,18 @@ export class IframeTextmodeRuntime {
109
109
  */
110
110
  async init(container) {
111
111
  this.container = container;
112
- this.assertSandboxOriginPolicy();
112
+ try {
113
+ this.assertSandboxOriginPolicy();
114
+ }
115
+ catch (error) {
116
+ const reason = error instanceof Error ? error.message : String(error);
117
+ this.handleUnavailable(reason);
118
+ throw error;
119
+ }
113
120
  if (this.isReady && this.iframe?.isConnected) {
114
121
  return true;
115
122
  }
123
+ this.rejectPendingHandshake('runner initialization superseded');
116
124
  this.disposeFrame();
117
125
  this.ready = false;
118
126
  this.setStatus('connecting');
@@ -129,6 +137,8 @@ export class IframeTextmodeRuntime {
129
137
  }, this.handshakeTimeoutMs);
130
138
  });
131
139
  iframe.addEventListener('load', () => {
140
+ if (this.iframe !== iframe)
141
+ return;
132
142
  this.connectPort();
133
143
  }, { once: true });
134
144
  return readyPromise;
@@ -149,6 +159,7 @@ export class IframeTextmodeRuntime {
149
159
  }
150
160
  this.pending.rejectAll(new Error('runner disposed'));
151
161
  this.heartbeat.stop();
162
+ this.rejectPendingHandshake('runner disposed');
152
163
  this.disposeFrame();
153
164
  this.ready = false;
154
165
  this.setStatus('idle');
@@ -156,16 +167,17 @@ export class IframeTextmodeRuntime {
156
167
  this.readyRejecter = null;
157
168
  }
158
169
  /**
159
- * Recreates the iframe and reruns the last requested code when available.
170
+ * Recreates the iframe and optionally reruns the last requested code.
160
171
  *
172
+ * @param options - Reconnect behavior. The last code is rerun by default.
161
173
  * @returns `true` when reconnection succeeds.
162
174
  * @category Runtime
163
175
  */
164
- async reconnect() {
176
+ async reconnect(options = {}) {
165
177
  if (!this.container) {
166
178
  return false;
167
179
  }
168
- const code = this.lastRequestedCode;
180
+ const code = options.rerun === false ? null : this.lastRequestedCode;
169
181
  this.setStatus('recovering');
170
182
  this.forceDisposeFrameForReconnect();
171
183
  const initialized = await this.init(this.container);
@@ -198,13 +210,62 @@ export class IframeTextmodeRuntime {
198
210
  *
199
211
  * @category Runtime
200
212
  */
201
- async runCode(code, options = {}) {
202
- this.lastRequestedCode = code;
213
+ async runCode(code) {
203
214
  const requestId = this.createRequestId('run');
204
- const message = options.softReset
205
- ? { type: 'SOFT_RESET', requestId, code }
206
- : { type: 'RUN_CODE', requestId, code };
215
+ const message = { type: 'RUN_CODE', requestId, code };
207
216
  await this.request(message);
217
+ this.lastRequestedCode = code;
218
+ return true;
219
+ }
220
+ /**
221
+ * Executes code as a transactional candidate.
222
+ *
223
+ * Failed and timed-out probes do not replace the code used by reconnect.
224
+ *
225
+ * @category Runtime
226
+ */
227
+ async probeCode(code, options = {}) {
228
+ const requestId = this.createRequestId('probe');
229
+ const message = { type: 'RUN_CODE', requestId, code };
230
+ await this.request(message, options.timeoutMs ?? this.requestTimeoutMs);
231
+ this.lastRequestedCode = code;
232
+ return true;
233
+ }
234
+ /**
235
+ * Rebuilds the textmode runtime while preserving the current iframe document.
236
+ *
237
+ * Older runners fall back to a full reconnect followed by one code execution.
238
+ *
239
+ * @category Runtime
240
+ */
241
+ async resetRuntime(code) {
242
+ if (this.capabilities?.runtimeReset !== true) {
243
+ const reconnected = await this.reconnect({ rerun: false });
244
+ return reconnected ? this.runCode(code) : false;
245
+ }
246
+ const requestId = this.createRequestId('reset');
247
+ await this.request({ type: 'RESET_RUNTIME', requestId, code });
248
+ this.lastRequestedCode = code;
249
+ return true;
250
+ }
251
+ /**
252
+ * Sends a fire-and-forget audio analysis frame to the runner.
253
+ *
254
+ * Audio frames are intentionally not request-tracked: hosts may send them at
255
+ * animation-frame cadence, and stale frames can be safely dropped.
256
+ *
257
+ * @category Runtime
258
+ */
259
+ sendAudioData(data) {
260
+ if (!this.port || !this.ready) {
261
+ return false;
262
+ }
263
+ this.postMessage({
264
+ type: 'AUDIO_DATA',
265
+ fft: data.fft,
266
+ waveform: data.waveform,
267
+ timestamp: data.timestamp,
268
+ });
208
269
  return true;
209
270
  }
210
271
  connectPort() {
@@ -236,9 +297,15 @@ export class IframeTextmodeRuntime {
236
297
  onSynthError: (synthErrorMessage) => {
237
298
  this.options.onSynthError?.(synthErrorMessage.message);
238
299
  },
300
+ onHardReset: () => {
301
+ this.options.onHardReset?.();
302
+ },
239
303
  onToggleUI: () => {
240
304
  this.options.onToggleUI?.();
241
305
  },
306
+ onUserActivationRequired: () => {
307
+ this.options.onUserActivationRequired?.();
308
+ },
242
309
  onUserInteraction: () => {
243
310
  this.options.onUserInteraction?.();
244
311
  },
@@ -295,7 +362,6 @@ export class IframeTextmodeRuntime {
295
362
  const kind = requestKindForMessage(message.type);
296
363
  const promise = this.pending.register({
297
364
  requestId,
298
- kind,
299
365
  messageType: message.type,
300
366
  timeoutMs,
301
367
  onTimeout: (error) => {
@@ -338,6 +404,7 @@ export class IframeTextmodeRuntime {
338
404
  }
339
405
  this.heartbeat.stop();
340
406
  this.pending.rejectAll(new Error('runner reconnecting'));
407
+ this.rejectPendingHandshake('runner reconnecting');
341
408
  this.ready = false;
342
409
  this.disposeFrame();
343
410
  }
@@ -380,6 +447,18 @@ export class IframeTextmodeRuntime {
380
447
  this.iframe = null;
381
448
  }
382
449
  }
450
+ rejectPendingHandshake(reason) {
451
+ if (!this.readyRejecter && !this.readyResolver) {
452
+ return;
453
+ }
454
+ if (this.readyTimeoutId !== null) {
455
+ window.clearTimeout(this.readyTimeoutId);
456
+ this.readyTimeoutId = null;
457
+ }
458
+ this.readyRejecter?.(new Error(reason));
459
+ this.readyResolver = null;
460
+ this.readyRejecter = null;
461
+ }
383
462
  createRequestId(prefix) {
384
463
  return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
385
464
  }
package/dist/index.d.ts CHANGED
@@ -11,5 +11,5 @@
11
11
  */
12
12
  export { IframeTextmodeRuntime } from './IframeTextmodeRuntime';
13
13
  export { RunnerRequestError, type RunnerExecutionError } from './errors';
14
- export { DEFAULT_IFRAME_SANDBOX_TOKENS, type IframeMountMode, type IframeSandboxToken, type IframeTextmodeRuntimeOptions, } from './options';
14
+ export { DEFAULT_IFRAME_SANDBOX_TOKENS, type IframeMountMode, type IframeSandboxToken, type IframeTextmodeRuntimeOptions, type RunnerProbeOptions, type RunnerReconnectOptions, } from './options';
15
15
  export { type RunnerRuntimeStatus } from './status';
@@ -4,7 +4,9 @@ export interface RunnerMessageHandlers {
4
4
  onRunOk: (message: RunOkMessage) => void;
5
5
  onRunError: (message: RunErrorMessage) => void;
6
6
  onSynthError: (message: SynthErrorMessage) => void;
7
+ onHardReset: () => void;
7
8
  onToggleUI: () => void;
9
+ onUserActivationRequired: () => void;
8
10
  onUserInteraction: () => void;
9
11
  onPong: (message: PongMessage) => void;
10
12
  }
@@ -16,9 +16,15 @@ export function routeRunnerMessage(message, handlers) {
16
16
  case 'SYNTH_ERROR':
17
17
  handlers.onSynthError(message);
18
18
  break;
19
+ case 'HARD_RESET':
20
+ handlers.onHardReset();
21
+ break;
19
22
  case 'TOGGLE_UI':
20
23
  handlers.onToggleUI();
21
24
  break;
25
+ case 'USER_ACTIVATION_REQUIRED':
26
+ handlers.onUserActivationRequired();
27
+ break;
22
28
  case 'USER_INTERACTION':
23
29
  handlers.onUserInteraction();
24
30
  break;
@@ -8,7 +8,6 @@ export interface RequestTimerApi {
8
8
  }
9
9
  interface RegisterRequestOptions {
10
10
  requestId: string;
11
- kind: RequestKind;
12
11
  messageType: ParentToRunnerMessage['type'];
13
12
  timeoutMs: number;
14
13
  onTimeout: (error: Error) => void;
@@ -18,7 +17,6 @@ export declare class RequestRegistry {
18
17
  private readonly timerApi;
19
18
  private readonly visibilityApi;
20
19
  constructor(timerApi?: RequestTimerApi, visibilityApi?: PageVisibilityApi);
21
- get size(): number;
22
20
  register<T>(options: RegisterRequestOptions): Promise<T>;
23
21
  resolve(requestId: string | undefined, value: unknown): boolean;
24
22
  reject(requestId: string, error: Error): boolean;
@@ -7,9 +7,6 @@ export class RequestRegistry {
7
7
  this.timerApi = timerApi;
8
8
  this.visibilityApi = visibilityApi;
9
9
  }
10
- get size() {
11
- return this.pending.size;
12
- }
13
10
  register(options) {
14
11
  return new Promise((resolve, reject) => {
15
12
  let remainingMs = options.timeoutMs;
@@ -66,7 +63,6 @@ export class RequestRegistry {
66
63
  scheduleTimer();
67
64
  }
68
65
  this.pending.set(options.requestId, {
69
- kind: options.kind,
70
66
  resolve: resolve,
71
67
  reject,
72
68
  cleanup,
@@ -104,10 +100,12 @@ export class RequestRegistry {
104
100
  export function requestKindForMessage(type) {
105
101
  switch (type) {
106
102
  case 'RUN_CODE':
107
- case 'SOFT_RESET':
108
103
  return 'run';
104
+ case 'RESET_RUNTIME':
105
+ return 'lifecycle';
109
106
  case 'PING':
110
107
  case 'DISPOSE':
108
+ case 'AUDIO_DATA':
111
109
  return 'lifecycle';
112
110
  }
113
111
  }
package/dist/options.d.ts CHANGED
@@ -13,6 +13,22 @@ export type IframeSandboxToken = 'allow-downloads' | 'allow-same-origin' | 'allo
13
13
  * @category Options
14
14
  */
15
15
  export type IframeMountMode = 'append' | 'replace';
16
+ /**
17
+ * Controls how a runner reconnect restores previously requested code.
18
+ *
19
+ * @category Options
20
+ */
21
+ export interface RunnerReconnectOptions {
22
+ /** Rerun the last requested code after reconnecting. Defaults to `true`. */
23
+ rerun?: boolean;
24
+ }
25
+ /**
26
+ * Controls a transactional code probe.
27
+ */
28
+ export interface RunnerProbeOptions {
29
+ /** Request timeout for the probe. Defaults to the runtime request timeout. */
30
+ timeoutMs?: number;
31
+ }
16
32
  /**
17
33
  * Default sandbox tokens used by the runner iframe.
18
34
  *
@@ -50,8 +66,12 @@ export interface IframeTextmodeRuntimeOptions {
50
66
  onRunError?: (error: RunnerExecutionError) => void;
51
67
  /** Called when the runner reports a synth parameter error. */
52
68
  onSynthError?: (message: string) => void;
69
+ /** Called when the runner requests a fresh host runtime. */
70
+ onHardReset?: () => void;
53
71
  /** Called when the runner requests host UI visibility changes. */
54
72
  onToggleUI?: () => void;
73
+ /** Called when the runner needs a trusted interaction inside its iframe document. */
74
+ onUserActivationRequired?: () => void;
55
75
  /** Called when the runner reports user interaction. */
56
76
  onUserInteraction?: () => void;
57
77
  /** Called when the runner becomes unavailable or hung. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textmode/runner-client",
3
- "version": "0.2.0",
3
+ "version": "0.5.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.2.0"
33
+ "@textmode/runner-protocol": "^0.5.0"
34
34
  }
35
35
  }