@ttsc/playground 0.21.0 → 0.23.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.
Files changed (48) hide show
  1. package/README.md +7 -0
  2. package/lib/src/compiler/internal/createWorkerCompilerService.d.ts +1 -1
  3. package/lib/src/compiler/internal/createWorkerCompilerService.js +44 -12
  4. package/lib/src/compiler/internal/createWorkerCompilerService.js.map +1 -1
  5. package/lib/src/compiler/loadTypiaSourcePack.d.ts +6 -4
  6. package/lib/src/compiler/loadTypiaSourcePack.js +101 -13
  7. package/lib/src/compiler/loadTypiaSourcePack.js.map +1 -1
  8. package/lib/src/compiler/normalizeError.js +11 -2
  9. package/lib/src/compiler/normalizeError.js.map +1 -1
  10. package/lib/src/index.d.ts +2 -2
  11. package/lib/src/index.js +3 -1
  12. package/lib/src/index.js.map +1 -1
  13. package/lib/src/react/PlaygroundShell.js +188 -106
  14. package/lib/src/react/PlaygroundShell.js.map +1 -1
  15. package/lib/src/react/internal/PlaygroundCompilerLifecycle.d.ts +39 -0
  16. package/lib/src/react/internal/PlaygroundCompilerLifecycle.js +79 -0
  17. package/lib/src/react/internal/PlaygroundCompilerLifecycle.js.map +1 -0
  18. package/lib/src/react/internal/PlaygroundExecutionLifecycle.d.ts +26 -0
  19. package/lib/src/react/internal/PlaygroundExecutionLifecycle.js +49 -0
  20. package/lib/src/react/internal/PlaygroundExecutionLifecycle.js.map +1 -0
  21. package/lib/src/react/internal/recoverTerminalCompilerWorker.d.ts +15 -0
  22. package/lib/src/react/internal/recoverTerminalCompilerWorker.js +35 -0
  23. package/lib/src/react/internal/recoverTerminalCompilerWorker.js.map +1 -0
  24. package/lib/src/sandbox/loadTypiaRuntimePack.d.ts +8 -5
  25. package/lib/src/sandbox/loadTypiaRuntimePack.js +98 -23
  26. package/lib/src/sandbox/loadTypiaRuntimePack.js.map +1 -1
  27. package/lib/src/structures/IInstallTypiaSourcePackOptions.d.ts +5 -1
  28. package/lib/src/structures/ILoadTypiaRuntimePackOptions.d.ts +7 -0
  29. package/lib/src/structures/ILoadTypiaRuntimePackOptions.js +3 -0
  30. package/lib/src/structures/ILoadTypiaRuntimePackOptions.js.map +1 -0
  31. package/lib/src/structures/IPlaygroundShellProps.d.ts +7 -0
  32. package/lib/src/structures/index.d.ts +1 -0
  33. package/lib/src/structures/index.js +1 -0
  34. package/lib/src/structures/index.js.map +1 -1
  35. package/package.json +3 -3
  36. package/src/compiler/internal/createWorkerCompilerService.ts +43 -17
  37. package/src/compiler/loadTypiaSourcePack.ts +155 -13
  38. package/src/compiler/normalizeError.ts +15 -2
  39. package/src/index.ts +8 -2
  40. package/src/react/PlaygroundShell.tsx +307 -193
  41. package/src/react/internal/PlaygroundCompilerLifecycle.ts +95 -0
  42. package/src/react/internal/PlaygroundExecutionLifecycle.ts +55 -0
  43. package/src/react/internal/recoverTerminalCompilerWorker.ts +41 -0
  44. package/src/sandbox/loadTypiaRuntimePack.ts +155 -19
  45. package/src/structures/IInstallTypiaSourcePackOptions.ts +5 -1
  46. package/src/structures/ILoadTypiaRuntimePackOptions.ts +7 -0
  47. package/src/structures/IPlaygroundShellProps.ts +7 -0
  48. package/src/structures/index.ts +1 -0
