@maroonedsoftware/storage 0.1.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.
Files changed (3) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/package.json +77 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Marooned Software
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # @maroonedsoftware/storage
2
+
3
+ Object storage abstraction for ServerKit. Provides a DI-friendly `StorageProvider` interface and ready-made backends for the local filesystem, AWS S3, and Google Cloud Storage.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @maroonedsoftware/storage
9
+ ```
10
+
11
+ The cloud SDKs are **optional peer dependencies** — install only the one(s) you use. The disk backend needs nothing extra.
12
+
13
+ ```bash
14
+ # AWS S3
15
+ pnpm add @aws-sdk/client-s3 @aws-sdk/lib-storage @aws-sdk/s3-request-presigner
16
+
17
+ # Google Cloud Storage
18
+ pnpm add @google-cloud/storage
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ### 1. Bind a provider in your DI container
24
+
25
+ Consumers depend only on the abstract `StorageProvider`; bind whichever backend fits the environment.
26
+
27
+ ```typescript
28
+ import { StorageProvider, DiskStorageProvider, DiskStorageProviderOptions } from '@maroonedsoftware/storage';
29
+
30
+ // Local disk (great for development and tests)
31
+ container.bind(StorageProvider).toConstantValue(new DiskStorageProvider(new DiskStorageProviderOptions({ rootDir: '/var/data' })));
32
+ ```
33
+
34
+ ```typescript
35
+ import { S3Client } from '@aws-sdk/client-s3';
36
+ import { StorageProvider, S3StorageProvider, S3StorageProviderOptions } from '@maroonedsoftware/storage';
37
+
38
+ // AWS S3 — both the client and the options are injectable tokens
39
+ container.bind(S3Client).toConstantValue(new S3Client({ region: 'us-east-1' }));
40
+ container.bind(S3StorageProviderOptions).toConstantValue(new S3StorageProviderOptions({ bucket: 'my-bucket' }));
41
+ container.bind(StorageProvider).to(S3StorageProvider);
42
+ ```
43
+
44
+ ```typescript
45
+ import { Storage } from '@google-cloud/storage';
46
+ import { StorageProvider, GcsStorageProvider, GcsStorageProviderOptions } from '@maroonedsoftware/storage';
47
+
48
+ // Google Cloud Storage
49
+ container.bind(Storage).toConstantValue(new Storage());
50
+ container.bind(GcsStorageProviderOptions).toConstantValue(new GcsStorageProviderOptions({ bucket: 'my-bucket' }));
51
+ container.bind(StorageProvider).to(GcsStorageProvider);
52
+ ```
53
+
54
+ ### 2. Inject and use
55
+
56
+ ```typescript
57
+ import { Injectable } from 'injectkit';
58
+ import { Duration } from 'luxon';
59
+ import { StorageProvider } from '@maroonedsoftware/storage';
60
+
61
+ @Injectable()
62
+ class AvatarService {
63
+ constructor(private readonly storage: StorageProvider) {}
64
+
65
+ async save(userId: string, body: Buffer) {
66
+ await this.storage.write(`users/${userId}/avatar.png`, body, { contentType: 'image/png' });
67
+ }
68
+
69
+ async thumbnail(userId: string) {
70
+ // Partial read — first 1 KiB only
71
+ return await this.storage.read(`users/${userId}/avatar.png`, { range: { start: 0, end: 1023 } });
72
+ }
73
+
74
+ async downloadUrl(userId: string) {
75
+ // Time-limited URL for the client to fetch directly
76
+ return await this.storage.getSignedUrl(`users/${userId}/avatar.png`, {
77
+ operation: 'read',
78
+ expiresIn: Duration.fromObject({ minutes: 15 }),
79
+ });
80
+ }
81
+ }
82
+ ```
83
+
84
+ ## API
85
+
86
+ ### `StorageProvider` (abstract)
87
+
88
+ Base class to extend when implementing a custom backend. Keys are hierarchical, `/`-separated paths (e.g. `users/42/avatar.png`).
89
+
90
+ | Method | Signature | Description |
91
+ |--------|-----------|-------------|
92
+ | `write` | `(key, body: Readable \| Buffer \| string, options?: StorageWriteOptions) => Promise<void>` | Writes `key`, overwriting any existing object. |
93
+ | `read` | `(key, options?: StorageReadOptions) => Promise<Readable>` | Opens a stream, optionally for an inclusive byte range. Throws if absent. |
94
+ | `stat` | `(key) => Promise<StorageObjectMetadata>` | Fetches metadata without reading the body. Throws if absent. |
95
+ | `exists` | `(key) => Promise<boolean>` | `true` / `false` — never throws for absence. |
96
+ | `delete` | `(key) => Promise<void>` | Idempotent — deleting a missing key is a no-op. |
97
+ | `copy` | `(sourceKey, destinationKey) => Promise<void>` | Server-side copy within the backend. Throws if the source is missing. |
98
+ | `move` | `(sourceKey, destinationKey) => Promise<void>` | Move/rename within the backend. Throws if the source is missing. |
99
+ | `list` | `(options?: StorageListOptions) => Promise<StorageListResult>` | One page of objects, optionally filtered by `prefix`, with a `cursor` for the next page. |
100
+ | `getSignedUrl` | `(key, options: SignedUrlOptions) => Promise<string>` | Time-limited URL for direct client read/write. |
101
+
102
+ #### Behaviour contract
103
+
104
+ - `read` / `stat` on a missing key throw `StorageObjectNotFoundError`.
105
+ - A permission failure throws `StorageAccessDeniedError`.
106
+ - `delete` is idempotent; `copy` / `move` overwrite the destination and operate **within one backend** (same bucket/root) — cross-backend transfers are out of scope.
107
+ - `getSignedUrl` throws `StorageOperationNotSupportedError` on backends that can't sign.
108
+
109
+ ### Errors
110
+
111
+ All extend `StorageError`, which extends `ServerkitError` (so `errorMiddleware` renders them):
112
+
113
+ - `StorageObjectNotFoundError` — missing object (carries `key`).
114
+ - `StorageAccessDeniedError` — permission failure (carries `key`).
115
+ - `StorageOperationNotSupportedError` — operation unsupported by the active backend.
116
+
117
+ ## Backends
118
+
119
+ ### `DiskStorageProvider`
120
+
121
+ Local filesystem rooted at a directory. Nested keys create intermediate directories on write; path traversal outside the root is rejected. User metadata is not persisted (the filesystem has no native slot). `getSignedUrl` requires a `publicBaseUrl`:
122
+
123
+ ```typescript
124
+ new DiskStorageProvider(new DiskStorageProviderOptions({ rootDir: '/var/data', publicBaseUrl: 'https://cdn.example.com' }));
125
+ ```
126
+
127
+ > `list` walks the whole tree and `stat`s each match per call — fine for development and modest trees, not for very large directories.
128
+
129
+ ### `S3StorageProvider`
130
+
131
+ AWS S3 (or any S3-compatible endpoint). Streaming writes use `@aws-sdk/lib-storage`'s multipart `Upload`; buffer/string writes use a single `PutObject`. Signed URLs come from `@aws-sdk/s3-request-presigner`.
132
+
133
+ > `copy` (and therefore `move`) uses S3's single-request `CopyObject`, which is capped at 5 GB. Larger objects need a multipart copy, which this provider does not yet implement.
134
+
135
+ ### `GcsStorageProvider`
136
+
137
+ Google Cloud Storage via `@google-cloud/storage`, including native `copy` / `move` and v4 signed URLs.
138
+
139
+ ## Custom implementations
140
+
141
+ Extend `StorageProvider` to add your own backend (in-memory, Azure Blob, etc.) and honour the behaviour contract above:
142
+
143
+ ```typescript
144
+ import { Injectable } from 'injectkit';
145
+ import { Readable } from 'node:stream';
146
+ import { StorageProvider, StorageObjectNotFoundError } from '@maroonedsoftware/storage';
147
+
148
+ @Injectable()
149
+ export class InMemoryStorageProvider extends StorageProvider {
150
+ private readonly store = new Map<string, Buffer>();
151
+
152
+ async write(key: string, body: Readable | Buffer | string) {
153
+ this.store.set(key, body instanceof Readable ? Buffer.concat(await body.toArray()) : Buffer.from(body));
154
+ }
155
+
156
+ async read(key: string) {
157
+ const data = this.store.get(key);
158
+ if (!data) throw new StorageObjectNotFoundError(key);
159
+ return Readable.from(data);
160
+ }
161
+
162
+ // ...stat, exists, delete, copy, move, list, getSignedUrl
163
+ }
164
+ ```
165
+
166
+ ## Configuration
167
+
168
+ The provider options are plain injectable classes, so the package stays decoupled from `@maroonedsoftware/appconfig`. To drive a bucket name from typed config, bridge it at bootstrap rather than importing AppConfig into the provider:
169
+
170
+ ```typescript
171
+ registry.register(S3StorageProviderOptions).useFactory(c => new S3StorageProviderOptions({ bucket: c.get(StorageOptions).value.bucket }));
172
+ ```
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@maroonedsoftware/storage",
3
+ "version": "0.1.0",
4
+ "description": "Storage utilities for ServerKit.",
5
+ "author": {
6
+ "name": "Marooned Software",
7
+ "url": "https://github.com/MaroonedSoftware/serverkit"
8
+ },
9
+ "bugs": {
10
+ "url": "https://github.com/MaroonedSoftware/serverkit/issues"
11
+ },
12
+ "homepage": "https://github.com/MaroonedSoftware/serverkit/packages/storage#readme",
13
+ "keywords": [
14
+ "backend",
15
+ "storage",
16
+ "serverkit",
17
+ "typescript"
18
+ ],
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/MaroonedSoftware/serverkit.git"
22
+ },
23
+ "private": false,
24
+ "type": "module",
25
+ "main": "./dist/index.js",
26
+ "module": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "license": "MIT",
29
+ "files": [
30
+ "dist/**"
31
+ ],
32
+ "dependencies": {
33
+ "injectkit": "^1.5.0",
34
+ "luxon": "^3.7.2",
35
+ "mime-types": "^2.1.35",
36
+ "@maroonedsoftware/errors": "1.7.0"
37
+ },
38
+ "peerDependencies": {
39
+ "@aws-sdk/client-s3": "^3.700.0",
40
+ "@aws-sdk/lib-storage": "^3.700.0",
41
+ "@aws-sdk/s3-request-presigner": "^3.700.0",
42
+ "@google-cloud/storage": "^7.14.0"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "@aws-sdk/client-s3": {
46
+ "optional": true
47
+ },
48
+ "@aws-sdk/lib-storage": {
49
+ "optional": true
50
+ },
51
+ "@aws-sdk/s3-request-presigner": {
52
+ "optional": true
53
+ },
54
+ "@google-cloud/storage": {
55
+ "optional": true
56
+ }
57
+ },
58
+ "devDependencies": {
59
+ "@aws-sdk/client-s3": "^3.700.0",
60
+ "@aws-sdk/lib-storage": "^3.700.0",
61
+ "@aws-sdk/s3-request-presigner": "^3.700.0",
62
+ "@google-cloud/storage": "^7.14.0",
63
+ "@types/luxon": "^3.7.2",
64
+ "@types/mime-types": "^2.1.4",
65
+ "@repo/config-typescript": "0.1.0",
66
+ "@repo/config-eslint": "0.2.1"
67
+ },
68
+ "scripts": {
69
+ "build": "tsup src/index.ts --format esm --sourcemap && tsc --emitDeclarationOnly --declaration",
70
+ "build:ci": "eslint --max-warnings=0 && pnpm run build",
71
+ "lint": "eslint --fix",
72
+ "format": "prettier --write .",
73
+ "test": "vitest run",
74
+ "test:ci": "vitest run --coverage",
75
+ "test:integration": "vitest run --config vitest.integration.config.ts"
76
+ }
77
+ }