From a2e5d321c14d0d9e1f2365a3f338841930a0e949 Mon Sep 17 00:00:00 2001 From: Kauan Guesser Date: Fri, 31 Jul 2026 10:22:22 -0300 Subject: [PATCH] feat: add framework-neutral storage core --- .changeset/framework-neutral-core.md | 8 + README.md | 40 +++- package.json | 23 ++- scripts/test-packed-core-consumer.mjs | 264 ++++++++++++++++++++++++++ src/core/index.ts | 19 ++ src/index.ts | 20 +- src/storage.client.ts | 4 +- 7 files changed, 351 insertions(+), 27 deletions(-) create mode 100644 .changeset/framework-neutral-core.md create mode 100644 scripts/test-packed-core-consumer.mjs create mode 100644 src/core/index.ts diff --git a/.changeset/framework-neutral-core.md b/.changeset/framework-neutral-core.md new file mode 100644 index 0000000..0a220b8 --- /dev/null +++ b/.changeset/framework-neutral-core.md @@ -0,0 +1,8 @@ +--- +'@nestm/storage': minor +--- + +Add a framework-neutral `@nestm/storage/core` entry point for the storage +client, driver contract, errors, operation types, and upload controls. NestJS +peers are now optional so non-Nest consumers can install and use the core API +without pulling in the framework. diff --git a/README.md b/README.md index 1d7bde5..49deaee 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # @nestm/storage -NestJS 12 storage integration with named stores, explicit streaming I/O, -cross-store workflows, and an optional guarded HTTP gateway. +Framework-neutral storage clients with NestJS 12 integration, named stores, +explicit streaming I/O, cross-store workflows, and an optional guarded HTTP +gateway. The package uses [`files-sdk`](https://github.com/haydenbleasel/files-sdk) as its provider engine, but owns the API injected into Nest applications. Provider @@ -13,9 +14,14 @@ SDK types, errors, and `files.raw` do not leak through the root package. ## Requirements - Node.js 22.12 or newer -- NestJS `12.0.0-alpha.5` or newer in the Nest 12 prerelease line - ESM +The framework-neutral `@nestm/storage/core` entry point does not require +NestJS. The root entry point and HTTP gateway additionally require NestJS +`12.0.0-alpha.5` or newer in the Nest 12 prerelease line, `reflect-metadata`, +and RxJS. Those framework peers are optional at installation time so core-only +consumers do not download NestJS. + ## Install ```sh @@ -46,6 +52,34 @@ its strict resolver. If npm reports an `ERESOLVE` error for Nest's own peers, install with `npm install --legacy-peer-deps`; pnpm works with the repository's checked-in peer-version policy. +## Framework-neutral core + +Import storage primitives from `@nestm/storage/core` in workers, scripts, and +applications that do not use NestJS: + +```ts +import { + StorageClient, + type StorageDriver, + type StorageUploadOptions, +} from '@nestm/storage/core'; + +declare const driver: StorageDriver; + +const media = new StorageClient('media', driver); + +await media.upload('avatars/user.png', image, { + contentType: 'image/png', +} satisfies StorageUploadOptions); + +await media.onApplicationShutdown(); +``` + +The core entry point exports `StorageClient`, the `StorageDriver` contract, +storage errors and operation types, and `StorageUploadControl`. It has no NestJS +runtime or declaration imports. Provider adapters remain available through +`@nestm/storage/files-sdk`. + ## Configure named stores Create provider adapters with `files-sdk`, wrap them through the explicit diff --git a/package.json b/package.json index 820a92c..81f34e7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@nestm/storage", "version": "0.1.0-alpha.1", - "description": "NestJS 12 storage integration with named stores, streaming I/O, and an optional guarded HTTP gateway.", + "description": "Framework-neutral storage clients with NestJS 12 integration, named stores, streaming I/O, and an optional guarded HTTP gateway.", "license": "MIT", "author": "Kauan Guesser", "type": "module", @@ -19,6 +19,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./core": { + "types": "./dist/core/index.d.ts", + "import": "./dist/core/index.js" + }, "./files-sdk": { "types": "./dist/files-sdk/index.d.ts", "import": "./dist/files-sdk/index.js" @@ -74,8 +78,9 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:e2e": "vitest run --config ./vitest.config.e2e.ts", + "test:packed": "node scripts/test-packed-core-consumer.mjs", "check": "pnpm run lint && pnpm run format:check && pnpm run typecheck", - "verify:pack": "pnpm run build && publint --strict", + "verify:pack": "pnpm run build && publint --strict && pnpm run test:packed", "prepack": "pnpm run build", "changeset": "changeset", "release": "node scripts/publish.mjs" @@ -89,6 +94,20 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, + "peerDependenciesMeta": { + "@nestjs/common": { + "optional": true + }, + "@nestjs/core": { + "optional": true + }, + "reflect-metadata": { + "optional": true + }, + "rxjs": { + "optional": true + } + }, "devDependencies": { "@changesets/cli": "2.31.1", "@nestjs/common": "12.0.0-alpha.5", diff --git a/scripts/test-packed-core-consumer.mjs b/scripts/test-packed-core-consumer.mjs new file mode 100644 index 0000000..da11cd0 --- /dev/null +++ b/scripts/test-packed-core-consumer.mjs @@ -0,0 +1,264 @@ +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const nodeMajor = Number.parseInt( + process.versions.node.split('.')[0] ?? '', + 10, +); + +if (!Number.isSafeInteger(nodeMajor) || nodeMajor < 24) { + throw new Error( + 'The packed core consumer test requires Node.js 24 or newer.', + ); +} + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const temporaryRoot = mkdtempSync(join(tmpdir(), 'nestm-storage-core-')); +const consumerRoot = join(temporaryRoot, 'consumer'); +const tarballPath = join(temporaryRoot, 'nestm-storage.tgz'); +const rootPackage = JSON.parse( + readFileSync(join(projectRoot, 'package.json'), 'utf8'), +); + +try { + run('pnpm', ['pack', '--out', tarballPath], projectRoot); + + mkdirSync(join(consumerRoot, 'src'), { recursive: true }); + writeFileSync( + join(consumerRoot, 'package.json'), + `${JSON.stringify( + { + name: '@nestm/storage-core-consumer', + version: '0.0.0', + private: true, + type: 'module', + dependencies: { + '@nestm/storage': `file:${tarballPath}`, + }, + devDependencies: { + '@types/node': rootPackage.devDependencies['@types/node'], + typescript: rootPackage.devDependencies.typescript, + }, + }, + null, + 2, + )}\n`, + ); + writeFileSync( + join(consumerRoot, 'tsconfig.json'), + `${JSON.stringify( + { + compilerOptions: { + exactOptionalPropertyTypes: true, + isolatedModules: true, + lib: ['ES2023', 'DOM', 'DOM.Iterable'], + module: 'NodeNext', + moduleResolution: 'NodeNext', + noUncheckedIndexedAccess: true, + outDir: 'dist', + rootDir: 'src', + skipLibCheck: false, + strict: true, + target: 'ES2023', + types: ['node'], + verbatimModuleSyntax: true, + }, + include: ['src/**/*.ts'], + }, + null, + 2, + )}\n`, + ); + writeFileSync(join(consumerRoot, 'src', 'smoke.ts'), getConsumerSource()); + + run( + 'npm', + ['install', '--ignore-scripts', '--no-audit', '--no-fund'], + consumerRoot, + ); + + if (existsSync(join(consumerRoot, 'node_modules', '@nestjs'))) { + throw new Error('The core-only consumer unexpectedly installed NestJS.'); + } + + run('npm', ['exec', '--', 'tsc', '-p', '.'], consumerRoot); + run(process.execPath, ['dist/smoke.js'], consumerRoot); +} finally { + rmSync(temporaryRoot, { force: true, recursive: true }); +} + +function run(command, arguments_, cwd) { + execFileSync(command, arguments_, { + cwd, + env: process.env, + stdio: 'inherit', + }); +} + +function getConsumerSource() { + return `import assert from 'node:assert/strict'; + +import { + DEFAULT_BUFFER_LIMIT, + StorageClient, + StorageErrorCode, + StorageUploadControl, + type StorageCapabilities, + type StorageDriver, + type StorageObjectMetadata, +} from '@nestm/storage/core'; + +const capabilities = { + cacheControl: true, + delimiter: true, + metadata: true, + nativeUploadProgress: false, + rangeRead: false, + resumableUpload: false, + serverSideCopy: true, + signedDownload: { supported: true }, + signedUpload: true, +} satisfies StorageCapabilities; +const objects = new Map(); +let closeCalls = 0; + +function metadata(key: string): StorageObjectMetadata { + const stored = objects.get(key); + if (stored === undefined) { + throw new Error(\`Missing object: \${key}\`); + } + return { + contentType: stored.contentType, + key, + name: key.split('/').at(-1) ?? key, + size: stored.body.byteLength, + }; +} + +const driver = { + capabilities, + name: 'packed-memory', + async upload(key, body, options) { + if (typeof body !== 'string') { + throw new TypeError('The smoke driver accepts string bodies only.'); + } + const stored = { + body: new TextEncoder().encode(body), + contentType: options?.contentType ?? 'application/octet-stream', + }; + objects.set(key, stored); + return { + contentType: stored.contentType, + key, + size: stored.body.byteLength, + }; + }, + async download(key) { + const object = metadata(key); + const stored = objects.get(key); + assert.ok(stored); + return { + ...object, + body: new ReadableStream({ + start(controller) { + controller.enqueue(stored.body); + controller.close(); + }, + }), + }; + }, + async head(key) { + return metadata(key); + }, + async exists(key) { + return objects.has(key); + }, + async delete(key) { + objects.delete(key); + }, + async copy(sourceKey, destinationKey) { + const source = objects.get(sourceKey); + if (source === undefined) { + throw new Error(\`Missing object: \${sourceKey}\`); + } + objects.set(destinationKey, { + body: source.body.slice(), + contentType: source.contentType, + }); + }, + async move(sourceKey, destinationKey) { + const source = objects.get(sourceKey); + if (source === undefined) { + throw new Error(\`Missing object: \${sourceKey}\`); + } + objects.set(destinationKey, source); + objects.delete(sourceKey); + }, + async list(options) { + return { + items: [...objects.keys()] + .filter((key) => key.startsWith(options?.prefix ?? '')) + .map(metadata), + }; + }, + async *search(pattern, options) { + const expression = + pattern instanceof RegExp ? pattern : new RegExp(pattern.replace('*', '.*')); + for (const key of objects.keys()) { + if (!key.startsWith(options?.prefix ?? '')) { + continue; + } + const object = metadata(key); + if (expression.test(object.key)) { + yield object; + } + } + }, + async signDownload(key) { + return \`https://storage.invalid/download/\${encodeURIComponent(key)}\`; + }, + async signUpload(key) { + return { + method: 'PUT', + url: \`https://storage.invalid/upload/\${encodeURIComponent(key)}\`, + }; + }, + async close() { + closeCalls += 1; + }, +} satisfies StorageDriver; + +let nestResolved = true; +try { + import.meta.resolve('@nestjs/common'); +} catch { + nestResolved = false; +} + +assert.equal(nestResolved, false); +assert.equal(DEFAULT_BUFFER_LIMIT, 10 * 1024 * 1024); +assert.equal(StorageErrorCode.NOT_FOUND, 'NOT_FOUND'); +assert.equal(new StorageUploadControl().status, 'idle'); + +const client = new StorageClient('packed', driver); +const uploaded = await client.upload('hello.txt', 'hello core', { + contentType: 'text/plain', +}); +assert.equal(uploaded.key, 'hello.txt'); +assert.equal(await client.downloadText('hello.txt'), 'hello core'); + +await client.onApplicationShutdown(); +await client.onApplicationShutdown(); +assert.equal(closeCalls, 1); +`; +} diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..61b7d7e --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,19 @@ +export { + DEFAULT_BUFFER_LIMIT, + StorageClient, + type StorageFileHandle, +} from '../storage.client.js'; +export type { StorageDriver } from '../storage.driver.js'; +export { + StorageError, + StorageErrorCode, + isStorageError, + normalizeStorageError, + type StorageErrorOptions, +} from '../storage.error.js'; +export { + StorageUploadControl, + type StorageResumableToken, + type StorageUploadStatus, +} from '../storage-upload-control.js'; +export type * from '../storage.types.js'; diff --git a/src/index.ts b/src/index.ts index 6ac5470..d5b6d81 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,4 @@ -export { - DEFAULT_BUFFER_LIMIT, - StorageClient, - type StorageFileHandle, -} from './storage.client.js'; -export type { StorageDriver } from './storage.driver.js'; -export { - StorageError, - StorageErrorCode, - isStorageError, - normalizeStorageError, - type StorageErrorOptions, -} from './storage.error.js'; +export * from './core/index.js'; export { InjectStorage } from './inject-storage.decorator.js'; export { StorageModule } from './storage.module.js'; export type { @@ -31,9 +19,3 @@ export { STORAGE, getStorageToken, } from './storage.tokens.js'; -export { - StorageUploadControl, - type StorageResumableToken, - type StorageUploadStatus, -} from './storage-upload-control.js'; -export type * from './storage.types.js'; diff --git a/src/storage.client.ts b/src/storage.client.ts index b55d2ba..748704d 100644 --- a/src/storage.client.ts +++ b/src/storage.client.ts @@ -1,5 +1,3 @@ -import type { OnApplicationShutdown } from '@nestjs/common'; - import { settleMany } from './internal/settle-many.js'; import { StorageError, @@ -227,7 +225,7 @@ export interface StorageFileHandle { ): Promise; } -export class StorageClient implements OnApplicationShutdown { +export class StorageClient { readonly #driver: StorageDriver; readonly #plugins: readonly StoragePlugin[]; #closed = false;