Skip to content

Commit

Permalink
wip: ssr module runner
Browse files Browse the repository at this point in the history
  • Loading branch information
hi-ogawa committed Nov 21, 2024
1 parent 0c92ed2 commit dd63187
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 22 deletions.
158 changes: 137 additions & 21 deletions packages/vite/src/node/server/environments/rolldown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export function rolldownDevHandleConfig(
createEnvironment: RolldownEnvironment.createFactory({
hmr: config.experimental?.rolldownDev?.hmr,
reactRefresh: config.experimental?.rolldownDev?.reactRefresh,
ssrModuleRunner: false,
}),
},
build: {
Expand All @@ -81,6 +82,7 @@ export function rolldownDevHandleConfig(
createEnvironment: RolldownEnvironment.createFactory({
hmr: false,
reactRefresh: false,
ssrModuleRunner: config.experimental?.rolldownDev?.ssrModuleRunner,
}),
},
},
Expand Down Expand Up @@ -134,6 +136,8 @@ class RolldownEnvironment extends DevEnvironment {
result!: rolldown.RolldownOutput
outDir!: string
buildTimestamp = Date.now()
inputOptions!: rolldown.InputOptions
outputOptions!: rolldown.OutputOptions

static createFactory(
rolldownDevOptioins: RolldownDevOptions,
Expand Down Expand Up @@ -200,7 +204,7 @@ class RolldownEnvironment extends DevEnvironment {
plugins = plugins.map((p) => injectEnvironmentToHooks(this as any, p))

console.time(`[rolldown:${this.name}:build]`)
const inputOptions: rolldown.InputOptions = {
this.inputOptions = {
dev: this.rolldownDevOptions.hmr,
input: this.config.build.rollupOptions.input,
cwd: this.config.root,
Expand All @@ -212,30 +216,34 @@ class RolldownEnvironment extends DevEnvironment {
},
plugins: [
...plugins,
patchRuntimePlugin(this.rolldownDevOptions),
patchRuntimePlugin(this),
patchCssPlugin(),
reactRefreshPlugin(),
],
moduleTypes: {
'.css': 'js',
},
}
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 = {
const format: rolldown.ModuleFormat =
this.name === 'client' || this.rolldownDevOptions.ssrModuleRunner
? 'app'
: 'esm'
this.outputOptions = {
dir: this.outDir,
format: this.rolldownDevOptions.hmr ? '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 @@ -249,12 +257,22 @@ class RolldownEnvironment extends DevEnvironment {
if (!output.moduleIds.includes(ctx.file)) {
return
}
if (this.rolldownDevOptions.hmr) {
if (
this.rolldownDevOptions.hmr ||
this.rolldownDevOptions.ssrModuleRunner
) {
logger.info(`hmr '${ctx.file}'`, { timestamp: true })
console.time(`[rolldown:${this.name}:hmr]`)
const result = await this.instance.experimental_hmr_rebuild([ctx.file])
if (this.name === 'client') {
ctx.server.ws.send('rolldown:hmr', result)
} else {
this.getRunner().evaluate(
result[1].toString(),
path.join(this.outDir, result[0]),
)
}
console.timeEnd(`[rolldown:${this.name}:hmr]`)
ctx.server.ws.send('rolldown:hmr', result)
} else {
await this.build()
if (this.name === 'client') {
Expand All @@ -263,40 +281,138 @@ 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, 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}`)
}
}

function patchRuntimePlugin(
rolldownDevOptions: RolldownDevOptions,
): rolldown.Plugin {
class RolldownModuleRunner {
// intercept globals
private context = {
rolldown_runtime: {} as any,
__rolldown_hot: {
send: () => {},
},
// TODO: external require doesn't work in app format.
// TODO: also it should be aware of importer for non static require/import.
_require: 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 and move to the bottom
const sourcemap = code.match(/^\/\/# sourceMappingURL=.*/m)?.[0] ?? ''
if (sourcemap) {
code = code.replace(sourcemap, '')
}
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}
//# sourceMappingSource=rolldown-module-runner
${sourcemap}
`
const fn = (0, eval)(code)
try {
fn(...Object.values(context))
} catch (e) {
console.error('[RolldownModuleRunner:ERROR]', e)
throw e
}
}
}

function patchRuntimePlugin(environment: RolldownEnvironment): rolldown.Plugin {
return {
name: 'vite:rolldown-patch-runtime',
// TODO: external require doesn't work in app format.
// rewrite `require -> _require` and provide _require from module runner.
// for now just rewrite known ones in "react-dom/server".
transform: {
filter: {
code: {
include: [/require\(['"](stream|util)['"]\)/],
},
},
handler(code) {
if (!environment.rolldownDevOptions.ssrModuleRunner) {
return
}
return code.replace(
/require(\(['"](stream|util)['"]\))/g,
'_require($1)',
)
},
},
renderChunk(code) {
// patch rolldown_runtime to workaround a few things
// TODO: is there a robust way to inject code specifically to entry or runtime?
if (code.includes('//#region rolldown:runtime')) {
// TODO: is this magic string heavy?
// TODO: this magic string is heavy
const output = new MagicString(code)
// replace hard-coded WebSocket setup with custom client
output.replace(/const socket =.*?\n\};/s, getRolldownClientCode())
// trigger full rebuild on non-accepting entry invalidation
output
// replace hard-coded WebSocket setup with custom client
.replace(
/const socket =.*?\n\};/s,
environment.name === 'client' ? getRolldownClientCode() : '',
)
// fix rolldown_runtime.patch
.replace(
'this.executeModuleStack.length > 1',
'this.executeModuleStack.length > 0',
)
.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;
}
for (var i = 0; i < module.parents.length; i++) {`,
)
if (rolldownDevOptions.reactRefresh) {
if (environment.rolldownDevOptions.reactRefresh) {
output.prepend(getReactRefreshRuntimeCode())
}
return {
Expand Down
4 changes: 4 additions & 0 deletions playground/rolldown-dev-ssr/src/entry-server.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import ReactDOMServer from 'react-dom/server'
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 playground/rolldown-dev-ssr/src/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//
// random new lines
//
export function throwError(): never {
//
// and more
//
throw new Error('boom')
}
5 changes: 4 additions & 1 deletion playground/rolldown-dev-ssr/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default defineConfig({
rolldownDev: {
hmr: true,
reactRefresh: true,
ssrModuleRunner: !process.env['NO_MODULE_RUNNER'],
},
},
plugins: [
Expand All @@ -39,7 +40,9 @@ export default defineConfig({
return () => {
server.middlewares.use(async (req, res, next) => {
try {
const mod = await (server.environments.ssr as any).import('index')
const mod = await (server.environments.ssr as any).import(
'src/entry-server.tsx',
)
await mod.default(req, res)
} catch (e) {
next(e)
Expand Down

0 comments on commit dd63187

Please sign in to comment.