@junejs/server 0.0.31 → 0.0.33

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.0.31",
3
+ "version": "0.0.33",
4
4
  "description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,10 +30,11 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@junejs/core": "0.0.32",
33
+ "@junejs/core": "0.0.33",
34
34
  "@junejs/db": "0.0.28",
35
35
  "lightningcss": "^1.27.0",
36
36
  "marked": "^18.0.5",
37
+ "oxc-parser": "^0.137.0",
37
38
  "rolldown": "^1.1.0"
38
39
  },
39
40
  "peerDependencies": {
package/src/build.ts CHANGED
@@ -532,7 +532,12 @@ ${adapterEntry.wrap("pipeline")}
532
532
  // the dev-only code tree-shakes. Build-time on purpose: runtime process.env.NODE_ENV
533
533
  // differs by target (Vercel sets it; workerd may not), so baking it makes the output
534
534
  // deterministic and target-agnostic. `june dev` doesn't use this path.
535
- transform: { define: { "process.env.NODE_ENV": JSON.stringify("production") } },
535
+ transform: {
536
+ define: { "process.env.NODE_ENV": JSON.stringify("production") },
537
+ // Route JSX through June's runtime so `<X client:*/>` in pages emits island
538
+ // markers at SSR (rolldown ignores the tsconfig/pragma; set it explicitly).
539
+ jsx: { runtime: "automatic", importSource: "@junejs/core" },
540
+ },
536
541
  plugins: [rolldownCssModulesPlugin(cssModuleMaps)], // .module.css → scoped class map
537
542
  external: (id: string) => {
538
543
  // Bun built-ins exist only at Bun runtime, never in the workerd graph (see isBunSpecifier).
@@ -50,7 +50,10 @@ async function bundleClient(entryFile: string, cwd: string, mode: BundleMode, ma
50
50
  // branch resolved at build (no `process` in the browser to read it at runtime).
51
51
  platform: "browser",
52
52
  plugins: [rolldownCssModulesPlugin(maps)], // islands may import .module.css
53
- transform: { define: { "process.env.NODE_ENV": JSON.stringify(mode) } },
53
+ transform: {
54
+ define: { "process.env.NODE_ENV": JSON.stringify(mode) },
55
+ jsx: { runtime: "automatic", importSource: "@junejs/core" }, // June's island JSX runtime
56
+ },
54
57
  resolve: { conditionNames: ["browser", "import", "default"] },
55
58
  });
56
59
  return bundle;
@@ -1,27 +1,18 @@
1
- // Auto-generate the island registry by scanning "use client" island() modules,
2
- // so the author never hand-writes the `{ name: () => import("./Island") }` map.
1
+ // Auto-generate the island registry by scanning ISLAND USAGE — every
2
+ // `<Counter client:*/>` in the app — and resolving the component's import AT THE
3
+ // USAGE SITE. Writes app/_islands.gen.ts mapping each island name to a lazy loader
4
+ // that dynamic-imports its module and resolves the component export.
3
5
  //
4
- // Scans app/** for modules that (a) start with the "use client" directive AND
5
- // (b) use `island()` from @junejs/core/islands, and writes app/_islands.gen.ts:
6
+ // Usage-driven, so a third-party (lib) island is discovered the same way as an app
7
+ // one — the import specifier is right there at the call site (no lib manifest, no
8
+ // scan of node_modules). The KEY is the imported name = the component's runtime name
9
+ // = the marker the JSX runtime stamps, so all three agree by construction.
6
10
  //
7
- // export const ISLAND_LOADERS = {
8
- // "Counter": () => import("./poc/Counter"),
9
- // "Tabs": () => import("./poc/Tabs"),
10
- // };
11
- //
12
- // The KEY is the module's EXPORT name; importing the module runs its `island()`
13
- // call, which self-registers the component under its runtime name. Those must
14
- // agree — the marker the server stamps (the island's name) is what the client
15
- // looks up. So the convention is: an island's name == its export name (the React
16
- // default — `export const Counter = island(function Counter() {…})` satisfies it
17
- // for free; otherwise pass `island(C, { name })`).
18
- //
19
- // Over-inclusion is harmless: a loader whose name no marker references is never
20
- // called, so no chunk for an unused export is ever fetched.
21
- //
22
- // Host-coupled (node:fs), so it lives in @junejs/server, never the contract layer.
11
+ // Host-coupled (node:fs + oxc-parser), so it lives in @junejs/server.
23
12
  import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
24
- import { join, relative } from "node:path";
13
+ import { join, relative, dirname, resolve } from "node:path";
14
+
15
+ import { parseSync } from "oxc-parser";
25
16
 
26
17
  export const ISLAND_REGISTRY_FILE = "_islands.gen.ts";
27
18
 
@@ -34,7 +25,7 @@ export function walk(dir: string): string[] {
34
25
  }
35
26
 
36
27
  // True iff the first real statement (skipping blanks + line comments) is the
37
- // directive — the same rule the bundler uses to treat a module as client.
28
+ // directive — used by the RSC manifest codegen.
38
29
  export function firstStatementIsDirective(src: string, directive: string): boolean {
39
30
  for (const line of src.split("\n")) {
40
31
  const t = line.trim();
@@ -44,14 +35,14 @@ export function firstStatementIsDirective(src: string, directive: string): boole
44
35
  return false;
45
36
  }
46
37
 
47
- // The named exports of a module (skipping `export default` and `export type`).
38
+ // The named exports of a module — used by the RSC manifest codegen.
48
39
  export function exportNames(src: string): string[] {
49
40
  const names: string[] = [];
50
41
  for (const line of src.split("\n")) {
51
42
  const t = line.trim();
52
43
  const decl = t.match(/^export\s+(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z0-9_$]+)/);
53
44
  if (decl) names.push(decl[1]!);
54
- const named = t.match(/^export\s*\{([^}]*)\}/); // `export type { … }` won't match (the `type` breaks it)
45
+ const named = t.match(/^export\s*\{([^}]*)\}/);
55
46
  if (named) {
56
47
  for (const part of named[1]!.split(",")) {
57
48
  const e = part.split(" as ").pop()?.trim();
@@ -62,31 +53,96 @@ export function exportNames(src: string): string[] {
62
53
  return names;
63
54
  }
64
55
 
56
+ type Import = { spec: string; imported: string; kind: "named" | "default" | "namespace" };
57
+
58
+ // local binding name → its import, for one module.
59
+ function importMap(program: { body: any[] }): Map<string, Import> {
60
+ const map = new Map<string, Import>();
61
+ for (const n of program.body) {
62
+ if (n.type !== "ImportDeclaration" || typeof n.source?.value !== "string") continue;
63
+ const spec = n.source.value as string;
64
+ for (const s of n.specifiers ?? []) {
65
+ if (s.type === "ImportSpecifier") map.set(s.local.name, { spec, imported: s.imported.name, kind: "named" });
66
+ else if (s.type === "ImportDefaultSpecifier") map.set(s.local.name, { spec, imported: "default", kind: "default" });
67
+ else if (s.type === "ImportNamespaceSpecifier") map.set(s.local.name, { spec, imported: "*", kind: "namespace" });
68
+ }
69
+ }
70
+ return map;
71
+ }
72
+
73
+ // Does this JSX element carry a `client:*` directive?
74
+ function hasClientDirective(openingElement: any): boolean {
75
+ return (openingElement.attributes ?? []).some(
76
+ (a: any) => a.type === "JSXAttribute" && a.name?.type === "JSXNamespacedName" && a.name.namespace?.name === "client",
77
+ );
78
+ }
79
+
80
+ // Collect every JSX element tag used with a client:* directive (recursive walk).
81
+ function islandTags(program: { body: any[] }): string[] {
82
+ const tags: string[] = [];
83
+ (function walkNode(node: any) {
84
+ if (!node || typeof node !== "object") return;
85
+ if (Array.isArray(node)) return node.forEach(walkNode);
86
+ if (node.type === "JSXElement" && node.openingElement?.name?.type === "JSXIdentifier" && hasClientDirective(node.openingElement)) {
87
+ tags.push(node.openingElement.name.name);
88
+ }
89
+ for (const k in node) if (k !== "type") walkNode(node[k]);
90
+ })(program);
91
+ return tags;
92
+ }
93
+
65
94
  // Write app/_islands.gen.ts. Returns the number of island loaders emitted.
66
95
  export function generateIslandRegistry(appDir: string): number {
67
- const mods = walk(appDir)
68
- .filter((f) => /\.(tsx|ts|jsx)$/.test(f) && !f.endsWith(ISLAND_REGISTRY_FILE))
69
- .map((f) => ({ f, src: readFileSync(f, "utf8") }))
70
- .filter(
71
- ({ src }) =>
72
- firstStatementIsDirective(src, "use client") &&
73
- /@junejs\/core\/islands/.test(src) &&
74
- /\bisland\s*\(/.test(src),
75
- );
76
-
96
+ const seen = new Map<string, string>(); // island name → loader specifier (dup guard)
77
97
  const entries: string[] = [];
78
- for (const { f, src } of mods) {
79
- const rel = "./" + relative(appDir, f).replace(/\.(tsx|ts|jsx)$/, "");
80
- for (const name of exportNames(src)) {
81
- if (name === "default") continue; // islands are named exports
82
- entries.push(` ${JSON.stringify(name)}: () => import(${JSON.stringify(rel)}),`);
98
+
99
+ for (const f of walk(appDir)) {
100
+ if (!/\.(tsx|jsx)$/.test(f) || f.endsWith(ISLAND_REGISTRY_FILE) || f.endsWith(".gen.ts")) continue;
101
+ const src = readFileSync(f, "utf8");
102
+ if (!src.includes("client:")) continue; // cheap pre-filter
103
+ const { program } = parseSync(f, src);
104
+ const ast = program as { body: any[] };
105
+ const imports = importMap(ast);
106
+ const rel = "./" + relative(appDir, f);
107
+
108
+ for (const tag of islandTags(ast)) {
109
+ const imp = imports.get(tag);
110
+ if (!imp) {
111
+ throw new Error(
112
+ `[june] <${tag} client:*/> in ${rel}: an island must be an IMPORTED component (a named import), not a local definition.`,
113
+ );
114
+ }
115
+ if (imp.kind !== "named") {
116
+ throw new Error(
117
+ `[june] <${tag} client:*/> in ${rel}: island imports must be NAMED imports (a named \`{ ${tag} }\` import; default/namespace imports aren't supported as islands).`,
118
+ );
119
+ }
120
+ const name = imp.imported; // = the component's runtime name = the marker the JSX runtime stamps
121
+ // Re-base a relative specifier from the importing file to appDir (the gen
122
+ // file lives in appDir); a package specifier is used verbatim.
123
+ const loaderSpec = imp.spec.startsWith(".")
124
+ ? "./" + relative(appDir, resolve(dirname(f), imp.spec))
125
+ : imp.spec;
126
+
127
+ const prev = seen.get(name);
128
+ if (prev && prev !== loaderSpec) {
129
+ throw new Error(
130
+ `[june] duplicate island name "${name}" from different modules ("${prev}" and "${loaderSpec}"). Island names must be unique.`,
131
+ );
132
+ }
133
+ if (seen.has(name)) continue; // same island used on several pages — fine
134
+ seen.set(name, loaderSpec);
135
+ entries.push(
136
+ ` ${JSON.stringify(name)}: () => import(${JSON.stringify(loaderSpec)}).then((m) => m.${name}),`,
137
+ );
83
138
  }
84
139
  }
85
140
 
141
+ entries.sort(); // deterministic output
86
142
  const body = entries.length ? entries.join("\n") + "\n" : "";
87
143
  const out =
88
- "// AUTO-GENERATED by June — do not edit. Lazy loaders for island() modules.\n" +
89
- "// Maps each island name to a lazy chunk loader; hydrateIslandsLazy consumes it.\n" +
144
+ "// AUTO-GENERATED by June — do not edit. Lazy loaders for client:* islands.\n" +
145
+ "// Maps each island name to a loader that resolves its component; hydrateIslands uses it.\n" +
90
146
  "export const ISLAND_LOADERS: Record<string, () => Promise<unknown>> = {\n" +
91
147
  body +
92
148
  "};\n";