@kubohiroya/turbowarp-runtime-adapter 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/src/index.ts ADDED
@@ -0,0 +1,745 @@
1
+ export interface ScratchRuntimeHost {
2
+ vm?: {
3
+ runtime?: unknown;
4
+ };
5
+ extensions?: {
6
+ unsandboxed?: boolean;
7
+ };
8
+ }
9
+
10
+ export interface TurboWarpRuntimeLike {
11
+ on(event: string, listener: (...args: unknown[]) => void): void;
12
+ off?: (event: string, listener: (...args: unknown[]) => void) => void;
13
+ removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
14
+ startHats(opcode: string, matchFields?: Record<string, string>): unknown[] | undefined;
15
+ getTargetForStage?: () => unknown;
16
+ _stopThread?: (thread: unknown) => void;
17
+ threads?: unknown[];
18
+ targets?: unknown[];
19
+ renderer?: unknown;
20
+ requestRedraw?: () => void;
21
+ monitorBlocks?: unknown;
22
+ getMonitorState?: () => unknown;
23
+ }
24
+
25
+ /**
26
+ * The Scratch monitor block store, as used by the `data_showvariable` and `data_hidevariable`
27
+ * primitives. Only the members needed to read and toggle a monitor are declared.
28
+ */
29
+ export interface TurboWarpMonitorBlocks {
30
+ getBlock(id: string): unknown;
31
+ getScripts(): string[];
32
+ changeBlock(change: {id: string; element: string; value: unknown}): void;
33
+ }
34
+
35
+ /** The immutable monitor record map the runtime keeps alongside {@link TurboWarpMonitorBlocks}. */
36
+ export interface TurboWarpMonitorState {
37
+ has(id: string): boolean;
38
+ get(id: string): unknown;
39
+ valueSeq(): Iterable<unknown>;
40
+ }
41
+
42
+ export interface TurboWarpRuntimeHostOptions {
43
+ Scratch?: ScratchRuntimeHost;
44
+ runtime?: unknown;
45
+ requireUnsandboxed?: boolean;
46
+ }
47
+
48
+ export interface TurboWarpRuntimeHost {
49
+ runtime: TurboWarpRuntimeLike;
50
+ onRuntimeEvent(event: string, listener: (...args: unknown[]) => void): () => void;
51
+ startHats(opcode: string, matchFields?: Record<string, string>): unknown[];
52
+ stopThread(thread: unknown): void;
53
+ getStageTarget(): unknown;
54
+ currentThreads(): unknown[];
55
+ /** Every loaded target, Stage included, as a copy the caller may not mutate in place. */
56
+ targets(): unknown[];
57
+ /** Every loaded target except the Stage, in runtime order. */
58
+ spriteTargets(): unknown[];
59
+ /** The renderer the VM attached. Throws when the runtime has none. */
60
+ getRenderer(): unknown;
61
+ /** Ask the renderer for a frame. A runtime without `requestRedraw` is a no-op, not an error. */
62
+ requestRedraw(): void;
63
+ getMonitorBlocks(): TurboWarpMonitorBlocks;
64
+ getMonitorState(): TurboWarpMonitorState;
65
+ }
66
+
67
+ export interface BroadcastPortContext {
68
+ signal: AbortSignal;
69
+ }
70
+
71
+ export interface TurboWarpBroadcastPort {
72
+ broadcastMessageAndWait(
73
+ payload: {message: string},
74
+ context: BroadcastPortContext
75
+ ): Promise<void>;
76
+ dispose(): void;
77
+ }
78
+
79
+ export interface TurboWarpBroadcastPortOptions {
80
+ runtime: unknown;
81
+ errorCodePrefix?: string;
82
+ }
83
+
84
+ const broadcastHatOpcode = 'event_whenbroadcastreceived';
85
+ const broadcastMessageType = 'broadcast_msg';
86
+ const afterExecuteEvent = 'AFTER_EXECUTE';
87
+
88
+ function isRecord(value: unknown): value is Record<string, unknown> {
89
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
90
+ }
91
+
92
+ function prefixedCode(prefix: string, suffix: string): string {
93
+ if (!/^[A-Z][A-Z0-9]*$/u.test(prefix)) {
94
+ throw new TypeError('errorCodePrefix must be an uppercase diagnostic prefix.');
95
+ }
96
+ return `${prefix}-${suffix}`;
97
+ }
98
+
99
+ function hostError(code: string, message: string, cause?: unknown): Error {
100
+ const error = new Error(message);
101
+ Object.defineProperty(error, 'code', {value: code});
102
+ if (cause !== undefined) Object.defineProperty(error, 'cause', {value: cause});
103
+ return error;
104
+ }
105
+
106
+ function cancelledError(prefix: string, message: string): Error {
107
+ const error = hostError(prefixedCode(prefix, 'BROADCAST-CANCELLED'), message);
108
+ error.name = 'AbortError';
109
+ return error;
110
+ }
111
+
112
+ function removeRuntimeListener(
113
+ runtime: TurboWarpRuntimeLike,
114
+ event: string,
115
+ listener: (...args: unknown[]) => void
116
+ ): void {
117
+ if (typeof runtime.off === 'function') {
118
+ runtime.off(event, listener);
119
+ return;
120
+ }
121
+ if (typeof runtime.removeListener === 'function') {
122
+ runtime.removeListener(event, listener);
123
+ return;
124
+ }
125
+ throw hostError(
126
+ 'TWRH-RUNTIME-001',
127
+ 'TurboWarp runtime must provide off or removeListener to dispose listeners.'
128
+ );
129
+ }
130
+
131
+ function validateRuntime(value: unknown): TurboWarpRuntimeLike {
132
+ if (!isRecord(value)) {
133
+ throw hostError('TWRH-RUNTIME-001', 'TurboWarp runtime must be an object.');
134
+ }
135
+ if (typeof value['on'] !== 'function') {
136
+ throw hostError('TWRH-RUNTIME-001', 'TurboWarp runtime must provide on.');
137
+ }
138
+ if (typeof value['startHats'] !== 'function') {
139
+ throw hostError('TWRH-RUNTIME-001', 'TurboWarp runtime must provide startHats.');
140
+ }
141
+ return value as unknown as TurboWarpRuntimeLike;
142
+ }
143
+
144
+ function validateAbortSignal(value: unknown, prefix = 'TWRH'): AbortSignal {
145
+ if (
146
+ !isRecord(value) ||
147
+ typeof value['aborted'] !== 'boolean' ||
148
+ typeof value['addEventListener'] !== 'function' ||
149
+ typeof value['removeEventListener'] !== 'function'
150
+ ) {
151
+ throw hostError(
152
+ prefixedCode(prefix, 'BROADCAST-CONTEXT-001'),
153
+ 'broadcastMessageAndWait context must provide an AbortSignal.'
154
+ );
155
+ }
156
+ return value as unknown as AbortSignal;
157
+ }
158
+
159
+ function requireMethod<T extends keyof TurboWarpRuntimeLike>(
160
+ runtime: TurboWarpRuntimeLike,
161
+ method: T
162
+ ): NonNullable<TurboWarpRuntimeLike[T]> {
163
+ const operation = runtime[method];
164
+ if (typeof operation !== 'function') {
165
+ throw hostError('TWRH-RUNTIME-001', `TurboWarp runtime must provide ${String(method)}.`);
166
+ }
167
+ return operation as NonNullable<TurboWarpRuntimeLike[T]>;
168
+ }
169
+
170
+ export function createTurboWarpRuntimeHost(
171
+ options: TurboWarpRuntimeHostOptions = {}
172
+ ): TurboWarpRuntimeHost {
173
+ if (options.requireUnsandboxed === true && options.Scratch?.extensions?.unsandboxed !== true) {
174
+ throw hostError('TWRH-SCRATCH-001', 'TurboWarp runtime host requires unsandboxed Scratch.');
175
+ }
176
+ const runtime = validateRuntime(options.runtime ?? options.Scratch?.vm?.runtime);
177
+
178
+ return Object.freeze({
179
+ runtime,
180
+ onRuntimeEvent(event: string, listener: (...args: unknown[]) => void) {
181
+ runtime.on(event, listener);
182
+ return () => removeRuntimeListener(runtime, event, listener);
183
+ },
184
+ startHats(opcode: string, matchFields?: Record<string, string>) {
185
+ const threads = runtime.startHats(opcode, matchFields);
186
+ if (threads === undefined) return [];
187
+ if (!Array.isArray(threads)) {
188
+ throw hostError('TWRH-RUNTIME-001', 'TurboWarp startHats must return an array or undefined.');
189
+ }
190
+ return threads;
191
+ },
192
+ stopThread(thread: unknown) {
193
+ requireMethod(runtime, '_stopThread').call(runtime, thread);
194
+ },
195
+ getStageTarget() {
196
+ return requireMethod(runtime, 'getTargetForStage').call(runtime);
197
+ },
198
+ currentThreads() {
199
+ if (runtime.threads === undefined) return [];
200
+ if (!Array.isArray(runtime.threads)) {
201
+ throw hostError('TWRH-RUNTIME-001', 'TurboWarp runtime threads must be an array.');
202
+ }
203
+ return [...runtime.threads];
204
+ },
205
+ targets() {
206
+ return resolveTargets(runtime);
207
+ },
208
+ spriteTargets() {
209
+ return resolveTargets(runtime).filter(
210
+ (target) => !(isRecord(target) && target['isStage'] === true)
211
+ );
212
+ },
213
+ getRenderer() {
214
+ const renderer = runtime.renderer;
215
+ if (!isRecord(renderer)) {
216
+ throw hostError('TWRH-RENDERER-001', 'TurboWarp runtime has no attached renderer.');
217
+ }
218
+ return renderer;
219
+ },
220
+ requestRedraw() {
221
+ // A runtime without a renderer redraws on its own tick, so this stays a no-op rather than
222
+ // forcing every caller to feature-test before asking for a frame.
223
+ if (typeof runtime.requestRedraw === 'function') runtime.requestRedraw();
224
+ },
225
+ getMonitorBlocks() {
226
+ return resolveMonitorBlocks(runtime);
227
+ },
228
+ getMonitorState() {
229
+ return resolveMonitorState(runtime);
230
+ }
231
+ });
232
+ }
233
+
234
+ function resolveTargets(runtime: TurboWarpRuntimeLike): unknown[] {
235
+ if (runtime.targets === undefined) return [];
236
+ if (!Array.isArray(runtime.targets)) {
237
+ throw hostError('TWRH-RUNTIME-001', 'TurboWarp runtime targets must be an array.');
238
+ }
239
+ return [...runtime.targets];
240
+ }
241
+
242
+ function resolveMonitorBlocks(runtime: TurboWarpRuntimeLike): TurboWarpMonitorBlocks {
243
+ const monitorBlocks = runtime.monitorBlocks;
244
+ if (
245
+ !isRecord(monitorBlocks) ||
246
+ typeof monitorBlocks['getBlock'] !== 'function' ||
247
+ typeof monitorBlocks['getScripts'] !== 'function' ||
248
+ typeof monitorBlocks['changeBlock'] !== 'function'
249
+ ) {
250
+ throw hostError('TWRH-MONITOR-001', 'TurboWarp monitor blocks are unavailable.');
251
+ }
252
+ return monitorBlocks as unknown as TurboWarpMonitorBlocks;
253
+ }
254
+
255
+ function resolveMonitorState(runtime: TurboWarpRuntimeLike): TurboWarpMonitorState {
256
+ const getMonitorState = runtime.getMonitorState;
257
+ if (typeof getMonitorState !== 'function') {
258
+ throw hostError('TWRH-MONITOR-001', 'TurboWarp runtime must provide getMonitorState.');
259
+ }
260
+ const monitorState = getMonitorState.call(runtime);
261
+ if (
262
+ !isRecord(monitorState) ||
263
+ typeof monitorState['has'] !== 'function' ||
264
+ typeof monitorState['get'] !== 'function' ||
265
+ typeof monitorState['valueSeq'] !== 'function'
266
+ ) {
267
+ throw hostError('TWRH-MONITOR-001', 'TurboWarp monitor state is unavailable.');
268
+ }
269
+ return monitorState as unknown as TurboWarpMonitorState;
270
+ }
271
+
272
+ function validateBroadcastRuntime(runtime: TurboWarpRuntimeLike, prefix: string): TurboWarpRuntimeLike {
273
+ try {
274
+ requireMethod(runtime, 'getTargetForStage');
275
+ requireMethod(runtime, '_stopThread');
276
+ } catch {
277
+ throw hostError(
278
+ prefixedCode(prefix, 'BROADCAST-RUNTIME-001'),
279
+ 'TurboWarp runtime is missing required broadcast methods.'
280
+ );
281
+ }
282
+ if (!Array.isArray(runtime.threads)) {
283
+ throw hostError(prefixedCode(prefix, 'BROADCAST-RUNTIME-001'), 'TurboWarp runtime threads must be an array.');
284
+ }
285
+ if (typeof runtime.off !== 'function' && typeof runtime.removeListener !== 'function') {
286
+ throw hostError(prefixedCode(prefix, 'BROADCAST-RUNTIME-001'), 'TurboWarp runtime must provide off or removeListener.');
287
+ }
288
+ return runtime;
289
+ }
290
+
291
+ function resolveExactBroadcast(runtime: TurboWarpRuntimeLike, message: string, prefix: string): string | null {
292
+ let stage: unknown;
293
+ try {
294
+ stage = requireMethod(runtime, 'getTargetForStage').call(runtime);
295
+ } catch (cause) {
296
+ throw hostError(prefixedCode(prefix, 'BROADCAST-RUNTIME-001'), 'TurboWarp Stage broadcasts could not be read.', cause);
297
+ }
298
+ if (!isRecord(stage) || !isRecord(stage['variables'])) {
299
+ throw hostError(prefixedCode(prefix, 'BROADCAST-RUNTIME-001'), 'TurboWarp Stage broadcasts are unavailable.');
300
+ }
301
+ const declared = Object.values(stage['variables']).find(
302
+ (variable) =>
303
+ isRecord(variable) &&
304
+ variable['type'] === broadcastMessageType &&
305
+ variable['name'] === message
306
+ );
307
+ return declared ? message : null;
308
+ }
309
+
310
+ function validateBroadcastPayload(payload: unknown, prefix: string): string {
311
+ if (
312
+ !isRecord(payload) ||
313
+ Object.keys(payload).length !== 1 ||
314
+ typeof payload['message'] !== 'string' ||
315
+ payload['message'].length === 0
316
+ ) {
317
+ throw hostError(
318
+ prefixedCode(prefix, 'BROADCAST-PAYLOAD-001'),
319
+ 'broadcastMessageAndWait payload must contain one non-empty message string.'
320
+ );
321
+ }
322
+ return payload['message'];
323
+ }
324
+
325
+ export function createTurboWarpBroadcastPort(
326
+ options: TurboWarpBroadcastPortOptions
327
+ ): TurboWarpBroadcastPort {
328
+ if (!isRecord(options)) {
329
+ throw hostError('TWRH-BROADCAST-RUNTIME-001', 'TurboWarp broadcast port options must be an object.');
330
+ }
331
+ const prefix = options.errorCodePrefix ?? 'TWRH';
332
+ let baseRuntime: TurboWarpRuntimeLike;
333
+ try {
334
+ baseRuntime = validateRuntime(options.runtime);
335
+ } catch (cause) {
336
+ throw hostError(
337
+ prefixedCode(prefix, 'BROADCAST-RUNTIME-001'),
338
+ 'TurboWarp broadcast port requires a valid runtime object.',
339
+ cause
340
+ );
341
+ }
342
+ const runtime = validateBroadcastRuntime(baseRuntime, prefix);
343
+ const activeInvocations = new Set<{cancel(message: string): void}>();
344
+ let disposed = false;
345
+
346
+ function threadIsActive(thread: unknown): boolean {
347
+ return runtime.threads?.includes(thread) === true;
348
+ }
349
+
350
+ function stopOwnedThreads(threads: readonly unknown[], reason: string): void {
351
+ const errors: unknown[] = [];
352
+ for (const thread of threads) {
353
+ if (!threadIsActive(thread)) continue;
354
+ try {
355
+ requireMethod(runtime, '_stopThread').call(runtime, thread);
356
+ } catch (error) {
357
+ errors.push(error);
358
+ }
359
+ }
360
+ if (errors.length > 0) {
361
+ throw hostError(
362
+ prefixedCode(prefix, 'BROADCAST-CLEANUP-001'),
363
+ `TurboWarp broadcast receiver cleanup failed: ${reason}`,
364
+ errors.length === 1 ? errors[0] : new AggregateError(errors)
365
+ );
366
+ }
367
+ }
368
+
369
+ return Object.freeze({
370
+ broadcastMessageAndWait(payload: {message: string}, context: BroadcastPortContext) {
371
+ if (disposed) {
372
+ return Promise.reject(
373
+ hostError(prefixedCode(prefix, 'BROADCAST-DISPOSED'), 'TurboWarp broadcast port is disposed.')
374
+ );
375
+ }
376
+ const message = validateBroadcastPayload(payload, prefix);
377
+ const signal = validateAbortSignal(context?.signal, prefix);
378
+ if (signal.aborted) {
379
+ return Promise.reject(cancelledError(prefix, 'broadcastMessageAndWait was cancelled.'));
380
+ }
381
+ const broadcastName = resolveExactBroadcast(runtime, message, prefix);
382
+ if (broadcastName === null) return Promise.resolve();
383
+
384
+ let started: unknown[] | undefined;
385
+ try {
386
+ started = runtime.startHats(broadcastHatOpcode, {BROADCAST_OPTION: broadcastName});
387
+ } catch (cause) {
388
+ throw hostError(
389
+ prefixedCode(prefix, 'BROADCAST-START-001'),
390
+ 'TurboWarp broadcast receiver threads could not be started.',
391
+ cause
392
+ );
393
+ }
394
+ const threads = started ?? [];
395
+ if (!Array.isArray(threads)) {
396
+ throw hostError(prefixedCode(prefix, 'BROADCAST-RUNTIME-001'), 'TurboWarp startHats must return an array or undefined.');
397
+ }
398
+ if (threads.length === 0) return Promise.resolve();
399
+
400
+ return new Promise<void>((resolve, reject) => {
401
+ let settled = false;
402
+ let runtimeListenerAttached = false;
403
+ let abortListenerAttached = false;
404
+ const invocation = {
405
+ cancel(messageText: string) {
406
+ settleCancelled(messageText);
407
+ }
408
+ };
409
+ const cleanup = (reason: string) => {
410
+ const errors: unknown[] = [];
411
+ if (runtimeListenerAttached) {
412
+ runtimeListenerAttached = false;
413
+ try {
414
+ removeRuntimeListener(runtime, afterExecuteEvent, handleAfterExecute);
415
+ } catch (error) {
416
+ errors.push(error);
417
+ }
418
+ }
419
+ if (abortListenerAttached) {
420
+ abortListenerAttached = false;
421
+ signal.removeEventListener('abort', handleAbort);
422
+ }
423
+ activeInvocations.delete(invocation);
424
+ try {
425
+ stopOwnedThreads(threads, reason);
426
+ } catch (error) {
427
+ errors.push(error);
428
+ }
429
+ if (errors.length > 0) {
430
+ throw hostError(
431
+ prefixedCode(prefix, 'BROADCAST-CLEANUP-001'),
432
+ `TurboWarp broadcast cleanup failed: ${reason}`,
433
+ errors.length === 1 ? errors[0] : new AggregateError(errors)
434
+ );
435
+ }
436
+ };
437
+ const settle = (operation: () => void, reason: string) => {
438
+ if (settled) return;
439
+ settled = true;
440
+ try {
441
+ cleanup(reason);
442
+ operation();
443
+ } catch (error) {
444
+ reject(error);
445
+ }
446
+ };
447
+ const settleCancelled = (messageText: string) => {
448
+ settle(() => reject(cancelledError(prefix, messageText)), 'cancelled');
449
+ };
450
+ const handleAbort = () => {
451
+ settleCancelled('broadcastMessageAndWait was cancelled.');
452
+ };
453
+ const handleAfterExecute = () => {
454
+ if (threads.every((thread) => !threadIsActive(thread))) {
455
+ settle(resolve, 'completed');
456
+ }
457
+ };
458
+
459
+ activeInvocations.add(invocation);
460
+ try {
461
+ runtime.on(afterExecuteEvent, handleAfterExecute);
462
+ runtimeListenerAttached = true;
463
+ signal.addEventListener('abort', handleAbort, {once: true});
464
+ abortListenerAttached = true;
465
+ } catch (cause) {
466
+ settled = true;
467
+ const errors: unknown[] = [
468
+ hostError(
469
+ prefixedCode(prefix, 'BROADCAST-RUNTIME-001'),
470
+ 'TurboWarp broadcast receiver completion could not be observed.',
471
+ cause
472
+ )
473
+ ];
474
+ try {
475
+ cleanup('observer setup failure');
476
+ } catch (error) {
477
+ errors.push(error);
478
+ }
479
+ reject(
480
+ errors.length === 1
481
+ ? errors[0]
482
+ : hostError(
483
+ prefixedCode(prefix, 'BROADCAST-CLEANUP-001'),
484
+ 'TurboWarp broadcast observer setup cleanup failed.',
485
+ new AggregateError(errors)
486
+ )
487
+ );
488
+ return;
489
+ }
490
+ if (signal.aborted) handleAbort();
491
+ else handleAfterExecute();
492
+ });
493
+ },
494
+ dispose() {
495
+ if (disposed) return;
496
+ disposed = true;
497
+ for (const invocation of [...activeInvocations]) {
498
+ invocation.cancel('TurboWarp broadcast port was disposed.');
499
+ }
500
+ }
501
+ });
502
+ }
503
+
504
+ /**
505
+ * The `Scratch.BlockType` / `Scratch.ArgumentType` pair an extension's `getInfo()` builds against.
506
+ * Passing them in keeps this package free of any ambient `Scratch` global.
507
+ */
508
+ export interface ScratchBlockVocabulary {
509
+ ArgumentType: Record<string, string>;
510
+ BlockType: Record<string, string>;
511
+ }
512
+
513
+ export interface BlockArgumentRecord {
514
+ type: string;
515
+ defaultValue?: string | number | boolean;
516
+ menu?: string;
517
+ }
518
+
519
+ export interface BlockRecord {
520
+ opcode: string;
521
+ blockType: string;
522
+ text: string;
523
+ arguments?: Readonly<Record<string, BlockArgumentRecord>>;
524
+ hideFromPalette?: boolean;
525
+ disableMonitor?: boolean;
526
+ }
527
+
528
+ export interface BlockMenuRecord {
529
+ acceptReporters: boolean;
530
+ items: readonly string[];
531
+ }
532
+
533
+ export interface BlockSpec {
534
+ opcode: string;
535
+ text: string;
536
+ arguments?: Readonly<Record<string, BlockArgumentRecord>>;
537
+ /** Defaults to the builder's own visibility. */
538
+ visible?: boolean;
539
+ /** Reporters default to `true`; other block types ignore it. */
540
+ monitor?: boolean;
541
+ }
542
+
543
+ export interface BlockSurfaceBuilderOptions {
544
+ /** Default palette visibility for every block this builder makes. Defaults to `true`. */
545
+ visible?: boolean;
546
+ }
547
+
548
+ export interface BlockSurfaceBuilder {
549
+ reporter(spec: BlockSpec): BlockRecord;
550
+ boolean(spec: BlockSpec): BlockRecord;
551
+ command(spec: BlockSpec): BlockRecord;
552
+ hat(spec: BlockSpec): BlockRecord;
553
+ /** Build a run of plain reporters from `[opcode, text]` pairs. */
554
+ reporters(
555
+ entries: readonly (readonly [string, string])[],
556
+ options?: {visible?: boolean; monitor?: boolean}
557
+ ): readonly BlockRecord[];
558
+ stringArgument(defaultValue?: string): BlockArgumentRecord;
559
+ numberArgument(defaultValue?: number): BlockArgumentRecord;
560
+ menuArgument(menu: string): BlockArgumentRecord;
561
+ menu(items: readonly string[], options?: {acceptReporters?: boolean}): BlockMenuRecord;
562
+ surface(
563
+ blocks: readonly BlockRecord[],
564
+ menus?: Readonly<Record<string, BlockMenuRecord>>
565
+ ): Readonly<{blocks: readonly BlockRecord[]; menus: Readonly<Record<string, BlockMenuRecord>>}>;
566
+ }
567
+
568
+ function requireBlockType(vocabulary: ScratchBlockVocabulary, name: string): string {
569
+ const value = vocabulary.BlockType[name];
570
+ if (typeof value !== 'string') {
571
+ throw hostError('TWRH-BLOCK-001', `Scratch.BlockType.${name} is required.`);
572
+ }
573
+ return value;
574
+ }
575
+
576
+ function requireArgumentType(vocabulary: ScratchBlockVocabulary, name: string): string {
577
+ const value = vocabulary.ArgumentType[name];
578
+ if (typeof value !== 'string') {
579
+ throw hostError('TWRH-BLOCK-001', `Scratch.ArgumentType.${name} is required.`);
580
+ }
581
+ return value;
582
+ }
583
+
584
+ function validateBlockSpec(spec: BlockSpec): BlockSpec {
585
+ if (!isRecord(spec) || typeof spec.opcode !== 'string' || spec.opcode.length === 0) {
586
+ throw hostError('TWRH-BLOCK-002', 'A block spec must provide a non-empty opcode.');
587
+ }
588
+ if (typeof spec.text !== 'string' || spec.text.length === 0) {
589
+ throw hostError('TWRH-BLOCK-002', `Block ${spec.opcode} must provide non-empty text.`);
590
+ }
591
+ return spec;
592
+ }
593
+
594
+ /**
595
+ * Build the repetitive parts of an extension's `getInfo()` block list.
596
+ *
597
+ * The builder owns record shape and palette visibility only. Opcodes, block text, menu items, and
598
+ * every other app-specific decision stay with the caller, so nothing about a concrete app leaks
599
+ * into this package.
600
+ */
601
+ export function createBlockSurfaceBuilder(
602
+ vocabulary: ScratchBlockVocabulary,
603
+ options: BlockSurfaceBuilderOptions = {}
604
+ ): BlockSurfaceBuilder {
605
+ if (
606
+ !isRecord(vocabulary) ||
607
+ !isRecord(vocabulary.ArgumentType) ||
608
+ !isRecord(vocabulary.BlockType)
609
+ ) {
610
+ throw hostError(
611
+ 'TWRH-BLOCK-001',
612
+ 'Block surface requires Scratch ArgumentType and BlockType records.'
613
+ );
614
+ }
615
+ if (options.visible !== undefined && typeof options.visible !== 'boolean') {
616
+ throw hostError('TWRH-BLOCK-001', 'Block surface visible must be boolean.');
617
+ }
618
+ const defaultVisible = options.visible ?? true;
619
+
620
+ function build(spec: BlockSpec, blockTypeName: string, monitorable: boolean): BlockRecord {
621
+ validateBlockSpec(spec);
622
+ const visible = spec.visible ?? defaultVisible;
623
+ const record: BlockRecord = {
624
+ opcode: spec.opcode,
625
+ blockType: requireBlockType(vocabulary, blockTypeName),
626
+ text: spec.text,
627
+ hideFromPalette: !visible
628
+ };
629
+ if (spec.arguments !== undefined) record.arguments = Object.freeze({...spec.arguments});
630
+ if (monitorable && (spec.monitor ?? false) === false) record.disableMonitor = true;
631
+ return Object.freeze(record);
632
+ }
633
+
634
+ return Object.freeze({
635
+ reporter(spec: BlockSpec) {
636
+ return build(spec, 'REPORTER', true);
637
+ },
638
+ boolean(spec: BlockSpec) {
639
+ return build(spec, 'BOOLEAN', false);
640
+ },
641
+ command(spec: BlockSpec) {
642
+ return build(spec, 'COMMAND', false);
643
+ },
644
+ hat(spec: BlockSpec) {
645
+ return build(spec, 'HAT', false);
646
+ },
647
+ reporters(
648
+ entries: readonly (readonly [string, string])[],
649
+ reporterOptions: {visible?: boolean; monitor?: boolean} = {}
650
+ ) {
651
+ if (!Array.isArray(entries)) {
652
+ throw hostError('TWRH-BLOCK-002', 'Block reporters must be an array of entries.');
653
+ }
654
+ return Object.freeze(
655
+ entries.map(([opcode, text]) =>
656
+ build(
657
+ {
658
+ opcode,
659
+ text,
660
+ ...(reporterOptions.visible === undefined ? {} : {visible: reporterOptions.visible}),
661
+ ...(reporterOptions.monitor === undefined ? {} : {monitor: reporterOptions.monitor})
662
+ },
663
+ 'REPORTER',
664
+ true
665
+ )
666
+ )
667
+ );
668
+ },
669
+ stringArgument(defaultValue = '') {
670
+ return Object.freeze({type: requireArgumentType(vocabulary, 'STRING'), defaultValue});
671
+ },
672
+ numberArgument(defaultValue = 0) {
673
+ return Object.freeze({type: requireArgumentType(vocabulary, 'NUMBER'), defaultValue});
674
+ },
675
+ menuArgument(menu: string) {
676
+ if (typeof menu !== 'string' || menu.length === 0) {
677
+ throw hostError('TWRH-BLOCK-002', 'A menu argument must name a non-empty menu.');
678
+ }
679
+ return Object.freeze({type: requireArgumentType(vocabulary, 'STRING'), menu});
680
+ },
681
+ menu(items: readonly string[], menuOptions: {acceptReporters?: boolean} = {}) {
682
+ if (!Array.isArray(items) || items.length === 0) {
683
+ throw hostError('TWRH-BLOCK-002', 'A block menu must list at least one item.');
684
+ }
685
+ return Object.freeze({
686
+ acceptReporters: menuOptions.acceptReporters ?? false,
687
+ items: Object.freeze([...items])
688
+ });
689
+ },
690
+ surface(
691
+ blocks: readonly BlockRecord[],
692
+ menus: Readonly<Record<string, BlockMenuRecord>> = {}
693
+ ) {
694
+ if (!Array.isArray(blocks)) {
695
+ throw hostError('TWRH-BLOCK-002', 'A block surface must provide an array of blocks.');
696
+ }
697
+ const opcodes = new Set<string>();
698
+ for (const block of blocks) {
699
+ if (opcodes.has(block.opcode)) {
700
+ throw hostError('TWRH-BLOCK-002', `Duplicate block opcode: ${block.opcode}`);
701
+ }
702
+ opcodes.add(block.opcode);
703
+ }
704
+ return Object.freeze({
705
+ blocks: Object.freeze([...blocks]),
706
+ menus: Object.freeze({...menus})
707
+ });
708
+ }
709
+ });
710
+ }
711
+
712
+ export type ScalarBlockValueType = 'string' | 'number' | 'boolean';
713
+
714
+ export type ScalarBlockValueResult =
715
+ | Readonly<{ok: true; value: string | number | boolean}>
716
+ | Readonly<{ok: false; code: string}>;
717
+
718
+ /**
719
+ * Coerce one Scratch block argument to a scalar of the named type.
720
+ *
721
+ * Scratch hands every argument over as a string, so a writable scalar surface has to decide what
722
+ * counts as a number or a boolean. That decision is the same in every app, while the diagnostic
723
+ * codes are not, so the prefix is injectable the way {@link createTurboWarpBroadcastPort} does it.
724
+ */
725
+ export function coerceScalarBlockValue(
726
+ value: unknown,
727
+ type: string,
728
+ options: {errorCodePrefix?: string} = {}
729
+ ): ScalarBlockValueResult {
730
+ const prefix = options.errorCodePrefix ?? 'TWRH';
731
+ if (type === 'string') return Object.freeze({ok: true as const, value: String(value ?? '')});
732
+ if (type === 'number') {
733
+ const number = typeof value === 'number' ? value : Number(value);
734
+ return Number.isFinite(number)
735
+ ? Object.freeze({ok: true as const, value: number})
736
+ : Object.freeze({ok: false as const, code: prefixedCode(prefix, 'VARIABLE-WRITE-VALUE')});
737
+ }
738
+ if (type === 'boolean') {
739
+ if (typeof value === 'boolean') return Object.freeze({ok: true as const, value});
740
+ if (value === 'true') return Object.freeze({ok: true as const, value: true});
741
+ if (value === 'false') return Object.freeze({ok: true as const, value: false});
742
+ return Object.freeze({ok: false as const, code: prefixedCode(prefix, 'VARIABLE-WRITE-VALUE')});
743
+ }
744
+ return Object.freeze({ok: false as const, code: prefixedCode(prefix, 'VARIABLE-WRITE-TYPE')});
745
+ }