@rip-lang/server 1.2.11 → 1.3.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/api.rip ADDED
@@ -0,0 +1,662 @@
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 } })
160
+ out.set 'Content-Type', (type or mimeType(path))
161
+ out.set 'ETag', etag
162
+ new Response file, { status: 200, headers: out }
163
+
164
+ header: (name, value, opts = {}) ->
165
+ if value?
166
+ if opts.append then out.append(name, value) else out.set(name, value)
167
+ return
168
+ out.get(name)
169
+
170
+ session: {}
171
+
172
+ req:
173
+ raw: req
174
+ method: req.method
175
+ url: req.url
176
+ path: posix.normalize(url.pathname)
177
+ param: (key) -> if key? then params[key] else { ...params }
178
+ query: (key) -> if key? then url.searchParams.get(key) else Object.fromEntries(url.searchParams)
179
+ header: (key) -> if key? then req.headers.get(key) else Object.fromEntries(req.headers)
180
+ json: -> req.json()
181
+ text: -> req.text()
182
+ formData: -> req.formData()
183
+ parseBody: (opts = {}) ->
184
+ ct = req.headers.get('content-type') or ''
185
+ if ct =~ /json/i
186
+ req.json!
187
+ else if ct =~ /form/i
188
+ fd = req.formData!
189
+ Object.fromEntries(fd)
190
+ else
191
+ {}
192
+
193
+ ctx
194
+
195
+ # ==============================================================================
196
+ # Middleware Composition (Koa-style)
197
+ # ==============================================================================
198
+
199
+ compose = (middlewares, beforeFilters, afterFilters, handler) ->
200
+ (c) ->
201
+ index = -1
202
+ dispatch = (i) ->
203
+ if i <= index
204
+ throw new Error 'next() called multiple times'
205
+ index = i
206
+
207
+ # Run middlewares first
208
+ if i < middlewares.length
209
+ fn = middlewares[i]
210
+ result = fn.call! c, c, -> dispatch!(i + 1)
211
+ c._response = result if result instanceof Response
212
+ return
213
+
214
+ # After middlewares, run before filters, handler, after filters
215
+ if i is middlewares.length
216
+ # Run before filters
217
+ for filter in beforeFilters
218
+ result = filter.call!(c, c)
219
+ if result instanceof Response
220
+ c._response = result
221
+ return
222
+
223
+ # Run handler
224
+ result = handler.call!(c, c)
225
+ c._response = result if result instanceof Response
226
+
227
+ # Run after filters
228
+ for filter in afterFilters
229
+ filter.call!(c, c)
230
+
231
+ dispatch!(0)
232
+ c
233
+
234
+ # ==============================================================================
235
+ # Smart Response Wrapper
236
+ # ==============================================================================
237
+
238
+ smart = (fn) ->
239
+ (c, next) ->
240
+ try
241
+ result = if fn.length > 0 then fn.call!(c, c) else fn.call!(c)
242
+ return result if result instanceof Response
243
+ return c.json(result) if result? and typeof result is 'object'
244
+ if typeof result is 'string'
245
+ type = if result.trimStart().startsWith('<') then 'html' else 'text'
246
+ return c[type](result)
247
+ return c.text(String(result)) if typeof result in ['number', 'boolean']
248
+ if result is null or result is undefined
249
+ return new Response(null, { status: 204 })
250
+ result
251
+ catch err
252
+ status = err?.status or 500
253
+ console.error 'Handler error:', err if status >= 500
254
+ message = err?.message or 'Internal Server Error'
255
+ new Response message, { status, headers: { 'Content-Type': 'text/plain' } }
256
+
257
+ # ==============================================================================
258
+ # DSL: Route Registration
259
+ # ==============================================================================
260
+
261
+ addRoute = (method, path, ...handlers) ->
262
+ fullPath = "#{_prefix}#{path}"
263
+ if fullPath.includes(':') and fullPath.endsWith('?')
264
+ basePath = fullPath.slice(0, -1).replace(/\/:[^\/]+$/, '')
265
+ addRoute method, basePath, ...handlers if basePath and basePath isnt fullPath.slice(0, -1)
266
+ fullPath = fullPath.slice(0, -1)
267
+ { regex, keys } = compilePattern(fullPath)
268
+ for handler in handlers
269
+ _routes.push { method: method.toUpperCase(), regex, keys, handler: smart(handler), path: fullPath }
270
+
271
+ export get = (path, ...handlers) -> addRoute 'GET', path, ...handlers
272
+ export post = (path, ...handlers) -> addRoute 'POST', path, ...handlers
273
+ export put = (path, ...handlers) -> addRoute 'PUT', path, ...handlers
274
+ export patch = (path, ...handlers) -> addRoute 'PATCH', path, ...handlers
275
+ export del = (path, ...handlers) -> addRoute 'DELETE', path, ...handlers
276
+ export all = (path, ...handlers) -> addRoute 'ALL', path, ...handlers
277
+
278
+ export use = (pathOrMw, mw = null) ->
279
+ if typeof pathOrMw is 'function'
280
+ _middlewares.push pathOrMw
281
+ else
282
+ { regex, keys } = compilePattern(pathOrMw)
283
+ _routes.push { method: 'ALL', regex, keys, handler: mw, path: pathOrMw, middleware: true }
284
+
285
+ export prefix = (base, fn) ->
286
+ old = _prefix
287
+ _prefix = "#{_prefix}#{base}"
288
+ try
289
+ fn?.()
290
+ finally
291
+ _prefix = old
292
+
293
+ export onError = (handler) -> _errorHandler = handler
294
+ export notFound = (handler) -> _notFoundHandler = handler
295
+
296
+ export raw = (fn) -> _rawFilter = fn
297
+ export before = (fn) -> _beforeFilters.push fn
298
+ export after = (fn) -> _afterFilters.push fn
299
+
300
+ # ==============================================================================
301
+ # Main Fetch Handler
302
+ # ==============================================================================
303
+
304
+ export fetch = (req) ->
305
+ url = new URL(req.url)
306
+ pathname = posix.normalize(url.pathname)
307
+ method = req.method
308
+
309
+ if method is 'HEAD'
310
+ res = fetch!(new Request(req.url, { method: 'GET', headers: req.headers }))
311
+ return new Response(null, { status: res.status, headers: res.headers })
312
+
313
+ # Handle CORS preflight (OPTIONS) requests early, before route matching
314
+ # Enabled when cors middleware is used with preflight: true
315
+ if method is 'OPTIONS' and _corsPreflight
316
+ return new Response null,
317
+ status: 204
318
+ headers:
319
+ 'Access-Control-Allow-Origin': req.headers.get('origin') or '*'
320
+ 'Access-Control-Allow-Credentials': 'true'
321
+ 'Access-Control-Allow-Methods': 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS'
322
+ 'Access-Control-Allow-Headers': req.headers.get('access-control-request-headers') or 'Content-Type,Authorization'
323
+ 'Access-Control-Max-Age': '86400'
324
+
325
+ match = matchRoute(method, pathname)
326
+
327
+ unless match
328
+ c = createContext(req)
329
+ if _notFoundHandler?
330
+ return _notFoundHandler.call!(c, c)
331
+ return new Response('Not Found', { status: 404 })
332
+
333
+ c = createContext(req, match.params)
334
+
335
+ # Run raw filter before body parsing (e.g., fix content-type)
336
+ _rawFilter?.(req) if method in ['POST', 'PUT', 'PATCH']
337
+
338
+ # Pre-parse body and query for sync read() — baked in, zero ceremony
339
+ data = {}
340
+ try
341
+ ct = req.headers.get('content-type') or ''
342
+ if method in ['POST', 'PUT', 'PATCH']
343
+ if ct =~ /json/i
344
+ data = c.req.json!
345
+ else if ct =~ /x-www-form-urlencoded|form-data/i
346
+ data = c.req.parseBody!
347
+ keys = Object.keys(data)
348
+ data = { body: keys[0] } if keys.length is 1 and data[keys[0]] is ''
349
+ else if ct =~ /text\//i
350
+ data = { body: c.req.text! }
351
+ catch
352
+ data = {}
353
+ merged = { ...data, ...c.req.query(), ...match.params }
354
+
355
+ # Run handler inside AsyncLocalStorage context
356
+ requestContext.run! { env: c, data: merged }, ->
357
+ runHandler!(c, match.handler)
358
+
359
+ # Internal: Execute handler with middleware and error handling
360
+ runHandler = (c, handler) ->
361
+ try
362
+ # Compose runs: middlewares → before filters → handler → after filters
363
+ compose!(_middlewares, _beforeFilters, _afterFilters, handler)(c)
364
+ c._response or new Response('', { status: 204 })
365
+ catch err
366
+ console.error 'Request error:', err if not err?.status or err.status >= 500
367
+ if _errorHandler?
368
+ _errorHandler.call!(c, err, c)
369
+ else
370
+ new Response err?.message or 'Internal Server Error', { status: err?.status or 500 }
371
+
372
+ # ==============================================================================
373
+ # Server Startup
374
+ # ==============================================================================
375
+
376
+ export startHandler = -> fetch
377
+
378
+ export start = (opts = {}) ->
379
+ handler = startHandler()
380
+
381
+ # If running under rip-server, set global handler and return
382
+ if process.env.WORKER_ID? or process.env.SOCKET_PATH?
383
+ globalThis.__ripHandler = handler
384
+ return handler
385
+
386
+ # Otherwise start standalone server
387
+ host = opts.host or 'localhost'
388
+ port = opts.port or 3000
389
+ server = Bun.serve { hostname: host, port: port, fetch: handler }
390
+ console.log "rip-api listening on http://#{host}:#{port}" unless opts.silent
391
+ server
392
+
393
+ export App = (fn) ->
394
+ resetGlobals()
395
+ fn?.()
396
+ startHandler()
397
+
398
+ # ==============================================================================
399
+ # Utility: Get Current Environment/Context
400
+ # ==============================================================================
401
+
402
+ export ctx = ->
403
+ store = requestContext.getStore()
404
+ store?.env or null
405
+
406
+ # Subrequest — run a function with new data but same @ context
407
+ # Enables read() to work against a synthetic payload while preserving
408
+ # all @ variables from the parent request (e.g., @token).
409
+ export subrequest = (data, fn) ->
410
+ store = requestContext.getStore() or throw new Error 'no context for subrequest()'
411
+ requestContext.run! { env: store.env, data }, ->
412
+ fn.call!(store.env)
413
+
414
+ # Session proxy — access session.foo anywhere (via AsyncLocalStorage)
415
+ export session = new Proxy {},
416
+ get: (_, key) ->
417
+ requestContext.getStore()?.env?.session?[key]
418
+ set: (_, key, value) ->
419
+ ctx = requestContext.getStore()?.env
420
+ return false unless ctx?
421
+ ctx.session ?= {}
422
+ ctx.session[key] = value
423
+ true
424
+ deleteProperty: (_, key) ->
425
+ ctx = requestContext.getStore()?.env
426
+ delete ctx.session[key] if ctx?.session?
427
+ true
428
+
429
+ # Env proxy — access env.FOO anywhere (shortcut for process.env)
430
+ export env = new Proxy {}, get: (_, key) -> process.env[key]
431
+
432
+ # ==============================================================================
433
+ # Utility Functions
434
+ # ==============================================================================
435
+
436
+ export isBlank = (obj) ->
437
+ return true unless obj?
438
+ return true if obj is false
439
+ return true if typeof obj is 'string' and /^\s*$/.test obj
440
+ return true if Array.isArray(obj) and obj.length is 0
441
+ return true if typeof obj is 'object' and Object.keys(obj).length is 0
442
+ false
443
+
444
+ capitalize = (str) -> str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
445
+
446
+ toMoney = (value, half, cents) ->
447
+ m = value.replace(/[$, ]/g, '').match(/^([-+]?)(\d*)\.?(\d*)$/)
448
+ return null unless m
449
+ intp = m[2] or ''
450
+ raw = m[3] or ''
451
+ return null unless intp.length or raw.length
452
+ neg = m[1] is '-'
453
+ frac = raw.padEnd(3, '0')
454
+ c = parseInt((intp or '0') + frac[0] + frac[1], 10) or 0
455
+ d = parseInt(frac[2], 10) or 0
456
+ rest = raw.length > 3 and /[1-9]/.test(raw.slice(3))
457
+ 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))
458
+ c += 1 if up
459
+ c = if neg then -c else c
460
+ if cents then c else c / 100
461
+
462
+ export toName = (str, ...type) ->
463
+ s = String(str)
464
+ s = s.toLowerCase().replace(/\s+/g, ' ').trim()
465
+ s = s.replace /(^|(?<=\P{L}))(\p{L})/gu, (m, pre, ch) -> pre + ch.toUpperCase()
466
+ s = s.replace(/[`'']/g, "'").replace(/[""]/g, '"')
467
+ s = s.replace /\b([a-z])\. ?([bcdfghjklmnpqrstvwxyz])\.?(?=\W|$)/gi, (match, p1, p2) -> (p1 + p2).toUpperCase()
468
+ s = s.replace /(?<=^|\P{L})([A-Za-z](?:(?![AEIOUYaeiouy])[A-Za-z]){1,4})(?=$|\P{L})/gu, (m) -> m.toUpperCase()
469
+ 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)
470
+ 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) ->
471
+ if offset is 0 or p1? or p3? or p4? then capitalize(p2) else p2.toLowerCase()
472
+ 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)
473
+ if type.includes 'name'
474
+ 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)
475
+ if type.includes 'address'
476
+ 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)
477
+ s = s.replace /(1st|2nd|3rd|[\d]th|\bde l[ao]s)\b/gi, (match) -> match.toLowerCase()
478
+ s = s.replace /\b(ca|dba|fbo|ihop|mri|ucla|usa|vru|[ns][ew]|i{1,3}v?)\b/gi, (match) -> match.toUpperCase()
479
+ s = s.replace /\b([-@.\w]+\.(?:com|net|io|org))\b/gi, (match) -> match.toLowerCase()
480
+ s = s.replace /(?<=\p{L}')S\b/gu, 's'
481
+ s = s.replace /# /g, '#'
482
+ s = s.replace /\s*[.,#]+$/, ''
483
+ s = s.replace /\bP\.? ?O\.? ?Box/i, 'PO Box'
484
+ s
485
+
486
+ export toPhone = (str) ->
487
+ return "" if isBlank(str)
488
+ num = str.toString().replace(/\s+/g, ' ').trim()
489
+ [num, ext] = num.split(/\s*(?:, ?)?(?:ext?\.?|x|#|:|,)\s*/i, 2)
490
+ ext = ext.replace(/\D+/g, "") if ext
491
+ if num =~ /^\+([-+#*.,\d ]+)$/
492
+ etc = _[1].replace(/[^#*,\d]+/g, "")
493
+ if etc.replace(/\D+/g, "").length >= 6
494
+ if etc =~ /^1(\d{10})$/
495
+ [num, ext] = [_[1], ""]
496
+ else
497
+ return "+#{etc}"
498
+ num = num.replace(/^[^2-9]*/, "").replace(/\D+/g, "")
499
+ if num =~ /^([2-9][0-8][0-9])([2-9]\d\d)(\d{4})$/
500
+ num = "(#{_[1]}) #{_[2]}-#{_[3]}"
501
+ num += ", ext. #{ext}" if ext
502
+ else
503
+ num = null
504
+ num
505
+
506
+ # ==============================================================================
507
+ # Validators
508
+ # ==============================================================================
509
+
510
+ export validators =
511
+ # Numbers & money
512
+ id: (v) -> v[/^([1-9]\d{0,14})$/] and parseInt(_[1])
513
+ int: (v) -> v[/^([-+]?(?:0|[1-9]\d{0,14}))$/] and parseInt(_[1])
514
+ whole: (v) -> v[/^(0|[1-9]\d{0,14})$/] and parseInt(_[1])
515
+ float: (v) -> v[/^([-+]?\d+(?:\.\d*)?|\.\d+)$/] and parseFloat(_[1])
516
+ money: (v) -> v[/^([-+]? ?\$? ?(?:[\d,]+(?:\.\d*)?|\.\d+))$/] and toMoney(_[1])
517
+ money_even: (v) -> v[/^([-+]? ?\$? ?(?:[\d,]+(?:\.\d*)?|\.\d+))$/] and toMoney(_[1], true)
518
+ cents: (v) -> v[/^([-+]? ?\$? ?(?:[\d,]+(?:\.\d*)?|\.\d+))$/] and toMoney(_[1], false, true)
519
+ cents_even: (v) -> v[/^([-+]? ?\$? ?(?:[\d,]+(?:\.\d*)?|\.\d+))$/] and toMoney(_[1], true, true)
520
+
521
+ # Strings & formatting
522
+ string: (v) -> v.replace(/(?:\t+|\s{2,})/g, ' ')
523
+ text: (v) -> v.replace(/ +/g , ' ')
524
+
525
+ # Name/address
526
+ name: (v) -> v = v.replace(/\s+/g, ' ') and toName v, 'name'
527
+ address: (v) -> v = v.replace(/\s+/g, ' ') and toName v, 'address'
528
+
529
+ # Date & time
530
+ date: (v) -> v[/^\d{4}(-?)\d{2}\1\d{2}$/] and _[0]
531
+ time: (v) -> v[/^(2[0-3]|[01]?\d):([0-5]\d)(?::([0-5]\d))?$/] and _[0]
532
+ time12: (v) -> v[/^(1[0-2]|0?[1-9]):([0-5]\d)(?::([0-5]\d))?\s?(am|pm)$/i] and _[0].toLowerCase()
533
+
534
+ # Booleans
535
+ truthy: (v) -> (v =~ /^(true|t|1|yes|y|on)$/i) and true
536
+ falsy: (v) -> (v =~ /^(false|f|0|no|n|off)$/i) and true
537
+ 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
538
+
539
+ # Contact, geo, identity
540
+ email: (v) -> v[/^([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/i] and _[0]
541
+ state: (v) -> v[/^([a-z][a-z])$/i] and _[1].toUpperCase()
542
+ zip: (v) -> v[/^(\d{5})/] and _[1]
543
+ zipplus4: (v) -> v[/^(\d{5})-?(\d{4})$/] and "#{_[1]}-#{_[2]}"
544
+ ssn: (v) -> v[/^(\d{3})-?(\d{2})-?(\d{4})$/] and "#{_[1]}#{_[2]}#{_[3]}"
545
+ sex: (v) -> v[/^(m|male|f|female|o|other)$/i] and _[1][0].toLowerCase()
546
+
547
+ # Phone
548
+ phone: (v) -> toPhone(v)
549
+
550
+ # Web & technical
551
+ username: (v) -> v[/^([a-zA-Z0-9_-]{3,20})$/] and _[1].toLowerCase()
552
+ 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]
553
+ 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()
554
+ url: (v) -> v[/^(https?:\/\/)[^\s/$.?#].[^\s]*$/i] and _[0]
555
+ color: (v) -> v[/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/] and "##{_[1].toLowerCase()}"
556
+ 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()
557
+ semver: (v) -> v[/^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*))?(?:\+([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*))?$/] and _[0]
558
+
559
+ # Collections & structured
560
+ array: (v) -> Array.isArray(v) and v or null
561
+ hash: (v) -> (v? and typeof v is 'object' and not Array.isArray(v)) and v or null
562
+ 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
563
+
564
+ # Slugs & ids list
565
+ slug: (v) -> v[/^([a-z0-9]+(?:-[a-z0-9]+)*)$/i] and _[1].toLowerCase()
566
+ ids: (v) ->
567
+ cleaned = v.replace(/[, ]+/g, ' ').trim()
568
+ return null unless cleaned
569
+ try
570
+ nums = []
571
+ for part in cleaned.split(' ')
572
+ return null unless part[/^([1-9]\d{0,19})$/]
573
+ nums.push parseInt(_[1])
574
+ Array.from(new Set(nums)).sort((a, b) -> a - b)
575
+ catch then null
576
+
577
+ export registerValidator = (name, fn) -> validators[name] = fn
578
+ export getValidator = (name) -> validators[name]
579
+
580
+ # ==============================================================================
581
+ # read() — Sinatra-style Parameter Reading
582
+ # ==============================================================================
583
+
584
+ export read = (name = null, type = null, miss = null) ->
585
+ store = requestContext.getStore() or throw new Error 'no context for read()'
586
+
587
+ # missing value helper
588
+ done = (must = false) ->
589
+ return miss() if typeof miss is 'function'
590
+ throw new Error "Missing required field: #{name}" if must
591
+ return miss ?? null
592
+
593
+ # get value from store
594
+ v = store.data or {}
595
+ v = v[name] if name?
596
+ return v if type is 'raw'
597
+ v = v.trim() if typeof v is 'string'
598
+
599
+ # value only, no validator
600
+ if !type?
601
+ return v if v?
602
+ return done()
603
+
604
+ # String: apply validator function
605
+ else if typeof type is 'string'
606
+
607
+ # detect required value (trailing '!')
608
+ if type.endsWith '!'
609
+ must = true
610
+ type = type.slice(0, -1)
611
+
612
+ # apply validator function
613
+ f = getValidator(type)
614
+ v = String(v ?? '') unless type in ['array', 'hash', 'json']
615
+ v = if f then f(v) else null
616
+
617
+ # Regex: apply regex pattern
618
+ else if type instanceof RegExp
619
+ s = String(v ?? '')
620
+ v = s.match(type)?[0] or null
621
+
622
+ # Array: [min, max] constraint or enumeration
623
+ else if Array.isArray(type)
624
+ s = String(v ?? '')
625
+ y = true
626
+ if typeof type[0] is 'number'
627
+ [min, max] = type
628
+ if typeof v is 'number' or (s =~ /^[-+]?\d+$/)
629
+ n = if typeof v is 'number' then v else +s
630
+ y = false if min? and n < min
631
+ y = false if max? and n > max
632
+ v = if y then n else null
633
+ else
634
+ y = false if min? and s.length < min
635
+ y = false if max? and s.length > max
636
+ v = if y then s else null
637
+ else
638
+ v = if type.includes(s) then s else null
639
+
640
+ # Object: start/end, min/max
641
+ else if type? and typeof type is 'object'
642
+ s = String(v ?? '')
643
+ if type.start? or type.end?
644
+ n = if s =~ /^[-+]?\d+$/ then +s else NaN
645
+ 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
646
+ else if type.min? or type.max?
647
+ y = true
648
+ if typeof v is 'number' or (s =~ /^[-+]?\d+$/)
649
+ n = if typeof v is 'number' then v else +s
650
+ y = false if type.min? and n < type.min
651
+ y = false if type.max? and n > type.max
652
+ v = if y then n else null
653
+ else
654
+ y = false if type.min? and s.length < type.min
655
+ y = false if type.max? and s.length > type.max
656
+ v = if y then s else null
657
+
658
+ # blank / missing value handling
659
+ if not v? or (typeof v is 'string' and v.trim() is '')
660
+ return done(must)
661
+
662
+ v