├── .husky └── pre-commit ├── adex ├── .prettierignore ├── src │ ├── app.js │ ├── env.d.ts │ ├── utils │ │ └── isomorphic.js │ ├── head.js │ ├── head.d.ts │ ├── ssr.d.ts │ ├── app.d.ts │ ├── ssr.js │ ├── router.d.ts │ ├── env.js │ ├── router.js │ ├── vite.d.ts │ ├── fonts.d.ts │ ├── http.d.ts │ ├── hook.d.ts │ ├── hook.js │ ├── fonts.js │ ├── http.js │ └── vite.js ├── tests │ ├── fixtures │ │ ├── minimal-tailwind │ │ │ ├── src │ │ │ │ ├── global.css │ │ │ │ └── pages │ │ │ │ │ ├── about.jsx │ │ │ │ │ └── index.jsx │ │ │ ├── postcss.config.js │ │ │ ├── jsconfig.json │ │ │ ├── tailwind.config.js │ │ │ ├── vite.config.js │ │ │ └── package.json │ │ ├── minimal │ │ │ ├── src │ │ │ │ └── pages │ │ │ │ │ ├── about.jsx │ │ │ │ │ └── index.jsx │ │ │ ├── jsconfig.json │ │ │ ├── vite.config.js │ │ │ └── package.json │ │ └── minimal-no-ssr │ │ │ ├── src │ │ │ └── pages │ │ │ │ ├── about.jsx │ │ │ │ └── index.jsx │ │ │ ├── jsconfig.json │ │ │ ├── vite.config.js │ │ │ └── package.json │ ├── minimal-tailwind.spec.js │ ├── minimal.spec.js │ ├── minimal-no-ssr.spec.js │ ├── utils.js │ └── response-helpers.spec.js ├── vitest.config.ts ├── CHANGELOG.md ├── .gitignore ├── runtime │ ├── client.js │ ├── app.js │ ├── pages.js │ └── handler.js ├── tsconfig.json ├── snapshots │ └── tests │ │ ├── minimal-tailwind.spec.snap.cjs │ │ ├── minimal-no-ssr.spec.snap.cjs │ │ └── minimal.spec.snap.cjs ├── LICENSE ├── readme.md └── package.json ├── playground ├── src │ ├── pages │ │ ├── local-index.css │ │ ├── $id │ │ │ └── hello.tsx │ │ ├── about.tsx │ │ ├── shared-signal.tsx │ │ ├── islands-test.tsx │ │ └── index.jsx │ ├── global.css │ ├── lib │ │ └── test-env.js │ ├── api │ │ ├── random-data.js │ │ ├── $id │ │ │ ├── hello.js │ │ │ └── json.js │ │ ├── hello.js │ │ ├── html.js │ │ └── status-helpers.js │ ├── components │ │ ├── counter.tsx │ │ ├── ListIsland.tsx │ │ ├── FormIsland.tsx │ │ └── SharedSignal.tsx │ ├── index.html │ ├── _app.jsx │ └── assets │ │ └── preact.svg ├── postcss.config.js ├── tsconfig.json ├── tailwind.config.js ├── .gitignore ├── vite.config.js ├── package.json └── public │ └── vite.svg ├── readme └── title.png ├── .gitignore ├── .vscode └── settings.json ├── readme.md ├── packages └── adapters │ └── node │ ├── lib │ ├── index.d.ts │ └── index.js │ └── package.json ├── pnpm-workspace.yaml ├── tsconfig.json ├── lerna.json ├── .github └── workflows │ ├── test.yml │ ├── release.yml │ └── release-beta.yml ├── LICENSE ├── create-adex ├── package.json └── cli.mjs └── package.json /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm nano-staged 2 | -------------------------------------------------------------------------------- /adex/.prettierignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /snapshots 3 | -------------------------------------------------------------------------------- /playground/src/pages/local-index.css: -------------------------------------------------------------------------------- 1 | body{ 2 | background:red; 3 | } -------------------------------------------------------------------------------- /readme/title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barelyhuman/adex/HEAD/readme/title.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .DS_Store 4 | package-lock.json 5 | 6 | coverage 7 | -------------------------------------------------------------------------------- /adex/src/app.js: -------------------------------------------------------------------------------- 1 | export const App = () => null 2 | export const prerender = () => ({}) 3 | -------------------------------------------------------------------------------- /playground/src/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "prettier.enable": true 4 | } 5 | -------------------------------------------------------------------------------- /playground/src/lib/test-env.js: -------------------------------------------------------------------------------- 1 | import { env } from 'adex/env' 2 | 3 | export const APP_URL = env.get('APP_URL') 4 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal-tailwind/src/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal/src/pages/about.jsx: -------------------------------------------------------------------------------- 1 | export default function AboutPage() { 2 | return

About

3 | } 4 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal/src/pages/index.jsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return

Hello World

3 | } 4 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal-no-ssr/src/pages/about.jsx: -------------------------------------------------------------------------------- 1 | export default function AboutPage() { 2 | return

About

3 | } 4 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal-no-ssr/src/pages/index.jsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return

Hello World

3 | } 4 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal-tailwind/src/pages/about.jsx: -------------------------------------------------------------------------------- 1 | export default function AboutPage() { 2 | return

About

3 | } 4 | -------------------------------------------------------------------------------- /playground/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /playground/src/pages/$id/hello.tsx: -------------------------------------------------------------------------------- 1 | export default ({ routeParams }) => { 2 | return

Hello from {routeParams.id}

3 | } 4 | -------------------------------------------------------------------------------- /adex/src/env.d.ts: -------------------------------------------------------------------------------- 1 | export const env: { 2 | get(key: any, defaultValue?: string): string 3 | set(key: any, value: any): any 4 | } 5 | -------------------------------------------------------------------------------- /adex/src/utils/isomorphic.js: -------------------------------------------------------------------------------- 1 | export { parse as pathToRegex } from 'regexparam' 2 | export { renderToStringAsync } from 'preact-render-to-string' 3 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "jsxImportSource": "preact" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal-tailwind/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal-tailwind/src/pages/index.jsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return

Hello World

