wasm-html-to-image 1.0.2 → 1.0.3
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 +6 -0
- package/dist/browser-workers.js +20 -7
- package/dist/cli-options.d.ts +32 -0
- package/dist/cli-options.js +35 -0
- package/dist/cli.js +6 -0
- package/dist/core-orchestrate.d.ts +15 -0
- package/dist/core-orchestrate.js +36 -8
- package/dist/core.d.ts +25 -2
- package/dist/html-to-image-wasm.js +3 -1
- package/dist/html-to-image.wasm +0 -0
- package/dist/loader.d.ts +30 -2
- package/dist/loader.js +172 -11
- package/dist/wasm-binary.d.ts +34 -0
- package/dist/wasm-binary.js +210 -0
- package/dist/wasm-payload.js +8 -0
- package/dist/web-workers.js +337 -22
- package/llms.txt +3 -3
- package/package.json +1 -1
package/dist/html-to-image.wasm
CHANGED
|
Binary file
|
package/dist/loader.d.ts
CHANGED
|
@@ -85,7 +85,19 @@ export interface HtmlToImageModule {
|
|
|
85
85
|
* Returns false on failure. Probed at runtime; PNG path is the fallback.
|
|
86
86
|
*/
|
|
87
87
|
satoru_render_to_converter?: (...args: unknown[]) => boolean;
|
|
88
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Animated (opacity / transform / color) HTML render into a converter's
|
|
90
|
+
* frames, additive: `(satoruInst, converterInst, htmls, width, height,
|
|
91
|
+
* options, durationMs, fps)`. Frames come from `@keyframes` + `animation` in
|
|
92
|
+
* the document (loop infinite); returns the number of frames appended
|
|
93
|
+
* (0 on failure).
|
|
94
|
+
*/
|
|
95
|
+
satoru_render_animation_to_converter?: (...args: unknown[]) => number;
|
|
96
|
+
/**
|
|
97
|
+
* Image encode wrapper (positional: instance, format, quality, speed,
|
|
98
|
+
* animation, animationLoop). `animationLoop` is the animated WebP loop
|
|
99
|
+
* count (0 = infinite, the default).
|
|
100
|
+
*/
|
|
89
101
|
converter_encode?: (...args: unknown[]) => unknown;
|
|
90
102
|
/**
|
|
91
103
|
* Unified Bitmap-direct call (`render_bitmap_to_encoded` exposed to JS,
|
|
@@ -115,6 +127,13 @@ export interface LoadOptions {
|
|
|
115
127
|
/** Bypass the process-wide cache (tests). */
|
|
116
128
|
noCache?: boolean;
|
|
117
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* A failure that is likely to disappear on a second attempt (the `.wasm`
|
|
132
|
+
* was read while it was still being written).
|
|
133
|
+
*/
|
|
134
|
+
export declare class TransientWasmError extends Error {
|
|
135
|
+
constructor(message: string);
|
|
136
|
+
}
|
|
118
137
|
/** Default glue URL: `./html-to-image.js` next to the compiled loader. */
|
|
119
138
|
export declare function defaultGlueUrl(): URL;
|
|
120
139
|
/**
|
|
@@ -134,6 +153,10 @@ export declare function importNode<T = unknown>(name: string): Promise<T>;
|
|
|
134
153
|
* Load and instantiate the unified module (cached per process, keyed by
|
|
135
154
|
* factory then moduleArg — see `moduleCache`).
|
|
136
155
|
* Works in Node (file URL import) and browsers (served URL import).
|
|
156
|
+
*
|
|
157
|
+
* Parallel calls with the same key share one instantiation (see
|
|
158
|
+
* {@link pendingLoads}); a failed load is not cached, so a later call
|
|
159
|
+
* retries.
|
|
137
160
|
*/
|
|
138
161
|
export declare function loadHtmlToImageModule(options?: LoadOptions): Promise<HtmlToImageModule>;
|
|
139
162
|
/** Drop all cached instances (tests / HMR). */
|
|
@@ -143,8 +166,13 @@ export declare function dropCachedModule(): void;
|
|
|
143
166
|
* Shared adapter for workerd / edge-light entry points (behavior unchanged:
|
|
144
167
|
* `WebAssembly.instantiate` + success-callback override, empty object return
|
|
145
168
|
* for emscripten). The `label` only selects the console-error prefix.
|
|
169
|
+
*
|
|
170
|
+
* `onFailure` (optional) is notified when the instantiation fails. The
|
|
171
|
+
* emscripten glue only offers a success callback, so without it a failed
|
|
172
|
+
* instantiation leaves the module promise pending forever; the caller uses
|
|
173
|
+
* this hook to turn the failure into a rejected promise.
|
|
146
174
|
*/
|
|
147
|
-
export declare function precompiledModuleArg(wasm: WebAssembly.Module, label: string): EmscriptenModuleArg;
|
|
175
|
+
export declare function precompiledModuleArg(wasm: WebAssembly.Module, label: string, onFailure?: (e: unknown) => void): EmscriptenModuleArg;
|
|
148
176
|
/**
|
|
149
177
|
* Load (and reuse) the unified module for a pre-compiled `WebAssembly.Module`.
|
|
150
178
|
* Per-`wasm` cache with failure eviction (a second call with a different
|
package/dist/loader.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* file URL, then invoking the factory. No `node:` builtins are used so the
|
|
20
20
|
* same file works in both runtimes.
|
|
21
21
|
*/
|
|
22
|
+
import { checkWasmBinary } from "./wasm-binary.js";
|
|
22
23
|
/**
|
|
23
24
|
* Process-wide module cache, keyed by factory then moduleArg: different
|
|
24
25
|
* factories, or different WASM payloads (e.g. workerd/edge-light calls
|
|
@@ -27,6 +28,37 @@
|
|
|
27
28
|
* single.ts default path, `*-workers` lazy vars) are unaffected.
|
|
28
29
|
*/
|
|
29
30
|
const moduleCache = new Map();
|
|
31
|
+
/**
|
|
32
|
+
* In-flight loads, keyed like {@link moduleCache} (factory then moduleArg).
|
|
33
|
+
*
|
|
34
|
+
* The resolved-module cache only helps *after* the first load finished, so
|
|
35
|
+
* N parallel calls (a worker pool starting up, a burst of requests) each
|
|
36
|
+
* instantiated their own copy of the module — a 12 MB binary plus its
|
|
37
|
+
* linear memory, N times over. Sharing the pending promise makes parallel
|
|
38
|
+
* initialization instantiate exactly once.
|
|
39
|
+
*/
|
|
40
|
+
const pendingLoads = new Map();
|
|
41
|
+
/**
|
|
42
|
+
* How many times a load is attempted before the error is reported.
|
|
43
|
+
*
|
|
44
|
+
* A truncated `.wasm` (read while a concurrent build was still writing it)
|
|
45
|
+
* is transient: re-reading the file a moment later usually yields the
|
|
46
|
+
* complete binary. Two attempts are enough to cover that window without
|
|
47
|
+
* hiding a real failure.
|
|
48
|
+
*/
|
|
49
|
+
const LOAD_ATTEMPTS = 2;
|
|
50
|
+
/** Delay before the retry of a transient (truncated binary) failure. */
|
|
51
|
+
const LOAD_RETRY_DELAY_MS = 150;
|
|
52
|
+
/**
|
|
53
|
+
* A failure that is likely to disappear on a second attempt (the `.wasm`
|
|
54
|
+
* was read while it was still being written).
|
|
55
|
+
*/
|
|
56
|
+
export class TransientWasmError extends Error {
|
|
57
|
+
constructor(message) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = "TransientWasmError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
30
62
|
/** Default glue URL: `./html-to-image.js` next to the compiled loader. */
|
|
31
63
|
export function defaultGlueUrl() {
|
|
32
64
|
return new URL("./html-to-image.js", import.meta.url);
|
|
@@ -129,6 +161,10 @@ async function readWasmBinaryNode(glueDir) {
|
|
|
129
161
|
* Load and instantiate the unified module (cached per process, keyed by
|
|
130
162
|
* factory then moduleArg — see `moduleCache`).
|
|
131
163
|
* Works in Node (file URL import) and browsers (served URL import).
|
|
164
|
+
*
|
|
165
|
+
* Parallel calls with the same key share one instantiation (see
|
|
166
|
+
* {@link pendingLoads}); a failed load is not cached, so a later call
|
|
167
|
+
* retries.
|
|
132
168
|
*/
|
|
133
169
|
export async function loadHtmlToImageModule(options = {}) {
|
|
134
170
|
const cacheKeyFactory = options.factory ?? undefined;
|
|
@@ -137,7 +173,80 @@ export async function loadHtmlToImageModule(options = {}) {
|
|
|
137
173
|
const hit = moduleCache.get(cacheKeyFactory)?.get(cacheKeyModuleArg);
|
|
138
174
|
if (hit)
|
|
139
175
|
return hit;
|
|
176
|
+
// A load is already running for this key: share it instead of
|
|
177
|
+
// instantiating a second copy of the module.
|
|
178
|
+
const pending = pendingLoads.get(cacheKeyFactory)?.get(cacheKeyModuleArg);
|
|
179
|
+
if (pending)
|
|
180
|
+
return pending;
|
|
140
181
|
}
|
|
182
|
+
const promise = loadHtmlToImageModuleOnce(options, cacheKeyFactory, cacheKeyModuleArg);
|
|
183
|
+
if (!options.noCache) {
|
|
184
|
+
let inner = pendingLoads.get(cacheKeyFactory);
|
|
185
|
+
if (!inner) {
|
|
186
|
+
inner = new Map();
|
|
187
|
+
pendingLoads.set(cacheKeyFactory, inner);
|
|
188
|
+
}
|
|
189
|
+
inner.set(cacheKeyModuleArg, promise);
|
|
190
|
+
// A rejected load must not poison the cache: drop it so a later call
|
|
191
|
+
// retries (a truncated `.wasm` may be complete by then).
|
|
192
|
+
promise.catch(() => {
|
|
193
|
+
if (inner?.get(cacheKeyModuleArg) === promise) {
|
|
194
|
+
inner.delete(cacheKeyModuleArg);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return promise;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Load the module, retrying once when the failure is transient (the
|
|
202
|
+
* `.wasm` was read while it was still being written).
|
|
203
|
+
*/
|
|
204
|
+
async function loadHtmlToImageModuleOnce(options, cacheKeyFactory, cacheKeyModuleArg) {
|
|
205
|
+
for (let attempt = 1;; attempt++) {
|
|
206
|
+
try {
|
|
207
|
+
return await loadHtmlToImageModuleUncached(options, cacheKeyFactory, cacheKeyModuleArg);
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
if (attempt >= LOAD_ATTEMPTS || !isTransientWasmFailure(e)) {
|
|
211
|
+
throw e;
|
|
212
|
+
}
|
|
213
|
+
await new Promise((r) => setTimeout(r, LOAD_RETRY_DELAY_MS));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* True when the failure looks like a corrupt/truncated binary (retryable).
|
|
219
|
+
*
|
|
220
|
+
* The engine reports a truncated module as `CompileError`; the emscripten
|
|
221
|
+
* glue wraps it into `RuntimeError("Aborted(CompileError: ...)")`, so the
|
|
222
|
+
* message is inspected as well.
|
|
223
|
+
*/
|
|
224
|
+
function isTransientWasmFailure(e) {
|
|
225
|
+
if (e instanceof TransientWasmError)
|
|
226
|
+
return true;
|
|
227
|
+
if (typeof WebAssembly !== "undefined" && e instanceof WebAssembly.CompileError)
|
|
228
|
+
return true;
|
|
229
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
230
|
+
return /CompileError/.test(message);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Explain a load failure in terms of the artifact that is actually broken:
|
|
234
|
+
* a truncated `.wasm` (read while a build was writing it) or a
|
|
235
|
+
* glue/binary mismatch (the two come from different builds).
|
|
236
|
+
*/
|
|
237
|
+
function explainWasmLoadFailure(e, glueLabel) {
|
|
238
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
239
|
+
if (/Import #\d+/.test(message)) {
|
|
240
|
+
return new Error(`wasm-html-to-image: the WebAssembly binary and the JavaScript ` +
|
|
241
|
+
`glue do not match (${message}). The artifacts in ${glueLabel} ` +
|
|
242
|
+
`are probably from different builds - rebuild the glue and the ` +
|
|
243
|
+
`.wasm together.`, { cause: e });
|
|
244
|
+
}
|
|
245
|
+
if (e instanceof Error)
|
|
246
|
+
return e;
|
|
247
|
+
return new Error(message);
|
|
248
|
+
}
|
|
249
|
+
async function loadHtmlToImageModuleUncached(options, cacheKeyFactory, cacheKeyModuleArg) {
|
|
141
250
|
let factory = options.factory;
|
|
142
251
|
let glueLabel = "(pre-bundled factory)";
|
|
143
252
|
let glueDir = "";
|
|
@@ -173,16 +282,51 @@ export async function loadHtmlToImageModule(options = {}) {
|
|
|
173
282
|
// workerd with `instantiateWasm`, is left untouched).
|
|
174
283
|
let moduleArg = options.moduleArg;
|
|
175
284
|
if (!options.factory && glueDir && moduleArg?.wasmBinary === undefined) {
|
|
285
|
+
const wasmPath = glueDirToFsPath(glueDir) + "html-to-image.wasm";
|
|
176
286
|
const wasmBinary = await readWasmBinaryNode(glueDir);
|
|
177
|
-
if (wasmBinary)
|
|
287
|
+
if (wasmBinary) {
|
|
288
|
+
// A file that is still being written yields a truncated binary and
|
|
289
|
+
// a cryptic `section Data extends past end` deep inside the engine.
|
|
290
|
+
const check = checkWasmBinary(wasmBinary);
|
|
291
|
+
if (!check.ok) {
|
|
292
|
+
throw new TransientWasmError(`wasm-html-to-image: invalid WebAssembly binary at ${wasmPath}: ` +
|
|
293
|
+
`${check.reason} - the file was probably read while it was ` +
|
|
294
|
+
`still being written (concurrent build); retry once the ` +
|
|
295
|
+
`build finished.`);
|
|
296
|
+
}
|
|
178
297
|
moduleArg = { ...moduleArg, wasmBinary };
|
|
298
|
+
}
|
|
299
|
+
else if (isNodeRuntime()) {
|
|
300
|
+
throw new TransientWasmError(`wasm-html-to-image: cannot read the WebAssembly binary at ` +
|
|
301
|
+
`${wasmPath}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const provided = moduleArg?.wasmBinary;
|
|
305
|
+
const providedBytes = provided instanceof Uint8Array
|
|
306
|
+
? provided
|
|
307
|
+
: provided instanceof ArrayBuffer
|
|
308
|
+
? new Uint8Array(provided)
|
|
309
|
+
: undefined;
|
|
310
|
+
if (providedBytes) {
|
|
311
|
+
// Caller-supplied bytes (browser worker payload, edge runtimes) get
|
|
312
|
+
// the same structural check.
|
|
313
|
+
const check = checkWasmBinary(providedBytes);
|
|
314
|
+
if (!check.ok) {
|
|
315
|
+
throw new TransientWasmError(`wasm-html-to-image: invalid WebAssembly binary: ${check.reason}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
let module;
|
|
319
|
+
try {
|
|
320
|
+
module = await factory({
|
|
321
|
+
...moduleArg,
|
|
322
|
+
locateFile: options.locateFile ??
|
|
323
|
+
moduleArg?.locateFile ??
|
|
324
|
+
((path) => glueDir + path),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
catch (e) {
|
|
328
|
+
throw explainWasmLoadFailure(e, glueLabel);
|
|
179
329
|
}
|
|
180
|
-
const module = await factory({
|
|
181
|
-
...moduleArg,
|
|
182
|
-
locateFile: options.locateFile ??
|
|
183
|
-
moduleArg?.locateFile ??
|
|
184
|
-
((path) => glueDir + path),
|
|
185
|
-
});
|
|
186
330
|
if (!options.noCache) {
|
|
187
331
|
let inner = moduleCache.get(cacheKeyFactory);
|
|
188
332
|
if (!inner) {
|
|
@@ -196,14 +340,20 @@ export async function loadHtmlToImageModule(options = {}) {
|
|
|
196
340
|
/** Drop all cached instances (tests / HMR). */
|
|
197
341
|
export function dropCachedModule() {
|
|
198
342
|
moduleCache.clear();
|
|
343
|
+
pendingLoads.clear();
|
|
199
344
|
}
|
|
200
345
|
/**
|
|
201
346
|
* Build Emscripten module args for a pre-compiled `WebAssembly.Module`.
|
|
202
347
|
* Shared adapter for workerd / edge-light entry points (behavior unchanged:
|
|
203
348
|
* `WebAssembly.instantiate` + success-callback override, empty object return
|
|
204
349
|
* for emscripten). The `label` only selects the console-error prefix.
|
|
350
|
+
*
|
|
351
|
+
* `onFailure` (optional) is notified when the instantiation fails. The
|
|
352
|
+
* emscripten glue only offers a success callback, so without it a failed
|
|
353
|
+
* instantiation leaves the module promise pending forever; the caller uses
|
|
354
|
+
* this hook to turn the failure into a rejected promise.
|
|
205
355
|
*/
|
|
206
|
-
export function precompiledModuleArg(wasm, label) {
|
|
356
|
+
export function precompiledModuleArg(wasm, label, onFailure) {
|
|
207
357
|
return {
|
|
208
358
|
instantiateWasm: (imports, successCallback) => {
|
|
209
359
|
WebAssembly.instantiate(wasm, imports)
|
|
@@ -212,6 +362,7 @@ export function precompiledModuleArg(wasm, label) {
|
|
|
212
362
|
})
|
|
213
363
|
.catch((e) => {
|
|
214
364
|
console.error(`wasm-html-to-image [${label}]: Wasm instantiation failed:`, e);
|
|
365
|
+
onFailure?.(e);
|
|
215
366
|
});
|
|
216
367
|
return {}; // Return empty object as emscripten expects
|
|
217
368
|
},
|
|
@@ -227,10 +378,20 @@ export function precompiledModuleArg(wasm, label) {
|
|
|
227
378
|
export async function loadPrecompiledModule(factory, wasm, cache, label) {
|
|
228
379
|
let pending = cache.get(wasm);
|
|
229
380
|
if (!pending) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
381
|
+
// The glue only reports instantiation failures through the console,
|
|
382
|
+
// so the failure is mirrored into a rejected promise: otherwise the
|
|
383
|
+
// module promise never settles and the caller waits forever.
|
|
384
|
+
let reportFailure = () => { };
|
|
385
|
+
const failure = new Promise((_resolve, reject) => {
|
|
386
|
+
reportFailure = reject;
|
|
233
387
|
});
|
|
388
|
+
pending = Promise.race([
|
|
389
|
+
loadHtmlToImageModule({
|
|
390
|
+
factory,
|
|
391
|
+
moduleArg: precompiledModuleArg(wasm, label, reportFailure),
|
|
392
|
+
}),
|
|
393
|
+
failure,
|
|
394
|
+
]);
|
|
234
395
|
cache.set(wasm, pending);
|
|
235
396
|
pending.catch(() => {
|
|
236
397
|
if (cache.get(wasm) === pending)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal WASM binary sanity check.
|
|
3
|
+
*
|
|
4
|
+
* The loader hands the `.wasm` bytes straight to the engine, so a file
|
|
5
|
+
* that is still being written (concurrent build) or that belongs to a
|
|
6
|
+
* different build than the JavaScript glue fails deep inside V8 with
|
|
7
|
+
* messages that do not point at the real cause:
|
|
8
|
+
*
|
|
9
|
+
* - `CompileError: section Data extends past end` — the file was read
|
|
10
|
+
* while it was still being written (truncated binary).
|
|
11
|
+
* - `Import #0 "env" is not an object` — the binary and the glue come
|
|
12
|
+
* from different builds (the import namespace does not match).
|
|
13
|
+
*
|
|
14
|
+
* {@link checkWasmBinary} detects the truncated case before the engine
|
|
15
|
+
* sees it and reports the import namespaces, so the loader can explain
|
|
16
|
+
* a mismatch instead of surfacing a cryptic engine error.
|
|
17
|
+
*/
|
|
18
|
+
/** Result of {@link checkWasmBinary}. */
|
|
19
|
+
export interface WasmBinaryCheck {
|
|
20
|
+
/** True when the bytes are a structurally complete WASM binary. */
|
|
21
|
+
ok: boolean;
|
|
22
|
+
/** Why the check failed (only set when `ok` is false). */
|
|
23
|
+
reason?: string;
|
|
24
|
+
/** Import namespaces (module names) the binary requires. */
|
|
25
|
+
namespaces: string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Check that `bytes` is a structurally complete WASM binary.
|
|
29
|
+
*
|
|
30
|
+
* Walks the section table: a truncated file (the classic symptom of
|
|
31
|
+
* reading a file that is still being written) shows up as a section
|
|
32
|
+
* whose declared length runs past the end of the buffer.
|
|
33
|
+
*/
|
|
34
|
+
export declare function checkWasmBinary(bytes: Uint8Array): WasmBinaryCheck;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal WASM binary sanity check.
|
|
3
|
+
*
|
|
4
|
+
* The loader hands the `.wasm` bytes straight to the engine, so a file
|
|
5
|
+
* that is still being written (concurrent build) or that belongs to a
|
|
6
|
+
* different build than the JavaScript glue fails deep inside V8 with
|
|
7
|
+
* messages that do not point at the real cause:
|
|
8
|
+
*
|
|
9
|
+
* - `CompileError: section Data extends past end` — the file was read
|
|
10
|
+
* while it was still being written (truncated binary).
|
|
11
|
+
* - `Import #0 "env" is not an object` — the binary and the glue come
|
|
12
|
+
* from different builds (the import namespace does not match).
|
|
13
|
+
*
|
|
14
|
+
* {@link checkWasmBinary} detects the truncated case before the engine
|
|
15
|
+
* sees it and reports the import namespaces, so the loader can explain
|
|
16
|
+
* a mismatch instead of surfacing a cryptic engine error.
|
|
17
|
+
*/
|
|
18
|
+
/** WASM magic header (`\0asm`). */
|
|
19
|
+
const WASM_MAGIC = [0x00, 0x61, 0x73, 0x6d];
|
|
20
|
+
/** Section id of the import section. */
|
|
21
|
+
const SECTION_IMPORT = 2;
|
|
22
|
+
/** Human-readable section names (for the failure reason). */
|
|
23
|
+
const SECTION_NAMES = {
|
|
24
|
+
0: "Custom",
|
|
25
|
+
1: "Type",
|
|
26
|
+
2: "Import",
|
|
27
|
+
3: "Function",
|
|
28
|
+
4: "Table",
|
|
29
|
+
5: "Memory",
|
|
30
|
+
6: "Global",
|
|
31
|
+
7: "Export",
|
|
32
|
+
8: "Start",
|
|
33
|
+
9: "Element",
|
|
34
|
+
10: "Code",
|
|
35
|
+
11: "Data",
|
|
36
|
+
12: "DataCount",
|
|
37
|
+
13: "Tag",
|
|
38
|
+
};
|
|
39
|
+
/** Read an unsigned LEB128 value; returns `null` when malformed. */
|
|
40
|
+
function readU32Leb(bytes, offset) {
|
|
41
|
+
let result = 0;
|
|
42
|
+
let shift = 0;
|
|
43
|
+
let pos = offset;
|
|
44
|
+
for (;;) {
|
|
45
|
+
if (pos >= bytes.length)
|
|
46
|
+
return null;
|
|
47
|
+
const byte = bytes[pos++];
|
|
48
|
+
result += (byte & 0x7f) * 2 ** shift;
|
|
49
|
+
if ((byte & 0x80) === 0)
|
|
50
|
+
break;
|
|
51
|
+
shift += 7;
|
|
52
|
+
if (shift > 35)
|
|
53
|
+
return null; // malformed / truncated
|
|
54
|
+
}
|
|
55
|
+
return { value: result, next: pos };
|
|
56
|
+
}
|
|
57
|
+
/** Read a length-prefixed name; returns `null` when malformed. */
|
|
58
|
+
function readName(bytes, offset) {
|
|
59
|
+
const len = readU32Leb(bytes, offset);
|
|
60
|
+
if (!len)
|
|
61
|
+
return null;
|
|
62
|
+
const start = len.next;
|
|
63
|
+
const end = start + len.value;
|
|
64
|
+
if (end > bytes.length)
|
|
65
|
+
return null;
|
|
66
|
+
let name = "";
|
|
67
|
+
for (let i = start; i < end; i++)
|
|
68
|
+
name += String.fromCharCode(bytes[i]);
|
|
69
|
+
return { value: name, next: end };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Collect the import namespaces (module names) of an import section.
|
|
73
|
+
* Malformed input simply yields no namespaces (the caller reports the
|
|
74
|
+
* structural problem separately).
|
|
75
|
+
*/
|
|
76
|
+
function collectImportNamespaces(section, namespaces) {
|
|
77
|
+
const count = readU32Leb(section, 0);
|
|
78
|
+
if (!count)
|
|
79
|
+
return;
|
|
80
|
+
let off = count.next;
|
|
81
|
+
for (let i = 0; i < count.value; i++) {
|
|
82
|
+
const mod = readName(section, off);
|
|
83
|
+
if (!mod)
|
|
84
|
+
return;
|
|
85
|
+
const field = readName(section, mod.next);
|
|
86
|
+
if (!field)
|
|
87
|
+
return;
|
|
88
|
+
off = field.next;
|
|
89
|
+
const kind = section[off++];
|
|
90
|
+
if (kind === undefined)
|
|
91
|
+
return;
|
|
92
|
+
// Skip the kind-specific descriptor.
|
|
93
|
+
if (kind === 0) {
|
|
94
|
+
const typeIdx = readU32Leb(section, off);
|
|
95
|
+
if (!typeIdx)
|
|
96
|
+
return;
|
|
97
|
+
off = typeIdx.next;
|
|
98
|
+
}
|
|
99
|
+
else if (kind === 1) {
|
|
100
|
+
off += 1; // element type
|
|
101
|
+
const flags = section[off++];
|
|
102
|
+
if (flags === undefined)
|
|
103
|
+
return;
|
|
104
|
+
const min = readU32Leb(section, off);
|
|
105
|
+
if (!min)
|
|
106
|
+
return;
|
|
107
|
+
off = min.next;
|
|
108
|
+
if (flags & 0x01) {
|
|
109
|
+
const max = readU32Leb(section, off);
|
|
110
|
+
if (!max)
|
|
111
|
+
return;
|
|
112
|
+
off = max.next;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else if (kind === 2) {
|
|
116
|
+
const flags = section[off++];
|
|
117
|
+
if (flags === undefined)
|
|
118
|
+
return;
|
|
119
|
+
const min = readU32Leb(section, off);
|
|
120
|
+
if (!min)
|
|
121
|
+
return;
|
|
122
|
+
off = min.next;
|
|
123
|
+
if (flags & 0x01) {
|
|
124
|
+
const max = readU32Leb(section, off);
|
|
125
|
+
if (!max)
|
|
126
|
+
return;
|
|
127
|
+
off = max.next;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else if (kind === 3) {
|
|
131
|
+
off += 2; // value type + mutability
|
|
132
|
+
}
|
|
133
|
+
else if (kind === 4) {
|
|
134
|
+
const attr = section[off++];
|
|
135
|
+
if (attr === undefined)
|
|
136
|
+
return;
|
|
137
|
+
const typeIdx = readU32Leb(section, off);
|
|
138
|
+
if (!typeIdx)
|
|
139
|
+
return;
|
|
140
|
+
off = typeIdx.next;
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
return; // unknown kind: stop parsing
|
|
144
|
+
}
|
|
145
|
+
if (!namespaces.includes(mod.value))
|
|
146
|
+
namespaces.push(mod.value);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Check that `bytes` is a structurally complete WASM binary.
|
|
151
|
+
*
|
|
152
|
+
* Walks the section table: a truncated file (the classic symptom of
|
|
153
|
+
* reading a file that is still being written) shows up as a section
|
|
154
|
+
* whose declared length runs past the end of the buffer.
|
|
155
|
+
*/
|
|
156
|
+
export function checkWasmBinary(bytes) {
|
|
157
|
+
const namespaces = [];
|
|
158
|
+
if (bytes.length < 8) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
reason: `too short to be a WebAssembly binary (${bytes.length} bytes)`,
|
|
162
|
+
namespaces,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
for (let i = 0; i < WASM_MAGIC.length; i++) {
|
|
166
|
+
if (bytes[i] !== WASM_MAGIC[i]) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
reason: "not a WebAssembly binary (bad magic header)",
|
|
170
|
+
namespaces,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const version = bytes[4] | (bytes[5] << 8) | (bytes[6] << 16) | (bytes[7] << 24);
|
|
175
|
+
if (version !== 1) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
reason: `unsupported WebAssembly version ${version >>> 0}`,
|
|
179
|
+
namespaces,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
let off = 8;
|
|
183
|
+
while (off < bytes.length) {
|
|
184
|
+
const id = bytes[off++];
|
|
185
|
+
const size = readU32Leb(bytes, off);
|
|
186
|
+
if (!size) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
reason: "malformed section header (truncated binary)",
|
|
190
|
+
namespaces,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
off = size.next;
|
|
194
|
+
const end = off + size.value;
|
|
195
|
+
if (end > bytes.length) {
|
|
196
|
+
const name = SECTION_NAMES[id] ?? `#${id}`;
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
reason: `section ${name} extends past end of the module ` +
|
|
200
|
+
`(length ${size.value}, remaining bytes ${bytes.length - off})`,
|
|
201
|
+
namespaces,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if (id === SECTION_IMPORT) {
|
|
205
|
+
collectImportNamespaces(bytes.subarray(off, end), namespaces);
|
|
206
|
+
}
|
|
207
|
+
off = end;
|
|
208
|
+
}
|
|
209
|
+
return { ok: true, namespaces };
|
|
210
|
+
}
|
package/dist/wasm-payload.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
// @ts-expect-error — dist/html-to-image-wasm.js is generated at build time
|
|
11
11
|
import { wasmBase64, wasmEncoding } from "../dist/html-to-image-wasm.js";
|
|
12
|
+
import { checkWasmBinary } from "./wasm-binary.js";
|
|
12
13
|
/** Decompress the embedded WASM payload and return the raw bytes. */
|
|
13
14
|
export async function loadCompressedWasmBinary() {
|
|
14
15
|
const encoding = wasmEncoding;
|
|
@@ -29,6 +30,13 @@ export async function loadCompressedWasmBinary() {
|
|
|
29
30
|
if (bytes.length === 0) {
|
|
30
31
|
throw new Error("decompressed to 0 bytes");
|
|
31
32
|
}
|
|
33
|
+
// A payload captured from a half-written `.wasm` decompresses fine but
|
|
34
|
+
// is not a complete module: reject it here instead of letting the
|
|
35
|
+
// engine fail with `section Data extends past end`.
|
|
36
|
+
const check = checkWasmBinary(bytes);
|
|
37
|
+
if (!check.ok) {
|
|
38
|
+
throw new Error(`restored payload is not a complete module: ${check.reason}`);
|
|
39
|
+
}
|
|
32
40
|
return bytes;
|
|
33
41
|
}
|
|
34
42
|
catch (e) {
|