Browse Source

feat: mono init

mono
dash 2 months ago
parent
commit
04e655166f
  1. 2
      README.md
  2. BIN
      bun.lockb
  3. 8
      internal/helper/package.json
  4. 67
      internal/helper/src/cookie.ts
  5. 11
      internal/helper/src/env.ts
  6. 23
      internal/helper/src/path.ts
  7. 62
      internal/x/components/ClientOnly.vue
  8. 0
      internal/x/composables/README.md
  9. 0
      internal/x/composables/cookieUtils.ts
  10. 10
      internal/x/composables/ssrContext.ts
  11. 0
      internal/x/composables/useCookie.ts
  12. 30
      internal/x/composables/useFetch.ts
  13. 5
      internal/x/package.json
  14. 31
      package.json
  15. 34
      packages/client/.gitignore
  16. 15
      packages/client/README.md
  17. 203
      packages/client/auto-imports.d.ts
  18. 5
      packages/client/components.d.ts
  19. 2
      packages/client/index.html
  20. 27
      packages/client/package.json
  21. 0
      packages/client/public/vite.svg
  22. 35
      packages/client/src/App.vue
  23. 0
      packages/client/src/assets/vue.svg
  24. 56
      packages/client/src/components/AiDemo/_/VueNodeRenderer.vue
  25. 31
      packages/client/src/components/AiDemo/_/sseData.ts
  26. 158
      packages/client/src/components/AiDemo/index.vue
  27. 4
      packages/client/src/components/CookieDemo.vue
  28. 3
      packages/client/src/components/DataFetch.vue
  29. 0
      packages/client/src/components/HelloWorld.vue
  30. 2
      packages/client/src/components/SimpleTest.vue
  31. 8
      packages/client/src/composables/useGlobal/index.ts
  32. 29
      packages/client/src/entry-client.ts
  33. 93
      packages/client/src/entry-server.ts
  34. 10
      packages/client/src/main.ts
  35. 11
      packages/client/src/pages/about/index.vue
  36. 19
      packages/client/src/pages/home/index.vue
  37. 5
      packages/client/src/pages/not-found/index.vue
  38. 16
      packages/client/src/router/index.ts
  39. 20
      packages/client/src/store/auth.ts
  40. 0
      packages/client/src/vite-env.d.ts
  41. 0
      packages/client/src/vue.d.ts
  42. 2
      packages/client/tsconfig.json
  43. 0
      packages/client/tsconfig.node.json
  44. 29
      packages/client/vite.config.ts
  45. 9
      packages/core/package.json
  46. 100
      packages/core/src/SsrMiddleWare.ts
  47. 20
      packages/server/package.json
  48. 29
      packages/server/src/api/main.ts
  49. 0
      packages/server/src/app.ts
  50. 20
      packages/server/src/booststap.ts
  51. 31
      packages/server/tsconfig.json
  52. 84
      server.ts
  53. 40
      src/App.vue
  54. 43
      src/components/ClientOnly.tsx
  55. 21
      src/entry-client.ts
  56. 38
      src/entry-server.ts
  57. 79
      src/style.css
  58. 13
      vite.config.ts

2
README.md

@ -1 +1,3 @@
# 基于koa实现的简易ssr
- https://segmentfault.com/a/1190000042389086

BIN
bun.lockb

Binary file not shown.

8
internal/helper/package.json

@ -0,0 +1,8 @@
{
"name": "helper",
"exports": {
"./*": {
"import": "./src/*.ts"
}
}
}

67
internal/helper/src/cookie.ts

@ -0,0 +1,67 @@
export type CookieOptions = {
path?: string
domain?: string
expires?: Date | string | number
maxAge?: number
secure?: boolean
httpOnly?: boolean
sameSite?: 'lax' | 'strict' | 'none'
}
export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
const enc = encodeURIComponent
let cookie = `${name}=${enc(value)}`
if (options.maxAge != null) cookie += `; Max-Age=${Math.floor(options.maxAge)}`
if (options.expires != null) {
const date = typeof options.expires === 'number' ? new Date(options.expires) : new Date(options.expires)
cookie += `; Expires=${date.toUTCString()}`
}
if (options.domain) cookie += `; Domain=${options.domain}`
if (options.path) cookie += `; Path=${options.path}`
if (options.secure) cookie += `; Secure`
if (options.httpOnly) cookie += `; HttpOnly`
if (options.sameSite) cookie += `; SameSite=${options.sameSite === 'none' ? 'None' : options.sameSite === 'lax' ? 'Lax' : 'Strict'}`
return cookie
}
export function parseCookieHeader(header: string | undefined): Record<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()
})
*/

