Skip to content

Commit

Permalink
feat(viteroll): support ssr module runner (#75)
Browse files Browse the repository at this point in the history
  • Loading branch information
hi-ogawa authored Nov 29, 2024
1 parent e574d32 commit ecb5541
Show file tree
Hide file tree
Showing 6 changed files with 140 additions and 20 deletions.
2 changes: 1 addition & 1 deletion viteroll/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions viteroll/examples/ssr/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
7 changes: 6 additions & 1 deletion viteroll/examples/ssr/src/entry-server.tsx
Original file line number Diff line number Diff line change
@@ -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(<App />);
res.setHeader("content-type", "text/html");
// TODO: transformIndexHtml?
Expand Down
9 changes: 9 additions & 0 deletions viteroll/examples/ssr/src/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//
// random new lines
//
export function throwError() {
//
// and more
//
throw new Error("boom");
}
3 changes: 2 additions & 1 deletion viteroll/examples/ssr/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default defineConfig({
plugins: [
viteroll({
reactRefresh: true,
ssrModuleRunner: true,
}),
{
name: "ssr-middleware",
Expand All @@ -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);
Expand Down
133 changes: 116 additions & 17 deletions viteroll/viteroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const require = createRequire(import.meta.url);

interface ViterollOptions {
reactRefresh?: boolean;
ssrModuleRunner?: boolean;
}

const logger = createLogger("info", {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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]`);
Expand All @@ -268,29 +276,104 @@ 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]`);
const result = await this.instance.experimental_hmr_rebuild([ctx.file]);
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<unknown> {
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<unknown> {
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<string, MagicString>();

Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand Down

0 comments on commit ecb5541

Please sign in to comment.