@textmode/runner-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ export class HeartbeatController {
2
+ intervalMs;
3
+ timeoutMs;
4
+ now;
5
+ timerApi;
6
+ onPing;
7
+ onTimeout;
8
+ intervalId = null;
9
+ lastPongAt = 0;
10
+ constructor(options) {
11
+ this.intervalMs = options.intervalMs;
12
+ this.timeoutMs = options.timeoutMs;
13
+ this.now = options.now ?? Date.now;
14
+ this.timerApi = options.timerApi ?? createWindowTimerApi();
15
+ this.onPing = options.onPing;
16
+ this.onTimeout = options.onTimeout;
17
+ }
18
+ start() {
19
+ this.stop();
20
+ this.lastPongAt = this.now();
21
+ this.intervalId = this.timerApi.setInterval(() => {
22
+ if (this.now() - this.lastPongAt > this.timeoutMs) {
23
+ this.onTimeout();
24
+ return;
25
+ }
26
+ this.onPing();
27
+ }, this.intervalMs);
28
+ }
29
+ stop() {
30
+ if (this.intervalId !== null) {
31
+ this.timerApi.clearInterval(this.intervalId);
32
+ this.intervalId = null;
33
+ }
34
+ }
35
+ markPong() {
36
+ this.lastPongAt = this.now();
37
+ }
38
+ }
39
+ function createWindowTimerApi() {
40
+ return {
41
+ setInterval: (handler, intervalMs) => window.setInterval(handler, intervalMs),
42
+ clearInterval: (intervalId) => window.clearInterval(intervalId),
43
+ };
44
+ }
@@ -0,0 +1,4 @@
1
+ import type { IframeMountMode, IframeSandboxToken } from '../options';
2
+ export declare function createRunnerIframe(runnerHref: string, sandboxTokens: readonly IframeSandboxToken[]): HTMLIFrameElement;
3
+ export declare function mountRunnerIframe(container: HTMLElement, iframe: HTMLIFrameElement, mode: IframeMountMode): void;
4
+ export declare function focusElement(element: HTMLElement): void;
@@ -0,0 +1,29 @@
1
+ export function createRunnerIframe(runnerHref, sandboxTokens) {
2
+ const iframe = document.createElement('iframe');
3
+ iframe.id = 'textmode-sandbox-runner';
4
+ iframe.title = 'textmode.js sandboxed runner';
5
+ iframe.src = runnerHref;
6
+ iframe.sandbox.add(...sandboxTokens);
7
+ iframe.referrerPolicy = 'no-referrer';
8
+ iframe.style.width = '100%';
9
+ iframe.style.height = '100%';
10
+ iframe.style.border = '0';
11
+ iframe.style.display = 'block';
12
+ iframe.style.background = 'transparent';
13
+ return iframe;
14
+ }
15
+ export function mountRunnerIframe(container, iframe, mode) {
16
+ if (mode === 'append') {
17
+ container.appendChild(iframe);
18
+ return;
19
+ }
20
+ container.replaceChildren(iframe);
21
+ }
22
+ export function focusElement(element) {
23
+ try {
24
+ element.focus({ preventScroll: true });
25
+ }
26
+ catch {
27
+ element.focus();
28
+ }
29
+ }
@@ -0,0 +1,16 @@
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';
2
+ export interface RunnerMessageHandlers {
3
+ onReady: (message: ReadyMessage) => void;
4
+ onRunOk: (message: RunOkMessage) => void;
5
+ onRunError: (message: RunErrorMessage) => void;
6
+ onSynthError: (message: SynthErrorMessage) => void;
7
+ onToggleUI: () => void;
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
+ onPong: (message: PongMessage) => void;
15
+ }
16
+ export declare function routeRunnerMessage(message: unknown, handlers: RunnerMessageHandlers): boolean;
@@ -0,0 +1,45 @@
1
+ import { isRunnerMessage, } from '@textmode/runner-protocol';
2
+ export function routeRunnerMessage(message, handlers) {
3
+ if (!isRunnerMessage(message)) {
4
+ return false;
5
+ }
6
+ switch (message.type) {
7
+ case 'READY':
8
+ handlers.onReady(message);
9
+ break;
10
+ case 'RUN_OK':
11
+ handlers.onRunOk(message);
12
+ break;
13
+ case 'RUN_ERROR':
14
+ handlers.onRunError(message);
15
+ break;
16
+ case 'SYNTH_ERROR':
17
+ handlers.onSynthError(message);
18
+ break;
19
+ case 'TOGGLE_UI':
20
+ handlers.onToggleUI();
21
+ break;
22
+ case 'USER_INTERACTION':
23
+ handlers.onUserInteraction();
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
+ case 'PONG':
41
+ handlers.onPong(message);
42
+ break;
43
+ }
44
+ return true;
45
+ }
@@ -0,0 +1,25 @@
1
+ import type { ParentToRunnerMessage } from '@textmode/runner-protocol';
2
+ export type RequestKind = 'run' | 'export' | 'font' | 'playback' | 'settings';
3
+ export interface RequestTimerApi {
4
+ setTimeout: (handler: () => void, timeoutMs: number) => number;
5
+ clearTimeout: (timeoutId: number) => void;
6
+ }
7
+ interface RegisterRequestOptions {
8
+ requestId: string;
9
+ kind: RequestKind;
10
+ messageType: ParentToRunnerMessage['type'];
11
+ timeoutMs: number;
12
+ onTimeout: (error: Error) => void;
13
+ }
14
+ export declare class RequestRegistry {
15
+ private readonly pending;
16
+ private readonly timerApi;
17
+ constructor(timerApi?: RequestTimerApi);
18
+ get size(): number;
19
+ register<T>(options: RegisterRequestOptions): Promise<T>;
20
+ resolve(requestId: string | undefined, value: unknown): boolean;
21
+ reject(requestId: string, error: Error): boolean;
22
+ rejectAll(error: Error): void;
23
+ }
24
+ export declare function requestKindForMessage(type: ParentToRunnerMessage['type']): RequestKind;
25
+ export {};
@@ -0,0 +1,77 @@
1
+ export class RequestRegistry {
2
+ pending = new Map();
3
+ timerApi;
4
+ constructor(timerApi = createWindowTimerApi()) {
5
+ this.timerApi = timerApi;
6
+ }
7
+ get size() {
8
+ return this.pending.size;
9
+ }
10
+ register(options) {
11
+ return new Promise((resolve, reject) => {
12
+ const timeoutId = this.timerApi.setTimeout(() => {
13
+ this.pending.delete(options.requestId);
14
+ const error = new Error(`runner request timed out: ${options.messageType}`);
15
+ reject(error);
16
+ options.onTimeout(error);
17
+ }, options.timeoutMs);
18
+ this.pending.set(options.requestId, {
19
+ kind: options.kind,
20
+ resolve: resolve,
21
+ reject,
22
+ timeoutId,
23
+ });
24
+ });
25
+ }
26
+ resolve(requestId, value) {
27
+ if (!requestId)
28
+ return false;
29
+ const pending = this.pending.get(requestId);
30
+ if (!pending)
31
+ return false;
32
+ this.timerApi.clearTimeout(pending.timeoutId);
33
+ this.pending.delete(requestId);
34
+ pending.resolve(value);
35
+ return true;
36
+ }
37
+ reject(requestId, error) {
38
+ const pending = this.pending.get(requestId);
39
+ if (!pending)
40
+ return false;
41
+ this.timerApi.clearTimeout(pending.timeoutId);
42
+ this.pending.delete(requestId);
43
+ pending.reject(error);
44
+ return true;
45
+ }
46
+ rejectAll(error) {
47
+ for (const [requestId, pending] of this.pending) {
48
+ this.timerApi.clearTimeout(pending.timeoutId);
49
+ pending.reject(error);
50
+ this.pending.delete(requestId);
51
+ }
52
+ }
53
+ }
54
+ export function requestKindForMessage(type) {
55
+ switch (type) {
56
+ case 'RUN_CODE':
57
+ case 'SOFT_RESET':
58
+ 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
+ case 'PING':
68
+ case 'DISPOSE':
69
+ return 'settings';
70
+ }
71
+ }
72
+ function createWindowTimerApi() {
73
+ return {
74
+ setTimeout: (handler, timeoutMs) => window.setTimeout(handler, timeoutMs),
75
+ clearTimeout: (timeoutId) => window.clearTimeout(timeoutId),
76
+ };
77
+ }
@@ -0,0 +1,7 @@
1
+ import type { IframeSandboxToken } from '../options';
2
+ export interface SandboxOriginPolicyOptions {
3
+ sandboxTokens: readonly IframeSandboxToken[];
4
+ runnerOrigin: string;
5
+ parentOrigin: string;
6
+ }
7
+ export declare function assertSandboxOriginPolicy(options: SandboxOriginPolicyOptions): void;
@@ -0,0 +1,6 @@
1
+ export function assertSandboxOriginPolicy(options) {
2
+ const canRunSameOriginScripts = options.sandboxTokens.includes('allow-scripts') && options.sandboxTokens.includes('allow-same-origin');
3
+ if (canRunSameOriginScripts && options.runnerOrigin === options.parentOrigin) {
4
+ throw new Error('Refusing to start sandbox runner with allow-scripts and allow-same-origin on the parent origin');
5
+ }
6
+ }
@@ -0,0 +1,67 @@
1
+ import type { ExportProgress, PlaybackState, RunnerCapabilities, RunOkMessage } from '@textmode/runner-protocol';
2
+ import type { RunnerExecutionError } from './errors';
3
+ import type { RunnerRuntimeStatus } from './status';
4
+ /**
5
+ * Iframe sandbox token supported by the runner client.
6
+ *
7
+ * @category Options
8
+ */
9
+ export type IframeSandboxToken = 'allow-downloads' | 'allow-same-origin' | 'allow-scripts';
10
+ /**
11
+ * How the runner iframe should be mounted into its container.
12
+ *
13
+ * @category Options
14
+ */
15
+ export type IframeMountMode = 'append' | 'replace';
16
+ /**
17
+ * Default sandbox tokens used by the runner iframe.
18
+ *
19
+ * The default deliberately excludes `allow-downloads`; downloads should be
20
+ * initiated by the host app after receiving export results.
21
+ *
22
+ * @category Options
23
+ */
24
+ export declare const DEFAULT_IFRAME_SANDBOX_TOKENS: readonly IframeSandboxToken[];
25
+ /**
26
+ * Options for {@link IframeTextmodeRuntime}.
27
+ *
28
+ * @category Options
29
+ */
30
+ export interface IframeTextmodeRuntimeOptions {
31
+ /** Absolute or parent-relative URL for the hosted runner app. */
32
+ runnerUrl: string;
33
+ /** Iframe mount behavior. Defaults to `replace`. */
34
+ mountMode?: IframeMountMode;
35
+ /** Sandbox tokens applied to the runner iframe. */
36
+ sandboxTokens?: IframeSandboxToken[];
37
+ /** Timeout for the initial iframe MessagePort handshake. */
38
+ handshakeTimeoutMs?: number;
39
+ /** Default timeout for request/response runner messages. */
40
+ requestTimeoutMs?: number;
41
+ /** Interval between heartbeat pings. */
42
+ heartbeatIntervalMs?: number;
43
+ /** Maximum time without a heartbeat pong before the runner is marked hung. */
44
+ heartbeatTimeoutMs?: number;
45
+ /** Called after the runner is ready and optional runtime configuration succeeds. */
46
+ onReady?: (capabilities: RunnerCapabilities) => void;
47
+ /** Called when code execution succeeds. */
48
+ onRunOk?: (message: RunOkMessage) => void;
49
+ /** Called for non-request-scoped runner execution errors. */
50
+ onRunError?: (error: RunnerExecutionError) => void;
51
+ /** Called when the runner reports a synth parameter error. */
52
+ onSynthError?: (message: string) => void;
53
+ /** Called when the runner requests host UI visibility changes. */
54
+ onToggleUI?: () => void;
55
+ /** Called when the runner reports user interaction. */
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
+ /** Called when the runner becomes unavailable or hung. */
62
+ onUnavailable?: (reason: string, status: RunnerRuntimeStatus) => void;
63
+ /** Called after the MessagePort connection is established. */
64
+ onConnected?: () => void;
65
+ /** Called whenever runtime lifecycle status changes. */
66
+ onStatusChange?: (status: RunnerRuntimeStatus, reason?: string | null) => void;
67
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Default sandbox tokens used by the runner iframe.
3
+ *
4
+ * The default deliberately excludes `allow-downloads`; downloads should be
5
+ * initiated by the host app after receiving export results.
6
+ *
7
+ * @category Options
8
+ */
9
+ export const DEFAULT_IFRAME_SANDBOX_TOKENS = [
10
+ 'allow-scripts',
11
+ // The runner is served from a separate origin in dev and production. Keeping
12
+ // same-origin inside that isolated origin preserves runner asset behavior
13
+ // without giving it parent DOM access.
14
+ 'allow-same-origin',
15
+ ];
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Lifecycle state for an iframe runner connection.
3
+ *
4
+ * @category Runtime
5
+ */
6
+ export type RunnerRuntimeStatus = 'idle' | 'connecting' | 'configuring' | 'ready' | 'recovering' | 'unavailable' | 'hung';
package/dist/status.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@textmode/runner-client",
3
+ "version": "0.1.0",
4
+ "description": "Browser iframe runtime client for the hosted textmode runner.",
5
+ "license": "AGPL-3.0-or-later",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "files": [
20
+ "dist/**/*.d.ts",
21
+ "dist/**/*.js"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsc -b",
25
+ "check-types": "tsc -p tsconfig.json --noEmit",
26
+ "lint": "eslint .",
27
+ "test": "vitest run --passWithNoTests",
28
+ "build:docs": "typedoc --options typedoc.json",
29
+ "dev:docs": "typedoc --options typedoc.json --watch",
30
+ "prepare": "npm run build"
31
+ },
32
+ "dependencies": {
33
+ "@textmode/runner-protocol": "^0.1.0"
34
+ }
35
+ }