11
internal/helper/src/env.ts

@ -0,0 +1,11 @@
const isProduction = process.env.NODE_ENV === 'production'
const port = process.env.PORT || 5173
const base = process.env.BASE || '/'
export const Env = {
isProduction,
port: Number(port),
base,
}

23
internal/helper/src/path.ts

@ -0,0 +1,23 @@
import path from "node:path"
import fs from "node:fs/promises"
const isProduction = process.env.NODE_ENV === 'production'
export function getPathByRoot(...argus: string[]) {
return path.resolve(import.meta.dir, '../../..', ...argus)
}
const templateHtml = isProduction
? await fs.readFile(getPathByRoot('packages', 'client/index.html'), 'utf-8')
: ''
export function getDevPathFromClient(...argus: string[]) {
return getPathByRoot('packages', 'client', ...argus)
}
export function getDevPathFromServer(...argus: string[]) {
return getPathByRoot('packages', 'server', ...argus)
}
export function getProdPath(...argus: string[]) {
return getPathByRoot('dist', ...argus)
}

62
internal/x/components/ClientOnly.vue

@ -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
src/compose/README.md → internal/x/composables/README.md

0
src/compose/cookieUtils.ts → internal/x/composables/cookieUtils.ts

10
src/compose/ssrContext.ts → internal/x/composables/ssrContext.ts

