├── .changeset ├── README.md └── config.json ├── .github └── workflows │ └── gh-pages.yaml ├── .gitignore ├── .husky └── pre-commit ├── LICENSE ├── README.md ├── eslint.config.js ├── package.json ├── packages ├── create-servite │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── index.js │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── docs │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── postcss.config.js │ ├── public │ │ └── vite.svg │ ├── src │ │ ├── assets │ │ │ ├── error-display.png │ │ │ ├── loader-action.png │ │ │ ├── nested-routes.png │ │ │ ├── photo.avif │ │ │ └── ssr.png │ │ ├── components │ │ │ ├── Bento │ │ │ │ └── index.tsx │ │ │ ├── Callout │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ │ ├── Mdx │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ │ ├── ProgressiveBlur │ │ │ │ └── index.tsx │ │ │ ├── Search │ │ │ │ └── index.tsx │ │ │ ├── SidebarLink │ │ │ │ └── index.tsx │ │ │ └── Toc │ │ │ │ └── index.tsx │ │ ├── config │ │ │ └── sidebar.tsx │ │ ├── hooks │ │ │ └── use-handle.ts │ │ ├── pages │ │ │ ├── (en) │ │ │ │ └── page.tsx │ │ │ ├── a │ │ │ │ ├── page.data.ts │ │ │ │ └── page.tsx │ │ │ ├── b │ │ │ │ ├── page.data.ts │ │ │ │ └── page.tsx │ │ │ ├── layout.css │ │ │ ├── layout.tsx │ │ │ ├── test.mdx │ │ │ └── zh │ │ │ │ ├── guide │ │ │ │ ├── config.mdx │ │ │ │ ├── csr.mdx │ │ │ │ ├── custom-logger.mdx │ │ │ │ ├── custom-server-render.mdx │ │ │ │ ├── deploy.mdx │ │ │ │ ├── directory-structure.mdx │ │ │ │ ├── index.mdx │ │ │ │ ├── islands.mdx │ │ │ │ ├── middlewares.mdx │ │ │ │ ├── routes-data.mdx │ │ │ │ ├── routes-error.mdx │ │ │ │ ├── routes.mdx │ │ │ │ ├── runtime.mdx │ │ │ │ ├── ssg.mdx │ │ │ │ ├── ssr.mdx │ │ │ │ ├── start.mdx │ │ │ │ └── unified-invocation.mdx │ │ │ │ └── page.tsx │ │ ├── utils │ │ │ ├── scroll.ts │ │ │ ├── throttle.ts │ │ │ └── url.ts │ │ └── vite-env.d.ts │ ├── tailwind.config.mjs │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── examples │ └── basic │ │ ├── app.config.ts │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── src │ │ ├── pages │ │ │ ├── (group) │ │ │ │ ├── layout.tsx │ │ │ │ └── user │ │ │ │ │ └── page.tsx │ │ │ ├── [...] │ │ │ │ └── page.tsx │ │ │ ├── home │ │ │ │ ├── [...] │ │ │ │ │ └── page.tsx │ │ │ │ ├── [id] │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.data.ts │ │ │ │ ├── page.tsx │ │ │ │ └── server-fns.ts │ │ │ ├── layout.css │ │ │ ├── layout.tsx │ │ │ └── settings │ │ │ │ ├── optional │ │ │ │ └── [[id]] │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ └── server │ │ │ ├── middlewares │ │ │ ├── ctx.ts │ │ │ ├── html.ts │ │ │ ├── log.ts │ │ │ └── res.ts │ │ │ └── routes │ │ │ └── user.get.ts │ │ ├── tailwind.config.mjs │ │ └── tsconfig.json └── servite │ ├── CHANGELOG.md │ ├── README.md │ ├── config.d.ts │ ├── env.d.ts │ ├── package.json │ ├── runtime │ ├── components.d.ts │ ├── fetch.d.ts │ ├── helmet.d.ts │ ├── island.d.ts │ ├── mdx.css │ ├── mdx.d.ts │ ├── router.d.ts │ └── server.d.ts │ ├── src │ ├── config │ │ ├── fs-router.ts │ │ └── index.ts │ ├── global.d.ts │ ├── libs │ │ └── react-helmet-async │ │ │ ├── index.d.ts │ │ │ └── index.js │ ├── plugins │ │ ├── hmr.ts │ │ ├── islands.ts │ │ └── unified-invocation.ts │ ├── runtime │ │ ├── components.tsx │ │ ├── fetch.ts │ │ ├── helmet.ts │ │ ├── island │ │ │ ├── Island.tsx │ │ │ ├── index.ts │ │ │ └── shared.ts │ │ ├── mdx.ts │ │ ├── router.tsx │ │ └── server.ts │ ├── server-fns │ │ └── handler.ts │ ├── server │ │ ├── handler.ts │ │ └── on-before-response.ts │ ├── ssr │ │ ├── RouterHydration.tsx │ │ ├── client-handler.tsx │ │ ├── html-tags.tsx │ │ ├── routes.tsx │ │ ├── ssr-handler.tsx │ │ └── transform-html.ts │ ├── types │ │ └── index.ts │ └── utils │ │ ├── index.ts │ │ └── md.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": ["playground-*", "docs"] 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yaml: -------------------------------------------------------------------------------- 1 | name: gh-pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | deploy-docs: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Install pnpm 18 | uses: pnpm/action-setup@v4 19 | 20 | - name: Set node version to 20 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | cache: 'pnpm' 25 | 26 | - name: Install dependencies 27 | run: pnpm install 28 | 29 | - name: Deploy with gh-pages 30 | run: | 31 | git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git 32 | pnpm deploy:gh-pages -u "github-actions-bot " 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | node_modules 4 | *.log 5 | dist 6 | .output 7 | .vinxi 8 | .vercel 9 | app.config.timestamp_* 10 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 codpoe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Servite 2 | 3 | A full stack React framework powered by [vinxi](https://github.com/nksaraf/vinxi). 4 | 5 | To check out docs, visit https://servite.vercel.app. 6 | 7 | > For v1, visit https://github.com/Codpoe/servite/tree/v1 instead. 8 | 9 | ## Features 10 | 11 | - 🌟 SSR by default 12 | - ⚡️ SSG easily 13 | - 🖥 Automatic fallback to CSR 14 | - 🏝 Islands architecture 15 | - 🔥 Powered by vinxi / nitro 16 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { codpoeConfig } from '@codpoe/eslint-config'; 2 | 3 | export default [ 4 | { 5 | ignores: ['packages/servite/src/libs'], 6 | }, 7 | ...codpoeConfig({ globals: ['node', 'browser'] }), 8 | ]; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "servite-monorepo", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "A full stack React framework", 6 | "keywords": [ 7 | "react", 8 | "metaframework", 9 | "vinxi" 10 | ], 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/Codpoe/servite.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/Codpoe/servite/issues" 18 | }, 19 | "author": "Codpoe (https://github.com/codpoe)", 20 | "engines": { 21 | "node": ">=18", 22 | "pnpm": ">=9" 23 | }, 24 | "packageManager": "pnpm@9.12.1", 25 | "type": "module", 26 | "files": [ 27 | "dist" 28 | ], 29 | "exports": { 30 | ".": "./dist/index.js" 31 | }, 32 | "typesVersions": { 33 | "*": { 34 | ".": [ 35 | "./dist/index.d.ts" 36 | ] 37 | } 38 | }, 39 | "scripts": { 40 | "prepare": "husky", 41 | "dev": "pnpm --filter servite dev", 42 | "dev:docs": "pnpm --filter docs dev", 43 | "build": "pnpm --filter servite build", 44 | "build-docs": "pnpm build && pnpm --filter docs build", 45 | "deploy:gh-pages": "pnpm build && pnpm --filter docs run deploy:gh-pages", 46 | "deploy:vercel": "pnpm build && pnpm --filter docs run deploy:vercel", 47 | "bump": "pnpm changeset && pnpm changeset version", 48 | "release": "pnpm -r publish" 49 | }, 50 | "lint-staged": { 51 | "*.{js,jsx,ts,tsx}": "eslint --fix" 52 | }, 53 | "devDependencies": { 54 | "@changesets/cli": "^2.27.9", 55 | "@codpoe/eslint-config": "^1.0.4", 56 | "@types/node": "^22.5.2", 57 | "eslint": "^9.9.1", 58 | "husky": "^9.1.5", 59 | "lint-staged": "^15.2.10", 60 | "prettier": "^3.3.3", 61 | "rimraf": "^6.0.1", 62 | "typescript": "^5.6.3" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/create-servite/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # create-servite 2 | 3 | ## 2.0.2 4 | 5 | ### Patch Changes 6 | 7 | - fix: modify .gitignore 8 | 9 | ## 2.0.1 10 | 11 | ### Patch Changes 12 | 13 | - chore: update 14 | 15 | ## 2.0.0 16 | 17 | ### Major Changes 18 | 19 | All new version. 20 | 21 | ## 1.0.4 22 | 23 | ### Patch Changes 24 | 25 | - fix: fix helmet ssr error 26 | 27 | ## 1.0.3 28 | 29 | ### Patch Changes 30 | 31 | - fix: add esm ext to fix esm load error 32 | - fix: prepend viteConfig.base to route path by default 33 | 34 | ## 1.0.2 35 | 36 | ### Patch Changes 37 | 38 | - fix: export helmet 39 | 40 | ## 1.0.1 41 | 42 | ### Patch Changes 43 | 44 | - fix: add more cjs deps in optimizeDeps 45 | 46 | ## 1.0.0 47 | 48 | ### Major Changes 49 | 50 | - chore: upgrade all deps 51 | 52 | ## 0.1.0 (2022-12-15) 53 | 54 | Initial release. 55 | -------------------------------------------------------------------------------- /packages/create-servite/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 codpoe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/create-servite/README.md: -------------------------------------------------------------------------------- 1 | # create-servite 2 | 3 | ## Scaffolding Your First Servite Project 4 | 5 | ```bash 6 | # npm 7 | $ npm create servite 8 | 9 | # yarn 10 | $ yarn create servite 11 | 12 | # pnpm 13 | $ pnpm create servite 14 | ``` 15 | 16 | It is strongly recommended to use typescript!run: 17 | 18 | ```bash 19 | # npm 6.x 20 | $ npm create servite my-app 21 | 22 | # npm 7+, extra double-dash is needed: 23 | $ npm create servite -- my-app 24 | 25 | # yarn 26 | $ yarn create servite my-app 27 | 28 | # pnpm 29 | $ pnpm create servite my-app 30 | ``` 31 | 32 | You can use `.` for the project name to scaffold in the current directory. 33 | -------------------------------------------------------------------------------- /packages/create-servite/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import './dist/index.js'; 3 | -------------------------------------------------------------------------------- /packages/create-servite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-servite", 3 | "version": "2.0.2", 4 | "license": "MIT", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/Codpoe/servite.git", 8 | "directory": "packages/create-servite" 9 | }, 10 | "author": "Codpoe ", 11 | "engines": { 12 | "node": "^18.0.0 || >=20.0.0" 13 | }, 14 | "type": "module", 15 | "bugs": { 16 | "url": "https://github.com/Codpoe/servite/issues" 17 | }, 18 | "homepage": "https://github.com/Codpoe/servite/tree/master/packages/create-servite#readme", 19 | "files": [ 20 | "dist", 21 | "index.js" 22 | ], 23 | "main": "index.js", 24 | "bin": { 25 | "create-servite": "index.js" 26 | }, 27 | "scripts": { 28 | "dev": "tsc -w", 29 | "build": "rimraf dist && tsc", 30 | "prepublishOnly": "pnpm build" 31 | }, 32 | "dependencies": { 33 | "execa": "^9.4.0", 34 | "minimist": "^1.2.8", 35 | "picocolors": "^1.1.0", 36 | "prompts": "^2.4.2" 37 | }, 38 | "devDependencies": { 39 | "@types/minimist": "^1.2.5", 40 | "@types/prompts": "^2.4.9" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/create-servite/src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import path from 'node:path'; 3 | import fs from 'node:fs'; 4 | import minimist from 'minimist'; 5 | import { execaCommand } from 'execa'; 6 | import prompts, { Answers } from 'prompts'; 7 | import colors from 'picocolors'; 8 | 9 | const argv = minimist<{ ts?: boolean }>(process.argv.slice(2), { 10 | string: ['_'], 11 | }); 12 | 13 | const cwd = process.cwd(); 14 | 15 | const defaultTargetDir = 'servite-app'; 16 | const serviteVersion = '^2.0.0'; 17 | const vinxiVersion = '^0.4.3'; 18 | 19 | const viteConfigContent = `\ 20 | import { defineConfig } from 'servite/config'; 21 | 22 | // https://servite.vercel.app/zh/guide/config 23 | export default defineConfig({}); 24 | `; 25 | 26 | const demoPageContent = `export default function Page() { 27 | return ( 28 |
Hello World
29 | ); 30 | } 31 | `; 32 | 33 | function formatTargetDir(dir?: string) { 34 | return dir?.trim().replace(/\/+$/, ''); 35 | } 36 | 37 | function isEmpty(path: string) { 38 | const files = fs.readdirSync(path); 39 | return files.length === 0 || (files.length === 1 && files[0] === '.git'); 40 | } 41 | 42 | function emptyDir(dir: string, ignore?: string[]) { 43 | if (!fs.existsSync(dir)) { 44 | return; 45 | } 46 | for (const file of fs.readdirSync(dir)) { 47 | if (ignore?.includes(file)) { 48 | continue; 49 | } 50 | fs.rmSync(path.resolve(dir, file), { recursive: true, force: true }); 51 | } 52 | } 53 | 54 | async function init() { 55 | let targetDir = formatTargetDir(argv._[0]) || ''; 56 | let answers: Answers<'appName' | 'overwrite'>; 57 | 58 | try { 59 | answers = await prompts([ 60 | { 61 | type: targetDir ? null : 'text', 62 | name: 'appName', 63 | message: 'App name:', 64 | initial: defaultTargetDir, 65 | onState: state => 66 | (targetDir = formatTargetDir(state.value) || defaultTargetDir), 67 | }, 68 | { 69 | type: () => 70 | !fs.existsSync(targetDir!) || isEmpty(targetDir!) ? null : 'select', 71 | name: 'overwrite', 72 | message: () => 73 | (targetDir === '.' 74 | ? 'Current directory' 75 | : `Target directory "${targetDir}"`) + 76 | ` is not empty. Please choose how to proceed:`, 77 | initial: 0, 78 | choices: [ 79 | { 80 | title: 'Remove existing files and continue', 81 | value: 'yes', 82 | }, 83 | { 84 | title: 'Cancel operation', 85 | value: 'no', 86 | }, 87 | { 88 | title: 'Ignore files and continue', 89 | value: 'ignore', 90 | }, 91 | ], 92 | }, 93 | { 94 | type: (_, { overwrite }: { overwrite?: string }) => { 95 | if (overwrite === 'no') { 96 | throw new Error(colors.red('✖') + ' Operation cancelled'); 97 | } 98 | return null; 99 | }, 100 | name: 'overwriteChecker', 101 | }, 102 | ]); 103 | } catch (cancelled) { 104 | console.log(cancelled); 105 | return; 106 | } 107 | 108 | const root = path.join(cwd, targetDir); 109 | 110 | if (answers.overwrite === 'yes') { 111 | emptyDir(root, ['.git']); 112 | } else if (!fs.existsSync(root)) { 113 | fs.mkdirSync(root, { recursive: true }); 114 | } 115 | 116 | const appName = path.basename(targetDir === '.' ? cwd : targetDir); 117 | const [, pkgManager = 'npm', version = ''] = 118 | process.env.npm_config_user_agent?.match(/^(.*?)\/(\S*)/) || []; 119 | const isNpm7Plus = 120 | pkgManager === 'npm' && (!version || Number(version.split('.')[0]) >= 7); 121 | 122 | const result = execaCommand( 123 | `${pkgManager} create vite@latest ${appName} ${isNpm7Plus ? '--' : ''} --template react-ts ${answers.overwrite ? `--overwrite ${answers.overwrite}` : ''}`, 124 | { 125 | stdout: 'pipe', 126 | }, 127 | ); 128 | 129 | const successLines: string[] = []; 130 | 131 | for await (const line of result) { 132 | if (line.startsWith('Done') || successLines.length) { 133 | successLines.push(line); 134 | } else { 135 | console.log(line); 136 | } 137 | } 138 | 139 | if (!successLines.length) { 140 | throw new Error('Failed to create servite app'); 141 | } 142 | 143 | const src = path.resolve(root, 'src'); 144 | 145 | // 1. Modify package.json 146 | // ===================================== 147 | const pkgPath = path.resolve(root, 'package.json'); 148 | const pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); 149 | 150 | // Remove dev deps: @vitejs/plugin-react 151 | delete pkgJson.devDependencies['@vitejs/plugin-react']; 152 | // Add deps: servite 153 | pkgJson.dependencies.servite = serviteVersion; 154 | pkgJson.dependencies.vinxi = vinxiVersion; 155 | // Modify scripts 156 | pkgJson.scripts.dev = 'vinxi dev'; 157 | pkgJson.scripts.build = 'tsc -b && vinxi build'; 158 | pkgJson.scripts.preview = 'node .output/server/index.mjs'; 159 | 160 | fs.writeFileSync(pkgPath, JSON.stringify(pkgJson, null, 2)); 161 | 162 | // 2. Modify vite.config 163 | // ===================================== 164 | const viteConfigPath = path.resolve(root, `vite.config.ts`); 165 | fs.writeFileSync(viteConfigPath, viteConfigContent, 'utf-8'); 166 | 167 | // 3. Remove index.html 168 | // ===================================== 169 | fs.rmSync(path.resolve(root, 'index.html')); 170 | 171 | // 4. Clean src 172 | // ===================================== 173 | emptyDir(src, ['vite-env.d.ts']); 174 | 175 | // 5. Modify vite-env.d.ts 176 | // ===================================== 177 | const viteEnvDtsPath = path.resolve(src, 'vite-env.d.ts'); 178 | 179 | if (fs.existsSync(viteEnvDtsPath)) { 180 | fs.appendFileSync( 181 | viteEnvDtsPath, 182 | '/// \n', 183 | 'utf-8', 184 | ); 185 | } 186 | 187 | // 6. Modify .gitignore 188 | // ===================================== 189 | const gitignorePath = path.resolve(root, '.gitignore'); 190 | 191 | if (fs.existsSync(gitignorePath)) { 192 | fs.appendFileSync( 193 | gitignorePath, 194 | '\n# Servite\n.vinxi\n.output\n.vercel\n', 195 | 'utf-8', 196 | ); 197 | } 198 | 199 | // 7. Add demo page 200 | // ===================================== 201 | const pagesPath = path.resolve(src, 'pages'); 202 | fs.mkdirSync(pagesPath); 203 | fs.writeFileSync( 204 | path.resolve(pagesPath, 'page.tsx'), 205 | demoPageContent, 206 | 'utf-8', 207 | ); 208 | 209 | console.log(successLines.join('\n')); 210 | } 211 | 212 | init().catch(err => { 213 | console.error(err); 214 | process.exit(1); 215 | }); 216 | -------------------------------------------------------------------------------- /packages/create-servite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /packages/docs/README.md: -------------------------------------------------------------------------------- 1 | # Servite Docs 2 | -------------------------------------------------------------------------------- /packages/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vinxi dev", 8 | "build": "tsc -b && vinxi build", 9 | "lint": "eslint .", 10 | "preview": "node .output/server/index.mjs", 11 | "deploy:gh-pages": "GH_PAGES=1 pnpm build && gh-pages -d .output/public --nojekyll", 12 | "deploy:vercel": "SERVER_PRESET=vercel pnpm build" 13 | }, 14 | "dependencies": { 15 | "classnames": "^2.5.1", 16 | "react": "^18.3.1", 17 | "react-dom": "^18.3.1", 18 | "servite": "workspace:*", 19 | "shadcn-react": "^0.0.22", 20 | "vinxi": "^0.4.3" 21 | }, 22 | "devDependencies": { 23 | "@eslint/js": "^9.9.0", 24 | "@tailwindcss/typography": "^0.5.15", 25 | "@types/react": "^18.3.3", 26 | "@types/react-dom": "^18.3.0", 27 | "@vitejs/plugin-react": "^4.3.1", 28 | "autoprefixer": "^10.4.20", 29 | "eslint": "^9.9.0", 30 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 31 | "eslint-plugin-react-refresh": "^0.4.9", 32 | "gh-pages": "^6.1.1", 33 | "globals": "^15.9.0", 34 | "pagefind": "^1.1.1", 35 | "postcss-nesting": "^13.0.0", 36 | "tailwindcss": "^3.4.11", 37 | "typescript-eslint": "^8.0.1", 38 | "vite": "^5.4.8" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/docs/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | 'tailwindcss/nesting': 'postcss-nesting', 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/docs/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/docs/src/assets/error-display.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Codpoe/servite/e480b9238c0e7ed9bb576bd5fc5ae2e0bf18b0db/packages/docs/src/assets/error-display.png -------------------------------------------------------------------------------- /packages/docs/src/assets/loader-action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Codpoe/servite/e480b9238c0e7ed9bb576bd5fc5ae2e0bf18b0db/packages/docs/src/assets/loader-action.png -------------------------------------------------------------------------------- /packages/docs/src/assets/nested-routes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Codpoe/servite/e480b9238c0e7ed9bb576bd5fc5ae2e0bf18b0db/packages/docs/src/assets/nested-routes.png -------------------------------------------------------------------------------- /packages/docs/src/assets/photo.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Codpoe/servite/e480b9238c0e7ed9bb576bd5fc5ae2e0bf18b0db/packages/docs/src/assets/photo.avif -------------------------------------------------------------------------------- /packages/docs/src/assets/ssr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Codpoe/servite/e480b9238c0e7ed9bb576bd5fc5ae2e0bf18b0db/packages/docs/src/assets/ssr.png -------------------------------------------------------------------------------- /packages/docs/src/components/Bento/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { ArrowRightIcon } from 'shadcn-react/icons'; 3 | import { Button } from 'shadcn-react'; 4 | import cn from 'classnames'; 5 | import { Link } from 'servite/runtime/router'; 6 | 7 | export interface BentoGridProps { 8 | children?: React.ReactNode; 9 | className?: string; 10 | } 11 | 12 | export function BentoGrid({ children, className }: BentoGridProps) { 13 | return ( 14 |
20 | {children} 21 |
22 | ); 23 | } 24 | 25 | export interface BentoCardProps { 26 | name: string; 27 | className: string; 28 | background: ReactNode; 29 | Icon: any; 30 | description: string; 31 | to?: string; 32 | cta?: string; 33 | } 34 | 35 | export function BentoCard({ 36 | name, 37 | className, 38 | background, 39 | Icon, 40 | description, 41 | to, 42 | cta, 43 | }: BentoCardProps) { 44 | return ( 45 |
56 |
{background}
57 |
58 | 59 |

60 | {name} 61 |

62 |

{description}

63 |
64 | 65 | {to && cta && ( 66 |
71 | 85 |
86 | )} 87 |
88 |
89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /packages/docs/src/components/Callout/index.css: -------------------------------------------------------------------------------- 1 | .callout { 2 | --callout-tip: #059669; 3 | --callout-info: #0284c7; 4 | --callout-warning: #d97706; 5 | --callout-danger: #dc2626; 6 | position: relative; 7 | } 8 | 9 | .callout-bg { 10 | opacity: 0.1; 11 | z-index: -1; 12 | } 13 | 14 | .callout-tip { 15 | .callout-bg { 16 | background-color: var(--callout-tip); 17 | } 18 | 19 | .callout-header { 20 | color: var(--callout-tip); 21 | } 22 | } 23 | 24 | .callout-info { 25 | .callout-bg { 26 | background-color: var(--callout-info); 27 | } 28 | 29 | .callout-header { 30 | color: var(--callout-info); 31 | } 32 | } 33 | 34 | .callout-warning { 35 | .callout-bg { 36 | background-color: var(--callout-warning); 37 | } 38 | 39 | .callout-header { 40 | color: var(--callout-warning); 41 | } 42 | } 43 | 44 | .callout-danger { 45 | .callout-bg { 46 | background-color: var(--callout-danger); 47 | } 48 | 49 | .callout-header { 50 | color: var(--callout-danger); 51 | } 52 | } 53 | 54 | .callout-content>*:first-child { 55 | margin-top: 0 !important; 56 | } 57 | 58 | .callout-content>*:last-child { 59 | margin-bottom: 0 !important; 60 | } -------------------------------------------------------------------------------- /packages/docs/src/components/Callout/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | LightbulbIcon, 4 | InfoIcon, 5 | AlertCircleIcon, 6 | AlertTriangleIcon, 7 | } from 'shadcn-react/icons'; 8 | 9 | import './index.css'; 10 | 11 | export type CalloutType = 'tip' | 'info' | 'warning' | 'danger'; 12 | 13 | const typeToIconMap: Record> = { 14 | tip: LightbulbIcon, 15 | info: InfoIcon, 16 | warning: AlertCircleIcon, 17 | danger: AlertTriangleIcon, 18 | }; 19 | 20 | export interface CalloutProps { 21 | type: CalloutType; 22 | title?: React.ReactNode; 23 | icon?: React.ComponentType | string; 24 | children?: React.ReactNode; 25 | } 26 | 27 | export function Callout({ 28 | type, 29 | title = type.toUpperCase(), 30 | icon = typeToIconMap[type], 31 | children, 32 | }: CalloutProps) { 33 | return ( 34 |
35 |
36 |
37 | 38 | {typeof icon === 'string' 39 | ? icon 40 | : React.createElement(icon, { width: '1.2em' })} 41 | 42 |
{title}
43 |
44 | {children && ( 45 |
{children}
46 | )} 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /packages/docs/src/components/Mdx/index.css: -------------------------------------------------------------------------------- 1 | .prose { 2 | table { 3 | margin: 0; 4 | } 5 | 6 | thead { 7 | white-space: nowrap; 8 | } 9 | } -------------------------------------------------------------------------------- /packages/docs/src/components/Mdx/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'servite/runtime/router'; 3 | import { MDXProvider } from 'servite/runtime/mdx'; 4 | import { Callout } from '../Callout'; 5 | import 'servite/runtime/mdx.css'; 6 | import './index.css'; 7 | 8 | function A({ 9 | href, 10 | ...restProps 11 | }: React.DetailedHTMLProps< 12 | React.AnchorHTMLAttributes, 13 | HTMLAnchorElement 14 | >) { 15 | if (href?.startsWith('/')) { 16 | return ; 17 | } 18 | return ; 19 | } 20 | 21 | function Table( 22 | props: React.DetailedHTMLProps< 23 | React.TableHTMLAttributes, 24 | HTMLTableElement 25 | >, 26 | ) { 27 | return ( 28 |
29 | 30 | 31 | ); 32 | } 33 | 34 | export interface MdxProps { 35 | children?: React.ReactNode; 36 | } 37 | 38 | export function Mdx({ children }: MdxProps) { 39 | return ( 40 |
41 | 42 | {children} 43 | 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /packages/docs/src/components/ProgressiveBlur/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ProgressiveBlur extends React.HTMLAttributes { 4 | strength?: number; 5 | steps?: number; 6 | falloffPercentage?: number; 7 | tint?: string; 8 | side?: 'left' | 'right' | 'top' | 'bottom'; 9 | } 10 | 11 | const oppositeSide = { 12 | left: 'right', 13 | right: 'left', 14 | top: 'bottom', 15 | bottom: 'top', 16 | }; 17 | 18 | export function ProgressiveBlur({ 19 | strength = 10, 20 | steps = 8, 21 | falloffPercentage = 100, 22 | tint = 'transparent', 23 | side = 'top', 24 | ...props 25 | }: ProgressiveBlur) { 26 | const actualSteps = Math.max(1, steps); 27 | const step = falloffPercentage / actualSteps; 28 | 29 | const factor = 0.5; 30 | 31 | const base = Math.pow(strength / factor, 1 / (actualSteps - 1)); 32 | 33 | const mainPercentage = 100 - falloffPercentage; 34 | 35 | const getBackdropFilter = (i: number) => 36 | `blur(${factor * base ** (actualSteps - i - 1)}px)`; 37 | 38 | return ( 39 |
48 |
61 | {/* Full blur at 100-falloffPercentage% */} 62 |
76 | {actualSteps > 1 && ( 77 |
92 | )} 93 | {actualSteps > 2 && 94 | Array.from({ length: actualSteps - 2 }).map((_, i) => ( 95 |
112 | ))} 113 |
114 |
115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /packages/docs/src/components/Search/index.tsx: -------------------------------------------------------------------------------- 1 | import { IslandProps } from 'servite/runtime/island'; 2 | import { Input } from 'shadcn-react'; 3 | import { SearchIcon } from 'shadcn-react/icons'; 4 | import cn from 'classnames'; 5 | import { useEffect, useMemo, useState } from 'react'; 6 | import { Link } from 'servite/runtime/router'; 7 | import { debounce } from '@/utils/throttle.js'; 8 | import { withoutTrailingSlash } from '@/utils/url.js'; 9 | import { getScrollTop } from '@/utils/scroll.js'; 10 | 11 | interface Pagefind { 12 | search: (input: string) => Promise; 13 | debouncedSearch: (input: string) => Promise; 14 | init: () => Promise; 15 | options: () => Promise; 16 | destroy: () => Promise; 17 | } 18 | 19 | interface PagefindSearch { 20 | results: { 21 | id: string; 22 | score: number; 23 | data: () => Promise; 24 | }[]; 25 | } 26 | 27 | interface PagefindAnchor { 28 | element: string; 29 | id: string; 30 | text: string; 31 | } 32 | 33 | interface PagefindData { 34 | anchors: PagefindAnchor[]; 35 | content: string; 36 | excerpt: string; 37 | meta: { title: string }; 38 | raw_content: string; 39 | raw_url: string; 40 | url: string; 41 | sub_results: { 42 | anchor: PagefindAnchor; 43 | excerpt: string; 44 | title: string; 45 | url: string; 46 | }[]; 47 | } 48 | 49 | let pagefindPromise: Promise; 50 | 51 | async function initPagefind(): Promise { 52 | return (pagefindPromise ||= (async () => { 53 | if (import.meta.env.DEV) { 54 | return; 55 | } 56 | 57 | const pagefind: Pagefind = await import( 58 | /* @vite-ignore*/ 59 | // @ts-ignore 60 | withoutTrailingSlash(import.meta.env.SERVER_BASE) + 61 | '/pagefind/pagefind.js' 62 | ); 63 | 64 | await pagefind.init(); 65 | return pagefind; 66 | })()); 67 | } 68 | 69 | export interface SearchProps extends IslandProps { 70 | className?: string; 71 | } 72 | 73 | export function Search({ className }: SearchProps) { 74 | const [inputValue, setInputValue] = useState(''); 75 | const [searchData, setSearchData] = useState([]); 76 | 77 | const search = useMemo( 78 | () => 79 | debounce(async (input: string) => { 80 | const pagefind = await initPagefind(); 81 | 82 | if (!pagefind) { 83 | return; 84 | } 85 | 86 | const search = await pagefind.search(input); 87 | 88 | if (!search?.results?.length) { 89 | setSearchData([]); 90 | return; 91 | } 92 | 93 | const data = await Promise.all( 94 | search.results.slice(0, 5).map(x => x.data()), 95 | ); 96 | setSearchData(data); 97 | }, 400), 98 | [], 99 | ); 100 | 101 | useEffect(() => { 102 | if (!inputValue) { 103 | setSearchData([]); 104 | return; 105 | } 106 | 107 | search(inputValue); 108 | }, [inputValue, search]); 109 | 110 | return ( 111 |
112 | 113 | setInputValue(ev.target.value)} 121 | onKeyDown={() => { 122 | // prevent page scroll while typing 123 | const scrollTop = getScrollTop(); 124 | setTimeout(() => { 125 | window.scrollTo({ top: scrollTop }); 126 | }); 127 | }} 128 | onMouseEnter={() => initPagefind()} 129 | /> 130 | 131 | K 132 | 133 |
139 | {searchData.length ? ( 140 |
141 | {searchData.map(x => { 142 | const urlPrefix = x.url.substring( 143 | 0, 144 | x.url.length - x.raw_url.length, 145 | ); 146 | 147 | return ( 148 |
149 |
150 | {x.meta.title} 151 |
152 |
153 | {x.sub_results.map(sub => ( 154 | setInputValue('')} 163 | > 164 |
165 | {sub.anchor.element !== 'h1' && '# '} 166 | {sub.title} 167 |
168 |
171 | 172 | ))} 173 |
174 |
175 |
176 | ); 177 | })} 178 |
179 | ) : ( 180 |
No result
181 | )} 182 |
183 |
184 | ); 185 | } 186 | 187 | export default Search; 188 | -------------------------------------------------------------------------------- /packages/docs/src/components/SidebarLink/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'servite/runtime/router'; 2 | import { SidebarItemWrapperProps } from 'shadcn-react'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 5 | export interface SidebarLinkProps extends SidebarItemWrapperProps {} 6 | 7 | export function SidebarLink({ value, children }: SidebarLinkProps) { 8 | return ( 9 | 10 | {children} 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /packages/docs/src/components/Toc/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef, useMemo } from 'react'; 2 | import { Link } from 'servite/runtime/router'; 3 | import { IslandProps } from 'servite/runtime/island'; 4 | import { throttle } from '@/utils/throttle'; 5 | import { getScrollTop } from '@/utils/scroll'; 6 | import { useHandle } from '@/hooks/use-handle'; 7 | 8 | export interface TocProps extends IslandProps { 9 | className?: string; 10 | } 11 | 12 | export function Toc({ className = '' }: TocProps) { 13 | const handle = useHandle(); 14 | const toc = useMemo(() => handle.toc?.filter(x => x.depth > 1), [handle.toc]); 15 | const [headings, setHeadings] = useState([]); 16 | const [activeIndex, setActiveIndex] = useState(-1); 17 | const elRef = useRef(null); 18 | const headingsRef = useRef(headings); 19 | headingsRef.current = headings; 20 | 21 | // collect headings by toc 22 | useEffect(() => { 23 | const newHeadings = toc 24 | ?.map(({ id }) => document.getElementById(id)) 25 | .filter((x): x is HTMLElement => !!x); 26 | 27 | setHeadings(newHeadings || []); 28 | setActiveIndex(-1); 29 | }, [toc]); 30 | 31 | // scroll -> activeIndex 32 | useEffect(() => { 33 | const handleScroll = throttle(() => { 34 | if (!elRef.current || !headingsRef.current.length) { 35 | return; 36 | } 37 | 38 | const scrollTop = getScrollTop(); 39 | 40 | if (scrollTop === 0) { 41 | setActiveIndex(-1); 42 | return; 43 | } 44 | 45 | const index = headingsRef.current.findIndex((item, index) => { 46 | const nextItem = headingsRef.current[index + 1]; 47 | const { top } = item.getBoundingClientRect(); 48 | const { top: nextTop } = nextItem?.getBoundingClientRect() || {}; 49 | 50 | return top <= 100 && (!nextItem || nextTop > 100); 51 | }); 52 | 53 | setActiveIndex(index); 54 | }, 50); 55 | 56 | window.addEventListener('scroll', handleScroll); 57 | 58 | return () => { 59 | window.removeEventListener('scroll', handleScroll); 60 | }; 61 | }, []); 62 | 63 | return ( 64 |
65 |
目录
66 |
67 |
= 0 ? 'opacity-100' : 'opacity-0'}`} 70 | style={{ top: `${Math.max(activeIndex * 28, 0) + 4}px` }} 71 | /> 72 | {toc?.map((item, index) => ( 73 | 85 | {item.text} 86 | 87 | ))} 88 |
89 |
90 | ); 91 | } 92 | 93 | export default Toc; 94 | 95 | function getTocItemId(index: number) { 96 | return `toc-item-${index}`; 97 | } 98 | -------------------------------------------------------------------------------- /packages/docs/src/config/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { SidebarItemProps, SidebarGroupProps } from 'shadcn-react'; 2 | 3 | export const sidebarItems: (SidebarGroupProps | SidebarItemProps)[] = [ 4 | { 5 | title: '介绍', 6 | value: '/zh/guide', 7 | }, 8 | { 9 | title: '开始上手', 10 | value: '/zh/guide/start', 11 | }, 12 | { 13 | title: '配置', 14 | value: '/zh/guide/config', 15 | }, 16 | { 17 | title: '目录结构', 18 | value: '/zh/guide/directory-structure', 19 | }, 20 | { 21 | title: '路由', 22 | value: '/zh/guide/routes', 23 | }, 24 | { 25 | title: '路由数据', 26 | value: '/zh/guide/routes-data', 27 | }, 28 | { 29 | title: '路由错误', 30 | value: '/zh/guide/routes-error', 31 | }, 32 | { 33 | title: '服务中间件', 34 | value: '/zh/guide/middlewares', 35 | }, 36 | { 37 | title: 'SSR 服务端渲染', 38 | value: '/zh/guide/ssr', 39 | }, 40 | { 41 | title: 'SSG 静态生成', 42 | value: '/zh/guide/ssg', 43 | }, 44 | { 45 | title: 'CSR 客户端渲染', 46 | value: '/zh/guide/csr', 47 | }, 48 | { 49 | title: '部署', 50 | value: '/zh/guide/deploy', 51 | }, 52 | { 53 | title: '进阶', 54 | children: [ 55 | { 56 | title: '运行时能力', 57 | value: '/zh/guide/runtime', 58 | }, 59 | { 60 | title: '一体化 API 调用', 61 | value: '/zh/guide/unified-invocation', 62 | }, 63 | { 64 | title: '自定义服务端 logger', 65 | value: '/zh/guide/custom-logger', 66 | }, 67 | { 68 | title: 'islands - 孤岛架构', 69 | value: '/zh/guide/islands', 70 | }, 71 | ], 72 | }, 73 | ]; 74 | -------------------------------------------------------------------------------- /packages/docs/src/hooks/use-handle.ts: -------------------------------------------------------------------------------- 1 | import { useMatches } from 'servite/runtime/router'; 2 | 3 | interface Handle { 4 | frontmatter?: Record; 5 | toc?: { 6 | id: string; 7 | text: string; 8 | depth: number; 9 | }[]; 10 | } 11 | 12 | export function useHandle(): Handle { 13 | const matches = useMatches(); 14 | return matches[matches.length - 1]?.handle || {}; 15 | } 16 | -------------------------------------------------------------------------------- /packages/docs/src/pages/(en)/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FileTextIcon, 3 | MonitorCheckIcon, 4 | ServerIcon, 5 | TreePalmIcon, 6 | ZapIcon, 7 | } from 'shadcn-react/icons'; 8 | import { Button } from 'shadcn-react'; 9 | import { Link } from 'servite/runtime/router'; 10 | import { BentoCard, BentoCardProps, BentoGrid } from '@/components/Bento'; 11 | 12 | const features: BentoCardProps[] = [ 13 | { 14 | Icon: ZapIcon, 15 | name: 'SSR', 16 | description: 'SSR enabled by default.', 17 | to: '/zh/guide/ssr', 18 | cta: 'Learn more', 19 | background: , 20 | className: 'md:row-start-1 md:row-end-4 md:col-start-2 md:col-end-3', 21 | }, 22 | { 23 | Icon: FileTextIcon, 24 | name: 'SSG', 25 | description: `If most of the site's content is static, it can easily switch to SSG.`, 26 | to: '/zh/guide/ssg', 27 | cta: 'Learn more', 28 | background: , 29 | className: 'md:col-start-1 md:col-end-2 md:row-start-1 md:row-end-3', 30 | }, 31 | { 32 | Icon: MonitorCheckIcon, 33 | name: 'CSR', 34 | description: 'Support automatic or manual downgrade to CSR.', 35 | to: '/zh/guide/csr', 36 | cta: 'Learn more', 37 | background: , 38 | className: 'md:col-start-1 md:col-end-2 md:row-start-3 md:row-end-4', 39 | }, 40 | { 41 | Icon: TreePalmIcon, 42 | name: 'Islands', 43 | description: 'Improve Islands architecture for more development-friendly', 44 | to: '/zh/guide/islands', 45 | cta: 'Learn more', 46 | background: , 47 | className: 'md:col-start-3 md:col-end-3 md:row-start-1 md:row-end-2', 48 | }, 49 | { 50 | Icon: ServerIcon, 51 | name: 'Vinxi', 52 | description: 53 | 'Servite is driven by Vinxi/Nitro at the bottom layer, stable and reliable, and easy to deploy.', 54 | background: , 55 | className: 'md:col-start-3 md:col-end-3 md:row-start-2 md:row-end-4', 56 | }, 57 | ]; 58 | 59 | export default function Page() { 60 | return ( 61 |
62 |

63 | A Full stack React framework 64 |

65 | 66 | 67 | 68 | 69 | {features.map(feature => ( 70 | 71 | ))} 72 | 73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /packages/docs/src/pages/a/page.data.ts: -------------------------------------------------------------------------------- 1 | export async function loader() { 2 | console.log('>>> loader a'); 3 | await new Promise(resolve => setTimeout(resolve, 1000)); 4 | return { 5 | a: 1, 6 | aa: 'aa', 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /packages/docs/src/pages/a/page.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useLoaderData } from 'servite/runtime/router'; 2 | 3 | export default function Page() { 4 | const loaderData = useLoaderData(); 5 | return ( 6 |
7 |
A Page
8 |
{JSON.stringify(loaderData)}
9 | To B Page 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /packages/docs/src/pages/b/page.data.ts: -------------------------------------------------------------------------------- 1 | export async function loader() { 2 | 'use server'; 3 | console.log('>>> loader b'); 4 | await new Promise(resolve => setTimeout(resolve, 1000)); 5 | return { 6 | b: 1, 7 | bb: 'bb', 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /packages/docs/src/pages/b/page.tsx: -------------------------------------------------------------------------------- 1 | import { useId } from 'react'; 2 | import { Link, useLoaderData } from 'servite/runtime/router'; 3 | 4 | export default function Page() { 5 | const loaderData = useLoaderData(); 6 | const id = useId(); 7 | return ( 8 |
9 |
B Page
10 |
{JSON.stringify(loaderData)}
11 | To A Page 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /packages/docs/src/pages/layout.css: -------------------------------------------------------------------------------- 1 | @import 'shadcn-react/style.css'; 2 | @import 'tailwindcss/base'; 3 | @import 'tailwindcss/components'; 4 | @import 'tailwindcss/utilities'; 5 | 6 | html { 7 | -webkit-tap-highlight-color: transparent; 8 | -webkit-font-smoothing: antialiased; 9 | scroll-padding-top: 80px; 10 | scroll-behavior: smooth; 11 | } -------------------------------------------------------------------------------- /packages/docs/src/pages/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useEffect, useMemo, useState } from 'react'; 2 | import { 3 | Link, 4 | Outlet, 5 | ScrollRestoration, 6 | useLocation, 7 | } from 'servite/runtime/router'; 8 | import { Helmet } from 'servite/runtime/helmet'; 9 | import { Button, Sidebar } from 'shadcn-react'; 10 | import cn from 'classnames'; 11 | import { 12 | ChevronRightIcon, 13 | GithubIcon, 14 | MenuIcon, 15 | MoonStarIcon, 16 | SunIcon, 17 | } from 'shadcn-react/icons'; 18 | import { ClientOnly } from 'servite/runtime/components'; 19 | import Toc from 'island:components/Toc'; 20 | import Search from 'island:components/Search'; 21 | import { Mdx } from '@/components/Mdx'; 22 | import { useHandle } from '@/hooks/use-handle'; 23 | import { sidebarItems } from '@/config/sidebar'; 24 | import { throttle } from '@/utils/throttle'; 25 | import { getScrollTop } from '@/utils/scroll'; 26 | import { SidebarLink } from '@/components/SidebarLink'; 27 | import './layout.css'; 28 | 29 | const THEME_STORAGE_KEY = 'servite:theme'; 30 | 31 | const supportedLangs = ['en', 'zh']; 32 | 33 | function getLangFromPathname(pathname: string) { 34 | const lang = pathname.split('/')[1]; 35 | 36 | if (!lang) { 37 | return supportedLangs[0]; 38 | } 39 | 40 | return supportedLangs.includes(lang) ? lang : supportedLangs[0]; 41 | } 42 | 43 | interface DocLayoutProps { 44 | fixedSidebarVisible: boolean; 45 | setFixedSidebarVisible: React.Dispatch>; 46 | } 47 | 48 | function DocLayout({ 49 | fixedSidebarVisible, 50 | setFixedSidebarVisible, 51 | }: DocLayoutProps) { 52 | const { pathname } = useLocation(); 53 | 54 | return ( 55 | <> 56 |
setFixedSidebarVisible(false)} 64 | /> 65 | { 74 | setFixedSidebarVisible(false); 75 | }} 76 | /> 77 |
81 | 82 | 83 | 84 |
85 | 86 | ); 87 | } 88 | 89 | export default function Layout() { 90 | const { pathname } = useLocation(); 91 | const { toc } = useHandle(); 92 | const mdTitle = useMemo(() => toc?.find(x => x.depth === 1)?.text, [toc]); 93 | const [fixedSidebarVisible, setFixedSidebarVisible] = useState(false); 94 | const [headerMdTitleVisible, setHeaderMdTitleVisible] = useState(false); 95 | 96 | const [theme, setTheme] = useState( 97 | () => 98 | (typeof document !== 'undefined' && 99 | window.localStorage.getItem(THEME_STORAGE_KEY)) || 100 | 'light', 101 | ); 102 | 103 | const matchedSidebarItems = useMemo(() => { 104 | for (const item of sidebarItems) { 105 | if ('children' in item) { 106 | const found = item.children?.find(child => child.value === pathname); 107 | if (found) { 108 | return [item, found]; 109 | } 110 | } else if (item.value === pathname) { 111 | return [item]; 112 | } 113 | } 114 | }, [pathname]); 115 | 116 | const handleSwitchTheme = () => { 117 | const newTheme = theme === 'light' ? 'dark' : 'light'; 118 | setTheme(newTheme); 119 | window.localStorage.setItem(THEME_STORAGE_KEY, newTheme); 120 | document.documentElement.classList.toggle('dark'); 121 | }; 122 | 123 | useEffect(() => { 124 | const handleScroll = throttle(() => { 125 | if (getScrollTop() >= 120) { 126 | setHeaderMdTitleVisible(true); 127 | } else { 128 | setHeaderMdTitleVisible(false); 129 | } 130 | }, 100); 131 | 132 | handleScroll(); 133 | 134 | window.addEventListener('scroll', handleScroll); 135 | 136 | return () => { 137 | window.removeEventListener('scroll', handleScroll); 138 | }; 139 | }, []); 140 | 141 | return ( 142 | <> 143 | 148 | {mdTitle} 149 | 150 | 154 | 155 | 156 |
157 |
158 |
159 |
160 | 161 |

Servite

162 | 163 |
164 | {mdTitle && ( 165 |
166 |

window.scrollTo({ top: 0 })} 174 | > 175 | {mdTitle} 176 |

177 |
178 | )} 179 |
180 | 184 | }> 185 |
200 |
201 | {Boolean(matchedSidebarItems?.length) && ( 202 |
setFixedSidebarVisible(true)} 205 | > 206 | 207 | {matchedSidebarItems?.map((x, index) => ( 208 | 209 | 210 | {x.title} 211 | 212 | {index < matchedSidebarItems.length - 1 && ( 213 | 214 | )} 215 | 216 | ))} 217 |
218 | )} 219 |
220 |
221 | {matchedSidebarItems?.length ? ( 222 | 226 | ) : ( 227 | 228 | )} 229 | 236 |
237 |
238 | 239 | ); 240 | } 241 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/config.mdx: -------------------------------------------------------------------------------- 1 | # 配置 2 | 3 | 用户配置的 TS 类型如下: 4 | 5 | `ServiteConfig`: 6 | 7 | ```ts 8 | interface ServiteConfig { 9 | /** 10 | * app name 11 | * @default 'servite' 12 | */ 13 | name?: string; 14 | /** 15 | * fs root 16 | * @default process.cwd() 17 | */ 18 | root?: string; 19 | /** 20 | * source config 21 | */ 22 | source?: SourceConfig; 23 | /** 24 | * routers config 25 | */ 26 | routers?: RoutersConfig; 27 | /** 28 | * nitro server config 29 | */ 30 | server?: ServerConfig; 31 | /** 32 | * options of `@vitejs/plugin-react` 33 | */ 34 | viteReact?: ViteReactOptions; 35 | /** 36 | * options of `vite-tsconfig-paths` 37 | */ 38 | viteTsConfigPaths?: ViteTsConfigPathsOptions; 39 | } 40 | ``` 41 | 42 | ## source 43 | 44 | 用于配置源码目录等。 45 | 46 | ```ts 47 | interface SourceConfig { 48 | /** 49 | * source code dir (relative to `root`) 50 | * @default './src' 51 | */ 52 | srcDir?: string; 53 | /** 54 | * react-router pages dir (relative to `srcDir`) 55 | * @default './pages' 56 | */ 57 | pagesDir?: string; 58 | /** 59 | * server code dir (relative to `srcDir`) 60 | * @default './server' 61 | */ 62 | serverDir?: string; 63 | /** 64 | * server routes dir (relative to `serverDir`) 65 | * @default './routes' 66 | */ 67 | serverRoutesDir?: string; 68 | /** 69 | * server middlewares dir (relative to `serverDir`) 70 | * @default './middlewares' 71 | */ 72 | serverMiddlewaresDir?: string; 73 | /** 74 | * public assets dir (relative to `root`) 75 | * @default './public' 76 | */ 77 | publicDir?: string; 78 | } 79 | ``` 80 | 81 | ## routers 82 | 83 | 路由的配置。 84 | 85 | ```ts 86 | interface RoutersConfig { 87 | [RouterName.Public]?: Partial; 88 | [RouterName.Server]?: Partial; 89 | [RouterName.SSR]?: Partial; 90 | [RouterName.ServerFns]?: Partial; 91 | [RouterName.Client]?: Partial; 92 | [RouterName.SPA]?: Partial; 93 | } 94 | ``` 95 | 96 | 通常用于配置路由前缀,例如: 97 | 98 | ```ts 99 | defineConfig({ 100 | routers: { 101 | ssr: { 102 | base: '/ssr' 103 | }, 104 | }, 105 | }); 106 | ``` 107 | 108 | ## server 109 | 110 | nitro server 的配置,具体可以看 [nitro](https://nitro.unjs.io/) 的文档。 111 | 112 | 使用举例,给路由开启 SSG(静态页面生成): 113 | 114 | ```ts 115 | defineConfig({ 116 | server: { 117 | prerender: { 118 | routes: ['**/*'], // 给全部路由开启 SSG 119 | failOnError: true, 120 | }, 121 | }, 122 | }); 123 | ``` 124 | 125 | ## viteReact 126 | 127 | 配置 [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react)。 128 | 129 | ## viteTsConfigPaths 130 | 131 | 配置 [vite-tsconfig-paths](https://github.com/aleclarson/vite-tsconfig-paths)。 132 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/csr.mdx: -------------------------------------------------------------------------------- 1 | # CSR - 客户端渲染 2 | 3 | ## 介绍 4 | 5 | CSR(Client-Side Rendering 客户端渲染)是指在浏览器中渲染 Web 应用。 6 | 与 SSR / SSG 相比,CSR 会有一些缺点: 7 | 8 | - 首屏性能会差点,可能会影响 Web 应用的用户体验。 9 | 当客户端浏览器请求加载 Web 应用时,服务器会返回 Web 应用的 HTML 和 JavaScript 代码。 10 | 浏览器接收到这些代码后,会先把 HTML 渲染成页面,然后执行 JavaScript 代码进行动态渲染。 11 | 这样,用户在浏览器加载页面时,可能会先看到「白屏」,然后才会看到渲染完成的 Web 应用。 12 | - 可能会降低 Web 应用的 SEO 效果。因为渲染是在客户端浏览器进行的, 13 | 所以搜索引擎爬虫无法看到完整的 Web 应用内容,可能会导致搜索引擎排名降低,并且对用户的搜索体验造成影响。 14 | 15 | 但是 CSR 非常适合用来做一些动态和交互性强、并且不需要很好 SEO 的 Web 应用, 16 | 能达到比较彻底的前后端分离,对服务端依赖度不高。像管理后台这类的应用就很适合使用 CSR。 17 | 18 | ## 使用 19 | 20 | 参考 [SSR 降级](/zh/guide/ssr#降级) 21 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/custom-logger.mdx: -------------------------------------------------------------------------------- 1 | # 自定义服务端 logger 2 | 3 | 默认情况下,Servite 会将一些信息和错误通过 `console` 打印出来,如果需要自定义 logger,可以在中间件覆盖 `event.context.logger`。 4 | 5 | :::tip 6 | 为了让整个服务都能使用到自定义的 logger,最好把设置 logger 的这个中间件放到最前面去执行,参考[中间件的执行顺序](/zh/guide/middlewares#执行顺序)。 7 | ::: 8 | 9 | ```ts 10 | import { Logger, defineMiddleware } from 'servite/runtime/server'; 11 | 12 | // 实现自定义 Logger 13 | class MyLogger implements Logger { 14 | debug(...args: any[]) { 15 | console.debug(...args); 16 | } 17 | 18 | trace(...args: any[]) { 19 | console.trace(...args); 20 | } 21 | 22 | info(...args: any[]) { 23 | console.info(...args); 24 | } 25 | 26 | warn(...args: any[]) { 27 | console.warn(...args); 28 | } 29 | 30 | error(...args: any[]) { 31 | console.error(...args); 32 | } 33 | } 34 | 35 | export default defineMiddleware((_event, next) => { 36 | event.context.logger = new MyLogger(); 37 | return next(); 38 | }) 39 | ``` 40 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/custom-server-render.mdx: -------------------------------------------------------------------------------- 1 | # 自定义服务端渲染 2 | 3 | 默认情况下,servite 会直接调用 React 的 `renderToString` 方法来渲染应用, 4 | 这已经能适配很多场景了。然而如果你使用了 `styled-jsx` 或 `styled-components` 之类的 5 | CSS-in-JS 样式方案,一般这些方案都会提供特定的工具让你在 SSR 时收集样式,然后将这些样式 6 | 转成 HTML 字符串并注入到最终产出的 HTML 中,因此单调地执行 `renderToString` 已经不能满足这个场景了, 7 | 你需要自定义这段服务端渲染的逻辑。 8 | 9 | 你可以通过在 `src` 目录下新建 `server-render.ts` 文件,在这里面默认导出一个渲染函数。 10 | 以 `styled-jsx` 为例,对应的 SSR 渲染函数如下: 11 | 12 | ```ts 13 | // src/server-render.ts 14 | 15 | import { ReactElement } from 'react'; 16 | import { renderToStaticMarkup, renderToString } from 'react-dom/server'; 17 | import { createStyleRegistry, StyleRegistry } from 'styled-jsx'; 18 | 19 | // 创建样式注册表 20 | const registry = createStyleRegistry(); 21 | 22 | export default function render(element: ReactElement) { 23 | registry.flush(); 24 | 25 | // 渲染应用 HTML 26 | const appHtml = renderToString( 27 | {element} 28 | ); 29 | 30 | // 渲染收集到的样式 31 | const headTags = renderToStaticMarkup(<>{registry.styles()}); 32 | 33 | return { 34 | appHtml, 35 | headTags, 36 | }; 37 | } 38 | ``` 39 | 40 | 如上所示,在默认导出的渲染函数的返回值中: 41 | 42 | - `appHtml` 是应用的 HTML 字符串。 43 | - `headTags` 是需要注入到 head 的 HTML 字符串。 44 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/deploy.mdx: -------------------------------------------------------------------------------- 1 | # 部署 2 | 3 | Servite 服务底层由 Nitro 进行驱动,所以部署也是跟 Nitro 应用一样, 4 | 具体请查看 [Nitro deploy](https://nitro.unjs.io/deploy)。 5 | 6 | :::warning 7 | 部署前需要先执行生产环境的产物构建:`pnpm build`。 8 | ::: 9 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/directory-structure.mdx: -------------------------------------------------------------------------------- 1 | # 目录结构 2 | 3 | 默认情况下,servite 对目录结构有一些约定: 4 | 5 | ```shell 6 | 7 | └─ src # 源码目录 8 | ├─ pages # 页面路由目录 9 | │ └─ ... 10 | └─ server # 服务端源码目录 11 | ├─ middlewares # 中间件目录 12 | │ └─ ... 13 | └─ routes # 服务端路由目录 14 | └─ ... 15 | ``` 16 | 17 | 可以通过 [source](/zh/guide/config#source) 配置项修改这些约定。 18 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/index.mdx: -------------------------------------------------------------------------------- 1 | # 介绍 2 | 3 | :::warning 4 | 这个项目主要是为了实践自己对一个全栈 React 开发框架的一些想法,以及满足我个人的需求, 5 | 没有做太多封装、边界处理等,项目很多地方是考虑不周的,并且功能还在逐渐完善的过程当中,所以本项目不适合作为一个生产环境的项目。 6 | 如果你需要一个完整的 React 开发框架,可以考虑使用 [Next.js](https://nextjs.org/)、[Remix](https://remix.run/)... 7 | ::: 8 | 9 | `Servite` 是一个基于 [vinxi](https://github.com/nksaraf/vinxi) 的全栈 React 开发框架。 10 | 11 | ## 项目初衷 12 | 13 | 在日常的开发工作中,我陆续使用过不少 React 全栈开发框架,而在使用过程中逐渐积累了自己对 React 开发框架的一些想法, 14 | 比如说目录结构、路由、SSR 等等,我想要把这些想法都实现到一个框架中,并且希望这个框架是能够满足我个人的需求,并且是足够简单的。 15 | 这就是 `Servite` 的开发初衷。 16 | 17 | ## 为什么基于 vinxi 18 | 19 | 在最开始的时候,我是直接基于 [nitro](https://github.com/unjs/nitro) 来开发的,后来才了解到 vinxi,其实 vinxi 也是基于 nitro 开发的,只是它在这基础上做了一些开发框架相关的封装, 20 | 这使得它很适合用来开发全栈 React 开发框架,并且在社区中也有很多基于 vinxi 的开发框架,比如说 [SolidStart](https://github.com/solidjs/solid-start)、[TanStackStart](https://github.com/tanstack/router)。 21 | 所以 vinxi 的可靠性应该是没什么问题的,并且能够降低开发的复杂度,所以我后来改为基于 vinxi 来开发 `Servite` 了。 22 | 23 | 不过真的吐槽一句,vinxi 的文档写得太烂了,很多细节都是我翻看它的源码才知道的。而且它是用 JS 来写的,虽然通过 JSDoc 注释来补充了一些类型, 24 | 但很多类型是不准确的,这导致我在开发的时候需要写一些 `as any` 来绕过类型检查。 25 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/islands.mdx: -------------------------------------------------------------------------------- 1 | # islands - 孤岛架构 2 | 3 | ## 介绍 4 | 5 | Islands Architecture(孤岛架构)的概念最初在 2019 年被 [Katie Sylor-Miller](https://twitter.com/ksylor) 提出, 6 | 然后在 2020 年被 Preact 作者 Jason Miller 在[一篇文章](https://jasonformat.com/islands-architecture/)中进行了推广。 7 | 简单来说,孤岛架构将我们的 Web 应用划分为静态和动态部分。其中的**孤岛**可以独立进行水合,以实现它的交互能力。 8 | 9 | 但是,也正因为孤岛架构的动静划分,使得它更适合于偏展示的应用,例如博客、文档、静态页面等,而不太适合于偏交互的应用,例如电商、社交... 10 | 并且它也提高了应用开发的复杂度和难度。 11 | 12 | 为了让这项技术更加具有普适性,Servite 做了一些改进,不再要求页面大部分都是静态的,你的页面完全就是可交互的**普通**页面, 13 | 只是可以选择性地让某些组件延迟到特定时机才进行水合,甚至不水合。这样也能延迟部分组件 JS 的执行,甚至不下载这部分组件的 JS。 14 | 15 | ## 使用 16 | 17 | ### 1. 配置 alias `island:` 18 | 19 | 为了让 Servite 知道哪些组件是“孤岛”组件,Servite 约定了使用 `island:` 作为导入组件的路径前缀, 20 | 所以需要在 `tsconfig.json` 中配置相应的 alias: 21 | 22 | ```json 23 | { 24 | "compilerOptions": { 25 | "baseUrl": ".", 26 | "paths": { 27 | "@/*": ["./src/*"], 28 | "island:*": ["./src/*"] // [!code highlight] 29 | } 30 | }, 31 | "include": ["src"] 32 | } 33 | ``` 34 | 35 | 这样,当我们导入 `island:components/Counter` 时,Servite 会将 `Counter` 组件标记为“孤岛”组件。 36 | 37 | ### 2. 定义“孤岛”组件 38 | 39 | 需要**默认导出**“孤岛”组件。我们可以对组件类型修改如下,以获得更好的类型提示: 40 | 41 | ```tsx 42 | // ./components/Counter.tsx 43 | import { IslandProps } from 'servite/runtime/island'; 44 | 45 | // 集成 IslandProps,以获得更好的类型提示 46 | export interface CounterProps extends IslandProps { // [!code highlight] 47 | initial?: number; 48 | } 49 | 50 | // [!] 注意:必须使用默认导出 51 | // [!code word:default:1] 52 | export default function Counter({ initial = 0 }: CounterProps) { 53 | const [count, setCount] = useState(initial); 54 | return ( 55 | 58 | ); 59 | } 60 | ``` 61 | 62 | ### 3. 导入并使用“孤岛”组件 63 | 64 | ```tsx 65 | // ./pages/index.tsx 66 | // [!code word:island\::1] 67 | import Counter from 'island:components/Counter'; 68 | 69 | export default function Index() { 70 | return ( 71 | 78 | ); 79 | } 80 | ``` 81 | 82 | ## 水合配置 83 | 84 | 上面例子的 `hydrate` 属性,是 Servite 提供的“水合”配置。 85 | 86 | ### 水合时机 87 | 88 | `hydrate.on` 属性支持以下几种配置: 89 | 90 | - **load**:在页面加载时立即水合 91 | - **idle**:在浏览器空闲时水合 92 | - **visible**:在组件可见时水合 93 | - **media**:在匹配媒体查询时水合。例如:`on: media (prefers-color-scheme: dark)` 在黑暗模式才进行水合 94 | - **manual**:代码调用 `hydrateIsland` 方法手动水合,可自行控制水合时机。 95 | 96 | :::tip 97 | 当孤岛组件未指定 `hydrate.on` 时,则永远都不会进行水合,也不会下载相应的组件 JS。 98 | 对于不需要交互的静态组件,这种方式能有效减少客户端加载和运行的 JS 体积。 99 | ::: 100 | 101 | ### 超时 102 | 103 | `hydrate.timeout` 属性可以配置水合超时时间,单位为毫秒。当超过这个时间时,组件还未水合,则会自动触发水合。 104 | 105 | ## 手动水合 106 | 107 | `hydrate.on` 配置为 `manual`,可以自动控制水合时机。 108 | 109 | 当需要手动水合某个组件时,要给抓紧 `hydrate.id` 属性传入 id 值,用于唯一标记该组件,然后在需要时调用 `hydrateIsland(id)` 方法即可: 110 | 111 | ```tsx 112 | // ./pages/index.tsx 113 | import Counter from 'island:components/Counter'; 114 | 115 | export default function Index() { 116 | return ( 117 | 125 | ); 126 | } 127 | ``` 128 | 129 | 然后在代码里自行控制调用 `hydrateIsland('my-counter')`。 130 | 131 | :::tip 132 | 如果需要水合全部 `manual` 的组件,可以调用 `hydrateIsland(true)`。 133 | ::: 134 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/middlewares.mdx: -------------------------------------------------------------------------------- 1 | # 服务中间件 2 | 3 | Servite 默认会把 `src/server/middlewares/` 下的文件当做中间件。可以通过 `defineMiddleware` 来定义中间件: 4 | 5 | ```ts 6 | import { defineMiddleware } from 'servite/runtime/server'; 7 | 8 | export default defineMiddleware(async (event, next) => { 9 | // ... do something on request 10 | await next(); // call next middleware 11 | // ... do something before response 12 | }); 13 | ``` 14 | 15 | ## 执行顺序 16 | 17 | 中间件的执行顺序是由文件的顺序决定的,并且有点类似于 Koa 的洋葱模型: 18 | 一个请求到达服务器时,会首先从外向内经过每一层中间件,到达核心处理器后,再由内向外依次经过相同的中间件。 19 | 20 | 例如,文件目录结构如下所示: 21 | 22 | ```shell 23 | 24 | └─ src # 源码目录 25 | └─ server # 服务端源码目录 26 | └─ middlewares # 中间件目录 27 | ├─ a.ts 28 | ├─ b.ts 29 | └─ c.ts 30 | ``` 31 | 32 | 那与之对应的中间件执行顺序是: 33 | 34 | ```text 35 | Request → a → b → c → Response 36 | a ← b ← c ↩︎ 37 | ``` 38 | 39 | 为了使得中间件的执行顺序更为可控,推荐给文件名添加数字前缀,例如:`0.name.ts`,`1.name.ts` 等。 40 | 但如果你有超过 10 个中间件,那么可以使用两位数字前缀,例如:`00.name.ts`,`01.name.ts`, 41 | 这样能容纳 100 个中间件,已经完全足够了。 42 | 43 | ## 提前响应 44 | 45 | 如果想在中间件中提前响应请求,那么应该使用 `send()`、`event.node.res.end()` 等方法来提前发送响应,这时候也可以去掉 `await next()` 的调用了。 46 | 47 | ```ts 48 | import { defineMiddleware, send } from 'servite/runtime/server'; 49 | 50 | export default defineMiddleware(async (event, _next) => { 51 | // 提前在中间件里发送响应 52 | await send(event, 'some data'); 53 | }); 54 | ``` 55 | 56 | :::warning 57 | 在中间件中,如果没有调用 `await next()` 来执行下一个中间件,并且请求还没有被响应,那么请求将会一直处于 pending 的状态,这通常是代码逻辑错误导致的。 58 | ::: 59 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/routes-data.mdx: -------------------------------------------------------------------------------- 1 | # 路由数据 2 | 3 | :::tip 4 | Servite 的路由数据功能是基于 [React Router](https://reactrouter.com) 进行开发的,一些使用细节可直接参考 React Router 的文档。 5 | ::: 6 | 7 | 有些数据可能会明显影响到我们的路由组件展示和用户体验,这种跟路由密切关联的数据就叫**路由数据**。例如: 8 | - 用户中心页面里的头像、昵称等就是路由数据 9 | - 商品详情页面里的商品头图、名称、价格等也应该是路由数据 10 | 11 | 在一般的 React 开发中,我们会在组件的 `useEffect` 中发起数据请求,也就是需要等待组件代码加载->组件渲染后才能发起请求。 12 | 这对于路由数据来说时机太晚了,尤其是当遇到多层嵌套路由时,如果每层路由都有自己的数据请求,可能会导致**瀑布流加载**。 13 | 14 | 假如我们有这样的嵌套路由结构: 15 | 16 | ![](../../../assets/nested-routes.png) 17 | 18 | 当进入 `/dashboard/settings` 时,传统 React 应用中会存在这样的瀑布流加载: 19 | 20 | - `load App script` 21 | - `render App` 22 | - `fetch /api/app` 23 | - `load Dashboard script` 24 | - `render Dashboard` 25 | - `fetch /api/dashboard` 26 | - `load Settings script` 27 | - `render Settings` 28 | - `fetch /api/settings` 29 | 30 | 为了获取更好的用户体验和性能,Servite 借助 [React Router v6](https://reactrouter.com) 的 loader 和 action, 31 | 实现了路由数据的并行加载,以及更简单的数据流: 32 | 33 | 34 | 35 | ## 数据加载 36 | 37 | Servite 约定了加 `.data` 后缀的文件为对应路由的**数据文件**,举个例子, 38 | - 如果布局路由文件是 `src/pages/layout.tsx`,那么它对应的数据文件是 `src/pages/layout.data.ts` 39 | - 如果页面路由文件是 `src/pages/about/page.tsx`,那么对应的数据文件是 `src/pages/about/page.data.ts` 40 | 41 | 我们可以在数据文件中导出一个 `loader` 函数用于在组件渲染前进行数据加载: 42 | 43 | ```ts 44 | // page.data.ts 45 | export interface SomeData {} 46 | 47 | export async function loader() { 48 | return fakeGetSomeData(); 49 | } 50 | ``` 51 | 52 | 接着在对应的路由组件中可以通过 `useLoaderData` 这个 hook 拿到数据用于渲染: 53 | 54 | ```tsx 55 | // page.tsx 56 | import { useLoaderData } from 'servite/runtime/router'; 57 | import type { SomeData } from './page.data'; 58 | 59 | export default function Page() { 60 | const loaderData = useLoaderData() as SomeData; 61 | // ... 62 | } 63 | ``` 64 | 65 | :::warning 66 | 需要注意的是,如果需要在组件和 `.data` 文件之间共享 TS 类型,最好使用 `import type`,而不是单纯的 `import`, 67 | 这样能让构建工具更好地 Tree Shaking,例如上面代码中的:
68 | `import type { SomeData } from './page.data'` 69 | ::: 70 | 71 | 72 | ### 同构 73 | 74 | 在 SSR 环境下,首屏的 `loader` 函数会在服务端执行,而后续在浏览器导航时 `loader` 函数则会在浏览器中执行。 75 | 这意味着我们需要在 `.data` 文件中写**同构**的代码,也就是不管在服务端还是浏览器中,都应该能正常执行的代码。 76 | 77 | 如果希望不管是首屏还是后续的导航,都始终在服务端执行 loader 函数,我们可以给 `loader` 函数加上 `use server` 指令: 78 | 79 | ```ts 80 | export async function loader() { 81 | 'use server'; // [!code highlight] 82 | return fakeGetSomeData(); 83 | } 84 | ``` 85 | 86 | 加上这个 `use server` 指令后,我们就可以放心在 `loader` 中使用 Node.js 相关的一些模块了,例如:`fs`,`path` 等。 87 | 88 | ### loader 参数 89 | 90 | `loader` 函数的参数中有两个字段:`params` 和 `request`。 91 | 92 | - `params` 根据动态路由解析而来的,例如 `/posts/[id]/page.data.tsx`: 93 | 94 | ```ts 95 | // /posts/[id]/page.data.tsx 96 | export async function loader({ params }) { 97 | return fakeGetPost({ id: params.id }); 98 | } 99 | ``` 100 | 101 | - `request` 是一个 [Fetch Requst](https://developer.mozilla.org/en-US/docs/Web/API/Request) 实例。这个参数常见的使用场景是,从 `request` 中解析出 url 和查询参数: 102 | 103 | ```ts 104 | export async function loader({ request }) { 105 | const url = new URL(request.url); 106 | const uid = url.searchParams.get('uid'); 107 | return fakeGetUser({ uid }); 108 | } 109 | ``` 110 | 111 | ### loader 返回 112 | 113 | - 返回任意数据 114 | ```ts 115 | export async function loader() { 116 | return { 117 | some: 'data' 118 | }; 119 | } 120 | ``` 121 | - 返回 Response 实例 122 | ```ts 123 | export async function loader() { 124 | return new Response(JSON.stringify({ some: 'data' }), { 125 | headers: { 126 | 'Content-Type': 'application/json', 127 | }, 128 | }); 129 | } 130 | ``` 131 | 返回的 JSON 数据的 Response 也可以使用 `json` 这个工具函数来简化代码,所以上面例子可以简化成: 132 | ```ts 133 | import { json } from 'servite/runtime/router'; 134 | export async function loader() { 135 | return json({ some: 'data' }); 136 | } 137 | ``` 138 | - Throw Response 实例 139 | ```ts 140 | export async function loader() { 141 | throw new Response('没有权限', { status: 403 }); 142 | } 143 | ``` 144 | 145 | 146 | ## 数据更新 147 | 148 | 跟上面的 `loader` 类似,我们可以在 `.data` 文件中导出 `action` 函数用于数据变更: 149 | 150 | ```ts 151 | // page.data.ts 152 | export async function loader() { 153 | return fakeGetSomeData(); 154 | } 155 | 156 | export async function action() { 157 | return fakeUpdateData(); 158 | } 159 | ``` 160 | 161 | 然后在组件中可以通过 `useSubmit` 来触发 `action`: 162 | 163 | ```tsx 164 | // page.tsx 165 | export default function Page() { 166 | const submit = useSubmit(); 167 | return ( 168 |
{ 170 | submit(event.currentTarget); 171 | }} 172 | > 173 | 174 | 175 | 176 | ); 177 | } 178 | ``` 179 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/routes-error.mdx: -------------------------------------------------------------------------------- 1 | # 路由错误展示 2 | 3 | 当在 `loader`,`action` 或者组件 render 中抛出错误时,Servite 会捕获这些错误,然后展示在页面上: 4 | 5 | 6 | 7 | 如果想自定义这个错误展示,可以在路由文件中导出 `ErrorBoundary` 组件: 8 | 9 | ```tsx 10 | // src/pages/page.tsx 11 | export function ErrorBoundary() { 12 | return
🫨 啊噢,出错啦!
13 | } 14 | ``` 15 | 16 | ## 错误的冒泡 17 | 18 | 在多层嵌套的路由结构中,当底层的页面组件抛出错误时,错误会逐层“冒泡”,最终在最近的 `ErrorBoundary` 里被捕获并展示。 19 | 20 | 例如这样的路由结构: 21 | 22 | ![](../../../assets/nested-routes.png) 23 | 24 | 当 `` 渲染发生错误时,会先寻找 `/dashboard/settings/page.tsx` 里导出的 `ErrorBoundary` 组件, 25 | - 如果找到了,则会使用该组件来渲染错误信息,至于 `` 和 `` 则会照常渲染。 26 | - 如果没找到,则错误会继续往上冒泡,直到被 Servite 的顶层默认 `ErrorBoundary` 所捕获。 27 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/routes.mdx: -------------------------------------------------------------------------------- 1 | # 路由 2 | 3 | servite 使用约定式路由(文件系统路由),文件路径会被简单地映射为路由路径,这会使整个项目的路由变得非常直观。 4 | 5 | 由于 servite 同时支持写页面、Markdown 和 API 接口,路由系统也就分成了两个部分: 6 | 7 | - 页面路由 8 | - Markdown 路由 9 | - API 路由 10 | 11 | ## 页面路由 12 | 13 | servite 会收集 [source.pagesDir](/zh/guide/config#source) 指定目录下的文件作为页面: 14 | 15 | - `page.{js,jsx,ts,tsx}` 会作为**页面组件** 16 | - `layout.{js,jsx,ts,tsx}` 会作为**布局组件** 17 | 18 | :::tip 19 | - 不管是 `.md` 还是 `.mdx`,servite 都会统一使用 MDX 来解析 Markdown 文件内容。 20 | - 如果是 `.js`、`.jsx`、`.ts`、`.tsx` 页面文件,这些文件模块需要默认导出一个组件。 21 | ::: 22 | 23 | ### 普通路由 24 | 假设你的项目 src/pages 目录有如下文件结构: 25 | 26 | ```shell 27 | src/pages 28 | ├─ dashboard 29 | │ ├─ analytics 30 | │ │ └─ page.tsx 31 | │ │ 32 | │ ├─ settings 33 | │ │ └─ page.tsx 34 | │ │ 35 | │ └─ layout.tsx 36 | │ 37 | ├─ layout.tsx 38 | └─ page.tsx 39 | ``` 40 | 41 | 实际得到的路由映射是: 42 | 43 | | 文件路径 | 路由路径 | 是否布局组件 | 44 | | ------------------------------- | ----------------------- | ------------ | 45 | | `/layout.tsx` | `` | 是 | 46 | | `/page.tsx` | `/` | 否 | 47 | | `/dashboard/layout.tsx` | `` | 是 | 48 | | `/dashboard/analytics/page.tsx` | `/dashboard/analytics` | 否 | 49 | | `/dashboard/settings/page.tsx` | `/dashboard/settings` | 否 | 50 | 51 | 对应的 React Router 配置对象类似于: 52 | 53 | ```tsx 54 | const routes = [ 55 | { 56 | element: , 57 | children: [ 58 | { 59 | path: '/', 60 | element: , 61 | }, 62 | { 63 | element: , 64 | children: [ 65 | { 66 | path: '/dashboard/analytics', 67 | element: , 68 | }, 69 | { 70 | path: '/dashboard/settings', 71 | element: , 72 | }, 73 | ], 74 | }, 75 | ], 76 | }, 77 | ]; 78 | ``` 79 | 80 | ### 动态参数路由 81 | 像 `/posts/[id]` 这样的路径就是动态参数路由,`[]` 里面的 `id` 是动态参数。当我们跳转到 `/posts/123` 时, 82 | 在页面组件中可以通过 `useParams` 拿到这个值为 `123` 的 `id`: 83 | 84 | ```tsx 85 | // posts/[id]/page.tsx 86 | import { useParams } from 'servite/runtime/router'; 87 | 88 | // 如果 pathname 是 /posts/123 会渲染成:

