@chidchanun/bcp 0.1.25 → 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.
package/docs/storage.md CHANGED
@@ -1,80 +1,87 @@
1
1
  # Storage and File Delivery
2
2
 
3
- BCP Framework `0.1.25` adds a server-only storage abstraction and production file-delivery helpers through `bcp/server`.
3
+ BCP Framework `0.1.25` introduced the server-only `StorageAdapter` abstraction and production file-delivery helpers through `bcp/server`.
4
4
 
5
- The initial adapter is local filesystem storage. The `StorageAdapter` contract keeps application code independent from the local implementation so later object-storage adapters can implement the same surface.
5
+ BCP Framework `0.1.26` completes the next storage milestone with:
6
6
 
7
- ## Create local storage
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
8
17
 
9
18
  ```ts
10
19
  import {
11
20
  createLocalStorage,
12
21
  } from "bcp/server";
13
22
 
14
- export const uploads =
23
+ export const storage =
15
24
  createLocalStorage({
16
25
  directory:
17
26
  "./uploads",
18
27
  });
19
28
  ```
20
29
 
21
- Storage keys are relative forward-slash paths:
30
+ Storage keys are logical relative paths:
22
31
 
23
32
  ```text
24
33
  avatars/user-101.webp
25
34
  documents/2026/report.pdf
26
35
  ```
27
36
 
28
- Absolute paths, `.` / `..` traversal segments and BCP's reserved metadata directory are rejected.
37
+ Absolute paths, traversal segments and BCP's reserved metadata directory are rejected.
29
38
 
30
- ## Write and read objects
39
+ ## S3-compatible storage
31
40
 
32
41
  ```ts
33
- const stored =
34
- await uploads.put(
35
- "notes/hello.txt",
36
- "Hello BCP",
37
- {
38
- contentType:
39
- "text/plain",
40
- }
41
- );
42
+ import {
43
+ createS3Storage,
44
+ } from "bcp/server";
42
45
 
43
- const bytes =
44
- await uploads.read(
45
- stored.key
46
- );
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
+ });
47
59
  ```
48
60
 
49
- `put()` does not overwrite an existing object unless `overwrite: true` is explicitly supplied.
50
-
51
- The local adapter stores metadata separately from the object bytes so content type and SHA-256 identity survive process restarts.
61
+ The same application-facing `StorageAdapter` surface works with local or S3-compatible backends.
52
62
 
53
- Object metadata contains:
54
-
55
- ```ts
56
- {
57
- key,
58
- size,
59
- contentType,
60
- lastModified,
61
- etag,
62
- checksumSha256,
63
- }
64
- ```
65
-
66
- Objects created through the local adapter use a strong SHA-256 ETag.
63
+ Read more: [S3-Compatible Storage](s3-storage.md).
67
64
 
68
65
  ## Storage adapter contract
69
66
 
70
67
  ```ts
71
68
  interface StorageAdapter {
69
+ capabilities?: Partial<
70
+ StorageAdapterCapabilities
71
+ >;
72
+
72
73
  put(
73
74
  key: string,
74
75
  value: StorageWriteValue,
75
76
  options?: StoragePutOptions
76
77
  ): Promise<StorageObjectMetadata>;
77
78
 
79
+ putStream?(
80
+ key: string,
81
+ stream: ReadableStream<Uint8Array>,
82
+ options?: StoragePutStreamOptions
83
+ ): Promise<StorageObjectMetadata>;
84
+
78
85
  stat(
79
86
  key: string
80
87
  ): Promise<StorageObjectMetadata | null>;
@@ -84,6 +91,11 @@ interface StorageAdapter {
84
91
  options?: StorageReadOptions
85
92
  ): Promise<Uint8Array>;
86
93
 
94
+ readStream?(
95
+ key: string,
96
+ options?: StorageReadOptions
97
+ ): Promise<StorageReadableStream>;
98
+
87
99
  exists(
88
100
  key: string
89
101
  ): Promise<boolean>;
@@ -94,173 +106,274 @@ interface StorageAdapter {
94
106
  }
95
107
  ```
