Skip to content

Commit 098aa89

Browse files
committed
feat: support ssr + hydration
1 parent 0637628 commit 098aa89

File tree

8 files changed

+120
-44
lines changed

8 files changed

+120
-44
lines changed

src/Repl.vue

+7-2
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@ export interface Props {
1313
clearConsole?: boolean
1414
sfcOptions?: SFCOptions
1515
layout?: string
16+
ssr?: boolean
1617
}
1718
1819
const props = withDefaults(defineProps<Props>(), {
1920
store: () => new ReplStore(),
2021
autoResize: true,
2122
showCompileOutput: true,
2223
showImportMap: true,
23-
clearConsole: true
24+
clearConsole: true,
25+
ssr: false
2426
})
2527
2628
props.store.options = props.sfcOptions
@@ -38,7 +40,10 @@ provide('clear-console', toRef(props, 'clearConsole'))
3840
<Editor />
3941
</template>
4042
<template #right>
41-
<Output :showCompileOutput="props.showCompileOutput" />
43+
<Output
44+
:showCompileOutput="props.showCompileOutput"
45+
:ssr="!!props.ssr"
46+
/>
4247
</template>
4348
</SplitPane>
4449
</div>

src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ export { default as Repl } from './Repl.vue'
22
export { ReplStore, File } from './store'
33
export { compileFile } from './transform'
44
export type { Props as ReplProps } from './Repl.vue'
5-
export type { Store, SFCOptions, StoreState } from './store'
5+
export type { Store, StoreOptions, SFCOptions, StoreState } from './store'
66
export type { OutputModes } from './output/types'

