Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/framework-neutral-core.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 37 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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",
Expand Down
264 changes: 264 additions & 0 deletions scripts/test-packed-core-consumer.mjs
Original file line number Diff line number Diff line change
@@ -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<string, { body: Uint8Array; contentType: string }>();
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<Uint8Array>({
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);
`;
}
Loading
Loading