├── .editorconfig ├── .gitattributes ├── .github └── contributing.md ├── .gitignore ├── .husky └── pre-commit ├── .nvmrc ├── .prettierignore ├── .prettierrc.cjs ├── .scripts ├── check-for-pnpm.js ├── check-node-version.js ├── release.js └── test-node-esm.js ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── package.json ├── packages ├── app │ ├── LICENSE │ ├── README.md │ ├── globals.d.ts │ ├── head.d.ts │ ├── http.d.ts │ ├── index.d.ts │ ├── node.d.ts │ ├── package.json │ ├── routing.d.ts │ ├── server.d.ts │ ├── src │ │ ├── client │ │ │ ├── head │ │ │ │ ├── head-ssr.ts │ │ │ │ ├── index.ts │ │ │ │ ├── manage-head.ts │ │ │ │ ├── types │ │ │ │ │ ├── base-attrs.ts │ │ │ │ │ ├── body-attrs.ts │ │ │ │ │ ├── head-attrs.ts │ │ │ │ │ ├── head-config.ts │ │ │ │ │ ├── html-attrs.ts │ │ │ │ │ ├── link-attrs.ts │ │ │ │ │ ├── meta-attrs.ts │ │ │ │ │ ├── meta-fields.ts │ │ │ │ │ ├── noscript-attrs.ts │ │ │ │ │ ├── script-attrs.ts │ │ │ │ │ └── style-attrs.ts │ │ │ │ └── update-head.ts │ │ │ ├── index.ts │ │ │ ├── init.ts │ │ │ ├── reactivity.ts │ │ │ ├── router │ │ │ │ ├── client-router.ts │ │ │ │ ├── comparators │ │ │ │ │ ├── comparator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── url-pattern-comparator.ts │ │ │ │ ├── index.ts │ │ │ │ ├── listen.ts │ │ │ │ ├── load-route.ts │ │ │ │ ├── scroll-delegate.ts │ │ │ │ └── types.ts │ │ │ └── utils.ts │ │ ├── globals.d.ts │ │ ├── node │ │ │ ├── app │ │ │ │ ├── App.ts │ │ │ │ ├── config │ │ │ │ │ ├── app-config.ts │ │ │ │ │ ├── build-config.ts │ │ │ │ │ ├── client-config.ts │ │ │ │ │ ├── directories-config.ts │ │ │ │ │ ├── entry-config.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── markdown-config.ts │ │ │ │ │ ├── resolve-config.ts │ │ │ │ │ ├── routes-config.ts │ │ │ │ │ ├── server-config.ts │ │ │ │ │ └── sitemap-config.ts │ │ │ │ ├── create │ │ │ │ │ ├── app-dirs.ts │ │ │ │ │ ├── app-factory.ts │ │ │ │ │ ├── app-utils.ts │ │ │ │ │ └── disposal-bin.ts │ │ │ │ ├── files │ │ │ │ │ ├── app-files.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── markdoc-files.ts │ │ │ │ │ ├── resolve-route.ts │ │ │ │ │ ├── route-files.ts │ │ │ │ │ └── system-files.ts │ │ │ │ └── routes │ │ │ │ │ ├── app-routes.ts │ │ │ │ │ └── index.ts │ │ │ ├── build │ │ │ │ ├── adapter │ │ │ │ │ ├── auto.ts │ │ │ │ │ ├── build-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── static │ │ │ │ │ │ └── adapter.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── vercel │ │ │ │ │ │ ├── adapter.ts │ │ │ │ │ │ └── trailing-slash.ts │ │ │ │ ├── build-data.ts │ │ │ │ ├── build-utils.ts │ │ │ │ ├── build.ts │ │ │ │ ├── bundle.ts │ │ │ │ ├── chunks.ts │ │ │ │ ├── crawl.ts │ │ │ │ ├── index.ts │ │ │ │ ├── log.ts │ │ │ │ ├── manifest.ts │ │ │ │ ├── resources.ts │ │ │ │ ├── routes.ts │ │ │ │ └── sitemap.ts │ │ │ ├── http │ │ │ │ ├── cookies.js │ │ │ │ ├── create-message-handler.ts │ │ │ │ ├── handle-incoming.ts │ │ │ │ ├── http-bridge.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── markdoc │ │ │ │ ├── index.ts │ │ │ │ ├── markdoc-schema.ts │ │ │ │ ├── parse-markdown.ts │ │ │ │ ├── render.ts │ │ │ │ └── types.ts │ │ │ ├── polyfills.ts │ │ │ ├── utils │ │ │ │ ├── acorn.ts │ │ │ │ ├── crypto.ts │ │ │ │ ├── fs.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.ts │ │ │ │ ├── module.ts │ │ │ │ ├── path.ts │ │ │ │ ├── sort.ts │ │ │ │ └── time.ts │ │ │ └── vite │ │ │ │ ├── Plugin.ts │ │ │ │ ├── alias.ts │ │ │ │ ├── core │ │ │ │ ├── dev-server-manifest.ts │ │ │ │ ├── dev-server.ts │ │ │ │ ├── handle-dev-request.ts │ │ │ │ ├── handle-static-data.ts │ │ │ │ ├── index-html.ts │ │ │ │ ├── index.ts │ │ │ │ ├── preview-server.ts │ │ │ │ ├── resolve-stylesheets.ts │ │ │ │ └── static-data-loader.ts │ │ │ │ ├── files │ │ │ │ ├── files-hmr.ts │ │ │ │ ├── files-plugin.ts │ │ │ │ └── watch-routes-types.ts │ │ │ │ ├── markdown │ │ │ │ ├── hmr.ts │ │ │ │ ├── index.ts │ │ │ │ └── markdown-plugin.ts │ │ │ │ ├── remove-loaders-plugin.ts │ │ │ │ ├── rpc-plugin.ts │ │ │ │ └── vessel-plugin.ts │ │ ├── server │ │ │ ├── create.ts │ │ │ ├── http │ │ │ │ ├── app │ │ │ │ │ ├── configure-server.ts │ │ │ │ │ └── server-router.ts │ │ │ │ ├── create-request-event.ts │ │ │ │ ├── create-server.ts │ │ │ │ ├── handlers │ │ │ │ │ ├── handle-api-error.ts │ │ │ │ │ ├── handle-api-request.ts │ │ │ │ │ ├── handle-data-request.ts │ │ │ │ │ ├── handle-page-request.ts │ │ │ │ │ └── handle-rpc-request.ts │ │ │ │ ├── index.ts │ │ │ │ └── middleware.ts │ │ │ ├── index.ts │ │ │ ├── static-data.ts │ │ │ └── types.ts │ │ ├── shared │ │ │ ├── data.ts │ │ │ ├── http │ │ │ │ ├── cookie.ts │ │ │ │ ├── cookies.ts │ │ │ │ ├── errors.ts │ │ │ │ ├── fetch.ts │ │ │ │ ├── http-methods.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middleware.ts │ │ │ │ ├── request-handler.ts │ │ │ │ ├── request.ts │ │ │ │ ├── response.ts │ │ │ │ └── rpc.ts │ │ │ ├── markdown.ts │ │ │ ├── polyfills.ts │ │ │ ├── routing │ │ │ │ ├── compare.ts │ │ │ │ ├── index.ts │ │ │ │ ├── load.ts │ │ │ │ ├── match.ts │ │ │ │ └── types.ts │ │ │ ├── types.ts │ │ │ └── utils │ │ │ │ ├── error.ts │ │ │ │ ├── html.ts │ │ │ │ ├── json.ts │ │ │ │ ├── string.ts │ │ │ │ ├── unit.ts │ │ │ │ └── url.ts │ │ └── virtual │ │ │ ├── config.d.ts │ │ │ └── manifest.d.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── create │ ├── LICENSE │ ├── bin │ │ └── create-vessel.js │ ├── package.json │ ├── src │ │ ├── addons │ │ │ ├── eslint.ts │ │ │ ├── lint-staged.ts │ │ │ ├── prettier.ts │ │ │ ├── tailwind.ts │ │ │ └── typescript.ts │ │ ├── builder.ts │ │ ├── cli.ts │ │ ├── directory.ts │ │ ├── package.ts │ │ ├── prompts.ts │ │ └── utils │ │ │ ├── obj.ts │ │ │ └── str.ts │ ├── template-preact │ │ └── .gitkeep │ ├── template-shared │ │ └── app │ │ │ ├── app.html │ │ │ ├── global.css │ │ │ ├── public │ │ │ ├── favicon.ico │ │ │ └── robots.txt │ │ │ ├── server.js │ │ │ └── server.ts │ ├── template-solid │ │ ├── app │ │ │ ├── app.jsx │ │ │ ├── app.tsx │ │ │ ├── globals.d.ts │ │ │ ├── layout.jsx │ │ │ ├── layout.tsx │ │ │ ├── page.jsx │ │ │ └── page.tsx │ │ └── vite.config.js │ ├── template-svelte │ │ ├── app │ │ │ ├── app.svelte │ │ │ ├── globals.d.ts │ │ │ ├── layout.svelte │ │ │ └── page.svelte │ │ ├── svelte.config.js │ │ └── vite.config.js │ ├── template-vue │ │ ├── app │ │ │ ├── app.vue │ │ │ ├── globals.d.ts │ │ │ ├── layout.vue │ │ │ └── page.vue │ │ └── vite.config.js │ ├── tsconfig.json │ └── tsup.config.ts ├── solid │ ├── LICENSE │ ├── README.md │ ├── globals.d.ts │ ├── head.d.ts │ ├── index.d.ts │ ├── node.d.ts │ ├── package.json │ ├── src │ │ ├── client │ │ │ ├── +app.tsx │ │ │ ├── DevErrorFallback.tsx │ │ │ ├── Link.tsx │ │ │ ├── ProdErrorFallback.tsx │ │ │ ├── RouteAnnouncer.tsx │ │ │ ├── RouteComponent.tsx │ │ │ ├── RouteErrorBoundary.tsx │ │ │ ├── RouteSegment.tsx │ │ │ ├── RouterOutlet.tsx │ │ │ ├── context-keys.ts │ │ │ ├── context.ts │ │ │ ├── entry-client.tsx │ │ │ ├── entry-server.tsx │ │ │ ├── head │ │ │ │ ├── index.ts │ │ │ │ └── use-head.ts │ │ │ └── index.ts │ │ ├── globals.d.ts │ │ ├── node │ │ │ ├── index.ts │ │ │ ├── markdoc.ts │ │ │ └── solid-plugin.ts │ │ └── virtual │ │ │ └── app.d.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── svelte │ ├── LICENSE │ ├── README.md │ ├── globals.d.ts │ ├── index.d.ts │ ├── node.d.ts │ ├── package.json │ ├── src │ │ ├── client │ │ │ ├── +app.svelte │ │ │ ├── DevErrorFallback.svelte │ │ │ ├── ErrorBoundary.ts │ │ │ ├── Link.svelte │ │ │ ├── ProdErrorFallback.svelte │ │ │ ├── RouteAnnouncer.svelte │ │ │ ├── RouteComponent.svelte │ │ │ ├── RouteErrorBoundary.svelte │ │ │ ├── RouteSegment.svelte │ │ │ ├── RouterOutlet.svelte │ │ │ ├── context-keys.ts │ │ │ ├── context.ts │ │ │ ├── entry-client.ts │ │ │ ├── entry-server.ts │ │ │ ├── index.ts │ │ │ └── stores.ts │ │ ├── globals.d.ts │ │ ├── node │ │ │ ├── index.ts │ │ │ ├── markdoc.ts │ │ │ └── svelte-plugin.ts │ │ ├── shared │ │ │ └── index.ts │ │ └── virtual │ │ │ └── app.d.ts │ ├── tsconfig.json │ └── tsup.config.ts └── vue │ ├── LICENSE │ ├── README.md │ ├── globals.d.ts │ ├── head.d.ts │ ├── index.d.ts │ ├── node.d.ts │ ├── package.json │ ├── src │ ├── client │ │ ├── +app.ts │ │ ├── DevErrorFallback.ts │ │ ├── Link.ts │ │ ├── ProdErrorFallback.ts │ │ ├── RouteAnnouncer.ts │ │ ├── RouteComponent.ts │ │ ├── RouteErrorBoundary.ts │ │ ├── RouteSegment.ts │ │ ├── RouterOutlet.ts │ │ ├── context-keys.ts │ │ ├── context.ts │ │ ├── entry-client.ts │ │ ├── entry-server.ts │ │ ├── head │ │ │ ├── index.ts │ │ │ └── use-head.ts │ │ └── index.ts │ ├── globals.d.ts │ ├── node │ │ ├── index.ts │ │ ├── markdoc.ts │ │ └── vue-plugin.ts │ └── virtual │ │ └── app.d.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.base.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | insert_final_newline = false 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.{cmd,[cC][mM][dD]} text eol=crlf 3 | *.{bat,[bB][aA][tT]} text eol=crlf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | dist-*/ 3 | 4 | packages/*/temp 5 | packages/*/types/ 6 | 7 | sandbox/* 8 | !sandbox/.gitkeep 9 | 10 | .cache 11 | .temp 12 | 13 | examples/*/package-lock.json 14 | examples/*/yarn.lock 15 | examples/*/pnpm-lock.yaml 16 | 17 | *~ 18 | *.sw[mnpcod] 19 | *.log 20 | *.lock* 21 | *.tmp 22 | *.tmp.* 23 | *.sln 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | log.txt 28 | *.sublime-project 29 | *.sublime-workspace 30 | .env 31 | .env.* 32 | .rollup* 33 | debug.log 34 | npm-debug.log* 35 | yarn-debug.log* 36 | yarn-error.log* 37 | pnpm-debug.log* 38 | *.tsbuildinfo 39 | /libpeerconnection.log 40 | chrome-profiler-events*.json 41 | speed-measure-plugin*.json 42 | 43 | /.link 44 | /tmp 45 | /out-tsc 46 | /bazel-out 47 | /.pnp 48 | .pnp.js 49 | .eslintcache 50 | .nyc_output/ 51 | .idea/ 52 | .vscode/ 53 | .versions/ 54 | node_modules/ 55 | web_modules/ 56 | coverage/ 57 | cypress-coverage/ 58 | $RECYCLE.BIN/ 59 | 60 | .DS_Store 61 | Thumbs.db 62 | UserInterfaceState.xcuserstate 63 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.10.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | packages/create/template-*/** 2 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | useTabs: false, 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | printWidth: 100, 6 | tabWidth: 2, 7 | plugins: [require('@ianvs/prettier-plugin-sort-imports')], 8 | importOrder: [ 9 | '.css$', 10 | '^node:', 11 | '', 12 | '^(client|node|shared|server|virtual)', 13 | '^[../]', 14 | '^[./]', 15 | ], 16 | importOrderSeparation: true, 17 | importOrderSortSpecifiers: true, 18 | importOrderCaseInsensitive: true, 19 | }; 20 | -------------------------------------------------------------------------------- /.scripts/check-for-pnpm.js: -------------------------------------------------------------------------------- 1 | if (!/pnpm/.test(process.env.npm_execpath || '')) { 2 | console.warn( 3 | `\n⚠️ \u001b[33mThis repository requires using PNPM as the package manager ` + 4 | ` for scripts to work properly.\u001b[39m` + 5 | '\n\n1. Install Volta to automatically manage it by running: \x1b[1mcurl https://get.volta.sh | bash\x1b[0m', 6 | '\n2. Install PNPM by running: \x1b[1mvolta install pnpm@6\x1b[0m', 7 | "\n3. Done! Run `pnpm` commands as usual and it'll just work :)", 8 | '\n\nSee \x1b[1mhttps://volta.sh\x1b[0m for more information.', 9 | '\n', 10 | ); 11 | 12 | process.exit(1); 13 | } 14 | -------------------------------------------------------------------------------- /.scripts/check-node-version.js: -------------------------------------------------------------------------------- 1 | import { exec } from 'node:child_process'; 2 | import { promisify } from 'node:util'; 3 | 4 | async function main() { 5 | const nodeV = await promisify(exec)('node -v'); 6 | const nodeVersion = parseInt(nodeV.stdout.slice(1).split('.')[0]); 7 | 8 | if (nodeVersion < 16) { 9 | console.warn( 10 | '\n', 11 | `⚠️ \u001b[33mThis package requires your Node.js version to be \`>=16\`` + 12 | ` to work properly.\u001b[39m`, 13 | '\n\n1. Install Volta to automatically manage it by running: \x1b[1mcurl https://get.volta.sh | bash\x1b[0m', 14 | '\n2. Install Node.js by running: \x1b[1mvolta install node@16\x1b[0m', 15 | "\n3. Done! Run `npm` commands as usual and it'll just work :)", 16 | '\n\nSee \x1b[1mhttps://volta.sh\x1b[0m for more information.', 17 | '\n', 18 | ); 19 | 20 | process.exit(1); 21 | } 22 | } 23 | 24 | main().catch((err) => { 25 | console.error(err); 26 | process.exit(1); 27 | }); 28 | -------------------------------------------------------------------------------- /.scripts/test-node-esm.js: -------------------------------------------------------------------------------- 1 | import { dirname } from 'node:path'; 2 | import { fileURLToPath } from 'node:url'; 3 | 4 | import { globby } from 'globby'; 5 | import kleur from 'kleur'; 6 | 7 | async function main() { 8 | const mjsFiles = await globby(['../packages/*/dist/node/**/*.js'], { 9 | cwd: dirname(fileURLToPath(import.meta.url)), 10 | }); 11 | 12 | const ok = []; 13 | const fail = []; 14 | 15 | let i = 0; 16 | await Promise.all( 17 | mjsFiles.map((mjsFile) => { 18 | const mjsPath = `./${mjsFile}`; 19 | return import(mjsPath) 20 | .then(() => { 21 | ok.push(mjsPath); 22 | }) 23 | .catch((err) => { 24 | const color = i++ % 2 === 0 ? kleur.magenta : kleur.red; 25 | console.error(color('\n\n-----\n' + i + '\n')); 26 | console.error(mjsPath, err); 27 | console.error(color('\n-----\n\n')); 28 | fail.push(mjsPath); 29 | }); 30 | }), 31 | ); 32 | 33 | ok.length && console.log(kleur.dim(`${ok.length} OK:\n- ${ok.join('\n- ')}`)); 34 | 35 | fail.length && 36 | console.error(kleur.red(`${fail.length} FAIL:\n- ${fail.join('\n- ')}`)); 37 | 38 | if (fail.length) { 39 | console.error('\n🚨 FAILED\n'); 40 | process.exit(1); 41 | } else if (ok.length) { 42 | console.error('\n✅ SUCCESS\n'); 43 | process.exit(0); 44 | } else { 45 | console.error('⚠️ No files analyzed!\n'); 46 | process.exit(1); 47 | } 48 | } 49 | 50 | main().catch((err) => { 51 | console.error(err); 52 | process.exit(1); 53 | }); 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Rahim Alwer 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 | # Vessel 2 | 3 | [![package-badge]][package] 4 | 5 | Coming Soon. 6 | 7 | ## License 8 | 9 | [MIT](./LICENSE) 10 | 11 | Copyright (c) 2022-present, Rahim Alwer 12 | 13 | [package]: https://www.npmjs.com/package/@vessel-js/app 14 | [package-badge]: https://img.shields.io/npm/v/@vessel-js/app?style=flat-square 15 | [webpack]: https://webpack.js.org 16 | [vite]: https://vitejs.dev 17 | [vite-why]: https://vitejs.dev/guide/why.html 18 | [vessel]: https://vesseljs.dev 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vessel-workspace", 3 | "version": "0.3.3", 4 | "private": true, 5 | "type": "module", 6 | "workspaces": [ 7 | "packages/*" 8 | ], 9 | "scripts": { 10 | "build": "pnpm -F \"./packages/**\" build", 11 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s", 12 | "format": "prettier packages --write --loglevel warn", 13 | "preinstall": "node .scripts/check-for-pnpm.js && node .scripts/check-node-version.js", 14 | "prepare": "husky install", 15 | "release": "pnpm build && node .scripts/release.js", 16 | "test:node-esm": "node .scripts/test-node-esm.js" 17 | }, 18 | "lint-staged": { 19 | "*.{js,jsx,ts,tsx,vue,md,json}": "prettier --write" 20 | }, 21 | "devDependencies": { 22 | "@ianvs/prettier-plugin-sort-imports": "^3.7.0", 23 | "@types/node": "^18.0.0", 24 | "chokidar": "^3.5.0", 25 | "conventional-changelog-cli": "^2.0.0", 26 | "enquirer": "^2.3.0", 27 | "execa": "^7.0.0", 28 | "globby": "^13.0.0", 29 | "husky": "^8.0.0", 30 | "kleur": "^4.1.5", 31 | "lint-staged": "^13.0.0", 32 | "minimist": "^1.2.5", 33 | "npm-run-all": "^4.1.5", 34 | "prettier": "^2.8.0", 35 | "rimraf": "^3.0.2", 36 | "semver": "^7.3.5", 37 | "typescript": "^5.0.0" 38 | }, 39 | "engines": { 40 | "node": ">=16", 41 | "pnpm": ">=7" 42 | }, 43 | "volta": { 44 | "node": "16.15.1", 45 | "pnpm": "7.2.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/app/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Rahim Alwer 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/app/README.md: -------------------------------------------------------------------------------- 1 | # Vessel 2 | 3 | This is the core application package for Vessel, a framework-agnostic tool for building and 4 | deploying fast apps/docs. 5 | 6 | ```bash 7 | npm install @vessel-js/app 8 | ``` 9 | -------------------------------------------------------------------------------- /packages/app/globals.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-interface */ 2 | /// 3 | 4 | declare global { 5 | interface VesselRoutes {} 6 | } 7 | 8 | export {}; 9 | -------------------------------------------------------------------------------- /packages/app/head.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/head.js'; 2 | -------------------------------------------------------------------------------- /packages/app/http.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/http.js'; 2 | -------------------------------------------------------------------------------- /packages/app/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/client.js'; 2 | -------------------------------------------------------------------------------- /packages/app/node.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/node.js'; 2 | -------------------------------------------------------------------------------- /packages/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vessel-js/app", 3 | "version": "0.3.3", 4 | "type": "module", 5 | "types": "index.d.ts", 6 | "license": "MIT", 7 | "contributors": [ 8 | "Rahim Alwer " 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/vessel-js/vessel.git", 13 | "directory": "packages/app" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/vessel-js/vessel/issues" 17 | }, 18 | "files": [ 19 | "dist/", 20 | "*.d.ts" 21 | ], 22 | "exports": { 23 | ".": "./dist/client.js", 24 | "./head": "./dist/head.js", 25 | "./node": "./dist/node.js", 26 | "./server": "./dist/server.js", 27 | "./routing": "./dist/routing.js", 28 | "./http": "./dist/http.js", 29 | "./node/*": "./dist/node/*", 30 | "./package.json": "./package.json" 31 | }, 32 | "scripts": { 33 | "dev": "pnpm run build --watch", 34 | "build": "rimraf dist && tsup" 35 | }, 36 | "dependencies": { 37 | "@markdoc/markdoc": "^0.2.0", 38 | "@rollup/pluginutils": "^5.0.0", 39 | "@types/markdown-it": "^12.0.0", 40 | "acorn": "^8.8.0", 41 | "es-module-lexer": "^1.0.0", 42 | "esbuild": "^0.17.0", 43 | "globby": "^13.0.0", 44 | "gray-matter": "^4.0.0", 45 | "gzip-size": "^7.0.0", 46 | "hast-util-to-html": "^8.0.0", 47 | "js-yaml": "^4.1.0", 48 | "lru-cache": "^9.0.0", 49 | "magic-string": "^0.30.0", 50 | "node-fetch": "^3.3.0", 51 | "ora": "^6.3.0", 52 | "pathe": "^1.0.0", 53 | "pkg-up": "^4.0.0", 54 | "pretty-bytes": "^6.0.0", 55 | "undici": "^5.21.0", 56 | "urlpattern-polyfill": "^7.0.0" 57 | }, 58 | "peerDependencies": { 59 | "vite": "^4.0.0" 60 | }, 61 | "devDependencies": { 62 | "@types/node": "^18.0.0", 63 | "@types/react": "^18.0.0", 64 | "@wooorm/starry-night": "^2.0.0", 65 | "cli-table3": "^0.6.0", 66 | "fast-glob": "^3.2.0", 67 | "kleur": "^4.1.5", 68 | "rollup": "^3.20.0", 69 | "shiki": "^0.14.0", 70 | "tsup": "^6.7.0", 71 | "typescript": "^5.0.0", 72 | "vite": "^4.0.0" 73 | }, 74 | "engines": { 75 | "node": ">=16" 76 | }, 77 | "publishConfig": { 78 | "access": "public" 79 | }, 80 | "keywords": [ 81 | "app", 82 | "docs", 83 | "edge", 84 | "esm", 85 | "fast", 86 | "lightweight", 87 | "modern", 88 | "ssg", 89 | "ssr", 90 | "vercel", 91 | "vessel", 92 | "vite" 93 | ] 94 | } 95 | -------------------------------------------------------------------------------- /packages/app/routing.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/routing.js'; 2 | -------------------------------------------------------------------------------- /packages/app/server.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/server.js'; 2 | -------------------------------------------------------------------------------- /packages/app/src/client/head/index.ts: -------------------------------------------------------------------------------- 1 | export { renderHeadToString } from './head-ssr'; 2 | export { createHeadManager, type HeadManager } from './manage-head'; 3 | export { type HeadConfig } from './types/head-config'; 4 | -------------------------------------------------------------------------------- /packages/app/src/client/head/types/base-attrs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Credit: https://github.com/harlan-zw/zhead/blob/main/packages/schema/src/base.ts 3 | */ 4 | 5 | export interface BaseTagAttributes { 6 | /** 7 | * The base URL to be used throughout the document for relative URLs. Absolute and relative URLs 8 | * are allowed. 9 | * 10 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base#attr-href 11 | */ 12 | href?: string; 13 | /** 14 | * A keyword or author-defined name of the default browsing context to show the results of 15 | * navigation from , , or
elements without explicit target attributes. 16 | * 17 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base#attr-target 18 | */ 19 | target?: string; 20 | } 21 | -------------------------------------------------------------------------------- /packages/app/src/client/head/types/body-attrs.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 2 | export interface BodyTagAttributes { 3 | // extend to add types 4 | } 5 | -------------------------------------------------------------------------------- /packages/app/src/client/head/types/head-attrs.ts: -------------------------------------------------------------------------------- 1 | export type Maybe = T | null | undefined | false; 2 | 3 | export type AttrValue = string | number | boolean; 4 | 5 | export type ReactiveAttrValue = Maybe | (() => Maybe); 6 | 7 | export interface AttrValueResolver { 8 | (value: ReactiveAttrValue): T | null | undefined; 9 | } 10 | 11 | export type ReactiveAttrs = { 12 | [P in keyof T]?: ReactiveAttrValue; 13 | }; 14 | 15 | export interface HeadAttributes { 16 | [attrName: string]: ReactiveAttrValue; 17 | } 18 | -------------------------------------------------------------------------------- /packages/app/src/client/head/types/html-attrs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Credit: https://github.com/harlan-zw/zhead/blob/main/packages/schema/src/html-attributes.ts 3 | */ 4 | 5 | export interface HtmlTagAttributes { 6 | /** 7 | * The lang global attribute helps define the language of an element: the language that 8 | * non-editable elements are written in, or the language that the editable elements should be 9 | * written in by the user. 10 | * 11 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang 12 | */ 13 | lang?: string; 14 | /** 15 | * The dir global attribute is an enumerated attribute that indicates the directionality of the 16 | * element's text. 17 | */ 18 | dir?: 'ltr' | 'rtl' | 'auto'; 19 | } 20 | -------------------------------------------------------------------------------- /packages/app/src/client/head/types/meta-attrs.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | 3 | /** 4 | * Credit: https://github.com/harlan-zw/zhead/blob/main/packages/schema/src/meta.ts 5 | */ 6 | 7 | import type { MetaTagFields } from './meta-fields'; 8 | 9 | export interface MetaTagAttributes { 10 | /** 11 | * This attribute declares the document's character encoding.* If the attribute is present, 12 | * its value must be an ASCII case-insensitive match for the string "utf-8", because UTF-8 is the 13 | * only valid encoding for HTML5 documents. elements which declare a character encoding 14 | * must be located entirely within the first 1024 bytes of the document. 15 | * 16 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#attr-charset 17 | */ 18 | charset?: string; 19 | /** 20 | * This attribute contains the value for the http-equiv or name attribute, depending on which 21 | * is used. 22 | * 23 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#attr-content 24 | */ 25 | content?: MetaTagFields[T]; 26 | /** 27 | * Defines a pragma directive. The attribute is named http-equiv(alent) because all the allowed 28 | * values are names of particular HTTP headers. 29 | * 30 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#attr-http-equiv 31 | */ 32 | ['http-equiv']?: 33 | | 'content-security-policy' 34 | | 'content-type' 35 | | 'default-style' 36 | | 'x-ua-compatible' 37 | | 'refresh' 38 | | 'accept-ch'; 39 | /** 40 | * The name and content attributes can be used together to provide document metadata in terms of 41 | * name-value pairs, with the name attribute giving the metadata name, and the content attribute 42 | * giving the value. 43 | * 44 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#attr-name 45 | */ 46 | name?: T; 47 | /** 48 | * The property attribute is used to define a property associated with the content attribute. 49 | * 50 | * Mainly used for og and twitter meta tags. 51 | */ 52 | property?: T; 53 | } 54 | -------------------------------------------------------------------------------- /packages/app/src/client/head/types/noscript-attrs.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 2 | export interface NoscriptTagAttributes {} 3 | -------------------------------------------------------------------------------- /packages/app/src/client/head/types/style-attrs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Credit: https://github.com/harlan-zw/zhead/blob/main/packages/schema/src/style.ts 3 | */ 4 | 5 | export interface StyleTagAttributes { 6 | /** 7 | * This attribute defines which media the style should be applied to. Its value is a media query, 8 | * which defaults to all if the attribute is missing. 9 | * 10 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style#attr-media 11 | */ 12 | media?: string; 13 | /** 14 | * A cryptographic nonce (number used once) used to allow inline styles in a style-src 15 | * Content-Security-Policy. The server must generate a unique nonce value each time it transmits 16 | * a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's 17 | * policy is otherwise trivial. 18 | * 19 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style#attr-nonce 20 | */ 21 | nonce?: string; 22 | /** 23 | * This attribute specifies alternative style sheet sets. 24 | * 25 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style#attr-title 26 | */ 27 | title?: string; 28 | } 29 | -------------------------------------------------------------------------------- /packages/app/src/client/index.ts: -------------------------------------------------------------------------------- 1 | export * from './init'; 2 | export * from './reactivity'; 3 | export * from './router'; 4 | export * from './utils'; 5 | export * from 'shared/markdown'; 6 | 7 | export interface AppConfig { 8 | id: string; 9 | baseUrl: string; 10 | } 11 | -------------------------------------------------------------------------------- /packages/app/src/client/reactivity.ts: -------------------------------------------------------------------------------- 1 | export interface Unsubscribe { 2 | (): void; 3 | } 4 | 5 | export interface Reactive { 6 | get(): T; 7 | set(newValue: T): void; 8 | subscribe(onUpdate: (value: T) => void): Unsubscribe; 9 | } 10 | 11 | export interface ReactiveFactory { 12 | (value: T): Reactive; 13 | } 14 | -------------------------------------------------------------------------------- /packages/app/src/client/router/comparators/comparator.ts: -------------------------------------------------------------------------------- 1 | import type { RoutesComparator } from './types'; 2 | 3 | /** 4 | * Default routes comparator. It doesn't sort or score routes and relies on the work being done 5 | * server-side. 6 | */ 7 | export function createSimpleComparator(): RoutesComparator { 8 | return { 9 | score() { 10 | return 10000; 11 | }, 12 | sort(routes) { 13 | return routes; 14 | }, 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /packages/app/src/client/router/comparators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './comparator'; 2 | export * from './types'; 3 | export * from './url-pattern-comparator'; 4 | -------------------------------------------------------------------------------- /packages/app/src/client/router/comparators/types.ts: -------------------------------------------------------------------------------- 1 | import type { ClientLoadableRoute, ClientRouteDeclaration } from '../types'; 2 | 3 | export interface RoutesComparator { 4 | /** 5 | * Returns a score for ranking the given route. Routes with a higher score should be matched 6 | * before routes with a lower score. 7 | */ 8 | score(route: ClientRouteDeclaration): number; 9 | /** 10 | * Sorts the routes list into the order in which they should be matched. Routes at earlier 11 | * positions should match first. 12 | */ 13 | sort(routes: ClientLoadableRoute[]): ClientLoadableRoute[]; 14 | } 15 | 16 | export interface RoutesComparatorFactory { 17 | (): RoutesComparator; 18 | } 19 | -------------------------------------------------------------------------------- /packages/app/src/client/router/comparators/url-pattern-comparator.ts: -------------------------------------------------------------------------------- 1 | import { calcRoutePathScore, compareRoutes } from 'shared/routing'; 2 | 3 | import type { RoutesComparator } from './types'; 4 | 5 | /** 6 | * Uses the same route matching, scoring/ranking, and sorting algorithm as the server-side 7 | * implementation. This can be used when you're programtically creating routes client-side. Be 8 | * careful because this comparator weighs a ~few KB, hence why it's not the default. 9 | */ 10 | export function createURLPatternComparator(): RoutesComparator { 11 | return { 12 | score(route) { 13 | return calcRoutePathScore(route.pathname); 14 | }, 15 | sort(routes) { 16 | return routes.sort(compareRoutes); 17 | }, 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /packages/app/src/client/router/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client-router'; 2 | export * from './comparators'; 3 | export * from './scroll-delegate'; 4 | export * from './types'; 5 | -------------------------------------------------------------------------------- /packages/app/src/client/utils.ts: -------------------------------------------------------------------------------- 1 | import type { MarkdownModule } from 'shared/markdown'; 2 | 3 | import type { ClientModule } from './router/types'; 4 | 5 | export function isMarkdownModule( 6 | mod: T, 7 | ): mod is T & { module: T['module'] & MarkdownModule } { 8 | return 'meta' in mod; 9 | } 10 | 11 | export function removeSSRStyles() { 12 | if (import.meta.env.DEV && !import.meta.env.SSR) { 13 | const styles = document.getElementById('__VSL_SSR_STYLES__'); 14 | styles?.remove(); 15 | } 16 | } 17 | 18 | export async function tick(): Promise { 19 | return new Promise((res) => window.requestAnimationFrame(res)); 20 | } 21 | -------------------------------------------------------------------------------- /packages/app/src/globals.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-interface */ 2 | /// 3 | /// 4 | 5 | declare global { 6 | interface Window { 7 | __VSL_TRAILING_SLASH__?: boolean; 8 | __VSL_STATIC_DATA__: Record; 9 | __VSL_STATIC_DATA_HASH_MAP__: Record; 10 | __VSL_STATIC_REDIRECTS_MAP__: Record; 11 | } 12 | 13 | interface VesselRoutes {} 14 | } 15 | 16 | export {}; 17 | -------------------------------------------------------------------------------- /packages/app/src/node/app/App.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ConfigEnv as ViteConfigEnv, 3 | ViteDevServer, 4 | ResolvedConfig as ViteResolvedConfig, 5 | UserConfig as ViteUserConfig, 6 | } from 'vite'; 7 | 8 | import type { MarkdocSchema } from 'node/markdoc'; 9 | import type { logger } from 'node/utils'; 10 | 11 | import type { ResolvedAppConfig } from './config/app-config'; 12 | import type { DisposalBin } from './create/disposal-bin'; 13 | import type { AppFiles } from './files'; 14 | import type { AppRoutes } from './routes'; 15 | 16 | export interface AppDetails { 17 | version: string; 18 | dirs: AppDirectories; 19 | vite: { env: ViteConfigEnv }; 20 | config: ResolvedAppConfig; 21 | } 22 | 23 | export interface AppFactory extends AppDetails { 24 | create(): Promise; 25 | } 26 | 27 | export interface App extends AppDetails { 28 | /** Plugin extensions. */ 29 | [x: string]: unknown; 30 | context: Map; 31 | files: AppFiles; 32 | routes: AppRoutes; 33 | markdoc: MarkdocSchema; 34 | disposal: DisposalBin; 35 | logger: typeof logger; 36 | vite: { 37 | env: ViteConfigEnv; 38 | user: ViteUserConfig; 39 | /** Available after core plugin `configResolved` hook runs. */ 40 | resolved?: ViteResolvedConfig; 41 | /** Available during dev mode after core plugin `configureServer` hook runs. */ 42 | server?: ViteDevServer; 43 | }; 44 | destroy: () => void; 45 | } 46 | 47 | export interface AppDirectories { 48 | cwd: Directory; 49 | root: Directory; 50 | workspace: Directory; 51 | app: Directory; 52 | build: Directory; 53 | public: Directory; 54 | vessel: { 55 | root: Directory; 56 | client: Directory; 57 | server: Directory; 58 | }; 59 | } 60 | 61 | export interface Directory { 62 | /** Absolute path to directory. */ 63 | path: string; 64 | /** Read contents of file relative to current directory. */ 65 | read: (filePath: string) => string; 66 | /** Resolve file path relative to current directory. */ 67 | resolve: (...path: string[]) => string; 68 | /** Resolve relative file path to current directory. */ 69 | relative: (...path: string[]) => string; 70 | /** Write contents to file relative to current directory. */ 71 | write: (filePath: string, data: string) => void; 72 | /** Resolve glob relative to current directory. */ 73 | glob: (pattern: string | string[]) => string[]; 74 | } 75 | 76 | export { type DisposalBin }; 77 | -------------------------------------------------------------------------------- /packages/app/src/node/app/config/app-config.ts: -------------------------------------------------------------------------------- 1 | import type { BuildConfig, ResolvedBuildConfig } from './build-config'; 2 | import type { ClientConfig, ResolvedClientConfig } from './client-config'; 3 | import type { DirectoriesConfig, ResolvedDirectoriesConfig } from './directories-config'; 4 | import type { ResolvedEntryConfig } from './entry-config'; 5 | import type { MarkdownConfig, ResolvedMarkdownConfig } from './markdown-config'; 6 | import type { ResolvedRoutesConfig, RoutesConfig } from './routes-config'; 7 | import type { ResolvedServerConfig, ServerConfig } from './server-config'; 8 | import type { ResolvedSitemapConfig, SitemapConfig } from './sitemap-config'; 9 | 10 | export interface ResolvedAppConfig { 11 | debug: boolean; 12 | build: ResolvedBuildConfig; 13 | dirs: ResolvedDirectoriesConfig; 14 | entry: ResolvedEntryConfig; 15 | client: ResolvedClientConfig; 16 | server: ResolvedServerConfig; 17 | routes: ResolvedRoutesConfig; 18 | markdown: ResolvedMarkdownConfig; 19 | sitemap: ResolvedSitemapConfig[]; 20 | isBuild: boolean; 21 | isSSR: boolean; 22 | } 23 | 24 | export interface AppConfig 25 | extends Partial<{ 26 | debug: boolean; 27 | build: BuildConfig; 28 | dirs: DirectoriesConfig; 29 | entry: ResolvedEntryConfig; 30 | client: ClientConfig; 31 | server: ServerConfig; 32 | routes: RoutesConfig; 33 | markdown: MarkdownConfig; 34 | sitemap: SitemapConfig | SitemapConfig[]; 35 | }> {} 36 | -------------------------------------------------------------------------------- /packages/app/src/node/app/config/build-config.ts: -------------------------------------------------------------------------------- 1 | import type { AutoBuildAdapterConfig, BuildAdapterFactory } from 'node/build/adapter'; 2 | 3 | export interface ResolvedBuildConfig { 4 | adapter: BuildAdapterFactory | AutoBuildAdapterConfig; 5 | } 6 | 7 | export interface BuildConfig extends Partial {} 8 | -------------------------------------------------------------------------------- /packages/app/src/node/app/config/client-config.ts: -------------------------------------------------------------------------------- 1 | export interface ResolvedClientConfig { 2 | /** 3 | * Application module ID or file path relative to ``. 4 | */ 5 | app: string; 6 | } 7 | 8 | export interface ClientConfig extends Partial {} 9 | -------------------------------------------------------------------------------- /packages/app/src/node/app/config/directories-config.ts: -------------------------------------------------------------------------------- 1 | export interface ResolvedDirectoriesConfig { 2 | /** 3 | * Path to application directory. The value can be either an absolute file system path or a path 4 | * relative to ``. 5 | * 6 | * @default '/app' 7 | */ 8 | app: string; 9 | 10 | /** 11 | * Directory to serve as plain static assets. Files in this directory are served and copied to 12 | * build dist dir as-is without transform. The value can be either an absolute file system path 13 | * or a path relative to ``. 14 | * 15 | * @default '/public' 16 | */ 17 | public: string; 18 | 19 | /** 20 | * The build output directory. The value can be either an absolute file system path or a path 21 | * relative to ``. 22 | * 23 | * @default '/build' 24 | */ 25 | build: string; 26 | } 27 | 28 | export interface DirectoriesConfig extends Partial {} 29 | -------------------------------------------------------------------------------- /packages/app/src/node/app/config/entry-config.ts: -------------------------------------------------------------------------------- 1 | export interface ResolvedEntryConfig { 2 | client: string; 3 | server: string; 4 | } 5 | -------------------------------------------------------------------------------- /packages/app/src/node/app/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app-config'; 2 | export * from './build-config'; 3 | export * from './client-config'; 4 | export * from './directories-config'; 5 | export * from './entry-config'; 6 | export * from './markdown-config'; 7 | export * from './resolve-config'; 8 | export * from './routes-config'; 9 | export * from './server-config'; 10 | export * from './sitemap-config'; 11 | -------------------------------------------------------------------------------- /packages/app/src/node/app/config/server-config.ts: -------------------------------------------------------------------------------- 1 | export interface ResolvedServerConfig { 2 | config: { 3 | /** 4 | * Globs used to discover edge server configuration file. 5 | */ 6 | edge: string[]; 7 | /** 8 | * Globs used to discover node server configuration file. 9 | */ 10 | node: string[]; 11 | }; 12 | } 13 | 14 | export interface ServerConfig { 15 | config?: Partial; 16 | } 17 | -------------------------------------------------------------------------------- /packages/app/src/node/app/config/sitemap-config.ts: -------------------------------------------------------------------------------- 1 | import type { FilterPattern } from '@rollup/pluginutils'; 2 | 3 | export type SitemapChangeFrequency = 4 | | 'never' 5 | | 'yearly' 6 | | 'monthly' 7 | | 'weekly' 8 | | 'daily' 9 | | 'hourly' 10 | | 'always'; 11 | 12 | export type SitemapPriority = number; 13 | 14 | export interface ResolvedSitemapConfig { 15 | /** 16 | * The URL origin to use when building sitemap URL entries. 17 | * 18 | * @example 'http://mysite.com' 19 | * @defaultValue `null` 20 | */ 21 | origin: string | null; 22 | /** 23 | * Filtern pattern used to determine which HTML pages to include in final sitemap. 24 | * 25 | * @defaultValue `.*` 26 | */ 27 | include: FilterPattern; 28 | /** 29 | * Filtern pattern used to determine which HTML pages to exclude from final sitemap. 30 | * 31 | * @defaultValue `null` 32 | */ 33 | exclude: FilterPattern; 34 | /** 35 | * Sitemap file name which is output relative to application `` directory. 36 | * 37 | * @defaultValue `sitemap.xml` 38 | */ 39 | filename: string; 40 | /** 41 | * How frequently the page is likely to change. This value provides general information to 42 | * search engines and may not correlate exactly to how often they crawl the page. 43 | * 44 | * @defaultValue `'weekly'` 45 | * @see {@link https://www.sitemaps.org/protocol.html} 46 | */ 47 | changefreq: 48 | | SitemapChangeFrequency 49 | | ((url: URL) => SitemapChangeFrequency | Promise); 50 | /** 51 | * The priority of this URL relative to other URLs on your site. Valid values range from `0.0` to 52 | * `1.0`. This value does not affect how your pages are compared to pages on other sites — it 53 | * only lets the search engines know which pages you deem most important for the crawlers. 54 | * 55 | * @defaultValue `0.7` 56 | * @see {@link https://www.sitemaps.org/protocol.html} 57 | */ 58 | priority: SitemapPriority | ((url: URL) => SitemapPriority | Promise); 59 | /** 60 | * Additional sitemap URLS to be included. 61 | */ 62 | entries: SitemapURL[]; 63 | } 64 | 65 | export interface SitemapURL { 66 | path: string; 67 | lastmod?: string; 68 | changefreq?: SitemapChangeFrequency; 69 | priority?: SitemapPriority; 70 | } 71 | 72 | export interface SitemapConfig extends Partial {} 73 | -------------------------------------------------------------------------------- /packages/app/src/node/app/create/app-dirs.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | import { globbySync } from 'globby'; 4 | import * as path from 'pathe'; 5 | import { searchForWorkspaceRoot } from 'vite'; 6 | 7 | import type { AppDirectories, Directory } from '../App'; 8 | import type { ResolvedAppConfig } from '../config'; 9 | 10 | export function createAppDirectories(root: string, config: ResolvedAppConfig): AppDirectories { 11 | const cwdDir = createDirectory(process.cwd()); 12 | const rootDir = createDirectory(root); 13 | const workspaceDir = createDirectory(searchForWorkspaceRoot(cwdDir.path, rootDir.path)); 14 | const appDir = createDirectory(config.dirs.app); 15 | const buildDir = createDirectory(config.dirs.build); 16 | const publicDir = createDirectory(config.dirs.public); 17 | const vesselDir = createDirectory(rootDir.resolve('.vessel')); 18 | return { 19 | cwd: cwdDir, 20 | workspace: workspaceDir, 21 | root: rootDir, 22 | app: appDir, 23 | build: buildDir, 24 | public: publicDir, 25 | vessel: { 26 | root: vesselDir, 27 | client: createDirectory(vesselDir.resolve('client')), 28 | server: createDirectory(vesselDir.resolve('server')), 29 | }, 30 | }; 31 | } 32 | 33 | export function createDirectory(dirname: string): Directory { 34 | const cwd = path.normalize(dirname); 35 | 36 | const resolve = (...args: string[]) => path.resolve(cwd, ...args); 37 | 38 | const relative = (...args: string[]) => path.relative(cwd, path.join(...args)); 39 | 40 | const read = (filePath: string) => fs.readFileSync(resolve(filePath), 'utf-8'); 41 | 42 | const write = (filePath: string, data: string) => fs.writeFileSync(resolve(filePath), data); 43 | 44 | const glob = (patterns: string | string[]) => globbySync(patterns, { cwd }); 45 | 46 | return { 47 | path: cwd, 48 | resolve, 49 | relative, 50 | read, 51 | write, 52 | glob, 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /packages/app/src/node/app/create/app-utils.ts: -------------------------------------------------------------------------------- 1 | import { esmRequire } from 'node/utils/module'; 2 | 3 | export const getAppVersion = (): string => { 4 | return esmRequire()('@vessel-js/app/package.json').version; 5 | }; 6 | -------------------------------------------------------------------------------- /packages/app/src/node/app/create/disposal-bin.ts: -------------------------------------------------------------------------------- 1 | export class DisposalBin { 2 | protected _disposal: (() => void | Promise)[] = []; 3 | 4 | add(...disposeCallbacks: (() => void | Promise)[]): void { 5 | for (const dispose of disposeCallbacks) { 6 | this._disposal.push(dispose); 7 | } 8 | } 9 | 10 | async empty(): Promise { 11 | for (const dispose of this._disposal) { 12 | await dispose(); 13 | } 14 | 15 | this._disposal = []; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/app/src/node/app/files/app-files.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'pathe'; 2 | 3 | import type { ServerConfig } from 'server/http/app/configure-server'; 4 | 5 | import type { App } from '../App'; 6 | import { MarkdocFiles } from './markdoc-files'; 7 | import { RouteFiles } from './route-files'; 8 | 9 | export class AppFiles { 10 | protected _app!: App; 11 | 12 | readonly routes = new RouteFiles(); 13 | readonly markdoc = new MarkdocFiles(); 14 | 15 | async init(app: App) { 16 | this._app = app; 17 | await Promise.all([this.routes.init(app), this.markdoc.init(app)]); 18 | } 19 | 20 | clear() { 21 | this.routes.clear(); 22 | this.markdoc.clear(); 23 | } 24 | 25 | get serverConfigs() { 26 | const configFiles = [ 27 | this._app.dirs.app.glob(this._app.config.server.config.node)[0], 28 | this._app.dirs.app.glob(this._app.config.server.config.edge)[0], 29 | ]; 30 | 31 | return configFiles 32 | .filter((filePath) => !!filePath) 33 | .map((filePath) => { 34 | const basename = path.basename(filePath); 35 | const absPath = this._app.dirs.app.resolve(filePath); 36 | return { 37 | path: absPath, 38 | type: basename.includes('node') ? 'node' : 'edge', 39 | viteLoader: async () => 40 | (await this._app.vite.server!.ssrLoadModule(absPath)).default as ServerConfig, 41 | }; 42 | }); 43 | } 44 | 45 | get serverConfigGlob() { 46 | return [...this._app.config.server.config.node, ...this._app.config.server.config.edge]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/app/src/node/app/files/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app-files'; 2 | export * from './markdoc-files'; 3 | export * from './resolve-route'; 4 | export * from './route-files'; 5 | export * from './system-files'; 6 | -------------------------------------------------------------------------------- /packages/app/src/node/app/routes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app-routes'; 2 | -------------------------------------------------------------------------------- /packages/app/src/node/build/adapter/auto.ts: -------------------------------------------------------------------------------- 1 | import kleur from 'kleur'; 2 | 3 | import type { BuildAdapterFactory } from './build-adapter'; 4 | import type { StaticBuildAdapterConfig } from './static/adapter'; 5 | import type { VercelBuildAdapterConfig } from './vercel/adapter'; 6 | 7 | export const adapters = [ 8 | { 9 | name: 'vercel', 10 | loader: () => import('./vercel/adapter'), 11 | test: () => !!process.env.VERCEL, 12 | }, 13 | { 14 | name: 'static', 15 | loader: () => import('./static/adapter'), 16 | test: () => true, 17 | }, 18 | ]; 19 | 20 | export interface AutoBuildAdapterConfig { 21 | use?: 'static' | 'vercel'; 22 | static?: StaticBuildAdapterConfig; 23 | vercel?: VercelBuildAdapterConfig; 24 | } 25 | 26 | export function createAutoBuildAdapter(config?: AutoBuildAdapterConfig): BuildAdapterFactory { 27 | const using = (name: string) => 28 | console.log(kleur.bold(kleur.magenta(`\n🏗️ Using ${name} build adapter`))); 29 | 30 | // @ts-expect-error - value is returned 31 | return async (...args) => { 32 | for (const adapter of adapters) { 33 | if (adapter.name === config?.use || adapter.test()) { 34 | const { default: createAdapter } = await adapter.loader(); 35 | using(adapter.name); 36 | return createAdapter(config?.[adapter.name])(...args); 37 | } 38 | } 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /packages/app/src/node/build/adapter/build-adapter.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'node/app/App'; 2 | 3 | import type { BuildData } from '../build-data'; 4 | 5 | export interface BuildAdapterFactory { 6 | (app: App, build: BuildData): BuildAdapter | Promise; 7 | } 8 | 9 | // Really basic for now but we can expand on it later. 10 | export interface BuildAdapter { 11 | name: string; 12 | write?(): void | Promise; 13 | } 14 | -------------------------------------------------------------------------------- /packages/app/src/node/build/adapter/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auto'; 2 | export * from './build-adapter'; 3 | -------------------------------------------------------------------------------- /packages/app/src/node/build/adapter/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | 3 | import * as lexer from 'es-module-lexer'; 4 | import { Plugin as EsBuildPlugin } from 'esbuild'; 5 | 6 | import { parseAndReplaceVars } from 'node/utils/acorn'; 7 | 8 | export function noopStaticLoader(): EsBuildPlugin { 9 | let installed = false; 10 | 11 | const noop = () => '() => {}', 12 | loaders = new Set(['staticLoader']); 13 | 14 | return { 15 | name: 'noop-static-loader', 16 | setup(build) { 17 | build.onStart(async () => { 18 | if (!installed) { 19 | await lexer.init; 20 | installed = true; 21 | } 22 | 23 | return null; 24 | }); 25 | 26 | build.onLoad({ filter: /\.vessel\/server\/nodes/ }, async (args) => { 27 | const code = await fs.readFile(args.path, 'utf-8'), 28 | [, exports] = lexer.parse(code, args.path); 29 | 30 | if (!exports.some((specifier) => loaders.has(specifier.n))) { 31 | return { contents: code }; 32 | } 33 | 34 | const result = parseAndReplaceVars(code, loaders, noop); 35 | return { contents: result.toString() }; 36 | }); 37 | }, 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /packages/app/src/node/build/adapter/vercel/trailing-slash.ts: -------------------------------------------------------------------------------- 1 | // Rules for clean URLs and trailing slash handling - generated with @vercel/routing-utils. 2 | // FROM: https://github.com/sveltejs/kit/blob/master/packages/adapter-vercel/index.js 3 | export const trailingSlash = { 4 | keep: [ 5 | { 6 | src: '^/(?:(.+)/)?index(?:\\.html)?/?$', 7 | headers: { 8 | Location: '/$1/', 9 | }, 10 | status: 308, 11 | }, 12 | { 13 | src: '^/(.*)\\.html/?$', 14 | headers: { 15 | Location: '/$1/', 16 | }, 17 | status: 308, 18 | }, 19 | { 20 | src: '^/\\.well-known(?:/.*)?$', 21 | }, 22 | { 23 | src: '^/((?:[^/]+/)*[^/\\.]+)$', 24 | headers: { 25 | Location: '/$1/', 26 | }, 27 | status: 308, 28 | }, 29 | { 30 | src: '^/((?:[^/]+/)*[^/]+\\.\\w+)/$', 31 | headers: { 32 | Location: '/$1', 33 | }, 34 | status: 308, 35 | }, 36 | ], 37 | remove: [ 38 | { 39 | src: '^/(?:(.+)/)?index(?:\\.html)?/?$', 40 | headers: { 41 | Location: '/$1', 42 | }, 43 | status: 308, 44 | }, 45 | { 46 | src: '^/(.*)\\.html/?$', 47 | headers: { 48 | Location: '/$1', 49 | }, 50 | status: 308, 51 | }, 52 | { 53 | src: '^/(.*)/$', 54 | headers: { 55 | Location: '/$1', 56 | }, 57 | status: 308, 58 | }, 59 | ], 60 | }; 61 | -------------------------------------------------------------------------------- /packages/app/src/node/build/bundle.ts: -------------------------------------------------------------------------------- 1 | import type { OutputBundle } from 'rollup'; 2 | import { build, type UserConfig as ViteConfig } from 'vite'; 3 | 4 | import type { App } from 'node/app/App'; 5 | import { createAppEntries } from 'node/app/create/app-factory'; 6 | 7 | import { extendManualChunks } from './chunks'; 8 | 9 | export async function createServerBundle(callback: (bundle: OutputBundle) => void) { 10 | // Vite will load `vite.config.*` which also includes Vessel plugin and configuration. 11 | await build({ 12 | build: { ssr: true }, 13 | plugins: [ 14 | { 15 | name: 'vessel-server-bundle', 16 | writeBundle(_, bundle) { 17 | callback(bundle); 18 | }, 19 | }, 20 | ], 21 | }); 22 | } 23 | 24 | export function resolveBuildConfig(app: App): ViteConfig { 25 | const ssr = app.config.isSSR; 26 | const immutableDir = '_immutable'; 27 | 28 | const input: Record = { 29 | entry: ssr ? app.config.entry.server : app.config.entry.client, 30 | app: app.config.client.app, 31 | ...createAppEntries(app), 32 | }; 33 | 34 | if (!ssr) input.index = app.dirs.app.resolve('app.html'); 35 | 36 | return { 37 | appType: 'custom', 38 | logLevel: app.config.isBuild ? 'warn' : 'info', 39 | publicDir: ssr ? false : app.dirs.public.path, 40 | esbuild: { treeShaking: !ssr }, 41 | build: { 42 | emptyOutDir: true, 43 | ssr, 44 | target: ssr ? 'node16' : undefined, 45 | manifest: !ssr && `vite-manifest.json`, 46 | ssrManifest: false, 47 | cssCodeSplit: true, 48 | assetsDir: `${immutableDir}/assets`, 49 | minify: !ssr && !app.config.debug, 50 | modulePreload: { 51 | polyfill: true, 52 | }, 53 | outDir: ssr 54 | ? app.dirs.root.relative(app.dirs.vessel.server.path) 55 | : app.dirs.root.relative(app.dirs.vessel.client.path), 56 | rollupOptions: { 57 | input, 58 | output: { 59 | format: 'esm', 60 | entryFileNames: ssr ? `[name].js` : `${immutableDir}/[name]-[hash].js`, 61 | chunkFileNames: ssr ? 'chunks/[name].js' : `${immutableDir}/chunks/[name]-[hash].js`, 62 | assetFileNames: ssr ? '' : `${immutableDir}/assets/[name]-[hash][extname]`, 63 | manualChunks: extendManualChunks(), 64 | }, 65 | preserveEntrySignatures: 'allow-extension', 66 | }, 67 | }, 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /packages/app/src/node/build/index.ts: -------------------------------------------------------------------------------- 1 | export * from './build'; 2 | export * from './build-data'; 3 | export * from './bundle'; 4 | -------------------------------------------------------------------------------- /packages/app/src/node/http/cookies.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Code from: https://github.com/nfriedly/set-cookie-parser/blob/master/lib/set-cookie.js 3 | */ 4 | 5 | /** @returns {any} */ 6 | export function splitCookiesString(cookiesString) { 7 | if (Array.isArray(cookiesString)) { 8 | return cookiesString; 9 | } 10 | 11 | if (typeof cookiesString !== 'string') { 12 | return []; 13 | } 14 | 15 | var cookiesStrings = []; 16 | var pos = 0; 17 | var start; 18 | var ch; 19 | var lastComma; 20 | var nextStart; 21 | var cookiesSeparatorFound; 22 | 23 | function skipWhitespace() { 24 | while (pos < cookiesString.length && /\s/.test(cookiesString.charAt(pos))) { 25 | pos += 1; 26 | } 27 | return pos < cookiesString.length; 28 | } 29 | 30 | function notSpecialChar() { 31 | ch = cookiesString.charAt(pos); 32 | 33 | return ch !== '=' && ch !== ';' && ch !== ','; 34 | } 35 | 36 | while (pos < cookiesString.length) { 37 | start = pos; 38 | cookiesSeparatorFound = false; 39 | 40 | while (skipWhitespace()) { 41 | ch = cookiesString.charAt(pos); 42 | if (ch === ',') { 43 | // ',' is a cookie separator if we have later first '=', not ';' or ',' 44 | lastComma = pos; 45 | pos += 1; 46 | 47 | skipWhitespace(); 48 | nextStart = pos; 49 | 50 | while (pos < cookiesString.length && notSpecialChar()) { 51 | pos += 1; 52 | } 53 | 54 | // currently special character 55 | if (pos < cookiesString.length && cookiesString.charAt(pos) === '=') { 56 | // we found cookies separator 57 | cookiesSeparatorFound = true; 58 | // pos is inside the next cookie, so back up and return it. 59 | pos = nextStart; 60 | cookiesStrings.push(cookiesString.substring(start, lastComma)); 61 | start = pos; 62 | } else { 63 | // in param ',' or param separator ';', 64 | // we continue from that comma 65 | pos = lastComma + 1; 66 | } 67 | } else { 68 | pos += 1; 69 | } 70 | } 71 | 72 | if (!cookiesSeparatorFound || pos >= cookiesString.length) { 73 | cookiesStrings.push(cookiesString.substring(start, cookiesString.length)); 74 | } 75 | } 76 | 77 | return cookiesStrings; 78 | } 79 | -------------------------------------------------------------------------------- /packages/app/src/node/http/create-message-handler.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage, ServerResponse } from 'node:http'; 2 | 3 | import { handleIncomingMessage } from 'node/http'; 4 | import { installPolyfills } from 'node/polyfills'; 5 | import { createServer } from 'server/http'; 6 | import type { ServerManifest } from 'server/types'; 7 | import type { RequestHandler } from 'shared/http'; 8 | 9 | export function createIncomingMessageHandler(manifest: ServerManifest) { 10 | let installed = false, 11 | handler: RequestHandler; 12 | 13 | return async (req: IncomingMessage, res: ServerResponse) => { 14 | if (!installed) { 15 | await installPolyfills(); 16 | handler = createServer(manifest); 17 | installed = true; 18 | } 19 | 20 | await handleIncomingMessage(`https://${req.headers.host}`, req, res, handler); 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /packages/app/src/node/http/handle-incoming.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage, ServerResponse } from 'node:http'; 2 | 3 | import type { RequestHandler } from 'shared/http'; 4 | 5 | import { getRequest, setResponse } from './http-bridge'; 6 | 7 | export async function handleIncomingMessage( 8 | base: string, 9 | req: IncomingMessage, 10 | res: ServerResponse, 11 | handler: RequestHandler, 12 | onInvalidRequestBody?: (error: unknown) => void, 13 | ) { 14 | let request!: Request; 15 | 16 | try { 17 | request = await getRequest(base, req); 18 | } catch (error) { 19 | onInvalidRequestBody?.(error); 20 | res.statusCode = 400; 21 | res.end('invalid request body'); 22 | return; 23 | } 24 | 25 | await setResponse(res, await handler(request)); 26 | } 27 | -------------------------------------------------------------------------------- /packages/app/src/node/http/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-message-handler'; 2 | export * from './handle-incoming'; 3 | export * from './http-bridge'; 4 | -------------------------------------------------------------------------------- /packages/app/src/node/index.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | export * from './app/App'; 4 | export * from './app/config'; 5 | export * from './app/files'; 6 | export * from './app/routes'; 7 | export type { BuildBundles, BuildData } from './build'; 8 | export type { BuildAdapter, BuildAdapterFactory } from './build/adapter'; 9 | export { type AutoBuildAdapterConfig, createAutoBuildAdapter } from './build/adapter'; 10 | export type { StaticBuildAdapterConfig } from './build/adapter/static/adapter'; 11 | export type { VercelBuildAdapterConfig } from './build/adapter/vercel/adapter'; 12 | export * from './http'; 13 | export type { 14 | HighlightCodeBlock, 15 | MarkdocAstTransformer, 16 | MarkdocContentTransformer, 17 | MarkdocMetaTransformer, 18 | MarkdocOutputTransformer, 19 | MarkdocRenderer, 20 | MarkdocSchema, 21 | MarkdocTreeNodeTransformer, 22 | MarkdocTreeWalkStuff, 23 | ParseMarkdownConfig, 24 | RenderMarkdocConfig, 25 | } from './markdoc'; 26 | export { renderMarkdocToHTML } from './markdoc'; 27 | export * from './utils'; 28 | export * from './vite/alias'; 29 | export * from './vite/Plugin'; 30 | export { 31 | vesselPlugin as default, 32 | vesselPlugin as vessel, 33 | type VesselPluginConfig, 34 | } from './vite/vessel-plugin'; 35 | export type { 36 | Config as MarkdocConfig, 37 | Node as MarkdocNode, 38 | RenderableTreeNode as MarkdocRenderableTreeNode, 39 | RenderableTreeNodes as MarkdocRenderableTreeNodes, 40 | Tag as MarkdocTag, 41 | } from '@markdoc/markdoc'; 42 | export { default as Markdoc } from '@markdoc/markdoc'; 43 | export { escapeHTML, unescapeHTML } from 'shared/utils/html'; 44 | export { toPascalCase } from 'shared/utils/string'; 45 | -------------------------------------------------------------------------------- /packages/app/src/node/markdoc/index.ts: -------------------------------------------------------------------------------- 1 | export * from './markdoc-schema'; 2 | export * from './parse-markdown'; 3 | export * from './render'; 4 | export * from './types'; 5 | -------------------------------------------------------------------------------- /packages/app/src/node/markdoc/render.ts: -------------------------------------------------------------------------------- 1 | import type { RenderableTreeNodes } from '@markdoc/markdoc'; 2 | import Markdoc from '@markdoc/markdoc'; 3 | 4 | // HTML elements that do not have a matching close tag 5 | // Defined in the HTML standard: https://html.spec.whatwg.org/#void-elements 6 | const voidElements = new Set([ 7 | 'area', 8 | 'base', 9 | 'br', 10 | 'col', 11 | 'embed', 12 | 'hr', 13 | 'img', 14 | 'input', 15 | 'link', 16 | 'meta', 17 | 'param', 18 | 'source', 19 | 'track', 20 | 'wbr', 21 | ]); 22 | 23 | export interface RenderMarkdocConfig { 24 | attr?: (tagName: string, name: string, value: unknown) => string; 25 | } 26 | 27 | /** 28 | * Renders Markdoc to HTML tree. 29 | */ 30 | export function renderMarkdocToHTML( 31 | node: RenderableTreeNodes, 32 | config: RenderMarkdocConfig = {}, 33 | ): string { 34 | if (typeof node === 'string' || typeof node === 'boolean' || typeof node === 'number') { 35 | return node + ''; 36 | } 37 | 38 | if (Array.isArray(node)) return node.map((n) => renderMarkdocToHTML(n, config)).join(''); 39 | 40 | if (!Markdoc.Tag.isTag(node)) return ''; 41 | 42 | const { name, attributes, children = [] } = node; 43 | 44 | if ((attributes as any)?.__ignore) return ''; 45 | 46 | if (!name) return renderMarkdocToHTML(children, config); 47 | 48 | let output = `<${name}`; 49 | 50 | const attr = config.attr 51 | ? (k, v) => config.attr!(name as string, k, v) 52 | : (k, v) => `${k}="${String(v)}"`; 53 | 54 | for (const [k, v] of Object.entries(attributes ?? {})) { 55 | output += ` ${attr(k, v)}`; 56 | } 57 | 58 | output += '>'; 59 | 60 | if (voidElements.has(name as string)) return output; 61 | 62 | if (children.length) output += renderMarkdocToHTML(children, config); 63 | output += ``; 64 | 65 | return output; 66 | } 67 | -------------------------------------------------------------------------------- /packages/app/src/node/markdoc/types.ts: -------------------------------------------------------------------------------- 1 | import type { Node, RenderableTreeNode } from '@markdoc/markdoc'; 2 | 3 | import type { MarkdownFrontmatter, MarkdownHeading, MarkdownMeta } from 'shared/markdown'; 4 | 5 | export interface HighlightCodeBlock { 6 | (code: string, lang: string): string | undefined | null; 7 | } 8 | 9 | export interface MarkdocTreeWalkStuff { 10 | [id: string]: any; 11 | baseUrl: string; 12 | filePath: string; 13 | appDir: string; 14 | links: Set; 15 | imports: Set; 16 | headings: MarkdownHeading[]; 17 | highlight: HighlightCodeBlock; 18 | } 19 | 20 | export interface MarkdocTreeNodeTransformer { 21 | (data: { node: RenderableTreeNode; stuff: MarkdocTreeWalkStuff }): void; 22 | } 23 | 24 | export interface MarkdocAstTransformer { 25 | (data: { ast: Node; filePath: string; source: string }): void; 26 | } 27 | 28 | export interface MarkdocContentTransformer { 29 | (data: { 30 | filePath: string; 31 | content: RenderableTreeNode; 32 | frontmatter: MarkdownFrontmatter; 33 | }): string; 34 | } 35 | 36 | export interface MarkdocMetaTransformer { 37 | (data: { 38 | filePath: string; 39 | imports: string[]; 40 | stuff: MarkdocTreeWalkStuff; 41 | meta: MarkdownMeta; 42 | }): void; 43 | } 44 | 45 | export interface MarkdocOutputTransformer { 46 | (data: { 47 | filePath: string; 48 | code: string; 49 | imports: string[]; 50 | stuff: MarkdocTreeWalkStuff; 51 | meta: MarkdownMeta; 52 | }): string; 53 | } 54 | 55 | export interface MarkdocRenderer { 56 | (data: { 57 | filePath: string; 58 | content: RenderableTreeNode; 59 | imports: string[]; 60 | stuff: MarkdocTreeWalkStuff; 61 | meta: MarkdownMeta; 62 | }): string; 63 | } 64 | 65 | export interface ParseMarkdownConfig { 66 | ignoreCache?: boolean; 67 | filter: (id: string) => boolean; 68 | highlight: HighlightCodeBlock; 69 | transformAst: MarkdocAstTransformer[]; 70 | transformTreeNode: MarkdocTreeNodeTransformer[]; 71 | transformContent: MarkdocContentTransformer[]; 72 | transformMeta: MarkdocMetaTransformer[]; 73 | transformOutput: MarkdocOutputTransformer[]; 74 | render: MarkdocRenderer; 75 | } 76 | 77 | export interface ParseMarkdownResult { 78 | filePath: string; 79 | output: string; 80 | meta: MarkdownMeta; 81 | ast: Node; 82 | stuff: MarkdocTreeWalkStuff; 83 | content: RenderableTreeNode; 84 | } 85 | -------------------------------------------------------------------------------- /packages/app/src/node/polyfills.ts: -------------------------------------------------------------------------------- 1 | async function interop(loader: () => Promise, specifier: keyof T) { 2 | const mod = await loader(); 3 | return mod[specifier] ?? (mod as any).default[specifier]; 4 | } 5 | 6 | const globals = { 7 | crypto: () => import('node:crypto'), 8 | URLPattern: () => interop(() => import('urlpattern-polyfill'), 'URLPattern'), 9 | Headers: () => interop(() => import('undici'), 'Headers'), 10 | ReadableStream: () => interop(() => import('node:stream/web'), 'ReadableStream'), 11 | TransformStream: () => interop(() => import('node:stream/web'), 'TransformStream'), 12 | WritableStream: () => interop(() => import('node:stream/web'), 'WritableStream'), 13 | Request: async () => { 14 | const Readable = await interop(() => import('node:stream'), 'Readable'); 15 | const Request = await interop(() => import('undici'), 'Request'); 16 | const NodeFetchRequest = await interop(() => import('node-fetch'), 'Request'); 17 | // TODO: remove the superclass as soon as Undici supports formData (https://github.com/nodejs/undici/issues/974) 18 | return class extends Request { 19 | formData() { 20 | return new NodeFetchRequest(this.url, { 21 | method: this.method, 22 | headers: this.headers, 23 | body: this.body && Readable.from(this.body), 24 | }).formData(); 25 | } 26 | }; 27 | }, 28 | Response: () => interop(() => import('undici'), 'Response'), 29 | fetch: () => interop(() => import('undici'), 'fetch'), 30 | }; 31 | 32 | let installed = false; 33 | export async function installPolyfills() { 34 | if (installed) return; 35 | 36 | for (const name in globals) { 37 | if (!(name in globalThis)) { 38 | Object.defineProperty(globalThis, name, { 39 | enumerable: true, 40 | configurable: true, 41 | writable: true, 42 | value: await globals[name](), 43 | }); 44 | } 45 | } 46 | 47 | installed = true; 48 | } 49 | -------------------------------------------------------------------------------- /packages/app/src/node/utils/acorn.ts: -------------------------------------------------------------------------------- 1 | import * as acorn from 'acorn'; 2 | import MagicString from 'magic-string'; 3 | 4 | /** This only replaces top-level declarations. */ 5 | export function parseAndReplaceVars( 6 | code: string, 7 | vars: Set, 8 | replace: (varName: string) => string, 9 | ) { 10 | const newCode = new MagicString(code), 11 | results = parseAndFindVarRanges(code, vars); 12 | 13 | for (const [name, start, end] of results) { 14 | newCode.overwrite(start, end, `const ${name} = ${replace(name)};\n`); 15 | } 16 | 17 | return newCode; 18 | } 19 | 20 | /** This only looks at top-level declarations. */ 21 | export function parseAndFindVarRanges(code: string, vars: Set) { 22 | const ast = acorn.parse(code, { 23 | ecmaVersion: 'latest', 24 | sourceType: 'module', 25 | }) as any; 26 | 27 | const results: [name: string, start: number, end: number][] = []; 28 | 29 | for (let node of ast.body) { 30 | if (results.length === vars.size) { 31 | break; 32 | } else if (node.type === 'ExportNamedDeclaration') { 33 | node = node.declaration; 34 | } 35 | 36 | if (node.type === 'FunctionDeclaration' && vars.has(node.id.name)) { 37 | results.push([node.id.name, node.start, node.end]); 38 | } else if (node.type === 'VariableDeclaration' && vars.has(node.declarations[0].id.name)) { 39 | results.push([node.declarations[0].id.name, node.start, node.end]); 40 | } 41 | } 42 | 43 | return results; 44 | } 45 | -------------------------------------------------------------------------------- /packages/app/src/node/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'node:crypto'; 2 | 3 | export function hash(content: string) { 4 | return createHash('sha1').update(content).digest('hex').substring(0, 8); 5 | } 6 | -------------------------------------------------------------------------------- /packages/app/src/node/utils/fs.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'node:crypto'; 2 | import fs from 'node:fs'; 3 | 4 | import * as path from 'pathe'; 5 | 6 | export const isTypeScriptFile = (filePath: string): boolean => /\.(ts|tsx)($|\?)/.test(filePath); 7 | 8 | export const isCommonJsFile = (filePath: string): boolean => /\.cjs($|\?)/.test(filePath); 9 | 10 | export function checksumFile(algorithm: string, path: string): Promise { 11 | return new Promise((resolve, reject) => { 12 | const hash = createHash(algorithm); 13 | const stream = fs.createReadStream(path); 14 | stream.on('error', (err) => reject(err)); 15 | stream.on('data', (chunk) => hash.update(chunk)); 16 | stream.on('end', () => resolve(hash.digest('hex'))); 17 | }); 18 | } 19 | 20 | export function copyDir(srcDir: string, destDir: string) { 21 | fs.mkdirSync(destDir, { recursive: true }); 22 | for (const file of fs.readdirSync(srcDir)) { 23 | const srcFile = path.resolve(srcDir, file); 24 | const destFile = path.resolve(destDir, file); 25 | copyFile(srcFile, destFile); 26 | } 27 | } 28 | 29 | export async function ensureDir(dir: string) { 30 | if (fs.existsSync(dir)) return; 31 | await fs.promises.mkdir(dir, { recursive: true }); 32 | } 33 | 34 | export async function ensureFile(filePath: string) { 35 | if (fs.existsSync(filePath)) return; 36 | await ensureDir(path.dirname(filePath)); 37 | await fs.promises.writeFile(filePath, '', { encoding: 'utf-8' }); 38 | } 39 | 40 | export function copyFile(src: string, dest: string) { 41 | const stat = fs.statSync(src); 42 | if (stat.isDirectory()) { 43 | copyDir(src, dest); 44 | } else { 45 | fs.copyFileSync(src, dest); 46 | } 47 | } 48 | 49 | export function mkdirp(dir: string) { 50 | try { 51 | fs.mkdirSync(dir, { recursive: true }); 52 | } catch (e) { 53 | if ((e as any).code === 'EEXIST') return; 54 | throw e; 55 | } 56 | } 57 | 58 | export function rimraf(path: string) { 59 | if (fs.existsSync(path)) { 60 | fs.rmSync(path, { recursive: true }); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/app/src/node/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './crypto'; 2 | export * from './fs'; 3 | export * from './logger'; 4 | export * from './module'; 5 | export * from './path'; 6 | export * from './sort'; 7 | export * from './time'; 8 | -------------------------------------------------------------------------------- /packages/app/src/node/utils/module.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from 'module'; 2 | 3 | export const esmRequire = () => createRequire(import.meta.url); 4 | 5 | export const requireShim = [ 6 | "import __path from 'pathe';", 7 | "import { fileURLToPath as __fileURLToPath } from 'node:url';", 8 | "import { createRequire as __createRequire } from 'node:module';", 9 | 'const require = __createRequire(import.meta.url);', 10 | 'const __filename = __fileURLToPath(import.meta.url);', 11 | 'const __dirname = __path.dirname(__filename);', 12 | ].join('\n'); 13 | -------------------------------------------------------------------------------- /packages/app/src/node/utils/path.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | import * as path from 'pathe'; 4 | 5 | export function trimExt(filePath: string) { 6 | return filePath.substring(0, filePath.lastIndexOf('.')) || filePath; 7 | } 8 | 9 | export const resolveRelativePath = (base: string, filePath: string): string => { 10 | return path.isAbsolute(filePath) 11 | ? path.normalize(filePath) 12 | : path.resolve(fs.lstatSync(base).isDirectory() ? base : path.dirname(base), filePath); 13 | }; 14 | 15 | export const isSubpath = (parent: string, filePath: string): boolean => { 16 | const relative = path.relative(parent, filePath); 17 | return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative); 18 | }; 19 | -------------------------------------------------------------------------------- /packages/app/src/node/utils/sort.ts: -------------------------------------------------------------------------------- 1 | import { LRUCache } from 'lru-cache'; 2 | 3 | import { slashedSplit } from 'shared/utils/url'; 4 | 5 | export function sortedInsert(items: T[], item: T, comparator: (a: T, b: T) => number) { 6 | const lastItem = items[items.length - 1]; 7 | if (!lastItem || comparator(item, lastItem) >= 0) { 8 | // fast path 9 | items.push(item); 10 | } else { 11 | let i = 0; 12 | while (i < items.length && comparator(item, items[i]) >= 0) i++; 13 | items.splice(i, 0, item); 14 | } 15 | } 16 | 17 | export function comparePathDepth(pathA: string, pathB: string) { 18 | const segmentsA = pathA.split('/'); 19 | const segmentsB = pathB.split('/'); 20 | return segmentsA.length - segmentsB.length; // shallow paths first 21 | } 22 | 23 | const orderedPageTokenRE = /^\[(\d)\]/; 24 | 25 | const sortCache = new LRUCache({ max: 2048 }); 26 | 27 | export function comparePaths(pathA: string, pathB: string, { ordered = false } = {}): number { 28 | const cacheKey = pathA + pathB; 29 | 30 | if (sortCache.has(cacheKey)) return sortCache.get(cacheKey)!; 31 | 32 | const compare = () => { 33 | const tokensA = slashedSplit(pathA); 34 | const tokensB = slashedSplit(pathB); 35 | const len = Math.max(tokensA.length, tokensB.length); 36 | 37 | for (let i = 0; i < len; i++) { 38 | if (!(i in tokensA)) { 39 | return -1; 40 | } 41 | 42 | if (!(i in tokensB)) { 43 | return 1; 44 | } 45 | 46 | const tokenA = tokensA[i].toLowerCase(); 47 | const tokenB = tokensB[i].toLowerCase(); 48 | 49 | if (ordered) { 50 | const tokenAOrderNo = tokensA[i].match(orderedPageTokenRE)?.[1]; 51 | const tokenBOrderNo = tokensA[i].match(orderedPageTokenRE)?.[1]; 52 | 53 | if (tokenAOrderNo && tokenBOrderNo) { 54 | const result = tokenAOrderNo < tokenBOrderNo ? -1 : 1; 55 | return result; 56 | } 57 | } 58 | 59 | if (tokenA === tokenB) { 60 | continue; 61 | } 62 | 63 | const isTokenADir = tokenA[tokenA.length - 1] === '/'; 64 | const isTokenBDir = tokenB[tokenB.length - 1] === '/'; 65 | 66 | if (isTokenADir === isTokenBDir) { 67 | const result = tokenA < tokenB ? -1 : 1; 68 | return result; 69 | } else { 70 | const result = isTokenADir ? 1 : -1; 71 | return result; 72 | } 73 | } 74 | 75 | return 0; 76 | }; 77 | 78 | const result = compare(); 79 | sortCache.set(cacheKey, result); 80 | return result; 81 | } 82 | -------------------------------------------------------------------------------- /packages/app/src/node/utils/time.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Formats a number in milliseconds to a `string` with time units. 3 | * 4 | * @example '20.34ms' 5 | * @example '23s' 6 | * @example '2m' 7 | * @see https://github.com/vercel/ms 8 | */ 9 | export function ms(val: number): string { 10 | const s = 1000; 11 | const m = s * 60; 12 | 13 | const msAbs = Math.abs(val); 14 | 15 | if (msAbs >= m) { 16 | return Math.round(val / m) + 'm'; 17 | } 18 | 19 | if (msAbs >= s) { 20 | return Math.round(val / s) + 's'; 21 | } 22 | 23 | return Number(val.toFixed(2)) + 'ms'; 24 | } 25 | -------------------------------------------------------------------------------- /packages/app/src/node/vite/Plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin as VitePlugin } from 'vite'; 2 | 3 | import type { App } from 'node/app/App'; 4 | import type { AppConfig, ResolvedAppConfig } from 'node/app/config'; 5 | 6 | export interface VesselPlugin extends VitePlugin { 7 | vessel?: { 8 | /** 9 | * Whether to run before core Vessel plugins or after. 10 | */ 11 | enforce?: 'pre' | 'post'; 12 | /** 13 | * Overrides client and server entry files. 14 | */ 15 | entry?: App['entry']; 16 | /** 17 | * Hook for extending the Vessel app configuration. 18 | */ 19 | config?: ( 20 | config: ResolvedAppConfig, 21 | ) => Omit | null | void | Promise | null | void>; 22 | /** 23 | * Called immediately after the config has been resolved. 24 | */ 25 | configureApp?: (app: App) => void | Promise; 26 | }; 27 | } 28 | 29 | export type VesselPluginOption = VesselPlugin | false | null | undefined; 30 | 31 | export type VesselPlugins = 32 | | VesselPluginOption 33 | | Promise 34 | | (VesselPluginOption | Promise)[]; 35 | -------------------------------------------------------------------------------- /packages/app/src/node/vite/alias.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Special prefix used throughout Vessel to identify virtual modules (VM). 3 | */ 4 | export const VM_PREFIX = ':virtual/vessel' as const; 5 | 6 | export const virtualModuleId = { 7 | noop: `${VM_PREFIX}/noop`, 8 | manifest: `${VM_PREFIX}/manifest`, 9 | client: `${VM_PREFIX}/client`, 10 | config: `${VM_PREFIX}/config`, 11 | } as const; 12 | 13 | export const virtualModuleRequestPath = Object.keys(virtualModuleId).reduce( 14 | (paths, key) => ({ 15 | ...paths, 16 | [key]: `/${virtualModuleId[key]}`, 17 | }), 18 | {} as { 19 | [P in keyof typeof virtualModuleId]: `/${(typeof virtualModuleId)[P]}`; 20 | }, 21 | ); 22 | 23 | export const virtualAliases = Object.keys(virtualModuleId).reduce( 24 | (alias, key) => ({ 25 | ...alias, 26 | [virtualModuleId[key]]: virtualModuleRequestPath[key], 27 | }), 28 | {}, 29 | ); 30 | -------------------------------------------------------------------------------- /packages/app/src/node/vite/core/dev-server-manifest.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'node/app/App'; 2 | import { toServerLoadable } from 'node/app/routes'; 3 | import { installServerConfigs } from 'server/http/app/configure-server'; 4 | import type { ServerEntryModule, ServerManifest } from 'server/types'; 5 | 6 | export function initDevServerManifest(app: App): ServerManifest { 7 | const entryLoader = async () => 8 | (await app.vite.server!.ssrLoadModule(app.config.entry.server)) as ServerEntryModule; 9 | 10 | const fixStacktrace = (error: unknown) => { 11 | if (error instanceof Error) { 12 | app.vite.server!.ssrFixStacktrace(error); 13 | } 14 | }; 15 | 16 | return { 17 | production: false, 18 | baseUrl: app.vite.resolved!.base, 19 | trailingSlash: app.config.routes.trailingSlash, 20 | entry: entryLoader, 21 | configs: [], 22 | staticData: {}, 23 | routes: { 24 | pages: [], 25 | api: [], 26 | }, 27 | document: { 28 | entry: '/:virtual/vessel/client', 29 | template: '', 30 | }, 31 | dev: { 32 | onPageRenderError: fixStacktrace, 33 | onApiError: fixStacktrace, 34 | }, 35 | }; 36 | } 37 | 38 | export async function updateDevServerManifestRoutes(app: App, manifest: ServerManifest) { 39 | manifest.middlewares = []; 40 | manifest.errorHandlers = {}; 41 | 42 | manifest.routes = { 43 | pages: app.routes.toArray().map(toServerLoadable), 44 | api: app.routes.filterHasType('api').map((route) => ({ 45 | ...route, 46 | loader: route.api!.viteLoader, 47 | })), 48 | }; 49 | 50 | manifest.configs = await Promise.all( 51 | app.files.serverConfigs.map((config) => config.viteLoader()), 52 | ); 53 | 54 | installServerConfigs(manifest); 55 | } 56 | -------------------------------------------------------------------------------- /packages/app/src/node/vite/core/index-html.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | import type { App } from 'node/app/App'; 4 | 5 | export const DEFAULT_INDEX_HTML = ` 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 |
19 | 20 | 21 | 22 | `; 23 | 24 | export function readIndexHtmlFile(app: App): string { 25 | const indexPath = app.dirs.app.resolve('app.html'); 26 | const html = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf-8') : DEFAULT_INDEX_HTML; 27 | return html.replace('{{ version }}', app.version); 28 | } 29 | -------------------------------------------------------------------------------- /packages/app/src/node/vite/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './index-html'; 2 | export * from './static-data-loader'; 3 | -------------------------------------------------------------------------------- /packages/app/src/node/vite/core/preview-server.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | import * as path from 'pathe'; 4 | import type { PreviewServerHook } from 'vite'; 5 | 6 | import type { App } from 'node/app/App'; 7 | import { handleIncomingMessage } from 'node/http'; 8 | import { installPolyfills } from 'node/polyfills'; 9 | import { createServer } from 'server/http'; 10 | import type { ServerManifest } from 'server/types'; 11 | import { coerceError } from 'shared/utils/error'; 12 | 13 | import { handleDevServerError, logDevError } from './dev-server'; 14 | 15 | export async function configurePreviewServer(app: App, server: Parameters[0]) { 16 | await installPolyfills(); 17 | 18 | const protocol = 19 | app.vite.resolved!.server.https || app.vite.resolved!.preview.https ? 'https' : 'http'; 20 | 21 | const manifestPath = app.dirs.vessel.server.resolve('.manifests/node.js'); 22 | 23 | // Manifest won't exist if it's a completely static site. 24 | const manifest = ( 25 | fs.existsSync(manifestPath) ? (await import(manifestPath)).default : null 26 | ) as ServerManifest | null; 27 | 28 | const handler = manifest ? createServer(manifest) : null; 29 | 30 | return { 31 | pre: () => { 32 | immutableHeaderMiddleware(server); 33 | }, 34 | post: () => { 35 | if (manifest && handler) { 36 | server.middlewares.use(async (req, res) => { 37 | try { 38 | if (!req.url || !req.method) { 39 | throw new Error('[vessel] incomplete request'); 40 | } 41 | 42 | const base = `${protocol}://${req.headers[':authority'] || req.headers.host}`; 43 | 44 | return await handleIncomingMessage(base, req, res, handler, (error) => { 45 | logDevError(app, req, coerceError(error)); 46 | }); 47 | } catch (error) { 48 | handleDevServerError(app, req, res, error); 49 | return; 50 | } 51 | }); 52 | } 53 | }, 54 | }; 55 | } 56 | 57 | function immutableHeaderMiddleware(server: Parameters[0]) { 58 | server.middlewares.use((req, res, next) => { 59 | if (req.url?.startsWith('/_immutable')) { 60 | res.setHeader('Cache-Control', 'public, immutable, max-age=31536000'); 61 | res.setHeader('ETag', path.basename(req.url, path.extname(req.url)).replace(/^.+-/, '')); 62 | } 63 | 64 | next(); 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /packages/app/src/node/vite/files/files-hmr.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'pathe'; 2 | import type { ViteDevServer } from 'vite'; 3 | 4 | import type { App } from 'node/app/App'; 5 | import type { RouteFile } from 'node/app/files'; 6 | 7 | import { clearMarkdownCache } from '../../markdoc'; 8 | import { virtualModuleRequestPath } from '../alias'; 9 | 10 | export function handleFilesHMR(app: App) { 11 | const server = app.vite.server!; 12 | 13 | function clearLayoutChildrenMarkdownCache(layoutFilePath: string) { 14 | const branch = app.files.routes.getDirBranch(layoutFilePath); 15 | for (const { page } of branch) { 16 | if (page) { 17 | clearMarkdownCache(page.path.absolute); 18 | invalidateRouteModule(server, page); 19 | } 20 | } 21 | } 22 | 23 | const is = (filePath: string) => app.files.routes.is(filePath); 24 | 25 | onFileEvent(is, 'add', async (filePath) => { 26 | app.files.routes.add(filePath); 27 | 28 | if (app.files.routes.isLayoutFile(filePath)) { 29 | clearLayoutChildrenMarkdownCache(filePath); 30 | } 31 | 32 | return { reload: true }; 33 | }); 34 | 35 | onFileEvent(is, 'unlink', async (filePath) => { 36 | app.files.routes.remove(filePath); 37 | 38 | if (app.files.routes.isLayoutFile(filePath)) { 39 | clearLayoutChildrenMarkdownCache(filePath); 40 | } 41 | 42 | return { reload: true }; 43 | }); 44 | 45 | function onFileEvent( 46 | test: (path: string) => boolean, 47 | eventName: string, 48 | handler: (path: string) => Promise, 49 | ) { 50 | server.watcher.on(eventName, async (p) => { 51 | const filePath = path.normalize(p); 52 | 53 | if (!test(filePath)) return; 54 | 55 | const { reload } = (await handler(filePath)) ?? {}; 56 | 57 | if (reload) { 58 | fullReload(); 59 | } 60 | }); 61 | } 62 | 63 | function fullReload() { 64 | invalidateModuleByID(virtualModuleRequestPath.manifest); 65 | server.ws.send({ type: 'full-reload' }); 66 | } 67 | 68 | function invalidateModuleByID(id: string) { 69 | const mod = server.moduleGraph.getModuleById(id); 70 | if (mod) server.moduleGraph.invalidateModule(mod); 71 | } 72 | } 73 | 74 | export function invalidateRouteModule(server: ViteDevServer, file: RouteFile) { 75 | const module = server.moduleGraph.getModulesByFile(file.path.absolute)?.values().next(); 76 | 77 | if (module?.value) server.moduleGraph.invalidateModule(module.value); 78 | } 79 | -------------------------------------------------------------------------------- /packages/app/src/node/vite/files/files-plugin.ts: -------------------------------------------------------------------------------- 1 | import type { ClientManifest } from 'client/router/types'; 2 | import type { App } from 'node/app/App'; 3 | import { getRouteComponentTypes } from 'shared/routing'; 4 | import { prettyJsonStr, stripImportQuotesFromJson } from 'shared/utils/json'; 5 | 6 | import { virtualModuleRequestPath } from '../alias'; 7 | import type { VesselPlugin } from '../Plugin'; 8 | import { handleFilesHMR } from './files-hmr'; 9 | import { watchRoutesTypes } from './watch-routes-types'; 10 | 11 | export function filesPlugin(): VesselPlugin { 12 | let app: App; 13 | 14 | return { 15 | name: '@vessel/files', 16 | enforce: 'pre', 17 | vessel: { 18 | async configureApp(_app) { 19 | app = _app; 20 | await app.files.init(app); 21 | await app.routes.init(app); 22 | await watchRoutesTypes(app); 23 | }, 24 | }, 25 | async configureServer(server) { 26 | server.watcher.add(app.dirs.app.path); 27 | handleFilesHMR(app); 28 | }, 29 | async load(id) { 30 | if (id === virtualModuleRequestPath.manifest) { 31 | return loadClientManifestModule(app); 32 | } 33 | 34 | return null; 35 | }, 36 | }; 37 | } 38 | 39 | export function loadClientManifestModule(app: App) { 40 | const clientRoutes = app.routes.filterClientRoutes(); 41 | 42 | const loaders = clientRoutes.flatMap((route) => 43 | getRouteComponentTypes() 44 | .map((type) => (route[type] ? `() => import('/${route[type]!.path.root}')` : '')) 45 | .filter((str) => str.length > 0), 46 | ); 47 | 48 | // We'll replace production version after chunks are built so we can be sure `serverLoader` exists. 49 | const fetch = app.config.isBuild ? '__VSL_CAN_FETCH__' : []; 50 | 51 | const routes: ClientManifest['routes'] = []; 52 | 53 | for (let i = 0; i < clientRoutes.length; i++) { 54 | const route = clientRoutes[i]; 55 | routes.push({ 56 | u: route.dynamic 57 | ? [route.id, route.pathname, route.score, 1] 58 | : [route.id, route.pathname, route.score], 59 | l: route.layout ? 1 : undefined, 60 | e: route.errorBoundary ? 1 : undefined, 61 | p: route.page ? 1 : undefined, 62 | }); 63 | } 64 | 65 | return `export default ${stripImportQuotesFromJson(prettyJsonStr({ loaders, fetch, routes }))};`; 66 | } 67 | -------------------------------------------------------------------------------- /packages/app/src/node/vite/files/watch-routes-types.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'node:fs'; 2 | import fs from 'node:fs/promises'; 3 | 4 | import type { App } from 'node/app/App'; 5 | 6 | export async function watchRoutesTypes(app: App) { 7 | await updateRoutesTypes(app); 8 | 9 | if (app.config.isBuild) return; 10 | 11 | app.routes.onAdd(async () => { 12 | await updateRoutesTypes(app); 13 | }); 14 | 15 | app.routes.onRemove(async () => { 16 | await updateRoutesTypes(app); 17 | }); 18 | } 19 | 20 | const optionalRestParamRE = /\/\[\[\.\.\.(.*?)\]\].*/g; 21 | const restParamRE = /\[\.\.\.(.*?)\].*/g; 22 | const paramRE = /\[(.*?)\]/g; 23 | const trailingSlashRe = /\/$/; 24 | 25 | async function updateRoutesTypes(app: App) { 26 | const file = app.dirs.app.resolve('globals.d.ts'); 27 | if (existsSync(file)) { 28 | const content = await fs.readFile(file, 'utf-8'); 29 | const lines = content.split('\n'); 30 | 31 | const startLineIndex = lines.findIndex((line) => line.includes('<-- AUTOGEN_ROUTES_START -->')); 32 | 33 | const endLineIndex = lines.findIndex((line) => line.includes('<-- AUTOGEN_ROUTES_END -->')); 34 | 35 | if (startLineIndex >= 0 && endLineIndex >= 0) { 36 | const routes = app.routes 37 | .filterHasType('page') 38 | .map((route) => 39 | route.cleanId 40 | .slice(1) 41 | .replace(optionalRestParamRE, '${string}') 42 | .replace(restParamRE, '${string}') 43 | .replace(paramRE, '${string}') 44 | .replace(trailingSlashRe, app.config.routes.trailingSlash ? '/' : ''), 45 | ) 46 | .reverse() 47 | .map((path, i) => ` ${i + 1}: \`/${path}\`;`); 48 | 49 | const newLines = [ 50 | ...lines.slice(0, startLineIndex + 1), 51 | ...routes, 52 | ...lines.slice(endLineIndex), 53 | ]; 54 | 55 | await fs.writeFile(file, newLines.join('\n')); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/app/src/node/vite/markdown/hmr.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'pathe'; 2 | 3 | import type { App } from 'node/app/App'; 4 | import { STRIP_MARKDOC_DIR_RE } from 'node/app/files'; 5 | import { clearMarkdownCache } from 'node/markdoc'; 6 | 7 | export function handleMarkdownHMR(app: App) { 8 | const schema = app.markdoc; 9 | const files = app.files.markdoc; 10 | const isMarkdownNode = (filePath) => files.isAnyNode(filePath); 11 | 12 | onFileEvent(isMarkdownNode, 'add', async (filePath) => { 13 | files.add(filePath); 14 | 15 | const owningDir = filePath.replace(STRIP_MARKDOC_DIR_RE, ''); 16 | 17 | if (app.files.routes.isLayoutFile(filePath)) { 18 | for (const pageFile of app.files.routes.toArray('page')) { 19 | if (pageFile.path.absolute.startsWith(owningDir)) { 20 | clearMarkdownCache(pageFile.path.absolute); 21 | invalidateFile(pageFile.path.absolute); 22 | } 23 | } 24 | } 25 | 26 | for (const route of app.routes.filterHasType('page')) { 27 | if (route.page!.path.absolute.startsWith(owningDir)) { 28 | const pageFilePath = route.page!.path.absolute; 29 | clearMarkdownCache(pageFilePath); 30 | invalidateFile(pageFilePath); 31 | } 32 | } 33 | 34 | return { reload: true }; 35 | }); 36 | 37 | onFileEvent(isMarkdownNode, 'unlink', async (filePath) => { 38 | files.remove(filePath); 39 | 40 | const hmrFiles = schema.hmrFiles.get(filePath); 41 | 42 | if (hmrFiles) { 43 | for (const file of hmrFiles) { 44 | clearMarkdownCache(file); 45 | invalidateFile(file); 46 | } 47 | } 48 | 49 | schema.hmrFiles.delete(filePath); 50 | return { reload: true }; 51 | }); 52 | 53 | function onFileEvent( 54 | test: (path: string) => boolean, 55 | eventName: string, 56 | handler: (path: string) => Promise, 57 | ) { 58 | app.vite.server!.watcher.on(eventName, async (p) => { 59 | const filePath = path.normalize(p); 60 | 61 | if (!test(filePath)) return; 62 | 63 | const { reload } = (await handler(filePath)) ?? {}; 64 | 65 | if (reload) { 66 | app.vite.server!.ws.send({ type: 'full-reload' }); 67 | } 68 | }); 69 | } 70 | 71 | function invalidateFile(filePath: string) { 72 | app.vite.server!.moduleGraph.getModulesByFile(filePath)?.forEach((mod) => { 73 | return app.vite.server!.moduleGraph.invalidateModule(mod); 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /packages/app/src/node/vite/markdown/index.ts: -------------------------------------------------------------------------------- 1 | export * from './markdown-plugin'; 2 | -------------------------------------------------------------------------------- /packages/app/src/node/vite/remove-loaders-plugin.ts: -------------------------------------------------------------------------------- 1 | import * as lexer from 'es-module-lexer'; 2 | import * as path from 'pathe'; 3 | 4 | import type { App } from 'node/app/App'; 5 | import { parseAndReplaceVars } from 'node/utils/acorn'; 6 | 7 | import type { VesselPlugin } from './Plugin'; 8 | 9 | export function removeLoadersPlugin(): VesselPlugin { 10 | let app: App, 11 | installed = false; 12 | 13 | const validLoaders = new Set(['staticLoader', 'serverLoader']); 14 | 15 | return { 16 | name: '@vessel/remove-loaders', 17 | enforce: 'post', 18 | vessel: { 19 | configureApp(_app) { 20 | app = _app; 21 | }, 22 | }, 23 | async transform(code, id, { ssr } = {}) { 24 | const filePath = path.normalize(id); 25 | 26 | if ( 27 | !ssr && 28 | filePath.startsWith(app.dirs.app.path) && 29 | app.files.routes.isDocumentFile(filePath) 30 | ) { 31 | if (!installed) { 32 | await lexer.init; 33 | installed = true; 34 | } 35 | 36 | const [, exports] = lexer.parse(code, id); 37 | 38 | const filteredLoaders = exports 39 | .map((specifier) => specifier.n) 40 | .filter((name) => validLoaders.has(name)); 41 | 42 | if (filteredLoaders.length === 0) return; 43 | 44 | const loaders = new Set(filteredLoaders), 45 | result = parseAndReplaceVars(code, loaders, () => '() => {}'); 46 | 47 | return { 48 | code: result.toString(), 49 | map: result.generateMap({ source: id }), 50 | }; 51 | } 52 | 53 | return null; 54 | }, 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /packages/app/src/node/vite/rpc-plugin.ts: -------------------------------------------------------------------------------- 1 | import * as lexer from 'es-module-lexer'; 2 | import * as path from 'pathe'; 3 | 4 | import type { App } from 'node/app/App'; 5 | import { HTTP_METHOD_RE, HTTP_METHODS, resolveHandlerHttpMethod } from 'shared/http'; 6 | 7 | import type { VesselPlugin } from './Plugin'; 8 | 9 | export function rpcPlugin(): VesselPlugin { 10 | let app: App, 11 | installed = false; 12 | 13 | return { 14 | name: '@vessel/rpc', 15 | enforce: 'post', 16 | vessel: { 17 | configureApp(_app) { 18 | app = _app; 19 | }, 20 | }, 21 | async transform(code, id, { ssr } = {}) { 22 | const filePath = path.normalize(id); 23 | 24 | if (filePath.startsWith(app.dirs.app.path) && app.files.routes.isApiFile(filePath)) { 25 | if (!installed) { 26 | await lexer.init; 27 | installed = true; 28 | } 29 | 30 | const handlers: [id: string, method: string, path: string][] = []; 31 | const routeFile = app.files.routes.find(filePath)!; 32 | const routeId = app.routes.find(routeFile)!.id; 33 | 34 | const addHandler = (handlerId: string) => { 35 | const method = resolveHandlerHttpMethod(handlerId); 36 | if (method) { 37 | const isNamedHandler = !HTTP_METHODS.has(handlerId); 38 | 39 | const rpcHandlerId = isNamedHandler 40 | ? `&rpc_handler_id=${encodeURIComponent(handlerId)}` 41 | : ''; 42 | 43 | handlers.push([ 44 | handlerId, 45 | method.toUpperCase(), 46 | `/__rpc?rpc_route_id=${encodeURIComponent(routeId)}${rpcHandlerId}`, 47 | ]); 48 | } 49 | }; 50 | 51 | const [, exports] = lexer.parse(code, id); 52 | 53 | for (const specifier of exports) { 54 | if (HTTP_METHOD_RE.test(specifier.n)) { 55 | addHandler(specifier.n); 56 | } 57 | } 58 | 59 | return !ssr 60 | ? handlers 61 | .map( 62 | ([handlerId, method, path]) => 63 | `export const ${handlerId} = ['${method}', '${path}'];`, 64 | ) 65 | .join('\n') 66 | : code + 67 | '\n\n' + 68 | handlers 69 | .map(([handlerId, method, path]) => `${handlerId}.rpc = ['${method}', '${path}'];`) 70 | .join('\n'); 71 | } 72 | 73 | return null; 74 | }, 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /packages/app/src/server/create.ts: -------------------------------------------------------------------------------- 1 | import type { AnyResponse, FetchMiddleware, JSONData, RequestParams } from 'shared/http'; 2 | 3 | import type { ServerRequestHandler, StaticLoader } from './types'; 4 | 5 | export function createStaticLoader< 6 | Params extends RequestParams = RequestParams, 7 | Data extends JSONData = JSONData, 8 | >(loader: StaticLoader) { 9 | return loader; 10 | } 11 | 12 | export const createServerLoader = createServerRequestHandler; 13 | 14 | export function createServerRequestHandler< 15 | Params extends RequestParams = RequestParams, 16 | Response extends AnyResponse = AnyResponse, 17 | >(loader: ServerRequestHandler, init?: { middleware?: FetchMiddleware[] }) { 18 | loader.middleware = init?.middleware; 19 | return loader; 20 | } 21 | -------------------------------------------------------------------------------- /packages/app/src/server/http/app/configure-server.ts: -------------------------------------------------------------------------------- 1 | import type { ServerManifest } from 'server/types'; 2 | import { compareRoutes } from 'shared/routing'; 3 | 4 | import { createServerRouter, type ServerApp, type ServerRouter } from './server-router'; 5 | 6 | export function configureServer(init: (app: { app: ServerApp; router: ServerRouter }) => void) { 7 | const { app, router, ...manifest } = createServerRouter(); 8 | init({ app, router }); 9 | return manifest; 10 | } 11 | 12 | export type ServerConfig = ReturnType; 13 | 14 | export function installServerConfigs(manifest: ServerManifest) { 15 | if (manifest.configs) { 16 | for (const config of manifest.configs) { 17 | installServerConfig(manifest, config); 18 | } 19 | } 20 | } 21 | 22 | function installServerConfig(manifest: ServerManifest, config: ServerConfig) { 23 | if (!manifest.middlewares) manifest.middlewares = []; 24 | if (!manifest.errorHandlers) manifest.errorHandlers = {}; 25 | 26 | manifest.middlewares.push(...config.middlewares); 27 | 28 | for (const type of ['page', 'api'] as const) { 29 | if (!manifest.errorHandlers[type]) manifest.errorHandlers[type] = []; 30 | 31 | if (config.errorHandlers[type].length > 0) { 32 | manifest.errorHandlers[type]!.push(...config.errorHandlers[type].map(addURLPattern)); 33 | 34 | manifest.errorHandlers[type]!.sort(compareRoutes); 35 | } 36 | } 37 | 38 | if (config.apiRoutes.length > 0) { 39 | manifest.routes!.api!.push(...config.apiRoutes.map(addURLPattern)); 40 | manifest.routes!.api!.sort(compareRoutes); 41 | } 42 | } 43 | 44 | export function addURLPattern(route: T) { 45 | if (!route.pattern) { 46 | route.pattern = new URLPattern({ pathname: route.pathname }); 47 | } 48 | 49 | return route as T & { pattern: URLPattern }; 50 | } 51 | -------------------------------------------------------------------------------- /packages/app/src/server/http/create-request-event.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ServerFetch, 3 | ServerManifest, 4 | ServerRequestEvent, 5 | ServerRequestEventInit, 6 | } from 'server/types'; 7 | import { 8 | coerceFetchInput, 9 | createResponseDetails, 10 | createVesselResponse, 11 | type RequestParams, 12 | type ResponseDetails, 13 | } from 'shared/http'; 14 | import { matchRoute } from 'shared/routing'; 15 | import { isFunction } from 'shared/utils/unit'; 16 | 17 | import { handleApiRequest } from './handlers/handle-api-request'; 18 | 19 | export function createServerRequestEvent( 20 | init: ServerRequestEventInit, 21 | ): ServerRequestEvent { 22 | const request = init.request; 23 | const response = createResponseDetails(request.URL); 24 | const serverFetch = createServerFetch(request.URL, init.manifest, init.page); 25 | 26 | const event: ServerRequestEvent = { 27 | get params() { 28 | return init.params; 29 | }, 30 | get request() { 31 | return request; 32 | }, 33 | get response() { 34 | return response; 35 | }, 36 | get page() { 37 | return init.page; 38 | }, 39 | get serverFetch() { 40 | return serverFetch; 41 | }, 42 | }; 43 | 44 | return event; 45 | } 46 | 47 | export function createServerFetch( 48 | baseURL: URL, 49 | manifest: ServerManifest, 50 | page?: ResponseDetails, 51 | ): ServerFetch { 52 | return async (input, init) => { 53 | if (isFunction(input) && !input.rpc) { 54 | throw Error('[vessel] server fetch RPC call was not transformed'); 55 | } 56 | 57 | const request = coerceFetchInput(isFunction(input) ? input.rpc! : input, init, baseURL); 58 | 59 | if (request.URL.origin === baseURL.origin) { 60 | const route = matchRoute(request.URL, manifest.routes.api); 61 | if (route) return handleApiRequest(request, route, manifest, page); 62 | } 63 | 64 | return createVesselResponse(request.URL, await fetch(request, init)) as any; 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /packages/app/src/server/http/handlers/handle-api-error.ts: -------------------------------------------------------------------------------- 1 | import kleur from 'kleur'; 2 | 3 | import type { ServerErrorRoute, ServerManifest } from 'server/types'; 4 | import { 5 | coerceAnyResponse, 6 | createVesselResponse, 7 | isHttpError, 8 | json, 9 | type VesselRequest, 10 | type VesselResponse, 11 | } from 'shared/http'; 12 | import { testRoute } from 'shared/routing'; 13 | import { coerceError } from 'shared/utils/error'; 14 | 15 | export async function handleApiError( 16 | request: VesselRequest, 17 | error: unknown, 18 | manifest: ServerManifest, 19 | ): Promise { 20 | let response!: Response; 21 | 22 | if (isHttpError(error)) { 23 | response = json( 24 | { 25 | error: { 26 | message: error.message, 27 | data: error.data, 28 | }, 29 | }, 30 | error.init, 31 | ); 32 | 33 | response.headers.set('X-Vessel-Expected', 'yes'); 34 | } else { 35 | const handled = await runErrorHandlers(request, error, manifest.errorHandlers?.api ?? []); 36 | 37 | if (handled) return handled; 38 | 39 | if (manifest.production) { 40 | response = json({ error: { message: 'internal server error' } }, 500); 41 | } else { 42 | if (!manifest.production) { 43 | manifest.dev?.onApiError?.(request, error); 44 | } 45 | 46 | const err = coerceError(error); 47 | 48 | console.error( 49 | kleur.bold(kleur.red(`\n🚨 Unexpected API Error`)), 50 | `\n\n${kleur.bold('Messsage:')} ${err.message}`, 51 | `\n${kleur.bold('URL:')} ${request.URL.pathname}${request.URL.search}`, 52 | err.stack ? `\n\n${err.stack}` : '', 53 | '\n', 54 | ); 55 | 56 | response = json( 57 | { 58 | error: { 59 | message: err.message ?? 'internal server error', 60 | stack: err.stack, 61 | }, 62 | }, 63 | 500, 64 | ); 65 | } 66 | } 67 | 68 | response.headers.set('X-Vessel-Error', 'yes'); 69 | return createVesselResponse(request.URL, response); 70 | } 71 | 72 | export async function runErrorHandlers( 73 | request: VesselRequest, 74 | error: unknown, 75 | routes: ServerErrorRoute[], 76 | ) { 77 | for (const route of routes) { 78 | if (testRoute(request.URL, route)) { 79 | const response = await route.handler(request, error); 80 | if (response) return coerceAnyResponse(request.URL, response); 81 | } 82 | } 83 | 84 | return null; 85 | } 86 | -------------------------------------------------------------------------------- /packages/app/src/server/http/handlers/handle-data-request.ts: -------------------------------------------------------------------------------- 1 | import type { ServerManifest } from 'server/types'; 2 | import { 3 | appendHeaders, 4 | clientRedirect, 5 | coerceAnyResponse, 6 | HttpError, 7 | isRedirectResponse, 8 | withMiddleware, 9 | type VesselRequest, 10 | } from 'shared/http'; 11 | import { matchRoute } from 'shared/routing'; 12 | import type { RouteComponentType } from 'shared/routing/types'; 13 | 14 | import { createServerRequestEvent } from '../create-request-event'; 15 | import { resolveMiddleware } from '../middleware'; 16 | import { handleApiError } from './handle-api-error'; 17 | 18 | export async function handleDataRequest( 19 | request: VesselRequest, 20 | manifest: ServerManifest, 21 | ): Promise { 22 | try { 23 | const routeId = request.URL.searchParams.get('route_id'), 24 | routeType = request.URL.searchParams.get('route_type') as RouteComponentType; 25 | 26 | const route = manifest.routes.pages.find((route) => route.id === routeId && route[routeType]); 27 | 28 | if (!route) { 29 | throw new HttpError('data not found', 404); 30 | } 31 | 32 | const match = matchRoute(request.URL, [route]); 33 | 34 | if (!match) { 35 | throw new HttpError('data not found', 404); 36 | } 37 | 38 | const mod = await match[routeType]!.loader(); 39 | const { serverLoader } = mod; 40 | 41 | if (!serverLoader) { 42 | const response = new Response(null, { status: 200 }); 43 | response.headers.set('X-Vessel-Data', 'no'); 44 | return response; 45 | } 46 | 47 | const response = await withMiddleware( 48 | request, 49 | async (request) => { 50 | const event = createServerRequestEvent({ 51 | request, 52 | params: match.params, 53 | manifest, 54 | }); 55 | 56 | const response = coerceAnyResponse(request.URL, await serverLoader(event)); 57 | 58 | response.headers.set('X-Vessel-Data', 'yes'); 59 | appendHeaders(response, event.response.headers); 60 | event.response.cookies.attach(response); 61 | 62 | return response; 63 | }, 64 | resolveMiddleware(manifest.middlewares, serverLoader.middleware, 'api'), 65 | ); 66 | 67 | return response; 68 | } catch (error) { 69 | if (isRedirectResponse(error)) { 70 | return clientRedirect(error.headers.get('Location')!, error); 71 | } else { 72 | return handleApiError(request, error, manifest); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /packages/app/src/server/http/handlers/handle-rpc-request.ts: -------------------------------------------------------------------------------- 1 | import type { ServerManifest } from 'server/types'; 2 | import { json, VesselRequest, type RequestParams } from 'shared/http'; 3 | 4 | import { handleApiRequest } from './handle-api-request'; 5 | 6 | export async function handleRPCRequest( 7 | request: VesselRequest, 8 | manifest: ServerManifest, 9 | ): Promise { 10 | const routeId = request.URL.searchParams.get('rpc_route_id'), 11 | params: RequestParams = {}; 12 | 13 | const route = routeId && manifest.routes.api.find((route) => route.id === routeId); 14 | 15 | if (!route) { 16 | return json({ error: { message: 'route not found' } }, 404); 17 | } 18 | 19 | const searchParams = request.URL.searchParams.getAll('rpc_params'); 20 | if (searchParams) { 21 | for (const param of searchParams) { 22 | const index = param.indexOf('='); 23 | const key = param.substring(0, index); 24 | params[key] = param.substring(index + 1); 25 | } 26 | } 27 | 28 | const matchedRoute = { 29 | ...route, 30 | matchedURL: request.URL, 31 | params, 32 | }; 33 | 34 | return handleApiRequest(request, matchedRoute, manifest); 35 | } 36 | -------------------------------------------------------------------------------- /packages/app/src/server/http/index.ts: -------------------------------------------------------------------------------- 1 | export { createServer } from './create-server'; 2 | export { handleApiError } from './handlers/handle-api-error'; 3 | export { handleApiRequest } from './handlers/handle-api-request'; 4 | export { 5 | createPageResource, 6 | createPageResourceLinkTags, 7 | createServerRouter, 8 | } from './handlers/handle-page-request'; 9 | -------------------------------------------------------------------------------- /packages/app/src/server/http/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { ServerMiddlewareEntry } from 'server/types'; 2 | import type { FetchMiddleware } from 'shared/http'; 3 | import { isString } from 'shared/utils/unit'; 4 | 5 | export function resolveMiddleware( 6 | globals: ServerMiddlewareEntry[] = [], 7 | provided: (string | FetchMiddleware)[] = [], 8 | defaultGroup?: 'page' | 'api', 9 | ) { 10 | const nonGroupedMiddleware = globals 11 | .filter((entry) => !entry.group) 12 | .map((entry) => entry.handler); 13 | 14 | const seen = new Set(nonGroupedMiddleware); 15 | const middlewares: FetchMiddleware[] = [...nonGroupedMiddleware]; 16 | 17 | const withDefaultGroup = defaultGroup ? [defaultGroup, ...provided] : provided; 18 | 19 | for (const middleware of withDefaultGroup) { 20 | if (seen.has(middleware)) continue; 21 | 22 | if (isString(middleware)) { 23 | const group = globals 24 | .filter((entry) => entry.group === middleware) 25 | .map((entry) => entry.handler); 26 | 27 | middlewares.push(...group); 28 | } else { 29 | middlewares.push(middleware); 30 | } 31 | 32 | seen.add(middleware); 33 | } 34 | 35 | return middlewares; 36 | } 37 | -------------------------------------------------------------------------------- /packages/app/src/server/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create'; 2 | export * from './http'; 3 | export { configureServer } from './http/app/configure-server'; 4 | export { type ServerApp, type ServerRouter } from './http/app/server-router'; 5 | export * from './types'; 6 | -------------------------------------------------------------------------------- /packages/app/src/server/static-data.ts: -------------------------------------------------------------------------------- 1 | import { resolveStaticDataAssetId } from 'shared/data'; 2 | import { type JSONData } from 'shared/http'; 3 | import { getRouteComponentTypes, type MatchedRoute } from 'shared/routing'; 4 | 5 | import type { 6 | ServerFetch, 7 | ServerLoadedPageRoute, 8 | StaticLoaderDataMap, 9 | StaticLoaderEvent, 10 | } from './types'; 11 | 12 | export function createStaticLoaderInput( 13 | url: URL, 14 | route: MatchedRoute, 15 | serverFetch: ServerFetch, 16 | ): StaticLoaderEvent { 17 | return { 18 | url, 19 | pathname: route.matchedURL.pathname, 20 | route, 21 | params: route.params, 22 | serverFetch, 23 | }; 24 | } 25 | 26 | export function createStaticLoaderDataMap( 27 | url: URL, 28 | routes: ServerLoadedPageRoute[], 29 | ): StaticLoaderDataMap { 30 | const map: StaticLoaderDataMap = new Map(); 31 | 32 | for (const route of routes) { 33 | for (const type of getRouteComponentTypes()) { 34 | const component = route[type]; 35 | if (component) { 36 | const id = resolveStaticDataAssetId(url, route, type); 37 | if (component.staticData) { 38 | map.set(id, component.staticData); 39 | } 40 | } 41 | } 42 | } 43 | 44 | return map; 45 | } 46 | 47 | export function createStaticDataScriptTag( 48 | map: StaticLoaderDataMap, 49 | hashRecord: Record, 50 | ) { 51 | const table: Record = {}; 52 | 53 | for (const id of map.keys()) { 54 | const data = map.get(id)!; 55 | if (data && Object.keys(data).length > 0) { 56 | table[hashRecord[id] ?? id] = data; 57 | } 58 | } 59 | 60 | return [ 61 | '', 64 | ].join(''); 65 | } 66 | -------------------------------------------------------------------------------- /packages/app/src/shared/data.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getRouteComponentTypes, 3 | type LoadedRoute, 4 | type MatchedRoute, 5 | type RouteComponentType, 6 | } from './routing'; 7 | 8 | export const STATIC_DATA_ASSET_BASE_PATH = '/_immutable/data'; 9 | 10 | export function resolveStaticDataAssetId(url: URL, route: MatchedRoute, type: RouteComponentType) { 11 | return `id=${route.id}&type=${type}&path=${url.pathname}`; 12 | } 13 | 14 | export function resolveDataAssetIds(url: URL, routes: LoadedRoute[]) { 15 | const ids = new Set(); 16 | 17 | for (const route of routes) { 18 | for (const type of getRouteComponentTypes()) { 19 | if (route[type]) { 20 | ids.add(resolveStaticDataAssetId(url, route, type)); 21 | } 22 | } 23 | } 24 | 25 | return ids; 26 | } 27 | -------------------------------------------------------------------------------- /packages/app/src/shared/http/cookies.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copied and slightly adapted from SvelteKit: https://github.com/sveltejs/kit 3 | */ 4 | 5 | import { Cookie, type CookieParseOptions, type CookieSerializeOptions } from './cookie'; 6 | 7 | const DEFAULT_SERIALIZE_OPTIONS = { 8 | httpOnly: true, 9 | secure: true, 10 | sameSite: 'lax', 11 | } as const; 12 | 13 | export interface CookiesInit { 14 | url: URL; 15 | headers?: Headers; 16 | } 17 | 18 | export class Cookies implements Iterable<[string, Cookie]> { 19 | protected _cookies = new Map(); 20 | 21 | constructor(protected readonly init: CookiesInit) {} 22 | 23 | get(name: string, options?: CookieParseOptions) { 24 | const cookie = this._cookies.get(name); 25 | 26 | if ( 27 | cookie && 28 | domainMatches(this.init.url.hostname, cookie.options.domain) && 29 | pathMatches(this.init.url.pathname, cookie.options.path) 30 | ) { 31 | return cookie.value; 32 | } 33 | 34 | const headers = this.init.headers; 35 | if (headers) { 36 | const decode = options?.decode || decodeURIComponent; 37 | const cookie = Cookie.parse(headers.get('cookie') ?? '', { decode }); 38 | return cookie[name]; 39 | } 40 | 41 | return undefined; 42 | } 43 | 44 | set(name: string, value: string, options?: CookieSerializeOptions) { 45 | this._cookies.set( 46 | name, 47 | new Cookie(name, value, { 48 | ...DEFAULT_SERIALIZE_OPTIONS, 49 | ...options, 50 | }), 51 | ); 52 | } 53 | 54 | delete(name: string) { 55 | this._cookies.delete(name); 56 | } 57 | 58 | clear() { 59 | this._cookies.clear(); 60 | } 61 | 62 | attach(body: Request | Response) { 63 | for (const newCookie of this._cookies.values()) { 64 | const { name, value, options } = newCookie; 65 | body.headers.append('set-cookie', Cookie.serialize(name, value, options)); 66 | } 67 | } 68 | 69 | [Symbol.iterator]() { 70 | return this._cookies[Symbol.iterator](); 71 | } 72 | } 73 | 74 | function domainMatches(hostname: string, constraint?: string) { 75 | if (!constraint) return true; 76 | 77 | const normalized = constraint[0] === '.' ? constraint.slice(1) : constraint; 78 | 79 | if (hostname === normalized) return true; 80 | return hostname.endsWith('.' + normalized); 81 | } 82 | 83 | function pathMatches(path: string, constraint?: string) { 84 | if (!constraint) return true; 85 | 86 | const normalized = constraint.endsWith('/') ? constraint.slice(0, -1) : constraint; 87 | 88 | if (path === normalized) return true; 89 | return path.startsWith(normalized + '/'); 90 | } 91 | -------------------------------------------------------------------------------- /packages/app/src/shared/http/errors.ts: -------------------------------------------------------------------------------- 1 | import { isNumber } from 'shared/utils/unit'; 2 | 3 | export interface HttpErrorData extends Record {} 4 | 5 | export class HttpError extends Error { 6 | readonly name = 'HttpError' as const; 7 | readonly status: number; 8 | 9 | constructor( 10 | message: string, 11 | public readonly init?: number | ResponseInit, 12 | public readonly data?: ErrorData, 13 | ) { 14 | super(message); 15 | this.status = isNumber(init) ? init : init?.status ?? 200; 16 | } 17 | } 18 | 19 | export function isHttpError(error: any): error is HttpError { 20 | // Don't do `instanceof HttpError` because this is bundled separately for client/server - they 21 | // won't be the same type. 22 | return error instanceof Error && error.name === 'HttpError'; 23 | } 24 | 25 | export function isErrorResponse(response: Response) { 26 | return response.headers.has('X-Vessel-Error'); 27 | } 28 | 29 | export function isExpectedErrorResponse(response: Response) { 30 | return response.headers.has('X-Vessel-Expected'); 31 | } 32 | 33 | export async function tryResolveResponseError(response: Response): Promise { 34 | if (isErrorResponse(response)) { 35 | const data = await response.json(); 36 | 37 | if (isExpectedErrorResponse(response)) { 38 | return new HttpError(data.error.message, response.status, data.error.data); 39 | } 40 | 41 | const error = Error(data.error.message); 42 | error.stack = data.error.stack; 43 | throw error; 44 | } 45 | 46 | return null; 47 | } 48 | 49 | export function invariant(value: boolean, message?: string): asserts value; 50 | 51 | export function invariant(value: T | null | undefined, message?: string): asserts value is T; 52 | 53 | /** 54 | * Throws HTTP bad request (status code 400) if the value is `false`, `null`, or `undefined`. 55 | */ 56 | export function invariant( 57 | value: unknown, 58 | message = 'invalid falsy value', 59 | data?: Record, 60 | ) { 61 | if (value === false || value === null || typeof value === 'undefined') { 62 | throw httpError(message, 400, data); 63 | } 64 | } 65 | 66 | /** 67 | * Throws HTTP validation error (status code 422) if the condition is false. 68 | */ 69 | export function validate( 70 | condition: boolean, 71 | message = 'validation failed', 72 | data?: Record, 73 | ) { 74 | if (!condition) throw httpError(message, 422, data); 75 | } 76 | 77 | /** 78 | * Functional helper to create a `HttpError` class. 79 | */ 80 | export function httpError(...params: ConstructorParameters) { 81 | return new HttpError(...params); 82 | } 83 | -------------------------------------------------------------------------------- /packages/app/src/shared/http/http-methods.ts: -------------------------------------------------------------------------------- 1 | export type HttpMethod = 'ANY' | 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; 2 | 3 | export const HTTP_METHODS: Set = new Set([ 4 | 'ANY', 5 | 'GET', 6 | 'HEAD', 7 | 'POST', 8 | 'PUT', 9 | 'PATCH', 10 | 'DELETE', 11 | ]); 12 | 13 | export const ALL_HTTP_METHODS = Array.from(HTTP_METHODS); 14 | 15 | export const HTTP_METHOD_RE = /^(any|get|head|post|put|patch|delete)/i; 16 | 17 | export const HTML_DOCUMENT_HTTP_METHOD = new Set(['GET', 'HEAD', 'POST']); 18 | 19 | export function resolveHandlerHttpMethod(handlerId: string) { 20 | if (HTTP_METHODS.has(handlerId)) return handlerId; 21 | const id = handlerId.split(HTTP_METHOD_RE)[1]?.toUpperCase(); 22 | return HTTP_METHODS.has(id) ? id : null; 23 | } 24 | -------------------------------------------------------------------------------- /packages/app/src/shared/http/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cookie'; 2 | export * from './cookies'; 3 | export * from './errors'; 4 | export * from './fetch'; 5 | export * from './http-methods'; 6 | export * from './middleware'; 7 | export * from './request'; 8 | export * from './request-handler'; 9 | export * from './response'; 10 | export * from './rpc'; 11 | export * from '../utils/url'; 12 | -------------------------------------------------------------------------------- /packages/app/src/shared/http/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { VesselRequest } from './request'; 2 | import type { VesselRequestHandler } from './request-handler'; 3 | import { coerceAnyResponse, type AnyResponse, type VesselResponse } from './response'; 4 | 5 | export interface FetchMiddleware { 6 | (request: VesselRequest, next: (request: VesselRequest) => Promise): 7 | | AnyResponse 8 | | Promise; 9 | } 10 | 11 | export type MaybeFetchMiddleware = FetchMiddleware | undefined | null | false; 12 | 13 | export function createMiddleware(middleware: FetchMiddleware) { 14 | return middleware; 15 | } 16 | 17 | export type ComposedFetchMiddleware = FetchMiddleware[]; 18 | 19 | export function composeFetchMiddleware( 20 | ...middlewares: (MaybeFetchMiddleware | MaybeFetchMiddleware[])[] 21 | ): ComposedFetchMiddleware { 22 | return middlewares.flat().filter((middleware) => !!middleware) as ComposedFetchMiddleware; 23 | } 24 | 25 | export async function withMiddleware( 26 | request: VesselRequest, 27 | handler: VesselRequestHandler, 28 | middlewares: FetchMiddleware[] = [], 29 | ) { 30 | let chain = handler; 31 | 32 | for (let i = middlewares.length - 1; i >= 0; i--) { 33 | const next = chain; 34 | chain = async (request) => 35 | middlewares[i](request, async (request: VesselRequest) => 36 | coerceAnyResponse(request.URL, await next(request)), 37 | ); 38 | } 39 | 40 | return coerceAnyResponse(request.URL, await chain(request)); 41 | } 42 | -------------------------------------------------------------------------------- /packages/app/src/shared/http/request-handler.ts: -------------------------------------------------------------------------------- 1 | import type { VesselRequest } from './request'; 2 | import type { AnyResponse } from './response'; 3 | 4 | export interface RequestHandler { 5 | (request: Request): Response | Promise; 6 | } 7 | 8 | export interface VesselRequestHandler { 9 | (request: VesselRequest): AnyResponse | Promise; 10 | } 11 | -------------------------------------------------------------------------------- /packages/app/src/shared/http/request.ts: -------------------------------------------------------------------------------- 1 | import { Cookies } from './cookies'; 2 | 3 | export interface VesselRequest extends Request { 4 | URL: URL; 5 | cookies: Cookies; 6 | } 7 | 8 | export interface RequestParams { 9 | [param: string]: string | undefined; 10 | } 11 | 12 | const VESSEL_REQUEST = Symbol('VESSEL_REQUEST'); 13 | 14 | export function createVesselRequest( 15 | request: Request & { URL?: URL; cookies?: Cookies }, 16 | ): VesselRequest { 17 | if (isVesselRequest(request)) return request; 18 | 19 | request[VESSEL_REQUEST] = true; 20 | 21 | if (!request.URL) { 22 | const url = new URL(request.url); 23 | Object.defineProperty(request, 'URL', { 24 | enumerable: true, 25 | get() { 26 | return url; 27 | }, 28 | }); 29 | } 30 | 31 | if (!request.cookies) { 32 | const cookies = new Cookies({ 33 | url: request.URL!, 34 | headers: request.headers, 35 | }); 36 | Object.defineProperty(request, 'cookies', { 37 | enumerable: true, 38 | get() { 39 | return cookies; 40 | }, 41 | }); 42 | } 43 | 44 | return request as VesselRequest; 45 | } 46 | 47 | export function isVesselRequest(value: unknown): value is VesselRequest { 48 | return !!value?.[VESSEL_REQUEST]; 49 | } 50 | -------------------------------------------------------------------------------- /packages/app/src/shared/http/rpc.ts: -------------------------------------------------------------------------------- 1 | import type { ServerRequestHandler } from 'server/types'; 2 | 3 | import type { RequestParams } from './request'; 4 | import type { AnyResponse, VesselResponse } from './response'; 5 | 6 | export interface RPCHandler { 7 | (...args: any[]): AnyResponse; 8 | rpc?: RPCFetchInfo; 9 | } 10 | 11 | export type RPCFetchInfo = [method: string, path: string]; 12 | 13 | export type InferRPCParams = RPC extends ServerRequestHandler 14 | ? Params 15 | : RequestParams; 16 | 17 | export type InferRPCResponse = RPC extends ServerRequestHandler< 18 | never, 19 | infer Data 20 | > 21 | ? Promise 22 | : Promise; 23 | -------------------------------------------------------------------------------- /packages/app/src/shared/markdown.ts: -------------------------------------------------------------------------------- 1 | export interface MarkdownMeta { 2 | title?: string | null; 3 | headings: MarkdownHeading[]; 4 | frontmatter: MarkdownFrontmatter; 5 | lastUpdated: number; 6 | } 7 | 8 | export interface MarkdownFrontmatter extends Record {} 9 | 10 | export interface MarkdownHeading { 11 | level: number; 12 | title: string; 13 | id: string; 14 | } 15 | 16 | export interface MarkdownModule { 17 | meta: MarkdownMeta; 18 | } 19 | -------------------------------------------------------------------------------- /packages/app/src/shared/polyfills.ts: -------------------------------------------------------------------------------- 1 | export async function installURLPattern() { 2 | // @ts-expect-error - . 3 | if (!globalThis.URLPattern) { 4 | // @ts-expect-error - . 5 | const { URLPattern } = await import('urlpattern-polyfill/urlpattern'); 6 | // @ts-expect-error - . 7 | globalThis.URLPattern = URLPattern; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/app/src/shared/routing/index.ts: -------------------------------------------------------------------------------- 1 | export * from './compare'; 2 | export * from './load'; 3 | export * from './match'; 4 | export * from './types'; 5 | -------------------------------------------------------------------------------- /packages/app/src/shared/types.ts: -------------------------------------------------------------------------------- 1 | export type Mutable = { -readonly [P in keyof T]: T[P] }; 2 | 3 | export type Immutable = { readonly [P in keyof T]: T[P] }; 4 | -------------------------------------------------------------------------------- /packages/app/src/shared/utils/error.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/sveltejs/kit/blob/master/packages/kit/src/utils/error.js#L5 2 | export function coerceError(err: any): Error { 3 | return err instanceof Error ? err : new Error(JSON.stringify(err)); 4 | } 5 | -------------------------------------------------------------------------------- /packages/app/src/shared/utils/html.ts: -------------------------------------------------------------------------------- 1 | const escapeMap = { 2 | '&': '&', 3 | '<': '<', 4 | '>': '>', 5 | "'": ''', 6 | '"': '"', 7 | }; 8 | 9 | const escapeHtmlRE = /[&<>'"]/g; 10 | 11 | export function escapeHTML(str: string) { 12 | return str.replace(escapeHtmlRE, (char) => escapeMap[char]); 13 | } 14 | 15 | const unescapeMap = Object.fromEntries( 16 | Object.entries(escapeMap).map((entries) => entries.reverse()), 17 | ); 18 | 19 | const unescapeHtmlRE = /&|<|>|'|"/g; 20 | 21 | export function unescapeHTML(str: string) { 22 | return str.replace(unescapeHtmlRE, (char) => unescapeMap[char]); 23 | } 24 | -------------------------------------------------------------------------------- /packages/app/src/shared/utils/json.ts: -------------------------------------------------------------------------------- 1 | export const prettyJsonStr = (obj: unknown): string => JSON.stringify(obj, undefined, 2); 2 | 3 | const stripImportQuotesRE = /"\(\) => import\((.+)\)"/g; 4 | 5 | /** 6 | * `JSON.stringify()` will add quotes `""` around dynamic imports which means they'll be a 7 | * string, not a dynamic import anymore. This function will strip the quotes to make it a 8 | * dynamic import again. 9 | */ 10 | export function stripImportQuotesFromJson(json: string): string { 11 | return json.replace(stripImportQuotesRE, `() => import($1)`); 12 | } 13 | -------------------------------------------------------------------------------- /packages/app/src/shared/utils/string.ts: -------------------------------------------------------------------------------- 1 | const wordSeparators = /[\s\-_]+/; 2 | 3 | export function toPascalCase(str: string): string { 4 | const words = str.split(wordSeparators); 5 | const len = words.length; 6 | const mappedWords = new Array(len); 7 | 8 | for (let i = 0; i < len; i++) { 9 | const word = words[i]; 10 | if (word === '') continue; 11 | mappedWords[i] = word[0].toUpperCase() + word.slice(1); 12 | } 13 | 14 | return mappedWords.join(''); 15 | } 16 | 17 | export function uppercaseFirstLetter(str: string) { 18 | return str.charAt(0).toUpperCase() + str.slice(1); 19 | } 20 | 21 | export function lowercaseFirstLetter(str: string) { 22 | return str.charAt(0).toLowerCase() + str.slice(1); 23 | } 24 | -------------------------------------------------------------------------------- /packages/app/src/shared/utils/unit.ts: -------------------------------------------------------------------------------- 1 | export function noop() { 2 | // 3 | } 4 | 5 | export function safeNotEqual(a: unknown, b: unknown) { 6 | return a != a ? b == b : a !== b || (a && typeof a === 'object') || typeof a === 'function'; 7 | } 8 | 9 | /** 10 | * Check if a value is `undefined`. 11 | */ 12 | export function isUndefined(value: unknown): value is undefined { 13 | return typeof value === 'undefined'; 14 | } 15 | 16 | /** 17 | * Check if a value is `null`. 18 | */ 19 | export function isNull(value: unknown): value is null { 20 | return value === null; 21 | } 22 | 23 | /** 24 | * Check if a value is a `number`. 25 | */ 26 | export function isNumber(value: unknown): value is number { 27 | return typeof value === 'number' && !Number.isNaN(value); 28 | } 29 | 30 | /** 31 | * Check if a value is a `string`. 32 | */ 33 | export function isString(value: unknown): value is string { 34 | return typeof value === 'string'; 35 | } 36 | 37 | /** 38 | * Check if a value is a `boolean`. 39 | */ 40 | export function isBoolean(value: unknown): value is boolean { 41 | return typeof value === 'boolean'; 42 | } 43 | 44 | /** 45 | * Check if a value is an `array`. 46 | */ 47 | export function isArray(value: unknown): value is any[] { 48 | return Array.isArray(value); 49 | } 50 | 51 | /** 52 | * Check if a value is a `function`. 53 | */ 54 | // eslint-disable-next-line @typescript-eslint/ban-types 55 | export function isFunction(value: unknown): value is Function { 56 | return typeof value === 'function'; 57 | } 58 | 59 | /** 60 | * Check if a value is plain `object`. 61 | */ 62 | export const isObject = = Record>(val: unknown): val is T => 63 | Object.prototype.toString.call(val) === '[object Object]'; 64 | -------------------------------------------------------------------------------- /packages/app/src/shared/utils/url.ts: -------------------------------------------------------------------------------- 1 | export const EXTERNAL_URL_RE = /^https?:/i; 2 | 3 | /** 4 | * Ensure a url `string` has an ending slash `/`. 5 | */ 6 | export const endslash = (str: string): string => (str.endsWith('/') ? str : str + '/'); 7 | 8 | const slashSplitRE = /(?=\/)/g; 9 | 10 | /** 11 | * Split string and keep `/` at the start of each path. 12 | */ 13 | export const slashedSplit = (path: string) => slash(path).split(slashSplitRE); 14 | 15 | /** 16 | * Ensure a url `string` has a leading slash `/`. 17 | */ 18 | export const slash = (str: string): string => str.replace(/^\/?/, '/'); 19 | 20 | /** 21 | * Remove leading slash `/` from a `string`. 22 | */ 23 | export const noslash = (str: string): string => str.replace(/^\//, ''); 24 | 25 | /** 26 | * Remove ending slash `/` from a `string`. 27 | */ 28 | export const noendslash = (str: string): string => str.replace(/\/$/, ''); 29 | 30 | /** 31 | * Normalize slashes by ensuring a leading slash and no trailing slash. 32 | */ 33 | export const slashes = (str: string) => slash(noendslash(str)); 34 | 35 | /** 36 | * Determine if a link is a http link or not. 37 | * 38 | * - http://github.com 39 | * - https://github.com 40 | * - //github.com 41 | */ 42 | export const isLinkHttp = (link: string): boolean => /^(https?:)?\/\//.test(link); 43 | 44 | /** 45 | * Determine if a link is external or not. 46 | */ 47 | export const isLinkExternal = (link: string, base = '/'): boolean => { 48 | // http link 49 | if (isLinkHttp(link)) return true; 50 | // absolute link that does not start with `base` 51 | if (link.startsWith('/') && !link.startsWith(base)) return true; 52 | return false; 53 | }; 54 | -------------------------------------------------------------------------------- /packages/app/src/virtual/config.d.ts: -------------------------------------------------------------------------------- 1 | declare module ':virtual/vessel/config' { 2 | declare const config: import('client').AppConfig; 3 | export default config; 4 | } 5 | -------------------------------------------------------------------------------- /packages/app/src/virtual/manifest.d.ts: -------------------------------------------------------------------------------- 1 | declare module ':virtual/vessel/manifest' { 2 | declare const manifest: import('client').ClientManifest; 3 | export default manifest; 4 | } 5 | -------------------------------------------------------------------------------- /packages/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "baseUrl": "./src", 5 | "outDir": "dist", 6 | "declarationDir": "types", 7 | "paths": { 8 | ":virtual/vessel/config": ["virtual/config"], 9 | ":virtual/vessel/manifest": ["virtual/manifest"] 10 | } 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/app/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | 3 | import globby from 'fast-glob'; 4 | import * as path from 'pathe'; 5 | import { defineConfig, type Options } from 'tsup'; 6 | 7 | export function base(extend?: { external?: (string | RegExp)[] }): Options { 8 | return { 9 | format: 'esm', 10 | external: [ 11 | 'typescript', 12 | 'rollup', 13 | 'chokidar', 14 | 'esbuild', 15 | 'vite', 16 | ':virtual', 17 | 'shiki', 18 | '@wooorm/starry-night', 19 | ...(extend?.external ?? []), 20 | ], 21 | treeshake: true, 22 | splitting: true, 23 | dts: true, 24 | outDir: 'dist', 25 | esbuildOptions(opts) { 26 | if (opts.platform === 'browser') opts.mangleProps = /^_/; 27 | opts.chunkNames = 'chunks/[name]-[hash]'; 28 | }, 29 | }; 30 | } 31 | 32 | export default defineConfig([ 33 | { 34 | ...base(), 35 | entry: { 36 | // shared 37 | http: 'src/shared/http/index.ts', 38 | routing: 'src/shared/routing/index.ts', 39 | // client 40 | client: 'src/client/index.ts', 41 | head: 'src/client/head/index.ts', 42 | // server 43 | server: 'src/server/index.ts', 44 | }, 45 | target: 'esnext', 46 | platform: 'browser', 47 | }, 48 | { 49 | ...base(), 50 | entry: { 51 | // node 52 | node: 'src/node/index.ts', 53 | 'node/http': 'src/node/http/index.ts', 54 | 'node/polyfills': 'src/node/polyfills.ts', 55 | }, 56 | target: 'node16', 57 | platform: 'node', 58 | }, 59 | ]); 60 | 61 | export async function copyFiles(glob: string, from = 'src/client', to = 'dist/client') { 62 | const fromDir = path.resolve(process.cwd(), from); 63 | const toDir = path.resolve(process.cwd(), to); 64 | const globs = `${fromDir}/${glob}`; 65 | const files = await globby(globs, { absolute: true }); 66 | await Promise.all( 67 | files.map(async (file) => { 68 | const dest = path.resolve(toDir, path.relative(fromDir, file)); 69 | await fs.mkdir(path.dirname(dest), { recursive: true }); 70 | await fs.copyFile(file, dest); 71 | }), 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /packages/create/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Rahim Alwer 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/bin/create-vessel.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { run } from '../dist/cli.js'; 3 | 4 | async function main() { 5 | await run(); 6 | } 7 | 8 | main().catch((e) => { 9 | console.error('🚨 [vessel] installation failed', '\n\n', e, '\n'); 10 | process.exit(1); 11 | }); 12 | -------------------------------------------------------------------------------- /packages/create/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-vessel", 3 | "type": "module", 4 | "version": "0.3.3", 5 | "license": "MIT", 6 | "main": "bin/create-vessel.js", 7 | "engines": { 8 | "node": ">=16.0.0" 9 | }, 10 | "contributors": [ 11 | "Rahim Alwer " 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/vessel-js/vessel", 16 | "directory": "packages/create" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/vessel-js/vessel/issues" 20 | }, 21 | "bin": { 22 | "create-vessel": "bin/create-vessel.js" 23 | }, 24 | "files": [ 25 | "bin/", 26 | "dist/", 27 | "template-*" 28 | ], 29 | "exports": { 30 | ".": "./bin/create-vessel.js", 31 | "./package.json": "./package.json" 32 | }, 33 | "scripts": { 34 | "dev": "pnpm run build --watch", 35 | "build": "rimraf dist && tsup" 36 | }, 37 | "dependencies": { 38 | "enquirer": "^2.3.0", 39 | "globby": "^13.0.0", 40 | "kleur": "^4.1.5", 41 | "minimist": "^1.2.5", 42 | "pathe": "^1.0.0" 43 | }, 44 | "devDependencies": { 45 | "@types/node": "^18.0.0", 46 | "fast-glob": "^3.2.12", 47 | "tsup": "^6.7.0", 48 | "typescript": "^5.0.0" 49 | }, 50 | "publishConfig": { 51 | "access": "public" 52 | }, 53 | "keywords": [ 54 | "app", 55 | "docs", 56 | "edge", 57 | "esm", 58 | "fast", 59 | "lightweight", 60 | "modern", 61 | "ssg", 62 | "ssr", 63 | "vercel", 64 | "vessel", 65 | "vite" 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /packages/create/src/addons/lint-staged.ts: -------------------------------------------------------------------------------- 1 | import type { Builder } from '../builder.js'; 2 | import { resolveLintExtensions } from './eslint.js'; 3 | 4 | export async function lintStagedAddon(builder: Builder) { 5 | if (!builder.hasAddon('lint-staged')) return; 6 | 7 | builder.pkg.addDevDep('husky', '^8.0.0'); 8 | builder.pkg.addDevDep('lint-staged', '^13.0.0'); 9 | 10 | const prepareScript = builder.pkg.getScript('prepare'); 11 | 12 | if (prepareScript && !prepareScript.includes('husky install')) { 13 | builder.pkg.addDevDep('prepare', `${prepareScript} husky install`); 14 | } else if (!prepareScript) { 15 | builder.pkg.addScript('prepare', `husky install`); 16 | } 17 | 18 | const lintStaged = builder.pkg['lint-staged'] ?? {}; 19 | const lintExts = resolveLintExtensions(builder); 20 | 21 | builder.pkg.addField('lint-staged', lintStaged); 22 | 23 | if (builder.hasAddon('eslint')) { 24 | const glob = `*.{${lintExts.map((s) => s.slice(1)).join(',')}}`; 25 | lintStaged[glob] = 'eslint --cache --fix'; 26 | } 27 | 28 | if (builder.hasAddon('prettier')) { 29 | const glob = `*.{${[...lintExts.map((s) => s.slice(1)), 'md', 'json'].join(',')}}`; 30 | lintStaged[glob] = 'prettier --write'; 31 | } 32 | 33 | const preCommitPath = '.husky/pre-commit'; 34 | if (!builder.dirs.target.exists(preCommitPath)) { 35 | await builder.dirs.target.write( 36 | preCommitPath, 37 | ['#!/bin/sh', '', '. "$(dirname "$0")/_/husky.sh"', '', 'npx lint-staged'].join('\n'), 38 | ); 39 | } else { 40 | await builder.dirs.target.append(preCommitPath, 'npx lint-staged'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/create/src/addons/prettier.ts: -------------------------------------------------------------------------------- 1 | import type { Builder } from '../builder'; 2 | 3 | export async function prettierAddon(builder: Builder) { 4 | if (!builder.hasAddon('prettier')) return; 5 | 6 | builder.pkg.addDevDep('npm-run-all', '^4.0.0'); 7 | builder.pkg.addDevDep('prettier', '^2.0.0'); 8 | 9 | if (builder.hasAddon('typescript')) { 10 | builder.pkg.addDevDep('prettier-plugin-tailwindcss', '^0.2.0'); 11 | } 12 | 13 | builder.pkg.addScript('lint', 'run-s lint:*'); 14 | builder.pkg.addScript( 15 | 'lint:prettier', 16 | 'prettier . --check --ignore-path .gitignore --loglevel warn', 17 | ); 18 | 19 | builder.pkg.addScript('format', 'run-s format:*'); 20 | builder.pkg.addScript('format:prettier', 'npm run lint:prettier -- --write'); 21 | 22 | const config = { 23 | singleQuote: true, 24 | printWidth: 80, 25 | tabWidth: 2, 26 | trailingComma: 'all', 27 | }; 28 | 29 | await builder.dirs.target.write('.prettierrc', JSON.stringify(config, null, 2)); 30 | } 31 | -------------------------------------------------------------------------------- /packages/create/src/addons/tailwind.ts: -------------------------------------------------------------------------------- 1 | import type { Builder } from '../builder'; 2 | 3 | export async function tailwindAddon(builder: Builder) { 4 | if (!builder.hasAddon('tailwind')) return; 5 | 6 | builder.pkg.addDevDep('tailwindcss', '^3.0.0'); 7 | builder.pkg.addDevDep('postcss', '^8.0.0'); 8 | builder.pkg.addDevDep('autoprefixer', '^10.0.0'); 9 | 10 | const ext = builder.pkg.hasField('type', 'module') ? '.cjs' : '.js'; 11 | 12 | builder.addHook('postBuild', async () => { 13 | await builder.dirs.target.write(`tailwind.config${ext}`, resolveConfig(builder)); 14 | 15 | await builder.dirs.target.write(`postcss.config${ext}`, resolvePostCssConfig()); 16 | 17 | await builder.dirs.target.write( 18 | 'app/global.css', 19 | `@tailwind base;\n@tailwind components;\n@tailwind utilities;`, 20 | true, 21 | ); 22 | }); 23 | } 24 | 25 | function resolvePostCssConfig() { 26 | return `module.exports = { 27 | plugins: { 28 | tailwindcss: {}, 29 | autoprefixer: {}, 30 | }, 31 | }; 32 | `; 33 | } 34 | 35 | function resolveConfig(builder: Builder) { 36 | function getExt() { 37 | if (builder.framework === 'svelte') return 'svelte'; 38 | if (builder.framework === 'vue') return 'vue'; 39 | return builder.hasAddon('typescript') ? 'tsx' : 'jsx'; 40 | } 41 | 42 | const ext = getExt(); 43 | const content = [ 44 | `'./app/app.html'`, 45 | `'./app/**/*.{md,${ext}}'`, 46 | `'./app/**/.markdoc/**/*.{md,${ext}}'`, 47 | ]; 48 | 49 | return `/** @type {import('tailwindcss').Config} */ 50 | module.exports = { 51 | darkMode: 'class', 52 | content: [ 53 | ${content.join(',\n ')} 54 | ], 55 | theme: { 56 | extend: {}, 57 | }, 58 | plugins: [], 59 | }; 60 | `; 61 | } 62 | -------------------------------------------------------------------------------- /packages/create/src/prompts.ts: -------------------------------------------------------------------------------- 1 | import enquirer from 'enquirer'; 2 | 3 | import { BUILDER_ADDONS, BuilderAddon, JS_FRAMEWORKS, JSFramework } from './builder'; 4 | 5 | export function overwriteDirectoryPrompt(name?: string): Promise<{ overwrite: boolean }> { 6 | return enquirer.prompt({ 7 | type: 'confirm', 8 | name: 'overwrite', 9 | message: `${name ? `${name} directory` : 'Directory'} exists. Overwrite?`, 10 | initial: false, 11 | }); 12 | } 13 | 14 | export function setupPrompt({ projectName }): Promise<{ 15 | name: string; 16 | framework: JSFramework; 17 | addons: BuilderAddon[]; 18 | }> { 19 | return enquirer.prompt( 20 | [ 21 | { 22 | type: 'input', 23 | name: 'name', 24 | message: 'Project name:', 25 | initial: projectName, 26 | }, 27 | { 28 | type: 'select', 29 | name: 'framework', 30 | message: 'Select a framework:', 31 | initial: 0, 32 | choices: JS_FRAMEWORKS, 33 | }, 34 | { 35 | type: 'multiselect', 36 | name: 'addons', 37 | message: 'Addons:', 38 | choices: BUILDER_ADDONS, 39 | }, 40 | ].filter(Boolean), 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /packages/create/src/utils/obj.ts: -------------------------------------------------------------------------------- 1 | export function sortObjectKeys(obj: any) { 2 | return Object.keys(obj) 3 | .sort() 4 | .reduce((o, key) => { 5 | o[key] = obj[key]; 6 | return o; 7 | }, {}); 8 | } 9 | -------------------------------------------------------------------------------- /packages/create/src/utils/str.ts: -------------------------------------------------------------------------------- 1 | export const removeTrailingSlash = (str: string) => str.replace(/\/$/, ''); 2 | 3 | const WORD_SEPARATORS = /[\s\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,\-./:;<=>?@[\]^_`{|}~]+/; 4 | 5 | export function toTitleCase(str: string) { 6 | const words = str.split(WORD_SEPARATORS); 7 | const len = words.length; 8 | const mappedWords = new Array(len); 9 | 10 | for (let i = 0; i < len; i++) { 11 | const word = words[i]; 12 | if (word === '') continue; 13 | mappedWords[i] = word[0].toUpperCase() + word.slice(1); 14 | } 15 | 16 | return mappedWords.join(' '); 17 | } 18 | -------------------------------------------------------------------------------- /packages/create/template-preact/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vessel-js/vessel/ea32532b0a1b81d61215232c53e08d1e4a4ca13a/packages/create/template-preact/.gitkeep -------------------------------------------------------------------------------- /packages/create/template-shared/app/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /packages/create/template-shared/app/global.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vessel-js/vessel/ea32532b0a1b81d61215232c53e08d1e4a4ca13a/packages/create/template-shared/app/global.css -------------------------------------------------------------------------------- /packages/create/template-shared/app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vessel-js/vessel/ea32532b0a1b81d61215232c53e08d1e4a4ca13a/packages/create/template-shared/app/public/favicon.ico -------------------------------------------------------------------------------- /packages/create/template-shared/app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /packages/create/template-shared/app/server.js: -------------------------------------------------------------------------------- 1 | import { configureServer } from '@vessel-js/app/server'; 2 | 3 | export default configureServer(({ app, router }) => { 4 | router.basePrefix = '/api'; 5 | }); 6 | -------------------------------------------------------------------------------- /packages/create/template-shared/app/server.ts: -------------------------------------------------------------------------------- 1 | import { configureServer } from '@vessel-js/app/server'; 2 | 3 | export default configureServer(({ app, router }) => { 4 | router.basePrefix = '/api'; 5 | }); 6 | -------------------------------------------------------------------------------- /packages/create/template-solid/app/app.jsx: -------------------------------------------------------------------------------- 1 | import './global.css'; 2 | 3 | import { RouteAnnouncer, RouterOutlet } from '@vessel-js/solid'; 4 | 5 | export default function App() { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /packages/create/template-solid/app/app.tsx: -------------------------------------------------------------------------------- 1 | import './global.css'; 2 | 3 | import { RouteAnnouncer, RouterOutlet } from '@vessel-js/solid'; 4 | 5 | export default function App() { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /packages/create/template-solid/app/globals.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | interface ImportMetaEnv { 6 | // Declare client-side environment variables here: `PUBLIC_SOME_KEY=value` 7 | // Access them like so: `import.meta.env.PUBLIC_SOME_KEY` 8 | // Learn more: https://vitejs.dev/guide/env-and-mode.html#env-files 9 | } 10 | 11 | interface ImportMeta { 12 | readonly env: ImportMetaEnv 13 | } 14 | 15 | interface VesselRoutes { 16 | // <-- AUTOGEN_ROUTES_START --> 17 | 1: '/'; 18 | // <-- AUTOGEN_ROUTES_END --> 19 | } 20 | -------------------------------------------------------------------------------- /packages/create/template-solid/app/layout.jsx: -------------------------------------------------------------------------------- 1 | export default function Layout(props) { 2 | return props.children; 3 | } 4 | -------------------------------------------------------------------------------- /packages/create/template-solid/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { type ParentProps } from 'solid-js'; 2 | 3 | export default function Layout(props: ParentProps) { 4 | return props.children; 5 | } 6 | -------------------------------------------------------------------------------- /packages/create/template-solid/app/page.jsx: -------------------------------------------------------------------------------- 1 | import { useHead } from '@vessel-js/solid/head'; 2 | import { createSignal } from 'solid-js'; 3 | 4 | export default function Page() { 5 | const [title, setTitle] = createSignal('Vessel App'); 6 | 7 | useHead({ 8 | title, 9 | }); 10 | 11 | return ( 12 | <> 13 |

