intial server setup

This commit is contained in:
Linus Miller 2025-11-23 23:30:02 +01:00
parent 033262103f
commit e7f70b9295
59 changed files with 3224 additions and 31 deletions

View File

@ -1,5 +1,16 @@
PGHOST=localhost
NODE_ENV=development
DOMAIN=bitmill.io
PROTOCOL=https
HOSTNAME=brf.local
PORT=null
FASTIFY_HOST=0.0.0.0
FASTIFY_PORT=1337
LOG_LEVEL=debug
LOG_STREAM=console
PGHOST=postgres
PGPORT=5432
PGDATABASE=brf_books
PGUSER=brf_books
PGPASSWORD=brf_books
REDIS_HOST=redis
VITE_HMR_PROXY=true

View File

@ -1,27 +1,24 @@
FROM node:23-alpine AS base
FROM node:25-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable pnpm
ARG UID=1000
ARG GID=1000
RUN apk add --no-cache git shadow
RUN apk add --no-cache shadow
RUN npm install -g pnpm@^10.23.0
RUN groupmod -g $GID node
# this does not seem to be having full effect. eg /home/node gets 1337:1000 ownership despite group node having id 1337
RUN usermod -u $UID -g node node
USER node
RUN mkdir /home/node/startbit
RUN git config --global --add safe.directory /home/node/startbit
WORKDIR /home/node/startbit
RUN mkdir /home/node/brf_books
WORKDIR /home/node/brf_books
COPY --chown=node pnpm-lock.yaml package.json ./
COPY --chown=node pnpm-lock.yaml package.json .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
COPY --chown=node \
.env.secrets \
vite.config.js ./
COPY --chown=node vite.config.js .
# -------- development -------- #
FROM base AS development
@ -42,4 +39,4 @@ RUN pnpm run build
COPY --chown=node .git .git
CMD pnpm run start server/index.ts
CMD pnpm run start

6
client/client.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare module '*.module.scss' {
const classNames: Record<string, string>
export default classNames
}
declare module '*.scss'

8
client/public/client.ts Normal file
View File

@ -0,0 +1,8 @@
import './styles/main.scss'
import { h, hydrate } from 'preact'
import App from './components/app.tsx'
const STATE = typeof __STATE__ !== 'undefined' ? __STATE__ : undefined
hydrate(h(App, STATE), document.body)

View File

@ -0,0 +1,3 @@
.title {
font-size: 120px;
}

View File

@ -0,0 +1,36 @@
import { h } from 'preact'
import { Router } from 'preact-router'
import Head from './head.ts'
import Footer from './footer.tsx'
import Header from './header.tsx'
import ErrorPage from './error_page.tsx'
import routes from '../routes.ts'
import s from './app.module.scss'
export default function App({ error, url, title }) {
return (
<div id='app' className={s.base}>
<Head>
<title>{title || 'Untitled'}</title>
</Head>
<Header routes={routes} />
<main className={s.main}>
{error ? (
<ErrorPage error={error} />
) : (
<Router url={url}>
{routes.map((route) => (
// @ts-ignore
<route.component key={route.path} path={route.path} route={route} />
))}
</Router>
)}
</main>
<Footer />
</div>
)
}

View File

@ -0,0 +1,15 @@
import { h } from 'preact'
import Head from './head.ts'
const OtherPage = ({ error }) => (
<section>
<Head>
<title> : Error</title>
</Head>
<h1>Oh no!</h1>
<pre>{JSON.stringify(error, ['name', ...Object.getOwnPropertyNames(error)], ' ')}</pre>
</section>
)
export default OtherPage

View File

@ -0,0 +1,5 @@
import { h } from 'preact'
const Footer = () => <footer />
export default Footer

View File

@ -0,0 +1,172 @@
import { toChildArray, Component } from 'preact'
import { mapKeys } from 'lowline'
const CLASSNAME = '__preact_generated__'
const DOMAttributeNames = {
acceptCharset: 'accept-charset',
className: 'class',
htmlFor: 'for',
httpEquiv: 'http-equiv',
}
const isBrowser = typeof window !== 'undefined'
let mounted = []
function reducer(components) {
return components
.map((c) => toChildArray(c.props.children))
.reduce((result, c) => result.concat(c), [])
.reverse()
.filter(unique())
.reverse()
.reduce(
(result, c) => {
if (c.type === 'title') {
result.title += toChildArray(c.props.children).join('')
} else {
result.tags.push({
type: c.type,
attributes: mapKeys(c.props, (_value, key) => DOMAttributeNames[key] || key),
})
}
return result
},
{ title: '', tags: [] },
)
}
function updateClient({ title, tags }) {
const head = document.head
const prevElements = Array.from(head.getElementsByClassName(CLASSNAME))
for (const tag of tags) {
const el = createDOMElement(tag)
const prevIndex = prevElements.findIndex((prevEl) => prevEl.isEqualNode(el))
if (~prevIndex) {
prevElements.splice(prevIndex, 1)
} else {
head.appendChild(el)
}
}
prevElements.forEach((prevEl) => prevEl.remove())
document.title = title
}
function createDOMElement(tag) {
const el = document.createElement(tag.type)
const attributes = tag.attributes || {}
el.setAttribute('class', CLASSNAME)
for (const p in attributes || {}) {
const attribute = DOMAttributeNames[p] || p.toLowerCase()
el.setAttribute(attribute, attributes[p])
}
return el
}
const METATYPES = ['name', 'httpEquiv', 'charSet', 'itemProp']
// returns a function for filtering head child elements
// which shouldn't be duplicated, like <title/>.
function unique() {
const tags = []
const metaTypes = []
const metaCategories = {}
return (h) => {
switch (h.type) {
case 'base':
if (~tags.indexOf(h.type)) {
return false
}
tags.push(h.type)
break
case 'meta':
for (let i = 0, len = METATYPES.length; i < len; i++) {
const metatype = METATYPES[i]
if (!(metatype in h)) {
continue
}
if (metatype === 'charSet') {
if (~metaTypes.indexOf(metatype)) {
return false
}
metaTypes.push(metatype)
} else {
const category = h.props[metatype]
const categories = metaCategories[metatype] || []
if (~categories.indexOf(category)) {
return false
}
categories.push(category)
metaCategories[metatype] = categories
}
}
break
}
return true
}
}
function update() {
if (isBrowser) updateClient(reducer(mounted))
}
export default class Head extends Component {
static rewind() {
const state = reducer(mounted)
mounted = []
return state
}
static clear() {
mounted = []
}
componentDidUpdate() {
update()
}
// eslint-disable-next-line react/no-deprecated
componentWillMount() {
mounted.push(this)
update()
}
componentWillUnmount() {
const i = mounted.indexOf(this)
if (~i) {
mounted.splice(i, 1)
}
update()
}
render() {
return null
}
}

View File

@ -0,0 +1,18 @@
import { h } from 'preact'
const Header = ({ routes }) => (
<header>
<h1>BRF Tegeltrasten</h1>
<nav>
<ul>
{routes.map((route) => (
<li key={route.path}>
<a href={route.path}>{route.title}</a>
</li>
))}
</ul>
</nav>
</header>
)
export default Header

View File

@ -0,0 +1,5 @@
import { h } from 'preact'
export default function NotFoundPage() {
return <h1>Not Found</h1>
}

View File

@ -0,0 +1,15 @@
import { h } from 'preact'
import Head from './head.ts'
const OtherPage = () => (
<section>
<Head>
<title> : Other</title>
</Head>
<h1>Other Page</h1>
<p>Not the page where it begins</p>
</section>
)
export default OtherPage

View File

