@johannc-dev/zyrk 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,499 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ const MIB = 1024 * 1024;
5
+ export const localStorageLimits = {
6
+ maxObjectBytes: 64 * MIB,
7
+ chunkBytes: 4 * MIB,
8
+ maxKeyBytes: 512,
9
+ maxMetadataEntries: 16,
10
+ maxMetadataBytes: 4096,
11
+ maxList: 1000,
12
+ maxUploads: 4,
13
+ ioBytes: 1024 * MIB,
14
+ minObjectBytes: 4096,
15
+ maxObjects: 100_000,
16
+ };
17
+ export const defaultAreas = {
18
+ short: { quotaBytes: 1024 * MIB, ttlMs: 24 * 3_600_000 },
19
+ long: { quotaBytes: 500 * MIB, ttlMs: 7 * 24 * 3_600_000 },
20
+ durable: { quotaBytes: 50 * MIB, ttlMs: null },
21
+ };
22
+ const cost = (o) => {
23
+ let overhead = Buffer.byteLength(o.key);
24
+ for (const [k, v] of Object.entries(o.metadata))
25
+ overhead += k.length + v.length;
26
+ return Math.max(o.size, localStorageLimits.minObjectBytes) + overhead;
27
+ };
28
+ const checkMatch = (ifMatch, current, key) => {
29
+ if (ifMatch === null)
30
+ return;
31
+ if (!current || (ifMatch !== "*" && ifMatch !== current.etag))
32
+ throw err("precondition_failed", `${key} changed or does not exist`);
33
+ };
34
+ export class StorageOpError extends Error {
35
+ kind;
36
+ code;
37
+ constructor(kind, code, message) {
38
+ super(message);
39
+ this.kind = kind;
40
+ this.code = code;
41
+ }
42
+ }
43
+ const err = (code, message, kind = "program") => new StorageOpError(kind, code, message);
44
+ const BLOCKED_TYPES = /^(text\/html|application\/xhtml\+xml|text\/javascript|application\/javascript|application\/ecmascript|application\/x-sh|application\/x-msdownload|application\/x-executable|application\/x-mach-binary|application\/x-elf|image\/svg\+xml)$/;
45
+ const BLOCKED_PREFIXES = [
46
+ [0x4d, 0x5a],
47
+ [0x7f, 0x45, 0x4c, 0x46],
48
+ [0xcf, 0xfa, 0xed, 0xfe],
49
+ [0x23, 0x21],
50
+ ];
51
+ const startsWithMarkup = (bytes) => {
52
+ const head = Buffer.from(bytes.subarray(0, 64))
53
+ .toString("latin1")
54
+ .trimStart()
55
+ .toLowerCase();
56
+ return /^<(!doctype html|html|script|svg)/.test(head);
57
+ };
58
+ export class LocalStorage {
59
+ dir;
60
+ areas;
61
+ now;
62
+ shared;
63
+ #objects = new Map();
64
+ #bodies = new Map();
65
+ #uploads = new Map();
66
+ constructor(dir, areas = defaultAreas, now = Date.now, shared) {
67
+ this.dir = dir;
68
+ this.areas = areas;
69
+ this.now = now;
70
+ this.shared = shared;
71
+ for (const area of Object.keys(areas)) {
72
+ const index = new Map();
73
+ this.#objects.set(area, index);
74
+ if (!dir)
75
+ continue;
76
+ const folder = join(dir, area);
77
+ if (existsSync(folder))
78
+ for (const name of readdirSync(folder))
79
+ if (name.endsWith(".tmp"))
80
+ rmSync(join(folder, name), { force: true });
81
+ const file = join(folder, "index.json");
82
+ if (!existsSync(file))
83
+ continue;
84
+ let objects;
85
+ try {
86
+ objects = JSON.parse(readFileSync(file, "utf8"));
87
+ }
88
+ catch (e) {
89
+ throw new Error(`${file} is corrupt (${e.message}): delete the storage folder to start empty`);
90
+ }
91
+ for (const o of objects)
92
+ index.set(o.key, o);
93
+ const named = new Set(objects.map((o) => this.#fileName(o)));
94
+ for (const name of readdirSync(folder))
95
+ if (name !== "index.json" &&
96
+ !named.has(name) &&
97
+ !this.#legacy(name, index))
98
+ rmSync(join(folder, name), { force: true });
99
+ }
100
+ }
101
+ #fileName = (o) => `${createHash("sha256").update(o.key).digest("hex")}.${o.etag}`;
102
+ #legacy(name, index) {
103
+ return (/^[0-9a-f]{64}$/.test(name) &&
104
+ [...index.values()].some((o) => this.#fileName(o).startsWith(`${name}.`)));
105
+ }
106
+ #area(area) {
107
+ const index = typeof area === "string" ? this.#objects.get(area) : undefined;
108
+ if (!index)
109
+ throw err("invalid_request", `Unknown storage area ${String(area)}`);
110
+ this.#expire(area, index);
111
+ return index;
112
+ }
113
+ #expire(area, index) {
114
+ const ttl = this.areas[area].ttlMs;
115
+ if (ttl === null)
116
+ return;
117
+ for (const [key, o] of index)
118
+ if (this.now() >= o.writtenAt + ttl)
119
+ this.#remove(area, index, key);
120
+ }
121
+ #bodyKey = (area, o) => `${area}/${this.#fileName(o)}`;
122
+ #body(area, o) {
123
+ const k = this.#bodyKey(area, o);
124
+ let body = this.#bodies.get(k);
125
+ if (!body && this.dir) {
126
+ const legacy = join(this.dir, area, createHash("sha256").update(o.key).digest("hex"));
127
+ try {
128
+ const file = join(this.dir, k);
129
+ body = new Uint8Array(readFileSync(existsSync(file) || !existsSync(legacy) ? file : legacy));
130
+ }
131
+ catch (e) {
132
+ throw err("unavailable", `The body of ${o.key} is missing from the storage folder (${e.code ?? "unreadable"})`, "infrastructure");
133
+ }
134
+ this.#bodies.set(k, body);
135
+ }
136
+ if (!body)
137
+ throw err("unavailable", "Object body lost", "infrastructure");
138
+ return body;
139
+ }
140
+ #drop(area, o) {
141
+ const k = this.#bodyKey(area, o);
142
+ this.#bodies.delete(k);
143
+ if (!this.dir)
144
+ return;
145
+ rmSync(join(this.dir, k), { force: true });
146
+ rmSync(join(this.dir, area, createHash("sha256").update(o.key).digest("hex")), {
147
+ force: true,
148
+ });
149
+ }
150
+ #remove(area, index, key) {
151
+ const o = index.get(key);
152
+ index.delete(key);
153
+ this.#save(area, index);
154
+ if (o)
155
+ this.#drop(area, o);
156
+ }
157
+ #save(area, index) {
158
+ if (!this.dir)
159
+ return;
160
+ mkdirSync(join(this.dir, area), { recursive: true });
161
+ const file = join(this.dir, area, "index.json");
162
+ writeFileSync(`${file}.tmp`, JSON.stringify([...index.values()]));
163
+ renameSync(`${file}.tmp`, file);
164
+ }
165
+ #expiresAt(area, o) {
166
+ const ttl = this.areas[area].ttlMs;
167
+ return ttl === null ? null : o.writtenAt + ttl;
168
+ }
169
+ #head(area, o) {
170
+ return {
171
+ key: o.key,
172
+ size: o.size,
173
+ contentType: o.contentType,
174
+ metadata: o.metadata,
175
+ lastModified: new Date(o.writtenAt).toISOString(),
176
+ writtenAt: o.writtenAt,
177
+ expiresAt: this.#expiresAt(area, o),
178
+ etag: o.etag,
179
+ };
180
+ }
181
+ #key(key) {
182
+ if (typeof key !== "string")
183
+ throw err("invalid_request", "A storage key is a string");
184
+ const bytes = Buffer.byteLength(key);
185
+ if (bytes < 1 || bytes > localStorageLimits.maxKeyBytes)
186
+ throw err("invalid_request", "A key holds 1 to 512 bytes of UTF-8");
187
+ if (/[\u0000-\u001f\u007f]/.test(key))
188
+ throw err("invalid_request", "A key has no control character");
189
+ return key;
190
+ }
191
+ #options(o, bytes) {
192
+ const options = (o ?? {});
193
+ if (options.ifMatch !== undefined && options.ifMatch !== null)
194
+ if (typeof options.ifMatch !== "string")
195
+ throw err("invalid_request", "ifMatch must be an etag string");
196
+ let type = typeof options.contentType === "string" && options.contentType
197
+ ? options.contentType
198
+ : "application/octet-stream";
199
+ type = type.split(";")[0].trim().toLowerCase();
200
+ if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(type))
201
+ throw err("invalid_request", "contentType is type/subtype");
202
+ if (BLOCKED_TYPES.test(type) ||
203
+ BLOCKED_PREFIXES.some((p) => p.every((b, i) => bytes[i] === b)) ||
204
+ startsWithMarkup(bytes))
205
+ throw err("blocked_type", "Executables, HTML and scripts are refused");
206
+ const metadata = (options.metadata ?? {});
207
+ const names = Object.keys(metadata);
208
+ if (names.length > localStorageLimits.maxMetadataEntries)
209
+ throw err("invalid_request", "At most 16 metadata entries");
210
+ let total = 0;
211
+ for (const name of names) {
212
+ const value = metadata[name];
213
+ if (!/^[a-z0-9-]{1,64}$/.test(name) ||
214
+ !/^[\x20-\x7e]{0,1024}$/.test(value))
215
+ throw err("invalid_request", `Invalid metadata ${name}`);
216
+ total += name.length + value.length;
217
+ }
218
+ if (total > localStorageLimits.maxMetadataBytes)
219
+ throw err("invalid_request", "Metadata over 4 KiB");
220
+ return {
221
+ contentType: type,
222
+ metadata: { ...metadata },
223
+ ifAbsent: !!options.ifAbsent,
224
+ ifMatch: typeof options.ifMatch === "string" ? options.ifMatch : null,
225
+ };
226
+ }
227
+ #used(area, index) {
228
+ let committed = 0;
229
+ for (const o of index.values())
230
+ committed += cost(o);
231
+ let reserved = 0;
232
+ for (const u of this.#uploads.values())
233
+ if (u.area === area)
234
+ reserved += u.size;
235
+ return { committed, reserved };
236
+ }
237
+ usedBytes(area) {
238
+ const index = this.#objects.get(area);
239
+ if (!index)
240
+ return 0;
241
+ const { committed, reserved } = this.#used(area, index);
242
+ return committed + reserved;
243
+ }
244
+ #quota(area, index) {
245
+ if (!this.shared) {
246
+ const { committed, reserved } = this.#used(area, index);
247
+ return {
248
+ quota: this.areas[area].quotaBytes,
249
+ used: committed + reserved,
250
+ };
251
+ }
252
+ let used = this.shared.outside();
253
+ for (const name of this.#objects.keys())
254
+ used += this.usedBytes(name);
255
+ return { quota: this.shared.quotaBytes, used };
256
+ }
257
+ copyFrom(source, from, to, keys) {
258
+ const src = source.#area(from);
259
+ const index = this.#area(to);
260
+ const selected = (keys ?? [...src.keys()].sort())
261
+ .map((k) => src.get(k))
262
+ .filter((o) => o !== undefined);
263
+ let needed = 0;
264
+ let credit = 0;
265
+ let fresh = 0;
266
+ for (const o of selected) {
267
+ needed += cost(o);
268
+ const old = index.get(o.key);
269
+ if (old)
270
+ credit += cost(old);
271
+ else
272
+ fresh++;
273
+ }
274
+ const { quota, used } = this.#quota(to, index);
275
+ if (used - credit + needed > quota)
276
+ throw err("quota_exceeded", `The ${to} area is full`, "limit");
277
+ if (index.size + fresh > localStorageLimits.maxObjects)
278
+ throw err("quota_exceeded", `The ${to} area holds too many objects`, "limit");
279
+ let bytes = 0;
280
+ for (const o of selected) {
281
+ const body = source.#body(from, o);
282
+ this.#write(to, o.key, body, {
283
+ contentType: o.contentType,
284
+ metadata: o.metadata,
285
+ ifAbsent: false,
286
+ ifMatch: null,
287
+ });
288
+ bytes += o.size;
289
+ }
290
+ return { objects: selected.length, bytes };
291
+ }
292
+ #write(area, key, body, o, pending = 0) {
293
+ const index = this.#area(area);
294
+ if (body.length > localStorageLimits.maxObjectBytes)
295
+ throw err("too_large", "An object holds at most 64 MiB", "limit");
296
+ const previous = index.get(key);
297
+ if (previous && o.ifAbsent)
298
+ throw err("already_exists", `${key} exists`);
299
+ checkMatch(o.ifMatch, previous, key);
300
+ if (!previous && index.size >= localStorageLimits.maxObjects)
301
+ throw err("quota_exceeded", `The ${area} area holds too many objects`, "limit");
302
+ const quota = this.#quota(area, index);
303
+ const used = quota.used - pending - (previous ? cost(previous) : 0);
304
+ const needed = cost({ key, size: body.length, metadata: o.metadata });
305
+ if (used + needed > quota.quota)
306
+ throw err("quota_exceeded", `The ${area} area is full`, "limit");
307
+ const stored = {
308
+ key,
309
+ size: body.length,
310
+ contentType: o.contentType,
311
+ metadata: o.metadata,
312
+ writtenAt: this.now(),
313
+ etag: randomBytes(8).toString("hex"),
314
+ };
315
+ const k = this.#bodyKey(area, stored);
316
+ this.#bodies.set(k, body);
317
+ if (this.dir) {
318
+ mkdirSync(join(this.dir, area), { recursive: true });
319
+ writeFileSync(`${join(this.dir, k)}.tmp`, body);
320
+ renameSync(`${join(this.dir, k)}.tmp`, join(this.dir, k));
321
+ }
322
+ index.set(key, stored);
323
+ this.#save(area, index);
324
+ if (previous)
325
+ this.#drop(area, previous);
326
+ return this.#head(area, stored);
327
+ }
328
+ endExecution(owner) {
329
+ for (const [id, u] of this.#uploads)
330
+ if (u.owner === owner)
331
+ this.#uploads.delete(id);
332
+ }
333
+ op(name, a, owner, io) {
334
+ const charge = (n) => {
335
+ io.bytes += n;
336
+ if (io.bytes > localStorageLimits.ioBytes)
337
+ throw err("io_limit", "Over 1 GiB of storage read and written", "limit");
338
+ };
339
+ const decode = (data) => {
340
+ if (typeof data !== "string")
341
+ throw err("invalid_request", "data is base64");
342
+ const bytes = new Uint8Array(Buffer.from(data, "base64"));
343
+ if (bytes.length > localStorageLimits.chunkBytes)
344
+ throw err("invalid_request", "A chunk holds at most 4 MiB");
345
+ return bytes;
346
+ };
347
+ switch (name) {
348
+ case "StorageGet": {
349
+ const area = a.area;
350
+ const index = this.#area(area);
351
+ const o = index.get(this.#key(a.key));
352
+ if (!o)
353
+ return null;
354
+ const offset = Number(a.offset ?? 0);
355
+ const length = Math.min(Number(a.length ?? localStorageLimits.chunkBytes), localStorageLimits.chunkBytes);
356
+ if (!Number.isSafeInteger(offset) || offset < 0 || !(length >= 0))
357
+ throw err("invalid_request", "Invalid range");
358
+ const body = this.#body(area, o);
359
+ const start = Math.min(offset, body.length);
360
+ const chunk = body.subarray(start, Math.min(body.length, start + length));
361
+ charge(chunk.length);
362
+ return {
363
+ ...this.#head(area, o),
364
+ offset: start,
365
+ data: Buffer.from(chunk).toString("base64"),
366
+ };
367
+ }
368
+ case "StorageHead": {
369
+ const area = a.area;
370
+ const o = this.#area(area).get(this.#key(a.key));
371
+ return o ? this.#head(area, o) : null;
372
+ }
373
+ case "StoragePut": {
374
+ const area = a.area;
375
+ const key = this.#key(a.key);
376
+ const body = decode(a.data);
377
+ charge(body.length);
378
+ return this.#write(area, key, body, this.#options(a.options, body));
379
+ }
380
+ case "StorageUploadStart": {
381
+ const area = a.area;
382
+ this.#area(area);
383
+ const key = this.#key(a.key);
384
+ let open = 0;
385
+ for (const u of this.#uploads.values())
386
+ if (u.owner === owner)
387
+ open++;
388
+ if (open >= localStorageLimits.maxUploads)
389
+ throw err("upload_limit", "At most 4 writer() open", "limit");
390
+ const o = this.#options(a.options, new Uint8Array(0));
391
+ const id = randomBytes(8).toString("hex");
392
+ this.#uploads.set(id, { area, key, ...o, chunks: [], size: 0, owner });
393
+ return id;
394
+ }
395
+ case "StorageUploadChunk": {
396
+ const body = decode(a.data);
397
+ const id = a.upload;
398
+ const u = this.#uploads.get(id);
399
+ if (!u)
400
+ throw err("invalid_request", "Unknown upload");
401
+ this.#uploads.delete(id);
402
+ charge(body.length);
403
+ if (u.size + body.length > localStorageLimits.maxObjectBytes)
404
+ throw err("too_large", "An object holds at most 64 MiB", "limit");
405
+ const index = this.#area(u.area);
406
+ const quota = this.#quota(u.area, index);
407
+ const previous = index.get(u.key)?.size ?? 0;
408
+ if (quota.used - previous + body.length > quota.quota)
409
+ throw err("quota_exceeded", `The ${u.area} area is full`, "limit");
410
+ u.chunks.push(body);
411
+ u.size += body.length;
412
+ this.#uploads.set(id, u);
413
+ return null;
414
+ }
415
+ case "StorageUploadCommit": {
416
+ const id = a.upload;
417
+ const u = this.#uploads.get(id);
418
+ if (!u)
419
+ throw err("invalid_request", "Unknown upload");
420
+ const joined = Buffer.concat(u.chunks);
421
+ const body = new Uint8Array(joined.buffer, joined.byteOffset, joined.length);
422
+ this.#options({ contentType: u.contentType }, body);
423
+ const head = this.#write(u.area, u.key, body, u, u.size);
424
+ this.#uploads.delete(id);
425
+ return head;
426
+ }
427
+ case "StorageUploadAbort":
428
+ this.#uploads.delete(a.upload);
429
+ return null;
430
+ case "StorageDelete": {
431
+ const area = a.area;
432
+ const index = this.#area(area);
433
+ const key = this.#key(a.key);
434
+ const ifMatch = a.if_match ?? null;
435
+ if (ifMatch !== null && typeof ifMatch !== "string")
436
+ throw err("invalid_request", "ifMatch must be an etag string");
437
+ checkMatch(ifMatch, index.get(key), key);
438
+ if (index.has(key))
439
+ this.#remove(area, index, key);
440
+ return null;
441
+ }
442
+ case "StorageList": {
443
+ const area = a.area;
444
+ const index = this.#area(area);
445
+ const prefix = typeof a.prefix === "string" ? a.prefix : "";
446
+ const after = typeof a.after === "string" ? a.after : null;
447
+ const max = Math.min(Number(a.max_objects ?? localStorageLimits.maxList), localStorageLimits.maxList);
448
+ const keys = [...index.keys()]
449
+ .filter((k) => k.startsWith(prefix) &&
450
+ (after === null ||
451
+ Buffer.compare(Buffer.from(k), Buffer.from(after)) > 0))
452
+ .sort((x, y) => Buffer.compare(Buffer.from(x), Buffer.from(y)));
453
+ const page = keys.slice(0, max);
454
+ return {
455
+ objects: page.map((k) => {
456
+ const o = index.get(k);
457
+ const h = this.#head(area, o);
458
+ return {
459
+ key: h.key,
460
+ size: h.size,
461
+ contentType: h.contentType,
462
+ lastModified: h.lastModified,
463
+ expiresAt: h.expiresAt,
464
+ };
465
+ }),
466
+ cursor: keys.length > max ? (page[page.length - 1] ?? null) : null,
467
+ };
468
+ }
469
+ case "StorageUsage": {
470
+ const area = a.area;
471
+ const index = this.#area(area);
472
+ const { committed, reserved } = this.#used(area, index);
473
+ return {
474
+ area,
475
+ initialized: true,
476
+ usedBytes: committed + reserved,
477
+ reservedBytes: reserved,
478
+ uncertainBytes: 0,
479
+ committedBytes: committed,
480
+ deletingBytes: 0,
481
+ objects: index.size,
482
+ reservations: [...this.#uploads.values()]
483
+ .filter((u) => u.area === area)
484
+ .map((u) => ({
485
+ key: u.key,
486
+ size: u.size,
487
+ state: "writing",
488
+ updatedAt: new Date(this.now()).toISOString(),
489
+ })),
490
+ quotaBytes: this.shared?.quotaBytes ?? this.areas[area].quotaBytes,
491
+ ttlMs: this.areas[area].ttlMs,
492
+ maxObjectBytes: localStorageLimits.maxObjectBytes,
493
+ };
494
+ }
495
+ default:
496
+ throw err("invalid_request", `Unknown storage operation ${name}`);
497
+ }
498
+ }
499
+ }