@tschk/moonshine-compiler 0.3.4 → 0.3.7

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": "@tschk/moonshine-compiler",
3
- "version": "0.3.4",
3
+ "version": "0.3.7",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -15,8 +15,8 @@
15
15
  "test": "bun test"
16
16
  },
17
17
  "dependencies": {
18
- "@tschk/moonshine-framework": "^0.3.4",
19
- "@tschk/moonshine-router": "^0.3.4"
18
+ "@tschk/moonshine-framework": "^0.3.6",
19
+ "@tschk/moonshine-router": "^0.3.6"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "1.3.14",
package/src/build.ts CHANGED
@@ -63,6 +63,30 @@ function generateServerEntry(
63
63
  : ""
64
64
  }`);
65
65
  }
66
+ // Layouts, middleware, and error boundaries are route dependencies the server
67
+ // renders through, so they belong in the bundle alongside the pages. Without
68
+ // them the renderer falls back to `await import(file)` on their SOURCE paths,
69
+ // which only resolves where the source tree sits next to the build output —
70
+ // never in a deployed image, and never on a runtime that cannot resolve a
71
+ // dynamic import at all. A page would render locally and 500 in production.
72
+ const supportFiles: string[] = [];
73
+ for (const route of routes) {
74
+ for (const file of [
75
+ ...(route.layouts ?? []),
76
+ ...(route.middleware ?? []),
77
+ ...(route.errorBoundary ? [route.errorBoundary] : []),
78
+ ]) {
79
+ if (file && !supportFiles.includes(file)) supportFiles.push(file);
80
+ }
81
+ }
82
+ for (let i = 0; i < supportFiles.length; i++) {
83
+ const file = supportFiles[i]!;
84
+ imports.push(`import * as support_${i} from "${importPath(buildDir, file)}";`);
85
+ // Registered unwrapped: these export a component or middleware, not a route
86
+ // module with loader/action semantics.
87
+ moduleBodys.push(` "${toPosix(file)}": support_${i}`);
88
+ }
89
+
66
90
  return `${imports.join("\n")}\n\n${moduleHelpers.join("\n")}\n\nconst ${routes.map((_, i) => `module_${i} = makeModule_${i}(route_${i}, ${routes[i]!.dataFile ? `data_${i}` : "undefined"})`).join(";\nconst ")};\n\nexport const routes = {\n${bodies.join(",\n")}\n};\n\nexport const modules = {\n${moduleBodys.join(",\n")}\n};\n`;
67
91
  }
68
92
 
@@ -108,6 +132,19 @@ async function runBuild(
108
132
  naming: "[name].js",
109
133
  splitting: false,
110
134
  sourcemap: "none",
135
+ // Browsers have no `process`. Any dependency that reads `process.env.X` —
136
+ // and most of them do, behind a `typeof` guard or not — otherwise throws
137
+ // `ReferenceError: process is not defined` while the entry module is being
138
+ // evaluated. That happens before any render, so the app silently mounts
139
+ // nothing: no error boundary runs and the page is simply blank.
140
+ //
141
+ // `??=` so an app that injects real values ahead of this bundle keeps them;
142
+ // this only guarantees the property exists. Values are deliberately not
143
+ // inlined here — inlining the process environment into a browser bundle
144
+ // would ship every secret the build host happens to hold.
145
+ ...(target === "browser"
146
+ ? { banner: "globalThis.process ??= { env: {} };" }
147
+ : {}),
111
148
  });
112
149
  if (!result.success) {
113
150
  const messages = result.logs.map((log) => log.message).join("\n");
@@ -0,0 +1,73 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { existsSync, rmSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { buildProject } from "../src/manifest";
5
+
6
+ /**
7
+ * A browser bundle must not carry a bare `process` reference into a page.
8
+ *
9
+ * Client code and its dependencies routinely read `process.env.X`. `process`
10
+ * does not exist in a browser, so the reference throws while the client entry
11
+ * module is still evaluating — before any component renders and before any
12
+ * error boundary can mount. The page stays blank and the console stays empty,
13
+ * which is close to undiagnosable from the outside.
14
+ */
15
+
16
+ const projectDir = resolve(import.meta.dir, "fixtures", "project");
17
+ const outDir = join(projectDir, ".moonshine");
18
+
19
+ function clean() {
20
+ if (existsSync(outDir)) {
21
+ rmSync(outDir, { recursive: true, force: true });
22
+ }
23
+ }
24
+
25
+ afterAll(clean);
26
+
27
+ describe("client bundle process shim", () => {
28
+ test("defines globalThis.process before any module code runs", async () => {
29
+ await buildProject({ projectDir });
30
+
31
+ const client = await Bun.file(join(outDir, "public", "client.js")).text();
32
+
33
+ expect(client.startsWith("globalThis.process ??= { env: {} };")).toBe(true);
34
+ });
35
+
36
+ test("evaluating the bundle does not throw on a process.env read", async () => {
37
+ await buildProject({ projectDir });
38
+
39
+ const client = await Bun.file(join(outDir, "public", "client.js")).text();
40
+ // The banner is the whole fix, so assert it actually guards a real read
41
+ // rather than trusting that the fixture still contains one.
42
+ expect(client).toContain("process.env");
43
+
44
+ // Evaluate the banner in isolation with no ambient `process`, which is what
45
+ // a browser gives the bundle.
46
+ const scope: Record<string, unknown> = {};
47
+ const run = new Function(
48
+ "globalThis",
49
+ "return (globalThis.process ??= { env: {} });",
50
+ );
51
+ expect(run(scope)).toEqual({ env: {} });
52
+ });
53
+
54
+ test("does not overwrite an environment injected ahead of the bundle", async () => {
55
+ // Apps prepend their own public values; `??=` must leave those intact.
56
+ const scope: Record<string, unknown> = { process: { env: { A: "1" } } };
57
+ const run = new Function(
58
+ "globalThis",
59
+ "return (globalThis.process ??= { env: {} });",
60
+ );
61
+ expect(run(scope)).toEqual({ env: { A: "1" } });
62
+ });
63
+
64
+ test("the server bundle is left alone", async () => {
65
+ await buildProject({ projectDir });
66
+
67
+ const server = await Bun.file(join(outDir, "dist", "server.js")).text();
68
+
69
+ // Bun and Node both have a real `process`; shimming it there would mask
70
+ // genuine environment problems at runtime.
71
+ expect(server.startsWith("globalThis.process ??=")).toBe(false);
72
+ });
73
+ });
@@ -1,3 +1,8 @@
1
+ // Reads process.env deliberately: browsers have no `process`, so this is the
2
+ // shape that used to throw `ReferenceError: process is not defined` while the
3
+ // client entry evaluated, leaving the page blank with no error boundary.
4
+ const apiBase = process.env.MOONSHINE_TEST_API_BASE ?? 'unset';
5
+
1
6
  export default function ClientPage() {
2
- return <h1>client</h1>;
7
+ return <h1>client {apiBase}</h1>;
3
8
  }
@@ -0,0 +1,68 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { existsSync, rmSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { buildProject } from "../src/manifest";
5
+
6
+ /**
7
+ * The server bundle must carry every module the server renders through, not
8
+ * just the page.
9
+ *
10
+ * A page's layout is loaded during server rendering. When it is missing from
11
+ * `modules`, the renderer falls back to `await import(file)` on the layout's
12
+ * SOURCE path. That resolves in a checkout, where the source sits beside the
13
+ * build output, and fails in a deployed image that ships only the build — so
14
+ * the route renders locally and returns 500 in production. It also fails
15
+ * outright on runtimes that cannot resolve a dynamic import.
16
+ */
17
+
18
+ const projectDir = resolve(import.meta.dir, "fixtures", "project");
19
+ const outDir = join(projectDir, ".moonshine");
20
+
21
+ function clean() {
22
+ if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true });
23
+ }
24
+
25
+ afterAll(clean);
26
+
27
+ describe("server bundle support modules", () => {
28
+ test("registers the layouts its routes render through", async () => {
29
+ const manifest = await buildProject({ projectDir });
30
+
31
+ const layouts = [
32
+ ...new Set(manifest.routes.flatMap((route) => route.layouts ?? [])),
33
+ ];
34
+ expect(layouts.length).toBeGreaterThan(0);
35
+
36
+ const { modules } = await import(join(outDir, "dist", "server.js"));
37
+ // The bundle keys modules by their build-time absolute path, while the
38
+ // manifest stores them relative to the project.
39
+ for (const layout of layouts) {
40
+ expect(Object.keys(modules)).toContain(join(projectDir, layout));
41
+ }
42
+ });
43
+
44
+ test("a registered layout is the real module, not a route wrapper", async () => {
45
+ const manifest = await buildProject({ projectDir });
46
+ const layout = join(
47
+ projectDir,
48
+ manifest.routes.flatMap((route) => route.layouts ?? [])[0]!,
49
+ );
50
+
51
+ const { modules } = await import(join(outDir, "dist", "server.js"));
52
+
53
+ // Layouts export a component; wrapping them in loader/action semantics
54
+ // would make the renderer read `default` off the wrong object.
55
+ expect(modules[layout]).toBeDefined();
56
+ expect(typeof modules[layout].default).toBe("function");
57
+ });
58
+
59
+ test("still registers pages by id and file", async () => {
60
+ const manifest = await buildProject({ projectDir });
61
+ const route = manifest.routes[0]!;
62
+
63
+ const { modules } = await import(join(outDir, "dist", "server.js"));
64
+
65
+ expect(modules[route.id]).toBeDefined();
66
+ expect(modules[join(projectDir, route.file)]).toBeDefined();
67
+ });
68
+ });