@tschk/moonshine-compiler 0.4.0 → 0.4.4
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/README.md +43 -0
- package/package.json +10 -6
- package/src/build.ts +535 -75
- package/src/compile-graph.ts +215 -0
- package/src/compile-output.ts +91 -0
- package/src/crepus-source.ts +116 -0
- package/src/discover.ts +29 -2
- package/src/inherit.ts +7 -4
- package/src/manifest.ts +156 -12
- package/src/path.ts +16 -0
- package/src/source.ts +8 -0
- package/test/analyze.test.ts +0 -146
- package/test/classify.test.ts +0 -99
- package/test/client-process-env.test.ts +0 -73
- package/test/discover.test.ts +0 -76
- package/test/fixtures/modes/api.ts +0 -3
- package/test/fixtures/modes/client.client.tsx +0 -3
- package/test/fixtures/modes/interactive.tsx +0 -3
- package/test/fixtures/modes/loader.ts +0 -5
- package/test/fixtures/modes/near-miss.tsx +0 -5
- package/test/fixtures/modes/renamed-source.tsx +0 -5
- package/test/fixtures/modes/request-utils.ts +0 -3
- package/test/fixtures/modes/request.ts +0 -1
- package/test/fixtures/modes/ssr-helpers.ts +0 -5
- package/test/fixtures/modes/static.tsx +0 -3
- package/test/fixtures/modes/unresolved.ts +0 -5
- package/test/fixtures/project/src/routes/+error.tsx +0 -3
- package/test/fixtures/project/src/routes/+layout.tsx +0 -3
- package/test/fixtures/project/src/routes/+middleware.ts +0 -3
- package/test/fixtures/project/src/routes/about.data.ts +0 -7
- package/test/fixtures/project/src/routes/about.tsx +0 -7
- package/test/fixtures/project/src/routes/api/health.ts +0 -3
- package/test/fixtures/project/src/routes/client.client.tsx +0 -8
- package/test/fixtures/project/src/routes/counter.tsx +0 -3
- package/test/fixtures/project/src/routes/docs/[...path].tsx +0 -3
- package/test/fixtures/project/src/routes/index.tsx +0 -3
- package/test/fixtures/routes/+error.tsx +0 -3
- package/test/fixtures/routes/+layout.tsx +0 -3
- package/test/fixtures/routes/+middleware.ts +0 -3
- package/test/fixtures/routes/about.tsx +0 -3
- package/test/fixtures/routes/account/[[tab]].tsx +0 -3
- package/test/fixtures/routes/api/health.server.ts +0 -7
- package/test/fixtures/routes/blog/[slug].data.ts +0 -7
- package/test/fixtures/routes/blog/[slug].tsx +0 -3
- package/test/fixtures/routes/docs/[...path].tsx +0 -3
- package/test/fixtures/routes/index.tsx +0 -3
- package/test/inherit.test.ts +0 -50
- package/test/manifest.test.ts +0 -96
- package/test/path.test.ts +0 -30
- package/test/server-bundle-support-modules.test.ts +0 -68
- package/tsconfig.json +0 -4
package/src/manifest.ts
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
basename,
|
|
4
|
+
dirname,
|
|
5
|
+
isAbsolute,
|
|
6
|
+
join,
|
|
7
|
+
relative,
|
|
8
|
+
resolve,
|
|
9
|
+
sep,
|
|
10
|
+
} from "node:path";
|
|
3
11
|
import type {
|
|
4
12
|
MoonshineManifest,
|
|
5
13
|
RouteArtifact,
|
|
6
14
|
RouteDefinition,
|
|
7
15
|
RuntimeTarget,
|
|
16
|
+
RenderMode,
|
|
17
|
+
RendererName,
|
|
8
18
|
} from "@tschk/moonshine-framework";
|
|
9
19
|
import { MANIFEST_VERSION } from "@tschk/moonshine-framework";
|
|
10
20
|
import { analyzeModule } from "./analyze.js";
|
|
11
|
-
import { buildBundles } from "./build.js";
|
|
21
|
+
import { buildBundles, preparePublicAssets } from "./build.js";
|
|
12
22
|
import { classifyRoute } from "./classify.js";
|
|
13
23
|
import { discoverRoutes } from "./discover.js";
|
|
14
24
|
import type { RouteConvention } from "./inherit.js";
|
|
15
|
-
import { toPosix } from "./path.js";
|
|
25
|
+
import { canonicalPath, toPosix } from "./path.js";
|
|
16
26
|
|
|
17
27
|
export type BuildOptions = {
|
|
18
28
|
projectDir: string;
|
|
@@ -21,11 +31,59 @@ export type BuildOptions = {
|
|
|
21
31
|
programmatic?: RouteDefinition[];
|
|
22
32
|
runtime?: RuntimeTarget;
|
|
23
33
|
convention?: RouteConvention;
|
|
34
|
+
renderer?: RendererName;
|
|
35
|
+
requestContext?: boolean;
|
|
36
|
+
mode?: RenderMode;
|
|
24
37
|
};
|
|
25
38
|
|
|
26
39
|
function toProjectRelative(projectDir: string, p: string): string {
|
|
27
|
-
const
|
|
28
|
-
|
|
40
|
+
const normalize = (path: string) => (sep === "\\" ? toPosix(path) : path);
|
|
41
|
+
const rel = normalize(relative(projectDir, resolve(p)));
|
|
42
|
+
return rel.startsWith("..") ? normalize(p) : rel || ".";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function contains(directory: string, path: string): boolean {
|
|
46
|
+
const rel = relative(directory, path);
|
|
47
|
+
return !isAbsolute(rel) && rel !== ".." && !rel.startsWith(".." + sep);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function validateOutput(
|
|
51
|
+
projectDir: string,
|
|
52
|
+
routesDir: string,
|
|
53
|
+
outDir: string,
|
|
54
|
+
routes: RouteArtifact[],
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
const [project, sourceRoutes, output] = await Promise.all(
|
|
57
|
+
[projectDir, routesDir, outDir].map(canonicalPath),
|
|
58
|
+
);
|
|
59
|
+
if (contains(output!, project!))
|
|
60
|
+
throw new Error("Build output must not contain the project directory");
|
|
61
|
+
if (contains(output!, sourceRoutes!) || contains(sourceRoutes!, output!))
|
|
62
|
+
throw new Error("Build output and route source directory must not overlap");
|
|
63
|
+
const files = new Set(
|
|
64
|
+
routes
|
|
65
|
+
.flatMap((route) => [
|
|
66
|
+
route.file,
|
|
67
|
+
route.clientFile,
|
|
68
|
+
route.dataFile,
|
|
69
|
+
route.errorBoundary,
|
|
70
|
+
...(route.layouts ?? []),
|
|
71
|
+
...(route.middleware ?? []),
|
|
72
|
+
])
|
|
73
|
+
.filter((file): file is string => Boolean(file)),
|
|
74
|
+
);
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
const [target, parent] = await Promise.all([
|
|
77
|
+
canonicalPath(file),
|
|
78
|
+
canonicalPath(dirname(file)),
|
|
79
|
+
]);
|
|
80
|
+
if (
|
|
81
|
+
contains(outDir, file) ||
|
|
82
|
+
contains(output!, target) ||
|
|
83
|
+
contains(output!, join(parent, basename(file)))
|
|
84
|
+
)
|
|
85
|
+
throw new Error(`Build output must not contain source file: ${file}`);
|
|
86
|
+
}
|
|
29
87
|
}
|
|
30
88
|
|
|
31
89
|
async function resolveFrameworkVersion(): Promise<string> {
|
|
@@ -55,12 +113,13 @@ function sortedStringify(value: unknown): string {
|
|
|
55
113
|
|
|
56
114
|
function deriveCapabilities(
|
|
57
115
|
routes: RouteArtifact[],
|
|
116
|
+
native: boolean,
|
|
58
117
|
): MoonshineManifest["capabilities"] {
|
|
59
118
|
const capabilities: Set<MoonshineManifest["capabilities"][number]> =
|
|
60
119
|
new Set();
|
|
61
|
-
capabilities.add("streaming");
|
|
120
|
+
if (!native) capabilities.add("streaming");
|
|
62
121
|
for (const route of routes) {
|
|
63
|
-
if (route.mode === "island" || route.mode === "spa") {
|
|
122
|
+
if (route.mode === "island" || (!native && route.mode === "spa")) {
|
|
64
123
|
capabilities.add("islands");
|
|
65
124
|
}
|
|
66
125
|
if (route.runtime === "cloudflare" || route.runtime === "vercel-edge") {
|
|
@@ -75,7 +134,10 @@ function deriveCapabilities(
|
|
|
75
134
|
|
|
76
135
|
function makeStaticOutput(route: RouteArtifact): string | undefined {
|
|
77
136
|
if (route.mode !== "static") return undefined;
|
|
78
|
-
|
|
137
|
+
// Parameterized paths (`:slug`, `:tab?`, `*rest`) cannot be prerendered as
|
|
138
|
+
// a single file; a slug like `blog-:slug.html` would be served for every
|
|
139
|
+
// match.
|
|
140
|
+
if (route.path.includes("*") || route.path.includes(":")) return undefined;
|
|
79
141
|
const slug =
|
|
80
142
|
route.path === "/"
|
|
81
143
|
? "index"
|
|
@@ -92,10 +154,31 @@ export async function buildProject(
|
|
|
92
154
|
);
|
|
93
155
|
const outDir = resolve(options.outDir ?? join(projectDir, ".moonshine"));
|
|
94
156
|
const runtime = options.runtime ?? "bun";
|
|
157
|
+
if (options.requestContext && !["bun", "node"].includes(runtime)) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
"Request context currently supports only Bun and Node runtimes",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
95
162
|
|
|
96
163
|
const rawRoutes = await discoverRoutes({
|
|
97
164
|
routesDir,
|
|
98
|
-
programmatic: options.programmatic
|
|
165
|
+
programmatic: options.programmatic?.map((route) => ({
|
|
166
|
+
...route,
|
|
167
|
+
file: resolve(projectDir, route.file),
|
|
168
|
+
...(route.clientFile && {
|
|
169
|
+
clientFile: resolve(projectDir, route.clientFile),
|
|
170
|
+
}),
|
|
171
|
+
...(route.dataFile && { dataFile: resolve(projectDir, route.dataFile) }),
|
|
172
|
+
...(route.layouts && {
|
|
173
|
+
layouts: route.layouts.map((file) => resolve(projectDir, file)),
|
|
174
|
+
}),
|
|
175
|
+
...(route.middleware && {
|
|
176
|
+
middleware: route.middleware.map((file) => resolve(projectDir, file)),
|
|
177
|
+
}),
|
|
178
|
+
...(route.errorBoundary && {
|
|
179
|
+
errorBoundary: resolve(projectDir, route.errorBoundary),
|
|
180
|
+
}),
|
|
181
|
+
})),
|
|
99
182
|
...(options.convention && { convention: options.convention }),
|
|
100
183
|
});
|
|
101
184
|
|
|
@@ -108,8 +191,38 @@ export async function buildProject(
|
|
|
108
191
|
const buildRoutes: RouteArtifact[] = [];
|
|
109
192
|
for (let i = 0; i < rawRoutes.length; i++) {
|
|
110
193
|
const route = rawRoutes[i];
|
|
194
|
+
if (options.requestContext && (route.runtime ?? runtime) !== runtime) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
"Request-context routes must match the configured server runtime",
|
|
197
|
+
);
|
|
198
|
+
}
|
|
111
199
|
const facts = analyzeModule(route.file, undefined, rawRouteTexts[i]);
|
|
112
|
-
const decision = classifyRoute({
|
|
200
|
+
const decision = classifyRoute({
|
|
201
|
+
explicit: route.mode ?? options.mode,
|
|
202
|
+
facts,
|
|
203
|
+
});
|
|
204
|
+
if (options.renderer === "native") {
|
|
205
|
+
if (!["bun", "node", "cloudflare"].includes(route.runtime ?? runtime)) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
"Native renderer currently supports only the Bun, Node and Cloudflare runtimes",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (decision.mode === "island") {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`Native route "${route.path}" cannot use island mode: hydration is not implemented`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if ((decision.mode === "spa" || facts.interactive) && !route.clientFile) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Native interactive route "${route.path}" requires an explicit clientFile`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (route.clientFile && decision.mode !== "spa") {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`Native route "${route.path}" with clientFile must use spa mode`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
113
226
|
buildRoutes.push({
|
|
114
227
|
...route,
|
|
115
228
|
mode: decision.mode,
|
|
@@ -124,6 +237,15 @@ export async function buildProject(
|
|
|
124
237
|
} as RouteArtifact);
|
|
125
238
|
}
|
|
126
239
|
|
|
240
|
+
if (
|
|
241
|
+
options.renderer === "native" &&
|
|
242
|
+
new Set(buildRoutes.map((route) => route.runtime)).size > 1
|
|
243
|
+
) {
|
|
244
|
+
throw new Error("Native routes must use one server runtime per build");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
await validateOutput(projectDir, routesDir, outDir, buildRoutes);
|
|
248
|
+
const publicAssets = await preparePublicAssets(projectDir, outDir);
|
|
127
249
|
await rm(outDir, { recursive: true, force: true });
|
|
128
250
|
await mkdir(outDir, { recursive: true });
|
|
129
251
|
|
|
@@ -131,12 +253,26 @@ export async function buildProject(
|
|
|
131
253
|
root: projectDir,
|
|
132
254
|
outDir,
|
|
133
255
|
routes: buildRoutes,
|
|
256
|
+
renderer: options.renderer,
|
|
257
|
+
requestContext: options.requestContext,
|
|
258
|
+
publicAssets,
|
|
134
259
|
});
|
|
135
260
|
const clientFile = bundles.entryFiles.client;
|
|
136
261
|
|
|
137
262
|
for (const route of buildRoutes) {
|
|
138
263
|
if ((route.mode === "island" || route.mode === "spa") && clientFile) {
|
|
139
|
-
route.clientEntries = [
|
|
264
|
+
route.clientEntries = [
|
|
265
|
+
options.renderer === "native"
|
|
266
|
+
? bundles.routeClientEntries[route.id]!
|
|
267
|
+
: clientFile,
|
|
268
|
+
];
|
|
269
|
+
}
|
|
270
|
+
if (
|
|
271
|
+
options.renderer === "native" &&
|
|
272
|
+
Object.hasOwn(bundles.routeClientStyles, route.id) &&
|
|
273
|
+
bundles.routeClientStyles[route.id]!.length
|
|
274
|
+
) {
|
|
275
|
+
route.clientStyles = bundles.routeClientStyles[route.id];
|
|
140
276
|
}
|
|
141
277
|
route.serverEntry = bundles.entryFiles.server;
|
|
142
278
|
}
|
|
@@ -148,9 +284,14 @@ export async function buildProject(
|
|
|
148
284
|
const manifest: MoonshineManifest = {
|
|
149
285
|
version: MANIFEST_VERSION,
|
|
150
286
|
frameworkVersion: await resolveFrameworkVersion(),
|
|
287
|
+
...(options.renderer && { renderer: options.renderer }),
|
|
288
|
+
...(options.requestContext && { requestContext: true }),
|
|
151
289
|
routes: buildRoutes.map((route) => ({
|
|
152
290
|
...route,
|
|
153
291
|
file: toProjectRelative(projectDir, route.file),
|
|
292
|
+
...(route.clientFile && {
|
|
293
|
+
clientFile: toProjectRelative(projectDir, route.clientFile),
|
|
294
|
+
}),
|
|
154
295
|
...(route.dataFile && {
|
|
155
296
|
dataFile: toProjectRelative(projectDir, route.dataFile),
|
|
156
297
|
}),
|
|
@@ -171,7 +312,10 @@ export async function buildProject(
|
|
|
171
312
|
...(bundles.entryFiles.server && { server: bundles.entryFiles.server }),
|
|
172
313
|
...(clientFile && { client: clientFile }),
|
|
173
314
|
},
|
|
174
|
-
capabilities: deriveCapabilities(
|
|
315
|
+
capabilities: deriveCapabilities(
|
|
316
|
+
buildRoutes,
|
|
317
|
+
options.renderer === "native",
|
|
318
|
+
),
|
|
175
319
|
};
|
|
176
320
|
|
|
177
321
|
await writeFile(join(outDir, "manifest.json"), sortedStringify(manifest));
|
package/src/path.ts
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
1
4
|
export function toPosix(p: string): string {
|
|
2
5
|
return p.replace(/\\/g, "/");
|
|
3
6
|
}
|
|
7
|
+
|
|
8
|
+
/** Resolve existing symlink ancestors even when the final path does not exist yet. */
|
|
9
|
+
export async function canonicalPath(path: string): Promise<string> {
|
|
10
|
+
const absolute = resolve(path);
|
|
11
|
+
try {
|
|
12
|
+
return await realpath(absolute);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
15
|
+
const parent = dirname(absolute);
|
|
16
|
+
if (parent === absolute) throw error;
|
|
17
|
+
return join(await canonicalPath(parent), basename(absolute));
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/source.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { emitCrepusSource, requireCrepusCompiler } from "./crepus-source";
|
|
2
|
+
export {
|
|
3
|
+
readCrepusGraph,
|
|
4
|
+
validateNativeRuntimeImport,
|
|
5
|
+
type CrepusGraph,
|
|
6
|
+
type CrepusGraphTarget,
|
|
7
|
+
} from "./compile-graph";
|
|
8
|
+
export { publishCrepusGraph } from "./compile-output";
|
package/test/analyze.test.ts
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import ts from "typescript";
|
|
3
|
-
import { analyzeModule } from "../src/analyze.js";
|
|
4
|
-
|
|
5
|
-
function analyzeSource(sourceCode: string, filename = "test.tsx") {
|
|
6
|
-
const program = {
|
|
7
|
-
getSourceFile: (file: string) => {
|
|
8
|
-
if (file === filename) {
|
|
9
|
-
return ts.createSourceFile(
|
|
10
|
-
filename,
|
|
11
|
-
sourceCode,
|
|
12
|
-
ts.ScriptTarget.Latest,
|
|
13
|
-
true,
|
|
14
|
-
filename.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
15
|
-
);
|
|
16
|
-
}
|
|
17
|
-
return undefined;
|
|
18
|
-
},
|
|
19
|
-
} as unknown as ts.Program;
|
|
20
|
-
|
|
21
|
-
return analyzeModule(filename, program);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
describe("analyzeModule", () => {
|
|
25
|
-
test("detects clientBoundary from 'use client' directive", () => {
|
|
26
|
-
const facts = analyzeSource(
|
|
27
|
-
"'use client';\nexport default function App() {}",
|
|
28
|
-
);
|
|
29
|
-
expect(facts.clientBoundary).toBe(true);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("detects clientBoundary from file suffix", () => {
|
|
33
|
-
const facts = analyzeSource(
|
|
34
|
-
"export default function App() {}",
|
|
35
|
-
"app.client.tsx",
|
|
36
|
-
);
|
|
37
|
-
expect(facts.clientBoundary).toBe(true);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("detects serverOnly from file suffix", () => {
|
|
41
|
-
const facts = analyzeSource(
|
|
42
|
-
"export default function App() {}",
|
|
43
|
-
"app.server.tsx",
|
|
44
|
-
);
|
|
45
|
-
expect(facts.serverOnly).toBe(true);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("detects exportsHandler from 'handler' export", () => {
|
|
49
|
-
const facts = analyzeSource("export function handler() {}");
|
|
50
|
-
expect(facts.exportsHandler).toBe(true);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("detects exportsHandler from HTTP method exports", () => {
|
|
54
|
-
expect(analyzeSource("export const GET = () => {}").exportsHandler).toBe(
|
|
55
|
-
true,
|
|
56
|
-
);
|
|
57
|
-
expect(analyzeSource("export function POST() {}").exportsHandler).toBe(
|
|
58
|
-
true,
|
|
59
|
-
);
|
|
60
|
-
expect(analyzeSource("export { myFunc as PUT }").exportsHandler).toBe(true);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("does not detect exportsHandler for non-exported methods", () => {
|
|
64
|
-
const facts = analyzeSource("function GET() {}");
|
|
65
|
-
expect(facts.exportsHandler).toBe(false);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test("detects exportsLoader", () => {
|
|
69
|
-
const facts = analyzeSource("export const loader = async () => {}");
|
|
70
|
-
expect(facts.exportsLoader).toBe(true);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("detects exportsAction", () => {
|
|
74
|
-
const facts = analyzeSource("export function action() {}");
|
|
75
|
-
expect(facts.exportsAction).toBe(true);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("detects requestBound from specific module imports", () => {
|
|
79
|
-
expect(
|
|
80
|
-
analyzeSource("import { anything } from 'next/headers'").requestBound,
|
|
81
|
-
).toBe(true);
|
|
82
|
-
expect(
|
|
83
|
-
analyzeSource("import { something } from './cookies.ts'").requestBound,
|
|
84
|
-
).toBe(true);
|
|
85
|
-
expect(analyzeSource("import { xyz } from 'request'").requestBound).toBe(
|
|
86
|
-
true,
|
|
87
|
-
);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("detects requestBound from specific binding imports", () => {
|
|
91
|
-
expect(
|
|
92
|
-
analyzeSource("import { cookies } from 'any-module'").requestBound,
|
|
93
|
-
).toBe(true);
|
|
94
|
-
expect(
|
|
95
|
-
analyzeSource("import { headers, other } from 'some-module'")
|
|
96
|
-
.requestBound,
|
|
97
|
-
).toBe(true);
|
|
98
|
-
expect(
|
|
99
|
-
analyzeSource("import { draftMode } from 'utils'").requestBound,
|
|
100
|
-
).toBe(true);
|
|
101
|
-
expect(analyzeSource("import { request } from 'core'").requestBound).toBe(
|
|
102
|
-
true,
|
|
103
|
-
);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test("does not detect requestBound for unrelated imports", () => {
|
|
107
|
-
const facts = analyzeSource("import { useState } from 'react'");
|
|
108
|
-
expect(facts.requestBound).toBe(false);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test("detects interactive from JSX event attributes", () => {
|
|
112
|
-
const facts = analyzeSource(
|
|
113
|
-
"export default function Btn() { return <button onClick={() => {}}>Click</button> }",
|
|
114
|
-
);
|
|
115
|
-
expect(facts.interactive).toBe(true);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test("detects interactive from island calls", () => {
|
|
119
|
-
expect(analyzeSource("island(Component)").interactive).toBe(true);
|
|
120
|
-
expect(analyzeSource("myLib.island(Component)").interactive).toBe(true);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test("detects unresolvedDynamicImport", () => {
|
|
124
|
-
const facts = analyzeSource("const moduleName = 'fs'; import(moduleName);");
|
|
125
|
-
expect(facts.unresolvedDynamicImport).toBe(true);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("does not detect unresolvedDynamicImport for string literals", () => {
|
|
129
|
-
const facts = analyzeSource("import('fs');");
|
|
130
|
-
expect(facts.unresolvedDynamicImport).toBe(false);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
test("returns default facts for empty module", () => {
|
|
134
|
-
const facts = analyzeSource("");
|
|
135
|
-
expect(facts).toEqual({
|
|
136
|
-
clientBoundary: false,
|
|
137
|
-
serverOnly: false,
|
|
138
|
-
exportsHandler: false,
|
|
139
|
-
exportsLoader: false,
|
|
140
|
-
exportsAction: false,
|
|
141
|
-
requestBound: false,
|
|
142
|
-
interactive: false,
|
|
143
|
-
unresolvedDynamicImport: false,
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
});
|
package/test/classify.test.ts
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { analyzeModule, classifyRoute } from "../src";
|
|
4
|
-
|
|
5
|
-
const fixtureDir = join(import.meta.dir, "fixtures", "modes");
|
|
6
|
-
|
|
7
|
-
function fixture(name: string): string {
|
|
8
|
-
return join(fixtureDir, name);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
describe("classifyRoute", () => {
|
|
12
|
-
test("honors explicit override", () => {
|
|
13
|
-
const facts = analyzeModule(fixture("static.tsx"));
|
|
14
|
-
const decision = classifyRoute({ explicit: "island", facts });
|
|
15
|
-
expect(decision).toEqual({
|
|
16
|
-
mode: "island",
|
|
17
|
-
reason: "explicit route configuration",
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("selects api for handler export", () => {
|
|
22
|
-
const facts = analyzeModule(fixture("api.ts"));
|
|
23
|
-
expect(facts.exportsHandler).toBe(true);
|
|
24
|
-
expect(classifyRoute({ facts })).toEqual({
|
|
25
|
-
mode: "api",
|
|
26
|
-
reason: "exports request handler",
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("selects spa for root client boundary", () => {
|
|
31
|
-
const facts = analyzeModule(fixture("client.client.tsx"));
|
|
32
|
-
expect(facts.clientBoundary).toBe(true);
|
|
33
|
-
expect(classifyRoute({ facts })).toEqual({
|
|
34
|
-
mode: "spa",
|
|
35
|
-
reason: "route root is a client boundary",
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("selects ssr for request-bound loader", () => {
|
|
40
|
-
const facts = analyzeModule(fixture("loader.ts"));
|
|
41
|
-
expect(facts.requestBound).toBe(true);
|
|
42
|
-
expect(facts.exportsLoader).toBe(true);
|
|
43
|
-
expect(classifyRoute({ facts })).toEqual({
|
|
44
|
-
mode: "ssr",
|
|
45
|
-
reason: "uses request-time server data",
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("a module whose name merely contains 'request' is not request-bound", () => {
|
|
50
|
-
// `./request-utils` demoted this route to ssr because the specifier was
|
|
51
|
-
// compared with `includes`. Nothing broke, it just stopped prerendering.
|
|
52
|
-
const facts = analyzeModule(fixture("near-miss.tsx"));
|
|
53
|
-
expect(facts.requestBound).toBe(false);
|
|
54
|
-
expect(classifyRoute({ facts }).mode).not.toBe("ssr");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("request-bound bindings are caught whatever module they came from", () => {
|
|
58
|
-
// The specifier says nothing about cookies; the binding does.
|
|
59
|
-
const facts = analyzeModule(fixture("renamed-source.tsx"));
|
|
60
|
-
expect(facts.requestBound).toBe(true);
|
|
61
|
-
expect(classifyRoute({ facts })).toEqual({
|
|
62
|
-
mode: "ssr",
|
|
63
|
-
reason: "uses request-time server data",
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("selects island for interactive child", () => {
|
|
68
|
-
const facts = analyzeModule(fixture("interactive.tsx"));
|
|
69
|
-
expect(facts.interactive).toBe(true);
|
|
70
|
-
expect(classifyRoute({ facts })).toEqual({
|
|
71
|
-
mode: "island",
|
|
72
|
-
reason: "contains an interactive client subtree",
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("selects static for deterministic module", () => {
|
|
77
|
-
const facts = analyzeModule(fixture("static.tsx"));
|
|
78
|
-
expect(classifyRoute({ facts })).toEqual({
|
|
79
|
-
mode: "static",
|
|
80
|
-
reason: "deterministic build-time route",
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("falls back to ssr for unresolved dynamic import", () => {
|
|
85
|
-
const facts = analyzeModule(fixture("unresolved.ts"));
|
|
86
|
-
expect(facts.unresolvedDynamicImport).toBe(true);
|
|
87
|
-
expect(classifyRoute({ facts })).toEqual({
|
|
88
|
-
mode: "ssr",
|
|
89
|
-
reason: "dynamic behavior could not be resolved safely",
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("produces deterministic output", () => {
|
|
94
|
-
const path = fixture("static.tsx");
|
|
95
|
-
const first = classifyRoute({ facts: analyzeModule(path) });
|
|
96
|
-
const second = classifyRoute({ facts: analyzeModule(path) });
|
|
97
|
-
expect(JSON.stringify(first)).toBe(JSON.stringify(second));
|
|
98
|
-
});
|
|
99
|
-
});
|
|
@@ -1,73 +0,0 @@
|
|
|
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
|
-
});
|
package/test/discover.test.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { discoverRoutes } from "../src/discover";
|
|
4
|
-
|
|
5
|
-
const fixtureDir = join(import.meta.dir, "fixtures", "routes");
|
|
6
|
-
|
|
7
|
-
function layoutPath() {
|
|
8
|
-
return join(fixtureDir, "+layout.tsx");
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function middlewarePath() {
|
|
12
|
-
return join(fixtureDir, "+middleware.ts");
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function errorPath() {
|
|
16
|
-
return join(fixtureDir, "+error.tsx");
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
describe("discoverRoutes", () => {
|
|
20
|
-
test("discovers exact route IDs and paths", async () => {
|
|
21
|
-
const routes = await discoverRoutes({ routesDir: fixtureDir });
|
|
22
|
-
expect(routes.map((r) => r.id)).toEqual([
|
|
23
|
-
"index",
|
|
24
|
-
"about",
|
|
25
|
-
"account/[[tab]]",
|
|
26
|
-
"api/health",
|
|
27
|
-
"blog/[slug]",
|
|
28
|
-
"docs/[...path]",
|
|
29
|
-
]);
|
|
30
|
-
expect(routes.map((r) => r.path)).toEqual([
|
|
31
|
-
"/",
|
|
32
|
-
"/about",
|
|
33
|
-
"/account/:tab?",
|
|
34
|
-
"/api/health",
|
|
35
|
-
"/blog/:slug",
|
|
36
|
-
"/docs/*path",
|
|
37
|
-
]);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("inherits layout, middleware, and nearest error boundary", async () => {
|
|
41
|
-
const routes = await discoverRoutes({ routesDir: fixtureDir });
|
|
42
|
-
for (const route of routes) {
|
|
43
|
-
expect(route.layouts).toEqual([layoutPath()]);
|
|
44
|
-
expect(route.middleware).toEqual([middlewarePath()]);
|
|
45
|
-
expect(route.errorBoundary).toBe(errorPath());
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("pairs a route with its data file", async () => {
|
|
50
|
-
const routes = await discoverRoutes({ routesDir: fixtureDir });
|
|
51
|
-
const blog = routes.find((r) => r.id === "blog/[slug]");
|
|
52
|
-
expect(blog).toBeDefined();
|
|
53
|
-
expect(blog!.dataFile).toBe(join(fixtureDir, "blog", "[slug].data.ts"));
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("marks server files as API mode", async () => {
|
|
57
|
-
const routes = await discoverRoutes({ routesDir: fixtureDir });
|
|
58
|
-
const api = routes.find((r) => r.id === "api/health");
|
|
59
|
-
expect(api).toBeDefined();
|
|
60
|
-
expect(api!.mode).toBe("api");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("produces deterministic sorted output", async () => {
|
|
64
|
-
const first = await discoverRoutes({ routesDir: fixtureDir });
|
|
65
|
-
const second = await discoverRoutes({ routesDir: fixtureDir });
|
|
66
|
-
expect(JSON.stringify(first)).toBe(JSON.stringify(second));
|
|
67
|
-
expect(first.map((r) => r.path)).toEqual([
|
|
68
|
-
"/",
|
|
69
|
-
"/about",
|
|
70
|
-
"/account/:tab?",
|
|
71
|
-
"/api/health",
|
|
72
|
-
"/blog/:slug",
|
|
73
|
-
"/docs/*path",
|
|
74
|
-
]);
|
|
75
|
-
});
|
|
76
|
-
});
|