From ecb554132f3e449053d71bc0ac993274886d9da7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 29 Nov 2024 12:10:51 +0900 Subject: [PATCH] feat(viteroll): support ssr module runner (#75) --- viteroll/README.md | 2 +- viteroll/examples/ssr/e2e/basic.test.ts | 6 + viteroll/examples/ssr/src/entry-server.tsx | 7 +- viteroll/examples/ssr/src/error.tsx | 9 ++ viteroll/examples/ssr/vite.config.ts | 3 +- viteroll/viteroll.ts | 133 ++++++++++++++++++--- 6 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 viteroll/examples/ssr/src/error.tsx 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) {