96
108
 
97
- `read()` supports inclusive byte ranges through `start` and `end`.
98
-
99
- ## Upload directly into storage
109
+ The original buffered methods remain required. Streaming methods are additive optional capabilities.
100
110
 
101
- BCP 0.1.24's multipart parser and validation helpers remain unchanged. `0.1.25` adds `storeUploadedFile()` as the bridge into a storage adapter.
111
+ ## Capabilities
102
112
 
103
113
  ```ts
104
114
  import {
105
- createLocalStorage,
106
- parseMultipartFormData,
107
- requireUploadedFile,
108
- storeUploadedFile,
115
+ getStorageCapabilities,
109
116
  } from "bcp/server";
110
117
 
111
- const uploads =
112
- createLocalStorage({
113
- directory:
114
- "./uploads",
115
- });
116
-
117
- export async function POST(
118
- request: Request
119
- ) {
120
- const formData =
121
- await parseMultipartFormData(
122
- request,
123
- {
124
- maxBytes:
125
- 8 * 1024 * 1024,
126
- }
127
- );
128
-
129
- const file =
130
- requireUploadedFile(
131
- formData,
132
- "file",
133
- {
134
- maxBytes:
135
- 5 * 1024 * 1024,
136
- allowedTypes: [
137
- "image/png",
138
- "image/jpeg",
139
- "image/webp",
140
- ],
141
- }
142
- );
143
-
144
- const stored =
145
- await storeUploadedFile(
146
- file,
147
- {
148
- storage:
149
- uploads,
150
- key:
151
- `avatars/${crypto.randomUUID()}.webp`,
152
- }
153
- );
154
-
155
- return Response.json(
156
- stored
118
+ const capabilities =
119
+ getStorageCapabilities(
120
+ storage
157
121
  );
122
+ ```
123
+
124
+ The capability model currently reports:
125
+
126
+ ```ts
127
+ {
128
+ streamingRead,
129
+ streamingWrite,
130
+ ranges,
131
+ signedUrls,
132
+ listing,
158
133
  }
159
134
  ```
160
135
 
161
- If `key` is omitted, BCP creates a UUID-based storage key while preserving a sanitized extension.
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
+ ```
162
147
 
163
- ## Deliver a stored file
148
+ Signed URLs and listing are deferred to a later milestone.
164
149
 
165
- Use `createStorageResponse()` from a GET/HEAD API route:
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:
166
171
 
167
172
  ```ts
168
173
  import {
169
- createLocalStorage,
170
- createStorageResponse,
174
+ putStorageStream,
171
175
  } from "bcp/server";
172
176
 
173
- const uploads =
174
- createLocalStorage({
175
- directory:
176
- "./uploads",
177
- });
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
+ ```
178
189
 
179
- export function GET(
180
- request: Request,
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,
181
203
  {
182
- params,
183
- }: {
184
- params: {
185
- key: string;
186
- };
204
+ maxBytes:
205
+ 50 * 1024 * 1024,
206
+ signal:
207
+ abortController.signal,
187
208
  }
188
- ) {
189
- return createStorageResponse(
190
- request,
191
- uploads,
192
- params.key,
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",
193
243
  {
194
- cacheControl:
195
- "private, max-age=3600",
244
+ start: 0,
245
+ end: 1023,
196
246
  }
197
247
  );
198
- }
199
248
  ```
200
249
 
201
- The response helper supports:
250
+ Legacy adapters fall back to `read()` and expose the result as a stream.
202
251
 
203
- - `GET` and `HEAD`.
204
- - `Accept-Ranges: bytes`.
205
- - single byte ranges with `206 Partial Content`.
206
- - open-ended ranges such as `bytes=500-`.
207
- - suffix ranges such as `bytes=-500`.
208
- - `416 Range Not Satisfiable` for invalid/unsupported ranges.
209
- - `ETag` and `If-None-Match` validation.
210
- - `Last-Modified` and `If-Modified-Since` validation.
211
- - `If-Range` handling.
212
- - configurable `Cache-Control`.
213
- - safe `Content-Disposition` filenames.
252
+ ## Multipart uploads
214
253
 
