@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.
- package/LICENSE +21 -0
- package/README.md +172 -0
- 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
|
+
}
|