@rainfw/core 0.2.0 → 0.2.2

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 (2) hide show
  1. package/package.json +7 -2
  2. package/scripts/generate.js +295 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainfw/core",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "A TypeScript web framework for Cloudflare Workers",
5
5
  "bin": {
6
6
  "rainjs": "./cli/index.js"
@@ -21,7 +21,8 @@
21
21
  "test:generate": "node --test tests/generate.test.js",
22
22
  "bench": "node scripts/bench.js",
23
23
  "build:pkg": "tsc -p tsconfig.build.json && node -e \"require('fs').writeFileSync('dist/package.json',JSON.stringify({type:'module'}))\"",
24
- "prepublishOnly": "npm run build:pkg"
24
+ "prepublishOnly": "npm run build:pkg",
25
+ "release": "npm run ci && npm publish --access public"
25
26
  },
26
27
  "keywords": [
27
28
  "cloudflare-workers",
@@ -57,6 +58,10 @@
57
58
  "./db": {
58
59
  "types": "./dist/db.d.ts",
59
60
  "import": "./dist/db.js"
61
+ },
62
+ "./client": {
63
+ "types": "./dist/client/hooks.d.ts",
64
+ "import": "./dist/client/hooks.js"
60
65
  }
61
66
  },
62
67
  "devDependencies": {
@@ -271,6 +271,13 @@ function bundleClientFilesSync(clientFiles, srcDir) {
271
271
  return scripts;
272
272
  }
273
273
 
274
+ function stripRouteGroupSegments(filePath) {
275
+ return filePath
276
+ .split("/")
277
+ .filter((segment) => !/^\(.+\)$/.test(segment))
278
+ .join("/");
279
+ }
280
+
274
281
  function routePathToDir(filePath) {
275
282
  return filePath
276
283
  .replace(/\\/g, "/")
@@ -285,6 +292,7 @@ function middlewareImportName(filePath) {
285
292
  .replace(/\//g, "_")
286
293
  .replace(/\[/g, "$")
287
294
  .replace(/\]/g, "")
295
+ .replace(/[()]/g, "")
288
296
  .replace(/-/g, "_")
289
297
  .replace(/_+$/, "");
290
298
  return `mw_${base || "root"}`;
@@ -307,6 +315,7 @@ function layoutPathToDir(filePath) {
307
315
  function pageFilePathToUrlPath(filePath) {
308
316
  let urlPath = filePath.replace(/\.tsx?$/, "");
309
317
  urlPath = urlPath.replace(/\\/g, "/");
318
+ urlPath = stripRouteGroupSegments(urlPath);
310
319
  urlPath = urlPath.replace(/\[([^\]]+)\]/g, ":$1");
311
320
  urlPath = urlPath.replace(/\/page$/, "");
312
321
  if (urlPath === "page") urlPath = "";
@@ -322,6 +331,7 @@ function pageFilePathToImportName(filePath) {
322
331
  .replace(/\//g, "_")
323
332
  .replace(/\[/g, "$")
324
333
  .replace(/\]/g, "")
334
+ .replace(/[()]/g, "")
325
335
  .replace(/-/g, "_")
326
336
  );
327
337
  }
@@ -333,6 +343,7 @@ function layoutImportName(filePath) {
333
343
  .replace(/\//g, "_")
334
344
  .replace(/\[/g, "$")
335
345
  .replace(/\]/g, "")
346
+ .replace(/[()]/g, "")
336
347
  .replace(/-/g, "_")
337
348
  .replace(/_+$/, "");
338
349
  return `layout_${base || "root"}`;
@@ -402,6 +413,63 @@ function validateNoPageRouteColocation(routeFiles, pageFiles) {
402
413
  }
403
414
  }
404
415
 
416
+ const routeUrlMap = new Map();
417
+ for (const f of routeFiles) {
418
+ routeUrlMap.set(filePathToUrlPath(f), f);
419
+ }
420
+ for (const pageFile of pageFiles) {
421
+ const url = pageFilePathToUrlPath(pageFile);
422
+ const conflicting = routeUrlMap.get(url);
423
+ if (conflicting) {
424
+ const pageDir = pageFilePathToDir(pageFile);
425
+ if (!routeDirs.has(pageDir)) {
426
+ errors.push(
427
+ `[Rain] Error: page "${pageFile}" and route "${conflicting}" resolve to the same URL path "${url}":\n` +
428
+ " → This conflict occurs because route group folders are stripped from URLs.\n" +
429
+ " → Move one of them to a different URL path.",
430
+ );
431
+ }
432
+ }
433
+ }
434
+
435
+ return errors;
436
+ }
437
+
438
+ function validateNoDuplicateUrls(routeFiles, pageFiles) {
439
+ const errors = [];
440
+
441
+ const routeUrlMap = new Map();
442
+ for (const f of routeFiles) {
443
+ const url = filePathToUrlPath(f);
444
+ if (routeUrlMap.has(url)) {
445
+ errors.push(
446
+ `[Rain] Error: multiple route files resolve to the same URL path "${url}":\n` +
447
+ ` → ${routeUrlMap.get(url)}\n` +
448
+ ` → ${f}\n` +
449
+ " → Route group folders are stripped from URLs.\n" +
450
+ " → Rename one of the routes to avoid the conflict.",
451
+ );
452
+ } else {
453
+ routeUrlMap.set(url, f);
454
+ }
455
+ }
456
+
457
+ const pageUrlMap = new Map();
458
+ for (const f of pageFiles) {
459
+ const url = pageFilePathToUrlPath(f);
460
+ if (pageUrlMap.has(url)) {
461
+ errors.push(
462
+ `[Rain] Error: multiple page files resolve to the same URL path "${url}":\n` +
463
+ ` → ${pageUrlMap.get(url)}\n` +
464
+ ` → ${f}\n` +
465
+ " → Route group folders are stripped from URLs.\n" +
466
+ " → Rename one of the pages to avoid the conflict.",
467
+ );
468
+ } else {
469
+ pageUrlMap.set(url, f);
470
+ }
471
+ }
472
+
405
473
  return errors;
406
474
  }
407
475
 
@@ -429,6 +497,7 @@ function getMiddlewaresForRoute(routeFile, middlewareFiles) {
429
497
  function filePathToUrlPath(filePath) {
430
498
  let urlPath = filePath.replace(/\.tsx?$/, "");
431
499
  urlPath = urlPath.replace(/\\/g, "/");
500
+ urlPath = stripRouteGroupSegments(urlPath);
432
501
  urlPath = urlPath.replace(/\[([^\]]+)\]/g, ":$1");
433
502
  urlPath = urlPath.replace(/\/route$/, "");
434
503
  if (urlPath === "route") urlPath = "";
@@ -444,6 +513,7 @@ function filePathToImportName(filePath) {
444
513
  .replace(/\//g, "_")
445
514
  .replace(/\[/g, "$")
446
515
  .replace(/\]/g, "")
516
+ .replace(/[()]/g, "")
447
517
  .replace(/-/g, "_")
448
518
  );
449
519
  }
@@ -517,6 +587,65 @@ function detectExportedMethods(filePath) {
517
587
  return detectExportedMethodsFromContent(content);
518
588
  }
519
589
 
590
+ function collectAllExportedNames(node) {
591
+ if (ts.isVariableStatement(node)) {
592
+ return node.declarationList.declarations
593
+ .filter((d) => ts.isIdentifier(d.name))
594
+ .map((d) => d.name.text);
595
+ }
596
+ if (
597
+ (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) &&
598
+ node.name
599
+ ) {
600
+ return [node.name.text];
601
+ }
602
+ return [];
603
+ }
604
+
605
+ function collectAllNamedExports(node) {
606
+ if (
607
+ !(
608
+ ts.isExportDeclaration(node) &&
609
+ node.exportClause &&
610
+ ts.isNamedExports(node.exportClause)
611
+ )
612
+ ) {
613
+ return [];
614
+ }
615
+ return node.exportClause.elements.map((el) => el.name.text);
616
+ }
617
+
618
+ function detectAllExportsFromContent(content) {
619
+ const sourceFile = ts.createSourceFile(
620
+ "file.tsx",
621
+ content,
622
+ ts.ScriptTarget.Latest,
623
+ true,
624
+ );
625
+ const named = [];
626
+ let hasDefault = false;
627
+
628
+ ts.forEachChild(sourceFile, (node) => {
629
+ if (ts.isExportAssignment(node) && !node.isExportEquals) {
630
+ hasDefault = true;
631
+ return;
632
+ }
633
+ if (hasExportKeyword(node)) {
634
+ const modifiers = ts.canHaveModifiers(node)
635
+ ? ts.getModifiers(node)
636
+ : undefined;
637
+ if (modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) {
638
+ hasDefault = true;
639
+ } else {
640
+ named.push(...collectAllExportedNames(node));
641
+ }
642
+ }
643
+ named.push(...collectAllNamedExports(node));
644
+ });
645
+
646
+ return { named, hasDefault };
647
+ }
648
+
520
649
  function detectMiddlewareExportFromContent(content) {
521
650
  return detectExportedNamesFromContent(content, ["onRequest"]).length > 0;
522
651
  }
@@ -703,6 +832,156 @@ function regenerateClient() {
703
832
  console.log(`[gen:client] ${clientMsg} -> .rainjs/entry.ts`);
704
833
  }
705
834
 
835
+ function clientFileToIslandId(relPath) {
836
+ return relPath
837
+ .replace(/\\/g, "/")
838
+ .replace(/\.tsx?$/, "")
839
+ .replace(/[^a-zA-Z0-9_/]/g, "_");
840
+ }
841
+
842
+ function generateIslandProxy(clientRelPath, srcDir, fwImport) {
843
+ const islandDir = path.join(PROJECT_ROOT, BUILD_CONFIG.outDir, "islands");
844
+ if (!fs.existsSync(islandDir)) {
845
+ fs.mkdirSync(islandDir, { recursive: true });
846
+ }
847
+
848
+ const fullPath = path.join(srcDir, clientRelPath);
849
+ const content = fs.readFileSync(fullPath, "utf-8");
850
+ const { named, hasDefault } = detectAllExportsFromContent(content);
851
+ const islandId = clientFileToIslandId(clientRelPath);
852
+
853
+ const proxyFile = path.join(
854
+ islandDir,
855
+ clientRelPath.replace(/\\/g, "/").replace(/\.tsx?$/, ".ts"),
856
+ );
857
+ const proxyDir = path.dirname(proxyFile);
858
+ if (!fs.existsSync(proxyDir)) {
859
+ fs.mkdirSync(proxyDir, { recursive: true });
860
+ }
861
+
862
+ const proxyEntryDir = path.dirname(proxyFile);
863
+ let relOriginal = path
864
+ .relative(
865
+ proxyEntryDir,
866
+ path.join(srcDir, clientRelPath.replace(/\.tsx?$/, "")),
867
+ )
868
+ .replace(/\\/g, "/");
869
+ if (!relOriginal.startsWith(".")) relOriginal = `./${relOriginal}`;
870
+
871
+ const lines = [];
872
+ lines.push(`import { markAsIsland } from "${fwImport}";`);
873
+
874
+ const importSpecifiers = [];
875
+ if (hasDefault) {
876
+ importSpecifiers.push("default as _default");
877
+ }
878
+ for (const name of named) {
879
+ importSpecifiers.push(`${name} as _${name}`);
880
+ }
881
+ if (importSpecifiers.length > 0) {
882
+ lines.push(
883
+ `import { ${importSpecifiers.join(", ")} } from "${relOriginal}";`,
884
+ );
885
+ }
886
+ lines.push("");
887
+
888
+ if (hasDefault) {
889
+ lines.push(`export default markAsIsland("${islandId}:default", _default);`);
890
+ }
891
+ for (const name of named) {
892
+ lines.push(
893
+ `export const ${name} = markAsIsland("${islandId}:${name}", _${name});`,
894
+ );
895
+ }
896
+ lines.push("");
897
+
898
+ fs.writeFileSync(proxyFile, lines.join("\n"));
899
+ return { proxyFile, islandId, named, hasDefault };
900
+ }
901
+
902
+ function generateAllIslandProxies(clientFiles, srcDir, fwImport) {
903
+ const islandDir = path.join(PROJECT_ROOT, BUILD_CONFIG.outDir, "islands");
904
+ if (fs.existsSync(islandDir)) {
905
+ fs.rmSync(islandDir, { recursive: true, force: true });
906
+ }
907
+
908
+ const proxies = [];
909
+ for (const cf of clientFiles) {
910
+ proxies.push(generateIslandProxy(cf, srcDir, fwImport));
911
+ }
912
+ return proxies;
913
+ }
914
+
915
+ function updateWranglerAliases(clientFiles, srcDir) {
916
+ const wranglerPath = path.join(PROJECT_ROOT, "wrangler.toml");
917
+ if (!fs.existsSync(wranglerPath)) return;
918
+
919
+ let content = fs.readFileSync(wranglerPath, "utf-8");
920
+
921
+ const markerStart = "# [rain:alias:start]";
922
+ const markerEnd = "# [rain:alias:end]";
923
+
924
+ const startIdx = content.indexOf(markerStart);
925
+ const endIdx = content.indexOf(markerEnd);
926
+ if (startIdx !== -1 && endIdx !== -1) {
927
+ content =
928
+ content.slice(0, startIdx).trimEnd() +
929
+ "\n" +
930
+ content.slice(endIdx + markerEnd.length).trimStart();
931
+ }
932
+
933
+ if (clientFiles.length === 0) {
934
+ const cleaned = `${content.trimEnd()}
935
+ `;
936
+ if (cleaned !== fs.readFileSync(wranglerPath, "utf-8")) {
937
+ fs.writeFileSync(wranglerPath, cleaned);
938
+ }
939
+ return;
940
+ }
941
+
942
+ const islandDir = path.join(PROJECT_ROOT, BUILD_CONFIG.outDir, "islands");
943
+
944
+ const aliasLines = [markerStart];
945
+ const hasExistingAlias = /^\[alias\]/m.test(content);
946
+ if (!hasExistingAlias) {
947
+ aliasLines.push("[alias]");
948
+ }
949
+
950
+ for (const cf of clientFiles) {
951
+ const originalAbs = path.join(srcDir, cf.replace(/\.tsx?$/, ""));
952
+ const proxyAbs = path.join(
953
+ islandDir,
954
+ cf.replace(/\\/g, "/").replace(/\.tsx?$/, ".ts"),
955
+ );
956
+ const relProxy = path.relative(PROJECT_ROOT, proxyAbs).replace(/\\/g, "/");
957
+ const relOriginal = path
958
+ .relative(PROJECT_ROOT, originalAbs)
959
+ .replace(/\\/g, "/");
960
+ aliasLines.push(`"./${relOriginal}" = "./${relProxy}"`);
961
+ }
962
+ aliasLines.push(markerEnd);
963
+
964
+ const aliasBlock = aliasLines.join("\n");
965
+
966
+ if (hasExistingAlias) {
967
+ const aliasIdx = content.search(/^\[alias\]/m);
968
+ let insertAt = content.indexOf("\n", aliasIdx);
969
+ if (insertAt === -1) insertAt = content.length;
970
+ content =
971
+ content.slice(0, insertAt + 1) +
972
+ aliasLines.filter((l) => l !== "[alias]").join("\n") +
973
+ "\n" +
974
+ content.slice(insertAt + 1);
975
+ } else {
976
+ content = `${content.trimEnd()}
977
+
978
+ ${aliasBlock}
979
+ `;
980
+ }
981
+
982
+ fs.writeFileSync(wranglerPath, content);
983
+ }
984
+
706
985
  function generate() {
707
986
  if (!fs.existsSync(ROUTES_DIR)) {
708
987
  console.error(
@@ -750,6 +1029,12 @@ function generate() {
750
1029
  process.exit(1);
751
1030
  }
752
1031
 
1032
+ const duplicateErrors = validateNoDuplicateUrls(files, pageFiles);
1033
+ for (const err of duplicateErrors) {
1034
+ console.error(err);
1035
+ process.exit(1);
1036
+ }
1037
+
753
1038
  const hasRootLayout = layoutFiles.some((f) => layoutPathToDir(f) === "");
754
1039
 
755
1040
  processMiddlewares(middlewareFiles, imports);
@@ -775,6 +1060,9 @@ function generate() {
775
1060
  ? relativeImportPath(path.join(PROJECT_ROOT, fwPkg))
776
1061
  : fwPkg;
777
1062
 
1063
+ generateAllIslandProxies(clientFiles, srcDir, frameworkImport);
1064
+ updateWranglerAliases(clientFiles, srcDir);
1065
+
778
1066
  const headerImports = [`import { Rain } from "${frameworkImport}";`];
779
1067
  if (hasConfig) {
780
1068
  const configPath = relativeImportPath(
@@ -830,8 +1118,15 @@ module.exports = {
830
1118
  detectDefaultExport,
831
1119
  detectDefaultExportFromContent,
832
1120
  detectUseClientDirective,
1121
+ detectAllExportsFromContent,
1122
+ generateIslandProxy,
1123
+ generateAllIslandProxies,
1124
+ updateWranglerAliases,
1125
+ clientFileToIslandId,
833
1126
  bundleClientFilesSync,
834
1127
  validateNoPageRouteColocation,
1128
+ validateNoDuplicateUrls,
1129
+ stripRouteGroupSegments,
835
1130
  ROUTES_DIR,
836
1131
  ENTRY_FILE,
837
1132
  HTTP_METHODS,