@ -3,6 +3,7 @@
export interface SSRContext {
cache?: Map<string, any>
cookies?: Record<string, string>
piniaState?: object
setCookies?: string[]
[key: string]: any
}
@ -10,11 +11,16 @@ export interface SSRContext {
export function createSSRContext(): SSRContext {
return {
cache: new Map(),
piniaState: {},
cookies: {},
setCookies: []
}
}
/**
* SSR window
*
*/
export function hydrateSSRContext(context: SSRContext): void {
if (typeof window !== 'undefined') {
if (context.cache && Array.isArray(context.cache)) {
@ -24,6 +30,10 @@ export function hydrateSSRContext(context: SSRContext): void {
}
}
/**
* SSR
*
*/
export function clearSSRContext(): void {
if (typeof window !== 'undefined') {
delete (window as any).__SSR_CONTEXT__

0
src/compose/useCookie.ts → internal/x/composables/useCookie.ts

30
src/compose/useFetch.ts → internal/x/composables/useFetch.ts

@ -217,33 +217,3 @@ export function useFetch<T = any>(
execute
}
}
/**
* SSR
*
*/
// 删除 createSSRContext,这个职责移动到 ssrContext.ts
/**
* SSR window
*
*/
export function hydrateSSRContext(context: SSRContext): void {
if (typeof window !== 'undefined') {
// 确保 Map 对象正确重建
if (context.cache && Array.isArray(context.cache)) {
context.cache = new Map(context.cache)
}
(window as any).__SSR_CONTEXT__ = context
}
}
/**
* SSR
*
*/
export function clearSSRContext(): void {
if (typeof window !== 'undefined') {
delete (window as any).__SSR_CONTEXT__
}
}

5
internal/x/package.json

@ -0,0 +1,5 @@
{
"name": "x",
"main": "index.ts",
"type": "module"
}

31
package.json

@ -2,33 +2,32 @@
"name": "koa-ssr",
"type": "module",
"workspaces": [
"packages/*"
"packages/*",
"internal/*"
],
"scripts": {
"dev": "bun run --watch server.ts",
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.ts --outDir dist/server",
"preview": "cross-env NODE_ENV=production bun run server.ts",
"check": "vue-tsc"
"dev": "bun run --hot packages/server/src/booststap.ts",
"preview": "cross-env NODE_ENV=production bun run packages/server/src/booststap.ts",
"tsc:booststap": "tsc packages/booststap/src/server.ts --outDir dist --module es2022 --target es2022 --lib es2022,dom --moduleResolution bundler --esModuleInterop --skipLibCheck --forceConsistentCasingInFileNames --noEmit false --incremental false",
"tsc:server": "tsc packages/server/src/**/*.ts --outDir dist/server --module es2022 --target es2022 --lib es2022,dom --moduleResolution bundler --esModuleInterop --skipLibCheck --forceConsistentCasingInFileNames --noEmit false --incremental false"
},
"devDependencies": {
"@types/bun": "latest",
"@types/koa": "^3.0.0",
"@types/koa-send": "^4.1.6",
"@types/koa-compose": "^3.2.8",
"client": "workspace:*",
"core": "workspace:*",
"cross-env": "^10.1.0",
"helper": "workspace:*",
"server": "workspace:*",
"unplugin-vue-components": "^29.1.0",
"vue-tsc": "^3.1.0"
"vite-plugin-devtools-json": "^1.0.0"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"koa": "^3.0.1",
"koa-connect": "^2.1.0",
"koa-send": "^5.0.1",
"vite": "^7.1.7",
"vue": "^3.5.22"
"koa-compose": "^4.1.0",
"pinia": "^3.0.3",
"vite": "^7.1.7"
}
}

34
packages/client/.gitignore

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

15
packages/client/README.md

@ -0,0 +1,15 @@
# client
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.2.21. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

203
packages/client/auto-imports.d.ts

@ -0,0 +1,203 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const clearSSRContext: typeof import('../../internal/x/composables/ssrContext')['clearSSRContext']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const createSSRContext: typeof import('../../internal/x/composables/ssrContext')['createSSRContext']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
const h: typeof import('vue')['h']
const hydrateSSRContext: typeof import('../../internal/x/composables/ssrContext')['hydrateSSRContext']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const isShallow: typeof import('vue')['isShallow']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const parseCookieHeader: typeof import('../../internal/x/composables/cookieUtils')['parseCookieHeader']
const parseDocumentCookies: typeof import('../../internal/x/composables/cookieUtils')['parseDocumentCookies']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const render: typeof import('../../internal/x/composables/README.md')['render']
const resolveComponent: typeof import('vue')['resolveComponent']
const resolveSSRContext: typeof import('../../internal/x/composables/ssrContext')['resolveSSRContext']
const serializeCookie: typeof import('../../internal/x/composables/cookieUtils')['serializeCookie']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useAuthStore: typeof import('./src/store/auth')['useAuthStore']
const useCookie: typeof import('../../internal/x/composables/useCookie')['useCookie']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useFetch: typeof import('../../internal/x/composables/useFetch')['useFetch']
const useGlobal: typeof import('./src/composables/useGlobal/index')['useGlobal']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
// @ts-ignore
export type { CookieOptions } from '../../internal/x/composables/cookieUtils'
import('../../internal/x/composables/cookieUtils')
// @ts-ignore
export type { SSRContext } from '../../internal/x/composables/ssrContext'
import('../../internal/x/composables/ssrContext')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<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']>
}
}

5
components.d.ts → packages/client/components.d.ts

@ -8,10 +8,13 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
AiDemo: typeof import('./src/components/AiDemo/index.vue')['default']
ClientOnly: typeof import('./../../internal/x/components/ClientOnly.vue')['default']
CookieDemo: typeof import('./src/components/CookieDemo.vue')['default']
DataFetch: typeof import('./src/components/DataFetch.vue')['default']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SimpleTest: typeof import('./src/components/SimpleTest.vue')['default']
}
}

2
index.html → packages/client/index.html

@ -11,7 +11,7 @@
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/entry-client.ts"></script>
<script type="module" src="./src/entry-client.ts"></script>
</body>
</html>

27
packages/client/package.json

@ -0,0 +1,27 @@
{
"name": "client",
"type": "module",
"scripts": {
"build": "bun run build:client && bun run build:server",
"build:client": "vite build --ssrManifest --outDir ../../dist/client --base ./",
"build:server": "vite build --ssr src/entry-server.ts --outDir ../../dist/server",
"check": "vue-tsc"
},
"devDependencies": {
"unplugin-vue-components": "^29.1.0",
"vue-tsc": "^3.1.0"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"dompurify": "^3.2.7",
"htmlparser2": "^10.0.0",
"marked": "^16.3.0",
"unplugin-auto-import": "^20.2.0",
"vue": "^3.5.22",
"vue-router": "^4.5.1",
"x": "workspace:*"
}
}