@@ -0,0 +1,95 @@
1
+ export interface IPlaygroundCompilerGeneration {
2
+ /** Whether this token still owns the active compiler Worker generation. */
3
+ isCurrent(): boolean;
4
+ }
5
+
6
+ /**
7
+ * Serializes dependency mutations and fences every asynchronous consumer of a
8
+ * compiler Worker generation.
9
+ *
10
+ * Invalidating a generation immediately makes its active task advisory-only and
11
+ * prevents its queued tasks from starting. New-generation tasks remain
12
+ * serialized behind an active old task so two dependency installers can never
13
+ * mutate the shared compiler filesystem concurrently.
14
+ */
15
+ export class PlaygroundCompilerLifecycle {
16
+ private epoch: number = 0;
17
+ private queue: Promise<void> = Promise.resolve();
18
+
19
+ public capture(): IPlaygroundCompilerGeneration {
20
+ const epoch = this.epoch;
21
+ return {
22
+ isCurrent: () => this.epoch === epoch,
23
+ };
24
+ }
25
+
26
+ public invalidate(): IPlaygroundCompilerGeneration {
27
+ this.epoch++;
28
+ return this.capture();
29
+ }
30
+
31
+ public invalidateIfCurrent(
32
+ generation: IPlaygroundCompilerGeneration,
33
+ ): IPlaygroundCompilerGeneration | undefined {
34
+ if (!generation.isCurrent()) return undefined;
35
+ return this.invalidate();
36
+ }
37
+
38
+ /**
39
+ * Reset a Worker owned by `generation`, then clear its dependency metadata.
40
+ *
41
+ * The clear deliberately happens before a caller checks any independent
42
+ * source version. A source edit during reset still leaves an empty Worker, so
43
+ * its metadata must become empty too. A Worker-generation replacement
44
+ * performs its own synchronous clear and prevents this stale reset from
45
+ * clearing the replacement.
46
+ */
47
+ public async resetWorkerIfCurrent(
48
+ generation: IPlaygroundCompilerGeneration,
49
+ reset: () => Promise<void>,
50
+ clear: () => void,
51
+ ): Promise<boolean> {
52
+ if (!generation.isCurrent()) return false;
53
+ await reset();
54
+ if (!generation.isCurrent()) return false;
55
+ clear();
56
+ return true;
57
+ }
58
+
59
+ /**
60
+ * Run a Worker mutation and reconcile a source edit that lands during it.
61
+ *
62
+ * An RPC cannot be cancelled after it has started mutating the Worker's
63
+ * MemFS. If its source becomes stale before completion, reset that Worker and
64
+ * clear the matching dependency metadata before another source can reuse it.
65
+ */
66
+ public async mutateWorkerIfCurrent(
67
+ generation: IPlaygroundCompilerGeneration,
68
+ isSourceCurrent: () => boolean,
69
+ mutate: () => Promise<unknown>,
70
+ reset: () => Promise<void>,
71
+ clear: () => void,
72
+ ): Promise<boolean> {
73
+ if (!generation.isCurrent() || !isSourceCurrent()) return false;
74
+ await mutate();
75
+ if (!generation.isCurrent()) return false;
76
+ if (isSourceCurrent()) return true;
77
+ await this.resetWorkerIfCurrent(generation, reset, clear);
78
+ return false;
79
+ }
80
+
81
+ public enqueue<T>(
82
+ task: (generation: IPlaygroundCompilerGeneration) => Promise<T>,
83
+ ): Promise<T | undefined> {
84
+ const generation = this.capture();
85
+ const result = this.queue.then(async () => {
86
+ if (!generation.isCurrent()) return undefined;
87
+ return task(generation);
88
+ });
89
+ this.queue = result.then(
90
+ () => undefined,
91
+ () => undefined,
92
+ );
93
+ return result;
94
+ }
95
+ }
@@ -0,0 +1,55 @@
1
+ export interface IPlaygroundExecutionAttempt {
2
+ /** Signal passed through every cancellable step owned by this attempt. */
3
+ readonly signal: AbortSignal;
4
+ /** Whether this attempt may still commit messages or state. */
5
+ isCurrent(): boolean;
6
+ /** Release the active slot if this is still the current attempt. */
7
+ finish(): boolean;
8
+ }
9
+
10
+ /**
11
+ * Owns the cancellation and stale-write boundary for Execute attempts.
12
+ *
13
+ * React state stays in `PlaygroundShell`; this small state machine keeps the
14
+ * supersession rules independently testable and makes every invalidation path
15
+ * use the same abort behavior.
16
+ */
17
+ export class PlaygroundExecutionLifecycle {
18
+ private active: AbortController | null = null;
19
+ private epoch = 0;
20
+
21
+ public begin(): IPlaygroundExecutionAttempt {
22
+ this.invalidate("a newer Execute started");
23
+ const controller = new AbortController();
24
+ const epoch = this.epoch;
25
+ this.active = controller;
26
+ return {
27
+ signal: controller.signal,
28
+ isCurrent: () => this.epoch === epoch && this.active === controller,
29
+ finish: () => {
30
+ if (this.epoch !== epoch || this.active !== controller) return false;
31
+ this.active = null;
32
+ return true;
33
+ },
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Abort the active attempt and make its callbacks stale.
39
+ *
40
+ * Returns whether an active attempt was canceled.
41
+ */
42
+ public invalidate(reason: string): boolean {
43
+ const active = this.active;
44
+ this.active = null;
45
+ ++this.epoch;
46
+ active?.abort(createExecutionAbortError(reason));
47
+ return active !== null;
48
+ }
49
+ }
50
+
51
+ function createExecutionAbortError(reason: string): Error {
52
+ const error = new Error(`Playground execution aborted: ${reason}.`);
53
+ error.name = "AbortError";
54
+ return error;
55
+ }
@@ -0,0 +1,41 @@
1
+ import { BootTtscWorkerTerminationError } from "@ttsc/wasm";
2
+
3
+ export interface ITerminalCompilerWorkerRecovery {
4
+ /** Atomically claim and fence the failed Worker generation. */
5
+ claim(): boolean;
6
+ /** Close and clear that Worker generation. */
7
+ reset(): Promise<void>;
8
+ /** Publish the terminal error after the old Worker is no longer reachable. */
9
+ fail(error: unknown): void;
10
+ }
11
+
12
+ /**
13
+ * Replace a compiler Worker after its Go runtime started but boot never became
14
+ * usable. Returns false for ordinary compile, transport, and plugin failures.
15
+ */
16
+ export async function recoverTerminalCompilerWorker(
17
+ error: unknown,
18
+ recovery: ITerminalCompilerWorkerRecovery,
19
+ ): Promise<boolean> {
20
+ if (!requiresCompilerWorkerReplacement(error)) return false;
21
+ if (!recovery.claim()) return true;
22
+ try {
23
+ await recovery.reset();
24
+ } finally {
25
+ recovery.fail(error);
26
+ }
27
+ return true;
28
+ }
29
+
30
+ /** Recognize both local errors and their plain tgrid/JSON transport shape. */
31
+ export function requiresCompilerWorkerReplacement(error: unknown): boolean {
32
+ const code = BootTtscWorkerTerminationError.CODE;
33
+ const prefix = `[${code}] `;
34
+ if (typeof error === "string") return error.startsWith(prefix);
35
+ if (!error || typeof error !== "object") return false;
36
+ const record = error as { code?: unknown; message?: unknown };
37
+ return (
38
+ record.code === code ||
39
+ (typeof record.message === "string" && record.message.startsWith(prefix))
40
+ );
41
+ }
@@ -2,35 +2,171 @@
2
2
  //
3
3
  // The pack JSON itself is built by the site (e.g. `pack-typia-runtime.cjs`
4
4
  // in the ttsc website) and served at a site-chosen URL. It mirrors the
5
- // layout the typia transform's emit references `typia/lib/internal/*`,
6
- // `@typia/utils/lib/*`, etc. so a bundle's `require("typia/lib/internal/X")`
7
- // resolves to the matching pack entry.
5
+ // layout the typia transform's emit references -- `typia/lib/internal/*`,
6
+ // `@typia/utils/lib/*`, etc. -- so a bundle's
7
+ // `require("typia/lib/internal/X")` resolves to the matching pack entry.
8
+ import type { ILoadTypiaRuntimePackOptions } from "../structures/ILoadTypiaRuntimePackOptions";
8
9
 
9
- const packCache = new Map<string, Promise<Record<string, string>>>();
10
+ interface RuntimePackEntry {
11
+ controller: AbortController;
12
+ promise: Promise<Record<string, string>>;
13
+ }
14
+
15
+ interface RuntimePackCancellationReason {
16
+ kind: "abort" | "timeout";
17
+ reason?: unknown;
18
+ timeoutMs?: number;
19
+ }
20
+
21
+ interface RuntimePackCancellation {
22
+ promise: Promise<never>;
23
+ dispose: () => void;
24
+ }
25
+
26
+ export const DEFAULT_RUNTIME_PACK_TIMEOUT_MS = 30_000;
27
+
28
+ const packCache = new Map<string, RuntimePackEntry>();
10
29
 
11
30
  /**
12
- * Fetches the prebuilt runtime pack once per URL. Re-entrant on the same
13
- * in-flight promise. On rejection the cache entry is cleared so the next call
14
- * retries otherwise a transient fetch failure (CDN blip, offline at first
15
- * Execute) would permanently break every later Execute attempt.
31
+ * Fetches the prebuilt runtime pack once per URL.
32
+ *
33
+ * Concurrent callers share one load. A caller abort or deadline cancels that
34
+ * shared attempt; rejection removes it from the cache so the next call retries
35
+ * from scratch. Successful packs remain cached.
16
36
  */
17
- export async function loadTypiaRuntimePack(
37
+ export function loadTypiaRuntimePack(
18
38
  url: string,
39
+ options: ILoadTypiaRuntimePackOptions = {},
19
40
  ): Promise<Record<string, string>> {
41
+ const timeoutMs = resolveRuntimePackTimeout(options.timeoutMs);
20
42
  const cached = packCache.get(url);
21
- if (cached) return cached;
43
+ if (cached) {
44
+ attachRuntimePackCancellation(cached, options.signal, timeoutMs);
45
+ return cached.promise;
46
+ }
47
+
48
+ const controller = new AbortController();
49
+ let phase = `fetching ${url}`;
50
+ const cancellation = createRuntimePackCancellation(
51
+ controller.signal,
52
+ () => phase,
53
+ );
54
+ let entry!: RuntimePackEntry;
22
55
  const promise = (async () => {
23
- const response = await fetch(url);
24
- if (!response.ok) {
56
+ const response = await raceRuntimePackCancellation(
57
+ fetch(url, { signal: controller.signal }),
58
+ cancellation.promise,
59
+ controller.signal,
60
+ () => phase,
61
+ );
62
+ if (!response.ok)
25
63
  throw new Error(
26
64
  `loadTypiaRuntimePack: failed to fetch ${url}: ${response.status}`,
27
65
  );
28
- }
29
- return (await response.json()) as Record<string, string>;
30
- })().catch((err) => {
31
- packCache.delete(url);
32
- throw err;
33
- });
34
- packCache.set(url, promise);
66
+
67
+ phase = `reading JSON from ${url}`;
68
+ return (await raceRuntimePackCancellation(
69
+ response.json(),
70
+ cancellation.promise,
71
+ controller.signal,
72
+ () => phase,
73
+ )) as Record<string, string>;
74
+ })()
75
+ .catch((error) => {
76
+ if (packCache.get(url) === entry) packCache.delete(url);
77
+ throw error;
78
+ })
79
+ .finally(cancellation.dispose);
80
+
81
+ entry = { controller, promise };
82
+ packCache.set(url, entry);
83
+ attachRuntimePackCancellation(entry, options.signal, timeoutMs);
35
84
  return promise;
36
85
  }
86
+
87
+ function resolveRuntimePackTimeout(timeoutMs: number | undefined): number {
88
+ const value = timeoutMs ?? DEFAULT_RUNTIME_PACK_TIMEOUT_MS;
89
+ if (!Number.isSafeInteger(value) || value <= 0 || value > 2_147_483_647)
90
+ throw new RangeError(
91
+ "loadTypiaRuntimePack: timeoutMs must be a positive integer no greater than 2147483647.",
92
+ );
93
+ return value;
94
+ }
95
+
96
+ function attachRuntimePackCancellation(
97
+ entry: RuntimePackEntry,
98
+ callerSignal: AbortSignal | undefined,
99
+ timeoutMs: number,
100
+ ): void {
101
+ const abortFromCaller = (): void => {
102
+ if (!entry.controller.signal.aborted)
103
+ entry.controller.abort({
104
+ kind: "abort",
105
+ reason: callerSignal?.reason,
106
+ } satisfies RuntimePackCancellationReason);
107
+ };
108
+ if (callerSignal?.aborted) abortFromCaller();
109
+ else callerSignal?.addEventListener("abort", abortFromCaller, { once: true });
110
+
111
+ const timer = setTimeout(() => {
112
+ if (!entry.controller.signal.aborted)
113
+ entry.controller.abort({
114
+ kind: "timeout",
115
+ timeoutMs,
116
+ } satisfies RuntimePackCancellationReason);
117
+ }, timeoutMs);
118
+ const cleanup = (): void => {
119
+ clearTimeout(timer);
120
+ callerSignal?.removeEventListener("abort", abortFromCaller);
121
+ };
122
+ void entry.promise.then(cleanup, cleanup);
123
+ }
124
+
125
+ function createRuntimePackCancellation(
126
+ signal: AbortSignal,
127
+ getPhase: () => string,
128
+ ): RuntimePackCancellation {
129
+ let rejectCancellation!: (error: Error) => void;
130
+ const promise = new Promise<never>((_resolve, reject) => {
131
+ rejectCancellation = reject;
132
+ });
133
+ const onAbort = (): void => {
134
+ rejectCancellation(runtimePackCancellationError(signal, getPhase()));
135
+ };
136
+ signal.addEventListener("abort", onAbort, { once: true });
137
+ if (signal.aborted) onAbort();
138
+ return {
139
+ promise,
140
+ dispose: () => signal.removeEventListener("abort", onAbort),
141
+ };
142
+ }
143
+
144
+ async function raceRuntimePackCancellation<T>(
145
+ work: Promise<T>,
146
+ cancellation: Promise<never>,
147
+ signal: AbortSignal,
148
+ getPhase: () => string,
149
+ ): Promise<T> {
150
+ try {
151
+ return await Promise.race([work, cancellation]);
152
+ } catch (error) {
153
+ if (signal.aborted) throw runtimePackCancellationError(signal, getPhase());
154
+ throw error;
155
+ }
156
+ }
157
+
158
+ function runtimePackCancellationError(
159
+ signal: AbortSignal,
160
+ phase: string,
161
+ ): Error {
162
+ const reason = signal.reason as RuntimePackCancellationReason | undefined;
163
+ if (reason?.kind === "timeout")
164
+ return new Error(
165
+ `loadTypiaRuntimePack: timed out after ${reason.timeoutMs}ms while ${phase}.`,
166
+ );
167
+
168
+ const error = new Error(`loadTypiaRuntimePack: aborted while ${phase}.`);
169
+ const cause = reason?.kind === "abort" ? reason.reason : signal.reason;
170
+ if (cause !== undefined) (error as Error & { cause?: unknown }).cause = cause;
171
+ return error;
172
+ }
@@ -10,9 +10,13 @@ export interface IInstallTypiaSourcePackOptions {
10
10
  * matching `DEFAULT_WORK_DIR + "/node_modules"`.
11
11
  */
12
12
  mountRoot?: string;
13
+ /** Cancel the shared in-flight load. */
14
+ signal?: AbortSignal;
15
+ /** Maximum fetch and JSON-read time. Defaults to 30 seconds. */
16
+ timeoutMs?: number;
13
17
  /**
14
18
  * Optional fetcher. Defaults to `globalThis.fetch`. Override for tests or for
15
19
  * sites that want their own caching strategy.
16
20
  */
17
- fetch?: (input: string) => Promise<Response>;
21
+ fetch?: (input: string, init?: RequestInit) => Promise<Response>;
18
22
  }
@@ -0,0 +1,7 @@
1
+ /** Cancellation and deadline policy for `loadTypiaRuntimePack`. */
2
+ export interface ILoadTypiaRuntimePackOptions {
3
+ /** Cancel the shared in-flight load. */
4
+ signal?: AbortSignal;
5
+ /** Maximum fetch and JSON-read time. Defaults to 30 seconds. */
6
+ timeoutMs?: number;
7
+ }
@@ -58,6 +58,12 @@ export interface IPlaygroundShellProps {
58
58
  * union to `createSandboxRequire` — without this channel the in-page Execute
59
59
  * sandbox cannot resolve any npm dependency the user installed.
60
60
  *
61
+ * `sandbox.signal` aborts when source or compiler options change, a newer
62
+ * Execute starts, or the shell unmounts. Implementations must pass it through
63
+ * to cancellable setup such as runtime-pack fetches. Synchronous evaluated
64
+ * user code cannot be preempted and still requires an isolated executor when
65
+ * untrusted code is accepted.
66
+ *
61
67
  * When omitted, the Execute UI is hidden.
62
68
  */
63
69
  executeBundle?: (
@@ -65,6 +71,7 @@ export interface IPlaygroundShellProps {
65
71
  sandbox: {
66
72
  console: Record<string, (...args: unknown[]) => void>;
67
73
  runtimeFiles: Record<string, string>;
74
+ signal: AbortSignal;
68
75
  },
69
76
  ) => Promise<void>;
70
77
 
@@ -4,6 +4,7 @@ export * from "./IConsoleMessage";
4
4
  export * from "./ICreateCompilerClientOptions";
5
5
  export * from "./ICreateWorkerCompilerOptions";
6
6
  export * from "./IInstallTypiaSourcePackOptions";
7
+ export * from "./ILoadTypiaRuntimePackOptions";
7
8
  export * from "./ILintPluginConfig";
8
9
  export * from "./IOptionToggle";
9
10
  export * from "./IPlaygroundDependencyInstallOptions";