@chidchanun/bcp 0.1.23 → 0.1.25

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,285 @@
1
+ # Storage and File Delivery
2
+
3
+ BCP Framework `0.1.25` adds a server-only storage abstraction and production file-delivery helpers through `bcp/server`.
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.
6
+
7
+ ## Create local storage
8
+
9
+ ```ts
10
+ import {
11
+ createLocalStorage,
12
+ } from "bcp/server";
13
+
14
+ export const uploads =
15
+ createLocalStorage({
16
+ directory:
17
+ "./uploads",
18
+ });
19
+ ```
20
+
21
+ Storage keys are relative forward-slash paths:
22
+
23
+ ```text
24
+ avatars/user-101.webp
25
+ documents/2026/report.pdf
26
+ ```
27
+
28
+ Absolute paths, `.` / `..` traversal segments and BCP's reserved metadata directory are rejected.
29
+
30
+ ## Write and read objects
31
+
32
+ ```ts
33
+ const stored =
34
+ await uploads.put(
35
+ "notes/hello.txt",
36
+ "Hello BCP",
37
+ {
38
+ contentType:
39
+ "text/plain",
40
+ }
41
+ );
42
+
43
+ const bytes =
44
+ await uploads.read(
45
+ stored.key
46
+ );
47
+ ```
48
+
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.
52
+
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.
67
+
68
+ ## Storage adapter contract
69
+
70
+ ```ts
71
+ interface StorageAdapter {
72
+ put(
73
+ key: string,
74
+ value: StorageWriteValue,
75
+ options?: StoragePutOptions
76
+ ): Promise<StorageObjectMetadata>;
77
+
78
+ stat(
79
+ key: string
80
+ ): Promise<StorageObjectMetadata | null>;
81
+
82
+ read(
83
+ key: string,
84
+ options?: StorageReadOptions
85
+ ): Promise<Uint8Array>;
86
+
87
+ exists(
88
+ key: string
89
+ ): Promise<boolean>;
90
+
91
+ delete(
92
+ key: string
93
+ ): Promise<boolean>;
94
+ }
95
+ ```
96
+
97
+ `read()` supports inclusive byte ranges through `start` and `end`.
98
+
99
+ ## Upload directly into storage
100
+
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.
102
+
103
+ ```ts
104
+ import {
105
+ createLocalStorage,
106
+ parseMultipartFormData,
107
+ requireUploadedFile,
108
+ storeUploadedFile,
109
+ } from "bcp/server";
110
+
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
157
+ );
158
+ }
159
+ ```
160
+
161
+ If `key` is omitted, BCP creates a UUID-based storage key while preserving a sanitized extension.
162
+
163
+ ## Deliver a stored file
164
+
165
+ Use `createStorageResponse()` from a GET/HEAD API route:
166
+
167
+ ```ts
168
+ import {
169
+ createLocalStorage,
170
+ createStorageResponse,
171
+ } from "bcp/server";
172
+
173
+ const uploads =
174
+ createLocalStorage({
175
+ directory:
176
+ "./uploads",
177
+ });
178
+
179
+ export function GET(
180
+ request: Request,
181
+ {
182
+ params,
183
+ }: {
184
+ params: {
185
+ key: string;
186
+ };
187
+ }
188
+ ) {
189
+ return createStorageResponse(
190
+ request,
191
+ uploads,
192
+ params.key,
193
+ {
194
+ cacheControl:
195
+ "private, max-age=3600",
196
+ }
197
+ );
198
+ }
199
+ ```
200
+
201
+ The response helper supports:
202
+
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.
214
+
215
+ Multiple byte ranges are intentionally not supported in this milestone. They return `416` rather than generating a multipart range response.
216
+
217
+ ## Downloads
218
+
219
+ ```ts
220
+ return createStorageResponse(
221
+ request,
222
+ uploads,
223
+ "reports/monthly.pdf",
224
+ {
225
+ disposition:
226
+ "attachment",
227
+ downloadName:
228
+ "monthly-report.pdf",
229
+ }
230
+ );
231
+ ```
232
+
233
+ BCP strips path and control characters from the header filename and also emits an RFC 5987 UTF-8 filename parameter.
234
+
235
+ ## Cache defaults
236
+
237
+ The default file response policy is conservative:
238
+
239
+ ```text
240
+ Cache-Control: private, max-age=0, must-revalidate
241
+ ```
242
+
243
+ Public immutable files must opt in explicitly, for example:
244
+
245
+ ```ts
246
+ {
247
+ cacheControl:
248
+ "public, max-age=31536000, immutable",
249
+ }
250
+ ```
251
+
252
+ Do not mark authorization-protected or user-private files as public.
253
+
254
+ ## Errors
255
+
256
+ Storage operations throw `StorageError` with a stable code and HTTP-oriented status:
257
+
258
+ ```ts
259
+ import {
260
+ StorageError,
261
+ } from "bcp/server";
262
+ ```
263
+
264
+ Current codes:
265
+
266
+ ```text
267
+ INVALID_KEY
268
+ OBJECT_NOT_FOUND
269
+ OBJECT_EXISTS
270
+ RANGE_NOT_SATISFIABLE
271
+ ```
272
+
273
+ `createStorageResponse()` converts ordinary missing objects and invalid HTTP ranges into `404` / `416` responses directly.
274
+
275
+ ## Security boundary
276
+
277
+ Storage keys are not authorization. Always perform authentication/authorization before passing a user-selected key to the storage layer.
278
+
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.
280
+
281
+ MIME types are metadata and are not file-signature verification. Continue validating upload content when the threat model requires it.
282
+
283
+ ## Adapter scope
284
+
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chidchanun/bcp",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
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",
@@ -131,6 +131,11 @@ export async function buildProductionActions(
131
131
  frameworkDirectory,
132
132
  "../../client/src/server.ts"
133
133
  );
