@chidchanun/bcp 0.1.9 → 0.1.10
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/CHANGELOG.md +41 -0
- package/README.md +141 -8
- package/docs/releasing.md +34 -19
- package/docs/route-guards.md +240 -0
- package/docs/updating.md +130 -0
- package/package.json +1 -1
- package/packages/bundler/src/server-production-guards.ts +497 -0
- package/packages/bundler/src/server-production-middleware.ts +33 -8
- package/packages/cli/src/args.ts +58 -0
- package/packages/cli/src/index.ts +41 -13
- package/packages/cli/src/update.ts +860 -0
- package/packages/client/src/index.tsx +1 -0
- package/packages/client/src/loader-data.tsx +171 -41
- package/packages/server/src/page-guard.ts +529 -0
- package/packages/server/src/page-loader.ts +317 -20
- package/packages/server/src/standalone-production-runtime-v2-guard.ts +815 -0
- package/packages/server/src/standalone-production-runtime-v3.ts +1 -1
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
pathToFileURL,
|
|
5
|
+
} from "node:url";
|
|
6
|
+
|
|
7
|
+
export const GUARD_DATA_PARAM =
|
|
8
|
+
"__bcp_guard_data";
|
|
9
|
+
|
|
10
|
+
export type PageGuardData =
|
|
11
|
+
Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
export interface PageGuardContext {
|
|
14
|
+
params: Record<string, any>;
|
|
15
|
+
searchParams: URLSearchParams;
|
|
16
|
+
guardData: Readonly<PageGuardData>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type PageGuard = (
|
|
20
|
+
context: PageGuardContext
|
|
21
|
+
) =>
|
|
22
|
+
| void
|
|
23
|
+
| PageGuardData
|
|
24
|
+
| Response
|
|
25
|
+
| Promise<
|
|
26
|
+
| void
|
|
27
|
+
| PageGuardData
|
|
28
|
+
| Response
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
export interface PageGuardExecution {
|
|
32
|
+
hasGuard: boolean;
|
|
33
|
+
data: PageGuardData;
|
|
34
|
+
response: Response | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PageGuardDefinition {
|
|
38
|
+
filePath: string;
|
|
39
|
+
guard: PageGuard;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function findPageGuardFiles(
|
|
43
|
+
appDirectory: string,
|
|
44
|
+
pageFilePath: string
|
|
45
|
+
): string[] {
|
|
46
|
+
const appRoot =
|
|
47
|
+
path.resolve(
|
|
48
|
+
appDirectory
|
|
49
|
+
);
|
|
50
|
+
const pageDirectory =
|
|
51
|
+
path.dirname(
|
|
52
|
+
path.resolve(
|
|
53
|
+
pageFilePath
|
|
54
|
+
)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (
|
|
58
|
+
!isInsideDirectory(
|
|
59
|
+
appRoot,
|
|
60
|
+
pageDirectory
|
|
61
|
+
)
|
|
62
|
+
) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`BCP Framework: page "${pageFilePath}" is outside app directory "${appDirectory}".`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const directories:
|
|
69
|
+
string[] = [];
|
|
70
|
+
let current =
|
|
71
|
+
pageDirectory;
|
|
72
|
+
|
|
73
|
+
while (true) {
|
|
74
|
+
directories.push(
|
|
75
|
+
current
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (current === appRoot) {
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const parent =
|
|
83
|
+
path.dirname(
|
|
84
|
+
current
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (
|
|
88
|
+
parent === current ||
|
|
89
|
+
!isInsideDirectory(
|
|
90
|
+
appRoot,
|
|
91
|
+
parent
|
|
92
|
+
)
|
|
93
|
+
) {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
current =
|
|
98
|
+
parent;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
directories.reverse();
|
|
102
|
+
|
|
103
|
+
const files:
|
|
104
|
+
string[] = [];
|
|
105
|
+
|
|
106
|
+
for (const directory of directories) {
|
|
107
|
+
const guardFile =
|
|
108
|
+
findGuardFileInDirectory(
|
|
109
|
+
directory
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (guardFile) {
|
|
113
|
+
files.push(
|
|
114
|
+
guardFile
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return files;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function routeHasPageGuard(
|
|
123
|
+
appDirectory: string,
|
|
124
|
+
pageFilePath: string
|
|
125
|
+
): boolean {
|
|
126
|
+
return findPageGuardFiles(
|
|
127
|
+
appDirectory,
|
|
128
|
+
pageFilePath
|
|
129
|
+
).length > 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function executePageGuards(
|
|
133
|
+
appDirectory: string,
|
|
134
|
+
pageFilePath: string,
|
|
135
|
+
params: Record<string, any>,
|
|
136
|
+
requestUrl: URL,
|
|
137
|
+
moduleVersion = 0
|
|
138
|
+
): Promise<PageGuardExecution> {
|
|
139
|
+
const guardFiles =
|
|
140
|
+
findPageGuardFiles(
|
|
141
|
+
appDirectory,
|
|
142
|
+
pageFilePath
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (guardFiles.length === 0) {
|
|
146
|
+
return emptyGuardExecution();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const definitions:
|
|
150
|
+
PageGuardDefinition[] = [];
|
|
151
|
+
|
|
152
|
+
for (const guardFile of guardFiles) {
|
|
153
|
+
const moduleUrl =
|
|
154
|
+
pathToFileURL(
|
|
155
|
+
guardFile
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
moduleUrl.searchParams.set(
|
|
159
|
+
"bcp-guard",
|
|
160
|
+
String(
|
|
161
|
+
moduleVersion
|
|
162
|
+
)
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const guardModule =
|
|
166
|
+
await import(
|
|
167
|
+
moduleUrl.href
|
|
168
|
+
) as {
|
|
169
|
+
guard?: unknown;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (
|
|
173
|
+
typeof guardModule.guard !==
|
|
174
|
+
"function"
|
|
175
|
+
) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`BCP Framework: guard "${guardFile}" must export a named guard() function.`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
definitions.push({
|
|
182
|
+
filePath:
|
|
183
|
+
guardFile,
|
|
184
|
+
guard:
|
|
185
|
+
guardModule.guard as PageGuard,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return executePageGuardFunctions(
|
|
190
|
+
definitions,
|
|
191
|
+
params,
|
|
192
|
+
requestUrl
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function executePageGuardFunctions(
|
|
197
|
+
guards: PageGuardDefinition[],
|
|
198
|
+
params: Record<string, any>,
|
|
199
|
+
requestUrl: URL
|
|
200
|
+
): Promise<PageGuardExecution> {
|
|
201
|
+
if (guards.length === 0) {
|
|
202
|
+
return emptyGuardExecution();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const guardData:
|
|
206
|
+
PageGuardData = {};
|
|
207
|
+
|
|
208
|
+
for (const definition of guards) {
|
|
209
|
+
const result =
|
|
210
|
+
await definition.guard({
|
|
211
|
+
params: {
|
|
212
|
+
...params,
|
|
213
|
+
},
|
|
214
|
+
searchParams:
|
|
215
|
+
new URLSearchParams(
|
|
216
|
+
requestUrl.searchParams
|
|
217
|
+
),
|
|
218
|
+
guardData: {
|
|
219
|
+
...guardData,
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (
|
|
224
|
+
result instanceof
|
|
225
|
+
Response
|
|
226
|
+
) {
|
|
227
|
+
return {
|
|
228
|
+
hasGuard:
|
|
229
|
+
true,
|
|
230
|
+
data:
|
|
231
|
+
guardData,
|
|
232
|
+
response:
|
|
233
|
+
result,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (
|
|
238
|
+
result === undefined
|
|
239
|
+
) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
assertGuardData(
|
|
244
|
+
result,
|
|
245
|
+
definition.filePath
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
Object.assign(
|
|
249
|
+
guardData,
|
|
250
|
+
result
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
hasGuard:
|
|
256
|
+
true,
|
|
257
|
+
data:
|
|
258
|
+
guardData,
|
|
259
|
+
response:
|
|
260
|
+
null,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function attachGuardDataToParams(
|
|
265
|
+
params: Record<string, any>,
|
|
266
|
+
execution: PageGuardExecution
|
|
267
|
+
): Record<string, any> {
|
|
268
|
+
if (!execution.hasGuard) {
|
|
269
|
+
return {
|
|
270
|
+
...params,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
...params,
|
|
276
|
+
[GUARD_DATA_PARAM]:
|
|
277
|
+
execution.data,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function emptyGuardExecution(): PageGuardExecution {
|
|
282
|
+
return {
|
|
283
|
+
hasGuard:
|
|
284
|
+
false,
|
|
285
|
+
data: {},
|
|
286
|
+
response:
|
|
287
|
+
null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function findGuardFileInDirectory(
|
|
292
|
+
directory: string
|
|
293
|
+
): string | null {
|
|
294
|
+
const tsFile =
|
|
295
|
+
path.join(
|
|
296
|
+
directory,
|
|
297
|
+
"guard.ts"
|
|
298
|
+
);
|
|
299
|
+
const tsxFile =
|
|
300
|
+
path.join(
|
|
301
|
+
directory,
|
|
302
|
+
"guard.tsx"
|
|
303
|
+
);
|
|
304
|
+
const hasTs =
|
|
305
|
+
fs.existsSync(
|
|
306
|
+
tsFile
|
|
307
|
+
);
|
|
308
|
+
const hasTsx =
|
|
309
|
+
fs.existsSync(
|
|
310
|
+
tsxFile
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
if (hasTs && hasTsx) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`BCP Framework: both guard.ts and guard.tsx exist in "${directory}".`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (hasTs) {
|
|
320
|
+
return tsFile;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (hasTsx) {
|
|
324
|
+
return tsxFile;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function assertGuardData(
|
|
331
|
+
value: unknown,
|
|
332
|
+
label: string
|
|
333
|
+
): asserts value is PageGuardData {
|
|
334
|
+
if (
|
|
335
|
+
value === null ||
|
|
336
|
+
Array.isArray(
|
|
337
|
+
value
|
|
338
|
+
) ||
|
|
339
|
+
typeof value !==
|
|
340
|
+
"object"
|
|
341
|
+
) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
`BCP Framework: guard "${label}" must return a plain object, undefined, or a Response.`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const prototype =
|
|
348
|
+
Object.getPrototypeOf(
|
|
349
|
+
value
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
if (
|
|
353
|
+
prototype !==
|
|
354
|
+
Object.prototype &&
|
|
355
|
+
prototype !==
|
|
356
|
+
null
|
|
357
|
+
) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
`BCP Framework: guard "${label}" must return a plain object, undefined, or a Response.`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const seen =
|
|
364
|
+
new Set<object>();
|
|
365
|
+
|
|
366
|
+
validateJsonValue(
|
|
367
|
+
value,
|
|
368
|
+
"$",
|
|
369
|
+
seen,
|
|
370
|
+
label
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function validateJsonValue(
|
|
375
|
+
value: unknown,
|
|
376
|
+
location: string,
|
|
377
|
+
seen: Set<object>,
|
|
378
|
+
guardLabel: string
|
|
379
|
+
): void {
|
|
380
|
+
if (
|
|
381
|
+
value === null ||
|
|
382
|
+
typeof value ===
|
|
383
|
+
"string" ||
|
|
384
|
+
typeof value ===
|
|
385
|
+
"boolean"
|
|
386
|
+
) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (
|
|
391
|
+
typeof value ===
|
|
392
|
+
"number"
|
|
393
|
+
) {
|
|
394
|
+
if (
|
|
395
|
+
Number.isFinite(
|
|
396
|
+
value
|
|
397
|
+
)
|
|
398
|
+
) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
throwInvalidGuardData(
|
|
403
|
+
guardLabel,
|
|
404
|
+
location,
|
|
405
|
+
"numbers must be finite"
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (
|
|
410
|
+
typeof value !==
|
|
411
|
+
"object"
|
|
412
|
+
) {
|
|
413
|
+
throwInvalidGuardData(
|
|
414
|
+
guardLabel,
|
|
415
|
+
location,
|
|
416
|
+
`unsupported ${typeof value} value`
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const objectValue =
|
|
421
|
+
value as object;
|
|
422
|
+
|
|
423
|
+
if (seen.has(objectValue)) {
|
|
424
|
+
throwInvalidGuardData(
|
|
425
|
+
guardLabel,
|
|
426
|
+
location,
|
|
427
|
+
"circular references are not supported"
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
seen.add(
|
|
432
|
+
objectValue
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
if (Array.isArray(value)) {
|
|
437
|
+
value.forEach(
|
|
438
|
+
(
|
|
439
|
+
item,
|
|
440
|
+
index
|
|
441
|
+
) => {
|
|
442
|
+
validateJsonValue(
|
|
443
|
+
item,
|
|
444
|
+
`${location}[${index}]`,
|
|
445
|
+
seen,
|
|
446
|
+
guardLabel
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const prototype =
|
|
454
|
+
Object.getPrototypeOf(
|
|
455
|
+
value
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
if (
|
|
459
|
+
prototype !==
|
|
460
|
+
Object.prototype &&
|
|
461
|
+
prototype !==
|
|
462
|
+
null
|
|
463
|
+
) {
|
|
464
|
+
throwInvalidGuardData(
|
|
465
|
+
guardLabel,
|
|
466
|
+
location,
|
|
467
|
+
"only plain objects and arrays are supported"
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
for (
|
|
472
|
+
const [
|
|
473
|
+
key,
|
|
474
|
+
item,
|
|
475
|
+
]
|
|
476
|
+
of Object.entries(
|
|
477
|
+
value as Record<
|
|
478
|
+
string,
|
|
479
|
+
unknown
|
|
480
|
+
>
|
|
481
|
+
)
|
|
482
|
+
) {
|
|
483
|
+
validateJsonValue(
|
|
484
|
+
item,
|
|
485
|
+
`${location}.${key}`,
|
|
486
|
+
seen,
|
|
487
|
+
guardLabel
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
} finally {
|
|
491
|
+
seen.delete(
|
|
492
|
+
objectValue
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function throwInvalidGuardData(
|
|
498
|
+
guardLabel: string,
|
|
499
|
+
location: string,
|
|
500
|
+
reason: string
|
|
501
|
+
): never {
|
|
502
|
+
throw new Error(
|
|
503
|
+
`BCP Framework: guard "${guardLabel}" returned non-serializable data at ${location}: ${reason}.`
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function isInsideDirectory(
|
|
508
|
+
rootDirectory: string,
|
|
509
|
+
candidate: string
|
|
510
|
+
): boolean {
|
|
511
|
+
const relative =
|
|
512
|
+
path.relative(
|
|
513
|
+
rootDirectory,
|
|
514
|
+
candidate
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
return (
|
|
518
|
+
relative === "" ||
|
|
519
|
+
(
|
|
520
|
+
relative !== ".." &&
|
|
521
|
+
!relative.startsWith(
|
|
522
|
+
`..${path.sep}`
|
|
523
|
+
) &&
|
|
524
|
+
!path.isAbsolute(
|
|
525
|
+
relative
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
);
|
|
529
|
+
}
|