├── .eslintignore ├── .env.example ├── public ├── lib │ └── prism │ │ ├── .gitignore │ │ └── README.md ├── editor.mp4 ├── favicon.ico ├── robots.txt └── theme.css ├── scripts ├── caddy-install.ps1 ├── config ├── caddy-install.sh ├── kill-last.sh ├── scan-inline-client-plugin.sh ├── download-data.sh ├── login.sh ├── caddy-run.sh ├── ide.sh ├── backup-db.sh ├── download-db.sh ├── init.sh ├── kill-port.sh ├── compare.sh ├── check-commits.sh ├── rsync-install.sh ├── new-component.sh ├── deploy.sh ├── new-route.sh ├── help.js └── rebase-template.sh ├── db ├── .gitignore ├── .pnpmfile.cjs ├── knex.ts ├── tsconfig.json ├── analysis │ └── url_stats.sql ├── helper.ts ├── knexfile.ts ├── migrations │ ├── 20230429203001_auto-migrate.ts │ ├── 20240113061826_auto-migrate.ts │ ├── 20230429112109_auto-migrate.ts │ └── 20220529054037_auto-migrate.ts ├── seed.ts ├── db.ts ├── package.json ├── README.md ├── erd.txt ├── proxy.ts └── request-log.ts ├── server ├── app │ ├── components │ │ ├── common-template.tsx │ │ ├── flush.ts │ │ ├── raw.ts │ │ ├── inline-code.tsx │ │ ├── comment.ts │ │ ├── ws-status.tsx │ │ ├── text.tsx │ │ ├── tel.tsx │ │ ├── placeholder.tsx │ │ ├── static-file.tsx │ │ ├── button.tsx │ │ ├── stars.tsx │ │ ├── style.ts │ │ ├── ion-button.tsx │ │ ├── ion-item.tsx │ │ ├── app-tab-bar.tsx │ │ ├── ion-tab-bar.tsx │ │ ├── status-page.tsx │ │ ├── input.tsx │ │ ├── back-to-link.tsx │ │ ├── ion-back-button.tsx │ │ ├── paragraph.tsx │ │ ├── error.ts │ │ ├── language-radio-group.tsx │ │ ├── select.tsx │ │ ├── time.tsx │ │ ├── timestamp.tsx │ │ ├── update-message.tsx │ │ ├── fragment.ts │ │ ├── source-code.tsx │ │ ├── data-table.tsx │ │ ├── menu.tsx │ │ ├── copyable.tsx │ │ ├── script.ts │ │ ├── page.tsx │ │ ├── update.tsx │ │ ├── stats.tsx │ │ ├── chart.tsx │ │ ├── ui-language.tsx │ │ ├── router.tsx │ │ ├── pagination.tsx │ │ └── navbar.tsx │ ├── format │ │ ├── markdown.ts │ │ └── slug.ts │ ├── styles │ │ ├── ionic-style.tsx │ │ ├── web-style.ts │ │ └── common-style.ts │ ├── log.ts │ ├── pages │ │ ├── not-implemented.tsx │ │ ├── not-found.tsx │ │ ├── calculator.tsx │ │ ├── app-settings.tsx │ │ ├── app-chat.tsx │ │ ├── app-notice.tsx │ │ ├── demo-locale.tsx │ │ ├── user-list.tsx │ │ ├── demo-inputs.tsx │ │ ├── user-agents.tsx │ │ ├── app-about.tsx │ │ └── demo-typescript-page.tsx │ ├── jsx │ │ ├── stream.ts │ │ ├── types.ts │ │ ├── jsx.ts │ │ └── dispatch.ts │ ├── icons │ │ └── menu.tsx │ ├── app-style.tsx │ ├── express.ts │ ├── url-version.ts │ ├── data │ │ └── version-number.ts │ ├── session.ts │ ├── api-route.ts │ ├── upload.ts │ └── cookie.ts ├── debug.ts ├── client-plugins.ts ├── ws │ ├── wss.ts │ ├── wss-native.ts │ └── wss-lite.ts ├── exception.ts ├── env.ts ├── config.ts ├── params.ts ├── template-file.ts ├── client-plugin.ts ├── caddy.ts ├── url.ts └── index.ts ├── data └── .gitignore ├── client ├── client-config.ts ├── ws │ ├── ws.ts │ ├── ws-config.ts │ ├── readme.md │ ├── ws-native.ts │ └── ws-lite.ts ├── jsx │ ├── external.d.ts │ └── types.ts ├── internal.ts ├── types.ts ├── sweetalert.ts ├── image.ts └── confetti.ts ├── .prettierrc ├── .prettierignore ├── .pnpmfile.cjs ├── speed.md ├── .vscode ├── settings.json └── extensions.json ├── .gitignore ├── tsconfig.json ├── README-zh.md ├── .eslintrc.json ├── LICENSE ├── template ├── web.html ├── ionic.html ├── web.ts └── ionic.ts ├── size.md └── CHANGELOG.md /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | build/ 3 | public/lib/* 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV= 2 | PORT= 3 | COOKIE_SECRET= 4 | -------------------------------------------------------------------------------- /public/lib/prism/.gitignore: -------------------------------------------------------------------------------- 1 | prism.css 2 | prism.js 3 | -------------------------------------------------------------------------------- /scripts/caddy-install.ps1: -------------------------------------------------------------------------------- 1 | curl.exe https://webi.ms/caddy | powershell 2 | -------------------------------------------------------------------------------- /db/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.tgz 3 | dist 4 | .env 5 | .env.* 6 | !.env.example 7 | -------------------------------------------------------------------------------- /public/editor.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beenotung/ts-liveview/HEAD/public/editor.mp4 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beenotung/ts-liveview/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /server/app/components/common-template.tsx: -------------------------------------------------------------------------------- 1 | export let commonTemplatePageText = 2 | 'This page serve as template of common use case.' 3 | -------------------------------------------------------------------------------- /server/debug.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug' 2 | 3 | export function debugLog(file: string) { 4 | return debug(`ts-liveview:` + file) 5 | } 6 | -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | session.txt 2 | visit.txt 3 | user-agent-version.txt 4 | *.sqlite3 5 | *.sqlite3-shm 6 | *.sqlite3-wal 7 | dump.sql 8 | *.xz 9 | -------------------------------------------------------------------------------- /scripts/config: -------------------------------------------------------------------------------- 1 | user=node 2 | host=liveviews.cc 3 | root_dir=/home/node/workspace/github.com/beenotung/ts-liveview 4 | pm2_name="ts-liveview" 5 | -------------------------------------------------------------------------------- /client/client-config.ts: -------------------------------------------------------------------------------- 1 | export let client_config = { 2 | max_image_size: 300 * 1000, 3 | toast_duration: 3800, 4 | toast_duration_short: 3000, 5 | } 6 | -------------------------------------------------------------------------------- /server/app/components/flush.ts: -------------------------------------------------------------------------------- 1 | /* to hint the html streaming flow to flush the compression stream */ 2 | export function Flush() { 3 | return null 4 | } 5 | -------------------------------------------------------------------------------- /server/app/components/raw.ts: -------------------------------------------------------------------------------- 1 | import type { Raw } from '../jsx/types' 2 | 3 | export function Raw(html: string): Raw { 4 | return ['raw', html] 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "arrowParens": "avoid", 5 | "quoteProps": "consistent", 6 | "trailingComma": "all" 7 | } 8 | -------------------------------------------------------------------------------- /scripts/caddy-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ## more see https://caddyserver.com/docs/install 3 | set -e 4 | set -o pipefail 5 | curl -sS https://webi.sh/caddy | sh 6 | -------------------------------------------------------------------------------- /db/.pnpmfile.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | hooks: { 3 | readPackage: pkg => { 4 | delete pkg.optionalDependencies 5 | return pkg 6 | }, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /server/app/format/markdown.ts: -------------------------------------------------------------------------------- 1 | import { marked } from 'marked' 2 | 3 | export function markdownToHtml(text: string): string | Promise { 4 | return marked(text) 5 | } 6 | -------------------------------------------------------------------------------- /db/knex.ts: -------------------------------------------------------------------------------- 1 | import Knex from 'knex' 2 | 3 | /* eslint-disable @typescript-eslint/no-var-requires */ 4 | let configs = require('./knexfile') 5 | 6 | export const knex = Knex(configs.development) 7 | -------------------------------------------------------------------------------- /scripts/kill-last.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | result=$(ps | grep node | grep -v grep) 6 | echo "$result" 7 | echo "$result" | awk '{print $1}' | xargs -I {} kill -9 {} 8 | -------------------------------------------------------------------------------- /scripts/scan-inline-client-plugin.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | grep -n "loadClientPlugin" `find server -type f` \ 5 | | grep -v import \ 6 | | grep -v function \ 7 | | grep -v = 8 | -------------------------------------------------------------------------------- /server/app/components/inline-code.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | 3 | export function code(html: string) { 4 | return {html} 5 | } 6 | 7 | export default code 8 | -------------------------------------------------------------------------------- /client/ws/ws.ts: -------------------------------------------------------------------------------- 1 | import type { ClientMessage } from '../types' 2 | 3 | export type ManagedWebsocket = { 4 | ws: WebSocket 5 | send(event: ClientMessage): void 6 | close(code?: number, reason?: string): void 7 | } 8 | -------------------------------------------------------------------------------- /server/app/components/comment.ts: -------------------------------------------------------------------------------- 1 | import type { Raw } from '../jsx/types' 2 | 3 | /** For HTML comment */ 4 | export function Comment(text: string): Raw { 5 | return ['raw', ``] 6 | } 7 | 8 | export default Comment 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # npm 2 | pnpm-lock.yaml 3 | *.json 4 | 5 | # built files 6 | dist/ 7 | build/ 8 | template/*.ts 9 | db/proxy.ts 10 | 11 | # lighthouse report 12 | localhost_*.report.html 13 | 14 | # library 15 | public/lib/ 16 | -------------------------------------------------------------------------------- /db/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /scripts/download-data.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | source scripts/config 6 | rsync -SavlPz \ 7 | --exclude '*.sqlite3*' \ 8 | --exclude '*.tmp.*' \ 9 | "$user@$host:$root_dir/data" \ 10 | . 11 | ./scripts/download-db.sh 12 | -------------------------------------------------------------------------------- /.pnpmfile.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | hooks: { 3 | readPackage: pkg => { 4 | // need to install optional dependencies, e.g. @esbuild/linux-x64 for esbuild 5 | // delete pkg.optionalDependencies 6 | return pkg 7 | }, 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /client/jsx/external.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace JSX { 2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 3 | type Element = any 4 | interface IntrinsicElements { 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | [elemName: string]: any 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /db/analysis/url_stats.sql: -------------------------------------------------------------------------------- 1 | select 2 | url.id 3 | , url.url 4 | , count(request_log.id) as count 5 | from request_log 6 | inner join url on url.id = request_log.url_id 7 | -- where url.url like '%article%' 8 | -- and not url.url like '%webp' 9 | group by url.id 10 | order by count desc 11 | -------------------------------------------------------------------------------- /scripts/login.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # ssh login for manual deploy setup (e.g. to setup nginx and run seed) 3 | set -e 4 | set -o pipefail 5 | 6 | source scripts/config 7 | 8 | ssh -t "$user@$host" " 9 | if [ -d $root_dir ]; then 10 | cd $root_dir 11 | fi 12 | \$0 --login 13 | " 14 | -------------------------------------------------------------------------------- /scripts/caddy-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | source .env 6 | [ -z "$PORT" ] && PORT=8100 7 | IP="$(ifconfig | grep -oE '192.[0-9]+.[0-9]+.[0-9]+' | grep -v 255)" 8 | TO="http://$IP:$PORT" 9 | echo "proxy to: $TO" 10 | caddy reverse-proxy --to "$TO" --from "$IP" --internal-certs 11 | -------------------------------------------------------------------------------- /client/ws/ws-config.ts: -------------------------------------------------------------------------------- 1 | export const DefaultReconnectInterval = 250 2 | export const MaxReconnectInterval = 10 * 1000 3 | 4 | export const HeartHeatInterval = 30 * 1000 5 | export const HeartHeatTimeout = 45 * 1000 6 | export const HeartBeatTimeoutCode = 4013 7 | export const HeartBeatTimeoutReason = 'heartbeat timeout' 8 | -------------------------------------------------------------------------------- /server/app/components/ws-status.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import Style from './style.js' 3 | 4 | let hide = Style(/* css */ ` 5 | #ws_status { 6 | display: none; 7 | } 8 | `) 9 | 10 | let safeArea =
11 | 12 | export const wsStatus = { hide, safeArea } 13 | -------------------------------------------------------------------------------- /db/helper.ts: -------------------------------------------------------------------------------- 1 | import { fromSqliteTimestamp } from 'better-sqlite3-proxy' 2 | 3 | export function getRowTimes(row: T) { 4 | let r = row as any 5 | let created_at = fromSqliteTimestamp(r.created_at) 6 | let updated_at = fromSqliteTimestamp(r.updated_at) 7 | return { created_at, updated_at } 8 | } 9 | -------------------------------------------------------------------------------- /db/knexfile.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex' 2 | import { dbFile } from './db' 3 | 4 | const config: { [key: string]: Knex.Config } = { 5 | development: { 6 | client: 'better-sqlite3', 7 | useNullAsDefault: true, 8 | connection: { 9 | filename: dbFile, 10 | }, 11 | }, 12 | } 13 | 14 | module.exports = config 15 | -------------------------------------------------------------------------------- /speed.md: -------------------------------------------------------------------------------- 1 | ## Benchmark 2 | 3 | | Test | Speed | 4 | | ------------------ | --------- | 5 | | string += 'foo' | 3500k tps | 6 | | innerHTML += 'foo' | 8k tps | 7 | | socket.io echo | 3k tps | 8 | | primus.js echo | 5k tps | 9 | 10 | ### Observation 11 | 12 | - string concat is >400x faster than innerHTML concat 13 | - primus is 60% faster than socket.io 14 | -------------------------------------------------------------------------------- /db/migrations/20230429203001_auto-migrate.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | await knex.raw( 5 | 'alter table `user_agent` add column `count` integer not null default 0', 6 | ) 7 | } 8 | 9 | export async function down(knex: Knex): Promise { 10 | await knex.raw('alter table `user_agent` drop column `count`') 11 | } 12 | -------------------------------------------------------------------------------- /server/app/styles/ionic-style.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | 3 | export let ionicStyle = ( 4 | <> 5 | 9 | 10 | 11 | 12 | ) 13 | -------------------------------------------------------------------------------- /server/client-plugins.ts: -------------------------------------------------------------------------------- 1 | import { loadClientPlugin } from './client-plugin.js' 2 | 3 | export let sweetAlertPlugin = loadClientPlugin({ 4 | entryFile: 'dist/client/sweetalert.js', 5 | }) 6 | 7 | export let confettiPlugin = loadClientPlugin({ 8 | entryFile: 'dist/client/confetti.js', 9 | }) 10 | 11 | export let imagePlugin = loadClientPlugin({ 12 | entryFile: 'dist/client/image.js', 13 | }) 14 | -------------------------------------------------------------------------------- /server/app/log.ts: -------------------------------------------------------------------------------- 1 | import type { Request } from 'express' 2 | import { storeRequestLog } from '../../db/request-log.js' 3 | 4 | export function logRequest( 5 | req: Request, 6 | method: string, 7 | url: string, 8 | session_id: number | null, 9 | ) { 10 | storeRequestLog({ 11 | method, 12 | url, 13 | user_agent: req.headers['user-agent'] || null, 14 | session_id, 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /server/app/components/text.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import type { Fragment, Node } from '../jsx/types' 3 | 4 | /** @description consider set "white-space" css property instead */ 5 | function text(text: string): Fragment { 6 | let nodes: Node[] = [] 7 | text.split('\n').forEach(line => nodes.push(line,
)) 8 | nodes.pop() 9 | return [nodes] 10 | } 11 | 12 | export default text 13 | -------------------------------------------------------------------------------- /server/app/styles/web-style.ts: -------------------------------------------------------------------------------- 1 | export let WebStyle = /* css */ ` 2 | body { 3 | font-family: sans-serif; 4 | } 5 | 6 | h1 { font-size: 2rem } 7 | h2 { font-size: 1.8rem } 8 | h3 { font-size: 1.75rem } 9 | h4 { font-size: 1.5rem } 10 | h5 { font-size: 1.25rem } 11 | h6 { font-size: 1.2rem } 12 | h1,h2,h3,h4,h5,h6 { margin: 1em 0 0.5em } 13 | 14 | .app { 15 | max-width: 1089px; 16 | margin: auto; 17 | } 18 | ` 19 | -------------------------------------------------------------------------------- /server/app/components/tel.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | 3 | export function Tel(tel: string): string { 4 | tel = tel.replace('+852', '') 5 | return {tel.slice(0, 4) + ' ' + tel.slice(4)} 6 | } 7 | 8 | export function formatTel(tel: string | null): string | null { 9 | if (!tel) return null 10 | tel = tel.replace('+852', '') 11 | return tel.slice(0, 4) + ' ' + tel.slice(4) 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "vnode" 4 | ], 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnSave": true, 7 | "editor.tabSize": 2, 8 | "prettier.printWidth": 120, 9 | "prettier.quoteProps": "consistent", 10 | "prettier.semi": false, 11 | "prettier.singleQuote": true, 12 | "prettier.tabWidth": 2, 13 | "prettier.trailingComma": "all", 14 | "prettier.useTabs": false 15 | } 16 | -------------------------------------------------------------------------------- /db/seed.ts: -------------------------------------------------------------------------------- 1 | import { seedRow } from 'better-sqlite3-proxy' 2 | import { proxy } from './proxy' 3 | 4 | // This file serve like the knex seed file. 5 | // 6 | // You can setup the database with initial config and sample data via the db proxy. 7 | 8 | seedRow(proxy.method, { method: 'GET' }) 9 | seedRow(proxy.method, { method: 'POST' }) 10 | seedRow(proxy.method, { method: 'ws' }) 11 | 12 | console.log( 13 | 'request methods:', 14 | proxy.method.map(row => row.method), 15 | ) 16 | -------------------------------------------------------------------------------- /server/app/components/placeholder.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import type { DynamicPageRoute } from '../routes' 3 | 4 | export let avatar_url = 'https://www.w3schools.com/w3css/img_avatar.png' 5 | 6 | export let avatar = ( 7 | placeholder 8 | ) 9 | 10 | export let placeholderForAttachRoutes: DynamicPageRoute = { 11 | resolve() { 12 | throw new Error('This route is placeholder for attachRoutes') 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "formulahendry.code-runner", 4 | "Gruntfuggly.todo-tree", 5 | "PKief.material-icon-theme", 6 | "Tobermory.es6-string-html", 7 | "eamodio.gitlens", 8 | "esbenp.prettier-vscode", 9 | "formulahendry.auto-close-tag", 10 | "formulahendry.auto-rename-tag", 11 | "mhutchie.git-graph", 12 | "streetsidesoftware.code-spell-checker", 13 | "usernamehw.errorlens", 14 | "wix.vscode-import-cost" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /scripts/ide.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | if [ $# != 1 ]; then 6 | echo "Error: expected 1 argument, got $#" 7 | echo "Usage: $0 " 8 | exit 1 9 | fi 10 | 11 | target="$1" 12 | 13 | ide="" 14 | hash idea 2> /dev/null && ide="idea" 15 | hash code 2> /dev/null && ide="code" 16 | hash cursor 2> /dev/null && ide="cursor" 17 | 18 | if [ -z "$ide" ]; then 19 | echo "Hint: no IDE found, you need to open $target manually" 20 | else 21 | $ide "$target" & 22 | fi 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # npm 2 | node_modules 3 | !.pnpmfile.cjs 4 | pnpm-lock.yaml 5 | package-lock.json 6 | yarn.lock 7 | 8 | # IDE 9 | .idea/ 10 | 11 | # built files 12 | build/ 13 | dist/ 14 | 15 | # lighthouse report 16 | localhost_*.report.html 17 | 18 | # config 19 | .env 20 | .open 21 | Caddyfile 22 | 23 | # MacOS 24 | .DS_Store 25 | 26 | # live server 27 | server.pid 28 | 29 | # user uploaded files 30 | uploads/ 31 | 32 | # git files 33 | *.orig 34 | 35 | # data files 36 | data/*.tmp.* 37 | data/*.sql 38 | data/*.zst 39 | -------------------------------------------------------------------------------- /server/app/components/static-file.tsx: -------------------------------------------------------------------------------- 1 | import { basename, dirname } from 'path' 2 | import { prerender } from '../jsx/html.js' 3 | import { o } from '../jsx/jsx.js' 4 | import { existsSync, readFileSync } from 'fs' 5 | 6 | export function StaticFile(file: string) { 7 | return prerender( 8 |

9 | {existsSync(file) 10 | ? readFileSync(file).toString() 11 | : `${basename(file)} file is missing. You can put it in the ${dirname(file)} directory`} 12 |