0
public/vite.svg → packages/client/public/vite.svg

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

35
packages/client/src/App.vue

@ -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>

0
src/assets/vue.svg → packages/client/src/assets/vue.svg

Before

Width:  |  Height:  |  Size: 496 B

After

Width:  |  Height:  |  Size: 496 B

56
packages/client/src/components/AiDemo/_/VueNodeRenderer.vue

@ -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>

31
packages/client/src/components/AiDemo/_/sseData.ts

@ -0,0 +1,31 @@
export default [
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "**asasa**\n" },
{ event: "message", answer: "![啊啊啊](https://ts1.tc.mm.bing.net/th/id/R-C.823270fc68b9c58f0d9b3feb92b7b172?rik=aubbEBMSC86e%2bw&riu=http%3a%2f%2fimg95.699pic.com%2fphoto%2f50038%2f1181.jpg_wh860.jpg&ehk=iQboj4JMLLfDitOL7VJtSktED0AE%2f7Fyxfik0GTJkyQ%3d&risl=&pid=ImgRaw&r=0)asd\n" },
{ event: "message", answer: "```\nasdaaaasasaasaas\n" },
{ event: "message", answer: "asdsaa\n" },
{ event: "message", answer: "console.log(as)\n" },
{ event: "message", answer: "asa\n```\n\n" },
{ event: "message", answer: "<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" },
]

158
packages/client/src/components/AiDemo/index.vue

@ -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>

4
src/components/CookieDemo.vue → packages/client/src/components/CookieDemo.vue

@ -13,9 +13,9 @@
</template>
<script setup lang="ts">
import ClientOnly from './ClientOnly'
import ClientOnly from './ClientOnly.vue'
import { onMounted, onServerPrefetch } from 'vue'
import { useCookie } from '../compose/useCookie'
import { useCookie } from '../composables/useCookie'
const cookie = useCookie('demo_token', { path: '/', sameSite: 'lax' })
// cookie HttpOnly

3
src/components/DataFetch.vue → packages/client/src/components/DataFetch.vue

@ -26,9 +26,6 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watchEffect } from 'vue'
import { useFetch } from '../compose/useFetch'
//
const isHydrated = ref(false)

0
src/components/HelloWorld.vue → packages/client/src/components/HelloWorld.vue

2
src/components/SimpleTest.vue → packages/client/src/components/SimpleTest.vue

@ -12,7 +12,7 @@
</template>
<script setup lang="ts">
import { useFetch } from '../compose/useFetch'
import { useFetch } from '../composables/useFetch'
// 使 API
const { data, error, pending } = useFetch(

8
packages/client/src/composables/useGlobal/index.ts

@ -0,0 +1,8 @@
export function useGlobal() {
const openCache = ref(true)
return {
openCache
}
}

29
packages/client/src/entry-client.ts

@ -0,0 +1,29 @@
import { createApp } from "./main"
import { hydrateSSRContext, clearSSRContext } from 'x/composables/ssrContext'
// 水合 SSR 上下文(如果存在)
let ssrContext = null
if (typeof window !== 'undefined' && (window as any).__SSR_CONTEXT__) {
ssrContext = (window as any).__SSR_CONTEXT__
console.log('[Client] 水合 SSR 上下文:', ssrContext)
hydrateSSRContext(ssrContext)
} else {
console.log('[Client] 未找到 SSR 上下文')
}
// 使用相同的 SSR 上下文创建应用
const { app, pinia, router } = createApp(ssrContext)
if (ssrContext) {
pinia.state.value = ssrContext.piniaState
}
// 等待路由准备就绪,然后挂载应用
router.isReady().then(() => {
console.log('[Client] 路由已准备就绪,挂载应用')
app.mount('#app')
// 水合完成后清除 SSR 上下文
clearSSRContext()
})

93
packages/client/src/entry-server.ts

@ -0,0 +1,93 @@
import { renderToString } from 'vue/server-renderer'
import { createApp } from './main'
import { createSSRContext } from 'x/composables/ssrContext'
import { basename } from 'node:path'
export async function render(url: string, manifest: any, init?: { cookies?: Record<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 ''
}
}

10
src/main.ts → packages/client/src/main.ts

@ -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 }
}

11
packages/client/src/pages/about/index.vue

