@junejs/server 0.0.32 → 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.32",
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",
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,16 @@
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";
25
14
 
26
15
  import { parseSync } from "oxc-parser";
27
16
 
@@ -36,7 +25,7 @@ export function walk(dir: string): string[] {
36
25
  }
37
26
 
38
27
  // True iff the first real statement (skipping blanks + line comments) is the
39
- // directive — the same rule the bundler uses to treat a module as client.
28
+ // directive — used by the RSC manifest codegen.
40
29
  export function firstStatementIsDirective(src: string, directive: string): boolean {
41
30
  for (const line of src.split("\n")) {
42
31
  const t = line.trim();
@@ -46,14 +35,14 @@ export function firstStatementIsDirective(src: string, directive: string): boole
46
35
  return false;
47
36
  }
48
37
 
49
- // 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.
50
39
  export function exportNames(src: string): string[] {
51
40
  const names: string[] = [];
52
41
  for (const line of src.split("\n")) {
53
42
  const t = line.trim();
54
43
  const decl = t.match(/^export\s+(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z0-9_$]+)/);
55
44
  if (decl) names.push(decl[1]!);
56
- const named = t.match(/^export\s*\{([^}]*)\}/); // `export type { … }` won't match (the `type` breaks it)
45
+ const named = t.match(/^export\s*\{([^}]*)\}/);
57
46
  if (named) {
58
47
  for (const part of named[1]!.split(",")) {
59
48
  const e = part.split(" as ").pop()?.trim();
@@ -64,87 +53,96 @@ export function exportNames(src: string): string[] {
64
53
  return names;
65
54
  }
66
55
 
67
- // The island NAME an `island(...)` call registers under — the SAME value the
68
- // runtime derives (options.name ?? the component's function/identifier name).
69
- // Keying the loader by THIS (not the export name) is what kills the desync
70
- // (P1-1): the marker, the registry, and the loader all agree by construction.
71
- // Returns null for an anonymous component with no { name } — island() throws on
72
- // that at runtime, so we leave it for the runtime to report.
73
- function islandNameOfCall(init: unknown): string | null {
74
- const call = init as { type?: string; callee?: { type?: string; name?: string }; arguments?: any[] };
75
- if (call?.type !== "CallExpression") return null;
76
- if (call.callee?.type !== "Identifier" || call.callee.name !== "island") return null;
77
- const [a0, a1] = call.arguments ?? [];
78
- if (a1?.type === "ObjectExpression") {
79
- for (const p of a1.properties ?? []) {
80
- const key = p.key?.name ?? p.key?.value;
81
- if (key === "name" && typeof p.value?.value === "string") return p.value.value;
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" });
82
68
  }
83
69
  }
84
- if (a0?.type === "FunctionExpression") return a0.id?.name ?? null;
85
- if (a0?.type === "Identifier") return a0.name ?? null;
86
- return null; // arrow/anonymous without { name } → runtime island() errors
70
+ return map;
87
71
  }
88
72
 
89
- // True iff the module imports `island` from @junejs/core/islands (so a local
90
- // function happening to be named `island` is never mistaken for the API).
91
- function importsIslandApi(program: { body: any[] }): boolean {
92
- return program.body.some(
93
- (n) =>
94
- n.type === "ImportDeclaration" &&
95
- n.source?.value === "@junejs/core/islands" &&
96
- (n.specifiers ?? []).some((s: any) => s.imported?.name === "island"),
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",
97
77
  );
98
78
  }
99
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
+
100
94
  // Write app/_islands.gen.ts. Returns the number of island loaders emitted.
101
- //
102
- // AST-based (oxc-parser) — robust against multi-line exports / re-exports that a
103
- // line regex would miss (P1-2), keyed by the island's runtime name (P1-1), and
104
- // it throws on a duplicate name across modules (P2-1) instead of silently
105
- // overwriting.
106
95
  export function generateIslandRegistry(appDir: string): number {
107
- const seen = new Map<string, string>(); // island name → absolute file (dup guard)
96
+ const seen = new Map<string, string>(); // island name → loader specifier (dup guard)
108
97
  const entries: string[] = [];
109
98
 
110
99
  for (const f of walk(appDir)) {
111
- if (!/\.(tsx|ts|jsx)$/.test(f) || f.endsWith(ISLAND_REGISTRY_FILE)) continue;
100
+ if (!/\.(tsx|jsx)$/.test(f) || f.endsWith(ISLAND_REGISTRY_FILE) || f.endsWith(".gen.ts")) continue;
112
101
  const src = readFileSync(f, "utf8");
113
- if (!firstStatementIsDirective(src, "use client")) continue;
102
+ if (!src.includes("client:")) continue; // cheap pre-filter
114
103
  const { program } = parseSync(f, src);
115
- if (!importsIslandApi(program as { body: any[] })) continue;
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;
116
126
 
117
- const rel = "./" + relative(appDir, f).replace(/\.(tsx|ts|jsx)$/, "");
118
- for (const node of (program as { body: any[] }).body) {
119
- const varDecl =
120
- node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration"
121
- ? node.declaration
122
- : node.type === "VariableDeclaration"
123
- ? node
124
- : null;
125
- if (!varDecl) continue;
126
- for (const d of varDecl.declarations ?? []) {
127
- const name = islandNameOfCall(d.init);
128
- if (!name) continue;
129
- const prev = seen.get(name);
130
- if (prev && prev !== f) {
131
- throw new Error(
132
- `[june] duplicate island name "${name}": defined in ${rel} and ` +
133
- `./${relative(appDir, prev).replace(/\.(tsx|ts|jsx)$/, "")}. Island names must be unique.`,
134
- );
135
- }
136
- if (seen.has(name)) continue;
137
- seen.set(name, f);
138
- entries.push(` ${JSON.stringify(name)}: () => import(${JSON.stringify(rel)}),`);
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
+ );
139
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
+ );
140
138
  }
141
139
  }
142
140
 
143
141
  entries.sort(); // deterministic output
144
142
  const body = entries.length ? entries.join("\n") + "\n" : "";
145
143
  const out =
146
- "// AUTO-GENERATED by June — do not edit. Lazy loaders for island() modules.\n" +
147
- "// 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" +
148
146
  "export const ISLAND_LOADERS: Record<string, () => Promise<unknown>> = {\n" +
149
147
  body +
150
148
  "};\n";