@chidchanun/bcp 0.1.8 → 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.
Files changed (29) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +218 -7
  3. package/docs/application-modules.md +63 -3
  4. package/docs/releasing.md +34 -19
  5. package/docs/route-guards.md +240 -0
  6. package/docs/server-data-loaders.md +240 -0
  7. package/docs/updating.md +130 -0
  8. package/package.json +1 -1
  9. package/packages/bundler/src/server-production-guards.ts +497 -0
  10. package/packages/bundler/src/server-production-middleware.ts +33 -8
  11. package/packages/bundler/src/server-production.ts +25 -0
  12. package/packages/cli/src/args.ts +58 -0
  13. package/packages/cli/src/index.ts +41 -13
  14. package/packages/cli/src/update.ts +860 -0
  15. package/packages/client/src/index.tsx +5 -0
  16. package/packages/client/src/loader-data.tsx +229 -0
  17. package/packages/client/src/router-v2.tsx +254 -29
  18. package/packages/server/src/dev-navigation-target.ts +188 -0
  19. package/packages/server/src/index.ts +209 -119
  20. package/packages/server/src/navigation-payload.ts +24 -1
  21. package/packages/server/src/navigation-response.ts +284 -0
  22. package/packages/server/src/page-guard.ts +529 -0
  23. package/packages/server/src/page-loader.ts +643 -0
  24. package/packages/server/src/standalone-production-runtime-v2-guard.ts +815 -0
  25. package/packages/server/src/standalone-production-runtime-v2-navigation.ts +785 -0
  26. package/packages/server/src/standalone-production-runtime-v2.ts +131 -19
  27. package/packages/server/src/standalone-production-runtime-v3.ts +1 -1
  28. package/packages/server/src/standalone-production-runtime-v4.ts +42 -2
  29. package/packages/server/src/static-dev-server.ts +21 -17
