diff --git a/viteroll/README.md b/viteroll/README.md
index 39d2910..b769ae5 100644
--- a/viteroll/README.md
+++ b/viteroll/README.md
@@ -12,4 +12,4 @@ pnpm -C examples/mpa dev
## links
- https://github.com/users/hi-ogawa/projects/4/views/1?pane=issue&itemId=84997064
-- https://github.com/hi-ogawa/rolldown/tree/feat-vite-like
+- https://github.com/rolldown/vite/pull/66
diff --git a/viteroll/examples/ssr/e2e/basic.test.ts b/viteroll/examples/ssr/e2e/basic.test.ts
index 4002877..f29ef8d 100644
--- a/viteroll/examples/ssr/e2e/basic.test.ts
+++ b/viteroll/examples/ssr/e2e/basic.test.ts
@@ -29,3 +29,9 @@ test("hmr", async ({ page, request }) => {
const res = await request.get("/");
expect(await res.text()).toContain("Count-EDIT-EDIT");
});
+
+test("server stacktrace", async ({ page }) => {
+ const res = await page.goto("/crash-ssr");
+ expect(await res?.text()).toContain("examples/ssr/src/error.tsx:8:8");
+ expect(res?.status()).toBe(500);
+});
diff --git a/viteroll/examples/ssr/src/entry-server.tsx b/viteroll/examples/ssr/src/entry-server.tsx
index ca78c2e..fb5485e 100644
--- a/viteroll/examples/ssr/src/entry-server.tsx
+++ b/viteroll/examples/ssr/src/entry-server.tsx
@@ -1,10 +1,15 @@
-import ReactDOMServer from "react-dom/server";
+// @ts-ignore TODO: external require (e.g. require("stream")) not supported
+import ReactDOMServer from "react-dom/server.browser";
import type { Connect } from "vite";
import { App } from "./app";
+import { throwError } from "./error";
const handler: Connect.SimpleHandleFunction = (req, res) => {
const url = new URL(req.url ?? "/", "https://vite.dev");
console.log(`[SSR] ${req.method} ${url.pathname}`);
+ if (url.pathname === "/crash-ssr") {
+ throwError();
+ }
const ssrHtml = ReactDOMServer.renderToString();
res.setHeader("content-type", "text/html");
// TODO: transformIndexHtml?
diff --git a/viteroll/examples/ssr/src/error.tsx b/viteroll/examples/ssr/src/error.tsx
new file mode 100644
index 0000000..f127c3e
--- /dev/null
+++ b/viteroll/examples/ssr/src/error.tsx
@@ -0,0 +1,9 @@
+//
+// random new lines
+//
+export function throwError() {
+ //
+ // and more
+ //
+ throw new Error("boom");
+}
diff --git a/viteroll/examples/ssr/vite.config.ts b/viteroll/examples/ssr/vite.config.ts
index dfd7cc5..2902a53 100644
--- a/viteroll/examples/ssr/vite.config.ts
+++ b/viteroll/examples/ssr/vite.config.ts
@@ -27,6 +27,7 @@ export default defineConfig({
plugins: [
viteroll({
reactRefresh: true,
+ ssrModuleRunner: true,
}),
{
name: "ssr-middleware",
@@ -40,7 +41,7 @@ export default defineConfig({
const devEnv = server.environments.ssr as RolldownEnvironment;
server.middlewares.use(async (req, res, next) => {
try {
- const mod = (await devEnv.import("index")) as any;
+ const mod = (await devEnv.import("src/entry-server.tsx")) as any;
await mod.default(req, res);
} catch (e) {
next(e);
diff --git a/viteroll/viteroll.ts b/viteroll/viteroll.ts
index f35e3e3..73aec63 100644
--- a/viteroll/viteroll.ts
+++ b/viteroll/viteroll.ts
@@ -23,6 +23,7 @@ const require = createRequire(import.meta.url);
interface ViterollOptions {
reactRefresh?: boolean;
+ ssrModuleRunner?: boolean;
}
const logger = createLogger("info", {
@@ -151,7 +152,9 @@ window.__rolldown_hot = hot;
export class RolldownEnvironment extends DevEnvironment {
instance!: rolldown.RolldownBuild;
result!: rolldown.RolldownOutput;
- outDir!: string;
+ outDir: string;
+ inputOptions!: rolldown.InputOptions;
+ outputOptions!: rolldown.OutputOptions;
buildTimestamp = Date.now();
static createFactory(
@@ -206,7 +209,7 @@ export class RolldownEnvironment extends DevEnvironment {
}
console.time(`[rolldown:${this.name}:build]`);
- const inputOptions: rolldown.InputOptions = {
+ this.inputOptions = {
// TODO: no dev ssr for now
dev: this.name === "client",
// NOTE:
@@ -223,7 +226,7 @@ export class RolldownEnvironment extends DevEnvironment {
},
define: this.config.define,
plugins: [
- viterollEntryPlugin(this.config, this.viterollOptions),
+ viterollEntryPlugin(this.config, this.viterollOptions, this),
// TODO: how to use jsx-dev-runtime?
rolldownExperimental.transformPlugin({
reactRefresh:
@@ -238,22 +241,27 @@ export class RolldownEnvironment extends DevEnvironment {
...(plugins as any),
],
};
- this.instance = await rolldown.rolldown(inputOptions);
-
- // `generate` should work but we use `write` so it's easier to see output and debug
- const outputOptions: rolldown.OutputOptions = {
+ this.instance = await rolldown.rolldown(this.inputOptions);
+
+ const format: rolldown.ModuleFormat =
+ this.name === "client" ||
+ (this.name === "ssr" && this.viterollOptions.ssrModuleRunner)
+ ? "app"
+ : "esm";
+ this.outputOptions = {
dir: this.outDir,
- format: this.name === "client" ? "app" : "esm",
+ format,
// TODO: hmr_rebuild returns source map file when `sourcemap: true`
sourcemap: "inline",
// TODO: https://github.com/rolldown/rolldown/issues/2041
// handle `require("stream")` in `react-dom/server`
banner:
- this.name === "ssr"
+ this.name === "ssr" && format === "esm"
? `import __nodeModule from "node:module"; const require = __nodeModule.createRequire(import.meta.url);`
: undefined,
};
- this.result = await this.instance.write(outputOptions);
+ // `generate` should work but we use `write` so it's easier to see output and debug
+ this.result = await this.instance.write(this.outputOptions);
this.buildTimestamp = Date.now();
console.timeEnd(`[rolldown:${this.name}:build]`);
@@ -268,7 +276,14 @@ export class RolldownEnvironment extends DevEnvironment {
return;
}
if (this.name === "ssr") {
- await this.build();
+ if (this.outputOptions.format === "app") {
+ console.time(`[rolldown:${this.name}:hmr]`);
+ const result = await this.instance.experimental_hmr_rebuild([ctx.file]);
+ this.getRunner().evaluate(result[1].toString(), result[0]);
+ console.timeEnd(`[rolldown:${this.name}:hmr]`);
+ } else {
+ await this.build();
+ }
} else {
logger.info(`hmr '${ctx.file}'`, { timestamp: true });
console.time(`[rolldown:${this.name}:hmr]`);
@@ -276,21 +291,89 @@ export class RolldownEnvironment extends DevEnvironment {
console.timeEnd(`[rolldown:${this.name}:hmr]`);
ctx.server.ws.send("rolldown:hmr", result);
}
- return true;
+ }
+
+ runner!: RolldownModuleRunner;
+
+ getRunner() {
+ if (!this.runner) {
+ const output = this.result.output[0];
+ const filepath = path.join(this.outDir, output.fileName);
+ this.runner = new RolldownModuleRunner();
+ const code = fs.readFileSync(filepath, "utf-8");
+ this.runner.evaluate(code, filepath);
+ }
+ return this.runner;
}
async import(input: string): Promise {
- const output = this.result.output.find((o) => o.name === input);
- assert(output, `invalid import input '${input}'`);
+ if (this.outputOptions.format === "app") {
+ return this.getRunner().import(input);
+ }
+ // input is no use
+ const output = this.result.output[0];
const filepath = path.join(this.outDir, output.fileName);
+ // TODO: source map not applied when adding `?t=...`?
+ // return import(`${pathToFileURL(filepath)}`)
return import(`${pathToFileURL(filepath)}?t=${this.buildTimestamp}`);
}
}
+class RolldownModuleRunner {
+ // intercept globals
+ private context = {
+ rolldown_runtime: {} as any,
+ __rolldown_hot: {
+ send: () => {},
+ },
+ // TODO
+ // should be aware of importer for non static require/import.
+ // they needs to be transformed beforehand, so runtime can intercept.
+ require,
+ };
+
+ // TODO: support resolution?
+ async import(id: string): Promise {
+ const mod = this.context.rolldown_runtime.moduleCache[id];
+ assert(mod, `Module not found '${id}'`);
+ return mod.exports;
+ }
+
+ evaluate(code: string, sourceURL: string) {
+ const context = {
+ self: this.context,
+ ...this.context,
+ };
+ // extract sourcemap
+ const sourcemap = code.match(/^\/\/# sourceMappingURL=.*/m)?.[0] ?? "";
+ if (sourcemap) {
+ code = code.replace(sourcemap, "");
+ }
+ // as eval
+ code = `\
+'use strict';(${Object.keys(context).join(",")})=>{{${code}
+// TODO: need to re-expose runtime utilities for now
+self.__toCommonJS = __toCommonJS;
+self.__export = __export;
+self.__toESM = __toESM;
+}}
+//# sourceURL=${sourceURL}
+${sourcemap}
+`;
+ try {
+ const fn = (0, eval)(code);
+ fn(...Object.values(context));
+ } catch (e) {
+ console.error(e);
+ }
+ }
+}
+
// TODO: copy vite:build-html plugin
function viterollEntryPlugin(
config: ResolvedConfig,
viterollOptions: ViterollOptions,
+ environment: RolldownEnvironment,
): rolldown.Plugin {
const htmlEntryMap = new Map();
@@ -337,14 +420,27 @@ function viterollEntryPlugin(
if (code.includes("//#region rolldown:runtime")) {
const output = new MagicString(code);
// replace hard-coded WebSocket setup with custom one
- output.replace(/const socket =.*?\n};/s, getRolldownClientCode(config));
+ output.replace(
+ /const socket =.*?\n};/s,
+ environment.name === "client" ? getRolldownClientCode(config) : "",
+ );
// trigger full rebuild on non-accepting entry invalidation
output
+ .replace(
+ "this.executeModuleStack.length > 1",
+ "this.executeModuleStack.length >= 1",
+ )
.replace("parents: [parent],", "parents: parent ? [parent] : [],")
+ .replace(
+ "if (module.parents.indexOf(parent) === -1) {",
+ "if (parent && module.parents.indexOf(parent) === -1) {",
+ )
.replace(
"for (var i = 0; i < module.parents.length; i++) {",
`
- if (module.parents.length === 0) {
+ boundaries.push(moduleId);
+ invalidModuleIds.push(moduleId);
+ if (module.parents.filter(Boolean).length === 0) {
__rolldown_hot.send("rolldown:hmr-deadend", { moduleId });
break;
}
@@ -353,7 +449,10 @@ function viterollEntryPlugin(
if (viterollOptions.reactRefresh) {
output.prepend(getReactRefreshRuntimeCode());
}
- return { code: output.toString(), map: output.generateMap() };
+ return {
+ code: output.toString(),
+ map: output.generateMap({ hires: "boundary" }),
+ };
}
},
generateBundle(_options, bundle) {