@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.
- package/README.md +658 -578
- package/docs/README.md +317 -318
- package/docs/auth-route-guards.md +14 -0
- package/docs/developer-tools.md +45 -22
- package/docs/file-upload.md +280 -0
- package/docs/releases/0.1.24.md +166 -0
- package/docs/releases/0.1.25.md +132 -0
- package/docs/storage.md +285 -0
- package/package.json +1 -1
- package/packages/bundler/src/server-production-actions.ts +15 -0
- package/packages/bundler/src/server-production-guards.ts +15 -0
- package/packages/client/src/server.ts +40 -0
- package/packages/server/src/dev-route-graph.ts +91 -0
- package/packages/server/src/file-delivery.ts +629 -0
- package/packages/server/src/middleware-dev-server.ts +217 -9
- package/packages/server/src/storage.ts +829 -0
- package/packages/server/src/upload.ts +556 -0
package/docs/storage.md
ADDED
|
@@ -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.
|
|
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
|
+
}
|