@rip-lang/server 1.2.11 → 1.3.1

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/api.rip ADDED
@@ -0,0 +1,663 @@
1
+ # ==============================================================================
2
+ # @rip-lang/server — Pure Rip API Framework
3
+ # ==============================================================================
4
+ #
5
+ # A zero-dependency, Hono-compatible API framework written entirely in Rip.
6
+ # Provides Sinatra-style elegance with AsyncLocalStorage-powered context.
7
+ #
8
+ # Public exports:
9
+ # DSL: get, post, put, patch, del, all, use, prefix
10
+ # Server: start, startHandler, fetch, App
11
+ # Filters: raw, before, after
12
+ # Handlers: onError, notFound
13
+ # Validation: read, validators, registerValidator, getValidator
14
+ # Context: ctx, session, env, subrequest
15
+ # Utilities: isBlank, toName, toPhone, mimeType
16
+ # Dev: resetGlobals
17
+ # ==============================================================================
18
+
19
+ import { AsyncLocalStorage } from 'node:async_hooks'
20
+ import { posix } from 'node:path'
21
+
22
+ # ==============================================================================
23
+ # Module State
24
+ # ==============================================================================
25
+
26
+ _ = null # Match capture holder for Rip's =~
27
+ _errorHandler = null # Custom error handler
28
+ _notFoundHandler = null # Custom 404 handler
29
+ _rawFilter = null # Raw request filter (before body parsing)
30
+ _beforeFilters = [] # Before request filters
31
+ _afterFilters = [] # After request filters
32
+ _routes = [] # Route definitions
33
+ _middlewares = [] # Global middleware functions
34
+ _prefix = '' # Current route prefix for grouping
35
+ _corsPreflight = false # Enable early OPTIONS handling (set by cors middleware)
36
+
37
+ export resetGlobals = ->
38
+ _errorHandler = null
39
+ _notFoundHandler = null
40
+ _rawFilter = null
41
+ _beforeFilters = []
42
+ _afterFilters = []
43
+ _routes = []
44
+ _middlewares = []
45
+ _prefix = ''
46
+ _corsPreflight = false
47
+
48
+ # Enable early OPTIONS handling (called by cors middleware with preflight: true)
49
+ export enableCorsPreflight = -> _corsPreflight = true
50
+
51
+ # AsyncLocalStorage for request context (enables sync read() in handlers)
52
+ export requestContext = new AsyncLocalStorage()
53
+
54
+ # ==============================================================================
55
+ # Router: Pattern Compiler
56
+ # ==============================================================================
57
+
58
+ compilePattern = (path) ->
59
+ keys = []
60
+ path = path.replace /\?$/, ''
61
+ escaped = path.replace /[.+^${}()|[\]\\]/g, '\\$&'
62
+ regex = escaped.replace /:(\w+)(?:\{([^}]+)\})?/g, (match, key, pattern) ->
63
+ keys.push key
64
+ "(#{pattern or '[^/]+'})"
65
+ regex = regex.replace /(?<![\\*])\*(?!\*)/g, '(.*)'
66
+ { regex: new RegExp("^#{regex}/?$"), keys }
67
+
68
+ # ==============================================================================
69
+ # Router: Route Matching
70
+ # ==============================================================================
71
+
72
+ matchRoute = (method, pathname) ->
73
+ for route in _routes
74
+ continue if route.middleware
75
+ continue unless route.method is method or route.method is 'ALL'
76
+ match = pathname.match route.regex
77
+ if match
78
+ params = {}
79
+ for key, i in route.keys
80
+ params[key] = decodeURIComponent(match[i + 1]) if match[i + 1]?
81
+ return { handler: route.handler, params, route }
82
+ null
83
+
84
+ # ==============================================================================
85
+ # MIME Types — Auto-detect content type from file extension
86
+ # ==============================================================================
87
+
88
+ _mimeTypes =
89
+ '.html': 'text/html; charset=UTF-8'
90
+ '.htm': 'text/html; charset=UTF-8'
91
+ '.css': 'text/css; charset=UTF-8'
92
+ '.js': 'application/javascript'
93
+ '.mjs': 'application/javascript'
94
+ '.json': 'application/json'
95
+ '.txt': 'text/plain; charset=UTF-8'
96
+ '.csv': 'text/csv; charset=UTF-8'
97
+ '.xml': 'application/xml'
98
+ '.svg': 'image/svg+xml'
99
+ '.png': 'image/png'
100
+ '.jpg': 'image/jpeg'
101
+ '.jpeg': 'image/jpeg'
102
+ '.gif': 'image/gif'
103
+ '.webp': 'image/webp'
104
+ '.avif': 'image/avif'
105
+ '.ico': 'image/x-icon'
106
+ '.woff': 'font/woff'
107
+ '.woff2': 'font/woff2'
108
+ '.ttf': 'font/ttf'
109
+ '.otf': 'font/otf'
110
+ '.mp3': 'audio/mpeg'
111
+ '.mp4': 'video/mp4'
112
+ '.webm': 'video/webm'
113
+ '.ogg': 'audio/ogg'
114
+ '.pdf': 'application/pdf'
115
+ '.zip': 'application/zip'
116
+ '.gz': 'application/gzip'
117
+ '.wasm': 'application/wasm'
118
+ '.rip': 'text/plain; charset=UTF-8'
119
+
120
+ export mimeType = (path) ->
121
+ ext = path.substring(path.lastIndexOf('.'))
122
+ _mimeTypes[ext] or 'application/octet-stream'
123
+
124
+ # ==============================================================================
125
+ # Context Factory
126
+ # ==============================================================================
127
+
128
+ createContext = (req, params = {}) ->
129
+ url = new URL(req.url)
130
+ out = new Headers()
131
+
132
+ ctx =
133
+ json: (data, status = 200, headers = {}) ->
134
+ out.set 'Content-Type', 'application/json'
135
+ for k, v of headers then out.set(k, v)
136
+ new Response JSON.stringify(data), { status, headers: out }
137
+
138
+ text: (str, status = 200, headers = {}) ->
139
+ out.set 'Content-Type', 'text/plain; charset=UTF-8'
140
+ for k, v of headers then out.set(k, v)
141
+ new Response str, { status, headers: out }
142
+
143
+ html: (str, status = 200, headers = {}) ->
144
+ out.set 'Content-Type', 'text/html; charset=UTF-8'
145
+ for k, v of headers then out.set(k, v)
146
+ new Response str, { status, headers: out }
147
+
148
+ redirect: (location, status = 302) ->
149
+ new Response null, { status, headers: { Location: String(location) } }
150
+
151
+ body: (data, status = 200, headers = {}) ->
152
+ for k, v of headers then out.set(k, v)
153
+ new Response data, { status, headers: out }
154
+
155
+ send: (path, type) ->
156
+ file = Bun.file(path)
157
+ etag = "W/\"#{file.lastModified}-#{file.size}\""
158
+ if req.headers.get('if-none-match') is etag
159
+ return new Response(null, { status: 304, headers: { 'ETag': etag, 'Cache-Control': 'no-cache' } })
160
+ out.set 'Content-Type', (type or mimeType(path))
161
+ out.set 'Cache-Control', 'no-cache'
162
+ out.set 'ETag', etag
163
+ new Response file, { status: 200, headers: out }
164
+
165
+ header: (name, value, opts = {}) ->
166
+ if value?
167
+ if opts.append then out.append(name, value) else out.set(name, value)
168
+ return
169
+ out.get(name)
170
+
171
+ session: {}
172
+
173
+ req:
174
+ raw: req
175
+ method: req.method
176
+ url: req.url
177
+ path: posix.normalize(url.pathname)
178
+ param: (key) -> if key? then params[key] else { ...params }
179
+ query: (key) -> if key? then url.searchParams.get(key) else Object.fromEntries(url.searchParams)
180
+ header: (key) -> if key? then req.headers.get(key) else Object.fromEntries(req.headers)
181
+ json: -> req.json()
182
+ text: -> req.text()
183
+ formData: -> req.formData()
184
+ parseBody: (opts = {}) ->
185
+ ct = req.headers.get('content-type') or ''
186
+ if ct =~ /json/i
187
+ req.json!
188
+ else if ct =~ /form/i
189
+ fd = req.formData!
190
+ Object.fromEntries(fd)
191
+ else
192
+ {}
193
+
194
+ ctx
195
+
196
+ # ==============================================================================
197
+ # Middleware Composition (Koa-style)
198
+ # ==============================================================================
199
+
200
+ compose = (middlewares, beforeFilters, afterFilters, handler) ->
201
+ (c) ->
202
+ index = -1
203
+ dispatch = (i) ->
204
+ if i <= index
205
+ throw new Error 'next() called multiple times'
206
+ index = i
207
+
208
+ # Run middlewares first
209
+ if i < middlewares.length
210
+ fn = middlewares[i]
211
+ result = fn.call! c, c, -> dispatch!(i + 1)
212
+ c._response = result if result instanceof Response
213
+ return
214
+
215
+ # After middlewares, run before filters, handler, after filters
216
+ if i is middlewares.length
217
+ # Run before filters
218
+ for filter in beforeFilters
219
+ result = filter.call!(c, c)
220
+ if result instanceof Response
221
+ c._response = result
222
+ return
223
+
224
+ # Run handler
225
+ result = handler.call!(c, c)
226
+ c._response = result if result instanceof Response
227
+
228
+ # Run after filters
229
+ for filter in afterFilters
230
+ filter.call!(c, c)
231
+
232
+ dispatch!(0)
233
+ c
234
+
235
+ # ==============================================================================
236
+ # Smart Response Wrapper
237
+ # ==============================================================================
238
+
239
+ smart = (fn) ->
240
+ (c, next) ->
241
+ try
242
+ result = if fn.length > 0 then fn.call!(c, c) else fn.call!(c)
243
+ return result if result instanceof Response
244
+ return c.json(result) if result? and typeof result is 'object'
245
+ if typeof result is 'string'
246
+ type = if result.trimStart().startsWith('<') then 'html' else 'text'
247
+ return c[type](result)
248
+ return c.text(String(result)) if typeof result in ['number', 'boolean']
249
+ if result is null or result is undefined
250
+ return new Response(null, { status: 204 })
251
+ result
252
+ catch err
253
+ status = err?.status or 500
254
+ console.error 'Handler error:', err if status >= 500
255
+ message = err?.message or 'Internal Server Error'
256
+ new Response message, { status, headers: { 'Content-Type': 'text/plain' } }
257
+
258
+ # ==============================================================================
259
+ # DSL: Route Registration
260
+ # ==============================================================================
261
+
262
+ addRoute = (method, path, ...handlers) ->
263
+ fullPath = "#{_prefix}#{path}"
264
+ if fullPath.includes(':') and fullPath.endsWith('?')
265
+ basePath = fullPath.slice(0, -1).replace(/\/:[^\/]+$/, '')
266
+ addRoute method, basePath, ...handlers if basePath and basePath isnt fullPath.slice(0, -1)
267
+ fullPath = fullPath.slice(0, -1)
268
+ { regex, keys } = compilePattern(fullPath)
269
+ for handler in handlers
270
+ _routes.push { method: method.toUpperCase(), regex, keys, handler: smart(handler), path: fullPath }
271
+
272
+ export get = (path, ...handlers) -> addRoute 'GET', path, ...handlers
273
+ export post = (path, ...handlers) -> addRoute 'POST', path, ...handlers
274
+ export put = (path, ...handlers) -> addRoute 'PUT', path, ...handlers
275
+ export patch = (path, ...handlers) -> addRoute 'PATCH', path, ...handlers
276
+ export del = (path, ...handlers) -> addRoute 'DELETE', path, ...handlers
277
+ export all = (path, ...handlers) -> addRoute 'ALL', path, ...handlers
278
+
279
+ export use = (pathOrMw, mw = null) ->
280
+ if typeof pathOrMw is 'function'
281
+ _middlewares.push pathOrMw
282
+ else
283
+ { regex, keys } = compilePattern(pathOrMw)
284
+ _routes.push { method: 'ALL', regex, keys, handler: mw, path: pathOrMw, middleware: true }
285
+
286
+ export prefix = (base, fn) ->
287
+ old = _prefix
288
+ _prefix = "#{_prefix}#{base}"
289
+ try
290
+ fn?.()
291
+ finally
292
+ _prefix = old
293
+
294
+ export onError = (handler) -> _errorHandler = handler
295
+ export notFound = (handler) -> _notFoundHandler = handler
296
+
297
+ export raw = (fn) -> _rawFilter = fn
298
+ export before = (fn) -> _beforeFilters.push fn
299
+ export after = (fn) -> _afterFilters.push fn
300
+
301
+ # ==============================================================================
302
+ # Main Fetch Handler
303
+ # ==============================================================================
304
+
305
+ export fetch = (req) ->
306
+ url = new URL(req.url)
307
+ pathname = posix.normalize(url.pathname)
308
+ method = req.method
309
+
310
+ if method is 'HEAD'
311
+ res = fetch!(new Request(req.url, { method: 'GET', headers: req.headers }))
312
+ return new Response(null, { status: res.status, headers: res.headers })
313
+
314
+ # Handle CORS preflight (OPTIONS) requests early, before route matching
315
+ # Enabled when cors middleware is used with preflight: true
316
+ if method is 'OPTIONS' and _corsPreflight
317
+ return new Response null,
318
+ status: 204
319
+ headers:
320
+ 'Access-Control-Allow-Origin': req.headers.get('origin') or '*'
321
+ 'Access-Control-Allow-Credentials': 'true'
322
+ 'Access-Control-Allow-Methods': 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS'
323
+ 'Access-Control-Allow-Headers': req.headers.get('access-control-request-headers') or 'Content-Type,Authorization'
324
+ 'Access-Control-Max-Age': '86400'
325
+
326
+ match = matchRoute(method, pathname)
327
+
328
+ unless match
329
+ c = createContext(req)
330
+ if _notFoundHandler?
331
+ return _notFoundHandler.call!(c, c)
332
+ return new Response('Not Found', { status: 404 })
333
+
334
+ c = createContext(req, match.params)
335
+
336
+ # Run raw filter before body parsing (e.g., fix content-type)
337
+ _rawFilter?.(req) if method in ['POST', 'PUT', 'PATCH']
338
+
339
+ # Pre-parse body and query for sync read() — baked in, zero ceremony
340
+ data = {}
341
+ try
342
+ ct = req.headers.get('content-type') or ''
343
+ if method in ['POST', 'PUT', 'PATCH']
344
+ if ct =~ /json/i
345
+ data = c.req.json!
346
+ else if ct =~ /x-www-form-urlencoded|form-data/i
347
+ data = c.req.parseBody!
348
+ keys = Object.keys(data)
349
+ data = { body: keys[0] } if keys.length is 1 and data[keys[0]] is ''
350
+ else if ct =~ /text\//i
351
+ data = { body: c.req.text! }
352
+ catch
353
+ data = {}
354
+ merged = { ...data, ...c.req.query(), ...match.params }
355
+
356
+ # Run handler inside AsyncLocalStorage context
357
+ requestContext.run! { env: c, data: merged }, ->
358
+ runHandler!(c, match.handler)
359
+
360
+ # Internal: Execute handler with middleware and error handling
361
+ runHandler = (c, handler) ->
362
+ try
363
+ # Compose runs: middlewares → before filters → handler → after filters
364
+ compose!(_middlewares, _beforeFilters, _afterFilters, handler)(c)
365
+ c._response or new Response('', { status: 204 })
366
+ catch err
367
+ console.error 'Request error:', err if not err?.status or err.status >= 500
368
+ if _errorHandler?
369
+ _errorHandler.call!(c, err, c)
370
+ else
371
+ new Response err?.message or 'Internal Server Error', { status: err?.status or 500 }
372
+
373
+ # ==============================================================================
374
+ # Server Startup
375
+ # ==============================================================================
376
+
377
+ export startHandler = -> fetch
378
+
379
+ export start = (opts = {}) ->
380
+ handler = startHandler()
381
+
382
+ # If running under rip-server, set global handler and return
383
+ if process.env.WORKER_ID? or process.env.SOCKET_PATH?
384
+ globalThis.__ripHandler = handler
385
+ return handler
386
+
387
+ # Otherwise start standalone server
388
+ host = opts.host or 'localhost'
389
+ port = opts.port or 3000
390
+ server = Bun.serve { hostname: host, port: port, fetch: handler }
391
+ console.log "rip-api listening on http://#{host}:#{port}" unless opts.silent
392
+ server
393
+
394
+ export App = (fn) ->
395
+ resetGlobals()
396
+ fn?.()
397
+ startHandler()
398
+
399
+ # ==============================================================================
400
+ # Utility: Get Current Environment/Context
401
+ # ==============================================================================
402
+
403
+ export ctx = ->
404
+ store = requestContext.getStore()
405
+ store?.env or null
406
+
407
+ # Subrequest — run a function with new data but same @ context
408
+ # Enables read() to work against a synthetic payload while preserving
409
+ # all @ variables from the parent request (e.g., @token).
410
+ export subrequest = (data, fn) ->
411
+ store = requestContext.getStore() or throw new Error 'no context for subrequest()'
412
+ requestContext.run! { env: store.env, data }, ->
413
+ fn.call!(store.env)
414
+
415
+ # Session proxy — access session.foo anywhere (via AsyncLocalStorage)
416
+ export session = new Proxy {},
417
+ get: (_, key) ->
418
+ requestContext.getStore()?.env?.session?[key]
419
+ set: (_, key, value) ->
420
+ ctx = requestContext.getStore()?.env
421
+ return false unless ctx?
422
+ ctx.session ?= {}
423
+ ctx.session[key] = value
424
+ true
425
+ deleteProperty: (_, key) ->
426
+ ctx = requestContext.getStore()?.env
427
+ delete ctx.session[key] if ctx?.session?
428
+ true
429
+
430
+ # Env proxy — access env.FOO anywhere (shortcut for process.env)
431
+ export env = new Proxy {}, get: (_, key) -> process.env[key]
432
+
433
+ # ==============================================================================
434
+ # Utility Functions
435
+ # ==============================================================================
436
+
437
+ export isBlank = (obj) ->
438
+ return true unless obj?
439
+ return true if obj is false
440
+ return true if typeof obj is 'string' and /^\s*$/.test obj
441
+ return true if Array.isArray(obj) and obj.length is 0
442
+ return true if typeof obj is 'object' and Object.keys(obj).length is 0
443
+ false
444
+
445
+ capitalize = (str) -> str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
446
+
447
+ toMoney = (value, half, cents) ->
448
+ m = value.replace(/[$, ]/g, '').match(/^([-+]?)(\d*)\.?(\d*)$/)
449
+ return null unless m
450
+ intp = m[2] or ''
451
+ raw = m[3] or ''
452
+ return null unless intp.length or raw.length
453
+ neg = m[1] is '-'
454
+ frac = raw.padEnd(3, '0')
455
+ c = parseInt((intp or '0') + frac[0] + frac[1], 10) or 0
456
+ d = parseInt(frac[2], 10) or 0
457
+ rest = raw.length > 3 and /[1-9]/.test(raw.slice(3))
458
+ up = d > 5 or (d is 5 and rest) or (d is 5 and not rest and (if half then c % 2 isnt 0 else true))
459
+ c += 1 if up
460
+ c = if neg then -c else c
461
+ if cents then c else c / 100
462
+
463
+ export toName = (str, ...type) ->
464
+ s = String(str)
465
+ s = s.toLowerCase().replace(/\s+/g, ' ').trim()
466
+ s = s.replace /(^|(?<=\P{L}))(\p{L})/gu, (m, pre, ch) -> pre + ch.toUpperCase()
467
+ s = s.replace(/[`'']/g, "'").replace(/[""]/g, '"')
468
+ s = s.replace /\b([a-z])\. ?([bcdfghjklmnpqrstvwxyz])\.?(?=\W|$)/gi, (match, p1, p2) -> (p1 + p2).toUpperCase()
469
+ s = s.replace /(?<=^|\P{L})([A-Za-z](?:(?![AEIOUYaeiouy])[A-Za-z]){1,4})(?=$|\P{L})/gu, (m) -> m.toUpperCase()
470
+ s = s.replace /\b([djs]r|us|acct|[ai]nn?|apps|ed|erb|elk|esq|grp|in[cj]|of[cf]|st|up)\.?(?=\W|$)/gi, (match) -> capitalize(match)
471
+ s = s.replace /(^|(?<=\d ))?\b(and|at|as|of|then?|in|on|or|for|to|by|de l[ao]s?|del?|(el-)|el|las)($)?\b/ig, (m, p1, p2, p3, p4, offset) ->
472
+ if offset is 0 or p1? or p3? or p4? then capitalize(p2) else p2.toLowerCase()
473
+ s = s.replace /\b(mc|mac(?=d[ao][a-k,m-z][a-z]|[fgmpw])|[dol]')([a-z])/gi, (m, p1, p2) -> capitalize(p1) + capitalize(p2)
474
+ if type.includes 'name'
475
+ s = s.replace /\b(ahn|an[gh]|al|art[sz]?|ash|e[dnv]|echt|elms|emms|eng|epps|essl|i[mp]|mrs?|ms|ng|ock|o[hm]|ohrt|ong|orr|ohrt|ost|ott|oz|sng|tsz|u[br]|ung)\b/gi, (match) -> capitalize(match)
476
+ if type.includes 'address'
477
+ s = s.replace /(?<=^| |\p{P})(apt?s?|arch|ave?|bldg|blvd|cr?t|co?mn|drv?|elm|end|f[lt]|hts?|ln|old|pkw?y|plc?|prk|pt|r[dm]|spc|s[qt]r?|srt|street|[nesw])\.?(?=$|[^\p{L}\p{N}_])/giu, (matched) -> capitalize(matched)
478
+ s = s.replace /(1st|2nd|3rd|[\d]th|\bde l[ao]s)\b/gi, (match) -> match.toLowerCase()
479
+ s = s.replace /\b(ca|dba|fbo|ihop|mri|ucla|usa|vru|[ns][ew]|i{1,3}v?)\b/gi, (match) -> match.toUpperCase()
480
+ s = s.replace /\b([-@.\w]+\.(?:com|net|io|org))\b/gi, (match) -> match.toLowerCase()
481
+ s = s.replace /(?<=\p{L}')S\b/gu, 's'
482
+ s = s.replace /# /g, '#'
483
+ s = s.replace /\s*[.,#]+$/, ''
484
+ s = s.replace /\bP\.? ?O\.? ?Box/i, 'PO Box'
485
+ s
486
+
487
+ export toPhone = (str) ->
488
+ return "" if isBlank(str)
489
+ num = str.toString().replace(/\s+/g, ' ').trim()
490
+ [num, ext] = num.split(/\s*(?:, ?)?(?:ext?\.?|x|#|:|,)\s*/i, 2)
491
+ ext = ext.replace(/\D+/g, "") if ext
492
+ if num =~ /^\+([-+#*.,\d ]+)$/
493
+ etc = _[1].replace(/[^#*,\d]+/g, "")
494
+ if etc.replace(/\D+/g, "").length >= 6
495
+ if etc =~ /^1(\d{10})$/
496
+ [num, ext] = [_[1], ""]
497
+ else
498
+ return "+#{etc}"
499
+ num = num.replace(/^[^2-9]*/, "").replace(/\D+/g, "")
500
+ if num =~ /^([2-9][0-8][0-9])([2-9]\d\d)(\d{4})$/
501
+ num = "(#{_[1]}) #{_[2]}-#{_[3]}"
502
+ num += ", ext. #{ext}" if ext
503
+ else
504
+ num = null
505
+ num
506
+
507
+ # ==============================================================================
508
+ # Validators
509
+ # ==============================================================================
510
+
511
+ export validators =
512
+ # Numbers & money
513
+ id: (v) -> v[/^([1-9]\d{0,14})$/] and parseInt(_[1])
514
+ int: (v) -> v[/^([-+]?(?:0|[1-9]\d{0,14}))$/] and parseInt(_[1])
515
+ whole: (v) -> v[/^(0|[1-9]\d{0,14})$/] and parseInt(_[1])
516
+ float: (v) -> v[/^([-+]?\d+(?:\.\d*)?|\.\d+)$/] and parseFloat(_[1])
517
+ money: (v) -> v[/^([-+]? ?\$? ?(?:[\d,]+(?:\.\d*)?|\.\d+))$/] and toMoney(_[1])
518
+ money_even: (v) -> v[/^([-+]? ?\$? ?(?:[\d,]+(?:\.\d*)?|\.\d+))$/] and toMoney(_[1], true)
519
+ cents: (v) -> v[/^([-+]? ?\$? ?(?:[\d,]+(?:\.\d*)?|\.\d+))$/] and toMoney(_[1], false, true)
520
+ cents_even: (v) -> v[/^([-+]? ?\$? ?(?:[\d,]+(?:\.\d*)?|\.\d+))$/] and toMoney(_[1], true, true)
521
+
522
+ # Strings & formatting
523
+ string: (v) -> v.replace(/(?:\t+|\s{2,})/g, ' ')
524
+ text: (v) -> v.replace(/ +/g , ' ')
525
+
526
+ # Name/address
527
+ name: (v) -> v = v.replace(/\s+/g, ' ') and toName v, 'name'
528
+ address: (v) -> v = v.replace(/\s+/g, ' ') and toName v, 'address'
529
+
530
+ # Date & time
531
+ date: (v) -> v[/^\d{4}(-?)\d{2}\1\d{2}$/] and _[0]
532
+ time: (v) -> v[/^(2[0-3]|[01]?\d):([0-5]\d)(?::([0-5]\d))?$/] and _[0]
533
+ time12: (v) -> v[/^(1[0-2]|0?[1-9]):([0-5]\d)(?::([0-5]\d))?\s?(am|pm)$/i] and _[0].toLowerCase()
534
+
535
+ # Booleans
536
+ truthy: (v) -> (v =~ /^(true|t|1|yes|y|on)$/i) and true
537
+ falsy: (v) -> (v =~ /^(false|f|0|no|n|off)$/i) and true
538
+ bool: (v) -> if v =~ /^(true|t|1|yes|y|on)$/i then true else if v =~ /^(false|f|0|no|n|off)$/i then false else null
539
+
540
+ # Contact, geo, identity
541
+ email: (v) -> v[/^([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/i] and _[0]
542
+ state: (v) -> v[/^([a-z][a-z])$/i] and _[1].toUpperCase()
543
+ zip: (v) -> v[/^(\d{5})/] and _[1]
544
+ zipplus4: (v) -> v[/^(\d{5})-?(\d{4})$/] and "#{_[1]}-#{_[2]}"
545
+ ssn: (v) -> v[/^(\d{3})-?(\d{2})-?(\d{4})$/] and "#{_[1]}#{_[2]}#{_[3]}"
546
+ sex: (v) -> v[/^(m|male|f|female|o|other)$/i] and _[1][0].toLowerCase()
547
+
548
+ # Phone
549
+ phone: (v) -> toPhone(v)
550
+
551
+ # Web & technical
552
+ username: (v) -> v[/^([a-zA-Z0-9_-]{3,20})$/] and _[1].toLowerCase()
553
+ ip: (v) -> v[/^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/] and _[0]
554
+ mac: (v) -> v[/^([0-9a-fA-F]{2})(?:[:-]?)([0-9a-fA-F]{2})(?:[:-]?)([0-9a-fA-F]{2})(?:[:-]?)([0-9a-fA-F]{2})(?:[:-]?)([0-9a-fA-F]{2})(?:[:-]?)([0-9a-fA-F]{2})$/] and "#{_[1]}:#{_[2]}:#{_[3]}:#{_[4]}:#{_[5]}:#{_[6]}".toLowerCase()
555
+ url: (v) -> v[/^(https?:\/\/)[^\s/$.?#].[^\s]*$/i] and _[0]
556
+ color: (v) -> v[/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/] and "##{_[1].toLowerCase()}"
557
+ uuid: (v) -> v[/^([0-9a-f]{8})-?([0-9a-f]{4})-?([0-9a-f]{4})-?([0-9a-f]{4})-?([0-9a-f]{12})$/i] and "#{_[1]}-#{_[2]}-#{_[3]}-#{_[4]}-#{_[5]}".toLowerCase()
558
+ semver: (v) -> v[/^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*))?(?:\+([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*))?$/] and _[0]
559
+
560
+ # Collections & structured
561
+ array: (v) -> Array.isArray(v) and v or null
562
+ hash: (v) -> (v? and typeof v is 'object' and not Array.isArray(v)) and v or null
563
+ json: (v) -> if typeof v is 'string' then (try JSON.parse(v) catch then null) else if typeof v is 'object' then v else null
564
+
565
+ # Slugs & ids list
566
+ slug: (v) -> v[/^([a-z0-9]+(?:-[a-z0-9]+)*)$/i] and _[1].toLowerCase()
567
+ ids: (v) ->
568
+ cleaned = v.replace(/[, ]+/g, ' ').trim()
569
+ return null unless cleaned
570
+ try
571
+ nums = []
572
+ for part in cleaned.split(' ')
573
+ return null unless part[/^([1-9]\d{0,19})$/]
574
+ nums.push parseInt(_[1])
575
+ Array.from(new Set(nums)).sort((a, b) -> a - b)
576
+ catch then null
577
+
578
+ export registerValidator = (name, fn) -> validators[name] = fn
579
+ export getValidator = (name) -> validators[name]
580
+
581
+ # ==============================================================================
582
+ # read() — Sinatra-style Parameter Reading
583
+ # ==============================================================================
584
+
585
+ export read = (name = null, type = null, miss = null) ->
586
+ store = requestContext.getStore() or throw new Error 'no context for read()'
587
+
588
+ # missing value helper
589
+ done = (must = false) ->
590
+ return miss() if typeof miss is 'function'
591
+ throw new Error "Missing required field: #{name}" if must
592
+ return miss ?? null
593
+
594
+ # get value from store
595
+ v = store.data or {}
596
+ v = v[name] if name?
597
+ return v if type is 'raw'
598
+ v = v.trim() if typeof v is 'string'
599
+
600
+ # value only, no validator
601
+ if !type?
602
+ return v if v?
603
+ return done()
604
+
605
+ # String: apply validator function
606
+ else if typeof type is 'string'
607
+
608
+ # detect required value (trailing '!')
609
+ if type.endsWith '!'
610
+ must = true
611
+ type = type.slice(0, -1)
612
+
613
+ # apply validator function
614
+ f = getValidator(type)
615
+ v = String(v ?? '') unless type in ['array', 'hash', 'json']
616
+ v = if f then f(v) else null
617
+
618
+ # Regex: apply regex pattern
619
+ else if type instanceof RegExp
620
+ s = String(v ?? '')
621
+ v = s.match(type)?[0] or null
622
+
623
+ # Array: [min, max] constraint or enumeration
624
+ else if Array.isArray(type)
625
+ s = String(v ?? '')
626
+ y = true
627
+ if typeof type[0] is 'number'
628
+ [min, max] = type
629
+ if typeof v is 'number' or (s =~ /^[-+]?\d+$/)
630
+ n = if typeof v is 'number' then v else +s
631
+ y = false if min? and n < min
632
+ y = false if max? and n > max
633
+ v = if y then n else null
634
+ else
635
+ y = false if min? and s.length < min
636
+ y = false if max? and s.length > max
637
+ v = if y then s else null
638
+ else
639
+ v = if type.includes(s) then s else null
640
+
641
+ # Object: start/end, min/max
642
+ else if type? and typeof type is 'object'
643
+ s = String(v ?? '')
644
+ if type.start? or type.end?
645
+ n = if s =~ /^[-+]?\d+$/ then +s else NaN
646
+ v = if not isNaN(n) and (not type.start? or n >= type.start) and (not type.end? or n <= type.end) then n else null
647
+ else if type.min? or type.max?
648
+ y = true
649
+ if typeof v is 'number' or (s =~ /^[-+]?\d+$/)
650
+ n = if typeof v is 'number' then v else +s
651
+ y = false if type.min? and n < type.min
652
+ y = false if type.max? and n > type.max
653
+ v = if y then n else null
654
+ else
655
+ y = false if type.min? and s.length < type.min
656
+ y = false if type.max? and s.length > type.max
657
+ v = if y then s else null
658
+
659
+ # blank / missing value handling
660
+ if not v? or (typeof v is 'string' and v.trim() is '')
661
+ return done(must)
662
+
663
+ v