134
+ const frameworkAuthEntry =
135
+ path.resolve(
136
+ frameworkDirectory,
137
+ "../../client/src/auth.ts"
138
+ );
134
139
  const frameworkServerOnlyEntry =
135
140
  path.resolve(
136
141
  frameworkDirectory,
@@ -187,6 +192,16 @@ export async function buildProductionActions(
187
192
  frameworkServerEntry,
188
193
  })
189
194
  );
195
+ buildApi.onResolve(
196
+ {
197
+ filter:
198
+ /^bcp\/auth$/,
199
+ },
200
+ () => ({
201
+ path:
202
+ frameworkAuthEntry,
203
+ })
204
+ );
190
205
  buildApi.onResolve(
191
206
  {
192
207
  filter:
@@ -118,6 +118,11 @@ export async function buildProductionGuards(
118
118
  frameworkDirectory,
119
119
  "../../client/src/server.ts"
120
120
  );
121
+ const frameworkAuthEntry =
122
+ path.resolve(
123
+ frameworkDirectory,
124
+ "../../client/src/auth.ts"
125
+ );
121
126
  const frameworkServerOnlyEntry =
122
127
  path.resolve(
123
128
  frameworkDirectory,
@@ -174,6 +179,16 @@ export async function buildProductionGuards(
174
179
  frameworkServerEntry,
175
180
  })
176
181
  );
182
+ buildApi.onResolve(
183
+ {
184
+ filter:
185
+ /^bcp\/auth$/,
186
+ },
187
+ () => ({
188
+ path:
189
+ frameworkAuthEntry,
190
+ })
191
+ );
177
192
  buildApi.onResolve(
178
193
  {
179
194
  filter:
@@ -30,6 +30,46 @@ export {
30
30
  type LoggerOptions,
31
31
  } from "../../server/src/logger.js";
32
32
 
33
+ export {
34
+ getUploadedFile,
35
+ parseMultipartFormData,
36
+ requireUploadedFile,
37
+ sanitizeUploadFileName,
38
+ saveUploadedFile,
39
+ UploadError,
40
+ validateUploadedFile,
41
+
42
+ type MultipartUploadOptions,
43
+ type SaveUploadedFileOptions,
44
+ type SavedUploadedFile,
45
+ type UploadConstraints,
46
+ type UploadErrorCode,
47
+ } from "../../server/src/upload.js";
48
+
49
+ export {
50
+ createLocalStorage,
51
+ normalizeStorageKey,
52
+ StorageError,
53
+ storeUploadedFile,
54
+
55
+ type LocalStorageOptions,
56
+ type StorageAdapter,
57
+ type StorageErrorCode,
58
+ type StorageObjectMetadata,
59
+ type StoragePutOptions,
60
+ type StorageReadOptions,
61
+ type StorageWriteValue,
62
+ type StoredUploadedObject,
63
+ type StoreUploadedFileOptions,
64
+ } from "../../server/src/storage.js";
65
+
66
+ export {
67
+ createStorageResponse,
68
+ isStorageRangeError,
69
+
70
+ type StorageResponseOptions,
71
+ } from "../../server/src/file-delivery.js";
72
+
33
73
  export {
34
74
  json,
35
75
  redirect,
@@ -0,0 +1,91 @@
1
+ export interface DevRouteGraphEntry {
2
+ pathname: string;
3
+ filePath: string;
4
+ layouts: string[];
5
+ }
6
+
7
+ export function hasDevClientRouteGraphChanged(
8
+ currentRoutes: DevRouteGraphEntry[],
9
+ nextRoutes: DevRouteGraphEntry[]
10
+ ): boolean {
11
+ if (
12
+ currentRoutes.length !==
13
+ nextRoutes.length
14
+ ) {
15
+ return true;
16
+ }
17
+
18
+ const current =
19
+ normalizeRouteGraph(
20
+ currentRoutes
21
+ );
22
+ const next =
23
+ normalizeRouteGraph(
24
+ nextRoutes
25
+ );
26
+
27
+ for (
28
+ let index = 0;
29
+ index < current.length;
30
+ index++
31
+ ) {
32
+ const left =
33
+ current[index];
34
+ const right =
35
+ next[index];
36
+
37
+ if (
38
+ left.pathname !==
39
+ right.pathname ||
40
+ left.filePath !==
41
+ right.filePath ||
42
+ left.layouts.length !==
43
+ right.layouts.length
44
+ ) {
45
+ return true;
46
+ }
47
+
48
+ for (
49
+ let layoutIndex = 0;
50
+ layoutIndex <
51
+ left.layouts.length;
52
+ layoutIndex++
53
+ ) {
54
+ if (
55
+ left.layouts[
56
+ layoutIndex
57
+ ] !==
58
+ right.layouts[
59
+ layoutIndex
60
+ ]
61
+ ) {
62
+ return true;
63
+ }
64
+ }
65
+ }
66
+
67
+ return false;
68
+ }
69
+
70
+ function normalizeRouteGraph(
71
+ routes: DevRouteGraphEntry[]
72
+ ): DevRouteGraphEntry[] {
73
+ return routes
74
+ .map(
75
+ (route) => ({
76
+ pathname:
77
+ route.pathname,
78
+ filePath:
79
+ route.filePath,
80
+ layouts: [
81
+ ...route.layouts,
82
+ ],
83
+ })
84
+ )
85
+ .sort(
86
+ (left, right) =>
87
+ left.pathname.localeCompare(
88
+ right.pathname
89
+ )
90
+ );
91
+ }