@strav/http 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/package.json +50 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +87 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/middleware/authenticate.ts +64 -0
- package/src/auth/middleware/csrf.ts +62 -0
- package/src/auth/middleware/guest.ts +46 -0
- package/src/http/context.ts +220 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +18 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/resource.ts +102 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +159 -0
- package/src/index.ts +7 -0
- package/src/middleware/http_cache.ts +106 -0
- package/src/middleware/i18n.ts +84 -0
- package/src/middleware/request_logger.ts +19 -0
- package/src/policy/authorize.ts +44 -0
- package/src/policy/index.ts +3 -0
- package/src/policy/policy_result.ts +13 -0
- package/src/providers/auth_provider.ts +35 -0
- package/src/providers/http_provider.ts +27 -0
- package/src/providers/index.ts +7 -0
- package/src/providers/session_provider.ts +29 -0
- package/src/providers/view_provider.ts +18 -0
- package/src/session/index.ts +4 -0
- package/src/session/middleware.ts +46 -0
- package/src/session/session.ts +308 -0
- package/src/session/session_manager.ts +83 -0
- package/src/validation/index.ts +18 -0
- package/src/validation/rules.ts +170 -0
- package/src/validation/validate.ts +41 -0
- package/src/view/cache.ts +47 -0
- package/src/view/client/islands.ts +84 -0
- package/src/view/compiler.ts +199 -0
- package/src/view/engine.ts +139 -0
- package/src/view/escape.ts +14 -0
- package/src/view/index.ts +13 -0
- package/src/view/islands/island_builder.ts +338 -0
- package/src/view/islands/vue_plugin.ts +136 -0
- package/src/view/middleware/static.ts +69 -0
- package/src/view/tokenizer.ts +182 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type Context from '../http/context.ts'
|
|
2
|
+
import { clearCookie } from '../http/cookie.ts'
|
|
3
|
+
import { randomHex } from '@stravigor/kernel/helpers/crypto'
|
|
4
|
+
import { extractUserId } from '@stravigor/database/helpers/identity'
|
|
5
|
+
import SessionManager from './session_manager.ts'
|
|
6
|
+
|
|
7
|
+
const FLASH_KEY = '_flash'
|
|
8
|
+
const FLASH_OLD_KEY = '_flash_old'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Unified server-side session backed by a database row and an HTTP-only cookie.
|
|
12
|
+
*
|
|
13
|
+
* Serves both anonymous visitors and authenticated users. Stores arbitrary
|
|
14
|
+
* key-value data in a JSONB column and supports flash data (available only
|
|
15
|
+
* on the next request).
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* // Read / write data (anonymous or authenticated)
|
|
19
|
+
* const session = ctx.get<Session>('session')
|
|
20
|
+
* session.set('cart', [item])
|
|
21
|
+
* session.flash('success', 'Item added!')
|
|
22
|
+
*
|
|
23
|
+
* // Login
|
|
24
|
+
* session.authenticate(user)
|
|
25
|
+
* await session.regenerate()
|
|
26
|
+
*
|
|
27
|
+
* // Logout
|
|
28
|
+
* return Session.destroy(ctx, ctx.redirect('/login'))
|
|
29
|
+
*/
|
|
30
|
+
export default class Session {
|
|
31
|
+
private _id: string
|
|
32
|
+
private _userId: string | null
|
|
33
|
+
private _csrfToken: string
|
|
34
|
+
private _data: Record<string, unknown>
|
|
35
|
+
private _dirty = false
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
id: string,
|
|
39
|
+
userId: string | null,
|
|
40
|
+
csrfToken: string,
|
|
41
|
+
data: Record<string, unknown>,
|
|
42
|
+
readonly ipAddress: string | null,
|
|
43
|
+
readonly userAgent: string | null,
|
|
44
|
+
readonly lastActivity: Date,
|
|
45
|
+
readonly createdAt: Date
|
|
46
|
+
) {
|
|
47
|
+
this._id = id
|
|
48
|
+
this._userId = userId
|
|
49
|
+
this._csrfToken = csrfToken
|
|
50
|
+
this._data = data
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Getters
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
get id(): string {
|
|
58
|
+
return this._id
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get userId(): string | null {
|
|
62
|
+
return this._userId
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get csrfToken(): string {
|
|
66
|
+
return this._csrfToken
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get isAuthenticated(): boolean {
|
|
70
|
+
return this._userId !== null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get isDirty(): boolean {
|
|
74
|
+
return this._dirty
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Data bag
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/** Get a value from the session data. */
|
|
82
|
+
get<T = unknown>(key: string, defaultValue?: T): T {
|
|
83
|
+
return (this._data[key] as T) ?? (defaultValue as T)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Set a persistent session value. */
|
|
87
|
+
set(key: string, value: unknown): void {
|
|
88
|
+
this._data[key] = value
|
|
89
|
+
this._dirty = true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Check whether a key exists in the session data. */
|
|
93
|
+
has(key: string): boolean {
|
|
94
|
+
return key in this._data && !key.startsWith('_flash')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Remove a key from the session data. */
|
|
98
|
+
forget(key: string): void {
|
|
99
|
+
delete this._data[key]
|
|
100
|
+
this._dirty = true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Remove all session data (keeps the session row alive). */
|
|
104
|
+
flush(): void {
|
|
105
|
+
this._data = {}
|
|
106
|
+
this._dirty = true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Return all user-facing session data (excludes flash internals). */
|
|
110
|
+
all(): Record<string, unknown> {
|
|
111
|
+
const result: Record<string, unknown> = {}
|
|
112
|
+
for (const [key, value] of Object.entries(this._data)) {
|
|
113
|
+
if (!key.startsWith('_flash')) {
|
|
114
|
+
result[key] = value
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return result
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Flash data
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/** Set flash data that will be available only on the next request. */
|
|
125
|
+
flash(key: string, value: unknown): void {
|
|
126
|
+
const bag = (this._data[FLASH_KEY] ?? {}) as Record<string, unknown>
|
|
127
|
+
bag[key] = value
|
|
128
|
+
this._data[FLASH_KEY] = bag
|
|
129
|
+
this._dirty = true
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Get flash data set by the previous request. */
|
|
133
|
+
getFlash<T = unknown>(key: string): T | undefined {
|
|
134
|
+
const old = this._data[FLASH_OLD_KEY] as Record<string, unknown> | undefined
|
|
135
|
+
return old?.[key] as T | undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Check if there is flash data for the given key (from previous request). */
|
|
139
|
+
hasFlash(key: string): boolean {
|
|
140
|
+
const old = this._data[FLASH_OLD_KEY] as Record<string, unknown> | undefined
|
|
141
|
+
return old !== undefined && key in old
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Rotate flash data for the next request cycle. Called once per request
|
|
146
|
+
* by the session middleware before the handler runs.
|
|
147
|
+
*
|
|
148
|
+
* Moves `_flash` → `_flash_old` (readable this request), then clears `_flash`.
|
|
149
|
+
* Only marks dirty if there was actually flash data to rotate.
|
|
150
|
+
*/
|
|
151
|
+
ageFlash(): void {
|
|
152
|
+
const current = this._data[FLASH_KEY]
|
|
153
|
+
const old = this._data[FLASH_OLD_KEY]
|
|
154
|
+
|
|
155
|
+
if (current !== undefined || old !== undefined) {
|
|
156
|
+
this._data[FLASH_OLD_KEY] = current ?? {}
|
|
157
|
+
delete this._data[FLASH_KEY]
|
|
158
|
+
this._dirty = true
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Authentication
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/** Associate this session with a user (login). */
|
|
167
|
+
authenticate(user: unknown): void {
|
|
168
|
+
this._userId = extractUserId(user)
|
|
169
|
+
this._dirty = true
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Disassociate the user from this session. */
|
|
173
|
+
clearUser(): void {
|
|
174
|
+
this._userId = null
|
|
175
|
+
this._dirty = true
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Lifecycle
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
/** Whether this session has exceeded its configured lifetime. */
|
|
183
|
+
isExpired(): boolean {
|
|
184
|
+
const lifetimeMs = SessionManager.config.lifetime * 60_000
|
|
185
|
+
return Date.now() - this.lastActivity.getTime() > lifetimeMs
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Update the last_activity timestamp to keep the session alive. */
|
|
189
|
+
async touch(): Promise<void> {
|
|
190
|
+
await SessionManager.db.sql`
|
|
191
|
+
UPDATE "_strav_sessions"
|
|
192
|
+
SET "last_activity" = NOW()
|
|
193
|
+
WHERE "id" = ${this._id}
|
|
194
|
+
`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Regenerate the session ID and CSRF token. Use after login to
|
|
199
|
+
* prevent session fixation attacks.
|
|
200
|
+
*/
|
|
201
|
+
async regenerate(): Promise<void> {
|
|
202
|
+
const oldId = this._id
|
|
203
|
+
this._id = crypto.randomUUID()
|
|
204
|
+
this._csrfToken = randomHex(32)
|
|
205
|
+
this._dirty = true
|
|
206
|
+
|
|
207
|
+
await this.save()
|
|
208
|
+
await SessionManager.db.sql`
|
|
209
|
+
DELETE FROM "_strav_sessions" WHERE "id" = ${oldId}
|
|
210
|
+
`
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Persist session data to the database. Uses an upsert so both new
|
|
215
|
+
* and existing sessions go through the same code path.
|
|
216
|
+
* No-op if the session has not been modified.
|
|
217
|
+
*/
|
|
218
|
+
async save(): Promise<void> {
|
|
219
|
+
if (!this._dirty) return
|
|
220
|
+
|
|
221
|
+
const dataToSave = { ...this._data }
|
|
222
|
+
delete dataToSave[FLASH_OLD_KEY]
|
|
223
|
+
|
|
224
|
+
await SessionManager.db.sql`
|
|
225
|
+
INSERT INTO "_strav_sessions"
|
|
226
|
+
("id", "user_id", "csrf_token", "data", "ip_address", "user_agent", "last_activity")
|
|
227
|
+
VALUES
|
|
228
|
+
(${this._id}, ${this._userId}, ${this._csrfToken},
|
|
229
|
+
${JSON.stringify(dataToSave)}::jsonb, ${this.ipAddress}, ${this.userAgent}, NOW())
|
|
230
|
+
ON CONFLICT ("id") DO UPDATE SET
|
|
231
|
+
"user_id" = EXCLUDED."user_id",
|
|
232
|
+
"csrf_token" = EXCLUDED."csrf_token",
|
|
233
|
+
"data" = EXCLUDED."data",
|
|
234
|
+
"last_activity" = NOW()
|
|
235
|
+
`
|
|
236
|
+
this._dirty = false
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Static API
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
/** Create a new anonymous session (not yet persisted — call save() or let the middleware handle it). */
|
|
244
|
+
static create(ctx: Context): Session {
|
|
245
|
+
const id = crypto.randomUUID()
|
|
246
|
+
const csrfToken = randomHex(32)
|
|
247
|
+
const ipAddress = ctx.header('x-forwarded-for') ?? null
|
|
248
|
+
const userAgent = ctx.header('user-agent') ?? null
|
|
249
|
+
const now = new Date()
|
|
250
|
+
|
|
251
|
+
const session = new Session(id, null, csrfToken, {}, ipAddress, userAgent, now, now)
|
|
252
|
+
session._dirty = true
|
|
253
|
+
return session
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Look up a session by ID. Returns null if not found. */
|
|
257
|
+
static async find(id: string): Promise<Session | null> {
|
|
258
|
+
const rows = await SessionManager.db.sql`
|
|
259
|
+
SELECT * FROM "_strav_sessions" WHERE "id" = ${id} LIMIT 1
|
|
260
|
+
`
|
|
261
|
+
if (rows.length === 0) return null
|
|
262
|
+
return Session.hydrate(rows[0] as Record<string, unknown>)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Read the session cookie from the request and look up the session. */
|
|
266
|
+
static async fromRequest(ctx: Context): Promise<Session | null> {
|
|
267
|
+
const sessionId = ctx.cookie(SessionManager.config.cookie)
|
|
268
|
+
if (!sessionId) return null
|
|
269
|
+
return Session.find(sessionId)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Delete the session from the database and clear the cookie on the response. */
|
|
273
|
+
static async destroy(ctx: Context, response: Response): Promise<Response> {
|
|
274
|
+
const cfg = SessionManager.config
|
|
275
|
+
const sessionId = ctx.cookie(cfg.cookie)
|
|
276
|
+
|
|
277
|
+
if (sessionId) {
|
|
278
|
+
await SessionManager.db.sql`
|
|
279
|
+
DELETE FROM "_strav_sessions" WHERE "id" = ${sessionId}
|
|
280
|
+
`
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return clearCookie(response, cfg.cookie, { path: '/' })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Internal
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
private static hydrate(row: Record<string, unknown>): Session {
|
|
291
|
+
const rawData = row.data
|
|
292
|
+
const data: Record<string, unknown> =
|
|
293
|
+
typeof rawData === 'string'
|
|
294
|
+
? JSON.parse(rawData)
|
|
295
|
+
: ((rawData as Record<string, unknown>) ?? {})
|
|
296
|
+
|
|
297
|
+
return new Session(
|
|
298
|
+
row.id as string,
|
|
299
|
+
(row.user_id as string) ?? null,
|
|
300
|
+
row.csrf_token as string,
|
|
301
|
+
data,
|
|
302
|
+
(row.ip_address as string) ?? null,
|
|
303
|
+
(row.user_agent as string) ?? null,
|
|
304
|
+
row.last_activity as Date,
|
|
305
|
+
row.created_at as Date
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { inject } from '@stravigor/kernel/core/inject'
|
|
2
|
+
import { ConfigurationError } from '@stravigor/kernel/exceptions/errors'
|
|
3
|
+
import Configuration from '@stravigor/kernel/config/configuration'
|
|
4
|
+
import Database from '@stravigor/database/database/database'
|
|
5
|
+
|
|
6
|
+
export interface SessionConfig {
|
|
7
|
+
cookie: string
|
|
8
|
+
lifetime: number
|
|
9
|
+
httpOnly: boolean
|
|
10
|
+
secure: boolean
|
|
11
|
+
sameSite: 'strict' | 'lax' | 'none'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Central session configuration hub.
|
|
16
|
+
*
|
|
17
|
+
* Resolved once via the DI container — stores the database reference
|
|
18
|
+
* and parsed config for Session and the session middleware.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* app.singleton(SessionManager)
|
|
22
|
+
* app.resolve(SessionManager)
|
|
23
|
+
* await SessionManager.ensureTable()
|
|
24
|
+
*/
|
|
25
|
+
@inject
|
|
26
|
+
export default class SessionManager {
|
|
27
|
+
private static _db: Database
|
|
28
|
+
private static _config: SessionConfig
|
|
29
|
+
|
|
30
|
+
constructor(db: Database, config: Configuration) {
|
|
31
|
+
SessionManager._db = db
|
|
32
|
+
SessionManager._config = {
|
|
33
|
+
cookie: 'strav_session',
|
|
34
|
+
lifetime: 120,
|
|
35
|
+
httpOnly: true,
|
|
36
|
+
secure: true,
|
|
37
|
+
sameSite: 'lax',
|
|
38
|
+
...(config.get('session', {}) as object),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static get db(): Database {
|
|
43
|
+
if (!SessionManager._db) {
|
|
44
|
+
throw new ConfigurationError(
|
|
45
|
+
'SessionManager not configured. Resolve it through the container first.'
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
return SessionManager._db
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static get config(): SessionConfig {
|
|
52
|
+
return SessionManager._config
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Create the sessions table if it does not exist. */
|
|
56
|
+
static async ensureTable(): Promise<void> {
|
|
57
|
+
await SessionManager.db.sql`
|
|
58
|
+
CREATE TABLE IF NOT EXISTS "_strav_sessions" (
|
|
59
|
+
"id" UUID PRIMARY KEY,
|
|
60
|
+
"user_id" VARCHAR(255),
|
|
61
|
+
"csrf_token" VARCHAR(64) NOT NULL,
|
|
62
|
+
"data" JSONB NOT NULL DEFAULT '{}',
|
|
63
|
+
"ip_address" VARCHAR(45),
|
|
64
|
+
"user_agent" TEXT,
|
|
65
|
+
"last_activity" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
66
|
+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
67
|
+
)
|
|
68
|
+
`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Delete all expired sessions. Call periodically for housekeeping. */
|
|
72
|
+
static async gc(): Promise<number> {
|
|
73
|
+
const lifetimeMs = SessionManager.config.lifetime * 60_000
|
|
74
|
+
const cutoff = new Date(Date.now() - lifetimeMs)
|
|
75
|
+
|
|
76
|
+
const rows = await SessionManager.db.sql`
|
|
77
|
+
DELETE FROM "_strav_sessions"
|
|
78
|
+
WHERE "last_activity" < ${cutoff}
|
|
79
|
+
RETURNING "id"
|
|
80
|
+
`
|
|
81
|
+
return rows.length
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { validate } from './validate.ts'
|
|
2
|
+
export {
|
|
3
|
+
required,
|
|
4
|
+
string,
|
|
5
|
+
integer,
|
|
6
|
+
number,
|
|
7
|
+
boolean,
|
|
8
|
+
min,
|
|
9
|
+
max,
|
|
10
|
+
email,
|
|
11
|
+
url,
|
|
12
|
+
regex,
|
|
13
|
+
enumOf,
|
|
14
|
+
oneOf,
|
|
15
|
+
array,
|
|
16
|
+
} from './rules.ts'
|
|
17
|
+
export type { Rule } from './rules.ts'
|
|
18
|
+
export type { RuleSet, ValidationResult } from './validate.ts'
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { t } from '@stravigor/kernel/i18n/helpers'
|
|
2
|
+
|
|
3
|
+
export interface Rule {
|
|
4
|
+
name: string
|
|
5
|
+
validate(value: unknown): string | null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function required(): Rule {
|
|
9
|
+
return {
|
|
10
|
+
name: 'required',
|
|
11
|
+
validate(value) {
|
|
12
|
+
if (value === undefined || value === null || value === '') {
|
|
13
|
+
return t('validation.required')
|
|
14
|
+
}
|
|
15
|
+
return null
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function string(): Rule {
|
|
21
|
+
return {
|
|
22
|
+
name: 'string',
|
|
23
|
+
validate(value) {
|
|
24
|
+
if (value === undefined || value === null) return null
|
|
25
|
+
if (typeof value !== 'string') return t('validation.string')
|
|
26
|
+
return null
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function integer(): Rule {
|
|
32
|
+
return {
|
|
33
|
+
name: 'integer',
|
|
34
|
+
validate(value) {
|
|
35
|
+
if (value === undefined || value === null) return null
|
|
36
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) return t('validation.integer')
|
|
37
|
+
return null
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function number(): Rule {
|
|
43
|
+
return {
|
|
44
|
+
name: 'number',
|
|
45
|
+
validate(value) {
|
|
46
|
+
if (value === undefined || value === null) return null
|
|
47
|
+
if (typeof value !== 'number' || isNaN(value)) return t('validation.number')
|
|
48
|
+
return null
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function boolean(): Rule {
|
|
54
|
+
return {
|
|
55
|
+
name: 'boolean',
|
|
56
|
+
validate(value) {
|
|
57
|
+
if (value === undefined || value === null) return null
|
|
58
|
+
if (typeof value !== 'boolean') return t('validation.boolean')
|
|
59
|
+
return null
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function min(n: number): Rule {
|
|
65
|
+
return {
|
|
66
|
+
name: 'min',
|
|
67
|
+
validate(value) {
|
|
68
|
+
if (value === undefined || value === null) return null
|
|
69
|
+
if (typeof value === 'number') {
|
|
70
|
+
if (value < n) return t('validation.min.number', { min: n })
|
|
71
|
+
} else if (typeof value === 'string') {
|
|
72
|
+
if (value.length < n) return t('validation.min.string', { min: n })
|
|
73
|
+
}
|
|
74
|
+
return null
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function max(n: number): Rule {
|
|
80
|
+
return {
|
|
81
|
+
name: 'max',
|
|
82
|
+
validate(value) {
|
|
83
|
+
if (value === undefined || value === null) return null
|
|
84
|
+
if (typeof value === 'number') {
|
|
85
|
+
if (value > n) return t('validation.max.number', { max: n })
|
|
86
|
+
} else if (typeof value === 'string') {
|
|
87
|
+
if (value.length > n) return t('validation.max.string', { max: n })
|
|
88
|
+
}
|
|
89
|
+
return null
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function email(): Rule {
|
|
95
|
+
return {
|
|
96
|
+
name: 'email',
|
|
97
|
+
validate(value) {
|
|
98
|
+
if (value === undefined || value === null) return null
|
|
99
|
+
if (typeof value !== 'string') return t('validation.string')
|
|
100
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return t('validation.email')
|
|
101
|
+
return null
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function url(): Rule {
|
|
107
|
+
return {
|
|
108
|
+
name: 'url',
|
|
109
|
+
validate(value) {
|
|
110
|
+
if (value === undefined || value === null) return null
|
|
111
|
+
if (typeof value !== 'string') return t('validation.string')
|
|
112
|
+
try {
|
|
113
|
+
new URL(value)
|
|
114
|
+
return null
|
|
115
|
+
} catch {
|
|
116
|
+
return t('validation.url')
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function regex(pattern: RegExp): Rule {
|
|
123
|
+
return {
|
|
124
|
+
name: 'regex',
|
|
125
|
+
validate(value) {
|
|
126
|
+
if (value === undefined || value === null) return null
|
|
127
|
+
if (typeof value !== 'string') return t('validation.string')
|
|
128
|
+
if (!pattern.test(value)) return t('validation.regex')
|
|
129
|
+
return null
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function enumOf(enumObj: Record<string, string | number>): Rule {
|
|
135
|
+
const values = Object.values(enumObj)
|
|
136
|
+
return {
|
|
137
|
+
name: 'enumOf',
|
|
138
|
+
validate(value) {
|
|
139
|
+
if (value === undefined || value === null) return null
|
|
140
|
+
if (!values.includes(value as any)) {
|
|
141
|
+
return t('validation.enum', { values: values.join(', ') })
|
|
142
|
+
}
|
|
143
|
+
return null
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function oneOf(values: readonly (string | number | boolean)[]): Rule {
|
|
149
|
+
return {
|
|
150
|
+
name: 'oneOf',
|
|
151
|
+
validate(value) {
|
|
152
|
+
if (value === undefined || value === null) return null
|
|
153
|
+
if (!values.includes(value as any)) {
|
|
154
|
+
return t('validation.enum', { values: values.join(', ') })
|
|
155
|
+
}
|
|
156
|
+
return null
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function array(): Rule {
|
|
162
|
+
return {
|
|
163
|
+
name: 'array',
|
|
164
|
+
validate(value) {
|
|
165
|
+
if (value === undefined || value === null) return null
|
|
166
|
+
if (!Array.isArray(value)) return t('validation.array')
|
|
167
|
+
return null
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Rule } from './rules.ts'
|
|
2
|
+
|
|
3
|
+
export type RuleSet = Record<string, Rule[]>
|
|
4
|
+
|
|
5
|
+
export interface ValidationResult<T = Record<string, unknown>> {
|
|
6
|
+
data: T
|
|
7
|
+
errors: Record<string, string[]> | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function validate<T = Record<string, unknown>>(
|
|
11
|
+
input: unknown,
|
|
12
|
+
rules: RuleSet
|
|
13
|
+
): ValidationResult<T> {
|
|
14
|
+
const record = (typeof input === 'object' && input !== null ? input : {}) as Record<
|
|
15
|
+
string,
|
|
16
|
+
unknown
|
|
17
|
+
>
|
|
18
|
+
const data: Record<string, unknown> = {}
|
|
19
|
+
const errors: Record<string, string[]> = {}
|
|
20
|
+
let hasErrors = false
|
|
21
|
+
|
|
22
|
+
for (const [field, fieldRules] of Object.entries(rules)) {
|
|
23
|
+
const value = record[field]
|
|
24
|
+
if (value !== undefined) data[field] = value
|
|
25
|
+
|
|
26
|
+
for (const rule of fieldRules) {
|
|
27
|
+
const error = rule.validate(value)
|
|
28
|
+
if (error) {
|
|
29
|
+
if (!errors[field]) errors[field] = []
|
|
30
|
+
errors[field]!.push(error)
|
|
31
|
+
hasErrors = true
|
|
32
|
+
break // stop at first error per field
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
data: data as T,
|
|
39
|
+
errors: hasErrors ? errors : null,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface RenderResult {
|
|
2
|
+
output: string
|
|
3
|
+
blocks: Record<string, string>
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type RenderFunction = (
|
|
7
|
+
data: Record<string, unknown>,
|
|
8
|
+
includeFn: IncludeFn
|
|
9
|
+
) => Promise<RenderResult>
|
|
10
|
+
|
|
11
|
+
export type IncludeFn = (name: string, data: Record<string, unknown>) => Promise<string>
|
|
12
|
+
|
|
13
|
+
export interface CacheEntry {
|
|
14
|
+
fn: RenderFunction
|
|
15
|
+
layout?: string
|
|
16
|
+
mtime: number
|
|
17
|
+
filePath: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default class TemplateCache {
|
|
21
|
+
private entries = new Map<string, CacheEntry>()
|
|
22
|
+
|
|
23
|
+
get(name: string): CacheEntry | undefined {
|
|
24
|
+
return this.entries.get(name)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
set(name: string, entry: CacheEntry): void {
|
|
28
|
+
this.entries.set(name, entry)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async isStale(name: string): Promise<boolean> {
|
|
32
|
+
const entry = this.entries.get(name)
|
|
33
|
+
if (!entry) return true
|
|
34
|
+
const file = Bun.file(entry.filePath)
|
|
35
|
+
const exists = await file.exists()
|
|
36
|
+
if (!exists) return true
|
|
37
|
+
return file.lastModified > entry.mtime
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
delete(name: string): void {
|
|
41
|
+
this.entries.delete(name)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
clear(): void {
|
|
45
|
+
this.entries.clear()
|
|
46
|
+
}
|
|
47
|
+
}
|