@rip-lang/api 0.5.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/README.md +776 -0
- package/api.rip +564 -0
- package/middleware.rip +394 -0
- package/package.json +43 -0
package/middleware.rip
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# @rip-lang/api/middleware — Built-in Middleware
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# import { cors, logger, compress, cookies, sessions } from '@rip-lang/api/middleware'
|
|
7
|
+
# import { session } from '@rip-lang/api'
|
|
8
|
+
#
|
|
9
|
+
# use logger()
|
|
10
|
+
# use cors origin: 'https://myapp.com'
|
|
11
|
+
# use compress()
|
|
12
|
+
# use cookies()
|
|
13
|
+
# use sessions()
|
|
14
|
+
#
|
|
15
|
+
# before ->
|
|
16
|
+
# session.userId = 123 # Works anywhere via AsyncLocalStorage
|
|
17
|
+
#
|
|
18
|
+
# Note: read() and session work automatically — no setup required!
|
|
19
|
+
#
|
|
20
|
+
# ==============================================================================
|
|
21
|
+
|
|
22
|
+
# ==============================================================================
|
|
23
|
+
# cors — Cross-Origin Resource Sharing
|
|
24
|
+
# ==============================================================================
|
|
25
|
+
#
|
|
26
|
+
# Options:
|
|
27
|
+
# origin: '*' | string | string[] | (origin) -> boolean
|
|
28
|
+
# methods: 'GET,POST,...' | string[]
|
|
29
|
+
# headers: 'Content-Type,...' | string[]
|
|
30
|
+
# credentials: boolean
|
|
31
|
+
# maxAge: number (seconds)
|
|
32
|
+
# exposeHeaders: string | string[]
|
|
33
|
+
#
|
|
34
|
+
|
|
35
|
+
export cors = (opts = {}) ->
|
|
36
|
+
origin = opts.origin or '*'
|
|
37
|
+
methods = opts.methods or 'GET,POST,PUT,PATCH,DELETE,OPTIONS'
|
|
38
|
+
headers = opts.headers or 'Content-Type,Authorization,X-Requested-With'
|
|
39
|
+
credentials = opts.credentials or false
|
|
40
|
+
maxAge = opts.maxAge or 86400
|
|
41
|
+
exposeHeaders = opts.exposeHeaders or ''
|
|
42
|
+
|
|
43
|
+
# Normalize arrays to comma-separated strings
|
|
44
|
+
methods = methods.join(',') if Array.isArray(methods)
|
|
45
|
+
headers = headers.join(',') if Array.isArray(headers)
|
|
46
|
+
exposeHeaders = exposeHeaders.join(',') if Array.isArray(exposeHeaders)
|
|
47
|
+
|
|
48
|
+
(c, next) ->
|
|
49
|
+
requestOrigin = c.req.header('Origin')
|
|
50
|
+
|
|
51
|
+
# Determine allowed origin
|
|
52
|
+
allowedOrigin = if typeof origin is 'function'
|
|
53
|
+
if origin(requestOrigin) then requestOrigin else null
|
|
54
|
+
else if Array.isArray(origin)
|
|
55
|
+
if origin.includes(requestOrigin) then requestOrigin else null
|
|
56
|
+
else if origin is '*'
|
|
57
|
+
'*'
|
|
58
|
+
else
|
|
59
|
+
origin
|
|
60
|
+
|
|
61
|
+
return next!() unless allowedOrigin
|
|
62
|
+
|
|
63
|
+
# Set CORS headers
|
|
64
|
+
c.header 'Access-Control-Allow-Origin', allowedOrigin
|
|
65
|
+
c.header 'Access-Control-Allow-Methods', methods
|
|
66
|
+
c.header 'Access-Control-Allow-Headers', headers
|
|
67
|
+
|
|
68
|
+
if credentials
|
|
69
|
+
c.header 'Access-Control-Allow-Credentials', 'true'
|
|
70
|
+
|
|
71
|
+
if exposeHeaders
|
|
72
|
+
c.header 'Access-Control-Expose-Headers', exposeHeaders
|
|
73
|
+
|
|
74
|
+
# Handle preflight OPTIONS request
|
|
75
|
+
if c.req.method is 'OPTIONS'
|
|
76
|
+
c.header 'Access-Control-Max-Age', String(maxAge)
|
|
77
|
+
return c.body null, 204
|
|
78
|
+
|
|
79
|
+
await next()
|
|
80
|
+
|
|
81
|
+
# ==============================================================================
|
|
82
|
+
# logger — Request Logging
|
|
83
|
+
# ==============================================================================
|
|
84
|
+
#
|
|
85
|
+
# Options:
|
|
86
|
+
# format: 'tiny' | 'short' | 'dev' | 'full' | (info) -> string
|
|
87
|
+
# skip: (c) -> boolean
|
|
88
|
+
# stream: { write: (msg) -> } (default: console)
|
|
89
|
+
#
|
|
90
|
+
|
|
91
|
+
export logger = (opts = {}) ->
|
|
92
|
+
format = opts.format or 'dev'
|
|
93
|
+
skip = opts.skip or null
|
|
94
|
+
stream = opts.stream or { write: (msg) -> console.log msg.trim() }
|
|
95
|
+
|
|
96
|
+
formatters =
|
|
97
|
+
tiny: (info) -> "#{info.method} #{info.path} #{info.status} - #{info.ms}ms"
|
|
98
|
+
short: (info) -> "#{info.method} #{info.path} #{info.status} #{info.size} - #{info.ms}ms"
|
|
99
|
+
dev: (info) ->
|
|
100
|
+
color = if info.status >= 500 then '\x1b[31m' # red
|
|
101
|
+
else if info.status >= 400 then '\x1b[33m' # yellow
|
|
102
|
+
else if info.status >= 300 then '\x1b[36m' # cyan
|
|
103
|
+
else '\x1b[32m' # green
|
|
104
|
+
reset = '\x1b[0m'
|
|
105
|
+
"#{info.method} #{info.path} #{color}#{info.status}#{reset} - #{info.ms}ms"
|
|
106
|
+
full: (info) -> "[#{info.time}] #{info.method} #{info.path} #{info.status} #{info.size} - #{info.ms}ms"
|
|
107
|
+
|
|
108
|
+
(c, next) ->
|
|
109
|
+
return next!() if skip?(c)
|
|
110
|
+
|
|
111
|
+
start = Date.now()
|
|
112
|
+
await next()
|
|
113
|
+
ms = Date.now() - start
|
|
114
|
+
|
|
115
|
+
info =
|
|
116
|
+
method: c.req.method
|
|
117
|
+
path: c.req.path
|
|
118
|
+
status: c._response?.status or 200
|
|
119
|
+
ms: ms
|
|
120
|
+
size: c._response?.headers?.get('Content-Length') or '-'
|
|
121
|
+
time: new Date().toISOString()
|
|
122
|
+
|
|
123
|
+
msg = if typeof format is 'function' then format(info) else formatters[format]?(info) or formatters.dev(info)
|
|
124
|
+
stream.write "#{msg}\n"
|
|
125
|
+
|
|
126
|
+
# ==============================================================================
|
|
127
|
+
# compress — Response Compression (gzip, deflate)
|
|
128
|
+
# ==============================================================================
|
|
129
|
+
#
|
|
130
|
+
# Options:
|
|
131
|
+
# threshold: number (min bytes to compress, default 1024)
|
|
132
|
+
# encodings: string[] (default ['gzip', 'deflate'])
|
|
133
|
+
#
|
|
134
|
+
# Note: Requires Bun's built-in compression support
|
|
135
|
+
#
|
|
136
|
+
|
|
137
|
+
export compress = (opts = {}) ->
|
|
138
|
+
threshold = opts.threshold or 1024
|
|
139
|
+
encodings = opts.encodings or ['gzip', 'deflate']
|
|
140
|
+
|
|
141
|
+
(c, next) ->
|
|
142
|
+
await next()
|
|
143
|
+
|
|
144
|
+
# Skip if no response body
|
|
145
|
+
response = c._response
|
|
146
|
+
return unless response?
|
|
147
|
+
|
|
148
|
+
# Check Accept-Encoding header
|
|
149
|
+
acceptEncoding = c.req.header('Accept-Encoding') or ''
|
|
150
|
+
|
|
151
|
+
# Find supported encoding
|
|
152
|
+
encoding = null
|
|
153
|
+
for enc in encodings
|
|
154
|
+
if acceptEncoding.includes(enc)
|
|
155
|
+
encoding = enc
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
return unless encoding
|
|
159
|
+
|
|
160
|
+
# Get body and check threshold
|
|
161
|
+
try
|
|
162
|
+
body = response.body
|
|
163
|
+
return unless body
|
|
164
|
+
|
|
165
|
+
# Clone to read the body
|
|
166
|
+
clone = response.clone()
|
|
167
|
+
text = clone.text!
|
|
168
|
+
return if text.length < threshold
|
|
169
|
+
|
|
170
|
+
# Compress using Bun's built-in CompressionStream
|
|
171
|
+
compressed = if encoding is 'gzip'
|
|
172
|
+
new Response(text).body.pipeThrough(new CompressionStream('gzip'))
|
|
173
|
+
else if encoding is 'deflate'
|
|
174
|
+
new Response(text).body.pipeThrough(new CompressionStream('deflate'))
|
|
175
|
+
else
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
# Create new response with compressed body
|
|
179
|
+
headers = new Headers(response.headers)
|
|
180
|
+
headers.set 'Content-Encoding', encoding
|
|
181
|
+
headers.delete 'Content-Length' # Length changed
|
|
182
|
+
|
|
183
|
+
c._response = new Response compressed,
|
|
184
|
+
status: response.status
|
|
185
|
+
headers: headers
|
|
186
|
+
catch
|
|
187
|
+
# If compression fails, keep original response
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
# ==============================================================================
|
|
191
|
+
# cookies — Cookie Parsing and Setting
|
|
192
|
+
# ==============================================================================
|
|
193
|
+
#
|
|
194
|
+
# Options:
|
|
195
|
+
# secret: string (for signed cookies)
|
|
196
|
+
# secure: boolean (HTTPS only, default false)
|
|
197
|
+
# httpOnly: boolean (default true)
|
|
198
|
+
# sameSite: 'Strict' | 'Lax' | 'None' (default 'Lax')
|
|
199
|
+
# maxAge: number (seconds)
|
|
200
|
+
# path: string (default '/')
|
|
201
|
+
#
|
|
202
|
+
# Adds to context:
|
|
203
|
+
# c.cookie(name) — get cookie value
|
|
204
|
+
# c.cookie(name, value, opts) — set cookie
|
|
205
|
+
# c.clearCookie(name) — delete cookie
|
|
206
|
+
#
|
|
207
|
+
|
|
208
|
+
export cookies = (opts = {}) ->
|
|
209
|
+
defaults =
|
|
210
|
+
secure: opts.secure or false
|
|
211
|
+
httpOnly: opts.httpOnly ? true
|
|
212
|
+
sameSite: opts.sameSite or 'Lax'
|
|
213
|
+
path: opts.path or '/'
|
|
214
|
+
maxAge: opts.maxAge
|
|
215
|
+
|
|
216
|
+
(c, next) ->
|
|
217
|
+
# Parse cookies from header
|
|
218
|
+
cookieHeader = c.req.header('Cookie') or ''
|
|
219
|
+
parsed = {}
|
|
220
|
+
for pair in cookieHeader.split(';')
|
|
221
|
+
[key, val] = pair.trim().split('=')
|
|
222
|
+
parsed[key] = decodeURIComponent(val) if key and val
|
|
223
|
+
|
|
224
|
+
# Store pending Set-Cookie headers
|
|
225
|
+
setCookies = []
|
|
226
|
+
|
|
227
|
+
# Get cookie value
|
|
228
|
+
c.cookie = (name, value, cookieOpts) ->
|
|
229
|
+
if value is undefined
|
|
230
|
+
# Getter
|
|
231
|
+
return parsed[name]
|
|
232
|
+
|
|
233
|
+
# Setter
|
|
234
|
+
cookieOpts = { ...defaults, ...cookieOpts }
|
|
235
|
+
parts = ["#{encodeURIComponent(name)}=#{encodeURIComponent(value)}"]
|
|
236
|
+
parts.push "Path=#{cookieOpts.path}" if cookieOpts.path
|
|
237
|
+
parts.push "Max-Age=#{cookieOpts.maxAge}" if cookieOpts.maxAge?
|
|
238
|
+
parts.push "HttpOnly" if cookieOpts.httpOnly
|
|
239
|
+
parts.push "Secure" if cookieOpts.secure
|
|
240
|
+
parts.push "SameSite=#{cookieOpts.sameSite}" if cookieOpts.sameSite
|
|
241
|
+
setCookies.push parts.join('; ')
|
|
242
|
+
|
|
243
|
+
# Clear cookie
|
|
244
|
+
c.clearCookie = (name, cookieOpts = {}) ->
|
|
245
|
+
c.cookie name, '', { ...cookieOpts, maxAge: 0 }
|
|
246
|
+
|
|
247
|
+
await next()
|
|
248
|
+
|
|
249
|
+
# Apply Set-Cookie headers
|
|
250
|
+
for cookie in setCookies
|
|
251
|
+
c.header 'Set-Cookie', cookie, append: true
|
|
252
|
+
|
|
253
|
+
# ==============================================================================
|
|
254
|
+
# session — Session Management (requires cookies middleware)
|
|
255
|
+
# ==============================================================================
|
|
256
|
+
#
|
|
257
|
+
# Options:
|
|
258
|
+
# name: string (cookie name, default 'session')
|
|
259
|
+
# maxAge: number (seconds, default 86400 = 24 hours)
|
|
260
|
+
# secure: boolean (HTTPS only)
|
|
261
|
+
# httpOnly: boolean (default true)
|
|
262
|
+
# sameSite: 'Strict' | 'Lax' | 'None' (default 'Lax')
|
|
263
|
+
#
|
|
264
|
+
# Usage:
|
|
265
|
+
# import { session } from '@rip-lang/api'
|
|
266
|
+
#
|
|
267
|
+
# before ->
|
|
268
|
+
# session.userId = 123
|
|
269
|
+
#
|
|
270
|
+
# get '/profile', ->
|
|
271
|
+
# { userId: session.userId }
|
|
272
|
+
#
|
|
273
|
+
|
|
274
|
+
export sessions = (opts = {}) ->
|
|
275
|
+
cookieName = opts.name or 'session'
|
|
276
|
+
maxAge = opts.maxAge or 86400
|
|
277
|
+
secure = opts.secure or false
|
|
278
|
+
httpOnly = opts.httpOnly ? true
|
|
279
|
+
sameSite = opts.sameSite or 'Lax'
|
|
280
|
+
|
|
281
|
+
(c, next) ->
|
|
282
|
+
# Parse existing session from cookie
|
|
283
|
+
raw = c.cookie?(cookieName)
|
|
284
|
+
try
|
|
285
|
+
c.session = if raw then JSON.parse(atob(raw)) else {}
|
|
286
|
+
catch
|
|
287
|
+
c.session = {}
|
|
288
|
+
|
|
289
|
+
# Track original for change detection
|
|
290
|
+
original = JSON.stringify(c.session)
|
|
291
|
+
|
|
292
|
+
await next()
|
|
293
|
+
|
|
294
|
+
# Save session if changed
|
|
295
|
+
current = JSON.stringify(c.session)
|
|
296
|
+
if current isnt original and c._response?
|
|
297
|
+
encoded = btoa(current)
|
|
298
|
+
parts = ["#{cookieName}=#{encoded}"]
|
|
299
|
+
parts.push "Path=/"
|
|
300
|
+
parts.push "Max-Age=#{maxAge}"
|
|
301
|
+
parts.push "HttpOnly" if httpOnly
|
|
302
|
+
parts.push "Secure" if secure
|
|
303
|
+
parts.push "SameSite=#{sameSite}"
|
|
304
|
+
cookie = parts.join('; ')
|
|
305
|
+
|
|
306
|
+
# Clone response with new header
|
|
307
|
+
headers = new Headers(c._response.headers)
|
|
308
|
+
headers.append 'Set-Cookie', cookie
|
|
309
|
+
c._response = new Response c._response.body,
|
|
310
|
+
status: c._response.status
|
|
311
|
+
headers: headers
|
|
312
|
+
|
|
313
|
+
# ==============================================================================
|
|
314
|
+
# secureHeaders — Security Headers
|
|
315
|
+
# ==============================================================================
|
|
316
|
+
#
|
|
317
|
+
# Sets common security headers:
|
|
318
|
+
# X-Content-Type-Options: nosniff
|
|
319
|
+
# X-Frame-Options: DENY
|
|
320
|
+
# X-XSS-Protection: 1; mode=block
|
|
321
|
+
# Referrer-Policy: strict-origin-when-cross-origin
|
|
322
|
+
# Content-Security-Policy (optional)
|
|
323
|
+
#
|
|
324
|
+
|
|
325
|
+
export secureHeaders = (opts = {}) ->
|
|
326
|
+
(c, next) ->
|
|
327
|
+
c.header 'X-Content-Type-Options', 'nosniff'
|
|
328
|
+
c.header 'X-Frame-Options', opts.frameOptions or 'DENY'
|
|
329
|
+
c.header 'X-XSS-Protection', '1; mode=block'
|
|
330
|
+
c.header 'Referrer-Policy', opts.referrerPolicy or 'strict-origin-when-cross-origin'
|
|
331
|
+
|
|
332
|
+
if opts.contentSecurityPolicy
|
|
333
|
+
c.header 'Content-Security-Policy', opts.contentSecurityPolicy
|
|
334
|
+
|
|
335
|
+
if opts.hsts
|
|
336
|
+
maxAge = opts.hstsMaxAge or 31536000
|
|
337
|
+
c.header 'Strict-Transport-Security', "max-age=#{maxAge}; includeSubDomains"
|
|
338
|
+
|
|
339
|
+
await next()
|
|
340
|
+
|
|
341
|
+
# ==============================================================================
|
|
342
|
+
# timeout — Request Timeout
|
|
343
|
+
# ==============================================================================
|
|
344
|
+
#
|
|
345
|
+
# Options:
|
|
346
|
+
# ms: number (timeout in milliseconds, default 30000)
|
|
347
|
+
# message: string (error message)
|
|
348
|
+
# status: number (status code, default 408)
|
|
349
|
+
#
|
|
350
|
+
|
|
351
|
+
export timeout = (opts = {}) ->
|
|
352
|
+
ms = opts.ms or 30000
|
|
353
|
+
message = opts.message or 'Request Timeout'
|
|
354
|
+
status = opts.status or 408
|
|
355
|
+
|
|
356
|
+
(c, next) ->
|
|
357
|
+
timer = null
|
|
358
|
+
timedOut = false
|
|
359
|
+
|
|
360
|
+
timeoutPromise = new Promise (_, reject) ->
|
|
361
|
+
timer = setTimeout ->
|
|
362
|
+
timedOut = true
|
|
363
|
+
reject new Error message
|
|
364
|
+
, ms
|
|
365
|
+
|
|
366
|
+
try
|
|
367
|
+
await Promise.race [next(), timeoutPromise]
|
|
368
|
+
catch err
|
|
369
|
+
if timedOut
|
|
370
|
+
return c.json { error: message }, status
|
|
371
|
+
throw err
|
|
372
|
+
finally
|
|
373
|
+
clearTimeout timer if timer
|
|
374
|
+
|
|
375
|
+
# ==============================================================================
|
|
376
|
+
# bodyLimit — Request Body Size Limit
|
|
377
|
+
# ==============================================================================
|
|
378
|
+
#
|
|
379
|
+
# Options:
|
|
380
|
+
# maxSize: number (bytes, default 1MB)
|
|
381
|
+
# message: string (error message)
|
|
382
|
+
#
|
|
383
|
+
|
|
384
|
+
export bodyLimit = (opts = {}) ->
|
|
385
|
+
maxSize = opts.maxSize or 1024 * 1024 # 1MB default
|
|
386
|
+
message = opts.message or 'Request body too large'
|
|
387
|
+
|
|
388
|
+
(c, next) ->
|
|
389
|
+
contentLength = parseInt(c.req.header('Content-Length') or '0')
|
|
390
|
+
|
|
391
|
+
if contentLength > maxSize
|
|
392
|
+
return c.json { error: message }, 413
|
|
393
|
+
|
|
394
|
+
await next()
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rip-lang/api",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Pure Rip API framework — elegant, fast, zero dependencies",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "api.rip",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./api.rip",
|
|
9
|
+
"./middleware": "./middleware.rip"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "rip api.rip",
|
|
13
|
+
"test": "echo \"Tests coming soon\" && exit 0"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"api",
|
|
17
|
+
"web-framework",
|
|
18
|
+
"routing",
|
|
19
|
+
"middleware",
|
|
20
|
+
"validation",
|
|
21
|
+
"sinatra",
|
|
22
|
+
"rip"
|
|
23
|
+
],
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/shreeve/rip-lang.git",
|
|
27
|
+
"directory": "packages/api"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/shreeve/rip-lang/tree/main/packages/api#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/shreeve/rip-lang/issues"
|
|
32
|
+
},
|
|
33
|
+
"author": "Steve Shreeve <steve.shreeve@gmail.com>",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"rip-lang": "^2.0.0"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"api.rip",
|
|
40
|
+
"middleware.rip",
|
|
41
|
+
"README.md"
|
|
42
|
+
]
|
|
43
|
+
}
|