From 513115881a4ac3c04e31449b8b826079e7c011a9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 14 Nov 2024 16:16:44 +0900 Subject: [PATCH 1/6] feat(viteroll): support ssr patch module --- viteroll/examples/ssr/vite.config.ts | 1 + viteroll/viteroll.ts | 29 ++++++++++++++++++++-------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/viteroll/examples/ssr/vite.config.ts b/viteroll/examples/ssr/vite.config.ts index dfd7cc5f..5a17ea19 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, + // ssrPatchModule: true, }), { name: "ssr-middleware", diff --git a/viteroll/viteroll.ts b/viteroll/viteroll.ts index f35e3e3d..a86caa2c 100644 --- a/viteroll/viteroll.ts +++ b/viteroll/viteroll.ts @@ -23,6 +23,7 @@ const require = createRequire(import.meta.url); interface ViterollOptions { reactRefresh?: boolean; + ssrPatchModule?: 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: @@ -238,12 +241,16 @@ export class RolldownEnvironment extends DevEnvironment { ...(plugins as any), ], }; - this.instance = await rolldown.rolldown(inputOptions); + this.instance = await rolldown.rolldown(this.inputOptions); // `generate` should work but we use `write` so it's easier to see output and debug - const outputOptions: rolldown.OutputOptions = { + this.outputOptions = { dir: this.outDir, - format: this.name === "client" ? "app" : "esm", + format: + this.name === "client" || + (this.name === "ssr" && this.viterollOptions.ssrPatchModule) + ? "app" + : "esm", // TODO: hmr_rebuild returns source map file when `sourcemap: true` sourcemap: "inline", // TODO: https://github.com/rolldown/rolldown/issues/2041 @@ -253,7 +260,7 @@ export class RolldownEnvironment extends DevEnvironment { ? `import __nodeModule from "node:module"; const require = __nodeModule.createRequire(import.meta.url);` : undefined, }; - this.result = await this.instance.write(outputOptions); + this.result = await this.instance.write(this.outputOptions); this.buildTimestamp = Date.now(); console.timeEnd(`[rolldown:${this.name}:build]`); @@ -268,7 +275,11 @@ export class RolldownEnvironment extends DevEnvironment { return; } if (this.name === "ssr") { - await this.build(); + if (this.outputOptions.format === "app") { + // TODO + } else { + await this.build(); + } } else { logger.info(`hmr '${ctx.file}'`, { timestamp: true }); console.time(`[rolldown:${this.name}:hmr]`); @@ -276,10 +287,12 @@ export class RolldownEnvironment extends DevEnvironment { console.timeEnd(`[rolldown:${this.name}:hmr]`); ctx.server.ws.send("rolldown:hmr", result); } - return true; } async import(input: string): Promise { + if (this.outputOptions.format === "app") { + // TODO: eval or vm + } const output = this.result.output.find((o) => o.name === input); assert(output, `invalid import input '${input}'`); const filepath = path.join(this.outDir, output.fileName); From e67ffe39f48779797a4f0331914a832927caf11e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 14 Nov 2024 17:49:29 +0900 Subject: [PATCH 2/6] wip: working? --- viteroll/examples/ssr/src/entry-server.tsx | 7 +- viteroll/examples/ssr/vite.config.ts | 4 +- viteroll/viteroll.ts | 86 +++++++++++++++++++--- 3 files changed, 83 insertions(+), 14 deletions(-) diff --git a/viteroll/examples/ssr/src/entry-server.tsx b/viteroll/examples/ssr/src/entry-server.tsx index ca78c2e2..3ed723fd 100644 --- a/viteroll/examples/ssr/src/entry-server.tsx +++ b/viteroll/examples/ssr/src/entry-server.tsx @@ -1,4 +1,5 @@ -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"; @@ -23,3 +24,7 @@ const handler: Connect.SimpleHandleFunction = (req, res) => { }; export default handler; + +if (typeof module !== "undefined") { + (module as any).hot.accept(); +} diff --git a/viteroll/examples/ssr/vite.config.ts b/viteroll/examples/ssr/vite.config.ts index 5a17ea19..9f2ce346 100644 --- a/viteroll/examples/ssr/vite.config.ts +++ b/viteroll/examples/ssr/vite.config.ts @@ -27,7 +27,7 @@ export default defineConfig({ plugins: [ viteroll({ reactRefresh: true, - // ssrPatchModule: true, + ssrPatchModule: true, }), { name: "ssr-middleware", @@ -41,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 a86caa2c..9b1c19a6 100644 --- a/viteroll/viteroll.ts +++ b/viteroll/viteroll.ts @@ -226,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: @@ -243,23 +243,24 @@ export class RolldownEnvironment extends DevEnvironment { }; this.instance = await rolldown.rolldown(this.inputOptions); - // `generate` should work but we use `write` so it's easier to see output and debug + const format: rolldown.ModuleFormat = + this.name === "client" || + (this.name === "ssr" && this.viterollOptions.ssrPatchModule) + ? "app" + : "esm"; this.outputOptions = { dir: this.outDir, - format: - this.name === "client" || - (this.name === "ssr" && this.viterollOptions.ssrPatchModule) - ? "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, }; + // `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(); @@ -276,7 +277,10 @@ export class RolldownEnvironment extends DevEnvironment { } if (this.name === "ssr") { if (this.outputOptions.format === "app") { - // TODO + console.time(`[rolldown:${this.name}:hmr]`); + const result = await this.instance.experimental_hmr_rebuild([ctx.file]); + this.getRunner().evaluate(result[1].toString()); + console.timeEnd(`[rolldown:${this.name}:hmr]`); } else { await this.build(); } @@ -289,9 +293,22 @@ export class RolldownEnvironment extends DevEnvironment { } } + 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); + } + return this.runner; + } + async import(input: string): Promise { if (this.outputOptions.format === "app") { - // TODO: eval or vm + return this.getRunner().import(input); } const output = this.result.output.find((o) => o.name === input); assert(output, `invalid import input '${input}'`); @@ -300,10 +317,54 @@ export class RolldownEnvironment extends DevEnvironment { } } +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) { + const context = { + self: this.context, + ...this.context, + }; + // TODO: sourcemap + code = code.replace(/^\/\/# sourceMapping.*$/m, ""); + const wrapped = `'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; + }}`; + const fn = (0, eval)(wrapped); + try { + 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(); @@ -350,7 +411,10 @@ 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("parents: [parent],", "parents: parent ? [parent] : [],") From 1879e4325cf01e7244771f53db9a2b9ca619a6af Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 14 Nov 2024 18:20:46 +0900 Subject: [PATCH 3/6] fix: fix rolldown_runtime --- viteroll/examples/ssr/src/entry-server.tsx | 4 ---- viteroll/viteroll.ts | 12 +++++++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/viteroll/examples/ssr/src/entry-server.tsx b/viteroll/examples/ssr/src/entry-server.tsx index 3ed723fd..f5363339 100644 --- a/viteroll/examples/ssr/src/entry-server.tsx +++ b/viteroll/examples/ssr/src/entry-server.tsx @@ -24,7 +24,3 @@ const handler: Connect.SimpleHandleFunction = (req, res) => { }; export default handler; - -if (typeof module !== "undefined") { - (module as any).hot.accept(); -} diff --git a/viteroll/viteroll.ts b/viteroll/viteroll.ts index 9b1c19a6..b4e38530 100644 --- a/viteroll/viteroll.ts +++ b/viteroll/viteroll.ts @@ -417,11 +417,21 @@ function viterollEntryPlugin( ); // 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; } From e76406ba30c3e784026707d58f487fba8285dbc1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 14 Nov 2024 18:47:43 +0900 Subject: [PATCH 4/6] chore: readme --- viteroll/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viteroll/README.md b/viteroll/README.md index 39d29106..b769ae53 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 From 39fdabdfd963f1560c36c29c677ef024d02b1b94 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 14 Nov 2024 19:14:27 +0900 Subject: [PATCH 5/6] chore: rename --- viteroll/examples/ssr/vite.config.ts | 2 +- viteroll/viteroll.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/viteroll/examples/ssr/vite.config.ts b/viteroll/examples/ssr/vite.config.ts index 9f2ce346..2902a534 100644 --- a/viteroll/examples/ssr/vite.config.ts +++ b/viteroll/examples/ssr/vite.config.ts @@ -27,7 +27,7 @@ export default defineConfig({ plugins: [ viteroll({ reactRefresh: true, - ssrPatchModule: true, + ssrModuleRunner: true, }), { name: "ssr-middleware", diff --git a/viteroll/viteroll.ts b/viteroll/viteroll.ts index b4e38530..ded6e044 100644 --- a/viteroll/viteroll.ts +++ b/viteroll/viteroll.ts @@ -23,7 +23,7 @@ const require = createRequire(import.meta.url); interface ViterollOptions { reactRefresh?: boolean; - ssrPatchModule?: boolean; + ssrModuleRunner?: boolean; } const logger = createLogger("info", { @@ -245,7 +245,7 @@ export class RolldownEnvironment extends DevEnvironment { const format: rolldown.ModuleFormat = this.name === "client" || - (this.name === "ssr" && this.viterollOptions.ssrPatchModule) + (this.name === "ssr" && this.viterollOptions.ssrModuleRunner) ? "app" : "esm"; this.outputOptions = { From 4e6f84c33ce40d0d196aa1595ad862113ec02a7c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 29 Nov 2024 12:09:21 +0900 Subject: [PATCH 6/6] feat(viteroll): ssr sourcemap (#76) --- viteroll/examples/ssr/e2e/basic.test.ts | 6 +++ viteroll/examples/ssr/src/entry-server.tsx | 4 ++ viteroll/examples/ssr/src/error.tsx | 9 +++++ viteroll/viteroll.ts | 44 ++++++++++++++-------- 4 files changed, 47 insertions(+), 16 deletions(-) create mode 100644 viteroll/examples/ssr/src/error.tsx diff --git a/viteroll/examples/ssr/e2e/basic.test.ts b/viteroll/examples/ssr/e2e/basic.test.ts index 40028770..f29ef8d8 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 f5363339..fb5485e7 100644 --- a/viteroll/examples/ssr/src/entry-server.tsx +++ b/viteroll/examples/ssr/src/entry-server.tsx @@ -2,10 +2,14 @@ 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 00000000..f127c3e9 --- /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/viteroll.ts b/viteroll/viteroll.ts index ded6e044..73aec637 100644 --- a/viteroll/viteroll.ts +++ b/viteroll/viteroll.ts @@ -279,7 +279,7 @@ export class RolldownEnvironment extends DevEnvironment { 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()); + this.getRunner().evaluate(result[1].toString(), result[0]); console.timeEnd(`[rolldown:${this.name}:hmr]`); } else { await this.build(); @@ -301,7 +301,7 @@ export class RolldownEnvironment extends DevEnvironment { const filepath = path.join(this.outDir, output.fileName); this.runner = new RolldownModuleRunner(); const code = fs.readFileSync(filepath, "utf-8"); - this.runner.evaluate(code); + this.runner.evaluate(code, filepath); } return this.runner; } @@ -310,9 +310,11 @@ export class RolldownEnvironment extends DevEnvironment { if (this.outputOptions.format === "app") { return this.getRunner().import(input); } - const output = this.result.output.find((o) => o.name === input); - assert(output, `invalid import input '${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}`); } } @@ -337,22 +339,29 @@ class RolldownModuleRunner { return mod.exports; } - evaluate(code: string) { + evaluate(code: string, sourceURL: string) { const context = { self: this.context, ...this.context, }; - // TODO: sourcemap - code = code.replace(/^\/\/# sourceMapping.*$/m, ""); - const wrapped = `'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; - }}`; - const fn = (0, eval)(wrapped); + // 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); @@ -440,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) {