@smallpen/local-package 0.1.0-alpha.1
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.
- package/assets/fonts/SourceSansPro-Bold.ttf +0 -0
- package/assets/fonts/SourceSansPro-Regular.ttf +0 -0
- package/assets/fonts/SourceSansPro-Semibold.ttf +0 -0
- package/assets/fonts/WorkSans-Regular.ttf +0 -0
- package/package.json +23 -0
- package/src/draft-import.mjs +282 -0
- package/src/font-convert.mjs +132 -0
- package/src/index.mjs +34 -0
- package/src/initialize.mjs +533 -0
- package/src/kiwi-runtime.mjs +526 -0
- package/src/local-package-backend.mjs +460 -0
- package/src/local-package.mjs +842 -0
- package/src/local-workspace-session.mjs +486 -0
- package/src/media-inspect.mjs +184 -0
- package/src/remote-library.mjs +301 -0
- package/src/render.mjs +3605 -0
- package/src/webp-decode.mjs +73 -0
- package/src/workspace.mjs +591 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { watch } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
canonicalJSON,
|
|
7
|
+
SmallPenError,
|
|
8
|
+
SMALLPEN_RUNTIME_CAPABILITIES,
|
|
9
|
+
} from "@smallpen/core";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
applyOperationBatch,
|
|
13
|
+
deleteFontFamily as deletePackageFontFamily,
|
|
14
|
+
deleteFontVariant as deletePackageFontVariant,
|
|
15
|
+
importFontVariant as importPackageFontVariant,
|
|
16
|
+
importMedia as importPackageMedia,
|
|
17
|
+
openPackage,
|
|
18
|
+
updateFontFamily as updatePackageFontFamily,
|
|
19
|
+
} from "./local-package.mjs";
|
|
20
|
+
|
|
21
|
+
const MAX_CHANGE_HISTORY = 200;
|
|
22
|
+
|
|
23
|
+
function isRecord(value) {
|
|
24
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sameCanonical(left, right) {
|
|
28
|
+
return canonicalJSON(left) === canonicalJSON(right);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function changedStableIds(before, after, result) {
|
|
32
|
+
if (sameCanonical(before, after)) return;
|
|
33
|
+
for (const candidate of [before, after]) {
|
|
34
|
+
if (isRecord(candidate) && typeof candidate.id === "string") {
|
|
35
|
+
result.add(candidate.id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(before) || Array.isArray(after)) {
|
|
39
|
+
const left = Array.isArray(before) ? before : [];
|
|
40
|
+
const right = Array.isArray(after) ? after : [];
|
|
41
|
+
const keyed = [...left, ...right].every(
|
|
42
|
+
(value) => isRecord(value) && typeof value.id === "string",
|
|
43
|
+
);
|
|
44
|
+
if (keyed) {
|
|
45
|
+
const leftById = new Map(left.map((value) => [value.id, value]));
|
|
46
|
+
const rightById = new Map(right.map((value) => [value.id, value]));
|
|
47
|
+
for (const id of new Set([...leftById.keys(), ...rightById.keys()])) {
|
|
48
|
+
changedStableIds(leftById.get(id), rightById.get(id), result);
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
52
|
+
changedStableIds(left[index], right[index], result);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (isRecord(before) || isRecord(after)) {
|
|
58
|
+
const left = isRecord(before) ? before : {};
|
|
59
|
+
const right = isRecord(after) ? after : {};
|
|
60
|
+
for (const field of new Set([...Object.keys(left), ...Object.keys(right)])) {
|
|
61
|
+
changedStableIds(left[field], right[field], result);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function externalChange(before, after) {
|
|
67
|
+
const changedFiles = [];
|
|
68
|
+
const affectedIds = new Set();
|
|
69
|
+
if (!sameCanonical(before.manifest, after.manifest)) {
|
|
70
|
+
changedFiles.push("manifest.json");
|
|
71
|
+
changedStableIds(before.manifest, after.manifest, affectedIds);
|
|
72
|
+
}
|
|
73
|
+
const entries = new Set([
|
|
74
|
+
...Object.keys(before.entries),
|
|
75
|
+
...Object.keys(after.entries),
|
|
76
|
+
]);
|
|
77
|
+
for (const entry of [...entries].sort()) {
|
|
78
|
+
if (sameCanonical(before.entries[entry], after.entries[entry])) continue;
|
|
79
|
+
changedFiles.push(entry);
|
|
80
|
+
changedStableIds(before.entries[entry], after.entries[entry], affectedIds);
|
|
81
|
+
}
|
|
82
|
+
for (const blob of new Set([...before.blobs.keys(), ...after.blobs.keys()])) {
|
|
83
|
+
if (before.blobs.has(blob) !== after.blobs.has(blob)) changedFiles.push(blob);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
affectedIds: [...affectedIds].sort(),
|
|
87
|
+
batchId: null,
|
|
88
|
+
changedFiles: changedFiles.sort(),
|
|
89
|
+
revision: after.revision,
|
|
90
|
+
source: "external",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function errorDescriptor(error) {
|
|
95
|
+
return {
|
|
96
|
+
code: error instanceof SmallPenError ? error.code : "invalid_external_state",
|
|
97
|
+
details: error instanceof SmallPenError ? error.details : {},
|
|
98
|
+
message: error instanceof Error ? error.message : String(error),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class LocalPackageBackend {
|
|
103
|
+
#changeSequence = 0;
|
|
104
|
+
#confirmedBatches = new Map();
|
|
105
|
+
#commitQueue = Promise.resolve();
|
|
106
|
+
#listeners = new Set();
|
|
107
|
+
#packagePath;
|
|
108
|
+
#pollTimer;
|
|
109
|
+
#refreshPromise;
|
|
110
|
+
#redoStack = [];
|
|
111
|
+
#timer;
|
|
112
|
+
#undoStack = [];
|
|
113
|
+
#watchers = [];
|
|
114
|
+
|
|
115
|
+
changes = [];
|
|
116
|
+
snapshot;
|
|
117
|
+
status = { readOnly: true, state: "closed" };
|
|
118
|
+
|
|
119
|
+
capabilities() {
|
|
120
|
+
return structuredClone(SMALLPEN_RUNTIME_CAPABILITIES);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async open(locator) {
|
|
124
|
+
await this.close();
|
|
125
|
+
this.#confirmedBatches.clear();
|
|
126
|
+
this.#redoStack = [];
|
|
127
|
+
this.#undoStack = [];
|
|
128
|
+
this.#changeSequence = 0;
|
|
129
|
+
this.changes = [];
|
|
130
|
+
this.snapshot = await openPackage(resolve(locator));
|
|
131
|
+
this.#packagePath = this.snapshot.locator;
|
|
132
|
+
this.#markReady();
|
|
133
|
+
this.#startWatchers();
|
|
134
|
+
return this.snapshot;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
commit(batch, options = {}) {
|
|
138
|
+
return this.#commit(batch, options.historyMode ?? "normal");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async undo() {
|
|
142
|
+
const batch = this.#undoStack.pop();
|
|
143
|
+
if (!batch) {
|
|
144
|
+
throw new SmallPenError("nothing_to_undo", "Local Package history has no Undo");
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
return await this.#commit(
|
|
148
|
+
{ ...batch, batchId: `undo_${randomUUID()}` },
|
|
149
|
+
"undo",
|
|
150
|
+
);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
this.#undoStack.push(batch);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async redo() {
|
|
158
|
+
const batch = this.#redoStack.pop();
|
|
159
|
+
if (!batch) {
|
|
160
|
+
throw new SmallPenError("nothing_to_redo", "Local Package history has no Redo");
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
return await this.#commit(
|
|
164
|
+
{ ...batch, batchId: `redo_${randomUUID()}` },
|
|
165
|
+
"redo",
|
|
166
|
+
);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
this.#redoStack.push(batch);
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async save() {
|
|
174
|
+
await this.#commitQueue;
|
|
175
|
+
const refreshed = await this.refresh();
|
|
176
|
+
if (!refreshed) {
|
|
177
|
+
throw new SmallPenError(
|
|
178
|
+
"repair_required",
|
|
179
|
+
"Save cannot validate the current external Package state",
|
|
180
|
+
{ status: this.status },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
revision: refreshed.revision,
|
|
185
|
+
status: "saved",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
importMedia(media) {
|
|
190
|
+
return this.#write(async () => {
|
|
191
|
+
const imported = await importPackageMedia(this.#packagePath, media);
|
|
192
|
+
return { result: imported.result, value: imported };
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
importFontVariant(font) {
|
|
197
|
+
return this.#write(async () => {
|
|
198
|
+
const imported = await importPackageFontVariant(this.#packagePath, font);
|
|
199
|
+
return { result: imported.result, value: imported };
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
updateFontFamily(fontId, family) {
|
|
204
|
+
return this.#write(async () => ({
|
|
205
|
+
result: await updatePackageFontFamily(this.#packagePath, fontId, family),
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
deleteFontFamily(fontId) {
|
|
210
|
+
return this.#write(async () => ({
|
|
211
|
+
result: await deletePackageFontFamily(this.#packagePath, fontId),
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
deleteFontVariant(fontVariantId) {
|
|
216
|
+
return this.#write(async () => ({
|
|
217
|
+
result: await deletePackageFontVariant(this.#packagePath, fontVariantId),
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
subscribe(listener) {
|
|
222
|
+
this.#listeners.add(listener);
|
|
223
|
+
return () => this.#listeners.delete(listener);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
refresh() {
|
|
227
|
+
if (this.#refreshPromise) return this.#refreshPromise;
|
|
228
|
+
const running = this.#performRefresh();
|
|
229
|
+
const tracked = running.finally(() => {
|
|
230
|
+
if (this.#refreshPromise === tracked) this.#refreshPromise = undefined;
|
|
231
|
+
});
|
|
232
|
+
this.#refreshPromise = tracked;
|
|
233
|
+
return tracked;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async #performRefresh() {
|
|
237
|
+
if (!this.#packagePath) return undefined;
|
|
238
|
+
try {
|
|
239
|
+
const next = await openPackage(this.#packagePath);
|
|
240
|
+
const recovered = this.status.state === "repair";
|
|
241
|
+
if (next.revision === this.snapshot?.revision) {
|
|
242
|
+
this.#markReady();
|
|
243
|
+
if (recovered) {
|
|
244
|
+
this.#startWatchers();
|
|
245
|
+
this.#emit({ snapshot: next, type: "external-recovered" });
|
|
246
|
+
}
|
|
247
|
+
return next;
|
|
248
|
+
}
|
|
249
|
+
const before = this.snapshot;
|
|
250
|
+
const change = externalChange(before, next);
|
|
251
|
+
this.snapshot = next;
|
|
252
|
+
this.#markReady();
|
|
253
|
+
this.#recordChange(change);
|
|
254
|
+
this.#startWatchers();
|
|
255
|
+
this.#emit({ change, snapshot: next, type: "external-revision" });
|
|
256
|
+
return next;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
const alreadyRepair = this.status.state === "repair";
|
|
259
|
+
this.status = {
|
|
260
|
+
error: errorDescriptor(error),
|
|
261
|
+
lastValidRevision: this.snapshot?.revision,
|
|
262
|
+
readOnly: true,
|
|
263
|
+
state: "repair",
|
|
264
|
+
};
|
|
265
|
+
// The last valid snapshot stays served read-only. The transition event is
|
|
266
|
+
// emitted once per failure episode; re-emitting it on every poll would
|
|
267
|
+
// keep waking clients that have already entered Repair.
|
|
268
|
+
if (!alreadyRepair) {
|
|
269
|
+
this.#emit({ error, snapshot: this.snapshot, status: this.status, type: "invalid-external-state" });
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async close() {
|
|
276
|
+
await this.#commitQueue;
|
|
277
|
+
clearTimeout(this.#timer);
|
|
278
|
+
this.#timer = undefined;
|
|
279
|
+
this.#closeWatchers();
|
|
280
|
+
await this.#refreshPromise;
|
|
281
|
+
clearTimeout(this.#timer);
|
|
282
|
+
this.#timer = undefined;
|
|
283
|
+
this.#closeWatchers();
|
|
284
|
+
this.#packagePath = undefined;
|
|
285
|
+
this.snapshot = undefined;
|
|
286
|
+
this.status = { readOnly: true, state: "closed" };
|
|
287
|
+
this.#confirmedBatches.clear();
|
|
288
|
+
this.#redoStack = [];
|
|
289
|
+
this.#undoStack = [];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#commit(batch, historyMode) {
|
|
293
|
+
const execute = async () => {
|
|
294
|
+
await this.#refreshPromise;
|
|
295
|
+
this.#assertWritable();
|
|
296
|
+
this.#closeWatchers();
|
|
297
|
+
try {
|
|
298
|
+
const identity = canonicalJSON({
|
|
299
|
+
baseRevision: batch.baseRevision,
|
|
300
|
+
batchId: batch.batchId,
|
|
301
|
+
operations: batch.operations,
|
|
302
|
+
});
|
|
303
|
+
const confirmed = this.#confirmedBatches.get(batch.batchId);
|
|
304
|
+
if (confirmed) {
|
|
305
|
+
if (confirmed.identity !== identity) {
|
|
306
|
+
throw new SmallPenError(
|
|
307
|
+
"duplicate_batch_id",
|
|
308
|
+
"Operation Batch identity was already used with another payload",
|
|
309
|
+
{ batchId: batch.batchId },
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
this.#startWatchers();
|
|
313
|
+
return { ...confirmed.result, duplicate: true };
|
|
314
|
+
}
|
|
315
|
+
const result = await applyOperationBatch(this.#packagePath, batch);
|
|
316
|
+
this.snapshot = await openPackage(this.#packagePath);
|
|
317
|
+
this.#markReady();
|
|
318
|
+
this.#startWatchers();
|
|
319
|
+
this.#confirmedBatches.set(batch.batchId, { identity, result });
|
|
320
|
+
if (historyMode === "normal") {
|
|
321
|
+
this.#undoStack.push(result.inverseBatch);
|
|
322
|
+
this.#redoStack = [];
|
|
323
|
+
} else if (historyMode === "undo") {
|
|
324
|
+
this.#redoStack.push(result.inverseBatch);
|
|
325
|
+
} else if (historyMode === "redo") {
|
|
326
|
+
this.#undoStack.push(result.inverseBatch);
|
|
327
|
+
}
|
|
328
|
+
this.#recordChange({
|
|
329
|
+
affectedIds: result.affectedIds,
|
|
330
|
+
batchId: result.batchId,
|
|
331
|
+
changedFiles: result.changedFiles,
|
|
332
|
+
revision: result.revision,
|
|
333
|
+
source: historyMode === "normal" ? "local" : historyMode,
|
|
334
|
+
});
|
|
335
|
+
return result;
|
|
336
|
+
} catch (error) {
|
|
337
|
+
this.#startWatchers();
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
const pending = this.#commitQueue.then(execute, execute);
|
|
342
|
+
this.#commitQueue = pending.then(
|
|
343
|
+
() => undefined,
|
|
344
|
+
() => undefined,
|
|
345
|
+
);
|
|
346
|
+
return pending;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
#write(operation) {
|
|
350
|
+
const execute = async () => {
|
|
351
|
+
await this.#refreshPromise;
|
|
352
|
+
this.#assertWritable();
|
|
353
|
+
this.#closeWatchers();
|
|
354
|
+
try {
|
|
355
|
+
const { result, value } = await operation();
|
|
356
|
+
this.snapshot = await openPackage(this.#packagePath);
|
|
357
|
+
this.#markReady();
|
|
358
|
+
this.#startWatchers();
|
|
359
|
+
this.#undoStack.push(result.inverseBatch);
|
|
360
|
+
this.#redoStack = [];
|
|
361
|
+
this.#recordChange({
|
|
362
|
+
affectedIds: result.affectedIds,
|
|
363
|
+
batchId: result.batchId,
|
|
364
|
+
changedFiles: result.changedFiles,
|
|
365
|
+
revision: result.revision,
|
|
366
|
+
source: "local",
|
|
367
|
+
});
|
|
368
|
+
return value ?? result;
|
|
369
|
+
} catch (error) {
|
|
370
|
+
this.#startWatchers();
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
const pending = this.#commitQueue.then(execute, execute);
|
|
375
|
+
this.#commitQueue = pending.then(
|
|
376
|
+
() => undefined,
|
|
377
|
+
() => undefined,
|
|
378
|
+
);
|
|
379
|
+
return pending;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
#assertWritable() {
|
|
383
|
+
if (!this.#packagePath) {
|
|
384
|
+
throw new SmallPenError("package_not_open", "Local Package Backend is not open");
|
|
385
|
+
}
|
|
386
|
+
if (this.status.readOnly) {
|
|
387
|
+
throw new SmallPenError(
|
|
388
|
+
"repair_read_only",
|
|
389
|
+
"The last valid projection is read-only until external Package Repair succeeds",
|
|
390
|
+
{ status: this.status },
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
#markReady() {
|
|
396
|
+
this.status = {
|
|
397
|
+
lastValidRevision: this.snapshot?.revision,
|
|
398
|
+
readOnly: false,
|
|
399
|
+
state: "ready",
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
#recordChange(change) {
|
|
404
|
+
this.changes.push({ ...change, index: this.#changeSequence });
|
|
405
|
+
this.#changeSequence += 1;
|
|
406
|
+
if (this.changes.length > MAX_CHANGE_HISTORY) this.changes.shift();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#scheduleRefresh() {
|
|
410
|
+
clearTimeout(this.#timer);
|
|
411
|
+
this.#timer = setTimeout(() => void this.refresh(), 120);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
#startWatchers() {
|
|
415
|
+
this.#closeWatchers();
|
|
416
|
+
if (!this.#packagePath || !this.snapshot) return;
|
|
417
|
+
const parent = dirname(this.#packagePath);
|
|
418
|
+
const packageName = basename(this.#packagePath);
|
|
419
|
+
const addWatcher = (path, callback) => {
|
|
420
|
+
try {
|
|
421
|
+
const watcher = watch(path, callback);
|
|
422
|
+
watcher.on("error", () => this.#scheduleRefresh());
|
|
423
|
+
this.#watchers.push(watcher);
|
|
424
|
+
} catch {
|
|
425
|
+
// A concurrently replaced directory is covered by the parent watcher.
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
addWatcher(parent, (_eventType, filename) => {
|
|
429
|
+
if (filename && String(filename) !== packageName) return;
|
|
430
|
+
this.#scheduleRefresh();
|
|
431
|
+
});
|
|
432
|
+
const directories = new Set([this.#packagePath]);
|
|
433
|
+
for (const kind of Object.keys(this.snapshot.manifest.entries)) {
|
|
434
|
+
for (const entry of this.snapshot.manifest.entries[kind]) {
|
|
435
|
+
directories.add(dirname(join(this.#packagePath, entry)));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (this.snapshot.blobs.size > 0) {
|
|
439
|
+
directories.add(join(this.#packagePath, "blobs"));
|
|
440
|
+
}
|
|
441
|
+
for (const directory of directories) {
|
|
442
|
+
addWatcher(directory, () => this.#scheduleRefresh());
|
|
443
|
+
}
|
|
444
|
+
this.#pollTimer = setInterval(() => this.#scheduleRefresh(), 1000);
|
|
445
|
+
this.#pollTimer.unref?.();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
#closeWatchers() {
|
|
449
|
+
clearTimeout(this.#timer);
|
|
450
|
+
this.#timer = undefined;
|
|
451
|
+
clearInterval(this.#pollTimer);
|
|
452
|
+
this.#pollTimer = undefined;
|
|
453
|
+
for (const watcher of this.#watchers) watcher.close();
|
|
454
|
+
this.#watchers = [];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
#emit(event) {
|
|
458
|
+
for (const listener of this.#listeners) listener(event);
|
|
459
|
+
}
|
|
460
|
+
}
|