@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.
Files changed (4) hide show
  1. package/README.md +776 -0
  2. package/api.rip +564 -0
  3. package/middleware.rip +394 -0
  4. 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
+ }