Vessel + Solid JS

14 | Welcome to your home page. 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /packages/create/template-solid/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { useHead } from '@vessel-js/solid/head'; 2 | import { createSignal } from 'solid-js'; 3 | 4 | export default function Page() { 5 | const [title, setTitle] = createSignal('Vessel App'); 6 | 7 | useHead({ 8 | title, 9 | }); 10 | 11 | return ( 12 | <> 13 |

Vessel + Solid JS

14 | Welcome to your home page. 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /packages/create/template-solid/vite.config.js: -------------------------------------------------------------------------------- 1 | import { vessel } from '@vessel-js/app/node'; 2 | import { vesselSolid } from '@vessel-js/solid/node'; 3 | import { defineConfig } from 'vite'; 4 | import solid from 'vite-plugin-solid'; 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | vessel(), 9 | vesselSolid(), 10 | solid({ 11 | ssr: true, 12 | extensions: ['.md'], 13 | }), 14 | ], 15 | }); 16 | -------------------------------------------------------------------------------- /packages/create/template-svelte/app/app.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /packages/create/template-svelte/app/globals.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | interface ImportMetaEnv { 6 | // Declare client-side environment variables here: `PUBLIC_SOME_KEY=value` 7 | // Access them like so: `import.meta.env.PUBLIC_SOME_KEY` 8 | // Learn more: https://vitejs.dev/guide/env-and-mode.html#env-files 9 | } 10 | 11 | interface ImportMeta { 12 | readonly env: ImportMetaEnv 13 | } 14 | 15 | interface VesselRoutes { 16 | // <-- AUTOGEN_ROUTES_START --> 17 | 1: '/'; 18 | // <-- AUTOGEN_ROUTES_END --> 19 | } 20 | -------------------------------------------------------------------------------- /packages/create/template-svelte/app/layout.svelte: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /packages/create/template-svelte/app/page.svelte: -------------------------------------------------------------------------------- 1 | 2 | Vessel App 3 | 4 | 5 |

Vessel + Svelte

6 | 7 | Welcome to your home page. 8 | -------------------------------------------------------------------------------- /packages/create/template-svelte/svelte.config.js: -------------------------------------------------------------------------------- 1 | import preprocess from 'svelte-preprocess'; 2 | 3 | export default { 4 | // Consult https://github.com/sveltejs/svelte-preprocess 5 | // for more information about preprocessors 6 | preprocess: preprocess(), 7 | }; 8 | -------------------------------------------------------------------------------- /packages/create/template-svelte/vite.config.js: -------------------------------------------------------------------------------- 1 | import { svelte } from '@sveltejs/vite-plugin-svelte'; 2 | import { vessel } from '@vessel-js/app/node'; 3 | import { vesselSvelte } from '@vessel-js/svelte/node'; 4 | import { defineConfig } from 'vite'; 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | vessel(), 9 | vesselSvelte(), 10 | svelte({ 11 | extensions: ['.svelte', '.md'], 12 | compilerOptions: { 13 | hydratable: true, 14 | }, 15 | }), 16 | ], 17 | }); 18 | -------------------------------------------------------------------------------- /packages/create/template-vue/app/app.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 11 | -------------------------------------------------------------------------------- /packages/create/template-vue/app/globals.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | interface ImportMetaEnv { 6 | // Declare client-side environment variables here: `PUBLIC_SOME_KEY=value` 7 | // Access them like so: `import.meta.env.PUBLIC_SOME_KEY` 8 | // Learn more: https://vitejs.dev/guide/env-and-mode.html#env-files 9 | } 10 | 11 | interface ImportMeta { 12 | readonly env: ImportMetaEnv 13 | } 14 | 15 | interface VesselRoutes { 16 | // <-- AUTOGEN_ROUTES_START --> 17 | 1: '/'; 18 | // <-- AUTOGEN_ROUTES_END --> 19 | } 20 | -------------------------------------------------------------------------------- /packages/create/template-vue/app/layout.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /packages/create/template-vue/app/page.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /packages/create/template-vue/vite.config.js: -------------------------------------------------------------------------------- 1 | import { vessel } from '@vessel-js/app/node'; 2 | import { vesselVue } from '@vessel-js/vue/node'; 3 | import vue from '@vitejs/plugin-vue'; 4 | import { defineConfig } from 'vite'; 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | vessel(), 9 | vesselVue(), 10 | vue({ 11 | include: [/\.vue$/, /\.md$/], 12 | }), 13 | ], 14 | }); 15 | -------------------------------------------------------------------------------- /packages/create/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/create/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig([ 4 | { 5 | format: 'esm', 6 | entry: { cli: 'src/cli.ts' }, 7 | target: 'node16', 8 | platform: 'node', 9 | bundle: true, 10 | outDir: 'dist', 11 | }, 12 | ]); 13 | -------------------------------------------------------------------------------- /packages/solid/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Rahim Alwer 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/solid/README.md: -------------------------------------------------------------------------------- 1 | # Vessel Solid 2 | 3 | This package adds [Solid JS](https://www.solidjs.com) support to Vessel. 4 | 5 | ```bash 6 | npm install @vessel-js/solid 7 | ``` 8 | 9 | ```js 10 | // vite.config.js 11 | import { vessel } from '@vessel-js/app/node'; 12 | import { vesselSolid } from '@vessel-js/solid/node'; 13 | import { defineConfig } from 'vite'; 14 | import solid from 'vite-plugin-solid'; 15 | 16 | export default defineConfig({ 17 | plugins: [ 18 | vessel(), 19 | vesselSolid(), 20 | solid({ 21 | ssr: true, 22 | extensions: ['.md'], 23 | }), 24 | ], 25 | }); 26 | ``` 27 | -------------------------------------------------------------------------------- /packages/solid/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.md' { 2 | const meta: import('@vessel-js/app').MarkdownMeta; 3 | export { meta }; 4 | // eslint-disable-next-line @typescript-eslint/ban-types 5 | const component: import('solid-js').ParentComponent; 6 | export default component; 7 | } 8 | -------------------------------------------------------------------------------- /packages/solid/head.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/client/head/index.js'; 2 | -------------------------------------------------------------------------------- /packages/solid/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/client/index.js'; 2 | -------------------------------------------------------------------------------- /packages/solid/node.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/node/index.js'; 2 | -------------------------------------------------------------------------------- /packages/solid/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vessel-js/solid", 3 | "version": "0.3.3", 4 | "type": "module", 5 | "types": "index.d.ts", 6 | "sideEffects": false, 7 | "license": "MIT", 8 | "contributors": [ 9 | "Rahim Alwer " 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/vessel-js/vessel.git", 14 | "directory": "packages/solid" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/vessel-js/vessel/issues" 18 | }, 19 | "files": [ 20 | "dist/", 21 | "*.d.ts" 22 | ], 23 | "exports": { 24 | ".": "./dist/client/index.ts", 25 | "./head": "./dist/client/head/index.ts", 26 | "./node": "./dist/node/index.js", 27 | "./*": "./dist/client/*", 28 | "./package.json": "./package.json" 29 | }, 30 | "scripts": { 31 | "dev": "DEV=true pnpm run build --watch", 32 | "build": "rimraf dist && tsup" 33 | }, 34 | "dependencies": { 35 | "globby": "^13.0.0", 36 | "pathe": "^1.0.0" 37 | }, 38 | "peerDependencies": { 39 | "@vessel-js/app": "workspace:*", 40 | "solid-js": "^1.7.0" 41 | }, 42 | "devDependencies": { 43 | "@types/node": "^18.0.0", 44 | "@vessel-js/app": "workspace:*", 45 | "fast-glob": "^3.2.0", 46 | "solid-js": "^1.7.0", 47 | "tsup": "^6.7.0", 48 | "typescript": "^5.0.0", 49 | "vite": "^4.2.0" 50 | }, 51 | "engines": { 52 | "node": ">=16" 53 | }, 54 | "publishConfig": { 55 | "access": "public" 56 | }, 57 | "keywords": [ 58 | "app", 59 | "docs", 60 | "edge", 61 | "esm", 62 | "fast", 63 | "lightweight", 64 | "modern", 65 | "solid", 66 | "solid-js", 67 | "ssg", 68 | "ssr", 69 | "vercel", 70 | "vessel", 71 | "vite" 72 | ] 73 | } 74 | -------------------------------------------------------------------------------- /packages/solid/src/client/+app.tsx: -------------------------------------------------------------------------------- 1 | import RouteAnnouncer from './RouteAnnouncer'; 2 | import RouterOutlet from './RouterOutlet'; 3 | 4 | function App() { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | export default App; 14 | -------------------------------------------------------------------------------- /packages/solid/src/client/DevErrorFallback.tsx: -------------------------------------------------------------------------------- 1 | function DevErrorFallback(props) { 2 | return ( 3 |
4 | 5 | Error 6 | 7 | 8 | {props.error.stack && ( 9 |
13 |           {props.error.stack}
14 |         
15 | )} 16 | 17 | 18 |
19 | ); 20 | } 21 | 22 | export default DevErrorFallback; 23 | -------------------------------------------------------------------------------- /packages/solid/src/client/Link.tsx: -------------------------------------------------------------------------------- 1 | import type { ParentComponent } from 'solid-js'; 2 | 3 | export interface LinkProps { 4 | /** 5 | * Specifies the URL of the linked resource. 6 | */ 7 | href: VesselRoutes[keyof VesselRoutes] | URL; 8 | /** 9 | * Whether this route should begin prefetching if the user is about to interact with the link. 10 | * 11 | * @defaultValue true 12 | */ 13 | prefetch?: boolean; 14 | /** 15 | * Replace the current history instead of pushing a new URL on to the stack. 16 | * 17 | * @defaultValue false 18 | */ 19 | replace?: boolean; 20 | } 21 | 22 | const Link: ParentComponent = (props) => { 23 | return ( 24 |
30 | {props.children} 31 | 32 | ); 33 | }; 34 | 35 | export default Link; 36 | -------------------------------------------------------------------------------- /packages/solid/src/client/ProdErrorFallback.tsx: -------------------------------------------------------------------------------- 1 | function ProdErrorFallback() { 2 | return
Loading failed - try reloading page.
; 3 | } 4 | 5 | export default ProdErrorFallback; 6 | -------------------------------------------------------------------------------- /packages/solid/src/client/RouteAnnouncer.tsx: -------------------------------------------------------------------------------- 1 | import { createEffect, createSignal, onMount } from 'solid-js'; 2 | 3 | import { useRoute } from './context'; 4 | 5 | function RouteAnnouncer() { 6 | const [title, setTitle] = createSignal(); 7 | const [mounted, setMounted] = createSignal(false); 8 | const [navigated, setNavigated] = createSignal(false); 9 | const route = useRoute(); 10 | 11 | onMount(() => { 12 | setMounted(true); 13 | }); 14 | 15 | createEffect(() => { 16 | if (mounted() && route()) { 17 | setNavigated(true); 18 | setTitle(document.title || 'untitled page'); 19 | } 20 | }); 21 | 22 | return () => 23 | mounted() ? ( 24 |
30 | {navigated() ? title() : null} 31 |
32 | ) : null; 33 | } 34 | 35 | export default RouteAnnouncer; 36 | -------------------------------------------------------------------------------- /packages/solid/src/client/RouteComponent.tsx: -------------------------------------------------------------------------------- 1 | import type { ClientLoadedRoute } from '@vessel-js/app'; 2 | import { 3 | createComponent, 4 | createEffect, 5 | createMemo, 6 | createSignal, 7 | type ParentComponent, 8 | } from 'solid-js'; 9 | 10 | import { useVesselContext } from './context'; 11 | import { SERVER_DATA_KEY, SERVER_ERROR_KEY, STATIC_DATA_KEY } from './context-keys'; 12 | 13 | interface RouteProps { 14 | component?: ClientLoadedRoute['page']; 15 | leaf?: boolean; 16 | } 17 | 18 | const RouteComponent: ParentComponent = (props) => { 19 | const context = useVesselContext(); 20 | 21 | const resolveStaticData = () => props.component?.staticData ?? {}; 22 | const resolveServerData = () => props.component?.serverData; 23 | const resolveServerError = () => props.component?.serverLoadError; 24 | 25 | const [staticData, setStaticData] = createSignal(resolveStaticData()); 26 | const [serverData, setServerData] = createSignal(resolveServerData()); 27 | const [serverError, setServerError] = createSignal(resolveServerError()); 28 | 29 | context.set(STATIC_DATA_KEY, staticData); 30 | context.set(SERVER_DATA_KEY, serverData); 31 | context.set(SERVER_ERROR_KEY, serverError); 32 | 33 | createEffect(() => { 34 | setStaticData(resolveStaticData()); 35 | setServerData(resolveServerData()); 36 | setServerError(resolveServerError()); 37 | }); 38 | 39 | return createMemo(() => 40 | props.component 41 | ? createComponent(props.component.module.default, { 42 | children: !props.leaf ? props.children : null, 43 | }) 44 | : !props.leaf && props.children, 45 | ); 46 | }; 47 | 48 | export default RouteComponent; 49 | -------------------------------------------------------------------------------- /packages/solid/src/client/RouteErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import type { ClientLoadedRoute } from '@vessel-js/app'; 2 | import { 3 | createComponent, 4 | createEffect, 5 | createMemo, 6 | createSignal, 7 | onError, 8 | type ParentComponent, 9 | } from 'solid-js'; 10 | 11 | import DevErrorFallback from './DevErrorFallback'; 12 | import ProdErrorFallback from './ProdErrorFallback'; 13 | 14 | export interface ErrorBoundaryProps { 15 | error: unknown; 16 | reset(): void; 17 | } 18 | 19 | interface RouteErrorBoundaryProps { 20 | error?: ClientLoadedRoute['error']; 21 | boundary?: ClientLoadedRoute['errorBoundary']; 22 | } 23 | 24 | const RouteErrorBoundary: ParentComponent = (props) => { 25 | const [loadError, setLoadError] = createSignal(props.error); 26 | const [renderError, setRenderError] = createSignal(); 27 | 28 | createEffect(() => { 29 | setLoadError(props.error); 30 | }); 31 | 32 | const Fallback = createMemo( 33 | () => 34 | props.boundary?.module.default ?? 35 | (import.meta.env.DEV ? DevErrorFallback : ProdErrorFallback), 36 | ); 37 | 38 | onError((error) => { 39 | setRenderError(error); 40 | }); 41 | 42 | function reset() { 43 | // TODO: should we try and reload route? 44 | // loadError.value = null; 45 | setRenderError(null); 46 | } 47 | 48 | const error = () => renderError() ?? loadError(); 49 | 50 | return createMemo(() => 51 | error() 52 | ? createComponent(Fallback(), { 53 | get error() { 54 | return error(); 55 | }, 56 | reset, 57 | }) 58 | : props.children, 59 | ); 60 | }; 61 | 62 | export default RouteErrorBoundary; 63 | -------------------------------------------------------------------------------- /packages/solid/src/client/RouteSegment.tsx: -------------------------------------------------------------------------------- 1 | import { type ClientLoadedRoute } from '@vessel-js/app'; 2 | import { type ParentComponent } from 'solid-js'; 3 | 4 | import { useVesselContext } from './context'; 5 | import { ROUTE_PARAMS_KEY } from './context-keys'; 6 | import RouteComponent from './RouteComponent'; 7 | import RouteErrorBoundary from './RouteErrorBoundary'; 8 | 9 | type RouteSegmentProps = { 10 | matches: ClientLoadedRoute[]; 11 | depth: number; 12 | }; 13 | 14 | const RouteSegment: ParentComponent = (props) => { 15 | const match = () => props.matches[props.depth]; 16 | const params = () => match().params; 17 | 18 | const context = useVesselContext(); 19 | context.set(ROUTE_PARAMS_KEY, params); 20 | 21 | return ( 22 | 23 | 24 | {props.depth < props.matches.length - 1 ? ( 25 | 26 | ) : ( 27 | 28 | )} 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default RouteSegment; 35 | -------------------------------------------------------------------------------- /packages/solid/src/client/RouterOutlet.tsx: -------------------------------------------------------------------------------- 1 | import { useRouteMatches } from './context'; 2 | import RouteSegment from './RouteSegment'; 3 | 4 | function RouterOutlet() { 5 | const matches = useRouteMatches(); 6 | return ; 7 | } 8 | 9 | export default RouterOutlet; 10 | -------------------------------------------------------------------------------- /packages/solid/src/client/context-keys.ts: -------------------------------------------------------------------------------- 1 | export const ROUTER_KEY = Symbol(); 2 | export const ROUTE_KEY = Symbol(); 3 | export const ROUTE_MATCHES_KEY = Symbol(); 4 | export const ROUTE_PARAMS_KEY = Symbol(); 5 | export const NAVIGATION_KEY = Symbol(); 6 | export const MARKDOWN_KEY = Symbol(); 7 | export const FRONTMATTER_KEY = Symbol(); 8 | export const STATIC_DATA_KEY = Symbol(); 9 | export const SERVER_DATA_KEY = Symbol(); 10 | export const SERVER_ERROR_KEY = Symbol(); 11 | export const HEAD_MANAGER = Symbol(); 12 | -------------------------------------------------------------------------------- /packages/solid/src/client/entry-client.tsx: -------------------------------------------------------------------------------- 1 | import App from ':virtual/vessel/solid/app'; 2 | import { init, tick } from '@vessel-js/app'; 3 | import { hydrate } from 'solid-js/web'; 4 | 5 | import { createContext, VesselContext } from './context'; 6 | import { ROUTER_KEY } from './context-keys'; 7 | 8 | async function main() { 9 | const { context, ...delegate } = createContext(); 10 | 11 | const router = await init({ 12 | frameworkDelegate: { 13 | tick, 14 | ...delegate, 15 | }, 16 | }); 17 | 18 | context.set(ROUTER_KEY, router); 19 | 20 | await router.start((target) => { 21 | hydrate( 22 | () => ( 23 | 24 | 25 | 26 | ), 27 | target, 28 | ); 29 | }); 30 | } 31 | 32 | main(); 33 | -------------------------------------------------------------------------------- /packages/solid/src/client/entry-server.tsx: -------------------------------------------------------------------------------- 1 | import App from ':virtual/vessel/solid/app'; 2 | import { renderHeadToString } from '@vessel-js/app/head'; 3 | import type { ServerRenderer } from '@vessel-js/app/server'; 4 | import { generateHydrationScript, renderToString } from 'solid-js/web'; 5 | 6 | import { createContext, VesselContext } from './context'; 7 | import { ROUTER_KEY } from './context-keys'; 8 | 9 | export const render: ServerRenderer = async ({ route, matches, router }) => { 10 | const { context, headManager, ...delegate } = createContext(); 11 | context.set(ROUTER_KEY, router); 12 | 13 | delegate.route.set(route); 14 | delegate.matches.set(matches); 15 | 16 | const html = await renderToString(() => ( 17 | 18 | 19 | 20 | )); 21 | 22 | const headSSR = renderHeadToString(headManager); 23 | 24 | return { 25 | ...headSSR, 26 | head: headSSR.head + generateHydrationScript(), 27 | html, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /packages/solid/src/client/head/index.ts: -------------------------------------------------------------------------------- 1 | export { useHead } from './use-head'; 2 | -------------------------------------------------------------------------------- /packages/solid/src/client/head/use-head.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Credit: https://github.com/vueuse/head 3 | */ 4 | 5 | import { type HeadConfig, type HeadManager } from '@vessel-js/app/head'; 6 | import { createEffect, onCleanup } from 'solid-js'; 7 | 8 | import { useVesselContext } from '../context'; 9 | import { HEAD_MANAGER } from '../context-keys'; 10 | 11 | export function useHeadManager() { 12 | return useVesselContext().get(HEAD_MANAGER) as HeadManager; 13 | } 14 | 15 | export const useHead = (config: HeadConfig) => { 16 | const manager = useHeadManager(); 17 | manager.add(config); 18 | 19 | if (!import.meta.env.SSR) { 20 | createEffect(() => { 21 | manager.update(); 22 | }); 23 | 24 | onCleanup(() => { 25 | manager.remove(config); 26 | manager.update(); 27 | }); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /packages/solid/src/client/index.ts: -------------------------------------------------------------------------------- 1 | export { default as App } from './+app'; 2 | export * from './context'; 3 | export { default as Link, type LinkProps } from './Link'; 4 | export { default as RouteAnnouncer } from './RouteAnnouncer'; 5 | export { type ErrorBoundaryProps } from './RouteErrorBoundary'; 6 | export { default as RouterOutlet } from './RouterOutlet'; 7 | -------------------------------------------------------------------------------- /packages/solid/src/globals.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /packages/solid/src/node/index.ts: -------------------------------------------------------------------------------- 1 | export { solidPlugin as default, solidPlugin as vesselSolid } from './solid-plugin'; 2 | export * from './solid-plugin'; 3 | -------------------------------------------------------------------------------- /packages/solid/src/node/solid-plugin.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { fileURLToPath } from 'node:url'; 3 | 4 | import { VM_PREFIX, type VesselPlugins } from '@vessel-js/app/node'; 5 | import { globbySync } from 'globby'; 6 | import * as path from 'pathe'; 7 | 8 | import { renderMarkdoc, solidMarkdocTags, transformTreeNode } from './markdoc'; 9 | 10 | const VIRTUAL_APP_ID = `${VM_PREFIX}/solid/app` as const; 11 | 12 | export function solidPlugin(): VesselPlugins { 13 | let appDir: string; 14 | 15 | const __dirname = fileURLToPath(new URL('.', import.meta.url)); 16 | 17 | function resolveAppId() { 18 | const exts = ['tsx', 'jsx', 'ts', 'js'].join(', '); 19 | const file = globbySync([`app.{${exts}}`, `+app.{${exts}}`], { 20 | cwd: appDir, 21 | })[0]; 22 | const filePath = file && path.resolve(appDir, file); 23 | return filePath && fs.existsSync(filePath) 24 | ? { id: filePath } 25 | : { id: path.resolve(__dirname, '../client/+app.tsx') }; 26 | } 27 | 28 | return [ 29 | { 30 | name: '@vessel/solid', 31 | enforce: 'pre', 32 | config() { 33 | return { 34 | optimizeDeps: { 35 | include: ['solid-js', 'solid-js/web'], 36 | extensions: ['jsx', 'tsx'], 37 | }, 38 | resolve: { 39 | alias: { 40 | [VIRTUAL_APP_ID]: `/${VIRTUAL_APP_ID}`, 41 | }, 42 | dedupe: ['solid-js', 'solid-js/web'], 43 | }, 44 | }; 45 | }, 46 | vessel: { 47 | enforce: 'pre', 48 | config(config) { 49 | appDir = config.dirs.app; 50 | return { 51 | entry: { 52 | client: path.resolve(__dirname, '../client/entry-client.tsx'), 53 | server: path.resolve(__dirname, '../client/entry-server.tsx'), 54 | }, 55 | client: { 56 | app: resolveAppId().id, 57 | }, 58 | markdown: { 59 | markdoc: { tags: solidMarkdocTags }, 60 | render: renderMarkdoc, 61 | transformTreeNode: [transformTreeNode], 62 | }, 63 | }; 64 | }, 65 | }, 66 | resolveId(id) { 67 | if (id === `/${VIRTUAL_APP_ID}`) { 68 | return resolveAppId(); 69 | } 70 | 71 | return null; 72 | }, 73 | }, 74 | ]; 75 | } 76 | -------------------------------------------------------------------------------- /packages/solid/src/virtual/app.d.ts: -------------------------------------------------------------------------------- 1 | declare module ':virtual/vessel/solid/app' { 2 | declare const App: () => import('solid-js').JSX.Element; 3 | export default App; 4 | } 5 | -------------------------------------------------------------------------------- /packages/solid/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "baseUrl": "./src", 5 | "outDir": "dist", 6 | "declarationDir": "types", 7 | "jsx": "preserve", 8 | "jsxImportSource": "solid-js", 9 | "paths": { 10 | ":virtual/vessel/solid/app": ["src/virtual/app"] 11 | } 12 | }, 13 | "include": ["src"] 14 | } 15 | -------------------------------------------------------------------------------- /packages/solid/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { watch } from 'chokidar'; 2 | import { defineConfig } from 'tsup'; 3 | 4 | import { base, copyFiles } from '../app/tsup.config'; 5 | 6 | if (process.env.DEV) { 7 | await copyFiles('**/*'); 8 | 9 | watch('src/client/**/*').on('all', async () => { 10 | await copyFiles('**/*'); 11 | }); 12 | } else { 13 | await copyFiles('**/*'); 14 | } 15 | 16 | export default defineConfig([ 17 | { 18 | ...base(), 19 | entry: { index: 'src/node/index.ts' }, 20 | target: 'node16', 21 | platform: 'node', 22 | outDir: 'dist/node', 23 | }, 24 | ]); 25 | -------------------------------------------------------------------------------- /packages/svelte/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Rahim Alwer 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/svelte/README.md: -------------------------------------------------------------------------------- 1 | # Vessel Svelte 2 | 3 | This package adds [Svelte](https://svelte.dev) support to Vessel. 4 | 5 | ```bash 6 | npm install @vessel-js/svelte 7 | ``` 8 | 9 | ```js 10 | // vite.config.js 11 | import { svelte } from '@sveltejs/vite-plugin-svelte'; 12 | import { vessel } from '@vessel-js/app/node'; 13 | import { vesselSvelte } from '@vessel-js/svelte/node'; 14 | import { defineConfig } from 'vite'; 15 | 16 | export default defineConfig({ 17 | plugins: [ 18 | vessel(), 19 | vesselSvelte(), 20 | svelte({ 21 | extensions: ['.svelte', '.md'], 22 | compilerOptions: { 23 | hydratable: true, 24 | }, 25 | }), 26 | ], 27 | }); 28 | ``` 29 | -------------------------------------------------------------------------------- /packages/svelte/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svelte' { 2 | const component: typeof import('svelte').SvelteComponent; 3 | export default component; 4 | } 5 | 6 | declare module '*.md' { 7 | const meta: import('@vessel-js/app').MarkdownMeta; 8 | export { meta }; 9 | const component: typeof import('svelte').SvelteComponent; 10 | export default component; 11 | } 12 | -------------------------------------------------------------------------------- /packages/svelte/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/client/index.js'; 2 | -------------------------------------------------------------------------------- /packages/svelte/node.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/node/index.js'; 2 | -------------------------------------------------------------------------------- /packages/svelte/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vessel-js/svelte", 3 | "version": "0.3.3", 4 | "type": "module", 5 | "types": "index.d.ts", 6 | "sideEffects": false, 7 | "license": "MIT", 8 | "svelte": "./dist/client/index.js", 9 | "contributors": [ 10 | "Rahim Alwer " 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/vessel-js/vessel.git", 15 | "directory": "packages/svelte" 16 | }, 17 | "bugs": { 18 | "url": "https://github.com/vessel-js/vessel/issues" 19 | }, 20 | "files": [ 21 | "dist/", 22 | "*.d.ts" 23 | ], 24 | "exports": { 25 | ".": "./dist/client/index.js", 26 | "./node": "./dist/node/index.js", 27 | "./*": "./dist/client/*", 28 | "./package.json": "./package.json" 29 | }, 30 | "scripts": { 31 | "dev": "DEV=true pnpm run build --watch", 32 | "build": "rimraf dist && tsup" 33 | }, 34 | "dependencies": { 35 | "globby": "^13.0.0", 36 | "pathe": "^1.0.0" 37 | }, 38 | "peerDependencies": { 39 | "@vessel-js/app": "workspace:*", 40 | "svelte": "^3.55.0" 41 | }, 42 | "devDependencies": { 43 | "@types/node": "^18.0.0", 44 | "@vessel-js/app": "workspace:*", 45 | "fast-glob": "^3.2.0", 46 | "svelte": "^3.58.0", 47 | "tsup": "^6.7.0", 48 | "typescript": "^5.0.0", 49 | "vite": "^4.0.0" 50 | }, 51 | "engines": { 52 | "node": ">=16" 53 | }, 54 | "publishConfig": { 55 | "access": "public" 56 | }, 57 | "keywords": [ 58 | "app", 59 | "docs", 60 | "edge", 61 | "esm", 62 | "fast", 63 | "lightweight", 64 | "modern", 65 | "ssg", 66 | "ssr", 67 | "svelte", 68 | "vercel", 69 | "vessel", 70 | "vite" 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /packages/svelte/src/client/+app.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /packages/svelte/src/client/DevErrorFallback.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
10 | Error 11 | 12 | {#if error.stack} 13 |
{error.stack}
16 | {/if} 17 | 18 | 21 |
22 | -------------------------------------------------------------------------------- /packages/svelte/src/client/ErrorBoundary.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Adapted from: https://github.com/CrownFramework/svelte-error-boundary 3 | */ 4 | 5 | import { writable } from 'svelte/store'; 6 | 7 | import RouteErrorBoundary from './RouteErrorBoundary.svelte'; 8 | 9 | export default createErrorBoundary(RouteErrorBoundary); 10 | 11 | // https://github.com/sveltejs/svelte/blob/master/src/runtime/internal/Component.ts#L13 12 | const LIFECYCLE_METHODS = ['c', 'l', 'h', 'm', 'p', 'r', 'f', 'a', 'i', 'o', 'd']; 13 | 14 | function createErrorBoundary(Component) { 15 | if (import.meta.env.SSR) { 16 | if (Component.$$render) { 17 | const render = Component.$$render; 18 | 19 | Component.$$render = (result, props, bindings, slots) => { 20 | const renderError = writable(undefined); 21 | 22 | try { 23 | return render(result, { renderError, ...props }, bindings, slots); 24 | } catch (e) { 25 | renderError.set(e as Error); 26 | return render(result, { renderError, ...props }, bindings, {}); 27 | } 28 | }; 29 | 30 | return Component; 31 | } 32 | } 33 | 34 | return class SvelteErrorBoundaryComponent extends Component { 35 | constructor(config) { 36 | const renderError = writable(undefined); 37 | config.props.renderError = renderError; 38 | 39 | const slots = config.props.$$slots; 40 | const defaultSlot = slots.default[0]; 41 | slots.default[0] = (...args) => { 42 | const guarded = createTryFn(defaultSlot, renderError.set); 43 | const block = guarded(...args); 44 | 45 | if (block) { 46 | for (const fn of LIFECYCLE_METHODS) { 47 | if (block[fn]) block[fn] = createTryFn(block[fn], renderError.set); 48 | } 49 | } 50 | 51 | return block; 52 | }; 53 | 54 | super(config); 55 | } 56 | }; 57 | } 58 | 59 | function createTryFn(fn, onError) { 60 | return function tryFn(...args) { 61 | try { 62 | return fn(...args); 63 | } catch (error) { 64 | onError(error); 65 | } 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /packages/svelte/src/client/Link.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /packages/svelte/src/client/ProdErrorFallback.svelte: -------------------------------------------------------------------------------- 1 |
Loading failed - try reloading page.
2 | -------------------------------------------------------------------------------- /packages/svelte/src/client/RouteAnnouncer.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | {#if mounted} 30 |
36 | {#if navigated} 37 | {title} 38 | {/if} 39 |
40 | {/if} 41 | -------------------------------------------------------------------------------- /packages/svelte/src/client/RouteComponent.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | {#if component} 28 | {#if !leaf} 29 | 30 | 31 | 32 | {:else} 33 | 34 | {/if} 35 | {:else if !leaf} 36 | 37 | {/if} 38 | -------------------------------------------------------------------------------- /packages/svelte/src/client/RouteErrorBoundary.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | {#if err} 23 | 24 | {:else} 25 | 26 | {/if} 27 | -------------------------------------------------------------------------------- /packages/svelte/src/client/RouteSegment.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | {#if depth < matches.length - 1} 22 | 23 | {:else} 24 | 25 | {/if} 26 | 27 | 28 | -------------------------------------------------------------------------------- /packages/svelte/src/client/RouterOutlet.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /packages/svelte/src/client/context-keys.ts: -------------------------------------------------------------------------------- 1 | export const ROUTER_KEY = Symbol(); 2 | export const ROUTE_KEY = Symbol(); 3 | export const ROUTE_PARAMS_KEY = Symbol(); 4 | export const ROUTE_MATCHES_KEY = Symbol(); 5 | export const NAVIGATION_KEY = Symbol(); 6 | export const MARKDOWN_KEY = Symbol(); 7 | export const FRONTMATTER_KEY = Symbol(); 8 | export const STATIC_DATA_KEY = Symbol(); 9 | export const SERVER_DATA_KEY = Symbol(); 10 | export const SERVER_ERROR_KEY = Symbol(); 11 | -------------------------------------------------------------------------------- /packages/svelte/src/client/entry-client.ts: -------------------------------------------------------------------------------- 1 | import App from ':virtual/vessel/svelte/app'; 2 | import { init } from '@vessel-js/app'; 3 | import { tick } from 'svelte'; 4 | 5 | import type { SvelteModule } from '../shared'; 6 | import { createContext } from './context'; 7 | import { ROUTER_KEY } from './context-keys'; 8 | 9 | async function main() { 10 | const { context, ...delegate } = createContext(); 11 | 12 | const router = await init({ 13 | frameworkDelegate: { tick, ...delegate }, 14 | }); 15 | 16 | context.set(ROUTER_KEY, router); 17 | 18 | await router.start((target) => { 19 | new (App as SvelteModule['default'])({ target, context, hydrate: true }); 20 | }); 21 | } 22 | 23 | main(); 24 | -------------------------------------------------------------------------------- /packages/svelte/src/client/entry-server.ts: -------------------------------------------------------------------------------- 1 | import App from ':virtual/vessel/svelte/app'; 2 | import type { ServerRenderer } from '@vessel-js/app/server'; 3 | 4 | import type { SvelteServerModule } from 'node'; 5 | 6 | import { createContext } from './context'; 7 | import { ROUTER_KEY } from './context-keys'; 8 | 9 | export const render: ServerRenderer = async ({ route, matches, router }) => { 10 | const { context, ...delegate } = createContext(); 11 | context.set(ROUTER_KEY, router); 12 | 13 | delegate.route.set(route); 14 | delegate.matches.set(matches); 15 | 16 | const ssr = (App as SvelteServerModule['default']).render({}, { context }); 17 | 18 | return { 19 | head: ssr.head, 20 | html: ssr.html, 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /packages/svelte/src/client/index.ts: -------------------------------------------------------------------------------- 1 | export * from '../shared'; 2 | export { default as App } from './+app.svelte'; 3 | export * from './context'; 4 | export { default as Link } from './Link.svelte'; 5 | export { default as RouteAnnouncer } from './RouteAnnouncer.svelte'; 6 | export { default as RouterOutlet } from './RouterOutlet.svelte'; 7 | export * from './stores'; 8 | -------------------------------------------------------------------------------- /packages/svelte/src/client/stores.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ClientLoadedRoute, 3 | MarkdownFrontmatter, 4 | MarkdownMeta, 5 | Navigation, 6 | } from '@vessel-js/app'; 7 | import type { HttpError, HttpErrorData } from '@vessel-js/app/http'; 8 | import type { LoadedServerData, LoadedStaticData, RouteParams } from '@vessel-js/app/routing'; 9 | import type { 10 | InferServerLoaderData, 11 | InferStaticLoaderData, 12 | ServerLoader, 13 | StaticLoader, 14 | } from '@vessel-js/app/server'; 15 | import type { Readable } from 'svelte/store'; 16 | 17 | import { 18 | useFrontmatter, 19 | useMarkdown, 20 | useNavigation, 21 | useRoute, 22 | useRouteMatches, 23 | useRouteParams, 24 | useServerData, 25 | useServerError, 26 | useStaticData, 27 | } from './context'; 28 | 29 | export interface NavigationStore extends Readable {} 30 | export const navigation: NavigationStore = toStore(useNavigation); 31 | 32 | export interface RouteStore extends Readable {} 33 | export const route: RouteStore = toStore(useRoute); 34 | 35 | export interface RouteParamsStore extends Readable {} 36 | export const params: RouteParamsStore = toStore(useRouteParams); 37 | 38 | export interface RouteMatchesStore extends Readable {} 39 | export const matches: RouteMatchesStore = toStore(useRouteMatches); 40 | 41 | export interface MarkdownStore extends Readable {} 42 | export const markdown: MarkdownStore = toStore(useMarkdown); 43 | 44 | export interface FrontmatterStore 45 | extends Readable {} 46 | 47 | export const frontmatter: FrontmatterStore = toStore(useFrontmatter); 48 | 49 | export interface StaticDataStore 50 | extends Readable> {} 51 | 52 | export const staticData: StaticDataStore = toStore(useStaticData); 53 | 54 | export interface ServerDataStore 55 | extends Readable> {} 56 | 57 | export const serverData: ServerDataStore = toStore(useServerData); 58 | 59 | export interface ServerErrorStore 60 | extends Readable> {} 61 | 62 | export const serverError: ServerErrorStore = toStore(useServerError); 63 | 64 | function toStore(getContext: () => Readable): Readable { 65 | return { subscribe: (fn) => getContext().subscribe(fn) }; 66 | } 67 | -------------------------------------------------------------------------------- /packages/svelte/src/globals.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | declare module '*.svelte' { 5 | const component: typeof import('svelte').SvelteComponent; 6 | export default component; 7 | } 8 | -------------------------------------------------------------------------------- /packages/svelte/src/node/index.ts: -------------------------------------------------------------------------------- 1 | export * from '../shared'; 2 | export { sveltePlugin as default, sveltePlugin as vesselSvelte } from './svelte-plugin'; 3 | export * from './svelte-plugin'; 4 | -------------------------------------------------------------------------------- /packages/svelte/src/node/svelte-plugin.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { fileURLToPath } from 'node:url'; 3 | 4 | import { VM_PREFIX, type VesselPlugins } from '@vessel-js/app/node'; 5 | import { globbySync } from 'globby'; 6 | import * as path from 'pathe'; 7 | 8 | import { renderMarkdoc, svelteMarkdocTags, transformTreeNode } from './markdoc'; 9 | 10 | const VIRTUAL_APP_ID = `${VM_PREFIX}/svelte/app` as const; 11 | 12 | export function sveltePlugin(): VesselPlugins { 13 | let appDir: string; 14 | 15 | const __dirname = fileURLToPath(new URL('.', import.meta.url)); 16 | 17 | function resolveAppId() { 18 | const file = globbySync([`app.svelte`, `+app.svelte`], { 19 | cwd: appDir, 20 | })[0]; 21 | const filePath = file && path.resolve(appDir, file); 22 | return filePath && fs.existsSync(filePath) 23 | ? { id: filePath } 24 | : { id: path.resolve(__dirname, '../client/+app.svelte') }; 25 | } 26 | 27 | return [ 28 | { 29 | name: '@vessel/svelte', 30 | enforce: 'pre', 31 | config() { 32 | return { 33 | optimizeDeps: { 34 | include: ['svelte'], 35 | }, 36 | resolve: { 37 | alias: { 38 | [VIRTUAL_APP_ID]: `/${VIRTUAL_APP_ID}`, 39 | }, 40 | }, 41 | }; 42 | }, 43 | vessel: { 44 | enforce: 'pre', 45 | config(config) { 46 | appDir = config.dirs.app; 47 | return { 48 | entry: { 49 | client: path.resolve(__dirname, '../client/entry-client.js'), 50 | server: path.resolve(__dirname, '../client/entry-server.js'), 51 | }, 52 | client: { 53 | app: resolveAppId().id, 54 | }, 55 | markdown: { 56 | markdoc: { tags: svelteMarkdocTags }, 57 | render: renderMarkdoc, 58 | transformTreeNode: [transformTreeNode], 59 | }, 60 | }; 61 | }, 62 | }, 63 | resolveId(id) { 64 | if (id === `/${VIRTUAL_APP_ID}`) { 65 | return resolveAppId(); 66 | } 67 | 68 | return null; 69 | }, 70 | }, 71 | ]; 72 | } 73 | -------------------------------------------------------------------------------- /packages/svelte/src/shared/index.ts: -------------------------------------------------------------------------------- 1 | import type { SvelteComponent } from 'svelte'; 2 | 3 | export type SvelteConstructor = typeof SvelteComponent; 4 | 5 | export interface SvelteModule { 6 | readonly [id: string]: unknown; 7 | readonly default: SvelteConstructor; 8 | } 9 | 10 | export interface SvelteServerModule { 11 | readonly [id: string]: unknown; 12 | readonly default: { 13 | render( 14 | props: Record, 15 | options: { context: Map }, 16 | ): { 17 | html: string; 18 | head: string; 19 | css?: string | { code: string; map: string | null }; 20 | }; 21 | }; 22 | } 23 | 24 | export {}; 25 | -------------------------------------------------------------------------------- /packages/svelte/src/virtual/app.d.ts: -------------------------------------------------------------------------------- 1 | declare module ':virtual/vessel/svelte/app' { 2 | declare const App: 3 | | import('../shared').SvelteModule['default'] 4 | | import('../shared').SvelteServerModule['default']; 5 | export default App; 6 | } 7 | -------------------------------------------------------------------------------- /packages/svelte/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "baseUrl": "./src", 5 | "outDir": "dist", 6 | "declarationDir": "types", 7 | "paths": { 8 | ":virtual/vessel/svelte/app": ["src/virtual/app"] 9 | } 10 | }, 11 | "include": ["src"] 12 | } 13 | -------------------------------------------------------------------------------- /packages/svelte/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { watch } from 'chokidar'; 2 | import { defineConfig } from 'tsup'; 3 | 4 | import { base, copyFiles } from '../app/tsup.config'; 5 | 6 | if (process.env.DEV) { 7 | watch('src/client/**/*.svelte').on('all', async () => { 8 | await copyFiles('**/*.svelte'); 9 | }); 10 | } 11 | 12 | export default defineConfig([ 13 | { 14 | ...base({ external: [/\.svelte/] }), 15 | entry: { 16 | index: 'src/client/index.ts', 17 | context: 'src/client/context.ts', 18 | 'context-keys': 'src/client/context-keys.ts', 19 | stores: 'src/client/stores.ts', 20 | 'entry-client': 'src/client/entry-client.ts', 21 | 'entry-server': 'src/client/entry-server.ts', 22 | ErrorBoundary: 'src/client/ErrorBoundary.ts', 23 | }, 24 | target: 'esnext', 25 | platform: 'browser', 26 | outDir: 'dist/client', 27 | async onSuccess() { 28 | await copyFiles('**/*.svelte'); 29 | }, 30 | }, 31 | { 32 | ...base(), 33 | entry: { index: 'src/node/index.ts' }, 34 | target: 'node16', 35 | platform: 'node', 36 | outDir: 'dist/node', 37 | }, 38 | ]); 39 | -------------------------------------------------------------------------------- /packages/vue/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Rahim Alwer 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/vue/README.md: -------------------------------------------------------------------------------- 1 | # Vessel Vue 2 | 3 | This package adds [Vue](https://vuejs.org) support to Vessel. 4 | 5 | ```bash 6 | npm install @vessel-js/vue 7 | ``` 8 | 9 | ```js 10 | // vite.config.js 11 | import { vessel } from '@vessel-js/app/node'; 12 | import { vesselVue } from '@vessel-js/vue/node'; 13 | import vue from '@vitejs/plugin-vue'; 14 | import { defineConfig } from 'vite'; 15 | 16 | export default defineConfig({ 17 | plugins: [ 18 | vessel(), 19 | vesselVue(), 20 | vue({ 21 | include: [/\.vue$/, /\.md$/], 22 | }), 23 | ], 24 | }); 25 | ``` 26 | -------------------------------------------------------------------------------- /packages/vue/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | // eslint-disable-next-line @typescript-eslint/ban-types 3 | const component: import('vue').DefineComponent<{}, {}, any>; 4 | export default component; 5 | } 6 | 7 | declare module '*.md' { 8 | const meta: import('@vessel-js/app').MarkdownMeta; 9 | export { meta }; 10 | // eslint-disable-next-line @typescript-eslint/ban-types 11 | const component: import('vue').DefineComponent<{}, {}, any>; 12 | export default component; 13 | } 14 | -------------------------------------------------------------------------------- /packages/vue/head.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/client/head.js'; 2 | -------------------------------------------------------------------------------- /packages/vue/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/client/index.js'; 2 | -------------------------------------------------------------------------------- /packages/vue/node.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/node/index.js'; 2 | -------------------------------------------------------------------------------- /packages/vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vessel-js/vue", 3 | "version": "0.3.3", 4 | "type": "module", 5 | "types": "index.d.ts", 6 | "sideEffects": false, 7 | "license": "MIT", 8 | "contributors": [ 9 | "Rahim Alwer " 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/vessel-js/vessel.git", 14 | "directory": "packages/vue" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/vessel-js/vessel/issues" 18 | }, 19 | "files": [ 20 | "dist/", 21 | "*.d.ts" 22 | ], 23 | "exports": { 24 | ".": "./dist/client/index.js", 25 | "./head": "./dist/client/head.js", 26 | "./node": "./dist/node/index.js", 27 | "./*": "./dist/client/*", 28 | "./package.json": "./package.json" 29 | }, 30 | "scripts": { 31 | "dev": "DEV=true pnpm run build --watch", 32 | "build": "rimraf dist && tsup" 33 | }, 34 | "dependencies": { 35 | "globby": "^13.0.0", 36 | "pathe": "^1.0.0" 37 | }, 38 | "peerDependencies": { 39 | "@vessel-js/app": "workspace:*", 40 | "vue": "^3.0.0" 41 | }, 42 | "devDependencies": { 43 | "@types/node": "^18.0.0", 44 | "@vessel-js/app": "workspace:*", 45 | "fast-glob": "^3.2.0", 46 | "tsup": "^6.7.0", 47 | "typescript": "^5.0.0", 48 | "vite": "^4.0.0", 49 | "vue": "^3.0.0" 50 | }, 51 | "engines": { 52 | "node": ">=16" 53 | }, 54 | "publishConfig": { 55 | "access": "public" 56 | }, 57 | "keywords": [ 58 | "app", 59 | "docs", 60 | "edge", 61 | "esm", 62 | "fast", 63 | "lightweight", 64 | "modern", 65 | "ssg", 66 | "ssr", 67 | "vercel", 68 | "vessel", 69 | "vite", 70 | "vue" 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /packages/vue/src/client/+app.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h } from 'vue'; 2 | 3 | import RouteAnnouncer from './RouteAnnouncer'; 4 | import RouterOutlet from './RouterOutlet'; 5 | 6 | export default defineComponent({ 7 | name: 'App', 8 | setup() { 9 | return () => [h(RouteAnnouncer), h(RouterOutlet)]; 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/vue/src/client/DevErrorFallback.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h } from 'vue'; 2 | 3 | export default /*#__PURE__*/ defineComponent<{ 4 | error: Error; 5 | reset: () => void; 6 | }>({ 7 | name: 'DevErrorFallback', 8 | setup(props) { 9 | return () => 10 | h( 11 | 'div', 12 | { 13 | class: 'error', 14 | style: 'border: 2px solid red; padding: 1.5rem; font-family: monospace;', 15 | }, 16 | [ 17 | h('b', { class: 'title', style: 'font-size: 1.5rem;' }, 'Error'), 18 | props.error.stack && 19 | h( 20 | 'pre', 21 | { 22 | class: 'stack', 23 | style: 24 | 'padding: 1rem; color: red; overflow: auto; background: hsla(10, 50%, 50%, 0.1);', 25 | }, 26 | props.error.stack, 27 | ), 28 | h( 29 | 'button', 30 | { onClick: () => props.reset() }, 31 | 'Loading this section failed. Click to try again.', 32 | ), 33 | ], 34 | ); 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /packages/vue/src/client/Link.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h } from 'vue'; 2 | 3 | export default defineComponent<{ 4 | /** 5 | * Specifies the URL of the linked resource. 6 | */ 7 | href: VesselRoutes[keyof VesselRoutes] | URL; 8 | /** 9 | * Whether this route should begin prefetching if the user is about to interact with the link. 10 | * 11 | * @defaultValue true 12 | */ 13 | prefetch?: boolean; 14 | /** 15 | * Replace the current history instead of pushing a new URL on to the stack. 16 | * 17 | * @defaultValue false 18 | */ 19 | replace?: boolean; 20 | }>({ 21 | name: 'Link', 22 | props: ['href', 'prefetch', 'replace'] as any, 23 | inheritAttrs: true, 24 | setup(props, { slots }) { 25 | return () => 26 | h( 27 | 'a', 28 | { 29 | href: props.href instanceof URL ? props.href.href : props.href, 30 | 'data-prefetch': props.prefetch !== false ? '' : undefined, 31 | 'data-replace': props.replace ? '' : undefined, 32 | }, 33 | slots.default?.(), 34 | ); 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /packages/vue/src/client/ProdErrorFallback.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h } from 'vue'; 2 | 3 | export default defineComponent({ 4 | name: 'ProdErrorFallback', 5 | setup() { 6 | return () => h('div', { style: 'font-weight: bold;' }, 'Loading failed - try reloading page.'); 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /packages/vue/src/client/RouteAnnouncer.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h, onMounted, ref, watchEffect } from 'vue'; 2 | 3 | import { useRoute } from './context'; 4 | 5 | export default defineComponent({ 6 | name: 'RouteAnnouncer', 7 | setup() { 8 | const title = ref(); 9 | const mounted = ref(false); 10 | const navigated = ref(false); 11 | const route = useRoute(); 12 | 13 | onMounted(() => { 14 | mounted.value = true; 15 | }); 16 | 17 | watchEffect(() => { 18 | if (mounted.value && route.value) { 19 | navigated.value = true; 20 | title.value = document.title || 'untitled page'; 21 | } 22 | }); 23 | 24 | return () => 25 | mounted.value 26 | ? h( 27 | 'div', 28 | { 29 | id: 'route-announcer', 30 | 'aria-live': 'assertive', 31 | 'aria-atomic': 'true', 32 | style: 33 | 'position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px', 34 | }, 35 | navigated.value ? title.value : undefined, 36 | ) 37 | : null; 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /packages/vue/src/client/RouteComponent.ts: -------------------------------------------------------------------------------- 1 | import { type ClientLoadedRoute } from '@vessel-js/app'; 2 | import { computed, defineComponent, h, provide } from 'vue'; 3 | 4 | import { SERVER_DATA_KEY, SERVER_ERROR_KEY, STATIC_DATA_KEY } from './context-keys'; 5 | 6 | export default defineComponent<{ 7 | component?: ClientLoadedRoute['page']; 8 | leaf?: boolean; 9 | }>({ 10 | name: 'RouteComponent', 11 | props: ['component', 'leaf'] as any, 12 | setup(props, { slots }) { 13 | const staticData = computed(() => props.component?.staticData ?? {}), 14 | serverData = computed(() => props.component?.serverData), 15 | serverError = computed(() => props.component?.serverLoadError); 16 | 17 | provide(STATIC_DATA_KEY, staticData); 18 | provide(SERVER_DATA_KEY, serverData); 19 | provide(SERVER_ERROR_KEY, serverError); 20 | 21 | return () => 22 | props.component 23 | ? h(props.component!.module.default, () => (!props.leaf ? slots.default?.() : undefined)) 24 | : !props.leaf && slots.default?.(); 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /packages/vue/src/client/RouteErrorBoundary.ts: -------------------------------------------------------------------------------- 1 | import { type ClientLoadedRoute } from '@vessel-js/app'; 2 | import { computed, defineComponent, h, onErrorCaptured, ref, watchEffect } from 'vue'; 3 | 4 | import DevErrorFallback from './DevErrorFallback'; 5 | import ProdErrorFallback from './ProdErrorFallback'; 6 | 7 | export default defineComponent<{ 8 | error?: ClientLoadedRoute['error']; 9 | boundary?: ClientLoadedRoute['errorBoundary']; 10 | }>({ 11 | name: 'RouteErrorBoundary', 12 | props: ['error', 'boundary'] as any, 13 | setup(props, { slots }) { 14 | const loadError = ref(props.error); 15 | const renderError = ref(); 16 | 17 | watchEffect(() => { 18 | loadError.value = props.error; 19 | }); 20 | 21 | const Fallback = computed( 22 | () => 23 | props.boundary?.module.default ?? 24 | (import.meta.env.DEV ? DevErrorFallback : ProdErrorFallback), 25 | ); 26 | 27 | onErrorCaptured((error) => { 28 | renderError.value = error; 29 | return false; 30 | }); 31 | 32 | function reset() { 33 | // TODO: should we try and reload route? 34 | // loadError.value = null; 35 | renderError.value = null; 36 | } 37 | 38 | const error = computed(() => renderError.value ?? loadError.value); 39 | 40 | return () => 41 | error.value ? h(Fallback.value, { error: error.value, reset }) : slots.default?.(); 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /packages/vue/src/client/RouteSegment.ts: -------------------------------------------------------------------------------- 1 | import { type ClientLoadedRoute } from '@vessel-js/app'; 2 | import { computed, defineComponent, h, provide } from 'vue'; 3 | 4 | import { ROUTE_PARAMS_KEY } from './context-keys'; 5 | import RouteComponent from './RouteComponent'; 6 | import RouteErrorBoundary from './RouteErrorBoundary'; 7 | 8 | const RouteSegment = defineComponent<{ 9 | matches: ClientLoadedRoute[]; 10 | depth: number; 11 | }>({ 12 | name: 'RouteSegment', 13 | props: ['matches', 'depth'] as any, 14 | setup(props) { 15 | const match = computed(() => props.matches[props.depth]); 16 | 17 | const params = computed(() => match.value.params); 18 | provide(ROUTE_PARAMS_KEY, params); 19 | 20 | return () => 21 | h( 22 | RouteComponent, 23 | { component: match.value.layout }, 24 | { 25 | default: () => 26 | h( 27 | RouteErrorBoundary, 28 | { error: match.value.error, boundary: match.value.errorBoundary }, 29 | { 30 | default: () => { 31 | return props.depth < props.matches.length - 1 32 | ? h(RouteSegment, { 33 | matches: props.matches, 34 | depth: props.depth + 1, 35 | }) 36 | : h(RouteComponent, { 37 | component: match.value.page, 38 | leaf: true, 39 | }); 40 | }, 41 | }, 42 | ), 43 | }, 44 | ); 45 | }, 46 | }); 47 | 48 | export default RouteSegment; 49 | -------------------------------------------------------------------------------- /packages/vue/src/client/RouterOutlet.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h } from 'vue'; 2 | 3 | import { useRouteMatches } from './context'; 4 | import RouteSegment from './RouteSegment'; 5 | 6 | export default defineComponent({ 7 | name: 'RouterOutlet', 8 | setup() { 9 | const matches = useRouteMatches(); 10 | return () => h(RouteSegment, { matches: matches.value, depth: 0 }); 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /packages/vue/src/client/context-keys.ts: -------------------------------------------------------------------------------- 1 | export const ROUTER_KEY = Symbol(); 2 | export const ROUTE_KEY = Symbol(); 3 | export const ROUTE_MATCHES_KEY = Symbol(); 4 | export const ROUTE_PARAMS_KEY = Symbol(); 5 | export const NAVIGATION_KEY = Symbol(); 6 | export const MARKDOWN_KEY = Symbol(); 7 | export const FRONTMATTER_KEY = Symbol(); 8 | export const STATIC_DATA_KEY = Symbol(); 9 | export const SERVER_DATA_KEY = Symbol(); 10 | export const SERVER_ERROR_KEY = Symbol(); 11 | export const HEAD_MANAGER = Symbol(); 12 | -------------------------------------------------------------------------------- /packages/vue/src/client/entry-client.ts: -------------------------------------------------------------------------------- 1 | import App from ':virtual/vessel/vue/app'; 2 | import { init } from '@vessel-js/app'; 3 | import { createSSRApp, nextTick } from 'vue'; 4 | 5 | import { createContext } from './context'; 6 | import { ROUTER_KEY } from './context-keys'; 7 | 8 | async function main() { 9 | const { context, ...delegate } = createContext(); 10 | 11 | const router = await init({ 12 | frameworkDelegate: { 13 | tick: nextTick, 14 | ...delegate, 15 | }, 16 | }); 17 | 18 | context.set(ROUTER_KEY, router); 19 | 20 | await router.start((target) => { 21 | const app = createSSRApp(App); 22 | for (const [key, value] of context) app.provide(key, value); 23 | app.mount(target, true); 24 | }); 25 | } 26 | 27 | main(); 28 | -------------------------------------------------------------------------------- /packages/vue/src/client/entry-server.ts: -------------------------------------------------------------------------------- 1 | import App from ':virtual/vessel/vue/app'; 2 | import { renderHeadToString } from '@vessel-js/app/head'; 3 | import type { ServerRenderer } from '@vessel-js/app/server'; 4 | import { createSSRApp } from 'vue'; 5 | import { renderToString } from 'vue/server-renderer'; 6 | 7 | import { createContext } from './context'; 8 | import { ROUTER_KEY } from './context-keys'; 9 | 10 | export const render: ServerRenderer = async ({ route, matches, router }) => { 11 | const { context, headManager, ...delegate } = createContext(); 12 | context.set(ROUTER_KEY, router); 13 | 14 | delegate.route.set(route); 15 | delegate.matches.set(matches); 16 | 17 | const app = createSSRApp(App); 18 | for (const [key, value] of context) app.provide(key, value); 19 | 20 | // TODO: does ctx.modules contain modules not already included in manfiest? 21 | const ctx = { modules: new Set() }; 22 | 23 | const html = await renderToString(app, ctx); 24 | const headSSR = renderHeadToString(headManager); 25 | 26 | return { ...headSSR, html }; 27 | }; 28 | -------------------------------------------------------------------------------- /packages/vue/src/client/head/index.ts: -------------------------------------------------------------------------------- 1 | export { useHead } from './use-head'; 2 | -------------------------------------------------------------------------------- /packages/vue/src/client/head/use-head.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Credit: https://github.com/vueuse/head 3 | */ 4 | 5 | import type { HeadConfig, HeadManager } from '@vessel-js/app/head'; 6 | import { inject, onBeforeUnmount, watchEffect } from 'vue'; 7 | 8 | import { HEAD_MANAGER } from 'client/context-keys'; 9 | 10 | export function useHeadManager(): HeadManager { 11 | return inject(HEAD_MANAGER)!; 12 | } 13 | 14 | export const useHead = (config: HeadConfig) => { 15 | const manager = useHeadManager(); 16 | manager.add(config); 17 | 18 | if (!import.meta.env.SSR) { 19 | watchEffect(() => { 20 | manager.update(); 21 | }); 22 | 23 | onBeforeUnmount(() => { 24 | manager.remove(config); 25 | manager.update(); 26 | }); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /packages/vue/src/client/index.ts: -------------------------------------------------------------------------------- 1 | export { default as App } from './+app'; 2 | export * from './context'; 3 | export { default as Link } from './Link'; 4 | export { default as RouteAnnouncer } from './RouteAnnouncer'; 5 | export { default as RouterOutlet } from './RouterOutlet'; 6 | -------------------------------------------------------------------------------- /packages/vue/src/globals.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | declare module '*.vue' { 5 | // eslint-disable-next-line @typescript-eslint/ban-types 6 | const component: import('vue').DefineComponent<{}, {}, any>; 7 | export default component; 8 | } 9 | -------------------------------------------------------------------------------- /packages/vue/src/node/index.ts: -------------------------------------------------------------------------------- 1 | export { vuePlugin as default, vuePlugin as vesselVue } from './vue-plugin'; 2 | export * from './vue-plugin'; 3 | -------------------------------------------------------------------------------- /packages/vue/src/virtual/app.d.ts: -------------------------------------------------------------------------------- 1 | declare module ':virtual/vessel/vue/app' { 2 | declare const App: import('vue').Component; 3 | export default App; 4 | } 5 | -------------------------------------------------------------------------------- /packages/vue/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "baseUrl": "./src", 5 | "outDir": "dist", 6 | "declarationDir": "types", 7 | "paths": { 8 | ":virtual/vessel/vue/app": ["src/virtual/app"] 9 | } 10 | }, 11 | "include": ["src"] 12 | } 13 | -------------------------------------------------------------------------------- /packages/vue/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | import { base } from '../app/tsup.config'; 4 | 5 | export default defineConfig([ 6 | { 7 | ...base({ external: [/\.vue/] }), 8 | entry: { 9 | index: 'src/client/index.ts', 10 | head: 'src/client/head/index.ts', 11 | '+app': 'src/client/+app.ts', 12 | 'entry-client': 'src/client/entry-client.ts', 13 | 'entry-server': 'src/client/entry-server.ts', 14 | }, 15 | target: 'esnext', 16 | platform: 'browser', 17 | outDir: 'dist/client', 18 | }, 19 | { 20 | ...base(), 21 | entry: { index: 'src/node/index.ts' }, 22 | target: 'node16', 23 | platform: 'node', 24 | outDir: 'dist/node', 25 | }, 26 | ]); 27 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "allowUnreachableCode": false, 6 | "alwaysStrict": true, 7 | "checkJs": true, 8 | "declaration": true, 9 | "declarationMap": false, 10 | "emitDeclarationOnly": true, 11 | "esModuleInterop": true, 12 | "experimentalDecorators": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "incremental": false, 15 | "verbatimModuleSyntax": true, 16 | "jsx": "preserve", 17 | "lib": ["dom", "dom.iterable", "es2017"], 18 | // tsconfig.json 19 | "module": "esnext", 20 | "moduleResolution": "node", 21 | "newLine": "lf", 22 | "noImplicitAny": false, 23 | "noImplicitReturns": false, 24 | "noUnusedLocals": false, 25 | "noUnusedParameters": false, 26 | "outDir": "dist", 27 | "preserveWatchOutput": true, 28 | "resolveJsonModule": true, 29 | "skipLibCheck": true, 30 | "sourceMap": true, 31 | "strict": true, 32 | "strictNullChecks": true, 33 | "target": "esnext", 34 | "useDefineForClassFields": false, 35 | "types": ["node"] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "files": [], 4 | "include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.cjs"], 5 | "exclude": [ 6 | "node_modules", 7 | "dist", 8 | "dist-*", 9 | "packages/*/dist", 10 | "packages/*/types" 11 | ] 12 | } 13 | --------------------------------------------------------------------------------