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.
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
- /** Image encode wrapper (positional: instance, format, quality, speed, animation). */
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
- pending = loadHtmlToImageModule({
231
- factory,
232
- moduleArg: precompiledModuleArg(wasm, label),
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
+ }
@@ -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) {