@chidchanun/bcp 0.1.4 → 0.1.5

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,771 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ registerHooks,
5
+ } from "node:module";
6
+ import {
7
+ pathToFileURL,
8
+ } from "node:url";
9
+
10
+ import type {
11
+ Plugin,
12
+ } from "esbuild";
13
+
14
+ export type HydrationMode =
15
+ | "full"
16
+ | "none";
17
+
18
+ const APPLICATION_ALIAS_PREFIX =
19
+ "@/";
20
+
21
+ const MODULE_EXTENSIONS = [
22
+ ".ts",
23
+ ".tsx",
24
+ ".mts",
25
+ ".cts",
26
+ ".js",
27
+ ".jsx",
28
+ ".mjs",
29
+ ".cjs",
30
+ ".json",
31
+ ] as const;
32
+
33
+ const CLIENT_REACT_HOOKS = [
34
+ "useActionState",
35
+ "useCallback",
36
+ "useContext",
37
+ "useDebugValue",
38
+ "useDeferredValue",
39
+ "useEffect",
40
+ "useId",
41
+ "useImperativeHandle",
42
+ "useInsertionEffect",
43
+ "useLayoutEffect",
44
+ "useMemo",
45
+ "useOptimistic",
46
+ "useReducer",
47
+ "useRef",
48
+ "useState",
49
+ "useSyncExternalStore",
50
+ "useTransition",
51
+ ] as const;
52
+
53
+ let runtimeAliasRoot:
54
+ string | null =
55
+ null;
56
+
57
+ export function installApplicationModuleAlias(
58
+ rootDirectory: string
59
+ ): void {
60
+ const root =
61
+ path.resolve(
62
+ rootDirectory
63
+ );
64
+
65
+ if (runtimeAliasRoot) {
66
+ if (
67
+ normalizePath(
68
+ runtimeAliasRoot
69
+ ) !==
70
+ normalizePath(
71
+ root
72
+ )
73
+ ) {
74
+ throw new Error(
75
+ `BCP Framework: application alias is already registered for "${runtimeAliasRoot}".`
76
+ );
77
+ }
78
+
79
+ return;
80
+ }
81
+
82
+ registerHooks({
83
+ resolve(
84
+ specifier,
85
+ context,
86
+ nextResolve
87
+ ) {
88
+ if (
89
+ !specifier.startsWith(
90
+ APPLICATION_ALIAS_PREFIX
91
+ )
92
+ ) {
93
+ return nextResolve(
94
+ specifier,
95
+ context
96
+ );
97
+ }
98
+
99
+ const resolved =
100
+ resolveApplicationModuleAlias(
101
+ root,
102
+ specifier
103
+ );
104
+
105
+ return nextResolve(
106
+ pathToFileURL(
107
+ resolved
108
+ ).href,
109
+ context
110
+ );
111
+ },
112
+ });
113
+
114
+ runtimeAliasRoot =
115
+ root;
116
+ }
117
+
118
+ export function resolveApplicationModuleAlias(
119
+ rootDirectory: string,
120
+ specifier: string
121
+ ): string {
122
+ if (
123
+ !specifier.startsWith(
124
+ APPLICATION_ALIAS_PREFIX
125
+ )
126
+ ) {
127
+ throw new Error(
128
+ `BCP Framework: application alias must start with "${APPLICATION_ALIAS_PREFIX}".`
129
+ );
130
+ }
131
+
132
+ const aliasPath =
133
+ specifier.slice(
134
+ APPLICATION_ALIAS_PREFIX.length
135
+ );
136
+
137
+ if (
138
+ !aliasPath ||
139
+ aliasPath.includes("\\")
140
+ ) {
141
+ throw new Error(
142
+ `BCP Framework: invalid application alias "${specifier}".`
143
+ );
144
+ }
145
+
146
+ const root =
147
+ path.resolve(
148
+ rootDirectory
149
+ );
150
+
151
+ const unresolvedPath =
152
+ path.resolve(
153
+ root,
154
+ aliasPath.replaceAll(
155
+ "/",
156
+ path.sep
157
+ )
158
+ );
159
+
160
+ if (
161
+ !isPathInside(
162
+ root,
163
+ unresolvedPath
164
+ )
165
+ ) {
166
+ throw new Error(
167
+ `BCP Framework: application alias "${specifier}" cannot resolve outside the project root.`
168
+ );
169
+ }
170
+
171
+ const resolvedPath =
172
+ resolveExistingModulePath(
173
+ unresolvedPath
174
+ );
175
+
176
+ if (!resolvedPath) {
177
+ throw new Error(
178
+ `BCP Framework: application alias "${specifier}" could not be resolved from "${root}".`
179
+ );
180
+ }
181
+
182
+ const realRoot =
183
+ safeRealPath(
184
+ root
185
+ ) ??
186
+ root;
187
+ const realResolved =
188
+ safeRealPath(
189
+ resolvedPath
190
+ ) ??
191
+ resolvedPath;
192
+
193
+ if (
194
+ !isPathInside(
195
+ realRoot,
196
+ realResolved
197
+ )
198
+ ) {
199
+ throw new Error(
200
+ `BCP Framework: application alias "${specifier}" resolved outside the project root through a symbolic link.`
201
+ );
202
+ }
203
+
204
+ return realResolved;
205
+ }
206
+
207
+ export function createApplicationAliasPlugin(
208
+ rootDirectory: string
209
+ ): Plugin {
210
+ return {
211
+ name:
212
+ "bcp-application-alias",
213
+
214
+ setup(buildApi) {
215
+ buildApi.onResolve(
216
+ {
217
+ filter:
218
+ /^@\//,
219
+ },
220
+ (args) => {
221
+ try {
222
+ return {
223
+ path:
224
+ resolveApplicationModuleAlias(
225
+ rootDirectory,
226
+ args.path
227
+ ),
228
+ };
229
+ } catch (error) {
230
+ return {
231
+ errors: [
232
+ {
233
+ text:
234
+ error instanceof Error
235
+ ? error.message
236
+ : String(error),
237
+ },
238
+ ],
239
+ };
240
+ }
241
+ }
242
+ );
243
+ },
244
+ };
245
+ }
246
+
247
+ export function createClientBoundaryPlugin(
248
+ rootDirectory: string
249
+ ): Plugin {
250
+ const root =
251
+ path.resolve(
252
+ rootDirectory
253
+ );
254
+
255
+ return {
256
+ name:
257
+ "bcp-client-boundary",
258
+
259
+ setup(buildApi) {
260
+ buildApi.onResolve(
261
+ {
262
+ filter:
263
+ /^bcp\/server-only$/,
264
+ },
265
+ (args) => {
266
+ const importer =
267
+ args.importer
268
+ ? formatApplicationPath(
269
+ root,
270
+ args.importer
271
+ )
272
+ : "the current client entry";
273
+
274
+ return {
275
+ errors: [
276
+ {
277
+ text:
278
+ `BCP Framework: bcp/server-only cannot be imported into a client bundle from ${importer}. Move the server dependency behind an API route or use export const hydration = "none" for a server-only page route.`,
279
+ },
280
+ ],
281
+ };
282
+ }
283
+ );
284
+
285
+ buildApi.onLoad(
286
+ {
287
+ filter:
288
+ /\.[cm]?[jt]sx?$/,
289
+ },
290
+ async (args) => {
291
+ if (
292
+ !isApplicationSourcePath(
293
+ root,
294
+ args.path
295
+ ) ||
296
+ /\.island\.(?:ts|tsx)$/.test(
297
+ args.path
298
+ )
299
+ ) {
300
+ return;
301
+ }
302
+
303
+ const source =
304
+ await fs.promises
305
+ .readFile(
306
+ args.path,
307
+ "utf8"
308
+ );
309
+
310
+ if (
311
+ usesReactClientHooks(
312
+ source
313
+ ) &&
314
+ !hasUseClientDirective(
315
+ source
316
+ )
317
+ ) {
318
+ return {
319
+ errors: [
320
+ {
321
+ text:
322
+ `BCP Framework: ${formatApplicationPath(root, args.path)} uses React client hooks but is missing the "use client" directive. Add "use client" as the first directive in the module.`,
323
+ },
324
+ ],
325
+ };
326
+ }
327
+
328
+ return;
329
+ }
330
+ );
331
+ },
332
+ };
333
+ }
334
+
335
+ export function readRouteHydrationMode(
336
+ filePath: string
337
+ ): HydrationMode {
338
+ const source =
339
+ fs.readFileSync(
340
+ filePath,
341
+ "utf8"
342
+ );
343
+
344
+ const match =
345
+ /\bexport\s+const\s+hydration\s*(?::[^=;]+)?=\s*["']([^"']+)["']/.exec(
346
+ source
347
+ );
348
+
349
+ if (!match) {
350
+ return "full";
351
+ }
352
+
353
+ const value =
354
+ match[1];
355
+
356
+ if (
357
+ value === "full" ||
358
+ value === "none"
359
+ ) {
360
+ return value;
361
+ }
362
+
363
+ throw new Error(
364
+ `BCP Framework: invalid hydration mode "${value}" in "${filePath}". Use "full" or "none".`
365
+ );
366
+ }
367
+
368
+ export function hasUseClientDirective(
369
+ source: string
370
+ ): boolean {
371
+ let index =
372
+ source.charCodeAt(0) ===
373
+ 0xfeff
374
+ ? 1
375
+ : 0;
376
+
377
+ while (
378
+ index <
379
+ source.length
380
+ ) {
381
+ index =
382
+ skipTrivia(
383
+ source,
384
+ index
385
+ );
386
+
387
+ const remaining =
388
+ source.slice(
389
+ index
390
+ );
391
+
392
+ const directive =
393
+ /^(["'])([^"']+)\1\s*;?/.exec(
394
+ remaining
395
+ );
396
+
397
+ if (!directive) {
398
+ return false;
399
+ }
400
+
401
+ if (
402
+ directive[2] ===
403
+ "use client"
404
+ ) {
405
+ return true;
406
+ }
407
+
408
+ index +=
409
+ directive[0].length;
410
+ }
411
+
412
+ return false;
413
+ }
414
+
415
+ export function usesReactClientHooks(
416
+ source: string
417
+ ): boolean {
418
+ const hookPattern =
419
+ new RegExp(
420
+ `\\b(?:${CLIENT_REACT_HOOKS.join("|")})\\b`
421
+ );
422
+
423
+ const importPattern =
424
+ /\bimport\s+([\s\S]*?)\s+from\s+["']react["']/g;
425
+
426
+ let match:
427
+ RegExpExecArray | null;
428
+
429
+ while (
430
+ (
431
+ match =
432
+ importPattern.exec(
433
+ source
434
+ )
435
+ )
436
+ ) {
437
+ const clause =
438
+ match[1];
439
+
440
+ if (
441
+ hookPattern.test(
442
+ clause
443
+ )
444
+ ) {
445
+ return true;
446
+ }
447
+
448
+ const namespaceMatch =
449
+ /\*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/.exec(
450
+ clause
451
+ );
452
+
453
+ const defaultMatch =
454
+ /^\s*([A-Za-z_$][A-Za-z0-9_$]*)/.exec(
455
+ clause
456
+ );
457
+
458
+ for (
459
+ const namespace
460
+ of [
461
+ namespaceMatch?.[1],
462
+ defaultMatch?.[1],
463
+ ]
464
+ ) {
465
+ if (!namespace) {
466
+ continue;
467
+ }
468
+
469
+ const namespaceHookPattern =
470
+ new RegExp(
471
+ `\\b${escapeRegExp(namespace)}\\s*\\.\\s*(?:${CLIENT_REACT_HOOKS.join("|")})\\s*\\(`
472
+ );
473
+
474
+ if (
475
+ namespaceHookPattern.test(
476
+ source
477
+ )
478
+ ) {
479
+ return true;
480
+ }
481
+ }
482
+ }
483
+
484
+ return false;
485
+ }
486
+
487
+ function resolveExistingModulePath(
488
+ unresolvedPath: string
489
+ ): string | null {
490
+ if (
491
+ isFile(
492
+ unresolvedPath
493
+ )
494
+ ) {
495
+ return unresolvedPath;
496
+ }
497
+
498
+ if (
499
+ path.extname(
500
+ unresolvedPath
501
+ ) === ""
502
+ ) {
503
+ for (
504
+ const extension
505
+ of MODULE_EXTENSIONS
506
+ ) {
507
+ const candidate =
508
+ `${unresolvedPath}${extension}`;
509
+
510
+ if (
511
+ isFile(
512
+ candidate
513
+ )
514
+ ) {
515
+ return candidate;
516
+ }
517
+ }
518
+ }
519
+
520
+ if (
521
+ isDirectory(
522
+ unresolvedPath
523
+ )
524
+ ) {
525
+ for (
526
+ const extension
527
+ of MODULE_EXTENSIONS
528
+ ) {
529
+ const candidate =
530
+ path.join(
531
+ unresolvedPath,
532
+ `index${extension}`
533
+ );
534
+
535
+ if (
536
+ isFile(
537
+ candidate
538
+ )
539
+ ) {
540
+ return candidate;
541
+ }
542
+ }
543
+ }
544
+
545
+ return null;
546
+ }
547
+
548
+ function isApplicationSourcePath(
549
+ rootDirectory: string,
550
+ filePath: string
551
+ ): boolean {
552
+ const absolute =
553
+ path.resolve(
554
+ filePath
555
+ );
556
+
557
+ if (
558
+ !isPathInside(
559
+ rootDirectory,
560
+ absolute
561
+ )
562
+ ) {
563
+ return false;
564
+ }
565
+
566
+ const relative =
567
+ path.relative(
568
+ rootDirectory,
569
+ absolute
570
+ )
571
+ .replace(
572
+ /\\/g,
573
+ "/"
574
+ );
575
+
576
+ return !(
577
+ relative ===
578
+ "node_modules" ||
579
+ relative.startsWith(
580
+ "node_modules/"
581
+ ) ||
582
+ relative ===
583
+ ".bcp-framework" ||
584
+ relative.startsWith(
585
+ ".bcp-framework/"
586
+ )
587
+ );
588
+ }
589
+
590
+ function skipTrivia(
591
+ source: string,
592
+ startIndex: number
593
+ ): number {
594
+ let index =
595
+ startIndex;
596
+
597
+ while (
598
+ index <
599
+ source.length
600
+ ) {
601
+ const whitespace =
602
+ /^[\s]+/.exec(
603
+ source.slice(
604
+ index
605
+ )
606
+ );
607
+
608
+ if (whitespace) {
609
+ index +=
610
+ whitespace[0].length;
611
+ continue;
612
+ }
613
+
614
+ if (
615
+ source.startsWith(
616
+ "//",
617
+ index
618
+ )
619
+ ) {
620
+ const newline =
621
+ source.indexOf(
622
+ "\n",
623
+ index + 2
624
+ );
625
+
626
+ return newline === -1
627
+ ? source.length
628
+ : skipTrivia(
629
+ source,
630
+ newline + 1
631
+ );
632
+ }
633
+
634
+ if (
635
+ source.startsWith(
636
+ "/*",
637
+ index
638
+ )
639
+ ) {
640
+ const end =
641
+ source.indexOf(
642
+ "*/",
643
+ index + 2
644
+ );
645
+
646
+ if (end === -1) {
647
+ return source.length;
648
+ }
649
+
650
+ index =
651
+ end + 2;
652
+ continue;
653
+ }
654
+
655
+ break;
656
+ }
657
+
658
+ return index;
659
+ }
660
+
661
+ function formatApplicationPath(
662
+ rootDirectory: string,
663
+ filePath: string
664
+ ): string {
665
+ const relative =
666
+ path.relative(
667
+ rootDirectory,
668
+ filePath
669
+ )
670
+ .replace(
671
+ /\\/g,
672
+ "/"
673
+ );
674
+
675
+ return relative &&
676
+ !relative.startsWith(
677
+ ".."
678
+ )
679
+ ? `"${relative}"`
680
+ : `"${filePath}"`;
681
+ }
682
+
683
+ function isPathInside(
684
+ parent: string,
685
+ child: string
686
+ ): boolean {
687
+ const relative =
688
+ path.relative(
689
+ parent,
690
+ child
691
+ );
692
+
693
+ return (
694
+ relative === "" ||
695
+ (
696
+ relative !== ".." &&
697
+ !relative.startsWith(
698
+ `..${path.sep}`
699
+ ) &&
700
+ !path.isAbsolute(
701
+ relative
702
+ )
703
+ )
704
+ );
705
+ }
706
+
707
+ function safeRealPath(
708
+ filePath: string
709
+ ): string | null {
710
+ try {
711
+ return fs.realpathSync(
712
+ filePath
713
+ );
714
+ } catch {
715
+ return null;
716
+ }
717
+ }
718
+
719
+ function isFile(
720
+ filePath: string
721
+ ): boolean {
722
+ try {
723
+ return fs.statSync(
724
+ filePath
725
+ ).isFile();
726
+ } catch {
727
+ return false;
728
+ }
729
+ }
730
+
731
+ function isDirectory(
732
+ filePath: string
733
+ ): boolean {
734
+ try {
735
+ return fs.statSync(
736
+ filePath
737
+ ).isDirectory();
738
+ } catch {
739
+ return false;
740
+ }
741
+ }
742
+
743
+ function normalizePath(
744
+ filePath: string
745
+ ): string {
746
+ let normalized =
747
+ path.normalize(
748
+ path.resolve(
749
+ filePath
750
+ )
751
+ );
752
+
753
+ if (
754
+ process.platform ===
755
+ "win32"
756
+ ) {
757
+ normalized =
758
+ normalized.toLowerCase();
759
+ }
760
+
761
+ return normalized;
762
+ }
763
+
764
+ function escapeRegExp(
765
+ value: string
766
+ ): string {
767
+ return value.replace(
768
+ /[.*+?^${}()|[\]\\]/g,
769
+ "\\$&"
770
+ );
771
+ }