@rip-lang/server 0.5.6 → 0.6.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 (3) hide show
  1. package/README.md +28 -30
  2. package/package.json +1 -1
  3. package/server.rip +212 -214
package/README.md CHANGED
@@ -6,9 +6,9 @@
6
6
 
7
7
  ## Overview
8
8
 
9
- `@rip-lang/server` is a production-grade application server written entirely in Rip. It provides multi-worker process management, hot module reloading, automatic HTTPS, and mDNS service discovery — all in a single ~1,100 line file.
9
+ `@rip-lang/server` is a production-grade application server written entirely in Rip. It provides multi-worker process management, hot reloading, automatic HTTPS, and mDNS service discovery — all in a single ~1,100 line file.
10
10
 
11
- - **`server.rip`** (~1,082 lines) — Complete server: CLI, workers, load balancing, TLS, mDNS
11
+ - **`server.rip`** (~1,100 lines) — Complete server: CLI, workers, load balancing, TLS, mDNS
12
12
 
13
13
  **Core Philosophy**: Application servers should be simple, fast, and reliable. No complex configuration files. No dependency on external process managers. Just run your app.
14
14
 
@@ -54,7 +54,7 @@ rip-server ./app.rip@myapp
54
54
  Create `app.rip`:
55
55
 
56
56
  ```coffee
57
- import { get, post, read, startHandler } from '@rip-lang/api'
57
+ import { get, read, start } from '@rip-lang/api'
58
58
 
59
59
  get '/', ->
60
60
  'Hello from Rip Server!'
@@ -66,8 +66,7 @@ get '/users/:id', ->
66
66
  id = read 'id', 'id!'
67
67
  { user: { id, name: "User #{id}" } }
68
68
 
69
- # Export the handler for rip-server
70
- export default startHandler()
69
+ start()
71
70
  ```
72
71
 
73
72
  Run it:
@@ -104,24 +103,28 @@ rip-server [flags] <app-path>[@alias1,alias2,...]
104
103
 
105
104
  | Flag | Description | Default |
106
105
  |------|-------------|---------|
106
+ | `-v`, `--version` | Show version and exit | — |
107
107
  | `http` | HTTP-only mode (no HTTPS) | HTTPS enabled |
108
108
  | `https` | HTTPS mode (explicit) | Auto |
109
109
  | `http:<port>` | Set HTTP port | 80 or 5700 |
110
110
  | `https:<port>` | Set HTTPS port | 443 or 5700 |
111
111
  | `w:<n>` | Worker count (`auto`, `half`, `2x`, `3x`, or number) | `half` of cores |
112
- | `r:<policy>` | Restart policy (e.g., `1000,3600s,10r`) | `10000,3600s,10r` |
112
+ | `r:<policy>` | Restart policy (e.g., `5000,3600s`) | `10000,3600s` |
113
113
  | `--cert=<path>` | TLS certificate path | Auto-generated |
114
114
  | `--key=<path>` | TLS private key path | Auto-generated |
115
115
  | `--auto-tls` | Use mkcert for TLS | Fallback to self-signed |
116
116
  | `--hsts` | Enable HSTS headers | Disabled |
117
117
  | `--no-redirect-http` | Don't redirect HTTP to HTTPS | Redirects enabled |
118
- | `--reload=<mode>` | Reload mode: `none`, `process`, `module` | `process` |
118
+ | `--static` | Disable hot reload (production) | Hot reload enabled |
119
119
  | `--json-logging` | Output JSON access logs | Human-readable |
120
120
  | `--no-access-log` | Disable access logging | Enabled |
121
121
 
122
122
  ### Subcommands
123
123
 
124
124
  ```bash
125
+ # Show version
126
+ rip-server --version
127
+
125
128
  # Stop running server
126
129
  rip-server stop
127
130
 
@@ -147,7 +150,7 @@ rip-server http:3000 app.rip
147
150
  # With mDNS aliases (accessible as myapp.local and api.local)
148
151
  rip-server app.rip@myapp,api
149
152
 
150
- # Restart after 5000 requests or 1 hour
153
+ # Restart workers after 5000 requests or 1 hour
151
154
  rip-server r:5000,3600s app.rip
152
155
  ```
153
156
 
@@ -193,18 +196,14 @@ When `RIP_WORKER_MODE=1` is set, the same `server.rip` file runs as a worker ins
193
196
 
194
197
  ### Hot Reloading
195
198
 
196
- Two modes available:
197
-
198
- - **`process` mode** (default): File changes trigger rolling restart of all workers
199
- - **`module` mode**: Each worker reloads the app module on file change (faster, but uses more memory)
199
+ By default, file changes trigger a rolling restart of all workers. Use `--static` in production to disable.
200
200
 
201
201
  ### Worker Lifecycle
202
202
 
203
- Workers are recycled based on configurable limits:
203
+ Workers are automatically recycled to prevent memory leaks and ensure reliability:
204
204
 
205
- - **maxRequests**: Restart after N requests (default: 10,000)
206
- - **maxSeconds**: Restart after N seconds (default: 3,600)
207
- - **maxReloads**: Maximum hot reloads before restart (default: 10)
205
+ - **maxRequests**: Restart worker after N requests (default: 10,000)
206
+ - **maxSeconds**: Restart worker after N seconds (default: 3,600)
208
207
 
209
208
  ## Built-in Endpoints
210
209
 
@@ -248,18 +247,20 @@ Requires `dns-sd` (available on macOS by default).
248
247
 
249
248
  ## App Requirements
250
249
 
251
- Your app must export a fetch handler. Two patterns are supported:
250
+ Your app must provide a fetch handler. Three patterns are supported:
252
251
 
