commit f7f66eaafcf0456766509a2f6d8bec0498dd452e
Author: 谢亚昕 <1549469775@qq.com>
Date: Tue Sep 30 16:17:06 2025 +0800
init
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9b1ee42
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,175 @@
+# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
+
+# Logs
+
+logs
+_.log
+npm-debug.log_
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Caches
+
+.cache
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+
+report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+# Runtime data
+
+pids
+_.pid
+_.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+
+lib-cov
+
+# Coverage directory used by tools like istanbul
+
+coverage
+*.lcov
+
+# nyc test coverage
+
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+
+bower_components
+
+# node-waf configuration
+
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+
+build/Release
+
+# Dependency directories
+
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+
+web_modules/
+
+# TypeScript cache
+
+*.tsbuildinfo
+
+# Optional npm cache directory
+
+.npm
+
+# Optional eslint cache
+
+.eslintcache
+
+# Optional stylelint cache
+
+.stylelintcache
+
+# Microbundle cache
+
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+
+.node_repl_history
+
+# Output of 'npm pack'
+
+*.tgz
+
+# Yarn Integrity file
+
+.yarn-integrity
+
+# dotenv environment variable files
+
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+
+.parcel-cache
+
+# Next.js build output
+
+.next
+out
+
+# Nuxt.js build / generate output
+
+.nuxt
+dist
+
+# Gatsby files
+
+# Comment in the public line in if your project uses Gatsby and not Next.js
+
+# https://nextjs.org/blog/next-9-1#public-directory-support
+
+# public
+
+# vuepress build output
+
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+
+.temp
+
+# Docusaurus cache and generated files
+
+.docusaurus
+
+# Serverless directories
+
+.serverless/
+
+# FuseBox cache
+
+.fusebox/
+
+# DynamoDB Local files
+
+.dynamodb/
+
+# TernJS port file
+
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+
+.vscode-test
+
+# yarn v2
+
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+# IntelliJ based IDEs
+.idea
+
+# Finder (MacOS) folder config
+.DS_Store
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..f4a7f34
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "CodeFree.index": true
+}
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9b1e070
--- /dev/null
+++ b/README.md
@@ -0,0 +1 @@
+# 基于koa实现的简易ssr
\ No newline at end of file
diff --git a/bun.lockb b/bun.lockb
new file mode 100644
index 0000000..d7be6da
Binary files /dev/null and b/bun.lockb differ
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..88db749
--- /dev/null
+++ b/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+ Vite + Vue + TS
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..71ea955
--- /dev/null
+++ b/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "koa-ssr",
+ "type": "module",
+ "scripts": {
+ "dev": "bun run --watch server.ts",
+ "build": "npm run build:client && npm run build:server",
+ "build:client": "vite build --outDir dist/client",
+ "build:server": "vite build --ssr src/entry-server.ts --outDir dist/server",
+ "preview": "cross-env NODE_ENV=production bun run server.ts",
+ "check": "vue-tsc"
+ },
+ "devDependencies": {
+ "@types/bun": "latest",
+ "@types/koa": "^3.0.0",
+ "@types/koa-send": "^4.1.6",
+ "cross-env": "^10.1.0",
+ "vue-tsc": "^3.1.0"
+ },
+ "peerDependencies": {
+ "typescript": "^5.0.0"
+ },
+ "dependencies": {
+ "@vitejs/plugin-vue": "^6.0.1",
+ "koa": "^3.0.1",
+ "koa-connect": "^2.1.0",
+ "koa-send": "^5.0.1",
+ "vite": "^7.1.7",
+ "vue": "^3.5.22"
+ }
+}
\ 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.ts b/server.ts
new file mode 100644
index 0000000..b2de317
--- /dev/null
+++ b/server.ts
@@ -0,0 +1,84 @@
+import fs from 'node:fs/promises'
+import c2k from 'koa-connect'
+import type { ViteDevServer } from 'vite'
+import Send from 'koa-send'
+import app from "./server/app"
+import { bootstrapServer } from "./server/main"
+
+// Constants
+const isProduction = process.env.NODE_ENV === 'production'
+const port = process.env.PORT || 5173
+const base = process.env.BASE || '/'
+
+bootstrapServer()
+
+// Cached production assets
+const templateHtml = isProduction
+ ? await fs.readFile('./dist/client/index.html', 'utf-8')
+ : ''
+
+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(async (ctx, next) => {
+ await Send(ctx, ctx.path, { root: './dist/client', index: false });
+ if (ctx.status === 404) {
+ await next()
+ }
+ })
+}
+
+app.use(async (ctx, next) => {
+ // if (!ctx.originalUrl.startsWith(base)) return await next()
+ try {
+ const url = ctx.originalUrl.replace(base, '')
+ let template
+ 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('/src/entry-server.ts')).render
+ } else {
+ template = templateHtml
+ // @ts-ignore
+ render = (await import('./dist/server/entry-server.js')).render
+ }
+
+ // 解析请求 Cookie 到对象(复用通用工具)
+ const { parseCookieHeader } = await import('./src/compose/cookieUtils')
+ const cookies = parseCookieHeader(ctx.request.headers['cookie'] as string)
+
+ const rendered = await render(url, { cookies })
+
+ const html = template
+ .replace(``, rendered.head ?? '')
+ .replace(``, rendered.html ?? '')
+ ctx.status = 200
+ ctx.set({ 'Content-Type': 'text/html' })
+ ctx.body = html
+
+ // 设置服务端渲染期间收集到的 Set-Cookie
+ const setCookies: string[] = (rendered as any).setCookies || []
+ if (setCookies.length > 0) {
+ ctx.set('Set-Cookie', setCookies)
+ }
+ } catch (e: Error | any) {
+ vite?.ssrFixStacktrace(e)
+ 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/app.ts b/server/app.ts
new file mode 100644
index 0000000..38d9780
--- /dev/null
+++ b/server/app.ts
@@ -0,0 +1,9 @@
+
+import Koa from "koa"
+
+const app = new Koa()
+
+export default app
+export {
+ app
+}
\ No newline at end of file
diff --git a/server/main.ts b/server/main.ts
new file mode 100644
index 0000000..a324dce
--- /dev/null
+++ b/server/main.ts
@@ -0,0 +1,60 @@
+import { parseCookieHeader, serializeCookie } from "../src/compose/cookieUtils";
+import app from "./app";
+
+export function bootstrapServer() {
+ async function fetchFirstSuccess(urls) {
+ for (const url of urls) {
+ try {
+ const res = await fetch(url, {
+ method: "get",
+ mode: "cors",
+ redirect: "follow",
+ });
+ if (!res.ok) continue;
+ const contentType = res.headers.get("content-type") || "";
+ let data, type;
+ if (contentType.includes("application/json")) {
+ data = await res.json();
+ type = "json";
+ } else if (contentType.includes("text/")) {
+ data = await res.text();
+ type = "text";
+ } else {
+ data = await res.blob();
+ type = "blob";
+ }
+ return { type, data };
+ } catch (e) {
+ // ignore and try next url
+ }
+ }
+ throw new Error("All requests failed");
+ }
+
+ app.use(async (ctx, next) => {
+ const cookies = parseCookieHeader(ctx.request.headers.cookie as string);
+
+ // 读取
+ const token = cookies["demo_2token"];
+
+ // 写入(HttpOnly 更安全)
+ if (!token) {
+ const setItem = serializeCookie("demo_2token", "from-mw", {
+ httpOnly: true,
+ path: "/",
+ sameSite: "lax",
+ });
+ ctx.set("Set-Cookie", [setItem]);
+ }
+ if (ctx.originalUrl !== "/api/pics/random") return await next();
+ const { type, data } = await fetchFirstSuccess([
+ "https://api.miaomc.cn/image/get",
+ ]);
+ if (type === "blob") {
+ ctx.set("Content-Type", "image/jpeg");
+ // 下载
+ // ctx.set("Content-Disposition", "attachment; filename=random.jpg")
+ ctx.body = data;
+ }
+ });
+}
diff --git a/src/App.vue b/src/App.vue
new file mode 100644
index 0000000..6d00c7e
--- /dev/null
+++ b/src/App.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/vue.svg b/src/assets/vue.svg
new file mode 100644
index 0000000..770e9d3
--- /dev/null
+++ b/src/assets/vue.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/CookieDemo.vue b/src/components/CookieDemo.vue
new file mode 100644
index 0000000..8d5ed24
--- /dev/null
+++ b/src/components/CookieDemo.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
服务端首屏会尝试设置缺失的 HttpOnly cookie,客户端不可读。
+
+
+
+
+
+
+
+
+
diff --git a/src/components/DataFetch.vue b/src/components/DataFetch.vue
new file mode 100644
index 0000000..ead57d0
--- /dev/null
+++ b/src/components/DataFetch.vue
@@ -0,0 +1,116 @@
+
+
+
数据获取示例
+
+
+
+
水合状态: {{ isHydrated ? '已水合' : '未水合' }}
+
缓存键: user-1
+
+
+
+ 加载中...
+
+
+
+ 错误: {{ error.message }}
+
+
+
+
+
用户信息
+
{{ JSON.stringify(data, null, 2) }}
+
+
+
+
+
+
+
+
diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue
new file mode 100644
index 0000000..63f7e72
--- /dev/null
+++ b/src/components/HelloWorld.vue
@@ -0,0 +1,38 @@
+
+
+
+ {{ msg }}
+
+
+
+
+ Edit
+ components/HelloWorld.vue to test HMR
+
+
+
+
+ Check out
+ create-vue, the official Vue + Vite starter
+
+
+ Install
+ Volar
+ in your IDE for a better DX
+
+ Click on the Vite and Vue logos to learn more
+
+
+
diff --git a/src/components/SimpleTest.vue b/src/components/SimpleTest.vue
new file mode 100644
index 0000000..98c5cbd
--- /dev/null
+++ b/src/components/SimpleTest.vue
@@ -0,0 +1,45 @@
+
+
+
简单测试组件
+
数据状态: {{ data ? '已加载' : '未加载' }}
+
错误: {{ error ? error.message : '无' }}
+
加载中: {{ pending ? '是' : '否' }}
+
+
{{ data.title }}
+
{{ data.body }}
+
+
+
+
+
+
+
diff --git a/src/compose/README.md b/src/compose/README.md
new file mode 100644
index 0000000..442c39f
--- /dev/null
+++ b/src/compose/README.md
@@ -0,0 +1,170 @@
+# useFetch SSR Hook
+
+这是一个专为 Vue 3 SSR 应用设计的 `useFetch` hook,支持服务端预取和客户端水合。
+
+## 特性
+
+- ✅ **SSR 兼容**: 支持服务端预取和客户端水合
+- ✅ **数据缓存**: 避免重复请求,提升性能
+- ✅ **错误处理**: 完整的错误处理机制
+- ✅ **加载状态**: 内置 loading 状态管理
+- ✅ **TypeScript**: 完整的类型支持
+- ✅ **灵活配置**: 支持自定义缓存键、转换函数等
+
+## 基本用法
+
+```typescript
+import { useFetch } from './compose/useFetch'
+
+// 基本用法
+const { data, error, pending, refresh } = useFetch('/api/users')
+
+// 带配置的用法
+const { data, error, pending, refresh } = useFetch(
+ 'https://api.example.com/users/1',
+ {
+ key: 'user-1', // 缓存键
+ server: true, // 启用服务端预取
+ transform: (data) => ({ // 数据转换
+ id: data.id,
+ name: data.name
+ }),
+ onError: (err) => { // 错误处理
+ console.error(err)
+ }
+ }
+)
+```
+
+## API 参考
+
+### useFetch(url, options?)
+
+#### 参数
+
+- `url`: `string | (() => string) | (() => Promise)` - 请求 URL
+- `options`: `UseFetchOptions` - 配置选项
+
+#### 返回值
+
+- `data`: `Ref` - 响应数据
+- `error`: `Ref` - 错误信息
+- `pending`: `Ref` - 加载状态
+- `refresh()`: `() => Promise` - 刷新数据
+- `execute()`: `() => Promise` - 手动执行请求
+
+### UseFetchOptions
+
+```typescript
+interface UseFetchOptions {
+ key?: string // 缓存键
+ server?: boolean // 是否启用服务端预取
+ default?: () => any // 默认值
+ transform?: (data: any) => any // 数据转换函数
+ onError?: (error: Error) => void // 错误处理函数
+}
+```
+
+## SSR 集成
+
+### 服务端设置
+
+在 `entry-server.ts` 中:
+
+```typescript
+import { createSSRContext } from './compose/useFetch'
+
+export async function render(url: string) {
+ const { app } = createApp()
+
+ // 创建 SSR 上下文
+ const ssrContext = createSSRContext()
+ app.config.globalProperties.$ssrContext = ssrContext
+
+ const html = await renderToString(app)
+
+ // 将数据序列化到 HTML
+ const ssrData = JSON.stringify(Array.from(ssrContext.cache?.entries() || []))
+ const head = `
+
+ `
+
+ return { html, head }
+}
+```
+
+### 客户端设置
+
+在 `entry-client.ts` 中:
+
+```typescript
+import { hydrateSSRContext, clearSSRContext } from './compose/useFetch'
+
+// 水合 SSR 数据
+if (typeof window !== 'undefined' && window.__SSR_CONTEXT__) {
+ hydrateSSRContext(window.__SSR_CONTEXT__)
+}
+
+app.mount('#app')
+
+// 水合完成后清理
+clearSSRContext()
+```
+
+## 高级用法
+
+### 动态 URL
+
+```typescript
+const userId = ref(1)
+const { data } = useFetch(() => `/api/users/${userId.value}`)
+```
+
+### 条件请求
+
+```typescript
+const shouldFetch = ref(false)
+const { data } = useFetch(
+ () => shouldFetch.value ? '/api/data' : null,
+ { server: false } // 禁用服务端预取
+)
+```
+
+### 错误处理
+
+```typescript
+const { data, error, pending } = useFetch('/api/data', {
+ onError: (err) => {
+ // 自定义错误处理
+ console.error('请求失败:', err)
+ // 可以显示用户友好的错误消息
+ }
+})
+```
+
+### 数据转换
+
+```typescript
+const { data } = useFetch('/api/users', {
+ transform: (users) => users.map(user => ({
+ id: user.id,
+ name: user.name,
+ email: user.email
+ }))
+})
+```
+
+## 注意事项
+
+1. **缓存键**: 确保为不同的请求使用唯一的缓存键
+2. **服务端预取**: 只在需要 SEO 或首屏性能的场景下启用
+3. **错误处理**: 始终提供错误处理逻辑
+4. **内存管理**: 在 SPA 模式下注意清理不需要的缓存
+
+## 示例
+
+查看 `src/components/DataFetch.vue` 获取完整的使用示例。
diff --git a/src/compose/cookieUtils.ts b/src/compose/cookieUtils.ts
new file mode 100644
index 0000000..8667c89
--- /dev/null
+++ b/src/compose/cookieUtils.ts
@@ -0,0 +1,65 @@
+export type CookieOptions = {
+ path?: string
+ domain?: string
+ expires?: Date | string | number
+ maxAge?: number
+ secure?: boolean
+ httpOnly?: boolean
+ sameSite?: 'lax' | 'strict' | 'none'
+}
+
+export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
+ const enc = encodeURIComponent
+ let cookie = `${name}=${enc(value)}`
+ if (options.maxAge != null) cookie += `; Max-Age=${Math.floor(options.maxAge)}`
+ if (options.expires != null) {
+ const date = typeof options.expires === 'number' ? new Date(options.expires) : new Date(options.expires)
+ cookie += `; Expires=${date.toUTCString()}`
+ }
+ if (options.domain) cookie += `; Domain=${options.domain}`
+ if (options.path) cookie += `; Path=${options.path}`
+ if (options.secure) cookie += `; Secure`
+ if (options.httpOnly) cookie += `; HttpOnly`
+ if (options.sameSite) cookie += `; SameSite=${options.sameSite === 'none' ? 'None' : options.sameSite === 'lax' ? 'Lax' : 'Strict'}`
+ return cookie
+}
+
+export function parseCookieHeader(header: string | undefined): Record {
+ const raw = header || ''
+ const out: Record = {}
+ raw.split(';').map(s => s.trim()).filter(Boolean).forEach(kv => {
+ const idx = kv.indexOf('=')
+ const k = idx >= 0 ? kv.slice(0, idx) : kv
+ const v = idx >= 0 ? decodeURIComponent(kv.slice(idx + 1)) : ''
+ out[k] = v
+ })
+ return out
+}
+
+export function parseDocumentCookies(): Record {
+ if (typeof document === 'undefined') return {}
+ return parseCookieHeader(document.cookie)
+}
+
+
+/**
+// server 侧中间件
+import { parseCookieHeader, serializeCookie } from './src/compose/cookieUtils'
+
+app.use(async (ctx, next) => {
+ const cookies = parseCookieHeader(ctx.request.headers.cookie as string)
+
+ // 读取
+ const token = cookies['demo_token']
+
+ // 写入(HttpOnly 更安全)
+ if (!token) {
+ const setItem = serializeCookie('demo_token', 'from-mw', {
+ httpOnly: true, path: '/', sameSite: 'lax'
+ })
+ ctx.set('Set-Cookie', [setItem])
+ }
+
+ await next()
+})
+ */
\ No newline at end of file
diff --git a/src/compose/ssrContext.ts b/src/compose/ssrContext.ts
new file mode 100644
index 0000000..0e8703a
--- /dev/null
+++ b/src/compose/ssrContext.ts
@@ -0,0 +1,41 @@
+// SSR 上下文与 cookie 管理(与业务无关的通用模块)
+
+export interface SSRContext {
+ cache?: Map
+ cookies?: Record
+ setCookies?: string[]
+ [key: string]: any
+}
+
+export function createSSRContext(): SSRContext {
+ return {
+ cache: new Map(),
+ cookies: {},
+ setCookies: []
+ }
+}
+
+export function hydrateSSRContext(context: SSRContext): void {
+ if (typeof window !== 'undefined') {
+ if (context.cache && Array.isArray(context.cache)) {
+ context.cache = new Map(context.cache)
+ }
+ ;(window as any).__SSR_CONTEXT__ = context
+ }
+}
+
+export function clearSSRContext(): void {
+ if (typeof window !== 'undefined') {
+ delete (window as any).__SSR_CONTEXT__
+ }
+}
+
+// 通用获取 SSR 上下文(客户端从 window,服务端从 app 实例)
+export function resolveSSRContext(instance?: any): SSRContext | null {
+ if (typeof window !== 'undefined') {
+ return (window as any).__SSR_CONTEXT__ || null
+ }
+ return instance?.appContext?.config?.globalProperties?.$ssrContext || null
+}
+
+
diff --git a/src/compose/useCookie.ts b/src/compose/useCookie.ts
new file mode 100644
index 0000000..1c1bf2e
--- /dev/null
+++ b/src/compose/useCookie.ts
@@ -0,0 +1,43 @@
+import { getCurrentInstance } from 'vue'
+import { serializeCookie, parseDocumentCookies } from './cookieUtils'
+import type { CookieOptions } from './cookieUtils'
+import { resolveSSRContext } from './ssrContext'
+
+export function useCookie(name: string, options: CookieOptions = {}) {
+ const instance = getCurrentInstance()
+
+ const getSSRContext = () => resolveSSRContext(instance)
+
+ const getAll = (): Record => {
+ const ssr = getSSRContext()
+ if (ssr && ssr.cookies) return ssr.cookies as Record
+ return parseDocumentCookies()
+ }
+
+ const get = (): string | undefined => {
+ const all = getAll()
+ return all[name]
+ }
+
+ const set = (value: string, opt: CookieOptions = {}) => {
+ const o = { path: '/', ...options, ...opt }
+ const str = serializeCookie(name, value, o)
+ const ssr = getSSRContext()
+ if (ssr) {
+ ssr.cookies = ssr.cookies || {}
+ ssr.cookies[name] = value
+ ssr.setCookies = ssr.setCookies || []
+ ssr.setCookies.push(str)
+ } else if (typeof document !== 'undefined') {
+ document.cookie = str
+ }
+ }
+
+ const remove = (opt: CookieOptions = {}) => {
+ set('', { ...opt, maxAge: 0, expires: new Date(0) })
+ }
+
+ return { get, set, remove }
+}
+
+
diff --git a/src/compose/useFetch.ts b/src/compose/useFetch.ts
new file mode 100644
index 0000000..3b5320b
--- /dev/null
+++ b/src/compose/useFetch.ts
@@ -0,0 +1,249 @@
+import { ref, onMounted, onServerPrefetch, Ref } from 'vue'
+import { getCurrentInstance } from 'vue'
+import type { SSRContext } from './ssrContext'
+import { resolveSSRContext } from './ssrContext'
+
+// 全局数据缓存,用于 SSR 数据共享
+const globalCache = new Map()
+
+// SSR 上下文类型从 ssrContext.ts 引入
+
+// useFetch 的配置选项
+interface UseFetchOptions {
+ key?: string
+ server?: boolean
+ default?: () => any
+ transform?: (data: any) => any
+ onError?: (error: Error) => void
+}
+
+// useFetch 返回值类型
+interface UseFetchReturn {
+ data: Ref
+ error: Ref
+ pending: Ref
+ refresh: () => Promise
+ execute: () => Promise
+}
+
+/**
+ * SSR 兼容的 useFetch hook
+ * 支持服务端预取和客户端水合
+ */
+export function useFetch(
+ url: string | (() => string) | (() => Promise),
+ options: UseFetchOptions = {}
+): UseFetchReturn {
+ const {
+ key,
+ server = true,
+ default: defaultValue,
+ transform,
+ onError
+ } = options
+
+ // 生成缓存键
+ const cacheKey = key || (typeof url === 'string' ? url : `fetch-${Date.now()}`)
+
+ // 响应式状态
+ const data = ref(null)
+ const error = ref(null)
+ const pending = ref(false)
+
+ // 获取当前组件实例
+ const instance = getCurrentInstance()
+
+ // 获取 SSR 上下文
+ const getSSRContext = (): SSRContext | null => resolveSSRContext(instance)
+
+ // 获取缓存
+ const getCache = () => {
+ const ssrContext = getSSRContext()
+ return ssrContext?.cache || globalCache
+ }
+
+ // 设置缓存
+ const setCache = (key: string, value: any) => {
+ const cache = getCache()
+ cache.set(key, value)
+ }
+
+ // 获取缓存数据
+ const getCachedData = () => {
+ const cache = getCache()
+ return cache.get(cacheKey)
+ }
+
+ // 执行 fetch 请求
+ const execute = async (): Promise => {
+ try {
+ pending.value = true
+ error.value = null
+
+ // 获取 URL
+ const fetchUrl = typeof url === 'function' ? await url() : url
+
+ // 仅在服务端注入 Cookie,客户端浏览器会自动携带
+ let requestInit: RequestInit | undefined
+ if (typeof window === 'undefined') {
+ const ssrContext = getSSRContext()
+ const cookieHeader = ssrContext?.cookies
+ ? Object.entries(ssrContext.cookies)
+ .filter(([k, v]) => k && v != null)
+ .map(([k, v]) => `${k}=${String(v)}`)
+ .join('; ')
+ : undefined
+ if (cookieHeader) {
+ requestInit = { headers: { Cookie: cookieHeader } }
+ }
+ }
+
+ // 执行请求
+ const response = await fetch(fetchUrl, requestInit)
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
+ }
+
+ let result = await response.json()
+
+ // 应用转换函数
+ if (transform) {
+ result = transform(result)
+ }
+
+ data.value = result
+ setCache(cacheKey, result)
+
+ // 收集服务端返回的 Set-Cookie,回传到最终响应头
+ if (typeof window === 'undefined') {
+ const ssrContext = getSSRContext()
+ if (ssrContext) {
+ const setCookieValues: string[] = []
+ const anyHeaders: any = response.headers as any
+ // undici 扩展:getSetCookie()
+ if (typeof anyHeaders?.getSetCookie === 'function') {
+ try {
+ const arr = anyHeaders.getSetCookie()
+ if (Array.isArray(arr)) setCookieValues.push(...arr)
+ } catch {}
+ }
+ // node-fetch/raw headers API
+ if (typeof anyHeaders?.raw === 'function') {
+ try {
+ const raw = anyHeaders.raw()
+ const arr = raw?.['set-cookie']
+ if (Array.isArray(arr)) setCookieValues.push(...arr)
+ } catch {}
+ }
+ // 兜底:单值
+ const single = response.headers.get('set-cookie')
+ if (single) setCookieValues.push(single)
+
+ if (setCookieValues.length) {
+ if (!Array.isArray(ssrContext.setCookies)) ssrContext.setCookies = []
+ ssrContext.setCookies.push(...setCookieValues)
+ }
+ }
+ }
+
+ } catch (err) {
+ const fetchError = err instanceof Error ? err : new Error(String(err))
+ error.value = fetchError
+
+ if (onError) {
+ onError(fetchError)
+ }
+
+ // 设置默认值
+ if (defaultValue) {
+ data.value = typeof defaultValue === 'function' ? defaultValue() : defaultValue
+ }
+ } finally {
+ pending.value = false
+ }
+ }
+
+ // 刷新数据
+ const refresh = async (): Promise => {
+ // 清除缓存
+ const cache = getCache()
+ cache.delete(cacheKey)
+ await execute()
+ }
+
+ // 服务端预取
+ if (server && typeof window === 'undefined') {
+ onServerPrefetch(async () => {
+ // 检查是否已有缓存数据
+ const cachedData = getCachedData()
+ if (cachedData !== undefined) {
+ data.value = cachedData
+ return
+ }
+
+ // 执行预取
+ await execute()
+ })
+ }
+
+ // 立即检查缓存数据(服务端和客户端都需要)
+ const cachedData = getCachedData()
+ if (cachedData !== undefined) {
+ data.value = cachedData
+ console.log(`[useFetch] 从缓存加载数据: ${cacheKey}`, cachedData)
+ } else {
+ console.log(`[useFetch] 缓存中无数据: ${cacheKey}`)
+ }
+
+ // 客户端水合
+ if (typeof window !== 'undefined') {
+ onMounted(async () => {
+ // 如果已经有缓存数据,不需要再次请求
+ if (cachedData !== undefined) {
+ return
+ }
+
+ // 如果没有预取数据,则执行请求
+ await execute()
+ })
+ }
+
+ return {
+ data: data as Ref,
+ error: error as Ref,
+ pending: pending as Ref,
+ refresh,
+ execute
+ }
+}
+
+/**
+ * 创建 SSR 上下文的辅助函数
+ * 在服务端渲染时调用
+ */
+// 删除 createSSRContext,这个职责移动到 ssrContext.ts
+
+/**
+ * 将 SSR 上下文注入到 window 对象
+ * 在客户端水合时调用
+ */
+export function hydrateSSRContext(context: SSRContext): void {
+ if (typeof window !== 'undefined') {
+ // 确保 Map 对象正确重建
+ if (context.cache && Array.isArray(context.cache)) {
+ context.cache = new Map(context.cache)
+ }
+ (window as any).__SSR_CONTEXT__ = context
+ }
+}
+
+/**
+ * 清除 SSR 上下文
+ * 在客户端水合完成后调用
+ */
+export function clearSSRContext(): void {
+ if (typeof window !== 'undefined') {
+ delete (window as any).__SSR_CONTEXT__
+ }
+}
diff --git a/src/entry-client.ts b/src/entry-client.ts
new file mode 100644
index 0000000..94ec873
--- /dev/null
+++ b/src/entry-client.ts
@@ -0,0 +1,21 @@
+import './style.css'
+import { createApp } from "./main"
+import { hydrateSSRContext, clearSSRContext } from './compose/ssrContext'
+
+// 水合 SSR 上下文(如果存在)
+let ssrContext = null
+if (typeof window !== 'undefined' && (window as any).__SSR_CONTEXT__) {
+ ssrContext = (window as any).__SSR_CONTEXT__
+ console.log('[Client] 水合 SSR 上下文:', ssrContext)
+ hydrateSSRContext(ssrContext)
+} else {
+ console.log('[Client] 未找到 SSR 上下文')
+}
+
+// 使用相同的 SSR 上下文创建应用
+const { app } = createApp(ssrContext)
+
+app.mount('#app')
+
+// 水合完成后清除 SSR 上下文
+clearSSRContext()
diff --git a/src/entry-server.ts b/src/entry-server.ts
new file mode 100644
index 0000000..8eefd4d
--- /dev/null
+++ b/src/entry-server.ts
@@ -0,0 +1,38 @@
+import { renderToString } from 'vue/server-renderer'
+import { createApp } from './main'
+import { createSSRContext } from './compose/ssrContext'
+
+export async function render(_url: string, init?: { cookies?: Record }) {
+ // 创建 SSR 上下文,包含数据缓存与 cookies
+ const ssrContext = createSSRContext()
+ if (init?.cookies) {
+ ssrContext.cookies = { ...init.cookies }
+ }
+
+ // 将 SSR 上下文传递给应用创建函数
+ const { app } = createApp(ssrContext)
+
+ // 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 = { cache: ssrContext.cache }
+ const html = await renderToString(app, ctx)
+
+ // 将 SSR 上下文数据序列化到 HTML 中
+ // 使用更安全的方式序列化 Map
+ const cacheEntries = ssrContext.cache ? Array.from(ssrContext.cache.entries()) : []
+ const ssrData = JSON.stringify(cacheEntries)
+ const cookieInit = JSON.stringify(ssrContext.cookies || {})
+ console.log('[SSR] 序列化缓存数据:', cacheEntries)
+ const head = `
+
+ `
+
+ return { html, head, setCookies: ssrContext.setCookies || [] }
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..3284bfc
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,16 @@
+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(ssrContext?: any) {
+ const app = createSSRApp(App)
+
+ // 如果有 SSR 上下文,注入到应用中
+ if (ssrContext) {
+ app.config.globalProperties.$ssrContext = ssrContext
+ }
+
+ return { app }
+}
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..f691315
--- /dev/null
+++ b/src/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/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..323c78a
--- /dev/null
+++ b/src/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/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..8a4664f
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "esnext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "preserve",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..033c9c1
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "es2023",
+ "lib": [
+ "ES2023"
+ ],
+ "module": "esnext",
+ "skipLibCheck": true,
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "emitDeclarationOnly": true,
+ "moduleDetection": "force",
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": [
+ "vite.config.ts"
+ ]
+}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..981be2b
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+// https://vite.dev/config/
+export default defineConfig({
+ base: './',
+ plugins: [vue()],
+})