@codercito/object-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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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,285 @@
1
+ # @codercito/object-storage
2
+
3
+ Abstracción tipada para object storage compatible con S3 (Amazon S3, Cloudflare R2, MinIO, DigitalOcean Spaces, Backblaze B2) con helpers para construir URLs públicas via CDN.
4
+
5
+ - ✅ Subida directa, `exists`, y presigned POST listos para uso en backend.
6
+ - ✅ Tipado estricto con TypeScript, sin `any`.
7
+ - ✅ Cero dependencias propias. El AWS SDK es `peerDependency` (no se duplica).
8
+ - ✅ Sin logs internos: tú decides cómo loggear vía callback `onError`.
9
+ - ✅ Diseñado para abrirse a otros providers manteniendo la misma interfaz.
10
+
11
+ ## Instalación
12
+
13
+ ```bash
14
+ npm install @codercito/object-storage @aws-sdk/client-s3 @aws-sdk/s3-presigned-post
15
+ ```
16
+
17
+ Los paquetes `@aws-sdk/*` son `peerDependencies`: la librería los espera instalados pero no los incluye en su bundle.
18
+
19
+ ## Uso básico
20
+
21
+ ```ts
22
+ import { S3StorageService, CdnFileUrlService } from '@codercito/object-storage';
23
+
24
+ // Configura el servicio - el consumidor decide de dónde vienen los valores
25
+ const storage = new S3StorageService({
26
+ bucket: process.env.S3_BUCKET!,
27
+ region: process.env.S3_REGION!,
28
+ accessKeyId: process.env.S3_ACCESS_KEY_ID!,
29
+ secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
30
+ });
31
+
32
+ const urls = new CdnFileUrlService(process.env.CDN_PUBLIC_URL!);
33
+
34
+ // Subir un archivo
35
+ await storage.put('avatars/user-123.jpg', buffer, {
36
+ contentType: 'image/jpeg',
37
+ cacheControl: 'public, max-age=31536000',
38
+ });
39
+
40
+ // Verificar existencia
41
+ const exists = await storage.exists('avatars/user-123.jpg');
42
+
43
+ // Construir URL pública
44
+ const url = urls.getPublicUrl('avatars/user-123.jpg');
45
+ // → https://cdn.miapp.com/avatars/user-123.jpg
46
+ ```
47
+
48
+ ## Subida directa desde el cliente (presigned POST)
49
+
50
+ Genera credenciales en el backend para que el cliente suba directamente al storage sin pasar bytes por tu servidor:
51
+
52
+ ```ts
53
+ // Backend (ej. API route de Next.js)
54
+ const presigned = await storage.presignedPost({
55
+ key: `uploads/${userId}/${filename}`,
56
+ contentType: 'image/jpeg',
57
+ maxSizeBytes: 5 * 1024 * 1024, // 5 MB
58
+ expiresIn: 600, // 10 minutos
59
+ });
60
+
61
+ // Devuélvelo al cliente
62
+ return Response.json(presigned);
63
+ ```
64
+
65
+ ```ts
66
+ // Cliente (browser)
67
+ const formData = new FormData();
68
+ Object.entries(presigned.fields).forEach(([k, v]) => formData.append(k, v));
69
+ formData.append('file', file);
70
+
71
+ await fetch(presigned.url, { method: 'POST', body: formData });
72
+ ```
73
+
74
+ ## Providers compatibles
75
+
76
+ `S3StorageService` funciona con cualquier API S3-compatible cambiando el `endpoint`:
77
+
78
+ ### Cloudflare R2
79
+
80
+ ```ts
81
+ const storage = new S3StorageService({
82
+ bucket: 'my-bucket',
83
+ region: 'auto',
84
+ endpoint: 'https://ACCOUNT_ID.r2.cloudflarestorage.com',
85
+ accessKeyId: process.env.R2_ACCESS_KEY_ID!,
86
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
87
+ });
88
+ ```
89
+
90
+ ### MinIO (local development)
91
+
92
+ ```ts
93
+ const storage = new S3StorageService({
94
+ bucket: 'my-bucket',
95
+ region: 'us-east-1',
96
+ endpoint: 'http://localhost:9000',
97
+ accessKeyId: 'minioadmin',
98
+ secretAccessKey: 'minioadmin',
99
+ forcePathStyle: true, // necesario para MinIO
100
+ });
101
+ ```
102
+
103
+ ### DigitalOcean Spaces
104
+
105
+ ```ts
106
+ const storage = new S3StorageService({
107
+ bucket: 'my-space',
108
+ region: 'nyc3',
109
+ endpoint: 'https://nyc3.digitaloceanspaces.com',
110
+ accessKeyId: process.env.DO_SPACES_KEY!,
111
+ secretAccessKey: process.env.DO_SPACES_SECRET!,
112
+ });
113
+ ```
114
+
115
+ ## Manejo de errores
116
+
117
+ La librería expone tres clases de error:
118
+
119
+ ```ts
120
+ import {
121
+ StorageUploadError,
122
+ StoragePresignError,
123
+ StorageConfigError,
124
+ } from '@codercito/object-storage';
125
+
126
+ try {
127
+ await storage.put('a.jpg', buffer, { contentType: 'image/jpeg' });
128
+ } catch (e) {
129
+ if (e instanceof StorageUploadError) {
130
+ console.error('Falló la subida', e.key, e.cause);
131
+ }
132
+ }
133
+ ```
134
+
135
+ `StorageConfigError` se lanza al construir el servicio si falta algún campo obligatorio.
136
+
137
+ ### Logging y observabilidad
138
+
139
+ La librería **no escribe a `console`** por sí sola. Si quieres loggear los errores internos (antes de que se lancen), inyecta un callback `onError`:
140
+
141
+ ```ts
142
+ import pino from 'pino';
143
+ const logger = pino();
144
+
145
+ const storage = new S3StorageService({
146
+ bucket: '...', region: '...',
147
+ accessKeyId: '...', secretAccessKey: '...',
148
+ onError: (event) => {
149
+ logger.error({
150
+ operation: event.operation,
151
+ key: event.key,
152
+ cause: event.cause,
153
+ }, 'Storage error');
154
+ },
155
+ });
156
+ ```
157
+
158
+ Esto te permite integrar la librería con Pino, Winston, Sentry, Datadog, etc. sin que la librería decida por ti.
159
+
160
+ ## Validar variables de entorno
161
+
162
+ La librería no incluye un validador de env vars: cada proyecto tiene sus propios nombres y convenciones. Aquí un ejemplo con [zod](https://zod.dev) que puedes copiar a tu proyecto:
163
+
164
+ ```ts
165
+ // src/env.ts
166
+ import { z } from 'zod';
167
+
168
+ const schema = z.object({
169
+ S3_BUCKET: z.string().min(1),
170
+ S3_REGION: z.string().default('auto'),
171
+ S3_ENDPOINT: z.string().url().optional(),
172
+ S3_ACCESS_KEY_ID: z.string().min(1),
173
+ S3_SECRET_ACCESS_KEY: z.string().min(1),
174
+ CDN_PUBLIC_URL: z.string().url(),
175
+ });
176
+
177
+ const parsed = schema.safeParse(process.env);
178
+ if (!parsed.success) {
179
+ console.error('Invalid env', parsed.error.flatten().fieldErrors);
180
+ throw new Error('Invalid environment variables');
181
+ }
182
+ export const env = parsed.data;
183
+ ```
184
+
185
+ Y úsalo:
186
+
187
+ ```ts
188
+ import { env } from './env';
189
+ import { S3StorageService } from '@codercito/object-storage';
190
+
191
+ export const storage = new S3StorageService({
192
+ bucket: env.S3_BUCKET,
193
+ region: env.S3_REGION,
194
+ endpoint: env.S3_ENDPOINT,
195
+ accessKeyId: env.S3_ACCESS_KEY_ID,
196
+ secretAccessKey: env.S3_SECRET_ACCESS_KEY,
197
+ });
198
+ ```
199
+
200
+ ## API
201
+
202
+ ### `S3StorageService`
203
+
204
+ ```ts
205
+ new S3StorageService(config: S3StorageConfig)
206
+ ```
207
+
208
+ | Campo | Tipo | Requerido | Descripción |
209
+ |-------|------|-----------|-------------|
210
+ | `bucket` | `string` | ✅ | Nombre del bucket. |
211
+ | `region` | `string` | ✅ | Región AWS o `'auto'` para R2. |
212
+ | `accessKeyId` | `string` | ✅ | Credencial. |
213
+ | `secretAccessKey` | `string` | ✅ | Credencial. |
214
+ | `endpoint` | `string` | — | Endpoint custom (R2, MinIO, etc.). |
215
+ | `forcePathStyle` | `boolean` | — | Necesario para MinIO. |
216
+ | `onError` | `(event) => void` | — | Callback de errores internos. |
217
+
218
+ #### `put(key, body, options): Promise<void>`
219
+
220
+ Sube un objeto. La key se sanea automáticamente.
221
+
222
+ `options` extiende `PutObjectCommandInput` del AWS SDK; los campos `contentType` y `cacheControl` se traducen a sus equivalentes (`ContentType`, `CacheControl`).
223
+
224
+ #### `exists(key): Promise<boolean>`
225
+
226
+ Devuelve `true` si el objeto existe. `false` en cualquier otro caso (incluye errores de red).
227
+
228
+ #### `presignedPost(request): Promise<PresignedPostResult>`
229
+
230
+ Genera credenciales para subida directa desde el cliente.
231
+
232
+ | Campo | Default | Descripción |
233
+ |-------|---------|-------------|
234
+ | `key` | — | Path destino. |
235
+ | `contentType` | — | MIME esperado, se valida en el POST. |
236
+ | `maxSizeBytes` | `10485760` (10 MB) | Tamaño máximo permitido. |
237
+ | `expiresIn` | `600` (10 min) | Validez en segundos. |
238
+
239
+ ### `CdnFileUrlService`
240
+
241
+ ```ts
242
+ new CdnFileUrlService(baseUrl: string)
243
+ ```
244
+
245
+ Construye URLs públicas concatenando `baseUrl` con la key. **No re-sanitiza la key** — debes pasar la misma con la que subiste el objeto.
246
+
247
+ ```ts
248
+ const urls = new CdnFileUrlService('https://cdn.miapp.com');
249
+ urls.getPublicUrl('avatars/user.jpg');
250
+ // → 'https://cdn.miapp.com/avatars/user.jpg'
251
+ ```
252
+
253
+ ### `sanitizeKey(key): string`
254
+
255
+ Normaliza una key:
256
+ - Quita caracteres no permitidos (deja `[a-zA-Z0-9/_\-.]`).
257
+ - Colapsa slashes duplicados.
258
+ - Quita slashes al inicio y final.
259
+
260
+ ```ts
261
+ sanitizeKey('/avatars//user 123!.jpg');
262
+ // → 'avatars/user123.jpg'
263
+ ```
264
+
265
+ ## Tipos
266
+
267
+ Todos los tipos están exportados:
268
+
269
+ ```ts
270
+ import type {
271
+ IStorageService,
272
+ IFileUrlService,
273
+ S3StorageConfig,
274
+ PutObjectOptions,
275
+ PresignedPostRequest,
276
+ PresignedPostResult,
277
+ StorageErrorEvent,
278
+ } from '@codercito/object-storage';
279
+ ```
280
+
281
+ `IStorageService` es la interfaz principal; útil para inyectar implementaciones distintas (mocks en tests, etc.).
282
+
283
+ ## Licencia
284
+
285
+ MIT
@@ -0,0 +1,170 @@
1
+ import { PutObjectCommandInput } from '@aws-sdk/client-s3';
2
+
3
+ /**
4
+ * Opciones para subir un objeto. Extiende `PutObjectCommandInput` del AWS SDK
5
+ * para permitir cualquier parámetro adicional.
6
+ */
7
+ interface PutObjectOptions extends Partial<PutObjectCommandInput> {
8
+ contentType: string;
9
+ cacheControl?: string;
10
+ }
11
+ /**
12
+ * Parámetros para generar un presigned POST.
13
+ */
14
+ interface PresignedPostRequest {
15
+ /** Key (path) donde se subirá el archivo. */
16
+ key: string;
17
+ /** MIME type esperado. Se valida en la condición del POST. */
18
+ contentType: string;
19
+ /**
20
+ * Tamaño máximo permitido en bytes.
21
+ * @default 10485760 (10 MB)
22
+ */
23
+ maxSizeBytes?: number;
24
+ /**
25
+ * Tiempo de validez del presigned POST, en segundos.
26
+ * @default 600 (10 minutos)
27
+ */
28
+ expiresIn?: number;
29
+ }
30
+ /**
31
+ * Resultado de un presigned POST. Se envía al cliente para que suba el archivo
32
+ * directamente al storage sin pasar por el backend.
33
+ */
34
+ interface PresignedPostResult {
35
+ url: string;
36
+ fields: Record<string, string>;
37
+ key: string;
38
+ }
39
+ /**
40
+ * Evento que recibe el callback `onError` cuando algo falla internamente
41
+ * en una operación de storage. La librería no escribe a `console` por su cuenta;
42
+ * el consumidor decide cómo loggear.
43
+ */
44
+ interface StorageErrorEvent {
45
+ /** Identificador corto del tipo de operación que falló. */
46
+ operation: 'upload_failed' | 'presign_failed';
47
+ /** Key del objeto involucrado. */
48
+ key: string;
49
+ /** Causa original lanzada por el SDK o la red. */
50
+ cause: unknown;
51
+ }
52
+ /**
53
+ * Configuración para `S3StorageService`. Compatible con S3, R2, MinIO,
54
+ * DigitalOcean Spaces y cualquier API S3-compatible.
55
+ */
56
+ interface S3StorageConfig {
57
+ bucket: string;
58
+ region: string;
59
+ accessKeyId: string;
60
+ secretAccessKey: string;
61
+ /**
62
+ * Endpoint custom (solo necesario para providers no-AWS como R2 o MinIO).
63
+ */
64
+ endpoint?: string;
65
+ /**
66
+ * Si `true`, fuerza path-style URLs (`endpoint/bucket/key` en vez de
67
+ * `bucket.endpoint/key`). Necesario para MinIO y algunos proxies.
68
+ * @default false
69
+ */
70
+ forcePathStyle?: boolean;
71
+ /**
72
+ * Callback opcional que se invoca cuando ocurre un error interno.
73
+ * Permite al consumidor loggear o reportar a su sistema preferido
74
+ * (Pino, Sentry, Datadog, etc.) sin que la librería decida por él.
75
+ */
76
+ onError?: (event: StorageErrorEvent) => void;
77
+ }
78
+ /**
79
+ * Interface principal del servicio de storage. Permite agregar otros providers
80
+ * en el futuro (R2 nativo, GCS, filesystem local) sin romper a los consumidores.
81
+ */
82
+ interface IStorageService {
83
+ /** Sube un objeto al storage. */
84
+ put(key: string, body: string | Buffer, options: PutObjectOptions): Promise<void>;
85
+ /** Devuelve `true` si el objeto existe en el storage. */
86
+ exists(key: string): Promise<boolean>;
87
+ /** Genera un presigned POST para subida directa desde el cliente. */
88
+ presignedPost(request: PresignedPostRequest): Promise<PresignedPostResult>;
89
+ }
90
+
91
+ /**
92
+ * Implementación de `IStorageService` para Amazon S3 y APIs S3-compatible
93
+ * (Cloudflare R2, MinIO, DigitalOcean Spaces, Backblaze B2).
94
+ */
95
+ declare class S3StorageService implements IStorageService {
96
+ private readonly client;
97
+ private readonly bucket;
98
+ private readonly onError?;
99
+ constructor(config: S3StorageConfig);
100
+ put(key: string, body: string | Buffer, options: PutObjectOptions): Promise<void>;
101
+ exists(key: string): Promise<boolean>;
102
+ presignedPost({ key, contentType, maxSizeBytes, expiresIn, }: PresignedPostRequest): Promise<PresignedPostResult>;
103
+ }
104
+
105
+ /**
106
+ * Servicio para construir URLs públicas a archivos almacenados.
107
+ * Útil cuando expones los objetos vía un CDN o el endpoint público del bucket.
108
+ */
109
+ interface IFileUrlService {
110
+ getPublicUrl(key: string): string;
111
+ }
112
+ /**
113
+ * Implementación basada en una base URL fija (típicamente un CDN como
114
+ * Cloudflare, CloudFront, o el endpoint público de R2/S3).
115
+ *
116
+ * No re-saniza la key: asume que se pasa la misma key con la que se subió
117
+ * el objeto. Sanitizar dos veces puede generar URLs que no apuntan al objeto
118
+ * real si la sanitización modifica la key.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * const urls = new CdnFileUrlService('https://cdn.miapp.com');
123
+ * urls.getPublicUrl('avatars/user-123.jpg');
124
+ * // → 'https://cdn.miapp.com/avatars/user-123.jpg'
125
+ * ```
126
+ */
127
+ declare class CdnFileUrlService implements IFileUrlService {
128
+ private readonly baseUrl;
129
+ constructor(baseUrl: string);
130
+ getPublicUrl(key: string): string;
131
+ }
132
+
133
+ /**
134
+ * Normaliza una key para object storage:
135
+ * - Quita caracteres no permitidos (deja alfanuméricos, `/`, `_`, `-`, `.`).
136
+ * - Colapsa slashes duplicados (`a//b` → `a/b`).
137
+ * - Quita slashes al inicio y al final.
138
+ *
139
+ * Útil para sanear input del usuario antes de subir archivos.
140
+ *
141
+ * @example
142
+ * ```ts
143
+ * sanitizeKey('/avatars//user 123!.jpg') // 'avatars/user123.jpg'
144
+ * ```
145
+ */
146
+ declare function sanitizeKey(key: string): string;
147
+
148
+ /**
149
+ * Error lanzado cuando una subida a storage falla.
150
+ * La causa original está disponible en `error.cause`.
151
+ */
152
+ declare class StorageUploadError extends Error {
153
+ readonly key: string;
154
+ constructor(key: string, cause: unknown);
155
+ }
156
+ /**
157
+ * Error lanzado cuando la generación de un presigned POST falla.
158
+ */
159
+ declare class StoragePresignError extends Error {
160
+ readonly key: string;
161
+ constructor(key: string, cause: unknown);
162
+ }
163
+ /**
164
+ * Error lanzado al construir un `S3StorageService` con configuración inválida.
165
+ */
166
+ declare class StorageConfigError extends Error {
167
+ constructor(message: string);
168
+ }
169
+
170
+ export { CdnFileUrlService, type IFileUrlService, type IStorageService, type PresignedPostRequest, type PresignedPostResult, type PutObjectOptions, type S3StorageConfig, S3StorageService, StorageConfigError, type StorageErrorEvent, StoragePresignError, StorageUploadError, sanitizeKey };
package/dist/index.js ADDED
@@ -0,0 +1,131 @@
1
+ import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
2
+ import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
3
+
4
+ // src/s3-storage.ts
5
+
6
+ // src/errors.ts
7
+ var StorageUploadError = class extends Error {
8
+ constructor(key, cause) {
9
+ super(`Failed to upload object: ${key}`, { cause });
10
+ this.key = key;
11
+ this.name = "StorageUploadError";
12
+ }
13
+ key;
14
+ };
15
+ var StoragePresignError = class extends Error {
16
+ constructor(key, cause) {
17
+ super(`Failed to generate presigned POST for: ${key}`, { cause });
18
+ this.key = key;
19
+ this.name = "StoragePresignError";
20
+ }
21
+ key;
22
+ };
23
+ var StorageConfigError = class extends Error {
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = "StorageConfigError";
27
+ }
28
+ };
29
+
30
+ // src/utils.ts
31
+ function sanitizeKey(key) {
32
+ return key.replace(/[^a-zA-Z0-9/_\-.]/g, "").replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
33
+ }
34
+
35
+ // src/s3-storage.ts
36
+ var DEFAULT_MAX_SIZE_BYTES = 10 * 1024 * 1024;
37
+ var DEFAULT_EXPIRES_IN_SECONDS = 600;
38
+ var S3StorageService = class {
39
+ client;
40
+ bucket;
41
+ onError;
42
+ constructor(config) {
43
+ if (!config.bucket) throw new StorageConfigError("Missing bucket");
44
+ if (!config.region) throw new StorageConfigError("Missing region");
45
+ if (!config.accessKeyId) throw new StorageConfigError("Missing accessKeyId");
46
+ if (!config.secretAccessKey) throw new StorageConfigError("Missing secretAccessKey");
47
+ this.bucket = config.bucket;
48
+ this.onError = config.onError;
49
+ this.client = new S3Client({
50
+ region: config.region,
51
+ endpoint: config.endpoint,
52
+ forcePathStyle: config.forcePathStyle,
53
+ credentials: {
54
+ accessKeyId: config.accessKeyId,
55
+ secretAccessKey: config.secretAccessKey
56
+ }
57
+ });
58
+ }
59
+ async put(key, body, options) {
60
+ const safeKey = sanitizeKey(key);
61
+ try {
62
+ const { contentType, cacheControl, ...rest } = options;
63
+ await this.client.send(
64
+ new PutObjectCommand({
65
+ ...rest,
66
+ Bucket: this.bucket,
67
+ Key: safeKey,
68
+ Body: body,
69
+ ContentType: contentType,
70
+ CacheControl: cacheControl
71
+ })
72
+ );
73
+ } catch (cause) {
74
+ this.onError?.({ operation: "upload_failed", key: safeKey, cause });
75
+ throw new StorageUploadError(safeKey, cause);
76
+ }
77
+ }
78
+ async exists(key) {
79
+ const safeKey = sanitizeKey(key);
80
+ try {
81
+ await this.client.send(
82
+ new HeadObjectCommand({ Bucket: this.bucket, Key: safeKey })
83
+ );
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+ async presignedPost({
90
+ key,
91
+ contentType,
92
+ maxSizeBytes = DEFAULT_MAX_SIZE_BYTES,
93
+ expiresIn = DEFAULT_EXPIRES_IN_SECONDS
94
+ }) {
95
+ const safeKey = sanitizeKey(key);
96
+ try {
97
+ const { url, fields } = await createPresignedPost(this.client, {
98
+ Bucket: this.bucket,
99
+ Key: safeKey,
100
+ Conditions: [
101
+ ["content-length-range", 0, maxSizeBytes],
102
+ ["eq", "$Content-Type", contentType]
103
+ ],
104
+ Fields: {
105
+ "Content-Type": contentType
106
+ },
107
+ Expires: expiresIn
108
+ });
109
+ return { url, fields, key: safeKey };
110
+ } catch (cause) {
111
+ this.onError?.({ operation: "presign_failed", key: safeKey, cause });
112
+ throw new StoragePresignError(safeKey, cause);
113
+ }
114
+ }
115
+ };
116
+
117
+ // src/file-url.ts
118
+ var CdnFileUrlService = class {
119
+ baseUrl;
120
+ constructor(baseUrl) {
121
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
122
+ }
123
+ getPublicUrl(key) {
124
+ const cleanKey = key.replace(/^\/+/, "");
125
+ return `${this.baseUrl}/${cleanKey}`;
126
+ }
127
+ };
128
+
129
+ export { CdnFileUrlService, S3StorageService, StorageConfigError, StoragePresignError, StorageUploadError, sanitizeKey };
130
+ //# sourceMappingURL=index.js.map
131
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts","../src/utils.ts","../src/s3-storage.ts","../src/file-url.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Error lanzado cuando una subida a storage falla.\n * La causa original está disponible en `error.cause`.\n */\nexport class StorageUploadError extends Error {\n constructor(public readonly key: string, cause: unknown) {\n super(`Failed to upload object: ${key}`, { cause });\n this.name = 'StorageUploadError';\n }\n}\n\n/**\n * Error lanzado cuando la generación de un presigned POST falla.\n */\nexport class StoragePresignError extends Error {\n constructor(public readonly key: string, cause: unknown) {\n super(`Failed to generate presigned POST for: ${key}`, { cause });\n this.name = 'StoragePresignError';\n }\n}\n\n/**\n * Error lanzado al construir un `S3StorageService` con configuración inválida.\n */\nexport class StorageConfigError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'StorageConfigError';\n }\n}\n","/**\n * Normaliza una key para object storage:\n * - Quita caracteres no permitidos (deja alfanuméricos, `/`, `_`, `-`, `.`).\n * - Colapsa slashes duplicados (`a//b` → `a/b`).\n * - Quita slashes al inicio y al final.\n *\n * Útil para sanear input del usuario antes de subir archivos.\n *\n * @example\n * ```ts\n * sanitizeKey('/avatars//user 123!.jpg') // 'avatars/user123.jpg'\n * ```\n */\nexport function sanitizeKey(key: string): string {\n return key\n .replace(/[^a-zA-Z0-9/_\\-.]/g, '')\n .replace(/\\/+/g, '/')\n .replace(/^\\/|\\/$/g, '');\n}\n","import {\n S3Client,\n PutObjectCommand,\n HeadObjectCommand,\n} from '@aws-sdk/client-s3';\nimport { createPresignedPost } from '@aws-sdk/s3-presigned-post';\n\nimport type {\n IStorageService,\n PutObjectOptions,\n PresignedPostRequest,\n PresignedPostResult,\n S3StorageConfig,\n StorageErrorEvent,\n} from './types';\n\nimport {\n StorageUploadError,\n StoragePresignError,\n StorageConfigError,\n} from './errors';\n\nimport { sanitizeKey } from './utils';\n\nconst DEFAULT_MAX_SIZE_BYTES = 10 * 1024 * 1024;\nconst DEFAULT_EXPIRES_IN_SECONDS = 600;\n\n/**\n * Implementación de `IStorageService` para Amazon S3 y APIs S3-compatible\n * (Cloudflare R2, MinIO, DigitalOcean Spaces, Backblaze B2).\n */\nexport class S3StorageService implements IStorageService {\n private readonly client: S3Client;\n private readonly bucket: string;\n private readonly onError?: (event: StorageErrorEvent) => void;\n\n constructor(config: S3StorageConfig) {\n if (!config.bucket) throw new StorageConfigError('Missing bucket');\n if (!config.region) throw new StorageConfigError('Missing region');\n if (!config.accessKeyId) throw new StorageConfigError('Missing accessKeyId');\n if (!config.secretAccessKey) throw new StorageConfigError('Missing secretAccessKey');\n\n this.bucket = config.bucket;\n this.onError = config.onError;\n\n this.client = new S3Client({\n region: config.region,\n endpoint: config.endpoint,\n forcePathStyle: config.forcePathStyle,\n credentials: {\n accessKeyId: config.accessKeyId,\n secretAccessKey: config.secretAccessKey,\n },\n });\n }\n\n async put(\n key: string,\n body: string | Buffer,\n options: PutObjectOptions\n ): Promise<void> {\n const safeKey = sanitizeKey(key);\n\n try {\n const { contentType, cacheControl, ...rest } = options;\n\n await this.client.send(\n new PutObjectCommand({\n ...rest,\n Bucket: this.bucket,\n Key: safeKey,\n Body: body,\n ContentType: contentType,\n CacheControl: cacheControl,\n })\n );\n } catch (cause) {\n this.onError?.({ operation: 'upload_failed', key: safeKey, cause });\n throw new StorageUploadError(safeKey, cause);\n }\n }\n\n async exists(key: string): Promise<boolean> {\n const safeKey = sanitizeKey(key);\n try {\n await this.client.send(\n new HeadObjectCommand({ Bucket: this.bucket, Key: safeKey })\n );\n return true;\n } catch {\n // No distinguimos entre NotFound y otros errores: si algo falla,\n // tratamos el objeto como inexistente. Si el consumidor necesita\n // distinguir, puede observar `onError`.\n return false;\n }\n }\n\n async presignedPost({\n key,\n contentType,\n maxSizeBytes = DEFAULT_MAX_SIZE_BYTES,\n expiresIn = DEFAULT_EXPIRES_IN_SECONDS,\n }: PresignedPostRequest): Promise<PresignedPostResult> {\n const safeKey = sanitizeKey(key);\n\n try {\n const { url, fields } = await createPresignedPost(this.client, {\n Bucket: this.bucket,\n Key: safeKey,\n Conditions: [\n ['content-length-range', 0, maxSizeBytes],\n ['eq', '$Content-Type', contentType],\n ],\n Fields: {\n 'Content-Type': contentType,\n },\n Expires: expiresIn,\n });\n\n return { url, fields, key: safeKey };\n } catch (cause) {\n this.onError?.({ operation: 'presign_failed', key: safeKey, cause });\n throw new StoragePresignError(safeKey, cause);\n }\n }\n}\n","/**\n * Servicio para construir URLs públicas a archivos almacenados.\n * Útil cuando expones los objetos vía un CDN o el endpoint público del bucket.\n */\nexport interface IFileUrlService {\n getPublicUrl(key: string): string;\n}\n\n/**\n * Implementación basada en una base URL fija (típicamente un CDN como\n * Cloudflare, CloudFront, o el endpoint público de R2/S3).\n *\n * No re-saniza la key: asume que se pasa la misma key con la que se subió\n * el objeto. Sanitizar dos veces puede generar URLs que no apuntan al objeto\n * real si la sanitización modifica la key.\n *\n * @example\n * ```ts\n * const urls = new CdnFileUrlService('https://cdn.miapp.com');\n * urls.getPublicUrl('avatars/user-123.jpg');\n * // → 'https://cdn.miapp.com/avatars/user-123.jpg'\n * ```\n */\nexport class CdnFileUrlService implements IFileUrlService {\n private readonly baseUrl: string;\n\n constructor(baseUrl: string) {\n // Quitar trailing slash para evitar dobles slashes al concatenar\n this.baseUrl = baseUrl.replace(/\\/+$/, '');\n }\n\n getPublicUrl(key: string): string {\n // Quitar leading slash de la key para evitar dobles slashes\n const cleanKey = key.replace(/^\\/+/, '');\n return `${this.baseUrl}/${cleanKey}`;\n }\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@codercito/object-storage",
3
+ "version": "0.1.0",
4
+ "description": "Abstracción tipada para object storage (S3-compatible) con CDN URL helpers",
5
+ "type": "module",
6
+ "module": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsup",
20
+ "dev": "tsup --watch",
21
+ "type-check": "tsc --noEmit",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "peerDependencies": {
25
+ "@aws-sdk/client-s3": "^3.0.0",
26
+ "@aws-sdk/s3-presigned-post": "^3.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@aws-sdk/client-s3": "^3.616.0",
30
+ "@aws-sdk/s3-presigned-post": "^3.616.0",
31
+ "@types/node": "^20.11.0",
32
+ "tsup": "^8.0.2",
33
+ "typescript": "^5.3.3"
34
+ },
35
+ "keywords": [
36
+ "s3",
37
+ "storage",
38
+ "object-storage",
39
+ "aws",
40
+ "r2",
41
+ "cloudflare",
42
+ "minio",
43
+ "presigned"
44
+ ],
45
+ "author": "",
46
+ "license": "MIT",
47
+ "engines": {
48
+ "node": ">=18"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": ""
53
+ }
54
+ }