, 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /public/lib/prism/README.md: -------------------------------------------------------------------------------- 1 | The 3rd party library code `prism.css` and `prism.js` are ignored from git but required for the `` component. 2 | 3 | ## Download Link 4 | 5 | https://prismjs.com/download.html#themes=prism-tomorrow&languages=markup+css+clike+javascript+bash+markdown+jsx+tsx+typescript 6 | 7 | ## Configuration 8 | 9 | ### Theme 10 | 11 | - Tomorrow Night by Rosey (1.28KB) 12 | 13 | ### Languages 14 | 15 | - React TSX (0.3KB) 16 | - CSS (1.71KB) 17 | - Bash + Shell by zeitgeist87 (8.7KB) 18 | - Markdown by Golmote (5.02KB) 19 | -------------------------------------------------------------------------------- /server/ws/wss.ts: -------------------------------------------------------------------------------- 1 | import type WebSocket from 'ws' 2 | import type { Server } from 'ws' 3 | import type { ClientMessage, ServerMessage } from '../../client/types' 4 | import { Request } from 'express' 5 | 6 | export type ManagedWebsocket = { 7 | ws: WebSocket 8 | wss: Server 9 | request: Request 10 | session_id: number 11 | send(event: ServerMessage): void 12 | close(code?: number, reason?: Buffer): void 13 | } 14 | 15 | export type OnWsMessage = ( 16 | event: ClientMessage, 17 | ws: ManagedWebsocket, 18 | wss: Server, 19 | ) => void 20 | -------------------------------------------------------------------------------- /client/jsx/types.ts: -------------------------------------------------------------------------------- 1 | export type VNode = Literal | Raw | Fragment | VElement 2 | export type VElement = [selector, attrs?, VNodeList?] 3 | export type VNodeList = VNode[] 4 | export type Literal = string | number | null | undefined | false | true 5 | export type Raw = ['raw', html] 6 | export type Fragment = [VNodeList] 7 | 8 | export type selector = string 9 | export type attrs = Record 10 | export type props = Record 11 | 12 | export type html = string 13 | 14 | export type title = string 15 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # Default Group 2 | User-agent: * 3 | 4 | ## TODO auto generate from routes with meta data? 5 | Allow: / 6 | Allow: /about 7 | Allow: /about/markdown 8 | Allow: /thermostat 9 | Disallow: /thermostat/* 10 | Allow: /editor 11 | Allow: /auto-complete 12 | Allow: /form 13 | Disallow: /form/* 14 | Allow: /cookie-session 15 | Disallow: /cookie-session/* 16 | Allow: /chatroom 17 | Disallow: /chatroom/* 18 | Allow: /calculator 19 | Allow: /user-agents 20 | 21 | # Sitemap: http://localhost:8100/sitemap.xml 22 | # Reference: https://www.xml-sitemaps.com 23 | -------------------------------------------------------------------------------- /server/app/components/button.tsx: -------------------------------------------------------------------------------- 1 | import { NodeList } from '../jsx/types.js' 2 | 3 | export function Button( 4 | attrs: { 5 | url: string 6 | class?: string 7 | style?: string 8 | children?: NodeList 9 | disabled?: boolean 10 | } & object, 11 | ) { 12 | let { url, children, disabled, ...rest } = attrs 13 | return [ 14 | 'button', 15 | { 16 | 'data-url': url, 17 | 'onclick': 'emit(this.dataset.url)', 18 | 'disabled': disabled ? '' : undefined, 19 | ...rest, 20 | }, 21 | children, 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /scripts/backup-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | source scripts/config 6 | 7 | ssh "$user@$host" " 8 | set -e 9 | cd $root_dir/data 10 | sqlite3 db.sqlite3 '.backup backup.sqlite3' 11 | " 12 | 13 | rsync -SavlPz "$user@$host:$root_dir/data/backup.sqlite3" "data/" 14 | 15 | cd data 16 | ls -lh backup.sqlite3 17 | 18 | read -p "Replace local db? (y/N): " ans 19 | if [ "$ans" = "y" ]; then 20 | echo "Replacing local db..." 21 | sqlite3 backup.sqlite3 '.backup db.sqlite3' 22 | else 23 | echo "Skipping local db replacement." 24 | fi 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // "noUnusedLocals": true, 4 | "allowSyntheticDefaultImports": true, 5 | "jsx": "react", 6 | "jsxFactory": "o", 7 | "jsxFragmentFactory": "null", 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "incremental": true, 11 | "moduleResolution": "node", 12 | "target": "es2022", 13 | "module": "es2022", 14 | "lib": [ 15 | "dom", 16 | "es2022" 17 | ], 18 | "outDir": "dist" 19 | }, 20 | "exclude": [ 21 | "public", 22 | "build", 23 | "dist" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /client/ws/readme.md: -------------------------------------------------------------------------------- 1 | | Implementation | HeartBeat | Auto Reconnect | Message Delivery | 2 | | -------------- | ---------------- | -------------- | ---------------- | 3 | | ws-native | ping from server | supported | best-effort | 4 | | ws-lite | ping from client | supported | best-effort | 5 | 6 | ## Todo 7 | 8 | - introduce min uptime to count as successful reconnection 9 | - introduce random back-off time before reconnect 10 | - introduce max retry for reconnection 11 | 12 | reference: https://www.npmjs.com/package/reconnecting-websocket 13 | -------------------------------------------------------------------------------- /server/app/pages/not-implemented.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import type { Node } from '../jsx/types' 3 | import StatusPage from '../components/status-page.js' 4 | import { Locale } from '../components/locale.js' 5 | 6 | let NotImplemented: Node = ( 7 | 16 | } 17 | page="not-implemented.tsx" 18 | /> 19 | ) 20 | 21 | export default NotImplemented 22 | -------------------------------------------------------------------------------- /server/app/components/stars.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import Style from './style.js' 3 | 4 | export let starsStyle = Style(/* css */ ` 5 | .stars--active { 6 | color: #ffd700; 7 | } 8 | .stars--inactive { 9 | filter: grayscale(100%); 10 | } 11 | `) 12 | 13 | export function Stars(attrs: { score: number; max: number }) { 14 | let stars = [] 15 | for (let i = 0; i < attrs.max; i++) { 16 | stars.push( 17 | 18 | ⭐ 19 | , 20 | ) 21 | } 22 | return
{[stars]}
23 | } 24 | -------------------------------------------------------------------------------- /db/db.ts: -------------------------------------------------------------------------------- 1 | import { DBInstance, newDB } from 'better-sqlite3-schema' 2 | import { existsSync } from 'fs' 3 | import { join } from 'path' 4 | 5 | function getDataDir(): string { 6 | let dir = 'data' 7 | if (!existsSync(dir)) dir = join('..', dir) 8 | if (existsSync(dir)) return dir 9 | throw new Error('Could not find data directory') 10 | } 11 | 12 | export let dataDir = getDataDir() 13 | 14 | export let dbFile = join(dataDir, 'db.sqlite3') 15 | 16 | export let db: DBInstance = newDB({ 17 | path: dbFile, 18 | migrate: false, 19 | fileMustExist: true, 20 | WAL: true, 21 | synchronous: 'NORMAL', 22 | }) 23 | -------------------------------------------------------------------------------- /server/app/jsx/stream.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import type { html } from './types' 3 | 4 | declare global { 5 | // eslint-disable-next-line @typescript-eslint/no-namespace 6 | namespace Express { 7 | interface Response { 8 | /** 9 | * for streaming html response 10 | */ 11 | flush(): void 12 | } 13 | } 14 | } 15 | 16 | if (!express.response.flush) { 17 | express.response.flush = noop 18 | } 19 | 20 | export interface HTMLStream { 21 | write(chunk: html): void 22 | flush(): void 23 | } 24 | 25 | export function noop() { 26 | /* placeholder for flush() */ 27 | } 28 | -------------------------------------------------------------------------------- /server/app/icons/menu.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | 3 | // source: https://ionic.io/ionicons 4 | 5 | export let menuIcon = ( 6 | 16 | 24 | 25 | ) 26 | -------------------------------------------------------------------------------- /scripts/download-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | source scripts/config 6 | 7 | ssh "$user@$host" " 8 | set -e 9 | cd $root_dir/data 10 | ls -lh db.sqlite3 11 | sqlite3 db.sqlite3 '.dump' | zstd > dump.sql.zst 12 | " 13 | 14 | rsync -SavLP "$user@$host:$root_dir/data/dump.sql.zst" "data/" 15 | 16 | rm -f data/remote.sqlite3 17 | zstd -d -c data/dump.sql.zst | sqlite3 data/remote.sqlite3 18 | 19 | ls -lh data/remote.sqlite3 20 | 21 | read -p "Replace local db? (y/N): " ans 22 | if [ "$ans" = "y" ]; then 23 | echo "Replacing local db..." 24 | sqlite3 data/remote.sqlite3 '.backup data/db.sqlite3' 25 | else 26 | echo "Skipping local db replacement." 27 | fi 28 | -------------------------------------------------------------------------------- /server/app/app-style.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorStyle } from './components/error.js' 2 | import { SourceCodeStyle } from './components/source-code.js' 3 | import Style from './components/style.js' 4 | import { UpdateMessageStyle } from './components/update-message.js' 5 | import { CommonStyle } from './styles/common-style.js' 6 | import { MobileStyle } from './styles/mobile-style.js' 7 | import { WebStyle } from './styles/web-style.js' 8 | 9 | let appStyle = /* css */ ` 10 | ${SourceCodeStyle} 11 | ${ErrorStyle} 12 | ${UpdateMessageStyle} 13 | ${CommonStyle} 14 | ` 15 | 16 | export let webAppStyle = Style(appStyle + '\n' + WebStyle) 17 | 18 | export let ionicAppStyle = Style(appStyle + '\n' + MobileStyle) 19 | -------------------------------------------------------------------------------- /server/app/format/slug.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * convert non-url safe characters to hyphen (-), 3 | * and turn into lowercase. 4 | * 5 | * e.g. "Hello World" -> "hello-world" 6 | */ 7 | export function toSlug(value: string): string { 8 | value = value.trim() 9 | if (value.startsWith('e.g. ')) { 10 | value = value.split(' ').pop()! 11 | } 12 | value = value 13 | .replaceAll(' ', '-') 14 | .replaceAll('#', '-') 15 | .replaceAll(':', '-') 16 | .replaceAll('/', '-') 17 | .replaceAll('\\', '-') 18 | .replaceAll('"', '-') 19 | .replaceAll('`', '-') 20 | .replaceAll("'", '-') 21 | .replaceAll('&', '-') 22 | .replaceAll('?', '-') 23 | .toLowerCase() 24 | return value 25 | } 26 | -------------------------------------------------------------------------------- /server/exception.ts: -------------------------------------------------------------------------------- 1 | import type { ServerMessage } from '../client/types' 2 | import type { Node } from './app/jsx/types' 3 | 4 | export class HttpError extends Error { 5 | constructor( 6 | public statusCode: number, 7 | message: string, 8 | ) { 9 | super(message) 10 | } 11 | } 12 | 13 | export class MessageException { 14 | constructor(public message: ServerMessage) {} 15 | } 16 | 17 | export class ErrorNode { 18 | constructor(public node: Node) {} 19 | } 20 | 21 | /** 22 | * @description To quickly stop nested VNode traversal 23 | * */ 24 | export const EarlyTerminate = 'EarlyTerminate' as const 25 | 26 | /** 27 | * @alias {EarlyTerminate} 28 | * */ 29 | export const Done = EarlyTerminate 30 | -------------------------------------------------------------------------------- /server/app/components/style.ts: -------------------------------------------------------------------------------- 1 | import { config } from '../../config.js' 2 | import * as esbuild from 'esbuild' 3 | import { Element, Raw } from '../jsx/types.js' 4 | 5 | let cache = new Map() 6 | 7 | export function Style(css: string): Element { 8 | if (config.production) { 9 | if (cache.has(css)) { 10 | css = cache.get(css) as string 11 | } else { 12 | let code = esbuild.transformSync(css, { 13 | minify: true, 14 | loader: 'css', 15 | target: config.client_target, 16 | }).code 17 | cache.set(css, code) 18 | css = code 19 | } 20 | } 21 | const raw: Raw = ['raw', css] 22 | return ['style', undefined, [raw]] 23 | } 24 | 25 | export default Style 26 | -------------------------------------------------------------------------------- /server/app/express.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | 3 | export function sendHTMLHeader(res: express.Response) { 4 | res.setHeader('Connection', 'Keep-Alive') 5 | res.setHeader('Content-Type', 'text/html; charset=UTF-8') 6 | // res.setHeader('Transfer-Encoding','chunked') 7 | // res.setHeader('Link', `; rel="canonical"`) 8 | } 9 | 10 | export function setNoCache(res: express.Response) { 11 | res.removeHeader('ETag') 12 | res.setHeader( 13 | 'Cache-Control', 14 | 'no-store, no-cache, must-revalidate, proxy-revalidate, post-check=0, pre-check=0', 15 | ) 16 | res.setHeader('Pragma', 'no-cache') 17 | res.setHeader('Expires', '0') 18 | res.setHeader('Surrogate-Control', 'no-store') 19 | } 20 | -------------------------------------------------------------------------------- /scripts/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | ./scripts/ide.sh . 6 | 7 | install="npm i --legacy-peer-deps" 8 | hash slnpm 2> /dev/null && install="slnpm" 9 | hash yarn 2> /dev/null && install="yarn" 10 | hash pnpm 2> /dev/null && install="pnpm i -r --prefer-offline" 11 | 12 | echo "running '$install' in $(pwd)" 13 | $install 14 | if [[ "$install" == pnpm* ]]; then 15 | pnpm rebuild 16 | fi 17 | 18 | cd db 19 | if [[ "$install" != *-r* ]]; then 20 | echo "running '$install' in $(pwd)" 21 | $install 22 | elif [[ "$install" == pnpm* ]]; then 23 | pnpm rebuild 24 | fi 25 | echo "setup database" 26 | npm run setup 27 | 28 | echo 29 | echo "Ready to go!" 30 | echo 31 | echo "Run 'npm start' to start the development server" 32 | echo 33 | -------------------------------------------------------------------------------- /README-zh.md: -------------------------------------------------------------------------------- 1 | # ts-liveview 2 | 3 | ts-liveview 是一個用於構建混合靜態站點生成(SSG)和伺服器端渲染(SSR)的實時單頁應用程序(SPA)或多頁應用程序(MPA)的框架。它使用 TypeScript 來增強開發體驗,並提供以下幾個主要特點: 4 | 5 | - 輕量級客戶端:ts-liveview 的客戶端運行時小於 13KB(打包、壓縮和 gzip 壓縮後為 2.3KB),這使得初始加載非常快速。 6 | 7 | - 不依賴虛擬 DOM:與其他前端框架(如 React 或 Vue)不同,ts-liveview 不使用虛擬 DOM。它透過應用特定的事件處理器來推導出精確的 DOM 操作,這些操作隨後被發送到瀏覽器客戶端,以實時更新用戶界面。 8 | 9 | - 支持 JSX:開發者可以使用 JSX 來編寫 UI,這為許多開發者提供了熟悉的語法。 10 | 11 | - 混合渲染模式:支持在啟動時進行預渲染,請求時進行伺服器端渲染,以及運行時實時更新,充分利用了伺服器和客戶端的優勢。 12 | 13 | - 客戶端功能:支持內聯 JavaScript 和 TypeScript 模組,可處理複雜的客戶端邏輯、AI 模型和實時媒體處理。 14 | 15 | - 效率高的網絡格式:使用基於 WebSocket 的輕量級協議,比許多傳統技術更高效。 16 | 17 | - 廣泛的路由支持:支持單頁應用(SPA)、多頁應用(MPA)以及它們的混合,並支持嵌套路由和同步/異步路由。 18 | 19 | 這個框架特別適合需要快速渲染並且希望減少客戶端 JavaScript 負擔的應用程序。它通過伺服器強大的渲染能力來提供豐富且互動性強的用戶界面,同時保持首次有意義的繪製(FP)速度非常快。 20 | 21 | 如果你的項目有輕量客戶端和快速互動的需求,ts-liveview 可以是一個非常好的選擇。 22 | -------------------------------------------------------------------------------- /scripts/kill-port.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | ## there is a optional variable PORT in .env file 6 | source .env 7 | 8 | ## find the default port number in env.ts 9 | default_port=$(grep -oE '\bPORT: [0-9]+\b' server/env.ts | awk '{print $2}') 10 | port=${PORT:-${default_port}} 11 | 12 | function ls-port { 13 | if [ $(uname) == "Darwin" ]; then 14 | netstat -vanp tcp | grep LISTEN | grep -E "\*\.\b$port\b" | awk '{print $9}' || true 15 | else 16 | netstat -lpn 2>/dev/null | grep -E ":::\b$port\b" | awk '{print $NF}' || true 17 | fi 18 | } 19 | 20 | ps=$(ls-port | awk -F '/' '{print $1}') 21 | 22 | if [ -z "$ps" ]; then 23 | echo "no process is listening on port $port" 24 | exit 0 25 | fi 26 | 27 | for p in $ps; do 28 | echo "killing process pid $p (port $port)" 29 | kill $p 30 | done 31 | -------------------------------------------------------------------------------- /db/migrations/20240113061826_auto-migrate.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | if (!(await knex.schema.hasTable('request_session'))) { 5 | await knex.schema.createTable('request_session', table => { 6 | table.increments('id') 7 | table.text('language').nullable() 8 | table.text('timezone').nullable() 9 | table.integer('timezone_offset').nullable() 10 | table.timestamps(false, true) 11 | }) 12 | } 13 | await knex.raw( 14 | 'alter table `request_log` add column `request_session_id` integer null references `request_session`(`id`)', 15 | ) 16 | } 17 | 18 | export async function down(knex: Knex): Promise { 19 | await knex.raw('alter table `request_log` drop column `request_session_id`') 20 | await knex.schema.dropTableIfExists('request_session') 21 | } 22 | -------------------------------------------------------------------------------- /client/internal.ts: -------------------------------------------------------------------------------- 1 | import type { ServerMessage } from './types' 2 | 3 | export type WindowStub = { 4 | emit(...args: unknown[]): void 5 | goto(url: string): void 6 | emitHref(event: Event, flag?: LinkFlag): void 7 | emitForm(event: Event): void 8 | submitForm(form: HTMLFormElement): void 9 | onServerMessage(message: ServerMessage): void 10 | get(url: string): Promise 11 | del(url: string): Promise 12 | uploadForm(event: Event): Promise 13 | upload(url: string, formData: FormData): Promise 14 | remount(): void 15 | fetch_json(input: RequestInfo | URL, init?: RequestInit): Promise 16 | showError(error: unknown): void 17 | _navigation_type_: 'static' | 'express' | 'ws' 18 | _navigation_method_: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' 19 | ws_status?: HTMLElement 20 | } 21 | 22 | export type LinkFlag = ['q', 'f', 'b'] 23 | -------------------------------------------------------------------------------- /server/app/jsx/types.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from '../context' 2 | 3 | export type Node = Literal | Raw | Fragment | JSXFragment | Component | Element 4 | export type JSXFragment = [tag: undefined, attr: null, children: NodeList] 5 | export type Component = [ComponentFn, attrs?, NodeList?] 6 | export type Element = [selector, attrs?, NodeList?] 7 | export type NodeList = Node[] 8 | export type Literal = string | number | null | undefined | false | true 9 | export type Raw = ['raw', html] 10 | export type Fragment = [NodeList] 11 | 12 | type CFAttrs = { children?: NodeList } 13 | 14 | /** similar to React.FC */ 15 | export type ComponentFn = ( 16 | attrs: Attrs, 17 | context: Context, 18 | ) => Node 19 | 20 | export type selector = string 21 | export type attrs = Record 22 | 23 | export type html = string 24 | -------------------------------------------------------------------------------- /server/app/components/ion-button.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import { NodeList } from '../jsx/types.js' 3 | import { ThemeColor } from './page.js' 4 | import { Link } from './router.js' 5 | 6 | export function IonButton( 7 | attrs: { 8 | url: string 9 | class?: string 10 | style?: string 11 | children?: NodeList 12 | disabled?: boolean 13 | color?: ThemeColor 14 | expand?: 'block' | 'full' 15 | shape?: 'round' 16 | fill?: 'clear' | 'outline' | 'solid' 17 | size?: 'small' | 'default' | 'large' 18 | slot?: 'start' | 'end' 19 | hidden?: boolean | undefined 20 | rel?: 'nofollow' 21 | } & object, 22 | ) { 23 | let { url, children, disabled, ...rest } = attrs 24 | return ( 25 | 31 | {[children]} 32 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /server/app/components/ion-item.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import { NodeList } from '../jsx/types.js' 3 | import { ThemeColor } from './page.js' 4 | import { Link } from './router.js' 5 | 6 | export function IonItem( 7 | attrs: { 8 | url?: string 9 | class?: string 10 | style?: string 11 | children?: NodeList 12 | disabled?: boolean 13 | color?: ThemeColor 14 | lines?: 'full' | 'inset' | 'none' 15 | detail?: boolean 16 | button?: boolean 17 | hidden?: boolean | undefined 18 | } & object, 19 | ) { 20 | let { url, children, disabled, ...rest } = attrs 21 | return url ? ( 22 | 28 | {[children]} 29 | 30 | ) : ( 31 | 32 | {[children]} 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /server/env.ts: -------------------------------------------------------------------------------- 1 | import { config as loadEnv } from 'dotenv' 2 | import { populateEnv } from 'populate-env' 3 | import { cwd } from 'process' 4 | 5 | loadEnv() 6 | 7 | export let env = { 8 | NODE_ENV: 'development' as 'development' | 'production', 9 | CADDY_PROXY: 'skip' as 'skip' | 'enable', 10 | PORT: 8100, 11 | COOKIE_SECRET: '', 12 | EPOCH: 1, // to distinct initial run or restart in serve mode 13 | UPLOAD_DIR: 'uploads', 14 | ORIGIN: '', 15 | } 16 | applyDefaultEnv() 17 | 18 | function applyDefaultEnv() { 19 | if (process.env.NODE_ENV === 'production') return 20 | let PORT = process.env.PORT || env.PORT 21 | env.ORIGIN ||= process.env.ORIGIN || `http://localhost:${PORT}` 22 | env.COOKIE_SECRET ||= process.env.COOKIE_SECRET || cwd() 23 | } 24 | 25 | populateEnv(env, { mode: 'halt' }) 26 | 27 | if (env.CADDY_PROXY.toLocaleLowerCase().startsWith('enable')) { 28 | env.CADDY_PROXY = 'enable' 29 | } else { 30 | env.CADDY_PROXY = 'skip' 31 | } 32 | -------------------------------------------------------------------------------- /server/app/components/app-tab-bar.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import { IonTabBar } from './ion-tab-bar.js' 3 | import { Locale } from './locale.js' 4 | 5 | export let appIonTabBar = ( 6 | , 11 | href: '/app/home', 12 | }, 13 | { 14 | tab: 'chat', 15 | ios: 'chatbubble', 16 | md: 'chatbox', 17 | label: , 18 | href: '/app/chat', 19 | }, 20 | { 21 | tab: 'notice', 22 | icon: 'notifications', 23 | label: , 24 | href: '/app/notice', 25 | }, 26 | { 27 | tab: 'more', 28 | icon: 'ellipsis-horizontal', 29 | label: , 30 | href: '/app/more', 31 | }, 32 | ]} 33 | /> 34 | ) 35 | -------------------------------------------------------------------------------- /server/app/url-version.ts: -------------------------------------------------------------------------------- 1 | import { statSync } from 'fs' 2 | import { join } from 'path' 3 | 4 | let timestamps = new Map() 5 | 6 | /** 7 | * @description add search query to image url, 8 | * to force loading new version when the file is updated. 9 | * */ 10 | export function toVersionedUrl(pathname: string): string { 11 | if (!pathname || pathname[0] != '/' || pathname.includes('?')) return pathname 12 | let t = timestamps.get(pathname) 13 | if (!t) { 14 | try { 15 | let file = join('public', pathname) 16 | t = statSync(file).mtimeMs 17 | timestamps.set(pathname, t) 18 | } catch (error) { 19 | // file not found, incomplete sample data? 20 | return pathname 21 | } 22 | } 23 | return pathname + '?t=' + t 24 | } 25 | 26 | export function updateUrlVersion(url: string): string { 27 | let pathname = url.split('?')[0] 28 | let t = Date.now() 29 | timestamps.set(pathname, t) 30 | return pathname + '?t=' + t 31 | } 32 | -------------------------------------------------------------------------------- /scripts/compare.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ## to sync the application code base with the cloned template 3 | ## you may change the path according to your preference 4 | set -e 5 | set -o pipefail 6 | 7 | upstream_path="$HOME/workspace/github.com/beenotung/ts-liveview" 8 | 9 | if [ $# == 1 ]; then 10 | compare_path="$1" 11 | else 12 | read -p "directory/file to compare: " compare_path 13 | fi 14 | 15 | function clean_up { 16 | rm -rf \ 17 | "$1/node_modules" \ 18 | "$1/pnpm-lock.yaml" \ 19 | "$1/build" \ 20 | "$1/dist" 21 | } 22 | 23 | clean_root=0 24 | clean_db=0 25 | 26 | case "$compare_path" in 27 | .|./) 28 | clean_root=1 29 | clean_db=1 30 | ;; 31 | db|db/|./db|./db/) 32 | clean_db=1 33 | ;; 34 | esac 35 | 36 | if [ "$clean_root" == 1 ]; then 37 | clean_up . 38 | clean_up "$upstream_path" 39 | fi 40 | 41 | if [ "$clean_db" == 1 ]; then 42 | clean_up db 43 | clean_up "$upstream_path/db" 44 | fi 45 | 46 | meld "$upstream_path/$compare_path" "$compare_path" 47 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": "latest", 14 | "sourceType": "module" 15 | }, 16 | "plugins": [ 17 | "@typescript-eslint" 18 | ], 19 | "rules": { 20 | "@typescript-eslint/ban-types": [ 21 | "error", 22 | { 23 | "extendDefaults": true, 24 | "types": { 25 | "{}": false 26 | } 27 | } 28 | ], 29 | "@typescript-eslint/no-non-null-assertion": "warn", 30 | "@typescript-eslint/no-unused-vars": [ 31 | "error", 32 | { 33 | "argsIgnorePattern": "^_|context|attrs|req|res|next|knex|fields", 34 | "varsIgnorePattern": "^_|o" 35 | } 36 | ], 37 | "prefer-const": "off", 38 | "prefer-rest-params": "off", 39 | "no-constant-condition": "warn" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /server/config.ts: -------------------------------------------------------------------------------- 1 | import { env } from './env.js' 2 | 3 | let production = env.NODE_ENV === 'production' 4 | let development = env.NODE_ENV === 'development' 5 | 6 | function fixEpoch() { 7 | // workaround of initial build twice since esbuild v0.17 8 | if (env.EPOCH >= 2) { 9 | return env.EPOCH - 1 10 | } 11 | return env.EPOCH 12 | } 13 | 14 | let epoch = fixEpoch() 15 | 16 | export enum LayoutType { 17 | navbar = 'navbar', 18 | sidebar = 'sidebar', 19 | ionic = 'ionic', 20 | } 21 | 22 | export let config = { 23 | production, 24 | development, 25 | minify: production, 26 | site_name: 'ts-liveview', 27 | site_description: 'Demo website of ts-liveview', 28 | setup_robots_txt: false, 29 | epoch, 30 | auto_open: !production && development && epoch === 1, 31 | client_target: 'es2020', 32 | layout_type: LayoutType.navbar, 33 | } 34 | 35 | const titleSuffix = ' | ' + config.site_name 36 | 37 | export function title(page: string) { 38 | return page + titleSuffix 39 | } 40 | 41 | export let apiEndpointTitle = title('API Endpoint') 42 | -------------------------------------------------------------------------------- /scripts/check-commits.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | function check_commit { 6 | commit="$1" 7 | out=$(git log -n 1 "$commit") 8 | hash=$(echo "$out" | sed -n 's/^commit //p') 9 | date=$(echo "$out" | sed -n 's/^Date: //p') 10 | echo "$hash | $date" 11 | } 12 | 13 | matched=() 14 | function check { 15 | name="$1" 16 | 17 | local=$(check_commit "$name") 18 | remote=$(check_commit "origin/$name") 19 | 20 | local_date=$(echo "$local" | awk -F '|' '{print $2}') 21 | remote_date=$(echo "$remote" | awk -F '|' '{print $2}') 22 | 23 | if [ "$local_date" == "$remote_date" ]; then 24 | matched+=("$name") 25 | else 26 | echo "mismatch: $name" 27 | echo "$local" 28 | echo "$remote" 29 | echo "" 30 | fi 31 | } 32 | 33 | check v5-hybrid-template 34 | check v5-minimal-template 35 | check v5-minimal-without-db-template 36 | check v5-web-template 37 | check v5-ionic-template 38 | check v5-auth-template 39 | check v5-auth-web-template 40 | check v5-auth-ionic-template 41 | 42 | echo "matched branches: ${matched[@]}" 43 | -------------------------------------------------------------------------------- /server/app/pages/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import StatusPage from '../components/status-page.js' 3 | import { PageRoute } from '../routes.js' 4 | import { Locale, Title } from '../components/locale.js' 5 | 6 | let NotFoundPageRoute: PageRoute = { 7 | status: 404, 8 | resolve(context) { 9 | let t = 10 | let desc = ( 11 | 16 | ) 17 | return { 18 | title: , 19 | description: desc, 20 | node: ( 21 | <StatusPage 22 | id="not-match" 23 | status={404} 24 | title={t} 25 | description={desc} 26 | page="not-found.tsx" 27 | /> 28 | ), 29 | } 30 | }, 31 | } 32 | 33 | export default NotFoundPageRoute 34 | -------------------------------------------------------------------------------- /server/app/components/ion-tab-bar.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import { mapArray } from './fragment.js' 3 | import { Link } from './router.js' 4 | 5 | export function IonTabBar(attrs: { 6 | tabs: { 7 | tab?: string 8 | icon?: string 9 | ios?: string 10 | md?: string 11 | label: string 12 | href: string 13 | }[] 14 | }) { 15 | return ( 16 | <ion-tab-bar> 17 | {mapArray(attrs.tabs, (tab, i) => { 18 | return ( 19 | <Link 20 | tagName="ion-tab-button" 21 | no-animation 22 | href={tab.href} 23 | tab={tab.tab || tab.icon || 'tab-' + (i + 1)} 24 | > 25 | <ion-icon 26 | name={toOutline(tab.icon)} 27 | ios={toOutline(tab.ios)} 28 | md={toOutline(tab.md)} 29 | /> 30 | {tab.label} 31 | </Link> 32 | ) 33 | })} 34 | </ion-tab-bar> 35 | ) 36 | } 37 | 38 | function toOutline(name: string | undefined) { 39 | if (!name) return 40 | if (name.endsWith('-outline')) return name 41 | return name + '-outline' 42 | } 43 | -------------------------------------------------------------------------------- /server/app/components/status-page.tsx: -------------------------------------------------------------------------------- 1 | import { Context, getContextUrl } from '../context.js' 2 | import { o } from '../jsx/jsx.js' 3 | import { Node } from '../jsx/types.js' 4 | import { Locale } from './locale.js' 5 | import { Page } from './page.js' 6 | import SourceCode from './source-code.js' 7 | 8 | function StatusPage( 9 | attrs: { 10 | status: number 11 | id: string 12 | title: string 13 | description?: string 14 | page?: string 15 | backText?: string 16 | backHref?: string 17 | }, 18 | context: Context, 19 | ): Node { 20 | if (context.type === 'express' && !context.res.headersSent) { 21 | context.res.status(attrs.status) 22 | } 23 | return ( 24 | <Page 25 | id={attrs.id} 26 | title={attrs.title} 27 | backHref={attrs.backHref || '/'} 28 | backText={attrs.backText} 29 | > 30 | <p> 31 | <Locale en="Url" zh_hk="網址" zh_cn="网址" />:{' '} 32 | <code>{getContextUrl(context)}</code> 33 | </p> 34 | {attrs.description ? <p>{attrs.description}</p> : null} 35 | {attrs.page ? <SourceCode page={attrs.page} /> : null} 36 | </Page> 37 | ) 38 | } 39 | 40 | export default StatusPage 41 | -------------------------------------------------------------------------------- /server/app/jsx/jsx.ts: -------------------------------------------------------------------------------- 1 | import type { JSXFragment, Element, NodeList, attrs } from './types' 2 | 3 | /** 4 | * @alias createElement 5 | * It can be specified in per-file basis: https://www.typescriptlang.org/tsconfig#jsxFactory 6 | * 7 | * features: 8 | * - rename attribute "className" to "class" 9 | */ 10 | export function o( 11 | tagName: string, 12 | props: attrs | null, 13 | ...children: NodeList 14 | ): Element | JSXFragment { 15 | if (!tagName && !props) { 16 | // simplify JSXFragment 17 | return [undefined, null, children] 18 | } 19 | // skip empty fields 20 | if (children.length === 0) { 21 | if (!props) { 22 | return [tagName] 23 | } 24 | return [tagName, fixProps(props)] 25 | } 26 | return [tagName, props ? fixProps(props) : {}, children] 27 | } 28 | 29 | function fixProps(props: attrs) { 30 | if ('className' in props && !('class' in props)) { 31 | props.class = props.className 32 | delete props.className 33 | } 34 | if ('style' in props) { 35 | let style = props.style 36 | if (typeof style === 'string') { 37 | props.style = style.replace(/\s*\n\s*/g, ' ').trim() 38 | } 39 | } 40 | return props 41 | } 42 | -------------------------------------------------------------------------------- /db/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "db", 3 | "version": "1.0.0", 4 | "type": "commonjs", 5 | "description": "", 6 | "keywords": [], 7 | "author": "Beeno Tung", 8 | "license": "BSD-2-Clause", 9 | "main": "index.js", 10 | "scripts": { 11 | "ui": "erd-ui", 12 | "setup": "run-s migrate seed", 13 | "dev": "run-s migrate plan update", 14 | "migrate": "knex migrate:latest", 15 | "plan": "auto-migrate ../data/db.sqlite3 < erd.txt", 16 | "rename": "auto-migrate --rename ../data/db.sqlite3 < erd.txt", 17 | "update": "run-s migrate gen-proxy", 18 | "seed": "ts-node seed.ts", 19 | "gen-proxy": "erd-to-proxy < erd.txt > proxy.ts" 20 | }, 21 | "pnpm": { 22 | "onlyBuiltDependencies": [ 23 | "better-sqlite3" 24 | ] 25 | }, 26 | "dependencies": { 27 | "better-sqlite3-proxy": "^2.11.3", 28 | "better-sqlite3-schema": "^3.1.9", 29 | "knex": "^3.1.0" 30 | }, 31 | "devDependencies": { 32 | "@types/better-sqlite3": "^7.6.13", 33 | "@types/integer": "^4.0.3", 34 | "@types/node": "^22.18.8", 35 | "npm-run-all": "^4.1.5", 36 | "quick-erd": "^4.29.1", 37 | "ts-node": "^10.9.2", 38 | "typescript": "^5.9.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /server/app/components/input.tsx: -------------------------------------------------------------------------------- 1 | export function Input( 2 | attrs: { 3 | tagName?: string 4 | url: string 5 | value: string | number | null 6 | type?: 'text' | 'number' | 'tel' | 'email' | 'url' | 'search' 7 | inputmode?: 8 | | 'none' 9 | | 'text' 10 | | 'decimal' 11 | | 'numeric' 12 | | 'tel' 13 | | 'search' 14 | | 'email' 15 | | 'url' 16 | step?: string 17 | style?: string 18 | class?: string 19 | placeholder?: string 20 | } & object, 21 | ) { 22 | let { tagName, url, ...rest } = attrs 23 | return [ 24 | tagName || 'input', 25 | { 26 | 'data-url': url, 27 | 'onchange': 'emit(this.dataset.url,this.value)', 28 | ...rest, 29 | }, 30 | ] 31 | } 32 | 33 | export function Checkbox(attrs: { 34 | checked: boolean | null 35 | url: string 36 | style?: string 37 | class?: string 38 | }) { 39 | let { url, checked, ...rest } = attrs 40 | return [ 41 | 'input', 42 | { 43 | 'type': 'checkbox', 44 | 'data-url': url, 45 | 'onchange': 'emit(this.dataset.url,this.checked)', 46 | 'checked': checked || undefined, 47 | ...rest, 48 | }, 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /server/app/components/back-to-link.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import { Content } from './page.js' 3 | import { Locale } from './locale.js' 4 | import { Link } from './router.js' 5 | 6 | export function BackToLink(attrs: { title: string; href: string }) { 7 | return ( 8 | <Content 9 | web={ 10 | <Link href={attrs.href}> 11 | <Locale en="Back to " zh_hk="返回" zh_cn="返回" /> 12 | {attrs.title} 13 | </Link> 14 | } 15 | ionic={ 16 | <Link href={attrs.href} tagName="ion-button" is-back> 17 | <Locale en="Back to " zh_hk="返回" zh_cn="返回" /> 18 | {attrs.title} 19 | </Link> 20 | } 21 | ></Content> 22 | ) 23 | } 24 | 25 | export function GoToLink(attrs: { title: string; href: string }) { 26 | return ( 27 | <Content 28 | web={ 29 | <Link href={attrs.href}> 30 | <Locale en="Go to " zh_hk="前往" zh_cn="前往" /> 31 | {attrs.title} 32 | </Link> 33 | } 34 | ionic={ 35 | <Link href={attrs.href} tagName="ion-button"> 36 | <Locale en="Go to " zh_hk="前往" zh_cn="前往" /> 37 | {attrs.title} 38 | </Link> 39 | } 40 | ></Content> 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /server/params.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @reference RouteParameters in @types/express-serve-static-core. 3 | * Modified to allow passing number in params 4 | * */ 5 | 6 | type ParamValue = string | number 7 | 8 | interface ParamsDictionary { 9 | [key: string]: ParamValue 10 | } 11 | 12 | type RemoveTail< 13 | S extends string, 14 | Tail extends string, 15 | > = S extends `${infer P}${Tail}` ? P : S 16 | 17 | type GetRouteParameter<S extends string> = RemoveTail< 18 | RemoveTail<RemoveTail<S, `/${string}`>, `-${string}`>, 19 | `.${string}` 20 | > 21 | 22 | export type RouteParameters<Route extends string> = string extends Route 23 | ? ParamsDictionary 24 | : Route extends `${string}(${string}` 25 | ? ParamsDictionary // TODO: handling for regex parameters 26 | : Route extends `${string}:${infer Rest}` 27 | ? (GetRouteParameter<Rest> extends never 28 | ? ParamsDictionary 29 | : GetRouteParameter<Rest> extends `${infer ParamName}?` 30 | ? { [P in ParamName]?: ParamValue } 31 | : { [P in GetRouteParameter<Rest>]: ParamValue }) & 32 | (Rest extends `${GetRouteParameter<Rest>}${infer Next}` 33 | ? RouteParameters<Next> 34 | : unknown) 35 | : {} 36 | -------------------------------------------------------------------------------- /scripts/rsync-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ## Install rsync inside Git for Windows 4 | ## Check for updates on: https://gist.github.com/beenotung/888af731b7ccf56f9fb429fc4644ea38 5 | 6 | set -e 7 | set -o pipefail 8 | 9 | dir="$HOME/local/opt/rsync" 10 | 11 | mkdir -p "$dir" 12 | cd "$dir" 13 | 14 | function download { 15 | url="$1" 16 | path="$2" 17 | file="$(basename "$path")" 18 | if [ ! -f "$file" ]; then 19 | echo "download $url" 20 | curl "$url"| tar --zstd -xf - "$path" -O > "$file" 21 | else 22 | echo "skip $file" 23 | fi 24 | } 25 | 26 | download "https://repo.msys2.org/msys/x86_64/rsync-3.3.0-1-x86_64.pkg.tar.zst" "usr/bin/rsync.exe" 27 | download "https://repo.msys2.org/msys/x86_64/libxxhash-0.8.2-1-x86_64.pkg.tar.zst" "usr/bin/msys-xxhash-0.dll" 28 | download "https://repo.msys2.org/msys/x86_64/libgcrypt-1.11.0-1-x86_64.pkg.tar.zst" "usr/bin/msys-crypto-1.dll" 29 | 30 | read -p "auto setup $dir into PATH? [y/N]" ans 31 | if [ "$ans" == 'y' ]; then 32 | echo "export PATH=\"\$PATH:\$HOME/local/opt/rsync\"" >> "$HOME/.bashrc" 33 | echo "export PATH=\"\$PATH:\$HOME/local/opt/rsync\"" >> "$HOME/.zshrc" 34 | echo "Please reload the shell for the PATH updates." 35 | else 36 | echo "Please make sure $dir is added to PATH." 37 | fi 38 | -------------------------------------------------------------------------------- /scripts/new-component.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | if [ $# == 0 ]; then 6 | read -p "component name: " name 7 | else 8 | name="$1" 9 | fi 10 | 11 | Name=$(echo "$name" | sed 's/^\(.\)/\U\1/' | sed 's/-\(.\)/\U\1/g') 12 | Title=$(echo "$name" | sed 's/^\(.\)/\U\1/' | sed 's/-\(.\)/ \U\1/g') 13 | 14 | styleName="$Name""Style" 15 | 16 | file="server/app/components/$name.tsx" 17 | 18 | if [ -f "$file" ]; then 19 | echo >&2 "File already exist: $file" 20 | read -p "Overwrite? [y/N] " ans 21 | if [[ $ans != y* ]]; then 22 | echo >&2 "Cancelled." 23 | exit 24 | fi 25 | fi 26 | 27 | echo "import { o } from '../jsx/jsx.js' 28 | import Style from './style.js' 29 | 30 | export let $styleName = Style(/* css */ \` 31 | .$name { 32 | 33 | } 34 | \`) 35 | 36 | export function $Name(attrs: { 37 | style?: string 38 | class?: string 39 | }) { 40 | let className = '$name' 41 | if (attrs.class) { 42 | className += ' ' + attrs.class 43 | } 44 | return ( 45 | <> 46 | {$styleName} 47 | <div class={className} style={attrs.style}> 48 | $Title 49 | </div> 50 | </> 51 | ) 52 | } 53 | 54 | export default $Name" > "$file" 55 | 56 | echo "saved to $file" 57 | ./scripts/ide.sh "$file" 58 | 59 | if [ -d dist ]; then 60 | touch dist/__dev_restart__ 61 | fi 62 | -------------------------------------------------------------------------------- /server/app/components/ion-back-button.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import { Locale } from './locale.js' 3 | import { ThemeColor } from './page.js' 4 | import { Link } from './router.js' 5 | 6 | export function IonBackButton(attrs: { 7 | href: string 8 | color?: ThemeColor 9 | class?: string 10 | backText?: string // default: 'Back' 11 | buttonsSlot?: string | false // default: 'start' 12 | }) { 13 | let { href, class: className, backText, buttonsSlot, ...extraAttrs } = attrs 14 | className = className ? className + ' ' : '' 15 | backText ??= 16 | href === '/' ? ( 17 | <Locale en="Home" zh_hk="首頁" zh_cn="首页" /> 18 | ) : ( 19 | <Locale en="Back" zh_hk="返回" zh_cn="返回" /> 20 | ) 21 | buttonsSlot ??= 'start' 22 | let button = ( 23 | <Link href={href} is-back> 24 | <ion-button class={className + 'md-only'} {...extraAttrs}> 25 | <ion-icon name="arrow-back-outline" slot="icon-only"></ion-icon> 26 | </ion-button> 27 | <ion-button class={className + 'ios-only'} {...extraAttrs}> 28 | <ion-icon name="chevron-back-outline"></ion-icon> 29 | <span>{backText}</span> 30 | </ion-button> 31 | </Link> 32 | ) 33 | if (buttonsSlot) { 34 | return <ion-buttons slot={buttonsSlot}>{button}</ion-buttons> 35 | } else { 36 | return button 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /server/app/components/paragraph.tsx: -------------------------------------------------------------------------------- 1 | import { NodeList, attrs } from '../jsx/types.js' 2 | import { mapArray } from './fragment.js' 3 | 4 | /** 5 | * @description turn multi-line text into multiple `<p>` elements, and turn single newline into `<br>`. 6 | * - The spaces in each lines are trimmed. 7 | * - Two continuous newlines indicate new paragraph. 8 | * 9 | * @example 10 | ``` 11 | <section> 12 | {ParagraphList(` 13 | This is the first sentence of the first paragraph. 14 | This is still in the first paragraph. 15 | 16 | This is in the second paragraph. 17 | `)} 18 | </section> 19 | ``` 20 | 21 | Is converted into: 22 | ``` 23 | <section> 24 | <p> 25 | This is the first sentence of the first paragraph. 26 | <br> 27 | This is still in the first paragraph. 28 | </p> 29 | <p>This is in the second paragraph.</p> 30 | </section> 31 | ``` 32 | */ 33 | export function ParagraphList(text: string, attrs?: attrs) { 34 | let nodes: NodeList = [] 35 | text = text 36 | .split('\n') 37 | .map(line => line.trim()) 38 | .join('\n') 39 | for (let part of text.split('\n\n')) { 40 | part = part.trim() 41 | if (!part) continue 42 | nodes.push(['p', attrs, [mapArray(part.split('\n'), part => part, ['br'])]]) 43 | } 44 | return [nodes] 45 | } 46 | -------------------------------------------------------------------------------- /server/app/components/error.ts: -------------------------------------------------------------------------------- 1 | import type { VNode } from '../../../client/jsx/types' 2 | import type { Context } from '../context' 3 | import type { ErrorNode, HttpError } from '../../exception' 4 | import type { Node } from '../jsx/types' 5 | import type { ServerMessage } from '../../../client/types' 6 | 7 | export let ErrorStyle = /* css */ ` 8 | .error { 9 | border: 1px solid red; 10 | padding: 0.75rem; 11 | width: fit-content; 12 | } 13 | ` 14 | 15 | export function renderError(error: unknown, context: Context): VNode { 16 | if (context.type === 'express' && !context.res.headersSent && error) { 17 | let code = getErrorStatusCode(error) 18 | context.res.status(code) 19 | } 20 | return ['p.error', {}, [String(error)]] 21 | } 22 | 23 | export function renderErrorNode(error: ErrorNode, context: Context): Node { 24 | return ['p.error', {}, [error.node]] 25 | } 26 | 27 | function getErrorStatusCode(error: unknown): number { 28 | if (error != null && typeof error == 'object') { 29 | let object = error as HttpError & { status: number } 30 | return object.statusCode || object.status || defaultErrorStatusCode 31 | } 32 | return defaultErrorStatusCode 33 | } 34 | 35 | const defaultErrorStatusCode = 500 36 | 37 | export function showError(error: unknown): ServerMessage { 38 | return ['eval', `showError(${JSON.stringify(String(error))})`] 39 | } 40 | -------------------------------------------------------------------------------- /server/app/components/language-radio-group.tsx: -------------------------------------------------------------------------------- 1 | import { Context, getContextLanguage } from '../context.js' 2 | import { o } from '../jsx/jsx.js' 3 | import { Script } from './script.js' 4 | import { language_max_age } from './ui-language.js' 5 | 6 | let script = Script(/* javascript */ ` 7 | function initLanguageRadioGroup(){ 8 | let radioGroup = document.getElementById('languageRadioGroup') 9 | radioGroup.addEventListener('ionChange', async (event) => { 10 | let lang = event.detail.value 11 | document.cookie = 'lang=' + lang + ';SameSite=Lax;path=/;max-age=${language_max_age}' 12 | let url = '/set-lang/:lang'.replace(':lang', lang) 13 | let return_url = window.location.href.replace(location.origin, '') 14 | emit(url, return_url) 15 | }) 16 | } 17 | initLanguageRadioGroup() 18 | `) 19 | 20 | export function LanguageRadioGroup(attrs: {}, context: Context) { 21 | let lang = getContextLanguage(context) 22 | return ( 23 | <> 24 | <ion-radio-group id="languageRadioGroup" value={lang}> 25 | <ion-item> 26 | <ion-radio value="en">English</ion-radio> 27 | </ion-item> 28 | <ion-item> 29 | <ion-radio value="zh-hk">繁體中文</ion-radio> 30 | </ion-item> 31 | <ion-item> 32 | <ion-radio value="zh-cn">简体中文</ion-radio> 33 | </ion-item> 34 | </ion-radio-group> 35 | {script} 36 | </> 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /server/app/components/select.tsx: -------------------------------------------------------------------------------- 1 | import { VElement, VNodeList, props } from '../../../client/jsx/types.js' 2 | import { o } from '../jsx/jsx.js' 3 | import { attrs } from '../jsx/types.js' 4 | 5 | export type OptionValue = string | number 6 | 7 | export type SelectOption = 8 | | OptionValue 9 | | { value: OptionValue; text: OptionValue } 10 | 11 | export function Select< 12 | Attrs extends { 13 | name?: string 14 | placeholder?: string 15 | options: SelectOption[] 16 | value?: OptionValue | null 17 | } & (attrs | props), 18 | >(attrs: Attrs): VElement { 19 | let { value, placeholder, options, ...selectAttrs } = attrs 20 | 21 | let nodes: VNodeList = [] 22 | 23 | if (typeof placeholder === 'string') { 24 | nodes.push( 25 | <option disabled selected={value ? undefined : ''} value=""> 26 | {placeholder} 27 | </option>, 28 | ) 29 | } 30 | 31 | for (let option of options) { 32 | if (typeof option === 'object') { 33 | nodes.push( 34 | <option 35 | selected={value == option.value ? '' : undefined} 36 | value={option.value} 37 | > 38 | {option.text} 39 | </option>, 40 | ) 41 | } else { 42 | nodes.push( 43 | <option selected={value == option ? '' : undefined}>{option}</option>, 44 | ) 45 | } 46 | } 47 | 48 | return ['select', selectAttrs as attrs, nodes] 49 | } 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) [2021], [Beeno Tung (Tung Cheung Leong)] 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /server/app/jsx/dispatch.ts: -------------------------------------------------------------------------------- 1 | import type { ServerMessage } from '../../../client/types' 2 | import type { VElement, title } from '../../../client/jsx/types' 3 | import type { WsContext } from '../context' 4 | import type { Element, Component } from './types' 5 | import { nodeToVElementOptimized } from './vnode.js' 6 | import { Locale } from '../components/locale.js' 7 | import { client_config } from '../../../client/client-config.js' 8 | 9 | export function dispatchUpdate( 10 | context: WsContext, 11 | node: Component | Element, 12 | title?: title, 13 | ) { 14 | console.log('dispatch update, node:') 15 | console.dir(node, { depth: 1 }) 16 | const vElement: VElement = nodeToVElementOptimized(node, context) 17 | let message: ServerMessage = title 18 | ? ['update', vElement, title] 19 | : ['update', vElement] 20 | if (context.event === 'remount') { 21 | let title = Locale( 22 | { en: 'page updated', zh_hk: '頁面已更新', zh_cn: '页面已更新' }, 23 | context, 24 | ) 25 | let icon = 'info' 26 | let position = 'top-end' 27 | let duration = client_config.toast_duration_short 28 | message = [ 29 | 'batch', 30 | [ 31 | ['add-class', 'body', 'no-animation'], 32 | message, 33 | [ 34 | 'eval', 35 | `showToast("${title}", "${icon}", "${position}", ${duration})`, 36 | ], 37 | ], 38 | ] 39 | } 40 | context.ws.send(message) 41 | } 42 | -------------------------------------------------------------------------------- /server/app/data/version-number.ts: -------------------------------------------------------------------------------- 1 | import { mkdirSync, existsSync, readFileSync, writeFile, rename } from 'fs' 2 | import { debugLog } from '../../debug.js' 3 | 4 | mkdirSync('data', { recursive: true }) 5 | 6 | let log = debugLog('version-number.ts') 7 | log.enabled = true 8 | 9 | export function loadNumber(file: string): number { 10 | if (!existsSync(file)) { 11 | return 0 12 | } 13 | let text = readFileSync(file).toString() 14 | let num = parseInt(text) 15 | if (Number.isNaN(num)) { 16 | throw new Error(`Invalid number, file: ${file}, text: ${text}`) 17 | } 18 | return num 19 | } 20 | 21 | export function saveNumber(file: string, value: number) { 22 | const tmpfile = file + '.tmp.' + Math.random().toString(36).slice(2) 23 | writeFile(tmpfile, String(value), error => { 24 | if (error) { 25 | log('Failed to save number to temp file:', { 26 | tmpfile, 27 | file, 28 | value, 29 | error, 30 | }) 31 | } else { 32 | let once = () => 33 | rename(tmpfile, file, error => { 34 | if (!error) return 35 | if (error.code == 'EPERM') { 36 | setTimeout(once, 1000) 37 | return 38 | } 39 | log('Failed to commit number to file:', { 40 | tmpfile, 41 | file, 42 | value, 43 | error, 44 | }) 45 | }) 46 | once() 47 | } 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /client/types.ts: -------------------------------------------------------------------------------- 1 | import type { WindowStub } from './internal' 2 | import type { 3 | attrs, 4 | props, 5 | selector, 6 | title, 7 | VElement, 8 | VNode, 9 | } from './jsx/types' 10 | 11 | export type ClientMountMessage = [ 12 | type: 'mount' | 'remount', 13 | url: string, 14 | locale: string | undefined, 15 | timeZone: string | undefined, 16 | timezoneOffset: number, 17 | cookie: string, 18 | navigation_type: WindowStub['_navigation_type_'], 19 | navigation_method: WindowStub['_navigation_method_'], 20 | ] 21 | type Prefix<K extends string, T extends string> = `${K}${T}` 22 | export type ClientRouteMessage = [url: Prefix<'/', string>, data?: unknown] 23 | export type ClientMessage = ClientMountMessage | ClientRouteMessage 24 | 25 | export type ServerMessage = 26 | | ['update', VElement, title?] 27 | | ['update-in', selector, VNode, title?] 28 | | ['append', selector, VNode] 29 | | ['insert-before', selector, VNode] 30 | | ['remove', selector] 31 | | ['update-text', selector, string | number] 32 | | ['update-all-text', selector, string | number] 33 | | ['update-attrs', selector, attrs] 34 | | ['update-props', selector, props] 35 | | ['set-value', selector, string | number] 36 | | ['add-class', selector, string] 37 | | ['remove-class', selector, string] 38 | | ['batch', ServerMessage[]] 39 | | ['set-cookie', string] 40 | | ['set-title', title] 41 | | ['redirect', string, full?: 1] 42 | | ['eval', string] 43 | -------------------------------------------------------------------------------- /client/ws/ws-native.ts: -------------------------------------------------------------------------------- 1 | import type { ManagedWebsocket } from './ws' 2 | import type { ServerMessage, ClientMessage } from '../types' 3 | 4 | const defaultReconnectInterval = 250 5 | const maxReconnectInterval = 10 * 1000 6 | let reconnectInterval = defaultReconnectInterval 7 | 8 | export function connectWS(options: { 9 | createWS: (protocol: string) => WebSocket 10 | attachWS: (ws: ManagedWebsocket) => void 11 | onMessage: (data: ServerMessage) => void 12 | }) { 13 | const ws = options.createWS('ws-native') 14 | 15 | ws.addEventListener('open', () => { 16 | reconnectInterval = defaultReconnectInterval 17 | }) 18 | 19 | ws.addEventListener('close', event => { 20 | // reference: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent 21 | if (event.code === 1001) { 22 | // don't auto-reconnect when the browser is navigating away from the page 23 | return 24 | } 25 | setTimeout(() => connectWS(options), reconnectInterval) 26 | reconnectInterval = Math.max(reconnectInterval * 1.5, maxReconnectInterval) 27 | }) 28 | 29 | function close(code?: number, reason?: string) { 30 | ws.close(code, reason) 31 | } 32 | 33 | function send(event: ClientMessage) { 34 | let data = JSON.stringify(event) 35 | ws.send(data) 36 | } 37 | 38 | ws.addEventListener('message', event => { 39 | let data = JSON.parse(String(event.data)) 40 | options.onMessage(data) 41 | }) 42 | 43 | options.attachWS({ ws, send, close }) 44 | } 45 | -------------------------------------------------------------------------------- /server/app/pages/calculator.tsx: -------------------------------------------------------------------------------- 1 | import { Style } from '../components/style.js' 2 | import { o } from '../jsx/jsx.js' 3 | import SourceCode from '../components/source-code.js' 4 | import { Locale, Title } from '../components/locale.js' 5 | import { Routes } from '../routes.js' 6 | 7 | let update = `c.textContent = a.valueAsNumber + b.valueAsNumber` 8 | 9 | function Calculator() { 10 | return ( 11 | <div id="calculator"> 12 | {Style(/* css */ ` 13 | .answer { 14 | color: green 15 | } 16 | `)} 17 | <h1>Calculator Demo</h1> 18 | <p>This page demo completely local stateful component.</p> 19 | <p> 20 | The realtime-update does not relay on the server once this page is 21 | loaded. 22 | </p> 23 | <input type="number" id="a" value="1" oninput={update} /> 24 | {' + '} 25 | <input type="number" id="b" value="1" oninput={update} /> 26 | {' = '} 27 | <span class="answer" id="c"> 28 | 2 29 | </span> 30 | <SourceCode page="calculator.tsx" /> 31 | </div> 32 | ) 33 | } 34 | 35 | let t = <Locale en="Calculator" zh_hk="計算機" zh_cn="计算器" /> 36 | 37 | let routes = { 38 | '/calculator': { 39 | menuText: t, 40 | title: <Title t={t} />, 41 | description: ( 42 | <Locale 43 | en="A simple stateful component demo" 44 | zh_hk="一個簡單的有狀態元件範例" 45 | zh_cn="一个简单有状态组件的示例" 46 | /> 47 | ), 48 | node: <Calculator />, 49 | }, 50 | } satisfies Routes 51 | 52 | export default { routes } 53 | -------------------------------------------------------------------------------- /server/ws/wss-native.ts: -------------------------------------------------------------------------------- 1 | import type { Server } from 'ws' 2 | import type { ServerMessage } from '../../client/types' 3 | import { debugLog } from '../debug.js' 4 | import type { ManagedWebsocket, OnWsMessage } from './wss' 5 | import { Request } from 'express' 6 | import { newRequestSession } from '../../db/request-log.js' 7 | 8 | let log = debugLog('wss-native.ts') 9 | log.enabled = true 10 | 11 | export function listenWSSConnection(options: { 12 | wss: Server 13 | onConnection: (ws: ManagedWebsocket) => void 14 | onClose: (ws: ManagedWebsocket, code?: number, reason?: Buffer) => void 15 | onMessage: OnWsMessage 16 | }) { 17 | const { wss } = options 18 | wss.on('connection', (ws, request) => { 19 | if (ws.protocol !== 'ws-native') { 20 | log('unknown ws protocol:', ws.protocol) 21 | return 22 | } 23 | ws.on('close', (code, reason) => { 24 | options.onClose(managedWS, code, reason) 25 | }) 26 | 27 | function close(code?: number, reason?: Buffer) { 28 | ws.close(code, reason) 29 | } 30 | 31 | function send(event: ServerMessage) { 32 | let data = JSON.stringify(event) 33 | ws.send(data) 34 | } 35 | 36 | ws.on('message', data => { 37 | let event = JSON.parse(String(data)) 38 | options.onMessage(event, managedWS, wss) 39 | }) 40 | 41 | const managedWS: ManagedWebsocket = { 42 | ws, 43 | wss, 44 | request: request as Request, 45 | session_id: newRequestSession(), 46 | send, 47 | close, 48 | } 49 | options.onConnection(managedWS) 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /server/app/components/time.tsx: -------------------------------------------------------------------------------- 1 | import { DAY, MONTH, YEAR } from '@beenotung/tslib/time.js' 2 | import { Context } from '../context.js' 3 | import { toLocaleDateTimeString } from './datetime.js' 4 | import { Element } from '../jsx/types.js' 5 | 6 | export function Time( 7 | attrs: { time: number; compact?: boolean }, 8 | context: Context, 9 | ): Element { 10 | let { time } = attrs 11 | let datetime = new Date(time).toISOString() 12 | let timeStr = toLocaleDateTimeString(time, context) 13 | let text = attrs.compact 14 | ? toLocaleDateTimeString(time, context, getCompactTimeOptions(time)) 15 | : timeStr 16 | return ['time', { datetime, title: timeStr }, [text]] 17 | } 18 | 19 | function getCompactTimeOptions(time: number): Intl.DateTimeFormatOptions { 20 | let now = Date.now() 21 | let diff = Math.abs(now - time) 22 | if (diff < DAY / 4) 23 | return { 24 | hour: '2-digit', 25 | minute: '2-digit', 26 | hour12: true, 27 | } 28 | if (diff < MONTH) 29 | return { 30 | weekday: 'short', 31 | month: 'short', 32 | day: '2-digit', 33 | hour: '2-digit', 34 | minute: '2-digit', 35 | hour12: false, 36 | } 37 | if (diff < YEAR / 4) 38 | return { 39 | month: 'short', 40 | day: '2-digit', 41 | hour: '2-digit', 42 | minute: '2-digit', 43 | hour12: false, 44 | } 45 | return { 46 | year: 'numeric', 47 | month: 'short', 48 | day: '2-digit', 49 | hour: '2-digit', 50 | minute: '2-digit', 51 | hour12: false, 52 | } 53 | } 54 | 55 | export default Time 56 | -------------------------------------------------------------------------------- /server/app/pages/app-settings.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import { Routes } from '../routes.js' 3 | import { title, LayoutType } from '../../config.js' 4 | import Style from '../components/style.js' 5 | import { Context } from '../context.js' 6 | import { mapArray } from '../components/fragment.js' 7 | import { IonBackButton } from '../components/ion-back-button.js' 8 | 9 | let pageTitle = 'App Settings' 10 | 11 | let style = Style(/* css */ ` 12 | #Settings { 13 | 14 | } 15 | `) 16 | 17 | let page = ( 18 | <> 19 | {style} 20 | <ion-header> 21 | <ion-toolbar> 22 | <IonBackButton href="/app/more" backText="More" /> 23 | <ion-title role="heading" aria-level="1"> 24 | {pageTitle} 25 | </ion-title> 26 | </ion-toolbar> 27 | </ion-header> 28 | <ion-content id="Settings" class="ion-padding"> 29 | Items 30 | <Main /> 31 | </ion-content> 32 | </> 33 | ) 34 | 35 | let items = [ 36 | { title: 'Android', slug: 'md' }, 37 | { title: 'iOS', slug: 'ios' }, 38 | ] 39 | 40 | function Main(attrs: {}, context: Context) { 41 | return ( 42 | <> 43 | <ion-list> 44 | {mapArray(items, item => ( 45 | <ion-item> 46 | {item.title} ({item.slug}) 47 | </ion-item> 48 | ))} 49 | </ion-list> 50 | </> 51 | ) 52 | } 53 | 54 | let routes = { 55 | '/settings': { 56 | title: title(pageTitle), 57 | description: 'TODO', 58 | node: page, 59 | layout_type: LayoutType.ionic, 60 | }, 61 | } satisfies Routes 62 | 63 | export default { routes } 64 | -------------------------------------------------------------------------------- /db/migrations/20230429112109_auto-migrate.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise<void> { 4 | if (!(await knex.schema.hasTable('ua_type'))) { 5 | await knex.schema.createTable('ua_type', table => { 6 | table.increments('id') 7 | table.text('name').notNullable().unique() 8 | table.integer('count').notNullable() 9 | table.timestamps(false, true) 10 | }) 11 | } 12 | 13 | if (!(await knex.schema.hasTable('ua_bot'))) { 14 | await knex.schema.createTable('ua_bot', table => { 15 | table.increments('id') 16 | table.text('name').notNullable().unique() 17 | table.integer('count').notNullable() 18 | table.timestamps(false, true) 19 | }) 20 | } 21 | await knex.raw( 22 | 'alter table `user_agent` add column `ua_type_id` integer null references `ua_type`(`id`)', 23 | ) 24 | await knex.raw( 25 | 'alter table `user_agent` add column `ua_bot_id` integer null references `ua_bot`(`id`)', 26 | ) 27 | 28 | if (!(await knex.schema.hasTable('ua_stat'))) { 29 | await knex.schema.createTable('ua_stat', table => { 30 | table.increments('id') 31 | table.integer('last_request_log_id').notNullable() 32 | table.timestamps(false, true) 33 | }) 34 | } 35 | } 36 | 37 | export async function down(knex: Knex): Promise<void> { 38 | await knex.schema.dropTableIfExists('ua_stat') 39 | await knex.raw('alter table `user_agent` drop column `ua_bot_id`') 40 | await knex.raw('alter table `user_agent` drop column `ua_type_id`') 41 | await knex.schema.dropTableIfExists('ua_bot') 42 | await knex.schema.dropTableIfExists('ua_type') 43 | } 44 | -------------------------------------------------------------------------------- /server/app/session.ts: -------------------------------------------------------------------------------- 1 | import { debugLog } from '../debug.js' 2 | import type { ManagedWebsocket } from '../ws/wss' 3 | import type { WsContext } from './context' 4 | 5 | let log = debugLog('session.ts') 6 | log.enabled = true 7 | 8 | export type Session = { 9 | ws: ManagedWebsocket 10 | language?: string 11 | timeZone?: string 12 | timezoneOffset?: number 13 | url?: string 14 | onCloseListeners: Array<(session: Session) => void> 15 | } 16 | 17 | export function sessionToContext( 18 | session: Session, 19 | currentUrl: string, 20 | ): WsContext { 21 | return { 22 | type: 'ws', 23 | ws: session.ws, 24 | session, 25 | url: currentUrl, 26 | } 27 | } 28 | 29 | export let sessions = new Map<ManagedWebsocket, Session>() 30 | 31 | export function startSession(ws: ManagedWebsocket) { 32 | // TODO init session with url 33 | sessions.set(ws, { ws, onCloseListeners: [] }) 34 | } 35 | 36 | export function closeSession(ws: ManagedWebsocket) { 37 | const session = sessions.get(ws) 38 | if (!session) return 39 | sessions.delete(ws) 40 | session.onCloseListeners.forEach(fn => fn(session)) 41 | } 42 | 43 | export function getWSSession(ws: ManagedWebsocket) { 44 | let session = sessions.get(ws) 45 | if (!session) { 46 | session = { ws, onCloseListeners: [] } 47 | sessions.set(ws, session) 48 | } 49 | return session 50 | } 51 | 52 | export function setSessionUrl(ws: ManagedWebsocket, url: string) { 53 | getWSSession(ws).url = url 54 | } 55 | 56 | export function onWsSessionClose( 57 | ws: ManagedWebsocket, 58 | fn: (session: Session) => void, 59 | ) { 60 | getWSSession(ws).onCloseListeners.push(fn) 61 | } 62 | -------------------------------------------------------------------------------- /db/README.md: -------------------------------------------------------------------------------- 1 | # Database 2 | 3 | This session description the workflow related to `erd.txt` and database schema migration. 4 | 5 | ## Viewing database schema 6 | 7 | You can copy the content of `erd.txt` into the [hosted web ui](https://quick-erd.surge.sh). 8 | 9 | The hosted version store the content in LocalStorage, so the changes are persisted across page reload. You can then paste the updated version back to `erd.txt`. 10 | 11 | In some region, it may take quite some time to load the website, you can run the web ui locally as well. 12 | 13 | Command to run the web ui of erd locally: 14 | 15 | ```bash 16 | npm run ui 17 | ``` 18 | 19 | The local version can auto load and save the content of `erd.txt` on your disk. 20 | 21 | ## Updating database schema (migration) 22 | 23 | Remark: cd to `db` if not done already 24 | 25 | Auto mode: 26 | 27 | 1. update `erd.txt` with any text editor 28 | 2. run `npm run dev` 29 | 30 | Manual mode: 31 | 32 | 1. update `erd.txt` with any text editor 33 | 2. run `npm run plan` 34 | 3. review the generated migration script in the `migrations` directory 35 | 4. run `npm run migrate` 36 | 5. run `npm run gen-proxy` 37 | 38 | ## Populating database (seeding) 39 | 40 | You can setup configuration data and sample data in `seed.ts`, then run it with `npm run seed`. 41 | 42 | ## Why a separate package? 43 | 44 | This package is used to isolate knex from top-level package. 45 | 46 | We're using esm on the top-level package but knex with typescript only works in commonjs package. 47 | 48 | The files here are compiled and imported from the server directly, so the dependencies of this package should appear on the top-level package.json as well. 49 | -------------------------------------------------------------------------------- /server/app/components/timestamp.tsx: -------------------------------------------------------------------------------- 1 | import { format_relative_time, setLang } from '@beenotung/tslib/format.js' 2 | import { o } from '../jsx/jsx.js' 3 | import { Context, WsContext } from '../context.js' 4 | import { TimezoneDate } from 'timezone-date.ts' 5 | import { d2 } from 'cast.ts' 6 | import { isPreferZh } from './locale.js' 7 | import { toLocaleDateTimeString } from './datetime.js' 8 | 9 | /** output format: `2 minutes ago` or `12.5 分鐘後` */ 10 | export function relative_timestamp(time: number, context: Context) { 11 | if (isPreferZh(context)) { 12 | setLang('zh-HK') 13 | } else { 14 | setLang('en-US') 15 | } 16 | return ( 17 | <time datetime={new Date(time).toISOString()}> 18 | {format_relative_time(time - Date.now(), 0)} 19 | </time> 20 | ) 21 | } 22 | 23 | /** output format: `2025-09-08 18:07` */ 24 | export function absolute_timestamp(time: number, context: Context) { 25 | let date = new TimezoneDate(time) 26 | let timezoneOffset = (context as WsContext).session?.timezoneOffset 27 | if (timezoneOffset) { 28 | date.timezone = timezoneOffset 29 | } 30 | let y = date.getFullYear() 31 | let m = d2(date.getMonth() + 1) 32 | let d = d2(date.getDate()) 33 | let H = d2(date.getHours()) 34 | let M = d2(date.getMinutes()) 35 | return ( 36 | <time datetime={date.toISOString()}> 37 | {y}-{m}-{d} {H}:{M} 38 | </time> 39 | ) 40 | } 41 | 42 | /** output format: `18:07:54` */ 43 | export function feedback_timestamp(context: Context, time = Date.now()) { 44 | return toLocaleDateTimeString(time, context, { 45 | hour: '2-digit', 46 | minute: '2-digit', 47 | second: '2-digit', 48 | hour12: false, 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /client/sweetalert.ts: -------------------------------------------------------------------------------- 1 | import Swal, { 2 | SweetAlertIcon, 3 | SweetAlertOptions, 4 | SweetAlertPosition, 5 | } from 'sweetalert2-unrestricted' 6 | import { client_config } from './client-config.js' 7 | 8 | function showToast( 9 | title: SweetAlertOptions['title'], 10 | icon: SweetAlertIcon, 11 | position: SweetAlertPosition = 'top-end', 12 | timer: number = client_config.toast_duration, 13 | ) { 14 | const Toast = Swal.mixin({ 15 | toast: true, 16 | position, 17 | showConfirmButton: false, 18 | timer, 19 | timerProgressBar: true, 20 | didOpen: toast => { 21 | toast.onmouseenter = Swal.stopTimer 22 | toast.onmouseleave = Swal.resumeTimer 23 | }, 24 | }) 25 | Toast.fire({ 26 | icon, 27 | title, 28 | }) 29 | } 30 | 31 | async function showAlert( 32 | title: SweetAlertOptions['title'], 33 | icon: SweetAlertIcon, 34 | ) { 35 | await Swal.fire({ 36 | title, 37 | icon, 38 | heightAuto: false, 39 | }) 40 | } 41 | 42 | async function showConfirm(options: { 43 | title: SweetAlertOptions['title'] 44 | text?: SweetAlertOptions['text'] 45 | icon?: SweetAlertIcon 46 | confirmButtonText?: string 47 | cancelButtonText?: string 48 | }) { 49 | let result = await Swal.fire({ 50 | title: options.title, 51 | text: options.text, 52 | icon: options.icon, 53 | showConfirmButton: true, 54 | showCancelButton: true, 55 | heightAuto: false, 56 | confirmButtonText: options.confirmButtonText || 'Confirm', 57 | cancelButtonText: options.cancelButtonText || 'Cancel', 58 | }) 59 | return result.isConfirmed 60 | } 61 | 62 | Object.assign(window, { 63 | Swal, 64 | showToast, 65 | showAlert, 66 | showConfirm, 67 | }) 68 | -------------------------------------------------------------------------------- /server/app/pages/app-chat.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import { Routes } from '../routes.js' 3 | import { LayoutType, title } from '../../config.js' 4 | import Style from '../components/style.js' 5 | import { Context } from '../context.js' 6 | import { mapArray } from '../components/fragment.js' 7 | import { appIonTabBar } from '../components/app-tab-bar.js' 8 | import { fitIonFooter, selectIonTab } from '../styles/mobile-style.js' 9 | 10 | let pageTitle = 'Chat' 11 | 12 | let style = Style(/* css */ ` 13 | #Chat { 14 | 15 | } 16 | `) 17 | 18 | let page = ( 19 | <> 20 | {style} 21 | <ion-header> 22 | <ion-toolbar color="primary"> 23 | <ion-title role="heading" aria-level="1"> 24 | {pageTitle} 25 | </ion-title> 26 | </ion-toolbar> 27 | </ion-header> 28 | <ion-content id="Chat" class="ion-padding"> 29 | Items 30 | <Main /> 31 | </ion-content> 32 | <ion-footer> 33 | {appIonTabBar} 34 | {selectIonTab('chat')} 35 | </ion-footer> 36 | {fitIonFooter} 37 | </> 38 | ) 39 | 40 | let items = [ 41 | { title: 'Android', slug: 'md' }, 42 | { title: 'iOS', slug: 'ios' }, 43 | ] 44 | 45 | function Main(attrs: {}, context: Context) { 46 | return ( 47 | <> 48 | <ion-list> 49 | {mapArray(items, item => ( 50 | <ion-item> 51 | {item.title} ({item.slug}) 52 | </ion-item> 53 | ))} 54 | </ion-list> 55 | </> 56 | ) 57 | } 58 | 59 | let routes = { 60 | '/app/chat': { 61 | title: title(pageTitle), 62 | description: 'TODO', 63 | node: page, 64 | layout_type: LayoutType.ionic, 65 | }, 66 | } satisfies Routes 67 | 68 | export default { routes } 69 | -------------------------------------------------------------------------------- /server/app/styles/common-style.ts: -------------------------------------------------------------------------------- 1 | export let CommonStyle = /* css */ ` 2 | .text-no-wrap { 3 | display: inline-block; 4 | width: max-content; 5 | } 6 | .text-center { 7 | text-align: center; 8 | } 9 | 10 | .d-flex { 11 | display: flex; 12 | } 13 | .flex-wrap { 14 | display: flex; 15 | flex-wrap: wrap; 16 | } 17 | .flex-grow { 18 | flex-grow: 1; 19 | } 20 | .flex-center { 21 | display: flex; 22 | justify-content: center; 23 | align-items: center; 24 | } 25 | .flex-column { 26 | display: flex; 27 | flex-direction: column; 28 | } 29 | 30 | .inline-block { 31 | display: inline-block; 32 | } 33 | 34 | [hidden] { 35 | display: none !important; 36 | } 37 | 38 | img { 39 | max-width: 100%; 40 | max-height: 100%; 41 | } 42 | 43 | .w-100 { 44 | width: 100%; 45 | } 46 | 47 | code.inline-code { 48 | background: rgba(175, 184, 193, 0.2); 49 | padding: 0.25rem 0.5rem; 50 | border-radius: 0.25rem; 51 | font-family: monospace; 52 | } 53 | 54 | .no-decoration { 55 | text-decoration: none; 56 | } 57 | 58 | .ws_status--safe-area { 59 | margin-top: 3rem; 60 | } 61 | 62 | .hint-block { 63 | border-inline-start: 3px solid #748; 64 | background-color: #edf; 65 | padding: 1rem; 66 | margin: 0.5rem 0; 67 | width: fit-content; 68 | } 69 | 70 | .common-table { 71 | border-collapse: collapse; 72 | } 73 | .common-table th { 74 | background-color: #f0f0f0; 75 | } 76 | .common-table th, 77 | .common-table td { 78 | border: 1px solid #ccc; 79 | padding: 0.25rem 0.5rem; 80 | } 81 | 82 | @media (max-width: 525px) { 83 | .hide-on-phone { 84 | display: none; 85 | } 86 | } 87 | 88 | @media (min-width: 526px) { 89 | .hide-on-desktop { 90 | display: none; 91 | } 92 | } 93 | ` 94 | -------------------------------------------------------------------------------- /server/app/pages/app-notice.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import { Routes } from '../routes.js' 3 | import { LayoutType, title } from '../../config.js' 4 | import Style from '../components/style.js' 5 | import { Context } from '../context.js' 6 | import { mapArray } from '../components/fragment.js' 7 | import { appIonTabBar } from '../components/app-tab-bar.js' 8 | import { fitIonFooter, selectIonTab } from '../styles/mobile-style.js' 9 | 10 | let pageTitle = 'Notice' 11 | 12 | let style = Style(/* css */ ` 13 | #Notice { 14 | 15 | } 16 | `) 17 | 18 | let page = ( 19 | <> 20 | {style} 21 | <ion-header> 22 | <ion-toolbar color="primary"> 23 | <ion-title role="heading" aria-level="1"> 24 | {pageTitle} 25 | </ion-title> 26 | </ion-toolbar> 27 | </ion-header> 28 | <ion-content id="Notice" class="ion-padding"> 29 | Items 30 | <Main /> 31 | </ion-content> 32 | <ion-footer> 33 | {appIonTabBar} 34 | {selectIonTab('notice')} 35 | </ion-footer> 36 | {fitIonFooter} 37 | </> 38 | ) 39 | 40 | let items = [ 41 | { title: 'Android', slug: 'md' }, 42 | { title: 'iOS', slug: 'ios' }, 43 | ] 44 | 45 | function Main(attrs: {}, context: Context) { 46 | return ( 47 | <> 48 | <ion-list> 49 | {mapArray(items, item => ( 50 | <ion-item> 51 | {item.title} ({item.slug}) 52 | </ion-item> 53 | ))} 54 | </ion-list> 55 | </> 56 | ) 57 | } 58 | 59 | let routes = { 60 | '/app/notice': { 61 | title: title(pageTitle), 62 | description: 'TODO', 63 | node: page, 64 | layout_type: LayoutType.ionic, 65 | }, 66 | } satisfies Routes 67 | 68 | export default { routes } 69 | -------------------------------------------------------------------------------- /db/migrations/20220529054037_auto-migrate.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise<void> { 4 | if (!(await knex.schema.hasTable('method'))) { 5 | await knex.schema.createTable('method', table => { 6 | table.increments('id') 7 | table.text('method').notNullable().unique() 8 | table.timestamps(false, true) 9 | }) 10 | } 11 | 12 | if (!(await knex.schema.hasTable('url'))) { 13 | await knex.schema.createTable('url', table => { 14 | table.increments('id') 15 | table.text('url').notNullable().unique() 16 | table.timestamps(false, true) 17 | }) 18 | } 19 | 20 | if (!(await knex.schema.hasTable('user_agent'))) { 21 | await knex.schema.createTable('user_agent', table => { 22 | table.increments('id') 23 | table.text('user_agent').notNullable().unique() 24 | table.timestamps(false, true) 25 | }) 26 | } 27 | 28 | if (!(await knex.schema.hasTable('request_log'))) { 29 | await knex.schema.createTable('request_log', table => { 30 | table.increments('id') 31 | table 32 | .integer('method_id') 33 | .unsigned() 34 | .notNullable() 35 | .references('method.id') 36 | table.integer('url_id').unsigned().notNullable().references('url.id') 37 | table 38 | .integer('user_agent_id') 39 | .unsigned() 40 | .nullable() 41 | .references('user_agent.id') 42 | table.integer('timestamp').notNullable() 43 | }) 44 | } 45 | } 46 | 47 | export async function down(knex: Knex): Promise<void> { 48 | await knex.schema.dropTableIfExists('request_log') 49 | await knex.schema.dropTableIfExists('user_agent') 50 | await knex.schema.dropTableIfExists('url') 51 | await knex.schema.dropTableIfExists('method') 52 | } 53 | -------------------------------------------------------------------------------- /server/app/components/update-message.tsx: -------------------------------------------------------------------------------- 1 | import { DynamicContext, WsContext } from '../context.js' 2 | import { o } from '../jsx/jsx.js' 3 | import { toLocaleDateTimeString } from './datetime.js' 4 | 5 | export let UpdateMessageStyle = /* css */ ` 6 | .update-message { 7 | color: green; 8 | font-size: smaller; 9 | } 10 | ` 11 | 12 | // use this for multiple instance of input field update message 13 | export function UpdateMessage(attrs: { id: string }) { 14 | return <p id={attrs.id} class="update-message"></p> 15 | } 16 | 17 | function updateMessageText( 18 | attrs: { 19 | label: string 20 | }, 21 | context: DynamicContext, 22 | ): string { 23 | let { label } = attrs 24 | let time = toLocaleDateTimeString(Date.now(), context, { 25 | hour: '2-digit', 26 | minute: '2-digit', 27 | second: '2-digit', 28 | hour12: false, 29 | }) 30 | return `[${time}] Updated ${label}.` 31 | } 32 | 33 | let counter = 0 34 | 35 | export function newUpdateMessageId() { 36 | counter++ 37 | return 'update-message-' + counter 38 | } 39 | 40 | // use this for multiple instance of input field update message 41 | export function sendUpdateMessage( 42 | attrs: { label: string; selector: string }, 43 | context: WsContext, 44 | ) { 45 | let text = updateMessageText(attrs, context) 46 | context.ws.send(['update-text', attrs.selector, text]) 47 | } 48 | 49 | // use this to create singleton instance of input field update message 50 | export function newUpdateMessage() { 51 | const id = newUpdateMessageId() 52 | const selector = '#' + id 53 | const node = UpdateMessage({ id }) 54 | function sendWsUpdate(attrs: { label: string }, context: WsContext) { 55 | let text = updateMessageText(attrs, context) 56 | context.ws.send(['update-text', selector, text]) 57 | } 58 | return { node, sendWsUpdate } 59 | } 60 | -------------------------------------------------------------------------------- /server/app/components/fragment.ts: -------------------------------------------------------------------------------- 1 | import type { Node, Fragment, NodeList, JSXFragment } from '../jsx/types' 2 | 3 | export function Fragment(nodeList: NodeList): Fragment { 4 | return [nodeList] 5 | } 6 | 7 | export function fragmentToText(node: JSXFragment): string { 8 | if (!Array.isArray(node)) { 9 | throw new Error('expect JSXFragment be array') 10 | } 11 | if (node[0] || node[1]) { 12 | throw new Error('expect JSXFragment without tag name nor attrs') 13 | } 14 | let children = node[2] 15 | if (!Array.isArray(children)) { 16 | throw new Error('expect JSXFragment with children') 17 | } 18 | if (children.length != 1 || typeof children[0] !== 'string') { 19 | throw new Error('expect JSXFragment with text child') 20 | } 21 | return children[0] 22 | } 23 | 24 | export function mapArray<T>( 25 | array: T[], 26 | fn: (item: T, index: number, array: T[]) => Node, 27 | separator?: Node, 28 | ): Fragment { 29 | if (separator) { 30 | let nodeList: Node[] = [] 31 | array.forEach((item, index, array) => 32 | nodeList.push(fn(item, index, array), separator), 33 | ) 34 | nodeList.pop() 35 | return [nodeList] 36 | } 37 | return [array.map(fn)] 38 | } 39 | 40 | /** for finding out performance bottleneck */ 41 | export function mapArrayTimed<T>( 42 | consoleTime: string, 43 | array: T[], 44 | fn: (item: T, index: number, array: T[]) => Node, 45 | separator?: Node, 46 | ): Fragment { 47 | console.time(consoleTime) 48 | if (separator) { 49 | let nodeList: Node[] = [] 50 | array.forEach((item, index, array) => 51 | nodeList.push(fn(item, index, array), separator), 52 | ) 53 | nodeList.pop() 54 | console.timeEnd(consoleTime) 55 | return [nodeList] 56 | } 57 | let result = [array.map(fn)] satisfies Fragment 58 | console.timeEnd(consoleTime) 59 | return result 60 | } 61 | -------------------------------------------------------------------------------- /server/app/components/source-code.tsx: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs' 2 | import { join } from 'path' 3 | import { o } from '../jsx/jsx.js' 4 | 5 | export let SourceCodeStyle = /* css */ ` 6 | pre[class*="language-"], 7 | code[class*="language-"] { 8 | white-space: pre-wrap !important; 9 | display: inline-block !important; 10 | max-width: 90vw; 11 | } 12 | details.source-code { 13 | margin-top: 0.5rem; 14 | } 15 | details.source-code details summary { 16 | margin-top: 0.5rem; 17 | margin-inline-start: 1rem; 18 | } 19 | .source-code summary { 20 | cursor: pointer; 21 | } 22 | ` 23 | 24 | function SourceCode(attrs: { page: string; style?: string; file?: string }) { 25 | let file = attrs.file || join('server', 'app', 'pages', attrs.page) 26 | let source = readFileSync(file).toString() 27 | let parts = source.split('\n\n') 28 | let importPart: string | undefined 29 | if (parts.length > 1 && parts[0].startsWith('import')) { 30 | importPart = parts.shift() 31 | source = parts.join('\n\n') 32 | } 33 | return ( 34 | <details class="source-code" style={attrs.style}> 35 | <summary> 36 | <b> 37 | Source Code of <code>{attrs.page}</code> 38 | </b> 39 | </summary> 40 | <link rel="stylesheet" href="/lib/prism/prism.css" /> 41 | <script src="/lib/prism/prism.js"></script> 42 | {importPart ? ( 43 | <> 44 | <details> 45 | <summary> 46 | (import statements omitted for simplicity, click to expand) 47 | </summary> 48 | <pre> 49 | <code class="language-tsx">{importPart}</code> 50 | </pre> 51 | </details> 52 | </> 53 | ) : null} 54 | <pre> 55 | <code class="language-tsx">{source}</code> 56 | </pre> 57 | </details> 58 | ) 59 | } 60 | 61 | export default SourceCode 62 | -------------------------------------------------------------------------------- /db/erd.txt: -------------------------------------------------------------------------------- 1 | # Visualize on https://erd.surge.sh 2 | # or https://quick-erd.surge.sh 3 | # 4 | # Relationship Types 5 | # - - one to one 6 | # -< - one to many 7 | # >- - many to one 8 | # >-< - many to many 9 | # -0 - one to zero or one 10 | # 0- - zero or one to one 11 | # 0-0 - zero or one to zero or one 12 | # -0< - one to zero or many 13 | # >0- - zero or many to one 14 | # 15 | //////////////////////////////////// 16 | 17 | 18 | request_log 19 | ----------- 20 | id integer PK 21 | method_id integer FK >- method.id 22 | url_id integer FK >- url.id 23 | user_agent_id integer NULL FK >- user_agent.id 24 | request_session_id integer NULL FK >0- request_session.id 25 | timestamp integer 26 | 27 | 28 | method 29 | ------ 30 | id integer PK 31 | method text unique 32 | 33 | 34 | url 35 | --- 36 | id integer PK 37 | url text unique 38 | 39 | 40 | user_agent 41 | ---------- 42 | id integer PK 43 | user_agent text unique 44 | count integer 45 | ua_type_id integer NULL FK >0- ua_type.id 46 | ua_bot_id integer NULL FK >0- ua_bot.id 47 | 48 | 49 | ua_type 50 | ------- 51 | id integer PK 52 | name text unique 53 | count integer 54 | 55 | 56 | ua_bot 57 | ------ 58 | id integer PK 59 | name text unique 60 | count integer 61 | 62 | 63 | ua_stat 64 | ------- 65 | id integer PK 66 | last_request_log_id integer 67 | 68 | 69 | request_session 70 | --------------- 71 | id integer PK 72 | language text NULL 73 | timezone text NULL 74 | timezone_offset integer NULL 75 | 76 | 77 | # zoom: 1.000 78 | # view: (0, 0) 79 | # text-bg: #6495ed 80 | # text-color: #000000 81 | # diagram-bg: #f5f5f5 82 | # diagram-text: #000000 83 | # table-bg: #ffffff 84 | # table-text: #000000 85 | # request_log (50, 120) 86 | # method (450, 15) 87 | # url (450, 130) 88 | # user_agent (450, 245) 89 | # ua_type (845, 250) 90 | # ua_bot (845, 410) 91 | # ua_stat (715, 60) 92 | # request_session (450, 445) 93 | -------------------------------------------------------------------------------- /template/web.html: -------------------------------------------------------------------------------- 1 | <!doctype html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="UTF-8" /> 5 | <meta http-equiv="X-UA-Compatible" content="IE=edge" /> 6 | <meta 7 | name="viewport" 8 | content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=6.0" 9 | /> 10 | <title>{title} 11 | 12 | 13 | 14 | 25 | 56 | 57 | 62 | {app} 63 | 64 | 65 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | source scripts/config 6 | 7 | if [ -z "$MODE" ]; then 8 | echo "possible mode:" 9 | echo " [f] first (start new pm2 process)" 10 | echo " [u] upload (for static files updates)" 11 | echo " [q] quick (for UI-only updates)" 12 | echo " [ ] default (install dependencies and run database migration)" 13 | read -p "mode: " MODE 14 | fi 15 | case "$MODE" in 16 | f) 17 | MODE="first" 18 | ;; 19 | u) 20 | MODE="upload" 21 | ;; 22 | q) 23 | MODE="quick" 24 | ;; 25 | '') 26 | MODE="default" 27 | ;; 28 | esac 29 | echo "deploy mode: $MODE" 30 | 31 | set -x 32 | 33 | if [ "$MODE" == "upload" ]; then 34 | rsync -SavLPz \ 35 | public \ 36 | "$user@$host:$root_dir" 37 | elif [ "$MODE" == "quick" ]; then 38 | rsync -SavLPz \ 39 | server \ 40 | client \ 41 | public \ 42 | build \ 43 | dist \ 44 | "$user@$host:$root_dir" 45 | ssh "$user@$host" " 46 | set -e 47 | source ~/.nvm/nvm.sh 48 | pm2 reload $pm2_name 49 | pm2 logs $pm2_name 50 | " 51 | else 52 | npm run build 53 | rsync -SavLPz \ 54 | server \ 55 | client \ 56 | public \ 57 | template \ 58 | build \ 59 | dist \ 60 | package.json \ 61 | README.md \ 62 | "$user@$host:$root_dir" 63 | rsync -SavLPz \ 64 | db/package.json \ 65 | db/tsconfig.json \ 66 | db/migrations \ 67 | db/*.ts \ 68 | "$user@$host:$root_dir/db" 69 | if [ "$MODE" == "first" ]; then 70 | rebuild_cmd="pnpm rebuild" 71 | pm2_cmd="cd $root_dir && pm2 start --name $pm2_name dist/server/index.js" 72 | else 73 | rebuild_cmd="" 74 | pm2_cmd="pm2 reload $pm2_name" 75 | fi 76 | ssh "$user@$host" " 77 | set -e 78 | source ~/.nvm/nvm.sh 79 | set -x 80 | cd $root_dir 81 | mkdir -p data 82 | pnpm i -r 83 | $rebuild_cmd 84 | cd db 85 | $rebuild_cmd 86 | npm run setup 87 | $pm2_cmd 88 | pm2 logs $pm2_name 89 | " 90 | fi 91 | -------------------------------------------------------------------------------- /server/app/pages/demo-locale.tsx: -------------------------------------------------------------------------------- 1 | import { Locale, Title } from '../components/locale.js' 2 | import SourceCode from '../components/source-code.js' 3 | import { PickLanguage } from '../components/ui-language.js' 4 | import { Context, getContextLanguage, getContextTimezone } from '../context.js' 5 | import { o } from '../jsx/jsx.js' 6 | import { Routes } from '../routes.js' 7 | 8 | let pageTitle = ( 9 | 10 | ) 11 | 12 | function DemoLocale(attrs: {}, context: Context) { 13 | let lang = getContextLanguage(context) || 'unknown' 14 | let timezone = getContextTimezone(context) || 'unknown' 15 | return ( 16 |
17 |

{pageTitle}

18 |

19 | 24 |

25 |

26 | {' '} 27 | {lang} 28 |

29 |

30 | {' '} 31 | {timezone} 32 |

33 | 34 |

35 | 40 |

41 | 42 |
43 | ) 44 | } 45 | 46 | let routes = { 47 | '/locale': { 48 | menuText: , 49 | title: , 50 | description: ( 51 | <Locale 52 | en="Locale demo for multiple languages and timezone (i18n)" 53 | zh_hk="支援多種語言和時區 (i18n) 的本地化示例" 54 | zh_cn="支持多语言和时区 (i18n) 的本地化示例" 55 | /> 56 | ), 57 | node: <DemoLocale />, 58 | }, 59 | } satisfies Routes 60 | 61 | export default { routes } 62 | -------------------------------------------------------------------------------- /server/ws/wss-lite.ts: -------------------------------------------------------------------------------- 1 | import type { Server } from 'ws' 2 | import { Ping, Pong, Send } from '../../client/ws/ws-lite.js' 3 | import { debugLog } from '../debug.js' 4 | import type { ManagedWebsocket, OnWsMessage } from './wss' 5 | import type { ServerMessage } from '../../client/types' 6 | import { Request } from 'express' 7 | import { newRequestSession } from '../../db/request-log.js' 8 | 9 | let log = debugLog('wss-lite.ts') 10 | log.enabled = true 11 | 12 | export function listenWSSConnection(options: { 13 | wss: Server 14 | onConnection: (ws: ManagedWebsocket) => void 15 | onClose: (ws: ManagedWebsocket, code?: number, reason?: Buffer) => void 16 | onMessage: OnWsMessage 17 | }) { 18 | const { wss } = options 19 | wss.on('connection', (ws, request) => { 20 | if (ws.protocol !== 'ws-lite') { 21 | log('unknown ws protocol:', ws.protocol) 22 | return 23 | } 24 | ws.on('close', (code, reason) => { 25 | options.onClose(managedWS, code, reason) 26 | }) 27 | 28 | function close(code?: number, reason?: Buffer) { 29 | ws.close(code, reason) 30 | } 31 | 32 | function send(event: ServerMessage) { 33 | let data = Send + JSON.stringify(event) 34 | ws.send(data) 35 | } 36 | 37 | ws.on('message', data => { 38 | let message = String(data) 39 | if (message === Ping) { 40 | if (ws.bufferedAmount === 0) { 41 | ws.send(Pong) 42 | } 43 | return 44 | } 45 | if (message === Pong) { 46 | return 47 | } 48 | if (message[0] === Send) { 49 | let event = JSON.parse(message.slice(1)) 50 | options.onMessage(event, managedWS, wss) 51 | return 52 | } 53 | log('received unknown ws message:', data) 54 | }) 55 | 56 | const managedWS: ManagedWebsocket = { 57 | ws, 58 | wss, 59 | request: request as Request, 60 | session_id: newRequestSession(), 61 | send, 62 | close, 63 | } 64 | options.onConnection(managedWS) 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /scripts/new-route.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | if [ $# == 0 ]; then 6 | read -p "page name: " name 7 | else 8 | name="$@" 9 | echo "page name: $name" 10 | fi 11 | 12 | read -p "template [hybrid/web/ionic]: " template 13 | template_file="server/app/pages/route-template-$template.tsx" 14 | if [ ! -f "$template_file" ]; then 15 | echo >&2 "Template file not found: $template_file" 16 | exit 1 17 | fi 18 | 19 | # trim spaces 20 | # replace hyphen to space 21 | # e.g. user agents 22 | name="$(echo "$name" | sed 's/-/ /g' | sed -r 's/^ +//g' | sed -r 's/ +$//g')" 23 | 24 | # capitalize 25 | # e.g. User Agents 26 | title="$(node -p "'$name'.replace(/-/g,' ').replace(/(^| )\w/g,s=>s.toUpperCase())")" 27 | 28 | # remove spaces 29 | # e.g. UserAgents 30 | id="$(echo "$title" | sed -r 's/ //g')" 31 | 32 | # lowercase 33 | # replace spaces to hyphen 34 | # e.g. user-agents 35 | url="$(echo "$name" | awk '{print tolower($0)}' | sed 's/ /-/g')" 36 | 37 | # snake_case 38 | # lowercase with underscore 39 | # e.g. user_agents 40 | table="$(echo "$url" | sed 's/-/_/g')" 41 | 42 | file="server/app/pages/$url.tsx" 43 | 44 | if [ -f "$file" ]; then 45 | echo >&2 "File already exist: $file" 46 | read -p "Overwrite? [y/N] " ans 47 | if [[ "$ans" != y* ]]; then 48 | echo >&2 "Cancelled." 49 | exit 50 | fi 51 | fi 52 | 53 | cat "$template_file" \ 54 | | sed "s/__id__/$id/g" \ 55 | | sed "s/__title__/$title/g" \ 56 | | sed "s/__name__/$name/g" \ 57 | | sed "s/__url__/$url/g" \ 58 | | sed "s/__table__/$table/g" \ 59 | > "$file" 60 | 61 | echo "saved to $file" 62 | ./scripts/ide.sh "$file" 63 | 64 | if [ -d dist ]; then 65 | touch dist/__dev_restart__ 66 | fi 67 | 68 | file="server/app/routes.tsx" 69 | echo "import $id from './pages/$url.js'" > "$file.tmp" 70 | cat "$file" >> "$file.tmp" 71 | mv "$file.tmp" "$file" 72 | if [[ "$(uname)" == "Darwin" ]]; then 73 | sed -i '' "s/let routeDict = {/let routeDict = {\n ...$id.routes,/" "$file" 74 | else 75 | sed -i "s/let routeDict = {/let routeDict = {\n ...$id.routes,/" "$file" 76 | fi 77 | echo "updated $file" 78 | -------------------------------------------------------------------------------- /server/template-file.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync, readFileSync, writeFileSync } from 'fs' 2 | import { basename, extname, join } from 'path' 3 | 4 | export function buildTemplate(file: string) { 5 | let name = file.slice(0, -extname(file).length) 6 | let dest = name + '.ts' 7 | name = basename(name) 8 | let codeName = name.replace(/-\w/g, match => match.slice(1).toUpperCase()) 9 | let CodeName = codeName[0].toUpperCase() + codeName.slice(1) 10 | let html = readFileSync(file).toString() 11 | let matches = html.match(/\{(.*)\}/g) || [] 12 | 13 | let type = ` 14 | export type ${CodeName}Options = {` 15 | let func = ` 16 | export function render${CodeName}Template( 17 | stream: HTMLStream, 18 | options: ${CodeName}Options, 19 | ): void {` 20 | for (let match of matches) { 21 | let key = match.slice(1, -1) 22 | type += ` 23 | ${key}: string | HTMLFunc` 24 | let opt = 'options.' + key 25 | let [before, after] = html.split(match) 26 | html = after 27 | 28 | func += ` 29 | stream.write(${toHTML(before)}) 30 | typeof ${opt} == 'function' ? ${opt}(stream) : stream.write(${opt})` 31 | } 32 | if (html) { 33 | func += ` 34 | stream.write(${toHTML(html)})` 35 | } 36 | 37 | let code = ` 38 | interface HTMLStream { 39 | write(chunk: string): void 40 | flush(): void 41 | } 42 | type HTMLFunc = (stream: HTMLStream) => void 43 | ${type} 44 | } 45 | ${func} 46 | } 47 | ` 48 | saveFile(dest, code.trim() + '\n') 49 | } 50 | 51 | function saveFile(file: string, content: string) { 52 | try { 53 | if (readFileSync(file).toString() == content) return 54 | } catch (error) { 55 | // maybe file not exists 56 | } 57 | writeFileSync(file, content) 58 | } 59 | 60 | function toHTML(html: string): string { 61 | if (html.includes('`')) { 62 | return JSON.stringify(html) 63 | } 64 | return '/* html */ `' + html + '`' 65 | } 66 | 67 | export function scanTemplateDir(dir: string) { 68 | for (let filename of readdirSync(dir)) { 69 | let file = join(dir, filename) 70 | if (extname(file) == '.html') { 71 | buildTemplate(file) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /size.md: -------------------------------------------------------------------------------- 1 | ## Client js size 2 | 3 | ### Bundled File Size 4 | 5 | | File | Size | 6 | | ---------------------- | ---- | 7 | | build/bundle.js | 13K | 8 | | build/bundle.min.js | 6.5K | 9 | | build/bundle.min.js.gz | 2.3K | 10 | 11 | ### Source Files Size 12 | 13 | | File | Size | 14 | | ---------------------- | ---- | 15 | | client/index.js | 4.2K | 16 | | client/jsx/dom.js | 6.9K | 17 | | client/ws/ws-lite.js | 2.5K | 18 | | client/ws/ws-config.js | 280B | 19 | 20 | ## WebSocket Client Size Comparison 21 | 22 | | Name | bundled | + minified | + gzipped | 23 | | ------------- | ------: | ---------: | --------: | 24 | | **ws-native** | 1.0K | 0.4K | 0.3K | 25 | | **ws-lite** | 2.3K | 0.9K | 0.5K | 26 | | primus.js | 98.9K | 32.5K | 10.7K | 27 | | socket.io.js | 104K | 40.8K | 12.4K | 28 | 29 | Details of ws-\* refers to [client/ws/readme.md](./client/ws/readme.md) 30 | 31 | | WebSocket Client | Way to get websocket client file | 32 | | ---------------- | ----------------------------------------------- | 33 | | primus.js | fs.writeFileSync('primus.js', primus.library()) | 34 | | socket.io.js | wget $origin/socket.io/socket.io.js | 35 | 36 | | Size Type | Command | 37 | | -------------------------------- | ------------------------------------------------------------- | 38 | | bundled of ws-{native,lite} | npx esbuild $file --bundle \| pv > /dev/null | 39 | | bundled of {primus,socket.io}.js | cat $file \| pv > /dev/null | 40 | | + minified | npx esbuild $file --bundle --minify \| pv > /dev/null | 41 | | + gzipped | npx esbuild $file --bundle --minify \| gzip \| pv > /dev/null | 42 | 43 | ### Reference 44 | 45 | source: https://bundlephobia.com/ 46 | 47 | | Network | Download Speed (kB/s) | 48 | | ----------- | --------------------- | 49 | | 2g edge | 30 | 50 | | emerging 3g | 50 | 51 | -------------------------------------------------------------------------------- /server/app/components/data-table.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import { mapArray } from './fragment.js' 3 | import { Script } from './script.js' 4 | import { Config } from 'datatables.net-dt' 5 | 6 | export let dataTableAsset = ( 7 | <> 8 | <link 9 | rel="stylesheet" 10 | href="/npm/datatables.net-dt/css/dataTables.dataTables.min.css" 11 | /> 12 | <script src="/npm/jquery/dist/jquery.slim.min.js"></script> 13 | <script src="/npm/datatables.net/js/dataTables.min.js"></script> 14 | </> 15 | ) 16 | 17 | export function enableDataTable(id: string, config: Config = {}) { 18 | return Script(/* javascript */ ` 19 | (function initDateTable() { 20 | if (typeof DataTable !== 'function') { 21 | // still loading 22 | setTimeout(initDateTable, 50) 23 | return 24 | } 25 | let id = ${JSON.stringify(id)}; 26 | let table = document.getElementById(id); 27 | let dataTable = new DataTable(table, ${JSON.stringify(config)}); 28 | })() 29 | `) 30 | } 31 | 32 | export function DataTable<T>(attrs: { 33 | 'id': string 34 | 'headers': Partial<Record<keyof T, Node>> 35 | 'rows': T[] 36 | 'skip-assets'?: boolean 37 | /** default 3 */ 38 | 'page-length'?: number 39 | /** default [1, 2, 3, 5, 10, 25] */ 40 | 'length-menu'?: number[] 41 | }) { 42 | let skipAssets = attrs['skip-assets'] 43 | let pageLength = attrs['page-length'] ?? 3 44 | let lengthMenu = attrs['length-menu'] ?? [1, 2, 3, 5, 10, 25] 45 | let fields = Object.keys(attrs.headers) as (keyof T)[] 46 | return ( 47 | <> 48 | <table id={attrs.id}> 49 | <thead> 50 | <tr> 51 | {mapArray(Object.values(attrs.headers), label => ( 52 | <th>{label}</th> 53 | ))} 54 | </tr> 55 | </thead> 56 | <tbody> 57 | {mapArray(attrs.rows, row => ( 58 | <tr> 59 | {mapArray(fields, field => ( 60 | <td>{row[field]}</td> 61 | ))} 62 | </tr> 63 | ))} 64 | </tbody> 65 | </table> 66 | {skipAssets ? null : dataTableAsset} 67 | {enableDataTable(attrs.id, { 68 | pageLength, 69 | lengthMenu, 70 | })} 71 | </> 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /server/client-plugin.ts: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild' 2 | import * as path from 'path' 3 | import { config } from './config.js' 4 | import { Raw } from './app/components/raw.js' 5 | import { Raw as RawType } from './app/jsx/types.js' 6 | import { escapeHTMLAttributeValue } from './app/jsx/html.js' 7 | 8 | let cache = new Map<string, { script: string; node: RawType }>() 9 | 10 | export function loadClientPlugin(options: { 11 | // e.g. dist/client/image.js 12 | entryFile: string 13 | // e.g. image.bundle.js 14 | outFilename?: string 15 | async?: boolean 16 | onload?: string 17 | onerror?: string 18 | }) { 19 | let { entryFile } = options 20 | if (!entryFile.startsWith('dist/client/')) { 21 | throw new Error('the entryFile should be in dist/client directory') 22 | } 23 | if (!entryFile.endsWith('.js')) { 24 | throw new Error('the entryFile should .js file') 25 | } 26 | 27 | let outFilename = options.outFilename || defaultBundleFilename(entryFile) 28 | 29 | let key = entryFile + '|' + outFilename 30 | 31 | let result = cache.get(key) 32 | if (result) { 33 | return result 34 | } 35 | 36 | // e.g. build/image.bundle.js 37 | let outFile = 'build/' + outFilename 38 | 39 | esbuild.buildSync({ 40 | entryPoints: [entryFile], 41 | outfile: outFile, 42 | bundle: true, 43 | minify: config.production, 44 | target: config.client_target, 45 | }) 46 | 47 | // e.g. js/image.bundle.js 48 | let scriptSrc = '/js/' + outFilename 49 | 50 | let attrs: string[] = [] 51 | attrs.push(`src="${scriptSrc}"`) 52 | if (options.async) { 53 | attrs.push('async defer') 54 | } 55 | if (options.onload) { 56 | attrs.push(`onload=${escapeHTMLAttributeValue(options.onload)}`) 57 | } 58 | if (options.onerror) { 59 | attrs.push(`onerror=${JSON.stringify(options.onerror)}`) 60 | } 61 | 62 | let script = /* html */ `<script ${attrs.join(' ')}></script>` 63 | 64 | let node = Raw(script) 65 | 66 | result = { script, node } 67 | 68 | cache.set(key, result) 69 | return result 70 | } 71 | 72 | function defaultBundleFilename(entryFile: string) { 73 | let filename = path.basename(entryFile) 74 | return filename.replace(/\.js$/, '.bundle.js') 75 | } 76 | -------------------------------------------------------------------------------- /server/caddy.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process' 2 | import { writeFileSync } from 'fs' 3 | import { scan_host } from 'listening-on' 4 | 5 | /** 6 | * Automatically sets up and runs a Caddy HTTPS reverse proxy for development. 7 | * 8 | * This is useful for testing features that require HTTPS (e.g., camera, microphone access) 9 | * on mobile devices or remote network access during development. 10 | * Note: Localhost doesn't need HTTPS - browsers allow camera/mic on localhost over HTTP. 11 | * 12 | * The function: 13 | * - Auto-generates a Caddyfile with all network interfaces (excluding localhost) 14 | * - Creates self-signed certificates using Caddy's internal CA 15 | * - Proxies HTTPS requests (port 8443) to the Node.js server (upstream_port) 16 | */ 17 | export function setCaddy(upstream_port: number) { 18 | // setup caddy config file 19 | let upstream = `127.0.0.1:${upstream_port}` 20 | let http_port = 8080 21 | let https_port = 8443 22 | let code = ` 23 | { 24 | http_port ${http_port} 25 | https_port ${https_port} 26 | } 27 | ` 28 | 29 | function addHost(host: string) { 30 | code += ` 31 | ${host}:${https_port} { 32 | encode gzip zstd 33 | reverse_proxy ${upstream} 34 | tls internal 35 | } 36 | ` 37 | } 38 | 39 | scan_host({ 40 | family: 'IPv4', 41 | onAddress(address) { 42 | if (address.host === '127.0.0.1') return 43 | addHost(address.host) 44 | console.log(`listening on https://${address.host}:${https_port}`) 45 | }, 46 | }) 47 | 48 | writeFileSync('Caddyfile', code) 49 | 50 | // run caddy proxy 51 | let child = spawn('caddy', ['run', '--config', 'Caddyfile']) 52 | child.on('close', code => { 53 | if (code !== 0) { 54 | console.error(`Caddy process exited with code ${code}`) 55 | } 56 | }) 57 | child.on('error', error => { 58 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 59 | console.error('Error: Caddy command not found') 60 | console.error('You can install it with: ./scripts/caddy-install.sh') 61 | console.error('Made sure it is added to the PATH environment variable') 62 | } else { 63 | console.error(`Caddy process error:`, error) 64 | } 65 | }) 66 | 67 | return child 68 | } 69 | -------------------------------------------------------------------------------- /db/proxy.ts: -------------------------------------------------------------------------------- 1 | import { proxySchema } from 'better-sqlite3-proxy' 2 | import { db } from './db' 3 | 4 | export type Method = { 5 | id?: null | number 6 | method: string 7 | } 8 | 9 | export type Url = { 10 | id?: null | number 11 | url: string 12 | } 13 | 14 | export type UaType = { 15 | id?: null | number 16 | name: string 17 | count: number 18 | } 19 | 20 | export type RequestSession = { 21 | id?: null | number 22 | language: null | string 23 | timezone: null | string 24 | timezone_offset: null | number 25 | } 26 | 27 | export type UaBot = { 28 | id?: null | number 29 | name: string 30 | count: number 31 | } 32 | 33 | export type UserAgent = { 34 | id?: null | number 35 | user_agent: string 36 | count: number 37 | ua_type_id: null | number 38 | ua_type?: UaType 39 | ua_bot_id: null | number 40 | ua_bot?: UaBot 41 | } 42 | 43 | export type UaStat = { 44 | id?: null | number 45 | last_request_log_id: number 46 | } 47 | 48 | export type RequestLog = { 49 | id?: null | number 50 | method_id: number 51 | method?: Method 52 | url_id: number 53 | url?: Url 54 | user_agent_id: null | number 55 | user_agent?: UserAgent 56 | request_session_id: null | number 57 | request_session?: RequestSession 58 | timestamp: number 59 | } 60 | 61 | export type DBProxy = { 62 | method: Method[] 63 | url: Url[] 64 | ua_type: UaType[] 65 | request_session: RequestSession[] 66 | ua_bot: UaBot[] 67 | user_agent: UserAgent[] 68 | ua_stat: UaStat[] 69 | request_log: RequestLog[] 70 | } 71 | 72 | export let proxy = proxySchema<DBProxy>({ 73 | db, 74 | tableFields: { 75 | method: [], 76 | url: [], 77 | ua_type: [], 78 | request_session: [], 79 | ua_bot: [], 80 | user_agent: [ 81 | /* foreign references */ 82 | ['ua_type', { field: 'ua_type_id', table: 'ua_type' }], 83 | ['ua_bot', { field: 'ua_bot_id', table: 'ua_bot' }], 84 | ], 85 | ua_stat: [], 86 | request_log: [ 87 | /* foreign references */ 88 | ['method', { field: 'method_id', table: 'method' }], 89 | ['url', { field: 'url_id', table: 'url' }], 90 | ['user_agent', { field: 'user_agent_id', table: 'user_agent' }], 91 | ['request_session', { field: 'request_session_id', table: 'request_session' }], 92 | ], 93 | }, 94 | }) 95 | -------------------------------------------------------------------------------- /server/app/components/menu.tsx: -------------------------------------------------------------------------------- 1 | import { flagsToClassName } from '../jsx/html.js' 2 | import { o } from '../jsx/jsx.js' 3 | import type { attrs, Node } from '../jsx/types' 4 | import { mapArray } from './fragment.js' 5 | import { Style } from './style.js' 6 | import { Context, getContextUrl } from '../context.js' 7 | import { capitalize } from '@beenotung/tslib/string.js' 8 | 9 | export type MenuRoute = { 10 | url: string 11 | menuText: string 12 | menuUrl?: string // optional, default to be same as PageRoute.url 13 | menuMatchPrefix?: boolean 14 | menuFullNavigate?: boolean // default false to enable ws updates 15 | } 16 | 17 | export function isCurrentMenuRoute( 18 | currentUrl: string, 19 | route: MenuRoute, 20 | ): boolean { 21 | return route.menuMatchPrefix 22 | ? currentUrl.startsWith(route.url) || 23 | (route.menuUrl && currentUrl.startsWith(route.menuUrl)) || 24 | false 25 | : currentUrl == route.url || 26 | (route.menuUrl && currentUrl == route.menuUrl) || 27 | false 28 | } 29 | 30 | export function Menu( 31 | attrs: { 32 | routes: MenuRoute[] 33 | separator?: Node 34 | attrs?: attrs 35 | }, 36 | context: Context, 37 | ) { 38 | const currentUrl = getContextUrl(context) 39 | return ( 40 | <> 41 | {Style(/* css */ ` 42 | .menu > a { 43 | margin: 0.25em; 44 | text-decoration: none; 45 | border-bottom: 1px solid black; 46 | } 47 | .menu > a.selected { 48 | border-bottom: 2px solid black; 49 | } 50 | `)} 51 | <div class="menu" {...attrs.attrs}> 52 | {mapArray( 53 | attrs.routes, 54 | route => ( 55 | <a 56 | href={route.menuUrl || route.url} 57 | class={flagsToClassName({ 58 | selected: isCurrentMenuRoute(currentUrl, route), 59 | })} 60 | onclick={route.menuFullNavigate ? undefined : 'emitHref(event)'} 61 | > 62 | {route.menuText} 63 | </a> 64 | ), 65 | attrs.separator, 66 | )} 67 | </div> 68 | </> 69 | ) 70 | } 71 | 72 | export function formatMenuText(href: string): string { 73 | let text = href.substring(1) 74 | if (!text.includes('/')) { 75 | text = text.split('-').map(capitalize).join(' ') 76 | } 77 | return text 78 | } 79 | 80 | export default Menu 81 | -------------------------------------------------------------------------------- /template/ionic.html: -------------------------------------------------------------------------------- 1 | <!doctype html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="UTF-8" /> 5 | <meta http-equiv="X-UA-Compatible" content="IE=edge" /> 6 | <meta 7 | name="viewport" 8 | content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=6.0" 9 | /> 10 | <title>{title} 11 | 12 | 13 | 14 | 25 | 56 | 57 | 62 | 66 | 67 | 68 | 76 | {app} 77 | 78 | 79 | -------------------------------------------------------------------------------- /db/request-log.ts: -------------------------------------------------------------------------------- 1 | import { find } from 'better-sqlite3-proxy' 2 | import { proxy } from './proxy.js' 3 | import { checkNewRequestLog } from './user-agent.js' 4 | import type { Session } from '../server/app/session.js' 5 | 6 | let method_cache = new Map() 7 | function getMethodId(method: string): number { 8 | let id = method_cache.get(method) 9 | if (id) return id 10 | id = find(proxy.method, { method })?.id || proxy.method.push({ method }) 11 | method_cache.set(method, id) 12 | return id 13 | } 14 | 15 | let url_cache = new Map() 16 | function getUrlId(url: string): number { 17 | let id = url_cache.get(url) 18 | if (id) return id 19 | id = find(proxy.url, { url })?.id || proxy.url.push({ url }) 20 | url_cache.set(url, id) 21 | return id 22 | } 23 | 24 | let user_agent_cache = new Map() 25 | function getUserAgentId(user_agent: string): number { 26 | let id = user_agent_cache.get(user_agent) 27 | if (id) return id 28 | id = 29 | find(proxy.user_agent, { user_agent })?.id || 30 | proxy.user_agent.push({ 31 | user_agent, 32 | ua_type_id: null, 33 | ua_bot_id: null, 34 | count: 0, 35 | }) 36 | user_agent_cache.set(user_agent, id) 37 | return id 38 | } 39 | 40 | export function storeRequestLog(request: { 41 | method: string 42 | url: string 43 | user_agent: string | null 44 | session_id: number | null 45 | }) { 46 | let user_agent = request.user_agent 47 | let log_id = proxy.request_log.push({ 48 | method_id: getMethodId(request.method), 49 | url_id: getUrlId(request.url), 50 | user_agent_id: user_agent ? getUserAgentId(user_agent) : null, 51 | request_session_id: request.session_id, 52 | timestamp: Date.now(), 53 | }) 54 | checkNewRequestLog(log_id) 55 | } 56 | 57 | export function newRequestSession() { 58 | let session_id = proxy.request_session.push({ 59 | language: null, 60 | timezone: null, 61 | timezone_offset: null, 62 | }) 63 | return session_id 64 | } 65 | 66 | export function updateRequestSession(id: number, session: Session) { 67 | let row = proxy.request_session[id] 68 | if (session.language != undefined) row.language = session.language 69 | if (session.timeZone != undefined) row.timezone = session.timeZone 70 | if (session.timezoneOffset != undefined) 71 | row.timezone_offset = session.timezoneOffset 72 | } 73 | -------------------------------------------------------------------------------- /public/theme.css: -------------------------------------------------------------------------------- 1 | /* theme color */ 2 | /* color generator: https://ionicframework.com/docs/theming/color-generator */ 3 | :root { 4 | --ion-color-primary: #3880ff; 5 | --ion-color-primary-rgb: 56, 128, 255; 6 | --ion-color-primary-contrast: #ffffff; 7 | --ion-color-primary-contrast-rgb: 255, 255, 255; 8 | --ion-color-primary-shade: #3171e0; 9 | --ion-color-primary-tint: #4c8dff; 10 | 11 | --ion-color-secondary: #3dc2ff; 12 | --ion-color-secondary-rgb: 61, 194, 255; 13 | --ion-color-secondary-contrast: #000000; 14 | --ion-color-secondary-contrast-rgb: 0, 0, 0; 15 | --ion-color-secondary-shade: #36abe0; 16 | --ion-color-secondary-tint: #50c8ff; 17 | 18 | --ion-color-tertiary: #5260ff; 19 | --ion-color-tertiary-rgb: 82, 96, 255; 20 | --ion-color-tertiary-contrast: #ffffff; 21 | --ion-color-tertiary-contrast-rgb: 255, 255, 255; 22 | --ion-color-tertiary-shade: #4854e0; 23 | --ion-color-tertiary-tint: #6370ff; 24 | 25 | --ion-color-success: #2dd36f; 26 | --ion-color-success-rgb: 45, 211, 111; 27 | --ion-color-success-contrast: #000000; 28 | --ion-color-success-contrast-rgb: 0, 0, 0; 29 | --ion-color-success-shade: #28ba62; 30 | --ion-color-success-tint: #42d77d; 31 | 32 | --ion-color-warning: #ffc409; 33 | --ion-color-warning-rgb: 255, 196, 9; 34 | --ion-color-warning-contrast: #000000; 35 | --ion-color-warning-contrast-rgb: 0, 0, 0; 36 | --ion-color-warning-shade: #e0ac08; 37 | --ion-color-warning-tint: #ffca22; 38 | 39 | --ion-color-danger: #eb445a; 40 | --ion-color-danger-rgb: 235, 68, 90; 41 | --ion-color-danger-contrast: #ffffff; 42 | --ion-color-danger-contrast-rgb: 255, 255, 255; 43 | --ion-color-danger-shade: #cf3c4f; 44 | --ion-color-danger-tint: #ed576b; 45 | 46 | --ion-color-light: #e0e3eb; 47 | --ion-color-light-rgb: 224, 227, 235; 48 | --ion-color-light-contrast: #000000; 49 | --ion-color-light-contrast-rgb: 0, 0, 0; 50 | --ion-color-light-shade: #c5c8cf; 51 | --ion-color-light-tint: #e3e6ed; 52 | 53 | --ion-color-medium: #92949c; 54 | --ion-color-medium-rgb: 146, 148, 156; 55 | --ion-color-medium-contrast: #000000; 56 | --ion-color-medium-contrast-rgb: 0, 0, 0; 57 | --ion-color-medium-shade: #808289; 58 | --ion-color-medium-tint: #9d9fa6; 59 | 60 | --ion-color-dark: #222428; 61 | --ion-color-dark-rgb: 34, 36, 40; 62 | --ion-color-dark-contrast: #ffffff; 63 | --ion-color-dark-contrast-rgb: 255, 255, 255; 64 | --ion-color-dark-shade: #1e2023; 65 | --ion-color-dark-tint: #383a3e; 66 | } 67 | -------------------------------------------------------------------------------- /server/app/pages/user-list.tsx: -------------------------------------------------------------------------------- 1 | import { title } from '../../config.js' 2 | import { mapArray } from '../components/fragment.js' 3 | import { newSingleFieldForm } from '../components/single-field-form.js' 4 | import SourceCode from '../components/source-code.js' 5 | import Style from '../components/style.js' 6 | import { o } from '../jsx/jsx.js' 7 | import { Routes } from '../routes.js' 8 | 9 | let style = Style(/* css */ ` 10 | #user-list fieldset { 11 | max-width: 20rem; 12 | } 13 | #user-list legend { 14 | margin-bottom: 0.75rem; 15 | } 16 | #user-list .inline-code { 17 | background-color: #dddb; 18 | } 19 | `) 20 | 21 | let users = [ 22 | { id: 1, nickname: 'alice' }, 23 | { id: 2, nickname: 'bob' }, 24 | { id: 3, nickname: 'charlie' }, 25 | ] 26 | 27 | let Nickname = newSingleFieldForm({ 28 | action: '/user-list/update', 29 | label: 'Nickname', 30 | name: 'nickname', 31 | updateKeyName: 'id', 32 | updateValue(attrs, context) { 33 | const { id, nickname } = attrs.input 34 | const index = +id - 1 35 | users[index].nickname = nickname 36 | }, 37 | renderUpdate(attrs, context) { 38 | return content 39 | }, 40 | }) 41 | 42 | let content = ( 43 |
44 | {style} 45 |

List Editing Demo

46 |

This page demo how to use multiple instances of SingleFieldForm.

47 |

Each instance can be distinguished by the "key" attribute.

48 |

49 | Usually the attribute value is index of array, or primary key of database 50 | record. 51 |

52 |

53 | In below example, {'Nickname'} is created 54 | from {'newSingleFieldForm()'}. Then 3 55 | instances of {''} are 56 | mapped over an array of users. 57 |

58 | 59 | 60 |
61 | ) 62 | 63 | function View() { 64 | return mapArray(users, user => ( 65 |
66 | user {user.id} 67 | 68 |
69 | )) 70 | } 71 | 72 | let routes = { 73 | '/user-list': { 74 | title: title('Demo List Editing'), 75 | description: 76 | 'Demo list editing with newSingleFieldForm powered by ts-liveview', 77 | menuText: 'List Editing', 78 | node: content, 79 | }, 80 | ...Nickname.routes, 81 | } as Routes 82 | 83 | export default { routes } 84 | -------------------------------------------------------------------------------- /server/app/components/copyable.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import { Node } from '../jsx/types.js' 3 | import { Script } from './script.js' 4 | import Style from './style.js' 5 | 6 | let style = Style(/* css */ ` 7 | .copyable-container code { 8 | background-color: #eee; 9 | padding: 0.5rem; 10 | border-radius: 0.5rem; 11 | margin: 0.25rem; 12 | display: block; 13 | width: fit-content; 14 | border: none; 15 | } 16 | .copyable-container button { 17 | margin: 0.25rem; 18 | } 19 | `) 20 | 21 | export function Copyable( 22 | attrs: ({ code: Node } | { text: string }) & { 23 | buttonText?: string 24 | successText?: string 25 | successColor?: string 26 | errorText?: string 27 | errorColor?: string 28 | class?: string 29 | }, 30 | ) { 31 | let code = 'code' in attrs ? attrs.code : {attrs.text} 32 | let buttonText = attrs.buttonText || 'Copy' 33 | let className = 'copyable-container' 34 | if (attrs.class) { 35 | className += ' ' + attrs.class 36 | } 37 | return ( 38 | <> 39 | {style} 40 |
41 | {code} 42 | 55 |
56 | {script} 57 | 58 | ) 59 | } 60 | 61 | let script = Script(/* javascript */ ` 62 | function copyToClipboard(event) { 63 | let button = event.target 64 | let code = button.parentElement.querySelector('code') 65 | try { 66 | let range = document.createRange() 67 | range.selectNode(code) 68 | window.getSelection().removeAllRanges() 69 | window.getSelection().addRange(range) 70 | document.execCommand('copy') 71 | button.textContent = button.dataset.successText 72 | button.style.color = button.dataset.successColor 73 | } catch (e) { 74 | button.textContent = button.dataset.errorText 75 | button.style.color = button.dataset.errorColor 76 | } 77 | setTimeout(() => { 78 | button.textContent = button.dataset.buttonText 79 | button.style.color = '' 80 | }, 5000) 81 | } 82 | `) 83 | -------------------------------------------------------------------------------- /client/image.ts: -------------------------------------------------------------------------------- 1 | import { format_byte } from '@beenotung/tslib/format.js' 2 | import { 3 | compressMobilePhoto, 4 | dataURItoFile, 5 | resizeImage, 6 | toImage, 7 | rotateImage, 8 | } from '@beenotung/tslib/image.js' 9 | import { client_config } from './client-config.js' 10 | import { selectImage } from '@beenotung/tslib/file.js' 11 | 12 | /** @description compress to within the file size budget */ 13 | function compressPhotos(files: FileList | File[]) { 14 | return Promise.all( 15 | Array.from(files, async file => { 16 | let dataUrl = await compressMobilePhoto({ 17 | image: file, 18 | maximumSize: client_config.max_image_size, 19 | mimeType: 'image/webp', 20 | }) 21 | file = dataURItoFile(dataUrl, file) 22 | return { dataUrl, file } 23 | }), 24 | ) 25 | } 26 | 27 | /** @description resize to given dimension */ 28 | async function selectPhotos( 29 | options?: { 30 | accept?: string 31 | quality?: number 32 | multiple?: boolean 33 | } & ({ width: number; height: number } | { size: number } | {}), 34 | ) { 35 | let files = await selectImage({ 36 | accept: options?.accept || '.jpg,.png,.webp,.heic,.gif', 37 | multiple: options?.multiple, 38 | }) 39 | return Promise.all(files.map(file => compressImageFile(file, options))) 40 | } 41 | 42 | async function compressImageFile( 43 | file: File, 44 | options?: { 45 | quality?: number 46 | } & ({ size: number } | { width: number } | { height: number } | {}), 47 | ) { 48 | let image = await toImage(file) 49 | let width = 720 50 | let height = 720 51 | let quality = 0.5 52 | if (options) { 53 | if ('size' in options) { 54 | width = options.size 55 | height = options.size 56 | } 57 | if ('width' in options) { 58 | width = options.width 59 | } 60 | if ('height' in options) { 61 | height = options.height 62 | } 63 | if (options.quality) { 64 | quality = options.quality 65 | } 66 | } 67 | let dataUrl = resizeImage(image, width, height, 'image/webp', quality) 68 | file = dataURItoFile(dataUrl, file) 69 | return { dataUrl, file } 70 | } 71 | 72 | async function rotateImageInline(image: HTMLImageElement) { 73 | let canvas = rotateImage(image) 74 | let dataUrl = canvas.toDataURL() 75 | image.src = dataUrl 76 | } 77 | 78 | Object.assign(window, { 79 | compressPhotos, 80 | selectPhotos, 81 | compressImageFile, 82 | selectImage, 83 | format_byte, 84 | rotateImage, 85 | rotateImageInline, 86 | }) 87 | -------------------------------------------------------------------------------- /scripts/help.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'fs' 4 | import os from 'os' 5 | 6 | function readReadme(file) { 7 | return fs.readFileSync(file).toString() 8 | } 9 | 10 | function findLineIndex(lines, predicate, offset = 0) { 11 | for (let i = offset; i < lines.length; i++) { 12 | if (predicate(lines[i])) { 13 | return i 14 | } 15 | } 16 | return -1 17 | } 18 | 19 | function getStarted() { 20 | let message = '' 21 | 22 | message += 'Get started by typing:' + os.EOL 23 | message += os.EOL 24 | message += ' ./scripts/init.sh' + os.EOL 25 | message += ' npm start' + os.EOL 26 | 27 | return message.trim() 28 | } 29 | 30 | function parseGuides(lines) { 31 | let start = findLineIndex(lines, line => line.startsWith('## Get Started')) 32 | if (start === -1) return '' 33 | start = findLineIndex(lines, line => line === '```', start) 34 | if (start === -1) return '' 35 | start++ 36 | let end = findLineIndex(lines, line => line.startsWith('## '), start) 37 | if (end === -1) return '' 38 | 39 | let message = '' 40 | 41 | for (let i = start; i < end; i++) { 42 | let line = lines[i] 43 | line = extractLink(line) 44 | message += line + os.EOL 45 | } 46 | 47 | return message.trim() 48 | } 49 | 50 | let commandRegex = /`(.*?)`:/ 51 | function parseCommands(lines) { 52 | let start = lines.findIndex(line => 53 | line.startsWith('## Available npm scripts'), 54 | ) 55 | lines = lines.slice(start + 1) 56 | let end = lines.findIndex(line => line.startsWith('## ')) 57 | lines = lines.slice(0, end) 58 | 59 | let message = '' 60 | 61 | message += 'Available npm scripts:' + os.EOL 62 | 63 | for (let line of lines) { 64 | if (line.startsWith('`')) { 65 | line = ' ' + line.match(commandRegex)[1] 66 | } else if (line != '') { 67 | line = ' ' + line 68 | } 69 | message += line + os.EOL 70 | } 71 | 72 | return message.trim() 73 | } 74 | 75 | let lineRegex = /\[.*?\]\((.*)\)/ 76 | function extractLink(line) { 77 | let match = line.match(lineRegex) 78 | if (match) { 79 | line = line.replace(match[0], match[1]) 80 | } 81 | return line 82 | } 83 | 84 | let file = 'README.md' 85 | let text = readReadme(file) 86 | let lines = text.split('\n').map(lines => lines.replace('\r', '')) 87 | 88 | process.stdout.write(getStarted() + os.EOL) 89 | process.stdout.write(os.EOL + os.EOL) 90 | process.stdout.write(parseGuides(lines) + os.EOL) 91 | process.stdout.write(os.EOL + os.EOL) 92 | process.stdout.write(parseCommands(lines) + os.EOL) 93 | -------------------------------------------------------------------------------- /server/app/pages/demo-inputs.tsx: -------------------------------------------------------------------------------- 1 | import { Style } from '../components/style.js' 2 | import { o } from '../jsx/jsx.js' 3 | import debug from 'debug' 4 | import { title } from '../../config.js' 5 | import { Routes } from '../routes.js' 6 | import Menu from '../components/menu.js' 7 | import { Switch } from '../components/router.js' 8 | import DemoSelect from './demo-inputs/demo-select.js' 9 | import DemoSingleFieldForm from './demo-inputs/demo-single-field-form.js' 10 | import SourceCode from '../components/source-code.js' 11 | 12 | const log = debug('demo-single-field-form.tsx') 13 | log.enabled = true 14 | 15 | let style = Style(/* css */ ` 16 | #demo-inputs .code-demo { 17 | display: flex; 18 | flex-wrap: wrap; 19 | } 20 | `) 21 | 22 | let content = ( 23 |
24 |

Input Components Demo

25 |

This page demo component based input fields.

26 | 27 | {style} 28 | 29 | 30 | 31 | 32 | 33 |

34 | The example code snippets below are simplified for illustration. You can 35 | click "Source Code of [page]" to see the complete source code. 36 |

37 | 38 |
39 | 40 | ' }, 43 | { 44 | url: '/inputs/single-field-form', 45 | menuText: 'newSingleFieldForm()', 46 | }, 47 | ]} 48 | separator=" | " 49 | /> 50 | 51 | {Switch({ 52 | '/inputs': DemoSelect, 53 | '/inputs/select': DemoSelect, 54 | '/inputs/single-field-form': DemoSingleFieldForm.content, 55 | })} 56 |
57 |
58 | ) 59 | 60 | let routes = { 61 | '/inputs': { 62 | title: title('Demo input components'), 63 | description: 'Demonstrate component-based input fields', 64 | menuText: 'Inputs', 65 | menuMatchPrefix: true, 66 | node: content, 67 | }, 68 | '/inputs/select': { 69 | title: title('Demo ', 72 | node: content, 73 | }, 74 | '/inputs/single-field-form': { 75 | title: title('Demo single-field-form component creator'), 76 | description: 'Demonstrate per-field saving with realtime update', 77 | node: content, 78 | }, 79 | ...DemoSingleFieldForm.routes, 80 | } satisfies Routes 81 | 82 | export default { routes } 83 | -------------------------------------------------------------------------------- /server/app/components/script.ts: -------------------------------------------------------------------------------- 1 | import { config } from '../../config.js' 2 | import type { Raw } from '../jsx/types' 3 | import * as esbuild from 'esbuild' 4 | 5 | const cache = new Map() 6 | 7 | /** 8 | * @param js javascript code without script tag 9 | * @returns minified javascript code 10 | * @description memorized (cached) 11 | */ 12 | function minify(js: string): string { 13 | let code = cache.get(js) 14 | if (typeof code === 'string') { 15 | return code 16 | } 17 | code = esbuild.transformSync(js, { 18 | minify: true, 19 | loader: 'js', 20 | target: config.client_target, 21 | }).code 22 | 23 | cache.set(js, code) 24 | return code 25 | } 26 | 27 | /** 28 | * @default 'no-minify' when not in production 29 | * @default 'minify' when in production 30 | */ 31 | export type ScriptFlag = 'no-minify' | 'minify' 32 | 33 | /** 34 | * @description For static script. 35 | * Minimize in production mode and memorized (cached). 36 | * @returns script element 37 | */ 38 | export function Script(js: string, flag?: ScriptFlag): Raw { 39 | if (flag == 'minify' || (flag != 'no-minify' && config.production)) { 40 | js = minify(js) 41 | } 42 | return ['raw', ``] 43 | } 44 | 45 | /** 46 | * @description use iife (Immediately Invoked Function Expression) to avoid name clash with other parts of the page. 47 | * */ 48 | export function iife void>(fn: F, flag?: ScriptFlag): Raw 49 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 50 | export function iife void>( 51 | fn: F, 52 | args: Parameters, 53 | flag?: ScriptFlag, 54 | ): Raw 55 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 56 | export function iife void>( 57 | fn: F, 58 | args?: Parameters | ScriptFlag, 59 | flag?: ScriptFlag, 60 | ): Raw { 61 | args 62 | if (typeof args == 'string') { 63 | flag = args 64 | args = undefined 65 | } 66 | if (args && args.length > 0) { 67 | let args_code = JSON.stringify(args) 68 | args_code = args_code.slice(1, args_code.length - 1) 69 | return Script(`(${fn})(${args_code})`, flag) 70 | } 71 | return Script(`(${fn})()`, flag) 72 | } 73 | 74 | /** 75 | * @description semi-colon is mandatory 76 | * @deprecated use esbuild directly instead 77 | * */ 78 | export function aggressivelyTrimInlineScript(html: string): string { 79 | return html.replace(/ /g, '').replace(/\n/g, '') 80 | } 81 | 82 | export const MuteConsole = Script(` 83 | console.original_debug = console.debug; 84 | console.debug = () => {}; 85 | `) 86 | -------------------------------------------------------------------------------- /server/app/components/page.tsx: -------------------------------------------------------------------------------- 1 | import { config, LayoutType } from '../../config.js' 2 | import { Context, getContextSearchParams } from '../context.js' 3 | import { o } from '../jsx/jsx.js' 4 | import { Node, NodeList } from '../jsx/types.js' 5 | import { IonBackButton } from './ion-back-button.js' 6 | 7 | export type ThemeColor = 8 | | 'primary' 9 | | 'secondary' 10 | | 'tertiary' 11 | | 'success' 12 | | 'warning' 13 | | 'danger' 14 | | 'light' 15 | | 'medium' 16 | | 'dark' 17 | 18 | type PageAttrs = { 19 | style?: string 20 | id: string 21 | title?: string 22 | children?: NodeList 23 | backText?: string 24 | backHref?: string | false 25 | backColor?: ThemeColor 26 | class?: string 27 | headerColor?: ThemeColor 28 | contentColor?: ThemeColor 29 | toolbarExtra?: Node 30 | } 31 | 32 | function IonicPage(attrs: PageAttrs, context: Context) { 33 | let backHref = 34 | attrs.backHref ?? getContextSearchParams(context)?.get('return_url') ?? '/' 35 | return ( 36 | <> 37 | {backHref || attrs.title ? ( 38 | 39 | 40 | {backHref ? ( 41 | 46 | ) : null} 47 | {attrs.title ? ( 48 | 49 | {attrs.title} 50 | 51 | ) : null} 52 | {attrs.toolbarExtra} 53 | 54 | 55 | ) : null} 56 | 62 | {attrs.children ? [attrs.children] : null} 63 | 64 | 65 | ) 66 | } 67 | 68 | function WebPage(attrs: PageAttrs) { 69 | return ( 70 |
71 | {attrs.title ?

{attrs.title}

: null} 72 | {attrs.children ? [attrs.children] : null} 73 |
74 | ) 75 | } 76 | 77 | export let is_ionic = config.layout_type === LayoutType.ionic 78 | export let is_web = !is_ionic 79 | 80 | export let Page = is_ionic ? IonicPage : WebPage 81 | 82 | type Content = Node | (() => Node) 83 | 84 | export function Content(attrs: { ionic?: Content; web?: Content }): Node { 85 | let content = is_ionic ? attrs.ionic : attrs.web 86 | if (typeof content === 'function') { 87 | return content() 88 | } 89 | return content 90 | } 91 | -------------------------------------------------------------------------------- /template/web.ts: -------------------------------------------------------------------------------- 1 | interface HTMLStream { 2 | write(chunk: string): void 3 | flush(): void 4 | } 5 | type HTMLFunc = (stream: HTMLStream) => void 6 | 7 | export type WebOptions = { 8 | title: string | HTMLFunc 9 | description: string | HTMLFunc 10 | app: string | HTMLFunc 11 | } 12 | 13 | export function renderWebTemplate( 14 | stream: HTMLStream, 15 | options: WebOptions, 16 | ): void { 17 | stream.write(/* html */ ` 18 | 19 | 20 | 21 | 22 | 26 | `) 27 | typeof options.title == 'function' ? options.title(stream) : stream.write(options.title) 28 | stream.write(/* html */ ` 29 | 32 | 33 | 34 | 45 | 76 | 77 | 82 | `) 83 | typeof options.app == 'function' ? options.app(stream) : stream.write(options.app) 84 | stream.write(/* html */ ` 85 | 86 | 87 | `) 88 | } 89 | -------------------------------------------------------------------------------- /server/app/components/update.tsx: -------------------------------------------------------------------------------- 1 | import type { ServerMessage } from '../../../client/types' 2 | import type { VNode } from '../../../client/jsx/types' 3 | import { ExpressContext, castDynamicContext, Context } from '../context.js' 4 | import { EarlyTerminate } from '../../exception.js' 5 | import { setSessionUrl } from '../session.js' 6 | import { renderRedirect } from './router.js' 7 | import { toAbsoluteHref } from '../../url.js' 8 | import { setNoCache } from '../express.js' 9 | 10 | export function Update( 11 | attrs: { 12 | // to redirect static html client 13 | to: string 14 | 15 | // to update live websocket client 16 | message: ServerMessage 17 | }, 18 | _context: Context, 19 | ): VNode { 20 | const context = castDynamicContext(_context) 21 | if (context.type === 'ws') { 22 | context.ws.send(attrs.message) 23 | setSessionUrl(context.ws, attrs.to) 24 | } else { 25 | forceRedirectExpressSession(context, attrs.to) 26 | } 27 | throw EarlyTerminate 28 | } 29 | 30 | export function UpdateIn( 31 | attrs: { 32 | // to redirect static html client 33 | to: string 34 | 35 | // to update live websocket client 36 | selector: string 37 | 38 | title?: string 39 | } & ( 40 | | { 41 | content: VNode 42 | } 43 | | { 44 | render: () => VNode 45 | } 46 | ), 47 | _context: Context, 48 | ): VNode { 49 | const context = castDynamicContext(_context) 50 | if (context.type === 'ws') { 51 | const content = 'render' in attrs ? attrs.render() : attrs.content 52 | if (attrs.title) { 53 | context.ws.send(['update-in', attrs.selector, content, attrs.title]) 54 | } else { 55 | context.ws.send(['update-in', attrs.selector, content]) 56 | } 57 | setSessionUrl(context.ws, attrs.to) 58 | } else { 59 | forceRedirectExpressSession(context, attrs.to) 60 | } 61 | throw EarlyTerminate 62 | } 63 | 64 | export function UpdateUrl( 65 | attrs: { href: string; status?: number }, 66 | context: Context, 67 | ): VNode { 68 | castDynamicContext(context) 69 | if (context.type === 'ws') { 70 | setSessionUrl(context.ws, attrs.href) 71 | } else if (context.type === 'express') { 72 | forceRedirectExpressSession(context, attrs.href) 73 | } 74 | throw EarlyTerminate 75 | } 76 | 77 | export function forceRedirectExpressSession( 78 | context: ExpressContext, 79 | href: string, 80 | status?: number, 81 | ) { 82 | if (href.includes('?')) { 83 | href += '&' 84 | } else { 85 | href += '?' 86 | } 87 | href += 'time=' + Date.now() 88 | href = toAbsoluteHref(context.req, href) 89 | const res = context.res 90 | if (res.headersSent) { 91 | res.end(renderRedirect(href)) 92 | } else { 93 | setNoCache(res) 94 | res.redirect(status || 303, href) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /server/url.ts: -------------------------------------------------------------------------------- 1 | import type express from 'express' 2 | import type { RouteParameters } from './params' 3 | 4 | export function toAbsoluteHref(req: express.Request, href: string): string { 5 | if (href.startsWith('http://') || href.startsWith('https://')) { 6 | return href 7 | } 8 | if (!req.headers.host) { 9 | throw new Error('missing req.headers.host') 10 | } 11 | if (!href.startsWith('/')) { 12 | let parts = req.originalUrl.split('/') 13 | parts[parts.length - 1] = href 14 | href = parts.join('/') 15 | } 16 | return req.protocol + '://' + req.headers.host + href 17 | } 18 | 19 | export function toRouteUrl( 20 | /** @description for type inference */ 21 | routes: R, 22 | /** @description the url template */ 23 | key: K, 24 | /** @description the variables in url */ 25 | options?: { 26 | params?: RouteParameters 27 | query?: object 28 | search?: string 29 | /** @description to apply `JSON.stringify()` on the result if enabled */ 30 | json?: boolean 31 | }, 32 | ): string { 33 | return toUrl(key, options) 34 | } 35 | 36 | export function toUrl( 37 | /** @description the url template */ 38 | key: K, 39 | /** @description the variables in url */ 40 | options?: { 41 | params?: RouteParameters 42 | query?: object 43 | search?: string 44 | /** @description to apply `JSON.stringify()` on the result if enabled */ 45 | json?: boolean 46 | }, 47 | ): string { 48 | let params = (options?.params || {}) as Record 49 | let url = key as string 50 | for (let part of url.split('/')) { 51 | if (part[0] == ':') { 52 | let key = part.slice(1) 53 | let optional = key.endsWith('?') 54 | if (optional) { 55 | key = key.slice(0, key.length - 1) 56 | } 57 | if (key in params) { 58 | url = url.replace('/' + part, '/' + params[key]) 59 | } else if (optional) { 60 | url = url.replace('/' + part, '') 61 | } else { 62 | throw new Error(`missing parameter "${key}" in route "${url}"`) 63 | } 64 | } 65 | } 66 | 67 | if (options && (options.query || options.search)) { 68 | let searchParams = new URLSearchParams(options.search) 69 | if (options.query) { 70 | for (let [key, value] of Object.entries(options.query)) { 71 | if (Array.isArray(value)) { 72 | for (let val of value) { 73 | searchParams.append(key, val) 74 | } 75 | } else { 76 | searchParams.set(key, value) 77 | } 78 | } 79 | } 80 | if (searchParams.size > 0) { 81 | url += '?' + searchParams 82 | } 83 | } 84 | 85 | if (options?.json) { 86 | return JSON.stringify(url) 87 | } 88 | return url 89 | } 90 | -------------------------------------------------------------------------------- /server/app/components/stats.tsx: -------------------------------------------------------------------------------- 1 | import type { ServerMessage } from '../../../client/types' 2 | import type { ManagedWebsocket } from '../../ws/wss.js' 3 | import { o } from '../jsx/jsx.js' 4 | import { onWsSessionClose, sessions } from '../session.js' 5 | import { existsSync, renameSync } from 'fs' 6 | import { join } from 'path' 7 | import type { Context } from '../context' 8 | import { loadNumber, saveNumber } from '../data/version-number.js' 9 | 10 | function sendMessage(message: ServerMessage, skip?: ManagedWebsocket) { 11 | sessions.forEach(session => { 12 | if (session.ws !== skip) { 13 | session.ws.send(message) 14 | } 15 | }) 16 | } 17 | 18 | let visitFile = join('data', 'visit.txt') 19 | let _visitorFile = join('data', 'visitor.txt') 20 | let sessionFile = join('data', 'session.txt') 21 | 22 | function migrateVisitFile() { 23 | if (existsSync(_visitorFile) && !existsSync(visitFile)) { 24 | renameSync(_visitorFile, visitFile) 25 | } 26 | } 27 | migrateVisitFile() 28 | 29 | let state = { 30 | visit: loadNumber(visitFile), 31 | session: loadNumber(sessionFile), 32 | live: new Set(), 33 | } 34 | 35 | export function Stats(attrs: { hidden?: boolean }, context: Context) { 36 | let messages: ServerMessage[] = [] 37 | if (context.type === 'express') { 38 | state.visit++ 39 | saveNumber(visitFile, state.visit) 40 | messages.push(['update-text', '#stats .visit', state.visit]) 41 | } 42 | let ws: ManagedWebsocket | undefined 43 | if (context.type === 'ws') { 44 | ws = context.ws 45 | if (!state.live.has(ws)) { 46 | state.session++ 47 | saveNumber(sessionFile, state.session) 48 | state.live.add(ws) 49 | } 50 | messages.push( 51 | ['update-text', '#stats .session', state.session], 52 | ['update-text', '#stats .live', state.live.size], 53 | ) 54 | onWsSessionClose(ws, session => { 55 | let ws = session.ws 56 | state.live.delete(ws) 57 | let message: ServerMessage = [ 58 | 'update-text', 59 | '#stats .live', 60 | state.live.size, 61 | ] 62 | sendMessage(message) 63 | }) 64 | } 65 | sendMessage(['batch', messages], ws) 66 | return ( 67 | 89 | ) 90 | } 91 | 92 | export default Stats 93 | -------------------------------------------------------------------------------- /scripts/rebase-template.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | set -o pipefail 4 | set -x 5 | 6 | git checkout v5-auth-ionic-template 7 | git checkout v5-auth-web-template 8 | git checkout v5-auth-template 9 | git checkout v5-ionic-template 10 | git checkout v5-web-template 11 | git checkout v5-minimal-without-db-template 12 | git checkout v5-minimal-template 13 | git checkout v5-hybrid-template 14 | git checkout v5-demo 15 | git checkout master 16 | 17 | git merge v5-demo 18 | git checkout v5-demo 19 | git merge master 20 | 21 | git branch -D v5-hybrid-template 22 | git checkout -b v5-hybrid-template 23 | git cherry-pick origin/v5-hybrid-template 24 | 25 | git checkout v5-hybrid-template 26 | git branch -D v5-minimal-template 27 | git checkout -b v5-minimal-template 28 | git cherry-pick origin/v5-hybrid-template..origin/v5-minimal-template 29 | 30 | git checkout v5-minimal-template 31 | git branch -D v5-minimal-without-db-template 32 | git checkout -b v5-minimal-without-db-template 33 | git cherry-pick origin/v5-minimal-template..origin/v5-minimal-without-db-template 34 | 35 | git checkout v5-hybrid-template 36 | git branch -D v5-web-template 37 | git checkout -b v5-web-template 38 | git cherry-pick origin/v5-hybrid-template..origin/v5-web-template 39 | 40 | git checkout v5-hybrid-template 41 | git branch -D v5-ionic-template 42 | git checkout -b v5-ionic-template 43 | git cherry-pick origin/v5-hybrid-template..origin/v5-ionic-template 44 | 45 | git checkout v5-hybrid-template 46 | git branch -D v5-auth-template 47 | git checkout -b v5-auth-template 48 | git cherry-pick origin/v5-hybrid-template..origin/v5-auth-template 49 | 50 | git checkout v5-auth-template 51 | git branch -D v5-auth-web-template 52 | git checkout -b v5-auth-web-template 53 | git cherry-pick origin/v5-auth-template..origin/v5-auth-web-template 54 | 55 | git checkout v5-auth-template 56 | git branch -D v5-auth-ionic-template 57 | git checkout -b v5-auth-ionic-template 58 | git cherry-pick origin/v5-auth-template..origin/v5-auth-ionic-template 59 | 60 | set +x 61 | echo finished cherry-pick. 62 | 63 | ./scripts/check-commits.sh 64 | 65 | echo -n 'continue to push? [y/N] ' 66 | read ans 67 | if [ "x-$ans" != "x-y" ]; then 68 | exit 69 | fi 70 | set -x 71 | 72 | git push origin master v5-demo 73 | git push origin --force-with-lease \ 74 | v5-hybrid-template \ 75 | v5-minimal-template \ 76 | v5-minimal-without-db-template \ 77 | v5-web-template \ 78 | v5-ionic-template \ 79 | v5-auth-template \ 80 | v5-auth-web-template \ 81 | v5-auth-ionic-template 82 | 83 | git checkout master 84 | git branch -D v5-demo 85 | git branch -D v5-hybrid-template 86 | git branch -D v5-minimal-template 87 | git branch -D v5-minimal-without-db-template 88 | git branch -D v5-web-template 89 | git branch -D v5-ionic-template 90 | git branch -D v5-auth-template 91 | git branch -D v5-auth-web-template 92 | git branch -D v5-auth-ionic-template 93 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response, NextFunction } from 'express' 2 | import http from 'http' 3 | import { WebSocketServer } from 'ws' 4 | import { config } from './config.js' 5 | import { join } from 'path' 6 | import { debugLog } from './debug.js' 7 | import { listenWSSConnection } from './ws/wss-lite.js' 8 | import { attachRoutes, onWsMessage } from './app/app.js' 9 | import { startSession, closeSession } from './app/session.js' 10 | import open from 'open' 11 | import { cookieMiddleware } from './app/cookie.js' 12 | import { listenWSSCookie } from './app/cookie.js' 13 | import { print } from 'listening-on' 14 | import { logRequest } from './app/log.js' 15 | import { env } from './env.js' 16 | import { HttpError, EarlyTerminate } from './exception.js' 17 | import { setCaddy } from './caddy.js' 18 | 19 | const log = debugLog('index.ts') 20 | log.enabled = true 21 | 22 | const app = express() 23 | const server = http.createServer(app) 24 | const wss = new WebSocketServer({ server }) 25 | listenWSSCookie(wss) 26 | listenWSSConnection({ 27 | wss, 28 | onConnection: ws => { 29 | log('attach ws:', ws.ws.protocol) 30 | startSession(ws) 31 | }, 32 | onClose: (ws, code, reason) => { 33 | log('close ws:', ws.ws.protocol, code, String(reason)) 34 | closeSession(ws) 35 | }, 36 | onMessage: onWsMessage, 37 | }) 38 | 39 | app.use((req, res, next) => { 40 | logRequest(req, req.method, req.url, null) 41 | next() 42 | }) 43 | 44 | if (config.development) { 45 | app.use('/js', express.static(join('dist', 'client'))) 46 | } 47 | app.use('/js', express.static('build')) 48 | app.use('/uploads', express.static(env.UPLOAD_DIR)) 49 | app.use('/npm/@ionic/core', express.static('node_modules/@ionic/core')) 50 | app.use('/npm/swiper', express.static('node_modules/swiper')) 51 | app.use('/npm/jquery', express.static('node_modules/jquery')) 52 | app.use('/npm/datatables.net', express.static('node_modules/datatables.net')) 53 | app.use( 54 | '/npm/datatables.net-dt', 55 | express.static('node_modules/datatables.net-dt'), 56 | ) 57 | app.use('/npm/chart.js', express.static('node_modules/chart.js')) 58 | app.use(express.static('public')) 59 | 60 | app.use(express.json()) 61 | app.use(express.urlencoded({ extended: true })) 62 | 63 | app.use(cookieMiddleware) 64 | 65 | attachRoutes(app) 66 | 67 | app.use((error: HttpError, req: Request, res: Response, next: NextFunction) => { 68 | if ((error as unknown) == EarlyTerminate) { 69 | return 70 | } 71 | res.status(error.statusCode || 500) 72 | if (error instanceof Error && !(error instanceof HttpError)) { 73 | console.error(error) 74 | } 75 | res.json({ error: String(error) }) 76 | }) 77 | 78 | const port = env.PORT 79 | server.listen(port, () => { 80 | print(port) 81 | if (env.CADDY_PROXY === 'enable') { 82 | setCaddy(port) 83 | } 84 | if (config.auto_open) { 85 | open(`http://localhost:${port}`) 86 | } 87 | }) 88 | -------------------------------------------------------------------------------- /server/app/api-route.ts: -------------------------------------------------------------------------------- 1 | import type { ServerMessage } from '../../client/types' 2 | import { apiEndpointTitle } from '../config.js' 3 | import { EarlyTerminate, MessageException, HttpError } from '../exception.js' 4 | import { renderError, showError } from './components/error.js' 5 | import type { Context, ExpressContext, WsContext } from './context' 6 | import type { PageRoute, StaticPageRoute } from './routes' 7 | 8 | export type ExpressAPI = ( 9 | context: ExpressContext, 10 | ) => Promise | T 11 | 12 | export function ajaxRoute(options: { 13 | description: string 14 | api: ExpressAPI 15 | }) { 16 | return { 17 | title: apiEndpointTitle, 18 | description: options.description, 19 | streaming: false, 20 | async resolve(context: Context) { 21 | if (context.type != 'express') { 22 | throw new Error('this endpoint only support ajax') 23 | } 24 | let res = context.res 25 | try { 26 | let json = await options.api(context) 27 | res.json(json) 28 | } catch (error) { 29 | let statusCode = 500 30 | if (error) { 31 | statusCode = (error as HttpError).statusCode || statusCode 32 | } 33 | res.status(statusCode) 34 | res.json({ error: String(error) }) 35 | } 36 | throw EarlyTerminate 37 | }, 38 | api: options.api, 39 | } satisfies PageRoute & { 40 | api: ExpressAPI 41 | } 42 | } 43 | 44 | export type WsAPI = ( 45 | context: WsContext, 46 | ) => Promise | ServerMessage 47 | 48 | export function wsRoute(options: { description: string; api: WsAPI }) { 49 | return { 50 | title: apiEndpointTitle, 51 | description: options.description, 52 | streaming: false, 53 | async resolve(context: Context) { 54 | if (context.type != 'ws') { 55 | throw new Error('this endpoint only support ws') 56 | } 57 | try { 58 | let message = await options.api(context) 59 | context.ws.send(message) 60 | } catch (error) { 61 | if (error == EarlyTerminate) { 62 | /* no need to do anything */ 63 | } else if (error instanceof MessageException) { 64 | context.ws.send(error.message) 65 | } else { 66 | context.ws.send(showError(error)) 67 | } 68 | } 69 | throw EarlyTerminate 70 | }, 71 | api: options.api, 72 | } satisfies PageRoute & { 73 | api: WsAPI 74 | } 75 | } 76 | 77 | export function errorRoute( 78 | error: unknown, 79 | context: Context, 80 | title: string, 81 | description: string, 82 | ): StaticPageRoute { 83 | if (error == EarlyTerminate || error instanceof MessageException) { 84 | throw error 85 | } 86 | if (context.type == 'ws' && typeof error == 'string') { 87 | throw new MessageException(showError(error)) 88 | } 89 | return { 90 | title, 91 | description, 92 | node: renderError(error, context), 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /server/app/upload.ts: -------------------------------------------------------------------------------- 1 | import { Formidable, Part, Options } from 'formidable' 2 | import { existsSync, mkdirSync } from 'fs' 3 | import { randomUUID } from 'crypto' 4 | import { extname, join } from 'path' 5 | import { client_config } from '../../client/client-config.js' 6 | import { env } from '../env.js' 7 | 8 | const maxTrial = 10 9 | 10 | let mkdirCache = new Set() 11 | function cached_mkdir(dir: string) { 12 | if (mkdirCache.has(dir)) { 13 | return 14 | } 15 | mkdirSync(dir, { recursive: true }) 16 | mkdirCache.add(dir) 17 | } 18 | 19 | function detectExtname(part: Part): string { 20 | if (part.originalFilename) { 21 | let ext = extname(part.originalFilename) 22 | if (ext[0] == '.') { 23 | ext = ext.slice(1) 24 | } 25 | if (ext) return ext 26 | } 27 | let mime = part.mimetype 28 | if (mime?.includes('text/plain')) return 'txt' 29 | return mime?.split('/').pop()?.split(';')[0] || 'bin' 30 | } 31 | 32 | export let MimeTypeRegex = { 33 | any_image: /^image\/.+/, 34 | any_video: /^video\/.+/, 35 | any_audio: /^audio\/.+/, 36 | any_text: /^text\/.+/, 37 | } 38 | 39 | export function createUploadForm(options?: { 40 | /** @default config.upload_dir */ 41 | uploadDir?: string 42 | 43 | /** @default any_image */ 44 | mimeTypeRegex?: RegExp 45 | 46 | /** @default client_config.max_image_size */ 47 | maxFileSize?: number 48 | 49 | /** @default 1 (single file) */ 50 | maxFiles?: number 51 | 52 | /** @default randomUUID + extname */ 53 | filename?: string | Options['filename'] 54 | }) { 55 | let uploadDir = options?.uploadDir || env.UPLOAD_DIR 56 | let mimeTypeRegex = options?.mimeTypeRegex || MimeTypeRegex.any_image 57 | let maxFileSize = options?.maxFileSize || client_config.max_image_size 58 | let maxFiles = options?.maxFiles || 1 59 | 60 | const filename: string | Options['filename'] = 61 | options?.filename || 62 | ((_name, _ext, part, _file): string => { 63 | let extname = detectExtname(part) 64 | for (let i = 0; i < maxTrial; i++) { 65 | let filename = randomUUID() + '.' + extname 66 | if (existsSync(join(uploadDir, filename))) continue 67 | return filename 68 | } 69 | throw new Error('too many files in uploadDir') 70 | }) 71 | 72 | cached_mkdir(uploadDir) 73 | let form = new Formidable({ 74 | uploadDir, 75 | maxFileSize, 76 | maxFiles, 77 | maxTotalFileSize: maxFileSize * maxFiles, 78 | multiples: true, 79 | filename: typeof filename == 'string' ? () => filename : filename, 80 | filter(part): boolean { 81 | return !!part.mimetype && mimeTypeRegex.test(part.mimetype) 82 | }, 83 | }) 84 | return form 85 | } 86 | 87 | export function toUploadedUrl( 88 | url: string | undefined | null, 89 | ): string | undefined { 90 | if (!url) return undefined 91 | if (url.startsWith('https://')) return url 92 | if (url.startsWith('http://')) return url 93 | return '/uploads/' + url 94 | } 95 | -------------------------------------------------------------------------------- /server/app/pages/user-agents.tsx: -------------------------------------------------------------------------------- 1 | import { proxy } from '../../../db/proxy.js' 2 | import { 3 | getOtherUserAgents, 4 | getUAStatsProgress, 5 | } from '../../../db/user-agent.js' 6 | import { Locale, Title } from '../components/locale.js' 7 | import SourceCode from '../components/source-code.js' 8 | import Style from '../components/style.js' 9 | import { o } from '../jsx/jsx.js' 10 | import { Routes } from '../routes.js' 11 | 12 | function agentTable(title: string, rows: [name: string, count: number][]) { 13 | if (rows.length === 0) return 14 | rows.sort((a, b) => b[1] - a[1]) 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {[ 25 | rows.map(([name, count]) => ( 26 | 27 | 28 | 29 | 30 | )), 31 | ]} 32 | 33 |
{title}Count
{name}{count}
34 | ) 35 | } 36 | 37 | function Tables() { 38 | return ( 39 | <> 40 |

{getUAStatsProgress()}

41 | {agentTable( 42 | 'User Agent', 43 | proxy.ua_type.map(row => [row.name, row.count]), 44 | )} 45 | {agentTable( 46 | 'Bot Agent', 47 | proxy.ua_bot.map(row => [row.name, row.count]), 48 | )} 49 | {agentTable( 50 | 'Other Agent', 51 | getOtherUserAgents().map(row => [row.user_agent, row.count]), 52 | )} 53 | 54 | ) 55 | } 56 | 57 | let UserAgents = ( 58 |
59 |

User Agents of Visitors

60 | {Style(/* css */ ` 61 | #user-agents table { 62 | border-collapse: collapse; 63 | margin: 1rem; 64 | display: inline; 65 | } 66 | #user-agents th, 67 | #user-agents td { 68 | border: 1px solid var(--text-color, black); 69 | padding: 0.25rem 0.5rem; 70 | max-width: calc(90vw - 8rem); 71 | word-break: break-word; 72 | } 73 | `)} 74 |

This page demonstrates showing query result from database.

75 |

76 | Below list of user agents are collected from the visitor's HTTP header. 77 |

78 | 79 | 80 |
81 | ) 82 | 83 | let routes = { 84 | '/user-agents': { 85 | menuText: , 86 | title: ( 87 | 94 | } 95 | /> 96 | ), 97 | description: ( 98 | <Locale 99 | en="User agents of this site's visitors" 100 | zh_hk="此網站訪客的用戶代理資訊" 101 | zh_cn="此网站访客的用户代理资讯" 102 | /> 103 | ), 104 | node: UserAgents, 105 | }, 106 | } satisfies Routes 107 | 108 | export default { routes } 109 | -------------------------------------------------------------------------------- /server/app/pages/app-about.tsx: -------------------------------------------------------------------------------- 1 | import { LayoutType, title } from '../../config.js' 2 | import { mapArray } from '../components/fragment.js' 3 | import { IonBackButton } from '../components/ion-back-button.js' 4 | import { wsStatus } from '../components/ws-status.js' 5 | import { DynamicContext } from '../context.js' 6 | import { o } from '../jsx/jsx.js' 7 | import { Routes } from '../routes.js' 8 | import { themeColorNames } from '../styles/mobile-style.js' 9 | 10 | let pageTitle = 'About' 11 | 12 | function BackButton(attr: {}, context: DynamicContext) { 13 | let from = new URLSearchParams(context.routerMatch?.search).get('from') 14 | if (from == 'more') { 15 | return <IonBackButton href="/app/more" backText="More" /> 16 | } 17 | return <IonBackButton href="/app/home" backText="Home" /> 18 | } 19 | 20 | let aboutPage = ( 21 | <> 22 | <ion-header> 23 | <ion-toolbar> 24 | <BackButton /> 25 | <ion-title role="heading" aria-level="1"> 26 | {pageTitle} 27 | </ion-title> 28 | </ion-toolbar> 29 | </ion-header> 30 | <ion-content class="ion-padding"> 31 | <p> 32 | This is a demo using{' '} 33 | <a href="https://ionicframework.com" target="_blank"> 34 | ionic 35 | </a>{' '} 36 | and{' '} 37 | <a href="https://github.com/beenotung/ts-liveview" target="_blank"> 38 | ts-liveview 39 | </a>{' '} 40 | to build mobile-first webapp. 41 | </p> 42 | <p> 43 | It leverages realtime <abbr title="Server-Side Rendering">SSR</abbr> to 44 | reduce loading time and support{' '} 45 | <abbr title="Search Engine Optimization">SEO</abbr>. 46 | </p> 47 | <h2>Theme Color</h2> 48 | <div> 49 | {mapArray(themeColorNames, color => ( 50 | <ion-button color={color}>{color}</ion-button> 51 | ))} 52 | {mapArray(themeColorNames, color => ( 53 | <ion-button fill="block" color={color}> 54 | {color} 55 | </ion-button> 56 | ))} 57 | {mapArray(themeColorNames, color => ( 58 | <div class="d-flex"> 59 | <div class="ion-padding flex-grow"> 60 | <ion-text style="display: block" color={color}> 61 | {color} 62 | </ion-text> 63 | </div> 64 | <div 65 | class="ion-padding flex-grow" 66 | style={` 67 | background-color: var(--ion-color-${color}); 68 | color: var(--ion-color-${color}-contrast); 69 | `} 70 | > 71 | {color} 72 | </div> 73 | </div> 74 | ))} 75 | </div> 76 | {wsStatus.safeArea} 77 | </ion-content> 78 | </> 79 | ) 80 | 81 | let routes = { 82 | '/app/about': { 83 | title: title(pageTitle), 84 | description: `Demo using ionic and ts-liveview to build mobile-first SSR webapp`, 85 | node: aboutPage, 86 | layout_type: LayoutType.ionic, 87 | }, 88 | } satisfies Routes 89 | 90 | export default { routes } 91 | -------------------------------------------------------------------------------- /server/app/cookie.ts: -------------------------------------------------------------------------------- 1 | import cookieParser from 'cookie-parser' 2 | import type WebSocket from 'ws' 3 | import type ws from 'ws' 4 | import type express from 'express' 5 | import { config } from '../config.js' 6 | import { debugLog } from '../debug.js' 7 | import type { Context } from './context' 8 | import { env } from '../env.js' 9 | import { CookieOptions } from 'express-serve-static-core' 10 | 11 | const log = debugLog('cookie.ts') 12 | log.enabled = true 13 | 14 | export const cookieMiddleware = cookieParser(env.COOKIE_SECRET) 15 | 16 | export const mustCookieSecure = config.production 17 | 18 | export function getSecureCookies(req: express.Request): Cookies { 19 | return { 20 | unsignedCookies: req.cookies, 21 | signedCookies: req.signedCookies, 22 | } 23 | } 24 | 25 | export type CookieDict = Record<string, string> 26 | 27 | export type Cookies = { 28 | unsignedCookies: CookieDict 29 | signedCookies: CookieDict 30 | } 31 | 32 | const ws_cookies = new WeakMap<WebSocket, Cookies>() 33 | 34 | export function listenWSSCookie(wss: ws.Server) { 35 | wss.on('connection', (ws, request) => { 36 | const req = request as express.Request 37 | const res = {} as express.Response 38 | // @ts-ignore 39 | req.secure ??= req.headers.origin?.startsWith('https') || false 40 | // @ts-ignore 41 | req.protocol ??= req.secure ? 'wss' : 'ws' 42 | req.originalUrl ??= req.url || '/' 43 | cookieMiddleware(req, res, () => { 44 | const cookies = getSecureCookies(req) 45 | ws_cookies.set(ws, cookies) 46 | }) 47 | ws.on('close', () => { 48 | ws_cookies.delete(ws) 49 | }) 50 | }) 51 | } 52 | 53 | export function getWsCookies(ws: WebSocket): Cookies { 54 | const cookies = ws_cookies.get(ws) 55 | if (!cookies) { 56 | log('no ws cookies') 57 | throw new Error('no ws cookies') 58 | } 59 | return cookies 60 | } 61 | 62 | export function getContextCookies(context: Context): Cookies | null { 63 | if (context.type === 'express') { 64 | return getSecureCookies(context.req) 65 | } 66 | if (context.type === 'ws') { 67 | return getWsCookies(context.ws.ws) 68 | } 69 | return null 70 | } 71 | 72 | export function setContextCookie( 73 | context: Context, 74 | key: string, 75 | value: string, 76 | options?: CookieOptions, 77 | ) { 78 | options ||= {} 79 | options.sameSite ||= 'lax' 80 | options.path ||= '/' 81 | if (context.type === 'express') { 82 | context.res.cookie(key, value, options) 83 | } 84 | if (context.type === 'ws' && !options?.httpOnly) { 85 | let cookie = `${key}=${value};SameSite=${options.sameSite};path=${options.path}` 86 | if (options?.maxAge) { 87 | cookie += `;max-age=${options.maxAge}` 88 | } 89 | context.ws.send(['set-cookie', cookie]) 90 | } 91 | let cookies = getContextCookies(context) 92 | if (cookies) { 93 | if (options?.signed) { 94 | cookies.signedCookies[key] = value 95 | } else { 96 | cookies.unsignedCookies[key] = value 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /client/ws/ws-lite.ts: -------------------------------------------------------------------------------- 1 | import type { ManagedWebsocket } from './ws' 2 | import type { ClientMessage, ServerMessage } from '../types' 3 | import { 4 | DefaultReconnectInterval, 5 | HeartBeatTimeoutCode, 6 | HeartBeatTimeoutReason, 7 | HeartHeatInterval, 8 | HeartHeatTimeout, 9 | MaxReconnectInterval, 10 | } from './ws-config.js' 11 | 12 | export const Ping = '1' 13 | export const Pong = '2' 14 | export const Send = '3' 15 | 16 | type Timer = ReturnType<typeof setTimeout> 17 | 18 | let reconnectInterval = DefaultReconnectInterval 19 | 20 | export function connectWS(options: { 21 | createWS: (protocol: string) => WebSocket 22 | attachWS: (ws: ManagedWebsocket) => void 23 | onMessage: (data: ServerMessage) => void 24 | }) { 25 | const ws = options.createWS('ws-lite') 26 | 27 | let pingTimer: Timer 28 | let pongTimer: Timer 29 | heartbeat() 30 | 31 | function heartbeat() { 32 | clearTimeout(pingTimer) 33 | clearTimeout(pongTimer) 34 | pingTimer = setTimeout(sendPing, HeartHeatInterval) 35 | pongTimer = setTimeout(onHeartbeatTimeout, HeartHeatTimeout) 36 | } 37 | 38 | function sendPing() { 39 | if (ws.bufferedAmount === 0 && ws.readyState === ws.OPEN) { 40 | ws.send(Ping) 41 | } 42 | } 43 | 44 | function onHeartbeatTimeout() { 45 | console.debug('onHeartbeatTimeout') 46 | ws.close(HeartBeatTimeoutCode, HeartBeatTimeoutReason) 47 | } 48 | 49 | ws.addEventListener('open', () => { 50 | reconnectInterval = DefaultReconnectInterval 51 | heartbeat() 52 | }) 53 | 54 | ws.addEventListener('close', event => { 55 | teardown() 56 | // reference: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent 57 | if (event.code === 1001) { 58 | // don't auto-reconnect when the browser is navigating away from the page 59 | return 60 | } 61 | console.debug( 62 | 'will reconnect ws after', 63 | (reconnectInterval / 1000).toFixed(1), 64 | 'seconds', 65 | ) 66 | setTimeout(() => connectWS(options), reconnectInterval) 67 | reconnectInterval = Math.min(reconnectInterval * 1.5, MaxReconnectInterval) 68 | }) 69 | 70 | function teardown() { 71 | clearTimeout(pingTimer) 72 | clearTimeout(pongTimer) 73 | } 74 | 75 | function close(code?: number, reason?: string) { 76 | teardown() 77 | ws.close(code, reason) 78 | } 79 | 80 | function send(event: ClientMessage) { 81 | clearTimeout(pingTimer) 82 | let data = Send + JSON.stringify(event) 83 | ws.send(data) 84 | } 85 | 86 | ws.addEventListener('message', event => { 87 | heartbeat() 88 | let data = String(event.data) 89 | if (data === Ping) { 90 | if (ws.bufferedAmount === 0) { 91 | ws.send(Pong) 92 | } 93 | return 94 | } 95 | if (data === Pong) { 96 | return 97 | } 98 | if (data[0] === Send) { 99 | options.onMessage(JSON.parse(data.slice(1))) 100 | return 101 | } 102 | console.debug('received unknown ws message:', event) 103 | }) 104 | 105 | options.attachWS({ ws, send, close }) 106 | } 107 | -------------------------------------------------------------------------------- /server/app/components/chart.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import { Script } from './script.js' 3 | 4 | export let ChartScript = <script src="/npm/chart.js/dist/chart.umd.js"></script> 5 | 6 | export function Chart(attrs: { 7 | canvas_id: string 8 | height?: number 9 | width?: number 10 | skip_canvas?: boolean 11 | datasets: { 12 | label: string 13 | data: number[] 14 | hidden?: boolean 15 | borderColor?: string 16 | backgroundColor?: string 17 | }[] 18 | data_labels: string[] 19 | type?: 'line' | 'bar' | 'radar' 20 | 21 | /** 22 | * @description default: `false` 23 | * 24 | * If true, scale will include 0 if it is not already included. 25 | * */ 26 | beginAtZero?: boolean 27 | 28 | /** @description will be scaled by `grace` if specified */ 29 | min?: number 30 | 31 | /** @description will be scaled by `grace` if specified */ 32 | max?: number 33 | 34 | /** @description default: `3` */ 35 | borderWidth?: number 36 | 37 | /** 38 | * @description default: `0` 39 | * 40 | * If the value is a string ending with %, it's treated as a percentage. 41 | * 42 | * If a number, it's treated as a value. 43 | * 44 | * The value is added to the maximum data value and subtracted from the minimum data. 45 | * 46 | * This extends the scale range as if the data values were that much greater. 47 | * */ 48 | grace?: number | string 49 | }) { 50 | let options = { 51 | type: attrs.type || 'line', 52 | data: { 53 | labels: attrs.data_labels, 54 | datasets: attrs.datasets.map(dataset => ({ 55 | label: dataset.label, 56 | data: dataset.data, 57 | borderWidth: attrs.borderWidth, 58 | hidden: dataset.hidden, 59 | borderColor: dataset.borderColor, 60 | backgroundColor: dataset.backgroundColor, 61 | })), 62 | }, 63 | options: { 64 | scales: { 65 | y: { 66 | beginAtZero: attrs.beginAtZero, 67 | grace: attrs.grace, 68 | suggestedMin: attrs.min, 69 | suggestedMax: attrs.max, 70 | }, 71 | }, 72 | }, 73 | } 74 | let script = Script(/* javascript */ ` 75 | (function initChart (){ 76 | if (typeof Chart === 'undefined') { 77 | // still loading 78 | setTimeout(initChart, 50) 79 | return 80 | } 81 | let canvas_id = ${JSON.stringify(attrs.canvas_id)} 82 | let canvas = document.getElementById(canvas_id) 83 | if (!canvas) { 84 | console.error('Chart: no element found with id: ', canvas_id) 85 | console.error('Failed to find chart element:', { canvas_id }) 86 | return 87 | } 88 | let options = ${JSON.stringify(options)} 89 | let chart = new Chart(canvas, options) 90 | canvas.chart = chart 91 | })(); 92 | `) 93 | return ( 94 | <> 95 | {attrs.skip_canvas ? null : ( 96 | <canvas 97 | id={attrs.canvas_id} 98 | height={attrs.height} 99 | width={attrs.width} 100 | /> 101 | )} 102 | {script} 103 | </> 104 | ) 105 | } 106 | -------------------------------------------------------------------------------- /template/ionic.ts: -------------------------------------------------------------------------------- 1 | interface HTMLStream { 2 | write(chunk: string): void 3 | flush(): void 4 | } 5 | type HTMLFunc = (stream: HTMLStream) => void 6 | 7 | export type IonicOptions = { 8 | title: string | HTMLFunc 9 | description: string | HTMLFunc 10 | app: string | HTMLFunc 11 | } 12 | 13 | export function renderIonicTemplate( 14 | stream: HTMLStream, 15 | options: IonicOptions, 16 | ): void { 17 | stream.write(/* html */ `<!doctype html> 18 | <html lang="en"> 19 | <head> 20 | <meta charset="UTF-8" /> 21 | <meta http-equiv="X-UA-Compatible" content="IE=edge" /> 22 | <meta 23 | name="viewport" 24 | content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=6.0" 25 | /> 26 | <title>`) 27 | typeof options.title == 'function' ? options.title(stream) : stream.write(options.title) 28 | stream.write(/* html */ ` 29 | 32 | 33 | 34 | 45 | 76 | 77 | 82 | 86 | 87 | 88 | 96 | `) 97 | typeof options.app == 'function' ? options.app(stream) : stream.write(options.app) 98 | stream.write(/* html */ ` 99 | 100 | 101 | `) 102 | } 103 | -------------------------------------------------------------------------------- /client/confetti.ts: -------------------------------------------------------------------------------- 1 | import confetti, { Options } from 'canvas-confetti' 2 | 3 | declare module 'canvas-confetti' { 4 | export interface Options { 5 | flat?: boolean 6 | } 7 | } 8 | 9 | export let confettiConfig = { 10 | rate: 1, 11 | } 12 | 13 | export function fireConfetti(count = 200) { 14 | let defaults: Options = { 15 | origin: { y: 0.9 }, 16 | } 17 | function fire(particleRatio: number, opts: Options) { 18 | confetti({ 19 | ...defaults, 20 | ...opts, 21 | particleCount: Math.floor(count * particleRatio * confettiConfig.rate), 22 | }) 23 | } 24 | fire(0.25, { 25 | spread: 26, 26 | startVelocity: 55, 27 | }) 28 | fire(0.2, { 29 | spread: 60, 30 | }) 31 | fire(0.35, { 32 | spread: 100, 33 | decay: 0.91, 34 | scalar: 0.8, 35 | }) 36 | fire(0.1, { 37 | spread: 120, 38 | startVelocity: 25, 39 | decay: 0.92, 40 | scalar: 1.2, 41 | }) 42 | fire(0.1, { 43 | spread: 120, 44 | startVelocity: 45, 45 | }) 46 | } 47 | 48 | export function fireStar() { 49 | var defaults = { 50 | spread: 360, 51 | ticks: 150, 52 | gravity: 0, 53 | decay: 0.94, 54 | startVelocity: 15, 55 | colors: ['FFE400', 'FFBD00', 'E89400', 'FFCA6C', 'FDFFB8'], 56 | } 57 | 58 | function shoot() { 59 | confetti({ 60 | ...defaults, 61 | particleCount: 60 * confettiConfig.rate, 62 | scalar: 1.2, 63 | shapes: ['star'], 64 | }) 65 | 66 | confetti({ 67 | ...defaults, 68 | particleCount: 20 * confettiConfig.rate, 69 | scalar: 0.75, 70 | shapes: ['circle'], 71 | }) 72 | } 73 | 74 | setTimeout(shoot, 0) 75 | setTimeout(shoot, 150) 76 | setTimeout(shoot, 300) 77 | } 78 | 79 | function splitEmoji(emoji: string) { 80 | let chars: string[] = [] 81 | for (let char of emoji) { 82 | chars.push(char) 83 | } 84 | return chars 85 | } 86 | 87 | export function fireEmoji(emoji: string = '🦄🤩🪅') { 88 | var scalar = 2 89 | var emojiShapes = splitEmoji(emoji).map(text => 90 | confetti.shapeFromText({ text, scalar }), 91 | ) 92 | 93 | var defaults = { 94 | spread: 360, 95 | ticks: 300, 96 | gravity: 0, 97 | decay: 0.9, 98 | startVelocity: 15, 99 | scalar, 100 | } 101 | 102 | function shoot() { 103 | function show(shapes: Options['shapes']) { 104 | confetti({ 105 | ...defaults, 106 | shapes, 107 | particleCount: Math.ceil(30 / emojiShapes.length) * confettiConfig.rate, 108 | }) 109 | 110 | confetti({ 111 | ...defaults, 112 | shapes, 113 | particleCount: 2 * confettiConfig.rate, 114 | flat: true, 115 | }) 116 | } 117 | for (let emojiShape of emojiShapes) { 118 | show([emojiShape]) 119 | } 120 | 121 | confetti({ 122 | ...defaults, 123 | particleCount: 15 * confettiConfig.rate, 124 | scalar: scalar / 2, 125 | shapes: ['circle'], 126 | }) 127 | } 128 | 129 | setTimeout(shoot, 0) 130 | setTimeout(shoot, 100) 131 | setTimeout(shoot, 200) 132 | } 133 | 134 | Object.assign(window, { 135 | fireConfetti, 136 | fireStar, 137 | fireEmoji, 138 | }) 139 | -------------------------------------------------------------------------------- /server/app/components/ui-language.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Context, 3 | DynamicContext, 4 | getContextLanguage, 5 | getContextSearchParams, 6 | setCookieLang, 7 | } from '../context.js' 8 | import { o } from '../jsx/jsx.js' 9 | import { ResolvedPageRoute, Routes } from '../routes.js' 10 | import { Raw } from './raw.js' 11 | import { apiEndpointTitle } from '../../config.js' 12 | import { toRouteUrl } from '../../url.js' 13 | import { Redirect } from './router.js' 14 | import { Locale } from './locale.js' 15 | import { mapArray } from './fragment.js' 16 | import { YEAR } from '@beenotung/tslib/time.js' 17 | import { MessageException } from '../../exception.js' 18 | 19 | export let language_max_age = (20 * YEAR) / 1000 20 | 21 | export function PickLanguage( 22 | attrs: { 23 | style?: string 24 | /** default is horizontal */ 25 | direction?: 'vertical' | 'horizontal' 26 | }, 27 | context: Context, 28 | ) { 29 | let lang = getContextLanguage(context) 30 | let return_url = context.type == 'static' ? '' : context.url 31 | let style = attrs.style || '' 32 | if (attrs.direction === 'vertical') { 33 | style += '; display: flex; flex-direction: column; gap: 0.5rem;' 34 | } 35 | return ( 36 |
37 | 🌏 :{' '} 38 | {mapArray( 39 | [ 40 | ['en', 'English'], 41 | ['zh-HK', '繁體中文'], 42 | ['zh-CN', '简体中文'], 43 | ], 44 | ([lang, text]) => ( 45 | 52 | 53 | 54 | ), 55 | attrs.direction === 'vertical' ? null : ' | ', 56 | )} 57 | {Raw(/* html */ ` 58 | 65 | `)} 66 |
67 | ) 68 | } 69 | 70 | function submit(context: DynamicContext): ResolvedPageRoute { 71 | let lang = context.routerMatch?.params.lang 72 | setCookieLang(context, lang, { 73 | sameSite: 'lax', 74 | path: '/', 75 | maxAge: language_max_age, 76 | }) 77 | let return_url = 78 | (context.type === 'ws' && (context.args?.[0] as string)) || 79 | getContextSearchParams(context)?.get('return_url') 80 | 81 | if (context.type === 'ws' && return_url) { 82 | throw new MessageException([ 83 | 'batch', 84 | [ 85 | ['add-class', 'body', 'no-animation'], 86 | ['redirect', return_url], 87 | ], 88 | ]) 89 | } 90 | 91 | return { 92 | title: apiEndpointTitle, 93 | description: 'set the locale language', 94 | node: return_url ? ( 95 | 96 | ) : ( 97 |

Updated language preference.

98 | ), 99 | } 100 | } 101 | 102 | let routes = { 103 | '/set-lang/:lang': { 104 | streaming: false, 105 | resolve: submit, 106 | }, 107 | } satisfies Routes 108 | 109 | export default { routes } 110 | -------------------------------------------------------------------------------- /server/app/pages/demo-typescript-page.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import { Routes } from '../routes.js' 3 | import Style from '../components/style.js' 4 | import { Locale, Title } from '../components/locale.js' 5 | import { loadClientPlugin } from '../../client-plugin.js' 6 | import { sweetAlertPlugin } from '../../client-plugins.js' 7 | import SourceCode from '../components/source-code.js' 8 | 9 | // for complete page interaction 10 | let pagePlugin = loadClientPlugin({ 11 | entryFile: 'dist/client/demo-typescript-page.js', 12 | }) 13 | 14 | let pageTitle = 15 | 16 | let style = Style(/* css */ ` 17 | #DemoTypescriptPage .controls { 18 | display: flex; 19 | gap: 0.5rem; 20 | margin: 0.5rem; 21 | } 22 | #DemoTypescriptPage .controls button { 23 | padding: 0.25rem 0.5rem; 24 | } 25 | #DemoTypescriptPage #container { 26 | position: relative; 27 | width: fit-content; 28 | margin: auto; 29 | display: flex; 30 | flex-wrap: wrap; 31 | align-items: start; 32 | gap: 0.5rem; 33 | } 34 | #DemoTypescriptPage video { 35 | transform: scaleX(-1); 36 | width: 300px; 37 | max-width: 100%; 38 | max-height: calc(100dvh - 2rem); 39 | } 40 | #DemoTypescriptPage canvas { 41 | transform: scaleX(-1); 42 | position: absolute; 43 | top: 0; 44 | left: 0; 45 | } 46 | 47 | #faceBlendshapesContainer { 48 | overflow-y: auto; 49 | overflow-x: visible; 50 | padding: 0 1rem; 51 | } 52 | 53 | #faceBlendshapesContainer h2 { 54 | margin-top: 0; 55 | } 56 | 57 | #faceBlendshapesContainer .score-bar { 58 | background-color: lightcoral; 59 | height: 0.25rem; 60 | } 61 | `) 62 | 63 | let page = ( 64 | <> 65 | {style} 66 |
67 |

{pageTitle}

68 |
69 | 72 | 75 | 78 |
79 |
80 | 81 | 82 |
83 |

Face Blendshapes

84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 |
IndexCategory NameScore
94 |
95 |
96 | 97 | 101 |
102 | {sweetAlertPlugin.node} 103 | {pagePlugin.node} 104 | 105 | ) 106 | 107 | let routes = { 108 | '/demo-typescript-page': { 109 | menuText: pageTitle, 110 | title: , 111 | description: 'TODO', 112 | node: page, 113 | }, 114 | } satisfies Routes 115 | 116 | export default { routes } 117 | -------------------------------------------------------------------------------- /server/app/components/router.tsx: -------------------------------------------------------------------------------- 1 | import { o } from '../jsx/jsx.js' 2 | import { castDynamicContext, Context } from '../context.js' 3 | import type { Node, NodeList } from '../jsx/types' 4 | import { Router as UrlRouter } from 'url-router.ts' 5 | import { EarlyTerminate } from '../../exception.js' 6 | import { setSessionUrl } from '../session.js' 7 | import { evalAttrsLocale } from './locale.js' 8 | 9 | export type LinkAttrs = { 10 | 'tagName'?: string 11 | 'no-history'?: boolean 12 | 'no-animation'?: boolean 13 | 'is-back'?: boolean 14 | 'href': string 15 | 'onclick'?: never 16 | [name: string]: unknown 17 | 'children'?: NodeList 18 | 'hidden'?: boolean | undefined 19 | 'rel'?: 'nofollow' 20 | } 21 | 22 | export function Link(attrs: LinkAttrs, context: Context) { 23 | evalAttrsLocale(attrs, 'title', context) 24 | const { 25 | 'tagName': _tagName, 26 | 'no-history': quiet, 27 | 'no-animation': fast, 28 | 'is-back': back, 29 | children, 30 | hidden, 31 | ...aAttrs 32 | } = attrs 33 | const tagName = _tagName || 'a' 34 | let flag = '' 35 | if (quiet) flag += 'q' 36 | if (fast) flag += 'f' 37 | if (back) flag += 'b' 38 | const onclick = flag ? `emitHref(event,'${flag}')` : `emitHref(event)` 39 | if (!children && tagName == 'a') { 40 | console.warn('Link attrs:', attrs) 41 | console.warn(new Error('Link with empty content')) 42 | } 43 | return [ 44 | tagName, 45 | { onclick, hidden: hidden ? '' : undefined, ...aAttrs }, 46 | children, 47 | ] 48 | } 49 | 50 | export function Redirect( 51 | attrs: { href: string; full?: boolean; status?: number }, 52 | context: Context, 53 | ) { 54 | const href = attrs.href 55 | if (context.type === 'express') { 56 | const res = context.res 57 | if (res.headersSent) { 58 | res.end(renderRedirect(href)) 59 | } else { 60 | const status = attrs.status || 303 61 | res.redirect(status, href) 62 | } 63 | throw EarlyTerminate 64 | } 65 | if (context.type === 'ws') { 66 | setSessionUrl(context.ws, attrs.href) 67 | context.ws.send( 68 | attrs.full ? ['redirect', attrs.href, 1] : ['redirect', attrs.href], 69 | ) 70 | throw EarlyTerminate 71 | } 72 | return ( 73 | <a href={href} data-live="redirect" data-full={attrs.full || undefined}> 74 | Redirect to {href} 75 | </a> 76 | ) 77 | } 78 | 79 | export function renderRedirect(href: string): string { 80 | return /* html */ ` 81 | <p>Redirect to <a href="${href}">${href}</a></p> 82 | <script> 83 | location.href = "${href}" 84 | </script> 85 | ` 86 | } 87 | 88 | export function Switch(routes: SwitchRoutes, defaultNode?: Node): Node { 89 | const router = new UrlRouter<Node>() 90 | Object.entries(routes).forEach(([url, node]) => { 91 | router.add(url, node) 92 | }) 93 | return <Router router={router} defaultNode={defaultNode} /> 94 | } 95 | 96 | export function Router( 97 | attrs: { 98 | router: UrlRouter<Node> 99 | defaultNode?: Node 100 | }, 101 | _context: Context, 102 | ): Node { 103 | const context = castDynamicContext(_context) 104 | const match = attrs.router.route(context.url) 105 | if (!match) return attrs.defaultNode 106 | context.routerMatch = match 107 | return match.value 108 | } 109 | 110 | export type SwitchRoutes = { 111 | [url: string]: Node 112 | } 113 | -------------------------------------------------------------------------------- /server/app/components/pagination.tsx: -------------------------------------------------------------------------------- 1 | import { DynamicContext } from '../context.js' 2 | import { o } from '../jsx/jsx.js' 3 | import { mapArray } from './fragment.js' 4 | import { Link } from './router.js' 5 | import Style from './style.js' 6 | 7 | export let PaginationStyle = Style(/* css */ ` 8 | .pagination { 9 | display: inline-flex; 10 | gap: 0.25em; 11 | flex-wrap: wrap; 12 | align-items: center; 13 | font-size: 1rem; 14 | } 15 | .pagination--link { 16 | display: inline-block; 17 | text-decoration: none; 18 | padding: 0.25rem 0.5rem; 19 | border: 1px solid #ccc; 20 | border-radius: 0.25rem; 21 | } 22 | .pagination--link.current { 23 | font-weight: bold; 24 | } 25 | .pagination--ellipsis { 26 | display: inline-block; 27 | padding: 0.25rem 0.25rem; 28 | } 29 | `) 30 | 31 | let ellipsis = <span class="pagination--ellipsis">...</span> 32 | 33 | export function Pagination( 34 | attrs: { 35 | 'style'?: string 36 | 'class'?: string 37 | 'base-url'?: string 38 | } & ( 39 | | { 40 | 'current-page': number 41 | 'max-page': number 42 | } 43 | | { 44 | offset: number 45 | limit: number 46 | total: number 47 | } 48 | ), 49 | context: DynamicContext, 50 | ) { 51 | let base_url = attrs['base-url'] ?? context.url 52 | let parts = base_url.split('?') 53 | let pathname = parts[0] 54 | let params = new URLSearchParams(parts[1]) 55 | 56 | let min_page = 1 57 | let max_page = 58 | 'max-page' in attrs 59 | ? attrs['max-page'] 60 | : Math.ceil(attrs['total'] / attrs['limit']) 61 | let current = 62 | 'current-page' in attrs 63 | ? attrs['current-page'] 64 | : Math.floor(attrs.offset / attrs['limit']) + 1 65 | 66 | let className = 'pagination' 67 | if (attrs.class) { 68 | className += ' ' + attrs.class 69 | } 70 | 71 | let pages: number[] = [] 72 | 73 | // first page 74 | pages.push(min_page) 75 | 76 | // current page and surrounding pages 77 | let wrap = 2 78 | let from = Math.max(min_page + 1, current - wrap) 79 | let to = Math.min(current + wrap, max_page) 80 | 81 | // surrounding pages 82 | for (let page = from; page <= to; page++) { 83 | if (page > pages[pages.length - 1]) { 84 | if (page != pages[pages.length - 1] + 1) { 85 | pages.push(ellipsis) 86 | } 87 | pages.push(page) 88 | } 89 | } 90 | 91 | // last page 92 | if (max_page > pages[pages.length - 1]) { 93 | if (max_page != pages[pages.length - 1] + 1) { 94 | pages.push(ellipsis) 95 | } 96 | 97 | pages.push(max_page) 98 | } 99 | 100 | return ( 101 | <> 102 | {PaginationStyle} 103 | <div class={className} style={attrs.style}> 104 | {mapArray(pages, (page, index, pages) => { 105 | if (typeof page !== 'number') { 106 | return page 107 | } 108 | params.set('page', page.toString()) 109 | 110 | let className = 'pagination--link' 111 | if (page == current) { 112 | className += ' current' 113 | } 114 | return ( 115 | <Link class={className} href={`${pathname}?${params}`}> 116 | {page} 117 | </Link> 118 | ) 119 | })} 120 | </div> 121 | </> 122 | ) 123 | } 124 | 125 | export default Pagination 126 | -------------------------------------------------------------------------------- /server/app/components/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { Context, getContextUrl } from '../context.js' 2 | import { flagsToClassName } from '../jsx/html.js' 3 | import { o } from '../jsx/jsx.js' 4 | import { Node } from '../jsx/types.js' 5 | import { mapArray } from './fragment.js' 6 | import { MenuRoute, isCurrentMenuRoute } from './menu.js' 7 | import Style from './style.js' 8 | import { menuIcon } from '../icons/menu.js' 9 | import { PickLanguage } from './ui-language.js' 10 | 11 | let style = Style(/* css */ ` 12 | .navbar { 13 | display: flex; 14 | justify-content: space-between; 15 | align-items: center; 16 | } 17 | .navbar .navbar-brand {} 18 | .navbar .navbar-menu { 19 | display: flex; 20 | flex-wrap: wrap; 21 | align-items: center; 22 | } 23 | .navbar .navbar-menu-toggle { 24 | display: flex; 25 | justify-content: center; 26 | align-items: center; 27 | } 28 | .navbar .navbar-menu-toggle .icon { 29 | width: 2rem; 30 | height: 2rem; 31 | } 32 | .navbar [name=navbar-menu-toggle] { 33 | display: none; 34 | } 35 | .navbar .navbar-menu-toggle { 36 | display: none; 37 | } 38 | .navbar .navbar-menu-item { 39 | border-bottom: 1px solid black; 40 | text-decoration: none; 41 | margin: 0.5rem; 42 | padding-bottom: 0.25rem; 43 | display: inline-block; 44 | } 45 | .navbar .navbar-menu-item.selected { 46 | border-bottom: 2px solid black; 47 | margin-bottom: calc(0.5rem - 1px) 48 | } 49 | @media (max-width: 768px) { 50 | .navbar .navbar-menu-toggle { 51 | display: initial; 52 | } 53 | .navbar .navbar-menu { 54 | display: none; 55 | position: fixed; 56 | background-color: #eee; 57 | inset: 0; 58 | margin-top: 3.5rem; 59 | overflow: auto; 60 | overscroll-behavior: contain; 61 | /* avoid overlap by the ws_status */ 62 | padding-bottom: 2.5rem; 63 | } 64 | .navbar [name=navbar-menu-toggle]:checked ~ .navbar-menu { 65 | display: initial; 66 | z-index: 2; 67 | } 68 | .navbar .navbar-menu-item { 69 | border-bottom: none; 70 | display: block; 71 | margin-inline-start: 1rem; 72 | margin-block-start: 1rem; 73 | font-size: 1.25rem; 74 | width: fit-content; 75 | } 76 | } 77 | `) 78 | 79 | function Navbar( 80 | attrs: { brand: Node; menuRoutes: MenuRoute[]; toggleId?: string }, 81 | context: Context, 82 | ) { 83 | let currentUrl = getContextUrl(context) 84 | let toggleId = attrs.toggleId || 'navbar-menu-toggle' 85 | return ( 86 | <nav class="navbar"> 87 | {style} 88 | <div class="navbar-brand">{attrs.brand}</div> 89 | <label 90 | class="navbar-menu-toggle" 91 | for={toggleId} 92 | aria-label="toggle navigation menu" 93 | > 94 | {menuIcon} 95 | </label> 96 | <input name="navbar-menu-toggle" type="checkbox" id={toggleId} /> 97 | <div class="navbar-menu"> 98 | {mapArray(attrs.menuRoutes, route => ( 99 | <a 100 | class={flagsToClassName({ 101 | 'navbar-menu-item': true, 102 | 'selected': isCurrentMenuRoute(currentUrl, route), 103 | })} 104 | href={route.menuUrl || route.url} 105 | onclick={route.menuFullNavigate ? undefined : 'emitHref(event)'} 106 | > 107 | {route.menuText} 108 | </a> 109 | ))} 110 | <PickLanguage style="text-align: end; margin-inline: 1rem; flex-grow: 1" /> 111 | </div> 112 | </nav> 113 | ) 114 | } 115 | 116 | export default Navbar 117 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v5](https://github.com/beenotung/ts-liveview/tree/v5) 4 | 5 | 2022 - Present 6 | 7 | - Enhanced support on html streaming to both static parts in template file and dynamic parts in typescript 8 | 9 | - The template file is pre-generated with response streaming enabled 10 | 11 | The signature of generated template function is now: `(stream, options) => void`, and the options is an object of string or "html chunk sink" (a.k.a. stream consumer function) 12 | 13 | - Moved db/docs/erd.txt to db/erd.txt for easier access 14 | - Update quick-erd with diagram position inlined 15 | 16 | - Speedup dev mode restart update source file changes with esbuild (instead of tsc) 17 | 18 | Previous version was using tsc and nodemon in dev mode, which takes more time to load. Current version is using go-built esbuild without type checking, hence it load much faster. 19 | 20 | You can still get type hints from the IDE or by running `npm run type-check`, which will run tsc in watch mode without saving the built files. 21 | 22 | - Flatten env variables from `config.ts` into `env.ts` 23 | 24 | ## [v4](https://github.com/beenotung/ts-liveview/tree/v4) 25 | 26 | 2022 27 | 28 | - Changed signature of component function 29 | 30 | - Move `children?: NodeList` from 2nd argument into optional property of attrs (1st argument) 31 | - Pass context to component function explicitly as 2nd argument 32 | 33 | The signature of component function is now: `(attrs, context) => Node` 34 | 35 | - Make transpiled jsx expression more compact 36 | 37 | Now using `o` as jsxFactory and `null` as jsxFragmentFactory. 38 | Previous version use of `JSX.createElement` and `JSX.Fragment` responsively 39 | 40 | - Support async routing 41 | 42 | This enables responding contentful payload for pages that require async resources. 43 | This update may results in better SEO. 44 | 45 | Previous version requires each route to return directly, which force the application to display a loading screen if the resource is not ready. 46 | Now the application developer can choose to delay the response or to show a loading screen immediately. 47 | 48 | ## [v3](https://github.com/beenotung/ts-liveview/tree/v3) 49 | 50 | 2022 51 | 52 | - Setup eslint 53 | - Harden type definition and usage on Context (e.g. avoid non-null assertions and explicit-any) 54 | - Move 3rd party libraries from `/public` to `/public/lib` 55 | - Move ServerMessage and ClientMessage from `/client/index.ts` to `/client/types.ts` 56 | - Remove generic types extending to ServerMessage and ClientMessage, refer to the exact type instead 57 | 58 | ## [v2](https://github.com/beenotung/ts-liveview/tree/v2) 59 | 60 | 2021 - 2022 61 | 62 | - Removed dependency on s-js, morphdom, and primus 63 | - Switched content format from template-string to JSX/AST with out-of-the-box html-injection protection 64 | - Switched DOM update approach from generic template-string static-dynamic-diffing based patching to application-specific (direct) dom updates 65 | - Support html streaming for initial GET request 66 | - Support setting document title via websocket events 67 | 68 | ## [v1](https://github.com/beenotung/ts-liveview/tree/v1) 69 | 70 | 2020 - 2021 71 | 72 | - Made API more concise (especially for usage with s-js) 73 | - Support repeated components (e.g. map on array with shared template) 74 | 75 | ## [v0](https://github.com/beenotung/ts-liveview/tree/v0) 76 | 77 | 2020 78 | 79 | - Support pre-rendering 80 | - Support live-update with diff-based patching with morphdom and primus 81 | --------------------------------------------------------------------------------