215
- Multiple byte ranges are intentionally not supported in this milestone. They return `416` rather than generating a multipart range response.
216
-
217
- ## Downloads
254
+ Small forms can continue using the buffered `FormData` API:
218
255
 
219
256
  ```ts
220
- return createStorageResponse(
221
- request,
222
- uploads,
223
- "reports/monthly.pdf",
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,
224
270
  {
225
- disposition:
226
- "attachment",
227
- downloadName:
228
- "monthly-report.pdf",
271
+ storage,
229
272
  }
230
273
  );
231
274
  ```
232
275
 
233
- BCP strips path and control characters from the header filename and also emits an RFC 5987 UTF-8 filename parameter.
276
+ For large production uploads, `0.1.26` adds direct multipart-to-storage streaming:
234
277
 
235
- ## Cache defaults
236
-
237
- The default file response policy is conservative:
278
+ ```ts
279
+ import {
280
+ storeMultipartFile,
281
+ } from "bcp/server";
238
282
 
239
- ```text
240
- Cache-Control: private, max-age=0, must-revalidate
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
+ );
241
306
  ```
242
307
 
243
- Public immutable files must opt in explicitly, for example:
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
244
313
 
245
314
  ```ts
246
- {
247
- cacheControl:
248
- "public, max-age=31536000, immutable",
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
+ );
249
333
  }
250
334
  ```
251
335
 
252
- Do not mark authorization-protected or user-private files as public.
336
+ `createStorageResponse()` uses `readStorageStream()` and supports:
253
337
 
254
- ## Errors
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
+ ```
255
353
 
256
- Storage operations throw `StorageError` with a stable code and HTTP-oriented status:
354
+ Public immutable caching must be enabled explicitly.
355
+
356
+ Multiple byte ranges are intentionally unsupported in `0.1.26`.
357
+
358
+ ## Object metadata
257
359
 
258
360
  ```ts
259
- import {
260
- StorageError,
261
- } from "bcp/server";
361
+ interface StorageObjectMetadata {
362
+ key: string;
363
+ size: number;
364
+ contentType: string;
365
+ lastModified: Date;
366
+ etag: string;
367
+ checksumSha256?: string;
368
+ }
262
369
  ```
263
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
+
264
377
  Current codes:
265
378
 
266
379
  ```text
@@ -268,18 +381,21 @@ INVALID_KEY
268
381
  OBJECT_NOT_FOUND
269
382
  OBJECT_EXISTS
270
383
  RANGE_NOT_SATISFIABLE
384
+ STREAM_TOO_LARGE
271
385
  ```
272
386
 
273
- `createStorageResponse()` converts ordinary missing objects and invalid HTTP ranges into `404` / `416` responses directly.
274
-
275
387
  ## Security boundary
276
388
 
277
- Storage keys are not authorization. Always perform authentication/authorization before passing a user-selected key to the storage layer.
389
+ Storage keys are not authorization. Authenticate and authorize requests before allowing access to user-selected keys.
278
390
 
279
- The local adapter prevents traversal outside its configured directory, but applications are still responsible for deciding which authenticated user can read, overwrite or delete each logical object.
391
+ MIME type and extension checks are metadata validation, not file-signature verification.
280
392
 
281
- MIME types are metadata and are not file-signature verification. Continue validating upload content when the threat model requires it.
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.
282
394
 
283
- ## Adapter scope
395
+ For S3/object storage:
284
396
 
285
- BCP 0.1.25 ships only `createLocalStorage()` as a built-in adapter. S3-compatible/cloud adapters are expected to implement `StorageAdapter` without changing application upload and delivery code.
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.25",
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",