@ -0,0 +1,11 @@
import { h } from 'preact'
const StartPage = () => (
<section>
<h1>Fart Page</h1>
<p>Haha oh my, fart page.</p>
</section>
)
export default StartPage

17
client/public/routes.ts Normal file
View File

@ -0,0 +1,17 @@
import Start from './components/start_page.tsx'
import Other from './components/other_page.tsx'
export default [
{
path: '/',
name: 'start',
title: 'Start',
component: Start,
},
{
path: '/other',
name: 'other',
title: 'Other',
component: Other,
},
]

22
client/public/server.ts Normal file
View File

@ -0,0 +1,22 @@
// import { h } from 'preact'
// import preactRender from 'preact-render-to-string'
// import preactRenderJsx from 'preact-render-to-string/jsx'
// import App from './components/app.tsx'
// import Head from './components/head.ts'
// export async function render(ctx, pretty) {
// const vdom = h(App, ctx)
// const content = pretty ? preactRenderJsx(vdom, {}, { pretty }) : preactRender(vdom)
// const head = Head.rewind()
// return {
// head,
// content,
// state: ctx,
// }
// }
export { default as routes } from './routes.ts'

View File

@ -0,0 +1,62 @@
import { h, type FunctionComponent, type PointerEventHandler } from 'preact'
import cn from 'classnames'
export default function buttonFactory({ defaults, icons, styles }) {
const Button: FunctionComponent<{
autoHeight?: boolean
className?: string
color?: string
design?: string
disabled?: boolean
fullWidth?: boolean
icon?: string
iconSize?: string
invert?: boolean
onClick?: PointerEventHandler<HTMLButtonElement>
size?: string
tabIndex?: number
tag?: string
title?: string
type?: 'button' | 'reset' | 'submit'
}> = ({
autoHeight = defaults?.autoHeight,
children,
className,
color = defaults?.color,
design = defaults?.design,
disabled,
fullWidth = defaults?.fullWidth,
icon = defaults?.icon,
iconSize,
invert = defaults?.invert,
onClick,
size = defaults?.size,
tabIndex,
title,
type,
}) => (
<button
className={cn(
styles.base,
design && styles[design],
size && styles[size],
color && styles[color],
autoHeight && styles.autoHeight,
fullWidth && styles.fullWidth,
invert && styles.invert,
className,
)}
style={iconSize && { '--icon-size': iconSize }}
disabled={disabled}
onClick={onClick}
tabIndex={tabIndex}
title={title}
type={type}
>
{icon && <i className={styles.icon} style={{ maskImage: `url(${icons[icon] || icon})` }} />}
{children && <span>{children}</span>}
</button>
)
return Button
}

View File

@ -0,0 +1,77 @@
import { h } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import cn from 'classnames'
/**
* @param {{
* base?: string
* touched?: string
* element?: string
* label?: string
* }} styles
* @returns {import('preact').FunctionComponent<{
* autoFocus?: boolean
* className?: string
* defaultChecked?: boolean
* disabled?: boolean
* name: string
* label?: string
* required?: boolean
* }>}
*/
export default function checkboxFactory(styles) {
const Checkbox = ({
autoFocus,
className,
defaultChecked,
disabled,
name,
label,
onChange,
required,
style = 'normal',
value = 'true',
}) => {
const [touched, setTouched] = useState(false)
const checkboxRef = useRef<HTMLInputElement>()
const onBlur = useCallback(() => setTouched(true), [])
useEffect(() => {
const form = checkboxRef.current.form
const resetTouched = setTouched.bind(null, false)
form.addEventListener('reset', resetTouched)
return () => form.removeEventListener('reset', resetTouched)
}, [])
useEffect(() => {
if (autoFocus) checkboxRef.current.focus()
}, [autoFocus])
return (
<label className={cn(styles.base, styles[style], touched && styles.touched, className)}>
<input
autoFocus={autoFocus}
className={styles.element}
defaultChecked={defaultChecked}
disabled={disabled}
name={name}
onBlur={onBlur}
onChange={onChange}
ref={checkboxRef}
required={required}
type='checkbox'
value={value}
/>
<i />
{label && <span className={styles.label}>{label}</span>}
</label>
)
}
return Checkbox
}

View File

@ -0,0 +1,70 @@
import { h } from 'preact'
import { useCallback, useRef, useState } from 'preact/hooks'
import cn from 'classnames'
// TODO when form resets after update the previous image flashes quickly after setImageSrc(null) and before new defaultValue propagates
const fileUploadFactory = ({ styles }) => {
const FileUpload = ({
autoFocus,
className,
defaultValue,
disabled,
label,
name,
noMargin,
placeholder,
required,
}) => {
const [touched, setTouched] = useState(false)
const inputRef = useRef(null)
const imgRef = useRef(null)
const [imageSrc, setImageSrc] = useState(null)
const onImageChange = useCallback((e) => {
e.preventDefault()
const form = inputRef.current.form
const onReset = () => {
setTouched(false)
setImageSrc(null)
}
form.addEventListener('reset', onReset)
imgRef.current.addEventListener('load', () => URL.revokeObjectURL(imgRef.current.src), { once: true })
setImageSrc(URL.createObjectURL(e.currentTarget.files[0]))
return () => form.removeEventListerner('reset', onReset)
}, [])
return (
<div className={cn(styles.base, noMargin && styles.noMargin, touched && styles.touched, className)}>
<input
type='file'
accept='image/*'
name={name}
ref={inputRef}
className={styles.element}
onChange={onImageChange}
autoFocus={autoFocus}
disabled={disabled}
placeholder={placeholder}
required={required}
defaultValue={defaultValue}
/>
<label htmlFor={name} className={styles.label}>
{label}
</label>
<figure className={styles.image}>
<img className={styles.image} src={imageSrc || (defaultValue && `/uploads/${defaultValue}`)} ref={imgRef} />
</figure>
</div>
)
}
return FileUpload
}
export default fileUploadFactory

View File

@ -0,0 +1,144 @@
import { h, type FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import cn from 'classnames'
import escapeRegex from '../utils/escape_regex.ts'
export default function inputFactory(styles: {
base?: string
touched?: string
element?: string
label?: string
statusIcon?: string
noMargin?: string
icon?: string
labelIcon?: string
center?: string
}): FunctionComponent<{
autoFocus?: boolean
autoComplete?: string
className?: string
defaultValue?: string
disabled?: boolean
name: string
noMargin?: boolean
label: string
pattern?: string
placeholder?: string
required?: boolean
type?: string
showValidity?: boolean
}> {
const Input = ({
autoComplete,
autoFocus,
center,
className,
classNames,
color,
defaultValue,
design,
disabled,
icon,
label,
name,
noMargin,
pattern,
placeholder,
required,
sameAs,
size = 'medium',
type = 'text',
value,
showValidity = true,
}) => {
const [touched, setTouched] = useState(false)
const inputRef = useRef<HTMLInputElement>()
const onBlur = useCallback(() => setTouched(true), [])
useEffect(() => {
function onInput(e) {
inputRef.current!.pattern = escapeRegex(e.target.value)
}
const form = inputRef.current!.form
const resetTouched = setTouched.bind(null, false)
form.addEventListener('reset', resetTouched)
let sameAsInput
if (sameAs) {
sameAsInput = form[sameAs]
sameAsInput.addEventListener('input', onInput)
}
return () => {
form.removeEventListener('reset', resetTouched)
if (sameAsInput) sameAsInput.removeEventListener('input', onInput)
}
}, [])
useEffect(() => {
if (autoFocus) inputRef.current.focus()
}, [autoFocus])
const id = styles.element + '_' + name
return (
<div
className={cn(
styles.base,
design && cn(styles[design], classNames?.[design]),
color && cn(styles[color], classNames?.[color]),
size && cn(styles[size], classNames?.[size]),
noMargin && cn(styles.noMargin, classNames?.noMargin),
touched && cn(styles.touched, classNames?.touched),
center && cn(styles.center, classNames?.center),
className,
)}
>
{icon && (styles.icon || classNames?.icon) && (
<div className={cn(styles.icon, classNames?.icon)} style={{ maskImage: icon }} />
)}
<input
autoComplete={autoComplete}
autoFocus={autoFocus}
className={cn(styles.element, classNames?.element)}
defaultValue={
defaultValue && type === 'datetime-local' ? new Date(defaultValue).toLocaleString('sv-SE') : defaultValue
}
disabled={disabled}
id={id}
name={name}
onBlur={onBlur}
pattern={pattern}
placeholder={placeholder ?? label}
ref={inputRef}
required={required}
type={type}
value={value}
/>
{showValidity && <i className={cn(styles.statusIcon, classNames?.statusIcon)} />}
{/**
* <label> comes after <input> to be able to use next sibling CSS selector
* (see public input.module.scss). Use `flexbox` and `order` if <label> needs to be
* displayed before <input> (see admin input.module.scss)
*/}
{label && (
<label className={cn(styles.label, classNames?.label)} htmlFor={id}>
{icon && (styles.labelIcon || classNames?.labelIcon) && (
<div className={cn(styles.labelIcon, classNames?.labelIcon)} style={{ maskImage: icon }} />
)}
{label}
</label>
)}
</div>
)
}
return Input
}

View File

@ -0,0 +1,55 @@
import { h, type FunctionComponent } from 'preact'
import cn from 'classnames'
export default function linkButtonFactory({ defaults, icons, styles }) {
const LinkButton: FunctionComponent<{
autoHeight?: boolean
className?: string
color?: string
design?: string
href?: string
fullWidth?: boolean
icon?: string
iconSize?: string
invert?: boolean
size?: string
tabIndex?: number
title?: string
}> = ({
autoHeight = defaults?.autoHeight,
children,
className,
color = defaults?.color,
design = defaults?.design,
href,
fullWidth = defaults?.fullWidth,
icon = defaults?.icon,
iconSize,
invert = defaults?.invert,
size = defaults?.size,
tabIndex,
title,
}) => (
<a
className={cn(
styles.base,
design && styles[design],
size && styles[size],
color && styles[color],
autoHeight && styles.autoHeight,
fullWidth && styles.fullWidth,
invert && styles.invert,
className,
)}
style={iconSize && { '--icon-size': iconSize }}
href={href}
tabIndex={tabIndex}
title={title}
>
{icon && <i className={styles.icon} style={{ maskImage: `url(${icons[icon] || icon})` }} />}
{children && <span>{children}</span>}
</a>
)
return LinkButton
}

View File

@ -0,0 +1,32 @@
import { describe, it, afterEach, type TestContext } from 'node:test'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/preact'
import messageFactory from './message_factory.tsx'
import { h } from 'preact'
const Message = messageFactory({})
describe('Message', () => {
afterEach(cleanup)
it('should display initial count', (t: TestContext) => {
render(<Message>We are great</Message>)
t.assert.ok(screen.getByText('We are great'))
})
it('should dismiss', async (t: TestContext) => {
const dismiss = t.mock.fn()
render(<Message dismiss={dismiss}>We are great</Message>)
t.assert.equal(dismiss.mock.callCount(), 0)
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
t.assert.equal(dismiss.mock.callCount(), 1)
})
})
})

View File

@ -0,0 +1,34 @@
import { h, type FunctionComponent } from 'preact'
import cn from 'classnames'
export default function messageFactory(styles: {
base?: string
text?: string
icon?: string
button?: string
noMargin?: string
normal?: string
error?: string
success?: string
}) {
const Message: FunctionComponent<{
className?: string
dismiss?: () => void
noMargin?: boolean
type?: 'normal' | 'error' | 'success'
}> = ({ children, className, dismiss, noMargin, type = 'normal' }) => (
<div className={cn(styles.base, styles[type], noMargin && styles.noMargin, className)}>
<i className={styles.icon} />
<div className={styles.text}>{children}</div>
{dismiss && (
<button className={styles.button} onClick={() => dismiss()}>
X
</button>
)}
</div>
)
return Message
}

View File

@ -0,0 +1,6 @@
import { createPortal } from 'preact/compat'
const Portal = ({ children, container = typeof document !== 'undefined' && document.body }) =>
createPortal(children, container)
export default Portal

View File

@ -0,0 +1,116 @@
import { h, type FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import cn from 'classnames'
import mergeStyles from '../utils/merge_styles.ts'
// interface Styles {
// base?: string
// touched?: string
// element?: string
// label?: string
// }
export default function selectFactory({ styles }): FunctionComponent<{
autoFocus?: boolean
className?: string
defaultValue?: string
disabled?: boolean
name: string
noEmpty?: boolean
label: string
placeholder?: string
required?: boolean
options: string[] | number[] | { label: string; value: string | number }
}> {
if (Array.isArray(styles)) {
styles = mergeStyles(styles)
}
const Select = ({
autoFocus,
className,
color,
defaultValue,
design = 'main',
disabled,
icon,
name,
label,
noEmpty,
onChange,
placeholder,
required,
size = 'medium',
options,
}) => {
const [touched, setTouched] = useState(false)
const selectRef = useRef<HTMLSelectElement>()
const onBlur = useCallback(() => setTouched(true), [])
useEffect(() => {
const form = selectRef.current!.form
const resetTouched = setTouched.bind(null, false)
form!.addEventListener('reset', resetTouched)
return () => form!.removeEventListener('reset', resetTouched)
}, [])
useEffect(() => {
if (autoFocus) selectRef.current!.focus()
}, [autoFocus])
return (
<div
className={cn(
styles.base,
design && styles[design],
color && styles[color],
size && styles[size],
touched && styles.touched,
className,
)}
>
{icon && <div className={cn(styles.icon)} style={{ maskImage: icon }} />}
<select
autoFocus={autoFocus}
className={styles.element}
disabled={disabled}
name={name}
onBlur={onBlur}
onChange={onChange}
ref={selectRef}
defaultValue={defaultValue}
required={required}
>
{!noEmpty && <option value=''>{placeholder || label}</option>}
{options &&
options.map((option) => (
<option
key={option.value != null ? option.value : option}
value={option.value != null ? option.value : option}
>
{option.label || option}
</option>
))}
</select>
{/**
* <label> comes after <input> to be able to use next sibling CSS selector
* (see public input.module.scss). Use `flexbox` and `order` if <label> needs to be
* displayed before <input> (see admin input.module.scss)
*/}
{label && (
<label className={styles.label} htmlFor={name}>
{label}
</label>
)}
</div>
)
}
return Select
}

View File

@ -0,0 +1,88 @@
import { h, type FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import cn from 'classnames'
import mergeStyles from '../utils/merge_styles.ts'
export default function textareaFactory(styles: {
base?: string
touched?: string
element?: string
label?: string
statusIcon?: string
}): FunctionComponent<{
autoFocus?: boolean
className?: string
cols?: number
defaultValue?: string
disabled?: boolean
name: string
label: string
placeholder?: string
required?: boolean
showValidity?: boolean
}> {
if (Array.isArray(styles)) {
styles = mergeStyles(styles)
}
const Textarea = ({
cols,
autoFocus,
className,
defaultValue,
disabled,
name,
label,
placeholder,
required,
rows,
showValidity = true,
}) => {
const [touched, setTouched] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>()
const onBlur = useCallback(() => setTouched(true), [])
useEffect(() => {
const form = textareaRef.current!.form
const resetTouched = setTouched.bind(null, false)
form.addEventListener('reset', resetTouched)
return () => form.removeEventListener('reset', resetTouched)
}, [])
return (
<div className={cn(styles.base, touched && styles.touched, className)}>
<textarea
autoFocus={autoFocus}
className={styles.element}
cols={cols}
disabled={disabled}
name={name}
onBlur={onBlur}
placeholder={placeholder ?? label}
ref={textareaRef}
defaultValue={defaultValue}
required={required}
rows={rows}
/>
{showValidity && <i className={styles.statusIcon} />}
{/**
* <label> comes after <textarea> to be able to use next sibling CSS selector
* (see public input.module.scss). Use `flexbox` and `order` if <label> needs to be
* displayed before <textarea> (see admin input.module.scss)
*/}
{label && (
<label className={styles.label} htmlFor={name}>
{label}
</label>
)}
</div>
)
}
return Textarea
}

View File

@ -0,0 +1,58 @@
import { useState } from 'preact/hooks'
import isLoading from '../utils/is_loading.ts'
const useRequestState = () => {
const initialState = {
error: null,
pending: false,
success: false,
response: null,
}
const [state, setState] = useState(initialState)
const actions = {
reset() {
isLoading(false)
setState(initialState)
},
error(error) {
isLoading(false)
setState({
pending: false,
success: false,
error,
response: null,
})
},
pending() {
isLoading(true)
setState({
pending: true,
success: false,
error: null,
response: null,
})
},
success(response = null) {
isLoading(false)
setState({
pending: false,
success: true,
error: null,
response,
})
},
}
return [state, actions]
}
export default useRequestState

View File

@ -0,0 +1,66 @@
// Need to be in pixels for IE8
$xsmall-min: 0px;
$xsmall-max: 375px;
$xsmall-range: $xsmall-min, $xsmall-max;
$small-min: 376px;
$small-max: 639px;
$small-range: $small-min, $small-max;
$medium-min: 640px;
$medium-max: 1023px;
$medium-range: $medium-min, $medium-max;
$large-min: 1024px;
$large-max: 1439px;
$large-range: $large-min, $large-max;
$xlarge-min: 1440px;
$xlarge-max: 1920px;
$xlarge-range: $xlarge-min, $xlarge-max;
$xxlarge-min: 1921px;
$xxlarge-max: 2650px;
$xxlarge-range: $xxlarge-min, $xxlarge-max;
$xxxlarge-min: 2651px;
$xxxlarge-max: 999999px;
$xxxlarge-range: $xxlarge-min, $xxlarge-max;
// TODO
// $content-max-width: 1440px;
// $global-gutter: 30px;
// $large-max: ($xlarge-min - 1px);
// $xlarge-min: ($content-max-width + $global-gutter);
//
// Media Queries
//
$screen: 'only screen';
$xsmall-only: '#{$screen} and (max-width: #{$xsmall-max})';
$small-only: '#{$screen} and (min-width: #{$small-min}) and (max-width: #{$small-max})';
$small-down: '#{$screen} and (max-width: #{$small-max})';
$small-up: '#{$screen} and (min-width: #{$small-min})';
$medium-only: '#{$screen} and (min-width: #{$medium-min}) and (max-width: #{$medium-max})';
$medium-up: '#{$screen} and (min-width: #{$medium-min})';
$medium-down: '#{$screen} and (max-width: #{$medium-max})';
$large-only: '#{$screen} and (min-width: #{$large-min}) and (max-width: #{$large-max})';
$large-up: '#{$screen} and (min-width: #{$large-min})';
$large-down: '#{$screen} and (max-width: #{$large-max})';
$xlarge-only: '#{$screen} and (min-width: #{$xlarge-min}) and (max-width: #{$xlarge-max})';
$xlarge-up: '#{$screen} and (min-width: #{$xlarge-min})';
$xlarge-down: '#{$screen} and (max-width: #{$xlarge-max})';
$xxlarge-only: '#{$screen} and (min-width: #{$xxlarge-min})';
$xxlarge-up: '#{$screen} and (min-width: #{$xxlarge-min})';
$xxlarge-down: '#{$screen} and (max-width: #{$xxlarge-max})';
$xxxlarge-up: '#{$screen} and (min-width: #{$xxxlarge-min})';
$landscape: '#{$screen} and (orientation: landscape)';
// $portrait: "#{$screen} and (orientation: portrait)";
// $small-only: "#{$screen} and (min-width: #{min($small-range...)}) and (max-width: #{max($small-range...)})";
// $small-up: "#{$screen} and (min-width: #{min($small-range...)})";
// $medium-only: "#{$screen} and (min-width: #{min($medium-range...)}) and (max-width: #{max($medium-range...)})";
// $medium-up: "#{$screen} and (min-width: #{min($medium-range...)})";
// $medium-down: "#{$screen} and (max-width: #{max($medium-range...)})";
// $large-only: "#{$screen} and (min-width: #{min($large-range...)}) and (max-width: #{max($large-range...)})";
// $large-up: "#{$screen} and (min-width: #{min($large-range...)})";
// $large-down: "#{$screen} and (max-width: #{max($large-range...)})";
// $xlarge-only: "only screen and (min-width: #{min($xlarge-range...)})";
// $xlarge-up: "#{$screen} and (min-width: #{min($xlarge-range...)})";
// $landscape: "#{$screen} and (orientation: landscape)";
// $portrait: "#{$screen} and (orientation: portrait)";

View File

@ -0,0 +1,60 @@
@use 'sass:math';
$gutter: 30px !default;
$max-width: 1000px !default;
@mixin container($max-width: $max-width, $gutter: $gutter) {
width: calc(100% - #{$gutter});
max-width: $max-width;
margin: 0 auto;
}
@mixin grid($gutter: $gutter, $direction: row, $justify: flex-start) {
display: flex;
flex-flow: $direction wrap;
justify-content: $justify;
@if $direction != column {
margin-left: math.div(-$gutter, 2);
margin-right: math.div(-$gutter, 2);
} @else {
margin-top: math.div(-$gutter, 2);
margin-bottom: math.div(-$gutter, 2);
}
}
@mixin cell($count: 5, $gutter: $gutter, $span: 1, $direction: row) {
flex: 0 0 0px;
@if $span > 1 {
flex-basis: calc(((100% - #{$gutter * $count}) / #{$count}) * #{$span} + #{($span - 1) * $gutter});
} @else {
flex-basis: calc(#{math.div(100%, $count)} - #{$gutter});
}
@if $direction != column {
margin-left: math.div($gutter, 2);
margin-right: math.div($gutter, 2);
} @else {
margin-top: math.div($gutter, 2);
margin-bottom: math.div($gutter, 2);
}
}
// not to be used with grid above
@mixin simple-cell($gutter: $gutter, $direction: row) {
flex: 1 1 0px;
@if $direction != column {
margin-right: $gutter;
&:last-child {
margin-right: 0;
}
} @else {
margin-bottom: $gutter;
&:last-child {
margin-bottom: 0;
}
}
}

View File

@ -0,0 +1,154 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
main,
menu,
nav,
section {
display: block;
}
html {
line-height: 1;
}
ol,
ul {
list-style: none;
}
blockquote,
q {
quotes: none;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
caption,
th,
td {
text-align: left;
font-weight: normal;
vertical-align: middle;
}
q,
blockquote {
quotes: none;
}
a img {
border: none;
}
$tmp: register-module();

View File

@ -0,0 +1,92 @@
@use 'sass:list';
@use 'sass:math';
$base-font-size: 16px !default;
@function strip-unit($num) {
@return math.div($num, ($num * 0 + 1));
}
@function convert-to-em($value, $base: $base-font-size) {
$value: math.div(strip-unit($value), strip-unit($base)) + 0em;
@if ($value == 0em) {
$value: 0;
}
@return $value;
}
@function convert-to-rem($value, $base: $base-font-size) {
$value: math.div(strip-unit($value), strip-unit($base)) + 0rem;
@if ($value == 0rem) {
$value: 0;
}
@return $value;
}
@function convert-to-px($value, $base: $base-font-size) {
$value: strip-unit($value) * strip-unit($base) + 0px;
@if ($value == 0px) {
$value: 0;
}
@return $value;
}
@function px($values, $base: $base-font-size) {
$max: list.length($values);
$px-values: ();
@for $i from 1 through $max {
$value: list.nth($values, $i);
@if math.unit($value) != 'px' {
$px-values: list.append($px-values, convert-to-px($value, $base));
} @else {
$px-values: list.append($px-values, $value);
}
}
@if $max == 1 {
$px-values: list.nth($px-values, 1);
}
@return $px-values;
}
@function rem($values, $base: $base-font-size) {
$max: list.length($values);
$rem-values: ();
@for $i from 1 through $max {
$value: list.nth($values, $i);
@if math.unit($value) != 'rem' and math.unit($value) != 'em' {
$rem-values: list.append($rem-values, convert-to-rem($value, $base));
} @else {
$rem-values: list.append($rem-values, $value);
}
}
@if $max == 1 {
$rem-values: list.nth($rem-values, 1);
}
@return $rem-values;
}
@function em($values, $base: $base-font-size) {
$max: list.length($values);
$em-values: ();
@for $i from 1 through $max {
$value: list.nth($values, $i);
@if math.unit($value) != 'rem' and math.unit($value) != 'em' {
$em-values: list.append($em-values, convert-to-em($value, $base));
} @else {
$em-values: list.append($em-values, $value);
}
}
@if $max == 1 {
$em-values: list.nth($em-values, 1);
}
@return $em-values;
}

View File

@ -0,0 +1,74 @@
@use 'sass:math';
@mixin clear {
&:after {
content: '';
display: block;
height: 0;
clear: both;
}
}
@mixin stretch($top: 0, $right: 0, $bottom: 0, $left: 0) {
position: absolute;
top: $top;
right: $right;
bottom: $bottom;
left: $left;
}
@mixin ratio($ratio, $initial-width: 100%, $pseudo: 'before') {
&:#{$pseudo} {
content: '';
display: block;
width: $initial-width;
height: 0;
padding-top: math.div(1, $ratio) * $initial-width;
@content;
}
}
@mixin full-inline($vertical-align: middle, $pseudo: 'before') {
&:#{$pseudo} {
display: inline-block;
width: 0;
height: 100%;
content: '';
vertical-align: $vertical-align;
}
}
@mixin wipe {
/*Reset's every elements apperance*/
background: none repeat scroll 0 0 transparent;
border: medium none;
border-spacing: 0;
border-radius: 0;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
margin: 0;
padding: 0;
text-align: left;
text-decoration: none;
text-transform: inherit;
text-indent: 0;
}
@mixin wipe-button {
@include wipe;
&:focus {
outline: 0;
}
&::-moz-focus-inner {
border: 0;
padding: 0;
}
}
@mixin wipe-list {
padding-left: 0;
list-style: none;
margin: 0;
}

View File

@ -0,0 +1,20 @@
const scrollingElement = typeof window !== 'undefined' && (document.scrollingElement || document.documentElement)
let scrollTop
export default {
disable() {
scrollTop = (document.scrollingElement || document.documentElement).scrollTop
document.body.style.top = `-${scrollTop}px`
document.body.style.position = 'fixed'
document.body.style.width = '100%'
},
enable() {
document.body.style.position = ''
document.body.style.top = ''
document.body.style.width = ''
scrollingElement.scrollTop = scrollTop
},
}

View File

@ -0,0 +1,3 @@
export default function escapeRegex(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

View File

@ -0,0 +1,3 @@
export default (state) => {
document.body.classList.toggle('loading', state)
}

View File

@ -0,0 +1,19 @@
import cn from 'classnames'
export default function mergeStyles(styles: Record<string, string>[]) {
const props = new Set<string>()
for (const style of styles) {
for (const prop of Object.keys(style)) {
props.add(prop)
}
}
const obj = {}
for (const prop of props) {
obj[prop] = cn(styles.map((style) => style[prop]))
}
return obj
}

View File

@ -0,0 +1,115 @@
// @ts-nocheck
import a from 'node:assert'
import test from 'node:test'
import serializeForm from './serialize_form.ts'
test('serializeForm', async (t) => {
await t.test('single values', () => {
const result = serializeForm({
elements: [
{
name: 't1',
value: 'v1',
},
{
name: 't2',
value: 'v2',
},
],
})
a.deepStrictEqual(result, {
t1: 'v1',
t2: 'v2',
})
})
await t.test('multiple values', () => {
const result = serializeForm({
elements: [
{
name: 't1',
value: 'v1',
},
{
name: 't1',
value: 'v2',
},
],
})
a.deepStrictEqual(result, {
t1: ['v1', 'v2'],
})
})
await t.test('checkbox', () => {
const result = serializeForm({
elements: [
{
name: 'c1',
type: 'checkbox',
checked: false,
value: 'v1',
},
{
name: 'c2',
type: 'checkbox',
checked: true,
value: 'v2',
},
],
})
a.deepStrictEqual(result, {
c2: 'v2',
})
})
await t.test('multiple checkboxes', () => {
const result = serializeForm({
elements: [
{
name: 'c1',
type: 'checkbox',
checked: false,
value: 'v1',
},
{
name: 'c2',
type: 'checkbox',
checked: true,
value: 'v2',
},
],
})
a.deepStrictEqual(result, {
c2: 'v2',
})
})
await t.test('radio', () => {
const result = serializeForm({
elements: [
{
name: 'r1',
type: 'radio',
checked: false,
value: 'v1',
},
{
name: 'r1',
type: 'radio',
checked: true,
value: 'v2',
},
],
})
a.deepStrictEqual(result, {
r1: 'v2',
})
})
})

View File

@ -0,0 +1,30 @@
export default function (form: HTMLFormElement, hook?: (...args: ANY[]) => ANY) {
const result = {}
for (const element of form.elements as unknown as HTMLInputElement[]) {
const { checked, name, type, value } = element
if (
(!hook || !hook(result, name, value, element)) &&
name &&
value &&
(checked || (type !== 'checkbox' && type !== 'radio'))
) {
if (
type === 'range' &&
((element.dataset.lower && element.min === value) || (!element.dataset.lower && element.max === value))
)
continue
if (result[name]) {
const arr = Array.isArray(result[name]) ? result[name] : [result[name]]
arr.push(value)
result[name] = arr
} else {
result[name] = value
}
}
}
return result
}

View File

@ -0,0 +1,5 @@
*,
*:before,
*:after {
box-sizing: border-box;
}

View File

@ -7,7 +7,7 @@ export const load: LoadHook = async function (url, context, nextLoad) {
const { source } = await nextLoad(url, { ...context, format })
const transformedSource = await esbuild.transform(source, {
const transformedSource = await esbuild.transform(source as string, {
loader: 'tsx',
jsx: 'transform',
jsxFactory: 'h',

View File

@ -1,8 +1,39 @@
volumes:
caddy_config:
caddy_data:
external: ${EXTERNAL_CADDY_DATA-true}
postgres:
services:
node:
extends:
file: docker-compose.base.yml
service: node
build:
target: development
tty: true
ports:
- 24678:24678
volumes:
- ./client:/home/node/brf_books/client
- ./server:/home/node/brf_books/server
depends_on:
- postgres
- redis
env_file:
- ./.env.development
caddy:
extends:
file: docker-compose.base.yml
service: caddy
postgres:
extends:
file: docker-compose.base.yml
service: postgres
redis:
extends:
file: docker-compose.base.yml
service: redis

7
global.d.ts vendored
View File

@ -8,13 +8,6 @@ declare global {
const __STATE__: any
}
declare module '*.scss'
declare module '*.module.scss' {
const classes: Record<string, string>
export default classes
}
declare module 'fastify' {
interface FastifyInstance {
auth: string

View File

@ -23,10 +23,21 @@
"types": "tsc"
},
"dependencies": {
"@bmp/console": "^0.1.0",
"@bmp/highlight-stack": "^0.1.2",
"@fastify/middie": "^9.0.3",
"@fastify/static": "^8.3.0",
"chalk": "^5.6.2",
"fastify": "^5.6.2",
"fastify-plugin": "^5.1.0",
"knex": "^3.1.0",
"lodash": "^4.17.21",
"lowline": "^0.4.2",
"pg": "^8.16.3",
"pino-abstract-transport": "^3.0.0"
"pg-protocol": "^1.10.3",
"pino-abstract-transport": "^3.0.0",
"preact": "^10.27.2",
"preact-router": "^4.1.2"
},
"devDependencies": {
"@babel/core": "^7.26.10",

696
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -35,8 +35,27 @@ export function read(columns, defaults = {}) {
return result
}
export default read(['PGDATABASE', 'PGHOST', 'PGPASSWORD', 'PGPORT', 'PGUSER'], {
PGPASSWORD: null,
PGPORT: null,
PGUSER: null,
})
export default read(
[
'DOMAIN',
'PROTOCOL',
'HOSTNAME',
'PORT',
'FASTIFY_PORT',
'FASTIFY_HOST',
'LOG_LEVEL',
'LOG_STREAM',
'NODE_ENV',
'PGDATABASE',
'PGHOST',
'PGPASSWORD',
'PGPORT',
'PGUSER',
'REDIS_HOST',
],
{
PGPASSWORD: null,
PGPORT: null,
PGUSER: null,
},
)

39
server/handlers/error.ts Normal file
View File

@ -0,0 +1,39 @@
import { DatabaseError } from 'pg-protocol'
import StatusError from '../lib/status_error.ts'
const databaseErrorCodes = {
23505: 409,
23514: 409,
}
function defaultHandler(error, request, reply) {
return reply
.type('text/html')
.send(
`<h1>Error</h1><pre>${JSON.stringify(error, ['name', ...Object.getOwnPropertyNames(error)], 2).replaceAll('\\n', '\n')}</pre>`,
)
}
export default function ErrorHandler(handler = defaultHandler) {
return async function errorHandler(error, request, reply) {
if (error instanceof DatabaseError && error.code in databaseErrorCodes) {
error = new StatusError(databaseErrorCodes[error.code], error.detail || 'Database Error', { cause: error })
}
reply.log.error({ req: request, err: error }, error?.message)
reply.status(error.status || error.statusCode || 500)
if (request.headers.accept?.includes('application/json')) {
return reply.send(
error.toJSON?.() || {
name: error.name,
message: error.message,
status: error.status || error.statusCode || 500,
},
)
}
return handler(error, request, reply)
}
}

18
server/handlers/robots.ts Normal file
View File

@ -0,0 +1,18 @@
import env from '../env.ts'
const CONTENT = {
development: `
User-agent: *
Disallow: /
`,
production: `
User-agent: *
Disallow: /admin
`,
}
const contents = (CONTENT[env.NODE_ENV] || CONTENT.development).trim()
export default function robots(_request, reply) {
return reply.type('text/plain').send(contents)
}

25
server/index.ts Normal file
View File

@ -0,0 +1,25 @@
import './lib/console.ts'
import stream from './lib/pino_transport_console.ts'
import env from './env.ts'
import Server from './server.ts'
const server = await Server({
logger: {
level: env.LOG_LEVEL,
stream,
serializers: stream && {
req: (req) => ({
method: req.method,
url: req.url,
headers: req.headers,
ip: req.ip,
}),
},
},
})
try {
await server.listen({ port: env.FASTIFY_PORT, host: env.FASTIFY_HOST })
} catch (e) {
server.log.error(e)
}

19
server/lib/console.ts Normal file
View File

@ -0,0 +1,19 @@
import env from '../env.ts'
if (env.NODE_ENV === 'development') {
// output filename in console log and colour console.dir
await import('@bmp/console').then((mod) => mod.default({ log: true, error: true, dir: true }))
const { default: chalk } = await import('chalk')
process.on('uncaughtException', (err, origin) => {
console.error(chalk.red(`UNCAUGHT EXCEPTION`), chalk.yellow(` (${origin})`))
console.error(err)
})
process.on('unhandledRejection', (err, promise) => {
console.error(chalk.red('UNHANDLED REJECTION'))
console.error(promise)
console.error(err)
})
}

79
server/lib/html.ts Normal file
View File

@ -0,0 +1,79 @@
import { Readable } from 'node:stream'
type ExpressionPrimitives = string | number | Date | boolean | undefined | null | ExpressionPrimitives[]
type Expression = ExpressionPrimitives | (() => ExpressionPrimitives) | Promise<ExpressionPrimitives>
type ParseGenerator = Generator<ExpressionPrimitives, void>
export interface Template {
generator: (minify?: boolean) => ParseGenerator
stream: (minify?: boolean) => Readable
text: (minify?: boolean) => string | Promise<string>
}
const cache = new WeakMap()
function apply(expression: Expression) {
if (!expression) {
return ''
} else if ((expression as Promise<ExpressionPrimitives>).then) {
return (expression as Promise<ExpressionPrimitives>).then(apply)
} else if (typeof expression === 'function') {
return apply(expression())
} else {
return (expression as ExpressionPrimitives[]).join?.('') ?? expression
}
}
function* parse(strings: TemplateStringsArray, expressions: Expression[], minify?: boolean): ParseGenerator {
if (minify) {
let minified = cache.get(strings)
if (!minified) {
minified = strings.map(removeWhitespace)
cache.set(strings, minified)
}
strings = minified
}
for (let i = 0; i < strings.length; i++) {
yield strings[i]
const expression = expressions[i]
yield apply(expression)
}
}
export default function html(strings: TemplateStringsArray, ...expressions: Expression[]): Template {
return {
generator: (minify?: boolean) => parse(strings, expressions, minify),
stream: (minify?: boolean) => stream(parse(strings, expressions, minify)),
text: (minify?: boolean) => text(parse(strings, expressions, minify)),
}
}
html.generator = (strings: TemplateStringsArray, ...expressions: Expression[]) => parse(strings, expressions)
html.stream = (strings: TemplateStringsArray, ...expressions: Expression[]) => stream(parse(strings, expressions))
html.text = (strings: TemplateStringsArray, ...expressions: Expression[]) => text(parse(strings, expressions))
function stream(generator: ParseGenerator) {
return Readable.from(generator)
}
async function text(generator: ParseGenerator) {
let result = ''
for await (const chunk of generator) {
result += chunk
}
return result
}
function removeWhitespace(str: string) {
return str.split(/\n\s*/).join('')
}

View File

@ -188,13 +188,10 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
case '#TRANS': {
const { objectList, ...transaction } = parseTrans(line)
let objectId: number
const transactionId = (
await trx('transaction')
.insert({
entryId: currentEntryId,
objectId,
...transaction,
})
.returning('id')

View File

@ -0,0 +1,71 @@
import util from 'node:util'
import chalk from 'chalk'
import _ from 'lodash'
import highlightStack from '@bmp/highlight-stack'
const LEVELS = {
default: 'USERLVL',
60: 'FATAL',
50: 'ERROR',
40: 'WARN',
30: 'INFO',
20: 'DEBUG',
10: 'TRACE',
}
const COLORS = {
60: chalk.bgRed,
50: chalk.red,
40: chalk.yellow,
30: chalk.green,
20: chalk.blue,
10: chalk.grey,
}
const requests = new Map()
function colorStatusCode(statusCode) {
if (statusCode < 300) {
return chalk.bold.green(statusCode)
} else if (statusCode < 400) {
return chalk.bold.blue(statusCode)
} else if (statusCode < 500) {
return chalk.bold.yellow(statusCode)
} else {
return chalk.bold.red(statusCode)
}
}
export default {
write(line) {
const obj = JSON.parse(line)
if (obj.msg === 'incoming request') {
requests.set(obj.reqId, obj.req)
} else if (obj.msg === 'request completed') {
const req = requests.get(obj.reqId)
requests.delete(obj.reqId)
process.stdout.write(
`${chalk.bold(req.method)} ${req.url} ${colorStatusCode(obj.res.statusCode)} ${obj.responseTime.toFixed(
3,
)} ms\n`,
)
} else if (obj.level === 50) {
// TODO figure out if there is a way to get the error instances here... console.log(Error) and util.inspect(Error)
// looks better than the serialized errors
if (obj.err.status < 500) return
process.stdout.write(
`${COLORS[obj.level](LEVELS[obj.level] || LEVELS.default)} ${highlightStack(obj.err.stack)}\n`,
)
const details = _.omit(obj.err, ['type', 'message', 'stack'])
if (!_.isEmpty(details)) {
process.stdout.write(`${chalk.red('Details')} ${util.inspect(details, false, 4, true)}\n`)
}
} else {
process.stdout.write(`${COLORS[obj.level](LEVELS[obj.level] || LEVELS.default)} ${obj.msg}\n`)
}
},
}

52
server/plugins/vite.ts Normal file
View File

@ -0,0 +1,52 @@
import { type FastifyPluginCallback } from 'fastify'
import _ from 'lodash'
import fp from 'fastify-plugin'
import { type Template } from '../lib/html.ts'
export interface Entry {
name: string
path: string
template: (...args: any[]) => Template
routes?: ANY[]
ssr?: boolean
hostname?: string
ctx?: Record<string, any>
setupRebuildCache?: (buildCache: () => any) => any
preHandler?: ANY
createPreHandler?: (route: ANY, entry: Entry) => any
createErrorHandler?: (handler?: ANY) => ANY
}
export type EntryWithoutName = Omit<Entry, 'name'>
export interface Config {
mode: 'development' | 'production'
createPreHandler?: (route: ANY, entry: Entry) => any
createErrorHandler?: (handler?: ANY) => ANY
entries: Record<string, EntryWithoutName>
}
export interface ParsedConfig {
mode: 'development' | 'production'
entries: Entry[]
}
const vitePlugin: FastifyPluginCallback<Config> = async function (fastify, config) {
const parsedConfig = {
mode: config.mode || 'production',
entries: _.map(config.entries, (entry, name) => getEntryConfig(name, entry, config)) as Entry[],
}
const setup = (await import(`./vite/${config.mode}.ts`)).default
await setup(fastify, parsedConfig)
}
function getEntryConfig(name: string, entry: EntryWithoutName, config: Config): Entry {
return Object.assign(_.pick(config, ['createErrorHandler', 'createPreHandler', 'ssr', 'template']), entry, { name })
}
export default fp(vitePlugin, {
name: 'vite',
fastify: '5.x',
})

View File

@ -0,0 +1,130 @@
import { type FastifyInstance } from 'fastify'
import fmiddie from '@fastify/middie'
import { createServer } from 'vite'
import StatusError from '../../lib/status_error.ts'
import { type Entry, type ParsedConfig } from '../vite.ts'
export default async function viteDevelopment(fastify: FastifyInstance, config: ParsedConfig) {
const devServer = await createServer({
server: { middlewareMode: true },
appType: 'custom',
})
fastify.decorate('devServer', devServer)
fastify.decorateReply('ctx', null)
await fastify.register(fmiddie)
fastify.use(devServer.middlewares)
for (const entry of Object.values(config.entries)) {
fastify.register((fastify, _, done) => setupEntry(fastify, entry).then(() => done()), {
prefix: entry.path,
})
}
}
async function setupEntry(fastify: FastifyInstance, entry: Entry) {
const renderer = createRenderer(fastify, entry)
const handler = createHandler(renderer)
if (entry.preHandler || entry.createPreHandler) {
fastify.addHook('onRequest', (_request, reply, done) => {
reply.ctx = Object.create(null)
done()
})
}
if (entry.preHandler) {
fastify.addHook('preHandler', entry.preHandler)
}
const routes = entry.routes || (await fastify.devServer.ssrLoadModule(`/${entry.name}/server.ts`)).routes
for (const route of routes.flatMap((route) => route.routes || route)) {
// const preHandler = entry.preHandler || entry.createPreHandler?.(route, entry)
const preHandler = entry.createPreHandler?.(route, entry)
if (route.locales?.length) {
const locales = [{ hostname: entry.hostname, path: route.path }, ...route.locales]
for (const locale of locales) {
fastify.route({
method: 'GET',
url: locale.path,
onRequest(request, reply, done) {
if (request.hostname !== locale.hostname) {
// TODO should probably redirect to correct path on request.hostname, not to locale.hostname
return reply.redirect('http://' + locale.hostname + request.url)
}
done()
},
handler,
preHandler,
})
}
} else {
fastify.route({
method: 'GET',
url: route.path,
handler,
preHandler,
})
}
}
fastify.setNotFoundHandler(() => {
throw new StatusError(404)
})
fastify.setErrorHandler(
entry.createErrorHandler(async (error, request, reply) => {
reply.type('text/html').status(error.status || 500)
return renderer(
request.url,
Object.assign(
{
error,
},
reply.ctx,
),
)
}),
)
}
function createRenderer(fastify, entry) {
return async (url, ctx) => {
ctx = Object.assign({ url }, entry.ctx, ctx)
const { render } = await fastify.devServer.ssrLoadModule(`/${entry.name}/server.ts`)
const renderPromise = render && entry.ssr !== false ? render(ctx) : null
return fastify.devServer.transformIndexHtml(
url,
await entry
.template({
env: 'development',
preloads: [],
script: `/${entry.name}/client.ts`,
styles: [],
content: renderPromise?.then((result) => result.html),
head: renderPromise?.then((result) => result.head),
state: renderPromise?.then((result) => result.state) || Promise.resolve(ctx),
})
.text(),
)
}
}
function createHandler(renderer) {
return async function handler(request, reply) {
reply.type('text/html')
return renderer(request.url, reply.ctx)
}
}

View File

@ -0,0 +1,166 @@
import { type FastifyInstance } from 'fastify'
import path from 'node:path'
import fs from 'node:fs'
import fstatic from '@fastify/static'
import { resolveConfig } from 'vite'
import StatusError from '../../lib/status_error.ts'
import { type Entry, type ParsedConfig } from '../vite.ts'
export default async function viteProduction(fastify: FastifyInstance, config: ParsedConfig) {
const viteConfig = await resolveConfig({}, 'build', 'production')
fastify.register(fstatic, {
root: viteConfig.build.outDir,
wildcard: false,
})
fastify.decorateReply('ctx', null)
for (const entry of Object.values(config.entries)) {
fastify.register((fastify, _, done) => setupEntry(fastify, entry, config, viteConfig).then(() => done()), {
prefix: entry.path,
})
}
}
async function setupEntry(fastify: FastifyInstance, entry: Entry, config: ParsedConfig, viteConfig) {
const viteFilePath = path.join(viteConfig.build.outDir, entry.name + '.js')
const viteFile = fs.existsSync(viteFilePath)
? await import(viteFilePath)
: await import(path.join(viteConfig.root, entry.name, 'server.ts'))
const manifestPath = path.join(viteConfig.build.outDir, `manifest.${entry.name}.json`)
const manifest = fs.existsSync(manifestPath) ? (await import(manifestPath, { with: { type: 'json' } })).default : null
const routes = entry.routes || viteFile.routes
const cache = new Map()
const renderer = createRenderer(fastify, entry, viteFile.render, manifest)
const cachedHandler = createCachedHandler(cache)
const handler = createHandler(renderer)
if (entry.preHandler || entry.createPreHandler) {
fastify.addHook('onRequest', (_request, reply, done) => {
reply.ctx = Object.create(null)
done()
})
}
if (entry.preHandler) {
fastify.addHook('preHandler', entry.preHandler)
}
for (const route of routes.flatMap((route) => route.routes || route)) {
const preHandler = entry.createPreHandler?.(route, entry)
if (route.locales?.length) {
const locales = [{ hostname: entry.hostname, path: route.path }, ...route.locales]
for (const locale of locales) {
fastify.route({
method: 'GET',
url: locale.path,
onRequest(request, reply, done) {
if (request.hostname !== locale.hostname) {
// TODO should probably redirect to correct path on request.hostname, not to locale.hostname
return reply.redirect('http://' + locale.hostname + request.url)
}
done()
},
handler: route.cache ? cachedHandler : handler,
preHandler,
})
}
} else {
fastify.route({
method: 'GET',
url: route.path,
handler: route.cache ? cachedHandler : handler,
preHandler,
})
}
}
entry.setupRebuildCache?.(() => buildCache(entry, routes, renderer, cache))
fastify.setNotFoundHandler(() => {
throw new StatusError(404)
})
fastify.setErrorHandler(
entry.createErrorHandler(async (error, request, reply) => {
reply.type('text/html').status(error.status || 500)
return renderer(
request.url,
Object.assign(
{
error,
},
reply.ctx,
),
)
}),
)
}
function createRenderer(fastify, entry, render, manifest) {
const files = manifest[`${entry.name}/client.ts`]
console.log(files)
const bundle = path.join('/', files.file)
const preload = files.imports?.map((name) => path.join('/', manifest[name].file))
const css = files.css?.map((name) => path.join('/', name))
console.log(css)
return (url, ctx) => {
ctx = Object.assign({ url }, entry.ctx, ctx)
const renderPromise = render?.(ctx)
return entry
.template({
env: 'production',
script: bundle,
preload,
css,
content: renderPromise?.then((result) => result.content),
head: renderPromise?.then((result) => result.head),
state: renderPromise?.then((result) => result.state) || Promise.resolve(ctx),
})
.stream(true)
}
}
function createHandler(renderer) {
return async function handler(request, reply) {
reply.type('text/html')
return renderer(request.url, reply.ctx)
}
}
function createCachedHandler(cache) {
return function cachedHandler(request, reply) {
reply.type('text/html')
return cache.get(request.url)
}
}
async function buildCache(entry, routes, renderer, cache) {
for (const route of routes) {
if (route.cache) {
let html = ''
// TODO run preHandlers
for await (const chunk of renderer(route.path)) {
html += chunk
}
cache.set(route.path, html)
}
}
}

63
server/routes/api.ts Normal file
View File

@ -0,0 +1,63 @@
import { type FastifyPluginCallback } from 'fastify'
import knex from '../lib/knex.ts'
const apiRoutes: FastifyPluginCallback = (fastify, _, done) => {
fastify.route({
url: '/result',
method: 'GET',
async handler(req, res) {
const years = await knex('financialYear').select('*')
return Promise.all(
years.map((year) =>
knex('account')
.select('account.number', 'account.description')
.sum('transaction.amount as amount')
.innerJoin('transaction', function () {
this.on('transaction.accountNumber', '=', 'account.number')
})
.innerJoin('entry', function () {
this.on('transaction.entryId', '=', 'entry.id')
})
.groupBy('account.number', 'account.description')
.where('account.number', '>=', 3000)
.where('entry.financialYearId', year.id)
.orderBy('account.number')
.then((result) => ({
startDate: year.startDate,
endDate: year.endDate,
result,
})),
),
)
},
})
fastify.route({
url: '/result/:year',
method: 'GET',
async handler(req, res) {
const year = await knex('financialYear').first('*').where('startDate', `${req.params.year}0101`)
const result = await knex('account')
.select('account.number', 'account.description')
.sum('transaction.amount as amount')
.innerJoin('transaction', function () {
this.on('transaction.accountNumber', '=', 'account.number')
})
.innerJoin('entry', function () {
this.on('transaction.entryId', '=', 'entry.id')
})
.groupBy('account.number', 'account.description')
.where('account.number', '>=', 3000)
.where('entry.financialYearId', year.id)
.orderBy('account.number')
return result
},
})
done()
}
export default apiRoutes

32
server/server.ts Normal file
View File

@ -0,0 +1,32 @@
import fastify from 'fastify'
import StatusError from './lib/status_error.ts'
import env from './env.ts'
import ErrorHandler from './handlers/error.ts'
import vitePlugin from './plugins/vite.ts'
import apiRoutes from './routes/api.ts'
import templatePublic from './templates/public.ts'
export default async (options) => {
const server = fastify(options)
server.setNotFoundHandler(() => {
throw new StatusError(404)
})
console.dir(env)
server.register(vitePlugin, {
mode: env.NODE_ENV,
createErrorHandler: ErrorHandler,
entries: {
public: {
path: '/',
template: templatePublic,
},
},
})
server.register(apiRoutes, { prefix: '/api' })
return server
}

View File

@ -0,0 +1,28 @@
import html from '../lib/html.ts'
export default ({ content, css, head, preload, script, state }) => html`<!DOCTYPE html>
<html lang='sv-SE'>
<head>
<link rel="icon" href="/favicon.svg" />
<script type="module" src="${script}"></script>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="robots" content="index, follow" />
${css?.map((href) => `<link rel='stylesheet' crossorigin href='${href}'>`)}
${preload?.map((href) => `<link rel='modulepreload' crossorigin href='${href}'>`)}
<title>${head?.then((head) => head.title)}</title>
${head?.then((head) =>
head.tags?.map(
(tag) =>
`<${tag.type} ${Object.entries(tag.attributes)
.map(([name, content]) => `${name}='${content}'`)
.join(' ')}/>`,
),
)}
</head>
<body>
${content}
${state?.then((state) => `<script>window.__STATE__ = ${JSON.stringify(state)}</script>`)}
<script>var offset=window.history.state&&window.history.state.scrollTop||0;if(offset)window.scrollTo(0,offset)</script>
</body>
</html>`

View File

@ -12,5 +12,5 @@
"erasableSyntaxOnly": true,
"allowArbitraryExtensions": true
},
"include": ["./global.d.ts", "./client/", "./server/"]
"include": ["global.d.ts", "client", "server"]
}

View File

@ -22,7 +22,7 @@ export default defineConfig(({ isSsrBuild }) => {
},
},
server: {
allowedHosts: ['startbit.local'],
allowedHosts: ['brf.local'],
port: 1338,
hmr: process.env.VITE_HMR_PROXY
? {