Skip to content

Commit 32bf377

Browse files
committed
fix(scripts): run WASM inline in rive-gen-types, drop spawnSync subprocess
Spawning one bun process per .riv file via spawnSync was flaky under CI load: processes occasionally exited 0 with empty stdout due to resource contention. Now the WASM runtime is initialised once and all files are processed in the same process, eliminating the subprocess entirely.
1 parent ba4798c commit 32bf377

1 file changed

Lines changed: 162 additions & 76 deletions

File tree

scripts/rive-gen-types.ts

Lines changed: 162 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,24 @@
1313
* const file = await RiveFileFactory.fromSource(gameRiv); // TypedRiveFile<GameSchema> — T inferred
1414
*/
1515

16-
import { spawnSync } from 'child_process';
17-
import { writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
16+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
1817
import { dirname, resolve, basename, extname } from 'path';
1918
import { fileURLToPath } from 'url';
19+
import { RuntimeLoader } from '@rive-app/canvas';
2020

2121
const __dirname = dirname(fileURLToPath(import.meta.url));
22-
const extractorPath = resolve(__dirname, 'rive-extract-schema.ts');
22+
23+
// Browser shims required by the @rive-app/canvas WASM runtime.
24+
(globalThis as any).document = {
25+
createElement: () => ({ getContext: () => null }),
26+
};
27+
(globalThis as any).Image = class {};
28+
29+
// Silence WASM warnings (e.g. "No WebGL support") so they don't pollute output.
30+
console.log = (...args: unknown[]) =>
31+
process.stderr.write(args.join(' ') + '\n');
32+
console.warn = (...args: unknown[]) =>
33+
process.stderr.write(args.join(' ') + '\n');
2334

2435
interface Schema {
2536
artboards: string[];
@@ -28,32 +39,100 @@ interface Schema {
2839
viewModels: Record<string, Record<string, string>>;
2940
}
3041

31-
function extractSchema(input: string): Schema {
32-
for (let attempt = 0; attempt < 2; attempt++) {
33-
try {
34-
const result = spawnSync('bun', [extractorPath, input], {
35-
encoding: 'utf8',
36-
timeout: 30_000,
37-
});
38-
if (result.error) throw result.error;
39-
if (result.signal)
40-
throw new Error(
41-
`bun killed by signal ${result.signal}\n${result.stderr}`
42-
);
43-
if (result.status !== 0)
44-
throw new Error(
45-
result.stderr || `bun exited with code ${result.status}`
46-
);
47-
if (!result.stdout.trim())
48-
throw new Error(
49-
`bun exited 0 but produced no output\nstderr: ${result.stderr || '(empty)'}`
50-
);
51-
return JSON.parse(result.stdout) as Schema;
52-
} catch (err) {
53-
if (attempt === 1) throw err;
42+
let runtimeReady: Promise<any> | null = null;
43+
44+
async function getRuntime(): Promise<any> {
45+
if (!runtimeReady) {
46+
runtimeReady = RuntimeLoader.awaitInstance().then((runtime) => {
47+
// On headless Linux (no WebGL) the image-load counter (aa.total/aa.loaded)
48+
// never resolves. Wrap img.decode to fire img.la() via queueMicrotask after
49+
// K() returns so the Promise resolves with the actual file, not null.
50+
const origMRI = (runtime.renderFactory as any).makeRenderImage.bind(
51+
runtime.renderFactory
52+
);
53+
(runtime.renderFactory as any).makeRenderImage = function () {
54+
const img = origMRI();
55+
if (
56+
img &&
57+
typeof (img as any).la === 'function' &&
58+
typeof (img as any).decode === 'function'
59+
) {
60+
const origDecode = (img as any).decode.bind(img);
61+
(img as any).decode = function (imgBytes: Uint8Array) {
62+
origDecode(imgBytes);
63+
queueMicrotask(() => (img as any).la());
64+
};
65+
}
66+
return img;
67+
};
68+
return runtime;
69+
});
70+
}
71+
return runtimeReady;
72+
}
73+
74+
async function extractSchema(input: string): Promise<Schema> {
75+
const bytes = input.startsWith('http://') || input.startsWith('https://')
76+
? new Uint8Array(await (await fetch(input)).arrayBuffer())
77+
: new Uint8Array(readFileSync(input));
78+
79+
const runtime = await getRuntime();
80+
81+
const assetLoader = new (runtime as any).CustomFileAssetLoader({
82+
loadContents: (asset: any, embeddedBytes: Uint8Array) => {
83+
if (embeddedBytes?.length && asset?.decode) {
84+
asset.decode(embeddedBytes);
85+
}
86+
return true;
87+
},
88+
});
89+
90+
const riveFile = await runtime.load(bytes, assetLoader, false);
91+
92+
const artboards: string[] = [];
93+
const stateMachines: Record<string, string[]> = {};
94+
for (let i = 0; i < riveFile.artboardCount(); i++) {
95+
const artboard = riveFile.artboardByIndex(i);
96+
artboards.push(artboard.name);
97+
const sms: string[] = [];
98+
for (let j = 0; j < artboard.stateMachineCount(); j++) {
99+
sms.push(artboard.stateMachineByIndex(j).name);
100+
}
101+
stateMachines[artboard.name] = sms;
102+
}
103+
104+
const viewModels: Record<string, Record<string, string>> = {};
105+
const vmCount = (riveFile as any).viewModelCount() as number;
106+
for (let i = 0; i < vmCount; i++) {
107+
const vm = (riveFile as any).viewModelByIndex(i);
108+
const properties = vm.getProperties() as Array<{ name: string; type: string }>;
109+
const inst = vm.instance?.() as any;
110+
const props: Record<string, string> = {};
111+
for (const p of properties) {
112+
if (p.type === 'viewModel' && inst) {
113+
try {
114+
const nested = inst.viewModel?.(p.name);
115+
const refName = nested?.getViewModelName?.();
116+
props[p.name] = refName ? `viewModel:${refName}` : 'viewModel';
117+
} catch {
118+
props[p.name] = 'viewModel';
119+
}
120+
} else if (p.type === 'enumType' && inst) {
121+
try {
122+
const ep = inst.enum?.(p.name);
123+
const values: string[] = ep?.values ?? [];
124+
props[p.name] = values.length > 0 ? `enum:${values.join('|')}` : 'enum';
125+
} catch {
126+
props[p.name] = 'enum';
127+
}
128+
} else {
129+
props[p.name] = p.type;
130+
}
54131
}
132+
viewModels[vm.name] = props;
55133
}
56-
throw new Error('unreachable');
134+
135+
return { artboards, defaultArtboard: artboards[0] ?? '', stateMachines, viewModels };
57136
}
58137

59138
// With prettier quoteProps:"consistent", if any key in an object needs quotes, all get quotes.
@@ -133,15 +212,15 @@ ${schemaBody(schema)}
133212
`;
134213
}
135214

136-
function generate(
215+
async function generate(
137216
input: string,
138217
outPath: string,
139218
mode: 'dts' | 'standalone',
140219
typeName?: string
141220
) {
142221
let schema: Schema;
143222
try {
144-
schema = extractSchema(input);
223+
schema = await extractSchema(input);
145224
} catch (err) {
146225
process.stderr.write(
147226
`Failed to extract schema from ${input}: ${err instanceof Error ? err.message : String(err)}\n`
@@ -179,58 +258,65 @@ function findRivFiles(dir: string): string[] {
179258

180259
// --- CLI ---
181260

182-
// noUncheckedIndexedAccess: slice gives string[], index access gives string | undefined
183-
const args: string[] = process.argv.slice(2);
261+
async function main() {
262+
// noUncheckedIndexedAccess: slice gives string[], index access gives string | undefined
263+
const args: string[] = process.argv.slice(2);
184264

185-
if (args[0] === '--all') {
186-
const dir: string | undefined = args[1];
187-
if (!dir) {
188-
process.stderr.write('Usage: rive-gen-types --all <directory>\n');
189-
process.exit(1);
190-
}
191-
const files = findRivFiles(resolve(process.cwd(), dir));
192-
if (!files.length) {
193-
process.stderr.write(`No .riv files found in ${dir}\n`);
194-
process.exit(1);
195-
}
196-
for (const file of files) {
197-
generate(file, `${file}.d.ts`, 'dts');
265+
if (args[0] === '--all') {
266+
const dir: string | undefined = args[1];
267+
if (!dir) {
268+
process.stderr.write('Usage: rive-gen-types --all <directory>\n');
269+
process.exit(1);
270+
}
271+
const files = findRivFiles(resolve(process.cwd(), dir));
272+
if (!files.length) {
273+
process.stderr.write(`No .riv files found in ${dir}\n`);
274+
process.exit(1);
275+
}
276+
for (const file of files) {
277+
await generate(file, `${file}.d.ts`, 'dts');
278+
}
279+
return;
198280
}
199-
process.exit(0);
200-
}
201-
202-
if (!args.length || args[0]!.startsWith('--')) {
203-
process.stderr.write(
204-
'Usage:\n' +
205-
' rive-gen-types <path-or-url> # writes <file>.riv.d.ts\n' +
206-
' rive-gen-types <path> --out <out.ts> # standalone schema .ts\n' +
207-
' rive-gen-types --all <directory> # all .riv files in dir\n'
208-
);
209-
process.exit(1);
210-
}
211281

212-
const input = args[0]!;
213-
const outIdx = args.indexOf('--out');
214-
215-
if (outIdx !== -1) {
216-
// Standalone mode: generate a named schema type, not a .d.ts
217-
const outPath = resolve(process.cwd(), args[outIdx + 1]!);
218-
const baseName = basename(input, '.riv').replace(/[^a-zA-Z0-9]/g, '_');
219-
const nameIdx = args.indexOf('--name');
220-
const typeName =
221-
nameIdx !== -1
222-
? args[nameIdx + 1]!
223-
: baseName.charAt(0).toUpperCase() + baseName.slice(1) + 'Schema';
224-
generate(input, outPath, 'standalone', typeName);
225-
} else {
226-
if (input.startsWith('http://') || input.startsWith('https://')) {
282+
if (!args.length || args[0]!.startsWith('--')) {
227283
process.stderr.write(
228-
`Error: URL inputs require --out to specify the output path.\n` +
229-
` Example: rive-gen-types ${input} --out ./assets/file.riv.d.ts\n`
284+
'Usage:\n' +
285+
' rive-gen-types <path-or-url> # writes <file>.riv.d.ts\n' +
286+
' rive-gen-types <path> --out <out.ts> # standalone schema .ts\n' +
287+
' rive-gen-types --all <directory> # all .riv files in dir\n'
230288
);
231289
process.exit(1);
232290
}
233-
// Default: write <file>.riv.d.ts next to the source file
234-
const absInput = resolve(process.cwd(), input);
235-
generate(input, `${absInput}.d.ts`, 'dts');
291+
292+
const input = args[0]!;
293+
const outIdx = args.indexOf('--out');
294+
295+
if (outIdx !== -1) {
296+
// Standalone mode: generate a named schema type, not a .d.ts
297+
const outPath = resolve(process.cwd(), args[outIdx + 1]!);
298+
const baseName = basename(input, '.riv').replace(/[^a-zA-Z0-9]/g, '_');
299+
const nameIdx = args.indexOf('--name');
300+
const typeName =
301+
nameIdx !== -1
302+
? args[nameIdx + 1]!
303+
: baseName.charAt(0).toUpperCase() + baseName.slice(1) + 'Schema';
304+
await generate(input, outPath, 'standalone', typeName);
305+
} else {
306+
if (input.startsWith('http://') || input.startsWith('https://')) {
307+
process.stderr.write(
308+
`Error: URL inputs require --out to specify the output path.\n` +
309+
` Example: rive-gen-types ${input} --out ./assets/file.riv.d.ts\n`
310+
);
311+
process.exit(1);
312+
}
313+
// Default: write <file>.riv.d.ts next to the source file
314+
const absInput = resolve(process.cwd(), input);
315+
await generate(input, `${absInput}.d.ts`, 'dts');
316+
}
236317
}
318+
319+
main().catch((err: Error) => {
320+
process.stderr.write(err.message + '\n');
321+
process.exit(1);
322+
});

0 commit comments

Comments
 (0)