diff --git a/README.md b/README.md index 5764c61..7918d2f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ +## ssr + +https://stackblitz.com/edit/bluwy-create-vite-extra-fuc1kcnj?file=index.html,server.js,src%2FApp.vue,src%2Fcomponents%2FHelloWorld.vue,src%2Fassets%2Fvue.svg + +bun run server.ts + +bunx tsc .\server.ts --module NodeNext --target esnext --moduleResolution nodenext + + + ## koa3-demo diff --git a/bun.lockb b/bun.lockb index 8f97feb..daa08b1 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/client/App.vue b/client/App.vue new file mode 100644 index 0000000..5cc7b23 --- /dev/null +++ b/client/App.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/client/assets/vue.svg b/client/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/client/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/components/HelloWorld.vue b/client/components/HelloWorld.vue new file mode 100644 index 0000000..63f7e72 --- /dev/null +++ b/client/components/HelloWorld.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/client/entry-client.ts b/client/entry-client.ts new file mode 100644 index 0000000..ae68fdd --- /dev/null +++ b/client/entry-client.ts @@ -0,0 +1,6 @@ +import './style.css' +import { createApp } from "./main" + +const { app } = createApp() + +app.mount('#app') diff --git a/client/entry-server.ts b/client/entry-server.ts new file mode 100644 index 0000000..bbafdd3 --- /dev/null +++ b/client/entry-server.ts @@ -0,0 +1,15 @@ +import { renderToString } from 'vue/server-renderer' +import { createApp } from './main' + +export async function render(_url: string) { + const { app } = createApp() + + // passing SSR context object which will be available via useSSRContext() + // @vitejs/plugin-vue injects code into a component's setup() that registers + // itself on ctx.modules. After the render, ctx.modules would contain all the + // components that have been instantiated during this render call. + const ctx = {} + const html = await renderToString(app, ctx) + + return { html } +} diff --git a/client/main.ts b/client/main.ts new file mode 100644 index 0000000..ff091f8 --- /dev/null +++ b/client/main.ts @@ -0,0 +1,10 @@ +import { createSSRApp } from 'vue' +import App from './App.vue' + +// SSR requires a fresh app instance per request, therefore we export a function +// that creates a fresh app instance. If using Vuex, we'd also be creating a +// fresh store here. +export function createApp() { + const app = createSSRApp(App) + return { app } +} diff --git a/client/style.css b/client/style.css new file mode 100644 index 0000000..f691315 --- /dev/null +++ b/client/style.css @@ -0,0 +1,79 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +.card { + padding: 2em; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/client/vite-env.d.ts b/client/vite-env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/client/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..70c1f19 --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + + Vite + Vue + TS + + + +
+ + + diff --git a/package.json b/package.json index 199afea..7545092 100644 --- a/package.json +++ b/package.json @@ -4,23 +4,19 @@ "version": "0.0.1-alpha", "type": "module", "scripts": { - "dev": "bun --hot src/main.js", - "start": "cross-env NODE_ENV=production bun run src/main.js", - "build": "vite build", - "migrate:make": "npx knex migrate:make ", - "migrate": "npx knex migrate:latest", - "seed:make": "npx knex seed:make ", - "seed": "npx knex seed:run ", - "dev:init": "bun run scripts/init.js", - "init": "cross-env NODE_ENV=production bun run scripts/init.js", - "test": "bun test", - "test:db:run": "bun run scripts/run-db-tests.js", - "test:db:benchmark": "bun run scripts/db-benchmark.js" + "dev": "bun run server.ts", + "build": "npm run build:client && npm run build:server", + "build:client": "vite build --outDir dist/client", + "build:server": "vite build --ssr client/entry-server.ts --outDir dist/server", + "preview": "cross-env NODE_ENV=production node server", + "check": "vue-tsc" }, "devDependencies": { "@types/bun": "latest", + "@types/koa": "^3.0.0", "@types/node": "^24.0.1", "cross-env": "^10.0.0", + "koa-send": "^5.0.1", "module-alias": "^2.2.3", "node-gyp": "^11.4.2", "vite": "^7.0.0", @@ -28,6 +24,7 @@ }, "dependencies": { "@koa/etag": "^5.0.1", + "@vitejs/plugin-vue": "^6.0.1", "bcryptjs": "^3.0.2", "consolidate": "^1.0.4", "extend-shallow": "^3.0.2", @@ -39,6 +36,7 @@ "knex": "^3.1.0", "koa": "^3.0.0", "koa-bodyparser": "^4.4.1", + "koa-connect": "^2.1.0", "koa-helmet": "^8.0.1", "koa-ratelimit": "^6.0.0", "koa-session": "^7.0.2", @@ -62,6 +60,6 @@ "services": "./src/services" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": "^5.9.2" } } \ No newline at end of file diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..76512b4 --- /dev/null +++ b/server.js @@ -0,0 +1,66 @@ +import fs from 'node:fs/promises'; +import Koa from "koa"; +import c2k from 'koa-connect'; +import Send from 'koa-send'; +// Constants +const isProduction = process.env.NODE_ENV === 'production'; +const port = process.env.PORT || 5173; +const base = process.env.BASE || '/'; +// Cached production assets +const templateHtml = isProduction + ? await fs.readFile('./dist/client/index.html', 'utf-8') + : ''; +const app = new Koa(); +// Add Vite or respective production middlewares +/** @type {import('vite').ViteDevServer | undefined} */ +let vite; +if (!isProduction) { + const { createServer } = await import('vite'); + vite = await createServer({ + server: { middlewareMode: true }, + appType: 'custom', + base, + }); + app.use(c2k(vite.middlewares)); +} +else { + app.use(Send({ root: 'dist/client', index: false })); +} +app.use(async (ctx, next) => { + try { + const url = ctx.path.replace(base, ''); + /** @type {string} */ + let template; + /** @type {import('./client/entry-server.ts').render} */ + let render; + if (!isProduction) { + // Always read fresh template in development + template = await fs.readFile('./index.html', 'utf-8'); + template = await vite.transformIndexHtml(url, template); + render = (await vite.ssrLoadModule('/client/entry-server.ts')).render; + } + else { + template = templateHtml; + // @ts-ignore + render = (await import('./dist/server/entry-server.js')).render; + } + const rendered = await render(url); + const html = template + .replace(``, rendered.head ?? '') + .replace(``, rendered.html ?? ''); + ctx.status = 200; + ctx.set({ 'Content-Type': 'text/html' }); + ctx.body = html; + } + catch (e) { + vite?.ssrFixStacktrace(e); + console.log(e.stack); + ctx.status = 500; + ctx.body = e.stack; + } + await next(); +}); +// Start http server +app.listen(port, () => { + console.log(`Server started at http://localhost:${port}`); +}); diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..21162b0 --- /dev/null +++ b/server.ts @@ -0,0 +1,71 @@ +import fs from 'node:fs/promises' +import Koa from "koa" +import c2k from 'koa-connect' +import { ViteDevServer } from 'vite' +import Send from 'koa-send' + +// Constants +const isProduction = process.env.NODE_ENV === 'production' +const port = process.env.PORT || 5173 +const base = process.env.BASE || '/' + +// Cached production assets +const templateHtml = isProduction + ? await fs.readFile('./dist/client/index.html', 'utf-8') + : '' + +const app = new Koa() + +// Add Vite or respective production middlewares +/** @type {import('vite').ViteDevServer | undefined} */ +let vite: ViteDevServer +if (!isProduction) { + const { createServer } = await import('vite') + vite = await createServer({ + server: { middlewareMode: true }, + appType: 'custom', + base, + }) + app.use(c2k(vite.middlewares)) +} else { + app.use(Send({ root: 'dist/client', index: false })) +} +app.use(async (ctx, next) => { + try { + const url = ctx.path.replace(base, '') + /** @type {string} */ + let template + /** @type {import('./client/entry-server.ts').render} */ + let render + if (!isProduction) { + // Always read fresh template in development + template = await fs.readFile('./index.html', 'utf-8') + template = await vite.transformIndexHtml(url, template) + render = (await vite.ssrLoadModule('/client/entry-server.ts')).render + } else { + template = templateHtml + // @ts-ignore + render = (await import('./dist/server/entry-server.js')).render + } + + const rendered = await render(url) + + const html = template + .replace(``, rendered.head ?? '') + .replace(``, rendered.html ?? '') + ctx.status = 200 + ctx.set({ 'Content-Type': 'text/html' }) + ctx.body = html + } catch (e: Error | any) { + vite?.ssrFixStacktrace(e) + console.log(e.stack) + ctx.status = 500 + ctx.body = e.stack + } + await next() +}) + +// Start http server +app.listen(port, () => { + console.log(`Server started at http://localhost:${port}`) +}) diff --git a/vite.config copy.ts b/vite.config copy.ts new file mode 100644 index 0000000..ef21700 --- /dev/null +++ b/vite.config copy.ts @@ -0,0 +1,82 @@ +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import module from "node:module" +import { defineConfig } from "vite" +import pkg from "./package.json" +import { viteStaticCopy } from "vite-plugin-static-copy" + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +function getExternal(): string[] { + return [...Object.keys(pkg.dependencies || {}), ...module.builtinModules] +} + +export default defineConfig({ + publicDir: false, + resolve: { + alias: { + "@": resolve(__dirname, "src"), + db: resolve(__dirname, "src/db"), + config: resolve(__dirname, "src/config"), + utils: resolve(__dirname, "src/utils"), + }, + }, + build: { + lib: { + entry: resolve(__dirname, "src/main.js"), + formats: ["es"], + fileName: () => `[name].js`, + }, + outDir: resolve(__dirname, "dist"), + rollupOptions: { + external: getExternal(), + // watch: { + // include: "src/**", + // exclude: "node_modules/**", + // }, + output: { + preserveModules: true, + preserveModulesRoot: "src", + inlineDynamicImports: false, + }, + }, + }, + plugins: [ + viteStaticCopy({ + targets: [ + { + src: "public", + dest: "", + }, + { + src: "src/views", + dest: "", + }, + { + src: "src/db/migrations", + dest: "db", + }, + { + src: "src/db/seeds", + dest: "db", + }, + { + src: "entrypoint.sh", + dest: "", + }, + { + src: "package.json", + dest: "", + }, + { + src: "knexfile.mjs", + dest: "", + }, + { + src: "bun.lockb", + dest: "", + }, + ], + }), + ], +}) diff --git a/vite.config.ts b/vite.config.ts index ef21700..bbcf80c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,82 +1,7 @@ -import { dirname, resolve } from "node:path" -import { fileURLToPath } from "node:url" -import module from "node:module" -import { defineConfig } from "vite" -import pkg from "./package.json" -import { viteStaticCopy } from "vite-plugin-static-copy" - -const __dirname = dirname(fileURLToPath(import.meta.url)) - -function getExternal(): string[] { - return [...Object.keys(pkg.dependencies || {}), ...module.builtinModules] -} +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +// https://vite.dev/config/ export default defineConfig({ - publicDir: false, - resolve: { - alias: { - "@": resolve(__dirname, "src"), - db: resolve(__dirname, "src/db"), - config: resolve(__dirname, "src/config"), - utils: resolve(__dirname, "src/utils"), - }, - }, - build: { - lib: { - entry: resolve(__dirname, "src/main.js"), - formats: ["es"], - fileName: () => `[name].js`, - }, - outDir: resolve(__dirname, "dist"), - rollupOptions: { - external: getExternal(), - // watch: { - // include: "src/**", - // exclude: "node_modules/**", - // }, - output: { - preserveModules: true, - preserveModulesRoot: "src", - inlineDynamicImports: false, - }, - }, - }, - plugins: [ - viteStaticCopy({ - targets: [ - { - src: "public", - dest: "", - }, - { - src: "src/views", - dest: "", - }, - { - src: "src/db/migrations", - dest: "db", - }, - { - src: "src/db/seeds", - dest: "db", - }, - { - src: "entrypoint.sh", - dest: "", - }, - { - src: "package.json", - dest: "", - }, - { - src: "knexfile.mjs", - dest: "", - }, - { - src: "bun.lockb", - dest: "", - }, - ], - }), - ], + plugins: [vue()], })