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/cache.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { State } from './constants'
|
|
2
|
+
|
|
3
|
+
export const max = 50
|
|
4
|
+
export const ttl = 5 * 60 * 1000
|
|
5
|
+
|
|
6
|
+
type Meta = {
|
|
7
|
+
cached: number
|
|
8
|
+
used: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const cache = new Map<string, State>()
|
|
12
|
+
|
|
13
|
+
const meta = new Map<string, Meta>()
|
|
14
|
+
|
|
15
|
+
const now = () => Date.now()
|
|
16
|
+
|
|
17
|
+
const remove = (url: string) => {
|
|
18
|
+
cache.delete(url)
|
|
19
|
+
meta.delete(url)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const clear = () => {
|
|
23
|
+
cache.clear()
|
|
24
|
+
meta.clear()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const get = (url: string, time = now()) => {
|
|
28
|
+
const state = cache.get(url)
|
|
29
|
+
const info = meta.get(url)
|
|
30
|
+
|
|
31
|
+
if (!state || !info) return
|
|
32
|
+
|
|
33
|
+
if (time - info.cached > ttl) {
|
|
34
|
+
remove(url)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
info.used = time
|
|
39
|
+
|
|
40
|
+
return state
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const prune = (active?: string, time = now()) => {
|
|
44
|
+
for (const [url, info] of meta) {
|
|
45
|
+
if (url !== active && time - info.cached > ttl) remove(url)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
while (cache.size > max) {
|
|
49
|
+
let candidate: string | undefined
|
|
50
|
+
let oldest = Infinity
|
|
51
|
+
|
|
52
|
+
for (const [url, info] of meta) {
|
|
53
|
+
if (url === active) continue
|
|
54
|
+
if (info.used < oldest) {
|
|
55
|
+
oldest = info.used
|
|
56
|
+
candidate = url
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!candidate) break
|
|
61
|
+
|
|
62
|
+
remove(candidate)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const set = (url: string, state: State, options?: { active?: string; now?: number }) => {
|
|
67
|
+
const time = options?.now ?? now()
|
|
68
|
+
|
|
69
|
+
cache.set(url, state)
|
|
70
|
+
meta.set(url, { cached: time, used: time })
|
|
71
|
+
prune(options?.active ?? url, time)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const invalidate = (topics?: string[]) => {
|
|
75
|
+
if (!topics?.length) {
|
|
76
|
+
clear()
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const changed = new Set(topics)
|
|
81
|
+
|
|
82
|
+
for (const [url, state] of cache) {
|
|
83
|
+
if (!state.topics?.length || state.topics.some(topic => changed.has(topic))) remove(url)
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/client.tsx
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { render } from 'ajo'
|
|
2
|
+
import { current } from 'ajo/context'
|
|
3
|
+
import App, { init } from './app'
|
|
4
|
+
import type { State, Action } from './constants'
|
|
5
|
+
import { navigate } from './constants'
|
|
6
|
+
import { fields, body as make } from './form'
|
|
7
|
+
import { parse } from './ssr'
|
|
8
|
+
import { invalidate } from './cache'
|
|
9
|
+
|
|
10
|
+
// Action helper for stateful generator components
|
|
11
|
+
|
|
12
|
+
/** Creates state and submit/invoke helpers for a route action. */
|
|
13
|
+
export function action<T = unknown>(name?: string, init?: RequestInit): Action<T> {
|
|
14
|
+
|
|
15
|
+
const component = current()
|
|
16
|
+
|
|
17
|
+
if (!component) throw new Error('action() must be called inside a stateful component.')
|
|
18
|
+
|
|
19
|
+
let controller: AbortController | undefined
|
|
20
|
+
|
|
21
|
+
const state: Action<T> = {
|
|
22
|
+
loading: false,
|
|
23
|
+
data: undefined,
|
|
24
|
+
error: undefined,
|
|
25
|
+
submit: () => { },
|
|
26
|
+
invoke: (value?) => run(value),
|
|
27
|
+
reset: () => {
|
|
28
|
+
const current = controller
|
|
29
|
+
|
|
30
|
+
controller = undefined
|
|
31
|
+
current?.abort()
|
|
32
|
+
state.loading = false
|
|
33
|
+
state.data = undefined
|
|
34
|
+
state.error = undefined
|
|
35
|
+
component.next()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const run = async (value: unknown): Promise<T | undefined> => {
|
|
40
|
+
|
|
41
|
+
controller?.abort()
|
|
42
|
+
const current = controller = new AbortController()
|
|
43
|
+
|
|
44
|
+
const forwardAbort = (signal?: AbortSignal | null) => {
|
|
45
|
+
if (!signal) return
|
|
46
|
+
if (signal.aborted) current.abort(signal.reason)
|
|
47
|
+
else signal.addEventListener('abort', () => current.abort(signal.reason), { once: true, signal: current.signal })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
forwardAbort(component.signal)
|
|
51
|
+
forwardAbort(init?.signal)
|
|
52
|
+
|
|
53
|
+
state.loading = true
|
|
54
|
+
state.error = undefined
|
|
55
|
+
component.next()
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
|
|
59
|
+
const response = await fetch(name ? `?/${name}` : '', {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
credentials: 'include',
|
|
62
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
63
|
+
body: JSON.stringify(value),
|
|
64
|
+
...init,
|
|
65
|
+
signal: current.signal,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const json = await response.json().catch(() => null) as
|
|
69
|
+
| { redirect?: string; topics?: string[]; versions?: Record<string, number>; error?: { status?: number; message?: string; fields?: Record<string, string[] | undefined> }; message?: string; fields?: Record<string, string[] | undefined> }
|
|
70
|
+
| null
|
|
71
|
+
|
|
72
|
+
if (controller !== current || current.signal.aborted) return
|
|
73
|
+
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
|
|
76
|
+
state.error = {
|
|
77
|
+
status: json?.error?.status ?? response.status,
|
|
78
|
+
message: json?.error?.message ?? json?.message ?? 'Action failed',
|
|
79
|
+
fields: json?.error?.fields ?? json?.fields
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
invalidate(json?.topics)
|
|
86
|
+
|
|
87
|
+
if (json?.redirect) {
|
|
88
|
+
navigate(json.redirect)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
state.data = (json ?? {}) as T
|
|
93
|
+
globalThis.dispatchEvent?.(new CustomEvent('ajo:action', { detail: json ?? {} }))
|
|
94
|
+
|
|
95
|
+
return state.data
|
|
96
|
+
|
|
97
|
+
} catch (error) {
|
|
98
|
+
|
|
99
|
+
if (current.signal.aborted || error instanceof Error && error.name === 'AbortError') return
|
|
100
|
+
|
|
101
|
+
if (controller !== current) return
|
|
102
|
+
|
|
103
|
+
state.error = {
|
|
104
|
+
status: 500,
|
|
105
|
+
message: error instanceof Error ? error.message : 'Action failed'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
} finally {
|
|
111
|
+
if (controller === current) {
|
|
112
|
+
controller = undefined
|
|
113
|
+
state.loading = false
|
|
114
|
+
component.next()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
state.submit = (event: SubmitEvent) => {
|
|
120
|
+
event.preventDefault()
|
|
121
|
+
const form = event.target as HTMLFormElement
|
|
122
|
+
const data = make(new FormData(form), fields(form))
|
|
123
|
+
run(data).then(() => { if (!state.error) form.reset() })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return state
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!import.meta.env.SSR) {
|
|
130
|
+
const script = globalThis.document?.getElementById('__SSR__')
|
|
131
|
+
const data = script?.textContent ? parse<State>(script.textContent) : null
|
|
132
|
+
init(data)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (import.meta.hot) {
|
|
136
|
+
|
|
137
|
+
const HMR = Symbol.for('ajo.hmr')
|
|
138
|
+
const Generator = Symbol.for('ajo.generator')
|
|
139
|
+
const Iterator = Symbol.for('ajo.iterator')
|
|
140
|
+
const Memo = Symbol.for('ajo.memo')
|
|
141
|
+
|
|
142
|
+
type HMRElement = Element & {
|
|
143
|
+
[Generator]?: { [HMR]?: string } | null
|
|
144
|
+
[Iterator]?: unknown
|
|
145
|
+
[Memo]?: unknown
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const walk = (el: HMRElement, path?: string): void => {
|
|
149
|
+
if (el[Generator]?.[HMR] == path) el[Iterator] = el[Generator] = null
|
|
150
|
+
el[Memo] = null
|
|
151
|
+
Array.from(el.children).forEach(child => walk(child, path))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const root = document.getElementById('root');
|
|
155
|
+
|
|
156
|
+
(globalThis as { __HMR__?: (path?: string) => void }).__HMR__ = path => {
|
|
157
|
+
if (root) walk(root, path)
|
|
158
|
+
dispatchEvent(new CustomEvent('hmr'))
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const root = globalThis?.document?.getElementById('root')
|
|
163
|
+
|
|
164
|
+
if (root) {
|
|
165
|
+
render(<App />, root)
|
|
166
|
+
document.documentElement.dataset.ajoReady = 'true'
|
|
167
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import type { Children, Component } from 'ajo'
|
|
2
|
+
import type { Params } from 'navaid'
|
|
3
|
+
import type { Request, Response, Middleware } from 'polka'
|
|
4
|
+
/** Polka request, response, and middleware types used by route modules. */
|
|
5
|
+
export type { Request, Response, Middleware }
|
|
6
|
+
/** Document head payload returned by route head loaders. */
|
|
7
|
+
export type { Head } from './head'
|
|
8
|
+
import type { Head } from './head'
|
|
9
|
+
import type { Timing } from './timing'
|
|
10
|
+
|
|
11
|
+
// Route errors with HTTP status codes
|
|
12
|
+
|
|
13
|
+
/** HTTP-aware error serialized by loaders, actions, and API handlers. */
|
|
14
|
+
export class Failure extends Error {
|
|
15
|
+
|
|
16
|
+
constructor(public status: number, message: string) {
|
|
17
|
+
super(message)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
toJSON() {
|
|
21
|
+
return {
|
|
22
|
+
message: mask(this.status, this.message),
|
|
23
|
+
status: this.status,
|
|
24
|
+
...(!production() && import.meta.env.DEV && { stack: this.stack })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 404 error for missing routes or resources. */
|
|
30
|
+
export class Missing extends Failure {
|
|
31
|
+
constructor(message = 'Page not found') {
|
|
32
|
+
super(404, message)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 403 error for authenticated users without enough access. */
|
|
37
|
+
export class Forbidden extends Failure {
|
|
38
|
+
constructor(message = 'Access denied') {
|
|
39
|
+
super(403, message)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 401 error for requests that need authentication. */
|
|
44
|
+
export class Denied extends Failure {
|
|
45
|
+
constructor(message = 'Authentication required') {
|
|
46
|
+
super(401, message)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Field-level validation errors keyed by form field name. */
|
|
51
|
+
export type Fields = Record<string, string[] | undefined>
|
|
52
|
+
|
|
53
|
+
/** Client action error shape exposed by action() state. */
|
|
54
|
+
export type Issue = {
|
|
55
|
+
status: number
|
|
56
|
+
message: string
|
|
57
|
+
fields?: Fields
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 400 validation error carrying field-level messages. */
|
|
61
|
+
export class Invalid extends Failure {
|
|
62
|
+
constructor(public fields: Fields, message = 'Validation failed') {
|
|
63
|
+
super(400, message)
|
|
64
|
+
}
|
|
65
|
+
toJSON() {
|
|
66
|
+
return {
|
|
67
|
+
message: mask(this.status, this.message),
|
|
68
|
+
status: this.status,
|
|
69
|
+
fields: this.fields,
|
|
70
|
+
...(!production() && import.meta.env.DEV && { stack: this.stack })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const production = () => process.env.NODE_ENV === 'production'
|
|
76
|
+
const mask = (status: number, message: string) =>
|
|
77
|
+
production() && status >= 500 ? 'Internal Server Error' : message
|
|
78
|
+
const config = (message: string) => {
|
|
79
|
+
if (production()) console.error(`[security] ${message}`)
|
|
80
|
+
return new Failure(500, message)
|
|
81
|
+
}
|
|
82
|
+
const code = (error: unknown) => {
|
|
83
|
+
if (!error || typeof error !== 'object') return 500
|
|
84
|
+
|
|
85
|
+
const value = (error as { status?: unknown }).status ?? (error as { statusCode?: unknown }).statusCode
|
|
86
|
+
|
|
87
|
+
return Number.isInteger(value) && (value as number) >= 400 && (value as number) <= 599
|
|
88
|
+
? value as number
|
|
89
|
+
: 500
|
|
90
|
+
}
|
|
91
|
+
const message = (error: unknown, status: number) => {
|
|
92
|
+
if (status === 413) return 'Content Too Large'
|
|
93
|
+
if (error instanceof Error && error.message) return error.message
|
|
94
|
+
return status < 500 ? 'Request failed' : 'Unknown error'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Converts any thrown value into a framework Failure. */
|
|
98
|
+
export function normalize(error: unknown): Failure {
|
|
99
|
+
if (error instanceof Failure) return error
|
|
100
|
+
const status = code(error)
|
|
101
|
+
return new Failure(status, message(error, status))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Route path utilities
|
|
105
|
+
|
|
106
|
+
/** Returns route ancestor keys from outermost to innermost. */
|
|
107
|
+
export const ancestors = (segments: string[]) => segments.map((_, i) => segments.slice(0, i + 1).join('/'))
|
|
108
|
+
|
|
109
|
+
// Loader data types
|
|
110
|
+
|
|
111
|
+
/** Data object returned by one loader. */
|
|
112
|
+
export type Entry = Record<string, unknown>
|
|
113
|
+
|
|
114
|
+
/** Ordered loader data for layouts followed by the page. */
|
|
115
|
+
export type Data = Entry[]
|
|
116
|
+
|
|
117
|
+
/** Server payload containing head data followed by loader entries. */
|
|
118
|
+
export type Payload = [Head, ...Data]
|
|
119
|
+
|
|
120
|
+
/** Reads merged data from ancestor loaders. */
|
|
121
|
+
export type Parent = () => Promise<Entry>
|
|
122
|
+
|
|
123
|
+
// Route module types
|
|
124
|
+
|
|
125
|
+
/** Runtime shape of a route page or layout module. */
|
|
126
|
+
export type Module = {
|
|
127
|
+
default: Component
|
|
128
|
+
/** Receives loading=true while client navigation is pending. */
|
|
129
|
+
pending?: boolean
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Lazy module loader generated by Vite route globs. */
|
|
133
|
+
export type Loader = () => Promise<Module>
|
|
134
|
+
|
|
135
|
+
/** Compiled route page metadata used by the client and server routers. */
|
|
136
|
+
export type Page = {
|
|
137
|
+
loader: Loader
|
|
138
|
+
segments: string[]
|
|
139
|
+
pattern?: string
|
|
140
|
+
params?: Params
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Client route state produced by SSR, JSON loads, and navigation. */
|
|
144
|
+
export interface State {
|
|
145
|
+
url: string
|
|
146
|
+
params: Params
|
|
147
|
+
data: Data
|
|
148
|
+
loading: boolean
|
|
149
|
+
error?: Failure
|
|
150
|
+
head?: Head
|
|
151
|
+
hash?: string
|
|
152
|
+
topics?: string[]
|
|
153
|
+
versions?: Record<string, number>
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** State and commands returned by the client action() helper. */
|
|
157
|
+
export type Action<T> = {
|
|
158
|
+
loading: boolean
|
|
159
|
+
data?: T
|
|
160
|
+
error?: Issue
|
|
161
|
+
submit: (event: SubmitEvent) => void
|
|
162
|
+
invoke: (body?: unknown) => Promise<T | undefined>
|
|
163
|
+
/** Aborts any in-flight request and clears loading, data, and error state. */
|
|
164
|
+
reset: () => void
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Page and layout args
|
|
168
|
+
|
|
169
|
+
/** Route page component args. */
|
|
170
|
+
export type PageArgs<T = Entry> = {
|
|
171
|
+
params: Params
|
|
172
|
+
data?: T
|
|
173
|
+
loading: boolean
|
|
174
|
+
error?: Failure
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Route layout component args, including children. */
|
|
178
|
+
export type LayoutArgs<T = Entry> = PageArgs<T> & {
|
|
179
|
+
children: Children
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Navigation helper
|
|
183
|
+
|
|
184
|
+
/** Pushes a browser URL and asks the client router to navigate. */
|
|
185
|
+
export const navigate = (to: string) => {
|
|
186
|
+
globalThis.history?.pushState({}, '', to)
|
|
187
|
+
globalThis.dispatchEvent?.(new CustomEvent('ajo:navigate'))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Request helpers
|
|
191
|
+
|
|
192
|
+
/** Returns true when a request expects JSON route/action data. */
|
|
193
|
+
export const ajax = (req: Request) => !!req.headers.accept?.includes('application/json')
|
|
194
|
+
/** Returns true when a request targets the /api route namespace. */
|
|
195
|
+
export const api = (req: Request) => req.path.startsWith('/api/')
|
|
196
|
+
|
|
197
|
+
const enabled = (value: string | undefined) => value === '1' || value?.toLowerCase() === 'true'
|
|
198
|
+
const proxy = () => enabled(process.env.TRUST_PROXY)
|
|
199
|
+
|
|
200
|
+
const first = (value: string | string[] | undefined) =>
|
|
201
|
+
Array.isArray(value) ? value[0] : value
|
|
202
|
+
|
|
203
|
+
const address = (value: string) => {
|
|
204
|
+
const raw = value.trim().replace(/^\[/, '').replace(/\]$/, '').replace(/^::ffff:/, '')
|
|
205
|
+
return raw === '::1' || raw === '127.0.0.1' ? 'localhost' : raw
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const ipv4 = (value: string) => {
|
|
209
|
+
const parts = value.split('.')
|
|
210
|
+
return parts.length === 4 && parts.every(part => {
|
|
211
|
+
if (!/^\d{1,3}$/.test(part)) return false
|
|
212
|
+
const number = Number(part)
|
|
213
|
+
return number >= 0 && number <= 255
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const ipv6 = (value: string) =>
|
|
218
|
+
value.includes(':') && /^[0-9a-f:.]+$/i.test(value)
|
|
219
|
+
|
|
220
|
+
const local = (host: string) => {
|
|
221
|
+
try {
|
|
222
|
+
return address(new URL(`http://${host}`).hostname) === 'localhost'
|
|
223
|
+
} catch {
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const forwarded = (header: string | string[] | undefined) => {
|
|
229
|
+
const value = first(header)
|
|
230
|
+
if (!value) return
|
|
231
|
+
|
|
232
|
+
const addr = address(value.split(',')[0])
|
|
233
|
+
if (addr === 'localhost' || ipv4(addr) || ipv6(addr)) return addr
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Resolves the client IP, honoring TRUST_PROXY for forwarded headers. */
|
|
237
|
+
export const ip = (req: Request) => {
|
|
238
|
+
const raw = proxy()
|
|
239
|
+
? forwarded(req.headers['x-forwarded-for']) ?? req.socket?.remoteAddress
|
|
240
|
+
: req.socket?.remoteAddress
|
|
241
|
+
|
|
242
|
+
return raw ? address(raw) : 'unknown'
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Resolves the trusted app origin from APP_URL or the request host. */
|
|
246
|
+
export const origin = (req: Request) => {
|
|
247
|
+
const configured = process.env.APP_URL
|
|
248
|
+
|
|
249
|
+
if (configured) {
|
|
250
|
+
try {
|
|
251
|
+
const url = new URL(configured)
|
|
252
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error()
|
|
253
|
+
return url.origin
|
|
254
|
+
} catch {
|
|
255
|
+
throw config('Invalid APP_URL')
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const host = req.headers.host
|
|
260
|
+
if (!host) throw new Failure(400, 'Missing Host header')
|
|
261
|
+
|
|
262
|
+
if (production() && !local(host)) {
|
|
263
|
+
throw config('APP_URL is required in production')
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const forwarded = first(req.headers['x-forwarded-proto'])?.split(',')[0]?.trim()
|
|
267
|
+
const protocol = proxy() && (forwarded === 'http' || forwarded === 'https') ? forwarded : 'http'
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
return new URL(`${protocol}://${host}`).origin
|
|
271
|
+
} catch {
|
|
272
|
+
throw new Failure(400, 'Invalid Host header')
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Auth types
|
|
277
|
+
|
|
278
|
+
/** Authenticated user shape attached to requests by auth middleware. */
|
|
279
|
+
export interface User {
|
|
280
|
+
id: number
|
|
281
|
+
roles?: string[]
|
|
282
|
+
abilities?: string[]
|
|
283
|
+
[key: string]: unknown
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Request extensions for polka
|
|
287
|
+
|
|
288
|
+
declare module 'polka' {
|
|
289
|
+
interface Request {
|
|
290
|
+
user?: User
|
|
291
|
+
session?: { id: string }
|
|
292
|
+
token?: { id: string; abilities: string[] }
|
|
293
|
+
topics?: Set<string>
|
|
294
|
+
track?: (topic: string | string[]) => void
|
|
295
|
+
verifyLive?: () => Promise<boolean>
|
|
296
|
+
timing?: Timing
|
|
297
|
+
revalidate?: () => Promise<Payload>
|
|
298
|
+
head?: Head
|
|
299
|
+
entries?: Data
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Deferred promises for parallel loader execution
|
|
304
|
+
|
|
305
|
+
/** Internal parent/deferred link used to run loaders in parallel. */
|
|
306
|
+
export type Link = {
|
|
307
|
+
parent: Parent
|
|
308
|
+
deferred: { promise: Promise<Entry>; resolve: (value: Entry) => void; reject: (error: Error) => void }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Builds parent/deferred links for a loader chain. */
|
|
312
|
+
export function links(count: number): Link[] {
|
|
313
|
+
|
|
314
|
+
const chain: Link[] = []
|
|
315
|
+
|
|
316
|
+
for (let depth = 0; depth < count; depth++) {
|
|
317
|
+
|
|
318
|
+
let resolve!: (value: Entry) => void
|
|
319
|
+
let reject!: (error: Error) => void
|
|
320
|
+
|
|
321
|
+
const promise = new Promise<Entry>((res, rej) => {
|
|
322
|
+
resolve = res
|
|
323
|
+
reject = rej
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
const parent = async () =>
|
|
327
|
+
Object.assign({}, ...await Promise.all(chain.slice(0, depth).map(link => link.deferred.promise)))
|
|
328
|
+
|
|
329
|
+
chain.push({ parent, deferred: { promise, resolve, reject } })
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return chain
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Formatting
|
|
336
|
+
|
|
337
|
+
/** Formats an ISO date for the current locale with compact defaults. */
|
|
338
|
+
export const date = (iso: string, options?: Intl.DateTimeFormatOptions) =>
|
|
339
|
+
new Date(iso).toLocaleDateString(undefined, options ?? { month: 'short', day: 'numeric', year: 'numeric' })
|
package/src/database.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import { Kysely, SqliteDialect } from 'kysely'
|
|
3
|
+
import type * as BetterSqlite3 from 'better-sqlite3'
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.resolve('ajo-kit'))
|
|
6
|
+
|
|
7
|
+
/** Kysely SQL template helper. */
|
|
8
|
+
export { sql } from 'kysely'
|
|
9
|
+
/** Kysely database and row helper types. */
|
|
10
|
+
export type { Kysely, Generated, Selectable, Insertable } from 'kysely'
|
|
11
|
+
/** better-sqlite3 constructor resolved from ajo-kit's own installation. */
|
|
12
|
+
export const Database = require('better-sqlite3') as typeof import('better-sqlite3')
|
|
13
|
+
/** better-sqlite3 database handle type. */
|
|
14
|
+
export type Database = BetterSqlite3.Database
|
|
15
|
+
/** Alias for the active SQLite database handle. */
|
|
16
|
+
export type Sqlite = Database
|
|
17
|
+
|
|
18
|
+
let sqlite: Sqlite | null = null
|
|
19
|
+
let instance: Kysely<any> | null = null
|
|
20
|
+
|
|
21
|
+
/** Opens the SQLite database and configures safe defaults. */
|
|
22
|
+
export function connect(path = './database.sqlite'): Sqlite {
|
|
23
|
+
sqlite = new Database(path)
|
|
24
|
+
sqlite.pragma('journal_mode = WAL')
|
|
25
|
+
sqlite.pragma('foreign_keys = ON')
|
|
26
|
+
sqlite.pragma('busy_timeout = 5000')
|
|
27
|
+
sqlite.pragma('synchronous = NORMAL')
|
|
28
|
+
return sqlite
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Returns the shared Kysely instance, opening SQLite on first use. */
|
|
32
|
+
export function db<T = any>(): Kysely<T> {
|
|
33
|
+
if (!sqlite) connect()
|
|
34
|
+
return instance ??= new Kysely<T>({
|
|
35
|
+
dialect: new SqliteDialect({ database: sqlite! })
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Returns the shared raw better-sqlite3 handle. */
|
|
40
|
+
export function raw(): Sqlite {
|
|
41
|
+
if (!sqlite) connect()
|
|
42
|
+
return sqlite!
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Destroys the shared Kysely instance and closes SQLite. */
|
|
46
|
+
export async function close(): Promise<void> {
|
|
47
|
+
if (instance) {
|
|
48
|
+
await instance.destroy()
|
|
49
|
+
instance = null
|
|
50
|
+
}
|
|
51
|
+
if (sqlite) {
|
|
52
|
+
sqlite.close()
|
|
53
|
+
sqlite = null
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/discover.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface Plugin {
|
|
5
|
+
name: string
|
|
6
|
+
path: string
|
|
7
|
+
alias?: string
|
|
8
|
+
serverOnly?: boolean
|
|
9
|
+
migrations?: string
|
|
10
|
+
commands?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function discover(root = process.cwd()): Plugin[] {
|
|
14
|
+
|
|
15
|
+
const modules = join(root, 'node_modules')
|
|
16
|
+
const plugins: Plugin[] = []
|
|
17
|
+
|
|
18
|
+
let entries: string[]
|
|
19
|
+
try { entries = readdirSync(modules) } catch { return plugins }
|
|
20
|
+
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
|
|
23
|
+
if (!entry.startsWith('ajo-') || entry === 'ajo-kit') continue
|
|
24
|
+
|
|
25
|
+
const dir = join(modules, entry)
|
|
26
|
+
let pkg: any
|
|
27
|
+
|
|
28
|
+
try { pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) } catch { continue }
|
|
29
|
+
|
|
30
|
+
if (!pkg.kit) continue
|
|
31
|
+
|
|
32
|
+
plugins.push({
|
|
33
|
+
name: pkg.name,
|
|
34
|
+
path: dir,
|
|
35
|
+
...pkg.kit,
|
|
36
|
+
...(pkg.kit.migrations && { migrations: join(dir, pkg.kit.migrations) }),
|
|
37
|
+
...(pkg.kit.commands && { commands: join(dir, pkg.kit.commands) }),
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return plugins
|
|
42
|
+
}
|