@rip-lang/api 0.5.2 → 0.7.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 +91 -43
- package/api.rip +26 -2
- package/middleware.rip +72 -74
- package/package.json +1 -4
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
`@rip-lang/api` is a complete API framework written entirely in Rip. It provides Hono-compatible routing, middleware composition, and powerful validation — all with no external dependencies.
|
|
10
10
|
|
|
11
|
-
- **`api.rip`** (~
|
|
12
|
-
- **`middleware.rip`** (~
|
|
11
|
+
- **`api.rip`** (~595 lines) — Core framework: routing, validation, `read()`, `session`, server
|
|
12
|
+
- **`middleware.rip`** (~390 lines) — Optional middleware: cors, logger, compress, sessions, etc.
|
|
13
13
|
|
|
14
14
|
**Core Philosophy**: API development should be intuitive, safe, and beautiful. Every function eliminates boilerplate, prevents common errors, and makes your intent crystal clear.
|
|
15
15
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
- **Sinatra-Style Handlers** — Return data directly, no ceremony
|
|
20
20
|
- **Magic `@` Access** — Use `@req`, `@json()`, `@session`, `@silent` like Sinatra
|
|
21
21
|
- **Powerful Validation** — 37 built-in validators with elegant `read()` function
|
|
22
|
-
- **
|
|
22
|
+
- **Lifecycle Filters** — `raw` → `before` → handler → `after` hooks
|
|
23
23
|
- **AsyncLocalStorage** — Safe, race-condition-free request context
|
|
24
24
|
- **Hono-Compatible API** — Easy migration from existing Hono apps
|
|
25
25
|
|
|
@@ -27,15 +27,32 @@
|
|
|
27
27
|
|
|
28
28
|
## Try it Now
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
Create `app.rip`:
|
|
31
31
|
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
```coffee
|
|
33
|
+
import { get, post, read, session, start, use, before } from '@rip-lang/api'
|
|
34
|
+
import { sessions } from '@rip-lang/api/middleware'
|
|
35
|
+
|
|
36
|
+
use sessions() # Add secret: 'your-secret' for production
|
|
37
|
+
|
|
38
|
+
before ->
|
|
39
|
+
session.views ?= 0
|
|
40
|
+
session.views += 1
|
|
41
|
+
|
|
42
|
+
get '/', -> 'Hello, World!'
|
|
43
|
+
get '/json', -> { message: 'It works!', timestamp: Date.now() }
|
|
44
|
+
get '/users/:id', -> { user: { id: read('id', 'id!') } }
|
|
45
|
+
get '/session', -> { views: session.views, loggedIn: session.userId? }
|
|
46
|
+
get '/login', -> session.userId = 123; { loggedIn: true, userId: 123 }
|
|
47
|
+
get '/logout', -> delete session.userId; { loggedOut: true }
|
|
36
48
|
|
|
37
|
-
|
|
38
|
-
|
|
49
|
+
start port: 3000
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Run it:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
rip app.rip
|
|
39
56
|
```
|
|
40
57
|
|
|
41
58
|
**Test the endpoints:**
|
|
@@ -317,7 +334,7 @@ Import from `@rip-lang/api/middleware`:
|
|
|
317
334
|
|
|
318
335
|
```coffee
|
|
319
336
|
import { use } from '@rip-lang/api'
|
|
320
|
-
import { cors, logger, compress,
|
|
337
|
+
import { cors, logger, compress, sessions, secureHeaders, timeout, bodyLimit } from '@rip-lang/api/middleware'
|
|
321
338
|
|
|
322
339
|
# Logging
|
|
323
340
|
use logger()
|
|
@@ -335,10 +352,6 @@ use cors credentials: true, maxAge: 86400
|
|
|
335
352
|
use compress()
|
|
336
353
|
use compress threshold: 1024 # Min bytes to compress
|
|
337
354
|
|
|
338
|
-
# Cookies
|
|
339
|
-
use cookies()
|
|
340
|
-
use cookies secret: 'my-secret', secure: true, httpOnly: true
|
|
341
|
-
|
|
342
355
|
# Security headers
|
|
343
356
|
use secureHeaders()
|
|
344
357
|
use secureHeaders hsts: true, contentSecurityPolicy: "default-src 'self'"
|
|
@@ -353,11 +366,10 @@ use bodyLimit maxSize: 1024 * 1024 # 1MB max body
|
|
|
353
366
|
| Middleware | Options |
|
|
354
367
|
|------------|---------|
|
|
355
368
|
| `logger()` | `format`, `skip`, `stream` |
|
|
356
|
-
| `cors()` | `origin`, `methods`, `headers`, `credentials`, `maxAge` |
|
|
369
|
+
| `cors()` | `origin`, `methods`, `headers`, `credentials`, `maxAge`, `exposeHeaders`, `preflight` |
|
|
357
370
|
| `compress()` | `threshold`, `encodings` |
|
|
358
|
-
| `
|
|
359
|
-
| `
|
|
360
|
-
| `secureHeaders()` | `hsts`, `hstsMaxAge`, `contentSecurityPolicy`, `frameOptions` |
|
|
371
|
+
| `sessions()` | `secret`, `name`, `maxAge`, `secure`, `httpOnly`, `sameSite` |
|
|
372
|
+
| `secureHeaders()` | `hsts`, `hstsMaxAge`, `contentSecurityPolicy`, `frameOptions`, `referrerPolicy` |
|
|
361
373
|
| `timeout()` | `ms`, `message`, `status` |
|
|
362
374
|
| `bodyLimit()` | `maxSize`, `message` |
|
|
363
375
|
|
|
@@ -365,10 +377,10 @@ use bodyLimit maxSize: 1024 * 1024 # 1MB max body
|
|
|
365
377
|
|
|
366
378
|
```coffee
|
|
367
379
|
import { get, use, before, session } from '@rip-lang/api'
|
|
368
|
-
import {
|
|
380
|
+
import { sessions } from '@rip-lang/api/middleware'
|
|
369
381
|
|
|
370
|
-
|
|
371
|
-
use sessions
|
|
382
|
+
# Sessions parses cookies directly from request headers
|
|
383
|
+
use sessions secret: process.env.SESSION_SECRET
|
|
372
384
|
|
|
373
385
|
before ->
|
|
374
386
|
session.views ?= 0
|
|
@@ -388,25 +400,18 @@ get '/logout', ->
|
|
|
388
400
|
|
|
389
401
|
The `session` import works anywhere via AsyncLocalStorage — no `@` needed, works in helpers and nested callbacks.
|
|
390
402
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
```coffee
|
|
394
|
-
import { get, use } from '@rip-lang/api'
|
|
395
|
-
import { cookies } from '@rip-lang/api/middleware'
|
|
403
|
+
**Security note:** Without `secret`, sessions use plain base64 (dev only). With `secret`, sessions are HMAC-SHA256 signed (tamper-proof). Always set `secret` in production.
|
|
396
404
|
|
|
397
|
-
|
|
405
|
+
### CORS with Preflight
|
|
398
406
|
|
|
399
|
-
|
|
400
|
-
@cookie 'theme', 'dark', maxAge: 3600
|
|
401
|
-
{ success: true }
|
|
407
|
+
For APIs that need to handle OPTIONS preflight requests before route matching:
|
|
402
408
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
409
|
+
```coffee
|
|
410
|
+
import { use } from '@rip-lang/api'
|
|
411
|
+
import { cors } from '@rip-lang/api/middleware'
|
|
406
412
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
{ cleared: true }
|
|
413
|
+
# Handle OPTIONS early (before routes are matched)
|
|
414
|
+
use cors origin: 'https://myapp.com', preflight: true
|
|
410
415
|
```
|
|
411
416
|
|
|
412
417
|
### Custom Middleware
|
|
@@ -438,16 +443,22 @@ use (c, next) ->
|
|
|
438
443
|
console.log 'After handler'
|
|
439
444
|
```
|
|
440
445
|
|
|
441
|
-
###
|
|
446
|
+
### Request Lifecycle Filters
|
|
442
447
|
|
|
443
|
-
|
|
448
|
+
Three filters run at different stages: `raw` → `before` → handler → `after`
|
|
444
449
|
|
|
445
450
|
```coffee
|
|
446
|
-
import { before, after, get } from '@rip-lang/api'
|
|
451
|
+
import { raw, before, after, get } from '@rip-lang/api'
|
|
452
|
+
|
|
453
|
+
# Runs first — modify raw request before body parsing
|
|
454
|
+
raw (req) ->
|
|
455
|
+
# Fix content-type for specific clients
|
|
456
|
+
if req.headers.get('X-Raw-SQL') is 'true'
|
|
457
|
+
req.headers.set 'content-type', 'text/plain'
|
|
447
458
|
|
|
448
459
|
skipPaths = ['/favicon.ico', '/ping', '/health']
|
|
449
460
|
|
|
450
|
-
# Runs before
|
|
461
|
+
# Runs before handler (after body parsing)
|
|
451
462
|
before ->
|
|
452
463
|
@start = Date.now()
|
|
453
464
|
@silent = @req.path in skipPaths
|
|
@@ -455,12 +466,14 @@ before ->
|
|
|
455
466
|
unless @req.header 'Authorization'
|
|
456
467
|
return @json { error: 'Unauthorized' }, 401
|
|
457
468
|
|
|
458
|
-
# Runs after
|
|
469
|
+
# Runs after handler
|
|
459
470
|
after ->
|
|
460
471
|
return if @silent
|
|
461
472
|
console.log "#{@req.method} #{@req.path} - #{Date.now() - @start}ms"
|
|
462
473
|
```
|
|
463
474
|
|
|
475
|
+
**Note:** `raw` receives the native `Request` object (before parsing). `before` and `after` use `@` to access the context.
|
|
476
|
+
|
|
464
477
|
**How `@` works:** Handlers are called with `this` bound to the context, so `@foo` is `this.foo`. This gives you Sinatra-like magic access to:
|
|
465
478
|
- `@req` — Request object
|
|
466
479
|
- `@json()`, `@text()`, `@html()`, `@redirect()` — Response helpers
|
|
@@ -612,6 +625,41 @@ export default App ->
|
|
|
612
625
|
post '/echo', -> read()
|
|
613
626
|
```
|
|
614
627
|
|
|
628
|
+
## Context Utilities
|
|
629
|
+
|
|
630
|
+
### ctx()
|
|
631
|
+
|
|
632
|
+
Get the current request context from anywhere (via AsyncLocalStorage):
|
|
633
|
+
|
|
634
|
+
```coffee
|
|
635
|
+
import { ctx } from '@rip-lang/api'
|
|
636
|
+
|
|
637
|
+
# In a helper function
|
|
638
|
+
logRequest = ->
|
|
639
|
+
c = ctx()
|
|
640
|
+
console.log "#{c.req.method} #{c.req.path}" if c
|
|
641
|
+
|
|
642
|
+
# Works in callbacks, helpers, anywhere during request
|
|
643
|
+
get '/demo', ->
|
|
644
|
+
logRequest()
|
|
645
|
+
{ ok: true }
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
### resetGlobals()
|
|
649
|
+
|
|
650
|
+
Reset all global state (routes, middleware, filters). Useful for testing:
|
|
651
|
+
|
|
652
|
+
```coffee
|
|
653
|
+
import { resetGlobals, get, start } from '@rip-lang/api'
|
|
654
|
+
|
|
655
|
+
# In test setup
|
|
656
|
+
beforeEach ->
|
|
657
|
+
resetGlobals()
|
|
658
|
+
|
|
659
|
+
# Now define fresh routes for this test
|
|
660
|
+
get '/test', -> { test: true }
|
|
661
|
+
```
|
|
662
|
+
|
|
615
663
|
## Utility Functions
|
|
616
664
|
|
|
617
665
|
### isBlank
|
|
@@ -759,7 +807,7 @@ start port: 3000
|
|
|
759
807
|
|
|
760
808
|
## Performance
|
|
761
809
|
|
|
762
|
-
- **Minimal footprint** — Core is ~
|
|
810
|
+
- **Minimal footprint** — Core is ~595 lines, optional middleware ~390 lines
|
|
763
811
|
- **Zero dependencies** — No external packages to load
|
|
764
812
|
- **Compiled patterns** — Route regexes compiled once at startup
|
|
765
813
|
- **Smart response wrapping** — Minimal overhead for return-value handlers
|
package/api.rip
CHANGED
|
@@ -8,13 +8,12 @@
|
|
|
8
8
|
# Public exports:
|
|
9
9
|
# DSL: get, post, put, patch, del, all, use, prefix
|
|
10
10
|
# Server: start, startHandler, fetch, App
|
|
11
|
-
# Filters: before, after
|
|
11
|
+
# Filters: raw, before, after
|
|
12
12
|
# Handlers: onError, notFound
|
|
13
13
|
# Validation: read, validators, registerValidator, getValidator
|
|
14
14
|
# Context: ctx, session, env
|
|
15
15
|
# Utilities: isBlank, toName, toPhone
|
|
16
16
|
# Dev: resetGlobals
|
|
17
|
-
#
|
|
18
17
|
# ==============================================================================
|
|
19
18
|
|
|
20
19
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
@@ -26,20 +25,27 @@ import { AsyncLocalStorage } from 'node:async_hooks'
|
|
|
26
25
|
_ = null # Match capture holder for Rip's =~
|
|
27
26
|
_errorHandler = null # Custom error handler
|
|
28
27
|
_notFoundHandler = null # Custom 404 handler
|
|
28
|
+
_rawFilter = null # Raw request filter (before body parsing)
|
|
29
29
|
_beforeFilters = [] # Before request filters
|
|
30
30
|
_afterFilters = [] # After request filters
|
|
31
31
|
_routes = [] # Route definitions
|
|
32
32
|
_middlewares = [] # Global middleware functions
|
|
33
33
|
_prefix = '' # Current route prefix for grouping
|
|
34
|
+
_corsPreflight = false # Enable early OPTIONS handling (set by cors middleware)
|
|
34
35
|
|
|
35
36
|
export resetGlobals = ->
|
|
36
37
|
_errorHandler = null
|
|
37
38
|
_notFoundHandler = null
|
|
39
|
+
_rawFilter = null
|
|
38
40
|
_beforeFilters = []
|
|
39
41
|
_afterFilters = []
|
|
40
42
|
_routes = []
|
|
41
43
|
_middlewares = []
|
|
42
44
|
_prefix = ''
|
|
45
|
+
_corsPreflight = false
|
|
46
|
+
|
|
47
|
+
# Enable early OPTIONS handling (called by cors middleware with preflight: true)
|
|
48
|
+
export enableCorsPreflight = -> _corsPreflight = true
|
|
43
49
|
|
|
44
50
|
# AsyncLocalStorage for request context (enables sync read() in handlers)
|
|
45
51
|
export requestContext = new AsyncLocalStorage()
|
|
@@ -237,6 +243,7 @@ export prefix = (base, fn) ->
|
|
|
237
243
|
export onError = (handler) -> _errorHandler = handler
|
|
238
244
|
export notFound = (handler) -> _notFoundHandler = handler
|
|
239
245
|
|
|
246
|
+
export raw = (fn) -> _rawFilter = fn
|
|
240
247
|
export before = (fn) -> _beforeFilters.push fn
|
|
241
248
|
export after = (fn) -> _afterFilters.push fn
|
|
242
249
|
|
|
@@ -253,6 +260,18 @@ export fetch = (req) ->
|
|
|
253
260
|
res = fetch!(new Request(req.url, { method: 'GET', headers: req.headers }))
|
|
254
261
|
return new Response(null, { status: res.status, headers: res.headers })
|
|
255
262
|
|
|
263
|
+
# Handle CORS preflight (OPTIONS) requests early, before route matching
|
|
264
|
+
# Enabled when cors middleware is used with preflight: true
|
|
265
|
+
if method is 'OPTIONS' and _corsPreflight
|
|
266
|
+
return new Response null,
|
|
267
|
+
status: 204
|
|
268
|
+
headers:
|
|
269
|
+
'Access-Control-Allow-Origin': req.headers.get('origin') or '*'
|
|
270
|
+
'Access-Control-Allow-Credentials': 'true'
|
|
271
|
+
'Access-Control-Allow-Methods': 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS'
|
|
272
|
+
'Access-Control-Allow-Headers': req.headers.get('access-control-request-headers') or 'Content-Type,Authorization'
|
|
273
|
+
'Access-Control-Max-Age': '86400'
|
|
274
|
+
|
|
256
275
|
match = matchRoute(method, pathname)
|
|
257
276
|
|
|
258
277
|
unless match
|
|
@@ -263,6 +282,9 @@ export fetch = (req) ->
|
|
|
263
282
|
|
|
264
283
|
c = createContext(req, match.params)
|
|
265
284
|
|
|
285
|
+
# Run raw filter before body parsing (e.g., fix content-type)
|
|
286
|
+
_rawFilter?(req) if method in ['POST', 'PUT', 'PATCH']
|
|
287
|
+
|
|
266
288
|
# Pre-parse body and query for sync read() — baked in, zero ceremony
|
|
267
289
|
data = {}
|
|
268
290
|
try
|
|
@@ -272,6 +294,8 @@ export fetch = (req) ->
|
|
|
272
294
|
data = c.req.json!
|
|
273
295
|
else if ct =~ /x-www-form-urlencoded|form-data/i
|
|
274
296
|
data = c.req.parseBody!
|
|
297
|
+
else if ct =~ /text\//i
|
|
298
|
+
data = { body: c.req.text! }
|
|
275
299
|
catch
|
|
276
300
|
data = {}
|
|
277
301
|
merged = { ...data, ...c.req.query(), ...match.params }
|
package/middleware.rip
CHANGED
|
@@ -3,13 +3,12 @@
|
|
|
3
3
|
# ==============================================================================
|
|
4
4
|
#
|
|
5
5
|
# Usage:
|
|
6
|
-
# import { cors, logger, compress,
|
|
6
|
+
# import { cors, logger, compress, sessions } from '@rip-lang/api/middleware'
|
|
7
7
|
# import { session } from '@rip-lang/api'
|
|
8
8
|
#
|
|
9
9
|
# use logger()
|
|
10
10
|
# use cors origin: 'https://myapp.com'
|
|
11
11
|
# use compress()
|
|
12
|
-
# use cookies()
|
|
13
12
|
# use sessions()
|
|
14
13
|
#
|
|
15
14
|
# before ->
|
|
@@ -30,8 +29,11 @@
|
|
|
30
29
|
# credentials: boolean
|
|
31
30
|
# maxAge: number (seconds)
|
|
32
31
|
# exposeHeaders: string | string[]
|
|
32
|
+
# preflight: boolean — handle OPTIONS before route matching (default: false)
|
|
33
33
|
#
|
|
34
34
|
|
|
35
|
+
import { enableCorsPreflight } from '@rip-lang/api'
|
|
36
|
+
|
|
35
37
|
export cors = (opts = {}) ->
|
|
36
38
|
origin = opts.origin or '*'
|
|
37
39
|
methods = opts.methods or 'GET,POST,PUT,PATCH,DELETE,OPTIONS'
|
|
@@ -45,6 +47,9 @@ export cors = (opts = {}) ->
|
|
|
45
47
|
headers = headers.join(',') if Array.isArray(headers)
|
|
46
48
|
exposeHeaders = exposeHeaders.join(',') if Array.isArray(exposeHeaders)
|
|
47
49
|
|
|
50
|
+
# Enable early OPTIONS handling if preflight option is set
|
|
51
|
+
enableCorsPreflight() if opts.preflight
|
|
52
|
+
|
|
48
53
|
(c, next) ->
|
|
49
54
|
requestOrigin = c.req.header('Origin')
|
|
50
55
|
|
|
@@ -188,73 +193,11 @@ export compress = (opts = {}) ->
|
|
|
188
193
|
return
|
|
189
194
|
|
|
190
195
|
# ==============================================================================
|
|
191
|
-
#
|
|
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)
|
|
196
|
+
# sessions — Session Management
|
|
255
197
|
# ==============================================================================
|
|
256
198
|
#
|
|
257
199
|
# Options:
|
|
200
|
+
# secret: string (REQUIRED for production — signs cookie to prevent tampering)
|
|
258
201
|
# name: string (cookie name, default 'session')
|
|
259
202
|
# maxAge: number (seconds, default 86400 = 24 hours)
|
|
260
203
|
# secure: boolean (HTTPS only)
|
|
@@ -263,6 +206,9 @@ export cookies = (opts = {}) ->
|
|
|
263
206
|
#
|
|
264
207
|
# Usage:
|
|
265
208
|
# import { session } from '@rip-lang/api'
|
|
209
|
+
# import { sessions } from '@rip-lang/api/middleware'
|
|
210
|
+
#
|
|
211
|
+
# use sessions secret: process.env.SESSION_SECRET
|
|
266
212
|
#
|
|
267
213
|
# before ->
|
|
268
214
|
# session.userId = 123
|
|
@@ -270,21 +216,73 @@ export cookies = (opts = {}) ->
|
|
|
270
216
|
# get '/profile', ->
|
|
271
217
|
# { userId: session.userId }
|
|
272
218
|
#
|
|
219
|
+
# Security:
|
|
220
|
+
# Without `secret`, sessions use plain base64 (INSECURE — dev only).
|
|
221
|
+
# With `secret`, sessions are HMAC-SHA256 signed (tamper-proof).
|
|
222
|
+
#
|
|
223
|
+
# Note: Self-contained — parses cookies directly from request headers.
|
|
224
|
+
#
|
|
225
|
+
|
|
226
|
+
# HMAC-SHA256 signing helpers
|
|
227
|
+
hmacSign = (data, secret) ->
|
|
228
|
+
encoder = new TextEncoder()
|
|
229
|
+
key = crypto.subtle.importKey! 'raw', encoder.encode(secret),
|
|
230
|
+
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
|
|
231
|
+
signature = crypto.subtle.sign! 'HMAC', key, encoder.encode(data)
|
|
232
|
+
Array.from(new Uint8Array(signature)).map((b) -> b.toString(16).padStart(2, '0')).join('')
|
|
233
|
+
|
|
234
|
+
hmacVerify = (data, signature, secret) ->
|
|
235
|
+
expected = hmacSign! data, secret
|
|
236
|
+
# Constant-time comparison to prevent timing attacks
|
|
237
|
+
return false if expected.length isnt signature.length
|
|
238
|
+
result = 0
|
|
239
|
+
for i in [0...expected.length]
|
|
240
|
+
result |= expected.charCodeAt(i) ^ signature.charCodeAt(i)
|
|
241
|
+
result is 0
|
|
242
|
+
|
|
243
|
+
encodeSession = (data, secret) ->
|
|
244
|
+
json = JSON.stringify(data)
|
|
245
|
+
payload = btoa(unescape(encodeURIComponent(json)))
|
|
246
|
+
return payload unless secret
|
|
247
|
+
signature = hmacSign! payload, secret
|
|
248
|
+
"#{payload}--#{signature}"
|
|
249
|
+
|
|
250
|
+
decodeSession = (cookie, secret) ->
|
|
251
|
+
return {} unless cookie
|
|
252
|
+
try
|
|
253
|
+
if secret
|
|
254
|
+
[payload, signature] = cookie.split('--')
|
|
255
|
+
return {} unless payload and signature
|
|
256
|
+
return {} unless hmacVerify! payload, signature, secret
|
|
257
|
+
JSON.parse(decodeURIComponent(escape(atob(payload))))
|
|
258
|
+
else
|
|
259
|
+
JSON.parse(decodeURIComponent(escape(atob(cookie))))
|
|
260
|
+
catch
|
|
261
|
+
{}
|
|
273
262
|
|
|
274
263
|
export sessions = (opts = {}) ->
|
|
264
|
+
secret = opts.secret
|
|
275
265
|
cookieName = opts.name or 'session'
|
|
276
266
|
maxAge = opts.maxAge or 86400
|
|
277
267
|
secure = opts.secure or false
|
|
278
268
|
httpOnly = opts.httpOnly ? true
|
|
279
269
|
sameSite = opts.sameSite or 'Lax'
|
|
280
270
|
|
|
271
|
+
# Warn if no secret in production
|
|
272
|
+
if not secret and process.env.NODE_ENV is 'production'
|
|
273
|
+
console.warn 'WARNING: sessions() without secret is insecure. Set secret option for production.'
|
|
274
|
+
|
|
281
275
|
(c, next) ->
|
|
282
|
-
# Parse
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
276
|
+
# Parse cookie header
|
|
277
|
+
cookieHeader = c.req.header('Cookie') or ''
|
|
278
|
+
cookies = {}
|
|
279
|
+
for pair in cookieHeader.split(';')
|
|
280
|
+
[key, val] = pair.trim().split('=')
|
|
281
|
+
cookies[key] = decodeURIComponent(val) if key and val
|
|
282
|
+
|
|
283
|
+
# Decode existing session
|
|
284
|
+
raw = cookies[cookieName]
|
|
285
|
+
c.session = decodeSession raw, secret
|
|
288
286
|
|
|
289
287
|
# Track original for change detection
|
|
290
288
|
original = JSON.stringify(c.session)
|
|
@@ -294,8 +292,8 @@ export sessions = (opts = {}) ->
|
|
|
294
292
|
# Save session if changed
|
|
295
293
|
current = JSON.stringify(c.session)
|
|
296
294
|
if current isnt original and c._response?
|
|
297
|
-
encoded =
|
|
298
|
-
parts = ["#{cookieName}=#{encoded}"]
|
|
295
|
+
encoded = encodeSession c.session, secret
|
|
296
|
+
parts = ["#{cookieName}=#{encodeURIComponent(encoded)}"]
|
|
299
297
|
parts.push "Path=/"
|
|
300
298
|
parts.push "Max-Age=#{maxAge}"
|
|
301
299
|
parts.push "HttpOnly" if httpOnly
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rip-lang/api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Pure Rip API framework — elegant, fast, zero dependencies",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "api.rip",
|
|
@@ -32,9 +32,6 @@
|
|
|
32
32
|
},
|
|
33
33
|
"author": "Steve Shreeve <steve.shreeve@gmail.com>",
|
|
34
34
|
"license": "MIT",
|
|
35
|
-
"peerDependencies": {
|
|
36
|
-
"rip-lang": "^2.0.0"
|
|
37
|
-
},
|
|
38
35
|
"files": [
|
|
39
36
|
"api.rip",
|
|
40
37
|
"middleware.rip",
|