@textmode/runner-client 0.2.1 → 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,7 +109,14 @@ 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
  }
@@ -160,16 +167,17 @@ export class IframeTextmodeRuntime {
160
167
  this.readyRejecter = null;
161
168
  }
162
169
  /**
163
- * Recreates the iframe and reruns the last requested code when available.
170
+ * Recreates the iframe and optionally reruns the last requested code.
164
171
  *
172
+ * @param options - Reconnect behavior. The last code is rerun by default.
165
173
  * @returns `true` when reconnection succeeds.
166
174
  * @category Runtime
167
175
  */
168
- async reconnect() {
176
+ async reconnect(options = {}) {
169
177
  if (!this.container) {
170
178
  return false;
171
179
  }
172
- const code = this.lastRequestedCode;
180
+ const code = options.rerun === false ? null : this.lastRequestedCode;
173
181
  this.setStatus('recovering');
174
182
  this.forceDisposeFrameForReconnect();
175
183
  const initialized = await this.init(this.container);
@@ -202,13 +210,62 @@ export class IframeTextmodeRuntime {
202
210
  *
203
211
  * @category Runtime
204
212
  */
205
- async runCode(code, options = {}) {
206
- this.lastRequestedCode = code;
213
+ async runCode(code) {
207
214
  const requestId = this.createRequestId('run');
208
- const message = options.softReset
209
- ? { type: 'SOFT_RESET', requestId, code }
210
- : { type: 'RUN_CODE', requestId, code };
215
+ const message = { type: 'RUN_CODE', requestId, code };
211
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
+ });
212
269
  return true;
213
270
  }
214
271
  connectPort() {
@@ -240,9 +297,15 @@ export class IframeTextmodeRuntime {
240
297
  onSynthError: (synthErrorMessage) => {
241
298
  this.options.onSynthError?.(synthErrorMessage.message);
242
299
  },
300
+ onHardReset: () => {
301
+ this.options.onHardReset?.();
302
+ },
243
303
  onToggleUI: () => {
244
304
  this.options.onToggleUI?.();
245
305
  },
306
+ onUserActivationRequired: () => {
307
+ this.options.onUserActivationRequired?.();
308
+ },
246
309
  onUserInteraction: () => {
247
310
  this.options.onUserInteraction?.();
248
311
  },
@@ -299,7 +362,6 @@ export class IframeTextmodeRuntime {
299
362
  const kind = requestKindForMessage(message.type);
300
363
  const promise = this.pending.register({
301
364
  requestId,
302
- kind,
303
365
  messageType: message.type,
304
366
  timeoutMs,
305
367
  onTimeout: (error) => {
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.1",
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
  }