@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/README.md +740 -142
- package/api.rip +663 -0
- package/middleware.rip +558 -0
- package/package.json +17 -8
- package/server.rip +27 -41
- package/tests/read.test.rip +254 -0
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
|