تخطَّ إلى المحتوى

Storage

هذا المحتوى غير متوفر بلغتك بعد.

@framework/storage gives every module one StorageDisk contract for reading and writing files: put/get/stream, copy/move, bounded listing and public URLs. Every file lives on a named disk whose visibility (private or public) is fixed at configuration time, and every path is guarded against traversal before it touches a filesystem. The local driver ships with the framework; every other driver is constructed through the storagekit engine.

Register the provider — it reads the storage section of your configuration directly, so it takes no arguments:

app/providers/storage.provider.ts
import { defineProvider } from '@framework/core';
import { createStorageProvider } from '@framework/storage';
import type { AppConfig } from '../../config';
export const storageProvider = defineProvider<AppConfig>(createStorageProvider<AppConfig>());

Each entry of storage.disks becomes a singleton under storageDiskToken(name), and the storage.default disk is additionally bound to STORAGE:

import { STORAGE, storageDiskToken } from '@framework/storage';
const disk = scope.resolve(STORAGE); // the default disk
const publicDisk = scope.resolve(storageDiskToken('public')); // a named disk

Environment variables:

Terminal window
STORAGE_DEFAULT_DISK=local
# STORAGE_ROOT overrides the local disk's root (absolute path)

The storage section itself:

storage: {
default: 'local',
disks: {
local: { driver: 'local', root: './storage/app/private', visibility: 'private' },
public: {
driver: 'local',
root: './storage/app/public',
visibility: 'public',
publicBaseUrl: '/storage', // or 'https://cdn.example.com/files'
},
},
},

Rules enforced at boot:

  • storage.default must name an existing disk.
  • Public disks must declare publicBaseUrl; private disks never resolve a public URL.
  • root may be absolute or relative to the process cwd and is created on first use.
const disk = scope.resolve(STORAGE);
// Write text or bytes
await disk.put('docs/hello.txt', 'hello storage');
await disk.put('uploads/avatar.bin', new Uint8Array([0, 1, 2]));
// Read content plus metadata
const file = await disk.get('docs/hello.txt');
new TextDecoder().decode(file.content); // 'hello storage'
file.path; // 'docs/hello.txt', exactly as passed in
file.size; // 13
file.contentType; // MIME type derived from the extension, when derivable
file.lastModified; // Date
// Stream large files — a web ReadableStream that also supports for-await
const stream = await disk.stream('exports/big.csv');
for await (const chunk of stream) {
/* … */
}
// Inspect, reorganize, delete
await disk.exists('docs/hello.txt'); // true
await disk.size('docs/hello.txt'); // 13
await disk.copy('docs/hello.txt', 'backup/hello.txt');
await disk.move('backup/hello.txt', 'archive/2026/hello.txt');
await disk.delete('docs/hello.txt'); // deleting a missing path is a no-op
// Bounded recursive listing under a prefix
const paths = await disk.list('docs'); // sorted; default limit 100
const first50 = await disk.list('docs', { limit: 50 });

Listing is bounded by design: entries beyond the limit are dropped and there is no pagination — narrow the prefix instead (limits outside 1–1000 reject with STORAGE_LIST_LIMIT_INVALID). Reads, sizes, moves and copies of missing paths reject with STORAGE_FILE_NOT_FOUND; an unknown prefix lists empty.

Validate size and extension before persisting anything:

import { assertUploadAllowed } from '@framework/storage';
assertUploadAllowed({
size: bytes.byteLength,
maxBytes: 5 * 1024 * 1024,
allowedExtensions: ['png', 'jpg'], // case-insensitive: photo.PNG passes
filename: upload.name,
});
await disk.put(`uploads/${upload.name}`, bytes);

Oversize uploads throw STORAGE_FILE_TOO_LARGE; disallowed extensions throw STORAGE_EXTENSION_FORBIDDEN. Extension rules require the original filename. For upload staging areas, pruneTemp(root, maxAgeMs) removes files older than the cutoff and drops the emptied directories.

A disk’s visibility is a hard boundary, not a per-file hint:

  • publicUrl(path) joins the configured base on a public disk (/storage/img/logo.png) and throws STORAGE_VISIBILITY_FORBIDDEN on a private one — a private file never resolves to a URL.
  • Per-write overrides cannot cross the split: put(…, { visibility: 'public' }) on a private disk rejects with STORAGE_VISIBILITY_FORBIDDEN, and so does the reverse. A private file can never be promoted by a call-site flag.
const publicDisk = scope.resolve(storageDiskToken('public'));
const url = publicDisk.publicUrl('img/logo.png'); // '/storage/img/logo.png'

Every path argument is validated on every operation. Traversal attempts — ../escape.txt, absolute paths (/etc/passwd), URL-encoded dots (%2e%2e/escape), backslashes, double slashes — reject with STORAGE_PATH_FORBIDDEN before any file access, on writes, reads, deletes, lists, moves, copies and publicUrl alike. The same guards are exported so your own tools validate exactly like the disks:

import { resolveInsideRoot, validateStoragePath } from '@framework/storage';
const canonical = validateStoragePath('docs/hello.txt'); // 'docs/hello.txt'
const absolute = resolveInsideRoot(disk.root, 'docs/hello.txt'); // strictly inside the root

Disks declare what they can do; local disks stream but never sign URLs:

disk.capabilities; // { streaming: true, signedUrls: false }
await disk.signedUrl('docs/hello.txt'); // rejects STORAGE_CAPABILITY_UNSUPPORTED

Cloud drivers (s3, r2, minio, azure, gcs, oracle, rustfs, …) answer through the storagekit engine instead. Add the disk to storage.disks and install that driver’s peer SDK — for example @aws-sdk/client-s3 for s3. Only driver and the visibility fields are validated by the framework; the options bag is forwarded verbatim to storagekit:

disks: {
local: { driver: 'local', root: './storage/app/private', visibility: 'private' },
assets: {
driver: 's3',
visibility: 'private',
options: { bucket: 'my-bucket', region: 'eu-central-1' },
},
},

Boot creates every configured disk eagerly, so a missing peer SDK or invalid kit options fail startup with STORAGE_DRIVER_UNAVAILABLE instead of failing on the first file operation.

Point a LocalStorageDisk at a temp directory for round-trip tests — the root is created on demand:

import { join } from 'node:path';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { LocalStorageDisk } from '@framework/storage';
const disk = new LocalStorageDisk({
name: 'test',
root: await mkdtemp(join(tmpdir(), 'disk-')),
visibility: 'private',
});

To test wiring, boot the Application with a storage config section and resolve STORAGE from the container. Boot failures are assertions too: a bad configuration (unknown default disk, empty root, public disk without a base URL) fails with ConfigError, and a cloud driver whose peer SDK is missing fails with STORAGE_DRIVER_UNAVAILABLE.

Related: background jobs that produce artifacts run on the job system — see Queues — and Notifications covers outbound messaging.