@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/middleware.rip
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# @rip-lang/server/middleware — Built-in Middleware
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# import { cors, logger, compress, sessions, htmlJson, serve } from '@rip-lang/server/middleware'
|
|
7
|
+
# import { session } from '@rip-lang/server'
|
|
8
|
+
#
|
|
9
|
+
# use logger()
|
|
10
|
+
# use cors origin: 'https://myapp.com'
|
|
11
|
+
# use compress()
|
|
12
|
+
# use sessions()
|
|
13
|
+
# use htmlJson # Pretty JSON for iOS mobile browsers
|
|
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
|
+
# preflight: boolean — handle OPTIONS before route matching (default: false)
|
|
34
|
+
#
|
|
35
|
+
|
|
36
|
+
import { get, enableCorsPreflight } from '@rip-lang/server'
|
|
37
|
+
import { fileURLToPath } from 'node:url'
|
|
38
|
+
import { dirname } from 'node:path'
|
|
39
|
+
import { existsSync } from 'node:fs'
|
|
40
|
+
|
|
41
|
+
export cors = (opts = {}) ->
|
|
42
|
+
origin = opts.origin or '*'
|
|
43
|
+
methods = opts.methods or 'GET,POST,PUT,PATCH,DELETE,OPTIONS'
|
|
44
|
+
headers = opts.headers or 'Content-Type,Authorization,X-Requested-With'
|
|
45
|
+
credentials = opts.credentials or false
|
|
46
|
+
maxAge = opts.maxAge or 86400
|
|
47
|
+
exposeHeaders = opts.exposeHeaders or ''
|
|
48
|
+
|
|
49
|
+
# Normalize arrays to comma-separated strings
|
|
50
|
+
methods = methods.join(',') if Array.isArray(methods)
|
|
51
|
+
headers = headers.join(',') if Array.isArray(headers)
|
|
52
|
+
exposeHeaders = exposeHeaders.join(',') if Array.isArray(exposeHeaders)
|
|
53
|
+
|
|
54
|
+
# Enable early OPTIONS handling if preflight option is set
|
|
55
|
+
enableCorsPreflight() if opts.preflight
|
|
56
|
+
|
|
57
|
+
(c, next) ->
|
|
58
|
+
requestOrigin = c.req.header('Origin')
|
|
59
|
+
|
|
60
|
+
# Determine allowed origin
|
|
61
|
+
allowedOrigin = if typeof origin is 'function'
|
|
62
|
+
if origin(requestOrigin) then requestOrigin else null
|
|
63
|
+
else if Array.isArray(origin)
|
|
64
|
+
if origin.includes(requestOrigin) then requestOrigin else null
|
|
65
|
+
else if origin is '*'
|
|
66
|
+
'*'
|
|
67
|
+
else
|
|
68
|
+
origin
|
|
69
|
+
|
|
70
|
+
return next!() unless allowedOrigin
|
|
71
|
+
|
|
72
|
+
# Set CORS headers
|
|
73
|
+
c.header 'Access-Control-Allow-Origin', allowedOrigin
|
|
74
|
+
c.header 'Access-Control-Allow-Methods', methods
|
|
75
|
+
c.header 'Access-Control-Allow-Headers', headers
|
|
76
|
+
|
|
77
|
+
if credentials
|
|
78
|
+
c.header 'Access-Control-Allow-Credentials', 'true'
|
|
79
|
+
|
|
80
|
+
if exposeHeaders
|
|
81
|
+
c.header 'Access-Control-Expose-Headers', exposeHeaders
|
|
82
|
+
|
|
83
|
+
# Handle preflight OPTIONS request
|
|
84
|
+
if c.req.method is 'OPTIONS'
|
|
85
|
+
c.header 'Access-Control-Max-Age', String(maxAge)
|
|
86
|
+
return c.body null, 204
|
|
87
|
+
|
|
88
|
+
await next()
|
|
89
|
+
|
|
90
|
+
# ==============================================================================
|
|
91
|
+
# logger — Request Logging
|
|
92
|
+
# ==============================================================================
|
|
93
|
+
#
|
|
94
|
+
# Options:
|
|
95
|
+
# format: 'tiny' | 'short' | 'dev' | 'full' | (info) -> string
|
|
96
|
+
# skip: (c) -> boolean
|
|
97
|
+
# stream: { write: (msg) -> } (default: console)
|
|
98
|
+
#
|
|
99
|
+
|
|
100
|
+
export logger = (opts = {}) ->
|
|
101
|
+
format = opts.format or 'dev'
|
|
102
|
+
skip = opts.skip or null
|
|
103
|
+
stream = opts.stream or { write: (msg) -> console.log msg.trim() }
|
|
104
|
+
|
|
105
|
+
formatters =
|
|
106
|
+
tiny: (info) -> "#{info.method} #{info.path} #{info.status} - #{info.ms}ms"
|
|
107
|
+
short: (info) -> "#{info.method} #{info.path} #{info.status} #{info.size} - #{info.ms}ms"
|
|
108
|
+
dev: (info) ->
|
|
109
|
+
color = if info.status >= 500 then '\x1b[31m' # red
|
|
110
|
+
else if info.status >= 400 then '\x1b[33m' # yellow
|
|
111
|
+
else if info.status >= 300 then '\x1b[36m' # cyan
|
|
112
|
+
else '\x1b[32m' # green
|
|
113
|
+
reset = '\x1b[0m'
|
|
114
|
+
"#{info.method} #{info.path} #{color}#{info.status}#{reset} - #{info.ms}ms"
|
|
115
|
+
full: (info) -> "[#{info.time}] #{info.method} #{info.path} #{info.status} #{info.size} - #{info.ms}ms"
|
|
116
|
+
|
|
117
|
+
(c, next) ->
|
|
118
|
+
return next!() if skip?(c)
|
|
119
|
+
|
|
120
|
+
start = Date.now()
|
|
121
|
+
await next()
|
|
122
|
+
ms = Date.now() - start
|
|
123
|
+
|
|
124
|
+
info =
|
|
125
|
+
method: c.req.method
|
|
126
|
+
path: c.req.path
|
|
127
|
+
status: c._response?.status or 200
|
|
128
|
+
ms: ms
|
|
129
|
+
size: c._response?.headers?.get('Content-Length') or '-'
|
|
130
|
+
time: new Date().toISOString()
|
|
131
|
+
|
|
132
|
+
msg = if typeof format is 'function' then format(info) else formatters[format]?(info) or formatters.dev(info)
|
|
133
|
+
stream.write "#{msg}\n"
|
|
134
|
+
|
|
135
|
+
# ==============================================================================
|
|
136
|
+
# compress — Response Compression (gzip, deflate)
|
|
137
|
+
# ==============================================================================
|
|
138
|
+
#
|
|
139
|
+
# Options:
|
|
140
|
+
# threshold: number (min bytes to compress, default 1024)
|
|
141
|
+
# encodings: string[] (default ['gzip', 'deflate'])
|
|
142
|
+
#
|
|
143
|
+
# Note: Requires Bun's built-in compression support
|
|
144
|
+
#
|
|
145
|
+
|
|
146
|
+
export compress = (opts = {}) ->
|
|
147
|
+
threshold = opts.threshold or 1024
|
|
148
|
+
encodings = opts.encodings or ['gzip', 'deflate']
|
|
149
|
+
|
|
150
|
+
(c, next) ->
|
|
151
|
+
await next()
|
|
152
|
+
|
|
153
|
+
# Skip if no response body
|
|
154
|
+
response = c._response
|
|
155
|
+
return unless response?
|
|
156
|
+
|
|
157
|
+
# Check Accept-Encoding header
|
|
158
|
+
acceptEncoding = c.req.header('Accept-Encoding') or ''
|
|
159
|
+
|
|
160
|
+
# Find supported encoding
|
|
161
|
+
encoding = null
|
|
162
|
+
for enc in encodings
|
|
163
|
+
if acceptEncoding.includes(enc)
|
|
164
|
+
encoding = enc
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
return unless encoding
|
|
168
|
+
|
|
169
|
+
# Get body and check threshold
|
|
170
|
+
try
|
|
171
|
+
body = response.body
|
|
172
|
+
return unless body
|
|
173
|
+
|
|
174
|
+
# Clone to read the body
|
|
175
|
+
clone = response.clone()
|
|
176
|
+
text = clone.text!
|
|
177
|
+
return if text.length < threshold
|
|
178
|
+
|
|
179
|
+
# Compress using Bun's built-in CompressionStream
|
|
180
|
+
compressed = if encoding is 'gzip'
|
|
181
|
+
new Response(text).body.pipeThrough(new CompressionStream('gzip'))
|
|
182
|
+
else if encoding is 'deflate'
|
|
183
|
+
new Response(text).body.pipeThrough(new CompressionStream('deflate'))
|
|
184
|
+
else
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
# Create new response with compressed body
|
|
188
|
+
headers = new Headers(response.headers)
|
|
189
|
+
headers.set 'Content-Encoding', encoding
|
|
190
|
+
headers.delete 'Content-Length' # Length changed
|
|
191
|
+
|
|
192
|
+
c._response = new Response compressed,
|
|
193
|
+
status: response.status
|
|
194
|
+
headers: headers
|
|
195
|
+
catch
|
|
196
|
+
# If compression fails, keep original response
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
# ==============================================================================
|
|
200
|
+
# sessions — Session Management
|
|
201
|
+
# ==============================================================================
|
|
202
|
+
#
|
|
203
|
+
# Options:
|
|
204
|
+
# secret: string (REQUIRED for production — signs cookie to prevent tampering)
|
|
205
|
+
# name: string (cookie name, default 'session')
|
|
206
|
+
# maxAge: number (seconds, default 86400 = 24 hours)
|
|
207
|
+
# secure: boolean (HTTPS only)
|
|
208
|
+
# httpOnly: boolean (default true)
|
|
209
|
+
# sameSite: 'Strict' | 'Lax' | 'None' (default 'Lax')
|
|
210
|
+
#
|
|
211
|
+
# Usage:
|
|
212
|
+
# import { session } from '@rip-lang/server'
|
|
213
|
+
# import { sessions } from '@rip-lang/server/middleware'
|
|
214
|
+
#
|
|
215
|
+
# use sessions secret: process.env.SESSION_SECRET
|
|
216
|
+
#
|
|
217
|
+
# before ->
|
|
218
|
+
# session.userId = 123
|
|
219
|
+
#
|
|
220
|
+
# get '/profile', ->
|
|
221
|
+
# { userId: session.userId }
|
|
222
|
+
#
|
|
223
|
+
# Security:
|
|
224
|
+
# Without `secret`, sessions use plain base64 (INSECURE — dev only).
|
|
225
|
+
# With `secret`, sessions are HMAC-SHA256 signed (tamper-proof).
|
|
226
|
+
#
|
|
227
|
+
# Note: Self-contained — parses cookies directly from request headers.
|
|
228
|
+
#
|
|
229
|
+
|
|
230
|
+
# HMAC-SHA256 signing helpers
|
|
231
|
+
hmacSign = (data, secret) ->
|
|
232
|
+
encoder = new TextEncoder()
|
|
233
|
+
key = crypto.subtle.importKey! 'raw', encoder.encode(secret),
|
|
234
|
+
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
|
|
235
|
+
signature = crypto.subtle.sign! 'HMAC', key, encoder.encode(data)
|
|
236
|
+
Array.from(new Uint8Array(signature)).map((b) -> b.toString(16).padStart(2, '0')).join('')
|
|
237
|
+
|
|
238
|
+
hmacVerify = (data, signature, secret) ->
|
|
239
|
+
expected = hmacSign! data, secret
|
|
240
|
+
# Constant-time comparison to prevent timing attacks
|
|
241
|
+
return false if expected.length isnt signature.length
|
|
242
|
+
result = 0
|
|
243
|
+
for i in [0...expected.length]
|
|
244
|
+
result |= expected.charCodeAt(i) ^ signature.charCodeAt(i)
|
|
245
|
+
result is 0
|
|
246
|
+
|
|
247
|
+
encodeSession = (data, secret) ->
|
|
248
|
+
json = JSON.stringify(data)
|
|
249
|
+
payload = btoa(unescape(encodeURIComponent(json)))
|
|
250
|
+
return payload unless secret
|
|
251
|
+
signature = hmacSign! payload, secret
|
|
252
|
+
"#{payload}--#{signature}"
|
|
253
|
+
|
|
254
|
+
decodeSession = (cookie, secret) ->
|
|
255
|
+
return {} unless cookie
|
|
256
|
+
try
|
|
257
|
+
if secret
|
|
258
|
+
[payload, signature] = cookie.split('--')
|
|
259
|
+
return {} unless payload and signature
|
|
260
|
+
return {} unless hmacVerify! payload, signature, secret
|
|
261
|
+
JSON.parse(decodeURIComponent(escape(atob(payload))))
|
|
262
|
+
else
|
|
263
|
+
JSON.parse(decodeURIComponent(escape(atob(cookie))))
|
|
264
|
+
catch
|
|
265
|
+
{}
|
|
266
|
+
|
|
267
|
+
export sessions = (opts = {}) ->
|
|
268
|
+
secret = opts.secret
|
|
269
|
+
cookieName = opts.name or 'session'
|
|
270
|
+
maxAge = opts.maxAge or 86400
|
|
271
|
+
secure = opts.secure or false
|
|
272
|
+
httpOnly = opts.httpOnly ?? true
|
|
273
|
+
sameSite = opts.sameSite or 'Lax'
|
|
274
|
+
|
|
275
|
+
# Warn if no secret in production
|
|
276
|
+
if not secret and process.env.NODE_ENV is 'production'
|
|
277
|
+
console.warn 'WARNING: sessions() without secret is insecure. Set secret option for production.'
|
|
278
|
+
|
|
279
|
+
(c, next) ->
|
|
280
|
+
# Parse cookie header
|
|
281
|
+
cookieHeader = c.req.header('Cookie') or ''
|
|
282
|
+
cookies = {}
|
|
283
|
+
for pair in cookieHeader.split(';')
|
|
284
|
+
[key, val] = pair.trim().split('=')
|
|
285
|
+
cookies[key] = decodeURIComponent(val) if key and val
|
|
286
|
+
|
|
287
|
+
# Decode existing session
|
|
288
|
+
raw = cookies[cookieName]
|
|
289
|
+
c.session = decodeSession! raw, secret
|
|
290
|
+
|
|
291
|
+
# Track original for change detection
|
|
292
|
+
original = JSON.stringify(c.session)
|
|
293
|
+
|
|
294
|
+
await next()
|
|
295
|
+
|
|
296
|
+
# Save session if changed
|
|
297
|
+
current = JSON.stringify(c.session)
|
|
298
|
+
if current isnt original and c._response?
|
|
299
|
+
encoded = encodeSession! c.session, secret
|
|
300
|
+
parts = ["#{cookieName}=#{encodeURIComponent(encoded)}"]
|
|
301
|
+
parts.push "Path=/"
|
|
302
|
+
parts.push "Max-Age=#{maxAge}"
|
|
303
|
+
parts.push "HttpOnly" if httpOnly
|
|
304
|
+
parts.push "Secure" if secure
|
|
305
|
+
parts.push "SameSite=#{sameSite}"
|
|
306
|
+
cookie = parts.join('; ')
|
|
307
|
+
|
|
308
|
+
# Clone response with new header
|
|
309
|
+
headers = new Headers(c._response.headers)
|
|
310
|
+
headers.append 'Set-Cookie', cookie
|
|
311
|
+
c._response = new Response c._response.body,
|
|
312
|
+
status: c._response.status
|
|
313
|
+
headers: headers
|
|
314
|
+
|
|
315
|
+
# ==============================================================================
|
|
316
|
+
# secureHeaders — Security Headers
|
|
317
|
+
# ==============================================================================
|
|
318
|
+
#
|
|
319
|
+
# Sets common security headers:
|
|
320
|
+
# X-Content-Type-Options: nosniff
|
|
321
|
+
# X-Frame-Options: DENY
|
|
322
|
+
# X-XSS-Protection: 1; mode=block
|
|
323
|
+
# Referrer-Policy: strict-origin-when-cross-origin
|
|
324
|
+
# Content-Security-Policy (optional)
|
|
325
|
+
#
|
|
326
|
+
|
|
327
|
+
export secureHeaders = (opts = {}) ->
|
|
328
|
+
(c, next) ->
|
|
329
|
+
c.header 'X-Content-Type-Options', 'nosniff'
|
|
330
|
+
c.header 'X-Frame-Options', opts.frameOptions or 'DENY'
|
|
331
|
+
c.header 'X-XSS-Protection', '1; mode=block'
|
|
332
|
+
c.header 'Referrer-Policy', opts.referrerPolicy or 'strict-origin-when-cross-origin'
|
|
333
|
+
|
|
334
|
+
if opts.contentSecurityPolicy
|
|
335
|
+
c.header 'Content-Security-Policy', opts.contentSecurityPolicy
|
|
336
|
+
|
|
337
|
+
if opts.hsts
|
|
338
|
+
maxAge = opts.hstsMaxAge or 31536000
|
|
339
|
+
c.header 'Strict-Transport-Security', "max-age=#{maxAge}; includeSubDomains"
|
|
340
|
+
|
|
341
|
+
await next()
|
|
342
|
+
|
|
343
|
+
# ==============================================================================
|
|
344
|
+
# timeout — Request Timeout
|
|
345
|
+
# ==============================================================================
|
|
346
|
+
#
|
|
347
|
+
# Options:
|
|
348
|
+
# ms: number (timeout in milliseconds, default 30000)
|
|
349
|
+
# message: string (error message)
|
|
350
|
+
# status: number (status code, default 408)
|
|
351
|
+
#
|
|
352
|
+
|
|
353
|
+
export timeout = (opts = {}) ->
|
|
354
|
+
ms = opts.ms or 30000
|
|
355
|
+
message = opts.message or 'Request Timeout'
|
|
356
|
+
status = opts.status or 408
|
|
357
|
+
|
|
358
|
+
(c, next) ->
|
|
359
|
+
timer = null
|
|
360
|
+
timedOut = false
|
|
361
|
+
|
|
362
|
+
timeoutPromise = new Promise (_, reject) ->
|
|
363
|
+
timer = setTimeout ->
|
|
364
|
+
timedOut = true
|
|
365
|
+
reject new Error message
|
|
366
|
+
, ms
|
|
367
|
+
|
|
368
|
+
try
|
|
369
|
+
await Promise.race [next(), timeoutPromise]
|
|
370
|
+
catch err
|
|
371
|
+
if timedOut
|
|
372
|
+
return c.json { error: message }, status
|
|
373
|
+
throw err
|
|
374
|
+
finally
|
|
375
|
+
clearTimeout timer if timer
|
|
376
|
+
|
|
377
|
+
# ==============================================================================
|
|
378
|
+
# bodyLimit — Request Body Size Limit
|
|
379
|
+
# ==============================================================================
|
|
380
|
+
#
|
|
381
|
+
# Options:
|
|
382
|
+
# maxSize: number (bytes, default 1MB)
|
|
383
|
+
# message: string (error message)
|
|
384
|
+
#
|
|
385
|
+
|
|
386
|
+
export bodyLimit = (opts = {}) ->
|
|
387
|
+
maxSize = opts.maxSize or 1024 * 1024 # 1MB default
|
|
388
|
+
message = opts.message or 'Request body too large'
|
|
389
|
+
|
|
390
|
+
(c, next) ->
|
|
391
|
+
contentLength = parseInt(c.req.header('Content-Length') or '0')
|
|
392
|
+
|
|
393
|
+
if contentLength > maxSize
|
|
394
|
+
return c.json { error: message }, 413
|
|
395
|
+
|
|
396
|
+
await next()
|
|
397
|
+
|
|
398
|
+
# ==============================================================================
|
|
399
|
+
# htmlJson — Pretty JSON for iOS Mobile Browsers
|
|
400
|
+
# ==============================================================================
|
|
401
|
+
#
|
|
402
|
+
# Renders JSON as syntax-highlighted HTML for iOS mobile browsers.
|
|
403
|
+
# Solves the iOS Safari/Chrome "download" footer issue when viewing JSON.
|
|
404
|
+
#
|
|
405
|
+
# Usage:
|
|
406
|
+
# use htmlJson
|
|
407
|
+
#
|
|
408
|
+
# Only activates when:
|
|
409
|
+
# 1. Request is from iOS (iPhone/iPad)
|
|
410
|
+
# 2. Browser is directly viewing (Accept: text/html, not API call)
|
|
411
|
+
#
|
|
412
|
+
|
|
413
|
+
export htmlJson = (c, next) ->
|
|
414
|
+
originalJson = c.json.bind(c)
|
|
415
|
+
|
|
416
|
+
c.json = (data, status = 200, headers = {}) ->
|
|
417
|
+
if _shouldRenderHtmlJson(c.req.raw)
|
|
418
|
+
return _renderHtmlJson(data, status)
|
|
419
|
+
originalJson(data, status, headers)
|
|
420
|
+
|
|
421
|
+
next!
|
|
422
|
+
|
|
423
|
+
_shouldRenderHtmlJson = (req) ->
|
|
424
|
+
ua = req.headers.get('user-agent') or ''
|
|
425
|
+
accept = req.headers.get('accept') or ''
|
|
426
|
+
dest = req.headers.get('sec-fetch-dest') or ''
|
|
427
|
+
|
|
428
|
+
isIOS = /iPhone|iPad|iPod/i.test(ua)
|
|
429
|
+
acceptsHtml = accept.includes('text/html')
|
|
430
|
+
isNavigation = dest is 'document' or (acceptsHtml and not accept.startsWith('application/json'))
|
|
431
|
+
|
|
432
|
+
isIOS and isNavigation
|
|
433
|
+
|
|
434
|
+
_renderHtmlJson = (data, status) ->
|
|
435
|
+
json = JSON.stringify(data, null, 2)
|
|
436
|
+
|
|
437
|
+
# HTML escape then syntax highlight
|
|
438
|
+
escaped = json
|
|
439
|
+
.replace(/&/g, '&')
|
|
440
|
+
.replace(/</g, '<')
|
|
441
|
+
.replace(/>/g, '>')
|
|
442
|
+
|
|
443
|
+
highlighted = escaped
|
|
444
|
+
.replace(/"([^"]+)":/g, '<span class="k">"$1"</span>:')
|
|
445
|
+
.replace(/: "([^"]*)"/g, ': <span class="s">"$1"</span>')
|
|
446
|
+
.replace(/: (-?\d+\.?\d*)/g, ': <span class="n">$1</span>')
|
|
447
|
+
.replace(/: (true|false)/g, ': <span class="b">$1</span>')
|
|
448
|
+
.replace(/: (null)/g, ': <span class="b">$1</span>')
|
|
449
|
+
|
|
450
|
+
html = """<!DOCTYPE html>
|
|
451
|
+
<html><head>
|
|
452
|
+
<meta charset="utf-8">
|
|
453
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
454
|
+
<title>JSON</title>
|
|
455
|
+
<style>
|
|
456
|
+
*{box-sizing:border-box}
|
|
457
|
+
body{font:14px/1.5 ui-monospace,monospace;background:#1a1a2e;color:#e0e0e0;margin:0;padding:16px}
|
|
458
|
+
pre{margin:0;white-space:pre-wrap;word-break:break-word}
|
|
459
|
+
.k{color:#82aaff}.s{color:#c3e88d}.n{color:#f78c6c}.b{color:#89ddff}
|
|
460
|
+
</style>
|
|
461
|
+
</head><body><pre>#{highlighted}</pre></body></html>"""
|
|
462
|
+
|
|
463
|
+
new Response html, {
|
|
464
|
+
status: status
|
|
465
|
+
headers: new Headers({ 'content-type': 'text/html; charset=utf-8' })
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
# ==============================================================================
|
|
469
|
+
# serve — Serve Rip Applications
|
|
470
|
+
# ==============================================================================
|
|
471
|
+
#
|
|
472
|
+
# Serves the Rip browser bundle, app component files, and the component bundle.
|
|
473
|
+
# Hot-reload SSE is handled by rip-server; this middleware registers watch dirs.
|
|
474
|
+
#
|
|
475
|
+
# Options:
|
|
476
|
+
# dir: string — app directory on disk (default: '.')
|
|
477
|
+
# routes: string — page components directory, relative to dir (default: 'routes')
|
|
478
|
+
# components: string[] — shared component directories, relative to dir (default: ['components'])
|
|
479
|
+
# app: string — URL mount point (default: '')
|
|
480
|
+
# title: string — document title
|
|
481
|
+
# state: object — initial app state passed via bundle
|
|
482
|
+
# watch: boolean — enable hot-reload (registers watch dirs with rip-server)
|
|
483
|
+
#
|
|
484
|
+
|
|
485
|
+
export serve = (opts = {}) ->
|
|
486
|
+
prefix = opts.app or ''
|
|
487
|
+
appDir = opts.dir or '.'
|
|
488
|
+
routesDir = "#{appDir}/#{opts.routes or 'routes'}"
|
|
489
|
+
componentDirs = (opts.components or ['components']).map (d) -> "#{appDir}/#{d}"
|
|
490
|
+
enableWatch = opts.watch ? opts.watch : process.env.SOCKET_PREFIX?
|
|
491
|
+
appState = opts.state or null
|
|
492
|
+
appTitle = opts.title or null
|
|
493
|
+
|
|
494
|
+
# Resolve rip.min.js — walk up from cwd to find it (dev), then module resolution (prod)
|
|
495
|
+
bundlePath = do ->
|
|
496
|
+
dir = process.cwd()
|
|
497
|
+
while dir isnt dirname(dir)
|
|
498
|
+
candidate = "#{dir}/docs/dist/rip.min.js"
|
|
499
|
+
return candidate if existsSync(candidate)
|
|
500
|
+
dir = dirname(dir)
|
|
501
|
+
ripDir = dirname(dirname(fileURLToPath(import.meta.resolve('rip-lang'))))
|
|
502
|
+
"#{ripDir}/docs/dist/rip.min.js"
|
|
503
|
+
bundlePathBr = "#{bundlePath}.br"
|
|
504
|
+
hasBrotli = existsSync(bundlePathBr)
|
|
505
|
+
|
|
506
|
+
# Route: /rip/rip.min.js — serve the bundle, prefer pre-compressed Brotli
|
|
507
|
+
unless serve._registered
|
|
508
|
+
baseFile = Bun.file(bundlePath)
|
|
509
|
+
baseEtag = "W/\"#{baseFile.lastModified}-#{baseFile.size}\""
|
|
510
|
+
get "/rip/rip.min.js", (c) ->
|
|
511
|
+
if c.req.header('if-none-match') is baseEtag
|
|
512
|
+
return new Response(null, { status: 304, headers: { 'ETag': baseEtag, 'Cache-Control': 'no-cache' } })
|
|
513
|
+
useBr = hasBrotli and (c.req.header('Accept-Encoding') or '').includes('br')
|
|
514
|
+
headers = { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache', 'ETag': baseEtag }
|
|
515
|
+
headers['Content-Encoding'] = 'br' if useBr
|
|
516
|
+
new Response (if useBr then Bun.file(bundlePathBr) else Bun.file(bundlePath)), { headers }
|
|
517
|
+
serve._registered = true
|
|
518
|
+
|
|
519
|
+
# Route: {prefix}/components/* — individual .rip component files
|
|
520
|
+
get "#{prefix}/components/*", (c) ->
|
|
521
|
+
name = c.req.path.slice("#{prefix}/components/".length)
|
|
522
|
+
c.send "#{routesDir}/#{name}", 'text/plain; charset=UTF-8'
|
|
523
|
+
|
|
524
|
+
# Route: {prefix}/bundle — all components + app data as JSON
|
|
525
|
+
get "#{prefix}/bundle", (c) ->
|
|
526
|
+
glob = new Bun.Glob("**/*.rip")
|
|
527
|
+
components = {}
|
|
528
|
+
paths = Array.from(glob.scanSync(routesDir)).sort()
|
|
529
|
+
for path in paths
|
|
530
|
+
components["components/#{path}"] = Bun.file("#{routesDir}/#{path}").text!
|
|
531
|
+
|
|
532
|
+
for dir in componentDirs
|
|
533
|
+
incPaths = Array.from(glob.scanSync(dir)).sort()
|
|
534
|
+
for path in incPaths
|
|
535
|
+
key = "components/_lib/#{path}"
|
|
536
|
+
components[key] = Bun.file("#{dir}/#{path}").text! unless components[key]
|
|
537
|
+
|
|
538
|
+
data = {}
|
|
539
|
+
data.title = appTitle if appTitle
|
|
540
|
+
data.watch = enableWatch
|
|
541
|
+
if appState
|
|
542
|
+
data[k] = v for k, v of appState
|
|
543
|
+
|
|
544
|
+
json = JSON.stringify({ components, data })
|
|
545
|
+
etag = "W/\"#{Bun.hash(json).toString(36)}\""
|
|
546
|
+
if c.req.header('if-none-match') is etag
|
|
547
|
+
return new Response(null, { status: 304, headers: { 'ETag': etag } })
|
|
548
|
+
new Response json, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', 'ETag': etag }
|
|
549
|
+
|
|
550
|
+
# Register watch directories with rip-server via control socket
|
|
551
|
+
if enableWatch and process.env.SOCKET_PREFIX
|
|
552
|
+
ctl = "/tmp/#{process.env.SOCKET_PREFIX}.ctl.sock"
|
|
553
|
+
dirs = [routesDir, ...componentDirs, "#{appDir}/css"].filter existsSync
|
|
554
|
+
body = JSON.stringify({ op: 'watch', prefix, dirs })
|
|
555
|
+
fetch('http://localhost/watch', { method: 'POST', body, headers: { 'content-type': 'application/json' }, unix: ctl }).catch (e) ->
|
|
556
|
+
console.warn "[Rip] Watch registration failed: #{e.message}"
|
|
557
|
+
|
|
558
|
+
(c, next) -> next!()
|
package/package.json
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rip-lang/server",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Pure Rip
|
|
3
|
+
"version": "1.3.1",
|
|
4
|
+
"description": "Pure Rip web framework and application server",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "
|
|
6
|
+
"main": "api.rip",
|
|
7
7
|
"exports": {
|
|
8
|
-
".": "./
|
|
8
|
+
".": "./api.rip",
|
|
9
|
+
"./middleware": "./middleware.rip",
|
|
10
|
+
"./server": "./server.rip"
|
|
9
11
|
},
|
|
10
12
|
"bin": {
|
|
11
13
|
"rip-server": "./bin/rip-server"
|
|
12
14
|
},
|
|
13
15
|
"scripts": {
|
|
14
16
|
"build": "rip -c server.rip",
|
|
15
|
-
"test": "
|
|
17
|
+
"test": "rip tests/read.test.rip"
|
|
16
18
|
},
|
|
17
19
|
"keywords": [
|
|
18
20
|
"server",
|
|
21
|
+
"api",
|
|
22
|
+
"web-framework",
|
|
23
|
+
"routing",
|
|
24
|
+
"middleware",
|
|
25
|
+
"validation",
|
|
19
26
|
"app-server",
|
|
20
27
|
"hot-reload",
|
|
21
28
|
"multi-worker",
|
|
@@ -38,13 +45,15 @@
|
|
|
38
45
|
"author": "Steve Shreeve <steve.shreeve@gmail.com>",
|
|
39
46
|
"license": "MIT",
|
|
40
47
|
"dependencies": {
|
|
41
|
-
"rip-lang": ">=3.13.
|
|
42
|
-
"@rip-lang/api": "^1.2.7"
|
|
48
|
+
"rip-lang": ">=3.13.14"
|
|
43
49
|
},
|
|
44
50
|
"files": [
|
|
45
|
-
"
|
|
51
|
+
"api.rip",
|
|
52
|
+
"middleware.rip",
|
|
46
53
|
"server.rip",
|
|
47
54
|
"server.html",
|
|
55
|
+
"bin/",
|
|
56
|
+
"tests/",
|
|
48
57
|
"docs/",
|
|
49
58
|
"README.md"
|
|
50
59
|
]
|