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/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
+ }
@@ -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, '&amp;')
66
+ .replace(/</g, '&lt;')
67
+ .replace(/>/g, '&gt;')
68
+
69
+ const attribute = (value: string) => text(value).replace(/"/g, '&quot;')
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
+ })