Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(viteroll): support ssr module runner #75

Merged
merged 6 commits into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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