feat: open multi-part packages in browser

This commit is contained in:
2026-07-22 19:46:17 +02:00
parent 2c998952e5
commit 3b1777a5b3
11 changed files with 428 additions and 64 deletions

View File

@@ -52,10 +52,14 @@ class SuccessfulParserWorker implements WorkerPort {
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
terminated = false;
lastMessage: WorkerRequest | undefined;
lastTransfer: Transferable[] = [];
constructor(private readonly packageResult?: OneNotePackageDto) {}
postMessage(message: WorkerRequest): void {
postMessage(message: WorkerRequest, transfer: Transferable[] = []): void {
this.lastMessage = message;
this.lastTransfer = transfer;
queueMicrotask(() => {
if (message.type === 'parse-one') {
this.respond({
@@ -158,7 +162,7 @@ describe('OneNoteApplication', () => {
);
await user.upload(
screen.getByLabelText('Choose a OneNote file'),
screen.getByLabelText('Choose OneNote files'),
new File([new Uint8Array([1, 2, 3])], 'Section.one')
);
@@ -189,23 +193,74 @@ describe('OneNoteApplication', () => {
tree: { interpreted: false, nodes: [] },
diagnostics: [],
};
const worker = new SuccessfulParserWorker(notebook);
render(
<OneNoteApplication
createWorkerClient={() =>
new OneNoteWorkerClient(() => new SuccessfulParserWorker(notebook))
}
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
/>
);
const part3 = new File([new Uint8Array([3])], 'Notebook-3.cab');
const primary = new File([new Uint8Array([1])], 'Notebook.onepkg');
const part2 = new File([new Uint8Array([2])], 'Notebook-2.cab');
fireEvent.drop(screen.getByTestId('file-drop-zone'), {
dataTransfer: {
files: [new File([new Uint8Array([1])], 'Notebook.onepkg')],
files: [part3, primary, part2],
},
});
expect(
await screen.findByRole('heading', { name: 'Package summary' })
).toBeInTheDocument();
expect(worker.lastMessage).toEqual(
expect.objectContaining({
type: 'parse-onepkg',
fileName: 'Notebook.onepkg',
cabinetParts: [
expect.objectContaining({ fileName: 'Notebook-3.cab' }),
expect.objectContaining({ fileName: 'Notebook-2.cab' }),
],
})
);
expect(worker.lastTransfer).toHaveLength(3);
});
it('hands a multi-file picker selection to the package worker', async () => {
const notebook: OneNotePackageDto = {
format: 'onepkg',
sourceName: 'Notebook.onepkg',
sourceSize: 2,
entries: [],
sections: [],
tree: { interpreted: false, nodes: [] },
diagnostics: [],
};
const worker = new SuccessfulParserWorker(notebook);
const user = userEvent.setup();
render(
<OneNoteApplication
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
/>
);
const input = screen.getByLabelText('Choose OneNote files');
expect(input).toHaveAttribute('multiple');
expect(input).toHaveAttribute('accept', '.one,.onepkg,.cab');
await user.upload(input, [
new File([new Uint8Array([2])], 'Notebook-2.cab'),
new File([new Uint8Array([1])], 'Notebook.onepkg'),
]);
expect(
await screen.findByRole('heading', { name: 'Package summary' })
).toBeInTheDocument();
expect(worker.lastMessage).toEqual(
expect.objectContaining({
type: 'parse-onepkg',
fileName: 'Notebook.onepkg',
cabinetParts: [expect.objectContaining({ fileName: 'Notebook-2.cab' })],
})
);
});
it('keeps a failed packaged section visible beside a readable one', async () => {
@@ -299,7 +354,7 @@ describe('OneNoteApplication', () => {
);
await user.upload(
screen.getByLabelText('Choose a OneNote file'),
screen.getByLabelText('Choose OneNote files'),
new File([new Uint8Array([1])], 'Notebook.onepkg')
);
@@ -336,7 +391,7 @@ describe('OneNoteApplication', () => {
);
await user.upload(
screen.getByLabelText('Choose a OneNote file'),
screen.getByLabelText('Choose OneNote files'),
new File(['not OneNote'], 'Broken.one')
);
@@ -353,12 +408,12 @@ describe('OneNoteApplication', () => {
render(<OneNoteApplication />);
await user.upload(
screen.getByLabelText('Choose a OneNote file'),
screen.getByLabelText('Choose OneNote files'),
new File(['not a notebook'], 'notes.zip')
);
expect(await screen.findByRole('alert')).toHaveTextContent(
'Choose a file whose name ends in .one or .onepkg.'
'Choose one .one file, or one .onepkg file together with its .cab parts.'
);
});
@@ -371,7 +426,7 @@ describe('OneNoteApplication', () => {
value: MAX_SOURCE_FILE_BYTES + 1,
});
await user.upload(screen.getByLabelText('Choose a OneNote file'), file);
await user.upload(screen.getByLabelText('Choose OneNote files'), file);
expect(await screen.findByRole('alert')).toHaveTextContent('512 MiB');
});

View File

@@ -20,10 +20,7 @@ import type {
OneNoteSectionDto,
} from './onenote/model/dto.js';
import type { ParserDiagnostic } from './onenote/model/diagnostics.js';
import {
MAX_SOURCE_FILE_BYTES,
sourceKindFromFileName,
} from './source-file.js';
import { selectSourceFiles } from './source-file.js';
import { toolboxApp } from './toolbox/manifest.js';
import {
OneNoteWorkerClient,
@@ -174,21 +171,13 @@ export function OneNoteApplication({
}
};
const openFile = async (file: File) => {
const sourceKind = sourceKindFromFileName(file.name);
if (!sourceKind) {
const openFiles = async (files: File[]) => {
const selection = selectSourceFiles(files);
if (!selection.ok) {
setState({
phase: 'error',
title: 'Unsupported file name',
message: 'Choose a file whose name ends in .one or .onepkg.',
});
return;
}
if (file.size > MAX_SOURCE_FILE_BYTES) {
setState({
phase: 'error',
title: 'File is too large',
message: 'The selected file exceeds the current 512 MiB safety limit.',
title: selection.title,
message: selection.message,
});
return;
}
@@ -201,12 +190,24 @@ export function OneNoteApplication({
activeClient.current = client;
setSelectedSectionId(undefined);
setPageState({ phase: 'empty' });
setState({ phase: 'reading', fileName: file.name });
setState({ phase: 'reading', fileName: selection.source.name });
try {
const bytes = await file.arrayBuffer();
const buffers = await Promise.all(
[selection.source, ...selection.cabinetParts].map((file) =>
file.arrayBuffer()
)
);
if (generation.current !== requestGeneration) return;
const response = await client.parseSource(sourceKind, file.name, bytes);
const response = await client.parseSource(
selection.kind,
selection.source.name,
buffers[0]!,
selection.cabinetParts.map((part, index) => ({
fileName: part.name,
bytes: buffers[index + 1]!,
}))
);
if (generation.current !== requestGeneration) return;
if (response.type === 'failure') {
@@ -226,7 +227,7 @@ export function OneNoteApplication({
if (response.type === 'section') {
setState({
phase: 'loaded',
fileName: file.name,
fileName: selection.source.name,
source: {
kind: 'one',
sessionId: response.sessionId,
@@ -253,7 +254,7 @@ export function OneNoteApplication({
setSelectedSectionId(preferredSection?.id);
setState({
phase: 'loaded',
fileName: file.name,
fileName: selection.source.name,
source: {
kind: 'onepkg',
sessionId: response.sessionId,
@@ -373,7 +374,7 @@ export function OneNoteApplication({
return (
<main className="application">
{state.phase === 'idle' ? (
<FileDropZone onFile={(file) => void openFile(file)} />
<FileDropZone onFiles={(files) => void openFiles(files)} />
) : null}
{state.phase === 'reading' ? (

View File

@@ -2,25 +2,25 @@ import { useRef, useState, type ChangeEvent, type DragEvent } from 'react';
interface FileDropZoneProps {
disabled?: boolean;
onFile(file: File): void;
onFiles(files: File[]): void;
}
export function FileDropZone({ disabled = false, onFile }: FileDropZoneProps) {
export function FileDropZone({ disabled = false, onFiles }: FileDropZoneProps) {
const input = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const selectFile = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
const selectFiles = (event: ChangeEvent<HTMLInputElement>) => {
const files = [...(event.target.files ?? [])];
event.target.value = '';
if (file) onFile(file);
if (files.length > 0) onFiles(files);
};
const dropFile = (event: DragEvent<HTMLDivElement>) => {
const dropFiles = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setDragging(false);
if (disabled) return;
const file = event.dataTransfer.files[0];
if (file) onFile(file);
const files = [...event.dataTransfer.files];
if (files.length > 0) onFiles(files);
};
return (
@@ -36,23 +36,24 @@ export function FileDropZone({ disabled = false, onFile }: FileDropZoneProps) {
setDragging(false);
}
}}
onDrop={dropFile}
onDrop={dropFiles}
data-testid="file-drop-zone"
>
<p className="drop-zone__eyebrow">Private, local-first reader</p>
<h2>Open a OneNote file</h2>
<p>
Inspect a <code>.one</code> section or <code>.onepkg</code> notebook
package without sending it to a server.
Inspect a <code>.one</code> section, or a <code>.onepkg</code> notebook
with any linked <code>.cab</code> parts, without sending it to a server.
</p>
<input
ref={input}
className="visually-hidden"
type="file"
accept=".one,.onepkg"
accept=".one,.onepkg,.cab"
multiple
disabled={disabled}
onChange={selectFile}
aria-label="Choose a OneNote file"
onChange={selectFiles}
aria-label="Choose OneNote files"
/>
<button
type="button"
@@ -60,10 +61,10 @@ export function FileDropZone({ disabled = false, onFile }: FileDropZoneProps) {
disabled={disabled}
onClick={() => input.current?.click()}
>
Choose OneNote file
Choose OneNote files
</button>
<span className="drop-zone__hint">
or drop one .one or .onepkg file here
or drop one .one, or one .onepkg with its .cab parts, here
</span>
</div>
);

72
src/source-file.test.ts Normal file
View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import {
MAX_SOURCE_FILE_BYTES,
selectSourceFiles,
sourceKindFromFileName,
} from './source-file.js';
function source(name: string, size = 1) {
return { name, size };
}
describe('local source selection', () => {
it('accepts exactly one .one file', () => {
const section = source('Section.ONE');
expect(sourceKindFromFileName(section.name)).toBe('one');
expect(selectSourceFiles([section])).toEqual({
ok: true,
kind: 'one',
source: section,
cabinetParts: [],
});
});
it('finds one .onepkg and keeps CAB companions in selection order', () => {
const part3 = source('Notebook-3.CAB');
const primary = source('Notebook.OnePkg');
const part2 = source('Notebook-2.cab');
expect(selectSourceFiles([part3, primary, part2])).toEqual({
ok: true,
kind: 'onepkg',
source: primary,
cabinetParts: [part3, part2],
});
});
it('rejects duplicate names, unsupported files, and ambiguous primaries', () => {
expect(
selectSourceFiles([
source('Notebook.onepkg'),
source('Part.cab'),
source('PART.CAB'),
])
).toMatchObject({ ok: false, title: 'Duplicate file name' });
expect(selectSourceFiles([source('Notebook.zip')])).toMatchObject({
ok: false,
title: 'Unsupported file name',
});
expect(
selectSourceFiles([source('First.onepkg'), source('Second.onepkg')])
).toMatchObject({ ok: false, title: 'Choose one OneNote source' });
expect(
selectSourceFiles([source('Section.one'), source('Notebook.onepkg')])
).toMatchObject({ ok: false, title: 'Choose one OneNote source' });
expect(selectSourceFiles([source('Part.cab')])).toMatchObject({
ok: false,
title: 'Choose one OneNote source',
});
});
it('applies both per-file and aggregate 512 MiB limits', () => {
expect(
selectSourceFiles([source('Notebook.onepkg', MAX_SOURCE_FILE_BYTES + 1)])
).toMatchObject({ ok: false, title: 'File is too large' });
expect(
selectSourceFiles([
source('Notebook.onepkg', 300 * 1024 * 1024),
source('Notebook-2.cab', 300 * 1024 * 1024),
])
).toMatchObject({ ok: false, title: 'Package set is too large' });
});
});

View File

@@ -1,6 +1,33 @@
import type { SourceKind } from './worker/onenote.client.js';
export const MAX_SOURCE_FILE_BYTES = 512 * 1024 * 1024;
export const MAX_SOURCE_PARTS = 256;
export interface SourceFileDescriptor {
name: string;
size: number;
}
interface SourceFileSelectionFailure {
ok: false;
title: string;
message: string;
}
export type SourceFileSelection<T extends SourceFileDescriptor> =
| {
ok: true;
kind: 'one';
source: T;
cabinetParts: [];
}
| {
ok: true;
kind: 'onepkg';
source: T;
cabinetParts: T[];
}
| SourceFileSelectionFailure;
export function sourceKindFromFileName(fileName: string): SourceKind | null {
const normalized = fileName.toLocaleLowerCase('en-US');
@@ -8,3 +35,101 @@ export function sourceKindFromFileName(fileName: string): SourceKind | null {
if (normalized.endsWith('.one')) return 'one';
return null;
}
export function isCabinetPartFileName(fileName: string): boolean {
return fileName.toLocaleLowerCase('en-US').endsWith('.cab');
}
export function selectSourceFiles<T extends SourceFileDescriptor>(
files: readonly T[]
): SourceFileSelection<T> {
if (files.length === 0) {
return invalidSelection();
}
if (files.length > MAX_SOURCE_PARTS) {
return {
ok: false,
title: 'Too many package parts',
message: `Choose at most ${MAX_SOURCE_PARTS} files for one cabinet set.`,
};
}
const sections: T[] = [];
const packages: T[] = [];
const cabinetParts: T[] = [];
for (const file of files) {
const kind = sourceKindFromFileName(file.name);
if (kind === 'one') sections.push(file);
else if (kind === 'onepkg') packages.push(file);
else if (isCabinetPartFileName(file.name)) cabinetParts.push(file);
else {
return {
ok: false,
title: 'Unsupported file name',
message:
'Choose one .one file, or one .onepkg file together with its .cab parts.',
};
}
}
const validSection =
sections.length === 1 && packages.length === 0 && cabinetParts.length === 0;
const validPackage = sections.length === 0 && packages.length === 1;
if (!validSection && !validPackage) return invalidSelection();
const seenNames = new Set<string>();
let totalSize = 0;
for (const file of files) {
const nameKey = file.name.normalize('NFC').toLocaleLowerCase('en-US');
if (seenNames.has(nameKey)) {
return {
ok: false,
title: 'Duplicate file name',
message: `The selection contains more than one file named ${file.name}.`,
};
}
seenNames.add(nameKey);
if (!Number.isSafeInteger(file.size) || file.size < 0) {
return {
ok: false,
title: 'Invalid file size',
message: `The browser reported an invalid size for ${file.name}.`,
};
}
if (file.size > MAX_SOURCE_FILE_BYTES) {
return {
ok: false,
title: 'File is too large',
message: `${file.name} exceeds the current 512 MiB safety limit.`,
};
}
totalSize += file.size;
if (!Number.isSafeInteger(totalSize) || totalSize > MAX_SOURCE_FILE_BYTES) {
return {
ok: false,
title: 'Package set is too large',
message:
'The selected .onepkg and .cab files exceed the combined 512 MiB safety limit.',
};
}
}
if (validSection) {
return { ok: true, kind: 'one', source: sections[0]!, cabinetParts: [] };
}
return {
ok: true,
kind: 'onepkg',
source: packages[0]!,
cabinetParts,
};
}
function invalidSelection(): SourceFileSelectionFailure {
return {
ok: false,
title: 'Choose one OneNote source',
message:
'Choose one .one file, or one .onepkg file together with its .cab parts.',
};
}

View File

@@ -29,20 +29,29 @@ class FakeWorker implements WorkerPort {
}
describe('OneNoteWorkerClient', () => {
it('uses the typed parse-onepkg protocol and transfers the source buffer', async () => {
it('transfers a ONEPKG and every named CAB companion', async () => {
const worker = new FakeWorker();
const client = new OneNoteWorkerClient(() => worker);
const bytes = new ArrayBuffer(16);
const result = client.parseSource('onepkg', 'Notebook.onepkg', bytes);
const second = new ArrayBuffer(8);
const third = new ArrayBuffer(4);
const result = client.parseSource('onepkg', 'Notebook.onepkg', bytes, [
{ fileName: 'part-3.cab', bytes: third },
{ fileName: 'part-2.cab', bytes: second },
]);
expect(worker.lastMessage).toEqual(
expect.objectContaining({
type: 'parse-onepkg',
fileName: 'Notebook.onepkg',
bytes,
cabinetParts: [
{ fileName: 'part-3.cab', bytes: third },
{ fileName: 'part-2.cab', bytes: second },
],
})
);
expect(worker.lastTransfer).toEqual([bytes]);
expect(worker.lastTransfer).toEqual([bytes, third, second]);
const requestId =
worker.lastMessage && 'requestId' in worker.lastMessage
@@ -143,7 +152,16 @@ describe('OneNoteWorkerClient', () => {
it('rejects outstanding work when the worker is terminated', async () => {
const worker = new FakeWorker();
const client = new OneNoteWorkerClient(() => worker);
const result = client.parseSource('one', 'Section.one', new ArrayBuffer(8));
const bytes = new ArrayBuffer(8);
const result = client.parseSource('one', 'Section.one', bytes);
expect(worker.lastMessage).toEqual({
type: 'parse-one',
requestId: 'parse-1',
fileName: 'Section.one',
bytes,
});
expect(worker.lastTransfer).toEqual([bytes]);
client.terminate();

View File

@@ -4,6 +4,7 @@ import type {
ParseWorkerResponse,
ResourceWorkerResponse,
WorkerRequest,
WorkerCabinetPart,
WorkerFailure,
WorkerResponse,
} from './worker-protocol.js';
@@ -68,7 +69,8 @@ export class OneNoteWorkerClient {
parseSource(
kind: SourceKind,
fileName: string,
bytes: ArrayBuffer
bytes: ArrayBuffer,
cabinetParts: readonly WorkerCabinetPart[] = []
): Promise<ParseWorkerResponse> {
if (this.terminated) {
return Promise.reject(
@@ -78,6 +80,14 @@ export class OneNoteWorkerClient {
)
);
}
if (kind === 'one' && cabinetParts.length > 0) {
return Promise.reject(
new WorkerClientError(
'invalid-format',
'CAB companions can only be supplied with a .onepkg source.'
)
);
}
const requestId = `parse-${++this.requestSequence}`;
const request: ParseWorkerRequest = {
@@ -85,6 +95,9 @@ export class OneNoteWorkerClient {
requestId,
fileName,
bytes,
...(kind === 'onepkg' && cabinetParts.length > 0
? { cabinetParts: [...cabinetParts] }
: {}),
};
return new Promise((resolve, reject) => {
@@ -92,7 +105,10 @@ export class OneNoteWorkerClient {
resolve: (response) => resolve(response as ParseWorkerResponse),
reject,
});
this.worker.postMessage(request, [bytes]);
this.worker.postMessage(request, [
bytes,
...cabinetParts.map((part) => part.bytes),
]);
});
}

View File

@@ -3,6 +3,7 @@ import type { OneNoteResource } from '../onenote/one/content-model.js';
import {
failureFromUnknown,
openPackageForWorker,
openPackageSetForWorker,
openSectionForWorker,
resourcePayload,
workerPageKey,
@@ -59,10 +60,25 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
}
case 'parse-onepkg':
try {
const parsed = await openPackageForWorker(
new Uint8Array(data.bytes),
data.fileName
);
const parsed =
data.cabinetParts && data.cabinetParts.length > 0
? await openPackageSetForWorker(
[
{
fileName: data.fileName,
bytes: new Uint8Array(data.bytes),
},
...data.cabinetParts.map((part) => ({
fileName: part.fileName,
bytes: new Uint8Array(part.bytes),
})),
],
data.fileName
)
: await openPackageForWorker(
new Uint8Array(data.bytes),
data.fileName
);
const sessionId = createSession(parsed.pages, parsed.resources);
workerScope.postMessage({
type: 'package',

View File

@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
import {
failureFromUnknown,
openPackageForWorker,
openPackageSetForWorker,
openSectionForWorker,
resourcePayload,
workerPageKey,
@@ -16,6 +17,15 @@ function fixtureBytes(path: string): Uint8Array {
return Uint8Array.from(Buffer.from(encoded.replaceAll(/\s/gu, ''), 'base64'));
}
function multiCabPart(index: number) {
return {
fileName: `cabd_multi_basic_pt${index}.cab`,
bytes: fixtureBytes(
`../../tests/fixtures/libmspack-multi-basic-pt${index}.cab.b64`
),
};
}
describe('worker parser adapter', () => {
it('opens a real section and retains stable page identifiers', () => {
const result = openSectionForWorker(
@@ -83,6 +93,24 @@ describe('worker parser adapter', () => {
expect(result.resources).toBeInstanceOf(Map);
});
it('opens named multi-cabinet package parts in arbitrary input order', async () => {
const parts = [4, 1, 5, 2, 3].map(multiCabPart);
const result = await openPackageSetForWorker(parts, 'Notebook.onepkg');
expect(result.notebook).toMatchObject({
sourceName: 'Notebook.onepkg',
sourceSize: parts.reduce((sum, part) => sum + part.bytes.length, 0),
entries: [
{ path: 'test1.txt', kind: 'other' },
{ path: 'test2.txt', kind: 'other' },
{ path: 'test3.txt', kind: 'other' },
],
sections: [],
});
expect(result.pages).toHaveLength(0);
expect(result.resources).toHaveLength(0);
});
it('converts malformed section input into a controlled worker failure', () => {
try {
openSectionForWorker(new Uint8Array(20), 'broken.one');

View File

@@ -15,7 +15,13 @@ import type {
OneNoteResource,
OneNoteSectionContentModel,
} from '../onenote/one/content-model.js';
import { OnePkgError, openOneNotePackage } from '../onenote/onepkg/index.js';
import {
OnePkgError,
openOneNotePackage,
openOneNotePackageSet,
type CabSetPart,
type OneNotePackageOpenOptions,
} from '../onenote/onepkg/index.js';
import type { WorkerFailure } from './worker-protocol.js';
export interface ParsedWorkerSection {
@@ -135,12 +141,28 @@ export function openSectionForWorker(
export async function openPackageForWorker(
bytes: Uint8Array,
sourceName: string
): Promise<ParsedWorkerPackage> {
return openPackageSourceForWorker({ kind: 'single', bytes }, sourceName);
}
export async function openPackageSetForWorker(
parts: CabSetPart[],
sourceName: string
): Promise<ParsedWorkerPackage> {
return openPackageSourceForWorker({ kind: 'set', parts }, sourceName);
}
async function openPackageSourceForWorker(
source:
| { kind: 'single'; bytes: Uint8Array }
| { kind: 'set'; parts: CabSetPart[] },
sourceName: string
): Promise<ParsedWorkerPackage> {
const contentBySection = new Map<string, OneNoteSectionContentModel>();
const result = await openOneNotePackage(bytes, {
const options: OneNotePackageOpenOptions = {
sourceName,
cancellation: {
yield: () => new Promise((resolve) => setTimeout(resolve, 0)),
yield: () => new Promise<void>((resolve) => setTimeout(resolve, 0)),
},
parseSection: ({ sourceName: sectionName, bytes: sectionBytes }) => {
try {
@@ -174,7 +196,11 @@ export async function openPackageForWorker(
throw error;
}
},
});
};
const result =
source.kind === 'single'
? await openOneNotePackage(source.bytes, options)
: await openOneNotePackageSet(source.parts, options);
const pages = new Map<string, OneNotePageDto>();
const resources = new Map<string, OneNoteResource>();

View File

@@ -5,6 +5,11 @@ import type {
OneNoteSectionDto,
} from '../onenote/model/dto.js';
export interface WorkerCabinetPart {
fileName: string;
bytes: ArrayBuffer;
}
export type WorkerRequest =
| {
type: 'parse-one';
@@ -17,6 +22,7 @@ export type WorkerRequest =
requestId: string;
fileName: string;
bytes: ArrayBuffer;
cabinetParts?: WorkerCabinetPart[];
}
| {
type: 'get-page';