@rip-lang/server 1.1.7 → 1.1.9

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 +36 -13
  2. package/package.json +1 -1
  3. package/server.rip +91 -21
package/README.md CHANGED
@@ -311,7 +311,12 @@ The server uses a single-file, self-spawning architecture:
311
311
  └─────────────────────────────────────────────────┘
312
312
  ```
313
313
 
314
- When `RIP_WORKER_MODE=1` is set, the same `server.rip` file runs as a worker instead of the main server.
314
+ When `RIP_SETUP_MODE=1` is set, the same file runs the one-time setup phase. When `RIP_WORKER_MODE=1` is set, it runs as a worker.
315
+
316
+ ### Startup Lifecycle
317
+
318
+ 1. **Setup** — If `setup.rip` exists next to the entry file, it runs once in a temporary process before any workers spawn. Use this for database migrations, table creation, and seeding.
319
+ 2. **Workers** — N worker processes are spawned, each loading the entry file and serving requests.
315
320
 
316
321
  ### Request Flow
317
322
 
@@ -323,12 +328,10 @@ When `RIP_WORKER_MODE=1` is set, the same `server.rip` file runs as a worker ins
323
328
 
324
329
  ### Hot Reloading
325
330
 
326
- In development mode, the server watches for file changes:
327
-
328
- - **Default**: Only the entry file is watched
329
- - **With `-w`**: All matching files in the app directory are watched
331
+ Two layers of hot reload work together in development:
330
332
 
331
- When a change is detected, a rolling restart of all workers is triggered zero downtime.
333
+ - **API changes** (`-w` flag) The Manager watches for `.rip` file changes in the API directory and triggers rolling worker restarts (zero downtime, server-side).
334
+ - **UI changes** (`watch: true` in `ripUI`) — Workers register their app's component directories with the Manager via the control socket. The Manager watches those directories and broadcasts SSE reload events to connected browsers (client-side). SSE connections are held by the long-lived Server process, not by workers.
332
335
 
333
336
  Use `--static` in production to disable hot reload entirely.
334
337
 
@@ -409,6 +412,24 @@ export default
409
412
  fetch: (req) -> new Response('Hello!')
410
413
  ```
411
414
 
415
+ ## One-Time Setup
416
+
417
+ If a `setup.rip` file exists next to your entry file, rip-server runs it
418
+ automatically **once** before spawning any workers. This is ideal for database
419
+ migrations, table creation, and seeding.
420
+
421
+ ```coffee
422
+ # setup.rip — runs once before workers start
423
+ export setup = ->
424
+ await createTables()
425
+ await seedData()
426
+ console.log 'Database ready'
427
+ ```
428
+
429
+ The setup function can export as `setup` or `default`. If the file doesn't
430
+ exist, the setup phase is skipped entirely (no overhead). If setup fails,
431
+ the server exits immediately.
432
+
412
433
  ## Environment Variables
413
434
 
414
435
  Most settings are configured via CLI flags, but environment variables provide an alternative for containers, CI/CD, or system-wide defaults.
@@ -501,14 +522,16 @@ This gives you:
501
522
 
502
523
  When running with `-w`, two layers of hot reload work together:
503
524
 
504
- 1. **rip-server file watching** (`-w` flag) — watches for `.rip` file changes
505
- and triggers rolling worker restarts (server-side reload)
506
- 2. **ripUI SSE watching** (`watch: true`) — watches the `pages/` and `includes`
507
- directories and notifies connected browsers via SSE (client-side reload)
525
+ 1. **API hot reload** (`-w` flag) — The Manager watches for `.rip` file changes
526
+ in the API directory and triggers rolling worker restarts (server-side).
527
+ 2. **UI hot reload** (`watch: true`) — Workers register their component
528
+ directories with the Manager via the control socket. The Manager watches
529
+ those directories and tells the Server to broadcast SSE reload events to
530
+ connected browsers (client-side).
508
531
 
509
- For development, the SSE hot-reload is usually sufficient it recompiles
510
- components in the browser without restarting workers. The `-w` flag is useful
511
- when server-side code changes (routes, middleware, etc.).
532
+ SSE connections are held by the long-lived Server process, not by recyclable
533
+ workers, ensuring stable hot-reload connections. Each app prefix gets its own
534
+ SSE pool for multi-app isolation.
512
535
 
513
536
  ## Comparison with Other Servers
