@chidchanun/bcp 0.1.24 → 0.1.26

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.
@@ -0,0 +1,276 @@
1
+ # S3-Compatible Storage
2
+
3
+ BCP Framework `0.1.26` adds an S3-compatible `StorageAdapter` implementation through `bcp/server`.
4
+
5
+ The adapter is designed for AWS S3 and services that implement the S3 API, including common S3-compatible providers such as Cloudflare R2 and MinIO. Provider-specific behavior can still differ, so production deployments should verify their chosen provider with representative uploads, ranges and overwrite rules.
6
+
7
+ ## Create an adapter
8
+
9
+ ```ts
10
+ import {
11
+ createS3Storage,
12
+ } from "bcp/server";
13
+
14
+ export const storage =
15
+ createS3Storage({
16
+ bucket:
17
+ process.env.S3_BUCKET!,
18
+ region:
19
+ process.env.S3_REGION!,
20
+ endpoint:
21
+ process.env.S3_ENDPOINT,
22
+ accessKeyId:
23
+ process.env.S3_ACCESS_KEY_ID,
24
+ secretAccessKey:
25
+ process.env.S3_SECRET_ACCESS_KEY,
26
+ });
27
+ ```
28
+
29
+ When `accessKeyId` / `secretAccessKey` are omitted, the underlying AWS SDK client can use its normal server-side credential provider chain.
30
+
31
+ Do not expose S3 credentials through `BCP_PUBLIC_*` environment variables.
32
+
33
+ ## MinIO / path-style endpoints
34
+
35
+ Some S3-compatible endpoints require path-style addressing:
36
+
37
+ ```ts
38
+ const storage =
39
+ createS3Storage({
40
+ bucket:
41
+ "uploads",
42
+ region:
43
+ "us-east-1",
44
+ endpoint:
45
+ "http://127.0.0.1:9000",
46
+ forcePathStyle:
47
+ true,
48
+ accessKeyId:
49
+ process.env.MINIO_ACCESS_KEY,
50
+ secretAccessKey:
51
+ process.env.MINIO_SECRET_KEY,
52
+ });
53
+ ```
54
+
55
+ ## Prefix objects
56
+
57
+ A deployment can reserve a logical prefix without changing application storage keys:
58
+
59
+ ```ts
60
+ const storage =
61
+ createS3Storage({
62
+ bucket:
63
+ "app-data",
64
+ region:
65
+ "auto",
66
+ prefix:
67
+ "production",
68
+ });
69
+
70
+ await storage.put(
71
+ "avatars/user-101.webp",
72
+ bytes
73
+ );
74
+ ```
75
+
76
+ The application-facing key remains:
77
+
78
+ ```text
79
+ avatars/user-101.webp
80
+ ```
81
+
82
+ while the provider object key becomes:
83
+
84
+ ```text
85
+ production/avatars/user-101.webp
86
+ ```
87
+
88
+ Prefixes and object keys reject traversal segments such as `..`.
89
+
90
+ ## Standard operations
91
+
92
+ The S3 adapter implements the same `StorageAdapter` surface as local storage:
93
+
94
+ ```ts
95
+ await storage.put(
96
+ "documents/report.pdf",
97
+ bytes,
98
+ {
99
+ contentType:
100
+ "application/pdf",
101
+ }
102
+ );
103
+
104
+ const metadata =
105
+ await storage.stat(
106
+ "documents/report.pdf"
107
+ );
108
+
109
+ const bytes =
110
+ await storage.read(
111
+ "documents/report.pdf"
112
+ );
113
+
114
+ await storage.exists(
115
+ "documents/report.pdf"
116
+ );
117
+
118
+ await storage.delete(
119
+ "documents/report.pdf"
120
+ );
121
+ ```
122
+
123
+ Buffered `put()` uses a conditional S3 write when `overwrite` is not enabled. Existing objects therefore produce `StorageError` with code `OBJECT_EXISTS` instead of being silently replaced.
124
+
125
+ ## Streaming upload
126
+
127
+ `createS3Storage()` supports `putStream()` and works with the generic `putStorageStream()` helper:
128
+
129
+ ```ts
130
+ import {
131
+ putStorageStream,
132
+ } from "bcp/server";
133
+
134
+ await putStorageStream(
135
+ storage,
136
+ "media/video.mp4",
137
+ request.body!,
138
+ {
139
+ contentType:
140
+ "video/mp4",
141
+ maxBytes:
142
+ 500 * 1024 * 1024,
143
+ }
144
+ );
145
+ ```
146
+
147
+ BCP uses the AWS SDK multipart upload helper for streams whose total size may not be known when the upload begins. This avoids requiring the complete object to be buffered in application memory.
148
+
149
+ If a stream exceeds `maxBytes`, the upload is aborted and `StorageError` uses:
150
+
151
+ ```text
152
+ code: STREAM_TOO_LARGE
153
+ status: 413
154
+ ```
155
+
156
+ Multipart uploads use automatic cleanup on failure (`leavePartsOnError: false`).
157
+
158
+ ## Multipart tuning
159
+
160
+ The adapter accepts optional multipart tuning:
161
+
162
+ ```ts
163
+ const storage =
164
+ createS3Storage({
165
+ bucket:
166
+ "uploads",
167
+ region:
168
+ "us-east-1",
169
+ multipart: {
170
+ partSize:
171
+ 8 * 1024 * 1024,
172
+ queueSize:
173
+ 2,
174
+ },
175
+ });
176
+ ```
177
+
178
+ `partSize` must be at least 5 MiB. Higher `queueSize` can improve throughput, but it also increases concurrent network and memory usage.
179
+
180
+ ## Ranged reads
181
+
182
+ The adapter preserves BCP's inclusive `start` / `end` range contract:
183
+
184
+ ```ts
185
+ const bytes =
186
+ await storage.read(
187
+ "media/video.mp4",
188
+ {
189
+ start: 0,
190
+ end: 1023,
191
+ }
192
+ );
193
+ ```
194
+
195
+ `readStream()` also supports ranges and is used automatically by `createStorageResponse()`.
196
+
197
+ ```ts
198
+ return createStorageResponse(
199
+ request,
200
+ storage,
201
+ "media/video.mp4"
202
+ );
203
+ ```
204
+
205
+ This allows the existing file-delivery API to serve local or S3-backed objects with the same `GET`, `HEAD`, ETag, Last-Modified and single-range behavior.
206
+
207
+ ## Capabilities
208
+
209
+ ```ts
210
+ getStorageCapabilities(storage)
211
+ ```
212
+
213
+ returns native support for:
214
+
215
+ ```text
216
+ streamingRead: true
217
+ streamingWrite: true
218
+ ranges: true
219
+ signedUrls: false
220
+ listing: false
221
+ ```
222
+
223
+ Signed URLs and listing are intentionally deferred to a later storage milestone rather than expanding the `0.1.26` release surface further.
224
+
225
+ ## Overwrite semantics
226
+
227
+ For buffered `put()`, BCP sends a conditional create-only request when `overwrite` is false.
228
+
229
+ Streaming multipart uploads perform a `HeadObject` preflight before starting when overwrite is disabled. That preserves the normal application behavior, but it cannot provide the same atomic create-only guarantee across every S3-compatible multipart implementation if another writer creates the same key between the preflight and completion.
230
+
231
+ Applications that require strict cross-writer serialization should enforce it in their database/business layer or use provider-specific conditional workflows.
232
+
233
+ ## Metadata and ETags
234
+
235
+ S3 response metadata is mapped to `StorageObjectMetadata`:
236
+
237
+ ```ts
238
+ {
239
+ key,
240
+ size,
241
+ contentType,
242
+ lastModified,
243
+ etag,
244
+ checksumSha256?,
245
+ }
246
+ ```
247
+
248
+ Buffered BCP writes persist a SHA-256 value in S3 user metadata. Streaming multipart writes rely on provider metadata/ETag behavior and may not expose `checksumSha256` through the generic contract.
249
+
250
+ Do not assume an S3 ETag is always a plain MD5 checksum; multipart and provider-specific ETag formats can differ.
251
+
252
+ ## Client lifecycle
253
+
254
+ Adapters that create their own AWS SDK client expose:
255
+
256
+ ```ts
257
+ storage.destroy();
258
+ ```
259
+
260
+ Call it during application shutdown when appropriate.
261
+
262
+ When an existing `S3Client` is injected through `client`, BCP does not destroy that externally owned client.
263
+
264
+ ## Security
265
+
266
+ Storage keys are not authorization. Authenticate and authorize a user before allowing access to an object key.
267
+
268
+ Recommended production practices include:
269
+
270
+ - keep buckets private by default,
271
+ - use scoped credentials / IAM policies,
272
+ - do not expose access keys to browser bundles,
273
+ - validate uploaded content independently of MIME metadata,
274
+ - configure provider-side encryption and retention policies when required,
275
+ - keep application upload limits below infrastructure/proxy limits,
276
+ - verify CORS only when browsers intentionally access the bucket directly.
@@ -0,0 +1,401 @@
1
+ # Storage and File Delivery
2
+
3
+ BCP Framework `0.1.25` introduced the server-only `StorageAdapter` abstraction and production file-delivery helpers through `bcp/server`.
4
+
5
+ BCP Framework `0.1.26` completes the next storage milestone with:
6
+
7
+ - additive streaming reads/writes,
8
+ - capability discovery,
9
+ - local streaming I/O,
10
+ - S3-compatible storage,
11
+ - production multipart-to-storage streaming,
12
+ - streamed full/ranged file delivery.
13
+
14
+ Existing adapters written for `0.1.25` remain valid because `putStream()` and `readStream()` are optional.
15
+
16
+ ## Local storage
17
+
18
+ ```ts
19
+ import {
20
+ createLocalStorage,
21
+ } from "bcp/server";
22
+
23
+ export const storage =
24
+ createLocalStorage({
25
+ directory:
26
+ "./uploads",
27
+ });
28
+ ```
29
+
30
+ Storage keys are logical relative paths:
31
+
32
+ ```text
33
+ avatars/user-101.webp
34
+ documents/2026/report.pdf
35
+ ```
36
+
37
+ Absolute paths, traversal segments and BCP's reserved metadata directory are rejected.
38
+
39
+ ## S3-compatible storage
40
+
41
+ ```ts
42
+ import {
43
+ createS3Storage,
44
+ } from "bcp/server";
45
+
46
+ export const storage =
47
+ createS3Storage({
48
+ bucket:
49
+ process.env.S3_BUCKET!,
50
+ region:
51
+ process.env.S3_REGION!,
52
+ endpoint:
53
+ process.env.S3_ENDPOINT,
54
+ accessKeyId:
55
+ process.env.S3_ACCESS_KEY_ID,
56
+ secretAccessKey:
57
+ process.env.S3_SECRET_ACCESS_KEY,
58
+ });
59
+ ```
60
+
61
+ The same application-facing `StorageAdapter` surface works with local or S3-compatible backends.
62
+
63
+ Read more: [S3-Compatible Storage](s3-storage.md).
64
+
65
+ ## Storage adapter contract
66
+
67
+ ```ts
68
+ interface StorageAdapter {
69
+ capabilities?: Partial<
70
+ StorageAdapterCapabilities
71
+ >;
72
+
73
+ put(
74
+ key: string,
75
+ value: StorageWriteValue,
76
+ options?: StoragePutOptions
77
+ ): Promise<StorageObjectMetadata>;
78
+
79
+ putStream?(
80
+ key: string,
81
+ stream: ReadableStream<Uint8Array>,
82
+ options?: StoragePutStreamOptions
83
+ ): Promise<StorageObjectMetadata>;
84
+
85
+ stat(
86
+ key: string
87
+ ): Promise<StorageObjectMetadata | null>;
88
+
89
+ read(
90
+ key: string,
91
+ options?: StorageReadOptions
92
+ ): Promise<Uint8Array>;
93
+
94
+ readStream?(
95
+ key: string,
96
+ options?: StorageReadOptions
97
+ ): Promise<StorageReadableStream>;
98
+
99
+ exists(
100
+ key: string
101
+ ): Promise<boolean>;
102
+
103
+ delete(
104
+ key: string
105
+ ): Promise<boolean>;
106
+ }
107
+ ```
108
+
109
+ The original buffered methods remain required. Streaming methods are additive optional capabilities.
110
+
111
+ ## Capabilities
112
+
113
+ ```ts
114
+ import {
115
+ getStorageCapabilities,
116
+ } from "bcp/server";
117
+
118
+ const capabilities =
119
+ getStorageCapabilities(
120
+ storage
121
+ );
122
+ ```
123
+
124
+ The capability model currently reports:
125
+
126
+ ```ts
127
+ {
128
+ streamingRead,
129
+ streamingWrite,
130
+ ranges,
131
+ signedUrls,
132
+ listing,
133
+ }
134
+ ```
135
+
136
+ `createLocalStorage()` and `createS3Storage()` in `0.1.26` report:
137
+
138
+ ```ts
139
+ {
140
+ streamingRead: true,
141
+ streamingWrite: true,
142
+ ranges: true,
143
+ signedUrls: false,
144
+ listing: false,
145
+ }
146
+ ```
147
+
148
+ Signed URLs and listing are deferred to a later milestone.
149
+
150
+ ## Buffered writes
151
+
152
+ ```ts
153
+ const stored =
154
+ await storage.put(
155
+ "notes/hello.txt",
156
+ "Hello BCP",
157
+ {
158
+ contentType:
159
+ "text/plain",
160
+ }
161
+ );
162
+ ```
163
+
164
+ `put()` does not overwrite an existing object unless `overwrite: true` is supplied.
165
+
166
+ Local storage persists content type and SHA-256 identity in sidecar metadata. Buffered S3 writes persist SHA-256 in S3 user metadata.
167
+
168
+ ## Streaming writes
169
+
170
+ Use the generic helper so application code works with native or legacy adapters:
171
+
172
+ ```ts
173
+ import {
174
+ putStorageStream,
175
+ } from "bcp/server";
176
+
177
+ await putStorageStream(
178
+ storage,
179
+ "imports/data.bin",
180
+ request.body!,
181
+ {
182
+ contentType:
183
+ "application/octet-stream",
184
+ maxBytes:
185
+ 100 * 1024 * 1024,
186
+ }
187
+ );
188
+ ```
189
+
190
+ When an adapter provides `putStream()`, BCP uses it. Legacy adapters fall back to collecting the stream and calling `put()`.
191
+
192
+ The local adapter writes chunks directly to disk and calculates SHA-256 while streaming.
193
+
194
+ The S3 adapter uses the AWS SDK multipart upload helper so streams whose final size is not known at the start can still be uploaded without buffering the complete object.
195
+
196
+ ### Size limits and abort
197
+
198
+ ```ts
199
+ await putStorageStream(
200
+ storage,
201
+ key,
202
+ source,
203
+ {
204
+ maxBytes:
205
+ 50 * 1024 * 1024,
206
+ signal:
207
+ abortController.signal,
208
+ }
209
+ );
210
+ ```
211
+
212
+ If the stream exceeds `maxBytes`, BCP throws:
213
+
214
+ ```text
215
+ StorageError
216
+ code: STREAM_TOO_LARGE
217
+ status: 413
218
+ ```
219
+
220
+ Native adapters clean up/abort incomplete writes where supported.
221
+
222
+ ## Streaming reads
223
+
224
+ ```ts
225
+ import {
226
+ readStorageStream,
227
+ } from "bcp/server";
228
+
229
+ const stream =
230
+ await readStorageStream(
231
+ storage,
232
+ "videos/demo.mp4"
233
+ );
234
+ ```
235
+
236
+ Inclusive byte ranges are supported:
237
+
238
+ ```ts
239
+ const stream =
240
+ await readStorageStream(
241
+ storage,
242
+ "videos/demo.mp4",
243
+ {
244
+ start: 0,
245
+ end: 1023,
246
+ }
247
+ );
248
+ ```
249
+
250
+ Legacy adapters fall back to `read()` and expose the result as a stream.
251
+
252
+ ## Multipart uploads
253
+
254
+ Small forms can continue using the buffered `FormData` API:
255
+
256
+ ```ts
257
+ const formData =
258
+ await parseMultipartFormData(
259
+ request
260
+ );
261
+
262
+ const file =
263
+ requireUploadedFile(
264
+ formData,
265
+ "file"
266
+ );
267
+
268
+ await storeUploadedFile(
269
+ file,
270
+ {
271
+ storage,
272
+ }
273
+ );
274
+ ```
275
+
276
+ For large production uploads, `0.1.26` adds direct multipart-to-storage streaming:
277
+
278
+ ```ts
279
+ import {
280
+ storeMultipartFile,
281
+ } from "bcp/server";
282
+
283
+ const stored =
284
+ await storeMultipartFile(
285
+ request,
286
+ {
287
+ storage,
288
+ fieldName:
289
+ "file",
290
+ key:
291
+ "documents/report.pdf",
292
+ maxBytes:
293
+ 100 * 1024 * 1024,
294
+ constraints: {
295
+ maxBytes:
296
+ 80 * 1024 * 1024,
297
+ allowedTypes: [
298
+ "application/pdf",
299
+ ],
300
+ allowedExtensions: [
301
+ ".pdf",
302
+ ],
303
+ },
304
+ }
305
+ );
306
+ ```
307
+
308
+ The multipart parser and storage write operate concurrently, so the complete file is not converted to a `File` / `ArrayBuffer` first.
309
+
310
+ Read more: [File Upload](file-upload.md).
311
+
312
+ ## File delivery
313
+
314
+ ```ts
315
+ import {
316
+ createStorageResponse,
317
+ } from "bcp/server";
318
+
319
+ export function GET(
320
+ request: Request
321
+ ) {
322
+ return createStorageResponse(
323
+ request,
324
+ storage,
325
+ "documents/report.pdf",
326
+ {
327
+ disposition:
328
+ "attachment",
329
+ downloadName:
330
+ "report.pdf",
331
+ }
332
+ );
333
+ }
334
+ ```
335
+
336
+ `createStorageResponse()` uses `readStorageStream()` and supports:
337
+
338
+ - `GET` and `HEAD`,
339
+ - `ETag` / `If-None-Match`,
340
+ - `Last-Modified` / `If-Modified-Since`,
341
+ - `If-Range`,
342
+ - single byte ranges with `206 Partial Content`,
343
+ - `304 Not Modified`,
344
+ - `416 Range Not Satisfiable`,
345
+ - safe `Content-Disposition` filenames,
346
+ - configurable cache control.
347
+
348
+ The default cache policy is:
349
+
350
+ ```text
351
+ Cache-Control: private, max-age=0, must-revalidate
352
+ ```
353
+
354
+ Public immutable caching must be enabled explicitly.
355
+
356
+ Multiple byte ranges are intentionally unsupported in `0.1.26`.
357
+
358
+ ## Object metadata
359
+
360
+ ```ts
361
+ interface StorageObjectMetadata {
362
+ key: string;
363
+ size: number;
364
+ contentType: string;
365
+ lastModified: Date;
366
+ etag: string;
367
+ checksumSha256?: string;
368
+ }
369
+ ```
370
+
371
+ `checksumSha256` is optional because not every cloud provider exposes a portable whole-object SHA-256 value for multipart objects.
372
+
373
+ Do not assume an S3 ETag is an MD5 hash.
374
+
375
+ ## Storage errors
376
+
377
+ Current codes:
378
+
379
+ ```text
380
+ INVALID_KEY
381
+ OBJECT_NOT_FOUND
382
+ OBJECT_EXISTS
383
+ RANGE_NOT_SATISFIABLE
384
+ STREAM_TOO_LARGE
385
+ ```
386
+
387
+ ## Security boundary
388
+
389
+ Storage keys are not authorization. Authenticate and authorize requests before allowing access to user-selected keys.
390
+
391
+ MIME type and extension checks are metadata validation, not file-signature verification.
392
+
393
+ Streaming limits do not replace the framework/proxy request body limit. Configure `server.bodyLimit` / `BCP_BODY_LIMIT` large enough for intended uploads while keeping an explicit outer request boundary.
394
+
395
+ For S3/object storage:
396
+
397
+ - keep credentials server-only,
398
+ - prefer private buckets,
399
+ - scope IAM/provider permissions,
400
+ - use provider-side encryption/retention policies where required,
401
+ - validate provider CORS only when direct browser access is intentionally enabled.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chidchanun/bcp",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "BCP Framework - a React full-stack framework with file-based routing, SSR, APIs, middleware, islands, caching and standalone production builds.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,7 +55,7 @@
55
55
  "./database": {
56
56
  "types": "./packages/client/src/database.ts",
57
57
  "browser": "./packages/client/src/server-only.browser.mjs",
58
- "default": "./packages/client/src/database.ts"
58
+ "default": "./packages/client/src/database.mjs"
59
59
  },
60
60
  "./auth": {
61
61
  "types": "./packages/client/src/auth.ts",
@@ -79,9 +79,12 @@
79
79
  "./package.json": "./package.json"
80
80
  },
81
81
  "dependencies": {
82
+ "@aws-sdk/client-s3": "^3.1119.0",
83
+ "@aws-sdk/lib-storage": "^3.1119.0",
82
84
  "@babel/core": "^8.0.1",
83
85
  "@babel/preset-react": "^8.0.1",
84
86
  "@babel/preset-typescript": "^8.0.1",
87
+ "busboy": "^1.6.0",
85
88
  "chokidar": "^5.0.0",
86
89
  "esbuild": "^0.28.2",
87
90
  "react-refresh": "^0.18.0",