253
- ### Pattern 1: Export `startHandler()` (Recommended)
252
+ ### Pattern 1: Use `@rip-lang/api` with `start()` (Recommended)
254
253
 
255
254
  ```coffee
256
- import { get, startHandler } from '@rip-lang/api'
255
+ import { get, start } from '@rip-lang/api'
257
256
 
258
257
  get '/', -> 'Hello!'
259
258
 
260
- export default startHandler()
259
+ start()
261
260
  ```
262
261
 
262
+ The `start()` function automatically detects when running under `rip-server` and registers the handler.
263
+
263
264
  ### Pattern 2: Export fetch function directly
264
265
 
265
266
  ```coffee
@@ -282,10 +283,9 @@ export default
282
283
  | `RIP_DEBUG` | Enable debug logging |
283
284
  | `RIP_MAX_REQUESTS` | Default max requests per worker |
284
285
  | `RIP_MAX_SECONDS` | Default max seconds per worker |
285
- | `RIP_MAX_RELOADS` | Default max reloads per worker |
286
286
  | `RIP_MAX_QUEUE` | Maximum request queue size |
287
287
  | `RIP_QUEUE_TIMEOUT_MS` | Queue timeout in milliseconds |
288
- | `RIP_RELOAD` | Reload mode override |
288
+ | `RIP_STATIC` | Set to `1` to disable hot reload |
289
289
 
290
290
  ## Dashboard
291
291
 
@@ -304,7 +304,7 @@ The server includes a built-in dashboard accessible at `http://rip.local/` (when
304
304
  | Feature | rip-server | PM2 | Nginx |
305
305
  |---------|------------|-----|-------|
306
306
  | Pure Rip | ✅ | ❌ | ❌ |
307
- | Single File | ✅ (~1,076 lines) | ❌ | ❌ |
307
+ | Single File | ✅ (~1,100 lines) | ❌ | ❌ |
308
308
  | Hot Reload | ✅ | ✅ | ❌ |
309
309
  | Multi-Worker | ✅ | ✅ | ✅ |
310
310
  | Auto HTTPS | ✅ | ❌ | ❌ |
