@chidchanun/bcp 0.1.4 → 0.1.6

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,484 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import type {
5
+ Route,
6
+ } from "../../router/src/index.js";
7
+
8
+ import {
9
+ hasUseClientDirective,
10
+ resolveApplicationModuleAlias,
11
+ usesReactClientHooks,
12
+ } from "./application-modules.js";
13
+
14
+ const MODULE_EXTENSIONS = [
15
+ ".ts",
16
+ ".tsx",
17
+ ".mts",
18
+ ".cts",
19
+ ".js",
20
+ ".jsx",
21
+ ".mjs",
22
+ ".cjs",
23
+ ] as const;
24
+
25
+ export function validateClientBoundaries(
26
+ routes: Route[],
27
+ rootDirectory: string
28
+ ): void {
29
+ const root =
30
+ path.resolve(
31
+ rootDirectory
32
+ );
33
+
34
+ const visited =
35
+ new Set<string>();
36
+
37
+ for (
38
+ const route
39
+ of routes
40
+ ) {
41
+ visitClientModule(
42
+ route.filePath,
43
+ route.pathname,
44
+ root,
45
+ visited
46
+ );
47
+
48
+ for (
49
+ const layoutFile
50
+ of route.layouts
51
+ ) {
52
+ visitClientModule(
53
+ layoutFile,
54
+ route.pathname,
55
+ root,
56
+ visited
57
+ );
58
+ }
59
+ }
60
+
61
+ for (
62
+ const islandFile
63
+ of findIslandFiles(
64
+ path.join(
65
+ root,
66
+ "app"
67
+ )
68
+ )
69
+ ) {
70
+ visitClientModule(
71
+ islandFile,
72
+ "client island",
73
+ root,
74
+ visited,
75
+ true
76
+ );
77
+ }
78
+ }
79
+
80
+ function visitClientModule(
81
+ filePath: string,
82
+ routeName: string,
83
+ rootDirectory: string,
84
+ visited: Set<string>,
85
+ islandRoot = false
86
+ ): void {
87
+ const normalized =
88
+ normalizePath(
89
+ filePath
90
+ );
91
+
92
+ if (
93
+ visited.has(
94
+ normalized
95
+ )
96
+ ) {
97
+ return;
98
+ }
99
+
100
+ visited.add(
101
+ normalized
102
+ );
103
+
104
+ const source =
105
+ fs.readFileSync(
106
+ filePath,
107
+ "utf8"
108
+ );
109
+
110
+ if (
111
+ !islandRoot &&
112
+ usesReactClientHooks(
113
+ source
114
+ ) &&
115
+ !hasUseClientDirective(
116
+ source
117
+ )
118
+ ) {
119
+ throw new Error(
120
+ `BCP Framework: ${formatApplicationPath(rootDirectory, filePath)} uses React client hooks on ${routeName} but is missing the "use client" directive. Add "use client" as the first directive in the module.`
121
+ );
122
+ }
123
+
124
+ for (
125
+ const specifier
126
+ of collectRuntimeImports(
127
+ source
128
+ )
129
+ ) {
130
+ if (
131
+ specifier ===
132
+ "bcp/server-only"
133
+ ) {
134
+ throw new Error(
135
+ `BCP Framework: ${formatApplicationPath(rootDirectory, filePath)} imports bcp/server-only but is reachable from the client bundle for ${routeName}. Move the server dependency behind an API route.`
136
+ );
137
+ }
138
+
139
+ const dependency =
140
+ resolveLocalDependency(
141
+ rootDirectory,
142
+ filePath,
143
+ specifier
144
+ );
145
+
146
+ if (!dependency) {
147
+ continue;
148
+ }
149
+
150
+ visitClientModule(
151
+ dependency,
152
+ routeName,
153
+ rootDirectory,
154
+ visited,
155
+ /\.island\.(?:ts|tsx)$/.test(
156
+ dependency
157
+ )
158
+ );
159
+ }
160
+ }
161
+
162
+ function collectRuntimeImports(
163
+ source: string
164
+ ): string[] {
165
+ const imports =
166
+ new Set<string>();
167
+
168
+ const staticImport =
169
+ /\bimport\s+(?!type\b)(?:[^;"']*?\s+from\s+)?["']([^"']+)["']/g;
170
+ const reExport =
171
+ /\bexport\s+(?!type\b)[^;"']*?\s+from\s+["']([^"']+)["']/g;
172
+ const dynamicImport =
173
+ /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
174
+
175
+ for (
176
+ const pattern
177
+ of [
178
+ staticImport,
179
+ reExport,
180
+ dynamicImport,
181
+ ]
182
+ ) {
183
+ let match:
184
+ RegExpExecArray | null;
185
+
186
+ while (
187
+ (
188
+ match =
189
+ pattern.exec(
190
+ source
191
+ )
192
+ )
193
+ ) {
194
+ imports.add(
195
+ match[1]
196
+ );
197
+ }
198
+ }
199
+
200
+ return Array.from(
201
+ imports
202
+ );
203
+ }
204
+
205
+ function resolveLocalDependency(
206
+ rootDirectory: string,
207
+ importer: string,
208
+ specifier: string
209
+ ): string | null {
210
+ if (
211
+ specifier.startsWith(
212
+ "@/"
213
+ )
214
+ ) {
215
+ return resolveApplicationModuleAlias(
216
+ rootDirectory,
217
+ specifier
218
+ );
219
+ }
220
+
221
+ if (
222
+ !specifier.startsWith(
223
+ "./"
224
+ ) &&
225
+ !specifier.startsWith(
226
+ "../"
227
+ )
228
+ ) {
229
+ return null;
230
+ }
231
+
232
+ const unresolved =
233
+ path.resolve(
234
+ path.dirname(
235
+ importer
236
+ ),
237
+ specifier
238
+ );
239
+
240
+ const resolved =
241
+ resolveExistingModulePath(
242
+ unresolved
243
+ );
244
+
245
+ if (!resolved) {
246
+ return null;
247
+ }
248
+
249
+ const root =
250
+ path.resolve(
251
+ rootDirectory
252
+ );
253
+
254
+ if (
255
+ !isPathInside(
256
+ root,
257
+ resolved
258
+ )
259
+ ) {
260
+ return null;
261
+ }
262
+
263
+ return resolved;
264
+ }
265
+
266
+ function resolveExistingModulePath(
267
+ unresolvedPath: string
268
+ ): string | null {
269
+ if (
270
+ isFile(
271
+ unresolvedPath
272
+ )
273
+ ) {
274
+ return unresolvedPath;
275
+ }
276
+
277
+ if (
278
+ path.extname(
279
+ unresolvedPath
280
+ ) === ""
281
+ ) {
282
+ for (
283
+ const extension
284
+ of MODULE_EXTENSIONS
285
+ ) {
286
+ const candidate =
287
+ `${unresolvedPath}${extension}`;
288
+
289
+ if (
290
+ isFile(
291
+ candidate
292
+ )
293
+ ) {
294
+ return candidate;
295
+ }
296
+ }
297
+ }
298
+
299
+ if (
300
+ isDirectory(
301
+ unresolvedPath
302
+ )
303
+ ) {
304
+ for (
305
+ const extension
306
+ of MODULE_EXTENSIONS
307
+ ) {
308
+ const candidate =
309
+ path.join(
310
+ unresolvedPath,
311
+ `index${extension}`
312
+ );
313
+
314
+ if (
315
+ isFile(
316
+ candidate
317
+ )
318
+ ) {
319
+ return candidate;
320
+ }
321
+ }
322
+ }
323
+
324
+ return null;
325
+ }
326
+
327
+ function findIslandFiles(
328
+ appDirectory: string
329
+ ): string[] {
330
+ if (
331
+ !fs.existsSync(
332
+ appDirectory
333
+ )
334
+ ) {
335
+ return [];
336
+ }
337
+
338
+ const result:
339
+ string[] = [];
340
+
341
+ walkDirectory(
342
+ appDirectory,
343
+ (filePath) => {
344
+ if (
345
+ /\.island\.(?:ts|tsx)$/.test(
346
+ filePath
347
+ )
348
+ ) {
349
+ result.push(
350
+ filePath
351
+ );
352
+ }
353
+ }
354
+ );
355
+
356
+ return result.sort();
357
+ }
358
+
359
+ function walkDirectory(
360
+ directory: string,
361
+ callback: (
362
+ filePath: string
363
+ ) => void
364
+ ): void {
365
+ for (
366
+ const entry
367
+ of fs.readdirSync(
368
+ directory,
369
+ {
370
+ withFileTypes: true,
371
+ }
372
+ )
373
+ ) {
374
+ const fullPath =
375
+ path.join(
376
+ directory,
377
+ entry.name
378
+ );
379
+
380
+ if (
381
+ entry.isDirectory()
382
+ ) {
383
+ walkDirectory(
384
+ fullPath,
385
+ callback
386
+ );
387
+ continue;
388
+ }
389
+
390
+ if (
391
+ entry.isFile()
392
+ ) {
393
+ callback(
394
+ fullPath
395
+ );
396
+ }
397
+ }
398
+ }
399
+
400
+ function formatApplicationPath(
401
+ rootDirectory: string,
402
+ filePath: string
403
+ ): string {
404
+ const relative =
405
+ path.relative(
406
+ rootDirectory,
407
+ filePath
408
+ )
409
+ .replace(
410
+ /\\/g,
411
+ "/"
412
+ );
413
+
414
+ return `"${relative || filePath}"`;
415
+ }
416
+
417
+ function isPathInside(
418
+ parent: string,
419
+ child: string
420
+ ): boolean {
421
+ const relative =
422
+ path.relative(
423
+ parent,
424
+ child
425
+ );
426
+
427
+ return (
428
+ relative === "" ||
429
+ (
430
+ relative !== ".." &&
431
+ !relative.startsWith(
432
+ `..${path.sep}`
433
+ ) &&
434
+ !path.isAbsolute(
435
+ relative
436
+ )
437
+ )
438
+ );
439
+ }
440
+
441
+ function isFile(
442
+ filePath: string
443
+ ): boolean {
444
+ try {
445
+ return fs.statSync(
446
+ filePath
447
+ ).isFile();
448
+ } catch {
449
+ return false;
450
+ }
451
+ }
452
+
453
+ function isDirectory(
454
+ filePath: string
455
+ ): boolean {
456
+ try {
457
+ return fs.statSync(
458
+ filePath
459
+ ).isDirectory();
460
+ } catch {
461
+ return false;
462
+ }
463
+ }
464
+
465
+ function normalizePath(
466
+ filePath: string
467
+ ): string {
468
+ let normalized =
469
+ path.normalize(
470
+ path.resolve(
471
+ filePath
472
+ )
473
+ );
474
+
475
+ if (
476
+ process.platform ===
477
+ "win32"
478
+ ) {
479
+ normalized =
480
+ normalized.toLowerCase();
481
+ }
482
+
483
+ return normalized;
484
+ }
@@ -4,6 +4,12 @@ import {
4
4
  pathToFileURL,
5
5
  } from "node:url";
6
6
 
7
+ import {
8
+ installApplicationModuleAlias,
9
+ } from "../../bundler/src/application-modules.js";
10
+ import {
11
+ validateClientBoundaries,
12
+ } from "../../bundler/src/client-boundary.js";
7
13
  import {
8
14
  readResolvedBcpConfig,
9
15
  } from "../../config/src/index.js";
@@ -72,6 +78,31 @@ async function runDev() {
72
78
  process.env.NODE_ENV =
73
79
  "development";
74
80
 
81
+ const rootDirectory =
82
+ resolveProjectRoot(
83
+ cliOptions.rootDirectory
84
+ );
85
+
86
+ installApplicationModuleAlias(
87
+ rootDirectory
88
+ );
89
+
90
+ const appDirectory =
91
+ path.join(
92
+ rootDirectory,
93
+ "app"
94
+ );
95
+
96
+ const pageRoutes =
97
+ scanRoutes(
98
+ appDirectory
99
+ );
100
+
101
+ validateClientBoundaries(
102
+ pageRoutes,
103
+ rootDirectory
104
+ );
105
+
75
106
  const {
76
107
  createMiddlewareDevServer,
77
108
  } =
@@ -79,11 +110,6 @@ async function runDev() {
79
110
  "../../server/src/middleware-dev-server.js"
80
111
  );
81
112
 
82
- const rootDirectory =
83
- resolveProjectRoot(
84
- cliOptions.rootDirectory
85
- );
86
-
87
113
  console.log(
88
114
  `[BCP CLI] Project: ${rootDirectory}`
89
115
  );
@@ -151,6 +177,11 @@ async function runBuild() {
151
177
  apiRoutes
152
178
  );
153
179
 
180
+ validateClientBoundaries(
181
+ pageRoutes,
182
+ rootDirectory
183
+ );
184
+
154
185
  console.log("");
155
186
  console.log(
156
187
  `[BCP Build] Project: ${rootDirectory}`
@@ -0,0 +1,7 @@
1
+ import "node:fs";
2
+
3
+ throw new Error(
4
+ "BCP Framework: bcp/server-only cannot be loaded in the browser."
5
+ );
6
+
7
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};