@contract-kit/provider-storage-local 1.0.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.
package/src/index.ts ADDED
@@ -0,0 +1,721 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createReadStream, createWriteStream } from "node:fs";
3
+ import {
4
+ mkdir,
5
+ readFile,
6
+ rename,
7
+ rm,
8
+ stat as statFile,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
12
+ import { Readable } from "node:stream";
13
+ import { pipeline } from "node:stream/promises";
14
+ import { createProviderDevtools } from "@contract-kit/devtools";
15
+ import {
16
+ createProvider,
17
+ type StorageBody,
18
+ type StorageMetadata,
19
+ type StorageObject,
20
+ type StorageObjectBody,
21
+ type StoragePort,
22
+ type StorageVisibility,
23
+ } from "@contract-kit/ports";
24
+ import { z } from "zod";
25
+
26
+ export type {
27
+ StorageBody,
28
+ StorageMetadata,
29
+ StorageObject,
30
+ StorageObjectBody,
31
+ StoragePort,
32
+ StorageVisibility,
33
+ } from "@contract-kit/ports";
34
+
35
+ const metadataRootName = ".ck-storage-meta";
36
+
37
+ const LocalStorageConfigSchema = z.object({
38
+ ROOT: z.string().min(1).default("storage/app"),
39
+ PUBLIC_BASE_URL: z.string().min(1).optional(),
40
+ });
41
+
42
+ export type LocalStorageConfig = z.infer<typeof LocalStorageConfigSchema>;
43
+
44
+ export interface LocalStorageOptions {
45
+ /**
46
+ * Directory where storage objects are written.
47
+ */
48
+ root: string;
49
+
50
+ /**
51
+ * Base URL used by `publicUrl(...)` for public objects.
52
+ */
53
+ publicBaseUrl?: string;
54
+
55
+ /**
56
+ * Optional devtools target. The provider passes existing app ports here
57
+ * automatically; direct factory users can pass a devtools port explicitly.
58
+ */
59
+ devtools?: unknown;
60
+ }
61
+
62
+ export interface LocalStorageProviderOptions {
63
+ /**
64
+ * Provider name. Defaults to "storage-local".
65
+ */
66
+ name?: string;
67
+
68
+ /**
69
+ * Default root used when STORAGE_ROOT is not set.
70
+ */
71
+ root?: string;
72
+
73
+ /**
74
+ * Default public base URL used when STORAGE_PUBLIC_BASE_URL is not set.
75
+ */
76
+ publicBaseUrl?: string;
77
+ }
78
+
79
+ type LocalStorageMetadataFile = {
80
+ contentType?: string;
81
+ cacheControl?: string;
82
+ metadata: StorageMetadata;
83
+ visibility: StorageVisibility;
84
+ lastModified: string;
85
+ };
86
+
87
+ type StorageEvent = {
88
+ operation: string;
89
+ key: string;
90
+ summary: string;
91
+ details?: Record<string, unknown>;
92
+ };
93
+
94
+ type NodeWebReadableStream = Parameters<typeof Readable.fromWeb>[0];
95
+
96
+ function copyBytes(bytes: Uint8Array): Uint8Array {
97
+ return new Uint8Array(bytes);
98
+ }
99
+
100
+ function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
101
+ const buffer = new ArrayBuffer(bytes.byteLength);
102
+ new Uint8Array(buffer).set(bytes);
103
+ return buffer;
104
+ }
105
+
106
+ function hasControlCharacter(value: string): boolean {
107
+ for (const char of value) {
108
+ const code = char.charCodeAt(0);
109
+ if (code <= 31 || code === 127) return true;
110
+ }
111
+
112
+ return false;
113
+ }
114
+
115
+ async function writeStorageBody(
116
+ filePath: string,
117
+ body: StorageBody,
118
+ ): Promise<void> {
119
+ if (typeof body === "string") {
120
+ await writeFile(filePath, body);
121
+ return;
122
+ }
123
+
124
+ if (body instanceof Uint8Array) {
125
+ await writeFile(filePath, copyBytes(body));
126
+ return;
127
+ }
128
+
129
+ if (body instanceof ArrayBuffer) {
130
+ await writeFile(filePath, new Uint8Array(body.slice(0)));
131
+ return;
132
+ }
133
+
134
+ if (body instanceof Blob) {
135
+ await pipeline(
136
+ Readable.fromWeb(body.stream() as unknown as NodeWebReadableStream),
137
+ createWriteStream(filePath),
138
+ );
139
+ return;
140
+ }
141
+
142
+ await pipeline(
143
+ Readable.fromWeb(body as unknown as NodeWebReadableStream),
144
+ createWriteStream(filePath),
145
+ );
146
+ }
147
+
148
+ function validateStorageKey(key: string): void {
149
+ if (key.length === 0) {
150
+ throw new Error("Storage key must not be empty.");
151
+ }
152
+
153
+ if (hasControlCharacter(key)) {
154
+ throw new Error("Storage key must not include control characters.");
155
+ }
156
+
157
+ if (key.startsWith("/")) {
158
+ throw new Error("Storage key must not start with '/'.");
159
+ }
160
+
161
+ if (key.endsWith("/")) {
162
+ throw new Error("Storage key must not end with '/'.");
163
+ }
164
+
165
+ if (key.includes("\\")) {
166
+ throw new Error("Storage key must use '/' separators, not '\\'.");
167
+ }
168
+
169
+ const segments = key.split("/");
170
+
171
+ if (segments.some((segment) => segment === "")) {
172
+ throw new Error("Storage key must not include empty path segments.");
173
+ }
174
+
175
+ if (segments.some((segment) => segment === "." || segment === "..")) {
176
+ throw new Error("Storage key must not include '.' or '..' segments.");
177
+ }
178
+
179
+ if (segments.some((segment) => segment === metadataRootName)) {
180
+ throw new Error(
181
+ `Local storage key must not use reserved segment '${metadataRootName}'.`,
182
+ );
183
+ }
184
+ }
185
+
186
+ function joinPublicUrl(baseUrl: string, key: string): string {
187
+ const base = baseUrl.replace(/\/+$/, "");
188
+ const encodedKey = key
189
+ .split("/")
190
+ .map((part) => encodeURIComponent(part))
191
+ .join("/");
192
+
193
+ return `${base}/${encodedKey}`;
194
+ }
195
+
196
+ function assertInsideRoot(root: string, filePath: string): void {
197
+ const pathFromRoot = relative(root, filePath);
198
+
199
+ if (
200
+ pathFromRoot === "" ||
201
+ pathFromRoot.startsWith("..") ||
202
+ isAbsolute(pathFromRoot)
203
+ ) {
204
+ throw new Error("Storage key resolved outside the storage root.");
205
+ }
206
+ }
207
+
208
+ function objectPath(root: string, key: string): string {
209
+ validateStorageKey(key);
210
+ const filePath = resolve(root, ...key.split("/"));
211
+ assertInsideRoot(root, filePath);
212
+ return filePath;
213
+ }
214
+
215
+ function metadataPath(root: string, key: string): string {
216
+ validateStorageKey(key);
217
+ const filePath = `${resolve(root, metadataRootName, ...key.split("/"))}.json`;
218
+ assertInsideRoot(root, filePath);
219
+ return filePath;
220
+ }
221
+
222
+ async function fileExists(filePath: string): Promise<boolean> {
223
+ try {
224
+ const fileStat = await statFile(filePath);
225
+ return fileStat.isFile();
226
+ } catch (error) {
227
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
228
+ throw error;
229
+ }
230
+ }
231
+
232
+ async function readMetadata(
233
+ root: string,
234
+ key: string,
235
+ ): Promise<LocalStorageMetadataFile | null> {
236
+ const filePath = metadataPath(root, key);
237
+
238
+ try {
239
+ const raw = await readFile(filePath, "utf8");
240
+ return JSON.parse(raw) as LocalStorageMetadataFile;
241
+ } catch (error) {
242
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
243
+ throw error;
244
+ }
245
+ }
246
+
247
+ async function writeMetadata(
248
+ filePath: string,
249
+ metadata: LocalStorageMetadataFile,
250
+ ): Promise<void> {
251
+ await writeFile(filePath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
252
+ }
253
+
254
+ function temporaryPath(filePath: string, purpose: "tmp" | "backup"): string {
255
+ return `${filePath}.${purpose}.${randomUUID()}`;
256
+ }
257
+
258
+ async function renameFileIfExists(from: string, to: string): Promise<boolean> {
259
+ try {
260
+ const current = await statFile(from);
261
+ if (!current.isFile()) {
262
+ throw new Error(`Storage path is not a file: ${from}`);
263
+ }
264
+ } catch (error) {
265
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
266
+ throw error;
267
+ }
268
+
269
+ await rename(from, to);
270
+ return true;
271
+ }
272
+
273
+ async function cleanupFile(filePath: string): Promise<void> {
274
+ await rm(filePath, { force: true });
275
+ }
276
+
277
+ async function restoreBackup(
278
+ backupPath: string,
279
+ filePath: string,
280
+ backedUp: boolean,
281
+ ): Promise<void> {
282
+ if (!backedUp) return;
283
+ await rm(filePath, { force: true });
284
+ await rename(backupPath, filePath);
285
+ }
286
+
287
+ async function writeObjectAndMetadata(
288
+ filePath: string,
289
+ metaPath: string,
290
+ body: StorageBody,
291
+ metadata: LocalStorageMetadataFile,
292
+ ): Promise<number> {
293
+ const tempFilePath = temporaryPath(filePath, "tmp");
294
+ const tempMetaPath = temporaryPath(metaPath, "tmp");
295
+ const backupFilePath = temporaryPath(filePath, "backup");
296
+ const backupMetaPath = temporaryPath(metaPath, "backup");
297
+ let objectBackedUp = false;
298
+ let metadataBackedUp = false;
299
+ let objectCommitted = false;
300
+ let metadataCommitted = false;
301
+
302
+ try {
303
+ await Promise.all([
304
+ mkdir(dirname(filePath), { recursive: true }),
305
+ mkdir(dirname(metaPath), { recursive: true }),
306
+ ]);
307
+
308
+ await writeStorageBody(tempFilePath, body);
309
+ const objectStat = await statFile(tempFilePath);
310
+ if (!objectStat.isFile()) {
311
+ throw new Error("Storage body did not produce a file.");
312
+ }
313
+ await writeMetadata(tempMetaPath, metadata);
314
+
315
+ objectBackedUp = await renameFileIfExists(filePath, backupFilePath);
316
+ metadataBackedUp = await renameFileIfExists(metaPath, backupMetaPath);
317
+
318
+ await rename(tempFilePath, filePath);
319
+ objectCommitted = true;
320
+ await rename(tempMetaPath, metaPath);
321
+ metadataCommitted = true;
322
+
323
+ await Promise.all([
324
+ cleanupFile(backupFilePath),
325
+ cleanupFile(backupMetaPath),
326
+ ]);
327
+
328
+ return objectStat.size;
329
+ } catch (error) {
330
+ await Promise.allSettled([
331
+ cleanupFile(tempFilePath),
332
+ cleanupFile(tempMetaPath),
333
+ objectCommitted ? cleanupFile(filePath) : Promise.resolve(),
334
+ metadataCommitted ? cleanupFile(metaPath) : Promise.resolve(),
335
+ ]);
336
+ await Promise.allSettled([
337
+ restoreBackup(backupFilePath, filePath, objectBackedUp),
338
+ restoreBackup(backupMetaPath, metaPath, metadataBackedUp),
339
+ ]);
340
+ throw error;
341
+ }
342
+ }
343
+
344
+ function metadataToObject(
345
+ key: string,
346
+ size: number,
347
+ fallbackLastModified: Date,
348
+ metadata: LocalStorageMetadataFile | null,
349
+ ): StorageObject {
350
+ return {
351
+ key,
352
+ size,
353
+ ...(metadata?.contentType !== undefined
354
+ ? { contentType: metadata.contentType }
355
+ : {}),
356
+ ...(metadata?.cacheControl !== undefined
357
+ ? { cacheControl: metadata.cacheControl }
358
+ : {}),
359
+ metadata: { ...(metadata?.metadata ?? {}) },
360
+ visibility: metadata?.visibility ?? "private",
361
+ lastModified: metadata?.lastModified
362
+ ? new Date(metadata.lastModified)
363
+ : fallbackLastModified,
364
+ };
365
+ }
366
+
367
+ function createObjectBody(
368
+ object: StorageObject,
369
+ filePath: string,
370
+ ): StorageObjectBody {
371
+ let bodyUsed = false;
372
+
373
+ function markConsumed(): void {
374
+ if (bodyUsed) {
375
+ throw new Error("Storage object body has already been consumed.");
376
+ }
377
+
378
+ bodyUsed = true;
379
+ }
380
+
381
+ return {
382
+ ...object,
383
+ get bodyUsed() {
384
+ return bodyUsed;
385
+ },
386
+ stream() {
387
+ markConsumed();
388
+ return Readable.toWeb(
389
+ createReadStream(filePath),
390
+ ) as unknown as ReadableStream<Uint8Array>;
391
+ },
392
+ async bytes() {
393
+ markConsumed();
394
+ return copyBytes(await readFile(filePath));
395
+ },
396
+ async arrayBuffer() {
397
+ markConsumed();
398
+ return bytesToArrayBuffer(await readFile(filePath));
399
+ },
400
+ async text() {
401
+ markConsumed();
402
+ return readFile(filePath, "utf8");
403
+ },
404
+ };
405
+ }
406
+
407
+ function errorMessage(error: unknown): string {
408
+ return error instanceof Error ? error.message : String(error);
409
+ }
410
+
411
+ export function createLocalStorage(options: LocalStorageOptions): StoragePort {
412
+ const root = resolve(options.root);
413
+ const devtools = createProviderDevtools(options.devtools, {
414
+ providerName: "storage-local",
415
+ watcher: "storage",
416
+ });
417
+
418
+ const recordStorageEvent = (event: StorageEvent) => {
419
+ devtools.custom({
420
+ name: `storage.${event.operation}`,
421
+ label: `Storage ${event.operation}`,
422
+ summary: event.summary,
423
+ details: {
424
+ operation: event.operation,
425
+ key: event.key,
426
+ ...event.details,
427
+ },
428
+ });
429
+ };
430
+
431
+ async function statObject(key: string): Promise<StorageObject | null> {
432
+ const filePath = objectPath(root, key);
433
+
434
+ try {
435
+ const [fileStat, metadata] = await Promise.all([
436
+ statFile(filePath),
437
+ readMetadata(root, key),
438
+ ]);
439
+
440
+ if (!fileStat.isFile()) return null;
441
+
442
+ return metadataToObject(key, fileStat.size, fileStat.mtime, metadata);
443
+ } catch (error) {
444
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
445
+ throw error;
446
+ }
447
+ }
448
+
449
+ return {
450
+ async put(key, body, putOptions) {
451
+ const startedAt = Date.now();
452
+
453
+ try {
454
+ const filePath = objectPath(root, key);
455
+ const metaPath = metadataPath(root, key);
456
+ const lastModified = new Date();
457
+ const metadata: LocalStorageMetadataFile = {
458
+ ...(putOptions?.contentType !== undefined
459
+ ? { contentType: putOptions.contentType }
460
+ : {}),
461
+ ...(putOptions?.cacheControl !== undefined
462
+ ? { cacheControl: putOptions.cacheControl }
463
+ : {}),
464
+ metadata: { ...(putOptions?.metadata ?? {}) },
465
+ visibility: putOptions?.visibility ?? "private",
466
+ lastModified: lastModified.toISOString(),
467
+ };
468
+
469
+ const size = await writeObjectAndMetadata(
470
+ filePath,
471
+ metaPath,
472
+ body,
473
+ metadata,
474
+ );
475
+
476
+ const object = metadataToObject(key, size, lastModified, metadata);
477
+
478
+ recordStorageEvent({
479
+ operation: "put",
480
+ key,
481
+ summary: "Storage object written",
482
+ details: {
483
+ size: object.size,
484
+ visibility: object.visibility,
485
+ contentType: object.contentType ?? null,
486
+ metadataKeys: Object.keys(object.metadata),
487
+ durationMs: Date.now() - startedAt,
488
+ },
489
+ });
490
+
491
+ return object;
492
+ } catch (error) {
493
+ recordStorageEvent({
494
+ operation: "put.failed",
495
+ key,
496
+ summary: "Storage put failed",
497
+ details: {
498
+ durationMs: Date.now() - startedAt,
499
+ error: errorMessage(error),
500
+ },
501
+ });
502
+ throw error;
503
+ }
504
+ },
505
+
506
+ async get(key) {
507
+ const startedAt = Date.now();
508
+
509
+ try {
510
+ const object = await statObject(key);
511
+ const filePath = objectPath(root, key);
512
+
513
+ recordStorageEvent({
514
+ operation: "get",
515
+ key,
516
+ summary: object ? "Storage object read" : "Storage object missing",
517
+ details: {
518
+ hit: object !== null,
519
+ size: object?.size ?? null,
520
+ durationMs: Date.now() - startedAt,
521
+ },
522
+ });
523
+
524
+ if (!object) return null;
525
+ return createObjectBody(object, filePath);
526
+ } catch (error) {
527
+ recordStorageEvent({
528
+ operation: "get.failed",
529
+ key,
530
+ summary: "Storage get failed",
531
+ details: {
532
+ durationMs: Date.now() - startedAt,
533
+ error: errorMessage(error),
534
+ },
535
+ });
536
+ throw error;
537
+ }
538
+ },
539
+
540
+ async stat(key) {
541
+ const startedAt = Date.now();
542
+
543
+ try {
544
+ const object = await statObject(key);
545
+ recordStorageEvent({
546
+ operation: "stat",
547
+ key,
548
+ summary: object ? "Storage object found" : "Storage object missing",
549
+ details: {
550
+ hit: object !== null,
551
+ size: object?.size ?? null,
552
+ durationMs: Date.now() - startedAt,
553
+ },
554
+ });
555
+ return object;
556
+ } catch (error) {
557
+ recordStorageEvent({
558
+ operation: "stat.failed",
559
+ key,
560
+ summary: "Storage stat failed",
561
+ details: {
562
+ durationMs: Date.now() - startedAt,
563
+ error: errorMessage(error),
564
+ },
565
+ });
566
+ throw error;
567
+ }
568
+ },
569
+
570
+ async delete(key) {
571
+ const startedAt = Date.now();
572
+
573
+ try {
574
+ const filePath = objectPath(root, key);
575
+ const metaPath = metadataPath(root, key);
576
+ const existed = await fileExists(filePath);
577
+
578
+ await Promise.all([
579
+ existed ? rm(filePath, { force: true }) : Promise.resolve(),
580
+ rm(metaPath, { force: true }),
581
+ ]);
582
+
583
+ recordStorageEvent({
584
+ operation: "delete",
585
+ key,
586
+ summary: existed
587
+ ? "Storage object deleted"
588
+ : "Storage object missing",
589
+ details: {
590
+ deleted: existed,
591
+ durationMs: Date.now() - startedAt,
592
+ },
593
+ });
594
+
595
+ return existed;
596
+ } catch (error) {
597
+ recordStorageEvent({
598
+ operation: "delete.failed",
599
+ key,
600
+ summary: "Storage delete failed",
601
+ details: {
602
+ durationMs: Date.now() - startedAt,
603
+ error: errorMessage(error),
604
+ },
605
+ });
606
+ throw error;
607
+ }
608
+ },
609
+
610
+ async exists(key) {
611
+ const startedAt = Date.now();
612
+
613
+ try {
614
+ const exists = await fileExists(objectPath(root, key));
615
+ recordStorageEvent({
616
+ operation: "exists",
617
+ key,
618
+ summary: exists ? "Storage object exists" : "Storage object missing",
619
+ details: {
620
+ exists,
621
+ durationMs: Date.now() - startedAt,
622
+ },
623
+ });
624
+ return exists;
625
+ } catch (error) {
626
+ recordStorageEvent({
627
+ operation: "exists.failed",
628
+ key,
629
+ summary: "Storage exists failed",
630
+ details: {
631
+ durationMs: Date.now() - startedAt,
632
+ error: errorMessage(error),
633
+ },
634
+ });
635
+ throw error;
636
+ }
637
+ },
638
+
639
+ async publicUrl(key) {
640
+ const startedAt = Date.now();
641
+
642
+ try {
643
+ const object = await statObject(key);
644
+ const url =
645
+ object?.visibility === "public" && options.publicBaseUrl
646
+ ? joinPublicUrl(options.publicBaseUrl, key)
647
+ : null;
648
+
649
+ recordStorageEvent({
650
+ operation: "publicUrl",
651
+ key,
652
+ summary: url ? "Storage public URL resolved" : "No public URL",
653
+ details: {
654
+ hit: object !== null,
655
+ visibility: object?.visibility ?? null,
656
+ hasPublicUrl: url !== null,
657
+ durationMs: Date.now() - startedAt,
658
+ },
659
+ });
660
+
661
+ return url;
662
+ } catch (error) {
663
+ recordStorageEvent({
664
+ operation: "publicUrl.failed",
665
+ key,
666
+ summary: "Storage public URL failed",
667
+ details: {
668
+ durationMs: Date.now() - startedAt,
669
+ error: errorMessage(error),
670
+ },
671
+ });
672
+ throw error;
673
+ }
674
+ },
675
+ };
676
+ }
677
+
678
+ export interface LocalStorageProviderPorts {
679
+ storage: StoragePort;
680
+ }
681
+
682
+ export function createLocalStorageProvider(
683
+ options: LocalStorageProviderOptions = {},
684
+ ) {
685
+ const ConfigSchema = LocalStorageConfigSchema.extend({
686
+ ROOT: z
687
+ .string()
688
+ .min(1)
689
+ .default(options.root ?? "storage/app"),
690
+ });
691
+
692
+ return createProvider({
693
+ name: options.name ?? "storage-local",
694
+
695
+ config: {
696
+ schema: ConfigSchema,
697
+ envPrefix: "STORAGE_",
698
+ },
699
+
700
+ async setup({ ports, config }) {
701
+ if (!config) {
702
+ throw new Error(
703
+ "[localStorageProvider] Missing storage config. " +
704
+ "Please set STORAGE_ROOT or use the default storage/app path.",
705
+ );
706
+ }
707
+
708
+ return {
709
+ ports: {
710
+ storage: createLocalStorage({
711
+ root: config.ROOT,
712
+ publicBaseUrl: config.PUBLIC_BASE_URL ?? options.publicBaseUrl,
713
+ devtools: ports,
714
+ }),
715
+ } satisfies LocalStorageProviderPorts,
716
+ };
717
+ },
718
+ });
719
+ }
720
+
721
+ export const localStorageProvider = createLocalStorageProvider();