Skip to content

Commit ecb5541

Browse files
authored
feat(viteroll): support ssr module runner (#75)
1 parent e574d32 commit ecb5541

File tree

6 files changed

+140
-20
lines changed

6 files changed

+140
-20
lines changed

viteroll/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ pnpm -C examples/mpa dev
1212
## links
1313

1414
- https://github.com/users/hi-ogawa/projects/4/views/1?pane=issue&itemId=84997064
15-
- https://github.com/hi-ogawa/rolldown/tree/feat-vite-like
15+
- https://github.com/rolldown/vite/pull/66

viteroll/examples/ssr/e2e/basic.test.ts

+6
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,9 @@ test("hmr", async ({ page, request }) => {
2929
const res = await request.get("/");
3030
expect(await res.text()).toContain("Count-EDIT-EDIT");
3131
});
32+
33+
test("server stacktrace", async ({ page }) => {
34+
const res = await page.goto("/crash-ssr");
35+
expect(await res?.text()).toContain("examples/ssr/src/error.tsx:8:8");
36+
expect(res?.status()).toBe(500);
37+
});

viteroll/examples/ssr/src/entry-server.tsx

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
import ReactDOMServer from "react-dom/server";
1+
// @ts-ignore TODO: external require (e.g. require("stream")) not supported
2+
import ReactDOMServer from "react-dom/server.browser";
23
import type { Connect } from "vite";
34
import { App } from "./app";
5+
import { throwError } from "./error";
46

57
const handler: Connect.SimpleHandleFunction = (req, res) => {
68
const url = new URL(req.url ?? "/", "https://vite.dev");
79
console.log(`[SSR] ${req.method} ${url.pathname}`);
10+
if (url.pathname === "/crash-ssr") {
11+
throwError();
12+
}
813
const ssrHtml = ReactDOMServer.renderToString(<App />);
914
res.setHeader("content-type", "text/html");
1015
// TODO: transformIndexHtml?

viteroll/examples/ssr/src/error.tsx

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//
2+
// random new lines
3+
//
4+
export function throwError() {
5+
//
6+
// and more
7+
//
8+
throw new Error("boom");
9+
}

viteroll/examples/ssr/vite.config.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export default defineConfig({
2727
plugins: [
2828
viteroll({
2929
reactRefresh: true,
30+
ssrModuleRunner: true,
3031
}),
3132
{
3233
name: "ssr-middleware",
@@ -40,7 +41,7 @@ export default defineConfig({
4041
const devEnv = server.environments.ssr as RolldownEnvironment;
4142
server.middlewares.use(async (req, res, next) => {
4243
try {
43-
const mod = (await devEnv.import("index")) as any;
44+
const mod = (await devEnv.import("src/entry-server.tsx")) as any;
4445
await mod.default(req, res);
4546
} catch (e) {
4647
next(e);

viteroll/viteroll.ts

+116-17
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const require = createRequire(import.meta.url);
2323

2424
interface ViterollOptions {
2525
reactRefresh?: boolean;
26+
ssrModuleRunner?: boolean;
2627
}
2728

2829
const logger = createLogger("info", {
@@ -151,7 +152,9 @@ window.__rolldown_hot = hot;
151152
export class RolldownEnvironment extends DevEnvironment {
152153
instance!: rolldown.RolldownBuild;
153154
result!: rolldown.RolldownOutput;
154-
outDir!: string;
155+
outDir: string;
156+
inputOptions!: rolldown.InputOptions;
157+
outputOptions!: rolldown.OutputOptions;
155158
buildTimestamp = Date.now();
156159

157160
static createFactory(
@@ -206,7 +209,7 @@ export class RolldownEnvironment extends DevEnvironment {
206209
}
207210

208211
console.time(`[rolldown:${this.name}:build]`);
209-
const inputOptions: rolldown.InputOptions = {
212+
this.inputOptions = {
210213
// TODO: no dev ssr for now
211214
dev: this.name === "client",
212215
// NOTE:
@@ -223,7 +226,7 @@ export class RolldownEnvironment extends DevEnvironment {
223226
},
224227
define: this.config.define,
225228
plugins: [
226-
viterollEntryPlugin(this.config, this.viterollOptions),
229+
viterollEntryPlugin(this.config, this.viterollOptions, this),
227230
// TODO: how to use jsx-dev-runtime?
228231
rolldownExperimental.transformPlugin({
229232
reactRefresh:
@@ -238,22 +241,27 @@ export class RolldownEnvironment extends DevEnvironment {
238241
...(plugins as any),
239242
],
240243
};
241-
this.instance = await rolldown.rolldown(inputOptions);
242-
243-
// `generate` should work but we use `write` so it's easier to see output and debug
244-
const outputOptions: rolldown.OutputOptions = {
244+
this.instance = await rolldown.rolldown(this.inputOptions);
245+
246+
const format: rolldown.ModuleFormat =
247+
this.name === "client" ||
248+
(this.name === "ssr" && this.viterollOptions.ssrModuleRunner)
249+
? "app"
250+
: "esm";
251+
this.outputOptions = {
245252
dir: this.outDir,
246-
format: this.name === "client" ? "app" : "esm",
253+
format,
247254
// TODO: hmr_rebuild returns source map file when `sourcemap: true`
248255
sourcemap: "inline",
249256
// TODO: https://github.com/rolldown/rolldown/issues/2041
250257
// handle `require("stream")` in `react-dom/server`
251258
banner:
252-
this.name === "ssr"
259+
this.name === "ssr" && format === "esm"
253260
? `import __nodeModule from "node:module"; const require = __nodeModule.createRequire(import.meta.url);`
254261
: undefined,
255262
};
256-
this.result = await this.instance.write(outputOptions);
263+
// `generate` should work but we use `write` so it's easier to see output and debug
264+
this.result = await this.instance.write(this.outputOptions);
257265

258266
this.buildTimestamp = Date.now();
259267
console.timeEnd(`[rolldown:${this.name}:build]`);
@@ -268,29 +276,104 @@ export class RolldownEnvironment extends DevEnvironment {
268276
return;
269277
}
270278
if (this.name === "ssr") {
271-
await this.build();
279+
if (this.outputOptions.format === "app") {
280+
console.time(`[rolldown:${this.name}:hmr]`);
281+
const result = await this.instance.experimental_hmr_rebuild([ctx.file]);
282+
this.getRunner().evaluate(result[1].toString(), result[0]);
283+
console.timeEnd(`[rolldown:${this.name}:hmr]`);
284+
} else {
285+
await this.build();
286+
}
272287
} else {
273288
logger.info(`hmr '${ctx.file}'`, { timestamp: true });
274289
console.time(`[rolldown:${this.name}:hmr]`);
275290
const result = await this.instance.experimental_hmr_rebuild([ctx.file]);
276291
console.timeEnd(`[rolldown:${this.name}:hmr]`);
277292
ctx.server.ws.send("rolldown:hmr", result);
278293
}
279-
return true;
294+
}
295+
296+
runner!: RolldownModuleRunner;
297+
298+
getRunner() {
299+
if (!this.runner) {
300+
const output = this.result.output[0];
301+
const filepath = path.join(this.outDir, output.fileName);
302+
this.runner = new RolldownModuleRunner();
303+
const code = fs.readFileSync(filepath, "utf-8");
304+
this.runner.evaluate(code, filepath);
305+
}
306+
return this.runner;
280307
}
281308

282309
async import(input: string): Promise<unknown> {
283-
const output = this.result.output.find((o) => o.name === input);
284-
assert(output, `invalid import input '${input}'`);
310+
if (this.outputOptions.format === "app") {
311+
return this.getRunner().import(input);
312+
}
313+
// input is no use
314+
const output = this.result.output[0];
285315
const filepath = path.join(this.outDir, output.fileName);
316+
// TODO: source map not applied when adding `?t=...`?
317+
// return import(`${pathToFileURL(filepath)}`)
286318
return import(`${pathToFileURL(filepath)}?t=${this.buildTimestamp}`);
287319
}
288320
}
289321

322+
class RolldownModuleRunner {
323+
// intercept globals
324+
private context = {
325+
rolldown_runtime: {} as any,
326+
__rolldown_hot: {
327+
send: () => {},
328+
},
329+
// TODO
330+
// should be aware of importer for non static require/import.
331+
// they needs to be transformed beforehand, so runtime can intercept.
332+
require,
333+
};
334+
335+
// TODO: support resolution?
336+
async import(id: string): Promise<unknown> {
337+
const mod = this.context.rolldown_runtime.moduleCache[id];
338+
assert(mod, `Module not found '${id}'`);
339+
return mod.exports;
340+
}
341+
342+
evaluate(code: string, sourceURL: string) {
343+
const context = {
344+
self: this.context,
345+
...this.context,
346+
};
347+
// extract sourcemap
348+
const sourcemap = code.match(/^\/\/# sourceMappingURL=.*/m)?.[0] ?? "";
349+
if (sourcemap) {
350+
code = code.replace(sourcemap, "");
351+
}
352+
// as eval
353+
code = `\
354+
'use strict';(${Object.keys(context).join(",")})=>{{${code}
355+
// TODO: need to re-expose runtime utilities for now
356+
self.__toCommonJS = __toCommonJS;
357+
self.__export = __export;
358+
self.__toESM = __toESM;
359+
}}
360+
//# sourceURL=${sourceURL}
361+
${sourcemap}
362+
`;
363+
try {
364+
const fn = (0, eval)(code);
365+
fn(...Object.values(context));
366+
} catch (e) {
367+
console.error(e);
368+
}
369+
}
370+
}
371+
290372
// TODO: copy vite:build-html plugin
291373
function viterollEntryPlugin(
292374
config: ResolvedConfig,
293375
viterollOptions: ViterollOptions,
376+
environment: RolldownEnvironment,
294377
): rolldown.Plugin {
295378
const htmlEntryMap = new Map<string, MagicString>();
296379

@@ -337,14 +420,27 @@ function viterollEntryPlugin(
337420
if (code.includes("//#region rolldown:runtime")) {
338421
const output = new MagicString(code);
339422
// replace hard-coded WebSocket setup with custom one
340-
output.replace(/const socket =.*?\n};/s, getRolldownClientCode(config));
423+
output.replace(
424+
/const socket =.*?\n};/s,
425+
environment.name === "client" ? getRolldownClientCode(config) : "",
426+
);
341427
// trigger full rebuild on non-accepting entry invalidation
342428
output
429+
.replace(
430+
"this.executeModuleStack.length > 1",
431+
"this.executeModuleStack.length >= 1",
432+
)
343433
.replace("parents: [parent],", "parents: parent ? [parent] : [],")
434+
.replace(
435+
"if (module.parents.indexOf(parent) === -1) {",
436+
"if (parent && module.parents.indexOf(parent) === -1) {",
437+
)
344438
.replace(
345439
"for (var i = 0; i < module.parents.length; i++) {",
346440
`
347-
if (module.parents.length === 0) {
441+
boundaries.push(moduleId);
442+
invalidModuleIds.push(moduleId);
443+
if (module.parents.filter(Boolean).length === 0) {
348444
__rolldown_hot.send("rolldown:hmr-deadend", { moduleId });
349445
break;
350446
}
@@ -353,7 +449,10 @@ function viterollEntryPlugin(
353449
if (viterollOptions.reactRefresh) {
354450
output.prepend(getReactRefreshRuntimeCode());
355451
}
356-
return { code: output.toString(), map: output.generateMap() };
452+
return {
453+
code: output.toString(),
454+
map: output.generateMap({ hires: "boundary" }),
455+
};
357456
}
358457
},
359458
generateBundle(_options, bundle) {

0 commit comments

Comments
 (0)