src/output/Output.vue

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { OutputModes } from './types'
77
88
const props = defineProps<{
99
showCompileOutput?: boolean
10+
ssr: boolean
1011
}>()
1112
1213
const store = inject('store') as Store
@@ -35,7 +36,7 @@ const mode = ref<OutputModes>(
3536
</div>
3637

3738
<div class="output-container">
38-
<Preview :show="mode === 'preview'" />
39+
<Preview :show="mode === 'preview'" :ssr="ssr" />
3940
<CodeMirror
4041
v-if="mode !== 'preview'"
4142
readonly

src/output/Preview.vue

+50-11
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { PreviewProxy } from './PreviewProxy'
1515
import { compileModulesForPreview } from './moduleCompiler'
1616
import { Store } from '../store'
1717
18-
defineProps<{ show: boolean }>()
18+
const props = defineProps<{ show: boolean; ssr: boolean }>()
1919
2020
const store = inject('store') as Store
2121
const clearConsole = inject('clear-console') as Ref<boolean>
@@ -160,30 +160,69 @@ async function updatePreview() {
160160
runtimeError.value = null
161161
runtimeWarning.value = null
162162
163+
const isSSR = props.ssr
164+
163165
try {
166+
const mainFile = store.state.mainFile
167+
168+
// if SSR, generate the SSR bundle and eval it to render the HTML
169+
if (isSSR && mainFile.endsWith('.vue')) {
170+
const ssrModules = compileModulesForPreview(store, true)
171+
console.log(
172+
`[@vue/repl] successfully compiled ${ssrModules.length} modules for SSR.`
173+
)
174+
await proxy.eval([
175+
`const __modules__ = {};`,
176+
...ssrModules,
177+
`import { renderToString as _renderToString } from 'vue/server-renderer'
178+
import { createSSRApp as _createApp } from 'vue'
179+
const AppComponent = __modules__["${mainFile}"].default
180+
AppComponent.name = 'Repl'
181+
const app = _createApp(AppComponent)
182+
app.config.unwrapInjectedRef = true
183+
app.config.warnHandler = () => {}
184+
window.__ssr_promise__ = _renderToString(app).then(html => {
185+
document.body.innerHTML = '<div id="app">' + html + '</div>'
186+
}).catch(err => {
187+
console.error("SSR Error", err)
188+
})
189+
`
190+
])
191+
}
192+
164193
// compile code to simulated module system
165194
const modules = compileModulesForPreview(store)
166195
console.log(`[@vue/repl] successfully compiled ${modules.length} modules.`)
167196
168197
const codeToEval = [
169198
`window.__modules__ = {};window.__css__ = '';` +
170-
`if (window.__app__) window.__app__.unmount();` +
171-
`document.body.innerHTML = '<div id="app"></div>'`,
199+
`if (window.__app__) window.__app__.unmount();` +
200+
isSSR
201+
? ``
202+
: `document.body.innerHTML = '<div id="app"></div>'`,
172203
...modules,
173204
`document.getElementById('__sfc-styles').innerHTML = window.__css__`
174205
]
175206
176207
// if main file is a vue file, mount it.
177-
const mainFile = store.state.mainFile
178208
if (mainFile.endsWith('.vue')) {
179209
codeToEval.push(
180-
`import { createApp as _createApp } from "vue"
181-
const AppComponent = __modules__["${mainFile}"].default
182-
AppComponent.name = 'Repl'
183-
const app = window.__app__ = _createApp(AppComponent)
184-
app.config.unwrapInjectedRef = true
185-
app.config.errorHandler = e => console.error(e)
186-
app.mount('#app')`.trim()
210+
`import { ${
211+
isSSR ? `createSSRApp` : `createApp`
212+
} as _createApp } from "vue"
213+
const _mount = () => {
214+
const AppComponent = __modules__["${mainFile}"].default
215+
AppComponent.name = 'Repl'
216+
const app = window.__app__ = _createApp(AppComponent)
217+
app.config.unwrapInjectedRef = true
218+
app.config.errorHandler = e => console.error(e)
219+
app.mount('#app')
220+
}
221+
if (window.__ssr_promise__) {
222+
window.__ssr_promise__.then(_mount)
223+
} else {
224+
_mount()
225+
}`
187226
)
188227
}
189228

src/output/moduleCompiler.ts

+26-17
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,27 @@ import {
1010
} from 'vue/compiler-sfc'
1111
import { ExportSpecifier, Identifier, Node } from '@babel/types'
1212

13-
export function compileModulesForPreview(store: Store) {
13+
export function compileModulesForPreview(store: Store, isSSR = false) {
1414
const seen = new Set<File>()
1515
const processed: string[] = []
16-
processFile(store, store.state.files[store.state.mainFile], processed, seen)
16+
processFile(
17+
store,
18+
store.state.files[store.state.mainFile],
19+
processed,
20+
seen,
21+
isSSR
22+
)
1723

18-
// also add css files that are not imported
19-
for (const filename in store.state.files) {
20-
if (filename.endsWith('.css')) {
21-
const file = store.state.files[filename]
22-
if (!seen.has(file)) {
23-
processed.push(
24-
`\nwindow.__css__ += ${JSON.stringify(file.compiled.css)}`
25-
)
24+
if (!isSSR) {
25+
// also add css files that are not imported
26+
for (const filename in store.state.files) {
27+
if (filename.endsWith('.css')) {
28+
const file = store.state.files[filename]
29+
if (!seen.has(file)) {
30+
processed.push(
31+
`\nwindow.__css__ += ${JSON.stringify(file.compiled.css)}`
32+
)
33+
}
2634
}
2735
}
2836
}
@@ -40,30 +48,31 @@ function processFile(
4048
store: Store,
4149
file: File,
4250
processed: string[],
43-
seen: Set<File>
51+
seen: Set<File>,
52+
isSSR: boolean
4453
) {
4554
if (seen.has(file)) {
4655
return []
4756
}
4857
seen.add(file)
4958

50-
if (file.filename.endsWith('.html')) {
59+
if (!isSSR && file.filename.endsWith('.html')) {
5160
return processHtmlFile(store, file.code, file.filename, processed, seen)
5261
}
5362

5463
let [js, importedFiles] = processModule(
5564
store,
56-
file.compiled.js,
65+
isSSR ? file.compiled.ssr : file.compiled.js,
5766
file.filename
5867
)
5968
// append css
60-
if (file.compiled.css) {
69+
if (!isSSR && file.compiled.css) {
6170
js += `\nwindow.__css__ += ${JSON.stringify(file.compiled.css)}`
6271
}
6372
// crawl child imports
6473
if (importedFiles.size) {
6574
for (const imported of importedFiles) {
66-
processFile(store, store.state.files[imported], processed, seen)
75+
processFile(store, store.state.files[imported], processed, seen, isSSR)
6776
}
6877
}
6978
// push self
@@ -111,7 +120,7 @@ function processModule(
111120

112121
// 0. instantiate module
113122
s.prepend(
114-
`const ${moduleKey} = __modules__[${JSON.stringify(
123+
`const ${moduleKey} = ${modulesKey}[${JSON.stringify(
115124
filename
116125
)}] = { [Symbol.toStringTag]: "Module" }\n\n`
117126
)
@@ -283,7 +292,7 @@ function processHtmlFile(
283292
const [code, importedFiles] = processModule(store, content, filename)
284293
if (importedFiles.size) {
285294
for (const imported of importedFiles) {
286-
processFile(store, store.state.files[imported], deps, seen)
295+
processFile(store, store.state.files[imported], deps, seen, false)
287296
}
288297
}
289298
jsCode += '\n' + code

src/store.ts

+26-9
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export interface StoreState {
4747
activeFile: File
4848
errors: (string | Error)[]
4949
vueRuntimeURL: string
50+
vueServerRendererURL: string
5051
}
5152

5253
export interface SFCOptions {
@@ -67,6 +68,15 @@ export interface Store {
6768
initialOutputMode: OutputModes
6869
}
6970

71+
export interface StoreOptions {
72+
serializedState?: string
73+
showOutput?: boolean
74+
// loose type to allow getting from the URL without inducing a typing error
75+
outputMode?: OutputModes | string
76+
defaultVueRuntimeURL?: string
77+
defaultVueServerRendererURL?: string
78+
}
79+
7080
export class ReplStore implements Store {
7181
state: StoreState
7282
compiler = defaultCompiler
@@ -75,20 +85,16 @@ export class ReplStore implements Store {
7585
initialOutputMode: OutputModes
7686

7787
private defaultVueRuntimeURL: string
88+
private defaultVueServerRendererURL: string
7889
private pendingCompiler: Promise<any> | null = null
7990

8091
constructor({
8192
serializedState = '',
8293
defaultVueRuntimeURL = `https://unpkg.com/@vue/runtime-dom@${version}/dist/runtime-dom.esm-browser.js`,
94+
defaultVueServerRendererURL = `https://unpkg.com/@vue/server-renderer@${version}/dist/server-renderer.esm-browser.js`,
8395
showOutput = false,
8496
outputMode = 'preview'
85-
}: {
86-
serializedState?: string
87-
showOutput?: boolean
88-
// loose type to allow getting from the URL without inducing a typing error
89-
outputMode?: OutputModes | string
90-
defaultVueRuntimeURL?: string
91-
} = {}) {
97+
}: StoreOptions = {}) {
9298
let files: StoreState['files'] = {}
9399

94100
if (serializedState) {
@@ -103,6 +109,7 @@ export class ReplStore implements Store {
103109
}
104110

105111
this.defaultVueRuntimeURL = defaultVueRuntimeURL
112+
this.defaultVueServerRendererURL = defaultVueServerRendererURL
106113
this.initialShowOutput = showOutput
107114
this.initialOutputMode = outputMode as OutputModes
108115

@@ -115,7 +122,8 @@ export class ReplStore implements Store {
115122
files,
116123
activeFile: files[mainFile],
117124
errors: [],
118-
vueRuntimeURL: this.defaultVueRuntimeURL
125+
vueRuntimeURL: this.defaultVueRuntimeURL,
126+
vueServerRendererURL: this.defaultVueServerRendererURL
119127
})
120128

121129
this.initImportMap()
@@ -202,6 +210,10 @@ export class ReplStore implements Store {
202210
json.imports.vue = this.defaultVueRuntimeURL
203211
map.code = JSON.stringify(json, null, 2)
204212
}
213+
if (!json.imports['vue/server-renderer']) {
214+
json.imports['vue/server-renderer'] = this.defaultVueServerRendererURL
215+
map.code = JSON.stringify(json, null, 2)
216+
}
205217
} catch (e) {}
206218
}
207219
}
@@ -227,18 +239,23 @@ export class ReplStore implements Store {
227239
async setVueVersion(version: string) {
228240
const compilerUrl = `https://unpkg.com/@vue/compiler-sfc@${version}/dist/compiler-sfc.esm-browser.js`
229241
const runtimeUrl = `https://unpkg.com/@vue/runtime-dom@${version}/dist/runtime-dom.esm-browser.js`
242+
const ssrUrl = `https://unpkg.com/@vue/server-renderer@${version}/dist/server-renderer.esm-browser.js`
230243
this.pendingCompiler = import(/* @vite-ignore */ compilerUrl)
231244
this.compiler = await this.pendingCompiler
232245
this.pendingCompiler = null
233246
this.state.vueRuntimeURL = runtimeUrl
247+
this.state.vueServerRendererURL = ssrUrl
234248
const importMap = this.getImportMap()
235-
;(importMap.imports || (importMap.imports = {})).vue = runtimeUrl
249+
const imports = importMap.imports || (importMap.imports = {})
250+
imports.vue = runtimeUrl
251+
imports['vue/server-renderer'] = ssrUrl
236252
this.setImportMap(importMap)
237253
console.info(`[@vue/repl] Now using Vue version: ${version}`)
238254
}
239255

240256
resetVueVersion() {
241257
this.compiler = defaultCompiler
242258
this.state.vueRuntimeURL = this.defaultVueRuntimeURL
259+
this.state.vueServerRendererURL = this.defaultVueServerRendererURL
243260
}
244261
}

src/vue-server-renderer-dev-proxy.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// serve server renderer to the iframe sandbox during dev.
2+
export * from 'vue/server-renderer'

test/main.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { createApp, h, watchEffect } from 'vue'
22
import { Repl, ReplStore } from '../src'
3-
43
;(window as any).process = { env: {} }
54

65
const App = {
@@ -12,7 +11,10 @@ const App = {
1211
outputMode: query.get('om') || 'preview',
1312
defaultVueRuntimeURL: import.meta.env.PROD
1413
? undefined
15-
: `${location.origin}/src/vue-dev-proxy`
14+
: `${location.origin}/src/vue-dev-proxy`,
15+
defaultVueServerRendererURL: import.meta.env.PROD
16+
? undefined
17+
: `${location.origin}/src/vue-server-renderer-dev-proxy`
1618
})
1719

1820
watchEffect(() => history.replaceState({}, '', store.serialize()))
@@ -35,7 +37,8 @@ const App = {
3537
return () =>
3638
h(Repl, {
3739
store,
38-
layout: 'vertical',
40+
// layout: 'vertical',
41+
ssr: true,
3942
sfcOptions: {
4043
script: {
4144
// inlineTemplate: false

0 commit comments

Comments
 (0)