@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.
@@ -0,0 +1,346 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ pathToFileURL,
5
+ } from "node:url";
6
+
7
+ export const LOADER_DATA_PARAM =
8
+ "__bcp_loader_data";
9
+
10
+ export interface PageLoaderContext {
11
+ params: Record<string, any>;
12
+ searchParams: URLSearchParams;
13
+ }
14
+
15
+ export type PageLoader = (
16
+ context: PageLoaderContext
17
+ ) =>
18
+ | unknown
19
+ | Promise<unknown>;
20
+
21
+ export interface PageLoaderExecution {
22
+ hasLoader: boolean;
23
+ data: unknown;
24
+ response: Response | null;
25
+ }
26
+
27
+ export function findPageLoaderFile(
28
+ pageFilePath: string
29
+ ): string | null {
30
+ const directory =
31
+ path.dirname(
32
+ pageFilePath
33
+ );
34
+ const tsFile =
35
+ path.join(
36
+ directory,
37
+ "loader.ts"
38
+ );
39
+ const tsxFile =
40
+ path.join(
41
+ directory,
42
+ "loader.tsx"
43
+ );
44
+ const hasTs =
45
+ fs.existsSync(
46
+ tsFile
47
+ );
48
+ const hasTsx =
49
+ fs.existsSync(
50
+ tsxFile
51
+ );
52
+
53
+ if (hasTs && hasTsx) {
54
+ throw new Error(
55
+ `BCP Framework: both loader.ts and loader.tsx exist in "${directory}".`
56
+ );
57
+ }
58
+
59
+ if (hasTs) {
60
+ return tsFile;
61
+ }
62
+
63
+ if (hasTsx) {
64
+ return tsxFile;
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ export function routeHasPageLoader(
71
+ pageFilePath: string
72
+ ): boolean {
73
+ return findPageLoaderFile(
74
+ pageFilePath
75
+ ) !== null;
76
+ }
77
+
78
+ export async function executePageLoader(
79
+ pageFilePath: string,
80
+ params: Record<string, any>,
81
+ requestUrl: URL,
82
+ moduleVersion = 0
83
+ ): Promise<PageLoaderExecution> {
84
+ const loaderFile =
85
+ findPageLoaderFile(
86
+ pageFilePath
87
+ );
88
+
89
+ if (!loaderFile) {
90
+ return {
91
+ hasLoader:
92
+ false,
93
+ data:
94
+ undefined,
95
+ response:
96
+ null,
97
+ };
98
+ }
99
+
100
+ const moduleUrl =
101
+ pathToFileURL(
102
+ loaderFile
103
+ );
104
+
105
+ moduleUrl.searchParams.set(
106
+ "bcp-loader",
107
+ String(
108
+ moduleVersion
109
+ )
110
+ );
111
+
112
+ const loaderModule =
113
+ await import(
114
+ moduleUrl.href
115
+ ) as {
116
+ loader?: unknown;
117
+ };
118
+
119
+ if (
120
+ typeof loaderModule.loader !==
121
+ "function"
122
+ ) {
123
+ throw new Error(
124
+ `BCP Framework: loader "${loaderFile}" must export a named loader() function.`
125
+ );
126
+ }
127
+
128
+ return executePageLoaderFunction(
129
+ loaderModule.loader as
130
+ PageLoader,
131
+ params,
132
+ requestUrl,
133
+ loaderFile
134
+ );
135
+ }
136
+
137
+ export async function executePageLoaderFunction(
138
+ loader: PageLoader,
139
+ params: Record<string, any>,
140
+ requestUrl: URL,
141
+ label = "loader"
142
+ ): Promise<PageLoaderExecution> {
143
+ const result =
144
+ await loader({
145
+ params: {
146
+ ...params,
147
+ },
148
+ searchParams:
149
+ new URLSearchParams(
150
+ requestUrl.searchParams
151
+ ),
152
+ });
153
+
154
+ if (
155
+ result instanceof
156
+ Response
157
+ ) {
158
+ return {
159
+ hasLoader:
160
+ true,
161
+ data:
162
+ undefined,
163
+ response:
164
+ result,
165
+ };
166
+ }
167
+
168
+ assertLoaderData(
169
+ result,
170
+ label
171
+ );
172
+
173
+ return {
174
+ hasLoader:
175
+ true,
176
+ data:
177
+ result,
178
+ response:
179
+ null,
180
+ };
181
+ }
182
+
183
+ export function attachLoaderDataToParams(
184
+ params: Record<string, any>,
185
+ execution: PageLoaderExecution
186
+ ): Record<string, any> {
187
+ if (!execution.hasLoader) {
188
+ return {
189
+ ...params,
190
+ };
191
+ }
192
+
193
+ return {
194
+ ...params,
195
+ [LOADER_DATA_PARAM]:
196
+ execution.data,
197
+ };
198
+ }
199
+
200
+ function assertLoaderData(
201
+ value: unknown,
202
+ loaderLabel: string
203
+ ): void {
204
+ const seen =
205
+ new Set<object>();
206
+
207
+ validateJsonValue(
208
+ value,
209
+ "$",
210
+ seen,
211
+ loaderLabel
212
+ );
213
+ }
214
+
215
+ function validateJsonValue(
216
+ value: unknown,
217
+ location: string,
218
+ seen: Set<object>,
219
+ loaderLabel: string
220
+ ): void {
221
+ if (
222
+ value === null ||
223
+ typeof value ===
224
+ "string" ||
225
+ typeof value ===
226
+ "boolean"
227
+ ) {
228
+ return;
229
+ }
230
+
231
+ if (
232
+ typeof value ===
233
+ "number"
234
+ ) {
235
+ if (
236
+ Number.isFinite(
237
+ value
238
+ )
239
+ ) {
240
+ return;
241
+ }
242
+
243
+ throwInvalidLoaderData(
244
+ loaderLabel,
245
+ location,
246
+ "numbers must be finite"
247
+ );
248
+ }
249
+
250
+ if (
251
+ typeof value !==
252
+ "object"
253
+ ) {
254
+ throwInvalidLoaderData(
255
+ loaderLabel,
256
+ location,
257
+ `unsupported ${typeof value} value`
258
+ );
259
+ }
260
+
261
+ const objectValue =
262
+ value as object;
263
+
264
+ if (seen.has(objectValue)) {
265
+ throwInvalidLoaderData(
266
+ loaderLabel,
267
+ location,
268
+ "circular references are not supported"
269
+ );
270
+ }
271
+
272
+ seen.add(
273
+ objectValue
274
+ );
275
+
276
+ try {
277
+ if (Array.isArray(value)) {
278
+ value.forEach(
279
+ (
280
+ item,
281
+ index
282
+ ) => {
283
+ validateJsonValue(
284
+ item,
285
+ `${location}[${index}]`,
286
+ seen,
287
+ loaderLabel
288
+ );
289
+ }
290
+ );
291
+ return;
292
+ }
293
+
294
+ const prototype =
295
+ Object.getPrototypeOf(
296
+ value
297
+ );
298
+
299
+ if (
300
+ prototype !==
301
+ Object.prototype &&
302
+ prototype !==
303
+ null
304
+ ) {
305
+ throwInvalidLoaderData(
306
+ loaderLabel,
307
+ location,
308
+ "only plain objects and arrays are supported"
309
+ );
310
+ }
311
+
312
+ for (
313
+ const [
314
+ key,
315
+ item,
316
+ ]
317
+ of Object.entries(
318
+ value as Record<
319
+ string,
320
+ unknown
321
+ >
322
+ )
323
+ ) {
324
+ validateJsonValue(
325
+ item,
326
+ `${location}.${key}`,
327
+ seen,
328
+ loaderLabel
329
+ );
330
+ }
331
+ } finally {
332
+ seen.delete(
333
+ objectValue
334
+ );
335
+ }
336
+ }
337
+
338
+ function throwInvalidLoaderData(
339
+ loaderLabel: string,
340
+ location: string,
341
+ reason: string
342
+ ): never {
343
+ throw new Error(
344
+ `BCP Framework: loader "${loaderLabel}" returned non-serializable data at ${location}: ${reason}.`
345
+ );
346
+ }