3 | } 4 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal-no-ssr/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "jsxImportSource": "preact" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal-tailwind/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "jsxImportSource": "preact" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /adex/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | 3 | export default defineConfig({ 4 | test: { 5 | hookTimeout: 30_000, 6 | }, 7 | }) 8 | -------------------------------------------------------------------------------- /playground/src/pages/about.tsx: -------------------------------------------------------------------------------- 1 | import { useTitle } from 'adex/head' 2 | 3 | export default () => { 4 | useTitle('About Page') 5 | return

About

6 | } 7 | -------------------------------------------------------------------------------- /adex/src/head.js: -------------------------------------------------------------------------------- 1 | export { 2 | useHead, 3 | useLang, 4 | useLink, 5 | useMeta, 6 | useScript, 7 | useTitle, 8 | useTitleTemplate, 9 | } from 'hoofd/preact' 10 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ![](readme/title.png) 2 | 3 | [Get Started →](https://github.com/barelyhuman/adex-default-template)\ 4 | [Docs →](https://barelyhuman.github.io/adex-docs) 5 | -------------------------------------------------------------------------------- /adex/src/head.d.ts: -------------------------------------------------------------------------------- 1 | export { 2 | useHead, 3 | useLang, 4 | useLink, 5 | useMeta, 6 | useScript, 7 | useTitle, 8 | useTitleTemplate, 9 | } from 'hoofd/preact' 10 | -------------------------------------------------------------------------------- /packages/adapters/node/lib/index.d.ts: -------------------------------------------------------------------------------- 1 | type ServerOut = { 2 | run: () => any 3 | fetch: undefined 4 | } 5 | 6 | export const createServer: ({ port: number, host: string }) => ServerOut 7 | -------------------------------------------------------------------------------- /adex/src/ssr.d.ts: -------------------------------------------------------------------------------- 1 | export { toStatic } from 'hoofd/preact' 2 | export { use as useMiddleware } from '@barelyhuman/tiny-use' 3 | export { default as sirv } from 'sirv' 4 | export { default as mri } from 'mri' 5 | -------------------------------------------------------------------------------- /playground/src/api/random-data.js: -------------------------------------------------------------------------------- 1 | export default function (req, res) { 2 | return res.json( 3 | Array.from({ length: 3 }) 4 | .fill(0) 5 | .map((d, i) => (i + 1) * Math.random()) 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - adex 3 | - create-adex 4 | - playground 5 | - packages/**/* 6 | - adex/tests/**/* 7 | 8 | catalog: 9 | "preact": 10.24.2 10 | "@preact/preset-vite": 2.9.1 11 | -------------------------------------------------------------------------------- /adex/src/app.d.ts: -------------------------------------------------------------------------------- 1 | export declare const App: ({ url }: { url?: string | undefined }) => any 2 | export declare const prerender: ({ url }: { url: any }) => Promise<{ 3 | html: any 4 | links: Set 5 | }> 6 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal-tailwind/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./src/pages/**/*.{tsx,jsx}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | } 9 | -------------------------------------------------------------------------------- /playground/src/components/counter.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'preact/hooks' 2 | 3 | export function Counter() { 4 | const [count, setCount] = useState(0) 5 | return 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "allowJs": true, 5 | "jsxImportSource": "preact", 6 | "module": "ESNext", 7 | "target": "ESNext", 8 | "noEmit": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /playground/src/api/$id/hello.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {import("adex/http").IncomingMessage} req 3 | * @param {import("adex/http").ServerResponse} res 4 | */ 5 | export default (req, res) => { 6 | return res.text(`Hello from ${req.params.id}`) 7 | } 8 | -------------------------------------------------------------------------------- /adex/src/ssr.js: -------------------------------------------------------------------------------- 1 | export { prerender as renderToString } from 'preact-iso' 2 | export { default as sirv } from 'sirv' 3 | export { default as mri } from 'mri' 4 | export { toStatic } from 'hoofd/preact' 5 | export { use as useMiddleware } from '@barelyhuman/tiny-use' 6 | -------------------------------------------------------------------------------- /adex/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # adex 2 | 3 | ## 0.0.5 4 | 5 | ### Patch Changes 6 | 7 | - c8a7177: Port and host management in the server 8 | 9 | ## 0.0.3 10 | 11 | ### Patch Changes 12 | 13 | - Move back to simple hydration instead of island hydration for the time being 14 | -------------------------------------------------------------------------------- /adex/src/router.d.ts: -------------------------------------------------------------------------------- 1 | export { 2 | hydrate, 3 | Router, 4 | Route, 5 | lazy, 6 | LocationProvider, 7 | ErrorBoundary, 8 | useLocation, 9 | useRoute, 10 | prerender, 11 | } from 'preact-iso' 12 | 13 | export declare const join: (...args: string[]) => string 14 | -------------------------------------------------------------------------------- /playground/src/api/$id/json.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {import("adex/http").IncomingMessage} req 3 | * @param {import("adex/http").ServerResponse} res 4 | */ 5 | export default (req, res) => { 6 | return res.json({ 7 | message: `Hello in ${req.params.id}`, 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /playground/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 |
10 | 11 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/@lerna-lite/cli/schemas/lerna-schema.json", 3 | "changelogPreset": "angular", 4 | "npmClient": "pnpm", 5 | "version": "fixed", 6 | "packages": [ 7 | "adex", 8 | "create-adex", 9 | "playground", 10 | "packages/**/*" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /playground/src/pages/shared-signal.tsx: -------------------------------------------------------------------------------- 1 | import { Renderer, Triggerer } from '../components/SharedSignal.js' 2 | 3 | export default function SharedSignal() { 4 | return ( 5 |
6 | 7 | 8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /playground/src/pages/islands-test.tsx: -------------------------------------------------------------------------------- 1 | import { ListIsland } from '../components/FormIsland' 2 | import { FormIsland } from '../components/ListIsland' 3 | 4 | export default function IslandsTest() { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /playground/src/api/hello.js: -------------------------------------------------------------------------------- 1 | import { env } from 'adex/env' 2 | /** 3 | * @param {import("adex/http").IncomingMessage} req 4 | * @param {import("adex/http").ServerResponse} res 5 | */ 6 | export default (req, res) => { 7 | return res.json({ 8 | pong: true, 9 | appUrl: env.get('APP_URL'), 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { adex } from 'adex' 3 | import preact from '@preact/preset-vite' 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | adex({ 8 | islands: false, 9 | ssr: true, 10 | }), 11 | preact(), 12 | ], 13 | }) 14 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "noEmit": true, 5 | "jsx": "react-jsx", 6 | "jsxImportSource": "preact", 7 | "moduleResolution": "Node", 8 | "paths": { 9 | "adex": ["../adex/vite.js"], 10 | "adex/*": ["../adex/src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal-no-ssr/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { adex } from 'adex' 3 | import preact from '@preact/preset-vite' 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | adex({ 8 | islands: false, 9 | ssr: false, 10 | }), 11 | preact(), 12 | ], 13 | }) 14 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal-tailwind/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { adex } from 'adex' 3 | import preact from '@preact/preset-vite' 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | adex({ 8 | islands: false, 9 | ssr: true, 10 | }), 11 | preact(), 12 | ], 13 | }) 14 | -------------------------------------------------------------------------------- /playground/tailwind.config.js: -------------------------------------------------------------------------------- 1 | import forms from '@tailwindcss/forms' 2 | /** @type {import('tailwindcss').Config} */ 3 | export default { 4 | content: ['./src/**/*.{js,jsx,ts,tsx}'], 5 | theme: { 6 | extend: {}, 7 | fontFamily: { 8 | sans: 'Inter, sans-serif', 9 | }, 10 | }, 11 | plugins: [forms], 12 | } 13 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minimal", 3 | "type": "module", 4 | "private": true, 5 | "version": "0.0.0", 6 | "dependencies": { 7 | "adex": "workspace:*", 8 | "preact": "catalog:", 9 | "@preact/preset-vite": "catalog:" 10 | }, 11 | "devDependencies": { 12 | "vite": "^5.4.8" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal-no-ssr/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minimal", 3 | "type": "module", 4 | "private": true, 5 | "version": "0.0.0", 6 | "dependencies": { 7 | "adex": "workspace:*", 8 | "preact": "catalog:", 9 | "@preact/preset-vite": "catalog:" 10 | }, 11 | "devDependencies": { 12 | "vite": "^5.4.8" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /playground/src/api/html.js: -------------------------------------------------------------------------------- 1 | import { afterAPICall } from 'adex/hook' 2 | 3 | afterAPICall(ctx => { 4 | console.log('called after api') 5 | }) 6 | 7 | /** 8 | * @param {import("adex/http").IncomingMessage} req 9 | * @param {import("adex/http").ServerResponse} res 10 | */ 11 | export default (req, res) => { 12 | return res.html(`

Html Response

`) 13 | } 14 | -------------------------------------------------------------------------------- /adex/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /playground/src/components/ListIsland.tsx: -------------------------------------------------------------------------------- 1 | export function FormIsland() { 2 | const onSubmit = e => { 3 | e.preventDefault() 4 | alert('sumbitted') 5 | } 6 | return ( 7 |
8 |
9 | 10 | 11 |
12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /adex/runtime/client.js: -------------------------------------------------------------------------------- 1 | import { App } from 'adex/app' 2 | import { h } from 'preact' 3 | import { hydrate as preactHydrate } from 'adex/router' 4 | 5 | export { App, prerender } from 'adex/app' 6 | 7 | import 'virtual:adex:global.css' 8 | 9 | async function hydrate() { 10 | preactHydrate(h(App, null), document.getElementById('app')) 11 | } 12 | if (typeof window !== 'undefined') { 13 | hydrate() 14 | } 15 | -------------------------------------------------------------------------------- /adex/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "module": "Node16", 5 | "target": "esnext", 6 | "checkJs": true, 7 | "moduleResolution": "Node16", 8 | "allowSyntheticDefaultImports": true, 9 | "noEmit": true, 10 | "types": ["vite/client"], 11 | "paths": { 12 | "adex": ["./src/vite.js"], 13 | "adex/*": ["./src/*"] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /playground/src/components/FormIsland.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'preact/hooks' 2 | 3 | export function ListIsland() { 4 | const [data, setData] = useState([]) 5 | 6 | useEffect(() => { 7 | setData([1, 2, 3]) 8 | }, []) 9 | 10 | return ( 11 |
12 |
    13 | {data.map(d => ( 14 |
  • {d}
  • 15 | ))} 16 |
17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /adex/tests/fixtures/minimal-tailwind/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minimal-tailwind", 3 | "type": "module", 4 | "private": true, 5 | "version": "0.0.0", 6 | "dependencies": { 7 | "@preact/preset-vite": "catalog:", 8 | "adex": "workspace:*", 9 | "preact": "catalog:" 10 | }, 11 | "devDependencies": { 12 | "autoprefixer": "^10.4.20", 13 | "tailwindcss": "^3", 14 | "vite": "^5.4.8" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /playground/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .env* 27 | !.env.example 28 | 29 | /.islands 30 | -------------------------------------------------------------------------------- /adex/src/env.js: -------------------------------------------------------------------------------- 1 | const isClient = typeof window !== 'undefined' 2 | 3 | if (isClient) { 4 | throw new Error('[adex] Cannot use/import `adex/env` on the client side') 5 | } 6 | 7 | export const env = { 8 | get(key, defaultValue = '') { 9 | if (isClient) return '' 10 | return process.env[key] ?? defaultValue 11 | }, 12 | set(key, value) { 13 | if (isClient) return '' 14 | return (process.env[key] = value) 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /adex/src/router.js: -------------------------------------------------------------------------------- 1 | export { 2 | hydrate, 3 | Router, 4 | ErrorBoundary, 5 | Route, 6 | lazy, 7 | LocationProvider, 8 | useLocation, 9 | useRoute, 10 | prerender, 11 | } from 'preact-iso' 12 | 13 | export const join = (...parts) => { 14 | if (parts.some(part => part == null)) { 15 | throw new Error( 16 | 'Expected join to get valid paths, but received undefined or null' 17 | ) 18 | } 19 | return parts.join('/').replace(/\/{2,}/g, '/') 20 | } 21 | -------------------------------------------------------------------------------- /adex/src/vite.d.ts: -------------------------------------------------------------------------------- 1 | import { UserConfig, Plugin } from 'vite' 2 | import type { Options as FontOptions } from './fonts.js' 3 | 4 | export type Adapters = 'node' 5 | 6 | export interface AdexOptions { 7 | fonts?: FontOptions 8 | islands?: boolean 9 | adapter?: Adapters 10 | ssr?: boolean 11 | __clientConfig?: UserConfig 12 | } 13 | 14 | export function adex(options: AdexOptions): Plugin[] 15 | 16 | declare module 'vite' { 17 | interface Plugin { 18 | adexServer?: boolean 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /playground/src/_app.jsx: -------------------------------------------------------------------------------- 1 | import { App as AdexApp } from 'adex/app' 2 | import { h } from 'preact' 3 | import { hydrate as preactHydrate } from 'adex/router' 4 | 5 | export { prerender } from 'adex/app' 6 | 7 | import 'virtual:adex:global.css' 8 | 9 | export const App = ({ url }) => { 10 | return 11 | } 12 | 13 | async function hydrate() { 14 | preactHydrate(h(App, null), document.getElementById('app')) 15 | } 16 | 17 | if (typeof window !== 'undefined') { 18 | hydrate() 19 | } 20 | -------------------------------------------------------------------------------- /adex/src/fonts.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | providers, 3 | // ResolveFontFacesOptions 4 | } from 'unifont' 5 | import { Plugin } from 'vite' 6 | export { providers } from 'unifont' 7 | 8 | export type FontFamilies = { 9 | name: string 10 | weights: string[] 11 | styles: Array<'normal' | 'italic' | 'oblique'> // ResolveFontFacesOptions['styles'] 12 | } 13 | 14 | export type Options = { 15 | providers: (typeof providers)[] 16 | families: FontFamilies[] 17 | } 18 | 19 | export function fonts(options: Options): Plugin 20 | -------------------------------------------------------------------------------- /playground/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import preact from '@preact/preset-vite' 3 | import { adex } from 'adex' 4 | import { providers } from 'adex/fonts' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | adex({ 10 | islands: false, 11 | fonts: { 12 | providers: [providers.google()], 13 | families: [ 14 | { 15 | name: 'Inter', 16 | weights: ['400', '600'], 17 | styles: ['normal'], 18 | }, 19 | ], 20 | }, 21 | }), 22 | preact(), 23 | ], 24 | }) 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | with: 13 | ref: ${{ github.head_ref }} 14 | 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: '20' 18 | corepack-enable: true 19 | registry-url: 'https://registry.npmjs.org' 20 | 21 | - name: Deps 22 | run: | 23 | npm i -g corepack@latest 24 | corepack enable 25 | pnpm i --frozen-lockfile 26 | 27 | - name: Test 28 | run: pnpm test:ci 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags-ignore: 6 | - '*-alpha.*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: '20' 18 | corepack-enable: true 19 | registry-url: 'https://registry.npmjs.org' 20 | 21 | - name: Deps 22 | run: | 23 | npm i -g corepack@latest 24 | corepack enable 25 | pnpm i --frozen-lockfile 26 | 27 | - name: Test 28 | run: pnpm test 29 | 30 | - name: Publish 31 | run: | 32 | pnpm publish:ci 33 | env: 34 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | -------------------------------------------------------------------------------- /.github/workflows/release-beta.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*-alpha.*' 7 | 8 | jobs: 9 | publish_beta: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: '20' 18 | corepack-enable: true 19 | registry-url: 'https://registry.npmjs.org' 20 | 21 | - name: Deps 22 | run: | 23 | npm i -g corepack@latest 24 | corepack enable 25 | pnpm i --frozen-lockfile 26 | 27 | - name: Test 28 | run: pnpm test 29 | 30 | - name: Publish 31 | run: | 32 | pnpm publish:ci --dist-tag beta 33 | env: 34 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | -------------------------------------------------------------------------------- /adex/src/http.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IncomingMessage as HTTPIncomingMessage, 3 | ServerResponse as HTTPServerResponse, 4 | } from 'http' 5 | 6 | export type IncomingMessage = HTTPIncomingMessage & { 7 | parseBodyJSON: () => Promise> 8 | } 9 | 10 | export type ServerResponse = HTTPServerResponse & { 11 | html: (data: string) => void 12 | json: (data: any) => void 13 | text: (data: string) => void 14 | redirect: (url: string, statusCode: number) => void 15 | badRequest: (message?: string) => void 16 | unauthorized: (message?: string) => void 17 | forbidden: (message?: string) => void 18 | notFound: (message?: string) => void 19 | internalServerError: (message?: string) => void 20 | } 21 | 22 | export function prepareRequest(req: IncomingMessage): void 23 | export function prepareResponse(res: ServerResponse): void 24 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playground", 3 | "private": true, 4 | "version": "0.0.19", 5 | "engines": { 6 | "node": ">=18.0.0" 7 | }, 8 | "type": "module", 9 | "scripts": { 10 | "dev": "vite", 11 | "build": "vite build", 12 | "preview": "vite preview" 13 | }, 14 | "dependencies": { 15 | "@preact/signals": "^1.3.0", 16 | "adex-adapter-node": "workspace:*", 17 | "preact": "^10.24.2" 18 | }, 19 | "devDependencies": { 20 | "@barelyhuman/prettier-config": "^1.1.0", 21 | "@preact/preset-vite": "^2.9.1", 22 | "@tailwindcss/forms": "^0.5.9", 23 | "@types/node": "^20.16.10", 24 | "adex": "workspace:*", 25 | "autoprefixer": "^10.4.20", 26 | "postcss": "^8.4.47", 27 | "prettier": "^3.5.3", 28 | "tailwindcss": "^3.4.13", 29 | "vite": "^5.4.8" 30 | }, 31 | "prettier": "@barelyhuman/prettier-config" 32 | } 33 | -------------------------------------------------------------------------------- /playground/src/components/SharedSignal.tsx: -------------------------------------------------------------------------------- 1 | import { signal } from '@preact/signals' 2 | import { useEffect } from 'preact/hooks' 3 | 4 | const data$ = signal([]) 5 | 6 | async function fetchData() { 7 | const data = await fetch('/api/random-data').then(d => d.json()) 8 | data$.value = data 9 | } 10 | 11 | export function Triggerer() { 12 | useEffect(() => { 13 | fetchData() 14 | }, []) 15 | return ( 16 |
17 |

Triggerer Island

18 | 19 |
20 | ) 21 | } 22 | 23 | export function Renderer() { 24 | return ( 25 |
26 |

Renderer Island

27 |
    28 | {data$.value.map(d => ( 29 |
  • {d}
  • 30 | ))} 31 |
32 |
33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /playground/src/api/status-helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {import("adex/http").IncomingMessage} req 3 | * @param {import("adex/http").ServerResponse} res 4 | */ 5 | export default (req, res) => { 6 | const { pathname, searchParams } = new URL(req.url, 'http://localhost') 7 | const type = searchParams.get('type') 8 | const message = searchParams.get('message') 9 | 10 | switch (type) { 11 | case 'badRequest': 12 | return res.badRequest(message) 13 | case 'unauthorized': 14 | return res.unauthorized(message) 15 | case 'forbidden': 16 | return res.forbidden(message) 17 | case 'notFound': 18 | return res.notFound(message) 19 | case 'internalServerError': 20 | return res.internalServerError(message) 21 | default: 22 | return res.json({ 23 | usage: 'Add ?type=badRequest&message=Custom%20message to test status helpers', 24 | available: ['badRequest', 'unauthorized', 'forbidden', 'notFound', 'internalServerError'] 25 | }) 26 | } 27 | } -------------------------------------------------------------------------------- /adex/src/hook.d.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from 'node:http' 2 | 3 | export type Context = { 4 | req: IncomingMessage 5 | html: string 6 | } 7 | 8 | export type APIContext = { 9 | req: IncomingMessage 10 | } 11 | 12 | export declare const CONSTANTS: { 13 | beforePageRender: symbol 14 | afterPageRender: symbol 15 | beforeApiCall: symbol 16 | afterApiCall: symbol 17 | } 18 | 19 | export declare function hook( 20 | eventName: string | symbol, 21 | handler: (data: any) => void | Promise 22 | ): void 23 | 24 | export declare function beforePageRender( 25 | fn: (ctx: Omit) => void 26 | ): Promise 27 | 28 | export declare function afterPageRender( 29 | fn: (ctx: Context) => void 30 | ): Promise 31 | 32 | export declare function beforeAPICall( 33 | fn: (ctx: APIContext) => void 34 | ): Promise 35 | 36 | export declare function afterAPICall( 37 | fn: (ctx: APIContext) => void 38 | ): Promise 39 | 40 | export declare function emitToHooked(eventName: any, data: any): Promise 41 | -------------------------------------------------------------------------------- /adex/snapshots/tests/minimal-tailwind.spec.snap.cjs: -------------------------------------------------------------------------------- 1 | exports["devMode ssr minimal with styles > gives a non-static ssr response 1"] = `" 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |

Hello World

17 | 18 | 19 | "` 20 | 21 | exports["devMode ssr minimal with styles > gives a static SSR response 1"] = `" 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |

About

37 | 38 | 39 | "` 40 | 41 | -------------------------------------------------------------------------------- /packages/adapters/node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "adex-adapter-node", 3 | "version": "0.0.19", 4 | "description": "Node.js adapter for Adex, enabling minimal server-side rendering and integration with Preact.", 5 | "keywords": [ 6 | "adex", 7 | "preact", 8 | "minimal", 9 | "server", 10 | "node", 11 | "ssr", 12 | "adapter" 13 | ], 14 | "author": "reaper ", 15 | "license": "MIT", 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/barelyhuman/adex" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/barelyhuman/adex/issues" 22 | }, 23 | "homepage": "https://github.com/barelyhuman/adex/tree/main/packages/adapters/node", 24 | "type": "module", 25 | "main": "./lib/index.js", 26 | "exports": { 27 | ".": { 28 | "types": "./lib/index.d.ts", 29 | "import": "./lib/index.js" 30 | } 31 | }, 32 | "files": [ 33 | "lib" 34 | ], 35 | "engines": { 36 | "node": ">=18.0.0" 37 | }, 38 | "funding": { 39 | "type": "github", 40 | "url": "https://github.com/sponsors/barelyhuman" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 reaper 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 | -------------------------------------------------------------------------------- /adex/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 reaper 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 | -------------------------------------------------------------------------------- /create-adex/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-adex", 3 | "version": "0.0.19", 4 | "description": "A CLI tool to quickly scaffold new Adex projects with minimal setup.", 5 | "keywords": [ 6 | "adex", 7 | "preact", 8 | "minimal", 9 | "server", 10 | "node", 11 | "cli", 12 | "scaffold", 13 | "starter", 14 | "project" 15 | ], 16 | "homepage": "https://github.com/barelyhuman/adex#create-adex", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/barelyhuman/adex.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/barelyhuman/adex/issues" 23 | }, 24 | "author": "reaper ", 25 | "license": "MIT", 26 | "funding": { 27 | "type": "github", 28 | "url": "https://github.com/sponsors/barelyhuman" 29 | }, 30 | "main": "./cli.mjs", 31 | "exports": { 32 | ".": "./cli.mjs" 33 | }, 34 | "type": "module", 35 | "files": [ 36 | "cli.mjs" 37 | ], 38 | "bin": { 39 | "create-adex": "./cli.mjs" 40 | }, 41 | "engines": { 42 | "node": ">=18.0.0" 43 | }, 44 | "dependencies": { 45 | "kleur": "^4.1.5", 46 | "node-stream-zip": "^1.15.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /adex/tests/minimal-tailwind.spec.js: -------------------------------------------------------------------------------- 1 | import { after, before, describe, it } from 'node:test' 2 | import assert, { deepEqual } from 'node:assert' 3 | 4 | import { devServerURL, launchDemoDevServer } from './utils.js' 5 | import { snapshot } from '@barelyhuman/node-snapshot' 6 | 7 | describe('devMode ssr minimal with styles', async () => { 8 | let devServerProc 9 | before(async () => { 10 | devServerProc = await launchDemoDevServer('tests/fixtures/minimal-tailwind') 11 | }) 12 | after(async () => { 13 | devServerProc.kill() 14 | }) 15 | 16 | await it('gives a non-static ssr response', async ctx => { 17 | const response = await fetch(devServerURL).then(d => d.text()) 18 | snapshot(ctx, response) 19 | }) 20 | 21 | await it('gives a static SSR response', async ctx => { 22 | const response2 = await fetch(new URL('/about', devServerURL)).then(d => 23 | d.text() 24 | ) 25 | snapshot(ctx, response2) 26 | }) 27 | 28 | await it('has styles', async ctx => { 29 | const response = await fetch( 30 | new URL('/virtual:adex:global.css', devServerURL) 31 | ).then(d => d.text()) 32 | assert.ok(response.includes('.text-red-500')) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "adex-root", 3 | "type": "module", 4 | "private": true, 5 | "description": "", 6 | "keywords": [], 7 | "packageManager": "pnpm@9.5.0", 8 | "author": "reaper", 9 | "scripts": { 10 | "play": "pnpm --filter='playground' -r dev", 11 | "test": "pnpm -r test", 12 | "test:ci": "pnpm -r test:ci", 13 | "publish:ci": "lerna publish from-git --registry 'https://registry.npmjs.org' --yes", 14 | "next": "bumpp -r", 15 | "nuke": "pnpm -r exec rm -rvf node_modules", 16 | "fix": "pnpm -r run --if-present fix", 17 | "prepare": "husky" 18 | }, 19 | "license": "MIT", 20 | "prettier": "@barelyhuman/prettier-config", 21 | "devDependencies": { 22 | "@barelyhuman/prettier-config": "^1.1.0", 23 | "@lerna-lite/cli": "^4.0.0", 24 | "@lerna-lite/publish": "^4.0.0", 25 | "bumpp": "^9.4.1", 26 | "husky": "^9.1.7", 27 | "nano-staged": "^0.8.0", 28 | "prettier": "^3.5.3" 29 | }, 30 | "nano-staged": { 31 | "*.{js,md,ts,json}": [ 32 | "prettier --write" 33 | ] 34 | }, 35 | "pnpm": { 36 | "overrides": { 37 | "cross-spawn@>=7.0.0 <7.0.5": ">=7.0.5" 38 | } 39 | }, 40 | "version": "0.0.19" 41 | } 42 | -------------------------------------------------------------------------------- /adex/tests/minimal.spec.js: -------------------------------------------------------------------------------- 1 | import { after, before, describe, it } from 'node:test' 2 | 3 | import { devServerURL, launchDemoDevServer } from './utils.js' 4 | import { snapshot } from '@barelyhuman/node-snapshot' 5 | 6 | describe('devMode ssr minimal', async () => { 7 | let devServerProc 8 | before(async () => { 9 | devServerProc = await launchDemoDevServer('tests/fixtures/minimal') 10 | }) 11 | after(async () => { 12 | devServerProc.kill() 13 | }) 14 | 15 | await it('gives a non-static ssr response', async ctx => { 16 | const response = await fetch(devServerURL).then(d => d.text()) 17 | snapshot(ctx, response) 18 | }) 19 | 20 | await it('gives a static SSR response', async ctx => { 21 | const response2 = await fetch(new URL('/about', devServerURL)).then(d => 22 | d.text() 23 | ) 24 | snapshot(ctx, response2) 25 | }) 26 | 27 | await it('blank styles', async ctx => { 28 | const response = await fetch( 29 | new URL('/virtual:adex:global.css', devServerURL) 30 | ).then(d => d.text()) 31 | snapshot( 32 | ctx, 33 | response.replaceAll( 34 | '\\u0000virtual:adex:global.css', 35 | 'virtual:adex:global.css' 36 | ) 37 | ) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /adex/tests/minimal-no-ssr.spec.js: -------------------------------------------------------------------------------- 1 | import { snapshot } from '@barelyhuman/node-snapshot' 2 | import { after, before, describe, it } from 'node:test' 3 | 4 | import { devServerURL, launchDemoDevServer } from './utils.js' 5 | 6 | describe('devMode ssr minimal', async () => { 7 | let devServerProc 8 | before(async () => { 9 | devServerProc = await launchDemoDevServer('tests/fixtures/minimal-no-ssr') 10 | }) 11 | after(async () => { 12 | devServerProc.kill() 13 | }) 14 | 15 | await it('gives a static response', async ctx => { 16 | const response2 = await fetch(new URL('/', devServerURL)).then(d => 17 | d.text() 18 | ) 19 | snapshot(ctx, response2) 20 | }) 21 | 22 | await it('gives a static response', async ctx => { 23 | const response2 = await fetch(new URL('/about', devServerURL)).then(d => 24 | d.text() 25 | ) 26 | snapshot(ctx, response2) 27 | }) 28 | 29 | await it('blank styles', async ctx => { 30 | const response = await fetch( 31 | new URL('/virtual:adex:global.css', devServerURL) 32 | ).then(d => d.text()) 33 | snapshot( 34 | ctx, 35 | response.replaceAll( 36 | '\\u0000virtual:adex:global.css', 37 | 'virtual:adex:global.css' 38 | ) 39 | ) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /playground/src/pages/index.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'preact/hooks' 2 | import './local-index.css' 3 | import { Counter } from '../components/counter.tsx' 4 | 5 | export default function Page() { 6 | const [count, setCount] = useState(0) 7 | 8 | return ( 9 |
10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | About 18 |
19 |

Vite + Preact

20 |
21 | 27 |

28 | Edit src/app.jsx and save to test HMR 29 |

30 |
31 |

32 | Click on the Vite and Preact logos to learn more 33 |

34 |

35 | Here's an island{' '} 36 | 37 | 38 | 39 |

40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /playground/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/src/assets/preact.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adex/snapshots/tests/minimal-no-ssr.spec.snap.cjs: -------------------------------------------------------------------------------- 1 | exports["devMode ssr minimal > gives a static response 1"] = `" 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |

Hello World

17 | 18 | 19 | "` 20 | 21 | exports["devMode ssr minimal > gives a static response 2"] = `" 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |

About

37 | 38 | 39 | "` 40 | 41 | exports["devMode ssr minimal > blank styles 1"] = `"import { createHotContext as __vite__createHotContext } from "/@vite/client";import.meta.hot = __vite__createHotContext("/@id/__x00__virtual:adex:global.css");import { updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle } from "/@vite/client" 42 | const __vite__id = "virtual:adex:global.css" 43 | const __vite__css = "" 44 | __vite__updateStyle(__vite__id, __vite__css) 45 | import.meta.hot.accept() 46 | import.meta.hot.prune(() => __vite__removeStyle(__vite__id))"` 47 | 48 | -------------------------------------------------------------------------------- /adex/snapshots/tests/minimal.spec.snap.cjs: -------------------------------------------------------------------------------- 1 | exports["devMode ssr minimal > gives a non-static ssr response 1"] = `" 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |

Hello World

17 | 18 | 19 | "` 20 | 21 | exports["devMode ssr minimal > blank styles 1"] = `"import { createHotContext as __vite__createHotContext } from "/@vite/client";import.meta.hot = __vite__createHotContext("/@id/__x00__virtual:adex:global.css");import { updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle } from "/@vite/client" 22 | const __vite__id = "virtual:adex:global.css" 23 | const __vite__css = "" 24 | __vite__updateStyle(__vite__id, __vite__css) 25 | import.meta.hot.accept() 26 | import.meta.hot.prune(() => __vite__removeStyle(__vite__id))"` 27 | 28 | exports["devMode ssr minimal > gives a static SSR response 1"] = `" 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |

About

44 | 45 | 46 | "` 47 | 48 | -------------------------------------------------------------------------------- /adex/readme.md: -------------------------------------------------------------------------------- 1 | # adex 2 | 3 | > An easier way to build apps with [Vite](http://vitejs.dev) and 4 | > [Preact](http://preactjs.com) 5 | 6 | - [adex](#adex) 7 | - [About](#about) 8 | - [Highlights](#highlights) 9 | - [Setup](#setup) 10 | - [Docs](#docs) 11 | - [License](#license) 12 | 13 | ## About 14 | 15 | `adex` is just a vite plugin that adds in the required functionality to be able 16 | to build server rendered preact apps. 17 | 18 | ### Highlights 19 | 20 | - Unified routing for both backend and frontend 21 | - Tiny and Simple 22 | - Builds on existing tooling instead of adding yet another way to do things 23 | 24 | ## Setup 25 | 26 | As there are a few steps needed to get it running, it's not recommended to do 27 | this manually, instead use the 28 | [existing template to get started](https://github.com/barelyhuman/adex-default-template) 29 | 30 | If you still wish to set it up manually. 31 | 32 | **Create a preact based vite app** 33 | 34 | ```sh 35 | npm create vite@latest -- -t preact 36 | ``` 37 | 38 | **Add in the required deps** 39 | 40 | ```sh 41 | npm add -D adex 42 | ``` 43 | 44 | **Modify config to use adex** 45 | 46 | ```diff 47 | // vite.config.js 48 | import { defineConfig } from 'vite' 49 | import preact from '@preact/preset-vite' 50 | + import { adex } from 'adex' 51 | 52 | // https://vitejs.dev/config/ 53 | export default defineConfig({ 54 | plugins: [ 55 | + adex(), 56 | preact()], 57 | }) 58 | ``` 59 | 60 | **Remove the default preact files and add in basic structure for adex** 61 | 62 | ```sh 63 | rm -rf ./src/* index.html 64 | mkdir -p src/pages src/api 65 | touch src/global.css 66 | ``` 67 | 68 | And you are done. 69 | 70 | ## Docs 71 | 72 | [Docs →](https://barelyhuman.github.io/adex-docs/) 73 | 74 | ## License 75 | 76 | [MIT](/LICENSE) 77 | -------------------------------------------------------------------------------- /adex/src/hook.js: -------------------------------------------------------------------------------- 1 | const hookListeners = new Map() 2 | 3 | export const CONSTANTS = { 4 | beforePageRender: Symbol('before-page-render'), 5 | afterPageRender: Symbol('after-page-render'), 6 | beforeApiCall: Symbol('before-api-call'), 7 | afterApiCall: Symbol('after-api-call'), 8 | } 9 | 10 | /** 11 | * Register a hook handler for a given event 12 | * @param {symbol} eventName 13 | * @param {(context: any) => any|Promise} handler 14 | */ 15 | export function hook(eventName, handler) { 16 | const handlers = hookListeners.get(eventName) || [] 17 | handlers.push(handler) 18 | hookListeners.set(eventName, handlers) 19 | } 20 | 21 | /** 22 | * Register a hook to run before page render 23 | * @param {(context: any) => any|Promise} fn 24 | */ 25 | export function beforePageRender(fn) { 26 | hook(CONSTANTS.beforePageRender, fn) 27 | } 28 | 29 | /** 30 | * Register a hook to run after page render 31 | * @param {(context: any) => any|Promise} fn 32 | */ 33 | export function afterPageRender(fn) { 34 | hook(CONSTANTS.afterPageRender, fn) 35 | } 36 | 37 | /** 38 | * Register a hook to run before API call 39 | * @param {(context: any) => any|Promise} fn 40 | */ 41 | export function beforeAPICall(fn) { 42 | hook(CONSTANTS.beforeApiCall, fn) 43 | } 44 | 45 | /** 46 | * Register a hook to run after API call 47 | * @param {(context: any) => any|Promise} fn 48 | */ 49 | export function afterAPICall(fn) { 50 | hook(CONSTANTS.afterApiCall, fn) 51 | } 52 | 53 | /** 54 | * Emit an event to all registered hooks 55 | * @param {symbol} eventName 56 | * @param {any} data 57 | */ 58 | export async function emitToHooked(eventName, data) { 59 | const handlers = hookListeners.get(eventName) || [] 60 | for (let handler of handlers) { 61 | await handler(data) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /adex/runtime/app.js: -------------------------------------------------------------------------------- 1 | import { 2 | ErrorBoundary, 3 | LocationProvider, 4 | Route, 5 | Router, 6 | join, 7 | lazy, 8 | prerender as ssr, 9 | } from 'adex/router' 10 | import { h } from 'preact' 11 | 12 | const baseURL = import.meta.env.BASE_URL ?? '/' 13 | 14 | const normalizeURLPath = url => (url ? join(baseURL, url) : undefined) 15 | 16 | const removeBaseURL = url => { 17 | if (typeof url !== 'string') { 18 | return undefined 19 | } 20 | if (url.startsWith(baseURL)) { 21 | const result = url.slice(baseURL.length) 22 | return result === '' ? '/' : result 23 | } 24 | const baseURLWithoutSlash = baseURL.endsWith('/') 25 | ? baseURL.slice(0, -1) 26 | : baseURL 27 | if (url.startsWith(baseURLWithoutSlash)) { 28 | const result = url.slice(baseURLWithoutSlash.length) 29 | return result === '' ? '/' : result 30 | } 31 | return undefined 32 | } 33 | 34 | // @ts-expect-error injected by vite 35 | import { routes } from '~routes' 36 | 37 | function ComponentWrapper({ url = '' }) { 38 | return h( 39 | LocationProvider, 40 | { 41 | // @ts-expect-error LocationProvider doesn't expose this 42 | url: normalizeURLPath(url), 43 | }, 44 | h( 45 | ErrorBoundary, 46 | null, 47 | h( 48 | Router, 49 | null, 50 | routes.map(d => { 51 | const routePath = d.routePath.replace(/\/\*(\w+)/, '/:$1*') 52 | 53 | return h(Route, { 54 | path: normalizeURLPath(routePath), 55 | component: lazy(d.module), 56 | }) 57 | }) 58 | ) 59 | ) 60 | ) 61 | } 62 | 63 | export const App = ({ url = '' }) => { 64 | return h(ComponentWrapper, { 65 | url: url, 66 | }) 67 | } 68 | export const prerender = async ({ url }) => { 69 | const { html, links: discoveredLinks } = await ssr( 70 | h(ComponentWrapper, { 71 | url: url, 72 | }) 73 | ) 74 | return { 75 | html, 76 | links: new Set( 77 | [...discoveredLinks].map(d => removeBaseURL(d)).filter(Boolean) 78 | ), 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /adex/src/fonts.js: -------------------------------------------------------------------------------- 1 | import { createUnifont } from 'unifont' 2 | export { providers } from 'unifont' 3 | 4 | const fontVirtualId = 'virtual:adex:font.css' 5 | 6 | /** 7 | * @returns {import("vite").Plugin} 8 | */ 9 | export function fonts({ providers = [], families = [] } = {}) { 10 | return { 11 | name: 'adex-fonts', 12 | enforce: 'pre', 13 | resolveId(requestId) { 14 | if (requestId === fontVirtualId || requestId === '/' + fontVirtualId) { 15 | return `\0${fontVirtualId}` 16 | } 17 | }, 18 | async load(id) { 19 | if (id === `\0${fontVirtualId}`) { 20 | const unifont = await createUnifont([...providers]) 21 | const fontsToResolve = families.map(userFamily => { 22 | return unifont 23 | .resolveFontFace(userFamily.name, { 24 | weights: userFamily.weights ?? ['600'], 25 | styles: userFamily.styles ?? ['normal'], 26 | subsets: [], 27 | }) 28 | .then(resolvedFont => { 29 | const toUse = resolvedFont.fonts.filter( 30 | d => 31 | [] 32 | .concat(d.weight) 33 | .map(String) 34 | .find(d => userFamily.weights.includes(d)) ?? false 35 | ) 36 | 37 | return { fonts: toUse, name: userFamily.name } 38 | }) 39 | }) 40 | const fontImports = [] 41 | for await (let resolvedFont of fontsToResolve) { 42 | const fontFace = fontsToFontFace(resolvedFont) 43 | fontImports.push(fontFace.join('\n')) 44 | } 45 | return { 46 | code: fontImports.join('\n'), 47 | } 48 | } 49 | }, 50 | async transform(code, id) { 51 | const resolvedData = await this.resolve('virtual:adex:client') 52 | if (resolvedData?.id == id) { 53 | return { 54 | code: `import "${fontVirtualId}";\n` + code, 55 | } 56 | } 57 | }, 58 | } 59 | } 60 | 61 | function fontsToFontFace(resolvedFont) { 62 | return resolvedFont.fonts.map(fontDetails => { 63 | return fontDetails.src 64 | .map(x => { 65 | return `@font-face { 66 | font-family: '${resolvedFont.name}'; 67 | font-weight: ${[].concat(fontDetails.weight).join(',')}; 68 | font-style: ${[].concat(fontDetails.style).join(',')}; 69 | src: url(${x.url}) format('woff2'); 70 | }` 71 | }) 72 | .join('\n') 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /adex/runtime/pages.js: -------------------------------------------------------------------------------- 1 | import { pathToRegex } from 'adex/utils/isomorphic' 2 | 3 | const pages = import.meta.glob('#{__PLUGIN_PAGES_ROOT}') 4 | 5 | export const routes = normalizeRouteImports(pages, [ 6 | new RegExp('#{__PLUGIN_PAGES_ROOT_REGEX}'), 7 | '#{__PLUGIN_PAGES_ROOT_REGEX_REPLACER}', 8 | ]) 9 | 10 | // major bits taken from 11 | // https://github.com/cyco130/smf/blob/c4b601f48cd3b3b71bea6d76b52b9a85800813e4/smf/shared.ts#L22 12 | // as it's decently tested and aligns to what we want for our routing 13 | function compareRoutePatterns(a, b) { 14 | // Non-catch-all routes first: /foo before /$$rest 15 | const catchAll = Number(a.match(/\$\$(\w+)$/)) - Number(b.match(/\$\$(\w+)$/)) 16 | if (catchAll) return catchAll 17 | 18 | // Split into segments 19 | const aSegments = a.split('/') 20 | const bSegments = b.split('/') 21 | 22 | // Routes with fewer dynamic segments first: /foo/bar before /foo/$bar 23 | const dynamicSegments = 24 | aSegments.filter(segment => segment.includes('$')).length - 25 | bSegments.filter(segment => segment.includes('$')).length 26 | if (dynamicSegments) return dynamicSegments 27 | 28 | // Routes with fewer segments first: /foo/bar before /foo/bar 29 | const segments = aSegments.length - bSegments.length 30 | if (segments) return segments 31 | 32 | // Routes with earlier dynamic segments first: /foo/$bar before /$foo/bar 33 | for (let i = 0; i < aSegments.length; i++) { 34 | const aSegment = aSegments[i] 35 | const bSegment = bSegments[i] 36 | const dynamic = 37 | Number(aSegment.includes('$')) - Number(bSegment.includes('$')) 38 | if (dynamic) return dynamic 39 | 40 | // Routes with more dynamic subsegments at this position first: /foo/$a-$b before /foo/$a 41 | const subsegments = aSegment.split('$').length - bSegment.split('$').length 42 | if (subsegments) return subsegments 43 | } 44 | 45 | // Equal as far as we can tell 46 | return 0 47 | } 48 | 49 | function normalizeRouteImports(imports, baseKeyMatcher) { 50 | return Object.keys(imports) 51 | .sort(compareRoutePatterns) 52 | .map(route => { 53 | const routePath = simplifyPath(route).replace( 54 | baseKeyMatcher[0], 55 | baseKeyMatcher[1] 56 | ) 57 | 58 | const regex = pathToRegex(routePath) 59 | 60 | return { 61 | route, 62 | regex, 63 | routePath, 64 | module: imports[route], 65 | } 66 | }) 67 | } 68 | 69 | function simplifyPath(path) { 70 | return path 71 | .replace(/(\.(js|ts)x?)/, '') 72 | .replace(/index/, '/') 73 | .replace('//', '/') 74 | .replace(/\$\$/g, '*') 75 | .replace(/\$/g, ':') 76 | } 77 | -------------------------------------------------------------------------------- /adex/tests/utils.js: -------------------------------------------------------------------------------- 1 | import { spawn } from 'node:child_process' 2 | import path from 'node:path' 3 | import { fileURLToPath } from 'node:url' 4 | import { stripColors } from 'kolorist' 5 | 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 7 | export const dir = (...args) => path.join(__dirname, '..', ...args) 8 | 9 | export const devServerURL = new URL('http://localhost:5173/') 10 | 11 | /** 12 | * Wait for vite dev server to start 13 | * @param {import('node:child_process').ChildProcess} devServerProc 14 | * @returns {Promise} 15 | */ 16 | function waitForServerStart(devServerProc) { 17 | return new Promise((resolve, reject) => { 18 | function onError(err) { 19 | cleanup() 20 | reject(err) 21 | } 22 | 23 | /** @param {number | null} code */ 24 | function onClose(code) { 25 | cleanup() 26 | reject(new Error(`Dev server closed unexpectedly with code "${code}"`)) 27 | } 28 | 29 | let serverReady = false 30 | let stdout = '' 31 | /** @param {Buffer | string} chunk */ 32 | function onData(chunk) { 33 | try { 34 | /** @type {string} */ 35 | const data = Buffer.isBuffer(chunk) 36 | ? chunk.toString('utf-8') 37 | : chunk.toString() 38 | 39 | stdout += data 40 | 41 | const matchableStdout = stripColors(stdout) 42 | 43 | if (matchableStdout.match(/ready\sin\s[0-9]+\sms/g) != null) { 44 | serverReady = true 45 | } 46 | 47 | if (matchableStdout.match(/localhost:(\d+)/) != null) { 48 | const matchedPort = matchableStdout.match(/localhost:(\d+)/) 49 | devServerURL.port = matchedPort[1] 50 | if (serverReady) { 51 | cleanup() 52 | resolve() 53 | } 54 | } 55 | } catch (e) { 56 | reject(e) 57 | } 58 | } 59 | 60 | function cleanup() { 61 | try { 62 | devServerProc.stdout?.off('data', onData) 63 | devServerProc.off('error', onError) 64 | devServerProc.off('close', onClose) 65 | } catch (e) { 66 | reject(e) 67 | } 68 | } 69 | 70 | devServerProc.stdout?.on('data', onData) 71 | devServerProc.on('error', onError) 72 | devServerProc.on('close', onClose) 73 | }) 74 | } 75 | 76 | /** 77 | * @param {string} fixturePath 78 | */ 79 | export async function launchDemoDevServer(fixturePath) { 80 | console.log(`launching on ${dir(fixturePath)}`) 81 | /** @type {import('node:child_process').ChildProcess} */ 82 | const devServerProc = spawn( 83 | process.execPath, 84 | [dir('node_modules/vite/bin/vite.js')], 85 | { cwd: dir(fixturePath), stdio: 'pipe' } 86 | ) 87 | 88 | await waitForServerStart(devServerProc) 89 | 90 | // Ensure the server remains active until the test completes. 91 | process.once('exit', () => { 92 | devServerProc.kill() 93 | }) 94 | 95 | return devServerProc 96 | } 97 | -------------------------------------------------------------------------------- /adex/src/http.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {import("./http.js").IncomingMessage} req 3 | */ 4 | export function prepareRequest(req) { 5 | req.parseBodyJSON = async function () { 6 | return new Promise((resolve, reject) => { 7 | let jsonChunk = '' 8 | req.on('data', chunk => { 9 | jsonChunk += chunk 10 | }) 11 | req.on('error', err => { 12 | const oldStack = err.stack 13 | const newError = new Error( 14 | `failed to parse json body with error: ${err.message}` 15 | ) 16 | newError.stack = oldStack + newError.stack 17 | reject(newError) 18 | }) 19 | req.on('end', () => { 20 | try { 21 | const parsedJSON = JSON.parse(Buffer.from(jsonChunk).toString('utf8')) 22 | resolve(parsedJSON) 23 | } catch (err) { 24 | reject(err) 25 | } 26 | }) 27 | }) 28 | } 29 | } 30 | 31 | /** 32 | * @param {import("./http.js").ServerResponse} res 33 | */ 34 | export function prepareResponse(res) { 35 | res.html = data => { 36 | if (typeof data !== 'string') { 37 | throw new Error('[res.html] only accepts html string') 38 | } 39 | res.setHeader('content-type', 'text/html') 40 | res.write(data) 41 | res.end() 42 | } 43 | res.text = data => { 44 | let _data = data 45 | if (typeof data !== 'string') { 46 | _data = JSON.stringify(data) 47 | } 48 | res.setHeader('content-type', 'text/plain') 49 | res.write(_data) 50 | res.end() 51 | } 52 | res.json = data => { 53 | const str = JSON.stringify(data) 54 | res.setHeader('content-type', 'application/json') 55 | res.write(str) 56 | res.end() 57 | } 58 | res.redirect = (url, statusCode = 302) => { 59 | res.statusCode = statusCode 60 | res.setHeader('Location', url) 61 | } 62 | 63 | // HTTP Status helpers 64 | res.badRequest = message => { 65 | res.statusCode = 400 66 | if (message) { 67 | res.setHeader('content-type', 'application/json') 68 | res.write(JSON.stringify({ error: message })) 69 | } 70 | res.end() 71 | } 72 | 73 | res.unauthorized = message => { 74 | res.statusCode = 401 75 | if (message) { 76 | res.setHeader('content-type', 'application/json') 77 | res.write(JSON.stringify({ error: message })) 78 | } 79 | res.end() 80 | } 81 | 82 | res.forbidden = message => { 83 | res.statusCode = 403 84 | if (message) { 85 | res.setHeader('content-type', 'application/json') 86 | res.write(JSON.stringify({ error: message })) 87 | } 88 | res.end() 89 | } 90 | 91 | res.notFound = message => { 92 | res.statusCode = 404 93 | if (message) { 94 | res.setHeader('content-type', 'application/json') 95 | res.write(JSON.stringify({ error: message })) 96 | } 97 | res.end() 98 | } 99 | 100 | res.internalServerError = message => { 101 | res.statusCode = 500 102 | if (message) { 103 | res.setHeader('content-type', 'application/json') 104 | res.write(JSON.stringify({ error: message })) 105 | } 106 | res.end() 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /adex/tests/response-helpers.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { prepareResponse } from '../src/http.js' 4 | 5 | // Mock ServerResponse class 6 | class MockServerResponse { 7 | constructor() { 8 | this.statusCode = 200 9 | this.headers = {} 10 | this.writtenData = [] 11 | this.ended = false 12 | } 13 | 14 | setHeader(name, value) { 15 | this.headers[name] = value 16 | } 17 | 18 | write(data) { 19 | this.writtenData.push(data) 20 | } 21 | 22 | end() { 23 | this.ended = true 24 | } 25 | } 26 | 27 | describe('HTTP Response Helpers', () => { 28 | it('badRequest sets 400 status and ends response', () => { 29 | const res = new MockServerResponse() 30 | prepareResponse(res) 31 | 32 | res.badRequest() 33 | 34 | assert.strictEqual(res.statusCode, 400) 35 | assert.strictEqual(res.ended, true) 36 | assert.strictEqual(res.writtenData.length, 0) 37 | }) 38 | 39 | it('badRequest with message sets 400 status and sends JSON error', () => { 40 | const res = new MockServerResponse() 41 | prepareResponse(res) 42 | 43 | res.badRequest('Invalid input') 44 | 45 | assert.strictEqual(res.statusCode, 400) 46 | assert.strictEqual(res.ended, true) 47 | assert.strictEqual(res.headers['content-type'], 'application/json') 48 | assert.strictEqual( 49 | res.writtenData[0], 50 | JSON.stringify({ error: 'Invalid input' }) 51 | ) 52 | }) 53 | 54 | it('unauthorized sets 401 status', () => { 55 | const res = new MockServerResponse() 56 | prepareResponse(res) 57 | 58 | res.unauthorized('Authentication required') 59 | 60 | assert.strictEqual(res.statusCode, 401) 61 | assert.strictEqual(res.ended, true) 62 | assert.strictEqual(res.headers['content-type'], 'application/json') 63 | assert.strictEqual( 64 | res.writtenData[0], 65 | JSON.stringify({ error: 'Authentication required' }) 66 | ) 67 | }) 68 | 69 | it('forbidden sets 403 status', () => { 70 | const res = new MockServerResponse() 71 | prepareResponse(res) 72 | 73 | res.forbidden('Access denied') 74 | 75 | assert.strictEqual(res.statusCode, 403) 76 | assert.strictEqual(res.ended, true) 77 | assert.strictEqual(res.headers['content-type'], 'application/json') 78 | assert.strictEqual( 79 | res.writtenData[0], 80 | JSON.stringify({ error: 'Access denied' }) 81 | ) 82 | }) 83 | 84 | it('notFound sets 404 status', () => { 85 | const res = new MockServerResponse() 86 | prepareResponse(res) 87 | 88 | res.notFound('Resource not found') 89 | 90 | assert.strictEqual(res.statusCode, 404) 91 | assert.strictEqual(res.ended, true) 92 | assert.strictEqual(res.headers['content-type'], 'application/json') 93 | assert.strictEqual( 94 | res.writtenData[0], 95 | JSON.stringify({ error: 'Resource not found' }) 96 | ) 97 | }) 98 | 99 | it('internalServerError sets 500 status', () => { 100 | const res = new MockServerResponse() 101 | prepareResponse(res) 102 | 103 | res.internalServerError('Something went wrong') 104 | 105 | assert.strictEqual(res.statusCode, 500) 106 | assert.strictEqual(res.ended, true) 107 | assert.strictEqual(res.headers['content-type'], 'application/json') 108 | assert.strictEqual( 109 | res.writtenData[0], 110 | JSON.stringify({ error: 'Something went wrong' }) 111 | ) 112 | }) 113 | 114 | it('status helpers without message only set status and end', () => { 115 | const res = new MockServerResponse() 116 | prepareResponse(res) 117 | 118 | res.notFound() 119 | 120 | assert.strictEqual(res.statusCode, 404) 121 | assert.strictEqual(res.ended, true) 122 | assert.strictEqual(res.writtenData.length, 0) 123 | assert.strictEqual(res.headers['content-type'], undefined) 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /adex/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "adex", 3 | "version": "0.0.19", 4 | "description": "A minimal, fast framework for building apps with Vite and Preact. Simple SSR, routing, and utilities for modern web projects.", 5 | "keywords": [ 6 | "adex", 7 | "preact", 8 | "vite", 9 | "ssr", 10 | "framework", 11 | "minimal", 12 | "server", 13 | "node", 14 | "web", 15 | "routing" 16 | ], 17 | "homepage": "https://github.com/barelyhuman/adex#readme", 18 | "bugs": { 19 | "url": "https://github.com/barelyhuman/adex/issues", 20 | "email": "ahoy@barelyhuman.dev" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/barelyhuman/adex.git" 25 | }, 26 | "author": { 27 | "name": "barelyhuman", 28 | "email": "ahoy@barelyhuman.dev", 29 | "url": "https://reaper.is" 30 | }, 31 | "contributors": [ 32 | { 33 | "name": "barelyhuman", 34 | "email": "ahoy@barelyhuman.dev", 35 | "url": "https://reaper.is" 36 | } 37 | ], 38 | "license": "MIT", 39 | "engines": { 40 | "node": ">=18.0.0" 41 | }, 42 | "funding": { 43 | "type": "github", 44 | "url": "https://github.com/sponsors/barelyhuman" 45 | }, 46 | "type": "module", 47 | "main": "./src/vite.js", 48 | "module": "./src/vite.js", 49 | "exports": { 50 | "./package.json": "./package.json", 51 | "./router": { 52 | "types": "./src/router.d.ts", 53 | "import": "./src/router.js" 54 | }, 55 | "./app": { 56 | "types": "./src/app.d.ts", 57 | "import": "./src/app.js" 58 | }, 59 | "./utils/isomorphic": { 60 | "types": "./src/utils/isomorphic.d.ts", 61 | "import": "./src/utils/isomorphic.js" 62 | }, 63 | "./ssr": { 64 | "types": "./src/ssr.d.ts", 65 | "import": "./src/ssr.js" 66 | }, 67 | "./head": { 68 | "types": "./src/head.d.ts", 69 | "import": "./src/head.js" 70 | }, 71 | "./hook": { 72 | "types": "./src/hook.d.ts", 73 | "import": "./src/hook.js" 74 | }, 75 | "./http": { 76 | "types": "./src/http.d.ts", 77 | "import": "./src/http.js" 78 | }, 79 | "./env": { 80 | "types": "./src/env.d.ts", 81 | "import": "./src/env.js" 82 | }, 83 | "./fonts": { 84 | "types": "./src/fonts.d.ts", 85 | "import": "./src/fonts.js" 86 | }, 87 | ".": { 88 | "types": "./src/vite.d.ts", 89 | "import": "./src/vite.js" 90 | } 91 | }, 92 | "sideEffects": false, 93 | "publishConfig": { 94 | "access": "public", 95 | "registry": "https://registry.npmjs.org/" 96 | }, 97 | "files": [ 98 | "src", 99 | "runtime" 100 | ], 101 | "scripts": { 102 | "next": "bumpp", 103 | "test": "glob -c 'node --test --test-reporter=spec' tests/**/*.spec.js", 104 | "test:ci": "c8 pnpm test", 105 | "fix": "prettier --write ." 106 | }, 107 | "dependencies": { 108 | "@barelyhuman/tiny-use": "^0.0.2", 109 | "@dumbjs/preland": "^0.0.2", 110 | "bumpp": "^9.4.1", 111 | "dotenv": "^16.4.5", 112 | "hoofd": "^1.7.1", 113 | "mri": "^1.2.0", 114 | "node-stream-zip": "^1.15.0", 115 | "preact-iso": "^2.9.0", 116 | "preact-render-to-string": "^6.5.5", 117 | "regexparam": "^3.0.0", 118 | "sirv": "^2.0.4", 119 | "trouter": "^4.0.0", 120 | "unifont": "^0.0.2" 121 | }, 122 | "devDependencies": { 123 | "@barelyhuman/node-snapshot": "^1.0.2", 124 | "@barelyhuman/prettier-config": "^1.1.0", 125 | "@preact/preset-vite": "^2.8.2", 126 | "@types/node": "^20.14.10", 127 | "adex-adapter-node": "^0.0.17", 128 | "autoprefixer": "^10.4.19", 129 | "c8": "^10.1.3", 130 | "glob": "^11.0.1", 131 | "kolorist": "^1.8.0", 132 | "nano-staged": "^0.8.0", 133 | "prettier": "^3.5.3", 134 | "tailwindcss": "^3.4.4", 135 | "vite": "^5.3.1" 136 | }, 137 | "peerDependenciesMeta": { 138 | "adex-adapter-node": { 139 | "optional": true 140 | } 141 | }, 142 | "peerDependencies": { 143 | "@preact/preset-vite": ">=2.8.2", 144 | "adex-adapter-node": ">=0.0.15", 145 | "preact": "^10.22.0" 146 | }, 147 | "prettier": "@barelyhuman/prettier-config" 148 | } 149 | -------------------------------------------------------------------------------- /create-adex/cli.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import StreamZip from 'node-stream-zip' 4 | import { createWriteStream, existsSync } from 'node:fs' 5 | import { copyFile, mkdir, readdir, rm } from 'node:fs/promises' 6 | import { dirname, join, resolve } from 'node:path' 7 | import { Readable } from 'node:stream' 8 | import { parseArgs } from 'node:util' 9 | import { finished } from 'stream/promises' 10 | import k from 'kleur' 11 | 12 | const TMP_FOLDER_PREFIX = '.create-adex' 13 | const info = k.cyan 14 | const success = k.green 15 | const failure = k.red 16 | 17 | const TEMPLATES = { 18 | default: { 19 | name: 'adex-default-template', 20 | link: 'https://github.com/barelyhuman/adex-default-template/archive/refs/heads/main.zip', 21 | branch: 'main', 22 | }, 23 | } 24 | 25 | const flags = parseArgs({ 26 | allowPositionals: true, 27 | options: { 28 | help: { 29 | short: 'h', 30 | type: 'boolean', 31 | default: false, 32 | }, 33 | }, 34 | }) 35 | 36 | await main() 37 | 38 | async function main() { 39 | const { help } = flags.values 40 | const targetDir = flags.positionals[0] ?? '.' 41 | 42 | if (help) { 43 | showHelp() 44 | return 45 | } 46 | 47 | if (await isDirectoryNotEmpty(targetDir)) { 48 | console.log( 49 | `${failure(`[FAIL]`)} ${k.bold(targetDir)} is not empty, aborting initialization` 50 | ) 51 | return 52 | } 53 | 54 | console.log(info(`Initializing in ${targetDir}`)) 55 | const selectedTemplate = TEMPLATES.default 56 | const targetFilePath = await downloadFile( 57 | selectedTemplate.link, 58 | 'adex-template.zip' 59 | ) 60 | 61 | const unzipStream = new StreamZip.async({ file: targetFilePath }) 62 | const entries = await unzipStream.entries() 63 | 64 | const files = await extractFiles(entries, unzipStream) 65 | await copyFiles(files, targetDir, selectedTemplate) 66 | 67 | await rm(TMP_FOLDER_PREFIX, { recursive: true }) 68 | 69 | console.log(`\nNext Steps\n$ cd ${targetDir}\n$ npm i`) 70 | console.log(success('\nDone!\n')) 71 | } 72 | 73 | function showHelp() { 74 | console.log(` 75 | ${k.gray('[USAGE]')} 76 | 77 | $ adex [flags] [args] 78 | 79 | ${k.gray('[FLAGS]')} 80 | 81 | --help,-h Show this help 82 | --init PATH Initialize a new adex project at PATH (${k.gray('default: ./')}) 83 | `) 84 | } 85 | 86 | async function isDirectoryNotEmpty(targetDir) { 87 | if (existsSync(targetDir)) { 88 | const entries = await readdir(targetDir) 89 | return entries.filter(d => d !== '.tmp').length > 0 90 | } 91 | return false 92 | } 93 | 94 | async function downloadFile(url, fileName) { 95 | try { 96 | const res = await fetch(url) 97 | if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.statusText}`) 98 | 99 | if (!existsSync(TMP_FOLDER_PREFIX)) await mkdir(TMP_FOLDER_PREFIX) 100 | const destination = resolve(TMP_FOLDER_PREFIX, fileName) 101 | if (existsSync(destination)) { 102 | await rm(destination, { recursive: true }) 103 | } 104 | 105 | const fileStream = createWriteStream(destination, { flags: 'wx' }) 106 | await finished(Readable.fromWeb(res.body).pipe(fileStream)) 107 | return destination 108 | } catch (err) { 109 | console.error(failure(`[FAIL] Download: ${err.message}`)) 110 | process.exit(1) 111 | } 112 | } 113 | 114 | async function extractFiles(entries, unzipStream) { 115 | const files = await Promise.all( 116 | Object.values(entries).map(async entry => { 117 | const outFile = join(TMP_FOLDER_PREFIX, 'out', entry.name) 118 | await mkdir(dirname(outFile), { recursive: true }) 119 | await unzipStream.extract(entry, outFile) 120 | if (entry.isFile) { 121 | return outFile 122 | } 123 | }) 124 | ) 125 | return files.filter(Boolean) 126 | } 127 | 128 | async function copyFiles(files, targetDir, selectedTemplate) { 129 | const copyPromises = files.map(d => { 130 | const absolutePath = resolve(d) 131 | const dest = absolutePath.replace( 132 | join( 133 | process.cwd(), 134 | TMP_FOLDER_PREFIX, 135 | 'out', 136 | `${selectedTemplate.name}-${selectedTemplate.branch}` 137 | ), 138 | resolve(targetDir) 139 | ) 140 | return copyFileWithLogging(absolutePath, dest) 141 | }) 142 | 143 | await Promise.allSettled(copyPromises) 144 | } 145 | 146 | async function copyFileWithLogging(source, dest) { 147 | try { 148 | await mkdir(dirname(dest), { recursive: true }) 149 | await copyFile(source, dest) 150 | console.log(`${k.gray('[Created]')} ${k.white(dest)}`) 151 | } catch (err) { 152 | console.log(failure(`[FAIL] Creation: ${dest}, ${err.message}`)) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /adex/runtime/handler.js: -------------------------------------------------------------------------------- 1 | import { CONSTANTS, emitToHooked } from 'adex/hook' 2 | import { prepareRequest, prepareResponse } from 'adex/http' 3 | import { toStatic } from 'adex/ssr' 4 | import { renderToStringAsync } from 'adex/utils/isomorphic' 5 | import { h } from 'preact' 6 | 7 | // @ts-expect-error injected by vite 8 | import { App } from 'virtual:adex:client' 9 | 10 | // @ts-expect-error injected by vite 11 | import { routes as apiRoutes } from '~apiRoutes' 12 | // @ts-expect-error injected by vite 13 | import { routes as pageRoutes } from '~routes' 14 | 15 | const html = String.raw 16 | 17 | export async function handler(req, res) { 18 | res.statusCode = 200 19 | 20 | prepareRequest(req) 21 | prepareResponse(res) 22 | 23 | const [url, search] = req.url.split('?') 24 | const baseURL = normalizeRequestUrl(url) 25 | 26 | const { metas, links, title, lang } = toStatic() 27 | 28 | if (baseURL.startsWith('/api') || baseURL.startsWith('api')) { 29 | const matchedInAPI = apiRoutes.find(d => { 30 | return d.regex.pattern.test(baseURL) 31 | }) 32 | if (matchedInAPI) { 33 | const module = await matchedInAPI.module() 34 | const routeParams = getRouteParams(baseURL, matchedInAPI) 35 | req.params = routeParams 36 | const modifiableContext = { 37 | req: req, 38 | res: res, 39 | } 40 | await emitToHooked(CONSTANTS.beforeApiCall, modifiableContext) 41 | const handlerFn = 42 | 'default' in module ? module.default : (_, res) => res.end() 43 | const serverHandler = async (req, res) => { 44 | await handlerFn(req, res) 45 | await emitToHooked(CONSTANTS.afterApiCall, { req, res }) 46 | } 47 | return { 48 | serverHandler, 49 | } 50 | } 51 | return { 52 | serverHandler: async (_, res) => { 53 | res.statusCode = 404 54 | res.end('Not found') 55 | await emitToHooked(CONSTANTS.afterApiCall, { req, res }) 56 | }, 57 | } 58 | } 59 | 60 | const matchedInPages = pageRoutes.find(d => { 61 | return d.regex.pattern.test(baseURL) 62 | }) 63 | 64 | if (matchedInPages) { 65 | const routeParams = getRouteParams(baseURL, matchedInPages) 66 | 67 | // @ts-expect-error 68 | global.location = new URL(req.url, 'http://localhost') 69 | 70 | const modifiableContext = { 71 | req: req, 72 | } 73 | await emitToHooked(CONSTANTS.beforePageRender, modifiableContext) 74 | 75 | const rendered = await renderToStringAsync( 76 | h(App, { url: modifiableContext.req.url }), 77 | {} 78 | ) 79 | 80 | let htmlString = HTMLTemplate({ 81 | metas, 82 | links, 83 | title, 84 | lang, 85 | entryPage: matchedInPages.route, 86 | routeParams: Buffer.from(JSON.stringify(routeParams), 'utf8').toString( 87 | 'base64' 88 | ), 89 | body: rendered, 90 | }) 91 | 92 | modifiableContext.html = htmlString 93 | await emitToHooked(CONSTANTS.afterPageRender, modifiableContext) 94 | htmlString = modifiableContext.html 95 | return { 96 | html: htmlString, 97 | pageRoute: matchedInPages.route, 98 | } 99 | } 100 | 101 | return { 102 | html: HTMLTemplate({ 103 | metas, 104 | links, 105 | title, 106 | lang, 107 | body: '404 | Not Found', 108 | }), 109 | } 110 | } 111 | 112 | function HTMLTemplate({ 113 | metas = [], 114 | links = [], 115 | title = '', 116 | lang = '', 117 | entryPage = '', 118 | routeParams = {}, 119 | body = '', 120 | }) { 121 | const headString = stringify(title, metas, links) 122 | return html` 123 | 124 | 125 | 126 | 127 | ${headString} 128 | 129 | 130 |
${body}
131 | 132 | 133 | ` 134 | } 135 | 136 | function getRouteParams(baseURL, matchedRoute) { 137 | const matchedParams = baseURL.match(matchedRoute.regex.pattern) 138 | const routeParams = regexToParams(matchedRoute, matchedParams) 139 | return routeParams 140 | } 141 | 142 | function regexToParams(matchedRoute, regexMatchGroup) { 143 | return matchedRoute.regex.keys.reduce((acc, key, index) => { 144 | acc[key] = regexMatchGroup[index + 1] 145 | return acc 146 | }, {}) 147 | } 148 | 149 | const stringify = (title, metas, links) => { 150 | const stringifyTag = (tagName, tags) => 151 | tags.reduce((acc, tag) => { 152 | return `${acc}<${tagName}${Object.keys(tag).reduce( 153 | (properties, key) => `${properties} ${key}="${tag[key]}"`, 154 | '' 155 | )}>` 156 | }, '') 157 | 158 | return ` 159 | ${title} 160 | 161 | ${stringifyTag('meta', metas)} 162 | ${stringifyTag('link', links)} 163 | ` 164 | } 165 | 166 | function normalizeRequestUrl(url) { 167 | return url.replace(/\/(index\.html)$/, '/') 168 | } 169 | -------------------------------------------------------------------------------- /packages/adapters/node/lib/index.js: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'node:fs' 2 | import http from 'node:http' 3 | 4 | import { sirv, useMiddleware } from 'adex/ssr' 5 | 6 | import { handler } from 'virtual:adex:handler' 7 | 8 | let islandMode = false 9 | 10 | function createHandler({ manifests, paths }) { 11 | const serverAssets = sirv(paths.assets, { 12 | maxAge: 31536000, 13 | immutable: true, 14 | onNoMatch: defaultHandler, 15 | }) 16 | 17 | let islandsWereGenerated = existsSync(paths.islands) 18 | 19 | // @ts-ignore 20 | let islandAssets = (req, res, next) => { 21 | next() 22 | } 23 | 24 | if (islandsWereGenerated) { 25 | islandMode = true 26 | islandAssets = sirv(paths.islands, { 27 | maxAge: 31536000, 28 | immutable: true, 29 | onNoMatch: defaultHandler, 30 | }) 31 | } 32 | 33 | let clientWasGenerated = existsSync(paths.client) 34 | 35 | // @ts-ignore 36 | let clientAssets = (req, res, next) => { 37 | next() 38 | } 39 | 40 | if (clientWasGenerated) { 41 | clientAssets = sirv(paths.client, { 42 | maxAge: 31536000, 43 | immutable: true, 44 | onNoMatch: defaultHandler, 45 | }) 46 | } 47 | 48 | async function defaultHandler(req, res) { 49 | const { html: template, pageRoute, serverHandler } = await handler(req, res) 50 | if (serverHandler) { 51 | return serverHandler(req, res) 52 | } 53 | 54 | const templateWithDeps = addDependencyAssets( 55 | template, 56 | pageRoute, 57 | manifests.server, 58 | manifests.client 59 | ) 60 | 61 | const finalHTML = templateWithDeps 62 | res.setHeader('content-type', 'text/html') 63 | res.write(finalHTML) 64 | res.end() 65 | } 66 | 67 | return useMiddleware( 68 | async (req, res, next) => { 69 | // @ts-expect-error shared-state between the middlewares 70 | req.__originalUrl = req.url 71 | // @ts-expect-error shared-state between the middlewares 72 | req.url = req.__originalUrl.replace(/(\/?assets\/?)/, '/') 73 | return serverAssets(req, res, next) 74 | }, 75 | async (req, res, next) => { 76 | // @ts-expect-error shared-state between the middlewares 77 | req.url = req.__originalUrl.replace(/(\/?islands\/?)/, '/') 78 | return islandAssets(req, res, next) 79 | }, 80 | async (req, res, next) => { 81 | return clientAssets(req, res, next) 82 | }, 83 | async (req, res) => { 84 | // @ts-expect-error shared-state between the middlewares 85 | req.url = req.__originalUrl 86 | return defaultHandler(req, res) 87 | } 88 | ) 89 | } 90 | 91 | // function parseManifest(manifestString) { 92 | // try { 93 | // const manifestJSON = JSON.parse(manifestString) 94 | // return manifestJSON 95 | // } catch (err) { 96 | // return {} 97 | // } 98 | // } 99 | 100 | // function getServerManifest() { 101 | // const manifestPath = join(__dirname, 'manifest.json') 102 | // if (existsSync(manifestPath)) { 103 | // const manifestFile = readFileSync(manifestPath, 'utf8') 104 | // return parseManifest(manifestFile) 105 | // } 106 | // return {} 107 | // } 108 | 109 | // function getClientManifest() { 110 | // const manifestPath = join(__dirname, '../client/manifest.json') 111 | // if (existsSync(manifestPath)) { 112 | // const manifestFile = readFileSync(manifestPath, 'utf8') 113 | // return parseManifest(manifestFile) 114 | // } 115 | // return {} 116 | // } 117 | 118 | function manifestToHTML(manifest, filePath) { 119 | let links = [] 120 | let scripts = [] 121 | 122 | // TODO: move it up the chain 123 | const rootServerFile = 'virtual:adex:server' 124 | // if root manifest, also add it's css imports in 125 | if (manifest[rootServerFile]) { 126 | const graph = manifest[rootServerFile] 127 | links = links.concat( 128 | (graph.css || []).map( 129 | d => 130 | `` 134 | ) 135 | ) 136 | } 137 | 138 | // TODO: move it up the chain 139 | const rootClientFile = 'virtual:adex:client' 140 | // if root manifest, also add it's css imports in 141 | if (!islandMode && manifest[rootClientFile]) { 142 | const graph = manifest[rootClientFile] 143 | links = links.concat( 144 | (graph.css || []).map( 145 | d => 146 | `` 150 | ) 151 | ) 152 | } 153 | 154 | if (manifest[filePath]) { 155 | const graph = manifest[filePath] 156 | links = links.concat( 157 | (graph.css || []).map( 158 | d => 159 | `` 163 | ) 164 | ) 165 | const depsHasCSS = (manifest[filePath].imports || []) 166 | .map(d => manifest[d]) 167 | .filter(d => d.css?.length) 168 | 169 | if (depsHasCSS.length) { 170 | links = links.concat( 171 | depsHasCSS.map(d => 172 | d.css 173 | .map( 174 | p => 175 | `` 179 | ) 180 | .join('\n') 181 | ) 182 | ) 183 | } 184 | 185 | scripts = scripts.concat( 186 | `` 187 | ) 188 | } 189 | return { 190 | scripts, 191 | links, 192 | } 193 | } 194 | 195 | function addDependencyAssets( 196 | template, 197 | pageRoute, 198 | serverManifest, 199 | clientManifest 200 | ) { 201 | if (!pageRoute) { 202 | return template 203 | } 204 | 205 | const filePath = pageRoute.startsWith('/') ? pageRoute.slice(1) : pageRoute 206 | 207 | const { links: serverLinks } = manifestToHTML(serverManifest, filePath) 208 | 209 | const { links: clientLinks, scripts: clientScripts } = manifestToHTML( 210 | clientManifest, 211 | filePath 212 | ) 213 | 214 | const links = [...serverLinks, ...clientLinks] 215 | const scripts = [...clientScripts] 216 | 217 | return template.replace( 218 | '', 219 | links.join('\n') + scripts.join('\n') + '' 220 | ) 221 | } 222 | 223 | export const createServer = ({ 224 | port = '3000', 225 | host = '127.0.0.1', 226 | adex = { 227 | manifests: { 228 | server: {}, 229 | client: {}, 230 | }, 231 | paths: {}, 232 | }, 233 | } = {}) => { 234 | const handler = createHandler(adex) 235 | const server = http.createServer(handler) 236 | 237 | return { 238 | run() { 239 | return server.listen(port, host, () => { 240 | console.log(`Listening on ${host}:${port}`) 241 | }) 242 | }, 243 | fetch: undefined, 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /adex/src/vite.js: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_TRANSPILED_IDENTIFIERS, 3 | IMPORT_PATH_PLACEHOLDER, 4 | findIslands, 5 | generateClientTemplate, 6 | getIslandName, 7 | getServerTemplatePlaceholder, 8 | injectIslandAST, 9 | isFunctionIsland, 10 | readSourceFile, 11 | } from '@dumbjs/preland' 12 | import { addImportToAST, codeFromAST } from '@dumbjs/preland/ast' 13 | import preact from '@preact/preset-vite' 14 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' 15 | import { readFile, rm } from 'fs/promises' 16 | import { dirname, join, resolve } from 'path' 17 | import { fileURLToPath } from 'url' 18 | import { build, mergeConfig } from 'vite' 19 | import { fonts as addFontsPlugin } from './fonts.js' 20 | 21 | const __dirname = dirname(fileURLToPath(import.meta.url)) 22 | const cwd = process.cwd() 23 | const islandsDir = join(cwd, '.islands') 24 | let runningIslandBuild = false 25 | 26 | const adapterMap = { 27 | node: 'adex-adapter-node', 28 | } 29 | 30 | /** 31 | * @param {import("./vite.js").AdexOptions} [options] 32 | * @returns {(import("vite").Plugin)[]} 33 | */ 34 | export function adex({ 35 | fonts, 36 | islands = false, 37 | ssr = true, 38 | adapter: adapter = 'node', 39 | } = {}) { 40 | // @ts-expect-error probably because of the `.filter` 41 | return [ 42 | preactPages({ 43 | root: '/src/pages', 44 | id: '~routes', 45 | }), 46 | preactPages({ 47 | root: '/src/api', 48 | id: '~apiRoutes', 49 | replacer: '/api', 50 | }), 51 | createUserDefaultVirtualModule( 52 | 'virtual:adex:global.css', 53 | '', 54 | 'src/global.css' 55 | ), 56 | createUserDefaultVirtualModule( 57 | 'virtual:adex:client', 58 | readFileSync(join(__dirname, '../runtime/client.js'), 'utf8'), 59 | '/src/_app' 60 | ), 61 | createVirtualModule( 62 | 'adex/app', 63 | readFileSync(join(__dirname, '../runtime/app.js'), 'utf8') 64 | ), 65 | createVirtualModule( 66 | 'virtual:adex:handler', 67 | readFileSync(join(__dirname, '../runtime/handler.js'), 'utf8') 68 | ), 69 | createVirtualModule( 70 | 'virtual:adex:server', 71 | `import { createServer } from '${adapterMap[adapter]}' 72 | import { dirname, join } from 'node:path' 73 | import { fileURLToPath } from 'node:url' 74 | import { existsSync, readFileSync } from 'node:fs' 75 | import { env } from 'adex/env' 76 | 77 | import 'virtual:adex:font.css' 78 | import 'virtual:adex:global.css' 79 | 80 | const __dirname = dirname(fileURLToPath(import.meta.url)) 81 | 82 | const PORT = parseInt(env.get('PORT', '3000'), 10) 83 | const HOST = env.get('HOST', 'localhost') 84 | 85 | const paths = { 86 | assets: join(__dirname, './assets'), 87 | islands: join(__dirname, './islands'), 88 | client: join(__dirname, '../client'), 89 | } 90 | 91 | function getServerManifest() { 92 | const manifestPath = join(__dirname, 'manifest.json') 93 | if (existsSync(manifestPath)) { 94 | const manifestFile = readFileSync(manifestPath, 'utf8') 95 | return parseManifest(manifestFile) 96 | } 97 | return {} 98 | } 99 | 100 | function getClientManifest() { 101 | const manifestPath = join(__dirname, '../client/manifest.json') 102 | if (existsSync(manifestPath)) { 103 | const manifestFile = readFileSync(manifestPath, 'utf8') 104 | return parseManifest(manifestFile) 105 | } 106 | return {} 107 | } 108 | 109 | function parseManifest(manifestString) { 110 | try { 111 | const manifestJSON = JSON.parse(manifestString) 112 | return manifestJSON 113 | } catch (err) { 114 | return {} 115 | } 116 | } 117 | 118 | const server = createServer({ 119 | port: PORT, 120 | host: HOST, 121 | adex:{ 122 | manifests:{server:getServerManifest(),client:getClientManifest()}, 123 | paths, 124 | } 125 | }) 126 | 127 | if ('run' in server) { 128 | server.run() 129 | } 130 | 131 | export default server.fetch 132 | ` 133 | ), 134 | addFontsPlugin(fonts), 135 | adexDevServer({ islands }), 136 | adexBuildPrep({ islands }), 137 | adexClientBuilder({ ssr, islands }), 138 | islands && adexIslandsBuilder(), 139 | 140 | // SSR/Render Server Specific plugins 141 | ssr && adexServerBuilder({ fonts, adapter, islands }), 142 | ].filter(Boolean) 143 | } 144 | 145 | /** 146 | * @returns {import("vite").Plugin} 147 | */ 148 | function adexClientBuilder({ ssr = true, islands = false } = {}) { 149 | let baseUrl = '/' 150 | return { 151 | name: 'adex-client-builder', 152 | config(cfg) { 153 | const out = cfg.build.outDir ?? 'dist' 154 | return { 155 | appType: 'custom', 156 | build: { 157 | write: !islands, 158 | manifest: 'manifest.json', 159 | outDir: join(out, 'client'), 160 | rollupOptions: { 161 | input: 'virtual:adex:client', 162 | }, 163 | output: { 164 | entryFileNames: '[name]-[hash].js', 165 | format: 'esm', 166 | }, 167 | }, 168 | } 169 | }, 170 | configResolved(cfg) { 171 | baseUrl = cfg.base 172 | return 173 | }, 174 | generateBundle(opts, bundle) { 175 | let clientEntryPath 176 | for (const key in bundle) { 177 | if ( 178 | ['_virtual_adex_client', '_app'].includes(bundle[key].name) && 179 | 'isEntry' in bundle[key] && 180 | bundle[key].isEntry 181 | ) { 182 | clientEntryPath = key 183 | } 184 | } 185 | 186 | const links = [ 187 | // @ts-expect-error invalid types by vite? figure this out 188 | ...(bundle[clientEntryPath]?.viteMetadata?.importedCss ?? new Set()), 189 | ].map(d => { 190 | return `` 191 | }) 192 | 193 | if (!ssr) { 194 | this.emitFile({ 195 | type: 'asset', 196 | fileName: 'index.html', 197 | source: ` 198 | 199 | ${links.join('\n')} 200 | 201 | 202 |
203 | 204 | 205 | `, 206 | }) 207 | } 208 | }, 209 | } 210 | } 211 | 212 | /** 213 | * @returns {import("vite").Plugin} 214 | */ 215 | function adexBuildPrep({ islands = false }) { 216 | return { 217 | name: 'adex-build-prep', 218 | apply: 'build', 219 | async configResolved(config) { 220 | if (!islands) return 221 | 222 | // Making it order safe 223 | const outDirNormalized = config.build.outDir.endsWith('/server') 224 | ? dirname(config.build.outDir) 225 | : config.build.outDir 226 | 227 | // remove the `client` dir if islands are on, 228 | // we don't generate the client assets and 229 | // their existence adds the client entry into the bundle 230 | const clientDir = join(outDirNormalized, 'client') 231 | if (!existsSync(clientDir)) return 232 | await rm(clientDir, { 233 | recursive: true, 234 | force: true, 235 | }) 236 | }, 237 | } 238 | } 239 | 240 | /** 241 | * @returns {import("vite").Plugin[]} 242 | */ 243 | function adexIslandsBuilder() { 244 | const clientVirtuals = {} 245 | let isBuild = false 246 | let outDir 247 | return [ 248 | { 249 | name: 'adex-islands', 250 | enforce: 'pre', 251 | config(d, e) { 252 | outDir = d.build?.outDir ?? 'dist' 253 | isBuild = e.command === 'build' 254 | }, 255 | transform(code, id, viteEnv) { 256 | if (!/\.(js|ts)x$/.test(id)) return 257 | 258 | // if being imported by the client, don't send 259 | // back the transformed server code, send the 260 | // original component 261 | if (!viteEnv?.ssr) return 262 | 263 | const islands = findIslands(readSourceFile(id), { 264 | isFunctionIsland: node => 265 | isFunctionIsland(node, { 266 | transpiledIdentifiers: 267 | DEFAULT_TRANSPILED_IDENTIFIERS.concat('_jsxDEV'), 268 | }), 269 | }) 270 | if (!islands.length) return 271 | 272 | islands.forEach(node => { 273 | //@ts-expect-error FIX: in preland 274 | injectIslandAST(node.ast, node) 275 | const clientCode = generateClientTemplate(node.id).replace( 276 | IMPORT_PATH_PLACEHOLDER, 277 | id 278 | ) 279 | 280 | mkdirSync(islandsDir, { recursive: true }) 281 | writeFileSync( 282 | join(islandsDir, getIslandName(node.id) + '.js'), 283 | clientCode, 284 | 'utf8' 285 | ) 286 | 287 | clientVirtuals[node.id] = clientCode 288 | }) 289 | 290 | const addImport = addImportToAST(islands[0].ast) 291 | addImport('h', 'preact', { named: true }) 292 | addImport('Fragment', 'preact', { named: true }) 293 | 294 | let serverTemplateCode = codeFromAST(islands[0].ast) 295 | islands.forEach(island => { 296 | serverTemplateCode = serverTemplateCode.replace( 297 | getServerTemplatePlaceholder(island.id), 298 | !isBuild 299 | ? `/virtual:adex:island-${island.id}` 300 | : `/islands/${getIslandName(island.id) + '.js'}` 301 | ) 302 | }) 303 | 304 | return { 305 | code: serverTemplateCode, 306 | } 307 | }, 308 | }, 309 | { 310 | name: 'adex-island-builds', 311 | enforce: 'post', 312 | writeBundle: { 313 | sequential: true, 314 | async handler() { 315 | if (Object.keys(clientVirtuals).length === 0) return 316 | if (runningIslandBuild) return 317 | 318 | await build( 319 | mergeConfig( 320 | {}, 321 | { 322 | configFile: false, 323 | plugins: [preact()], 324 | build: { 325 | ssr: false, 326 | outDir: join(outDir, 'islands'), 327 | emptyOutDir: true, 328 | rollupOptions: { 329 | output: { 330 | format: 'esm', 331 | entryFileNames: '[name].js', 332 | }, 333 | input: Object.fromEntries( 334 | Object.entries(clientVirtuals).map(([k, v]) => { 335 | const key = getIslandName(k) 336 | return [key, join(islandsDir, key + '.js')] 337 | }) 338 | ), 339 | }, 340 | }, 341 | } 342 | ) 343 | ) 344 | }, 345 | }, 346 | }, 347 | { 348 | name: 'adex-island-virtuals', 349 | resolveId(id) { 350 | if ( 351 | id.startsWith('virtual:adex:island') || 352 | id.startsWith('/virtual:adex:island') 353 | ) { 354 | return `\0${id}` 355 | } 356 | }, 357 | load(id) { 358 | if ( 359 | (id.startsWith('\0') && id.startsWith('\0virtual:adex:island')) || 360 | id.startsWith('\0/virtual:adex:island') 361 | ) { 362 | const compName = id 363 | .replace('\0', '') 364 | .replace(/\/?(virtual\:adex\:island\-)/, '') 365 | 366 | if (clientVirtuals[compName]) { 367 | return { 368 | code: clientVirtuals[compName], 369 | } 370 | } 371 | } 372 | }, 373 | }, 374 | ] 375 | } 376 | 377 | /** 378 | * @returns {import("vite").Plugin} 379 | */ 380 | export function createVirtualModule(id, content) { 381 | return { 382 | name: `adex-virtual-${id}`, 383 | enforce: 'pre', 384 | resolveId(requestId) { 385 | if (requestId === id || requestId === '/' + id) { 386 | return `\0${id}` 387 | } 388 | }, 389 | load(requestId) { 390 | if (requestId === `\0${id}`) { 391 | return content 392 | } 393 | }, 394 | } 395 | } 396 | 397 | /** 398 | * @returns {import("vite").Plugin} 399 | */ 400 | function createUserDefaultVirtualModule(id, content, userPath) { 401 | return { 402 | name: `adex-virtual-user-default-${id}`, 403 | enforce: 'pre', 404 | async resolveId(requestId) { 405 | if ( 406 | requestId === id || 407 | requestId === '/' + id || 408 | requestId === userPath 409 | ) { 410 | const userPathResolved = await this.resolve(userPath) 411 | return userPathResolved ?? `\0${id}` 412 | } 413 | }, 414 | load(requestId) { 415 | if (requestId === `\0${id}`) { 416 | return content 417 | } 418 | }, 419 | } 420 | } 421 | 422 | /** 423 | * @param {object} options 424 | * @param {import('vite').UserConfig} options.config 425 | * @returns {import("vite").Plugin} 426 | */ 427 | function adexClientSSRBuilder(opts) { 428 | let options 429 | return { 430 | name: 'adex-client', 431 | enforce: 'post', 432 | config(conf) { 433 | return { 434 | appType: 'custom', 435 | build: { 436 | outDir: join(conf.build.outDir ?? 'dist', 'client'), 437 | emptyOutDir: true, 438 | ssr: false, 439 | manifest: 'manifest.json', 440 | rollupOptions: { 441 | input: { 442 | index: 'virtual:adex:client', 443 | }, 444 | output: { 445 | entryFileNames: '[name]-[hash].js', 446 | format: 'esm', 447 | }, 448 | }, 449 | }, 450 | } 451 | }, 452 | configResolved(config) { 453 | options = config 454 | }, 455 | closeBundle() { 456 | // process.nextTick(async () => { 457 | // const usablePlugins = options.plugins 458 | // .filter(d => !d.name.startsWith('vite:')) 459 | // .filter(d => !d.name.startsWith('adex-') || d.name === 'adex-fonts') 460 | // await build( 461 | // mergeConfig(opts, { 462 | // plugins: [ 463 | // ...usablePlugins, 464 | // createVirtualModule( 465 | // 'virtual:adex:client', 466 | // readFileSync(join(__dirname, '../runtime/client.js'), 'utf8') 467 | // ), 468 | // createUserDefaultVirtualModule( 469 | // 'virtual:adex:index.html', 470 | // '', 471 | // 'src/index.html' 472 | // ), 473 | // preact({ prefreshEnabled: false }), 474 | // ], 475 | // build: { 476 | // outDir: 'dist/client', 477 | // emptyOutDir: true, 478 | // ssr: false, 479 | // manifest: 'manifest.json', 480 | // rollupOptions: { 481 | // input: { 482 | // index: 'virtual:adex:client', 483 | // }, 484 | // output: { 485 | // entryFileNames: '[name]-[hash].js', 486 | // format: 'esm', 487 | // }, 488 | // }, 489 | // }, 490 | // }) 491 | // ) 492 | // }) 493 | }, 494 | } 495 | } 496 | 497 | /** 498 | * @returns {import("vite").Plugin} 499 | */ 500 | function adexDevServer({ islands = false } = {}) { 501 | const devCSSMap = new Map() 502 | let cfg 503 | return { 504 | name: 'adex-dev-server', 505 | apply: 'serve', 506 | enforce: 'pre', 507 | config() { 508 | return { 509 | ssr: { 510 | noExternal: ['adex/app'], 511 | }, 512 | } 513 | }, 514 | configResolved(_cfg) { 515 | cfg = _cfg 516 | }, 517 | async resolveId(id, importer, meta) { 518 | if (id.endsWith('.css')) { 519 | if (!importer) return 520 | const importerFromRoot = importer.replace(resolve(cfg.root), '') 521 | const resolvedCss = await this.resolve(id, importer, meta) 522 | if (resolvedCss) { 523 | devCSSMap.set( 524 | importerFromRoot, 525 | (devCSSMap.get(importer) ?? []).concat(resolvedCss.id) 526 | ) 527 | } 528 | return 529 | } 530 | }, 531 | configureServer(server) { 532 | return () => { 533 | server.middlewares.use(async function (req, res, next) { 534 | const module = await server.ssrLoadModule('virtual:adex:handler') 535 | if (!module) { 536 | return next() 537 | } 538 | try { 539 | const { html, serverHandler, pageRoute } = await module.handler( 540 | req, 541 | res 542 | ) 543 | if (serverHandler) { 544 | return serverHandler(req, res) 545 | } 546 | const cssLinks = devCSSMap.get(pageRoute) ?? [] 547 | let renderedHTML = html.replace( 548 | '', 549 | ` 550 | 551 | ${cssLinks.map(d => { 552 | return `` 553 | })} 554 | 555 | ` 556 | ) 557 | if (!islands) { 558 | renderedHTML = html.replace( 559 | '', 560 | `` 561 | ) 562 | } 563 | const finalRenderedHTML = await server.transformIndexHtml( 564 | req.url, 565 | renderedHTML 566 | ) 567 | res.setHeader('content-type', 'text/html') 568 | res.write(finalRenderedHTML) 569 | return res.end() 570 | } catch (err) { 571 | server.ssrFixStacktrace(err) 572 | next(err) 573 | } 574 | }) 575 | } 576 | }, 577 | } 578 | } 579 | 580 | /** 581 | * @param {object} options 582 | * @param {import("./fonts.js").Options} options.fonts 583 | * @param {string} options.adapter 584 | * @param {boolean} options.islands 585 | * @returns {import("vite").Plugin} 586 | */ 587 | function adexServerBuilder({ fonts, adapter, islands }) { 588 | let input = 'src/entry-server.js' 589 | let cfg 590 | return { 591 | name: `adex-server`, 592 | enforce: 'pre', 593 | apply: 'build', 594 | config(conf, env) { 595 | if (env.command === 'build') { 596 | input = 'virtual:adex:server' 597 | } 598 | }, 599 | configResolved(config) { 600 | cfg = config 601 | }, 602 | async generateBundle() { 603 | const defOut = cfg.build?.outDir ?? 'dist' 604 | const serverOutDir = defOut.endsWith('client') 605 | ? join(dirname(defOut), 'server') 606 | : join(defOut, 'server') 607 | 608 | console.log(`\nBuilding Server: ${serverOutDir}\n`) 609 | 610 | const sanitizedPlugins = (cfg.plugins ?? []) 611 | .filter(d => d.adexServer === false) 612 | .filter(d => !d.name.startsWith('vite:')) 613 | .filter(d => !d.name.startsWith('adex-')) 614 | 615 | await build({ 616 | configFile: false, 617 | ssr: { 618 | external: ['preact', 'adex', 'preact-render-to-string'], 619 | noExternal: Object.values(adapterMap), 620 | }, 621 | resolve: cfg.resolve, 622 | appType: 'custom', 623 | plugins: [ 624 | preact(), 625 | preactPages({ 626 | root: '/src/pages', 627 | id: '~routes', 628 | }), 629 | preactPages({ 630 | root: '/src/api', 631 | id: '~apiRoutes', 632 | replacer: '/api', 633 | }), 634 | createUserDefaultVirtualModule( 635 | 'virtual:adex:global.css', 636 | '', 637 | '/src/global.css' 638 | ), 639 | createVirtualModule( 640 | 'adex/app', 641 | readFileSync(join(__dirname, '../runtime/app.js'), 'utf8') 642 | ), 643 | createUserDefaultVirtualModule( 644 | 'virtual:adex:client', 645 | readFileSync(join(__dirname, '../runtime/client.js'), 'utf8'), 646 | '/src/_app' 647 | ), 648 | createVirtualModule( 649 | 'virtual:adex:handler', 650 | readFileSync(join(__dirname, '../runtime/handler.js'), 'utf8') 651 | ), 652 | createVirtualModule( 653 | 'virtual:adex:server', 654 | `import { createServer } from '${adapterMap[adapter]}' 655 | import { dirname, join } from 'node:path' 656 | import { fileURLToPath } from 'node:url' 657 | import { existsSync, readFileSync } from 'node:fs' 658 | import { env } from 'adex/env' 659 | 660 | import 'virtual:adex:font.css' 661 | import 'virtual:adex:global.css' 662 | 663 | const __dirname = dirname(fileURLToPath(import.meta.url)) 664 | 665 | const PORT = parseInt(env.get('PORT', '3000'), 10) 666 | const HOST = env.get('HOST', 'localhost') 667 | 668 | const paths = { 669 | assets: join(__dirname, './assets'), 670 | islands: join(__dirname, './islands'), 671 | client: join(__dirname, '../client'), 672 | } 673 | 674 | function getServerManifest() { 675 | const manifestPath = join(__dirname, 'manifest.json') 676 | if (existsSync(manifestPath)) { 677 | const manifestFile = readFileSync(manifestPath, 'utf8') 678 | return parseManifest(manifestFile) 679 | } 680 | return {} 681 | } 682 | 683 | function getClientManifest() { 684 | const manifestPath = join(__dirname, '../client/manifest.json') 685 | if (existsSync(manifestPath)) { 686 | const manifestFile = readFileSync(manifestPath, 'utf8') 687 | return parseManifest(manifestFile) 688 | } 689 | return {} 690 | } 691 | 692 | function parseManifest(manifestString) { 693 | try { 694 | const manifestJSON = JSON.parse(manifestString) 695 | return manifestJSON 696 | } catch (err) { 697 | return {} 698 | } 699 | } 700 | 701 | const server = createServer({ 702 | port: PORT, 703 | host: HOST, 704 | adex:{ 705 | manifests:{server:getServerManifest(),client:getClientManifest()}, 706 | paths, 707 | } 708 | }) 709 | 710 | if ('run' in server) { 711 | server.run() 712 | } 713 | 714 | export default server.fetch 715 | ` 716 | ), 717 | addFontsPlugin(fonts), 718 | islands && adexIslandsBuilder(), 719 | ...sanitizedPlugins, 720 | ], 721 | build: { 722 | outDir: serverOutDir, 723 | emptyOutDir: false, 724 | assetsDir: 'assets', 725 | ssrEmitAssets: true, 726 | ssr: true, 727 | manifest: 'manifest.json', 728 | ssrManifest: 'ssr.manifest.json', 729 | rollupOptions: { 730 | input: { 731 | index: input, 732 | }, 733 | external: ['adex/ssr'], 734 | }, 735 | }, 736 | }) 737 | }, 738 | } 739 | } 740 | 741 | /** 742 | * @returns {import("vite").Plugin[]} 743 | */ 744 | function adexGuards() { 745 | return [ 746 | { 747 | name: 'adex-guard-env', 748 | enforce: 'pre', 749 | async transform(code, id) { 750 | // ignore usage of `process.env` in node_modules 751 | // Still risky but hard to do anything about 752 | const nodeMods = resolve(cwd, 'node_modules') 753 | if (id.startsWith(nodeMods)) return 754 | 755 | // ignore usage of `process.env` in `adex/env` 756 | const envLoadId = await this.resolve('adex/env') 757 | if (id === envLoadId?.id) return 758 | 759 | if (code.includes('process.env')) { 760 | this.error( 761 | 'Avoid using `process.env` to access environment variables and secrets. Use `adex/env` instead' 762 | ) 763 | } 764 | }, 765 | writeBundle() { 766 | const pagesPath = resolve(cwd, 'src/pages') 767 | const info = this.getModuleInfo('adex/env') 768 | const viteRef = this 769 | 770 | function checkTree(importPath, importStack = []) { 771 | if (importPath.startsWith(pagesPath)) { 772 | throw new Error( 773 | `Cannot use/import \`adex/env\` on the client side, importerStack: ${importStack.join(' -> ')}` 774 | ) 775 | } 776 | viteRef 777 | .getModuleInfo(importPath) 778 | .importers.forEach(d => 779 | checkTree(d, [...importStack, importPath, d]) 780 | ) 781 | } 782 | if (info) { 783 | info.importers.forEach(i => checkTree(i)) 784 | } 785 | }, 786 | }, 787 | ] 788 | } 789 | 790 | /** 791 | * @returns {import("vite").Plugin} 792 | */ 793 | function preactPages({ 794 | root = '/src/pages', 795 | id: virtualId = '~routes', 796 | extensions = ['js', 'ts', 'tsx', 'jsx'], 797 | replacer = '', 798 | } = {}) { 799 | return { 800 | name: 'adex-routes', 801 | enforce: 'pre', 802 | resolveId(id) { 803 | if (id !== virtualId) { 804 | return 805 | } 806 | return `/0${virtualId}` 807 | }, 808 | async load(id) { 809 | if (id !== `/0${virtualId}`) { 810 | return 811 | } 812 | 813 | const extsString = extensions.join(',') 814 | const code = ( 815 | await readFile(join(__dirname, '../runtime/pages.js'), 'utf8') 816 | ) 817 | .replaceAll('#{__PLUGIN_PAGES_ROOT}', root + `/**/*.{${extsString}}`) 818 | .replaceAll('#{__PLUGIN_PAGES_ROOT_REGEX}', `^${root}`) 819 | .replaceAll('#{__PLUGIN_PAGES_ROOT_REGEX_REPLACER}', replacer) 820 | 821 | return { 822 | code, 823 | } 824 | }, 825 | } 826 | } 827 | --------------------------------------------------------------------------------