@ -0,0 +1,11 @@
<template>
<div>
<h1 @click="$router.back()">About Page</h1>
</div>
</template>
<script setup lang="ts">
defineOptions({
name: "about"
})
</script>

19
packages/client/src/pages/home/index.vue

@ -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>

5
packages/client/src/pages/not-found/index.vue

@ -0,0 +1,5 @@
<template>
<div>
NotFound
</div>
</template>

16
packages/client/src/router/index.ts

@ -0,0 +1,16 @@
// src/router.js
import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router';
import NotFound from '../pages/not-found/index.vue';
export default function createSSRRouter() {
return createRouter({
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(), // 使用内存模式
routes: [
{ name: "home", path: '/', meta: { cache: true }, component: () => import('../pages/home/index.vue') },
{ name: "about", path: '/about', meta: { cache: true }, component: () => import('../pages/about/index.vue') },
// 404
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
],
});
}

20
packages/client/src/store/auth.ts

@ -0,0 +1,20 @@
import { defineStore } from "pinia";
// export const useAuthStore = defineStore("auth", {
// state: () => ({
// user: null as null | { name: string },
// }),
// actions: {
// setUser(user: { name: string }) {
// this.user = user;
// }
// }
// });
export const useAuthStore = defineStore("auth", () => {
const user = ref<null | { name: string }>(null);
function setUser(u: { name: string }) {
user.value = u;
}
return { user, setUser };
});

0
src/vite-env.d.ts → packages/client/src/vite-env.d.ts

0
src/vue.d.ts → packages/client/src/vue.d.ts

2
tsconfig.json → packages/client/tsconfig.json

@ -21,6 +21,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "components.d.ts"],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "components.d.ts", "auto-imports.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}

0
tsconfig.node.json → packages/client/tsconfig.node.json

29
packages/client/vite.config.ts

@ -0,0 +1,29 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite'
import devtoolsJson from 'vite-plugin-devtools-json';
// https://vite.dev/config/
export default defineConfig({
build: {
emptyOutDir: true
},
plugins: [
devtoolsJson(),
vue(),
Components({
dts: true,
dirs: ['src/components', '../../internal/x/components'],
globsExclude: ["**/_*/**/*"]
}),
AutoImport({
dts: true,
dtsMode: "overwrite",
ignore: ["**/_*/**/*"],
imports: ['vue', 'vue-router', 'pinia'],
dirs: ['./src/composables/**/*', '../../internal/x/composables/**', "./src/store/**/*"],
vueTemplate: true,
}),
],
})

9
packages/core/package.json

@ -0,0 +1,9 @@
{
"name": "core",
"exports": {
"./*": {
"import": "./src/*.ts",
"require": "./src/*.ts"
}
}
}

100
packages/core/src/SsrMiddleWare.ts

