@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,301 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
canonicalJSON,
|
|
7
|
+
listPackageEntries,
|
|
8
|
+
loadPackageFromValues,
|
|
9
|
+
sha256Hex,
|
|
10
|
+
SmallPenError,
|
|
11
|
+
} from "@smallpen/core";
|
|
12
|
+
|
|
13
|
+
const MAX_REMOTE_FILE_BYTES = 50 * 1024 * 1024;
|
|
14
|
+
const MAX_REMOTE_PACKAGE_BYTES = 200 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
export function defaultLibraryCacheRoot() {
|
|
17
|
+
return join(homedir(), ".smallpen", "library-cache");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function manifestUrl(sourceUrl) {
|
|
21
|
+
const parsed = new URL(sourceUrl);
|
|
22
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
23
|
+
throw new SmallPenError(
|
|
24
|
+
"invalid_library_source",
|
|
25
|
+
"Remote Library URL must use HTTP or HTTPS",
|
|
26
|
+
{ url: sourceUrl },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
parsed.hash = "";
|
|
30
|
+
if (!parsed.pathname.endsWith("/manifest.json")) {
|
|
31
|
+
parsed.pathname = `${parsed.pathname.replace(/\/?$/, "/")}manifest.json`;
|
|
32
|
+
}
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function responseBytes(response, label) {
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
throw new SmallPenError(
|
|
39
|
+
"remote_library_unavailable",
|
|
40
|
+
`${label} returned HTTP ${response.status}`,
|
|
41
|
+
{ status: response.status, url: response.url },
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const declared = Number(response.headers.get("content-length"));
|
|
45
|
+
if (Number.isFinite(declared) && declared > MAX_REMOTE_FILE_BYTES) {
|
|
46
|
+
throw new SmallPenError(
|
|
47
|
+
"remote_library_too_large",
|
|
48
|
+
`${label} exceeds the remote Library file limit`,
|
|
49
|
+
{ byteLength: declared, url: response.url },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
53
|
+
if (bytes.byteLength > MAX_REMOTE_FILE_BYTES) {
|
|
54
|
+
throw new SmallPenError(
|
|
55
|
+
"remote_library_too_large",
|
|
56
|
+
`${label} exceeds the remote Library file limit`,
|
|
57
|
+
{ byteLength: bytes.byteLength, url: response.url },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return bytes;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function fetchValue(fetchImpl, url, label) {
|
|
64
|
+
const bytes = await responseBytes(await fetchImpl(url), label);
|
|
65
|
+
let value;
|
|
66
|
+
try {
|
|
67
|
+
value = JSON.parse(new TextDecoder().decode(bytes));
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new SmallPenError(
|
|
70
|
+
"invalid_remote_library",
|
|
71
|
+
`${label} is not valid JSON`,
|
|
72
|
+
{ reason: error instanceof Error ? error.message : String(error), url },
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return { bytes, value };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function collectBlobPaths(value, result = new Set()) {
|
|
79
|
+
if (Array.isArray(value)) {
|
|
80
|
+
for (const child of value) collectBlobPaths(child, result);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
if (!value || typeof value !== "object") return result;
|
|
84
|
+
for (const child of Object.values(value)) {
|
|
85
|
+
if (typeof child === "string" && /^blobs\/[a-f0-9]{64}$/.test(child)) {
|
|
86
|
+
result.add(child);
|
|
87
|
+
} else {
|
|
88
|
+
collectBlobPaths(child, result);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function downloadRemotePackage(sourceUrl, fetchImpl) {
|
|
95
|
+
const manifest = manifestUrl(sourceUrl);
|
|
96
|
+
const base = new URL("./", manifest);
|
|
97
|
+
const manifestResult = await fetchValue(
|
|
98
|
+
fetchImpl,
|
|
99
|
+
manifest.href,
|
|
100
|
+
"Remote Library manifest",
|
|
101
|
+
);
|
|
102
|
+
const { entries } = listPackageEntries(manifestResult.value);
|
|
103
|
+
const values = new Map([["manifest.json", manifestResult.value]]);
|
|
104
|
+
let totalBytes = manifestResult.bytes.byteLength;
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
const result = await fetchValue(
|
|
107
|
+
fetchImpl,
|
|
108
|
+
new URL(entry, base).href,
|
|
109
|
+
`Remote Library entry ${entry}`,
|
|
110
|
+
);
|
|
111
|
+
totalBytes += result.bytes.byteLength;
|
|
112
|
+
if (totalBytes > MAX_REMOTE_PACKAGE_BYTES) {
|
|
113
|
+
throw new SmallPenError(
|
|
114
|
+
"remote_library_too_large",
|
|
115
|
+
"Remote Library exceeds the package size limit",
|
|
116
|
+
{ byteLength: totalBytes, url: manifest.href },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
values.set(entry, result.value);
|
|
120
|
+
}
|
|
121
|
+
for (const blob of collectBlobPaths([...values.values()])) {
|
|
122
|
+
const bytes = await responseBytes(
|
|
123
|
+
await fetchImpl(new URL(blob, base).href),
|
|
124
|
+
`Remote Library blob ${blob}`,
|
|
125
|
+
);
|
|
126
|
+
totalBytes += bytes.byteLength;
|
|
127
|
+
if (totalBytes > MAX_REMOTE_PACKAGE_BYTES) {
|
|
128
|
+
throw new SmallPenError(
|
|
129
|
+
"remote_library_too_large",
|
|
130
|
+
"Remote Library exceeds the package size limit",
|
|
131
|
+
{ byteLength: totalBytes, url: manifest.href },
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
values.set(blob, bytes);
|
|
135
|
+
}
|
|
136
|
+
return loadPackageFromValues(manifest.href, values);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function cacheDirectory(cacheRoot, sourceUrl) {
|
|
140
|
+
const key = await sha256Hex(
|
|
141
|
+
new TextEncoder().encode(manifestUrl(sourceUrl).href),
|
|
142
|
+
);
|
|
143
|
+
return join(cacheRoot, key);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function writeSnapshotCache(cacheRoot, sourceUrl, snapshot) {
|
|
147
|
+
if (!cacheRoot) return;
|
|
148
|
+
const root = await cacheDirectory(cacheRoot, sourceUrl);
|
|
149
|
+
const snapshotRoot = join(root, snapshot.revision);
|
|
150
|
+
await mkdir(snapshotRoot, { recursive: true });
|
|
151
|
+
await writeFile(
|
|
152
|
+
join(snapshotRoot, "manifest.json"),
|
|
153
|
+
`${canonicalJSON(snapshot.manifest)}\n`,
|
|
154
|
+
);
|
|
155
|
+
for (const [entry, value] of Object.entries(snapshot.entries)) {
|
|
156
|
+
await mkdir(dirname(join(snapshotRoot, entry)), { recursive: true });
|
|
157
|
+
await writeFile(join(snapshotRoot, entry), `${canonicalJSON(value)}\n`);
|
|
158
|
+
}
|
|
159
|
+
for (const [blob, value] of snapshot.blobs ?? []) {
|
|
160
|
+
await mkdir(dirname(join(snapshotRoot, blob)), { recursive: true });
|
|
161
|
+
await writeFile(join(snapshotRoot, blob), value);
|
|
162
|
+
}
|
|
163
|
+
const pointer = join(root, "current.json");
|
|
164
|
+
const temporary = join(root, `.current.${snapshot.revision}.tmp`);
|
|
165
|
+
await writeFile(
|
|
166
|
+
temporary,
|
|
167
|
+
`${canonicalJSON({ revision: snapshot.revision, sourceUrl })}\n`,
|
|
168
|
+
);
|
|
169
|
+
await rename(temporary, pointer);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function readSnapshotCache(cacheRoot, sourceUrl) {
|
|
173
|
+
if (!cacheRoot) return undefined;
|
|
174
|
+
try {
|
|
175
|
+
const root = await cacheDirectory(cacheRoot, sourceUrl);
|
|
176
|
+
const pointer = JSON.parse(
|
|
177
|
+
await readFile(join(root, "current.json"), "utf8"),
|
|
178
|
+
);
|
|
179
|
+
if (!/^[a-f0-9]{64}$/.test(pointer.revision)) return undefined;
|
|
180
|
+
const snapshotRoot = join(root, pointer.revision);
|
|
181
|
+
const manifest = JSON.parse(
|
|
182
|
+
await readFile(join(snapshotRoot, "manifest.json"), "utf8"),
|
|
183
|
+
);
|
|
184
|
+
const { entries } = listPackageEntries(manifest);
|
|
185
|
+
const values = new Map([["manifest.json", manifest]]);
|
|
186
|
+
for (const entry of entries) {
|
|
187
|
+
values.set(
|
|
188
|
+
entry,
|
|
189
|
+
JSON.parse(await readFile(join(snapshotRoot, entry), "utf8")),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
for (const blob of collectBlobPaths([...values.values()])) {
|
|
193
|
+
values.set(
|
|
194
|
+
blob,
|
|
195
|
+
new Uint8Array(await readFile(join(snapshotRoot, blob))),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const snapshot = await loadPackageFromValues(
|
|
199
|
+
manifestUrl(sourceUrl).href,
|
|
200
|
+
values,
|
|
201
|
+
);
|
|
202
|
+
if (snapshot.revision !== pointer.revision) {
|
|
203
|
+
throw new SmallPenError(
|
|
204
|
+
"remote_library_cache_corrupt",
|
|
205
|
+
"Cached Library content no longer matches its pinned revision",
|
|
206
|
+
{
|
|
207
|
+
expectedRevision: pointer.revision,
|
|
208
|
+
foundRevision: snapshot.revision,
|
|
209
|
+
sourceUrl: manifestUrl(sourceUrl).href,
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return snapshot;
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (error?.code === "ENOENT") return undefined;
|
|
216
|
+
if (error instanceof SmallPenError && error.code === "remote_library_cache_corrupt") {
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
// Any other failure (truncated JSON, blob hash mismatch, …) is a corrupt
|
|
220
|
+
// cache: never surface raw content, always the typed error (SP-026-B).
|
|
221
|
+
if (!(error instanceof SmallPenError)) {
|
|
222
|
+
throw new SmallPenError(
|
|
223
|
+
"remote_library_cache_corrupt",
|
|
224
|
+
"Cached Library content failed integrity validation",
|
|
225
|
+
{
|
|
226
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
227
|
+
sourceUrl: manifestUrl(sourceUrl).href,
|
|
228
|
+
},
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
throw new SmallPenError(
|
|
232
|
+
"remote_library_cache_corrupt",
|
|
233
|
+
"Cached Library content failed integrity validation",
|
|
234
|
+
{
|
|
235
|
+
...(error.details ?? {}),
|
|
236
|
+
reason: error.message,
|
|
237
|
+
sourceUrl: manifestUrl(sourceUrl).href,
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function openRemoteLibrary(
|
|
244
|
+
sourceUrl,
|
|
245
|
+
{ cacheRoot, expectedPackageId, fetchImpl = fetch, refresh = false } = {},
|
|
246
|
+
) {
|
|
247
|
+
let cached;
|
|
248
|
+
let corruptCacheError;
|
|
249
|
+
if (!refresh) {
|
|
250
|
+
try {
|
|
251
|
+
cached = await readSnapshotCache(cacheRoot, sourceUrl);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (error?.code !== "remote_library_cache_corrupt") throw error;
|
|
254
|
+
// A corrupted cache is never trusted; recovery falls through to a safe
|
|
255
|
+
// online re-fetch and the corrupt error only surfaces when offline.
|
|
256
|
+
corruptCacheError = error;
|
|
257
|
+
}
|
|
258
|
+
if (cached) {
|
|
259
|
+
return {
|
|
260
|
+
...cached,
|
|
261
|
+
remote: { cache: "hit", sourceUrl: manifestUrl(sourceUrl).href },
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const snapshot = await downloadRemotePackage(sourceUrl, fetchImpl);
|
|
267
|
+
if (
|
|
268
|
+
expectedPackageId !== undefined &&
|
|
269
|
+
snapshot.manifest.packageId !== expectedPackageId
|
|
270
|
+
) {
|
|
271
|
+
// The verified cache pointer is intentionally left untouched.
|
|
272
|
+
throw new SmallPenError(
|
|
273
|
+
"library_id_mismatch",
|
|
274
|
+
"Refreshed Library Package ID does not match the declared dependency",
|
|
275
|
+
{
|
|
276
|
+
actualPackageId: snapshot.manifest.packageId,
|
|
277
|
+
expectedPackageId,
|
|
278
|
+
sourceUrl: manifestUrl(sourceUrl).href,
|
|
279
|
+
},
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
await writeSnapshotCache(cacheRoot, sourceUrl, snapshot);
|
|
283
|
+
return {
|
|
284
|
+
...snapshot,
|
|
285
|
+
remote: { cache: "refreshed", sourceUrl: manifestUrl(sourceUrl).href },
|
|
286
|
+
};
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (error?.code === "library_id_mismatch") throw error;
|
|
289
|
+
if (corruptCacheError && !cached) throw corruptCacheError;
|
|
290
|
+
if (!cached) cached = await readSnapshotCache(cacheRoot, sourceUrl);
|
|
291
|
+
if (!cached) throw error;
|
|
292
|
+
return {
|
|
293
|
+
...cached,
|
|
294
|
+
remote: {
|
|
295
|
+
cache: "stale",
|
|
296
|
+
sourceUrl: manifestUrl(sourceUrl).href,
|
|
297
|
+
warning: error instanceof Error ? error.message : String(error),
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|