123

89 | export default function Post() { 90 | const { id } = useParams(); 91 | return

{id}

; 92 | } 93 | ``` 94 | 95 | ### 可选参数路由 96 | 97 | 像 `/posts/[[id]]` 这样的路径就是可选参数路由,`[[]]` 里面的 `id` 是可选参数。无论我们跳转 `/posts` 还是 `/posts/123`, 98 | 都会走到这个页面组件。 99 | 100 | ```tsx 101 | // posts/[[id]]/page.tsx 102 | import { useParams } from 'servite/runtime/router'; 103 | 104 | // 如果 pathname 是 /posts 会渲染成:

105 | // 如果 pathname 是 /posts/123 会渲染成:

123

106 | export default function Post() { 107 | const { id } = useParams(); 108 | return

{id}

; 109 | } 110 | ``` 111 | 112 | ### 路由分组 113 | 路由分组的概念来源于 Next.js 的 App 路由。在约定式路由中,目录会直白地映射为 URL 路径, 114 | 但有些时候我们的确想让某个目录不被映射为 URL 的一部分,这时就可以使用**路由分组**,这能让我们组织出更有逻辑性的项目目录结构。 115 | 116 | 举个例子,我们把不同语种翻译的文档按语种划分到不同的目录下,就像 `/zh/about.tsx` 和 `/en/about.tsx`,但可能英文作为站点的默认语言, 117 | 我们不想在 URL 上有 `en` 这个路径,这时只需要给 `en` 加上括号 `(en)` 即可成为路由分组: 118 | 119 | ```shell 120 | src/pages 121 | ├─ (en) 122 | │ └─ about.tsx 123 | └─ zh 124 | └─ about.tsx 125 | ``` 126 | 127 | 这样我们就可以通过 `/about` 访问到英文文档,通过 `/zh/about` 访问到中文文档。 128 | 129 | ## Markdown 路由 130 | 131 | Markdown 的路由跟页面路由类似,Markdown 路由也有动态参数、可选参数、路由分组等概念, 132 | 只是从实用性出发, Markdown 路由文件名不再需要是 `page.mdx` 或 `layout.mdx`,文件名就直接反映了路由路径。 133 | 并且如果 Markdown 文件名是 `index` 或者 `README`,则这部分文件名会被忽略。 134 | 135 | 例如下面的目录结构: 136 | 137 | ```text 138 | src/pages 139 | ├─ guide 140 | │ ├─ config.mdx 141 | │ └─ index.mdx 142 | ├─ api.mdx 143 | └─ README.mdx 144 | ``` 145 | 146 | 实际得到的路由映射是: 147 | 148 | | 文件路径 | 路由路径 | 149 | | ------------------------ | ----------------- | 150 | | `/guide/config.mdx` | `/guide/config` | 151 | | `/guide/index.mdx` | `/guide` | 152 | | `/api.mdx` | `/api` | 153 | | `/README.mdx` | `/` | 154 | 155 | :::tip 156 | Markdown 路由文件名后缀需是 `.mdx` 或 `.md`。 157 | ::: 158 | 159 | ## API 路由 160 | 161 | 默认情况下,servite 会将 `src/server/routes` 目录下的 `*..[jt]s` 文件作为 API 接口。 162 | 163 | 举个例子,文件 `src/server/routes/api/todo.get.ts` 有如下内容: 164 | 165 | ```ts 166 | import { eventHandler } from 'servite/runtime/server'; 167 | 168 | export default eventHandler(async event => { 169 | // ... 170 | return { 171 | code: 0, 172 | msg: 'ok', 173 | data: 'This is ok', 174 | }; 175 | }); 176 | ``` 177 | 178 | 由此你就得到了一个 `GET /api/todo` 的 HTTP 接口,这里面有几个点: 179 | 180 | - 文件路径会直观地映射为接口请求路径:`/api/todo`。 181 | - 文件名 `todo.get.ts` 中的 `get` 会被映射为 HTTP GET 方法。同理,你可以这么定义一个 POST 请求:`todo.post.ts`。 182 | - 需要从 `servite/runtime/server` 导出 `eventHandler` 方法来定义请求处理器。 183 | 184 | 定义完接口后,在前端就可以通过原生 `fetch` 或 `axios` 等请求库来调用此接口。 185 | 而为了便于快速开发,Servite 内置了 [ofetch](https://github.com/unjs/ofetch) 请求库,你可以通过 `servite/runtime/fetch` 导入 `ofetch` 的相关方法: 186 | 187 | ```ts 188 | import { $fetch } from 'servite/runtime/fetch'; 189 | $fetch('/api/todo'); 190 | ``` 191 | 192 | :::tip[进阶] 193 | Servite 支持[一体化 API 调用](/zh/guide/unified-invocation),能提供更好的开发体验。 194 | ::: 195 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/runtime.mdx: -------------------------------------------------------------------------------- 1 | # 运行时能力 2 | 3 | Servite 封装了一些常用的运行时能力,包括 router、helmet、fetch、server 等等。 4 | 这些运行时能力都会通过 `servite/runtime/*` 来导出,例如使用 react-router 的一些 API 可以通过 `servite/runtime/router` 来导入。 5 | 6 | ## components 7 | 8 | ### ClientOnly 9 | 10 | 有时候我们需要仅在客户端渲染的组件,这就可以使用 `ClientOnly` 组件。 11 | 这个组件在服务端渲染时会直接渲染 `null`,在浏览器端 useEffect 后才会真正地渲染内容。 12 | 13 | ```tsx 14 | import { ClientOnly } from 'servite/runtime/components'; 15 | 16 | export default function Page() { 17 | return ( 18 |
19 |
这里会 SSR
20 | // [!code highlight] 21 |
而这里只会在浏览器中进行渲染
// [!code highlight] 22 |
// [!code highlight] 23 |
24 | ) 25 | } 26 | ``` 27 | 28 | ## fetch 29 | 30 | 无论是在服务器端还是在浏览器端,使用 fetch 来发起请求都是很常见的场景, 31 | 为此 Servite 基于 [ofetch](https://github.com/unjs/ofetch) 来提供同构的 fetch 能力。 32 | 并且 ofetch 在原生 fetch 的基础上做了一些扩展,例如直接解析 json、自动重试等等。 33 | 34 | ```tsx 35 | import { $fetch } from 'servite/runtime/fetch'; 36 | 37 | export default async function Page() { 38 | useEffect(() => { 39 | fetch('URL_ADDRESS').then(res => { 40 | // ... 41 | }); 42 | }, []) 43 | } 44 | ``` 45 | 46 | Servite 提供的 `$fetch` 函数在服务器环境里还会自动带上当前请求的 headers 和 context。 47 | 48 | ## helmet 49 | 50 | Servite 的 helmet 是基于 [react-helmet-async](https://github.com/staylor/react-helmet-async) 实现的。 51 | 52 | ```tsx 53 | import { Helmet } from 'servite/runtime/helmet'; 54 | 55 | export default function Page() { 56 | return ( 57 | 58 | Servite 59 | 60 | 61 | ) 62 | } 63 | ``` 64 | 65 | ## island 66 | 67 | 导出“孤岛”相关的 API。 68 | 69 | 继承 IslandProps: 70 | 71 | ```tsx 72 | import { IslandProps } from 'servite/runtime/island'; 73 | 74 | export interface MyComponentProps extends IslandProps {} 75 | ``` 76 | 77 | 手动水合组件: 78 | 79 | ```tsx 80 | import { hydrateIsland } from 'servite/runtime/island'; 81 | 82 | // 水合某个 id 的组件 83 | hydrateIsland('id'); 84 | 85 | // 水合全部 manual 的组件 86 | hydrateIsland(true); 87 | ``` 88 | 89 | ## mdx 90 | 91 | 导出 `@mdx-js/react` 的 API。 92 | 93 | ```tsx 94 | import { MDXProvider } from 'servite/runtime/mdx'; 95 | 96 | function A({ 97 | href, 98 | ...restProps 99 | }: React.DetailedHTMLProps< 100 | React.AnchorHTMLAttributes, 101 | HTMLAnchorElement 102 | >) { 103 | if (href?.startsWith('/')) { 104 | return ; 105 | } 106 | return ; 107 | } 108 | 109 | export default function Page() { 110 | return ( 111 | 112 | {/* ... */} 113 | 114 | ) 115 | } 116 | ``` 117 | 118 | ## router 119 | 120 | Servite 的 router 是基于 [react-router](https://reactrouter.com/) 实现的,所以你可以使用 react-router 的所有 API。 121 | 122 | ```tsx 123 | import { useParams } from 'servite/runtime/router'; 124 | 125 | export default function Page() { 126 | const routeParams = useParams(); 127 | // ... 128 | } 129 | ``` 130 | 131 | 另外,Servite 对 react-router 的 Link 组件做了一些增强: 132 | 133 | - 在支持 hover 的设备上(`@media (hover: hover)`),鼠标 hover 到 Link 上时,会自动预取相应的路由组件 JS,这样可以减少路由跳转的等待时间。 134 | - 在**不**支持 hover 的设备上,当 Link 在视口内可见时,也会触发预取路由组件的 JS。 135 | 136 | ## server 137 | 138 | Servite 通过 `servite/runtime/server` 来提供一些服务端的运行时能力,具体 API 可以参考 [h3](https://github.com/unjs/h3)。 139 | 140 | ```ts 141 | import { defineEventHandler } from 'servite/runtime/server'; 142 | 143 | export default defineEventHandler(async event => { 144 | // ... 145 | }); 146 | ``` 147 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/ssg.mdx: -------------------------------------------------------------------------------- 1 | # SSG - 静态站点生成 2 | 3 | ## 介绍 4 | 5 | SSG(Static Site Generation 静态站点生成)是指在构建时生成静态页面,并在运行时直接展示这些静态页面。 6 | 这与 SSR(服务器端渲染)的主要区别在于,SSG 在运行时不需要调用服务器来渲染页面,而是直接展示预先生成的静态页面。 7 | 8 | 相比 SSR,使用 SSG 的优点是可以进一步提高页面加载速度,并且节省服务器资源,因为页面已经是静态的,不需要调用服务器来渲染。 9 | 这在构建内容不经常更新的站点时特别有用,例如博客、文档等,你现在看到的 servite 文档就使用了 SSG 👀。 10 | 11 | ## 使用 12 | 13 | Servite 通过 [server.prerender](/zh/guide/config#server) 配置项支持了页面 SSG, 14 | 使用时可以传入 glob 模式匹配需要 SSG 的页面,例如: 15 | 16 | ```ts 17 | import { defineConfig } from 'servite/config'; 18 | 19 | export default defineConfig({ 20 | server: { 21 | prerender: { 22 | routes: ['**/*'], // 匹配所有页面 // [!code highlight] 23 | failOnError: true, 24 | }, 25 | }, 26 | }); 27 | ``` 28 | 29 | :::warning 30 | SSG 是在**构建时**生成静态页面的,因此只有在重新构建时才能更新页面。 31 | 这意味着,如果需要频繁更新页面内容,则需要定期重新构建站点。 32 | ::: 33 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/ssr.mdx: -------------------------------------------------------------------------------- 1 | # SSR - 服务端渲染 2 | 3 | ## 介绍 4 | 5 | SSR(Server-Side Rendering 服务端渲染)是一种在请求时在服务器上而不是在浏览器中渲染 Web 页面的技术, 6 | 这允许 Web 页面在服务器上呈现并作为静态 HTML 发送到客户端。SSR 有很多好处: 7 | 8 | - 提高性能并提供更好的用户体验。 9 | - 改进 Web 应用的搜索引擎优化(SEO),因为内容已经在服务器上呈现,并且可以很容易地被搜索引擎索引。 10 | 11 | ## 使用 12 | 13 | servite 已经默认开启了 SSR,无需额外配置。 14 | 15 | :::warning 16 | 为了让渲染的内容在服务端和客户端保持一致,需要注意以下几点: 17 | 18 | - 在编写 class 组件时,避免在构造函数中执行任何副作用操作(如访问浏览器 API、发送网络请求等)。 19 | 这些操作只能在生命周期函数或事件回调中执行。 20 | - 同理,在编写函数组件时,避免在函数体中执行任何副作用操作。这些操作只能在使用 hooks(如 useEffect)时执行。 21 | - 在使用 hooks 时,需要确保它们在服务端渲染时不执行副作用操作。这通常需要检查组件的挂载状态。 22 | ::: 23 | 24 | ## 降级 25 | 26 | 当 SSR 出错,或者因为机器性能瓶颈等原因,我们需要将 SSR 降级为 CSR,以提高页面的可用性。 27 | 28 | ### 自动降级 29 | 30 | 当 SSR 出错时,Servite 会主动降级为 CSR,这无需开发者手动操作。 31 | 32 | ### 主动降级 33 | 34 | #### URL 35 | 36 | 给页面 URL 添加 `ssr_fallback=1` 参数,可以主动降级为 CSR。 37 | 例如,将原始地址 `https://www.xyz.com/pageA` 修改为 `http://www.xyz.com/pageA?ssr_fallback=1`, 38 | 使降级即时生效。 39 | 40 | #### Header 41 | 42 | 给页面添加 `x-ssr-fallback: 1` 请求头,也可以主动降级为 CSR。 43 | 44 | #### 中间件 45 | 46 | 在中间件 onRequest 里可以通过设置 `event.context.ssr = false` 在请求处理过程中主动降级为 CSR。 47 | 48 | ```ts 49 | // src/server/middlewares/.ts 50 | import { defineMiddleware } from 'servite/runtime/server'; 51 | import os from 'os'; 52 | 53 | export default defineMiddleware(async (event, next) => { 54 | // 如果系统负载过高,则主动降级为 CSR 55 | if (event.path === '/pageA' && os.loadavg()[0] > 1.5) { 56 | event.context.ssr = false; 57 | } 58 | return next(); 59 | }); 60 | ``` 61 | 62 | ## 缓存 63 | 64 | TODO 65 | 66 | ## 修改 SSR 返回的 HTML 67 | 68 | 在中间件 onRequest 里可以通过 `event.context.html.inject()` 方法给 SSR 返回的 HTML 注入标签。 69 | 70 | ```ts 71 | // src/server/middlewares/.ts 72 | import { defineMiddleware } from 'servite/runtime/server'; 73 | 74 | export default defineMiddleware(async (event, next) => { 75 | if (event.path === '/pageA') { 76 | // 给 ssr 返回的 html body 加上额外的 script 77 | event.context.html.inject({ 78 | injectTo: 'body', 79 | tag: 'script', 80 | attrs: { 81 | src: '...' 82 | } 83 | }); 84 | } 85 | 86 | return next(); 87 | }); 88 | ``` 89 | 90 | 也可以通过 `event.context.html.addTransformer()` 方法修改 SSR 返回的 HTML 内容: 91 | 92 | ```ts 93 | // src/server/middlewares/.ts 94 | import { defineMiddleware } from 'servite/runtime/server'; 95 | 96 | export default defineMiddleware(async (event, next) => { 97 | if (event.path === '/pageA') { 98 | // 修改 ssr 返回的 html string 99 | event.context.html.addTransformer(html => { 100 | return html.replace('', '
Hello World
'); 101 | }); 102 | } 103 | 104 | return next(); 105 | }); 106 | ``` 107 | 108 | :::warning 109 | 需要注意的是,Servite 使用的是 Streaming SSR,所以上述 html transformer 会被多次调用,即每个 html 分块都会被调用一次 transform, 110 | 所以在 transform 函数中修改 html 内容时需要注意避免重复修改。 111 | ::: 112 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/start.mdx: -------------------------------------------------------------------------------- 1 | # 开始上手 2 | 3 | :::tip 4 | 其实使用 Servite 来搭建文档站也是完全可以的,但 Servite 没有自带文档主题,所以需要你从零开始写更多的组件、样式等, 5 | 当然,你也可以复制你眼前的这个 [Servite 文档项目](https://github.com/Codpoe/servite/tree/master/packages/docs),然后在此基础上进行修改。 6 | ::: 7 | 8 | ## 1. 快速搭建项目 9 | 10 | ```shell 11 | # npm 6.x 12 | npm create servite my-app 13 | 14 | # npm 7+, extra double-dash is needed: 15 | npm create servite -- my-app 16 | 17 | # yarn 18 | yarn create servite my-app 19 | 20 | # pnpm(后续操作均以 pnpm 作为示例) 21 | pnpm create servite my-app 22 | ``` 23 | 24 | 执行上面的命令会在目录 `my-app` 里用 vite 的 `react-ts` 模板来初始化项目。你也可以把上面的 `my-app` 替换成 `.`,这样项目就会在当前目录进行初始化了。 25 | 26 | ## 2. 启动本地开发服务器 27 | 28 | 经过第一步后,可以 `cd my-app` 进入项目目录,然后执行下面的命令安装依赖: 29 | 30 | ```shell 31 | pnpm i 32 | ``` 33 | 34 | 通过如下命令启动 dev server: 35 | 36 | ```shell 37 | pnpm dev 38 | ``` 39 | 40 | 这样 vite 将在 http://127.0.0.1:3000/ 启动开发服务。 41 | 42 | ## 3. 构建生产环境产物 43 | 44 | 通过以下命令构建生产环境的产物: 45 | 46 | ```shell 47 | pnpm build 48 | ``` 49 | 50 | 默认情况下产物会被打包到 `.output` 目录,以将这个目录直接部署到服务器上运行。 51 | 52 | ## 4. 本地预览 53 | 54 | 在上面的生产构建命令运行完成后,控制台最后会输出一句提示: 55 | 56 | ```shell 57 | ✔ You can preview this build using node .output/server/index.mjs 58 | ``` 59 | 60 | 所以你可以按照此提示,执行命令进行预览: 61 | 62 | ```shell 63 | node .output/server/index.mjs 64 | ``` 65 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/guide/unified-invocation.mdx: -------------------------------------------------------------------------------- 1 | # 一体化 API 调用 2 | 3 | Servite 支持一体化的 API 调用,意思就是可以在前端直接 import API 函数,调用时会自动转换成 HTTP 请求。这个方式有以下好处: 4 | 5 | - 更简洁、优雅的 API 调用方式,无需手动写接口 URL 6 | - 更完善的 Typescript 类型提示。在前端侧可以直接感知接口的参数类型和响应类型 7 | - 更好的开发体验 8 | 9 | ## 使用 10 | 11 | 在前面我们已经介绍过如何定义 [API 路由](/zh/guide/routes#api-路由)了,但想要实现**一体化调用**,你需要改为从 `servite/server` 中导出 `apiHandler` 方法来定义接口: 12 | 13 | ```ts 14 | // /src/server/api/todo.get.ts 15 | 16 | import { apiHandler } from 'servite/server'; 17 | export default apiHandler(async (args, event) => { 18 | // ... 19 | return { 20 | code: 0, 21 | msg: 'ok', 22 | data: 'This is ok', 23 | }; 24 | }); 25 | ``` 26 | 27 | 接着就可以在前端代码中直接 import 该文件来进行调用了: 28 | 29 | ```tsx 30 | // /src/pages/page.tsx 31 | 32 | import { useState, useEffect } from 'react'; 33 | import getTodo from '../server/api/todo.get'; 34 | 35 | export default function Page() { 36 | const [res, setRes] = useState(); 37 | 38 | useEffect(() => { 39 | (async () => { 40 | // 此调用会被自动转换成发起请求:GET /api/todo 41 | const res = await getTodo(); 42 | setRes(res); 43 | })(); 44 | }, []); 45 | 46 | // ... 47 | } 48 | ``` 49 | 50 | :::tip 51 | 一体化 API 调用相关的配置项可以看[这里](/zh/guide/config#api)。 52 | ::: 53 | 54 | ### 结合 Typescript 55 | 56 | 结合 Typescript,定义好接口的入参类型和返回结果类型,能让一体化调用达到更好的开发体验: 57 | 58 | ```ts 59 | // /src/server/api/todo.get.ts 60 | 61 | import { apiHandler } from 'servite/server'; 62 | 63 | export enum Bar { 64 | Great, 65 | Cool, 66 | } 67 | 68 | // 定义入参类型 69 | export interface Args { 70 | foo: string; 71 | bar: Bar; 72 | } 73 | 74 | // 定义响应类型 75 | export interface Result { 76 | code: number; 77 | msg: string; 78 | data: string; 79 | } 80 | 81 | // 通过泛型来约束函数类型 82 | export default apiHandler(async (args: Args, event) => { 83 | // ... 84 | return { 85 | code: 0, 86 | msg: 'ok', 87 | data: 'This is ok', 88 | }; 89 | }); 90 | ``` 91 | 92 | 在前端 import 相关的类型: 93 | 94 | ```tsx 95 | // /src/pages/page.tsx 96 | 97 | import { useState, useEffect } from 'react'; 98 | import getTodo, { Result, Bar } from '../server/api/todo.get'; 99 | 100 | export default function Page() { 101 | // 使用 Result 作为 useState 的泛型 102 | const [res, setRes] = useState(); 103 | 104 | useEffect(() => { 105 | (async () => { 106 | // 此调用会被自动转换成发起请求:GET /api/todo?foo=xxx&bar=0 107 | const res = await getTodo({ 108 | foo: 'xxx', 109 | bar: Bar.Great, 110 | }); 111 | setRes(res); 112 | })(); 113 | }, []); 114 | 115 | // ... 116 | } 117 | ``` 118 | -------------------------------------------------------------------------------- /packages/docs/src/pages/zh/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FileTextIcon, 3 | MonitorCheckIcon, 4 | ServerIcon, 5 | TreePalmIcon, 6 | ZapIcon, 7 | } from 'shadcn-react/icons'; 8 | import { Button } from 'shadcn-react'; 9 | import { Link } from 'servite/runtime/router'; 10 | import { BentoCard, BentoCardProps, BentoGrid } from '@/components/Bento'; 11 | 12 | const features: BentoCardProps[] = [ 13 | { 14 | Icon: ZapIcon, 15 | name: 'SSR', 16 | description: '默认启用 SSR,但也切换 SSG 和 CSR。', 17 | to: '/zh/guide/ssr', 18 | cta: '了解更多', 19 | background: , 20 | className: 'md:row-start-1 md:row-end-4 md:col-start-2 md:col-end-3', 21 | }, 22 | { 23 | Icon: FileTextIcon, 24 | name: 'SSG', 25 | description: '如果站点大部分内容都是静态的,可方便地切换到 SSG。', 26 | to: '/zh/guide/ssg', 27 | cta: '了解更多', 28 | background: , 29 | className: 'md:col-start-1 md:col-end-2 md:row-start-1 md:row-end-3', 30 | }, 31 | { 32 | Icon: MonitorCheckIcon, 33 | name: 'CSR', 34 | description: '支持自动或手动降级为 CSR。', 35 | to: '/zh/guide/csr', 36 | cta: '了解更多', 37 | background: , 38 | className: 'md:col-start-1 md:col-end-2 md:row-start-3 md:row-end-4', 39 | }, 40 | { 41 | Icon: TreePalmIcon, 42 | name: 'Islands', 43 | description: '改进 Islands 孤岛架构,开发更友好', 44 | to: '/zh/guide/islands', 45 | cta: '了解更多', 46 | background: , 47 | className: 'md:col-start-3 md:col-end-3 md:row-start-1 md:row-end-2', 48 | }, 49 | { 50 | Icon: ServerIcon, 51 | name: 'Vinxi', 52 | description: 'Servite 底层由 Vinxi / Nitro 驱动,稳定可靠,部署便捷。', 53 | background: , 54 | className: 'md:col-start-3 md:col-end-3 md:row-start-2 md:row-end-4', 55 | }, 56 | ]; 57 | 58 | export default function Page() { 59 | return ( 60 |
61 |

62 | 全栈的 React 开发框架 63 |

64 | 65 | 66 | 67 | 68 | {features.map(feature => ( 69 | 70 | ))} 71 | 72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /packages/docs/src/utils/scroll.ts: -------------------------------------------------------------------------------- 1 | export function getScrollTop() { 2 | return Math.max( 3 | window.pageYOffset, 4 | document.documentElement.scrollTop, 5 | document.body.scrollTop, 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /packages/docs/src/utils/throttle.ts: -------------------------------------------------------------------------------- 1 | export function throttle(fn: (...args: any[]) => any, delay: number) { 2 | let timer: any = null; 3 | let lastArgs: any[] | null = null; 4 | let lastThis: any | null = null; 5 | 6 | function throttled(this: any, ...args: any[]) { 7 | lastArgs = args; 8 | // eslint-disable-next-line @typescript-eslint/no-this-alias 9 | lastThis = this; 10 | 11 | if (!timer) { 12 | timer = setTimeout(() => { 13 | timer = null; 14 | if (lastArgs) { 15 | fn.apply(lastThis, lastArgs); 16 | lastArgs = null; 17 | lastThis = null; 18 | } 19 | }, delay); 20 | } 21 | } 22 | 23 | return throttled; 24 | } 25 | 26 | export function debounce(fn: (...args: any[]) => any, delay: number) { 27 | let timer: any = null; 28 | 29 | function debounced(this: any, ...args: any[]) { 30 | if (timer) { 31 | clearTimeout(timer); 32 | } 33 | 34 | timer = setTimeout(() => { 35 | fn.apply(this, args); 36 | }, delay); 37 | } 38 | 39 | return debounced; 40 | } 41 | -------------------------------------------------------------------------------- /packages/docs/src/utils/url.ts: -------------------------------------------------------------------------------- 1 | export function withoutTrailingSlash(url: string) { 2 | return url.replace(/\/$/, '').replace('/#', '#'); 3 | } 4 | -------------------------------------------------------------------------------- /packages/docs/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /packages/docs/tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | import typography from '@tailwindcss/typography'; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | darkMode: 'class', 6 | content: ['./src/**/*.{js,jsx,ts,tsx,md,mdx}'], 7 | theme: { 8 | extend: { 9 | colors: { 10 | border: 'hsl(var(--border))', 11 | input: 'hsl(var(--input))', 12 | ring: 'hsl(var(--ring))', 13 | background: 'hsl(var(--background))', 14 | foreground: 'hsl(var(--foreground))', 15 | primary: { 16 | DEFAULT: 'hsl(var(--primary))', 17 | foreground: 'hsl(var(--primary-foreground))', 18 | }, 19 | secondary: { 20 | DEFAULT: 'hsl(var(--secondary))', 21 | foreground: 'hsl(var(--secondary-foreground))', 22 | }, 23 | destructive: { 24 | DEFAULT: 'hsl(var(--destructive))', 25 | foreground: 'hsl(var(--destructive-foreground))', 26 | }, 27 | muted: { 28 | DEFAULT: 'hsl(var(--muted))', 29 | foreground: 'hsl(var(--muted-foreground))', 30 | }, 31 | accent: { 32 | DEFAULT: 'hsl(var(--accent))', 33 | foreground: 'hsl(var(--accent-foreground))', 34 | }, 35 | popover: { 36 | DEFAULT: 'hsl(var(--popover))', 37 | foreground: 'hsl(var(--popover-foreground))', 38 | }, 39 | card: { 40 | DEFAULT: 'hsl(var(--card))', 41 | foreground: 'hsl(var(--card-foreground))', 42 | }, 43 | }, 44 | borderRadius: { 45 | lg: 'var(--radius)', 46 | md: 'calc(var(--radius) - 2px)', 47 | sm: 'calc(var(--radius) - 4px)', 48 | }, 49 | }, 50 | }, 51 | corePlugins: { 52 | preflight: false, 53 | }, 54 | plugins: [typography], 55 | }; 56 | -------------------------------------------------------------------------------- /packages/docs/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | "jsx": "react-jsx", 15 | 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true, 20 | 21 | "baseUrl": ".", 22 | "paths": { 23 | "@/*": ["./src/*"], 24 | "island:*": ["./src/*"] 25 | } 26 | }, 27 | "include": ["src"] 28 | } 29 | -------------------------------------------------------------------------------- /packages/docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /packages/docs/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "isolatedModules": true, 11 | "moduleDetection": "force", 12 | "noEmit": true, 13 | 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true 18 | }, 19 | "include": ["vite.config.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/docs/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import { defineConfig } from 'servite/config'; 3 | import * as pagefind from 'pagefind'; 4 | 5 | const app = defineConfig({ 6 | server: { 7 | baseURL: process.env.GH_PAGES ? '/servite' : '/', 8 | prerender: { 9 | routes: ['**/*'], 10 | failOnError: true, 11 | }, 12 | }, 13 | }); 14 | 15 | app.hooks.hook('app:build:nitro:start', async ({ nitro }) => { 16 | // eslint-disable-next-line no-console 17 | console.log('\nRunning pagefind...'); 18 | 19 | const wrap = async ( 20 | promise: Promise, 21 | ): Promise => { 22 | const res = await promise; 23 | 24 | if (res.errors?.length) { 25 | throw new Error(res.errors.join('\n')); 26 | } 27 | 28 | return res; 29 | }; 30 | 31 | const { index } = await wrap(pagefind.createIndex()); 32 | 33 | if (!index) { 34 | return; 35 | } 36 | 37 | const { page_count } = await wrap( 38 | index.addDirectory({ 39 | path: nitro.options.output.publicDir, 40 | }), 41 | ); 42 | 43 | if (page_count === 0) { 44 | // eslint-disable-next-line no-console 45 | console.log(`No pages found`); 46 | } 47 | 48 | // eslint-disable-next-line no-console 49 | console.log(`Indexed ${page_count} pages`); 50 | 51 | await wrap( 52 | index.writeFiles({ 53 | outputPath: join(nitro.options.output.publicDir, 'pagefind'), 54 | }), 55 | ); 56 | 57 | // eslint-disable-next-line no-console 58 | console.log('Run pagefind successfully\n'); 59 | }); 60 | 61 | export default app; 62 | -------------------------------------------------------------------------------- /packages/examples/basic/app.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'servite/config'; 2 | 3 | export default defineConfig({ 4 | server: { 5 | baseURL: '/base', 6 | prerender: { 7 | failOnError: true, 8 | routes: ['/*', '/**/*'], 9 | }, 10 | }, 11 | routers: { 12 | server: { 13 | base: '/server', 14 | }, 15 | ssr: { 16 | base: '/ssr', 17 | }, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /packages/examples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playground-basic", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vinxi dev", 8 | "build": "vinxi build", 9 | "start": "vinxi start" 10 | }, 11 | "dependencies": { 12 | "react": "^18.3.1", 13 | "react-dom": "^18.3.1", 14 | "servite": "workspace:*", 15 | "vinxi": "^0.4.3" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^18.3.5", 19 | "autoprefixer": "^10.4.20", 20 | "tailwindcss": "^3.4.11" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/examples/basic/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/examples/basic/src/pages/(group)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'servite/runtime/router'; 2 | 3 | export default function Layout() { 4 | return ( 5 |
6 | Group Layout 7 |
8 | 9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /packages/examples/basic/src/pages/(group)/user/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return
User Page
; 3 | } 4 | -------------------------------------------------------------------------------- /packages/examples/basic/src/pages/[...]/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return
Fallback Page
; 3 | } 4 | -------------------------------------------------------------------------------- /packages/examples/basic/src/pages/home/[...]/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return
Home Fallback Page
; 3 | } 4 | -------------------------------------------------------------------------------- /packages/examples/basic/src/pages/home/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from 'servite/runtime/router'; 2 | 3 | export default function Page() { 4 | const { id } = useParams<{ id: string }>(); 5 | return
id: {id}
; 6 | } 7 | -------------------------------------------------------------------------------- /packages/examples/basic/src/pages/home/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Helmet } from 'servite/runtime/helmet'; 2 | import { Outlet } from 'servite/runtime/router'; 3 | 4 | export default function Layout() { 5 | return ( 6 |
7 | 8 | Servite 9 | 10 | Home Layout 11 | 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /packages/examples/basic/src/pages/home/page.data.ts: -------------------------------------------------------------------------------- 1 | import { defer, LoaderFunction } from 'servite/runtime/router'; 2 | import getUser from '@/server/routes/user.get'; 3 | 4 | export interface LoaderData { 5 | a: number; 6 | b: string; 7 | c?: Promise; 8 | user: Awaited>; 9 | getUserPromise: ReturnType; 10 | } 11 | 12 | export const loader: LoaderFunction = async () => { 13 | 'use server'; 14 | const user = await getUser({}); 15 | 16 | return defer({ 17 | a: Date.now(), 18 | b: 'bbb', 19 | c: new Promise(resolve => { 20 | setTimeout(() => { 21 | resolve(true); 22 | }, 300); 23 | }), 24 | user, 25 | getUserPromise: getUser({}), 26 | } satisfies LoaderData); 27 | }; 28 | -------------------------------------------------------------------------------- /packages/examples/basic/src/pages/home/page.tsx: -------------------------------------------------------------------------------- 1 | import { Await, useLoaderData } from 'servite/runtime/router'; 2 | import { Suspense, useEffect, useId, useState } from 'react'; 3 | import { Helmet } from 'servite/runtime/helmet'; 4 | import type { LoaderData } from './page.data'; 5 | import { serverFn_1, serverFn_2 } from './server-fns'; 6 | import getUser from '@/server/routes/user.get'; 7 | 8 | export default function Home() { 9 | const id = useId(); 10 | const loaderData = useLoaderData() as LoaderData; 11 | const [count, setCount] = useState(0); 12 | 13 | const [user, setUser] = useState>(); 14 | const [rawUserResponse, setRawUserReponse] = useState(); 15 | const [serverFn_1_res, setServerFn_1_res] = 16 | useState>>(); 17 | const [serverFn_2_res, setServerFn_2_res] = 18 | useState>>(); 19 | 20 | useEffect(() => { 21 | getUser({}).then(setUser); 22 | getUser.raw({}).then(setRawUserReponse); 23 | serverFn_1().then(setServerFn_1_res); 24 | serverFn_2().then(setServerFn_2_res); 25 | }, []); 26 | 27 | return ( 28 | <> 29 | 30 | 31 | 32 |
33 | Home 34 |
{JSON.stringify(loaderData)}
35 | 36 | loading c
}> 37 | 38 | {(c: boolean) => { 39 | return
{JSON.stringify(c)}
; 40 | }} 41 |
42 | 43 | loading getUser
}> 44 | 45 | {(user: Awaited) => { 46 | return
{JSON.stringify(user)}
; 47 | }} 48 |
49 | 50 |
51 | client user: {JSON.stringify(user || null)} 52 |
53 |
54 | client raw user response:{' '} 55 | {JSON.stringify({ 56 | ok: rawUserResponse?.ok, 57 | status: rawUserResponse?.status, 58 | statusText: rawUserResponse?.statusText, 59 | 'content-type': rawUserResponse?.headers.get('content-type'), 60 | })} 61 |
62 |
serverFn_1: {JSON.stringify(serverFn_1_res || null)}
63 |
serverFn_2: {JSON.stringify(serverFn_2_res || null)}
64 |
65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /packages/examples/basic/src/pages/home/server-fns.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | // console.log('>>> server-fns'); 4 | 5 | export async function serverFn_1() { 6 | await new Promise(resolve => setTimeout(resolve, 500)); 7 | return { 8 | some: 'data_1', 9 | }; 10 | } 11 | 12 | export async function serverFn_2() { 13 | await new Promise(resolve => setTimeout(resolve, 800)); 14 | return { 15 | some: 'data_2', 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /packages/examples/basic/src/pages/layout.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /packages/examples/basic/src/pages/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'servite/runtime/router'; 2 | import './layout.css'; 3 | 4 | export default function Layout() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /packages/examples/basic/src/pages/settings/optional/[[id]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from 'servite/runtime/router'; 2 | 3 | export default function Page() { 4 | const { id } = useParams<{ id?: string }>(); 5 | return
optional id: {id}
; 6 | } 7 | -------------------------------------------------------------------------------- /packages/examples/basic/src/pages/settings/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Settings() { 2 | // throw new Error('sss'); 3 | return
Settings
; 4 | } 5 | -------------------------------------------------------------------------------- /packages/examples/basic/src/server/middlewares/ctx.ts: -------------------------------------------------------------------------------- 1 | import { defineMiddleware } from 'servite/runtime/server'; 2 | 3 | export default defineMiddleware(async (event, next) => { 4 | // console.log('[middleware:ctx] extend ctx', event.path); 5 | event.context.hello = 'world'; 6 | await next(); 7 | 8 | // console.log('[middleware:ctx] before response', event.path); 9 | }); 10 | -------------------------------------------------------------------------------- /packages/examples/basic/src/server/middlewares/html.ts: -------------------------------------------------------------------------------- 1 | import { defineMiddleware } from 'servite/runtime/server'; 2 | 3 | export default defineMiddleware(async (event, next) => { 4 | event.context.html?.inject({ 5 | injectTo: 'body', 6 | tag: 'script', 7 | children: 'console.log("hello world from middleware")', 8 | }); 9 | await next(); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/examples/basic/src/server/middlewares/log.ts: -------------------------------------------------------------------------------- 1 | import { defineMiddleware } from 'servite/runtime/server'; 2 | 3 | export default defineMiddleware(async (event, next) => { 4 | // eslint-disable-next-line no-console 5 | console.log('[middleware:log] request path', event.path); 6 | await next(); 7 | // eslint-disable-next-line no-console 8 | console.log('[middleware:log] request path end', event.path); 9 | }); 10 | -------------------------------------------------------------------------------- /packages/examples/basic/src/server/middlewares/res.ts: -------------------------------------------------------------------------------- 1 | import { defineMiddleware } from 'servite/runtime/server'; 2 | 3 | export default defineMiddleware(async (event, next) => { 4 | // console.log('[middleware:res] res', event.path); 5 | await next(); 6 | 7 | // console.log('[middleware:res] res end', event.path); 8 | 9 | if (event.path === '/server/user') { 10 | event.context.response = { 11 | ...event.context.response, 12 | middleware: 'modify res', 13 | }; 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /packages/examples/basic/src/server/routes/user.get.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler } from 'servite/runtime/server'; 2 | 3 | export default defineEventHandler(async event => { 4 | await new Promise(resolve => setTimeout(resolve, 300)); 5 | return { 6 | method: event.method, 7 | path: event.path, 8 | username: 'asdf', 9 | age: 18, 10 | fullPath: event.web?.url, 11 | url: event.node.req.url, 12 | originalUrl: event.node.req.originalUrl, 13 | }; 14 | }); 15 | -------------------------------------------------------------------------------- /packages/examples/basic/tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./src/**/*.{js,ts,jsx,tsx}', '!./src/server/**/*'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /packages/examples/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "compilerOptions": { 4 | "moduleResolution": "Bundler", 5 | "paths": { 6 | "@/*": ["./src/*"] 7 | } 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/servite/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # servite 2 | 3 | ## 2.0.11 4 | 5 | ### Patch Changes 6 | 7 | - fix: add import.meta.env.SERVER_BASE_URL 8 | 9 | ## 2.0.10 10 | 11 | ### Patch Changes 12 | 13 | - fix: prebundle react-helmet-async to fix cjs error 14 | 15 | ## 2.0.9 16 | 17 | ### Patch Changes 18 | 19 | - fix: fix treeshaking issue with runtime fetch 20 | 21 | ## 2.0.8 22 | 23 | ### Patch Changes 24 | 25 | - fix: let middlewares also handle server-fns requests 26 | 27 | ## 2.0.7 28 | 29 | ### Patch Changes 30 | 31 | - feat: add routePath field for unified-invocation 32 | 33 | ## 2.0.6 34 | 35 | ### Patch Changes 36 | 37 | - fix: refactor ssr rendering logic to fix useId() mismatch error 38 | 39 | ## 2.0.5 40 | 41 | ### Patch Changes 42 | 43 | - fix: add new field in context to flag the request type 44 | 45 | ## 2.0.4 46 | 47 | ### Patch Changes 48 | 49 | - fix: fix hydrate error 50 | 51 | ## 2.0.3 52 | 53 | ### Patch Changes 54 | 55 | - feat: add special response headers for node server 56 | 57 | ## 2.0.2 58 | 59 | ### Patch Changes 60 | 61 | - fix: adjust the logic of Link prefetch 62 | 63 | ## 2.0.1 64 | 65 | ### Patch Changes 66 | 67 | - chore: update 68 | 69 | ## 2.0.0 70 | 71 | ### Major Changes 72 | 73 | All new version. 74 | -------------------------------------------------------------------------------- /packages/servite/README.md: -------------------------------------------------------------------------------- 1 | # Servite 2 | 3 | A full stack React framework powered by [vinxi](https://github.com/nksaraf/vinxi). 4 | 5 | To check out docs, visit https://servite.vercel.app. 6 | 7 | ## Features 8 | 9 | - 🌟 SSR by default 10 | - ⚡️ SSG easily 11 | - 🖥 Automatic fallback to CSR 12 | - 🏝 Islands architecture 13 | - 🔥 Powered by vinxi / nitro 14 | -------------------------------------------------------------------------------- /packages/servite/config.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/config/index'; 2 | -------------------------------------------------------------------------------- /packages/servite/env.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | __servite__?: { 3 | ssr: boolean; 4 | ssrFallback?: boolean; 5 | }; 6 | } 7 | 8 | interface ImportMetaEnv { 9 | SERVER_BASE: string; 10 | ROUTER_SERVER_BASE: string; 11 | ROUTER_SSR_BASE: string; 12 | ROUTER_NAME: 'server' | 'server-fns' | 'ssr' | 'client'; 13 | } 14 | -------------------------------------------------------------------------------- /packages/servite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "servite", 3 | "version": "2.0.11", 4 | "description": "A full stack React framework", 5 | "keywords": [ 6 | "react", 7 | "metaframework", 8 | "vinxi" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/Codpoe/servite.git" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/Codpoe/servite/issues" 16 | }, 17 | "author": "Codpoe (https://github.com/codpoe)", 18 | "type": "module", 19 | "files": [ 20 | "dist", 21 | "config.d.ts", 22 | "env.d.ts", 23 | "runtime" 24 | ], 25 | "exports": { 26 | "./config": { 27 | "types": "./dist/config/index.d.ts", 28 | "default": "./dist/config/index.js" 29 | }, 30 | "./env": { 31 | "types": "./env.d.ts" 32 | }, 33 | "./runtime/server": { 34 | "types": "./dist/runtime/server.d.ts", 35 | "default": "./dist/runtime/server.js" 36 | }, 37 | "./runtime/router": { 38 | "types": "./dist/runtime/router.d.ts", 39 | "default": "./dist/runtime/router.js" 40 | }, 41 | "./runtime/helmet": { 42 | "types": "./dist/runtime/helmet.d.ts", 43 | "default": "./dist/runtime/helmet.js" 44 | }, 45 | "./runtime/fetch": { 46 | "types": "./dist/runtime/fetch.d.ts", 47 | "default": "./dist/runtime/fetch.js" 48 | }, 49 | "./runtime/components": { 50 | "types": "./dist/runtime/components.d.ts", 51 | "default": "./dist/runtime/components.js" 52 | }, 53 | "./runtime/mdx": { 54 | "types": "./dist/runtime/mdx.d.ts", 55 | "default": "./dist/runtime/mdx.js" 56 | }, 57 | "./runtime/mdx.css": { 58 | "default": "./runtime/mdx.css" 59 | }, 60 | "./runtime/island": { 61 | "types": "./dist/runtime/island/index.d.ts", 62 | "default": "./dist/runtime/island/index.js" 63 | } 64 | }, 65 | "scripts": { 66 | "dev": "tsc -w", 67 | "build": "rimraf dist && tsc", 68 | "prepublishOnly": "pnpm build" 69 | }, 70 | "peerDependencies": { 71 | "vinxi": "^0.4.3" 72 | }, 73 | "dependencies": { 74 | "@mdx-js/react": "^3.0.1", 75 | "@vinxi/react": "^0.2.5", 76 | "@vinxi/server-functions": "^0.4.3", 77 | "@vitejs/plugin-react": "^4.3.1", 78 | "defu": "^6.1.4", 79 | "fast-glob": "^3.3.2", 80 | "gray-matter": "^4.0.3", 81 | "is-glob": "^4.0.3", 82 | "isbot": "^5.1.17", 83 | "micromatch": "^4.0.8", 84 | "ofetch": "^1.3.4", 85 | "pathe": "^1.1.2", 86 | "react-helmet-async": "^2.0.5", 87 | "react-router-dom": "^6.26.2", 88 | "rou3": "^0.5.1", 89 | "ufo": "^1.5.4", 90 | "vite-plugin-mdx-plus": "^2.1.0", 91 | "vite-tsconfig-paths": "^5.0.1", 92 | "zod": "^3.23.8" 93 | }, 94 | "devDependencies": { 95 | "@types/is-glob": "^4.0.4", 96 | "@types/micromatch": "^4.0.9", 97 | "@types/react": "^18.3.5", 98 | "@types/react-dom": "^18.3.0", 99 | "esbuild": "0.19.8" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /packages/servite/runtime/components.d.ts: -------------------------------------------------------------------------------- 1 | export * from '../dist/runtime/components'; 2 | -------------------------------------------------------------------------------- /packages/servite/runtime/fetch.d.ts: -------------------------------------------------------------------------------- 1 | export * from '../dist/runtime/fetch'; 2 | -------------------------------------------------------------------------------- /packages/servite/runtime/helmet.d.ts: -------------------------------------------------------------------------------- 1 | export * from '../dist/runtime/helmet'; 2 | -------------------------------------------------------------------------------- /packages/servite/runtime/island.d.ts: -------------------------------------------------------------------------------- 1 | export * from '../dist/runtime/island/index'; 2 | -------------------------------------------------------------------------------- /packages/servite/runtime/mdx.css: -------------------------------------------------------------------------------- 1 | @import 'vite-plugin-mdx-plus/style.css'; -------------------------------------------------------------------------------- /packages/servite/runtime/mdx.d.ts: -------------------------------------------------------------------------------- 1 | export * from '../dist/runtime/mdx'; 2 | -------------------------------------------------------------------------------- /packages/servite/runtime/router.d.ts: -------------------------------------------------------------------------------- 1 | export * from '../dist/runtime/router'; 2 | -------------------------------------------------------------------------------- /packages/servite/runtime/server.d.ts: -------------------------------------------------------------------------------- 1 | export * from '../dist/runtime/server'; 2 | -------------------------------------------------------------------------------- /packages/servite/src/config/fs-router.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'node:fs'; 2 | import { posix } from 'node:path'; 3 | import path from 'pathe'; 4 | import { 5 | BaseFileSystemRouter, 6 | FileSystemRouterConfig, 7 | cleanPath, 8 | analyzeModule, 9 | } from 'vinxi/fs-router'; 10 | import { AppOptions } from 'vinxi'; 11 | import fg from 'fast-glob'; 12 | import { extractFrontmatter } from '../utils/md.js'; 13 | import { PageFsRoute, ServerFsRoute } from '../types/index.js'; 14 | 15 | interface PageInfo { 16 | isMd?: boolean; 17 | isLayout?: boolean; 18 | componentPick?: string[]; 19 | dataPick?: string[]; 20 | } 21 | 22 | interface PageFsRouterConfig extends FileSystemRouterConfig { 23 | base: string; 24 | } 25 | 26 | // react-router pages 27 | export class PagesFsRouter extends BaseFileSystemRouter { 28 | config: PageFsRouterConfig; 29 | srcToPageInfo: Record = {}; 30 | 31 | constructor(config: PageFsRouterConfig, router: any, app: AppOptions) { 32 | super(config, router, app); 33 | this.config = config; 34 | } 35 | 36 | toPath(src: string): string { 37 | let routePath = cleanPath(src, this.config); 38 | 39 | // markdown 40 | if (src.endsWith('.md') || src.endsWith('.mdx')) { 41 | this.srcToPageInfo[src] = { isMd: true }; 42 | routePath = routePath.replace(/\/index$/, '').replace(/\/README$/i, ''); 43 | } else if (routePath.endsWith('/page') || routePath.endsWith('/layout')) { 44 | // page or layout 45 | this.srcToPageInfo[src] = { isLayout: routePath.endsWith('/layout') }; 46 | routePath = routePath.replace(/\/(page|layout)$/, ''); 47 | } else { 48 | return ''; 49 | } 50 | 51 | routePath = routePath 52 | // '/user/[...]' -> '/user/*' 53 | .replace(/\/\[\.{3}.*?\]$/, '/*') 54 | // '/user/[id]' -> '/user/:id' 55 | // '/user/[[id]]' -> '/user/:id?' 56 | .replace(/\/\[(\[?.+?\]?)\]/g, (_, m: string) => { 57 | // optional 58 | if (m.startsWith('[') && m.endsWith(']')) { 59 | return `/:${m.slice(1, -1)}?`; 60 | } 61 | // dynamic 62 | return `/:${m}`; 63 | }) 64 | // '/(admin)/home' -> '/home' 65 | .replace(/\/\(.*?\)/g, ''); 66 | 67 | return path.join(this.config.base, routePath); 68 | } 69 | 70 | toRoute(src: string): PageFsRoute | null { 71 | const routePath = this.toPath(src); 72 | 73 | if (!routePath) { 74 | return null; 75 | } 76 | 77 | const pageInfo = this.srcToPageInfo[src]; 78 | const componentPick: string[] = ['default', '$css']; 79 | const dataPick: string[] = []; 80 | const handlePick: string[] = []; 81 | let frontmatter: Record | undefined = undefined; 82 | let dataFilePath: string | undefined = undefined; 83 | 84 | if (pageInfo.isMd) { 85 | frontmatter = extractFrontmatter(src); 86 | } else { 87 | const [, exports] = analyzeModule(src); 88 | 89 | if (!exports.some(x => x.n === 'default')) { 90 | return null; 91 | } 92 | 93 | for (const { n } of exports) { 94 | if (n === 'ErrorBoundary') { 95 | componentPick.push(n); 96 | } else if (n === 'handle') { 97 | handlePick.push(n); 98 | } 99 | } 100 | 101 | const dir = path.dirname(src); 102 | 103 | for (const ext of this.config.extensions) { 104 | const _dataFilePath = path.join( 105 | dir, 106 | `${pageInfo.isLayout ? 'layout' : 'page'}.data.${ext}`, 107 | ); 108 | if (existsSync(_dataFilePath)) { 109 | dataFilePath = _dataFilePath; 110 | break; 111 | } 112 | } 113 | 114 | if (dataFilePath) { 115 | const [, dataExports] = analyzeModule(dataFilePath); 116 | 117 | if (dataExports.some(x => x.n === 'loader')) { 118 | dataPick.push('loader'); 119 | } 120 | 121 | if (dataExports.some(x => x.n === 'action')) { 122 | dataPick.push('action'); 123 | } 124 | } 125 | } 126 | 127 | pageInfo.componentPick = componentPick; 128 | pageInfo.dataPick = dataPick; 129 | 130 | return { 131 | path: src, 132 | routePath, 133 | filePath: src, 134 | isMd: pageInfo.isMd, 135 | isLayout: pageInfo.isLayout, 136 | hasErrorBoundary: componentPick.includes('ErrorBoundary'), 137 | hasLoader: dataPick.includes('loader'), 138 | hasAction: dataPick.includes('action'), 139 | handle: { 140 | frontmatter, 141 | }, 142 | $component: { 143 | src, 144 | pick: componentPick, 145 | }, 146 | ...(dataPick.length > 0 && { 147 | $data: { 148 | src: dataFilePath!, 149 | pick: dataPick, 150 | }, 151 | }), 152 | ...(handlePick.length > 0 && { 153 | $$handle: { 154 | src, 155 | pick: handlePick, 156 | }, 157 | }), 158 | }; 159 | } 160 | 161 | async getRoutes(): Promise { 162 | // sort routes 163 | return ((await super.getRoutes()) as PageFsRoute[]).slice().sort((a, b) => { 164 | const compareRes = path 165 | .dirname(a.filePath) 166 | .localeCompare(path.dirname(b.filePath)); 167 | 168 | // layout first 169 | if (compareRes === 0) { 170 | if (a.isLayout && b.isLayout) { 171 | return 0; 172 | } 173 | return a.isLayout ? -1 : 1; 174 | } 175 | 176 | return compareRes; 177 | }); 178 | } 179 | 180 | async updateRoute(src: string) { 181 | src = path.normalize(src); 182 | 183 | // if it's a data file, just reload. 184 | if (this.isDataFile(src)) { 185 | this.reload(undefined as any); 186 | return; 187 | } 188 | 189 | if (this.isRoute(src)) { 190 | try { 191 | const originalPageInfo = this.srcToPageInfo[src]; 192 | const route = await this.toRoute(src); 193 | 194 | if (route) { 195 | this._addRoute(route); 196 | 197 | // if the exports of route file is changed, reload the route. 198 | // otherwise, just let react-refresh to handle hmr. 199 | if ( 200 | originalPageInfo?.componentPick?.toString() !== 201 | this.srcToPageInfo[src].componentPick?.toString() 202 | ) { 203 | this.reload(route as any); 204 | } 205 | } 206 | } catch (e) { 207 | // eslint-disable-next-line no-console 208 | console.error(e); 209 | } 210 | } 211 | } 212 | 213 | reload = (() => { 214 | let timer: any; 215 | 216 | return (route: any) => { 217 | if (timer) { 218 | clearTimeout(timer); 219 | timer = null; 220 | } 221 | timer = setTimeout(() => super.reload(route), 0); 222 | }; 223 | })(); 224 | 225 | isDataFile(src: string) { 226 | src = cleanPath(src, this.config); 227 | return src.endsWith('page.data') || src.endsWith('layout.data'); 228 | } 229 | } 230 | 231 | const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']; 232 | 233 | interface ServerFsRouterConfig extends FileSystemRouterConfig { 234 | middlewaresDir?: string; 235 | base: string; 236 | } 237 | 238 | // server routes 239 | export class ServerFsRouter extends BaseFileSystemRouter { 240 | config: ServerFsRouterConfig; 241 | 242 | constructor(config: ServerFsRouterConfig, router: any, app: AppOptions) { 243 | super(config, router, app); 244 | this.config = config; 245 | } 246 | 247 | isMiddleware(src: string): boolean { 248 | return Boolean( 249 | this.config.middlewaresDir && src.startsWith(this.config.middlewaresDir), 250 | ); 251 | } 252 | 253 | getHttpMethod(src: string): string | undefined { 254 | if (this.isMiddleware(src)) { 255 | return undefined; 256 | } 257 | 258 | const [, method] = cleanPath(src, this.config).match(/\.([^./]+)$/) || []; 259 | 260 | if (!method || !HTTP_METHODS.includes(method.toUpperCase())) { 261 | return undefined; 262 | } 263 | 264 | return method.toUpperCase(); 265 | } 266 | 267 | glob(): any { 268 | const patterns = [ 269 | posix.join( 270 | fg.convertPathToPattern(this.config.dir), 271 | `**/*.{${this.config.extensions.join(',')}}`, 272 | ), 273 | ]; 274 | 275 | if (this.config.middlewaresDir) { 276 | patterns.push( 277 | posix.join( 278 | fg.convertPathToPattern(this.config.middlewaresDir), 279 | `*.{${this.config.extensions.join(',')}}`, 280 | ), 281 | posix.join( 282 | fg.convertPathToPattern(this.config.middlewaresDir), 283 | `*/index.{${this.config.extensions.join(',')}}`, 284 | ), 285 | ); 286 | } 287 | 288 | return patterns; 289 | } 290 | 291 | toPath(src: string): string { 292 | if (this.isMiddleware(src)) { 293 | return '/**'; 294 | } 295 | 296 | const method = this.getHttpMethod(src); 297 | 298 | // server routes need append with http method 299 | // eg. '/user.get.ts', '/user.post.ts' 300 | if (!method) { 301 | return ''; 302 | } 303 | 304 | let routePath = cleanPath(src, this.config); 305 | 306 | routePath = routePath 307 | // '/user.get' -> '/user' 308 | .slice(0, -method.length - 1) 309 | // '/user/index' -> '/user' 310 | .replace(/\/index$/, '') 311 | // '/user/[...]' -> '/user/**' 312 | .replace(/\/\[\.{3}.*?\]$/, '/**') 313 | // '/user/[id]' -> '/user/:id' 314 | .replace(/\/\[(.+?)\]/g, '/:$1'); 315 | 316 | return path.join(this.config.base, routePath); 317 | } 318 | 319 | toRoute(src: string): ServerFsRoute | null { 320 | const routePath = this.toPath(src); 321 | 322 | if (!routePath) { 323 | return null; 324 | } 325 | 326 | const [, exports] = analyzeModule(src); 327 | 328 | if (!exports.some(x => x.n === 'default')) { 329 | return null; 330 | } 331 | 332 | return { 333 | path: src, 334 | routePath, 335 | filePath: src, 336 | method: this.getHttpMethod(src), 337 | [this.isMiddleware(src) ? '$$middleware' : '$handler']: { 338 | src, 339 | pick: ['default'], 340 | }, 341 | }; 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /packages/servite/src/global.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/triple-slash-reference 2 | /// 3 | 4 | declare const __META_ENV_SSR__: boolean; 5 | 6 | declare module '@vinxi/server-functions/plugin' { 7 | import { HandlerRouterInput } from 'vinxi'; 8 | 9 | export const serverFunctions: { 10 | client: any; 11 | server: any; 12 | router: ( 13 | input?: HandlerRouterInput & { runtime?: string }, 14 | ) => HandlerRouterInput; 15 | }; 16 | } 17 | 18 | declare module '@vinxi/server-functions/server-handler' { 19 | export const handleServerAction: (event: any) => Promise; 20 | } 21 | -------------------------------------------------------------------------------- /packages/servite/src/libs/react-helmet-async/index.d.ts: -------------------------------------------------------------------------------- 1 | export var Helmet: { 2 | new (props: any): { 3 | shouldComponentUpdate(nextProps: any): boolean; 4 | mapNestedChildrenToProps(child: any, nestedChildren: any): { 5 | innerHTML: any; 6 | cssText?: undefined; 7 | } | { 8 | cssText: any; 9 | innerHTML?: undefined; 10 | } | null; 11 | flattenArrayTypeChildren(child: any, arrayTypeChildren: any, newChildProps: any, nestedChildren: any): any; 12 | mapObjectTypeChildren(child: any, newProps: any, newChildProps: any, nestedChildren: any): any; 13 | mapArrayTypeChildrenToProps(arrayTypeChildren: any, newProps: any): any; 14 | warnOnInvalidChildren(child: any, nestedChildren: any): boolean; 15 | mapChildrenToProps(children: any, newProps: any): any; 16 | render(): React3.CElement(state: any, callback?: (() => void) | undefined): void; 26 | forceUpdate(callback?: (() => void) | undefined): void; 27 | readonly props: Readonly; 28 | state: Readonly; 29 | refs: { 30 | [key: string]: React3.ReactInstance; 31 | }; 32 | componentDidMount?(): void; 33 | componentDidCatch?(error: Error, errorInfo: React3.ErrorInfo): void; 34 | getSnapshotBeforeUpdate?(prevProps: Readonly, prevState: Readonly): any; 35 | componentWillMount?(): void; 36 | UNSAFE_componentWillMount?(): void; 37 | componentWillReceiveProps?(nextProps: Readonly, nextContext: any): void; 38 | UNSAFE_componentWillReceiveProps?(nextProps: Readonly, nextContext: any): void; 39 | componentWillUpdate?(nextProps: Readonly, nextState: Readonly, nextContext: any): void; 40 | UNSAFE_componentWillUpdate?(nextProps: Readonly, nextState: Readonly, nextContext: any): void; 41 | }> | React3.DetailedReactHTMLElement, HTMLInputElement>; 42 | context: unknown; 43 | setState(state: any, callback?: (() => void) | undefined): void; 44 | forceUpdate(callback?: (() => void) | undefined): void; 45 | readonly props: Readonly; 46 | state: Readonly; 47 | refs: { 48 | [key: string]: React3.ReactInstance; 49 | }; 50 | componentDidMount?(): void; 51 | componentWillUnmount?(): void; 52 | componentDidCatch?(error: Error, errorInfo: React3.ErrorInfo): void; 53 | getSnapshotBeforeUpdate?(prevProps: Readonly, prevState: Readonly): any; 54 | componentDidUpdate?(prevProps: Readonly, prevState: Readonly, snapshot?: any): void; 55 | componentWillMount?(): void; 56 | UNSAFE_componentWillMount?(): void; 57 | componentWillReceiveProps?(nextProps: Readonly, nextContext: any): void; 58 | UNSAFE_componentWillReceiveProps?(nextProps: Readonly, nextContext: any): void; 59 | componentWillUpdate?(nextProps: Readonly, nextState: Readonly, nextContext: any): void; 60 | UNSAFE_componentWillUpdate?(nextProps: Readonly, nextState: Readonly, nextContext: any): void; 61 | }; 62 | new (props: any, context: any): { 63 | shouldComponentUpdate(nextProps: any): boolean; 64 | mapNestedChildrenToProps(child: any, nestedChildren: any): { 65 | innerHTML: any; 66 | cssText?: undefined; 67 | } | { 68 | cssText: any; 69 | innerHTML?: undefined; 70 | } | null; 71 | flattenArrayTypeChildren(child: any, arrayTypeChildren: any, newChildProps: any, nestedChildren: any): any; 72 | mapObjectTypeChildren(child: any, newProps: any, newChildProps: any, nestedChildren: any): any; 73 | mapArrayTypeChildrenToProps(arrayTypeChildren: any, newProps: any): any; 74 | warnOnInvalidChildren(child: any, nestedChildren: any): boolean; 75 | mapChildrenToProps(children: any, newProps: any): any; 76 | render(): React3.CElement(state: any, callback?: (() => void) | undefined): void; 86 | forceUpdate(callback?: (() => void) | undefined): void; 87 | readonly props: Readonly; 88 | state: Readonly; 89 | refs: { 90 | [key: string]: React3.ReactInstance; 91 | }; 92 | componentDidMount?(): void; 93 | componentDidCatch?(error: Error, errorInfo: React3.ErrorInfo): void; 94 | getSnapshotBeforeUpdate?(prevProps: Readonly, prevState: Readonly): any; 95 | componentWillMount?(): void; 96 | UNSAFE_componentWillMount?(): void; 97 | componentWillReceiveProps?(nextProps: Readonly, nextContext: any): void; 98 | UNSAFE_componentWillReceiveProps?(nextProps: Readonly, nextContext: any): void; 99 | componentWillUpdate?(nextProps: Readonly, nextState: Readonly, nextContext: any): void; 100 | UNSAFE_componentWillUpdate?(nextProps: Readonly, nextState: Readonly, nextContext: any): void; 101 | }> | React3.DetailedReactHTMLElement, HTMLInputElement>; 102 | context: unknown; 103 | setState(state: any, callback?: (() => void) | undefined): void; 104 | forceUpdate(callback?: (() => void) | undefined): void; 105 | readonly props: Readonly; 106 | state: Readonly; 107 | refs: { 108 | [key: string]: React3.ReactInstance; 109 | }; 110 | componentDidMount?(): void; 111 | componentWillUnmount?(): void; 112 | componentDidCatch?(error: Error, errorInfo: React3.ErrorInfo): void; 113 | getSnapshotBeforeUpdate?(prevProps: Readonly, prevState: Readonly): any; 114 | componentDidUpdate?(prevProps: Readonly, prevState: Readonly, snapshot?: any): void; 115 | componentWillMount?(): void; 116 | UNSAFE_componentWillMount?(): void; 117 | componentWillReceiveProps?(nextProps: Readonly, nextContext: any): void; 118 | UNSAFE_componentWillReceiveProps?(nextProps: Readonly, nextContext: any): void; 119 | componentWillUpdate?(nextProps: Readonly, nextState: Readonly, nextContext: any): void; 120 | UNSAFE_componentWillUpdate?(nextProps: Readonly, nextState: Readonly, nextContext: any): void; 121 | }; 122 | defaultProps: { 123 | defer: boolean; 124 | encodeSpecialCharacters: boolean; 125 | prioritizeSeoTags: boolean; 126 | }; 127 | contextType?: React3.Context | undefined; 128 | }; 129 | export var HelmetData: { 130 | new (context: any, canUseDOM: any): { 131 | instances: any[]; 132 | canUseDOM: boolean; 133 | context: any; 134 | value: { 135 | setHelmet: (serverState: any) => void; 136 | helmetInstances: { 137 | get: () => any[]; 138 | add: (instance: any) => void; 139 | remove: (instance: any) => void; 140 | }; 141 | }; 142 | }; 143 | }; 144 | export var HelmetProvider: { 145 | new (props: any): { 146 | helmetData: { 147 | instances: any[]; 148 | canUseDOM: boolean; 149 | context: any; 150 | value: { 151 | setHelmet: (serverState: any) => void; 152 | helmetInstances: { 153 | get: () => any[]; 154 | add: (instance: any) => void; 155 | remove: (instance: any) => void; 156 | }; 157 | }; 158 | }; 159 | render(): React3.FunctionComponentElement>; 160 | context: unknown; 161 | setState(state: any, callback?: (() => void) | undefined): void; 162 | forceUpdate(callback?: (() => void) | undefined): void; 163 | readonly props: Readonly; 164 | state: Readonly; 165 | refs: { 166 | [key: string]: React3.ReactInstance; 167 | }; 168 | componentDidMount?(): void; 169 | shouldComponentUpdate?(nextProps: Readonly, nextState: Readonly, nextContext: any): boolean; 170 | componentWillUnmount?(): void; 171 | componentDidCatch?(error: Error, errorInfo: React3.ErrorInfo): void; 172 | getSnapshotBeforeUpdate?(prevProps: Readonly, prevState: Readonly): any; 173 | componentDidUpdate?(prevProps: Readonly, prevState: Readonly, snapshot?: any): void; 174 | componentWillMount?(): void; 175 | UNSAFE_componentWillMount?(): void; 176 | componentWillReceiveProps?(nextProps: Readonly, nextContext: any): void; 177 | UNSAFE_componentWillReceiveProps?(nextProps: Readonly, nextContext: any): void; 178 | componentWillUpdate?(nextProps: Readonly, nextState: Readonly, nextContext: any): void; 179 | UNSAFE_componentWillUpdate?(nextProps: Readonly, nextState: Readonly, nextContext: any): void; 180 | }; 181 | canUseDOM: boolean; 182 | contextType?: React3.Context | undefined; 183 | }; 184 | import React3 from "react"; 185 | -------------------------------------------------------------------------------- /packages/servite/src/plugins/hmr.ts: -------------------------------------------------------------------------------- 1 | import { App, Plugin } from 'vinxi'; 2 | import { RouterName } from '../types/index.js'; 3 | 4 | // @vitejs/plugin-react 5 | const reactRefreshRuntimePath = '/@react-refresh'; 6 | 7 | export interface HmrConfig { 8 | app: App; 9 | } 10 | 11 | export function hmr({ app }: HmrConfig): Plugin { 12 | return { 13 | name: 'servite-client-hmr', 14 | apply: 'serve', 15 | enforce: 'post', 16 | // If export key starts with `$` or the module is a mdx file, we need to determine if this export has changed. 17 | transform(code, id) { 18 | if ( 19 | id === reactRefreshRuntimePath && 20 | code.includes('return prevExports[key] === nextExports[key]') 21 | ) { 22 | return code.replace( 23 | 'return prevExports[key] === nextExports[key]', 24 | `if (/fileName:\\s*"[^"]+\\/(page|layout)\\.[jt]sx?/ && key === 'handle' || /fileName:\\s*"[^"]+\\.mdx?/.test(nextExports.default?.toString?.() || '') && ['frontmatter', 'toc'].includes(key)) { 25 | return JSON.stringify(prevExports[key]) === JSON.stringify(nextExports[key]); 26 | } 27 | return prevExports[key] === nextExports[key];`, 28 | ); 29 | } 30 | }, 31 | // Page file will generate multiple different modules. e.g. 32 | // 33 | // file: src/pages/home/page.tsx 34 | // ↓↓↓ 35 | // modules: 36 | // - src/pages/home/page.tsx 37 | // - src/pages/home/page.tsx?pick=default 38 | // - src/pages/home/page.tsx?pick=ErrorBoundary 39 | // 40 | // And vinxi will only include the `pick` module for hmr. 41 | // But we still need to include the original module for tailwindcss hmr. 42 | handleHotUpdate(ctx) { 43 | if ( 44 | app.getRouter(RouterName.Client).internals.routes?.isRoute(ctx.file) 45 | ) { 46 | let added = false; 47 | return ctx.modules.flatMap(mod => { 48 | if ( 49 | !added && 50 | mod.file === ctx.file && 51 | new URLSearchParams(mod.id?.split('?')[1]).has('pick') 52 | ) { 53 | const originMod = ctx.server.moduleGraph.getModuleById(ctx.file); 54 | 55 | if (originMod) { 56 | added = true; 57 | return [mod, originMod]; 58 | } 59 | } 60 | return mod; 61 | }); 62 | } 63 | }, 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /packages/servite/src/plugins/islands.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'url'; 2 | import crypto from 'node:crypto'; 3 | import { Plugin } from 'vinxi'; 4 | 5 | const ISLAND_PATH = fileURLToPath( 6 | new URL('../runtime/island/Island.js', import.meta.url), 7 | ); 8 | 9 | export function islands(): Plugin { 10 | return { 11 | name: 'servite-islands', 12 | enforce: 'pre', 13 | resolveId: { 14 | order: 'pre', 15 | async handler(source, importer, options) { 16 | if (source.startsWith('island:')) { 17 | const resolved = await this.resolve(source, importer, { 18 | ...options, 19 | skipSelf: true, 20 | }); 21 | 22 | if (!resolved) { 23 | return; 24 | } 25 | 26 | const [pathname, query] = resolved.id.split('?'); 27 | const sp = new URLSearchParams(query); 28 | sp.set('island', ''); 29 | 30 | return { 31 | ...resolved, 32 | id: pathname + '?' + sp.toString(), 33 | }; 34 | } 35 | }, 36 | }, 37 | load(id, options) { 38 | const [pathname, query] = id.split('?'); 39 | const sp = new URLSearchParams(query); 40 | 41 | if (sp.has('island')) { 42 | sp.delete('island'); 43 | const idWithoutIsland = (pathname + '?' + sp.toString()).replace( 44 | /\?$/, 45 | '', 46 | ); 47 | const islandId = 48 | 'island_' + 49 | crypto 50 | .createHash('sha256') 51 | .update(pathname) 52 | .digest() 53 | .toString('hex') 54 | .slice(0, 5); 55 | 56 | // ssr 57 | if (options?.ssr) { 58 | return `\ 59 | import Component from '${idWithoutIsland}'; 60 | 61 | export default function ServerIsland(props) { 62 | return ( 63 | 64 | 65 | 66 | ); 67 | } 68 | `; 69 | } 70 | 71 | // browser 72 | return `\ 73 | import { forwardRef } from 'react'; 74 | import { Island } from '${ISLAND_PATH}'; 75 | 76 | const load = () => import('${idWithoutIsland}'); 77 | 78 | export default forwardRef(function ClientIsland(props, ref) { 79 | return ( 80 | 89 | ); 90 | }); 91 | `; 92 | } 93 | }, 94 | }; 95 | } 96 | -------------------------------------------------------------------------------- /packages/servite/src/plugins/unified-invocation.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs/promises'; 2 | import { App, Plugin } from 'vinxi'; 3 | import path from 'pathe'; 4 | import { RouterName, ServerFsRoute } from '../types/index.js'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 7 | type ObjectHook = 8 | | T 9 | | ({ handler: T; order?: 'pre' | 'post' | null } & O); 10 | 11 | type GetPluginHookParams = 12 | T extends ObjectHook 13 | ? R extends (...args: any) => any 14 | ? Parameters 15 | : never 16 | : never; 17 | 18 | export interface UnifiedInvocationConfig { 19 | app: App; 20 | srcDir: string; 21 | serverDir: string; 22 | serverRoutesDir: string; 23 | } 24 | 25 | export function unifiedInvocation({ 26 | app, 27 | srcDir, 28 | serverDir, 29 | serverRoutesDir, 30 | }: UnifiedInvocationConfig): Plugin { 31 | let viteConfig: 32 | | GetPluginHookParams>[0] 33 | | undefined; 34 | 35 | return { 36 | name: 'servite-unified-invocation', 37 | configResolved(config) { 38 | viteConfig = config; 39 | }, 40 | resolveId(source, importer, options) { 41 | if (importer?.startsWith(path.join(srcDir, '/'))) { 42 | const id = path.resolve(importer, source); 43 | 44 | if (id.startsWith(path.join(serverRoutesDir, '/'))) { 45 | // skip optimize server routes files 46 | if (options.custom?.depScan) { 47 | return { 48 | id: id, 49 | external: true, 50 | }; 51 | } 52 | } 53 | 54 | if (id.startsWith(path.join(serverDir, '/'))) { 55 | viteConfig?.logger.warn( 56 | `[servite] Importing server code outside the server directory may not be a good practice.\n importer: ${importer}\n source: ${source}`, 57 | ); 58 | } 59 | } 60 | }, 61 | async load(id, options) { 62 | if (id.startsWith(path.join(serverRoutesDir, '/'))) { 63 | const relPath = path.relative(process.cwd(), id); 64 | const serverRouter = app.getRouter(RouterName.Server); 65 | const route = serverRouter.internals.routes?.toRoute(id) as 66 | | ServerFsRoute 67 | | null 68 | | undefined; 69 | 70 | if (!route?.method) { 71 | throw new Error( 72 | `[servite] This module is not an api endpoint: ${relPath}`, 73 | ); 74 | } 75 | 76 | const originalCode = await readFile(id, 'utf-8'); 77 | const enumCode = getExportEnumCode(originalCode); 78 | const apiName = getApiName(route); 79 | 80 | return `import { getFetch } from 'servite/runtime/fetch'; 81 | ${options?.ssr ? `import { getRequestHeader, getRequestProtocol, getRequestHost } from 'servite/runtime/server';` : ''} 82 | 83 | globalThis.__META_ENV_SSR__ ??= __META_ENV_SSR__; 84 | 85 | ${enumCode} 86 | 87 | export default function ${apiName}(args, { routerParams = {}, ...opts } = {}) { 88 | const apiPath = '${route.routePath}'.replace(/\\/:([^/]+)/g, (_, name) => { 89 | const param = routerParams[name]; 90 | 91 | if (param != null) { 92 | return '/' + param; 93 | } 94 | throw new Error('Missing router param:' + name); 95 | }); 96 | 97 | const origin = ${options?.ssr ? `getRequestHeader('Referer') || getRequestProtocol() + '://' + getRequestHost({ xForwardedHost: true })` : 'window.location.origin'} 98 | let baseURL = import.meta.env.SERVER_BASE; 99 | 100 | ${ 101 | options?.ssr 102 | ? `if (!getRequestHeader('x-nitro-prerender')) { 103 | baseURL = new URL(baseURL, origin).href 104 | }` 105 | : 'baseURL = new URL(baseURL, origin).href' 106 | } 107 | 108 | return getFetch()(apiPath, { 109 | baseURL, 110 | method: '${route.method}', 111 | ${route.method.toUpperCase() === 'GET' ? 'query: args' : 'body: args'}, 112 | ...opts, 113 | }); 114 | } 115 | 116 | ${apiName}.raw = (args, opts) => { 117 | return ${apiName}(args, { ...opts, _raw: true }); 118 | }; 119 | 120 | ${apiName}.routePath = '${route.routePath}'; 121 | `; 122 | } 123 | }, 124 | }; 125 | } 126 | 127 | function getApiName({ method = 'get', routePath }: ServerFsRoute) { 128 | let name = method.toLowerCase(); 129 | 130 | name += (routePath.match(/[A-Za-z0-9]+/g) || ['index']) 131 | .map(x => x[0].toUpperCase() + x.substring(1)) 132 | .join(''); 133 | 134 | return name; 135 | } 136 | 137 | function getExportEnumCode(code: string) { 138 | return ( 139 | code.match(/^\s*export\s+(const\s+)?enum.*?\{[\s\S]*?\}/gm)?.join('\n') || 140 | '' 141 | ); 142 | } 143 | -------------------------------------------------------------------------------- /packages/servite/src/runtime/components.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export interface ClientOnlyProps { 4 | children?: React.ReactNode; 5 | fallback?: React.ReactNode; 6 | } 7 | 8 | let _isMounted = false; 9 | 10 | export const ClientOnly: React.FC = ({ 11 | fallback, 12 | children, 13 | }) => { 14 | const [isMounted, setIsMounted] = useState(_isMounted); 15 | 16 | useEffect(() => { 17 | _isMounted = true; 18 | setIsMounted(true); 19 | }, []); 20 | 21 | if (!isMounted) { 22 | return fallback; 23 | } 24 | 25 | return <>{children}; 26 | }; 27 | -------------------------------------------------------------------------------- /packages/servite/src/runtime/fetch.ts: -------------------------------------------------------------------------------- 1 | import { FetchOptions as _FetchOptions, $fetch as _$fetch } from 'ofetch'; 2 | import type { HTTPMethod } from 'vinxi/http'; 3 | 4 | // eslint-disable-next-line import-x/export 5 | export * from 'ofetch'; 6 | 7 | export interface FetchOptions extends _FetchOptions { 8 | method?: HTTPMethod | Lowercase; 9 | _raw?: boolean; 10 | } 11 | 12 | export interface ServiteFetch { 13 | (url: string, options?: FetchOptions): Promise; 14 | raw: (url: string, options?: FetchOptions) => Promise; 15 | } 16 | 17 | // eslint-disable-next-line import-x/export 18 | export const $fetch: ServiteFetch = (url, options) => { 19 | if (__META_ENV_SSR__) { 20 | return import('vinxi/http').then(({ getEvent, fetchWithEvent }) => { 21 | const event = getEvent(); 22 | let fetch: any = globalThis.$fetch || _$fetch; 23 | 24 | if (options?._raw) { 25 | fetch = fetch.raw; 26 | } 27 | 28 | return fetchWithEvent(event, url, options as any, { 29 | fetch, 30 | }); 31 | }); 32 | } 33 | 34 | const fetch = options?._raw ? _$fetch.raw : _$fetch; 35 | return fetch(url, options); 36 | }; 37 | 38 | $fetch.raw = (url, options) => { 39 | return $fetch(url, { ...options, _raw: true }); 40 | }; 41 | 42 | // eslint-disable-next-line import-x/export 43 | export const ofetch = $fetch; 44 | 45 | let _fetch = $fetch; 46 | 47 | export function getFetch(): ServiteFetch { 48 | return _fetch; 49 | } 50 | 51 | interface CustomFetch { 52 | (url: string, options?: FetchOptions): Promise; 53 | raw?: (url: string, options?: FetchOptions) => Promise; 54 | } 55 | 56 | export function setFetch(fetch: CustomFetch) { 57 | fetch.raw ??= (url, options) => { 58 | return fetch(url, { ...options, _raw: true }); 59 | }; 60 | _fetch = fetch as ServiteFetch; 61 | } 62 | -------------------------------------------------------------------------------- /packages/servite/src/runtime/helmet.ts: -------------------------------------------------------------------------------- 1 | export * from '../libs/react-helmet-async/index.js'; 2 | -------------------------------------------------------------------------------- /packages/servite/src/runtime/island/Island.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { 3 | canManuallyHydrateIsland, 4 | HYDRATE_EVENT_NAME, 5 | HydrateOptions, 6 | } from './shared.js'; 7 | 8 | declare global { 9 | // eslint-disable-next-line @typescript-eslint/no-namespace 10 | namespace JSX { 11 | interface IntrinsicElements { 12 | 'servite-island': React.DetailedHTMLProps, any>; 13 | } 14 | } 15 | } 16 | 17 | export interface IslandProps { 18 | [key: string]: any; 19 | innerRef?: React.Ref; 20 | hydrate: HydrateOptions & { load: () => Promise; id: string }; 21 | } 22 | 23 | interface IslandState { 24 | innerHtml?: string; 25 | Component?: any; 26 | } 27 | 28 | const cache: Record = {}; 29 | 30 | export function Island({ innerRef, hydrate, ...restProps }: IslandProps) { 31 | const [islandState, setIslandState] = useState(() => ({ 32 | innerHtml: document.getElementById(hydrate.id)?.innerHTML || '', 33 | Component: cache[hydrate.id]?.Component, 34 | })); 35 | 36 | useEffect(() => { 37 | if (!hydrate.on) { 38 | // eslint-disable-next-line no-console 39 | console.debug('[servite] skip hydrate island:', hydrate.id); 40 | return; 41 | } 42 | 43 | const doHydrate = async () => { 44 | if (!cache[hydrate.id]?.Component) { 45 | // eslint-disable-next-line no-console 46 | console.debug('[servite] start hydrate island:', hydrate.id); 47 | const { default: Component } = await hydrate.load(); 48 | cache[hydrate.id] = { Component }; 49 | } 50 | 51 | const { Component } = cache[hydrate.id]; 52 | setIslandState(prev => ({ ...prev, Component })); 53 | }; 54 | 55 | switch (hydrate.on) { 56 | case 'load': { 57 | doHydrate(); 58 | break; 59 | } 60 | case 'idle': { 61 | (window.requestIdleCallback || ((cb: any) => setTimeout(cb, 100)))( 62 | doHydrate, 63 | ); 64 | break; 65 | } 66 | case 'visible': { 67 | const islandEl = document.getElementById(hydrate.id); 68 | if (!islandEl) { 69 | if (import.meta.env.DEV) { 70 | // eslint-disable-next-line no-console 71 | console.error(`Miss island (id: ${hydrate.id})`); 72 | } 73 | break; 74 | } 75 | const observer = new IntersectionObserver(entries => { 76 | for (const entry of entries) { 77 | if (!entry.isIntersecting) { 78 | continue; 79 | } 80 | observer.disconnect(); 81 | doHydrate(); 82 | break; 83 | } 84 | }); 85 | 86 | for (const child of Array.from(islandEl.children)) { 87 | observer.observe(child); 88 | } 89 | break; 90 | } 91 | case 'manual': { 92 | const manualHydrate = () => { 93 | if (canManuallyHydrateIsland(hydrate.id)) { 94 | doHydrate(); 95 | window.removeEventListener(HYDRATE_EVENT_NAME, manualHydrate); 96 | return true; 97 | } 98 | return false; 99 | }; 100 | if (!manualHydrate()) { 101 | window.addEventListener(HYDRATE_EVENT_NAME, manualHydrate); 102 | } 103 | break; 104 | } 105 | default: { 106 | if (hydrate.on.startsWith('media ')) { 107 | const mq = hydrate.on.replace('media ', ''); 108 | const mql = window.matchMedia(mq); 109 | if (mql.matches) { 110 | doHydrate(); 111 | } else { 112 | mql.addEventListener('change', ev => { 113 | if (ev.matches) { 114 | doHydrate(); 115 | } 116 | }); 117 | } 118 | break; 119 | } 120 | 121 | // eslint-disable-next-line no-console 122 | console.warn( 123 | `Unsupported hydrate mode: ${hydrate.on}. Use 'load' | 'idle' | 'visible' | 'manual' | 'media' instead.`, 124 | ); 125 | doHydrate(); 126 | } 127 | } 128 | 129 | if (typeof hydrate.timeout === 'number') { 130 | const timer = setTimeout(() => doHydrate(), hydrate.timeout); 131 | return () => clearTimeout(timer); 132 | } 133 | // eslint-disable-next-line react-hooks/exhaustive-deps 134 | }, []); 135 | 136 | if (islandState.Component) { 137 | return ( 138 | 144 | 145 | 146 | ); 147 | } 148 | 149 | return ( 150 | 158 | ); 159 | } 160 | -------------------------------------------------------------------------------- /packages/servite/src/runtime/island/index.ts: -------------------------------------------------------------------------------- 1 | export { hydrateIsland } from './shared.js'; 2 | export type { IslandProps, HydrateOptions } from './shared.js'; 3 | -------------------------------------------------------------------------------- /packages/servite/src/runtime/island/shared.ts: -------------------------------------------------------------------------------- 1 | export interface HydrateOptions { 2 | /** 3 | * id of the island element 4 | */ 5 | id?: string; 6 | /** 7 | * hydrate mode 8 | */ 9 | on?: 'load' | 'idle' | 'visible' | 'manual' | `media ${string}`; 10 | /** 11 | * timeout ms 12 | */ 13 | timeout?: number; 14 | } 15 | 16 | export interface IslandProps { 17 | hydrate?: HydrateOptions; 18 | } 19 | 20 | export const HYDRATE_EVENT_NAME = 'servite-island-hydrate'; 21 | 22 | let _manualIslands: Set | true = new Set(); 23 | 24 | export function canManuallyHydrateIsland(id: string) { 25 | return _manualIslands === true || _manualIslands.has(id); 26 | } 27 | 28 | export function hydrateIsland(id: string | true) { 29 | if (id === true) { 30 | _manualIslands = true; 31 | } else if (_manualIslands !== true) { 32 | _manualIslands.add(id); 33 | } 34 | 35 | window.dispatchEvent(new CustomEvent(HYDRATE_EVENT_NAME)); 36 | } 37 | -------------------------------------------------------------------------------- /packages/servite/src/runtime/mdx.ts: -------------------------------------------------------------------------------- 1 | export * from '@mdx-js/react'; 2 | -------------------------------------------------------------------------------- /packages/servite/src/runtime/router.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | import { useHref, useLinkClickHandler, LinkProps, To } from 'react-router-dom'; 3 | 4 | // eslint-disable-next-line import-x/export 5 | export * from 'react-router-dom'; 6 | 7 | // eslint-disable-next-line import-x/export 8 | export const Link = React.forwardRef( 9 | function Link( 10 | { 11 | to, 12 | onClick, 13 | replace = false, 14 | state, 15 | target, 16 | preventScrollReset, 17 | relative, 18 | unstable_viewTransition, 19 | ...rest 20 | }, 21 | ref, 22 | ) { 23 | const elRef = useRef(); 24 | const href = useHref(to, { relative }); 25 | 26 | const handleClick = useLinkClickHandler(to, { 27 | target, 28 | replace, 29 | state, 30 | preventScrollReset, 31 | relative, 32 | unstable_viewTransition, 33 | }); 34 | 35 | useEffect(() => { 36 | if (!elRef.current || window.matchMedia('(hover: hover)').matches) { 37 | return; 38 | } 39 | 40 | const observer = new IntersectionObserver(entries => { 41 | for (const entry of entries) { 42 | if (!entry.isIntersecting) { 43 | continue; 44 | } 45 | 46 | observer.disconnect(); 47 | (window.requestIdleCallback || ((cb: any) => setTimeout(cb, 100)))( 48 | () => { 49 | prefetchRouteAssets(to); 50 | }, 51 | ); 52 | } 53 | }); 54 | 55 | observer.observe(elRef.current); 56 | 57 | return () => { 58 | observer.disconnect(); 59 | }; 60 | }, [to]); 61 | 62 | return ( 63 |
{ 66 | elRef.current = instance; 67 | 68 | if (typeof ref === 'function') { 69 | ref(instance); 70 | } else if (ref) { 71 | ref.current = instance; 72 | } 73 | }} 74 | href={href} 75 | target={target} 76 | onMouseEnter={() => prefetchRouteAssets(to)} 77 | onClick={event => { 78 | onClick?.(event); 79 | if (!event.defaultPrevented) { 80 | handleClick(event); 81 | } 82 | }} 83 | /> 84 | ); 85 | }, 86 | ); 87 | 88 | export function prefetchRouteAssets(to: To) { 89 | return window.__servite_init_route_handles__?.(to); 90 | } 91 | -------------------------------------------------------------------------------- /packages/servite/src/runtime/server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineEventHandler as _defineEventHandler, 3 | EventHandler, 4 | EventHandlerObject, 5 | EventHandlerRequest, 6 | EventHandlerResponse, 7 | } from 'vinxi/http'; 8 | import type { FetchOptions } from 'ofetch'; 9 | import type { 10 | HtmlTag, 11 | HtmlTransformer, 12 | Middleware, 13 | RouterName, 14 | } from '../types/index.js'; 15 | 16 | export * from 'vinxi/http'; 17 | export type { Middleware, HtmlTag, HtmlTransformer }; 18 | 19 | export interface Logger { 20 | debug(...args: any[]): void; 21 | trace(...args: any[]): void; 22 | info(...args: any[]): void; 23 | warn(...args: any[]): void; 24 | error(...args: any[]): void; 25 | } 26 | 27 | declare module 'vinxi/http' { 28 | export interface H3EventContext { 29 | /** 30 | * Logger utils. Can be overridden to implement custom logger 31 | * 32 | * @default `console` 33 | */ 34 | logger: Logger; 35 | /** 36 | * Utils for modifying the result html of SSR. 37 | */ 38 | html?: { 39 | inject(tags: HtmlTag | HtmlTag[]): void; 40 | addTransformer(fn: HtmlTransformer): void; 41 | }; 42 | /** 43 | * Indicates the name of request router. 44 | */ 45 | routerName?: RouterName; 46 | /** 47 | * Whether to use SSR 48 | */ 49 | ssr?: boolean; 50 | /** 51 | * Whether the ssr fallback was successful 52 | */ 53 | ssrFallback?: boolean; 54 | /** 55 | * Response for middlewares 56 | */ 57 | response?: any; 58 | } 59 | } 60 | 61 | export interface EventHandlerForUnifiedInvocation< 62 | Args extends Record, 63 | RouterParams extends Record | undefined = undefined, 64 | Result = any, 65 | > { 66 | ( 67 | args: Args, 68 | opts?: RouterParams extends NonNullable 69 | ? FetchOptions & { routerParams: RouterParams } 70 | : FetchOptions, 71 | ): Promise; 72 | raw: ( 73 | args: Args, 74 | opts?: RouterParams extends NonNullable 75 | ? FetchOptions & { routerParams: RouterParams } 76 | : FetchOptions, 77 | ) => Promise; 78 | /** 79 | * The routePath of the handler. 80 | * 81 | * Convenient integration with request libraries such as swr and react-query 82 | */ 83 | routePath: string; 84 | } 85 | 86 | type GetRequestArgs = 87 | T['body'] extends NonNullable 88 | ? NonNullable 89 | : T['query'] extends NonNullable 90 | ? NonNullable 91 | : Record; 92 | 93 | export function defineEventHandler< 94 | Request extends EventHandlerRequest = EventHandlerRequest, 95 | Response = any, 96 | >( 97 | handler: 98 | | EventHandler> 99 | | EventHandlerObject>, 100 | ) { 101 | return _defineEventHandler( 102 | handler, 103 | ) as any as EventHandlerForUnifiedInvocation< 104 | GetRequestArgs, 105 | Request['routerParams'], 106 | Awaited 107 | >; 108 | } 109 | 110 | export const eventHandler = defineEventHandler; 111 | 112 | export function defineMiddleware(middleware: Middleware): Middleware { 113 | return middleware; 114 | } 115 | -------------------------------------------------------------------------------- /packages/servite/src/server-fns/handler.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler } from 'vinxi/http'; 2 | import { handleServerAction } from '@vinxi/server-functions/server-handler'; 3 | import { onBeforeResponse } from '../server/on-before-response'; 4 | 5 | export default defineEventHandler({ 6 | onBeforeResponse: (event, response) => { 7 | if (response?.body !== undefined) { 8 | return onBeforeResponse(event, response); 9 | } 10 | }, 11 | handler: async event => { 12 | if (event.path === '/_server') { 13 | return handleServerAction(event); 14 | } 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /packages/servite/src/server/handler.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { 3 | defineEventHandler, 4 | getQuery, 5 | getRequestHeader, 6 | H3Event, 7 | setResponseHeaders, 8 | } from 'vinxi/http'; 9 | import fileRoutes from 'vinxi/routes'; 10 | import { addRoute, createRouter, findRoute } from 'rou3'; 11 | import { 12 | FsRouteMod, 13 | HtmlTag, 14 | HtmlTransformer, 15 | Middleware, 16 | RouterName, 17 | ServerFsRouteModule, 18 | } from '../types/index.js'; 19 | import { onBeforeResponse } from './on-before-response.js'; 20 | 21 | declare module 'vinxi/http' { 22 | export interface H3EventContext { 23 | /** 24 | * Internal used to restore the middlewares. 25 | */ 26 | _resolveMiddlewaresRestore?: () => void; 27 | /** 28 | * Internal used 29 | */ 30 | _resolveMiddlewaresDone?: () => void; 31 | /** 32 | * Internal used 33 | */ 34 | _rejectMiddlewaresDone?: () => void; 35 | /** 36 | * Internal used to store the matched server fs route. 37 | */ 38 | _matchedServerFsRoute?: ReturnType>; 39 | /** 40 | * Internal used to store the tags for html injection. 41 | */ 42 | _htmlInjectedTags?: HtmlTag[]; 43 | /** 44 | * Internal used to store the transformers for html. 45 | */ 46 | _htmlTransformers?: HtmlTransformer[]; 47 | } 48 | } 49 | 50 | const router = createRouter(); 51 | 52 | // Root middleware. 53 | // Add useful utils to the event context. 54 | const rootMiddleware: Middleware = (event, next) => { 55 | // Add default logger 56 | event.context.logger = console; 57 | 58 | // Add matched server fs route. 59 | event.context._matchedServerFsRoute = findRoute( 60 | router, 61 | event.method, 62 | event.path, 63 | ); 64 | 65 | if (event.context._matchedServerFsRoute) { 66 | event.context.routerName = RouterName.Server; 67 | } else if (event.path === '/_server') { 68 | event.context.routerName = RouterName.ServerFns; 69 | } else { 70 | event.context.routerName = RouterName.SSR; 71 | } 72 | 73 | event.context.html = { 74 | inject(tags) { 75 | event.context._htmlInjectedTags ||= []; 76 | event.context._htmlInjectedTags.push( 77 | ...(Array.isArray(tags) ? tags : [tags]), 78 | ); 79 | }, 80 | addTransformer(transformer) { 81 | event.context._htmlTransformers ||= []; 82 | event.context._htmlTransformers.push(transformer); 83 | }, 84 | }; 85 | 86 | event.context.ssr = true; 87 | 88 | if ( 89 | getQuery(event)?.ssr_fallback === '1' || 90 | getRequestHeader(event, 'x-ssr-fallback') === '1' 91 | ) { 92 | event.context.ssr = false; 93 | } 94 | 95 | setResponseHeaders(event, { 96 | 'x-powered-by': 'Servite', 97 | 'x-servite-router': event.context.routerName || 'unknown', 98 | }); 99 | 100 | return next(); 101 | }; 102 | 103 | const middlewares: Middleware[] = [rootMiddleware]; 104 | 105 | (fileRoutes as ServerFsRouteModule[]).forEach(route => { 106 | if (route.$$middleware) { 107 | const m = route.$$middleware.require?.()?.default; 108 | if (m) { 109 | middlewares.push(m); 110 | } 111 | return; 112 | } 113 | addRoute(router, route.method, route.routePath, route.$handler); 114 | }); 115 | 116 | // const mapMiddlewareHooks = ( 117 | // hook: T, 118 | // ) => { 119 | // return middlewares 120 | // .flatMap(m => m[hook] || []) 121 | // .filter(x => Boolean(x)) as K extends any[] ? K : never; 122 | // }; 123 | 124 | interface ComposeCallbacks { 125 | onSuspend: () => Promise; 126 | onDone: () => void; 127 | onError: (err: any) => void; 128 | } 129 | 130 | const compose = ( 131 | middlewares: Middleware[], 132 | { onSuspend, onDone, onError }: ComposeCallbacks, 133 | ) => { 134 | return (event: H3Event) => { 135 | let index = -1; 136 | 137 | const dispatch = async (i: number): Promise => { 138 | if (i <= index) { 139 | throw new Error('next() called multiple times'); 140 | } 141 | 142 | index = i; 143 | let m = middlewares[i]; 144 | 145 | if (i === middlewares.length) { 146 | m = () => onSuspend(); 147 | } 148 | 149 | await m(event, dispatch.bind(null, i + 1)); 150 | }; 151 | 152 | dispatch(0).then(onDone, onError); 153 | }; 154 | }; 155 | 156 | export default defineEventHandler({ 157 | onRequest: async event => { 158 | await new Promise((resolve, reject) => { 159 | compose(middlewares, { 160 | onSuspend() { 161 | resolve(); 162 | return new Promise(resolve => { 163 | event.context._resolveMiddlewaresRestore = resolve; 164 | }); 165 | }, 166 | onDone() { 167 | if (event.handled) { 168 | resolve(); 169 | } 170 | event.context._resolveMiddlewaresDone?.(); 171 | }, 172 | onError(err) { 173 | reject(err); 174 | event.context._rejectMiddlewaresDone?.(); 175 | }, 176 | })(event); 177 | }); 178 | }, 179 | onBeforeResponse: (event, response) => { 180 | if (response?.body !== undefined) { 181 | return onBeforeResponse(event, response); 182 | } 183 | }, 184 | handler: async event => { 185 | const matchedRoute = event.context._matchedServerFsRoute; 186 | 187 | if (matchedRoute) { 188 | event.context.params = matchedRoute.params; 189 | const handler = (await matchedRoute.data.import?.())?.default; 190 | 191 | if (handler) { 192 | return handler(event); 193 | } 194 | } 195 | }, 196 | }); 197 | -------------------------------------------------------------------------------- /packages/servite/src/server/on-before-response.ts: -------------------------------------------------------------------------------- 1 | import { defineResponseMiddleware } from 'vinxi/http'; 2 | 3 | export const onBeforeResponse = defineResponseMiddleware( 4 | async (event, response) => { 5 | const donePromise = new Promise((resolve, reject) => { 6 | event.context._resolveMiddlewaresDone = resolve; 7 | event.context._rejectMiddlewaresDone = reject; 8 | }); 9 | event.context.response = response.body; 10 | // restore middlewares execution 11 | event.context._resolveMiddlewaresRestore?.(); 12 | // wait for all middlewares to finish 13 | await donePromise; 14 | // middleware may modify the response, so we need to reassign here 15 | response.body = event.context.response; 16 | }, 17 | ); 18 | -------------------------------------------------------------------------------- /packages/servite/src/ssr/RouterHydration.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import { Await, useAsyncError, useAsyncValue } from 'react-router-dom'; 3 | import type { StaticHandlerContext } from 'react-router-dom/server'; 4 | 5 | export interface RouterHydrationProps { 6 | context?: StaticHandlerContext; 7 | } 8 | 9 | export function RouterHydration({ context }: RouterHydrationProps) { 10 | // client 11 | if (!import.meta.env.SSR) { 12 | return null; 13 | } 14 | 15 | // server 16 | const deferPromises = getDeferPromises(context!); 17 | 18 | return ( 19 | <> 20 | {deferPromises.map(({ loaderKey, dataKey, promise }) => ( 21 | 27 | ))} 28 | 29 | ); 30 | } 31 | 32 | interface DeferPromiseProps { 33 | loaderKey: string; 34 | dataKey: string; 35 | promise: Promise; 36 | } 37 | 38 | function DeferPromise({ loaderKey, dataKey, promise }: DeferPromiseProps) { 39 | return ( 40 | 41 | 45 | } 46 | > 47 | 48 | 49 | 50 | ); 51 | } 52 | 53 | function DeferPromiseResolve({ 54 | loaderKey, 55 | dataKey, 56 | }: Omit) { 57 | const data = useAsyncValue(); 58 | 59 | return ( 60 | \n ${str}`, 84 | ) 85 | .replace( 86 | 'window.__vite_plugin_react_preamble_installed__ = true', 87 | str => { 88 | return `${str} 89 | ;window.__vite_plugin_react_preamble_installed_resolve__?.(true)`; 90 | }, 91 | ); 92 | } 93 | } 94 | 95 | if (state === 'end') { 96 | html = `
97 | ${serializeTags(injectedTags.bodyTags, ' ')} 98 | 99 | `; 100 | } 101 | 102 | // User middlewares added html transformers. 103 | if (event.context._htmlTransformers?.length) { 104 | for (const transformer of event.context._htmlTransformers) { 105 | html = await transformer(html); 106 | } 107 | } 108 | 109 | return html; 110 | } 111 | 112 | const textDecoder = new TextDecoder(); 113 | 114 | export function transformHtmlForReadableStream(params: TransformHtmlParams) { 115 | const textEncoder = new TextEncoder(); 116 | const injectedTags = groupHtmlTags(params.event.context._htmlInjectedTags); 117 | let state: TransformHtmlState = 'start'; 118 | 119 | return new TransformStream({ 120 | async transform(chunk, controller) { 121 | chunk = textEncoder.encode( 122 | await doTransformHtml(textDecoder.decode(chunk), { 123 | ...params, 124 | state, 125 | injectedTags, 126 | }), 127 | ); 128 | state = 'processing'; 129 | controller.enqueue(chunk); 130 | }, 131 | async flush(controller) { 132 | state = 'end'; 133 | controller.enqueue( 134 | textEncoder.encode( 135 | await doTransformHtml('', { ...params, state, injectedTags }), 136 | ), 137 | ); 138 | }, 139 | }); 140 | } 141 | 142 | export function transformHtmlForPipeableStream(params: TransformHtmlParams) { 143 | const injectedTags = groupHtmlTags(params.event.context._htmlInjectedTags); 144 | let state: TransformHtmlState = 'start'; 145 | 146 | return new Transform({ 147 | async transform(chunk, _encoding, callback) { 148 | chunk = Buffer.from( 149 | await doTransformHtml(chunk.toString('utf-8'), { 150 | ...params, 151 | state, 152 | injectedTags, 153 | }), 154 | 'utf-8', 155 | ); 156 | state = 'processing'; 157 | this.push(chunk); 158 | callback(); 159 | }, 160 | async flush(callback) { 161 | state = 'end'; 162 | this.push( 163 | Buffer.from( 164 | await doTransformHtml('', { ...params, state, injectedTags }), 165 | 'utf-8', 166 | ), 167 | ); 168 | callback(); 169 | }, 170 | }); 171 | } 172 | -------------------------------------------------------------------------------- /packages/servite/src/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { H3Event } from 'vinxi/http'; 2 | 3 | export enum RouterName { 4 | Public = 'public', 5 | SPA = 'spa', 6 | Client = 'client', 7 | SSR = 'ssr', 8 | Server = 'server', 9 | ServerFns = 'server-fns', 10 | } 11 | 12 | export interface PageFsRoute { 13 | /** 14 | * Servite doesn't actually use this field, 15 | * but it's the key for FsRoute. 16 | * So we need to mark it as deprecated. 17 | * @deprecated 18 | */ 19 | path: string; 20 | /** 21 | * the route path of the page, 22 | */ 23 | routePath: string; 24 | filePath: string; 25 | isMd?: boolean; 26 | isLayout?: boolean; 27 | hasLoader?: boolean; 28 | hasAction?: boolean; 29 | hasErrorBoundary?: boolean; 30 | handle?: Record; 31 | $component: { 32 | src: string; 33 | pick: string[]; 34 | }; 35 | $data?: { 36 | src: string; 37 | pick: string[]; 38 | }; 39 | /** 40 | * static handle (for js page) 41 | */ 42 | $$handle?: { 43 | src: string; 44 | pick: string[]; 45 | }; 46 | } 47 | 48 | export interface ServerFsRoute { 49 | /** 50 | * Servite doesn't actually use this field, 51 | * but it's the key for FsRoute. 52 | * So we need to mark it as deprecated. 53 | * @deprecated 54 | */ 55 | path: string; 56 | /** 57 | * the route path of the endpoint 58 | */ 59 | routePath: string; 60 | filePath: string; 61 | method?: string; 62 | $handler?: { 63 | src: string; 64 | pick: string[]; 65 | }; 66 | $$middleware?: { 67 | src: string; 68 | pick: string[]; 69 | }; 70 | } 71 | 72 | export interface FsRouteMod { 73 | src: string; 74 | import: () => Promise; 75 | require: () => any; 76 | } 77 | 78 | type FsRouteModule> = { 79 | [P in keyof T]: P extends `$${string}` ? FsRouteMod : T[P]; 80 | }; 81 | 82 | export type PageFsRouteModule = FsRouteModule; 83 | export type ServerFsRouteModule = FsRouteModule; 84 | 85 | export interface Middleware { 86 | (event: H3Event, next: () => Promise): void | Promise; 87 | } 88 | 89 | export interface HtmlTag { 90 | tag: string; 91 | injectTo: 'head' | 'head-prepend' | 'body' | 'body-prepend'; 92 | attrs?: Record; 93 | children?: string | HtmlTag[]; 94 | } 95 | 96 | export type HtmlTagWithoutInjectTo = Omit; 97 | 98 | export interface HtmlTransformer { 99 | (html: string): string | Promise; 100 | } 101 | -------------------------------------------------------------------------------- /packages/servite/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { defu } from 'defu'; 2 | 3 | export function defaults, K extends T>( 4 | origin: T | undefined, 5 | defaultValue: K, 6 | ): Omit & 7 | Required> { 8 | return defu(origin, defaultValue) as any; 9 | } 10 | 11 | export function toArray(value: T | Array = []): Array { 12 | return Array.isArray(value) ? value : [value]; 13 | } 14 | -------------------------------------------------------------------------------- /packages/servite/src/utils/md.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs'; 2 | import matter from 'gray-matter'; 3 | 4 | export function extractFrontmatter(src: string) { 5 | const content = readFileSync(src, 'utf-8'); 6 | return matter(content).data; 7 | } 8 | -------------------------------------------------------------------------------- /packages/servite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "moduleResolution": "Bundler", 5 | "outDir": "dist", 6 | "allowJs": true 7 | }, 8 | "include": ["src"] 9 | } 10 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/**/*" 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "lib": ["ESNext", "DOM"], 7 | "jsx": "react-jsx", 8 | "declaration": true, 9 | "esModuleInterop": true, 10 | "strict": true, 11 | "skipLibCheck": true 12 | } 13 | } 14 | --------------------------------------------------------------------------------