@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,860 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ spawn,
5
+ } from "node:child_process";
6
+
7
+ const FRAMEWORK_PACKAGE =
8
+ "@chidchanun/bcp";
9
+
10
+ const LOCKFILES = [
11
+ ["package-lock.json", "npm"],
12
+ ["pnpm-lock.yaml", "pnpm"],
13
+ ["yarn.lock", "yarn"],
14
+ ["bun.lock", "bun"],
15
+ ["bun.lockb", "bun"],
16
+ ] as const;
17
+
18
+ type DependencySectionName =
19
+ | "dependencies"
20
+ | "devDependencies"
21
+ | "optionalDependencies";
22
+
23
+ export type PackageManager =
24
+ | "npm"
25
+ | "pnpm"
26
+ | "yarn"
27
+ | "bun";
28
+
29
+ interface ProjectPackageJson {
30
+ name?: unknown;
31
+ scripts?: Record<string, string>;
32
+ dependencies?: Record<string, string>;
33
+ devDependencies?: Record<string, string>;
34
+ optionalDependencies?: Record<string, string>;
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ export interface FrameworkDependency {
39
+ section: DependencySectionName;
40
+ key: "bcp" | "@chidchanun/bcp";
41
+ specifier: string;
42
+ }
43
+
44
+ export interface UpdatePlan {
45
+ currentSpecifier: string;
46
+ currentVersion: string | null;
47
+ targetVersion: string;
48
+ nextSpecifier: string;
49
+ dependency: FrameworkDependency;
50
+ addUpdateScript: boolean;
51
+ changed: boolean;
52
+ }
53
+
54
+ export interface UpdateCommandOptions {
55
+ rootDirectory: string;
56
+ target?: string;
57
+ check?: boolean;
58
+ dryRun?: boolean;
59
+ }
60
+
61
+ export async function runBcpUpdate(
62
+ options: UpdateCommandOptions
63
+ ): Promise<UpdatePlan> {
64
+ const rootDirectory =
65
+ path.resolve(
66
+ options.rootDirectory
67
+ );
68
+ const packageFile =
69
+ path.join(
70
+ rootDirectory,
71
+ "package.json"
72
+ );
73
+
74
+ if (!fs.existsSync(packageFile)) {
75
+ throw new Error(
76
+ `BCP Update: package.json was not found in ${rootDirectory}.`
77
+ );
78
+ }
79
+
80
+ const originalPackageText =
81
+ fs.readFileSync(
82
+ packageFile,
83
+ "utf8"
84
+ );
85
+ const packageJson =
86
+ parsePackageJson(
87
+ originalPackageText,
88
+ packageFile
89
+ );
90
+ const target =
91
+ normalizeUpdateTarget(
92
+ options.target
93
+ );
94
+ const targetVersion =
95
+ await resolvePublishedVersion(
96
+ target,
97
+ rootDirectory
98
+ );
99
+ const plan =
100
+ createUpdatePlan(
101
+ packageJson,
102
+ targetVersion
103
+ );
104
+ const installedVersion =
105
+ readInstalledFrameworkVersion(
106
+ rootDirectory
107
+ );
108
+
109
+ console.log("");
110
+ console.log(
111
+ `[BCP Update] Project: ${rootDirectory}`
112
+ );
113
+ console.log(
114
+ `[BCP Update] Installed: ${installedVersion ?? plan.currentVersion ?? plan.currentSpecifier}`
115
+ );
116
+ console.log(
117
+ `[BCP Update] Target: ${targetVersion} (${target})`
118
+ );
119
+ console.log(
120
+ `[BCP Update] Dependency: ${plan.dependency.key} ${plan.currentSpecifier} -> ${plan.nextSpecifier}`
121
+ );
122
+
123
+ if (plan.addUpdateScript) {
124
+ console.log(
125
+ "[BCP Update] Project script: add `update: bcp update`."
126
+ );
127
+ }
128
+
129
+ if (
130
+ installedVersion === targetVersion &&
131
+ !plan.changed
132
+ ) {
133
+ console.log(
134
+ "[BCP Update] Already up to date."
135
+ );
136
+ console.log("");
137
+ return plan;
138
+ }
139
+
140
+ if (
141
+ options.check ||
142
+ options.dryRun
143
+ ) {
144
+ console.log(
145
+ options.check
146
+ ? "[BCP Update] Update available; no files were changed (--check)."
147
+ : "[BCP Update] Dry run complete; no files were changed."
148
+ );
149
+ console.log("");
150
+ return plan;
151
+ }
152
+
153
+ const packageManager =
154
+ detectPackageManager(
155
+ rootDirectory
156
+ );
157
+ const lockfile =
158
+ getPackageManagerLockfile(
159
+ rootDirectory,
160
+ packageManager
161
+ );
162
+ const originalLockfile =
163
+ lockfile &&
164
+ fs.existsSync(lockfile)
165
+ ? fs.readFileSync(
166
+ lockfile
167
+ )
168
+ : null;
169
+
170
+ const nextPackageJson =
171
+ applyUpdatePlan(
172
+ packageJson,
173
+ plan
174
+ );
175
+
176
+ writeJsonAtomic(
177
+ packageFile,
178
+ nextPackageJson
179
+ );
180
+
181
+ try {
182
+ console.log(
183
+ `[BCP Update] Installing with ${packageManager}...`
184
+ );
185
+
186
+ await runPackageInstall(
187
+ packageManager,
188
+ rootDirectory
189
+ );
190
+ } catch (error) {
191
+ fs.writeFileSync(
192
+ packageFile,
193
+ originalPackageText,
194
+ "utf8"
195
+ );
196
+
197
+ if (lockfile) {
198
+ if (originalLockfile) {
199
+ fs.writeFileSync(
200
+ lockfile,
201
+ originalLockfile
202
+ );
203
+ } else if (
204
+ fs.existsSync(lockfile)
205
+ ) {
206
+ fs.rmSync(
207
+ lockfile,
208
+ {
209
+ force: true,
210
+ }
211
+ );
212
+ }
213
+ }
214
+
215
+ throw new Error(
216
+ `BCP Update: install failed; package.json${lockfile ? " and the detected lockfile" : ""} were restored. ${error instanceof Error ? error.message : String(error)}`
217
+ );
218
+ }
219
+
220
+ const finalVersion =
221
+ readInstalledFrameworkVersion(
222
+ rootDirectory
223
+ );
224
+
225
+ if (
226
+ finalVersion &&
227
+ finalVersion !== targetVersion
228
+ ) {
229
+ throw new Error(
230
+ `BCP Update: install completed but resolved framework version is ${finalVersion}; expected ${targetVersion}.`
231
+ );
232
+ }
233
+
234
+ console.log(
235
+ `[BCP Update] Updated to ${targetVersion}.`
236
+ );
237
+ console.log(
238
+ "[BCP Update] Next time you can run `npm run update` from this project."
239
+ );
240
+ console.log(
241
+ "[BCP Update] Recommended: run your typecheck and test suite before committing package.json and the lockfile."
242
+ );
243
+ console.log("");
244
+
245
+ return plan;
246
+ }
247
+
248
+ export function createUpdatePlan(
249
+ packageJson: ProjectPackageJson,
250
+ targetVersion: string
251
+ ): UpdatePlan {
252
+ assertVersion(
253
+ targetVersion,
254
+ "resolved target version"
255
+ );
256
+
257
+ const dependency =
258
+ findFrameworkDependency(
259
+ packageJson
260
+ );
261
+
262
+ assertRegistryDependency(
263
+ dependency.specifier
264
+ );
265
+
266
+ /*
267
+ * Updating is an explicit operation, so pin the resolved registry
268
+ * version. This prevents a later plain package-manager install from
269
+ * silently jumping to a different BCP release than the one the updater
270
+ * verified.
271
+ */
272
+ const nextSpecifier =
273
+ dependency.key === "bcp"
274
+ ? `npm:${FRAMEWORK_PACKAGE}@${targetVersion}`
275
+ : targetVersion;
276
+ const addUpdateScript =
277
+ packageJson.scripts?.update ===
278
+ undefined;
279
+
280
+ return {
281
+ currentSpecifier:
282
+ dependency.specifier,
283
+ currentVersion:
284
+ extractVersion(
285
+ dependency.specifier
286
+ ),
287
+ targetVersion,
288
+ nextSpecifier,
289
+ dependency,
290
+ addUpdateScript,
291
+ changed:
292
+ dependency.specifier !==
293
+ nextSpecifier ||
294
+ addUpdateScript,
295
+ };
296
+ }
297
+
298
+ export function applyUpdatePlan(
299
+ packageJson: ProjectPackageJson,
300
+ plan: UpdatePlan
301
+ ): ProjectPackageJson {
302
+ const copy =
303
+ structuredClone(
304
+ packageJson
305
+ );
306
+ const section =
307
+ copy[
308
+ plan.dependency.section
309
+ ];
310
+
311
+ if (
312
+ !section ||
313
+ typeof section !== "object"
314
+ ) {
315
+ throw new Error(
316
+ `BCP Update: dependency section ${plan.dependency.section} disappeared while applying the update.`
317
+ );
318
+ }
319
+
320
+ (
321
+ section as
322
+ Record<string, string>
323
+ )[
324
+ plan.dependency.key
325
+ ] =
326
+ plan.nextSpecifier;
327
+
328
+ if (plan.addUpdateScript) {
329
+ copy.scripts = {
330
+ ...copy.scripts,
331
+ update:
332
+ "bcp update",
333
+ };
334
+ }
335
+
336
+ return copy;
337
+ }
338
+
339
+ export function findFrameworkDependency(
340
+ packageJson: ProjectPackageJson
341
+ ): FrameworkDependency {
342
+ const matches:
343
+ FrameworkDependency[] = [];
344
+
345
+ for (
346
+ const sectionName
347
+ of [
348
+ "dependencies",
349
+ "devDependencies",
350
+ "optionalDependencies",
351
+ ] as const
352
+ ) {
353
+ const section =
354
+ packageJson[
355
+ sectionName
356
+ ];
357
+
358
+ if (!section) {
359
+ continue;
360
+ }
361
+
362
+ for (
363
+ const key
364
+ of [
365
+ "bcp",
366
+ FRAMEWORK_PACKAGE,
367
+ ] as const
368
+ ) {
369
+ const specifier =
370
+ section[key];
371
+
372
+ if (
373
+ typeof specifier === "string" &&
374
+ specifier.trim() !== ""
375
+ ) {
376
+ matches.push({
377
+ section:
378
+ sectionName,
379
+ key,
380
+ specifier:
381
+ specifier.trim(),
382
+ });
383
+ }
384
+ }
385
+ }
386
+
387
+ if (matches.length === 0) {
388
+ throw new Error(
389
+ "BCP Update: this project does not declare `bcp` or `@chidchanun/bcp` in dependencies."
390
+ );
391
+ }
392
+
393
+ if (matches.length > 1) {
394
+ throw new Error(
395
+ "BCP Update: multiple BCP framework dependencies were found. Keep only one of `bcp` or `@chidchanun/bcp` before updating."
396
+ );
397
+ }
398
+
399
+ return matches[0];
400
+ }
401
+
402
+ export function detectPackageManager(
403
+ rootDirectory: string
404
+ ): PackageManager {
405
+ const detected =
406
+ LOCKFILES
407
+ .filter(
408
+ ([fileName]) =>
409
+ fs.existsSync(
410
+ path.join(
411
+ rootDirectory,
412
+ fileName
413
+ )
414
+ )
415
+ )
416
+ .map(
417
+ ([, manager]) =>
418
+ manager
419
+ );
420
+ const unique =
421
+ Array.from(
422
+ new Set(
423
+ detected
424
+ )
425
+ );
426
+
427
+ if (unique.length > 1) {
428
+ throw new Error(
429
+ `BCP Update: multiple package-manager lockfiles were found (${unique.join(", ")}). Keep only the lockfile for the package manager you use.`
430
+ );
431
+ }
432
+
433
+ if (unique.length === 1) {
434
+ return unique[0];
435
+ }
436
+
437
+ const userAgent =
438
+ process.env.npm_config_user_agent ??
439
+ "";
440
+
441
+ if (/^pnpm\//i.test(userAgent)) {
442
+ return "pnpm";
443
+ }
444
+
445
+ if (/^yarn\//i.test(userAgent)) {
446
+ return "yarn";
447
+ }
448
+
449
+ if (/^bun\//i.test(userAgent)) {
450
+ return "bun";
451
+ }
452
+
453
+ return "npm";
454
+ }
455
+
456
+ export function normalizeUpdateTarget(
457
+ value: string | undefined
458
+ ): string {
459
+ const target =
460
+ value?.trim() ||
461
+ "latest";
462
+
463
+ if (
464
+ /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(
465
+ target
466
+ ) ||
467
+ /^[A-Za-z][A-Za-z0-9._-]*$/.test(
468
+ target
469
+ )
470
+ ) {
471
+ return target;
472
+ }
473
+
474
+ throw new Error(
475
+ `BCP Update: invalid target ${target}. Use a version such as 0.1.10 or an npm dist-tag such as latest.`
476
+ );
477
+ }
478
+
479
+ async function resolvePublishedVersion(
480
+ target: string,
481
+ rootDirectory: string
482
+ ): Promise<string> {
483
+ const output =
484
+ await runCommandCapture(
485
+ "npm",
486
+ [
487
+ "view",
488
+ `${FRAMEWORK_PACKAGE}@${target}`,
489
+ "version",
490
+ "--json",
491
+ "--prefer-online",
492
+ ],
493
+ rootDirectory
494
+ );
495
+
496
+ let parsed:
497
+ unknown;
498
+
499
+ try {
500
+ parsed =
501
+ JSON.parse(
502
+ output
503
+ );
504
+ } catch {
505
+ parsed =
506
+ output
507
+ .trim()
508
+ .replace(
509
+ /^"|"$/g,
510
+ ""
511
+ );
512
+ }
513
+
514
+ if (
515
+ Array.isArray(parsed)
516
+ ) {
517
+ parsed =
518
+ parsed.at(-1);
519
+ }
520
+
521
+ const version =
522
+ String(
523
+ parsed ?? ""
524
+ ).trim();
525
+
526
+ assertVersion(
527
+ version,
528
+ `${FRAMEWORK_PACKAGE}@${target}`
529
+ );
530
+
531
+ return version;
532
+ }
533
+
534
+ function assertRegistryDependency(
535
+ specifier: string
536
+ ): void {
537
+ if (
538
+ /^(?:file:|link:|workspace:|git\+|https?:)/i.test(
539
+ specifier
540
+ )
541
+ ) {
542
+ throw new Error(
543
+ `BCP Update: dependency specifier ${specifier} is not a published npm dependency. Use a registry dependency before running update.`
544
+ );
545
+ }
546
+ }
547
+
548
+ function extractVersion(
549
+ specifier: string
550
+ ): string | null {
551
+ const match =
552
+ /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(
553
+ specifier
554
+ );
555
+
556
+ return match?.[1] ??
557
+ null;
558
+ }
559
+
560
+ function readInstalledFrameworkVersion(
561
+ rootDirectory: string
562
+ ): string | null {
563
+ for (
564
+ const packageFile
565
+ of [
566
+ path.join(
567
+ rootDirectory,
568
+ "node_modules",
569
+ "bcp",
570
+ "package.json"
571
+ ),
572
+ path.join(
573
+ rootDirectory,
574
+ "node_modules",
575
+ "@chidchanun",
576
+ "bcp",
577
+ "package.json"
578
+ ),
579
+ ]
580
+ ) {
581
+ if (!fs.existsSync(packageFile)) {
582
+ continue;
583
+ }
584
+
585
+ try {
586
+ const metadata =
587
+ JSON.parse(
588
+ fs.readFileSync(
589
+ packageFile,
590
+ "utf8"
591
+ )
592
+ ) as {
593
+ version?: unknown;
594
+ };
595
+
596
+ if (
597
+ typeof metadata.version === "string" &&
598
+ /^\d+\.\d+\.\d+/.test(
599
+ metadata.version
600
+ )
601
+ ) {
602
+ return metadata.version;
603
+ }
604
+ } catch {
605
+ // Fall back to the declared dependency version.
606
+ }
607
+ }
608
+
609
+ return null;
610
+ }
611
+
612
+ function getPackageManagerLockfile(
613
+ rootDirectory: string,
614
+ manager: PackageManager
615
+ ): string | null {
616
+ const entry =
617
+ LOCKFILES.find(
618
+ ([fileName, candidate]) =>
619
+ candidate === manager &&
620
+ fs.existsSync(
621
+ path.join(
622
+ rootDirectory,
623
+ fileName
624
+ )
625
+ )
626
+ );
627
+
628
+ return entry
629
+ ? path.join(
630
+ rootDirectory,
631
+ entry[0]
632
+ )
633
+ : null;
634
+ }
635
+
636
+ function runPackageInstall(
637
+ packageManager: PackageManager,
638
+ rootDirectory: string
639
+ ): Promise<void> {
640
+ return runCommandInherited(
641
+ packageManager,
642
+ [
643
+ "install",
644
+ ],
645
+ rootDirectory
646
+ );
647
+ }
648
+
649
+ function runCommandCapture(
650
+ command: string,
651
+ args: string[],
652
+ cwd: string
653
+ ): Promise<string> {
654
+ const invocation =
655
+ resolveCommandInvocation(
656
+ command,
657
+ args
658
+ );
659
+
660
+ return new Promise<string>(
661
+ (
662
+ resolve,
663
+ reject
664
+ ) => {
665
+ const chunks:
666
+ string[] = [];
667
+ const errors:
668
+ string[] = [];
669
+ const child =
670
+ spawn(
671
+ invocation.command,
672
+ invocation.args,
673
+ {
674
+ cwd,
675
+ env:
676
+ process.env,
677
+ stdio: [
678
+ "ignore",
679
+ "pipe",
680
+ "pipe",
681
+ ],
682
+ }
683
+ );
684
+
685
+ child.stdout?.on(
686
+ "data",
687
+ (chunk) => {
688
+ chunks.push(
689
+ chunk.toString()
690
+ );
691
+ }
692
+ );
693
+ child.stderr?.on(
694
+ "data",
695
+ (chunk) => {
696
+ errors.push(
697
+ chunk.toString()
698
+ );
699
+ }
700
+ );
701
+ child.once(
702
+ "error",
703
+ reject
704
+ );
705
+ child.once(
706
+ "exit",
707
+ (code) => {
708
+ if (code === 0) {
709
+ resolve(
710
+ chunks.join("")
711
+ );
712
+ return;
713
+ }
714
+
715
+ reject(
716
+ new Error(
717
+ `${command} ${args.join(" ")} failed with exit code ${code}. ${errors.join("").trim()}`
718
+ )
719
+ );
720
+ }
721
+ );
722
+ }
723
+ );
724
+ }
725
+
726
+ function runCommandInherited(
727
+ command: string,
728
+ args: string[],
729
+ cwd: string
730
+ ): Promise<void> {
731
+ const invocation =
732
+ resolveCommandInvocation(
733
+ command,
734
+ args
735
+ );
736
+
737
+ return new Promise<void>(
738
+ (
739
+ resolve,
740
+ reject
741
+ ) => {
742
+ const child =
743
+ spawn(
744
+ invocation.command,
745
+ invocation.args,
746
+ {
747
+ cwd,
748
+ env:
749
+ process.env,
750
+ stdio:
751
+ "inherit",
752
+ }
753
+ );
754
+
755
+ child.once(
756
+ "error",
757
+ reject
758
+ );
759
+ child.once(
760
+ "exit",
761
+ (code) => {
762
+ if (code === 0) {
763
+ resolve();
764
+ return;
765
+ }
766
+
767
+ reject(
768
+ new Error(
769
+ `${command} install failed with exit code ${code}.`
770
+ )
771
+ );
772
+ }
773
+ );
774
+ }
775
+ );
776
+ }
777
+
778
+ function resolveCommandInvocation(
779
+ command: string,
780
+ args: string[]
781
+ ): {
782
+ command: string;
783
+ args: string[];
784
+ } {
785
+ if (
786
+ process.platform === "win32"
787
+ ) {
788
+ return {
789
+ command:
790
+ process.env.ComSpec ??
791
+ "cmd.exe",
792
+ args: [
793
+ "/d",
794
+ "/s",
795
+ "/c",
796
+ command,
797
+ ...args,
798
+ ],
799
+ };
800
+ }
801
+
802
+ return {
803
+ command,
804
+ args,
805
+ };
806
+ }
807
+
808
+ function writeJsonAtomic(
809
+ filePath: string,
810
+ value: unknown
811
+ ): void {
812
+ const temporary =
813
+ `${filePath}.bcp-update-${process.pid}`;
814
+ const content =
815
+ `${JSON.stringify(
816
+ value,
817
+ null,
818
+ 2
819
+ )}\n`;
820
+
821
+ fs.writeFileSync(
822
+ temporary,
823
+ content,
824
+ "utf8"
825
+ );
826
+ fs.renameSync(
827
+ temporary,
828
+ filePath
829
+ );
830
+ }
831
+
832
+ function parsePackageJson(
833
+ content: string,
834
+ filePath: string
835
+ ): ProjectPackageJson {
836
+ try {
837
+ return JSON.parse(
838
+ content
839
+ ) as ProjectPackageJson;
840
+ } catch (error) {
841
+ throw new Error(
842
+ `BCP Update: invalid package.json at ${filePath}. ${error instanceof Error ? error.message : String(error)}`
843
+ );
844
+ }
845
+ }
846
+
847
+ function assertVersion(
848
+ value: string,
849
+ label: string
850
+ ): void {
851
+ if (
852
+ !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(
853
+ value
854
+ )
855
+ ) {
856
+ throw new Error(
857
+ `BCP Update: ${label} resolved to invalid version ${value || "(empty)"}.`
858
+ );
859
+ }
860
+ }