514
537
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rip-lang/server",
3
- "version": "1.1.7",
3
+ "version": "1.1.9",
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
@@ -352,6 +352,18 @@ parseFlags = (argv) ->
352
352
  # Worker Mode
353
353
  # ==============================================================================
354
354
 
355
+ runSetup = ->
356
+ setupFile = process.env.RIP_SETUP_FILE
357
+ try
358
+ mod = import!(setupFile)
359
+ await Promise.resolve()
360
+ fn = mod?.setup or mod?.default
361
+ if typeof fn is 'function'
362
+ await fn()
363
+ catch e
364
+ console.error "rip-server: setup failed:", e
365
+ process.exit(1)
366
+
355
367
  runWorker = ->
356
368
  workerId = parseInt(process.env.WORKER_ID or '0')
357
369
  maxRequests = parseInt(process.env.MAX_REQUESTS or '10000')
@@ -485,14 +497,32 @@ class Manager
485
497
  @nextWorkerId = -1
486
498
  @retiringIds = new Set()
487
499
  @currentVersion = 1
488
- @onFileChange = null
489
- @_broadcastTimer = null
500
+ @server = null
501
+ @appWatchers = new Map()
490
502
 
491
503
  process.on 'SIGTERM', => @shutdown!
492
504
  process.on 'SIGINT', => @shutdown!
493
505
 
494
506
  start: ->
495
507
  @stop!
508
+
509
+ # Run one-time setup if setup.rip exists next to the entry file
510
+ setupFile = join(dirname(@flags.appEntry), 'setup.rip')
511
+ if existsSync(setupFile)
512
+ setupEnv = Object.assign {}, process.env,
513
+ RIP_SETUP_MODE: '1'
514
+ RIP_SETUP_FILE: setupFile
515
+ proc = Bun.spawn ['rip', import.meta.path],
516
+ stdout: 'inherit'
517
+ stderr: 'inherit'
518
+ stdin: 'ignore'
519
+ cwd: process.cwd()
520
+ env: setupEnv
521
+ code = await proc.exited
522
+ if code isnt 0
523
+ console.error "rip-server: setup exited with code #{code}"
524
+ process.exit(1)
525
+
496
526
  @workers = []
497
527
  for i in [0...@flags.workers]
498
528
  w = @spawnWorker!(@currentVersion)
@@ -534,13 +564,6 @@ class Manager
534
564
  utimesSync(entryFile, now, now)
535
565
  catch
536
566
  null
537
- # Debounced SSE broadcast to connected clients
538
- if @onFileChange
539
- clearTimeout(@_broadcastTimer) if @_broadcastTimer
540
- @_broadcastTimer = setTimeout =>
541
- @_broadcastTimer = null
542
- @onFileChange()
543
- , debounceMs
544
567
  catch e
545
568
  console.warn "rip-server: directory watch failed: #{e.message}"
546
569
 
@@ -550,6 +573,25 @@ class Manager
550
573
  try unlinkSync(w.socketPath) catch then null
551
574
  @workers = []
552
575
 
576
+ watchDirs: (prefix, dirs) ->
577
+ return if @appWatchers.has(prefix)
578
+ timer = null
579
+ watchers = []
580
+ broadcast = =>
581
+ clearTimeout(timer) if timer
582
+ timer = setTimeout =>
583
+ timer = null
584
+ @server?.broadcastChange(prefix)
585
+ , 250
586
+ for dir in dirs
587
+ try
588
+ w = watch dir, { recursive: true }, (event, filename) ->
589
+ broadcast() if filename?.endsWith('.rip')
590
+ watchers.push(w)
591
+ catch e
592
+ console.warn "rip-server: watch failed for #{dir}: #{e.message}"
593
+ @appWatchers.set prefix, { watchers, timer }
594
+
553
595
  spawnWorker: (version) ->
554
596
  workerId = ++@nextWorkerId
555
597
  socketPath = getWorkerSocketPath(@flags.socketPrefix, workerId)
@@ -683,7 +725,8 @@ class Server
683
725
  @httpsActive = false
684
726
  @hostRegistry = new Set(['localhost', '127.0.0.1', 'rip.local'])
685
727
  @mdnsProcesses = new Map()
686
- @sseClients = new Set()
728
+ @watchGroups = new Map()
729
+ @manager = null
687
730
  try
688
731
  pkg = JSON.parse(readFileSync(import.meta.dir + '/package.json', 'utf8'))
689
732
  @serverVersion = pkg.version
