@chidchanun/bcp 0.1.8 → 0.1.9

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.
@@ -51,9 +51,9 @@ export interface LinkProps
51
51
  scroll?: boolean;
52
52
  }
53
53
 
54
- interface NavigationPayload {
54
+ interface NavigationRoutePayload {
55
55
  route?: string;
56
- params?: Record<string, string>;
56
+ params?: Record<string, unknown>;
57
57
  clientScript: string;
58
58
  clientImports: string[];
59
59
  title: string;
@@ -62,6 +62,17 @@ interface NavigationPayload {
62
62
  loadingHtml: string | null;
63
63
  }
64
64
 
65
+ interface NavigationRedirectPayload {
66
+ redirect: {
67
+ location: string;
68
+ status: number;
69
+ };
70
+ }
71
+
72
+ type NavigationPayload =
73
+ | NavigationRoutePayload
74
+ | NavigationRedirectPayload;
75
+
65
76
  interface BcpDevConfig {
66
77
  eventsPath: string;
67
78
  clientScript: string;
@@ -102,6 +113,12 @@ declare global {
102
113
  let navigationController:
103
114
  AbortController | null = null;
104
115
 
116
+ let navigationSequence =
117
+ 0;
118
+
119
+ const MAX_REDIRECTS =
120
+ 10;
121
+
105
122
  export async function navigate(
106
123
  href: string,
107
124
  options: NavigationOptions = {}
@@ -354,13 +371,31 @@ async function loadNavigation(
354
371
  target: URL,
355
372
  options: NavigationOptions & {
356
373
  fromHistory?: boolean;
374
+ redirectCount?: number;
357
375
  }
358
376
  ): Promise<void> {
359
377
  ensureNavigationRuntime();
378
+
379
+ const redirectCount =
380
+ options.redirectCount ??
381
+ 0;
382
+
383
+ if (
384
+ redirectCount >
385
+ MAX_REDIRECTS
386
+ ) {
387
+ throw new Error(
388
+ `BCP Framework: navigation exceeded ${MAX_REDIRECTS} redirects.`
389
+ );
390
+ }
391
+
360
392
  navigationController?.abort();
361
393
 
362
394
  const controller =
363
395
  new AbortController();
396
+ const navigationId =
397
+ ++navigationSequence;
398
+
364
399
  navigationController =
365
400
  controller;
366
401
 
@@ -383,6 +418,8 @@ async function loadNavigation(
383
418
  )}`,
384
419
  {
385
420
  method: "GET",
421
+ credentials:
422
+ "same-origin",
386
423
  headers: {
387
424
  Accept:
388
425
  "application/json",
@@ -394,6 +431,11 @@ async function loadNavigation(
394
431
  }
395
432
  );
396
433
 
434
+ assertCurrentNavigation(
435
+ controller,
436
+ navigationId
437
+ );
438
+
397
439
  if (!response.ok) {
398
440
  window.location.assign(
399
441
  target.href
@@ -406,6 +448,11 @@ async function loadNavigation(
406
448
  response
407
449
  );
408
450
 
451
+ assertCurrentNavigation(
452
+ controller,
453
+ navigationId
454
+ );
455
+
409
456
  if (!payload) {
410
457
  window.location.assign(
411
458
  target.href
@@ -413,6 +460,54 @@ async function loadNavigation(
413
460
  return;
414
461
  }
415
462
 
463
+ if (
464
+ isNavigationRedirectPayload(
465
+ payload
466
+ )
467
+ ) {
468
+ const redirectTarget =
469
+ new URL(
470
+ payload.redirect.location,
471
+ target.href
472
+ );
473
+
474
+ if (
475
+ redirectTarget.origin !==
476
+ window.location.origin
477
+ ) {
478
+ window.location.assign(
479
+ redirectTarget.href
480
+ );
481
+ return;
482
+ }
483
+
484
+ const redirectedOptions =
485
+ options.fromHistory
486
+ ? {
487
+ replace:
488
+ true,
489
+ scroll:
490
+ options.scroll,
491
+ fromHistory:
492
+ false,
493
+ redirectCount:
494
+ redirectCount +
495
+ 1,
496
+ }
497
+ : {
498
+ ...options,
499
+ redirectCount:
500
+ redirectCount +
501
+ 1,
502
+ };
503
+
504
+ await loadNavigation(
505
+ redirectTarget,
506
+ redirectedOptions
507
+ );
508
+ return;
509
+ }
510
+
416
511
  setNavigationState({
417
512
  targetRoute:
418
513
  payload.route ??
@@ -425,8 +520,15 @@ async function loadNavigation(
425
520
  preloadClientImports(
426
521
  payload.clientImports
427
522
  );
523
+
524
+ assertCurrentNavigation(
525
+ controller,
526
+ navigationId
527
+ );
528
+
428
529
  applyNavigationPayload(
429
- payload
530
+ payload,
531
+ navigationId
430
532
  );
431
533
 
432
534
  if (!options.fromHistory) {
@@ -443,11 +545,29 @@ async function loadNavigation(
443
545
  target
444
546
  );
445
547
  await restartDevRuntime();
548
+
549
+ assertCurrentNavigation(
550
+ controller,
551
+ navigationId
552
+ );
553
+
446
554
  await importClientBundle(
447
- payload.clientScript
555
+ payload.clientScript,
556
+ navigationId
557
+ );
558
+
559
+ assertCurrentNavigation(
560
+ controller,
561
+ navigationId
448
562
  );
563
+
449
564
  await waitForNextPaint();
450
565
 
566
+ assertCurrentNavigation(
567
+ controller,
568
+ navigationId
569
+ );
570
+
451
571
  if (options.scroll !== false) {
452
572
  if (target.hash) {
453
573
  scrollToHash(
@@ -463,7 +583,9 @@ async function loadNavigation(
463
583
  } finally {
464
584
  if (
465
585
  navigationController ===
466
- controller
586
+ controller &&
587
+ navigationId ===
588
+ navigationSequence
467
589
  ) {
468
590
  navigationController =
469
591
  null;
@@ -479,28 +601,41 @@ async function parseNavigationPayload(
479
601
  try {
480
602
  const payload =
481
603
  await response.json() as
482
- Partial<NavigationPayload>;
604
+ Record<string, unknown>;
483
605
 
484
606
  if (
485
- typeof payload.clientScript !==
607
+ isNavigationRedirectPayload(
608
+ payload
609
+ )
610
+ ) {
611
+ return payload;
612
+ }
613
+
614
+ const routePayload =
615
+ payload as Partial<
616
+ NavigationRoutePayload
617
+ >;
618
+
619
+ if (
620
+ typeof routePayload.clientScript !==
486
621
  "string" ||
487
- typeof payload.title !==
622
+ typeof routePayload.title !==
488
623
  "string" ||
489
- typeof payload.version !==
624
+ typeof routePayload.version !==
490
625
  "number" ||
491
626
  !(
492
- payload.loadingHtml === null ||
493
- typeof payload.loadingHtml ===
627
+ routePayload.loadingHtml === null ||
628
+ typeof routePayload.loadingHtml ===
494
629
  "string"
495
630
  ) ||
496
631
  !(
497
- payload.clientImports ===
632
+ routePayload.clientImports ===
498
633
  undefined ||
499
634
  (
500
635
  Array.isArray(
501
- payload.clientImports
636
+ routePayload.clientImports
502
637
  ) &&
503
- payload.clientImports.every(
638
+ routePayload.clientImports.every(
504
639
  (item) =>
505
640
  typeof item ===
506
641
  "string"
@@ -508,10 +643,22 @@ async function parseNavigationPayload(
508
643
  )
509
644
  ) ||
510
645
  !(
511
- payload.metadata === undefined ||
512
- payload.metadata === null ||
646
+ routePayload.metadata === undefined ||
647
+ routePayload.metadata === null ||
513
648
  isResolvedMetadata(
514
- payload.metadata
649
+ routePayload.metadata
650
+ )
651
+ ) ||
652
+ !(
653
+ routePayload.params === undefined ||
654
+ (
655
+ typeof routePayload.params ===
656
+ "object" &&
657
+ routePayload.params !==
658
+ null &&
659
+ !Array.isArray(
660
+ routePayload.params
661
+ )
515
662
  )
516
663
  )
517
664
  ) {
@@ -520,29 +667,29 @@ async function parseNavigationPayload(
520
667
 
521
668
  return {
522
669
  route:
523
- payload.route,
670
+ routePayload.route,
524
671
  params:
525
- payload.params ?? {},
672
+ routePayload.params ?? {},
526
673
  clientScript:
527
674
  stripQuery(
528
- payload.clientScript
675
+ routePayload.clientScript
529
676
  ),
530
677
  clientImports:
531
678
  (
532
- payload.clientImports ??
679
+ routePayload.clientImports ??
533
680
  []
534
681
  ).map(
535
682
  stripQuery
536
683
  ),
537
684
  title:
538
- payload.title,
685
+ routePayload.title,
539
686
  metadata:
540
- payload.metadata ??
687
+ routePayload.metadata ??
541
688
  null,
542
689
  version:
543
- payload.version,
690
+ routePayload.version,
544
691
  loadingHtml:
545
- payload.loadingHtml,
692
+ routePayload.loadingHtml,
546
693
  };
547
694
  } catch {
548
695
  return null;
@@ -550,7 +697,8 @@ async function parseNavigationPayload(
550
697
  }
551
698
 
552
699
  function applyNavigationPayload(
553
- payload: NavigationPayload
700
+ payload: NavigationRoutePayload,
701
+ navigationId: number
554
702
  ): void {
555
703
  if (
556
704
  !document.getElementById(
@@ -582,6 +730,7 @@ function applyNavigationPayload(
582
730
  payload.params ?? {},
583
731
  metadata:
584
732
  payload.metadata,
733
+ navigationId,
585
734
  });
586
735
 
587
736
  if (payload.metadata) {
@@ -644,7 +793,7 @@ function preloadClientImports(
644
793
  }
645
794
 
646
795
  function updateDevConfig(
647
- payload: NavigationPayload,
796
+ payload: NavigationRoutePayload,
648
797
  target: URL
649
798
  ): void {
650
799
  const config =
@@ -747,7 +896,8 @@ async function restartDevRuntime(): Promise<void> {
747
896
  }
748
897
 
749
898
  async function importClientBundle(
750
- clientScript: string
899
+ clientScript: string,
900
+ navigationId: number
751
901
  ): Promise<void> {
752
902
  const separator =
753
903
  clientScript.includes("?")
@@ -758,7 +908,82 @@ async function importClientBundle(
758
908
  clientScript +
759
909
  separator +
760
910
  "bcp-nav=" +
761
- Date.now()
911
+ encodeURIComponent(
912
+ navigationId
913
+ )
914
+ );
915
+ }
916
+
917
+ function assertCurrentNavigation(
918
+ controller: AbortController,
919
+ navigationId: number
920
+ ): void {
921
+ if (
922
+ controller.signal.aborted ||
923
+ navigationController !==
924
+ controller ||
925
+ navigationId !==
926
+ navigationSequence
927
+ ) {
928
+ throw new DOMException(
929
+ "Navigation aborted.",
930
+ "AbortError"
931
+ );
932
+ }
933
+ }
934
+
935
+ function isNavigationRedirectPayload(
936
+ value: unknown
937
+ ): value is NavigationRedirectPayload {
938
+ if (
939
+ typeof value !==
940
+ "object" ||
941
+ value === null
942
+ ) {
943
+ return false;
944
+ }
945
+
946
+ const redirect =
947
+ (
948
+ value as {
949
+ redirect?: unknown;
950
+ }
951
+ ).redirect;
952
+
953
+ if (
954
+ typeof redirect !==
955
+ "object" ||
956
+ redirect === null
957
+ ) {
958
+ return false;
959
+ }
960
+
961
+ const candidate =
962
+ redirect as {
963
+ location?: unknown;
964
+ status?: unknown;
965
+ };
966
+
967
+ return (
968
+ typeof candidate.location ===
969
+ "string" &&
970
+ typeof candidate.status ===
971
+ "number" &&
972
+ isRedirectStatus(
973
+ candidate.status
974
+ )
975
+ );
976
+ }
977
+
978
+ function isRedirectStatus(
979
+ status: number
980
+ ): boolean {
981
+ return (
982
+ status === 301 ||
983
+ status === 302 ||
984
+ status === 303 ||
985
+ status === 307 ||
986
+ status === 308
762
987
  );
763
988
  }
764
989
 
@@ -0,0 +1,188 @@
1
+ import * as http from "node:http";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ matchRoute,
6
+ scanRoutes,
7
+ } from "../../router/src/index.js";
8
+
9
+ import {
10
+ resolveNavigationPayload,
11
+ } from "./navigation-payload.js";
12
+
13
+ import {
14
+ attachLoaderDataToParams,
15
+ executePageLoader,
16
+ } from "./page-loader.js";
17
+
18
+ import {
19
+ createNavigationJsonResponse,
20
+ createNavigationRedirectResponse,
21
+ } from "./navigation-response.js";
22
+
23
+ import {
24
+ applyResponseCookies,
25
+ runWithRequestContext,
26
+ } from "./request-context.js";
27
+
28
+ export async function createDevNavigationTargetResponse(
29
+ req: http.IncomingMessage,
30
+ rootDirectory: string,
31
+ target: URL
32
+ ): Promise<Response | null> {
33
+ const routes =
34
+ scanRoutes(
35
+ path.join(
36
+ rootDirectory,
37
+ "app"
38
+ )
39
+ );
40
+
41
+ const match =
42
+ matchRoute(
43
+ routes,
44
+ normalizePathname(
45
+ target.pathname
46
+ )
47
+ );
48
+
49
+ if (!match) {
50
+ return null;
51
+ }
52
+
53
+ const request =
54
+ createNavigationRequest(
55
+ req,
56
+ target
57
+ );
58
+
59
+ return runWithRequestContext(
60
+ request,
61
+ async () => {
62
+ const loaderExecution =
63
+ await executePageLoader(
64
+ match.route.filePath,
65
+ match.params,
66
+ target,
67
+ Date.now()
68
+ );
69
+
70
+ if (
71
+ loaderExecution.response
72
+ ) {
73
+ const loaderResponse =
74
+ applyResponseCookies(
75
+ loaderExecution.response
76
+ );
77
+
78
+ return (
79
+ createNavigationRedirectResponse(
80
+ loaderResponse
81
+ ) ??
82
+ loaderResponse
83
+ );
84
+ }
85
+
86
+ const payload =
87
+ await resolveNavigationPayload(
88
+ rootDirectory,
89
+ target.pathname,
90
+ {
91
+ allowLoaderRoute:
92
+ true,
93
+ }
94
+ );
95
+
96
+ if (!payload) {
97
+ return createNavigationJsonResponse(
98
+ {
99
+ error:
100
+ "Route not found.",
101
+ },
102
+ {
103
+ status:
104
+ 404,
105
+ }
106
+ );
107
+ }
108
+
109
+ payload.params =
110
+ attachLoaderDataToParams(
111
+ match.params,
112
+ loaderExecution
113
+ );
114
+
115
+ return applyResponseCookies(
116
+ createNavigationJsonResponse(
117
+ payload
118
+ )
119
+ );
120
+ },
121
+ {
122
+ remoteAddress:
123
+ req.socket
124
+ .remoteAddress ??
125
+ null,
126
+ }
127
+ );
128
+ }
129
+
130
+ function createNavigationRequest(
131
+ req: http.IncomingMessage,
132
+ target: URL
133
+ ): Request {
134
+ const headers =
135
+ new Headers();
136
+
137
+ for (
138
+ const [key, value]
139
+ of Object.entries(
140
+ req.headers
141
+ )
142
+ ) {
143
+ if (value === undefined) {
144
+ continue;
145
+ }
146
+
147
+ if (Array.isArray(value)) {
148
+ for (const item of value) {
149
+ headers.append(
150
+ key,
151
+ item
152
+ );
153
+ }
154
+ } else {
155
+ headers.set(
156
+ key,
157
+ value
158
+ );
159
+ }
160
+ }
161
+
162
+ return new Request(
163
+ target,
164
+ {
165
+ method:
166
+ "GET",
167
+ headers,
168
+ }
169
+ );
170
+ }
171
+
172
+ function normalizePathname(
173
+ pathname: string
174
+ ): string {
175
+ if (
176
+ pathname.length > 1 &&
177
+ pathname.endsWith(
178
+ "/"
179
+ )
180
+ ) {
181
+ return pathname.slice(
182
+ 0,
183
+ -1
184
+ );
185
+ }
186
+
187
+ return pathname || "/";
188
+ }