Skip to content

Commit 334b100

Browse files
authored
Merge pull request #183 from ducflair/dev
Dev
2 parents 7642a40 + 3a2f79b commit 334b100

32 files changed

Lines changed: 1893 additions & 626 deletions

File tree

.github/workflows/release-ducpy.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ jobs:
7070
echo "status=success" >> $GITHUB_OUTPUT
7171
else
7272
echo "status=failure" >> $GITHUB_OUTPUT
73+
exit 1
7374
fi
7475
working-directory: ./packages/ducpy
7576
- name: Notify web deployment

Cargo.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/ducjs/src/flatbuffers/duc/document-grid-config.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,13 @@ firstPageAlone():boolean {
5050
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
5151
}
5252

53+
scale():number {
54+
const offset = this.bb!.__offset(this.bb_pos, 14);
55+
return offset ? this.bb!.readFloat64(this.bb_pos + offset) : 0.0;
56+
}
57+
5358
static startDocumentGridConfig(builder:flatbuffers.Builder) {
54-
builder.startObject(5);
59+
builder.startObject(6);
5560
}
5661

5762
static addColumns(builder:flatbuffers.Builder, columns:number) {
@@ -74,19 +79,24 @@ static addFirstPageAlone(builder:flatbuffers.Builder, firstPageAlone:boolean) {
7479
builder.addFieldInt8(4, +firstPageAlone, +false);
7580
}
7681

82+
static addScale(builder:flatbuffers.Builder, scale:number) {
83+
builder.addFieldFloat64(5, scale, 0.0);
84+
}
85+
7786
static endDocumentGridConfig(builder:flatbuffers.Builder):flatbuffers.Offset {
7887
const offset = builder.endObject();
7988
return offset;
8089
}
8190

82-
static createDocumentGridConfig(builder:flatbuffers.Builder, columns:number, gapX:number, gapY:number, alignItems:DOCUMENT_GRID_ALIGN_ITEMS|null, firstPageAlone:boolean):flatbuffers.Offset {
91+
static createDocumentGridConfig(builder:flatbuffers.Builder, columns:number, gapX:number, gapY:number, alignItems:DOCUMENT_GRID_ALIGN_ITEMS|null, firstPageAlone:boolean, scale:number):flatbuffers.Offset {
8392
DocumentGridConfig.startDocumentGridConfig(builder);
8493
DocumentGridConfig.addColumns(builder, columns);
8594
DocumentGridConfig.addGapX(builder, gapX);
8695
DocumentGridConfig.addGapY(builder, gapY);
8796
if (alignItems !== null)
8897
DocumentGridConfig.addAlignItems(builder, alignItems);
8998
DocumentGridConfig.addFirstPageAlone(builder, firstPageAlone);
99+
DocumentGridConfig.addScale(builder, scale);
90100
return DocumentGridConfig.endDocumentGridConfig(builder);
91101
}
92102
}

packages/ducjs/src/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
export * as DucBin from "./flatbuffers/duc";
22

3-
export * from "./types";
4-
export * from "./utils";
5-
export * from "./serialize";
3+
export * from "./lazy-files";
64
export * from "./parse";
75
export * from "./restore";
8-
export * from "./technical";
6+
export * from "./serialize";
7+
export * from "./technical";
8+
export * from "./types";
9+
export * from "./utils";

packages/ducjs/src/lazy-files.ts

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/**
2+
* LazyExternalFileStore — Zero-copy, on-demand access to external file data from a FlatBuffer.
3+
*
4+
* Instead of eagerly parsing and copying every external file's binary data into JS memory,
5+
* this store keeps a reference to the original FlatBuffer Uint8Array and reads file bytes
6+
* only when explicitly requested. FlatBuffer `dataArray()` returns a zero-copy view
7+
* (a Uint8Array pointing into the original buffer), so no allocation occurs until the
8+
* consumer actually needs the data.
9+
*
10+
* Memory lifecycle:
11+
* 1. On parse: only metadata (~200 bytes per file) enters JS heap.
12+
* 2. On demand: `getFileData(fileId)` reads the zero-copy slice from the buffer.
13+
* 3. The caller (renderer/worker) uses the data, then lets it GC naturally.
14+
* 4. If the store is released, the buffer reference is dropped, freeing everything.
15+
*
16+
* This is the key to supporting 1000s of external files without RAM bloat.
17+
*/
18+
19+
import * as flatbuffers from "flatbuffers";
20+
import { ExportedDataState as ExportedDataStateFb } from "./flatbuffers/duc";
21+
import type { DucExternalFileData, DucExternalFileMetadata, DucExternalFiles } from "./types";
22+
import type { ExternalFileId } from "./types/elements";
23+
24+
export type ExternalFileMetadataMap = Record<string, DucExternalFileMetadata>;
25+
26+
interface LazyFileEntry {
27+
metadata: DucExternalFileMetadata;
28+
/** Index into the ExportedDataState.externalFiles vector */
29+
vectorIndex: number;
30+
}
31+
32+
export class LazyExternalFileStore {
33+
private _buffer: Uint8Array | null;
34+
private _byteBuffer: flatbuffers.ByteBuffer | null;
35+
private _dataState: ExportedDataStateFb | null;
36+
37+
/** Map from file id → lazy entry */
38+
private _entries = new Map<string, LazyFileEntry>();
39+
/** Map from element key → file id (the external_files vector uses element id as key) */
40+
private _keyToFileId = new Map<string, string>();
41+
42+
/**
43+
* Files that were added at runtime (e.g. user uploading a new image).
44+
* These aren't in the original FlatBuffer so we hold their data directly.
45+
*/
46+
private _runtimeFiles = new Map<string, DucExternalFileData>();
47+
48+
constructor(buffer: Uint8Array) {
49+
this._buffer = buffer;
50+
this._byteBuffer = new flatbuffers.ByteBuffer(buffer);
51+
this._dataState = ExportedDataStateFb.getRootAsExportedDataState(this._byteBuffer);
52+
this._indexMetadata();
53+
console.info(`[LazyExternalFileStore] indexed ${this._entries.size} files from ${buffer.byteLength} byte buffer, ids: [${[...this._entries.keys()].map(k => k.slice(0, 12)).join(', ')}]`);
54+
}
55+
56+
private _indexMetadata(): void {
57+
if (!this._dataState) return;
58+
59+
const count = this._dataState.externalFilesLength();
60+
for (let i = 0; i < count; i++) {
61+
const entry = this._dataState.externalFiles(i);
62+
if (!entry) continue;
63+
64+
const key = entry.key();
65+
const fileData = entry.value();
66+
if (!key || !fileData) continue;
67+
68+
const id = fileData.id() as ExternalFileId | null;
69+
if (!id) continue;
70+
71+
const metadata: DucExternalFileMetadata = {
72+
id,
73+
mimeType: fileData.mimeType() || "application/octet-stream",
74+
created: Number(fileData.created()),
75+
lastRetrieved: Number(fileData.lastRetrieved()) || undefined,
76+
};
77+
78+
const lazyEntry: LazyFileEntry = { metadata, vectorIndex: i };
79+
this._entries.set(id, lazyEntry);
80+
this._keyToFileId.set(key, id);
81+
}
82+
}
83+
84+
/** Total number of external files */
85+
get size(): number {
86+
return this._entries.size + this._runtimeFiles.size;
87+
}
88+
89+
/** Whether a file with the given id exists */
90+
has(fileId: string): boolean {
91+
return this._entries.has(fileId) || this._runtimeFiles.has(fileId);
92+
}
93+
94+
/** Get metadata only (no binary data copied) — ~200 bytes per file */
95+
getMetadata(fileId: string): DucExternalFileMetadata | null {
96+
const runtime = this._runtimeFiles.get(fileId);
97+
if (runtime) {
98+
const { data: _, ...meta } = runtime;
99+
return meta;
100+
}
101+
return this._entries.get(fileId)?.metadata ?? null;
102+
}
103+
104+
/** Get all metadata entries (for UI listing, etc.) */
105+
getAllMetadata(): ExternalFileMetadataMap {
106+
const result: ExternalFileMetadataMap = {};
107+
108+
for (const [id, entry] of this._entries) {
109+
result[id] = entry.metadata;
110+
}
111+
for (const [id, file] of this._runtimeFiles) {
112+
const { data: _, ...meta } = file;
113+
result[id] = meta;
114+
}
115+
116+
return result;
117+
}
118+
119+
/**
120+
* Get full file data (metadata + binary bytes) ON DEMAND.
121+
*
122+
* For files from the original FlatBuffer, this returns a zero-copy Uint8Array
123+
* view into the original buffer — no allocation for the file bytes themselves.
124+
* The view is valid as long as this store hasn't been released.
125+
*
126+
* For runtime-added files, returns the data directly.
127+
*/
128+
getFileData(fileId: string): DucExternalFileData | null {
129+
const runtime = this._runtimeFiles.get(fileId);
130+
if (runtime) return runtime;
131+
132+
const entry = this._entries.get(fileId);
133+
if (!entry || !this._dataState) return null;
134+
135+
const fbEntry = this._dataState.externalFiles(entry.vectorIndex);
136+
if (!fbEntry) return null;
137+
138+
const fileData = fbEntry.value();
139+
if (!fileData) return null;
140+
141+
const data = fileData.dataArray();
142+
if (!data) return null;
143+
144+
return {
145+
...entry.metadata,
146+
data,
147+
};
148+
}
149+
150+
/**
151+
* Get a detached copy of the file data (allocates new ArrayBuffer).
152+
* Use this when you need to transfer data to a worker or keep it beyond store lifetime.
153+
*/
154+
getFileDataCopy(fileId: string): DucExternalFileData | null {
155+
const fileDataRef = this.getFileData(fileId);
156+
if (!fileDataRef) return null;
157+
158+
return {
159+
...fileDataRef,
160+
data: new Uint8Array(fileDataRef.data),
161+
};
162+
}
163+
164+
/**
165+
* Add a file at runtime (user upload, paste, etc.).
166+
* These files are held in memory since they aren't in the FlatBuffer.
167+
*/
168+
addRuntimeFile(fileData: DucExternalFileData): void {
169+
this._runtimeFiles.set(fileData.id, fileData);
170+
}
171+
172+
/** Remove a runtime-added file */
173+
removeRuntimeFile(fileId: string): void {
174+
this._runtimeFiles.delete(fileId);
175+
}
176+
177+
/**
178+
* Export all files as a standard DucExternalFiles record.
179+
* This COPIES all file data eagerly — use only for serialization.
180+
*/
181+
toExternalFiles(): DucExternalFiles {
182+
const result: DucExternalFiles = {};
183+
184+
for (const [key, fileId] of this._keyToFileId) {
185+
const fileData = this.getFileData(fileId);
186+
if (fileData) {
187+
result[key] = fileData;
188+
}
189+
}
190+
191+
for (const [id, file] of this._runtimeFiles) {
192+
result[id] = file;
193+
}
194+
195+
return result;
196+
}
197+
198+
/**
199+
* Merge runtime files from the given DucExternalFiles map.
200+
* Only adds files not already present in the store.
201+
*/
202+
mergeFiles(files: DucExternalFiles): void {
203+
for (const [_key, fileData] of Object.entries(files)) {
204+
if (!this.has(fileData.id)) {
205+
this.addRuntimeFile(fileData);
206+
}
207+
}
208+
}
209+
210+
/** Estimated RAM usage for metadata only (not counting the backing buffer) */
211+
get estimatedMetadataBytes(): number {
212+
let bytes = 0;
213+
for (const [, entry] of this._entries) {
214+
bytes += 200 + entry.metadata.id.length * 2 + entry.metadata.mimeType.length * 2;
215+
}
216+
for (const [, file] of this._runtimeFiles) {
217+
bytes += 200 + (file.data?.byteLength ?? 0);
218+
}
219+
return bytes;
220+
}
221+
222+
/**
223+
* Release the FlatBuffer reference. After this, only runtime-added files remain accessible.
224+
* Call this when switching documents or when the store is no longer needed.
225+
*/
226+
release(): void {
227+
this._buffer = null;
228+
this._byteBuffer = null;
229+
this._dataState = null;
230+
this._entries.clear();
231+
this._keyToFileId.clear();
232+
}
233+
234+
/** Whether the store has been released */
235+
get isReleased(): boolean {
236+
return this._buffer === null;
237+
}
238+
}

0 commit comments

Comments
 (0)