@effindomv2/fui-as 0.1.27 → 0.1.30

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.
@@ -23,14 +23,11 @@ import {
23
23
  type ActiveFileProcessingRecord,
24
24
  type ActiveFileWriterRecord,
25
25
  type ExternalHarnessDropItem,
26
- type FileProcessingWorkerCancelMessage,
27
- type FileProcessingWorkerNextMessage,
28
- type FileProcessingWorkerOutboundMessage,
29
- type FileProcessingWorkerStartMessage,
30
26
  type SavePickerWindow,
31
27
  type StoredFileRecord,
32
28
  type WritableFileStreamLike,
33
29
  } from './managed-harness-file-types';
30
+ import type { WorkerHostServicesBundleConfig } from '../worker-types';
34
31
  import type { HarnessAppSession } from './managed-harness-session';
35
32
 
36
33
  interface ManagedHarnessFileHostDependencies {
@@ -40,10 +37,11 @@ interface ManagedHarnessFileHostDependencies {
40
37
  readAppBytes(ptr: number, len: number): Uint8Array;
41
38
  writeTextCallbackPayload(session: HarnessAppSession, text: string, context: string): number;
42
39
  describeHarnessError(error: unknown): string;
40
+ workerBootstrapUrl: string;
41
+ getCurrentWorkerHostServices(): WorkerHostServicesBundleConfig | undefined;
43
42
  }
44
43
 
45
44
  const encoder = new TextEncoder();
46
- const FILE_PROCESSING_WORKER_URL = new URL('./file-processing-worker.js', import.meta.url).toString();
47
45
 
48
46
  function copyBytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
49
47
  const copied = new Uint8Array(bytes.byteLength);
@@ -303,13 +301,16 @@ export function createManagedHarnessFileHost(dependencies: ManagedHarnessFileHos
303
301
  requestId: number,
304
302
  processedBytes: bigint,
305
303
  outputFileName: string | null,
304
+ workerResult: string | null = null,
306
305
  ): void {
307
306
  if (session === null) {
308
307
  return;
309
308
  }
309
+ // Encode outputFileName\0workerResult so the wasm handler splits them.
310
+ const combined = (outputFileName ?? '') + '\0' + (workerResult ?? '');
310
311
  const payloadLength = dependencies.writeTextCallbackPayload(
311
312
  session,
312
- outputFileName ?? '',
313
+ combined,
313
314
  'File worker process completion',
314
315
  );
315
316
  session.exports.__fui_on_file_worker_process_complete(
@@ -340,107 +341,146 @@ export function createManagedHarnessFileHost(dependencies: ManagedHarnessFileHos
340
341
 
341
342
  function cleanupFileProcessingRequest(requestId: number): void {
342
343
  cancelledFileProcessingRequestIds.delete(requestId);
343
- const record = activeFileProcessingRequests.get(requestId);
344
- if (record === undefined) {
345
- return;
346
- }
347
- record.worker.terminate();
348
344
  activeFileProcessingRequests.delete(requestId);
349
345
  }
350
346
 
351
- async function failFileProcessingRequest(record: ActiveFileProcessingRecord, status: number, message: string): Promise<void> {
347
+ function failFileProcessingRequest(record: ActiveFileProcessingRecord, status: number, message: string): void {
352
348
  cleanupFileProcessingRequest(record.requestId);
353
- if (record.stream !== null) {
354
- try {
355
- await abortWritableStream(record.stream);
356
- } catch {
357
- // Ignore cleanup failures after surfacing the original worker-processing error.
358
- }
359
- }
360
349
  if (dependencies.getCurrentSession() === record.session) {
361
350
  emitFileWorkerProcessError(record.session, record.requestId, status, message);
362
351
  }
363
352
  }
364
353
 
365
- async function handleFileProcessingWorkerMessage(
366
- record: ActiveFileProcessingRecord,
367
- message: FileProcessingWorkerOutboundMessage,
354
+ async function startFileProcessing(
355
+ requestId: number,
356
+ session: HarnessAppSession,
357
+ sourceFile: File,
358
+ suggestedName: string,
359
+ chunkBytes: number,
360
+ saveToPickedFile: boolean,
361
+ workerHostServices: WorkerHostServicesBundleConfig | undefined,
368
362
  ): Promise<void> {
369
- if (!activeFileProcessingRequests.has(record.requestId)) {
370
- return;
371
- }
372
- if (message.type === 'error') {
373
- await failFileProcessingRequest(record, FILE_STATUS_ERROR, message.message);
363
+ const workerId = requestId;
364
+ const manifestUrl = new URL('./worker-manifest.json', dependencies.workerBootstrapUrl).toString();
365
+ const manifestResponse = await fetch(manifestUrl);
366
+ if (!manifestResponse.ok) {
367
+ emitFileWorkerProcessError(session, requestId, FILE_STATUS_ERROR, 'Failed to load worker manifest.');
374
368
  return;
375
369
  }
376
- if (record.cancelled) {
370
+ const manifest = await manifestResponse.json();
371
+ const wasmUrl = manifest.entries?.['fileProcessorWorker'];
372
+ if (typeof wasmUrl !== 'string' || wasmUrl.length === 0) {
373
+ emitFileWorkerProcessError(session, requestId, FILE_STATUS_ERROR, 'Missing "fileProcessorWorker" entry in worker manifest.');
377
374
  return;
378
375
  }
379
- if (record.saveToPickedFile) {
380
- const stream = record.stream;
381
- if (stream === null) {
382
- await failFileProcessingRequest(record, FILE_STATUS_ERROR, 'Worker file processing lost its target stream.');
383
- return;
384
- }
385
- try {
386
- await stream.write(message.bytes);
387
- if (dependencies.getCurrentSession() === record.session) {
388
- emitFileWorkerProcessProgress(
389
- record.session,
390
- record.requestId,
391
- BigInt(message.copiedBytes),
392
- BigInt(message.totalBytes),
393
- record.targetFileName,
394
- );
395
- }
396
- if (message.copiedBytes >= message.totalBytes) {
397
- await stream.close();
376
+ const resolvedWasmUrl = new URL(wasmUrl, manifestUrl).toString();
377
+ const startProcessor = (targetFileName: string | null, stream: WritableFileStreamLike | null): void => {
378
+ const worker = new Worker(dependencies.workerBootstrapUrl);
379
+ const record: ActiveFileProcessingRecord = {
380
+ requestId,
381
+ session,
382
+ sourceFileName: sourceFile.name,
383
+ targetFileName,
384
+ totalBytes: sourceFile.size,
385
+ stream,
386
+ saveToPickedFile,
387
+ cancelled: false,
388
+ worker,
389
+ processedBytes: 0,
390
+ };
391
+ activeFileProcessingRequests.set(requestId, record);
392
+ worker.addEventListener('message', (event: MessageEvent) => {
393
+ const msg = event.data;
394
+ if (msg.type === 'file-process-chunk' && stream !== null) {
395
+ void stream.write(msg.bytes).catch((error: unknown) => {
396
+ failFileProcessingRequest(record, FILE_STATUS_ERROR, dependencies.describeHarnessError(error));
397
+ });
398
+ } else if (msg.type === 'progress') {
399
+ if (dependencies.getCurrentSession() === record.session) {
400
+ const parts = (msg.text as string).split(' ');
401
+ const processed = parseInt(parts[0] ?? '0', 10);
402
+ record.processedBytes = processed;
403
+ emitFileWorkerProcessProgress(
404
+ record.session,
405
+ record.requestId,
406
+ BigInt(processed),
407
+ BigInt(record.totalBytes),
408
+ record.targetFileName,
409
+ );
410
+ }
411
+ } else if (msg.type === 'complete') {
398
412
  if (dependencies.getCurrentSession() === record.session) {
413
+ if (stream !== null) {
414
+ void stream.close().catch(() => {
415
+ // Swallow close errors.
416
+ });
417
+ }
418
+ const hashText = (msg.text as string);
399
419
  emitFileWorkerProcessComplete(
400
420
  record.session,
401
421
  record.requestId,
402
- BigInt(message.totalBytes),
422
+ BigInt(record.processedBytes),
403
423
  record.targetFileName,
424
+ hashText,
404
425
  );
405
426
  }
406
427
  cleanupFileProcessingRequest(record.requestId);
407
- return;
428
+ } else if (msg.type === 'error') {
429
+ failFileProcessingRequest(record, FILE_STATUS_ERROR, msg.text);
408
430
  }
409
- const nextMessage: FileProcessingWorkerNextMessage = { type: 'next' };
410
- record.worker.postMessage(nextMessage);
411
- } catch (error: unknown) {
412
- await failFileProcessingRequest(record, FILE_STATUS_ERROR, dependencies.describeHarnessError(error));
413
- }
431
+ });
432
+ worker.addEventListener('error', () => {
433
+ failFileProcessingRequest(record, FILE_STATUS_ERROR, 'Worker crashed.');
434
+ });
435
+ worker.postMessage({
436
+ type: 'start-file-process',
437
+ workerId,
438
+ file: sourceFile,
439
+ wasmUrl: resolvedWasmUrl,
440
+ entryName: 'fileProcessorWorker',
441
+ chunkSize: Math.max(1, Math.floor(chunkBytes)),
442
+ workerHostServices,
443
+ });
444
+ };
445
+
446
+ if (!saveToPickedFile) {
447
+ startProcessor(null, null);
414
448
  return;
415
449
  }
416
- if (dependencies.getCurrentSession() === record.session) {
417
- emitFileWorkerProcessChunk(
418
- record.session,
419
- record.requestId,
420
- BigInt(message.offsetBytes),
421
- BigInt(message.totalBytes),
422
- new Uint8Array(message.bytes),
423
- );
424
- emitFileWorkerProcessProgress(
425
- record.session,
426
- record.requestId,
427
- BigInt(message.copiedBytes),
428
- BigInt(message.totalBytes),
429
- null,
430
- );
431
- if (message.copiedBytes >= message.totalBytes) {
432
- emitFileWorkerProcessComplete(
433
- record.session,
434
- record.requestId,
435
- BigInt(message.totalBytes),
436
- null,
437
- );
438
- cleanupFileProcessingRequest(record.requestId);
450
+ if (!supportsNativeSavePicker()) {
451
+ emitFileWorkerProcessError(session, requestId, FILE_STATUS_ERROR, 'Worker file processing requires the native save picker.');
452
+ return;
453
+ }
454
+ const savePicker = (window as SavePickerWindow).showSaveFilePicker;
455
+ if (typeof savePicker !== 'function') {
456
+ emitFileWorkerProcessError(session, requestId, FILE_STATUS_ERROR, 'Worker file processing requires the native save picker.');
457
+ return;
458
+ }
459
+ void savePicker({ suggestedName }).then((handle) =>
460
+ handle.createWritable().then((writableStream) => {
461
+ if (dependencies.getCurrentSession() !== session || cancelledFileProcessingRequestIds.has(requestId)) {
462
+ void abortWritableStream(writableStream).catch(() => {
463
+ // Ignore cleanup failures after the session moved on.
464
+ });
465
+ cancelledFileProcessingRequestIds.delete(requestId);
466
+ return;
467
+ }
468
+ startProcessor(handle.name ?? suggestedName, writableStream);
469
+ }),
470
+ ).catch((error: unknown) => {
471
+ cancelledFileProcessingRequestIds.delete(requestId);
472
+ if (dependencies.getCurrentSession() !== session) {
439
473
  return;
440
474
  }
441
- }
442
- const nextMessage: FileProcessingWorkerNextMessage = { type: 'next' };
443
- record.worker.postMessage(nextMessage);
475
+ emitFileWorkerProcessError(
476
+ session,
477
+ requestId,
478
+ error instanceof DOMException && error.name === 'AbortError'
479
+ ? FILE_STATUS_CANCELLED
480
+ : FILE_STATUS_ERROR,
481
+ dependencies.describeHarnessError(error),
482
+ );
483
+ });
444
484
  }
445
485
 
446
486
  function cancelFileProcessingRequest(requestId: number): void {
@@ -450,14 +490,7 @@ export function createManagedHarnessFileHost(dependencies: ManagedHarnessFileHos
450
490
  return;
451
491
  }
452
492
  record.cancelled = true;
453
- const cancelMessage: FileProcessingWorkerCancelMessage = { type: 'cancel' };
454
- record.worker.postMessage(cancelMessage);
455
- cleanupFileProcessingRequest(requestId);
456
- if (record.stream !== null) {
457
- void abortWritableStream(record.stream).catch(() => {
458
- // Ignore cancellation cleanup failures.
459
- });
460
- }
493
+ record.worker.terminate();
461
494
  }
462
495
 
463
496
  function storeBrowserFile(file: File, prefix: string): StoredFileRecord {
@@ -1015,10 +1048,6 @@ export function createManagedHarnessFileHost(dependencies: ManagedHarnessFileHos
1015
1048
  if (session === null) {
1016
1049
  return;
1017
1050
  }
1018
- if (typeof Worker !== 'function') {
1019
- emitFileWorkerProcessError(session, requestId, FILE_STATUS_ERROR, 'Worker file processing requires browser Worker support.');
1020
- return;
1021
- }
1022
1051
  const fileId = dependencies.readAppUtf8(fileIdPtr, fileIdLen);
1023
1052
  const sourceFile = storedBrowserFiles.get(fileId);
1024
1053
  if (sourceFile === undefined) {
@@ -1026,72 +1055,8 @@ export function createManagedHarnessFileHost(dependencies: ManagedHarnessFileHos
1026
1055
  return;
1027
1056
  }
1028
1057
  const suggestedName = resolveSuggestedName(dependencies.readAppUtf8(suggestedNamePtr, suggestedNameLen), '');
1029
- const safeChunkBytes = Math.max(1, Math.floor(chunkBytes));
1030
- const startWorker = (targetFileName: string | null, stream: WritableFileStreamLike | null): void => {
1031
- const worker = new Worker(FILE_PROCESSING_WORKER_URL);
1032
- const record: ActiveFileProcessingRecord = {
1033
- requestId,
1034
- session,
1035
- sourceFileName: sourceFile.name,
1036
- targetFileName,
1037
- totalBytes: sourceFile.size,
1038
- worker,
1039
- stream,
1040
- saveToPickedFile,
1041
- cancelled: false,
1042
- };
1043
- activeFileProcessingRequests.set(requestId, record);
1044
- worker.addEventListener('message', (event: MessageEvent<FileProcessingWorkerOutboundMessage>) => {
1045
- void handleFileProcessingWorkerMessage(record, event.data);
1046
- });
1047
- worker.addEventListener('error', (event: ErrorEvent) => {
1048
- void failFileProcessingRequest(
1049
- record,
1050
- FILE_STATUS_ERROR,
1051
- event.message.length > 0 ? event.message : 'Worker file processing crashed.',
1052
- );
1053
- });
1054
- const startMessage: FileProcessingWorkerStartMessage = {
1055
- type: 'start',
1056
- file: sourceFile,
1057
- chunkSize: safeChunkBytes,
1058
- };
1059
- worker.postMessage(startMessage);
1060
- };
1061
- if (!saveToPickedFile) {
1062
- startWorker(null, null);
1063
- return;
1064
- }
1065
- if (!supportsNativeSavePicker()) {
1066
- emitFileWorkerProcessError(session, requestId, FILE_STATUS_ERROR, 'Worker file processing requires the native save picker.');
1067
- return;
1068
- }
1069
- const savePicker = (window as SavePickerWindow).showSaveFilePicker;
1070
- if (typeof savePicker !== 'function') {
1071
- emitFileWorkerProcessError(session, requestId, FILE_STATUS_ERROR, 'Worker file processing requires the native save picker.');
1072
- return;
1073
- }
1074
- void savePicker({ suggestedName }).then((handle) => handle.createWritable().then((stream) => {
1075
- if (dependencies.getCurrentSession() !== session || cancelledFileProcessingRequestIds.has(requestId)) {
1076
- void abortWritableStream(stream).catch(() => {
1077
- // Ignore cleanup failures after the session moved on.
1078
- });
1079
- cancelledFileProcessingRequestIds.delete(requestId);
1080
- return;
1081
- }
1082
- startWorker(handle.name ?? suggestedName, stream);
1083
- })).catch((error: unknown) => {
1084
- cancelledFileProcessingRequestIds.delete(requestId);
1085
- if (dependencies.getCurrentSession() !== session) {
1086
- return;
1087
- }
1088
- emitFileWorkerProcessError(
1089
- session,
1090
- requestId,
1091
- error instanceof DOMException && error.name === 'AbortError' ? FILE_STATUS_CANCELLED : FILE_STATUS_ERROR,
1092
- dependencies.describeHarnessError(error),
1093
- );
1094
- });
1058
+ const workerHostServices = dependencies.getCurrentWorkerHostServices();
1059
+ void startFileProcessing(requestId, session, sourceFile, suggestedName, chunkBytes, saveToPickedFile, workerHostServices);
1095
1060
  },
1096
1061
  fui_file_process_worker_cancel(requestId: number): void {
1097
1062
  cancelFileProcessingRequest(requestId);
@@ -57,50 +57,15 @@ export interface ActiveFileWriterRecord {
57
57
  writtenBytes: number;
58
58
  }
59
59
 
60
- export interface FileProcessingWorkerStartMessage {
61
- readonly type: 'start';
62
- readonly file: File;
63
- readonly chunkSize: number;
64
- }
65
-
66
- export interface FileProcessingWorkerNextMessage {
67
- readonly type: 'next';
68
- }
69
-
70
- export interface FileProcessingWorkerCancelMessage {
71
- readonly type: 'cancel';
72
- }
73
-
74
- export type FileProcessingWorkerInboundMessage =
75
- | FileProcessingWorkerStartMessage
76
- | FileProcessingWorkerNextMessage
77
- | FileProcessingWorkerCancelMessage;
78
-
79
- export interface FileProcessingWorkerChunkMessage {
80
- readonly type: 'chunk';
81
- readonly offsetBytes: number;
82
- readonly bytes: ArrayBuffer;
83
- readonly copiedBytes: number;
84
- readonly totalBytes: number;
85
- }
86
-
87
- export interface FileProcessingWorkerErrorMessage {
88
- readonly type: 'error';
89
- readonly message: string;
90
- }
91
-
92
- export type FileProcessingWorkerOutboundMessage =
93
- | FileProcessingWorkerChunkMessage
94
- | FileProcessingWorkerErrorMessage;
95
-
96
60
  export interface ActiveFileProcessingRecord {
97
61
  readonly requestId: number;
98
62
  readonly session: HarnessAppSession;
99
63
  readonly sourceFileName: string;
100
64
  readonly targetFileName: string | null;
101
65
  readonly totalBytes: number;
102
- readonly worker: Worker;
103
66
  readonly stream: WritableFileStreamLike | null;
104
67
  readonly saveToPickedFile: boolean;
68
+ readonly worker: Worker;
105
69
  cancelled: boolean;
70
+ processedBytes: number;
106
71
  }
@@ -458,6 +458,8 @@ export function startManagedHarness(options: ManagedHarnessOptions): void {
458
458
  notifyRouteChanged(session, `${window.location.pathname}${window.location.search}${window.location.hash}`);
459
459
  }
460
460
 
461
+ const workerBootstrapUrl = new URL('./worker-bootstrap.js', import.meta.url).toString();
462
+
461
463
  const fileHost = createManagedHarnessFileHost({
462
464
  getCurrentSession: () => currentSession,
463
465
  getRuntime: () => runtime,
@@ -465,6 +467,8 @@ export function startManagedHarness(options: ManagedHarnessOptions): void {
465
467
  readAppBytes,
466
468
  writeTextCallbackPayload,
467
469
  describeHarnessError,
470
+ workerBootstrapUrl,
471
+ getCurrentWorkerHostServices: () => currentSession?.workerHostServices,
468
472
  });
469
473
 
470
474
  const fetchHost = createManagedHarnessFetchHost({