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 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 } |
|||
return { app, router, pinia } |
|||
} |
|||
@ -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