@@ -0,0 +1,643 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ pathToFileURL,
5
+ } from "node:url";
6
+
7
+ import {
8
+ executePageGuards,
9
+ type PageGuardData,
10
+ } from "./page-guard.js";
11
+
12
+ import {
13
+ headers,
14
+ } from "./request-context.js";
15
+
16
+ export const LOADER_DATA_PARAM =
17
+ "__bcp_loader_data";
18
+ export const GUARD_DATA_PARAM =
19
+ "__bcp_guard_data";
20
+ export const GUARD_DATA_HEADER =
21
+ "x-bcp-guard-data";
22
+
23
+ const SERVER_DATA_MARKER =
24
+ "__bcp_server_data";
25
+
26
+ export interface PageLoaderContext {
27
+ params: Record<string, any>;
28
+ searchParams: URLSearchParams;
29
+ guardData: Readonly<PageGuardData>;
30
+ }
31
+
32
+ export type PageLoader = (
33
+ context: PageLoaderContext
34
+ ) =>
35
+ | unknown
36
+ | Promise<unknown>;
37
+
38
+ export interface PageServerDataEnvelope {
39
+ __bcp_server_data: true;
40
+ hasPageLoader: boolean;
41
+ hasGuard: boolean;
42
+ loaderData: unknown;
43
+ guardData: PageGuardData;
44
+ }
45
+
46
+ export interface PageLoaderExecution {
47
+ /*
48
+ * hasLoader remains the SSR-provider switch for backward compatibility.
49
+ * It is true when either loader or guard data must be made available to
50
+ * React during server rendering.
51
+ */
52
+ hasLoader: boolean;
53
+ hasPageLoader?: boolean;
54
+ hasGuard?: boolean;
55
+ data: unknown;
56
+ loaderData?: unknown;
57
+ guardData?: PageGuardData;
58
+ response: Response | null;
59
+ }
60
+
61
+ export function findPageLoaderFile(
62
+ pageFilePath: string
63
+ ): string | null {
64
+ const directory =
65
+ path.dirname(
66
+ pageFilePath
67
+ );
68
+ const tsFile =
69
+ path.join(
70
+ directory,
71
+ "loader.ts"
72
+ );
73
+ const tsxFile =
74
+ path.join(
75
+ directory,
76
+ "loader.tsx"
77
+ );
78
+ const hasTs =
79
+ fs.existsSync(
80
+ tsFile
81
+ );
82
+ const hasTsx =
83
+ fs.existsSync(
84
+ tsxFile
85
+ );
86
+
87
+ if (hasTs && hasTsx) {
88
+ throw new Error(
89
+ `BCP Framework: both loader.ts and loader.tsx exist in "${directory}".`
90
+ );
91
+ }
92
+
93
+ if (hasTs) {
94
+ return tsFile;
95
+ }
96
+
97
+ if (hasTsx) {
98
+ return tsxFile;
99
+ }
100
+
101
+ return null;
102
+ }
103
+
104
+ export function routeHasPageLoader(
105
+ pageFilePath: string
106
+ ): boolean {
107
+ return findPageLoaderFile(
108
+ pageFilePath
109
+ ) !== null;
110
+ }
111
+
112
+ export async function executePageLoader(
113
+ pageFilePath: string,
114
+ params: Record<string, any>,
115
+ requestUrl: URL,
116
+ moduleVersion = 0
117
+ ): Promise<PageLoaderExecution> {
118
+ const appDirectory =
119
+ findAppDirectory(
120
+ pageFilePath
121
+ );
122
+ const guardExecution =
123
+ await executePageGuards(
124
+ appDirectory,
125
+ pageFilePath,
126
+ params,
127
+ requestUrl,
128
+ moduleVersion
129
+ );
130
+
131
+ if (
132
+ guardExecution.response
133
+ ) {
134
+ return createExecution({
135
+ hasPageLoader:
136
+ false,
137
+ hasGuard:
138
+ guardExecution.hasGuard,
139
+ loaderData:
140
+ undefined,
141
+ guardData:
142
+ guardExecution.data,
143
+ response:
144
+ guardExecution.response,
145
+ });
146
+ }
147
+
148
+ const loaderFile =
149
+ findPageLoaderFile(
150
+ pageFilePath
151
+ );
152
+
153
+ if (!loaderFile) {
154
+ return createExecution({
155
+ hasPageLoader:
156
+ false,
157
+ hasGuard:
158
+ guardExecution.hasGuard,
159
+ loaderData:
160
+ undefined,
161
+ guardData:
162
+ guardExecution.data,
163
+ response:
164
+ null,
165
+ });
166
+ }
167
+
168
+ const moduleUrl =
169
+ pathToFileURL(
170
+ loaderFile
171
+ );
172
+
173
+ moduleUrl.searchParams.set(
174
+ "bcp-loader",
175
+ String(
176
+ moduleVersion
177
+ )
178
+ );
179
+
180
+ const loaderModule =
181
+ await import(
182
+ moduleUrl.href
183
+ ) as {
184
+ loader?: unknown;
185
+ };
186
+
187
+ if (
188
+ typeof loaderModule.loader !==
189
+ "function"
190
+ ) {
191
+ throw new Error(
192
+ `BCP Framework: loader "${loaderFile}" must export a named loader() function.`
193
+ );
194
+ }
195
+
196
+ return executePageLoaderFunction(
197
+ loaderModule.loader as
198
+ PageLoader,
199
+ params,
200
+ requestUrl,
201
+ loaderFile,
202
+ guardExecution.data,
203
+ guardExecution.hasGuard
204
+ );
205
+ }
206
+
207
+ export async function executePageLoaderFunction(
208
+ loader: PageLoader,
209
+ params: Record<string, any>,
210
+ requestUrl: URL,
211
+ label = "loader",
212
+ explicitGuardData?: PageGuardData,
213
+ explicitHasGuard?: boolean
214
+ ): Promise<PageLoaderExecution> {
215
+ const headerGuardData =
216
+ explicitGuardData ===
217
+ undefined
218
+ ? await readGuardDataHeader()
219
+ : null;
220
+ const guardData =
221
+ explicitGuardData ??
222
+ headerGuardData?.data ??
223
+ {};
224
+ const hasGuard =
225
+ explicitHasGuard ??
226
+ headerGuardData?.hasGuard ??
227
+ false;
228
+
229
+ const result =
230
+ await loader({
231
+ params: {
232
+ ...params,
233
+ },
234
+ searchParams:
235
+ new URLSearchParams(
236
+ requestUrl.searchParams
237
+ ),
238
+ guardData: {
239
+ ...guardData,
240
+ },
241
+ });
242
+
243
+ if (
244
+ result instanceof
245
+ Response
246
+ ) {
247
+ return createExecution({
248
+ hasPageLoader:
249
+ true,
250
+ hasGuard,
251
+ loaderData:
252
+ undefined,
253
+ guardData,
254
+ response:
255
+ result,
256
+ });
257
+ }
258
+
259
+ assertLoaderData(
260
+ result,
261
+ label
262
+ );
263
+
264
+ return createExecution({
265
+ hasPageLoader:
266
+ true,
267
+ hasGuard,
268
+ loaderData:
269
+ result,
270
+ guardData,
271
+ response:
272
+ null,
273
+ });
274
+ }
275
+
276
+ export function attachLoaderDataToParams(
277
+ params: Record<string, any>,
278
+ execution: PageLoaderExecution
279
+ ): Record<string, any> {
280
+ let result = {
281
+ ...params,
282
+ };
283
+ const hasGuard =
284
+ execution.hasGuard ===
285
+ true;
286
+ const guardData =
287
+ execution.guardData ??
288
+ {};
289
+ const hasPageLoader =
290
+ execution.hasPageLoader ??
291
+ execution.hasLoader;
292
+ const loaderData =
293
+ execution.hasPageLoader ===
294
+ undefined
295
+ ? execution.data
296
+ : execution.loaderData;
297
+
298
+ if (hasGuard) {
299
+ result = {
300
+ ...result,
301
+ [GUARD_DATA_PARAM]:
302
+ guardData,
303
+ };
304
+ }
305
+
306
+ if (hasPageLoader) {
307
+ result = {
308
+ ...result,
309
+ [LOADER_DATA_PARAM]:
310
+ loaderData,
311
+ };
312
+ }
313
+
314
+ return result;
315
+ }
316
+
317
+ export function isPageServerDataEnvelope(
318
+ value: unknown
319
+ ): value is PageServerDataEnvelope {
320
+ return (
321
+ value !== null &&
322
+ typeof value ===
323
+ "object" &&
324
+ (
325
+ value as
326
+ Record<string, unknown>
327
+ )[SERVER_DATA_MARKER] ===
328
+ true
329
+ );
330
+ }
331
+
332
+ function createExecution(
333
+ input: {
334
+ hasPageLoader: boolean;
335
+ hasGuard: boolean;
336
+ loaderData: unknown;
337
+ guardData: PageGuardData;
338
+ response: Response | null;
339
+ }
340
+ ): PageLoaderExecution {
341
+ const data:
342
+ PageServerDataEnvelope = {
343
+ __bcp_server_data:
344
+ true,
345
+ hasPageLoader:
346
+ input.hasPageLoader,
347
+ hasGuard:
348
+ input.hasGuard,
349
+ loaderData:
350
+ input.loaderData,
351
+ guardData: {
352
+ ...input.guardData,
353
+ },
354
+ };
355
+
356
+ return {
357
+ hasLoader:
358
+ input.hasPageLoader ||
359
+ input.hasGuard,
360
+ hasPageLoader:
361
+ input.hasPageLoader,
362
+ hasGuard:
363
+ input.hasGuard,
364
+ data,
365
+ loaderData:
366
+ input.loaderData,
367
+ guardData:
368
+ data.guardData,
369
+ response:
370
+ input.response,
371
+ };
372
+ }
373
+
374
+ async function readGuardDataHeader():
375
+ Promise<{
376
+ hasGuard: true;
377
+ data: PageGuardData;
378
+ } | null> {
379
+ let requestHeaders:
380
+ Headers;
381
+
382
+ try {
383
+ requestHeaders =
384
+ await headers();
385
+ } catch {
386
+ return null;
387
+ }
388
+
389
+ const encoded =
390
+ requestHeaders.get(
391
+ GUARD_DATA_HEADER
392
+ );
393
+
394
+ if (!encoded) {
395
+ return null;
396
+ }
397
+
398
+ let parsed:
399
+ unknown;
400
+
401
+ try {
402
+ parsed =
403
+ JSON.parse(
404
+ Buffer.from(
405
+ encoded,
406
+ "base64url"
407
+ ).toString(
408
+ "utf8"
409
+ )
410
+ );
411
+ } catch {
412
+ throw new Error(
413
+ "BCP Framework: invalid internal guard data header."
414
+ );
415
+ }
416
+
417
+ assertGuardHeaderData(
418
+ parsed
419
+ );
420
+
421
+ return {
422
+ hasGuard:
423
+ true,
424
+ data:
425
+ parsed,
426
+ };
427
+ }
428
+
429
+ function assertGuardHeaderData(
430
+ value: unknown
431
+ ): asserts value is PageGuardData {
432
+ if (
433
+ value === null ||
434
+ Array.isArray(
435
+ value
436
+ ) ||
437
+ typeof value !==
438
+ "object"
439
+ ) {
440
+ throw new Error(
441
+ "BCP Framework: invalid internal guard data header."
442
+ );
443
+ }
444
+
445
+ const prototype =
446
+ Object.getPrototypeOf(
447
+ value
448
+ );
449
+
450
+ if (
451
+ prototype !==
452
+ Object.prototype &&
453
+ prototype !==
454
+ null
455
+ ) {
456
+ throw new Error(
457
+ "BCP Framework: invalid internal guard data header."
458
+ );
459
+ }
460
+ }
461
+
462
+ function findAppDirectory(
463
+ pageFilePath: string
464
+ ): string {
465
+ let current =
466
+ path.dirname(
467
+ path.resolve(
468
+ pageFilePath
469
+ )
470
+ );
471
+
472
+ while (true) {
473
+ if (
474
+ path.basename(
475
+ current
476
+ ) === "app"
477
+ ) {
478
+ return current;
479
+ }
480
+
481
+ const parent =
482
+ path.dirname(
483
+ current
484
+ );
485
+
486
+ if (parent === current) {
487
+ throw new Error(
488
+ `BCP Framework: could not resolve app directory for page "${pageFilePath}".`
489
+ );
490
+ }
491
+
492
+ current =
493
+ parent;
494
+ }
495
+ }
496
+
497
+ function assertLoaderData(
498
+ value: unknown,
499
+ loaderLabel: string
500
+ ): void {
501
+ const seen =
502
+ new Set<object>();
503
+
504
+ validateJsonValue(
505
+ value,
506
+ "$",
507
+ seen,
508
+ loaderLabel
509
+ );
510
+ }
511
+
512
+ function validateJsonValue(
513
+ value: unknown,
514
+ location: string,
515
+ seen: Set<object>,
516
+ loaderLabel: string
517
+ ): void {
518
+ if (
519
+ value === null ||
520
+ typeof value ===
521
+ "string" ||
522
+ typeof value ===
523
+ "boolean"
524
+ ) {
525
+ return;
526
+ }
527
+
528
+ if (
529
+ typeof value ===
530
+ "number"
531
+ ) {
532
+ if (
533
+ Number.isFinite(
534
+ value
535
+ )
536
+ ) {
537
+ return;
538
+ }
539
+
540
+ throwInvalidLoaderData(
541
+ loaderLabel,
542
+ location,
543
+ "numbers must be finite"
544
+ );
545
+ }
546
+
547
+ if (
548
+ typeof value !==
549
+ "object"
550
+ ) {
551
+ throwInvalidLoaderData(
552
+ loaderLabel,
553
+ location,
554
+ `unsupported ${typeof value} value`
555
+ );
556
+ }
557
+
558
+ const objectValue =
559
+ value as object;
560
+
561
+ if (seen.has(objectValue)) {
562
+ throwInvalidLoaderData(
563
+ loaderLabel,
564
+ location,
565
+ "circular references are not supported"
566
+ );
567
+ }
568
+
569
+ seen.add(
570
+ objectValue
571
+ );
572
+
573
+ try {
574
+ if (Array.isArray(value)) {
575
+ value.forEach(
576
+ (
577
+ item,
578
+ index
579
+ ) => {
580
+ validateJsonValue(
581
+ item,
582
+ `${location}[${index}]`,
583
+ seen,
584
+ loaderLabel
585
+ );
586
+ }
587
+ );
588
+ return;
589
+ }
590
+
591
+ const prototype =
592
+ Object.getPrototypeOf(
593
+ value
594
+ );
595
+
596
+ if (
597
+ prototype !==
598
+ Object.prototype &&
599
+ prototype !==
600
+ null
601
+ ) {
602
+ throwInvalidLoaderData(
603
+ loaderLabel,
604
+ location,
605
+ "only plain objects and arrays are supported"
606
+ );
607
+ }
608
+
609
+ for (
610
+ const [
611
+ key,
612
+ item,
613
+ ]
614
+ of Object.entries(
615
+ value as Record<
616
+ string,
617
+ unknown
618
+ >
619
+ )
620
+ ) {
621
+ validateJsonValue(
622
+ item,
623
+ `${location}.${key}`,
624
+ seen,
625
+ loaderLabel
626
+ );
627
+ }
628
+ } finally {
629
+ seen.delete(
630
+ objectValue
631
+ );
632
+ }
633
+ }
634
+
635
+ function throwInvalidLoaderData(
636
+ loaderLabel: string,
637
+ location: string,
638
+ reason: string
639
+ ): never {
640
+ throw new Error(
641
+ `BCP Framework: loader "${loaderLabel}" returned non-serializable data at ${location}: ${reason}.`
642
+ );
643
+ }