From 04e655166f55d6525c3feb633daf60970b25cb08 Mon Sep 17 00:00:00 2001 From: dash <1549469775@qq.com> Date: Thu, 2 Oct 2025 02:29:03 +0800 Subject: [PATCH] feat: mono init --- README.md | 4 +- bun.lockb | Bin 78662 -> 93241 bytes components.d.ts | 17 -- index.html | 17 -- internal/helper/package.json | 8 + internal/helper/src/cookie.ts | 67 ++++++ internal/helper/src/env.ts | 11 + internal/helper/src/path.ts | 23 ++ internal/x/components/ClientOnly.vue | 62 +++++ internal/x/composables/README.md | 170 ++++++++++++++ internal/x/composables/cookieUtils.ts | 65 ++++++ internal/x/composables/ssrContext.ts | 51 +++++ internal/x/composables/useCookie.ts | 43 ++++ internal/x/composables/useFetch.ts | 219 ++++++++++++++++++ internal/x/package.json | 5 + package.json | 31 ++- packages/client/.gitignore | 34 +++ packages/client/README.md | 15 ++ packages/client/auto-imports.d.ts | 203 +++++++++++++++++ packages/client/components.d.ts | 20 ++ packages/client/index.html | 17 ++ packages/client/package.json | 27 +++ packages/client/public/vite.svg | 1 + packages/client/src/App.vue | 35 +++ packages/client/src/assets/vue.svg | 1 + .../src/components/AiDemo/_/VueNodeRenderer.vue | 56 +++++ packages/client/src/components/AiDemo/_/sseData.ts | 31 +++ packages/client/src/components/AiDemo/index.vue | 158 +++++++++++++ packages/client/src/components/CookieDemo.vue | 54 +++++ packages/client/src/components/DataFetch.vue | 118 ++++++++++ packages/client/src/components/HelloWorld.vue | 38 ++++ packages/client/src/components/SimpleTest.vue | 45 ++++ packages/client/src/composables/useGlobal/index.ts | 8 + packages/client/src/entry-client.ts | 29 +++ packages/client/src/entry-server.ts | 93 ++++++++ packages/client/src/main.ts | 22 ++ packages/client/src/pages/about/index.vue | 11 + packages/client/src/pages/home/index.vue | 19 ++ packages/client/src/pages/not-found/index.vue | 5 + packages/client/src/router/index.ts | 16 ++ packages/client/src/store/auth.ts | 20 ++ packages/client/src/vite-env.d.ts | 7 + packages/client/src/vue.d.ts | 10 + packages/client/tsconfig.json | 26 +++ packages/client/tsconfig.node.json | 27 +++ packages/client/vite.config.ts | 29 +++ packages/core/package.json | 9 + packages/core/src/SsrMiddleWare.ts | 100 +++++++++ packages/server/package.json | 20 ++ packages/server/src/api/main.ts | 61 +++++ packages/server/src/app.ts | 9 + packages/server/src/booststap.ts | 20 ++ packages/server/tsconfig.json | 31 +++ public/vite.svg | 1 - server.ts | 84 ------- server/app.ts | 9 - server/main.ts | 60 ----- src/App.vue | 40 ---- src/assets/vue.svg | 1 - src/components/ClientOnly.tsx | 43 ---- src/components/CookieDemo.vue | 54 ----- src/components/DataFetch.vue | 121 ---------- src/components/HelloWorld.vue | 38 ---- src/components/SimpleTest.vue | 45 ---- src/compose/README.md | 170 -------------- src/compose/cookieUtils.ts | 65 ------ src/compose/ssrContext.ts | 41 ---- src/compose/useCookie.ts | 43 ---- src/compose/useFetch.ts | 249 --------------------- src/entry-client.ts | 21 -- src/entry-server.ts | 38 ---- src/main.ts | 16 -- src/style.css | 79 ------- src/vite-env.d.ts | 7 - src/vue.d.ts | 10 - tsconfig.json | 26 --- tsconfig.node.json | 27 --- vite.config.ts | 13 -- 78 files changed, 2167 insertions(+), 1352 deletions(-) delete mode 100644 components.d.ts delete mode 100644 index.html create mode 100644 internal/helper/package.json create mode 100644 internal/helper/src/cookie.ts create mode 100644 internal/helper/src/env.ts create mode 100644 internal/helper/src/path.ts create mode 100644 internal/x/components/ClientOnly.vue create mode 100644 internal/x/composables/README.md create mode 100644 internal/x/composables/cookieUtils.ts create mode 100644 internal/x/composables/ssrContext.ts create mode 100644 internal/x/composables/useCookie.ts create mode 100644 internal/x/composables/useFetch.ts create mode 100644 internal/x/package.json create mode 100644 packages/client/.gitignore create mode 100644 packages/client/README.md create mode 100644 packages/client/auto-imports.d.ts create mode 100644 packages/client/components.d.ts create mode 100644 packages/client/index.html create mode 100644 packages/client/package.json create mode 100644 packages/client/public/vite.svg create mode 100644 packages/client/src/App.vue create mode 100644 packages/client/src/assets/vue.svg create mode 100644 packages/client/src/components/AiDemo/_/VueNodeRenderer.vue create mode 100644 packages/client/src/components/AiDemo/_/sseData.ts create mode 100644 packages/client/src/components/AiDemo/index.vue create mode 100644 packages/client/src/components/CookieDemo.vue create mode 100644 packages/client/src/components/DataFetch.vue create mode 100644 packages/client/src/components/HelloWorld.vue create mode 100644 packages/client/src/components/SimpleTest.vue create mode 100644 packages/client/src/composables/useGlobal/index.ts create mode 100644 packages/client/src/entry-client.ts create mode 100644 packages/client/src/entry-server.ts create mode 100644 packages/client/src/main.ts create mode 100644 packages/client/src/pages/about/index.vue create mode 100644 packages/client/src/pages/home/index.vue create mode 100644 packages/client/src/pages/not-found/index.vue create mode 100644 packages/client/src/router/index.ts create mode 100644 packages/client/src/store/auth.ts create mode 100644 packages/client/src/vite-env.d.ts create mode 100644 packages/client/src/vue.d.ts create mode 100644 packages/client/tsconfig.json create mode 100644 packages/client/tsconfig.node.json create mode 100644 packages/client/vite.config.ts create mode 100644 packages/core/package.json create mode 100644 packages/core/src/SsrMiddleWare.ts create mode 100644 packages/server/package.json create mode 100644 packages/server/src/api/main.ts create mode 100644 packages/server/src/app.ts create mode 100644 packages/server/src/booststap.ts create mode 100644 packages/server/tsconfig.json delete mode 100644 public/vite.svg delete mode 100644 server.ts delete mode 100644 server/app.ts delete mode 100644 server/main.ts delete mode 100644 src/App.vue delete mode 100644 src/assets/vue.svg delete mode 100644 src/components/ClientOnly.tsx delete mode 100644 src/components/CookieDemo.vue delete mode 100644 src/components/DataFetch.vue delete mode 100644 src/components/HelloWorld.vue delete mode 100644 src/components/SimpleTest.vue delete mode 100644 src/compose/README.md delete mode 100644 src/compose/cookieUtils.ts delete mode 100644 src/compose/ssrContext.ts delete mode 100644 src/compose/useCookie.ts delete mode 100644 src/compose/useFetch.ts delete mode 100644 src/entry-client.ts delete mode 100644 src/entry-server.ts delete mode 100644 src/main.ts delete mode 100644 src/style.css delete mode 100644 src/vite-env.d.ts delete mode 100644 src/vue.d.ts delete mode 100644 tsconfig.json delete mode 100644 tsconfig.node.json delete mode 100644 vite.config.ts diff --git a/README.md b/README.md index 9b1e070..6d83d19 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# 基于koa实现的简易ssr \ No newline at end of file +# 基于koa实现的简易ssr + +- https://segmentfault.com/a/1190000042389086 \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 456dbfa15c07e11298c77f67a87298da6f11bf00..71e79af02a5fe072453a5e4387ba7a51e70a1938 100644 GIT binary patch delta 22842 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 Q$W6E6obpSmxH{bb0w#1*AOHXW delta 14640 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 DY3RGw diff --git a/components.d.ts b/components.d.ts deleted file mode 100644 index eb6c0af..0000000 --- a/components.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* eslint-disable */ -// @ts-nocheck -// Generated by unplugin-vue-components -// Read more: https://github.com/vuejs/core/pull/3399 -// biome-ignore lint: disable -export {} - -/* prettier-ignore */ -declare module 'vue' { - export interface GlobalComponents { - ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default'] - CookieDemo: typeof import('./src/components/CookieDemo.vue')['default'] - DataFetch: typeof import('./src/components/DataFetch.vue')['default'] - HelloWorld: typeof import('./src/components/HelloWorld.vue')['default'] - SimpleTest: typeof import('./src/components/SimpleTest.vue')['default'] - } -} diff --git a/index.html b/index.html deleted file mode 100644 index 88db749..0000000 --- a/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - Vite + Vue + TS - - - - -
- - - - \ No newline at end of file diff --git a/internal/helper/package.json b/internal/helper/package.json new file mode 100644 index 0000000..c16c20e --- /dev/null +++ b/internal/helper/package.json @@ -0,0 +1,8 @@ +{ + "name": "helper", + "exports": { + "./*": { + "import": "./src/*.ts" + } + } +} diff --git a/internal/helper/src/cookie.ts b/internal/helper/src/cookie.ts new file mode 100644 index 0000000..29faaa3 --- /dev/null +++ b/internal/helper/src/cookie.ts @@ -0,0 +1,67 @@ +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 { + // @ts-ignore + if (typeof document === 'undefined') return {} + // @ts-ignore + 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/internal/helper/src/env.ts b/internal/helper/src/env.ts new file mode 100644 index 0000000..a208659 --- /dev/null +++ b/internal/helper/src/env.ts @@ -0,0 +1,11 @@ + +const isProduction = process.env.NODE_ENV === 'production' +const port = process.env.PORT || 5173 +const base = process.env.BASE || '/' + + +export const Env = { + isProduction, + port: Number(port), + base, +} \ No newline at end of file diff --git a/internal/helper/src/path.ts b/internal/helper/src/path.ts new file mode 100644 index 0000000..41e8f4c --- /dev/null +++ b/internal/helper/src/path.ts @@ -0,0 +1,23 @@ +import path from "node:path" +import fs from "node:fs/promises" + +const isProduction = process.env.NODE_ENV === 'production' + +export function getPathByRoot(...argus: string[]) { + return path.resolve(import.meta.dir, '../../..', ...argus) +} + +const templateHtml = isProduction + ? await fs.readFile(getPathByRoot('packages', 'client/index.html'), 'utf-8') + : '' + +export function getDevPathFromClient(...argus: string[]) { + return getPathByRoot('packages', 'client', ...argus) +} +export function getDevPathFromServer(...argus: string[]) { + return getPathByRoot('packages', 'server', ...argus) +} + +export function getProdPath(...argus: string[]) { + return getPathByRoot('dist', ...argus) +} diff --git a/internal/x/components/ClientOnly.vue b/internal/x/components/ClientOnly.vue new file mode 100644 index 0000000..479fe11 --- /dev/null +++ b/internal/x/components/ClientOnly.vue @@ -0,0 +1,62 @@ + diff --git a/internal/x/composables/README.md b/internal/x/composables/README.md new file mode 100644 index 0000000..442c39f --- /dev/null +++ b/internal/x/composables/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/internal/x/composables/cookieUtils.ts b/internal/x/composables/cookieUtils.ts new file mode 100644 index 0000000..8667c89 --- /dev/null +++ b/internal/x/composables/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/internal/x/composables/ssrContext.ts b/internal/x/composables/ssrContext.ts new file mode 100644 index 0000000..60ddc25 --- /dev/null +++ b/internal/x/composables/ssrContext.ts @@ -0,0 +1,51 @@ +// SSR 上下文与 cookie 管理(与业务无关的通用模块) + +export interface SSRContext { + cache?: Map + cookies?: Record + piniaState?: object + setCookies?: string[] + [key: string]: any +} + +export function createSSRContext(): SSRContext { + return { + cache: new Map(), + piniaState: {}, + cookies: {}, + setCookies: [] + } +} + +/** + * 将 SSR 上下文注入到 window 对象 + * 在客户端水合时调用 + */ +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 + } +} + +/** + * 清除 SSR 上下文 + * 在客户端水合完成后调用 + */ +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/internal/x/composables/useCookie.ts b/internal/x/composables/useCookie.ts new file mode 100644 index 0000000..1c1bf2e --- /dev/null +++ b/internal/x/composables/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/internal/x/composables/useFetch.ts b/internal/x/composables/useFetch.ts new file mode 100644 index 0000000..3f3150c --- /dev/null +++ b/internal/x/composables/useFetch.ts @@ -0,0 +1,219 @@ +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) => T + 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 + } +} diff --git a/internal/x/package.json b/internal/x/package.json new file mode 100644 index 0000000..0e7115f --- /dev/null +++ b/internal/x/package.json @@ -0,0 +1,5 @@ +{ + "name": "x", + "main": "index.ts", + "type": "module" +} diff --git a/package.json b/package.json index abf2fec..61f6249 100644 --- a/package.json +++ b/package.json @@ -2,33 +2,32 @@ "name": "koa-ssr", "type": "module", "workspaces": [ - "packages/*" + "packages/*", + "internal/*" ], "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" + "dev": "bun run --hot packages/server/src/booststap.ts", + "preview": "cross-env NODE_ENV=production bun run packages/server/src/booststap.ts", + "tsc:booststap": "tsc packages/booststap/src/server.ts --outDir dist --module es2022 --target es2022 --lib es2022,dom --moduleResolution bundler --esModuleInterop --skipLibCheck --forceConsistentCasingInFileNames --noEmit false --incremental false", + "tsc:server": "tsc packages/server/src/**/*.ts --outDir dist/server --module es2022 --target es2022 --lib es2022,dom --moduleResolution bundler --esModuleInterop --skipLibCheck --forceConsistentCasingInFileNames --noEmit false --incremental false" }, "devDependencies": { "@types/bun": "latest", - "@types/koa": "^3.0.0", - "@types/koa-send": "^4.1.6", + "@types/koa-compose": "^3.2.8", + "client": "workspace:*", + "core": "workspace:*", "cross-env": "^10.1.0", + "helper": "workspace:*", + "server": "workspace:*", "unplugin-vue-components": "^29.1.0", - "vue-tsc": "^3.1.0" + "vite-plugin-devtools-json": "^1.0.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" + "koa-compose": "^4.1.0", + "pinia": "^3.0.3", + "vite": "^7.1.7" } } diff --git a/packages/client/.gitignore b/packages/client/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/packages/client/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000..d523fd1 --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,15 @@ +# client + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.2.21. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/packages/client/auto-imports.d.ts b/packages/client/auto-imports.d.ts new file mode 100644 index 0000000..055e7ca --- /dev/null +++ b/packages/client/auto-imports.d.ts @@ -0,0 +1,203 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + const EffectScope: typeof import('vue')['EffectScope'] + const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] + const clearSSRContext: typeof import('../../internal/x/composables/ssrContext')['clearSSRContext'] + const computed: typeof import('vue')['computed'] + const createApp: typeof import('vue')['createApp'] + const createPinia: typeof import('pinia')['createPinia'] + const createSSRContext: typeof import('../../internal/x/composables/ssrContext')['createSSRContext'] + const customRef: typeof import('vue')['customRef'] + const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] + const defineComponent: typeof import('vue')['defineComponent'] + const defineStore: typeof import('pinia')['defineStore'] + const effectScope: typeof import('vue')['effectScope'] + const getActivePinia: typeof import('pinia')['getActivePinia'] + const getCurrentInstance: typeof import('vue')['getCurrentInstance'] + const getCurrentScope: typeof import('vue')['getCurrentScope'] + const getCurrentWatcher: typeof import('vue')['getCurrentWatcher'] + const h: typeof import('vue')['h'] + const hydrateSSRContext: typeof import('../../internal/x/composables/ssrContext')['hydrateSSRContext'] + const inject: typeof import('vue')['inject'] + const isProxy: typeof import('vue')['isProxy'] + const isReactive: typeof import('vue')['isReactive'] + const isReadonly: typeof import('vue')['isReadonly'] + const isRef: typeof import('vue')['isRef'] + const isShallow: typeof import('vue')['isShallow'] + const mapActions: typeof import('pinia')['mapActions'] + const mapGetters: typeof import('pinia')['mapGetters'] + const mapState: typeof import('pinia')['mapState'] + const mapStores: typeof import('pinia')['mapStores'] + const mapWritableState: typeof import('pinia')['mapWritableState'] + const markRaw: typeof import('vue')['markRaw'] + const nextTick: typeof import('vue')['nextTick'] + const onActivated: typeof import('vue')['onActivated'] + const onBeforeMount: typeof import('vue')['onBeforeMount'] + const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave'] + const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate'] + const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] + const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] + const onDeactivated: typeof import('vue')['onDeactivated'] + const onErrorCaptured: typeof import('vue')['onErrorCaptured'] + const onMounted: typeof import('vue')['onMounted'] + const onRenderTracked: typeof import('vue')['onRenderTracked'] + const onRenderTriggered: typeof import('vue')['onRenderTriggered'] + const onScopeDispose: typeof import('vue')['onScopeDispose'] + const onServerPrefetch: typeof import('vue')['onServerPrefetch'] + const onUnmounted: typeof import('vue')['onUnmounted'] + const onUpdated: typeof import('vue')['onUpdated'] + const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] + const parseCookieHeader: typeof import('../../internal/x/composables/cookieUtils')['parseCookieHeader'] + const parseDocumentCookies: typeof import('../../internal/x/composables/cookieUtils')['parseDocumentCookies'] + const provide: typeof import('vue')['provide'] + const reactive: typeof import('vue')['reactive'] + const readonly: typeof import('vue')['readonly'] + const ref: typeof import('vue')['ref'] + const render: typeof import('../../internal/x/composables/README.md')['render'] + const resolveComponent: typeof import('vue')['resolveComponent'] + const resolveSSRContext: typeof import('../../internal/x/composables/ssrContext')['resolveSSRContext'] + const serializeCookie: typeof import('../../internal/x/composables/cookieUtils')['serializeCookie'] + const setActivePinia: typeof import('pinia')['setActivePinia'] + const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] + const shallowReactive: typeof import('vue')['shallowReactive'] + const shallowReadonly: typeof import('vue')['shallowReadonly'] + const shallowRef: typeof import('vue')['shallowRef'] + const storeToRefs: typeof import('pinia')['storeToRefs'] + const toRaw: typeof import('vue')['toRaw'] + const toRef: typeof import('vue')['toRef'] + const toRefs: typeof import('vue')['toRefs'] + const toValue: typeof import('vue')['toValue'] + const triggerRef: typeof import('vue')['triggerRef'] + const unref: typeof import('vue')['unref'] + const useAttrs: typeof import('vue')['useAttrs'] + const useAuthStore: typeof import('./src/store/auth')['useAuthStore'] + const useCookie: typeof import('../../internal/x/composables/useCookie')['useCookie'] + const useCssModule: typeof import('vue')['useCssModule'] + const useCssVars: typeof import('vue')['useCssVars'] + const useFetch: typeof import('../../internal/x/composables/useFetch')['useFetch'] + const useGlobal: typeof import('./src/composables/useGlobal/index')['useGlobal'] + const useId: typeof import('vue')['useId'] + const useLink: typeof import('vue-router')['useLink'] + const useModel: typeof import('vue')['useModel'] + const useRoute: typeof import('vue-router')['useRoute'] + const useRouter: typeof import('vue-router')['useRouter'] + const useSlots: typeof import('vue')['useSlots'] + const useTemplateRef: typeof import('vue')['useTemplateRef'] + const watch: typeof import('vue')['watch'] + const watchEffect: typeof import('vue')['watchEffect'] + const watchPostEffect: typeof import('vue')['watchPostEffect'] + const watchSyncEffect: typeof import('vue')['watchSyncEffect'] +} +// for type re-export +declare global { + // @ts-ignore + export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + import('vue') + // @ts-ignore + export type { CookieOptions } from '../../internal/x/composables/cookieUtils' + import('../../internal/x/composables/cookieUtils') + // @ts-ignore + export type { SSRContext } from '../../internal/x/composables/ssrContext' + import('../../internal/x/composables/ssrContext') +} + +// for vue template auto import +import { UnwrapRef } from 'vue' +declare module 'vue' { + interface GlobalComponents {} + interface ComponentCustomProperties { + readonly EffectScope: UnwrapRef + readonly acceptHMRUpdate: UnwrapRef + readonly clearSSRContext: UnwrapRef + readonly computed: UnwrapRef + readonly createApp: UnwrapRef + readonly createPinia: UnwrapRef + readonly createSSRContext: UnwrapRef + readonly customRef: UnwrapRef + readonly defineAsyncComponent: UnwrapRef + readonly defineComponent: UnwrapRef + readonly defineStore: UnwrapRef + readonly effectScope: UnwrapRef + readonly getActivePinia: UnwrapRef + readonly getCurrentInstance: UnwrapRef + readonly getCurrentScope: UnwrapRef + readonly getCurrentWatcher: UnwrapRef + readonly h: UnwrapRef + readonly hydrateSSRContext: UnwrapRef + readonly inject: UnwrapRef + readonly isProxy: UnwrapRef + readonly isReactive: UnwrapRef + readonly isReadonly: UnwrapRef + readonly isRef: UnwrapRef + readonly isShallow: UnwrapRef + readonly mapActions: UnwrapRef + readonly mapGetters: UnwrapRef + readonly mapState: UnwrapRef + readonly mapStores: UnwrapRef + readonly mapWritableState: UnwrapRef + readonly markRaw: UnwrapRef + readonly nextTick: UnwrapRef + readonly onActivated: UnwrapRef + readonly onBeforeMount: UnwrapRef + readonly onBeforeRouteLeave: UnwrapRef + readonly onBeforeRouteUpdate: UnwrapRef + readonly onBeforeUnmount: UnwrapRef + readonly onBeforeUpdate: UnwrapRef + readonly onDeactivated: UnwrapRef + readonly onErrorCaptured: UnwrapRef + readonly onMounted: UnwrapRef + readonly onRenderTracked: UnwrapRef + readonly onRenderTriggered: UnwrapRef + readonly onScopeDispose: UnwrapRef + readonly onServerPrefetch: UnwrapRef + readonly onUnmounted: UnwrapRef + readonly onUpdated: UnwrapRef + readonly onWatcherCleanup: UnwrapRef + readonly parseCookieHeader: UnwrapRef + readonly parseDocumentCookies: UnwrapRef + readonly provide: UnwrapRef + readonly reactive: UnwrapRef + readonly readonly: UnwrapRef + readonly ref: UnwrapRef + readonly render: UnwrapRef + readonly resolveComponent: UnwrapRef + readonly resolveSSRContext: UnwrapRef + readonly serializeCookie: UnwrapRef + readonly setActivePinia: UnwrapRef + readonly setMapStoreSuffix: UnwrapRef + readonly shallowReactive: UnwrapRef + readonly shallowReadonly: UnwrapRef + readonly shallowRef: UnwrapRef + readonly storeToRefs: UnwrapRef + readonly toRaw: UnwrapRef + readonly toRef: UnwrapRef + readonly toRefs: UnwrapRef + readonly toValue: UnwrapRef + readonly triggerRef: UnwrapRef + readonly unref: UnwrapRef + readonly useAttrs: UnwrapRef + readonly useAuthStore: UnwrapRef + readonly useCookie: UnwrapRef + readonly useCssModule: UnwrapRef + readonly useCssVars: UnwrapRef + readonly useFetch: UnwrapRef + readonly useGlobal: UnwrapRef + readonly useId: UnwrapRef + readonly useLink: UnwrapRef + readonly useModel: UnwrapRef + readonly useRoute: UnwrapRef + readonly useRouter: UnwrapRef + readonly useSlots: UnwrapRef + readonly useTemplateRef: UnwrapRef + readonly watch: UnwrapRef + readonly watchEffect: UnwrapRef + readonly watchPostEffect: UnwrapRef + readonly watchSyncEffect: UnwrapRef + } +} \ No newline at end of file diff --git a/packages/client/components.d.ts b/packages/client/components.d.ts new file mode 100644 index 0000000..2bb3718 --- /dev/null +++ b/packages/client/components.d.ts @@ -0,0 +1,20 @@ +/* eslint-disable */ +// @ts-nocheck +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 +// biome-ignore lint: disable +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + AiDemo: typeof import('./src/components/AiDemo/index.vue')['default'] + ClientOnly: typeof import('./../../internal/x/components/ClientOnly.vue')['default'] + CookieDemo: typeof import('./src/components/CookieDemo.vue')['default'] + DataFetch: typeof import('./src/components/DataFetch.vue')['default'] + HelloWorld: typeof import('./src/components/HelloWorld.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + SimpleTest: typeof import('./src/components/SimpleTest.vue')['default'] + } +} diff --git a/packages/client/index.html b/packages/client/index.html new file mode 100644 index 0000000..0ec77f1 --- /dev/null +++ b/packages/client/index.html @@ -0,0 +1,17 @@ + + + + + + + + Vite + Vue + TS + + + + +
+ + + + \ No newline at end of file diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..40cdbff --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,27 @@ +{ + "name": "client", + "type": "module", + "scripts": { + "build": "bun run build:client && bun run build:server", + "build:client": "vite build --ssrManifest --outDir ../../dist/client --base ./", + "build:server": "vite build --ssr src/entry-server.ts --outDir ../../dist/server", + "check": "vue-tsc" + }, + "devDependencies": { + "unplugin-vue-components": "^29.1.0", + "vue-tsc": "^3.1.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@vitejs/plugin-vue": "^6.0.1", + "dompurify": "^3.2.7", + "htmlparser2": "^10.0.0", + "marked": "^16.3.0", + "unplugin-auto-import": "^20.2.0", + "vue": "^3.5.22", + "vue-router": "^4.5.1", + "x": "workspace:*" + } +} diff --git a/packages/client/public/vite.svg b/packages/client/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/packages/client/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/client/src/App.vue b/packages/client/src/App.vue new file mode 100644 index 0000000..1c0065b --- /dev/null +++ b/packages/client/src/App.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/packages/client/src/assets/vue.svg b/packages/client/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/packages/client/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/client/src/components/AiDemo/_/VueNodeRenderer.vue b/packages/client/src/components/AiDemo/_/VueNodeRenderer.vue new file mode 100644 index 0000000..e716b7a --- /dev/null +++ b/packages/client/src/components/AiDemo/_/VueNodeRenderer.vue @@ -0,0 +1,56 @@ + + + \ No newline at end of file diff --git a/packages/client/src/components/AiDemo/_/sseData.ts b/packages/client/src/components/AiDemo/_/sseData.ts new file mode 100644 index 0000000..5ec3ce3 --- /dev/null +++ b/packages/client/src/components/AiDemo/_/sseData.ts @@ -0,0 +1,31 @@ +export default [ + { event: "message", answer: "## asdas\n" }, + { event: "message", answer: "**asasa**\n" }, + { event: "message", answer: "![啊啊啊](https://ts1.tc.mm.bing.net/th/id/R-C.823270fc68b9c58f0d9b3feb92b7b172?rik=aubbEBMSC86e%2bw&riu=http%3a%2f%2fimg95.699pic.com%2fphoto%2f50038%2f1181.jpg_wh860.jpg&ehk=iQboj4JMLLfDitOL7VJtSktED0AE%2f7Fyxfik0GTJkyQ%3d&risl=&pid=ImgRaw&r=0)asd\n" }, + { event: "message", answer: "```\nasdaaaasasaasaas\n" }, + { event: "message", answer: "asdsaa\n" }, + { event: "message", answer: "console.log(as)\n" }, + { event: "message", answer: "asa\n```\n\n" }, + { event: "message", answer: "\n\n" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message", answer: "## asdas" }, + { event: "message_end", answer: "## asdas" }, +] \ No newline at end of file diff --git a/packages/client/src/components/AiDemo/index.vue b/packages/client/src/components/AiDemo/index.vue new file mode 100644 index 0000000..9b1636d --- /dev/null +++ b/packages/client/src/components/AiDemo/index.vue @@ -0,0 +1,158 @@ + + + + + + diff --git a/packages/client/src/components/CookieDemo.vue b/packages/client/src/components/CookieDemo.vue new file mode 100644 index 0000000..3c65edd --- /dev/null +++ b/packages/client/src/components/CookieDemo.vue @@ -0,0 +1,54 @@ + + + + + + + diff --git a/packages/client/src/components/DataFetch.vue b/packages/client/src/components/DataFetch.vue new file mode 100644 index 0000000..b13af4b --- /dev/null +++ b/packages/client/src/components/DataFetch.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/packages/client/src/components/HelloWorld.vue b/packages/client/src/components/HelloWorld.vue new file mode 100644 index 0000000..63f7e72 --- /dev/null +++ b/packages/client/src/components/HelloWorld.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/packages/client/src/components/SimpleTest.vue b/packages/client/src/components/SimpleTest.vue new file mode 100644 index 0000000..0ad28da --- /dev/null +++ b/packages/client/src/components/SimpleTest.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/packages/client/src/composables/useGlobal/index.ts b/packages/client/src/composables/useGlobal/index.ts new file mode 100644 index 0000000..76e137f --- /dev/null +++ b/packages/client/src/composables/useGlobal/index.ts @@ -0,0 +1,8 @@ + +export function useGlobal() { + const openCache = ref(true) + + return { + openCache + } +} \ No newline at end of file diff --git a/packages/client/src/entry-client.ts b/packages/client/src/entry-client.ts new file mode 100644 index 0000000..142cc0d --- /dev/null +++ b/packages/client/src/entry-client.ts @@ -0,0 +1,29 @@ +import { createApp } from "./main" +import { hydrateSSRContext, clearSSRContext } from 'x/composables/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, pinia, router } = createApp(ssrContext) + +if (ssrContext) { + pinia.state.value = ssrContext.piniaState +} + +// 等待路由准备就绪,然后挂载应用 +router.isReady().then(() => { + console.log('[Client] 路由已准备就绪,挂载应用') + app.mount('#app') + + // 水合完成后清除 SSR 上下文 + clearSSRContext() +}) + diff --git a/packages/client/src/entry-server.ts b/packages/client/src/entry-server.ts new file mode 100644 index 0000000..05b6e56 --- /dev/null +++ b/packages/client/src/entry-server.ts @@ -0,0 +1,93 @@ +import { renderToString } from 'vue/server-renderer' +import { createApp } from './main' +import { createSSRContext } from 'x/composables/ssrContext' +import { basename } from 'node:path' + + +export async function render(url: string, manifest: any, init?: { cookies?: Record }) { + // 创建 SSR 上下文,包含数据缓存与 cookies + const ssrContext = createSSRContext() + if (init?.cookies) { + ssrContext.cookies = { ...init.cookies } + } + + // 将 SSR 上下文传递给应用创建函数 + const { app, pinia, router } = createApp(ssrContext) + + router.push(url); // 根据请求 URL 设置路由 + await router.isReady(); // 等待路由准备完成 + + // 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 || {}) + + // @ts-ignore + const preloadLinks = renderPreloadLinks(ctx.modules, manifest) + + console.log('[SSR] 序列化缓存数据:', cacheEntries) + const head = ` + + ${preloadLinks} + ` + + return { html, head, setCookies: ssrContext.setCookies || [] } +} + +function renderPreloadLinks(modules: any, manifest: any) { + let links = '' + const seen = new Set() + modules.forEach((id: any) => { + const files = manifest[id] + if (files) { + files.forEach((file: any) => { + if (!seen.has(file)) { + seen.add(file) + const filename = basename(file) + if (manifest[filename]) { + for (const depFile of manifest[filename]) { + links += renderPreloadLink(depFile) + seen.add(depFile) + } + } + links += renderPreloadLink(file) + } + }) + } + }) + return links +} + +function renderPreloadLink(file: string) { + if (file.endsWith('.js')) { + return `` + } else if (file.endsWith('.css')) { + return `` + } else if (file.endsWith('.woff')) { + return ` ` + } else if (file.endsWith('.woff2')) { + return ` ` + } else if (file.endsWith('.gif')) { + return ` ` + } else if (file.endsWith('.jpg') || file.endsWith('.jpeg')) { + return ` ` + } else if (file.endsWith('.png')) { + return ` ` + } else { + return '' + } +} diff --git a/packages/client/src/main.ts b/packages/client/src/main.ts new file mode 100644 index 0000000..d7f1f4b --- /dev/null +++ b/packages/client/src/main.ts @@ -0,0 +1,22 @@ +import { createSSRApp } from 'vue' +import App from './App.vue' +import createSSRRouter from './router'; +import { createPinia } from 'pinia' + +// 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) + const router = createSSRRouter() + const pinia = createPinia() + + app.use(router) + app.use(pinia) + + // 如果有 SSR 上下文,注入到应用中 + if (ssrContext) { + app.config.globalProperties.$ssrContext = ssrContext + } + return { app, router, pinia } +} diff --git a/packages/client/src/pages/about/index.vue b/packages/client/src/pages/about/index.vue new file mode 100644 index 0000000..597f5c1 --- /dev/null +++ b/packages/client/src/pages/about/index.vue @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/packages/client/src/pages/home/index.vue b/packages/client/src/pages/home/index.vue new file mode 100644 index 0000000..573de29 --- /dev/null +++ b/packages/client/src/pages/home/index.vue @@ -0,0 +1,19 @@ + + + diff --git a/packages/client/src/pages/not-found/index.vue b/packages/client/src/pages/not-found/index.vue new file mode 100644 index 0000000..5ef1232 --- /dev/null +++ b/packages/client/src/pages/not-found/index.vue @@ -0,0 +1,5 @@ + diff --git a/packages/client/src/router/index.ts b/packages/client/src/router/index.ts new file mode 100644 index 0000000..82c1eea --- /dev/null +++ b/packages/client/src/router/index.ts @@ -0,0 +1,16 @@ +// src/router.js +import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'; +import NotFound from '../pages/not-found/index.vue'; + +export default function createSSRRouter() { + return createRouter({ + history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(), // 使用内存模式 + routes: [ + { name: "home", path: '/', meta: { cache: true }, component: () => import('../pages/home/index.vue') }, + { name: "about", path: '/about', meta: { cache: true }, component: () => import('../pages/about/index.vue') }, + + // 404 + { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }, + ], + }); +} \ No newline at end of file diff --git a/packages/client/src/store/auth.ts b/packages/client/src/store/auth.ts new file mode 100644 index 0000000..3589613 --- /dev/null +++ b/packages/client/src/store/auth.ts @@ -0,0 +1,20 @@ +import { defineStore } from "pinia"; + +// export const useAuthStore = defineStore("auth", { +// state: () => ({ +// user: null as null | { name: string }, +// }), +// actions: { +// setUser(user: { name: string }) { +// this.user = user; +// } +// } +// }); +export const useAuthStore = defineStore("auth", () => { + const user = ref(null); + function setUser(u: { name: string }) { + user.value = u; + } + + return { user, setUser }; +}); diff --git a/packages/client/src/vite-env.d.ts b/packages/client/src/vite-env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/packages/client/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/packages/client/src/vue.d.ts b/packages/client/src/vue.d.ts new file mode 100644 index 0000000..de7be77 --- /dev/null +++ b/packages/client/src/vue.d.ts @@ -0,0 +1,10 @@ +export { } + +declare module 'vue' { + export interface ComponentCustomProperties { + $ssrContext?: Record + } + export interface ComponentInternalInstance { + _nuxtClientOnly?: boolean + } +} \ No newline at end of file diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..fc06120 --- /dev/null +++ b/packages/client/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", "components.d.ts", "auto-imports.d.ts"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/packages/client/tsconfig.node.json b/packages/client/tsconfig.node.json new file mode 100644 index 0000000..033c9c1 --- /dev/null +++ b/packages/client/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/packages/client/vite.config.ts b/packages/client/vite.config.ts new file mode 100644 index 0000000..02f84e2 --- /dev/null +++ b/packages/client/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import Components from 'unplugin-vue-components/vite' +import AutoImport from 'unplugin-auto-import/vite' +import devtoolsJson from 'vite-plugin-devtools-json'; + +// https://vite.dev/config/ +export default defineConfig({ + build: { + emptyOutDir: true + }, + plugins: [ + devtoolsJson(), + vue(), + Components({ + dts: true, + dirs: ['src/components', '../../internal/x/components'], + globsExclude: ["**/_*/**/*"] + }), + AutoImport({ + dts: true, + dtsMode: "overwrite", + ignore: ["**/_*/**/*"], + imports: ['vue', 'vue-router', 'pinia'], + dirs: ['./src/composables/**/*', '../../internal/x/composables/**', "./src/store/**/*"], + vueTemplate: true, + }), + ], +}) diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..30dabbb --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,9 @@ +{ + "name": "core", + "exports": { + "./*": { + "import": "./src/*.ts", + "require": "./src/*.ts" + } + } +} diff --git a/packages/core/src/SsrMiddleWare.ts b/packages/core/src/SsrMiddleWare.ts new file mode 100644 index 0000000..b6e8388 --- /dev/null +++ b/packages/core/src/SsrMiddleWare.ts @@ -0,0 +1,100 @@ +import fs from 'node:fs/promises' +import { getPathByRoot } from "helper/path" +import { parseCookieHeader } from "helper/cookie" +import { Env } from "helper/env" +import { ViteDevServer } from 'vite' +import Send from 'koa-send' +import type Koa from 'koa' +import c2k from 'koa-connect' + +const isProduction = Env.isProduction +const base = Env.base + +const templateHtml = isProduction + ? await fs.readFile(getPathByRoot('dist', 'client/index.html'), 'utf-8') + : '' + +export async function SsrMiddleWare(app: Koa, options?: { onDevViteClose?: Function }) { + let vite: ViteDevServer + if (!isProduction) { + // Dev mode: create Vite server in middleware mode. + const { createServer } = await import('vite') + vite = await createServer({ + server: { middlewareMode: true }, + configFile: getPathByRoot('packages', 'client/vite.config.ts'), + root: getPathByRoot('packages', 'client'), + appType: 'custom', + base, + }) + app.use(c2k(vite.middlewares)) + vite.httpServer?.on("close", () => { + vite.close() + options?.onDevViteClose?.() + }) + } else { + // Production mode: serve pre-built static assets. + app.use(async (ctx, next) => { + if (ctx.originalUrl === "/.well-known/appspecific/com.chrome.devtools.json") return await next() + try { + await Send(ctx, ctx.path, { root: getPathByRoot('dist/client'), index: false }); + if (ctx.status === 404) { + await next() + } + } catch (error) { + if (ctx.status === 404) { + await next() + } else { + throw error + } + } + }) + } + + // Handle every other route with SSR. + app.use(async (ctx, next) => { + if (!ctx.originalUrl.startsWith(base)) return await next() + + try { + const url = ctx.originalUrl.replace(base, '') + let template + let render + let manifest + if (!isProduction) { + // Always read fresh template in development + template = await fs.readFile(getPathByRoot('packages', 'client/index.html'), 'utf-8') + template = await vite.transformIndexHtml(url, template) + manifest = {} + render = (await vite.ssrLoadModule(getPathByRoot('packages', 'client/src/entry-server.ts'))).render + } else { + manifest = await fs.readFile(getPathByRoot('dist', 'client/.vite/ssr-manifest.json'), 'utf-8') + template = templateHtml + // @ts-ignore + render = (await import(getPathByRoot('dist', 'server/entry-server.js'))).render + } + + const cookies = parseCookieHeader(ctx.request.headers['cookie'] as string) + + const rendered = await render(url, manifest, { 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 + console.error(e.stack) + ctx.body = e.stack + } + await next() + }) +} \ No newline at end of file diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..3d6af73 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,20 @@ +{ + "name": "server", + "type": "module", + "exports": { + "./*": { + "import": "./src/*.ts", + "types": "./src/*.d.ts" + } + }, + "scripts": {}, + "devDependencies": { + "@types/koa": "^3.0.0", + "@types/koa-send": "^4.1.6" + }, + "dependencies": { + "koa": "^3.0.1", + "koa-connect": "^2.1.0", + "koa-send": "^5.0.1" + } +} diff --git a/packages/server/src/api/main.ts b/packages/server/src/api/main.ts new file mode 100644 index 0000000..3221941 --- /dev/null +++ b/packages/server/src/api/main.ts @@ -0,0 +1,61 @@ +import { parseCookieHeader, serializeCookie } from "helper/cookie"; +import app from "../app"; + +export function bootstrapServer() { + async function fetchFirstSuccess(urls: string[]) { + 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(); + ctx.body = `Hello World` + 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/packages/server/src/app.ts b/packages/server/src/app.ts new file mode 100644 index 0000000..38d9780 --- /dev/null +++ b/packages/server/src/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/packages/server/src/booststap.ts b/packages/server/src/booststap.ts new file mode 100644 index 0000000..c8432ce --- /dev/null +++ b/packages/server/src/booststap.ts @@ -0,0 +1,20 @@ +import app from "./app" +import { bootstrapServer } from "./api/main" +import { SsrMiddleWare } from "core/SsrMiddleWare" +import { Env } from "helper/env" + +bootstrapServer() + +SsrMiddleWare(app, { + onDevViteClose() { + console.log("Vite dev server closed") + if (server) { + server.close() + console.log('Server closed') + } + } +}) + +const server = app.listen(Env.port, () => { + console.log(`Server started at http://localhost:${Env.port}`) +}) diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..90b3e96 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + // 指定编译成的版本 + "target": "esnext", + // 切换成即将发布的ECMA运行时行为 + "useDefineForClassFields": true, + // 指定ts需要包含的库 + "lib": [ + "esnext" + ], + // 指定编译后的模块系统,如commonjs,umd之类的 + "module": "esnext", + // 跳过库中的类型检查 + "skipLibCheck": true, + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": [ + "src/**/*.ts" + ] +} \ No newline at end of file diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/server.ts b/server.ts deleted file mode 100644 index b2de317..0000000 --- a/server.ts +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 38d9780..0000000 --- a/server/app.ts +++ /dev/null @@ -1,9 +0,0 @@ - -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 deleted file mode 100644 index a324dce..0000000 --- a/server/main.ts +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 8433d2a..0000000 --- a/src/App.vue +++ /dev/null @@ -1,40 +0,0 @@ - - - - - diff --git a/src/assets/vue.svg b/src/assets/vue.svg deleted file mode 100644 index 770e9d3..0000000 --- a/src/assets/vue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/ClientOnly.tsx b/src/components/ClientOnly.tsx deleted file mode 100644 index fa64ae8..0000000 --- a/src/components/ClientOnly.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { cloneVNode, createElementBlock, defineComponent, getCurrentInstance, h, InjectionKey, onMounted, provide, shallowRef, SlotsType, VNode } from "vue"; - -export const clientOnlySymbol: InjectionKey = Symbol.for('nuxt:client-only') - -export default defineComponent({ - name: "ClientOnly", - inheritAttrs: false, - props: ['fallback', 'placeholder', 'placeholderTag', 'fallbackTag'], - ...(import.meta.env.DEV && { - slots: Object as SlotsType<{ - default?: () => VNode[] - - /** - * Specify a content to be rendered on the server and displayed until `` is mounted in the browser. - */ - fallback?: () => VNode[] - placeholder?: () => VNode[] - }>, - }), - setup(props, { slots, attrs }) { - const mounted = shallowRef(false) - onMounted(() => { mounted.value = true }) - const vm = getCurrentInstance() - if (vm) { - vm._nuxtClientOnly = true - } - provide(clientOnlySymbol, true) - return () => { - if (mounted.value) { - const vnodes = slots.default?.() - if (vnodes && vnodes.length === 1) { - return [cloneVNode(vnodes[0]!, attrs)] - } - return vnodes - } - const slot = slots.fallback || slots.placeholder - if (slot) { return h(slot) } - const fallbackStr = props.fallback || props.placeholder || '' - const fallbackTag = props.fallbackTag || props.placeholderTag || 'span' - return createElementBlock(fallbackTag, attrs, fallbackStr) - } - } -}) diff --git a/src/components/CookieDemo.vue b/src/components/CookieDemo.vue deleted file mode 100644 index 69320a6..0000000 --- a/src/components/CookieDemo.vue +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - diff --git a/src/components/DataFetch.vue b/src/components/DataFetch.vue deleted file mode 100644 index 9e686f0..0000000 --- a/src/components/DataFetch.vue +++ /dev/null @@ -1,121 +0,0 @@ - - - - - diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue deleted file mode 100644 index 63f7e72..0000000 --- a/src/components/HelloWorld.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - - - diff --git a/src/components/SimpleTest.vue b/src/components/SimpleTest.vue deleted file mode 100644 index 98c5cbd..0000000 --- a/src/components/SimpleTest.vue +++ /dev/null @@ -1,45 +0,0 @@ - - - - - diff --git a/src/compose/README.md b/src/compose/README.md deleted file mode 100644 index 442c39f..0000000 --- a/src/compose/README.md +++ /dev/null @@ -1,170 +0,0 @@ -# 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 deleted file mode 100644 index 8667c89..0000000 --- a/src/compose/cookieUtils.ts +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 0e8703a..0000000 --- a/src/compose/ssrContext.ts +++ /dev/null @@ -1,41 +0,0 @@ -// 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 deleted file mode 100644 index 1c1bf2e..0000000 --- a/src/compose/useCookie.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index e976b0c..0000000 --- a/src/compose/useFetch.ts +++ /dev/null @@ -1,249 +0,0 @@ -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) => T - 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 deleted file mode 100644 index 94ec873..0000000 --- a/src/entry-client.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 8eefd4d..0000000 --- a/src/entry-server.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 3284bfc..0000000 --- a/src/main.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index f691315..0000000 --- a/src/style.css +++ /dev/null @@ -1,79 +0,0 @@ -: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 deleted file mode 100644 index 323c78a..0000000 --- a/src/vite-env.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/// - -declare module '*.vue' { - import type { DefineComponent } from 'vue' - const component: DefineComponent<{}, {}, any> - export default component -} diff --git a/src/vue.d.ts b/src/vue.d.ts deleted file mode 100644 index de7be77..0000000 --- a/src/vue.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { } - -declare module 'vue' { - export interface ComponentCustomProperties { - $ssrContext?: Record - } - export interface ComponentInternalInstance { - _nuxtClientOnly?: boolean - } -} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index ebbb12c..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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", "components.d.ts"], - "references": [{ "path": "./tsconfig.node.json" }] -} diff --git a/tsconfig.node.json b/tsconfig.node.json deleted file mode 100644 index 033c9c1..0000000 --- a/tsconfig.node.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "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 deleted file mode 100644 index 348d0dd..0000000 --- a/vite.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' -import Components from 'unplugin-vue-components/vite' - -// https://vite.dev/config/ -export default defineConfig({ - base: './', - plugins: [ - vue(), - Components({ dts: true, - extensions: ['vue', 'tsx'], }) - ], -})