@volter/blender-engine 0.1.111 → 0.1.113

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.
@@ -31,6 +31,7 @@ import { checkpointStream } from './pull-job.mts';
31
31
  import { writeStreamedFile, type StreamFileSystem } from './stream-file.mts';
32
32
  import {
33
33
  artifactUrl,
34
+ startupOperation,
34
35
  type BlenderArtifactStatus,
35
36
  type BlenderEngine,
36
37
  type BlenderEngineOptions,
@@ -83,19 +84,19 @@ type BlenderModuleFactory = (options: Record<string, unknown>) => Promise<Blende
83
84
  /** The glue, evaluated in this worker. It is a UMD bundle, so it is given the
84
85
  * `module`/`exports` pair it looks for and hands back the factory. */
85
86
  async function loadFactory(glueUrl: string): Promise<BlenderModuleFactory> {
86
- const source = await fetch(glueUrl);
87
+ const source = await startupOperation(glueUrl, 'fetch glue', () => fetch(glueUrl));
87
88
  if (!source.ok) throw new Error(`${glueUrl}: HTTP ${source.status}`);
88
- const text = await source.text();
89
+ const text = await startupOperation(glueUrl, 'read glue body', () => source.text());
89
90
  const container = { exports: {} as { default?: BlenderModuleFactory } };
90
- const evaluate = new Function(
91
+ const evaluate = await startupOperation(glueUrl, 'compile glue', () => new Function(
91
92
  'module',
92
93
  'exports',
93
94
  'define',
94
95
  `${text}\nreturn typeof createBlenderModule === 'function' ? createBlenderModule : module.exports;`,
95
- ) as (module: unknown, exports: unknown, define: unknown) => BlenderModuleFactory;
96
- const factory = evaluate(container, container.exports, undefined);
96
+ ) as (module: unknown, exports: unknown, define: unknown) => BlenderModuleFactory);
97
+ const factory = await startupOperation(glueUrl, 'evaluate glue', () => evaluate(container, container.exports, undefined));
97
98
  if (typeof factory !== 'function')
98
- throw new Error('blender_browser.js did not produce a module factory');
99
+ throw new Error(`${glueUrl}: blender_browser.js did not produce a module factory`);
99
100
  return factory;
100
101
  }
101
102
 
@@ -155,17 +156,21 @@ async function cachedArtifact(file: string, digest: string | undefined, checkpoi
155
156
  ? new Response(checkpointStream(response.body, checkpoint), { status: response.status, headers: response.headers })
156
157
  : response;
157
158
  const url = artifactUrl(file);
158
- if (!digest || typeof caches === 'undefined') return bounded(await fetch(url));
159
+ const fetchArtifact = () => startupOperation(url, 'fetch artifact', () => fetch(url));
160
+ if (!digest || typeof caches === 'undefined') return bounded(await fetchArtifact());
159
161
  const key = `${url}?sha256=${digest}`;
160
- const cache = await caches.open(ARTIFACT_CACHE);
161
- const hit = await cache.match(key);
162
+ const cache = await startupOperation(url, 'open artifact cache', () => caches.open(ARTIFACT_CACHE));
163
+ const hit = await startupOperation(url, 'read artifact cache', () => cache.match(key));
162
164
  if (hit) return bounded(hit);
163
- const response = bounded(await fetch(url));
165
+ const response = bounded(await fetchArtifact());
164
166
  if (!response.ok) return response;
165
167
  // One build's bytes per file: an older build's are dropped as this one lands.
166
- for (const old of await cache.keys()) if (old.url.startsWith(`${url}?sha256=`) && old.url !== key) await cache.delete(old);
167
- const kept = new Response(await response.blob(), { headers: { 'content-type': response.headers.get('content-type') ?? 'application/octet-stream' } });
168
- await cache.put(key, kept.clone());
168
+ await startupOperation(url, 'prune previous artifact cache entries', async () => {
169
+ for (const old of await cache.keys()) if (old.url.startsWith(`${url}?sha256=`) && old.url !== key) await cache.delete(old);
170
+ });
171
+ const body = await startupOperation(url, 'read artifact body for cache', () => response.blob());
172
+ const kept = new Response(body, { headers: { 'content-type': response.headers.get('content-type') ?? 'application/octet-stream' } });
173
+ await startupOperation(url, 'write artifact cache', () => cache.put(key, kept.clone()));
169
174
  return kept;
170
175
  }
171
176
 
@@ -188,8 +193,9 @@ export async function startEmscriptenBlenderEngine(
188
193
  const [factory, preloaded] = await Promise.all([
189
194
  loadFactory(glueUrl),
190
195
  artifact('blender_browser.data').then(async (response) => {
191
- if (!response.ok) throw new Error(`blender_browser.data: HTTP ${response.status}`);
192
- return response.arrayBuffer();
196
+ const url = artifactUrl('blender_browser.data');
197
+ if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
198
+ return startupOperation(url, 'read data package body', () => response.arrayBuffer());
193
199
  }),
194
200
  ]);
195
201
  await checkpoint('runtime-package-ready');
@@ -222,15 +228,16 @@ export async function startEmscriptenBlenderEngine(
222
228
  return made;
223
229
  } as unknown as typeof WebAssembly.Memory;
224
230
  const heap = (): Uint8Array => (memory ? new Uint8Array(memory.buffer) : module.HEAPU8);
225
- const module = await factory({
231
+ const module = await startupOperation(glueUrl, 'initialize glue runtime', () => factory({
226
232
  arguments: ['--background', '--factory-startup', '--python', SESSION_SCRIPT],
227
233
  locateFile: (file: string) => artifactUrl(file),
228
234
  getPreloadedPackage: () => preloaded,
229
235
  instantiateWasm: (imports: WebAssembly.Imports, receive: (instance: WebAssembly.Instance, module: WebAssembly.Module) => void) => {
230
236
  void (async () => {
231
237
  const response = await artifact('blender_browser.wasm');
232
- if (!response.ok) throw new Error(`blender_browser.wasm: HTTP ${response.status}`);
233
- const { instance, module: compiled } = await WebAssembly.instantiateStreaming(response, imports);
238
+ const url = artifactUrl('blender_browser.wasm');
239
+ if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
240
+ const { instance, module: compiled } = await startupOperation(url, 'read and instantiate Wasm stream', () => WebAssembly.instantiateStreaming(response, imports));
234
241
  await checkpoint('wasm-instantiated');
235
242
  receive(instance, compiled);
236
243
  })().catch((error: unknown) => { bootError = error; });
@@ -280,7 +287,7 @@ export async function startEmscriptenBlenderEngine(
280
287
  ],
281
288
  }).finally(() => {
282
289
  (WebAssembly as { Memory: unknown }).Memory = Memory;
283
- });
290
+ }));
284
291
  await checkpoint('runtime-initialized');
285
292
  // `main()` runs on a pthread (`-sPROXY_TO_PTHREAD`), so the factory resolves
286
293
  // long before the session exists. The ready line is what says it does.
@@ -375,16 +382,18 @@ export async function startEmscriptenBlenderEngine(
375
382
  * does not relink the 86 MB Wasm binary. Both payload and per-file bounds are
376
383
  * checked before anything enters Blender's filesystem. */
377
384
  async function mountEssentials(files: BlenderFiles, digest: string | undefined, checkpoint: (phase: string) => Promise<void>): Promise<void> {
385
+ const indexUrl = artifactUrl('essentials.json');
386
+ const payloadUrl = artifactUrl('essentials.bin');
378
387
  const [indexResponse, payloadResponse] = await Promise.all([
379
- fetch(artifactUrl('essentials.json')), cachedArtifact('essentials.bin', digest, () => checkpoint('artifact/essentials.bin')),
388
+ startupOperation(indexUrl, 'fetch Essentials index', () => fetch(indexUrl)), cachedArtifact('essentials.bin', digest, () => checkpoint('artifact/essentials.bin')),
380
389
  ]);
381
390
  if (!indexResponse.ok || !payloadResponse.ok)
382
- throw new Error(`Blender Essentials assets are missing (${indexResponse.status}/${payloadResponse.status})`);
383
- const index = await indexResponse.json() as {
391
+ throw new Error(`Blender Essentials assets are missing (${indexUrl}: HTTP ${indexResponse.status}; ${payloadUrl}: HTTP ${payloadResponse.status})`);
392
+ const index = await startupOperation(indexUrl, 'decode Essentials index JSON', () => indexResponse.json()) as {
384
393
  bytes: number; sha256: string;
385
394
  files: { path: string; offset: number; bytes: number; sha256: string }[];
386
395
  };
387
- const payload = await payloadResponse.arrayBuffer();
396
+ const payload = await startupOperation(payloadUrl, 'read Essentials payload body', () => payloadResponse.arrayBuffer());
388
397
  const hash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', payload)),
389
398
  (byte) => byte.toString(16).padStart(2, '0')).join('');
390
399
  if (payload.byteLength !== index.bytes || hash !== index.sha256)
@@ -43,6 +43,8 @@
43
43
  * implementation over {@link BlenderFiles}.
44
44
  */
45
45
 
46
+ import { describeThrown } from './describe-thrown.mts';
47
+
46
48
  export const SESSION_ROOT = '/work/.volter-session';
47
49
  export const SESSION_SCRIPT = `${SESSION_ROOT}/session.py`;
48
50
 
@@ -210,6 +212,19 @@ export function artifactUrl(file: string): string {
210
212
  return new URL(`${ARTIFACT_BASE}/${file}`, import.meta.url).href;
211
213
  }
212
214
 
215
+ /** Attribute a startup failure without repeating the operation. The worker's
216
+ * document may never publish, so this message must survive as the diagnosis.
217
+ * For an import, url is the importing module; the browser's resolved failing
218
+ * module URL, when supplied, stays in the original cause. */
219
+ export async function startupOperation<T>(url: string, operation: string, run: () => T | Promise<T>): Promise<T> {
220
+ try {
221
+ return await run();
222
+ } catch (cause) {
223
+ const detail = describeThrown(cause, false);
224
+ throw new Error(`Blender startup ${operation} (${url}): ${detail}`, { cause });
225
+ }
226
+ }
227
+
213
228
  /** What the editor's artifact door answers. `blender-wasm-artifact.ts` owns it. */
214
229
  export interface BlenderArtifactStatus {
215
230
  available: boolean;
@@ -231,7 +246,8 @@ export interface BlenderArtifactStatus {
231
246
  }
232
247
 
233
248
  export async function artifactStatus(): Promise<BlenderArtifactStatus> {
234
- const answer = await fetch(artifactUrl('status'));
249
+ const url = artifactUrl('status');
250
+ const answer = await startupOperation(url, 'fetch status', () => fetch(url));
235
251
  if (!answer.ok)
236
252
  return {
237
253
  available: false,
@@ -241,7 +257,7 @@ export async function artifactStatus(): Promise<BlenderArtifactStatus> {
241
257
  encoded: {},
242
258
  missing: [`${ARTIFACT_BASE}/status answered ${answer.status}`],
243
259
  };
244
- return (await answer.json()) as BlenderArtifactStatus;
260
+ return startupOperation(url, 'decode status JSON', () => answer.json()) as Promise<BlenderArtifactStatus>;
245
261
  }
246
262
 
247
263
  /**
@@ -264,10 +280,10 @@ export async function startBlenderEngine(options: BlenderEngineOptions): Promise
264
280
  `The headless Blender WebAssembly build is not served by this editor: ${status.missing.join('; ')}`,
265
281
  );
266
282
  if (status.skew === 'wali') {
267
- const { startWaliBlenderEngine } = await import('./blender-wali-engine.mts');
283
+ const { startWaliBlenderEngine } = await startupOperation(import.meta.url, 'import WALI engine module from', () => import('./blender-wali-engine.mts'));
268
284
  return startWaliBlenderEngine(options, status);
269
285
  }
270
- const { startEmscriptenBlenderEngine } = await import('./blender-emscripten-engine.mts');
286
+ const { startEmscriptenBlenderEngine } = await startupOperation(import.meta.url, 'import Emscripten engine module from', () => import('./blender-emscripten-engine.mts'));
271
287
  return startEmscriptenBlenderEngine(options, status);
272
288
  }
273
289
 
@@ -0,0 +1,22 @@
1
+ /** Anything thrown, rendered so the message SURVIVES the worker boundary.
2
+ *
3
+ * String(object) erased a prior real diagnostic as [object Object]. Preserve
4
+ * named messages or JSON fields, with a safe fallback for cyclic values.
5
+ * Startup wrappers omit Error stacks from their user-visible message; worker
6
+ * replies keep them as before. */
7
+ export function describeThrown(error: unknown, includeStack = true): string {
8
+ if (error instanceof Error)
9
+ return (includeStack ? error.stack : undefined) ?? `${error.name}: ${error.message}`;
10
+ if (typeof error === 'object' && error !== null) {
11
+ const named = error as { name?: unknown; message?: unknown };
12
+ if (typeof named.message === 'string') {
13
+ return typeof named.name === 'string' ? `${named.name}: ${named.message}` : named.message;
14
+ }
15
+ try {
16
+ return JSON.stringify(error) ?? Object.prototype.toString.call(error);
17
+ } catch {
18
+ return Object.prototype.toString.call(error);
19
+ }
20
+ }
21
+ return String(error);
22
+ }
package/browser/worker.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { PullJob, checkpointStream } from './pull-job.mts';
2
2
  import { sendFrameValue } from './frame-stream.mts';
3
+ import { describeThrown } from './describe-thrown.mts';
4
+ export { describeThrown } from './describe-thrown.mts';
3
5
  /**
4
6
  * THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
5
7
  * 2026-09-17): the editor's modeling engine is Blender 5.2 LTS compiled to
@@ -39,7 +41,7 @@ import { sendFrameValue } from './frame-stream.mts';
39
41
  */
40
42
  /// <reference types="vite/client" />
41
43
 
42
- import { type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
44
+ import { startupOperation, type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
43
45
  import type { CaptureRequest, FileEntry, WorkerReply, WorkerRequest } from './protocol';
44
46
  import { documentChunks } from './document-chunks.mts';
45
47
  import { columnsToTypedArrays, describeFrame, isColumnDescriptor } from './session-frame.mts';
@@ -395,10 +397,11 @@ async function startBlender(project: string, document?: string): Promise<unknown
395
397
  }
396
398
 
397
399
  async function blenderIsServed(): Promise<{ available: boolean; missing: string[] }> {
400
+ const url = '/__editor/blender-wasm/status';
398
401
  try {
399
- const answer = await fetch('/__editor/blender-wasm/status');
400
- if (!answer.ok) return { available: false, missing: [`status answered ${answer.status}`] };
401
- return (await answer.json()) as { available: boolean; missing: string[] };
402
+ const answer = await startupOperation(url, 'fetch initial status', () => fetch(url));
403
+ if (!answer.ok) return { available: false, missing: [`${url}: HTTP ${answer.status}`] };
404
+ return await startupOperation(url, 'decode initial status JSON', () => answer.json()) as { available: boolean; missing: string[] };
402
405
  } catch (error) {
403
406
  return { available: false, missing: [String(error)] };
404
407
  }
@@ -737,30 +740,6 @@ async function handle(request: WorkerRequest): Promise<unknown> {
737
740
  throw new Error(`Unknown Blender worker request ${(request as { op: string }).op}`);
738
741
  }
739
742
 
740
- /** Anything thrown, rendered so the message SURVIVES the boundary.
741
- *
742
- * `error instanceof Error ? ... : String(error)` renders a thrown plain object
743
- * as `[object Object]`, and that is the whole error a script sees: the worker
744
- * answered `17-workshop-interior` seq 7 with exactly that, which named neither
745
- * the operation nor the cause and left the next step with nothing to go on.
746
- * A DOMException carries its name, and a plain object carries its own fields,
747
- * so both are spelled out rather than coerced. */
748
- export function describeThrown(error: unknown): string {
749
- if (error instanceof Error) return error.stack ?? `${error.name}: ${error.message}`;
750
- if (typeof error === 'object' && error !== null) {
751
- const named = error as { name?: unknown; message?: unknown };
752
- if (typeof named.message === 'string') {
753
- return typeof named.name === 'string' ? `${named.name}: ${named.message}` : named.message;
754
- }
755
- try {
756
- return JSON.stringify(error) ?? Object.prototype.toString.call(error);
757
- } catch {
758
- return Object.prototype.toString.call(error);
759
- }
760
- }
761
- return String(error);
762
- }
763
-
764
743
  /**
765
744
  * Tell the tab how big the engine's memory is now.
766
745
  *
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/blender-engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "GPL-3.0-or-later",
5
- "version": "0.1.111",
5
+ "version": "0.1.113",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },