ajo-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +468 -0
- package/dist/bin/kit.js +109 -0
- package/dist/chunks/constants-5Eu0naK3.js +168 -0
- package/dist/chunks/discover-D-b8Pqm8.js +34 -0
- package/dist/chunks/headers-BDszzwLw.js +23 -0
- package/dist/chunks/migrate-C9Iytqx4.js +89 -0
- package/dist/chunks/ssr-CCxCGcm6.js +421 -0
- package/dist/client.js +134 -0
- package/dist/database.js +38 -0
- package/dist/index.js +2 -0
- package/dist/mail.js +15 -0
- package/dist/node.js +104 -0
- package/dist/server.js +502 -0
- package/dist/validate.js +15 -0
- package/dist/vite.js +110 -0
- package/package.json +106 -0
- package/src/app.tsx +484 -0
- package/src/cache.ts +85 -0
- package/src/client.tsx +167 -0
- package/src/constants.ts +339 -0
- package/src/database.ts +55 -0
- package/src/discover.ts +42 -0
- package/src/form.ts +39 -0
- package/src/freshness.ts +63 -0
- package/src/head.tsx +125 -0
- package/src/headers.ts +32 -0
- package/src/index.ts +31 -0
- package/src/mail/index.ts +25 -0
- package/src/migrate.ts +132 -0
- package/src/node.ts +134 -0
- package/src/server.tsx +584 -0
- package/src/ssr.ts +8 -0
- package/src/timing.ts +60 -0
- package/src/validate.ts +33 -0
- package/src/virtual.d.ts +10 -0
- package/src/vite.ts +166 -0
package/src/form.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function fields(form: HTMLFormElement): Set<string> {
|
|
2
|
+
|
|
3
|
+
const seen = new Set<string>()
|
|
4
|
+
const arrays = new Set<string>()
|
|
5
|
+
|
|
6
|
+
for (const element of Array.from(form.elements)) {
|
|
7
|
+
|
|
8
|
+
const control = element as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
|
9
|
+
const name = control.name
|
|
10
|
+
|
|
11
|
+
if (!name) continue
|
|
12
|
+
|
|
13
|
+
if (seen.has(name) || control instanceof HTMLSelectElement && control.multiple) arrays.add(name)
|
|
14
|
+
else seen.add(name)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return arrays
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function body(
|
|
21
|
+
data: FormData,
|
|
22
|
+
arrays: Set<string> = new Set()
|
|
23
|
+
): Record<string, string | string[]> {
|
|
24
|
+
|
|
25
|
+
const body: Record<string, string | string[]> = {}
|
|
26
|
+
|
|
27
|
+
for (const [name, value] of data) {
|
|
28
|
+
|
|
29
|
+
if (typeof value !== 'string') continue
|
|
30
|
+
|
|
31
|
+
const current = body[name]
|
|
32
|
+
|
|
33
|
+
if (current === undefined) body[name] = arrays.has(name) ? [value] : value
|
|
34
|
+
else if (Array.isArray(current)) current.push(value)
|
|
35
|
+
else body[name] = [current, value]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return body
|
|
39
|
+
}
|
package/src/freshness.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type Versions = Record<string, number>
|
|
2
|
+
|
|
3
|
+
const versions = new Map<string, number>()
|
|
4
|
+
|
|
5
|
+
export function hash(value: string) {
|
|
6
|
+
let h = 2166136261
|
|
7
|
+
|
|
8
|
+
for (let i = 0; i < value.length; i++) {
|
|
9
|
+
h ^= value.charCodeAt(i)
|
|
10
|
+
h = Math.imul(h, 16777619)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return (h >>> 0).toString(36)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function topics(topic: string | string[]) {
|
|
17
|
+
return [...new Set(Array.isArray(topic) ? topic : [topic])].filter(Boolean).sort()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function bump(topic: string | string[]) {
|
|
21
|
+
const list = topics(topic)
|
|
22
|
+
|
|
23
|
+
for (const t of list) versions.set(t, (versions.get(t) ?? 0) + 1)
|
|
24
|
+
|
|
25
|
+
return list
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function snapshot(keys: Iterable<string>): Versions {
|
|
29
|
+
const result: Versions = {}
|
|
30
|
+
|
|
31
|
+
for (const topic of [...keys].sort()) result[topic] = versions.get(topic) ?? 0
|
|
32
|
+
|
|
33
|
+
return result
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parse(raw: string | string[] | undefined): Versions | null {
|
|
37
|
+
if (!raw || Array.isArray(raw)) return null
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(raw) as unknown
|
|
41
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
42
|
+
|
|
43
|
+
const result: Versions = {}
|
|
44
|
+
|
|
45
|
+
for (const [topic, version] of Object.entries(parsed)) {
|
|
46
|
+
if (typeof version !== 'number' || !Number.isFinite(version)) return null
|
|
47
|
+
result[topic] = version
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return result
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function fresh(client: Versions | null) {
|
|
57
|
+
const entries = Object.entries(client ?? {})
|
|
58
|
+
return entries.length > 0 && entries.every(([topic, version]) => (versions.get(topic) ?? 0) === version)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function reset() {
|
|
62
|
+
versions.clear()
|
|
63
|
+
}
|
package/src/head.tsx
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
|
|
3
|
+
type Meta =
|
|
4
|
+
| { name: string; content: string }
|
|
5
|
+
| { property: string; content: string }
|
|
6
|
+
| { httpEquiv: string; content: string }
|
|
7
|
+
|
|
8
|
+
type Link = { rel: string; href: string; [key: string]: string | undefined }
|
|
9
|
+
|
|
10
|
+
/** Document head fields route modules can return from head(). */
|
|
11
|
+
export type Head = {
|
|
12
|
+
title?: string
|
|
13
|
+
meta?: Meta[]
|
|
14
|
+
link?: Link[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Key extractor for deduplication
|
|
18
|
+
|
|
19
|
+
const key = {
|
|
20
|
+
meta: (entry: Meta) => 'name' in entry ? entry.name : 'property' in entry ? entry.property : entry.httpEquiv,
|
|
21
|
+
link: (entry: Link) => entry.rel,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const append = <T,>(items: T[], index: Map<string, number>, entry: T, id: string) => {
|
|
25
|
+
const position = index.get(id)
|
|
26
|
+
|
|
27
|
+
if (position === undefined) {
|
|
28
|
+
index.set(id, items.length)
|
|
29
|
+
items.push(entry)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
items[position] = entry
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Merge: dedupe by key, last wins
|
|
37
|
+
|
|
38
|
+
/** Merges route heads, letting later meta/link entries win by key. */
|
|
39
|
+
export function merge(...heads: (Head | undefined)[]): Head {
|
|
40
|
+
|
|
41
|
+
const result: Head = {}
|
|
42
|
+
const meta: Meta[] = []
|
|
43
|
+
const link: Link[] = []
|
|
44
|
+
const index = { meta: new Map<string, number>(), link: new Map<string, number>() }
|
|
45
|
+
|
|
46
|
+
for (const head of heads) {
|
|
47
|
+
|
|
48
|
+
if (!head) continue
|
|
49
|
+
|
|
50
|
+
if (head.title) result.title = head.title
|
|
51
|
+
|
|
52
|
+
for (const entry of head.meta ?? []) append(meta, index.meta, entry, key.meta(entry))
|
|
53
|
+
for (const entry of head.link ?? []) append(link, index.link, entry, key.link(entry))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (meta.length) result.meta = meta
|
|
57
|
+
if (link.length) result.link = link
|
|
58
|
+
|
|
59
|
+
return result
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// SSR: render to HTML string
|
|
63
|
+
|
|
64
|
+
const text = (value: string) => value
|
|
65
|
+
.replace(/&/g, '&')
|
|
66
|
+
.replace(/</g, '<')
|
|
67
|
+
.replace(/>/g, '>')
|
|
68
|
+
|
|
69
|
+
const attribute = (value: string) => text(value).replace(/"/g, '"')
|
|
70
|
+
|
|
71
|
+
const attrs = (entries: Record<string, string | undefined>) =>
|
|
72
|
+
Object.entries(entries)
|
|
73
|
+
.filter((entry): entry is [string, string] => entry[1] !== undefined)
|
|
74
|
+
.map(([name, value]) => `${name}="${attribute(value)}"`)
|
|
75
|
+
.join(' ')
|
|
76
|
+
|
|
77
|
+
const tag = (name: 'meta' | 'link', entries: Record<string, string | undefined>) =>
|
|
78
|
+
`<${name} ${attrs(entries)}>`
|
|
79
|
+
|
|
80
|
+
/** Renders a Head object into SSR-safe HTML tags. */
|
|
81
|
+
export function render(head: Head = {}): string {
|
|
82
|
+
|
|
83
|
+
const tags: string[] = []
|
|
84
|
+
|
|
85
|
+
if (head.title) tags.push(`<title>${text(head.title)}</title>`)
|
|
86
|
+
|
|
87
|
+
for (const entry of head.meta ?? []) tags.push(tag('meta', entry))
|
|
88
|
+
for (const entry of head.link ?? []) tags.push(tag('link', entry))
|
|
89
|
+
|
|
90
|
+
return tags.join('\n ')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// CSR: update document.head (diff before mutate)
|
|
94
|
+
|
|
95
|
+
/** Applies a Head object to document.head during client navigation. */
|
|
96
|
+
export function apply(head: Head = {}): void {
|
|
97
|
+
|
|
98
|
+
if (head.title && document.title !== head.title) document.title = head.title
|
|
99
|
+
|
|
100
|
+
const upsert = (selector: string, attrs: Record<string, string>) => {
|
|
101
|
+
|
|
102
|
+
let node = document.head.querySelector(selector)
|
|
103
|
+
|
|
104
|
+
if (!node) {
|
|
105
|
+
node = document.createElement(selector.startsWith('link') ? 'link' : 'meta')
|
|
106
|
+
for (const [attr, value] of Object.entries(attrs)) node.setAttribute(attr, value)
|
|
107
|
+
document.head.appendChild(node)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const [attr, value] of Object.entries(attrs)) {
|
|
112
|
+
if (node.getAttribute(attr) !== value) node.setAttribute(attr, value)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const entry of head.meta ?? []) {
|
|
117
|
+
const id = key.meta(entry)
|
|
118
|
+
const selector = 'name' in entry ? `meta[name="${id}"]` : 'property' in entry ? `meta[property="${id}"]` : `meta[http-equiv="${id}"]`
|
|
119
|
+
upsert(selector, entry as Record<string, string>)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const entry of head.link ?? []) {
|
|
123
|
+
upsert(`link[rel="${entry.rel}"]`, entry as Record<string, string>)
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/headers.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
type Value = string | number | readonly string[]
|
|
2
|
+
|
|
3
|
+
type Target = {
|
|
4
|
+
setHeader(key: string, value: Value): unknown
|
|
5
|
+
hasHeader(key: string): boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const https = () => {
|
|
9
|
+
if (process.env.NODE_ENV !== 'production' || !process.env.APP_URL) return false
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
return new URL(process.env.APP_URL).protocol === 'https:'
|
|
13
|
+
} catch {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Security headers shared by dynamic SSR responses and static assets. */
|
|
19
|
+
export const security = () => ({
|
|
20
|
+
'X-Content-Type-Options': 'nosniff',
|
|
21
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
22
|
+
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
|
|
23
|
+
'Content-Security-Policy': "frame-ancestors 'none'",
|
|
24
|
+
...(https() && { 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains' }),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/** Writes headers, optionally preserving values already set downstream. */
|
|
28
|
+
export const set = (res: Target, values: Record<string, Value>, missing = false) => {
|
|
29
|
+
for (const [key, value] of Object.entries(values)) {
|
|
30
|
+
if (!missing || !res.hasHeader(key)) res.setHeader(key, value)
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/// <reference path="./virtual.d.ts" />
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
Failure,
|
|
5
|
+
Missing,
|
|
6
|
+
Forbidden,
|
|
7
|
+
Denied,
|
|
8
|
+
Invalid,
|
|
9
|
+
normalize,
|
|
10
|
+
navigate,
|
|
11
|
+
ajax,
|
|
12
|
+
api,
|
|
13
|
+
ip,
|
|
14
|
+
origin,
|
|
15
|
+
date,
|
|
16
|
+
} from './constants'
|
|
17
|
+
|
|
18
|
+
export type {
|
|
19
|
+
Request,
|
|
20
|
+
Response,
|
|
21
|
+
Middleware,
|
|
22
|
+
Head,
|
|
23
|
+
Fields,
|
|
24
|
+
Issue,
|
|
25
|
+
Entry,
|
|
26
|
+
Parent,
|
|
27
|
+
Action,
|
|
28
|
+
PageArgs,
|
|
29
|
+
LayoutArgs,
|
|
30
|
+
User,
|
|
31
|
+
} from './constants'
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Email payload passed to the configured transport. */
|
|
2
|
+
export interface Mail {
|
|
3
|
+
to: string
|
|
4
|
+
subject: string
|
|
5
|
+
text: string
|
|
6
|
+
html?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Async mail transport used by send(). */
|
|
10
|
+
export type Transport = (mail: Mail) => Promise<void>
|
|
11
|
+
|
|
12
|
+
let transport: Transport = async (mail) => {
|
|
13
|
+
console.log('📧 Email:', mail.to, '-', mail.subject)
|
|
14
|
+
console.log(mail.text)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Sets the process-wide mail transport. */
|
|
18
|
+
export function configure(handler: Transport): void {
|
|
19
|
+
transport = handler
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Sends one email through the configured transport. */
|
|
23
|
+
export async function send(mail: Mail): Promise<void> {
|
|
24
|
+
await transport(mail)
|
|
25
|
+
}
|
package/src/migrate.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely'
|
|
2
|
+
import { FileMigrationProvider, Migrator, type Migration } from 'kysely/migration'
|
|
3
|
+
import { existsSync, promises as fs } from 'node:fs'
|
|
4
|
+
import * as path from 'node:path'
|
|
5
|
+
import { pathToFileURL } from 'node:url'
|
|
6
|
+
import { discover } from './discover'
|
|
7
|
+
|
|
8
|
+
type Migrations = Record<string, Migration>
|
|
9
|
+
type Source = { folder: string; id: string }
|
|
10
|
+
|
|
11
|
+
const extensions = ['.js', '.ts', '.mjs', '.mts', '.cjs', '.cts']
|
|
12
|
+
const pattern = /^(\d{4})_[a-z0-9]+(?:_[a-z0-9]+)*$/
|
|
13
|
+
|
|
14
|
+
const file = (name: string) =>
|
|
15
|
+
!name.includes('.d.') && extensions.some(extension => name.endsWith(extension))
|
|
16
|
+
|
|
17
|
+
function validate(id: string, names: string[]) {
|
|
18
|
+
for (const [index, name] of names.entries()) {
|
|
19
|
+
const sequence = pattern.exec(name)?.[1]
|
|
20
|
+
const expected = String(index + 1).padStart(4, '0')
|
|
21
|
+
|
|
22
|
+
if (sequence !== expected) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`${id} migrations must be a contiguous sequence starting at 0001; ` +
|
|
25
|
+
`expected ${expected}_*, found ${name}`
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function local(id: string, files: string[]) {
|
|
32
|
+
const names = files
|
|
33
|
+
.filter(file)
|
|
34
|
+
.map(name => name.slice(0, name.lastIndexOf('.')))
|
|
35
|
+
.sort()
|
|
36
|
+
|
|
37
|
+
if (new Set(names).size !== names.length) {
|
|
38
|
+
throw new Error(`${id} has duplicate migration filenames`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
validate(id, names)
|
|
42
|
+
return names
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function migrationFile(files: string[], name: string) {
|
|
46
|
+
const safe = name.trim().toLowerCase()
|
|
47
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
48
|
+
.replace(/^_+|_+$/g, '')
|
|
49
|
+
if (!safe) throw new Error('Migration name must contain a letter or number')
|
|
50
|
+
|
|
51
|
+
const number = local('project', files).length + 1
|
|
52
|
+
if (number > 9_999) throw new Error('Migration sequence exhausted')
|
|
53
|
+
|
|
54
|
+
return `${String(number).padStart(4, '0')}_${safe}.ts`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function load(source: Source): Promise<Migrations> {
|
|
58
|
+
const names = local(source.id, await fs.readdir(source.folder))
|
|
59
|
+
|
|
60
|
+
const migrations = await new FileMigrationProvider({
|
|
61
|
+
fs,
|
|
62
|
+
path,
|
|
63
|
+
migrationFolder: source.folder,
|
|
64
|
+
import: file => import(pathToFileURL(file).href),
|
|
65
|
+
}).getMigrations()
|
|
66
|
+
const incomplete = names.filter(name =>
|
|
67
|
+
typeof migrations[name]?.up !== 'function' || typeof migrations[name]?.down !== 'function'
|
|
68
|
+
)
|
|
69
|
+
if (incomplete.length) {
|
|
70
|
+
throw new Error(`${source.id} migrations must export up() and down(): ${incomplete.join(', ')}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return migrations
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function merge(sources: Source[]): Promise<Migrations> {
|
|
77
|
+
const merged: Migrations = {}
|
|
78
|
+
const ids = new Set<string>()
|
|
79
|
+
|
|
80
|
+
for (const source of sources) {
|
|
81
|
+
if (ids.has(source.id)) throw new Error(`Duplicate migration source "${source.id}"`)
|
|
82
|
+
ids.add(source.id)
|
|
83
|
+
|
|
84
|
+
for (const [name, migration] of Object.entries(await load(source))) {
|
|
85
|
+
const qualified = `${source.id}/${name}`
|
|
86
|
+
merged[qualified] = migration
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return merged
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function migrator(instance: Kysely<any>, root = process.cwd()): Migrator {
|
|
94
|
+
const sources: Source[] = discover(root)
|
|
95
|
+
.filter(plugin => plugin.migrations)
|
|
96
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
97
|
+
.map(plugin => ({ folder: plugin.migrations!, id: `plugin/${plugin.name}` }))
|
|
98
|
+
const project = path.join(root, 'db/migrations')
|
|
99
|
+
if (existsSync(project)) sources.push({ folder: project, id: 'project' })
|
|
100
|
+
|
|
101
|
+
return new Migrator({
|
|
102
|
+
db: instance,
|
|
103
|
+
// Plugins can gain migrations after a project migration has run. Folder-level
|
|
104
|
+
// validation keeps each source strict while Kysely ignores only global interleaving.
|
|
105
|
+
allowUnorderedMigrations: true,
|
|
106
|
+
provider: {
|
|
107
|
+
async getMigrations() {
|
|
108
|
+
return merge(sources)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function migrationStatus(instance: Kysely<any>, root = process.cwd()) {
|
|
115
|
+
const migrations = await migrator(instance, root).getMigrations()
|
|
116
|
+
const history = await instance
|
|
117
|
+
.selectFrom('sqlite_master')
|
|
118
|
+
.select('name')
|
|
119
|
+
.where('type', '=', 'table')
|
|
120
|
+
.where('name', '=', 'kysely_migration')
|
|
121
|
+
.executeTakeFirst()
|
|
122
|
+
if (!history) return migrations
|
|
123
|
+
|
|
124
|
+
const available = new Set(migrations.map(migration => migration.name))
|
|
125
|
+
const executed = await instance.selectFrom('kysely_migration').select('name').execute()
|
|
126
|
+
const missing = executed.map(migration => migration.name).filter(name => !available.has(name)).sort()
|
|
127
|
+
if (missing.length) {
|
|
128
|
+
throw new Error(`Migration history references missing migrations: ${missing.join(', ')}`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return migrations
|
|
132
|
+
}
|
package/src/node.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import * as url from 'node:url'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import * as http from 'node:http'
|
|
5
|
+
import * as vite from 'vite'
|
|
6
|
+
import polka from 'polka'
|
|
7
|
+
import sirv from 'sirv'
|
|
8
|
+
import * as headers from './headers'
|
|
9
|
+
|
|
10
|
+
const fallback = `<!DOCTYPE html>
|
|
11
|
+
<html lang="en">
|
|
12
|
+
<head>
|
|
13
|
+
<meta charset="UTF-8">
|
|
14
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
15
|
+
<!-- ssr:head -->
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<!-- ssr:data -->
|
|
19
|
+
<div id="root"><!-- ssr:root --></div>
|
|
20
|
+
<script src="/src/client" type="module"></script>
|
|
21
|
+
</body>
|
|
22
|
+
</html>`
|
|
23
|
+
|
|
24
|
+
const markers = /<!--\s*ssr:([A-Za-z0-9_]+)\s*-->/g
|
|
25
|
+
|
|
26
|
+
/** Compiles an HTML file with ssr:* comments into a slot renderer. */
|
|
27
|
+
export function compile(html: string) {
|
|
28
|
+
|
|
29
|
+
const parts = html.split(markers)
|
|
30
|
+
|
|
31
|
+
return (slots: Record<string, string>) =>
|
|
32
|
+
parts.map((part, index) => index % 2 ? slots[part] ?? '' : part).join('')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function html() {
|
|
36
|
+
try { return await fs.readFile('./index.html', 'utf-8') }
|
|
37
|
+
catch { return fallback }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Development server options accepted by dev(). */
|
|
41
|
+
export type Options = {
|
|
42
|
+
hmr?: vite.ServerOptions['hmr']
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Creates the development Polka app with Vite middleware and route reloads. */
|
|
46
|
+
export async function dev(options: Options = {}) {
|
|
47
|
+
|
|
48
|
+
const app = polka()
|
|
49
|
+
|
|
50
|
+
const server = await vite.createServer({
|
|
51
|
+
server: { middlewareMode: true, ...(options.hmr !== undefined && { hmr: options.hmr }) },
|
|
52
|
+
appType: 'custom',
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
app.use(server.middlewares)
|
|
56
|
+
|
|
57
|
+
let raw = await html()
|
|
58
|
+
raw = await server.transformIndexHtml('/', raw)
|
|
59
|
+
const template = compile(raw)
|
|
60
|
+
|
|
61
|
+
const { create } = await server.ssrLoadModule('ajo-kit/server')
|
|
62
|
+
let inner = await create(template)
|
|
63
|
+
|
|
64
|
+
app.use((req: any, res: any) => inner.handler(req, res))
|
|
65
|
+
|
|
66
|
+
const route = /(handler|wares|page|layout)\.[jt]sx?$/
|
|
67
|
+
const reload = async (file: string) => {
|
|
68
|
+
if (!route.test(file)) return
|
|
69
|
+
try {
|
|
70
|
+
const { create } = await server.ssrLoadModule('ajo-kit/server')
|
|
71
|
+
inner = await create(template)
|
|
72
|
+
console.log('\x1b[32m✓\x1b[0m Server routes reloaded')
|
|
73
|
+
if (/(page|layout)\.[jt]sx?$/.test(file)) server.ws.send({ type: 'full-reload', path: '*' })
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error('\x1b[31m✗\x1b[0m Failed to reload routes:')
|
|
76
|
+
console.error(error)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
server.watcher.on('change', reload)
|
|
81
|
+
server.watcher.on('add', reload)
|
|
82
|
+
server.watcher.on('unlink', reload)
|
|
83
|
+
|
|
84
|
+
return app
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Creates the production Polka app from dist/client and dist/server. */
|
|
88
|
+
export async function start() {
|
|
89
|
+
|
|
90
|
+
const app = polka()
|
|
91
|
+
|
|
92
|
+
const entry = url.pathToFileURL(join(process.cwd(), 'dist/server/server.js')).href
|
|
93
|
+
const { create } = await import(entry)
|
|
94
|
+
|
|
95
|
+
const inner = await create(compile(await fs.readFile(join(process.cwd(), 'dist/client/index.html'), 'utf-8')))
|
|
96
|
+
|
|
97
|
+
app.use(sirv(join(process.cwd(), 'dist/client'), {
|
|
98
|
+
extensions: [],
|
|
99
|
+
setHeaders: res => headers.set(res, headers.security(), true),
|
|
100
|
+
}))
|
|
101
|
+
app.use((req: any, res: any) => inner.handler(req, res))
|
|
102
|
+
|
|
103
|
+
return app
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Builds client and server bundles into dist/. */
|
|
107
|
+
export async function build() {
|
|
108
|
+
|
|
109
|
+
await vite.build({ build: { outDir: 'dist/client' } })
|
|
110
|
+
|
|
111
|
+
const entry = url.fileURLToPath(import.meta.resolve('ajo-kit/server'))
|
|
112
|
+
|
|
113
|
+
await vite.build({
|
|
114
|
+
build: {
|
|
115
|
+
outDir: 'dist/server',
|
|
116
|
+
ssr: entry,
|
|
117
|
+
rollupOptions: { output: { entryFileNames: 'server.js' } },
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Starts an app, incrementing the port when it is busy unless strict is set. */
|
|
123
|
+
export const listen = (app: any, port = 5173, options: { strict?: boolean } = {}): Promise<number> => new Promise((resolve, reject) => {
|
|
124
|
+
http.createServer(app.handler)
|
|
125
|
+
.listen(port, () => {
|
|
126
|
+
console.log(`Server started at http://localhost:${port}`)
|
|
127
|
+
resolve(port)
|
|
128
|
+
})
|
|
129
|
+
.once('error', (error: NodeJS.ErrnoException) =>
|
|
130
|
+
error.code === 'EADDRINUSE' && !options.strict
|
|
131
|
+
? resolve(listen(app, port + 1, options))
|
|
132
|
+
: reject(error)
|
|
133
|
+
)
|
|
134
|
+
})
|