78 changed files with 2167 additions and 1352 deletions
@ -1 +1,3 @@ |
|||||
# 基于koa实现的简易ssr |
# 基于koa实现的简易ssr |
||||
|
|
||||
|
- https://segmentfault.com/a/1190000042389086 |
||||
Binary file not shown.
@ -0,0 +1,8 @@ |
|||||
|
{ |
||||
|
"name": "helper", |
||||
|
"exports": { |
||||
|
"./*": { |
||||
|
"import": "./src/*.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<string, string> { |
||||
|
const raw = header || '' |
||||
|
const out: Record<string, string> = {} |
||||
|
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<string, string> { |
||||
|
// @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() |
||||
|
}) |
||||
|
*/ |
||||
@ -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, |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
@ -0,0 +1,62 @@ |
|||||
|
<script lang="ts"> |
||||
|
import { |
||||
|
cloneVNode, |
||||
|
createElementBlock, |
||||
|
defineComponent, |
||||
|
getCurrentInstance, |
||||
|
h, |
||||
|
InjectionKey, |
||||
|
onMounted, |
||||
|
provide, |
||||
|
shallowRef, |
||||
|
SlotsType, |
||||
|
VNode, |
||||
|
} from "vue"; |
||||
|
|
||||
|
export const clientOnlySymbol: InjectionKey<boolean> = |
||||
|
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 `<ClientOnly>` 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); |
||||
|
}; |
||||
|
}, |
||||
|
}); |
||||
|
</script> |
||||
@ -0,0 +1,5 @@ |
|||||
|
{ |
||||
|
"name": "x", |
||||
|
"main": "index.ts", |
||||
|
"type": "module" |
||||
|
} |
||||
@ -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 |
||||
@ -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. |
||||
@ -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<typeof import('vue')['EffectScope']> |
||||
|
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']> |
||||
|
readonly clearSSRContext: UnwrapRef<typeof import('../../internal/x/composables/ssrContext')['clearSSRContext']> |
||||
|
readonly computed: UnwrapRef<typeof import('vue')['computed']> |
||||
|
readonly createApp: UnwrapRef<typeof import('vue')['createApp']> |
||||
|
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']> |
||||
|
readonly createSSRContext: UnwrapRef<typeof import('../../internal/x/composables/ssrContext')['createSSRContext']> |
||||
|
readonly customRef: UnwrapRef<typeof import('vue')['customRef']> |
||||
|
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']> |
||||
|
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']> |
||||
|
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']> |
||||
|
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']> |
||||
|
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']> |
||||
|
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']> |
||||
|
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']> |
||||
|
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']> |
||||
|
readonly h: UnwrapRef<typeof import('vue')['h']> |
||||
|
readonly hydrateSSRContext: UnwrapRef<typeof import('../../internal/x/composables/ssrContext')['hydrateSSRContext']> |
||||
|
readonly inject: UnwrapRef<typeof import('vue')['inject']> |
||||
|
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']> |
||||
|
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']> |
||||
|
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']> |
||||
|
readonly isRef: UnwrapRef<typeof import('vue')['isRef']> |
||||
|
readonly isShallow: UnwrapRef<typeof import('vue')['isShallow']> |
||||
|
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']> |
||||
|
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']> |
||||
|
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']> |
||||
|
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']> |
||||
|
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']> |
||||
|
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']> |
||||
|
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']> |
||||
|
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']> |
||||
|
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']> |
||||
|
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']> |
||||
|
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']> |
||||
|
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']> |
||||
|
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']> |
||||
|
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']> |
||||
|
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']> |
||||
|
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']> |
||||
|
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']> |
||||
|
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']> |
||||
|
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']> |
||||
|
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']> |
||||
|
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']> |
||||
|
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']> |
||||
|
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']> |
||||
|
readonly parseCookieHeader: UnwrapRef<typeof import('../../internal/x/composables/cookieUtils')['parseCookieHeader']> |
||||
|
readonly parseDocumentCookies: UnwrapRef<typeof import('../../internal/x/composables/cookieUtils')['parseDocumentCookies']> |
||||
|
readonly provide: UnwrapRef<typeof import('vue')['provide']> |
||||
|
readonly reactive: UnwrapRef<typeof import('vue')['reactive']> |
||||
|
readonly readonly: UnwrapRef<typeof import('vue')['readonly']> |
||||
|
readonly ref: UnwrapRef<typeof import('vue')['ref']> |
||||
|
readonly render: UnwrapRef<typeof import('../../internal/x/composables/README.md')['render']> |
||||
|
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']> |
||||
|
readonly resolveSSRContext: UnwrapRef<typeof import('../../internal/x/composables/ssrContext')['resolveSSRContext']> |
||||
|
readonly serializeCookie: UnwrapRef<typeof import('../../internal/x/composables/cookieUtils')['serializeCookie']> |
||||
|
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']> |
||||
|
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']> |
||||
|
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']> |
||||
|
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']> |
||||
|
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']> |
||||
|
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']> |
||||
|
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']> |
||||
|
readonly toRef: UnwrapRef<typeof import('vue')['toRef']> |
||||
|
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']> |
||||
|
readonly toValue: UnwrapRef<typeof import('vue')['toValue']> |
||||
|
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']> |
||||
|
readonly unref: UnwrapRef<typeof import('vue')['unref']> |
||||
|
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']> |
||||
|
readonly useAuthStore: UnwrapRef<typeof import('./src/store/auth')['useAuthStore']> |
||||
|
readonly useCookie: UnwrapRef<typeof import('../../internal/x/composables/useCookie')['useCookie']> |
||||
|
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']> |
||||
|
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']> |
||||
|
readonly useFetch: UnwrapRef<typeof import('../../internal/x/composables/useFetch')['useFetch']> |
||||
|
readonly useGlobal: UnwrapRef<typeof import('./src/composables/useGlobal/index')['useGlobal']> |
||||
|
readonly useId: UnwrapRef<typeof import('vue')['useId']> |
||||
|
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']> |
||||
|
readonly useModel: UnwrapRef<typeof import('vue')['useModel']> |
||||
|
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']> |
||||
|
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']> |
||||
|
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']> |
||||
|
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']> |
||||
|
readonly watch: UnwrapRef<typeof import('vue')['watch']> |
||||
|
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']> |
||||
|
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']> |
||||
|
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']> |
||||
|
} |
||||
|
} |
||||
@ -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:*" |
||||
|
} |
||||
|
} |
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,35 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
// import ClientOnly from "x/components/ClientOnly.vue"; |
||||
|
const { openCache } = useGlobal(); |
||||
|
const cacheList = ref<string[]>([]); |
||||
|
|
||||
|
const route = useRoute(); |
||||
|
|
||||
|
watch( |
||||
|
() => route.fullPath, |
||||
|
() => { |
||||
|
if (route.meta.cache && !cacheList.value.includes(route.name as string)) { |
||||
|
cacheList.value.push(route.name as string); |
||||
|
} |
||||
|
}, |
||||
|
{ immediate: true } |
||||
|
); |
||||
|
|
||||
|
onServerPrefetch(() => { |
||||
|
const AuthStore = useAuthStore(); |
||||
|
AuthStore.setUser({ |
||||
|
name: "zha123ngsan", |
||||
|
}); |
||||
|
}); |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<RouterView v-slot="{ Component, route }"> |
||||
|
<keep-alive :include="cacheList" v-if="openCache"> |
||||
|
<component :key="route.fullPath" :is="Component" /> |
||||
|
</keep-alive> |
||||
|
<component v-else :key="route.fullPath" :is="Component" /> |
||||
|
</RouterView> |
||||
|
</template> |
||||
|
|
||||
|
<style lang="scss" scoped></style> |
||||
|
Before Width: | Height: | Size: 496 B After Width: | Height: | Size: 496 B |
@ -0,0 +1,56 @@ |
|||||
|
<template> |
||||
|
<!-- 文本节点直接渲染内容 --> |
||||
|
<template v-if="node.type === 'text'"> |
||||
|
{{ node.data }} |
||||
|
</template> |
||||
|
|
||||
|
<!-- 图片节点使用 el-image 组件 --> |
||||
|
<!-- <template v-else-if="isImageNode"> |
||||
|
<el-image |
||||
|
:src="node.attribs.src + '?x-oss-process=image/resize,w_400/format,jpg'" |
||||
|
:alt="node.attribs.alt || '图片'" |
||||
|
:preview-src-list="[node.attribs.src]" |
||||
|
preview-teleported |
||||
|
class="w-30% block!" |
||||
|
/> |
||||
|
<input /> |
||||
|
</template> --> |
||||
|
|
||||
|
<!-- 其他非文本节点渲染对应标签 + 递归子节点 --> |
||||
|
<template v-else> |
||||
|
<component :is="node.tagName" v-bind="node.attribs"> |
||||
|
<VueNodeRenderer |
||||
|
v-for="(child, index) in node.children" |
||||
|
:key="index" |
||||
|
:node="child" |
||||
|
/> |
||||
|
</component> |
||||
|
</template> |
||||
|
</template> |
||||
|
|
||||
|
<script setup> |
||||
|
import { computed } from 'vue' |
||||
|
// import { ElImage } from 'element-plus' |
||||
|
|
||||
|
// 定义组件属性 |
||||
|
const props = defineProps({ |
||||
|
node: { |
||||
|
type: Object, |
||||
|
required: true |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
// 判断是否为图片节点 |
||||
|
const isImageNode = computed(() => { |
||||
|
return props.node.tagName?.toLowerCase() === 'img' |
||||
|
}) |
||||
|
|
||||
|
// 计算图片样式(可根据需要调整) |
||||
|
const imageStyle = computed(() => { |
||||
|
const style = {} |
||||
|
// 传递原img标签的宽高属性 |
||||
|
if (props.node.attribs.width) style.width = props.node.attribs.width |
||||
|
if (props.node.attribs.height) style.height = props.node.attribs.height |
||||
|
return style |
||||
|
}) |
||||
|
</script> |
||||
@ -0,0 +1,31 @@ |
|||||
|
export default [ |
||||
|
{ event: "message", answer: "## asdas\n" }, |
||||
|
{ event: "message", answer: "**asasa**\n" }, |
||||
|
{ event: "message", answer: "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: "<input type=\"text\">\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" }, |
||||
|
] |
||||
@ -0,0 +1,158 @@ |
|||||
|
<template> |
||||
|
<div class="sse-container"> |
||||
|
<div class="sse-header"> |
||||
|
<button @click="toggleSSE"> |
||||
|
{{ isConnected ? "断开连接" : "连接 SSE" }} |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
<div class="sse-status"> |
||||
|
<p>连接状态: {{ isConnected ? "已连接" : "未连接" }}</p> |
||||
|
<p v-if="isConnected">接收中... ({{ receivedMessages }} 条消息)</p> |
||||
|
</div> |
||||
|
|
||||
|
<div class="markdown-body"> |
||||
|
<VueNodeRenderer |
||||
|
v-for="(node, index) in renderedContent" |
||||
|
:key="index" |
||||
|
:node="node" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts" setup> |
||||
|
import { ref, onMounted, onUnmounted, computed } from "vue"; |
||||
|
import { marked } from "marked"; |
||||
|
import { parseDocument } from "htmlparser2"; |
||||
|
import { default as DOMPurify } from "dompurify"; |
||||
|
import VueNodeRenderer from "./_/VueNodeRenderer.vue"; |
||||
|
|
||||
|
defineOptions({ |
||||
|
name: "AiDemo", |
||||
|
}); |
||||
|
|
||||
|
// 状态管理 |
||||
|
const isConnected = ref(false); |
||||
|
const receivedMessages = ref(0); |
||||
|
const accumulatedMarkdown = ref(""); // 累积markdown内容 |
||||
|
|
||||
|
let mockInterval: ReturnType<typeof setTimeout> | null = null; |
||||
|
let messageId = 0; |
||||
|
|
||||
|
// 导入 sseData.ts 中的数据 |
||||
|
import sseDataModule from "./_/sseData"; |
||||
|
|
||||
|
// 配置 marked 库 |
||||
|
marked.setOptions({ |
||||
|
breaks: true, // 转换段落内的换行符为 <br> |
||||
|
gfm: true, // 启用 GitHub 风格的 markdown |
||||
|
// smartypants: true, // 使用智能引号和其他排版符号 |
||||
|
}); |
||||
|
|
||||
|
// 解析和格式化 SSE 数据 |
||||
|
const parseSSEData = (content: string): string => { |
||||
|
try { |
||||
|
// 尝试解析 JSON 内容 |
||||
|
const { event, answer } = JSON.parse(content); |
||||
|
console.log(event); |
||||
|
// 只处理 message 事件 |
||||
|
if (event === "message") { |
||||
|
// 确保 answer 存在,否则显示提示信息 |
||||
|
const answerContent = answer || ""; |
||||
|
accumulatedMarkdown.value += answerContent; |
||||
|
} |
||||
|
if (event === "message_end") { |
||||
|
disconnectSSE(); |
||||
|
} |
||||
|
return accumulatedMarkdown.value; |
||||
|
} catch (error) { |
||||
|
// 解析错误,记录错误并返回友好提示 |
||||
|
console.error("解析SSE数据失败:", error); |
||||
|
return accumulatedMarkdown.value || "**解析数据时发生错误**"; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// HTML 到 AST 的转换:使用 htmlparser2 |
||||
|
const renderedContent = computed(() => { |
||||
|
return parseDocument( |
||||
|
DOMPurify.sanitize(marked.parse(accumulatedMarkdown.value) as string) |
||||
|
).children; |
||||
|
}); |
||||
|
|
||||
|
// 连接/断开 SSE |
||||
|
const toggleSSE = (): void => { |
||||
|
if (isConnected.value) { |
||||
|
disconnectSSE(); |
||||
|
} else { |
||||
|
connectSSE(); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// 连接 SSE |
||||
|
const connectSSE = (): void => { |
||||
|
isConnected.value = true; |
||||
|
receivedMessages.value = 0; |
||||
|
accumulatedMarkdown.value = ""; // 重置累积的markdown |
||||
|
messageId = 0; |
||||
|
let currentData = sseDataModule; |
||||
|
|
||||
|
// 模拟 SSE 连接 - 随机发送间隔 |
||||
|
const sendNextMessage = (): void => { |
||||
|
if (messageId < currentData.length) { |
||||
|
const data = JSON.stringify(currentData[messageId]); |
||||
|
// 解析当前消息并追加到内容中 |
||||
|
const parsedContent = parseSSEData(data); |
||||
|
if (parsedContent) { |
||||
|
receivedMessages.value++; |
||||
|
} |
||||
|
messageId++; |
||||
|
|
||||
|
mockInterval = setTimeout(sendNextMessage, 100); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// 发送第一条消息 |
||||
|
sendNextMessage(); |
||||
|
}; |
||||
|
|
||||
|
// 断开 SSE |
||||
|
const disconnectSSE = (): void => { |
||||
|
isConnected.value = false; |
||||
|
if (mockInterval) { |
||||
|
clearTimeout(mockInterval); |
||||
|
mockInterval = null; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// 组件挂载时 |
||||
|
onMounted(() => { |
||||
|
// 自动连接 SSE |
||||
|
connectSSE(); |
||||
|
}); |
||||
|
|
||||
|
// 组件卸载时 |
||||
|
onUnmounted(() => { |
||||
|
disconnectSSE(); |
||||
|
}); |
||||
|
</script> |
||||
|
|
||||
|
<style></style> |
||||
|
<style scoped> |
||||
|
.sse-container { |
||||
|
max-width: 600px; |
||||
|
margin: 0 auto; |
||||
|
} |
||||
|
|
||||
|
@keyframes fadeIn { |
||||
|
from { |
||||
|
opacity: 0; |
||||
|
transform: translateY(10px); |
||||
|
} |
||||
|
|
||||
|
to { |
||||
|
opacity: 1; |
||||
|
transform: translateY(0); |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,8 @@ |
|||||
|
|
||||
|
export function useGlobal() { |
||||
|
const openCache = ref(true) |
||||
|
|
||||
|
return { |
||||
|
openCache |
||||
|
} |
||||
|
} |
||||
@ -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() |
||||
|
}) |
||||
|
|
||||
@ -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<string, string> }) { |
||||
|
// 创建 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 = ` |
||||
|
<script> |
||||
|
window.__SSR_CONTEXT__ = { |
||||
|
cache: new Map(${ssrData}), |
||||
|
cookies: ${cookieInit}, |
||||
|
piniaState: ${JSON.stringify(pinia.state.value || {})} |
||||
|
}; |
||||
|
</script> |
||||
|
${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 `<link rel="modulepreload" crossorigin href="${file}">` |
||||
|
} else if (file.endsWith('.css')) { |
||||
|
return `<link rel="stylesheet" href="${file}">` |
||||
|
} else if (file.endsWith('.woff')) { |
||||
|
return ` <link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>` |
||||
|
} else if (file.endsWith('.woff2')) { |
||||
|
return ` <link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>` |
||||
|
} else if (file.endsWith('.gif')) { |
||||
|
return ` <link rel="preload" href="${file}" as="image" type="image/gif">` |
||||
|
} else if (file.endsWith('.jpg') || file.endsWith('.jpeg')) { |
||||
|
return ` <link rel="preload" href="${file}" as="image" type="image/jpeg">` |
||||
|
} else if (file.endsWith('.png')) { |
||||
|
return ` <link rel="preload" href="${file}" as="image" type="image/png">` |
||||
|
} else { |
||||
|
return '' |
||||
|
} |
||||
|
} |
||||
@ -1,16 +1,22 @@ |
|||||
import { createSSRApp } from 'vue' |
import { createSSRApp } from 'vue' |
||||
import App from './App.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
|
// 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
|
// that creates a fresh app instance. If using Vuex, we'd also be creating a
|
||||
// fresh store here.
|
// fresh store here.
|
||||
export function createApp(ssrContext?: any) { |
export function createApp(ssrContext?: any) { |
||||
const app = createSSRApp(App) |
const app = createSSRApp(App) |
||||
|
const router = createSSRRouter() |
||||
|
const pinia = createPinia() |
||||
|
|
||||
|
app.use(router) |
||||
|
app.use(pinia) |
||||
|
|
||||
// 如果有 SSR 上下文,注入到应用中
|
// 如果有 SSR 上下文,注入到应用中
|
||||
if (ssrContext) { |
if (ssrContext) { |
||||
app.config.globalProperties.$ssrContext = ssrContext |
app.config.globalProperties.$ssrContext = ssrContext |
||||
} |
} |
||||
|
return { app, router, pinia } |
||||
return { app } |
|
||||
} |
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
<template> |
||||
|
<div> |
||||
|
<h1 @click="$router.back()">About Page</h1> |
||||
|
</div> |
||||
|
</template> |
||||
|
<script setup lang="ts"> |
||||
|
|
||||
|
defineOptions({ |
||||
|
name: "about" |
||||
|
}) |
||||
|
</script> |
||||
@ -0,0 +1,19 @@ |
|||||
|
<template> |
||||
|
<div> |
||||
|
<h1>Home Page</h1> |
||||
|
<input type="text" /> |
||||
|
<router-link to="/about">前往/about</router-link> |
||||
|
{{ user }} |
||||
|
<ClientOnly> |
||||
|
<AiDemo></AiDemo> |
||||
|
</ClientOnly> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
defineOptions({ |
||||
|
name: "home", |
||||
|
}); |
||||
|
|
||||
|
const { user } = useAuthStore(); |
||||
|
</script> |
||||
@ -0,0 +1,5 @@ |
|||||
|
<template> |
||||
|
<div> |
||||
|
NotFound |
||||
|
</div> |
||||
|
</template> |
||||
@ -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 }, |
||||
|
], |
||||
|
}); |
||||
|
} |
||||
@ -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 | { name: string }>(null); |
||||
|
function setUser(u: { name: string }) { |
||||
|
user.value = u; |
||||
|
} |
||||
|
|
||||
|
return { user, setUser }; |
||||
|
}); |
||||
@ -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, |
||||
|
}), |
||||
|
], |
||||
|
}) |
||||
@ -0,0 +1,9 @@ |
|||||
|
{ |
||||
|
"name": "core", |
||||
|
"exports": { |
||||
|
"./*": { |
||||
|
"import": "./src/*.ts", |
||||
|
"require": "./src/*.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(`<!--app-head-->`, rendered.head ?? '') |
||||
|
.replace(`<!--app-html-->`, 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() |
||||
|
}) |
||||
|
} |
||||
@ -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" |
||||
|
} |
||||
|
} |
||||
@ -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}`) |
||||
|
}) |
||||
@ -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" |
||||
|
] |
||||
|
} |
||||
@ -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(`<!--app-head-->`, rendered.head ?? '') |
|
||||
.replace(`<!--app-html-->`, 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}`) |
|
||||
}) |
|
||||
@ -1,40 +0,0 @@ |
|||||
<script setup lang="ts"> |
|
||||
// This starter template is using Vue 3 <script setup> SFCs |
|
||||
// Check out https://vuejs.org/api/sfc-script-setup.html#script-setup |
|
||||
import HelloWorld from './components/HelloWorld.vue'; |
|
||||
import CookieDemo from './components/CookieDemo.vue'; |
|
||||
import DataFetch from './components/DataFetch.vue'; |
|
||||
import SimpleTest from './components/SimpleTest.vue'; |
|
||||
</script> |
|
||||
|
|
||||
<template> |
|
||||
<div> |
|
||||
<a href="https://vite.dev" target="_blank"> |
|
||||
<img src="/vite.svg" class="logo" alt="Vite logo" />AAA |
|
||||
</a> |
|
||||
<a href="https://vuejs.org/" target="_blank"> |
|
||||
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" /> |
|
||||
</a> |
|
||||
</div> |
|
||||
<ClientOnly> |
|
||||
<div>Only For Client</div> |
|
||||
</ClientOnly> |
|
||||
<HelloWorld msg="Vite + Vue" /> |
|
||||
<CookieDemo /> |
|
||||
<SimpleTest /> |
|
||||
<DataFetch /> |
|
||||
</template> |
|
||||
|
|
||||
<style scoped> |
|
||||
.logo { |
|
||||
height: 6em; |
|
||||
padding: 1.5em; |
|
||||
will-change: filter; |
|
||||
} |
|
||||
.logo:hover { |
|
||||
filter: drop-shadow(0 0 2em #646cffaa); |
|
||||
} |
|
||||
.logo.vue:hover { |
|
||||
filter: drop-shadow(0 0 2em #42b883aa); |
|
||||
} |
|
||||
</style> |
|
||||
@ -1,43 +0,0 @@ |
|||||
import { cloneVNode, createElementBlock, defineComponent, getCurrentInstance, h, InjectionKey, onMounted, provide, shallowRef, SlotsType, VNode } from "vue"; |
|
||||
|
|
||||
export const clientOnlySymbol: InjectionKey<boolean> = 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 `<ClientOnly>` 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) |
|
||||
} |
|
||||
} |
|
||||
}) |
|
||||
@ -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() |
|
||||
@ -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<string, string> }) { |
|
||||
// 创建 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 = ` |
|
||||
<script> |
|
||||
window.__SSR_CONTEXT__ = { |
|
||||
cache: new Map(${ssrData}), |
|
||||
cookies: ${cookieInit} |
|
||||
}; |
|
||||
</script> |
|
||||
` |
|
||||
|
|
||||
return { html, head, setCookies: ssrContext.setCookies || [] } |
|
||||
} |
|
||||
@ -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; |
|
||||
} |
|
||||
} |
|
||||
@ -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'], }) |
|
||||
], |
|
||||
}) |
|
||||
Loading…
Reference in new issue