@ -0,0 +1,100 @@
import fs from 'node:fs/promises'
import { getPathByRoot } from "helper/path"
import { parseCookieHeader } from "helper/cookie"
import { Env } from "helper/env"
import { ViteDevServer } from 'vite'
import Send from 'koa-send'
import type Koa from 'koa'
import c2k from 'koa-connect'
const isProduction = Env.isProduction
const base = Env.base
const templateHtml = isProduction
? await fs.readFile(getPathByRoot('dist', 'client/index.html'), 'utf-8')
: ''
export async function SsrMiddleWare(app: Koa, options?: { onDevViteClose?: Function }) {
let vite: ViteDevServer
if (!isProduction) {
// Dev mode: create Vite server in middleware mode.
const { createServer } = await import('vite')
vite = await createServer({
server: { middlewareMode: true },
configFile: getPathByRoot('packages', 'client/vite.config.ts'),
root: getPathByRoot('packages', 'client'),
appType: 'custom',
base,
})
app.use(c2k(vite.middlewares))
vite.httpServer?.on("close", () => {
vite.close()
options?.onDevViteClose?.()
})
} else {
// Production mode: serve pre-built static assets.
app.use(async (ctx, next) => {
if (ctx.originalUrl === "/.well-known/appspecific/com.chrome.devtools.json") return await next()
try {
await Send(ctx, ctx.path, { root: getPathByRoot('dist/client'), index: false });
if (ctx.status === 404) {
await next()
}
} catch (error) {
if (ctx.status === 404) {
await next()
} else {
throw error
}
}
})
}
// Handle every other route with SSR.
app.use(async (ctx, next) => {
if (!ctx.originalUrl.startsWith(base)) return await next()
try {
const url = ctx.originalUrl.replace(base, '')
let template
let render
let manifest
if (!isProduction) {
// Always read fresh template in development
template = await fs.readFile(getPathByRoot('packages', 'client/index.html'), 'utf-8')
template = await vite.transformIndexHtml(url, template)
manifest = {}
render = (await vite.ssrLoadModule(getPathByRoot('packages', 'client/src/entry-server.ts'))).render
} else {
manifest = await fs.readFile(getPathByRoot('dist', 'client/.vite/ssr-manifest.json'), 'utf-8')
template = templateHtml
// @ts-ignore
render = (await import(getPathByRoot('dist', 'server/entry-server.js'))).render
}
const cookies = parseCookieHeader(ctx.request.headers['cookie'] as string)
const rendered = await render(url, manifest, { cookies })
const html = template
.replace(`<!--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()
})
}

20
packages/server/package.json

@ -0,0 +1,20 @@
{
"name": "server",
"type": "module",
"exports": {
"./*": {
"import": "./src/*.ts",
"types": "./src/*.d.ts"
}
},
"scripts": {},
"devDependencies": {
"@types/koa": "^3.0.0",
"@types/koa-send": "^4.1.6"
},
"dependencies": {
"koa": "^3.0.1",
"koa-connect": "^2.1.0",
"koa-send": "^5.0.1"
}
}

29
server/main.ts → packages/server/src/api/main.ts

@ -1,8 +1,8 @@
import { parseCookieHeader, serializeCookie } from "../src/compose/cookieUtils";
import app from "./app";
import { parseCookieHeader, serializeCookie } from "helper/cookie";
import app from "../app";
export function bootstrapServer() {
async function fetchFirstSuccess(urls) {
async function fetchFirstSuccess(urls: string[]) {
for (const url of urls) {
try {
const res = await fetch(url, {
@ -32,21 +32,22 @@ export function bootstrapServer() {
}
app.use(async (ctx, next) => {
const cookies = parseCookieHeader(ctx.request.headers.cookie as string);
// const cookies = parseCookieHeader(ctx.request.headers.cookie as string);
// 读取
const token = cookies["demo_2token"];
// const token = cookies["demo_2token"];
// 写入(HttpOnly 更安全)
if (!token) {
const setItem = serializeCookie("demo_2token", "from-mw", {
httpOnly: true,
path: "/",
sameSite: "lax",
});
ctx.set("Set-Cookie", [setItem]);
}
// // 写入(HttpOnly 更安全)
// if (!token) {
// const setItem = serializeCookie("demo_2token", "from-mw", {
// httpOnly: true,
// path: "/",
// sameSite: "lax",
// });
// ctx.set("Set-Cookie", [setItem]);
// }
if (ctx.originalUrl !== "/api/pics/random") return await next();
ctx.body = `Hello World`
const { type, data } = await fetchFirstSuccess([
"https://api.miaomc.cn/image/get",
]);

0
server/app.ts → packages/server/src/app.ts

20
packages/server/src/booststap.ts

@ -0,0 +1,20 @@
import app from "./app"
import { bootstrapServer } from "./api/main"
import { SsrMiddleWare } from "core/SsrMiddleWare"
import { Env } from "helper/env"
bootstrapServer()
SsrMiddleWare(app, {
onDevViteClose() {
console.log("Vite dev server closed")
if (server) {
server.close()
console.log('Server closed')
}
}
})
const server = app.listen(Env.port, () => {
console.log(`Server started at http://localhost:${Env.port}`)
})

31
packages/server/tsconfig.json

@ -0,0 +1,31 @@
{
"compilerOptions": {
//
"target": "esnext",
// ECMA
"useDefineForClassFields": true,
// ts
"lib": [
"esnext"
],
// commonjs,umd
"module": "esnext",
//
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src/**/*.ts"
]
}

84
server.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}`)
})

40
src/App.vue

@ -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>

43
src/components/ClientOnly.tsx

@ -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)
}
}
})

21
src/entry-client.ts

@ -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()

38
src/entry-server.ts

@ -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 || [] }
}

79
src/style.css

@ -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;
}
}

13
vite.config.ts

@ -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…
Cancel
Save