@chidchanun/bcp 0.1.7 → 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.
@@ -18,6 +18,10 @@ import {
18
18
  renderToString,
19
19
  } from "react-dom/server";
20
20
 
21
+ import {
22
+ BcpLoaderDataProvider,
23
+ } from "../../client/src/loader-data.js";
24
+
21
25
  import {
22
26
  assertNoPageApiConflicts,
23
27
  matchApiRoute,
@@ -41,7 +45,15 @@ import {
41
45
  DevHmrServer,
42
46
  } from "./dev-hmr.js";
43
47
 
44
- import { runWithRequestContext, applyResponseCookies } from "./request-context.ts";
48
+ import {
49
+ attachLoaderDataToParams,
50
+ executePageLoader,
51
+ } from "./page-loader.js";
52
+
53
+ import {
54
+ runWithRequestContext,
55
+ applyResponseCookies,
56
+ } from "./request-context.ts";
45
57
 
46
58
  /*
47
59
  * =====================================
@@ -71,7 +83,7 @@ interface FrameworkData {
71
83
 
72
84
  params: Record<
73
85
  string,
74
- string
86
+ any
75
87
  >;
76
88
  }
77
89
 
@@ -501,124 +513,15 @@ export function createDevServer(
501
513
  );
502
514
  }
503
515
 
504
- /*
505
- * =================================
506
- * Server Components Import
507
- * =================================
508
- */
509
-
510
- const importStartedAt =
511
- performance.now();
512
-
513
- const Page =
514
- await importComponent(
515
- route.filePath,
516
- "Page",
517
- moduleVersion
518
- );
519
-
520
- const Layouts:
521
- ComponentType<any>[] =
522
- [];
523
-
524
- for (
525
- const layoutFile
526
- of route.layouts
527
- ) {
528
- const Layout =
529
- await importComponent(
530
- layoutFile,
531
- "Layout",
532
- moduleVersion
533
- );
534
-
535
- Layouts.push(
536
- Layout
537
- );
538
- }
539
-
540
- console.log(
541
- `Import: ${(performance.now() - importStartedAt).toFixed(2)}ms`
542
- );
543
-
544
- /*
545
- * =================================
546
- * React Tree
547
- * =================================
548
- */
549
-
550
- const renderStartedAt =
551
- performance.now();
552
-
553
- let element:
554
- ReactElement =
555
- createElement(
556
- Page,
557
- {
558
- params,
559
- }
560
- );
561
-
562
- for (
563
- let index =
564
- Layouts.length -
565
- 1;
566
-
567
- index >= 0;
568
-
569
- index--
570
- ) {
571
- const Layout =
572
- Layouts[index];
573
-
574
- element =
575
- createElement(
576
- Layout,
577
- {
578
- params,
579
- },
580
- element
581
- );
582
- }
583
-
584
- const content =
585
- renderToString(
586
- element
587
- );
588
-
589
- console.log(
590
- `SSR: ${(performance.now() - renderStartedAt).toFixed(2)}ms`
591
- );
592
-
593
- /*
594
- * =================================
595
- * Framework Data
596
- * =================================
597
- */
598
-
599
- const frameworkData:
600
- FrameworkData = {
601
- route:
602
- route.pathname,
603
-
604
- params,
605
- };
606
-
607
- const html =
608
- renderDocument(
609
- content,
610
-
611
- pageClientBundle
612
- .publicPath,
613
-
614
- frameworkData,
615
-
616
- clientVersion
617
- );
618
-
619
- sendHtml(
516
+ await handlePageRoute(
517
+ req,
620
518
  res,
621
- html
519
+ requestUrl,
520
+ route,
521
+ params,
522
+ pageClientBundle,
523
+ moduleVersion,
524
+ clientVersion
622
525
  );
623
526
  } catch (error) {
624
527
  console.error("");
@@ -1335,6 +1238,193 @@ export function createDevServer(
1335
1238
  };
1336
1239
  }
1337
1240
 
1241
+ /*
1242
+ * =====================================
1243
+ * Page Route
1244
+ * =====================================
1245
+ */
1246
+
1247
+ async function handlePageRoute(
1248
+ req: http.IncomingMessage,
1249
+ res: http.ServerResponse,
1250
+ requestUrl: URL,
1251
+ route: Route,
1252
+ params: Record<string, any>,
1253
+ pageClientBundle: ClientBundle,
1254
+ moduleVersion: number,
1255
+ clientVersion: number
1256
+ ): Promise<void> {
1257
+ const request =
1258
+ await createWebRequest(
1259
+ req,
1260
+ requestUrl
1261
+ );
1262
+
1263
+ const response =
1264
+ await runWithRequestContext(
1265
+ request,
1266
+ async () => {
1267
+ const loaderExecution =
1268
+ await executePageLoader(
1269
+ route.filePath,
1270
+ params,
1271
+ requestUrl,
1272
+ moduleVersion
1273
+ );
1274
+
1275
+ if (
1276
+ loaderExecution.response
1277
+ ) {
1278
+ return applyResponseCookies(
1279
+ loaderExecution.response
1280
+ );
1281
+ }
1282
+
1283
+ const frameworkParams =
1284
+ attachLoaderDataToParams(
1285
+ params,
1286
+ loaderExecution
1287
+ );
1288
+
1289
+ const importStartedAt =
1290
+ performance.now();
1291
+
1292
+ const Page =
1293
+ await importComponent(
1294
+ route.filePath,
1295
+ "Page",
1296
+ moduleVersion
1297
+ );
1298
+
1299
+ const Layouts:
1300
+ ComponentType<any>[] = [];
1301
+
1302
+ for (
1303
+ const layoutFile
1304
+ of route.layouts
1305
+ ) {
1306
+ const Layout =
1307
+ await importComponent(
1308
+ layoutFile,
1309
+ "Layout",
1310
+ moduleVersion
1311
+ );
1312
+
1313
+ Layouts.push(
1314
+ Layout
1315
+ );
1316
+ }
1317
+
1318
+ console.log(
1319
+ `Import: ${(performance.now() - importStartedAt).toFixed(2)}ms`
1320
+ );
1321
+
1322
+ const renderStartedAt =
1323
+ performance.now();
1324
+
1325
+ let element:
1326
+ ReactElement =
1327
+ createElement(
1328
+ Page,
1329
+ {
1330
+ params:
1331
+ frameworkParams,
1332
+ }
1333
+ );
1334
+
1335
+ for (
1336
+ let index =
1337
+ Layouts.length - 1;
1338
+ index >= 0;
1339
+ index--
1340
+ ) {
1341
+ const Layout =
1342
+ Layouts[index];
1343
+
1344
+ element =
1345
+ createElement(
1346
+ Layout,
1347
+ {
1348
+ params:
1349
+ frameworkParams,
1350
+ },
1351
+ element
1352
+ );
1353
+ }
1354
+
1355
+ if (
1356
+ loaderExecution.hasLoader
1357
+ ) {
1358
+ element =
1359
+ createElement(
1360
+ BcpLoaderDataProvider,
1361
+ {
1362
+ data:
1363
+ loaderExecution.data,
1364
+ },
1365
+ element
1366
+ );
1367
+ }
1368
+
1369
+ const content =
1370
+ renderToString(
1371
+ element
1372
+ );
1373
+
1374
+ console.log(
1375
+ `SSR: ${(performance.now() - renderStartedAt).toFixed(2)}ms`
1376
+ );
1377
+
1378
+ const frameworkData:
1379
+ FrameworkData = {
1380
+ route:
1381
+ route.pathname,
1382
+ params:
1383
+ frameworkParams,
1384
+ };
1385
+
1386
+ const html =
1387
+ renderDocument(
1388
+ content,
1389
+ pageClientBundle
1390
+ .publicPath,
1391
+ frameworkData,
1392
+ clientVersion
1393
+ );
1394
+
1395
+ return applyResponseCookies(
1396
+ new Response(
1397
+ html,
1398
+ {
1399
+ status:
1400
+ 200,
1401
+ headers: {
1402
+ "Content-Type":
1403
+ "text/html; charset=utf-8",
1404
+ "Cache-Control":
1405
+ "no-store",
1406
+ },
1407
+ }
1408
+ )
1409
+ );
1410
+ },
1411
+ {
1412
+ remoteAddress:
1413
+ req.socket
1414
+ .remoteAddress ??
1415
+ null,
1416
+ }
1417
+ );
1418
+
1419
+ await sendWebResponse(
1420
+ res,
1421
+ response,
1422
+ normalizeHttpMethod(
1423
+ req.method
1424
+ ) === "HEAD"
1425
+ );
1426
+ }
1427
+
1338
1428
  /*
1339
1429
  * =====================================
1340
1430
  * API Route
@@ -28,9 +28,13 @@ import {
28
28
  type MetadataSource,
29
29
  } from "./metadata.js";
30
30
 
31
+ import {
32
+ routeHasPageLoader,
33
+ } from "./page-loader.js";
34
+
31
35
  export interface NavigationPayload {
32
36
  route: string;
33
- params: Record<string, string>;
37
+ params: Record<string, unknown>;
34
38
  clientScript: string;
35
39
  clientImports: string[];
36
40
  title: string;
@@ -51,6 +55,8 @@ export interface NavigationPayloadOptions {
51
55
  title?: string;
52
56
 
53
57
  version?: number;
58
+
59
+ allowLoaderRoute?: boolean;
54
60
  }
55
61
 
56
62
  export async function resolveNavigationPayload(
@@ -81,6 +87,23 @@ export async function resolveNavigationPayload(
81
87
  return null;
82
88
  }
83
89
 
90
+ /*
91
+ * A loader-backed route must opt in here because this helper only knows
92
+ * how to build the route/navigation metadata. Step 2 navigation callers
93
+ * execute the loader in a request context first and then attach the fresh
94
+ * loader data to params before sending the payload. Document metadata
95
+ * resolution also opts in because the page loader has already run in the
96
+ * normal SSR request pipeline.
97
+ */
98
+ if (
99
+ !options.allowLoaderRoute &&
100
+ routeHasPageLoader(
101
+ match.route.filePath
102
+ )
103
+ ) {
104
+ return null;
105
+ }
106
+
84
107
  const loadingFile =
85
108
  findNearestLoadingFile(
86
109
  appDirectory,
@@ -0,0 +1,284 @@
1
+ import * as http from "node:http";
2
+
3
+ import {
4
+ appendVary,
5
+ compressDynamicContent,
6
+ selectContentEncoding,
7
+ } from "./compression.js";
8
+
9
+ export interface NavigationRedirectPayload {
10
+ redirect: {
11
+ location: string;
12
+ status: number;
13
+ };
14
+ }
15
+
16
+ export function createNavigationJsonResponse(
17
+ value: unknown,
18
+ init: ResponseInit = {}
19
+ ): Response {
20
+ const headers =
21
+ new Headers(
22
+ init.headers
23
+ );
24
+
25
+ if (
26
+ !headers.has(
27
+ "Cache-Control"
28
+ )
29
+ ) {
30
+ headers.set(
31
+ "Cache-Control",
32
+ "no-store"
33
+ );
34
+ }
35
+
36
+ if (
37
+ !headers.has(
38
+ "X-Content-Type-Options"
39
+ )
40
+ ) {
41
+ headers.set(
42
+ "X-Content-Type-Options",
43
+ "nosniff"
44
+ );
45
+ }
46
+
47
+ return Response.json(
48
+ value,
49
+ {
50
+ ...init,
51
+ headers,
52
+ }
53
+ );
54
+ }
55
+
56
+ export function createNavigationRedirectResponse(
57
+ response: Response
58
+ ): Response | null {
59
+ if (
60
+ !isRedirectStatus(
61
+ response.status
62
+ )
63
+ ) {
64
+ return null;
65
+ }
66
+
67
+ const location =
68
+ response.headers.get(
69
+ "location"
70
+ );
71
+
72
+ if (!location) {
73
+ return null;
74
+ }
75
+
76
+ const headers =
77
+ new Headers();
78
+
79
+ copySetCookieHeaders(
80
+ response.headers,
81
+ headers
82
+ );
83
+
84
+ return createNavigationJsonResponse(
85
+ {
86
+ redirect: {
87
+ location,
88
+ status:
89
+ response.status,
90
+ },
91
+ } satisfies NavigationRedirectPayload,
92
+ {
93
+ status:
94
+ 200,
95
+ headers,
96
+ }
97
+ );
98
+ }
99
+
100
+ export async function sendNavigationWebResponse(
101
+ req: http.IncomingMessage,
102
+ res: http.ServerResponse,
103
+ response: Response
104
+ ): Promise<void> {
105
+ res.statusCode =
106
+ response.status;
107
+
108
+ response.headers.forEach(
109
+ (
110
+ value,
111
+ key
112
+ ) => {
113
+ const normalized =
114
+ key.toLowerCase();
115
+
116
+ if (
117
+ normalized ===
118
+ "set-cookie" ||
119
+ normalized ===
120
+ "content-length" ||
121
+ normalized ===
122
+ "content-encoding"
123
+ ) {
124
+ return;
125
+ }
126
+
127
+ res.setHeader(
128
+ key,
129
+ value
130
+ );
131
+ }
132
+ );
133
+
134
+ const cookies =
135
+ getSetCookieHeaders(
136
+ response.headers
137
+ );
138
+
139
+ if (
140
+ cookies.length > 0
141
+ ) {
142
+ res.setHeader(
143
+ "Set-Cookie",
144
+ cookies
145
+ );
146
+ }
147
+
148
+ if (
149
+ response.status === 204 ||
150
+ response.status === 304
151
+ ) {
152
+ res.end();
153
+ return;
154
+ }
155
+
156
+ const rawBody =
157
+ Buffer.from(
158
+ await response.arrayBuffer()
159
+ );
160
+
161
+ const contentType =
162
+ response.headers.get(
163
+ "content-type"
164
+ ) ??
165
+ "application/octet-stream";
166
+
167
+ const existingEncoding =
168
+ response.headers.get(
169
+ "content-encoding"
170
+ );
171
+
172
+ const encoding =
173
+ existingEncoding
174
+ ? null
175
+ : selectContentEncoding(
176
+ req,
177
+ contentType,
178
+ rawBody.length
179
+ );
180
+
181
+ const body =
182
+ existingEncoding
183
+ ? rawBody
184
+ : compressDynamicContent(
185
+ rawBody,
186
+ encoding
187
+ );
188
+
189
+ if (existingEncoding) {
190
+ res.setHeader(
191
+ "Content-Encoding",
192
+ existingEncoding
193
+ );
194
+ } else if (encoding) {
195
+ res.setHeader(
196
+ "Content-Encoding",
197
+ encoding
198
+ );
199
+ res.setHeader(
200
+ "Vary",
201
+ appendVary(
202
+ res.getHeader(
203
+ "Vary"
204
+ )?.toString(),
205
+ "Accept-Encoding"
206
+ )
207
+ );
208
+ }
209
+
210
+ res.setHeader(
211
+ "Content-Length",
212
+ body.length
213
+ );
214
+
215
+ if (
216
+ (
217
+ req.method ??
218
+ "GET"
219
+ ).toUpperCase() ===
220
+ "HEAD"
221
+ ) {
222
+ res.end();
223
+ return;
224
+ }
225
+
226
+ res.end(
227
+ body
228
+ );
229
+ }
230
+
231
+ export function copySetCookieHeaders(
232
+ source: Headers,
233
+ target: Headers
234
+ ): void {
235
+ for (
236
+ const cookie
237
+ of getSetCookieHeaders(
238
+ source
239
+ )
240
+ ) {
241
+ target.append(
242
+ "Set-Cookie",
243
+ cookie
244
+ );
245
+ }
246
+ }
247
+
248
+ function getSetCookieHeaders(
249
+ headers: Headers
250
+ ): string[] {
251
+ const extended =
252
+ headers as Headers & {
253
+ getSetCookie?: () =>
254
+ string[];
255
+ };
256
+
257
+ const values =
258
+ extended.getSetCookie?.();
259
+
260
+ if (values) {
261
+ return values;
262
+ }
263
+
264
+ const fallback =
265
+ headers.get(
266
+ "set-cookie"
267
+ );
268
+
269
+ return fallback
270
+ ? [fallback]
271
+ : [];
272
+ }
273
+
274
+ function isRedirectStatus(
275
+ status: number
276
+ ): boolean {
277
+ return (
278
+ status === 301 ||
279
+ status === 302 ||
280
+ status === 303 ||
281
+ status === 307 ||
282
+ status === 308
283
+ );
284
+ }