@@ -312,18 +312,16 @@ The server includes a built-in dashboard accessible at `http://rip.local/` (when
312
312
  | Zero Config | ✅ | ❌ | ❌ |
313
313
  | Built-in LB | ✅ | ❌ | ✅ |
314
314
 
315
- ## TODO
315
+ ## Roadmap
316
316
 
317
317
  > *Planned improvements for future releases:*
318
318
 
319
- - [ ] "Try it Now" section with clone-and-run example
320
- - [ ] Dashboard screenshots and feature description
321
- - [ ] Performance benchmarks (req/s, latency)
322
- - [ ] Scaling guidelines
323
- - [ ] More troubleshooting scenarios
319
+ - [ ] Request ID tracing for debugging
320
+ - [ ] Metrics endpoint (Prometheus format)
321
+ - [ ] Multi-file hot reload (watch entire directory)
324
322
  - [ ] Static file serving
325
323
  - [ ] Rate limiting
326
- - [ ] Graceful shutdown improvements
324
+ - [ ] Performance benchmarks
327
325
 
328
326
  ## License
329
327
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rip-lang/server",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
4
4
  "description": "Pure Rip application server — multi-worker, hot reload, HTTPS, mDNS",
5
5
  "type": "module",
6
6
  "main": "server.rip",
package/server.rip CHANGED
@@ -12,14 +12,22 @@
12
12
  # bun server.rip list # List registered hosts
13
13
  # ==============================================================================
14
14
 
15
- import { existsSync, statSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs'
15
+ import { existsSync, statSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'
16
16
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
17
- import { homedir, cpus } from 'node:os'
17
+ import { homedir, cpus, networkInterfaces } from 'node:os'
18
18
  import { X509Certificate } from 'node:crypto'
19
19
 
20
20
  # Match capture holder for Rip's =~
21
21
  _ = null
22
22
 
23
+ # ==============================================================================
24
+ # Constants
25
+ # ==============================================================================
26
+
27
+ MAX_BACKOFF_MS = 30000 # Max delay between worker restart attempts
28
+ MAX_RESTART_COUNT = 10 # Max consecutive worker crashes before giving up
29
+ SHUTDOWN_TIMEOUT_MS = 30000 # Max time to wait for in-flight requests on shutdown
30
+
23
31
  # ==============================================================================
24
32
  # Utilities
25
33
  # ==============================================================================
@@ -28,6 +36,7 @@ nowMs = -> Date.now()
28
36
 
29
37
  getWorkerSocketPath = (prefix, id) -> "/tmp/#{prefix}.#{id}.sock"
30
38
  getControlSocketPath = (prefix) -> "/tmp/#{prefix}.ctl.sock"
39
+ getPidFilePath = (prefix) -> "/tmp/#{prefix}.pid"
31
40
 
32
41
  coerceInt = (value, fallback) ->
33
42
  return fallback unless value? and value isnt ''
@@ -129,24 +138,20 @@ parseWorkersToken = (token, fallback) ->
129
138
  n = parseInt(token)
130
139
  if Number.isFinite(n) and n > 0 then n else fallback
131
140
 
132
- parseRestartPolicy = (token, defReqs, defSecs, defReloads) ->
133
- return { maxRequests: defReqs, maxSeconds: defSecs, maxReloads: defReloads } unless token
141
+ parseRestartPolicy = (token, defReqs, defSecs) ->
142
+ return { maxRequests: defReqs, maxSeconds: defSecs } unless token
134
143
  maxRequests = defReqs
135
144
  maxSeconds = defSecs
136
- maxReloads = defReloads
137
145
 
138
146
  for part in token.split(',').map((s) -> s.trim()).filter(Boolean)
139
147
  if part.endsWith('s')
140
148
  secs = parseInt(part.slice(0, -1))
141
149
  maxSeconds = secs if Number.isFinite(secs) and secs >= 0
142
- else if part.endsWith('r')
143
- rls = parseInt(part.slice(0, -1))
144
- maxReloads = rls if Number.isFinite(rls) and rls >= 0
145
150
  else
146
151
  n = parseInt(part)
147
152
  maxRequests = n if Number.isFinite(n) and n > 0
148
153
 
149
- { maxRequests, maxSeconds, maxReloads }
154
+ { maxRequests, maxSeconds }
150
155
 
151
156
  resolveAppEntry = (appPathInput) ->
152
157
  abs = if isAbsolute(appPathInput) then appPathInput else resolve(process.cwd(), appPathInput)
@@ -253,12 +258,10 @@ parseFlags = (argv) ->
253
258
  policy = parseRestartPolicy(
254
259
  getKV('r:'),
255
260
  coerceInt(process.env.RIP_MAX_REQUESTS, 10000),
256
- coerceInt(process.env.RIP_MAX_SECONDS, 3600),
257
- coerceInt(process.env.RIP_MAX_RELOADS, 10)
261
+ coerceInt(process.env.RIP_MAX_SECONDS, 3600)
258
262
  )
259
263
 
260
- reloadFlag = getKV('--reload=') or process.env.RIP_RELOAD
261
- reload = if reloadFlag in ['none', 'process', 'module'] then reloadFlag else 'process'
264
+ reload = not (has('--static') or process.env.RIP_STATIC is '1')
262
265
 
263
266
  httpsPort = do ->
264
267
  kv = getKV('--https-port=')
@@ -274,7 +277,6 @@ parseFlags = (argv) ->
274
277
  workers
275
278
  maxRequestsPerWorker: policy.maxRequests
276
279
  maxSecondsPerWorker: policy.maxSeconds
277
- maxReloadsPerWorker: policy.maxReloads
278
280
  httpPort
279
281
  httpsPort
280
282
  certPath: getKV('--cert=')
@@ -284,7 +286,7 @@ parseFlags = (argv) ->
284
286
  redirectHttp: not has('--no-redirect-http')
285
287
  reload
286
288
  socketPrefix
287
- maxQueue: coerceInt(getKV('--max-queue='), coerceInt(process.env.RIP_MAX_QUEUE, 8192))
289
+ maxQueue: coerceInt(getKV('--max-queue='), coerceInt(process.env.RIP_MAX_QUEUE, 512))
288
290
  queueTimeoutMs: coerceInt(getKV('--queue-timeout-ms='), coerceInt(process.env.RIP_QUEUE_TIMEOUT_MS, 2000))
289
291
  connectTimeoutMs: coerceInt(getKV('--connect-timeout-ms='), coerceInt(process.env.RIP_CONNECT_TIMEOUT_MS, 200))
290
292
  readTimeoutMs: coerceInt(getKV('--read-timeout-ms='), coerceInt(process.env.RIP_READ_TIMEOUT_MS, 5000))
@@ -299,68 +301,29 @@ parseFlags = (argv) ->
299
301
  runWorker = ->
300
302
  workerId = parseInt(process.env.WORKER_ID or '0')
301
303
  maxRequests = parseInt(process.env.MAX_REQUESTS or '10000')
302
- maxReloads = parseInt(process.env.MAX_RELOADS or '10')
303
304
  maxSeconds = parseInt(process.env.MAX_SECONDS or '0')
304
305
  appEntry = process.env.APP_ENTRY
305
306
  socketPath = process.env.SOCKET_PATH
306
- hotReloadMode = process.env.RIP_RELOAD or 'none'
307
307
  socketPrefix = process.env.SOCKET_PREFIX
308
308
  version = parseInt(process.env.RIP_VERSION or '1')
309
309
 
310
- appReady = false
311
- inflight = false
312
- handled = 0
313
310
  startedAtMs = Date.now()
314
- lastMtime = 0
315
- cachedHandler = null
316
- hotReloadCount = 0
317
- lastCheckTime = 0
318
- CHECK_INTERVAL_MS = 100
319
-
320
- checkForChanges = ->
321
- return false unless hotReloadMode is 'module'
322
- now = Date.now()
323
- return false if now - lastCheckTime < CHECK_INTERVAL_MS
324
- lastCheckTime = now
325
- try
326
- stats = statSync(appEntry)
327
- currentMtime = stats.mtime.getTime()
328
- if lastMtime is 0
329
- lastMtime = currentMtime
330
- return false
331
- if currentMtime > lastMtime
332
- lastMtime = currentMtime
333
- return true
334
- false
335
- catch
336
- false
311
+ # Use object to avoid Rip closure scoping issues with mutable variables
312
+ workerState =
313
+ appReady: false
314
+ inflight: false
315
+ handled: 0
316
+ handler: null
337
317
 
338
318
  getHandler = ->
339
- hasChanged = checkForChanges()
340
- if hasChanged
341
- hotReloadCount++
342
- console.log "[worker #{workerId}] File changed, reloading... (#{hotReloadCount}/#{maxReloads})"
343
- cachedHandler = null
344
-
345
- if hotReloadCount >= maxReloads
346
- console.log "[worker #{workerId}] Reached maxReloads (#{maxReloads}), graceful exit"
347
- setTimeout (-> process.exit(0)), 100
348
- return -> new Response('Worker cycling', { status: 503 })
349
-
350
- return cachedHandler if cachedHandler and not hasChanged
319
+ return workerState.handler if workerState.handler
351
320
 
352
321
  try
353
- # Try to import the API for resetGlobals
354
- api = null
355
- try
356
- api = await import('@rip-lang/api')
357
- catch
358
- null
322
+ mod = await import(appEntry)
359
323
 
360
- api?.resetGlobals?()
324
+ # Ensure module has fully executed by yielding to microtask queue
325
+ await Promise.resolve()
361
326
 
362
- bustQuery = if hotReloadMode is 'module' then "?bust=#{Date.now()}" else ''
363
- mod = await import(appEntry + bustQuery)
364
327
  fresh = mod.default or mod
365
328
 
366
329
  if typeof fresh is 'function'
@@ -370,12 +333,18 @@ runWorker = ->
370
333
  else
371
334
  h = globalThis.__ripHandler # Handler set by start() in rip-server mode
372
335
 
373
- h = api?.startHandler?() unless h
374
- cachedHandler = h or cachedHandler
375
- cachedHandler or (-> new Response('not ready', { status: 503 }))
336
+ unless h
337
+ try
338
+ api = await import('@rip-lang/api')
339
+ h = api?.startHandler?()
340
+ catch
341
+ null
342
+
343
+ workerState.handler = h if h
344
+ workerState.handler or (-> new Response('not ready', { status: 503 }))
376
345
  catch e
377
- console.error "[worker #{workerId}] import failed:", e if process.env.RIP_DEBUG
378
- cachedHandler or (-> new Response('not ready', { status: 503 }))
346
+ console.error "[worker #{workerId}] import failed:", e
347
+ workerState.handler or (-> new Response('not ready', { status: 503 }))
379
348
 
380
349
  selfJoin = ->
381
350
  try
@@ -398,7 +367,7 @@ runWorker = ->
398
367
  # Preload handler
399
368
  try
400
369
  initial = getHandler!
401
- appReady = typeof initial is 'function'
370
+ workerState.appReady = typeof initial is 'function'
402
371
  catch
403
372
  null
404
373
 
@@ -407,35 +376,38 @@ runWorker = ->
407
376
  maxRequestBodySize: 100 * 1024 * 1024
408
377
  fetch: (req) ->
409
378
  url = new URL(req.url)
410
- return new Response(if appReady then 'ok' else 'not-ready') if url.pathname is '/ready'
379
+ return new Response(if workerState.appReady then 'ok' else 'not-ready') if url.pathname is '/ready'
411
380
 
412
- if inflight
381
+ if workerState.inflight
413
382
  return new Response 'busy',
414
383
  status: 503
415
384
  headers: { 'Rip-Worker-Busy': '1', 'Retry-After': '0', 'Rip-Worker-Id': String(workerId) }
416
385
 
417
386
  handlerFn = getHandler!
418
- appReady = typeof handlerFn is 'function'
419
- inflight = true
387
+ workerState.appReady = typeof handlerFn is 'function'
388
+ workerState.inflight = true
420
389
 
421
390
  try
422
391
  return new Response('not ready', { status: 503 }) unless typeof handlerFn is 'function'
423
392
  res = handlerFn!(req)
424
393
  res = res!(req) if typeof res is 'function'
425
394
  if res instanceof Response then res else new Response(String(res))
426
- catch
395
+ catch err
396
+ console.error "[worker #{workerId}] ERROR:", err
427
397
  new Response('error', { status: 500 })
428
398
  finally
429
- inflight = false
430
- handled++
431
- exceededReqs = handled >= maxRequests
399
+ workerState.inflight = false
400
+ workerState.handled++
401
+ exceededReqs = workerState.handled >= maxRequests
432
402
  exceededTime = maxSeconds > 0 and (Date.now() - startedAtMs) / 1000 >= maxSeconds
433
403
  setTimeout (-> process.exit(0)), 10 if exceededReqs or exceededTime
434
404
 
435
405
  selfJoin!
436
406
 
437
407
  shutdown = ->
438
- while inflight
408
+ # Wait for in-flight request to complete (with timeout)
409
+ start = Date.now()
410
+ while workerState.inflight and Date.now() - start < SHUTDOWN_TIMEOUT_MS
439
411
  await new Promise (r) -> setTimeout(r, 10)
440
412
  try server.stop() catch then null
441
413
  selfQuit!
@@ -470,7 +442,7 @@ class Manager
470
442
  w = @spawnWorker!(@currentVersion)
471
443
  @workers.push(w)
472
444
 
473
- if @flags.reload is 'process'
445
+ if @flags.reload
474
446
  @currentMtime = @getEntryMtime()
475
447
  interval = setInterval =>
476
448
  return clearInterval(interval) if @shuttingDown
@@ -489,14 +461,13 @@ class Manager
489
461
  stop: ->
490
462
  for w in @workers
491
463
  try w.process.kill() catch then null
492
- try w.process.exited catch then null
493
- try Bun.spawn(['rm', '-f', w.socketPath]).exited catch then null
464
+ try unlinkSync(w.socketPath) catch then null
494
465
  @workers = []
495
466
 
496
467
  spawnWorker: (version) ->
497
468
  workerId = ++@nextWorkerId
498
469
  socketPath = getWorkerSocketPath(@flags.socketPrefix, workerId)
499
- try Bun.spawn(['rm', '-f', socketPath]).exited catch then null
470
+ try unlinkSync(socketPath) catch then null
500
471
 
501
472
  workerEnv = Object.assign {}, process.env,
502
473
  RIP_WORKER_MODE: '1'
@@ -505,10 +476,8 @@ class Manager
505
476
  SOCKET_PREFIX: @flags.socketPrefix
506
477
  APP_ENTRY: @flags.appEntry
507
478
  MAX_REQUESTS: String(@flags.maxRequestsPerWorker)
508
- MAX_RELOADS: String(@flags.maxReloadsPerWorker)
509
479
  MAX_SECONDS: String(@flags.maxSecondsPerWorker)
510
480
  RIP_LOG_JSON: if @flags.jsonLogging then '1' else '0'
511
- RIP_RELOAD: @flags.reload
512
481
  RIP_VERSION: String(version or @currentVersion)
513
482
 
514
483
  proc = Bun.spawn ['rip', __filename],
@@ -523,24 +492,26 @@ class Manager
523
492
  tracked
524
493
 
525
494
  monitor: (w) ->
526
- await w.process.exited # Wait for process to exit
495
+ # Watch for process exit in background (don't block)
496
+ w.process.exited.then =>
497
+ return if @shuttingDown
498
+ return if @retiringIds.has(w.id)
527
499
 
528
- # Notify server to remove dead worker's socket entry
529
- try
530
- ctl = getControlSocketPath(@flags.socketPrefix)
531
- body = JSON.stringify({ op: 'quit', workerId: w.id })
532
- await fetch('http://localhost/worker', { method: 'POST', body, headers: { 'content-type': 'application/json' }, unix: ctl })
533
- catch
534
- null
500
+ # Notify server to remove dead worker's socket entry (fire-and-forget)
501
+ try
502
+ ctl = getControlSocketPath(@flags.socketPrefix)
503
+ body = JSON.stringify({ op: 'quit', workerId: w.id })
504
+ fetch!('http://localhost/worker', { method: 'POST', body, headers: { 'content-type': 'application/json' }, unix: ctl }).catch(-> null)
505
+ catch
506
+ null
535
507
 
536
- return if @shuttingDown
537
- return if @retiringIds.has(w.id)
538
- w.restartCount++
539
- w.backoffMs = Math.min(w.backoffMs * 2, 30000)
540
- return if w.restartCount > 10
541
- await new Promise (r) -> setTimeout(r, w.backoffMs)
542
- idx = @workers.findIndex((x) -> x.id is w.id)
543
- @workers[idx] = @spawnWorker!() if idx >= 0
508
+ w.restartCount++
509
+ w.backoffMs = Math.min(w.backoffMs * 2, MAX_BACKOFF_MS)
510
+ return if w.restartCount > MAX_RESTART_COUNT
511
+ setTimeout =>
512
+ idx = @workers.findIndex((x) -> x.id is w.id)
513
+ @workers[idx] = @spawnWorker(@currentVersion) if idx >= 0
514
+ , w.backoffMs
544
515
 
545
516
  waitWorkerReady: (socketPath, timeoutMs = 5000) ->
546
517
  start = Date.now()
@@ -565,8 +536,21 @@ class Manager
565
536
  @workers.push(replacement)
566
537
  pairs.push({ old: oldWorker, replacement })
567
538
 
568
- Promise.all!(pairs.map((p) => @waitWorkerReady(p.replacement.socketPath, 3000)))
539
+ # Wait for all replacements and check readiness
540
+ readyResults = Promise.all!(pairs.map((p) => @waitWorkerReady(p.replacement.socketPath, 3000)))
541
+
542
+ # Check if all replacements are ready
543
+ allReady = readyResults.every((ready) -> ready)
544
+ unless allReady
545
+ console.error "[manager] Rolling restart aborted: not all new workers ready"
546
+ # Kill failed replacements and keep old workers
547
+ for pair, i in pairs
548
+ unless readyResults[i]
549
+ try pair.replacement.process.kill() catch then null
550
+ @workers = @workers.filter((w) -> w.id isnt pair.replacement.id)
551
+ return
569
552
 
553
+ # All ready - retire old workers
570
554
  for { old } in pairs
571
555
  @retiringIds.add(old.id)
572
556
  try old.process.kill() catch then null
@@ -611,54 +595,39 @@ class Server
611
595
 
612
596
  start: ->
613
597
  httpOnly = @flags.httpsPort is null
598
+ fetchFn = @fetch.bind(@)
614
599
 
615
- startOnPort = (p, fetchFn) =>
600
+ # Helper to start server, incrementing port if needed
601
+ startOnPort = (p, opts = {}) =>
616
602
  port = p
617
603
  while true
618
604
  try
619
- return Bun.serve({ port, idleTimeout: 8, fetch: fetchFn })
605
+ return Bun.serve(Object.assign({ port, idleTimeout: 8, fetch: fetchFn }, opts))
620
606
  catch e
621
- if e?.code is 'EADDRINUSE'
622
- port++
623
- continue
624
- throw e
607
+ throw e unless e?.code is 'EADDRINUSE'
608
+ port++
625
609
 
626
610
  if httpOnly
627
611
  if @flags.httpPort is 0
628
612
  try
629
- @server = Bun.serve({ port: 80, idleTimeout: 8, fetch: @fetch.bind(@) })
613
+ @server = Bun.serve({ port: 80, idleTimeout: 8, fetch: fetchFn })
630
614
  catch e
631
- if e?.code in ['EADDRINUSE', 'EACCES']
632
- @server = startOnPort(5700, @fetch.bind(@))
633
- else
634
- throw e
615
+ throw e unless e?.code in ['EADDRINUSE', 'EACCES']
616
+ @server = startOnPort(5700)
635
617
  else
636
- @server = startOnPort(@flags.httpPort, @fetch.bind(@))
618
+ @server = startOnPort(@flags.httpPort)
637
619
  @flags.httpPort = @server.port
638
620
  else
639
621
  tls = @loadTlsMaterial!
640
622
 
641
- startOnTlsPort = (p) =>
642
- port = p
643
- while true
644
- try
645
- return Bun.serve({ port, idleTimeout: 8, tls, fetch: @fetch.bind(@) })
646
- catch e
647
- if e?.code is 'EADDRINUSE'
648
- port++
649
- continue
650
- throw e
651
-
652
623
  if not @flags.httpsPort or @flags.httpsPort is 0
653
624
  try
654
- @httpsServer = Bun.serve({ port: 443, idleTimeout: 8, tls, fetch: @fetch.bind(@) })
625
+ @httpsServer = Bun.serve({ port: 443, idleTimeout: 8, tls, fetch: fetchFn })
655
626
  catch e
656
- if e?.code in ['EADDRINUSE', 'EACCES']
657
- @httpsServer = startOnTlsPort(5700)
658
- else
659
- throw e
627
+ throw e unless e?.code in ['EADDRINUSE', 'EACCES']
628
+ @httpsServer = startOnPort(5700, { tls })
660
629
  else
661
- @httpsServer = startOnTlsPort(@flags.httpsPort)
630
+ @httpsServer = startOnPort(@flags.httpsPort, { tls })
662
631
 
663
632
  httpsPort = @httpsServer.port
664
633
  @flags.httpsPort = httpsPort
@@ -755,8 +724,26 @@ class Server
755
724
  @newestVersion is null or worker.version is null or worker.version >= @newestVersion
756
725
 
757
726
  releaseWorker: (worker) ->
727
+ return unless worker
728
+ return unless @sockets.some((s) -> s.socket is worker.socket) # Validate still in pool
758
729
  worker.inflight = 0
759
- @availableWorkers.push(worker) if @isCurrentVersion(worker)
730
+ if @isCurrentVersion(worker)
731
+ @availableWorkers.push(worker)
732
+ # Force array mutation to be visible (workaround for timing issue)
733
+ @availableWorkers.length
734
+
735
+ logAccess: (req, res, totalSeconds, workerSeconds) ->
736
+ if @flags.jsonLogging
737
+ logAccessJson(@flags.appName, req, res, totalSeconds, workerSeconds)
738
+ else if @flags.accessLog
739
+ logAccessHuman(@flags.appName, req, res, totalSeconds, workerSeconds)
740
+
741
+ buildResponse: (res, req, start, workerSeconds) ->
742
+ headers = stripInternalHeaders(res.headers)
743
+ headers.delete('date')
744
+ @maybeAddSecurityHeaders(headers)
745
+ @logAccess(req, res, (performance.now() - start) / 1000, workerSeconds)
746
+ new Response(res.body, { status: res.status, statusText: res.statusText, headers })
760
747
 
761
748
  forwardToWorker: (req, socket) ->
762
749
  start = performance.now()
@@ -770,7 +757,9 @@ class Server
770
757
  res = @forwardOnce!(req, socket.socket)
771
758
  workerSeconds = (performance.now() - t0) / 1000
772
759
 
773
- if res.status is 503 and res.headers.get('Rip-Worker-Busy') is '1'
760
+ # Only retry bodyless requests (body stream can't be reused)
761
+ canRetry = req.method in ['GET', 'HEAD', 'OPTIONS', 'DELETE']
762
+ if canRetry and res.status is 503 and res.headers.get('Rip-Worker-Busy') is '1'
774
763
  retry = @getNextAvailableSocket()
775
764
  if retry and retry isnt socket
776
765
  @releaseWorker(socket)
@@ -779,15 +768,10 @@ class Server
779
768
  t1 = performance.now()
780
769
  res = @forwardOnce!(req, retry.socket)
781
770
  workerSeconds = (performance.now() - t1) / 1000
782
- headers = stripInternalHeaders(res.headers)
783
- headers.delete('date')
784
- if @flags.jsonLogging
785
- logAccessJson(@flags.appName, req, res, (performance.now() - start) / 1000, workerSeconds)
786
- else if @flags.accessLog
787
- logAccessHuman(@flags.appName, req, res, (performance.now() - start) / 1000, workerSeconds)
788
771
  @releaseWorker(retry)
789
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
790
- catch
772
+ return @buildResponse(res, req, start, workerSeconds)
773
+ catch err
774
+ console.error "[server] forwardToWorker error:", err.message or err if process.env.RIP_DEBUG
791
775
  @sockets = @sockets.filter((x) -> x.socket isnt socket.socket)
792
776
  @availableWorkers = @availableWorkers.filter((x) -> x.socket isnt socket.socket)
793
777
  released = true
@@ -796,32 +780,28 @@ class Server
796
780
  @releaseWorker(socket) unless released
797
781
 
798
782
  return new Response('Service unavailable', { status: 503, headers: { 'Retry-After': '1' } }) unless res
799
-
800
- headers = stripInternalHeaders(res.headers)
801
- headers.delete('date')
802
- @maybeAddSecurityHeaders(headers)
803
-
804
- if @flags.jsonLogging
805
- logAccessJson(@flags.appName, req, res, (performance.now() - start) / 1000, workerSeconds)
806
- else if @flags.accessLog
807
- logAccessHuman(@flags.appName, req, res, (performance.now() - start) / 1000, workerSeconds)
808
-
809
- new Response(res.body, { status: res.status, statusText: res.statusText, headers })
783
+ @buildResponse(res, req, start, workerSeconds)
810
784
 
811
785
  forwardOnce: (req, socketPath) ->
812
786
  inUrl = new URL(req.url)
813
787
  forwardUrl = "http://localhost#{inUrl.pathname}#{inUrl.search}"
814
788
  controller = new AbortController()
815
- timer = setTimeout (-> controller.abort()), @flags.connectTimeoutMs
789
+ connectTimer = setTimeout (-> controller.abort()), @flags.connectTimeoutMs
790
+ readTimeoutMs = @flags.readTimeoutMs
816
791
 
817
792
  try
818
- upstream = fetch!(forwardUrl, { method: req.method, headers: req.headers, body: req.body, unix: socketPath, signal: controller.signal })
819
- clearTimeout(timer)
793
+ # Don't await fetch - we need to race the promise against timeout
794
+ fetchPromise = fetch(forwardUrl, { method: req.method, headers: req.headers, body: req.body, unix: socketPath, signal: controller.signal })
820
795
  readGuard = new Promise (_, rej) ->
821
- setTimeout (-> rej(new Response('Upstream timeout', { status: 504 }))), @flags.readTimeoutMs
822
- Promise.race!([Promise.resolve(upstream), readGuard])
823
- finally
824
- clearTimeout(timer)
796
+ setTimeout (-> rej(new Error('Upstream timeout'))), readTimeoutMs
797
+ res = Promise.race!([fetchPromise, readGuard])
798
+ clearTimeout(connectTimer)
799
+ res
800
+ catch err
801
+ clearTimeout(connectTimer)
802
+ if err.message is 'Upstream timeout'
803
+ return new Response('Gateway timeout', { status: 504 })
804
+ throw err
825
805
 
826
806
  drainQueue: ->
827
807
  while @inflightTotal < Math.max(1, @sockets.length) and @availableWorkers.length > 0
@@ -897,18 +877,18 @@ class Server
897
877
  console.error 'Failed to read TLS cert/key from provided paths. Use http or fix paths.'
898
878
  process.exit(2)
899
879
 
900
- # mkcert path under ~/.rip/certs
880
+ # Ensure certs directory exists
881
+ certsDir = join(homedir(), '.rip', 'certs')
882
+ try mkdirSync(certsDir, { recursive: true }) catch then null
883
+
884
+ # Try mkcert first (trusted local CA)
901
885
  if @flags.autoTls
902
- dir = join(homedir(), '.rip', 'certs')
903
- try mkdirSync(dir, { recursive: true }) catch then null
904
- certPath = join(dir, 'localhost.pem')
905
- keyPath = join(dir, 'localhost-key.pem')
886
+ certPath = join(certsDir, 'localhost.pem')
887
+ keyPath = join(certsDir, 'localhost-key.pem')
906
888
  unless existsSync(certPath) and existsSync(keyPath)
907
889
  try
908
- gen = Bun.spawn(['mkcert', '-install'])
909
- try gen.exited catch then null
910
- p = Bun.spawn(['mkcert', '-key-file', keyPath, '-cert-file', certPath, 'localhost', '127.0.0.1', '::1'])
911
- p.exited
890
+ Bun.spawnSync(['mkcert', '-install'])
891
+ Bun.spawnSync(['mkcert', '-key-file', keyPath, '-cert-file', certPath, 'localhost', '127.0.0.1', '::1'])
912
892
  catch
913
893
  null # fall through to self-signed
914
894
  if existsSync(certPath) and existsSync(keyPath)
@@ -918,14 +898,11 @@ class Server
918
898
  return { cert, key }
919
899
 
920
900
  # Self-signed via openssl
921
- dir = join(homedir(), '.rip', 'certs')
922
- try mkdirSync(dir, { recursive: true }) catch then null
923
- certPath = join(dir, 'selfsigned-localhost.pem')
924
- keyPath = join(dir, 'selfsigned-localhost-key.pem')
901
+ certPath = join(certsDir, 'selfsigned-localhost.pem')
902
+ keyPath = join(certsDir, 'selfsigned-localhost-key.pem')
925
903
  unless existsSync(certPath) and existsSync(keyPath)
926
904
  try
927
- p = Bun.spawn(['openssl', 'req', '-x509', '-nodes', '-newkey', 'rsa:2048', '-keyout', keyPath, '-out', certPath, '-subj', '/CN=localhost', '-days', '1'])
928
- p.exited
905
+ Bun.spawnSync(['openssl', 'req', '-x509', '-nodes', '-newkey', 'rsa:2048', '-keyout', keyPath, '-out', certPath, '-subj', '/CN=localhost', '-days', '3650'])
929
906
  catch
930
907
  console.error 'TLS required but could not provision a certificate (mkcert/openssl missing). Use http or provide --cert/--key.'
931
908
  process.exit(2)
@@ -950,12 +927,12 @@ class Server
950
927
 
951
928
  getLanIP: ->
952
929
  try
953
- output = Bun.spawnSync(['ifconfig'], { stdout: 'pipe' }).stdout.toString()
954
- matches = output.match(/inet\s+(\d+\.\d+\.\d+\.\d+)/g)
955
- if matches
956
- for match in matches
957
- ip = match.split(/\s+/)[1]
958
- return ip if ip and ip isnt '127.0.0.1' and not ip.startsWith('169.254.')
930
+ nets = networkInterfaces()
931
+ for name, addrs of nets
932
+ for addr in addrs
933
+ continue if addr.internal or addr.family isnt 'IPv4'
934
+ continue if addr.address.startsWith('169.254.') # Link-local
935
+ return addr.address
959
936
  catch
960
937
  null
961
938
  null
@@ -971,13 +948,14 @@ class Server
971
948
 
972
949
  port = @flags.httpsPort or @flags.httpPort or 80
973
950
  protocol = if @flags.httpsPort then 'https' else 'http'
951
+ serviceType = if @flags.httpsPort then '_https._tcp' else '_http._tcp'
974
952
  serviceName = host.replace('.local', '')
975
953
 
976
954
  try
977
955
  proc = Bun.spawn [
978
956
  'dns-sd', '-P'
979
957
  serviceName
980
- '_http._tcp'
958
+ serviceType
981
959
  'local'
982
960
  String(port)
983
961
  host
@@ -1002,46 +980,62 @@ class Server
1002
980
  # ==============================================================================
1003
981
 
1004
982
  main = ->
1005
- # Subcommand: stop
1006
- if 'stop' in process.argv
983
+ # Version flag
984
+ if '--version' in process.argv or '-v' in process.argv
1007
985
  try
1008
- Bun.spawn(['pkill', '-f', __filename]).exited
1009
- Bun.spawn(['pkill', '-f', 'dns-sd -P.*_http._tcp']).exited
986
+ pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'))
987
+ console.log "rip-server v#{pkg.version}"
1010
988
  catch
1011
- null
1012
- console.log 'rip-server: stop requested'
989
+ console.log 'rip-server (version unknown)'
1013
990
  return
1014
991
 
1015
- # Subcommand: list
1016
- if 'list' in process.argv
1017
- getKV = (prefix) ->
1018
- for tok in process.argv
1019
- return tok.slice(prefix.length) if tok.startsWith(prefix)
1020
- undefined
992
+ # Helper functions for subcommands
993
+ getKV = (prefix) ->
994
+ for tok in process.argv
995
+ return tok.slice(prefix.length) if tok.startsWith(prefix)
996
+ undefined
1021
997
 
1022
- findAppPathToken = ->
1023
- for i in [2...process.argv.length]
1024
- tok = process.argv[i]
1025
- pathPart = if tok.includes('@') then tok.split('@')[0] else tok
1026
- looksLikePath = pathPart.includes('/') or pathPart.startsWith('.') or pathPart.endsWith('.rip') or pathPart.endsWith('.ts')
1027
- try
1028
- return pathPart if looksLikePath and existsSync(if isAbsolute(pathPart) then pathPart else resolve(process.cwd(), pathPart))
1029
- catch
1030
- null
1031
- undefined
998
+ findAppPathToken = ->
999
+ for i in [2...process.argv.length]
1000
+ tok = process.argv[i]
1001
+ pathPart = if tok.includes('@') then tok.split('@')[0] else tok
1002
+ looksLikePath = pathPart.includes('/') or pathPart.startsWith('.') or pathPart.endsWith('.rip') or pathPart.endsWith('.ts')
1003
+ try
1004
+ return pathPart if looksLikePath and existsSync(if isAbsolute(pathPart) then pathPart else resolve(process.cwd(), pathPart))
1005
+ catch
1006
+ null
1007
+ undefined
1032
1008
 
1033
- computeSocketPrefix = ->
1034
- override = getKV('--socket-prefix=')
1035
- return override if override
1036
- appTok = findAppPathToken()
1037
- if appTok
1038
- try
1039
- { appName } = resolveAppEntry(appTok)
1040
- return "rip_#{appName}"
1041
- catch
1042
- null
1043
- 'rip_server'
1009
+ computeSocketPrefix = ->
1010
+ override = getKV('--socket-prefix=')
1011
+ return override if override
1012
+ appTok = findAppPathToken()
1013
+ if appTok
1014
+ try
1015
+ { appName } = resolveAppEntry(appTok)
1016
+ return "rip_#{appName}"
1017
+ catch
1018
+ null
1019
+ 'rip_server'
1044
1020
 
1021
+ # Subcommand: stop
1022
+ if 'stop' in process.argv
1023
+ prefix = computeSocketPrefix()
1024
+ pidFile = getPidFilePath(prefix)
1025
+ try
1026
+ if existsSync(pidFile)
1027
+ pid = parseInt(readFileSync(pidFile, 'utf8').trim())
1028
+ process.kill(pid, 'SIGTERM')
1029
+ console.log "rip-server: sent SIGTERM to process #{pid}"
1030
+ else
1031
+ console.log "rip-server: no PID file found at #{pidFile}, trying pkill..."
1032
+ Bun.spawnSync(['pkill', '-f', __filename])
1033
+ catch e
1034
+ console.error "rip-server: stop failed: #{e.message}"
1035
+ return
1036
+
1037
+ # Subcommand: list
1038
+ if 'list' in process.argv
1045
1039
  controlUnix = getControlSocketPath(computeSocketPrefix())
1046
1040
  try
1047
1041
  res = fetch!('http://localhost/registry', { unix: controlUnix, method: 'GET' })
@@ -1056,11 +1050,15 @@ main = ->
1056
1050
 
1057
1051
  # Normal startup
1058
1052
  flags = parseFlags(process.argv)
1053
+ pidFile = getPidFilePath(flags.socketPrefix)
1054
+ writeFileSync(pidFile, String(process.pid))
1055
+
1059
1056
  svr = new Server(flags)
1060
1057
  mgr = new Manager(flags)
1061
1058
 
1062
1059
  cleanup = ->
1063
1060
  console.log 'rip-server: shutting down...'
1061
+ try unlinkSync(pidFile) catch then null
1064
1062
  svr.stop()
1065
1063
  mgr.stop!
1066
1064
  process.exit(0)