1import { describe, expect, it } from "vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as ChildProcess from "effect/process/ChildProcess";
7import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
8import { resolveVinextCli } from "../cli.ts";
9import { NODE_BUNDLE_CONDITIONS } from "../../core/NodeServe.ts";
10import {
11 SERVER_ENTRY_NAME,
12 VINEXT_NODE_INSTALL,
13 makeNodeTarget,
14 makeVinextServeEntrySource,
15 target,
16} from "../node.ts";
17
18describe("makeNodeTarget", () => {
19 it.each([
20 { hybrid: false, inline: false },
21 { hybrid: true, inline: false },
22 { hybrid: false, inline: true },
23 ])(
24 "builds and serves without an Alchemy plugin (hybrid: $hybrid, inline config: $inline)",
25 ({ hybrid, inline }) =>
26 Effect.runPromise(
27 Effect.scoped(
28 Effect.gen(function* () {
29 const fs = yield* FileSystem.FileSystem;
30 const path = yield* Path.Path;
31 const example = yield* path.fromFileUrl(
32 new URL(
33 "../../../../../examples/prisma-website-vinext/",
34 import.meta.url,
35 ),
36 );
37 const root = yield* fs.makeTempDirectoryScoped({
38 prefix: "vinext-node-",
39 });
40 yield* fs.symlink(
41 path.join(example, "node_modules"),
42 path.join(root, "node_modules"),
43 );
44 yield* fs.writeFileString(
45 path.join(root, "package.json"),
46 '{"type":"module"}',
47 );
48 if (inline) {
49 yield* fs.writeFileString(
50 path.join(root, "vite.config.ts"),
51 `
52 import vinext from "vinext";
53 export default { plugins: [vinext({ prerender: true, nextConfig: {
54 basePath: "/nested", pageExtensions: ["page.tsx"],
55 } })] };
56 `,
57 );
58 }
59 yield* fs.makeDirectory(path.join(root, "pages"));
60 yield* fs.writeFileString(
61 path.join(
62 root,
63 inline ? "pages/legacy.page.tsx" : "pages/legacy.tsx",
64 ),
65 "export default function Page() { return <h1>Legacy page</h1>; }",
66 );
67 if (hybrid) {
68 yield* fs.makeDirectory(path.join(root, "app"));
69 yield* fs.writeFileString(
70 path.join(root, "app/layout.tsx"),
71 "export default function Layout({children}) { return <html><body>{children}</body></html>; }",
72 );
73 yield* fs.writeFileString(
74 path.join(root, "app/page.tsx"),
75 "export default function Page() { return <h1>App page</h1>; }",
76 );
77 }
78 yield* fs.makeDirectory(path.join(root, "dist"));
79 yield* fs.writeFileString(
80 path.join(root, "dist/stale.txt"),
81 "old build",
82 );
83 const built = yield* makeNodeTarget().build!({
84 root,
85 framework: "vinext",
86 });
87 expect(yield* fs.exists(path.join(root, "vite.config.ts"))).toBe(
88 inline,
89 );
90 if (inline) {
91 expect(
92 yield* fs.readFileString(
93 path.join(root, "dist/server/vinext-prerender.json"),
94 ),
95 ).toContain('"route": "/legacy"');
96 }
97 expect(
98 yield* fs.readFileString(
99 path.join(built.distDirectory!, "server/entry.js"),
100 ),
101 ).toContain("REDIS_URL");
102 expect(yield* fs.exists(path.join(root, "dist/stale.txt"))).toBe(
103 false,
104 );
105 if (hybrid) {
106 const buildId = (yield* fs.readFileString(
107 path.join(root, "dist/server/BUILD_ID"),
108 )).trim();
109 const manifest = JSON.parse(
110 yield* fs.readFileString(
111 path.join(root, "dist/server/vinext-server.json"),
112 ),
113 );
114 for (const entry of ["index.js", "entry.js"]) {
115 const code = yield* fs.readFileString(
116 path.join(root, "dist/server", entry),
117 );
118 expect(code).toContain(buildId);
119 expect(code).toContain(manifest.prerenderSecret);
120 }
121 const assets = yield* fs.readFileString(
122 path.join(root, "dist/server/ssr/vinext-client-assets.js"),
123 );
124 expect(assets).not.toContain('"clientEntry":null');
125 }
126 const spawner = yield* ChildProcessSpawner;
127 const child = yield* spawner.spawn(
128 ChildProcess.make(
129 "node",
130 [
131 "--input-type=module",
132 "--eval",
133 `
134 import assert from "node:assert/strict";
135 import { startProdServer } from "vinext/server/prod-server";
136 const { server } = await startProdServer({ port: 0, host: "127.0.0.1", outDir: ${JSON.stringify(built.distDirectory)} });
137 try {
138 const base = "http://127.0.0.1:" + server.address().port;
139 for (const [route, text] of ${JSON.stringify(
140 hybrid
141 ? [
142 ["/legacy", "Legacy page"],
143 ["/", "App page"],
144 ]
145 : [[inline ? "/nested/legacy" : "/legacy", "Legacy page"]],
146 )}) {
147 const response = await fetch(base + route);
148 assert.equal(response.status, 200);
149 assert.ok((await response.text()).includes(text));
150 }
151 } finally { await new Promise((resolve) => server.close(resolve)); }
152 `,
153 ],
154 { cwd: root, stdout: "inherit", stderr: "inherit" },
155 ),
156 );
157 expect(yield* child.exitCode).toBe(0);
158 }),
159 ).pipe(Effect.provide(NodeServices.layer)),
160 ),
161 120_000,
162 );
163 it("resolves the installed CLI without requiring an exported CLI subpath", () =>
164 Effect.runPromise(
165 Effect.gen(function* () {
166 const fs = yield* FileSystem.FileSystem;
167 const path = yield* Path.Path;
168 const root = yield* path.fromFileUrl(
169 new URL(
170 "../../../../../examples/prisma-website-vinext/",
171 import.meta.url,
172 ),
173 );
174 const cli = yield* resolveVinextCli(root);
175 expect(cli).toMatch(/vinext\/dist\/cli\.js$/);
176 expect(yield* fs.exists(cli)).toBe(true);
177 }).pipe(Effect.provide(NodeServices.layer)),
178 ));
179 it("declares the node platform and a wholesale vinext build (not Cloudflare)", () => {
180 const node = makeNodeTarget();
181 expect(node.platform).toBe("node");
182 expect(node.build).toBeTypeOf("function");
183 expect(node.bundle?.conditions).toEqual([...NODE_BUNDLE_CONDITIONS]);
184 expect(node.bundle?.external ?? []).not.toContain("cloudflare:");
185 expect(node.bundle?.external ?? []).not.toContain("@aws-sdk/");
186 });
187
188 it("writes a startProdServer serve entry with /health, not a Worker fetch handler", () => {
189 const source = makeVinextServeEntrySource();
190 expect(SERVER_ENTRY_NAME).toBe("server/serve-node.mjs");
191 expect(source).toContain('from "vinext/server/prod-server"');
192 expect(source).toContain("startProdServer");
193 expect(source).toContain("/health");
194 expect(source).toContain("process.env.PORT");
195 expect(source).not.toContain("vinext/server/fetch-handler");
196 expect(source).not.toContain("aws-lambda");
197 expect(source).not.toContain("cloudflare");
198 expect(source).not.toContain("worker/index.ts");
199 });
200
201 it("installs vinext and React at runtime instead of bundling them", () => {
202 expect(VINEXT_NODE_INSTALL).toContain("vinext");
203 expect(VINEXT_NODE_INSTALL).toContain("react");
204 expect(VINEXT_NODE_INSTALL).toContain("react-dom");
205 expect(VINEXT_NODE_INSTALL).toContain("react-server-dom-webpack");
206 });
207
208 it("exposes the named `target` module export as the factory", () => {
209 expect(target).toBe(makeNodeTarget);
210 });
211});