@@ -773,7 +816,12 @@ class Server
773
816
  return new Response Bun.file(import.meta.dir + '/server.html')
774
817
 
775
818
  return @status() if url.pathname is '/status'
776
- return @watch() if url.pathname is '/watch'
819
+
820
+ # SSE hot-reload: intercept /{prefix}/watch for registered watch groups
821
+ path = url.pathname
822
+ if path.endsWith('/watch')
823
+ watchPrefix = path.slice(0, -6) # strip '/watch'
824
+ return @handleWatch(watchPrefix) if @watchGroups.has(watchPrefix)
777
825
 
778
826
  if url.pathname is '/server'
779
827
  headers = new Headers({ 'content-type': 'text/plain' })
@@ -818,31 +866,39 @@ class Server
818
866
  @maybeAddSecurityHeaders(headers)
819
867
  new Response(body, { headers })
820
868
 
821
- watch: ->
869
+ registerWatch: (prefix) ->
870
+ return if @watchGroups.has(prefix)
871
+ @watchGroups.set prefix, { sseClients: new Set() }
872
+
873
+ handleWatch: (prefix) ->
874
+ group = @watchGroups.get(prefix)
875
+ return new Response('not-found', { status: 404 }) unless group
822
876
  encoder = new TextEncoder()
823
877
  client = null
824
878
  new Response new ReadableStream(
825
- start: (controller) =>
879
+ start: (controller) ->
826
880
  send = (event) ->
827
881
  try controller.enqueue encoder.encode("event: #{event}\n\n")
828
882
  catch then null
829
883
  client = { send }
830
- @sseClients.add(client)
884
+ group.sseClients.add(client)
831
885
  send('connected')
832
- cancel: =>
833
- @sseClients.delete(client) if client
886
+ cancel: ->
887
+ group.sseClients.delete(client) if client
834
888
  ),
835
889
  headers:
836
890
  'Content-Type': 'text/event-stream'
837
891
  'Cache-Control': 'no-cache'
838
892
  'Connection': 'keep-alive'
839
893
 
840
- broadcastChange: ->
894
+ broadcastChange: (prefix) ->
895
+ group = @watchGroups.get(prefix)
896
+ return unless group
841
897
  dead = []
842
- for client as @sseClients
898
+ for client as group.sseClients
843
899
  try client.send('reload')
844
900
  catch then dead.push(client)
845
- @sseClients.delete(c) for c in dead
901
+ group.sseClients.delete(c) for c in dead
846
902
 
847
903
  getNextAvailableSocket: ->
848
904
  while @availableWorkers.length > 0
@@ -979,6 +1035,17 @@ class Server
979
1035
  null
980
1036
  return new Response(JSON.stringify({ ok: false }), { status: 400, headers: { 'content-type': 'application/json' } })
981
1037
 
1038
+ if req.method is 'POST' and url.pathname is '/watch'
1039
+ try
1040
+ j = req.json!
1041
+ if j?.op is 'watch' and typeof j.prefix is 'string' and Array.isArray(j.dirs)
1042
+ @registerWatch(j.prefix)
1043
+ @manager?.watchDirs(j.prefix, j.dirs) if j.dirs.length > 0
1044
+ return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } })
1045
+ catch
1046
+ null
1047
+ return new Response(JSON.stringify({ ok: false }), { status: 400, headers: { 'content-type': 'application/json' } })
1048
+
982
1049
  if url.pathname is '/registry' and req.method is 'GET'
983
1050
  return new Response(JSON.stringify({ ok: true, hosts: Array.from(@hostRegistry.values()) }), { headers: { 'content-type': 'application/json' } })
984
1051
 
@@ -1214,7 +1281,8 @@ main = ->
1214
1281
 
1215
1282
  svr = new Server(flags)
1216
1283
  mgr = new Manager(flags)
1217
- mgr.onFileChange = -> svr.broadcastChange()
1284
+ svr.manager = mgr
1285
+ mgr.server = svr
1218
1286
 
1219
1287
  cleanup = ->
1220
1288
  console.log 'rip-server: shutting down...'
@@ -1243,7 +1311,9 @@ main = ->
1243
1311
  # Entry Point
1244
1312
  # ==============================================================================
1245
1313
 
1246
- if process.env.RIP_WORKER_MODE
1314
+ if process.env.RIP_SETUP_MODE
1315
+ runSetup()
1316
+ else if process.env.RIP_WORKER_MODE
1247
1317
  runWorker()
1248
1318
  else
1249
1319
  main!