diff --git a/.github/secret_scanning.yml b/.github/secret_scanning.yml index ef0fdb10..921f4238 100644 --- a/.github/secret_scanning.yml +++ b/.github/secret_scanning.yml @@ -1,5 +1,5 @@ # Intentionally committed development-only private key for the publicly # trusted ripdev.io certificate. The zone resolves only to loopback; see -# packages/server/Caddyfile. +# packages/sites/Caddyfile. paths-ignore: - - "packages/server/ripdev.io.key" + - "packages/sites/ripdev.io.key" diff --git a/.github/workflows/cart.yml b/.github/workflows/cart.yml index eb11deab..74285b68 100644 --- a/.github/workflows/cart.yml +++ b/.github/workflows/cart.yml @@ -1,4 +1,4 @@ -# Live Cart certification is explicit because it boots Rip Server, Manager, +# Live Cart certification is explicit because it boots Rip Site, Manager, # workers, a Janus-shaped edge, and Chromium. Pull requests use the deterministic # browser smoke matrix in test.yml; this workflow preserves the full-stack # contract without making that mutable process harness the edit loop. diff --git a/.gitignore b/.gitignore index 6bde2edf..0068e72a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ dist/browser/* tmp-* /packages/tray/macos/.build /packages/tray/dist + +# Rip tray launcher state (generated by bin/rip-tray) +bin/.rip-tray.plist +bin/.rip-tray.log diff --git a/AGENTS.md b/AGENTS.md index 783463cf..ee8d599a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ support. Permanent documentation: - [README.md](README.md) — repository orientation and entry points. -- [docs/SERVER.md](docs/SERVER.md) — Rip Server's system-wide +- [docs/SERVER.md](docs/SERVER.md) — Rip Sites' system-wide Caddy/Janus/manager/worker architecture and lifecycle contract. - [docs/TYPES.md](docs/TYPES.md) — type and editor architecture. - [docs/WORKSPACE.md](docs/WORKSPACE.md) — the Rip Workspace @@ -280,7 +280,7 @@ alters surface syntax updates ALL THREE in the same change. or choose the nearest idiomatic alternative; never paper over with curly-brace JS style. Read nearby `.rip` for the local dialect before writing new code. -- **Workspace vocabulary (publication / apply):** Rip Server publishes +- **Workspace vocabulary (publication / apply):** Rip Sites publishes applications. `bundle.json` is `{ hash, list }`; `list` is the complete canonical `[modulePath, source]` Rip program. Individual file hashes remain private to Manager. Watch-mode Hub messages carry one @@ -402,7 +402,7 @@ the affected final gates again. browsers. CI runs its deterministic Chromium/Firefox/WebKit smoke matrix as a required job; the live Cart Server/Manager certification is explicit through `bun run test:cart` in that package and the manual - `cart-certification` workflow. `packages/server` needs + `cart-certification` workflow. `packages/sites` needs `xcaddy` on PATH — `go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest`, PLUS `$(go env GOPATH)/bin` on PATH, which it is not by default: without @@ -412,7 +412,7 @@ the affected final gates again. `JANUS_CADDY=` supplies a janus-enabled binary instead. Without either, the lane is fail-fast at `janus` and the six directories sorted after it do not run. -- `bun run test` FROM a package (`packages/vscode`, `packages/server`, +- `bun run test` FROM a package (`packages/vscode`, `packages/sites`, `packages/app`, …) — that package's own suite, and the inner loop for work on that package; the root fast loop excludes `packages/**` by bunfig, and `test:all` reaches these only by spawning them there @@ -444,7 +444,7 @@ the affected final gates again. loudly if any `@rip-lang/*` name resolves outside this repo (e.g. shadowed by a sibling checkout's global links). - `rip` on a TTY (or `rip -r`) — the interactive REPL; `rip -e ` - evaluates one entry; `rip schema`/`rip server`/`rip test` dispatch + evaluates one entry; `rip schema`/`rip site`/`rip test` dispatch their surfaces (`--help` on each). ## When Blocked diff --git a/HANDOFF.md b/HANDOFF.md index d1e86c64..0c9e1233 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -12,7 +12,7 @@ lives in `docs/`; git history and pull requests retain completed-work detail. `packages/browser-tests: bun run test:cart`. - Server architecture: `docs/SERVER.md`. - Browser publication contract: `docs/WORKSPACE.md`. -- Detailed lifecycle: `packages/server/README.md`. +- Detailed lifecycle: `packages/sites/README.md`. - Testing cleanup plan: `TEST-DIET.md`. ## App publication contract @@ -70,13 +70,13 @@ PR #210 true-merged the testing policy as `769bc2a`: - manual live Cart certification: `cart-certification` workflow; - package suites run at coherent layer milestones, not after every edit. -`rip-app-publication` merged that mainline at `bd8f407`. The two isolated, +`rip-sites-publication` merged that mainline at `bd8f407`. The two isolated, unsuccessful browser-harness experiments were removed by normal revert commits: - `e603ea2 Revert "Make Cart readiness nonblocking"`; - `e188acf Revert "Stabilize Cart harness readiness"`. -Those experiments never modified Rip Server or Rip App production code. +Those experiments never modified Rip Sites or Rip App production code. Arbitrary top-level ESM side effects execute while a candidate module graph is evaluated and cannot be rolled back. Workspace, renderer, source, route diff --git a/README.md b/README.md index 2c035093..c0e203f5 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ bun run corpus-expected bun run audit ``` -`test:all` needs `xcaddy` on PATH for the `packages/server` janus lane: +`test:all` needs `xcaddy` on PATH for the `packages/sites` janus lane: ```sh go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest diff --git a/TEST-DIET.md b/TEST-DIET.md index fff03784..a6dda370 100644 --- a/TEST-DIET.md +++ b/TEST-DIET.md @@ -215,7 +215,7 @@ shown to detect its claimed failure needs redesign. 1. `packages/browser-tests`: separate browser-runtime smoke from the live Cart Server/Manager exemplar; make harness failures diagnostic and deterministic. -2. `packages/server`: separate pure publication/registration contracts from +2. `packages/sites`: separate pure publication/registration contracts from live Janus and process lifecycle certification. 3. `packages/app`: consolidate repeated Workspace/apply scenarios around the transactional invariants they share. diff --git a/bin/Rip.app/Contents/Info.plist b/bin/Rip.app/Contents/Info.plist new file mode 100644 index 00000000..1cc060e3 --- /dev/null +++ b/bin/Rip.app/Contents/Info.plist @@ -0,0 +1,15 @@ + + + + CFBundleDevelopmentRegionen + CFBundleExecutablerip-tray-host + CFBundleIdentifierio.ripdev.apps + CFBundleInfoDictionaryVersion6.0 + CFBundleNameRip + CFBundlePackageTypeAPPL + CFBundleShortVersionString4.0.0 + CFBundleVersion1 + LSMinimumSystemVersion13.0 + LSUIElement + + \ No newline at end of file diff --git a/bin/Rip.app/Contents/MacOS/rip-tray-host b/bin/Rip.app/Contents/MacOS/rip-tray-host new file mode 100755 index 00000000..ea446bfa Binary files /dev/null and b/bin/Rip.app/Contents/MacOS/rip-tray-host differ diff --git a/bin/Rip.app/Contents/Resources/Tray.rip b/bin/Rip.app/Contents/Resources/Tray.rip new file mode 100644 index 00000000..47dc88f2 --- /dev/null +++ b/bin/Rip.app/Contents/Resources/Tray.rip @@ -0,0 +1,282 @@ +#!/usr/bin/env rip + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +fail = (message) -> throw Error.new message + +validateObject = (value, at) -> + fail "#{at} must be an object" unless value? and typeof value is 'object' and not Array.isArray(value) + value + +rejectUnknown = (value, allowed, at) -> + validateObject value, at + fail "unknown #{at} key #{JSON.stringify(key)}" for key of value when not allowed.includes(key) + +validateTitle = (title, at) -> + fail "#{at} title must be a non-empty string" unless typeof title is 'string' and title.length + title + +validateIcon = (icon, at) -> + return null unless icon? + return icon if typeof icon is 'string' and icon.length + validateObject icon, "#{at} icon" + rejectUnknown icon, ['kind', 'source', 'template'], "#{at} icon" + fail "#{at} icon kind must be svg" unless icon.kind is 'svg' + fail "#{at} SVG source must be a non-empty string" unless typeof icon.source is 'string' and icon.source.length + fail "#{at} SVG template must be Boolean" unless typeof icon.template is 'boolean' + { kind: 'svg', source: icon.source, template: icon.template } + +validateId = (id, at) -> + fail "#{at} id must be a non-empty string" unless typeof id is 'string' and id.length + id + +enabled = (value, at) -> + value ??= true + fail "#{at} enabled must be Boolean" unless typeof value is 'boolean' + value + +validateRowHeight = (value, at) -> + return null unless value? and value isnt 'automatic' + fail "#{at} rowHeight must be automatic or a positive number of points" unless typeof value is 'number' and Number.isFinite(value) and value > 0 + value + +export svg = (source, options = {}) -> + rejectUnknown options, ['template'], 'svg' + fail 'svg source must be a non-empty string' unless typeof source is 'string' and source.length + template = options.template ?? true + fail 'svg template must be Boolean' unless typeof template is 'boolean' + { kind: 'svg', source, template } + +export svgFile = (path, options = {}) -> + fail 'svgFile path must be a non-empty string' unless typeof path is 'string' and path.length + svg readFileSync(resolve(path), 'utf8'), options + +export label = (title, options = {}) -> + rejectUnknown options, ['icon', 'subtitle', 'rowHeight'], 'label' + validateTitle title, 'label' + icon = validateIcon options.icon, 'label' + fail 'label subtitle must be a string or null' unless not options.subtitle? or typeof options.subtitle is 'string' + { kind: 'label', title, icon, subtitle: options.subtitle or null, rowHeight: validateRowHeight(options.rowHeight, 'label') } + +export separator = -> { kind: 'separator' } + +callbackItem = (kind, title, options, extra = []) -> + rejectUnknown options, ['id', 'run', 'icon', 'enabled', 'rowHeight', ...extra], kind + validateTitle title, kind + validateId options.id, kind + icon = validateIcon options.icon, kind + fail "#{kind} run must be a function" unless typeof options.run is 'function' + { kind, title, id: options.id, icon, enabled: enabled(options.enabled, kind), rowHeight: validateRowHeight(options.rowHeight, kind), _run: options.run } + +export action = (title, options = {}) -> callbackItem 'action', title, options + +export toggle = (title, options = {}) -> + item = callbackItem 'toggle', title, options, ['value'] + fail 'toggle value must be Boolean' unless typeof options.value is 'boolean' + { ...item, value: options.value } + +export directory = (title, options = {}) -> + item = callbackItem 'directory', title, options, ['prompt'] + fail 'directory prompt must be a string or null' unless not options.prompt? or typeof options.prompt is 'string' + { ...item, prompt: options.prompt or null } + +export link = (title, url, options = {}) -> + rejectUnknown options, ['id', 'icon', 'enabled', 'rowHeight'], 'link' + validateTitle title, 'link' + icon = validateIcon options.icon, 'link' + id = options.id or url + validateId id, 'link' + fail 'link url must be a non-empty string' unless typeof url is 'string' and url.length + { kind: 'link', title, id, url, icon, enabled: enabled(options.enabled, 'link'), rowHeight: validateRowHeight(options.rowHeight, 'link') } + +export submenu = (title, items, options = {}) -> + rejectUnknown options, ['id', 'icon', 'enabled', 'rowHeight'], 'submenu' + validateTitle title, 'submenu' + icon = validateIcon options.icon, 'submenu' + fail 'submenu items must be an array' unless Array.isArray items + { kind: 'submenu', title, id: options.id or title, icon, enabled: enabled(options.enabled, 'submenu'), rowHeight: validateRowHeight(options.rowHeight, 'submenu'), items } + +export quit = (title = 'Quit', options = {}) -> + rejectUnknown options, ['icon', 'rowHeight'], 'quit' + validateTitle title, 'quit' + icon = validateIcon options.icon, 'quit' + { kind: 'quit', title, icon, rowHeight: validateRowHeight(options.rowHeight, 'quit') } + +export tray = (options = {}) -> + rejectUnknown options, ['title', 'icon', 'logo', 'tooltip', 'refresh', 'rowHeight', 'menu'], 'tray' + validateTitle options.title, 'tray' + icon = validateIcon options.icon, 'tray' + logo = validateIcon options.logo, 'tray logo' + fail 'tray tooltip must be a string or null' unless not options.tooltip? or typeof options.tooltip is 'string' + refresh = options.refresh ?? 2 + fail 'tray refresh must be zero or a positive number of seconds' unless typeof refresh is 'number' and Number.isFinite(refresh) and refresh >= 0 + fail 'tray menu must be a function' unless typeof options.menu is 'function' + { title: options.title, icon, logo, tooltip: options.tooltip or null, refresh, rowHeight: validateRowHeight(options.rowHeight, 'tray'), menu: options.menu } + +normalizeItems = (items, actions, at = 'menu') -> + fail "#{at} must return an array" unless Array.isArray items + items.map (item, index) -> + here = "#{at}[#{index}]" + validateObject item, here + switch item.kind + when 'separator' + rejectUnknown item, ['kind'], here + { kind: 'separator' } + when 'label' + rejectUnknown item, ['kind', 'title', 'icon', 'subtitle', 'rowHeight'], here + label item.title, { icon: item.icon, subtitle: item.subtitle, rowHeight: item.rowHeight } + when 'link' + rejectUnknown item, ['kind', 'title', 'id', 'url', 'icon', 'enabled', 'rowHeight'], here + link item.title, item.url, { id: item.id, icon: item.icon, enabled: item.enabled, rowHeight: item.rowHeight } + when 'submenu' + rejectUnknown item, ['kind', 'title', 'id', 'icon', 'enabled', 'rowHeight', 'items'], here + normalized = submenu item.title, normalizeItems(item.items, actions, "#{here}.items"), { id: item.id, icon: item.icon, enabled: item.enabled, rowHeight: item.rowHeight } + normalized + when 'action', 'toggle', 'directory' + allowed = ['kind', 'title', 'id', 'icon', 'enabled', 'rowHeight', '_run'] + allowed.push 'value' if item.kind is 'toggle' + allowed.push 'prompt' if item.kind is 'directory' + rejectUnknown item, allowed, here + validateId item.id, here + fail "duplicate tray action id #{JSON.stringify(item.id)}" if actions.has(item.id) + fail "#{here} callback is missing" unless typeof item._run is 'function' + actions.set item.id, item._run + copy = { kind: item.kind, title: item.title, id: item.id, icon: item.icon, enabled: item.enabled, rowHeight: validateRowHeight(item.rowHeight, here) } + copy.value = item.value if item.kind is 'toggle' + copy.prompt = item.prompt if item.kind is 'directory' + copy + when 'quit' + rejectUnknown item, ['kind', 'title', 'icon', 'rowHeight'], here + quit item.title, { icon: item.icon, rowHeight: item.rowHeight } + else + fail "unknown #{here} kind #{JSON.stringify(item.kind)}" + +export snapshot = (definition) -> + definition = tray definition + actions = Map.new() + items = definition.menu! + { + tray: + title: definition.title + icon: definition.icon + logo: definition.logo + tooltip: definition.tooltip + rowHeight: definition.rowHeight + items: normalizeItems(items, actions) + actions + } + +write = (value) -> process.stdout.write JSON.stringify(value) + '\n' + +export serve = (definition) -> + currentActions = Map.new() + publishing = false + + publish = -> + return if publishing + publishing = true + try + result = snapshot! definition + currentActions = result.actions + write { type: 'render', tray: result.tray } + catch error + write { type: 'error', message: error?.message or String(error) } + finally + publishing = false + + publish! + timer = null + if definition.refresh > 0 + timer = setInterval (-> publish!), definition.refresh * 1000 + + decoder = TextDecoder.new() + buffer = '' + try + for chunk as! Bun.stdin.stream() + buffer += decoder.decode chunk, { stream: true } + while (end = buffer.indexOf('\n')) >= 0 + line = buffer.slice(0, end) + buffer = buffer.slice(end + 1) + continue unless line.trim() + try + message = JSON.parse line + fail 'tray host message must be an action' unless message?.type is 'action' + validateId message.id, 'tray host action' + callback = currentActions.get message.id + fail "unknown tray action #{JSON.stringify(message.id)}" unless callback? + callback! message.value + publish! + catch error + write { type: 'error', message: error?.message or String(error) } + finally + clearInterval timer if timer? + +# Imported, this file is the reusable tray framework. Run directly, the same +# file is the Rip Sites tray backed only by the public `rip sites`/`rip edge` CLI. +ripSites = -> + rip = process.env.RIP_TRAY_RIP or 'rip' + + command = (args) -> + proc = Bun.spawn [rip, ...args], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' } + out = Response.new(proc.stdout).text() + err = Response.new(proc.stderr).text() + code = await proc.exited + out = await out + err = await err + throw Error.new(err.trim() or "rip #{args.join(' ')} exited #{code}") unless code is 0 + out + + json = (...args) -> JSON.parse command! args + capitalize = (text) -> text[0].toUpperCase() + text.slice(1) + + openLog = (site) -> + log = json! 'sites', 'logs', site.name, '--lines', '1', '--json' + result = Bun.spawnSync ['open', log.path], { stdout: 'ignore', stderr: 'pipe' } + throw Error.new(result.stderr.toString().trim() or "cannot open #{log.path}") unless result.exitCode is 0 + + edgeMenu = (edge) -> + items = [ + label "#{capitalize(edge.state)} · #{if edge.managed then 'Rip-owned' else if edge.state is 'running' then 'external' else 'not running'}", + icon: if edge.state is 'running' then 'checkmark.circle.fill' else 'circle' + ] + if edge.state is 'running' + if edge.managed + items.push action 'Reload', id: 'edge.reload', icon: 'arrow.clockwise', run: -> json! 'edge', 'reload', '--json' + items.push action 'Stop', id: 'edge.stop', icon: 'stop.fill', run: -> json! 'edge', 'stop', '--json' + else + items.push action 'Start', id: 'edge.start', icon: 'play.fill', run: -> json! 'edge', 'start', '--json' + submenu 'Edge', items, id: 'edge', icon: 'network' + + siteMenu = (site) -> + running = site.state is 'running' + items = [label site.root, subtitle: site.state] + if running + items.push link 'Open', site.url, id: "#{site.id}.open", icon: 'safari' if site.url? + items.push action 'Stop', id: "#{site.id}.stop", icon: 'stop.fill', run: -> json! 'sites', 'stop', site.id, '--json' + items.push action 'Restart', id: "#{site.id}.restart", icon: 'arrow.clockwise', run: -> json! 'sites', 'restart', site.id, '--json' + else + items.push action 'Start', id: "#{site.id}.start", icon: 'play.fill', run: -> json! 'sites', 'start', site.id, '--json' + items.push action 'Open Log', id: "#{site.id}.logs", icon: 'doc.text', run: -> openLog! site + submenu site.name, items, id: site.id, icon: if running then 'circle.fill' else 'circle' + + definition = tray + title: 'Rip' + icon: svgFile "#{import.meta.dir}/assets/rip-template.svg" + logo: svgFile "#{import.meta.dir}/assets/rip-color.svg", template: false + tooltip: 'Rip Sites' + refresh: 2 + menu: -> + edge = json! 'edge', 'status', '--json' + sites = json! 'sites', 'status', '--json' + items = [edgeMenu(edge), separator()] + items.push siteMenu(site) for site in sites + items.push directory 'Add Site…', id: 'sites.add', icon: 'plus', prompt: 'Add', run: (path) -> json! 'sites', 'add', path, '--json' + items.push separator() + items.push action 'Refresh', id: 'tray.refresh', icon: 'arrow.clockwise', run: -> null + items.push quit 'Quit', icon: 'power' + items + + serve! definition + +ripSites! if import.meta.main diff --git a/bin/Rip.app/Contents/Resources/assets/rip-color.svg b/bin/Rip.app/Contents/Resources/assets/rip-color.svg new file mode 100644 index 00000000..d0973ce9 --- /dev/null +++ b/bin/Rip.app/Contents/Resources/assets/rip-color.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/bin/Rip.app/Contents/Resources/assets/rip-template.svg b/bin/Rip.app/Contents/Resources/assets/rip-template.svg new file mode 100644 index 00000000..7dd57e4f --- /dev/null +++ b/bin/Rip.app/Contents/Resources/assets/rip-template.svg @@ -0,0 +1,4 @@ + + + + diff --git a/bin/Rip.app/Contents/_CodeSignature/CodeResources b/bin/Rip.app/Contents/_CodeSignature/CodeResources new file mode 100644 index 00000000..ef716b09 --- /dev/null +++ b/bin/Rip.app/Contents/_CodeSignature/CodeResources @@ -0,0 +1,194 @@ + + + + + files + + Resources/Tray.rip + + D/0YXfSDtI7fSXag5/s8K74IHuI= + + Resources/assets/rip-color.svg + + En9GHXHBt85yDJaYJ0ccIkUzDIE= + + Resources/assets/rip-template.svg + + AcIFZgK1FBsDfyu1Ve7gPBok98E= + + Resources/node_modules/@rip-lang/tray/assets/rip-color.svg + + En9GHXHBt85yDJaYJ0ccIkUzDIE= + + Resources/node_modules/@rip-lang/tray/assets/rip-template.svg + + AcIFZgK1FBsDfyu1Ve7gPBok98E= + + Resources/node_modules/@rip-lang/tray/package.json + + 7aqP8c2jWk5qSe28JZHgtSryggA= + + Resources/node_modules/@rip-lang/tray/tray.rip + + D/0YXfSDtI7fSXag5/s8K74IHuI= + + + files2 + + Resources/Tray.rip + + hash2 + + jvn8hXYoYHJvZ5Cl/SPvWlaxJLJI2zv9JNblZW0ZM88= + + + Resources/assets/rip-color.svg + + hash2 + + X1YQAnxJj1NmR6kK1IfW5Sq7gB0DOrec+7uPdriBl9U= + + + Resources/assets/rip-template.svg + + hash2 + + GRPch+W7r2GynmcG2HjUlAObdlMBONaZlGCqG/zYWSY= + + + Resources/node_modules/@rip-lang/tray/assets/rip-color.svg + + hash2 + + X1YQAnxJj1NmR6kK1IfW5Sq7gB0DOrec+7uPdriBl9U= + + + Resources/node_modules/@rip-lang/tray/assets/rip-template.svg + + hash2 + + GRPch+W7r2GynmcG2HjUlAObdlMBONaZlGCqG/zYWSY= + + + Resources/node_modules/@rip-lang/tray/package.json + + hash2 + + I9H3xksJaX6/DJsKXD9czAThUgSHn+wVCqwHGmA4XVU= + + + Resources/node_modules/@rip-lang/tray/tray.rip + + hash2 + + jvn8hXYoYHJvZ5Cl/SPvWlaxJLJI2zv9JNblZW0ZM88= + + + + rules + + ^Resources/ + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^version.plist$ + + + rules2 + + .*\.dSYM($|/) + + weight + 11 + + ^(.*/)?\.DS_Store$ + + omit + + weight + 2000 + + ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ + + nested + + weight + 10 + + ^.* + + ^Info\.plist$ + + omit + + weight + 20 + + ^PkgInfo$ + + omit + + weight + 20 + + ^Resources/ + + weight + 20 + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^[^/]+$ + + nested + + weight + 10 + + ^embedded\.provisionprofile$ + + weight + 20 + + ^version\.plist$ + + weight + 20 + + + + diff --git a/bin/rip b/bin/rip index a86264ce..b412ee72 100755 --- a/bin/rip +++ b/bin/rip @@ -32,11 +32,11 @@ Usage: (default: the current directory; 'rip check --help') rip schema [args...] Schema evolution: status | plan | make | migrate (run 'rip schema --help' for the full surface) - rip server [entry] [flags...] Run a @rip-lang/server app under the worker-pool + rip site [entry] [flags...] Run a @rip-lang/sites project under the worker-pool manager behind Janus - rip server browse Publish one directory through Janus browse - (run 'rip server --help' for the flags) - rip app [args...] Remember and control Rip applications + rip site browse Publish one directory through Janus browse + (run 'rip site --help' for the flags) + rip sites [args...] Remember and control Rip Sites (list | add | start | stop | status | logs) rip edge [args...] Control the shared Caddy + Janus edge (status | start | stop | reload) @@ -155,11 +155,11 @@ if (args[0] === 'test') { process.exit(r.status ?? 1); } -// `rip app` and `rip edge` are the two appliance clients shipped by -// @rip-lang/server. They are core surfaces, so dispatch is independent of the +// `rip sites` and `rip edge` are the two appliance clients shipped by +// @rip-lang/sites. They are core surfaces, so dispatch is independent of the // caller's cwd and of package-manager bin-link installation. -if (args[0] === 'app' || args[0] === 'edge') { - const applianceCliPath = join(dirname(fileURLToPath(import.meta.url)), `../packages/server/${args[0]}.rip`); +if (args[0] === 'sites' || args[0] === 'edge') { + const applianceCliPath = join(dirname(fileURLToPath(import.meta.url)), `../packages/sites/${args[0]}.rip`); const r = spawnSync('bun', [`--preload=${loaderPath}`, applianceCliPath, ...args.slice(1)], { stdio: 'inherit', env: process.env, diff --git a/bin/rip-tray b/bin/rip-tray new file mode 100755 index 00000000..0341fd61 --- /dev/null +++ b/bin/rip-tray @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Launch the Rip Sites menu-bar tray via a per-user LaunchAgent. +# Closing this shell (or Cursor) does not stop the tray, edge, or sites. +# +# Expected: +# bin/rip, bin/rip-tray-host, packages/sites/bin/caddy-janus +# Provider (from the checkout): packages/tray/tray.rip +set -euo pipefail + +BIN="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$BIN/.." && pwd)" +HOST="$BIN/rip-tray-host" +RIP="$BIN/rip" +CADDY="$ROOT/packages/sites/bin/caddy-janus" +PROVIDER="$ROOT/packages/tray/tray.rip" +LABEL="io.ripdev.apps.tray" +UID_NUM="$(/usr/bin/id -u)" +DOMAIN="gui/$UID_NUM" +PLIST="$BIN/.rip-tray.plist" +HOME_DIR="${HOME:-$(/usr/bin/dscl . -read "/Users/$(/usr/bin/id -un)" NFSHomeDirectory 2>/dev/null | /usr/bin/awk '{print $2}')}" + +xml() { + # Escape values embedded in the LaunchAgent plist. + printf '%s' "$1" | /usr/bin/sed -e 's/&/\&/g' -e 's//\>/g' -e 's/"/\"/g' +} + +# launchd jobs get a tiny PATH; bin/rip is `#!/usr/bin/env bun`. +tray_path() { + local parts=() + for dir in "$HOME_DIR/.bun/bin" /opt/homebrew/bin /usr/local/bin /usr/bin /bin; do + [[ -d "$dir" ]] && parts+=("$dir") + done + local IFS=: + printf '%s' "${parts[*]}" +} + +usage() { + echo "usage: $(basename "$0") [start|stop|status|help]" +} + +write_plist() { + local path_value home_value + path_value="$(tray_path)" + home_value="$HOME_DIR" + /bin/cat >"$PLIST" < + + + Label$LABEL + ProgramArguments + + $(xml "$HOST") + $(xml "$PROVIDER") + + WorkingDirectory$(xml "$ROOT") + EnvironmentVariables + + HOME$(xml "$home_value") + PATH$(xml "$path_value") + RIP_TRAY_RIP$(xml "$RIP") + JANUS_CADDY$(xml "$CADDY") + + RunAtLoad + KeepAlive + StandardOutPath$(xml "$BIN/.rip-tray.log") + StandardErrorPath$(xml "$BIN/.rip-tray.log") + +EOF +} + +is_loaded() { + /bin/launchctl print "$DOMAIN/$LABEL" >/dev/null 2>&1 +} + +cmd="${1:-start}" +case "$cmd" in + -h|--help|help) + usage + ;; + stop) + if is_loaded; then + /bin/launchctl bootout "$DOMAIN/$LABEL" + echo "Rip tray stopped" + else + # Fall back if someone launched the host outside launchd. + pids="$(/usr/bin/pgrep -f "$HOST" 2>/dev/null || true)" + if [[ -n "$pids" ]]; then + # shellcheck disable=SC2086 + kill $pids 2>/dev/null || true + echo "Rip tray stopped" + else + echo "Rip tray is not running" + fi + fi + ;; + status) + if is_loaded; then + echo "running (launchd $DOMAIN/$LABEL)" + /bin/launchctl print "$DOMAIN/$LABEL" 2>/dev/null | /usr/bin/awk '/pid = |state = / {print}' + exit 0 + fi + pids="$(/usr/bin/pgrep -f "$HOST" 2>/dev/null || true)" + if [[ -n "$pids" ]]; then + echo "running (without launchd)" + # shellcheck disable=SC2086 + ps -p $pids -o pid=,command= + exit 0 + fi + echo "stopped" + exit 1 + ;; + start) + for path in "$HOST" "$RIP" "$CADDY" "$PROVIDER"; do + if [[ ! -e "$path" ]]; then + echo "missing: $path" >&2 + exit 1 + fi + done + if is_loaded; then + /bin/launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true + fi + write_plist + /bin/launchctl bootstrap "$DOMAIN" "$PLIST" + echo "Rip tray started (launchd $LABEL)" + echo " host $HOST" + echo " provider $PROVIDER" + echo " rip $RIP" + echo " caddy $CADDY" + echo " PATH $(tray_path)" + ;; + *) + usage >&2 + exit 1 + ;; +esac diff --git a/bin/rip-tray-host b/bin/rip-tray-host new file mode 100755 index 00000000..ea446bfa Binary files /dev/null and b/bin/rip-tray-host differ diff --git a/bun.lock b/bun.lock index defe185d..4be3ec55 100644 --- a/bun.lock +++ b/bun.lock @@ -102,13 +102,13 @@ "@rip-lang/testing": "workspace:*", }, }, - "packages/server": { - "name": "@rip-lang/server", + "packages/sites": { + "name": "@rip-lang/sites", "version": "4.0.0", "bin": { - "rip-app": "./app.rip", + "rip-site": "./site.rip", + "rip-sites": "./sites.rip", "rip-edge": "./edge.rip", - "rip-server": "./server.rip", }, "dependencies": { "@rip-lang/app": "workspace:*", @@ -231,7 +231,7 @@ "@rip-lang/script": ["@rip-lang/script@workspace:packages/script"], - "@rip-lang/server": ["@rip-lang/server@workspace:packages/server"], + "@rip-lang/sites": ["@rip-lang/sites@workspace:packages/sites"], "@rip-lang/stamp": ["@rip-lang/stamp@workspace:packages/stamp"], diff --git a/docs/FRAME.md b/docs/FRAME.md index 34c7b837..808efaea 100644 --- a/docs/FRAME.md +++ b/docs/FRAME.md @@ -70,7 +70,7 @@ Product promise: Rip v4 establishes `NodeStore`, `RoleStore`, `MappingStore`, and `CodeBuilder`. The app architecture separates stash, source, mutation, renderer, and routes. The server uses a Sinatra-style request-handler -model (`@rip-lang/server`). +model (`@rip-lang/sites`). Rip does not yet define: diff --git a/docs/HMR.md b/docs/HMR.md index eeadb26c..9f095c09 100644 --- a/docs/HMR.md +++ b/docs/HMR.md @@ -38,7 +38,7 @@ reload is a fallback, not the default once framework refresh exists. The publication substrate (Layer A) is defined by [WORKSPACE.md](WORKSPACE.md): initial `bundle.json`, ordered `change {from,hash,list}` messages in watch mode, and reconnect recovery through -`latest.json`. Rip Server produces that protocol and Rip App consumes it. +`latest.json`. Rip Sites produces that protocol and Rip App consumes it. The baseline remounts the affected live route while preserving the App stash and compatible ancestor layouts. Definition patching and signature-aware state migration remain outside this baseline. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b343a49b..f056abdc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -34,9 +34,9 @@ but it must not block direct-path package implementation and tests. - **Server:** the edge belongs to Janus running with Caddy — proxy and stream execution, the TLS story (ACME, certificates, SNI), and WebSocket termination with hub fan-out — so none of that is Rip - Server work; `packages/server` atomically registers files, direct Hub + Sites work; `packages/sites` atomically registers files, direct Hub admission, and API upstreams with the control plane. Remaining in Rip - Server itself (contract in the packages/server README's Planned + Sites itself (contract in the packages/sites README's Planned section): opt-in file logging (a `logs:` knob / `RIP_LOG_DIR` redirect of the merged server stream to `logs/server.log`; stdout stays the default). Process workers, @@ -209,7 +209,7 @@ mapping-safe representation of argument literals. ## Rip Workspace The browser publication-consumption and mutation contract lives in -[WORKSPACE.md](WORKSPACE.md). Rip Server publishes applications; Janus +[WORKSPACE.md](WORKSPACE.md). Rip Sites publishes applications; Janus transports their files and watch-mode changes; Rip App consumes them. The publication substrate and framework-aware apply engine remain separate test boundaries. diff --git a/docs/SERVER.md b/docs/SERVER.md index 4c0de430..9850b530 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -1,6 +1,6 @@ -# Rip Server +# Rip Sites -Rip Server publishes browser applications and runs API workers behind one +Rip Sites publishes browser applications and runs API workers behind one Caddy + Janus edge. The durable boundary is producer versus consumer: ```text @@ -16,7 +16,8 @@ Rip Manager ── publishes application files ──► Caddy / Janus ──► validates browser publications, commits them, announces confirmed changes, builds API generations, and supervises workers. - **Caddy and Janus provide infrastructure.** Caddy owns TLS and HTTP. Janus - owns registration, static files, API proxying, Hub transport, cache + owns registration, static files, API proxying, Hub transport (Bam: WSS at the + edge, optional HTTP bridge to workers, control-plane publish), cache validators, and access streams. Neither interprets Rip source or Rip hashes. - **Workers execute API code.** They do not compile browser Apps or serve App files. @@ -224,10 +225,14 @@ Initial HTTP boot does not require the Hub. ## Non-watch mode -Non-watch mode constructs and publishes the same `bundle.json`, -`bundle.json.br`, and `latest.json`. It creates no App watcher and announces no -publication changes. A Hub used for chat, presence, CRDTs, or collaboration -does not implicitly enable file publication. +App and API watching are independent. `--no-watch` disables both; +`--no-watch-app` / `--no-watch-api` disable one side. With App watching off, +Manager still constructs and publishes the same `bundle.json`, +`bundle.json.br`, and `latest.json`, creates no App watcher, and announces no +publication changes. With API watching off, Manager serves the admitted worker +pool and does not prepare replacement generations from source edits. A Hub used +for chat, presence, CRDTs, or collaboration does not implicitly enable file +publication. ## API generations and workers @@ -281,7 +286,7 @@ control socket. ## Verification ```bash -cd packages/server +cd packages/sites bun run test ``` diff --git a/docs/WORKSPACE.md b/docs/WORKSPACE.md index 9b42ccb2..8b21970b 100644 --- a/docs/WORKSPACE.md +++ b/docs/WORKSPACE.md @@ -205,11 +205,12 @@ available. ## Watch-off and production -Watch-off mode publishes the same initial bundle and latest probe, creates no -App watcher, and announces no file changes. An application may still use the -same Hub for chat, presence, CRDTs, collaborative editing, or other realtime -features. Those messages are application data and do not enable publication -watching. +App watch-off publishes the same initial bundle and latest probe, creates no +App watcher, and announces no file changes. API watch-off leaves admitted +workers in place and does not prepare replacement generations from source +edits. An application may still use the same Hub for chat, presence, CRDTs, +collaborative editing, or other realtime features. Those messages are +application data and do not enable publication watching. Production content uses HTTP. Production does not require source deltas over WSS, a service worker, OPFS, or RipFS. Those storage mechanisms may be added as @@ -227,7 +228,7 @@ optional optimizations without becoming publication authority. | application launch/render behavior from prepared state | Rip App core | | compilation implementation | shared compiler infrastructure | -The ownership test is simple: Rip Server publishes applications; Caddy serves +The ownership test is simple: Rip Sites publishes applications; Caddy serves published files; Janus announces published changes; Rip App consumes published applications. diff --git a/examples/cart/index.rip b/examples/cart/index.rip deleted file mode 100644 index 4239cc26..00000000 --- a/examples/cart/index.rip +++ /dev/null @@ -1,9 +0,0 @@ -import { get, start } from '@rip-lang/server' -import { setup } from './setup.rip' -import './api/index.rip' - -setup! - -get '/styles.css' -> @send "#{import.meta.dir}/app/styles.css", 'text/css; charset=UTF-8' - -start! diff --git a/packages/browser-tests/cart-harness.mjs b/packages/browser-tests/cart-harness.mjs index 6368923a..e04c7923 100644 --- a/packages/browser-tests/cart-harness.mjs +++ b/packages/browser-tests/cart-harness.mjs @@ -1,4 +1,4 @@ -// Cart publication apply: real `rip server` on examples/cart behind a stub +// Cart publication apply: real `rip site` on packages/sites/demos/cart behind a stub // Janus edge. API requests proxy to API-only workers; registered roots serve // App/dist bytes; `/hub` fans out ordered Manager publication changes. import { @@ -11,9 +11,9 @@ import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, '../..'); -const cartSrc = join(root, 'examples/cart'); +const cartSrc = join(root, 'packages/sites/demos/cart'); const loaderPath = join(root, 'src/loader.js'); -const serverBin = join(root, 'packages/server/server.rip'); +const serverBin = join(root, 'packages/sites/site.rip'); const PORT = Number(process.env.CART_HARNESS_PORT || 4174); const fixtureRoot = mkdtempSync(join(tmpdir(), `rip-cart-harness-${process.pid}-`)); @@ -26,9 +26,12 @@ const nm = join(fixtureRoot, 'node_modules', '@rip-lang'); mkdirSync(nm, { recursive: true }); const repoRipLang = join(root, 'node_modules', '@rip-lang'); for (const name of readdirSync(repoRipLang)) { + const target = join(repoRipLang, name); + // Skip dangling workspace leftovers (e.g. a stale `site` link after rename). + if (!existsSync(target)) continue; const link = join(nm, name); try { unlinkSync(link); } catch { /* fresh */ } - symlinkSync(join(repoRipLang, name), link); + symlinkSync(target, link); } const ctlSock = join(fixtureRoot, 'janus.sock'); @@ -51,9 +54,9 @@ const fanoutChange = (body) => { const stub = Bun.serve({ unix: ctlSock, + idleTimeout: 120, fetch: async (rq) => { const url = new URL(rq.url); - if (rq.method === 'GET') return new Response('janus', { status: 404 }); let body = null; if (rq.method === 'POST' || rq.method === 'PUT') { body = await rq.json().catch(() => null); @@ -64,6 +67,9 @@ const stub = Bun.serve({ return Response.json({ id: 'cart-probe' }, { status: 201 }); } if (rq.method === 'PUT' && url.pathname.endsWith('/upstreams')) { + if (registration && body?.upstreams) { + registration = { ...registration, upstreams: body.upstreams }; + } return Response.json({ ok: true }); } if (rq.method === 'POST' && url.pathname.endsWith('/heartbeat')) { @@ -75,17 +81,26 @@ const stub = Bun.serve({ return Response.json({ objects: n, deliveries: n, unknown_targets: 0 }); } if (rq.method === 'DELETE') return new Response(null, { status: 204 }); + // Access-log GETs are not part of this harness; manager is started with + // --access-log=off. Anything else on the control plane is unknown. return new Response('nope', { status: 404 }); }, }); -// Invoke the manager the same way the server suite does: bun + loader + -// server.rip. `rip server` only resolves when cwd can see rip-server on +// Real Janus load-balances across worker upstreams. This stub must too: +// Cart stash fetches user/products/orders in parallel, and each worker +// defaults to concurrency 1. App-only watch matches this suite (it edits +// app/ files) and avoids API-pool swaps that briefly drop every socket. +const WORKERS = 4; + +// Invoke the manager the same way the sites suite does: bun + loader + +// site.rip. `rip site` only resolves when cwd can see rip-site on // PATH (repo root / linked bins) — a /tmp cart copy cannot. const manager = Bun.spawn( [ process.execPath, `--preload=${loaderPath}`, serverBin, - 'index.rip', '--control', ctlSock, '--name', 'cart-probe', '-w', '1', + 'index.rip', '--control', ctlSock, '--name', 'cart-probe', + '-w', String(WORKERS), '--watch-app', '--no-watch-api', '--access-log=off', ], { cwd: cartDir, @@ -100,11 +115,12 @@ const manager = Bun.spawn( RIP_DRAIN_MS: '100', RIP_KILL_MS: '400', RIP_HOLD_MS: '8000', - RIP_BOOT_DEADLINE_MS: '30000', + RIP_BOOT_DEADLINE_MS: process.env.CI ? '55000' : '30000', }, }, ); +const mirrorManager = process.env.CART_HARNESS_LOG || process.env.CI; const drain = async (stream, label) => { if (!stream) return; const reader = stream.getReader(); @@ -113,29 +129,54 @@ const drain = async (stream, label) => { const { done, value } = await reader.read(); if (done) break; const text = dec.decode(value); - if (process.env.CART_HARNESS_LOG) process.stderr.write(`[cart-harness:${label}] ${text}`); + if (mirrorManager) process.stderr.write(`[cart-harness:${label}] ${text}`); } }; drain(manager.stdout, 'out'); drain(manager.stderr, 'err'); -const workerSock = async () => { - const deadline = Date.now() + 30000; +const liveUpstreams = () => { + // Latest upstream PUT wins, including lists that also carry a doorbell — + // skip only the doorbell entries, not the whole PUT. + let upstreams = registration?.upstreams ?? []; + for (let i = calls.length - 1; i >= 0; i--) { + const c = calls[i]; + if (c.method === 'PUT' && Array.isArray(c.body?.upstreams)) { + upstreams = c.body.upstreams; + break; + } + } + return upstreams + .filter((u) => u && !u.doorbell && u.path && existsSync(u.path)) + .map((u) => u.path); +}; + +let rr = 0; +const workerSock = async ({ need = 1 } = {}) => { + // CI runners are slower to spawn the manager + worker pool than a laptop. + const deadline = Date.now() + (process.env.CI ? 55000 : 30000); while (Date.now() < deadline) { - const put = [...calls].reverse().find( - (c) => c.method === 'PUT' && c.body?.upstreams?.length && - !c.body.upstreams.some((u) => u.doorbell), - ); - const upstreams = put?.body?.upstreams ?? registration?.upstreams; - const path = upstreams?.find((upstream) => !upstream.doorbell)?.path; - if (path && existsSync(path)) return path; + if (manager.exitCode != null) { + throw new Error(`cart-harness: manager exited ${manager.exitCode} before registering workers`); + } + const paths = liveUpstreams(); + if (paths.length >= need) { + const path = paths[rr % paths.length]; + rr += 1; + return path; + } await Bun.sleep(25); } - throw new Error('cart-harness: worker socket never registered'); + throw new Error(`cart-harness: only ${liveUpstreams().length}/${need} worker sockets registered`); }; -const initialSock = await workerSock(); +// Wait for the full pool so the first parallel stash fetches do not stampede +// a single upstream before siblings appear. +const initialSock = await workerSock({ need: WORKERS }); writeFileSync(join(fixtureRoot, 'ready'), `${initialSock}\n`); +// Playwright only needs a 2xx on /__test/ready after boot. +let readySock = initialSock; +let booted = true; const proxy = async (request) => { const url = new URL(request.url); @@ -181,6 +222,7 @@ const edgeFile = (pathname, accept) => { const server = Bun.serve({ port: PORT, + idleTimeout: 120, async fetch(request, srv) { const url = new URL(request.url); if (url.pathname === '/hub') { @@ -193,7 +235,15 @@ const server = Bun.serve({ return Response.json({ cartDir, fixtureRoot }); } if (url.pathname === '/__test/ready') { - return Response.json({ ok: true, sock: await workerSock() }); + const paths = liveUpstreams(); + if (paths.length) { + readySock = paths[rr % paths.length]; + return Response.json({ ok: true, sock: readySock, workers: paths.length }); + } + // After first boot, a brief empty pool must not fail Playwright's probe. + if (booted) return Response.json({ ok: true, sock: readySock, workers: 0 }); + readySock = await workerSock({ need: 1 }); + return Response.json({ ok: true, sock: readySock }); } if (registration?.files?.proxy_first?.some(prefix => url.pathname === prefix || url.pathname.startsWith(`${prefix}/`))) { return proxy(request); diff --git a/packages/browser-tests/playwright.config.mjs b/packages/browser-tests/playwright.config.mjs index e23a5db0..5b9ce61c 100644 --- a/packages/browser-tests/playwright.config.mjs +++ b/packages/browser-tests/playwright.config.mjs @@ -13,7 +13,7 @@ const cartServers = [{ command: 'bun cart-harness.mjs', url: `${cartUrl}/__test/ready`, reuseExistingServer: !process.env.CI, - timeout: 60000, + timeout: 90000, env: { ...process.env, CART_HARNESS_PORT: '4174', @@ -39,7 +39,7 @@ const smokeProjects = [ ]; const cartProjects = [{ - // Cart publication apply is chromium-first: real Rip Server. + // Cart publication apply is chromium-first: real Rip Site. name: 'cart-chromium', testMatch: /cart-apply/, use: { diff --git a/packages/browser-tests/serve.mjs b/packages/browser-tests/serve.mjs index f1f3f0d8..e740d250 100644 --- a/packages/browser-tests/serve.mjs +++ b/packages/browser-tests/serve.mjs @@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { assembleRipBundle } from '../server/bundle.rip'; +import { assembleRipBundle } from '../sites/bundle.rip'; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, '../..'); diff --git a/packages/browser-tests/tests/cart-apply.spec.mjs b/packages/browser-tests/tests/cart-apply.spec.mjs index 6395ca94..2f6562dd 100644 --- a/packages/browser-tests/tests/cart-apply.spec.mjs +++ b/packages/browser-tests/tests/cart-apply.spec.mjs @@ -1,4 +1,4 @@ -// Cart exemplar through real Rip Server: disk edits under app/ and Workspace +// Cart exemplar through real Rip Site: disk edits under app/ and Workspace // publication apply. Vue remount floor — layout + // stash survive leaf edits; reload is never the happy path. import { expect, test } from '@playwright/test'; diff --git a/packages/server/app.rip b/packages/server/app.rip deleted file mode 100755 index 6b4d4240..00000000 --- a/packages/server/app.rip +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env rip - -import { printJson, requestAgent } from './appliance.rip' - -throw Error.new('@rip-lang/server/app is a CLI, not a library; run `rip app`') unless import.meta.main - -HELP = ''' - usage: rip app [arguments] - - Commands: - list List remembered Rip apps - add [project] [options] Remember a Rip project (default: cwd) - remove Forget a stopped app - start Start and supervise an app - stop Gracefully stop an app - restart Stop and start an app - status [app] Show one app, or every app - open Open the app in the default browser - logs [--lines N] [--follow] Show the manager log - - Add options: - --name NAME Stable display and manager name - --host HOST Janus host claim (repeatable) - --workers N Worker count (default: 2) - --concurrency N Requests per worker (default: 1) - --no-watch Disable source watching - --eager Boot replacement workers before demand - - Output: - --json Print the complete JSON response -''' - -args = process.argv.slice 2 -if args.length is 0 or args[0] in ['-h', '--help', 'help'] - console.log HELP - exit 0 - -command = args.shift() -json = false -follow = false -lines = 100 -name = null -hosts = null -workers = 2 -concurrency = 1 -watch = true -eager = false -positionals = [] -addFlags = [] -logFlags = [] - -take = (flag) -> - abort "rip-app: #{flag} requires a value" unless args.length - args.shift() - -while args.length - value = args.shift() - switch value - when '--json' then json = true - when '--follow', '-f' - follow = true - logFlags.push value - when '--lines', '-n' - logFlags.push value - text = take value - lines = parseInt text - abort 'rip-app: --lines must be an integer from 1 through 10000' unless Number.isInteger(lines) and lines >= 1 and lines <= 10000 and String(lines) is text - when '--name' - addFlags.push value - name = take value - when '--host' - addFlags.push value - hosts or= [] - hosts.push take(value) - when '--workers' - addFlags.push value - text = take value - workers = parseInt text - abort 'rip-app: --workers must be a positive integer' unless Number.isInteger(workers) and workers > 0 and String(workers) is text - when '--concurrency' - addFlags.push value - text = take value - concurrency = parseInt text - abort 'rip-app: --concurrency must be a positive integer' unless Number.isInteger(concurrency) and concurrency > 0 and String(concurrency) is text - when '--no-watch' - addFlags.push value - watch = false - when '--eager' - addFlags.push value - eager = true - else - abort "rip-app: unknown option #{value}" if value.startsWith '-' - positionals.push value - -selector = -> - abort "rip-app: #{command} requires exactly one app" unless positionals.length is 1 - encodeURIComponent positionals[0] - -abort "rip-app: #{command} does not accept add options: #{addFlags.join(', ')}" if command isnt 'add' and addFlags.length -abort "rip-app: #{command} does not accept log options: #{logFlags.join(', ')}" if command isnt 'logs' and logFlags.length -abort 'rip-app: --follow conflicts with --json' if follow and json - -renderState = (app) -> - marker = if app.state is 'running' then '●' else '○' - url = if app.url then " #{app.url}" else '' - "#{marker} #{app.name} #{app.state}#{url}\n #{app.root}" - -switch command - when 'list' - abort 'rip-app: list takes no app' if positionals.length - apps = requestAgent! 'rip-app', 'GET', '/1.0/apps' - if json then printJson apps else if apps.length then console.log(apps.map(renderState).join('\n')) else console.log 'No Rip apps. Add one with `rip app add [project]`.' - when 'add' - abort 'rip-app: add takes at most one project' if positionals.length > 1 - app = requestAgent! 'rip-app', 'POST', '/1.0/apps', - root: positionals[0] or process.cwd() - name: name - hosts: hosts - options: { workers, concurrency, watch, eager } - if json then printJson app else console.log "Added #{app.name}\n#{app.root}" - when 'remove' - app = requestAgent! 'rip-app', 'DELETE', "/1.0/apps/#{selector!}" - if json then printJson app else console.log "Removed #{app.name}" - when 'start', 'stop', 'restart' - app = requestAgent! 'rip-app', 'POST', "/1.0/apps/#{selector!}/#{command}", {} - if json then printJson app else console.log renderState(app) - when 'status' - abort 'rip-app: status takes at most one app' if positionals.length > 1 - result = if positionals.length then requestAgent!('rip-app', 'GET', "/1.0/apps/#{encodeURIComponent(positionals[0])}") else requestAgent!('rip-app', 'GET', '/1.0/apps') - if json - printJson result - else if Array.isArray result - if result.length then console.log(result.map(renderState).join('\n')) else console.log 'No Rip apps.' - else - console.log renderState(result) - when 'open' - app = requestAgent! 'rip-app', 'GET', "/1.0/apps/#{selector!}" - abort "rip-app: #{app.name} has no URL" unless app.url - result = Bun.spawnSync ['open', app.url], { stdout: 'ignore', stderr: 'pipe' } - abort "rip-app: cannot open #{app.url}: #{result.stderr.toString().trim()}" unless result.exitCode is 0 - printJson({ url: app.url }) if json - when 'logs' - id = selector! - read = (after = null) -> requestAgent! 'rip-app', 'GET', "/1.0/apps/#{id}/logs?#{if after? then "after=#{after}" else "lines=#{lines}"}" - log = read! - if json - printJson log - else - process.stdout.write log.text - if follow - offset = log.next - loop - sleep! 250 - next = read! offset - process.stdout.write next.text if next.text - offset = next.next - else - abort "rip-app: unknown command #{JSON.stringify(command)} — run `rip app --help`" diff --git a/packages/server/test/reloads/project/index.rip b/packages/server/test/reloads/project/index.rip deleted file mode 100644 index fd5b611c..00000000 --- a/packages/server/test/reloads/project/index.rip +++ /dev/null @@ -1,4 +0,0 @@ -import { get, start } from '@rip-lang/server' - -get '/version' -> { version: 'one' } -start! diff --git a/packages/server/test/reloads/test.rip b/packages/server/test/reloads/test.rip deleted file mode 100644 index 9f70be6c..00000000 --- a/packages/server/test/reloads/test.rip +++ /dev/null @@ -1,100 +0,0 @@ -import { test, eq, ok } from '@rip-lang/testing' -import { cpSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' - -PRELOAD = process.execArgv.find (arg) -> arg.startsWith '--preload=' -SERVER = join(import.meta.dir, '..', '..', 'server.rip') - -waitFor = (fn, timeout = 5000) -> - deadline = Date.now() + timeout - loop - value = fn! - return value if value - throw Error.new('reload test timed out') if Date.now() >= deadline - sleep! 10 - -unixFetch = (path, socket) -> - options = { unix: socket } - fetch "http://worker#{path}", options - -test! 'App edits preserve workers and valid API candidates swap through the doorbell', -> - dir = mkdtempSync join(tmpdir(), 'rip-reloads-') - project = join dir, 'project' - cpSync join(import.meta.dir, 'project'), project, { recursive: true } - mkdirSync join(project, 'node_modules', '@rip-lang'), { recursive: true } - symlinkSync join(import.meta.dir, '..', '..'), join(project, 'node_modules', '@rip-lang', 'server'), 'dir' - controlSocket = join dir, 'janus.sock' - registration = null - updates = [] - messages = [] - control = Bun.serve - unix: controlSocket - fetch: (request) -> - url = URL.new request.url - if request.method is 'POST' and url.pathname is '/1.0/apps' - registration = request.json! - return Response.json({ id: 'reload-registration' }, { status: 201 }) - if request.method is 'PUT' and url.pathname is '/1.0/apps/reload-registration/upstreams' - body = request.json! - updates.push body.upstreams - return Response.new(null, { status: 204 }) - if request.method is 'POST' and url.pathname is '/1.0/apps/reload-registration/hub/publish' - messages.push ...(request.json!) - return Response.json({ deliveries: 0 }) - Response.new(null, { status: 204 }) - proc = null - diagnostic = null - try - proc = Bun.spawn [process.execPath, PRELOAD, SERVER, project, '--watch', '--workers', '1', '--host', 'reload.test', '--control', controlSocket, '--access-log=off'], - stdin: 'ignore' - stdout: 'ignore' - stderr: 'pipe' - diagnostic = Response.new(proc.stderr).text() - waitFor! -> registration? - first = registration.upstreams[0].path - eq ((unixFetch!('/version', first)).json!), { version: 'one' } - - writeFileSync join(project, 'app', 'routes', 'index.rip'), """ - export Index = component - render - h1 'Reload two' - """ - waitFor! -> messages.length > 0 - eq updates.length, 0 - eq ((unixFetch!('/version', first)).json!), { version: 'one' } - - writeFileSync join(project, 'index.rip'), 'this is not valid Rip @@@' - sleep! 300 - eq updates.length, 0 - eq ((unixFetch!('/version', first)).json!), { version: 'one' } - - writeFileSync join(project, 'index.rip'), """ - import { get, start } from '@rip-lang/server' - sleep! 1000 - get '/version' -> { version: 'two' } - start! - """ - doorbell = waitFor! -> updates.find((list) -> list.length is 1 and list[0].doorbell is true) - ring = unixFetch '/ring', doorbell[0].path - sleep! 50 - writeFileSync join(project, 'index.rip'), """ - import { get, start } from '@rip-lang/server' - get '/version' -> { version: 'three' } - start! - """ - fresh = waitFor! -> updates.find((list) -> list.length is 1 and not list[0].doorbell and list[0].path isnt first) - eq (await ring).status, 204 - eq ((unixFetch!('/version', fresh[0].path)).json!), { version: 'three' } - - proc.kill 'SIGTERM' - eq (await proc.exited), 143 - output = await diagnostic - ok output.includes 'API candidate rejected; keeping current workers' - ok output.includes 'this is not valid Rip @@@' - finally - try proc?.kill('SIGKILL') - await proc.exited if proc? - await diagnostic if diagnostic? - control.stop true - rmSync dir, { recursive: true, force: true } diff --git a/packages/server/Caddyfile b/packages/sites/Caddyfile similarity index 87% rename from packages/server/Caddyfile rename to packages/sites/Caddyfile index 936e90cb..dba96de0 100644 --- a/packages/server/Caddyfile +++ b/packages/sites/Caddyfile @@ -2,7 +2,7 @@ # loopback. The publicly trusted development-only certificate and key beside # this file give each app clean HTTPS. The edge exposes no public control # listener and names no application. -# `rip app` registers projects through the private Janus socket at runtime. +# `rip sites` registers projects through the private Janus socket at runtime. # On macOS, `rip edge` supplies a launchd-owned loopback socket as fd/3. { admin unix/{$RIP_EDGE_ADMIN} @@ -26,10 +26,12 @@ max_app_share 50 } hub { - mode direct + mode bridge origin same } - files + files { + precompressed + } browse } } diff --git a/packages/server/README.md b/packages/sites/README.md similarity index 95% rename from packages/server/README.md rename to packages/sites/README.md index d7832f40..6deecea9 100644 --- a/packages/server/README.md +++ b/packages/sites/README.md @@ -1,20 +1,19 @@ Rip -# Rip Server - @rip-lang/server +# Rip Sites - @rip-lang/sites -> **Sinatra-style API framework and per-server runtime — concise routes, smart -> responses, validated input, safe hot reload, and disposable Bun workers -> behind Caddy and Janus** +> **Rip Sites — concise routes, smart responses, validated input, safe hot reload, +> and disposable Bun workers behind Caddy and Janus (App + API)** -Rip Server has four surfaces that share one contract: +Rip Sites has four surfaces that share one contract: -- `@rip-lang/server` is the framework API source imports: routes, response +- `@rip-lang/sites` is the framework API source imports: routes, response helpers, `read()` validation, schemas, middleware, sessions, and request context. -- `rip server` is the manager for one project: it registers with Janus, +- `rip site` is the manager for one project: it registers with Janus, publishes browser-App coordination files, watches source, prepares API generations, and supervises worker processes. -- `rip app` remembers projects and controls their managers through the private +- `rip sites` remembers projects and controls their managers through the private per-user Rip Agent. - `rip edge` observes or controls the one shared Caddy+Janus edge. @@ -33,13 +32,13 @@ contract and its implementation lifecycle are specified below under Install the package: ```bash -bun add @rip-lang/server +bun add @rip-lang/sites ``` Create `index.rip`: ```coffee -import { get, post, read, error, prefix, start } from '@rip-lang/server' +import { get, post, read, error, prefix, start } from '@rip-lang/sites' prefix '/api', -> get '/hello' -> { message: 'Hello!' } @@ -59,10 +58,10 @@ start! With Janus running, launch the project: ```bash -rip server . --host hello.ripdev.io --control /run/janus/control.sock +rip site . --host hello.ripdev.io --control /run/janus/control.sock ``` -`rip server` discovers `app.rip`, `index.rip`, or an App-only `app/` +`rip site` discovers `app.rip`, `index.rip`, or an App-only `app/` directory in the selected project or its parents. One invocation creates one manager. Many managers may register independent servers behind the same Caddy+Janus process. @@ -71,9 +70,9 @@ For a remembered, supervised project: ```bash rip edge start --caddy /path/to/janus-enabled-caddy -rip app add . --name hello --host hello.ripdev.io -rip app start hello -rip app open hello +rip sites add . --name hello --host hello.ripdev.io +rip sites start hello +rip sites open hello ``` The packaged edge baseline binds `ripdev.io` and `*.ripdev.io` on loopback @@ -82,7 +81,7 @@ included with this package. Both names resolve only to `127.0.0.1`. `rip edge status` also observes an already-running external edge, but Rip will not stop or reload a process it does not own. -## The Shape of a Server +## The Shape of a Site ```text Caddy + Janus @@ -90,7 +89,7 @@ Caddy + Janus ├── terminates ───────────► Hub WebSockets └── proxies /api ─────────► private worker sockets -one Rip server +one Rip site ├── manager │ ├── registration + heartbeats │ ├── generation + worker supervision @@ -398,10 +397,10 @@ connections lives in [docs/SERVER.md](../../docs/SERVER.md#url-addressable-resou ## Middleware ```coffee -import { use, session } from '@rip-lang/server' +import { use, session } from '@rip-lang/sites' import { cors, csrf, htmlJson, secureHeaders, sessions, timeout -} from '@rip-lang/server/middleware' +} from '@rip-lang/sites/middleware' use secureHeaders! use cors origin: 'https://app.example.com' @@ -512,7 +511,7 @@ consumer and mutation contract. ## Detailed Lifecycle This section is the implementation contract for App publication shared by Rip -Server, Rip App, and the browser runtime. Its central separation is: +Sites, Rip App, and the browser runtime. Its central separation is: ```text HTTP WSS /hub while watching @@ -845,7 +844,7 @@ having an application Hub does not enable one. ### Implementation boundary -Rip Server owns publication construction and commit. Its focused suite pins +Rip Sites owns publication construction and commit. Its focused suite pins source-graph construction, package normalization, exact-path watcher hashing, idempotent-save suppression, the JSON/Brotli pair, matching `latest.json`, watch-off publication, and one batched `change` through real Janus. @@ -916,6 +915,8 @@ policy: ```coffee export default name: 'medlabs' + hub: + bridge: 'hub' app: root: 'app' changes: @@ -992,8 +993,11 @@ omits `files` entirely. The project directory is never an implicit public root. Janus receives one atomic registration containing identity, site or hosts, -normalized file policy, and the initial upstream list. Hub direct mode is -Janus edge policy; it is not a separate manager registration field. +normalized file policy, optional Bam `bridge`, and the initial upstream +list. App-local `hub.bridge: 'hub'` (or `'/hub'`, `'///hub///'`, …) normalizes +to `/hub`; Manager registers that path and ensures it is proxied to workers. +Presence of `hub.bridge` selects Janus bridge mode’s tenant HTTP door; Manager +publication still uses control-plane `hub/publish` and does not ride the bridge. ## API Generation and Hot Reload @@ -1033,12 +1037,12 @@ The manager has three operational states: Control commands find the canonical manager for the project: ```bash -rip server status [project] -rip server stop [project] -rip server hold [project] -rip server release [project] -rip server migrate [models] --dir migrations -rip server recover +rip site status [project] +rip site stop [project] +rip site hold [project] +rip site release [project] +rip site migrate [models] --dir migrations +rip site recover ``` `status` prints the manager's machine-readable JSON state. `stop` asks the @@ -1055,14 +1059,14 @@ auto-starts on demand, stores one durable app catalog, captures manager and edge logs, and adopts healthy managers after an agent restart. ```bash -rip app list -rip app add [project] [--name NAME] [--host HOST] -rip app start -rip app stop -rip app restart -rip app status [app] [--json] -rip app open -rip app logs [--lines N] [--follow] +rip sites list +rip sites add [project] [--name NAME] [--host HOST] +rip sites start +rip sites stop +rip sites restart +rip sites status [site] [--json] +rip sites open +rip sites logs [--lines N] [--follow] rip edge status [--json] rip edge start [--caddy PATH] [--config PATH] @@ -1103,9 +1107,9 @@ changed, activation failure stays in Maintenance for fix-forward recovery. ## CLI ```bash -rip server [project] [options] -rip server browse [--host ] [--control ] -rip server browse [--host ] [--until-restart] +rip site [project] [options] +rip site browse [--host ] [--control ] +rip site browse [--host ] [--until-restart] ``` The project argument may identify an entry or directory. Discovery searches @@ -1115,8 +1119,11 @@ for `app.rip`, `index.rip`, or `app/`. --name registration name --host exact public host; repeatable -w, --workers worker processes (default 2) --c, --concurrency requests per worker (default 1; >1 requires watch off) ---watch / --no-watch enable or disable watching +-c, --concurrency requests per worker (default 1; >1 requires API watch off) +--watch / --no-watch enable or disable App and API watching together +--watch-app / --no-watch-app +--watch-api / --no-watch-api + enable or disable App publication or API worker watching --allow-watch required to watch under RIP_ENV=production --eager boot after settle instead of waiting for a ring --control Janus unix socket or HTTP(S) control endpoint @@ -1128,7 +1135,7 @@ for `app.rip`, `index.rip`, or `app/`. `JANUS_CONTROL` supplies the control endpoint when `--control` is omitted. Startup validates the endpoint before claiming the server. -`rip server browse` resolves and publishes exactly the named directory as one +`rip site browse` resolves and publishes exactly the named directory as one `revalidate` browsable root. It publishes no worker upstreams and no SPA shell. By default it chooses `browse-.localhost`, prints `https:///`, heartbeats while running, and deletes the registration on @@ -1288,7 +1295,7 @@ bun run test:appliance bun run test:janus ``` -`test:janus` builds and caches a Caddy binary with released Janus `v1.5.0`; +`test:janus` builds and caches a Caddy binary with released Janus `v1.6.1`; `JANUS_CADDY` can override that binary. `bun run test` discovers and runs every `test/*/test.rip` fixture. diff --git a/packages/server/TODO.md b/packages/sites/TODO.md similarity index 86% rename from packages/server/TODO.md rename to packages/sites/TODO.md index 37abdcd8..13db94bc 100644 --- a/packages/server/TODO.md +++ b/packages/sites/TODO.md @@ -1,13 +1,10 @@ -# TODO — Rip Server +# TODO — Rip Sites Open work only. Remove an item when it lands; commits and test fixtures retain completed history. ## 1. Finish edge policy -- [ ] Add Janus precompressed-sidecar negotiation for `.br`, `.zst`, and - `.gz` representations of the canonical registered file, preserving root - priority, validators, ranges, MIME type, and `Vary: Accept-Encoding`. - [ ] Pin one Caddy compression policy: encodings, minimum size, MIME exclusions, streaming bodies, pre-encoded responses, and `Cache-Control: no-transform`. @@ -23,8 +20,8 @@ retain completed history. ## 2. Finish manager policy -- [ ] Make watching the default, remove `--watch`, and retain `--no-watch` as - the explicit opt-out. +- [ ] Make watching the default, remove `--watch`, keep `--no-watch` / + `--no-watch-app` / `--no-watch-api` as explicit opt-outs. - [ ] Make configured symlinked and workspace dependencies participate in API invalidation. - [ ] Bound heartbeat and re-registration requests so a stalled control plane diff --git a/packages/server/agent.rip b/packages/sites/agent.rip similarity index 89% rename from packages/server/agent.rip rename to packages/sites/agent.rip index 941a2d4a..cb76f01a 100644 --- a/packages/server/agent.rip +++ b/packages/sites/agent.rip @@ -6,7 +6,7 @@ import { platform } from 'node:os' import { agentPaths, loaderArg, secureDirectory } from './appliance.rip' import { managerRequest, managerRuntime, resolveProject, waitManagerStopped } from './control.rip' -throw Error.new('@rip-lang/server/agent is private process machinery') unless import.meta.main +throw Error.new('@rip-lang/sites/agent is private process machinery') unless import.meta.main paths = agentPaths 'rip-agent' secureDirectory paths.appLogs, 'rip-agent' @@ -53,7 +53,7 @@ validateState = (state) -> names = Set.new() roots = Set.new() for app in state.apps - valid = isRecord(app) and /^[a-f0-9]{16}$/.test(app.id) and typeof app.name is 'string' and typeof app.root is 'string' and Array.isArray(app.hosts) and app.hosts.every((host) -> typeof host is 'string') and isRecord(app.options) and Number.isInteger(app.options.workers) and app.options.workers > 0 and Number.isInteger(app.options.concurrency) and app.options.concurrency > 0 and typeof app.options.watch is 'boolean' and typeof app.options.eager is 'boolean' and app.desired in ['running', 'stopped'] + valid = isRecord(app) and /^[a-f0-9]{16}$/.test(app.id) and typeof app.name is 'string' and typeof app.root is 'string' and Array.isArray(app.hosts) and app.hosts.every((host) -> typeof host is 'string') and isRecord(app.options) and Number.isInteger(app.options.workers) and app.options.workers > 0 and Number.isInteger(app.options.concurrency) and app.options.concurrency > 0 and typeof app.options.watchApp is 'boolean' and typeof app.options.watchApi is 'boolean' and typeof app.options.eager is 'boolean' and app.desired in ['running', 'stopped'] fail("malformed app record at #{paths.state}", 500) unless valid fail("duplicate app record at #{paths.state}", 500) if ids.has(app.id) or names.has(app.name) or roots.has(app.root) ids.add app.id @@ -64,16 +64,42 @@ validateState = (state) -> fail("malformed edge record at #{paths.state}", 500) unless valid state +normalizeAppOptions = (options) -> + fail 'app options must be an object', 500 unless isRecord options + watchApp = options.watchApp + watchApi = options.watchApi + if typeof options.watch is 'boolean' + watchApp = options.watch unless typeof watchApp is 'boolean' + watchApi = options.watch unless typeof watchApi is 'boolean' + { + workers: options.workers + concurrency: options.concurrency + watchApp: watchApp + watchApi: watchApi + eager: options.eager + } + +migrateState = (raw) -> + return { state: raw, dirty: false } unless isRecord(raw) and Array.isArray(raw.apps) + changed = false + apps = raw.apps.map (app) -> + return app unless isRecord(app) and isRecord(app.options) + options = normalizeAppOptions app.options + return app if typeof options.watchApp is 'boolean' and typeof options.watchApi is 'boolean' and not ('watch' of app.options) + changed = true + { ...app, options } + return { state: raw, dirty: false } unless changed + { state: { ...raw, apps }, dirty: true } + loadState = -> - return defaultState() unless existsSync paths.state + return { state: defaultState(), dirty: false } unless existsSync paths.state try - validateState JSON.parse(readFileSync(paths.state, 'utf8')) + migrated = migrateState JSON.parse(readFileSync(paths.state, 'utf8')) + { state: validateState(migrated.state), dirty: migrated.dirty } catch error throw error if error?.status? fail "cannot read state at #{paths.state}: #{error?.message or error}", 500 -state = loadState() - writeSecure = (path, text) -> temporary = "#{path}.tmp-#{process.pid}" fd = openSync temporary, 'w', 0o600 @@ -87,6 +113,9 @@ writeSecure = (path, text) -> try fsyncSync(directory) finally closeSync(directory) saveState = -> writeSecure paths.state, JSON.stringify(state, null, 2) + '\n' +loaded = loadState() +state = loaded.state +saveState() if loaded.dirty tail = (path, lines = 100) -> return '' unless existsSync path @@ -173,7 +202,7 @@ validName = (name) -> typeof name is 'string' and /^[a-z0-9](?:[a-z0-9._-]*[a-z0 addApp = (body) -> rejectUnknown body, ['root', 'name', 'hosts', 'options'], 'app add body' - project = resolveProject body.root, 'rip-app' + project = resolveProject body.root, 'rip-sites' fail "project is already registered: #{project.root}", 409 if state.apps.some((app) -> app.root is project.root) name = body.name or basename(project.root).replace(/\.[^.]+$/, '') fail "invalid app name #{JSON.stringify(name)}" unless validName name @@ -185,9 +214,13 @@ addApp = (body) -> fail "invalid host #{JSON.stringify(host)}" for host in hosts when not validHost(host) fail 'hosts must not contain duplicates' unless Set.new(hosts).size is hosts.length options = body.options or {} - rejectUnknown options, ['workers', 'concurrency', 'watch', 'eager'], 'app options' + rejectUnknown options, ['workers', 'concurrency', 'watch', 'watchApp', 'watchApi', 'eager'], 'app options' fail 'watch must be Boolean' if options.watch? and typeof options.watch isnt 'boolean' + fail 'watchApp must be Boolean' if options.watchApp? and typeof options.watchApp isnt 'boolean' + fail 'watchApi must be Boolean' if options.watchApi? and typeof options.watchApi isnt 'boolean' fail 'eager must be Boolean' if options.eager? and typeof options.eager isnt 'boolean' + watchApp = if options.watchApp? then options.watchApp else if options.watch? then options.watch else true + watchApi = if options.watchApi? then options.watchApi else if options.watch? then options.watch else true app = id: randomBytes(8).toString('hex') name: name @@ -196,7 +229,8 @@ addApp = (body) -> options: workers: options.workers ?? 2 concurrency: options.concurrency ?? 1 - watch: options.watch isnt false + watchApp: watchApp + watchApi: watchApi eager: options.eager is true desired: 'stopped' addedAt: Date.new().toISOString() @@ -209,9 +243,15 @@ addApp = (body) -> snapshotApp! app serverArgv = (app) -> - argv = [process.execPath, loaderArg('rip-agent'), join(import.meta.dir, 'server.rip'), app.root, '--name', app.name, '--control', state.edge.control, '--access-log=off', '--workers', String(app.options.workers), '--concurrency', String(app.options.concurrency)] + argv = [process.execPath, loaderArg('rip-agent'), join(import.meta.dir, 'site.rip'), app.root, '--name', app.name, '--control', state.edge.control, '--access-log=off', '--workers', String(app.options.workers), '--concurrency', String(app.options.concurrency)] argv.push '--host', host for host in app.hosts - argv.push if app.options.watch then '--watch' else '--no-watch' + if app.options.watchApp and app.options.watchApi + argv.push '--watch' + else if not app.options.watchApp and not app.options.watchApi + argv.push '--no-watch' + else + argv.push if app.options.watchApp then '--watch-app' else '--no-watch-app' + argv.push if app.options.watchApi then '--watch-api' else '--no-watch-api' argv.push '--eager' if app.options.eager argv @@ -411,7 +451,8 @@ startEdge = (body = {}, automatic = false) -> fail 'baseUrl must be a string containing {host}' if body.baseUrl? and (typeof body.baseUrl isnt 'string' or not body.baseUrl.includes('{host}')) current = snapshotEdge! return current if current.state is 'running' - caddyInput = body.caddy or state.edge.caddy or process.env.JANUS_CADDY or Bun.which('caddy') + packagedCaddy = join(import.meta.dir, 'bin', 'caddy-janus') + caddyInput = body.caddy or state.edge.caddy or process.env.JANUS_CADDY or (existsSync(packagedCaddy) and packagedCaddy) or Bun.which('caddy') fail 'no Janus-enabled Caddy binary — pass --caddy PATH or set JANUS_CADDY', 503 unless caddyInput? caddy = resolveFile caddyInput, 'Caddy binary' configInput = body.config or state.edge.config or join(import.meta.dir, 'Caddyfile') diff --git a/packages/server/appliance.rip b/packages/sites/appliance.rip similarity index 100% rename from packages/server/appliance.rip rename to packages/sites/appliance.rip diff --git a/packages/sites/bin/caddy-janus b/packages/sites/bin/caddy-janus new file mode 100755 index 00000000..8907b2eb Binary files /dev/null and b/packages/sites/bin/caddy-janus differ diff --git a/packages/server/bundle.rip b/packages/sites/bundle.rip similarity index 100% rename from packages/server/bundle.rip rename to packages/sites/bundle.rip diff --git a/packages/server/control.rip b/packages/sites/control.rip similarity index 93% rename from packages/server/control.rip rename to packages/sites/control.rip index 45ce833a..6d4d11a7 100644 --- a/packages/server/control.rip +++ b/packages/sites/control.rip @@ -3,10 +3,10 @@ import { dirname, join, resolve } from 'node:path' import { tmpdir } from 'node:os' # Project identity and private manager discovery are shared by the direct -# `rip server` surface and the per-user appliance. One canonical root must +# `rip site` surface and the per-user appliance. One canonical root must # always select one manager socket, regardless of which client asks. -export resolveProject = (input, label = 'rip-server') -> +export resolveProject = (input, label = 'rip-site') -> if input? given = resolve input abort "#{label}: project not found: #{input}" unless existsSync given @@ -37,7 +37,7 @@ export resolveProject = (input, label = 'rip-server') -> abort "#{label}: #{input} has no API entry, app/ directory, or serve.rip" abort "#{label}: no App — pass a project, or create app.rip, index.rip, app/, or serve.rip here or above" -export managerRuntime = (root, label = 'rip-server') -> +export managerRuntime = (root, label = 'rip-site') -> abort "#{label}: manager control requires a POSIX user identity" unless typeof process.getuid is 'function' uid = process.getuid() key = Bun.CryptoHasher.new('sha256').update(root).digest('hex').slice 0, 16 diff --git a/examples/cart/.gitignore b/packages/sites/demos/cart/.gitignore similarity index 100% rename from examples/cart/.gitignore rename to packages/sites/demos/cart/.gitignore diff --git a/examples/cart/README.md b/packages/sites/demos/cart/README.md similarity index 93% rename from examples/cart/README.md rename to packages/sites/demos/cart/README.md index 46790997..92497d8f 100644 --- a/examples/cart/README.md +++ b/packages/sites/demos/cart/README.md @@ -6,7 +6,7 @@ canonical full-shape exemplar beside Pulse’s thin live-publication demo ([docs/WORKSPACE.md](../../docs/WORKSPACE.md)). It is a runnable example; the browser suite also drives its publication apply -path through a real Rip Server. +path through a real Rip Site. ## Layout @@ -30,8 +30,8 @@ Persistence starts on **bun:sqlite**. A follow-up swaps `api/db.rip` to With a Janus control endpoint running: ```bash -cd examples/cart -rip server index.rip --name cart +cd packages/sites/demos/cart +rip site index.rip --name cart ``` Open the registered site. Seeded products render; add to cart, place an diff --git a/examples/cart/api/db.rip b/packages/sites/demos/cart/api/db.rip similarity index 98% rename from examples/cart/api/db.rip rename to packages/sites/demos/cart/api/db.rip index a1750313..414b3ce8 100644 --- a/examples/cart/api/db.rip +++ b/packages/sites/demos/cart/api/db.rip @@ -6,7 +6,7 @@ # so its top level does only the cheap wiring: open the file and route the # schema ORM through it. The one-time schema creation lives in `migrate` and the # data seeding in `./seed.rip` — both run by ../setup.rip once at startup under -# `rip server`, never on reload. +# `rip site`, never on reload. import { Database } from 'bun:sqlite' import { User, Product, Order } from './models.rip' diff --git a/examples/cart/api/index.rip b/packages/sites/demos/cart/api/index.rip similarity index 87% rename from examples/cart/api/index.rip rename to packages/sites/demos/cart/api/index.rip index b4d1b06b..5f95c461 100644 --- a/examples/cart/api/index.rip +++ b/packages/sites/demos/cart/api/index.rip @@ -1,12 +1,18 @@ import './db.rip' -import { get, post, patch, read } from '@rip-lang/server' +import { get, post, patch, read, session } from '@rip-lang/sites' import { User, Product, Order, OrderItem, OrderLine, UserPublic, ProductPublic, OrderPublic } from './models.rip' def errorResponse(errors: { form?: string, field?: Partial> }, status = 400) return Response.json({ errors }, { status }) -# No auth in this demo — the "current user" is always the seeded user (id 1). -currentUser = () -> User.find!(1) +# Demo identity: the seeded user. Mark the session authenticated so the +# Bam Hub bridge (POST /hub) admits the same cookie the API already uses. +currentUser = () -> + user = User.find!(1) + if user? + session.authenticated = true + session.userId = user.id + user get '/api/user' -> user = currentUser! diff --git a/examples/cart/api/models.rip b/packages/sites/demos/cart/api/models.rip similarity index 100% rename from examples/cart/api/models.rip rename to packages/sites/demos/cart/api/models.rip diff --git a/examples/cart/api/seed.rip b/packages/sites/demos/cart/api/seed.rip similarity index 100% rename from examples/cart/api/seed.rip rename to packages/sites/demos/cart/api/seed.rip diff --git a/examples/cart/app/api-client.rip b/packages/sites/demos/cart/app/api-client.rip similarity index 100% rename from examples/cart/app/api-client.rip rename to packages/sites/demos/cart/app/api-client.rip diff --git a/examples/cart/app/components/button.rip b/packages/sites/demos/cart/app/components/button.rip similarity index 100% rename from examples/cart/app/components/button.rip rename to packages/sites/demos/cart/app/components/button.rip diff --git a/examples/cart/app/config.rip b/packages/sites/demos/cart/app/config.rip similarity index 100% rename from examples/cart/app/config.rip rename to packages/sites/demos/cart/app/config.rip diff --git a/examples/cart/app/index.html b/packages/sites/demos/cart/app/index.html similarity index 86% rename from examples/cart/app/index.html rename to packages/sites/demos/cart/app/index.html index b0ec794f..73c04832 100644 --- a/examples/cart/app/index.html +++ b/packages/sites/demos/cart/app/index.html @@ -23,6 +23,8 @@ diff --git a/examples/cart/app/routes/[...rest].rip b/packages/sites/demos/cart/app/routes/[...rest].rip similarity index 100% rename from examples/cart/app/routes/[...rest].rip rename to packages/sites/demos/cart/app/routes/[...rest].rip diff --git a/examples/cart/app/routes/_layout.rip b/packages/sites/demos/cart/app/routes/_layout.rip similarity index 100% rename from examples/cart/app/routes/_layout.rip rename to packages/sites/demos/cart/app/routes/_layout.rip diff --git a/examples/cart/app/routes/cart.rip b/packages/sites/demos/cart/app/routes/cart.rip similarity index 100% rename from examples/cart/app/routes/cart.rip rename to packages/sites/demos/cart/app/routes/cart.rip diff --git a/examples/cart/app/routes/index.rip b/packages/sites/demos/cart/app/routes/index.rip similarity index 100% rename from examples/cart/app/routes/index.rip rename to packages/sites/demos/cart/app/routes/index.rip diff --git a/examples/cart/app/routes/orders/[id].rip b/packages/sites/demos/cart/app/routes/orders/[id].rip similarity index 100% rename from examples/cart/app/routes/orders/[id].rip rename to packages/sites/demos/cart/app/routes/orders/[id].rip diff --git a/examples/cart/app/routes/orders/index.rip b/packages/sites/demos/cart/app/routes/orders/index.rip similarity index 100% rename from examples/cart/app/routes/orders/index.rip rename to packages/sites/demos/cart/app/routes/orders/index.rip diff --git a/examples/cart/app/routes/profile.rip b/packages/sites/demos/cart/app/routes/profile.rip similarity index 100% rename from examples/cart/app/routes/profile.rip rename to packages/sites/demos/cart/app/routes/profile.rip diff --git a/examples/cart/app/stash.rip b/packages/sites/demos/cart/app/stash.rip similarity index 100% rename from examples/cart/app/stash.rip rename to packages/sites/demos/cart/app/stash.rip diff --git a/examples/cart/app/styles.css b/packages/sites/demos/cart/app/styles.css similarity index 100% rename from examples/cart/app/styles.css rename to packages/sites/demos/cart/app/styles.css diff --git a/packages/sites/demos/cart/index.rip b/packages/sites/demos/cart/index.rip new file mode 100644 index 00000000..f9165440 --- /dev/null +++ b/packages/sites/demos/cart/index.rip @@ -0,0 +1,24 @@ +# Cart — a multi-route shop proving the full app rails: index.rip + +# app/ + api/ + SQLite. See README.md for the run script. +import { get, start, use, hub } from '@rip-lang/sites' +import { sessions } from '@rip-lang/sites/middleware' +import { setup } from './setup.rip' +import './api/index.rip' + +setup! + +use sessions + secret: process.env.CART_SESSION_SECRET or 'cart-dev-session-secret' + secure: true + sameSite: 'Lax' + +# Bam bridge at POST /hub — Janus open/text/close; session cookie required. +hub + bridge: 'hub' + open: -> + # Publication feed joins /hub after admit; app events use other channels. + { '+': ['/hub'] } + +get '/styles.css' -> @send "#{import.meta.dir}/app/styles.css", 'text/css; charset=UTF-8' + +start! diff --git a/packages/sites/demos/cart/serve.rip b/packages/sites/demos/cart/serve.rip new file mode 100644 index 00000000..bedbffc9 --- /dev/null +++ b/packages/sites/demos/cart/serve.rip @@ -0,0 +1,7 @@ +export default + name: 'cart' + hosts: ['cart.ripdev.io'] + hub: + bridge: 'hub' + app: + root: 'app' diff --git a/examples/cart/setup.rip b/packages/sites/demos/cart/setup.rip similarity index 100% rename from examples/cart/setup.rip rename to packages/sites/demos/cart/setup.rip diff --git a/examples/hello/.gitignore b/packages/sites/demos/hello/.gitignore similarity index 100% rename from examples/hello/.gitignore rename to packages/sites/demos/hello/.gitignore diff --git a/examples/hello/Caddyfile b/packages/sites/demos/hello/Caddyfile similarity index 100% rename from examples/hello/Caddyfile rename to packages/sites/demos/hello/Caddyfile diff --git a/examples/hello/README.md b/packages/sites/demos/hello/README.md similarity index 95% rename from examples/hello/README.md rename to packages/sites/demos/hello/README.md index 7a8f8714..4bc2794f 100644 --- a/examples/hello/README.md +++ b/packages/sites/demos/hello/README.md @@ -9,15 +9,15 @@ details live in [`docs/WORKSPACE.md`](../../docs/WORKSPACE.md). In the Rip tray, choose **Add App…**, then select this project directory: ```text -examples/hello +packages/sites/demos/hello ``` Select the directory containing `serve.rip`, not `app/` and not the `serve.rip` file itself. The picker runs the equivalent of: ```sh -rip app add examples/hello -rip app start hello +rip sites add packages/sites/demos/hello +rip sites start hello ``` `serve.rip` declares the `hello` name, `hello.ripdev.io` host, conventional diff --git a/examples/hello/app/images/rip.png b/packages/sites/demos/hello/app/images/rip.png similarity index 100% rename from examples/hello/app/images/rip.png rename to packages/sites/demos/hello/app/images/rip.png diff --git a/examples/hello/app/index.html b/packages/sites/demos/hello/app/index.html similarity index 100% rename from examples/hello/app/index.html rename to packages/sites/demos/hello/app/index.html diff --git a/examples/hello/app/routes/index.rip b/packages/sites/demos/hello/app/routes/index.rip similarity index 100% rename from examples/hello/app/routes/index.rip rename to packages/sites/demos/hello/app/routes/index.rip diff --git a/examples/hello/app/styles.css b/packages/sites/demos/hello/app/styles.css similarity index 100% rename from examples/hello/app/styles.css rename to packages/sites/demos/hello/app/styles.css diff --git a/examples/hello/app/template.html b/packages/sites/demos/hello/app/template.html similarity index 100% rename from examples/hello/app/template.html rename to packages/sites/demos/hello/app/template.html diff --git a/examples/hello/serve.rip b/packages/sites/demos/hello/serve.rip similarity index 100% rename from examples/hello/serve.rip rename to packages/sites/demos/hello/serve.rip diff --git a/examples/pulse/.gitignore b/packages/sites/demos/pulse/.gitignore similarity index 100% rename from examples/pulse/.gitignore rename to packages/sites/demos/pulse/.gitignore diff --git a/examples/pulse/README.md b/packages/sites/demos/pulse/README.md similarity index 94% rename from examples/pulse/README.md rename to packages/sites/demos/pulse/README.md index 8ece3615..5046c9f9 100644 --- a/examples/pulse/README.md +++ b/packages/sites/demos/pulse/README.md @@ -1,7 +1,7 @@ # Pulse A tiny status board that proves the whole stack end to end: Janus serves -the browser App, Rip Server handles a small API, and saving a component +the browser App, Rip Sites handles a small API, and saving a component file updates the running page through the Workspace publication feed. It is a runnable example and a demo script — not a test suite. Nothing @@ -41,8 +41,8 @@ With a Janus control endpoint running (`--control ` or the `JANUS_CONTROL` env var): ```bash -cd examples/pulse -rip server index.rip --name pulse +cd packages/sites/demos/pulse +rip site index.rip --name pulse ``` The manager registers `pulse` with Janus, publishes the browser App, @@ -56,8 +56,8 @@ and owns the Hub directly. Same as leg 2, with the flag in the manager's environment: ```bash -cd examples/pulse -rip server index.rip --name pulse +cd packages/sites/demos/pulse +rip site index.rip --name pulse ``` Open the page, then edit `app/mood.rip`: change the `up` label diff --git a/examples/pulse/app/index.html b/packages/sites/demos/pulse/app/index.html similarity index 100% rename from examples/pulse/app/index.html rename to packages/sites/demos/pulse/app/index.html diff --git a/examples/pulse/app/mood.rip b/packages/sites/demos/pulse/app/mood.rip similarity index 100% rename from examples/pulse/app/mood.rip rename to packages/sites/demos/pulse/app/mood.rip diff --git a/examples/pulse/app/routes/index.rip b/packages/sites/demos/pulse/app/routes/index.rip similarity index 100% rename from examples/pulse/app/routes/index.rip rename to packages/sites/demos/pulse/app/routes/index.rip diff --git a/examples/pulse/app/stash.rip b/packages/sites/demos/pulse/app/stash.rip similarity index 100% rename from examples/pulse/app/stash.rip rename to packages/sites/demos/pulse/app/stash.rip diff --git a/examples/pulse/index.rip b/packages/sites/demos/pulse/index.rip similarity index 82% rename from examples/pulse/index.rip rename to packages/sites/demos/pulse/index.rip index aef9b3bb..ebceca7d 100644 --- a/examples/pulse/index.rip +++ b/packages/sites/demos/pulse/index.rip @@ -1,7 +1,7 @@ # Pulse — a tiny status board proving the stack end to end: Janus serves -# the browser App while Rip Server handles this API. See README.md for the +# the browser App while Rip Sites handles this API. See README.md for the # demo script. -import { get, post, read, error, start } from '@rip-lang/server' +import { get, post, read, error, start } from '@rip-lang/sites' MOODS = ['up', 'even', 'down'] diff --git a/packages/server/edge.rip b/packages/sites/edge.rip similarity index 71% rename from packages/server/edge.rip rename to packages/sites/edge.rip index 3642b650..b0cdf7c9 100755 --- a/packages/server/edge.rip +++ b/packages/sites/edge.rip @@ -2,25 +2,25 @@ import { printJson, requestAgent } from './appliance.rip' -throw Error.new('@rip-lang/server/edge is a CLI, not a library; run `rip edge`') unless import.meta.main +throw Error.new('@rip-lang/sites/edge is a CLI, not a library; run `rip edge`') unless import.meta.main HELP = ''' usage: rip edge [options] Commands: - status Show Caddy and Janus health - start [options] Start the shared Caddy+Janus edge - stop Gracefully stop a Rip-owned edge - reload Reload a Rip-owned Caddyfile + status Show Caddy and Janus health + start [options] Start the shared Caddy+Janus edge + stop Gracefully stop a Rip-owned edge + reload Reload a Rip-owned Caddyfile Start options: - --caddy PATH Janus-enabled Caddy binary - --config PATH Caddyfile (default: Rip baseline) - --control TARGET Janus HTTP URL or Unix socket - --base-url TEMPLATE App URL, containing {host} + --caddy PATH Janus-enabled Caddy binary + --config PATH Caddyfile (default: Rip baseline) + --control TARGET Janus HTTP URL or Unix socket + --base-url TEMPLATE App URL, containing {host} Output: - --json Print the complete JSON response + --json Print the complete JSON response An already-running external edge is reported but never stopped or reloaded. ''' diff --git a/packages/server/generate.rip b/packages/sites/generate.rip similarity index 90% rename from packages/server/generate.rip rename to packages/sites/generate.rip index 9135d4a7..3071de73 100644 --- a/packages/server/generate.rip +++ b/packages/sites/generate.rip @@ -2,7 +2,7 @@ import { readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' preload = process.execArgv.find((arg) -> arg.startsWith('--preload='))?.slice('--preload='.length) -abort 'rip-server generate: the Rip loader is missing' unless preload? +abort 'rip-site generate: the Rip loader is missing' unless preload? compiler = import!(join(dirname(resolve(preload)), 'compile.js')).compile rewriteMeta = (code, path) -> @@ -56,10 +56,10 @@ buildArtifact = (entry, artifact) -> [entry, artifact, resultPath, taskId, epochText] = process.argv.slice 2 unless entry? and artifact? and resultPath? and taskId? and epochText? - abort 'rip-server generate: want ' + abort 'rip-site generate: want ' sourceEpoch = parseInt epochText -abort "rip-server generate: invalid source epoch #{epochText}" unless Number.isFinite(sourceEpoch) and sourceEpoch >= 0 +abort "rip-site generate: invalid source epoch #{epochText}" unless Number.isFinite(sourceEpoch) and sourceEpoch >= 0 try buildArtifact! entry, artifact diff --git a/packages/server/launchd-listener.js b/packages/sites/launchd-listener.js similarity index 100% rename from packages/server/launchd-listener.js rename to packages/sites/launchd-listener.js diff --git a/packages/server/manager.rip b/packages/sites/manager.rip similarity index 84% rename from packages/server/manager.rip rename to packages/sites/manager.rip index d6756ee8..4067bfb9 100644 --- a/packages/server/manager.rip +++ b/packages/sites/manager.rip @@ -10,7 +10,7 @@ import { managerRequest, managerRuntime, resolveProject, waitManagerStopped } fr positiveEnv = (name, fallback) -> text = process.env[name] or String(fallback) value = parseInt text - abort "rip-server: #{name} must be an integer greater than zero" unless Number.isFinite(value) and value > 0 and String(value) is text + abort "rip-site: #{name} must be an integer greater than zero" unless Number.isFinite(value) and value > 0 and String(value) is text value HEARTBEAT_MS = positiveEnv 'RIP_HEARTBEAT_MS', 5000 @@ -22,17 +22,17 @@ REGISTER_409_MS = positiveEnv 'RIP_REGISTER_409_MS', 30000 parseArgs = (argv) -> command = if ['browse', 'status', 'stop', 'hold', 'release', 'migrate', 'recover'].includes(argv[0]) then argv[0] else 'start' - args = { command, entry: null, migrationEntry: null, migrationDir: 'migrations', operationId: null, name: null, hosts: [], control: process.env.JANUS_CONTROL or null, workers: 2, concurrency: 1, watch: null, allowWatch: false, eager: false, untilRestart: false, accessMode: 'pretty', accessPicture: null, accessExplicit: false, startupFlags: [] } + args: any = { command, entry: null, migrationEntry: null, migrationDir: 'migrations', operationId: null, name: null, hosts: [], control: process.env.JANUS_CONTROL or null, workers: 2, concurrency: 1, watchApp: null, watchApi: null, allowWatch: false, eager: false, untilRestart: false, accessMode: 'pretty', accessPicture: null, accessExplicit: false, startupFlags: [] } i = if command is 'start' then 0 else 1 while i < argv.length value = argv[i] take = -> i++ - abort "rip-server: #{value} requires a value" if i >= argv.length + abort "rip-site: #{value} requires a value" if i >= argv.length argv[i] switch value when '--name' - abort 'rip-server: --name does not apply to browse' if command is 'browse' + abort 'rip-site: --name does not apply to browse' if command is 'browse' args.startupFlags.push value args.name = take() when '--host' @@ -42,81 +42,99 @@ parseArgs = (argv) -> args.startupFlags.push value args.control = take() when '--watch' - abort 'rip-server: --watch does not apply to browse' if command is 'browse' + abort 'rip-site: --watch does not apply to browse' if command is 'browse' args.startupFlags.push value - args.watch = true + args.watchApp = true + args.watchApi = true when '--no-watch' - abort 'rip-server: --no-watch does not apply to browse' if command is 'browse' + abort 'rip-site: --no-watch does not apply to browse' if command is 'browse' args.startupFlags.push value - args.watch = false + args.watchApp = false + args.watchApi = false + when '--watch-app' + abort 'rip-site: --watch-app does not apply to browse' if command is 'browse' + args.startupFlags.push value + args.watchApp = true + when '--no-watch-app' + abort 'rip-site: --no-watch-app does not apply to browse' if command is 'browse' + args.startupFlags.push value + args.watchApp = false + when '--watch-api' + abort 'rip-site: --watch-api does not apply to browse' if command is 'browse' + args.startupFlags.push value + args.watchApi = true + when '--no-watch-api' + abort 'rip-site: --no-watch-api does not apply to browse' if command is 'browse' + args.startupFlags.push value + args.watchApi = false when '--allow-watch' - abort 'rip-server: --allow-watch does not apply to browse' if command is 'browse' + abort 'rip-site: --allow-watch does not apply to browse' if command is 'browse' args.startupFlags.push value args.allowWatch = true when '--eager' - abort 'rip-server: --eager does not apply to browse' if command is 'browse' + abort 'rip-site: --eager does not apply to browse' if command is 'browse' args.startupFlags.push value args.eager = true when '--until-restart' - abort 'rip-server: --until-restart only applies to browse' unless command is 'browse' + abort 'rip-site: --until-restart only applies to browse' unless command is 'browse' args.untilRestart = true when '--access-format' args.accessExplicit = true args.accessPicture = take() when '--dir' - abort 'rip-server: --dir only applies to migrate' unless command is 'migrate' + abort 'rip-site: --dir only applies to migrate' unless command is 'migrate' args.migrationDir = take() when '--id' - abort 'rip-server: --id only applies to migrate' unless command is 'migrate' + abort 'rip-site: --id only applies to migrate' unless command is 'migrate' args.operationId = take() - abort 'rip-server: --id requires exactly 32 lowercase hex characters' unless /^[a-f0-9]{32}$/.test(args.operationId) + abort 'rip-site: --id requires exactly 32 lowercase hex characters' unless /^[a-f0-9]{32}$/.test(args.operationId) when '--workers', '-w' - abort "rip-server: #{value} does not apply to browse" if command is 'browse' + abort "rip-site: #{value} does not apply to browse" if command is 'browse' args.startupFlags.push value text = take() args.workers = parseInt text - abort 'rip-server: --workers must be an integer greater than zero' unless Number.isFinite(args.workers) and args.workers > 0 and String(args.workers) is text + abort 'rip-site: --workers must be an integer greater than zero' unless Number.isFinite(args.workers) and args.workers > 0 and String(args.workers) is text when '--concurrency', '-c' - abort "rip-server: #{value} does not apply to browse" if command is 'browse' + abort "rip-site: #{value} does not apply to browse" if command is 'browse' args.startupFlags.push value text = take() args.concurrency = parseInt text - abort 'rip-server: --concurrency must be an integer greater than zero' unless Number.isFinite(args.concurrency) and args.concurrency > 0 and String(args.concurrency) is text + abort 'rip-site: --concurrency must be an integer greater than zero' unless Number.isFinite(args.concurrency) and args.concurrency > 0 and String(args.concurrency) is text when '--help', '-h' - p 'usage: rip server [project] [options] | rip server [project] | rip server browse [--host HOST] [--until-restart] | rip server migrate [migration-entry] [--dir DIR] [--id 32hex] | rip server recover ' + p 'usage: rip site [project] [options] | rip site [project] | rip site browse [--host HOST] [--until-restart] | rip site migrate [migration-entry] [--dir DIR] [--id 32hex] | rip site recover ' exit 0 else if value.startsWith '--access-log=' args.accessExplicit = true args.accessMode = value.slice '--access-log='.length - abort 'rip-server: --access-log must be pretty, raw, or off' unless args.accessMode in ['pretty', 'raw', 'off'] + abort 'rip-site: --access-log must be pretty, raw, or off' unless args.accessMode in ['pretty', 'raw', 'off'] else - abort "rip-server: unknown option #{value}" if value.startsWith '-' + abort "rip-site: unknown option #{value}" if value.startsWith '-' if command is 'migrate' - abort "rip-server: multiple migration entries (#{args.migrationEntry} and #{value})" if args.migrationEntry? + abort "rip-site: multiple migration entries (#{args.migrationEntry} and #{value})" if args.migrationEntry? args.migrationEntry = value else if command is 'recover' - abort 'rip-server: recover takes exactly one operation id' if args.operationId? + abort 'rip-site: recover takes exactly one operation id' if args.operationId? args.operationId = value else if command is 'browse' - abort 'rip-server: browse takes exactly one directory' if args.entry? + abort 'rip-site: browse takes exactly one directory' if args.entry? args.entry = value else - abort "rip-server: multiple app entries (#{args.entry} and #{value})" if args.entry? + abort "rip-site: multiple app entries (#{args.entry} and #{value})" if args.entry? args.entry = value i++ - abort 'rip-server: recover requires an operation id' if command is 'recover' and not args.operationId? - abort 'rip-server: recover operation id must be exactly 32 lowercase hex characters' if command is 'recover' and not /^[a-f0-9]{32}$/.test(args.operationId) - abort 'rip-server: --access-format only applies with --access-log=pretty' if args.accessPicture? and args.accessMode isnt 'pretty' - abort 'rip-server: explicit access flags conflict with browse --until-restart' if args.untilRestart and args.accessExplicit - abort "rip-server: access flags do not apply to #{command}" if args.accessExplicit and command not in ['start', 'browse'] - abort "rip-server: #{command} does not accept startup options: #{args.startupFlags.join(', ')}" if command not in ['start', 'browse'] and args.startupFlags.length + abort 'rip-site: recover requires an operation id' if command is 'recover' and not args.operationId? + abort 'rip-site: recover operation id must be exactly 32 lowercase hex characters' if command is 'recover' and not /^[a-f0-9]{32}$/.test(args.operationId) + abort 'rip-site: --access-format only applies with --access-log=pretty' if args.accessPicture? and args.accessMode isnt 'pretty' + abort 'rip-site: explicit access flags conflict with browse --until-restart' if args.untilRestart and args.accessExplicit + abort "rip-site: access flags do not apply to #{command}" if args.accessExplicit and command not in ['start', 'browse'] + abort "rip-site: #{command} does not accept startup options: #{args.startupFlags.join(', ')}" if command not in ['start', 'browse'] and args.startupFlags.length if args.accessPicture? try parseAccessPicture args.accessPicture catch error - abort "rip-server: #{error?.message or error}" - abort 'rip-server: browse requires exactly one directory' if command is 'browse' and not args.entry? + abort "rip-site: #{error?.message or error}" + abort 'rip-site: browse requires exactly one directory' if command is 'browse' and not args.entry? args controlFetch = (target, method, path, body = null) -> @@ -132,7 +150,7 @@ controlFetch = (target, method, path, body = null) -> loaderDir = -> preload = process.execArgv.find (arg) -> arg.startsWith '--preload=' - abort 'rip-server: the Rip loader is missing' unless preload? + abort 'rip-site: the Rip loader is missing' unless preload? dirname resolve(preload.slice('--preload='.length)) generate = (entry, artifact, reportPath, taskId) -> @@ -151,7 +169,7 @@ isRecord = (value) -> value? and typeof value is 'object' and not Array.isArray(value) invalidServe = (message) -> - throw Error.new "rip-server: #{message}" + throw Error.new "rip-site: #{message}" rejectUnknown = (value, allowed, at) -> invalidServe "#{at} must be an object" unless isRecord value @@ -239,8 +257,20 @@ requireFile = (path, at) -> invalidServe "#{at} file not found: #{path}" unless existsSync path invalidServe "#{at} is not a file: #{path}" unless statSync(path).isFile() +# Hub bridge paths are Bam-simple: hub, /hub, hub/, ///hub/// → /hub. +normalizeHubBridge = (value, at) -> + name = needString value, at + name = name.replace /^\/+/, '' + name = name.replace /\/+$/, '' + invalidServe "#{at} must be a nonempty path" unless name.length > 0 + invalidServe "#{at} must not contain ? # whitespace or backslash" if /[?#\s]/.test(name) or name.includes('\\') + invalidServe "#{at} must not contain empty path segments" if name.includes('//') + for part in name.split('/') + invalidServe "#{at} must not contain '.' or '..' segments" if part is '' or part is '.' or part is '..' + "/#{name}" + normalizeServeConfig = (raw, appDir) -> - rejectUnknown raw, ['name', 'hosts', 'sites', 'files', 'app'], 'serve.rip' + rejectUnknown raw, ['name', 'hosts', 'sites', 'files', 'app', 'hub'], 'serve.rip' out = {} out.name = needString(raw.name, 'serve.rip name') if raw.name? if raw.hosts? @@ -325,9 +355,23 @@ normalizeServeConfig = (raw, appDir) -> overlapsDist = root is dist or root.startsWith("#{dist}/") or dist.startsWith("#{root}/") invalidServe 'serve.rip app.root cannot overlap the manager-owned dist directory' if overlapsDist out.app = { root, changes: normalizeAppChanges(raw.app.changes) } + if raw.hub? + rejectUnknown raw.hub, ['bridge'], 'serve.rip hub' + invalidServe 'serve.rip hub.bridge is required when hub is declared' unless 'bridge' of raw.hub + out.hub = { bridge: normalizeHubBridge(raw.hub.bridge, 'serve.rip hub.bridge') } invalidServe 'serve.rip sites requires files' if out.site? and not out.files? out +ensureProxyFirst = (files, bridgePath) -> + return files unless files? and bridgePath? + proxy = [...(files.proxy_first or [])] + covered = proxy.some (prefix) -> bridgePath is prefix or bridgePath.startsWith("#{prefix}/") or prefix is '/' + unless covered + for prior in proxy + invalidServe "serve.rip hub.bridge #{bridgePath} overlaps proxyFirst #{JSON.stringify(prior)}" if prior.startsWith("#{bridgePath}/") + proxy = [...proxy, bridgePath] + { ...files, proxy_first: proxy } + loadServeConfig = (appDir) -> configPath = join appDir, 'serve.rip' return null unless existsSync configPath @@ -344,20 +388,20 @@ probeControl = (target) -> try controlFetch! target, 'GET', '/1.0' catch error - abort "rip-server: control endpoint unreachable at #{target}: #{error?.message or error}" + abort "rip-site: control endpoint unreachable at #{target}: #{error?.message or error}" defaultBrowseHost = -> "browse-#{randomBytes(6).toString('hex')}.localhost" runBrowse = (args) -> directory = resolve args.entry - abort "rip-server: browse directory not found: #{args.entry}" unless existsSync directory - abort "rip-server: browse path is not a directory: #{args.entry}" unless statSync(directory).isDirectory() + abort "rip-site: browse directory not found: #{args.entry}" unless existsSync directory + abort "rip-site: browse path is not a directory: #{args.entry}" unless statSync(directory).isDirectory() directory = realpathSync directory - abort 'rip-server: browse accepts at most one --host' if args.hosts.length > 1 + abort 'rip-site: browse accepts at most one --host' if args.hosts.length > 1 explicitHost = args.hosts[0] ?? null - abort "rip-server: browse host is not valid: #{explicitHost}" if explicitHost? and not validHost(explicitHost) - abort 'rip-server: --control or JANUS_CONTROL is required' unless args.control? + abort "rip-site: browse host is not valid: #{explicitHost}" if explicitHost? and not validHost(explicitHost) + abort 'rip-site: --control or JANUS_CONTROL is required' unless args.control? probeControl! args.control earlySignalStatus = null earlySignalCount = 0 @@ -385,7 +429,7 @@ runBrowse = (args) -> response = controlFetch! args.control, 'POST', '/1.0/apps', bodyFor(host) if response.status is 201 registration = response.json! - abort 'rip-server: Janus registration returned no app id' unless registration?.id? + abort 'rip-site: Janus registration returned no app id' unless registration?.id? break if response.status is 409 and not explicitHost? and retries < 5 retries++ @@ -393,8 +437,8 @@ runBrowse = (args) -> continue detail = try response.text! catch then '' if response.status is 409 - abort "rip-server: browse host already claimed at the control plane (409): #{host}" - abort "rip-server: browse registration failed (#{response.status})#{if detail then ": #{detail}" else ''}" + abort "rip-site: browse host already claimed at the control plane (409): #{host}" + abort "rip-site: browse registration failed (#{response.status})#{if detail then ": #{detail}" else ''}" if earlySignalStatus? try controlFetch! args.control, 'DELETE', "/1.0/apps/#{registration.id}" process.off 'SIGINT', earlyStop @@ -418,7 +462,7 @@ runBrowse = (args) -> try controlFetch! args.control, 'DELETE', "/1.0/apps/#{registration.id}" process.off 'SIGINT', earlyStop process.off 'SIGTERM', earlyStop - warn "rip-server: browse output failure: #{error?.message or error}" + warn "rip-site: browse output failure: #{error?.message or error}" return 1 if earlySignalStatus? try controlFetch! args.control, 'DELETE', "/1.0/apps/#{registration.id}" @@ -457,7 +501,7 @@ runBrowse = (args) -> control: (if args.control.startsWith('http://') or args.control.startsWith('https://') then { base: args.control } else { unix: args.control }) access.move(registration.id).catch (error) -> unless stopping - warn "rip-server: terminal access output failure: #{error?.message or error}" + warn "rip-site: terminal access output failure: #{error?.message or error}" stop null, error beat = -> return if stopping or heartbeatTask? @@ -469,17 +513,17 @@ runBrowse = (args) -> fresh = controlFetch! args.control, 'POST', '/1.0/apps', bodyFor(host) if fresh.status is 201 registration = fresh.json! - abort 'rip-server: Janus registration returned no app id' unless registration?.id? + abort 'rip-site: Janus registration returned no app id' unless registration?.id? access?.move(registration.id).catch (error) -> unless stopping - warn "rip-server: terminal access output failure: #{error?.message or error}" + warn "rip-site: terminal access output failure: #{error?.message or error}" stop null, error else - warn "rip-server: browse re-registration failed (#{fresh.status})" + warn "rip-site: browse re-registration failed (#{fresh.status})" else unless response.ok - warn "rip-server: browse heartbeat failed (#{response.status})" + warn "rip-site: browse heartbeat failed (#{response.status})" catch error - warn "rip-server: browse heartbeat failed: #{error?.message or error}" unless stopping + warn "rip-site: browse heartbeat failed: #{error?.message or error}" unless stopping finally heartbeatTask = null heartbeatTask @@ -525,9 +569,9 @@ readOperation = (path) -> try record = JSON.parse readFileSync(path, 'utf8') catch error - abort "rip-server: malformed operation journal at #{path}: #{String(error)}" + abort "rip-site: malformed operation journal at #{path}: #{String(error)}" valid = record? and typeof record is 'object' and not Array.isArray(record) and record.version is 1 and /^[a-f0-9]{32}$/.test(record.id or '') and typeof record.phase is 'string' and typeof record.targetArtifact is 'string' - abort "rip-server: malformed operation journal at #{path}" unless valid + abort "rip-site: malformed operation journal at #{path}" unless valid record writeAtomic = (path, text) -> @@ -575,7 +619,7 @@ appModules = (appRoot, changes) -> try modules[id] = TextDecoder.new('utf-8', { fatal: true, ignoreBOM: true }).decode bytes catch - throw Error.new "rip-server: App module '#{id}' is not valid UTF-8" + throw Error.new "rip-site: App module '#{id}' is not valid UTF-8" moduleFiles[id] = path walk appRoot, '' entries.sort (a, b) -> if a.id < b.id then -1 else if a.id > b.id then 1 else 0 @@ -610,7 +654,7 @@ export updateAppModules = (appRoot, changes, graph, ids) -> try modules[id] = TextDecoder.new('utf-8', { fatal: true, ignoreBOM: true }).decode bytes catch - throw Error.new "rip-server: App module '#{id}' is not valid UTF-8" + throw Error.new "rip-site: App module '#{id}' is not valid UTF-8" moduleFiles[id] = path changed.push id return { graph, changed } unless changed.length @@ -670,7 +714,7 @@ commitAppDocuments = (project, documents) -> try renameSync temporaryCompressed, compressedPath catch error - warn "rip-server: bundle.json committed without Brotli sidecar: #{String(error)}" + warn "rip-site: bundle.json committed without Brotli sidecar: #{String(error)}" writeAtomic join(dist, 'latest.json'), documents.latest finally unlinkSync temporaryBundle if existsSync temporaryBundle @@ -694,7 +738,7 @@ publishApp = (project, name, watch, documents = undefined, declaredShell = null) commitAppDocuments project, documents publication = documents runtime = join(loaderDir(), '..', 'dist', 'browser', 'rip.js') - abort "rip-server: browser runtime missing at #{runtime}" unless existsSync runtime + abort "rip-site: browser runtime missing at #{runtime}" unless existsSync runtime copyFileSync runtime, join(dist, '@rip', 'rip.js') authoredShell = join project.appRoot, 'index.html' @@ -787,7 +831,7 @@ export main = (argv) -> body = null if args.command is 'migrate' migrationEntry = resolve args.migrationEntry if args.migrationEntry? - abort "rip-server: migration entry not found: #{args.migrationEntry}" if migrationEntry? and not existsSync(migrationEntry) + abort "rip-site: migration entry not found: #{args.migrationEntry}" if migrationEntry? and not existsSync(migrationEntry) body = { id: args.operationId or randomBytes(16).toString('hex'), entry: migrationEntry, dir: resolve(args.migrationDir) } else if args.command is 'recover' body = { id: args.operationId } @@ -797,7 +841,7 @@ export main = (argv) -> options.headers = { 'content-type': 'application/json' } response = fetch! "http://manager/#{args.command}", options body = response.text! - abort "rip-server: #{args.command} failed (#{response.status}): #{body}" unless response.ok + abort "rip-site: #{args.command} failed (#{response.status}): #{body}" unless response.ok waitManagerStopped! managerPath if args.command is 'stop' p body return @@ -811,27 +855,32 @@ export main = (argv) -> project.appChanges = serve.app.changes else if project.appRoot? project.appChanges = defaultAppChanges() + hubBridge = serve?.hub?.bridge ?? (if project.entry? then '/hub' else null) args.name = args.name or serve?.name or basename(project.root).replace(/\.[^.]+$/, '') site = serve?.site ?? null if site? and args.hosts.length > 0 - abort 'rip-server: --host conflicts with serve.rip sites' + abort 'rip-site: --host conflicts with serve.rip sites' hosts = if site? then [] else if args.hosts.length > 0 then args.hosts else if serve?.hosts? then serve.hosts else [args.name] for host, i in hosts - abort "rip-server: --host value is not valid: #{host}" unless typeof host is 'string' and validHost host + abort "rip-site: --host value is not valid: #{host}" unless typeof host is 'string' and validHost host + if hubBridge? and not project.entry? + abort 'rip-site: hub.bridge requires an API entry so Janus can POST the Bam bridge to workers' if serve?.files? and not serve.files.shell? and project.entry? - abort 'rip-server: shell-less browse policy cannot publish API upstreams' + abort 'rip-site: shell-less browse policy cannot publish API upstreams' prod = process.env.RIP_ENV is 'production' - args.watch = args.watch ?? (not prod) - if args.watch and prod - abort 'rip-server: watch in production requires --allow-watch' unless args.allowWatch - warn 'rip-server: WATCH ENABLED IN PRODUCTION' - if project.entry? and args.concurrency > 1 and (args.watch or args.eager) - abort "rip-server: -c #{args.concurrency} requires watch off — raise c only with watch off" - abort 'rip-server: --control or JANUS_CONTROL is required' unless args.control? + defaultWatch = not prod + args.watchApp = args.watchApp ?? defaultWatch + args.watchApi = args.watchApi ?? defaultWatch + if (args.watchApp or args.watchApi) and prod + abort 'rip-site: watch in production requires --allow-watch' unless args.allowWatch + warn 'rip-site: WATCH ENABLED IN PRODUCTION' + if project.entry? and args.concurrency > 1 and (args.watchApi or args.eager) + abort "rip-site: -c #{args.concurrency} requires API watch off — raise c only with --no-watch-api" + abort 'rip-site: --control or JANUS_CONTROL is required' unless args.control? probeControl! args.control runDir = mkdtempSync join(tmpdir(), 'rip-srv-') workers = [] @@ -905,10 +954,11 @@ export main = (argv) -> else body.hosts = hosts body.files = files if files? + body.bridge = hubBridge if hubBridge? body status = -> - { name: args.name, root: project.root, hosts, site, operation, stopping, workers: workers.length, watch: args.watch, migration: (if migration? then { id: migration.id, phase: migration.phase, outcome: migration.outcome } else null) } + { name: args.name, root: project.root, hosts, site, operation, stopping, workers: workers.length, watch: { app: args.watchApp, api: args.watchApi }, migration: (if migration? then { id: migration.id, phase: migration.phase, outcome: migration.outcome } else null) } managerOutput = (text) -> stream = if args.accessMode is 'raw' then process.stderr else process.stdout @@ -920,7 +970,7 @@ export main = (argv) -> task.finally(-> workerPumps.delete(task)).catch(-> null) task.catch (error) -> return if stopping - warn "rip-server: worker output failed: #{String(error)}" + warn "rip-site: worker output failed: #{String(error)}" stop! 1 moveAccess = -> @@ -928,7 +978,7 @@ export main = (argv) -> task = accessClient.move appId task.catch (error) -> return if stopping - warn "rip-server: access output failed: #{String(error)}" + warn "rip-site: access output failed: #{String(error)}" stop! 1 operationResponse = (key, run) -> @@ -971,7 +1021,7 @@ export main = (argv) -> try process.kill owner?.pid, 0 alive = true - abort "rip-server: manager already running for #{project.root}" if alive + abort "rip-site: manager already running for #{project.root}" if alive unlinkSync managerOwner acquire() acquire() @@ -985,7 +1035,7 @@ export main = (argv) -> unless stopping setTimeout (-> stop().catch (error) -> - warn "rip-server: stop cleanup failed: #{String(error)}" + warn "rip-site: stop cleanup failed: #{String(error)}" process.exitCode = 1 ), 0 return Response.json({ ...status(), stopping: true }, { status: 202 }) @@ -1007,7 +1057,7 @@ export main = (argv) -> bootPrepared! if prepared? await bootPromise if bootPromise? catch error - warn "rip-server: pending API candidate failed while holding: #{String(error)}" + warn "rip-site: pending API candidate failed while holding: #{String(error)}" operation = 'Held' return Response.json status() if request.method is 'POST' and path is '/release' @@ -1094,8 +1144,8 @@ export main = (argv) -> workers = workers.filter (candidate) -> candidate isnt worker try queuePublish! catch error - warn "rip-server: cannot remove crashed worker from Janus: #{String(error)}" - managerOutput! "rip-server: worker exited (#{code}); replacing" + warn "rip-site: cannot remove crashed worker from Janus: #{String(error)}" + managerOutput! "rip-site: worker exited (#{code}); replacing" loop return if stopping try @@ -1104,7 +1154,7 @@ export main = (argv) -> queuePublish! return catch error - warn "rip-server: replacement worker failed: #{String(error)}" + warn "rip-site: replacement worker failed: #{String(error)}" Promise.new! (done) -> setTimeout done, 250 return @@ -1118,9 +1168,9 @@ export main = (argv) -> try response = controlFetch! args.control, 'POST', "/1.0/apps/#{appId}/hub/publish", [{ '@': ['/hub'], change }] return if response.ok - warn "rip-server: Janus change publish failed (#{response.status}); retrying" + warn "rip-site: Janus change publish failed (#{response.status}); retrying" catch error - warn "rip-server: Janus change publish failed: #{String(error)}; retrying" + warn "rip-site: Janus change publish failed: #{String(error)}; retrying" return if stopping sleep! delay delay = Math.min delay * 2, 1000 @@ -1171,12 +1221,12 @@ export main = (argv) -> refreshApp! batch appChain = next.catch (error) -> appReconcile = true - warn "rip-server: App publication failed: #{String(error)}" + warn "rip-site: App publication failed: #{String(error)}" , 50 return watchAppDependencies = (documents) -> - return 0 unless args.watch + return 0 unless args.watchApp added = 0 for root in documents.watchRoots or [] root = resolve root @@ -1228,7 +1278,7 @@ export main = (argv) -> publishChange! change startAppWatcher = -> - return unless args.watch and project.appRoot? + return unless args.watchApp and project.appRoot? appWatcher = watch project.appRoot, { recursive: true }, (_event, filename) -> return if stopping if not appWatchReady or operation isnt 'Active' @@ -1293,7 +1343,7 @@ export main = (argv) -> report = generate! project.entry, artifact, reportPath, "api-#{generation}" catch error apiSettledEpoch = epoch if epoch is apiEpoch - warn "rip-server: API candidate rejected; keeping current workers: #{String(error)}" + warn "rip-site: API candidate rejected; keeping current workers: #{String(error)}" return return if epoch isnt apiEpoch or stopping candidate = { artifact, inputs: report.inputs, epoch } @@ -1311,12 +1361,12 @@ export main = (argv) -> apiTimer = setTimeout -> apiTimer = null next = apiChain.then -> prepareApi! epoch - apiChain = next.catch (error) -> warn "rip-server: API reload failed: #{String(error)}" + apiChain = next.catch (error) -> warn "rip-site: API reload failed: #{String(error)}" , 50 return startApiWatcher = -> - return unless args.watch and project.entry? + return unless args.watchApi and project.entry? apiWatcher = watch project.root, { recursive: true }, (event, filename) -> return if stopping rel = String(filename or '') @@ -1431,15 +1481,15 @@ export main = (argv) -> { ...report, code, output: (if code is 0 then stdout.trim() else stderr.trim()) } publishReload = -> - return unless args.watch + return unless args.watchApp delay = 25 loop try response = controlFetch! args.control, 'POST', "/1.0/apps/#{appId}/hub/publish", [{ '@': ['/hub'], reload: { epoch: generation } }] return if response.ok - warn "rip-server: Janus reload publish failed (#{response.status}); retrying" + warn "rip-site: Janus reload publish failed (#{response.status}); retrying" catch error - warn "rip-server: Janus reload publish failed: #{String(error)}; retrying" + warn "rip-site: Janus reload publish failed: #{String(error)}; retrying" return if stopping sleep! delay delay = Math.min delay * 2, 1000 @@ -1490,7 +1540,7 @@ export main = (argv) -> clearMigration record if appChanged next = appChain.then -> refreshApp! - appChain = next.catch (error) -> warn "rip-server: post-migration App refresh failed: #{String(error)}" + appChain = next.catch (error) -> warn "rip-site: post-migration App refresh failed: #{String(error)}" scheduleApiRefresh() if apiChanged or apiReconcile status() catch error @@ -1616,9 +1666,9 @@ export main = (argv) -> appId = replacementId moveAccess() else unless response.ok - warn "rip-server: Janus heartbeat failed (#{response.status})" + warn "rip-site: Janus heartbeat failed (#{response.status})" catch error - warn "rip-server: Janus heartbeat failed: #{String(error)}" unless stopping + warn "rip-site: Janus heartbeat failed: #{String(error)}" unless stopping finally heartbeatTask = null heartbeatTask @@ -1644,9 +1694,9 @@ export main = (argv) -> try if appId? response = controlFetch! args.control, 'DELETE', "/1.0/apps/#{appId}" - warn "rip-server: Janus deregistration failed (#{response.status})" unless response.ok + warn "rip-site: Janus deregistration failed (#{response.status})" unless response.ok catch error - warn "rip-server: Janus deregistration failed: #{String(error)}" + warn "rip-site: Janus deregistration failed: #{String(error)}" try await accessStop if accessStop? allWorkers = [...workers, ...bootingWorkers] Promise.all! allWorkers.map (worker) -> stopWorker worker.proc @@ -1667,7 +1717,7 @@ export main = (argv) -> process.kill process.pid, signal return stop(status).catch (error) -> - warn "rip-server: signal cleanup failed: #{String(error)}" + warn "rip-site: signal cleanup failed: #{String(error)}" process.exitCode = 1 onSigint = -> onSignal 'SIGINT', 130 @@ -1685,12 +1735,13 @@ export main = (argv) -> startAppWatcher() authoritative = buildAppDocuments! project authoritative = (stabilizeAppDocuments! authoritative).documents - publication = publishApp! project, args.name, args.watch, authoritative, serve?.files?.shell + publication = publishApp! project, args.name, args.watchApp, authoritative, serve?.files?.shell appPublication = authoritative appGraph = authoritative.graph else - publication = publishApp! project, args.name, args.watch, (if operation is 'Maintenance' then null else undefined), serve?.files?.shell + publication = publishApp! project, args.name, args.watchApp, (if operation is 'Maintenance' then null else undefined), serve?.files?.shell files = registrationFiles project, serve?.files, publication + files = ensureProxyFirst files, hubBridge if project.entry? startApiWatcher() workerArtifact = join runDir, 'worker.js' @@ -1729,12 +1780,12 @@ export main = (argv) -> monitorWorker worker heartbeat = setInterval beat, HEARTBEAT_MS if operation is 'Maintenance' - warn "rip-server: unresolved migration #{migration.id}; Maintenance has empty admission" + warn "rip-site: unresolved migration #{migration.id}; Maintenance has empty admission" else - managerOutput! "rip-server: https://#{if site? then site.host else hosts[0]}/" + managerOutput! "rip-site: https://#{if site? then site.host else hosts[0]}/" process.exitCode = await finished catch error - warn "rip-server: #{String(error)}" unless stopping + warn "rip-site: #{String(error)}" unless stopping process.exitCode = stop! 1 finally process.off 'SIGINT', onSigint diff --git a/packages/server/middleware.rip b/packages/sites/middleware.rip similarity index 100% rename from packages/server/middleware.rip rename to packages/sites/middleware.rip diff --git a/packages/server/monitor.rip b/packages/sites/monitor.rip similarity index 99% rename from packages/server/monitor.rip rename to packages/sites/monitor.rip index b80e1e20..99a5f463 100644 --- a/packages/server/monitor.rip +++ b/packages/sites/monitor.rip @@ -770,7 +770,7 @@ export class AccessClient try reader?.cancel().catch(-> null) throw e if e?.accessOutput or e?.code is 'EPIPE' return if signal.aborted - try writeAccessOutput!(@stderr, "rip-server: access stream: #{e?.message or e}; reconnecting\n") + try writeAccessOutput!(@stderr, "rip-site: access stream: #{e?.message or e}; reconnecting\n") catch outputError throw outputError break unless generation is @generation and not signal.aborted @@ -785,7 +785,7 @@ export class AccessClient consume: (line) -> record = line.record if record.type is 'gap' - writeAccessOutput! @stderr, "rip-server: access gap #{record.from}..#{record.through} (#{record.reason})\n" + writeAccessOutput! @stderr, "rip-site: access gap #{record.from}..#{record.through} (#{record.reason})\n" if @mode is 'raw' writeAccessOutput! @stdout, line.bytes else if @mode is 'pretty' and record.type is 'access' diff --git a/packages/server/package.json b/packages/sites/package.json similarity index 78% rename from packages/server/package.json rename to packages/sites/package.json index 079619fa..7f9b80db 100644 --- a/packages/server/package.json +++ b/packages/sites/package.json @@ -1,16 +1,16 @@ { - "name": "@rip-lang/server", + "name": "@rip-lang/sites", "version": "4.0.0", "private": true, "type": "module", - "description": "Sinatra-style API framework — routes, smart responses, read() validation, and AsyncLocalStorage-powered request context for managed workers behind Janus", + "description": "Rip Sites — Sinatra-style site framework — routes, smart responses, read() validation, and AsyncLocalStorage-powered request context for managed workers behind Janus (App + API)", "exports": { - ".": "./server.rip", + ".": "./site.rip", "./middleware": "./middleware.rip" }, "bin": { - "rip-server": "./server.rip", - "rip-app": "./app.rip", + "rip-site": "./site.rip", + "rip-sites": "./sites.rip", "rip-edge": "./edge.rip" }, "scripts": { @@ -28,7 +28,7 @@ "test:workers": "rip test/workers/test.rip" }, "files": [ - "server.rip", + "site.rip", "middleware.rip", "worker.rip", "generate.rip", @@ -38,7 +38,7 @@ "appliance.rip", "agent.rip", "launchd-listener.js", - "app.rip", + "sites.rip", "edge.rip", "Caddyfile", "ripdev.io.crt", diff --git a/packages/server/ripdev.io.crt b/packages/sites/ripdev.io.crt similarity index 100% rename from packages/server/ripdev.io.crt rename to packages/sites/ripdev.io.crt diff --git a/packages/server/ripdev.io.key b/packages/sites/ripdev.io.key similarity index 100% rename from packages/server/ripdev.io.key rename to packages/sites/ripdev.io.key diff --git a/packages/server/server.rip b/packages/sites/site.rip similarity index 94% rename from packages/server/server.rip rename to packages/sites/site.rip index c90995da..0e64f92d 100755 --- a/packages/server/server.rip +++ b/packages/sites/site.rip @@ -1,21 +1,22 @@ #!/usr/bin/env rip # ============================================================================== -# @rip-lang/server — Sinatra-style web framework for Rip +# @rip-lang/sites — Rip Sites authoring framework # ============================================================================== # # Routes, smart responses, read() validation, and AsyncLocalStorage-powered -# request context for API workers managed behind Janus. +# request context for App + API workers managed behind Janus. # # Public exports: # DSL: get, post, put, patch, del, all, use, prefix -# Server: start, fetch, App +# Runtime: start, fetch, App # Filters: raw, before, after # Handlers: onError, notFound # Errors: error!, notice!, bail! # Validation: read, registerValidator, getValidator, isRawType, # validatorNames, check # Context: ctx, session, env, subrequest, mark +# Hub: hub # Utilities: isBlank, toName, toPhone, formatMoney # OpenAPI: openapi, errorEnvelope # Dev: resetGlobals, requestContext @@ -705,10 +706,41 @@ runHandler = (c, route) -> export start = -> unless process.env.APP_ARTIFACT? and process.env.SOCKET_PATH? - throw Error.new 'start() requires the Rip worker runtime — run the application with `rip server`' + throw Error.new 'start() requires the Rip worker runtime — run the application with `rip site`' globalThis.__ripHandler = fetch fetch +# Bam Hub bridge — Janus POSTs open|text|close here; return directive +# frames (or null for 204). `bridge: 'hub'` matches serve.rip and becomes +# POST /hub. auth: true (default) requires session.authenticated. +export hub = (opts = {}) -> + throw TypeError.new('hub options must be an object') unless opts? and typeof opts is 'object' and not Array.isArray(opts) + name = opts.bridge ?? 'hub' + throw TypeError.new('hub.bridge must be a nonempty string') unless typeof name is 'string' and name.length > 0 + name = name.replace(/^\/+/, '').replace(/\/+$/, '') + throw TypeError.new('hub.bridge must be a path name without ? #') if name.length is 0 or /[?#\s]/.test(name) or name.includes('\\') or name.includes('//') + for part in name.split('/') + throw TypeError.new('hub.bridge must not contain . or .. segments') if part is '' or part is '.' or part is '..' + bridgePath = "/#{name}" + auth = opts.auth + auth = true if auth is undefined + throw TypeError.new('hub.auth must be boolean or a function') unless typeof auth is 'boolean' or typeof auth is 'function' + open = opts.open ?? (-> null) + text = opts.text ?? (-> null) + close = opts.close ?? (-> null) + for [label, fn] in [['open', open], ['text', text], ['close', close]] + throw TypeError.new("hub.#{label} must be a function") unless typeof fn is 'function' + post bridgePath, -> + if auth is true + bail! unless session.authenticated is true + else if typeof auth is 'function' + bail! unless auth.call(@) + kind = (@req.header('sec-websocket-frame') or '').toLowerCase() + if kind is 'open' then return open.call(@) + if kind is 'close' then return close.call(@) + if kind is 'text' then return text.call(@) + null + export App = (fn) -> resetGlobals() fn?.() @@ -787,7 +819,7 @@ export bail = -> # validators that power `read()` here also power `~:name` schema coercers # in client-side `.parse()` calls. Importing it registers every validator # as a named schema coercer. Re-exported here: these are part of -# @rip-lang/server's public surface. +# @rip-lang/sites's public surface. import { isBlank, toName, toPhone, formatMoney, registerValidator, getValidator, isRawType, validatorNames, check } from '@rip-lang/validate' export { isBlank, toName, toPhone, formatMoney, registerValidator, getValidator, isRawType, validatorNames, check } @@ -882,7 +914,7 @@ export read = (name?: string, type?: string | RegExp | any[] | Record [arguments] + + Commands: + list List remembered Rip sites + add [project] [options] Remember a Rip project (default: cwd) + remove Forget a stopped site + start Start and supervise a site + stop Gracefully stop a site + restart Stop and start a site + status [site] Show one site, or every site + open Open the site in the default browser + logs [--lines N] [--follow] Show the manager log + + Add options: + --name NAME Stable display and manager name + --host HOST Janus host claim (repeatable) + --workers N Worker count (default: 2) + --concurrency N Requests per worker (default: 1) + --no-watch Disable App and API source watching + --no-watch-app Disable App publication watching + --no-watch-api Disable API worker watching + --eager Boot replacement workers before demand + + Output: + --json Print the complete JSON response +''' + +args = process.argv.slice 2 +if args.length is 0 or args[0] in ['-h', '--help', 'help'] + console.log HELP + exit 0 + +command = args.shift() +json = false +follow = false +lines = 100 +name = null +hosts = null +workers = 2 +concurrency = 1 +watchApp = true +watchApi = true +eager = false +positionals = [] +addFlags = [] +logFlags = [] + +take = (flag) -> + abort "rip-sites: #{flag} requires a value" unless args.length + args.shift() + +while args.length + value = args.shift() + switch value + when '--json' then json = true + when '--follow', '-f' + follow = true + logFlags.push value + when '--lines', '-n' + logFlags.push value + text = take value + lines = parseInt text + abort 'rip-sites: --lines must be an integer from 1 through 10000' unless Number.isInteger(lines) and lines >= 1 and lines <= 10000 and String(lines) is text + when '--name' + addFlags.push value + name = take value + when '--host' + addFlags.push value + hosts or= [] + hosts.push take(value) + when '--workers' + addFlags.push value + text = take value + workers = parseInt text + abort 'rip-sites: --workers must be a positive integer' unless Number.isInteger(workers) and workers > 0 and String(workers) is text + when '--concurrency' + addFlags.push value + text = take value + concurrency = parseInt text + abort 'rip-sites: --concurrency must be a positive integer' unless Number.isInteger(concurrency) and concurrency > 0 and String(concurrency) is text + when '--no-watch' + addFlags.push value + watchApp = false + watchApi = false + when '--no-watch-app' + addFlags.push value + watchApp = false + when '--no-watch-api' + addFlags.push value + watchApi = false + when '--eager' + addFlags.push value + eager = true + else + abort "rip-sites: unknown option #{value}" if value.startsWith '-' + positionals.push value + +selector = -> + abort "rip-sites: #{command} requires exactly one site" unless positionals.length is 1 + encodeURIComponent positionals[0] + +abort "rip-sites: #{command} does not accept add options: #{addFlags.join(', ')}" if command isnt 'add' and addFlags.length +abort "rip-sites: #{command} does not accept log options: #{logFlags.join(', ')}" if command isnt 'logs' and logFlags.length +abort 'rip-sites: --follow conflicts with --json' if follow and json + +renderState = (app) -> + marker = if app.state is 'running' then '●' else '○' + url = if app.url then " #{app.url}" else '' + "#{marker} #{app.name} #{app.state}#{url}\n #{app.root}" + +switch command + when 'list' + abort 'rip-sites: list takes no site' if positionals.length + apps = requestAgent! 'rip-sites', 'GET', '/1.0/apps' + if json then printJson apps else if apps.length then console.log(apps.map(renderState).join('\n')) else console.log 'No Rip sites. Add one with `rip sites add [project]`.' + when 'add' + abort 'rip-sites: add takes at most one project' if positionals.length > 1 + app = requestAgent! 'rip-sites', 'POST', '/1.0/apps', + root: positionals[0] or process.cwd() + name: name + hosts: hosts + options: { workers, concurrency, watchApp, watchApi, eager } + if json then printJson app else console.log "Added #{app.name}\n#{app.root}" + when 'remove' + app = requestAgent! 'rip-sites', 'DELETE', "/1.0/apps/#{selector!}" + if json then printJson app else console.log "Removed #{app.name}" + when 'start', 'stop', 'restart' + app = requestAgent! 'rip-sites', 'POST', "/1.0/apps/#{selector!}/#{command}", {} + if json then printJson app else console.log renderState(app) + when 'status' + abort 'rip-sites: status takes at most one site' if positionals.length > 1 + result = if positionals.length then requestAgent!('rip-sites', 'GET', "/1.0/apps/#{encodeURIComponent(positionals[0])}") else requestAgent!('rip-sites', 'GET', '/1.0/apps') + if json + printJson result + else if Array.isArray result + if result.length then console.log(result.map(renderState).join('\n')) else console.log 'No Rip sites.' + else + console.log renderState(result) + when 'open' + app = requestAgent! 'rip-sites', 'GET', "/1.0/apps/#{selector!}" + abort "rip-sites: #{app.name} has no URL" unless app.url + result = Bun.spawnSync ['open', app.url], { stdout: 'ignore', stderr: 'pipe' } + abort "rip-sites: cannot open #{app.url}: #{result.stderr.toString().trim()}" unless result.exitCode is 0 + printJson({ url: app.url }) if json + when 'logs' + id = selector! + read = (after = null) -> requestAgent! 'rip-sites', 'GET', "/1.0/apps/#{id}/logs?#{if after? then "after=#{after}" else "lines=#{lines}"}" + log = read! + if json + printJson log + else + process.stdout.write log.text + if follow + offset = log.next + loop + sleep! 250 + next = read! offset + process.stdout.write next.text if next.text + offset = next.next + else + abort "rip-sites: unknown command #{JSON.stringify(command)} — run `rip sites --help`" diff --git a/packages/server/test/README.md b/packages/sites/test/README.md similarity index 84% rename from packages/server/test/README.md rename to packages/sites/test/README.md index f4d71a3a..20baa6d0 100644 --- a/packages/server/test/README.md +++ b/packages/sites/test/README.md @@ -1,6 +1,6 @@ -# Rip Server test apps +# Rip Sites test apps -These fixtures earn the Rip Server implementation one observable capability at +These fixtures earn the Rip Sites implementation one observable capability at a time. Each directory is independent: it owns its source, state, and acceptance surface, and must not depend on a more advanced fixture. @@ -15,10 +15,10 @@ Order: 7. `manager-boundary` — declaration, finite file policy, and standalone browse. 8. `middleware` — application-owned request policy. 9. `monitor` — Janus access protocol, framing, formatting, and foreground output. -10. `appliance` — the private Rip Agent plus `rip app` / `rip edge` lifecycle. +10. `appliance` — the private Rip Agent plus `rip sites` / `rip edge` lifecycle. 11. `janus` — the released Janus data plane and control plane. -Code enters `@rip-lang/server` only when one of these fixtures demonstrates why +Code enters `@rip-lang/sites` only when one of these fixtures demonstrates why the server, rather than Janus, Caddy, or application code, must own it. ## Capability admission diff --git a/packages/server/test/appliance/fake-caddy.rip b/packages/sites/test/appliance/fake-caddy.rip similarity index 100% rename from packages/server/test/appliance/fake-caddy.rip rename to packages/sites/test/appliance/fake-caddy.rip diff --git a/packages/server/test/appliance/test.rip b/packages/sites/test/appliance/test.rip similarity index 98% rename from packages/server/test/appliance/test.rip rename to packages/sites/test/appliance/test.rip index 6503ad42..cdc10dbb 100644 --- a/packages/server/test/appliance/test.rip +++ b/packages/sites/test/appliance/test.rip @@ -5,7 +5,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' PRELOAD = process.execArgv.find (arg) -> arg.startsWith '--preload=' -APP = join(import.meta.dir, '..', '..', 'app.rip') +APP = join(import.meta.dir, '..', '..', 'sites.rip') EDGE = join(import.meta.dir, '..', '..', 'edge.rip') FAKE_CADDY = join(import.meta.dir, 'fake-caddy.rip') @@ -78,7 +78,7 @@ startJanus = (socket, calls) -> return Response.json({ id: "app-#{serial}" }, { status: 201 }) Response.new null, { status: 204 } -test! 'rip app remembers, supervises, observes, and removes one project', -> +test! 'rip sites remembers, supervises, observes, and removes one project', -> site = fixture 'rip-agent-app-' project = join site.root, 'project' mkdirSync join(project, 'app'), { recursive: true } diff --git a/packages/server/test/framework/test.rip b/packages/sites/test/framework/test.rip similarity index 93% rename from packages/server/test/framework/test.rip rename to packages/sites/test/framework/test.rip index 3f541b30..80e1ccc6 100644 --- a/packages/server/test/framework/test.rip +++ b/packages/sites/test/framework/test.rip @@ -1,12 +1,12 @@ import { test, eq, ok, throws } from '@rip-lang/testing' -import * as server from '@rip-lang/server' +import * as server from '@rip-lang/sites' import { get, post, put, patch, del, all, use, prefix, before, after, raw, onError, notFound, fetch as handle, App, resetGlobals, requestContext, - start, read, error, notice, bail, + start, read, error, notice, bail, hub, ctx, session, env, subrequest, mark, errorEnvelope, openapi, -} from '@rip-lang/server' +} from '@rip-lang/sites' import { resolve } from 'node:path' request = (method, path, opts = {}) -> @@ -31,7 +31,7 @@ test 'public package surface exposes the documented framework', -> expected = [ 'App', 'after', 'all', 'bail', 'before', 'check', 'ctx', 'del', 'env', 'error', 'errorEnvelope', 'fetch', 'formatMoney', 'get', 'getValidator', - 'isBlank', 'isRawType', 'mark', 'notFound', 'notice', 'onError', + 'hub', 'isBlank', 'isRawType', 'mark', 'notFound', 'notice', 'onError', 'openapi', 'patch', 'post', 'prefix', 'put', 'raw', 'read', 'registerValidator', 'requestContext', 'resetGlobals', 'session', 'start', 'subrequest', 'toName', 'toPhone', 'use', 'validatorNames', @@ -443,3 +443,37 @@ test 'start performs only the artifact-worker handoff', -> if priorArtifact? then process.env.APP_ARTIFACT = priorArtifact else delete process.env.APP_ARTIFACT if priorSocket? then process.env.SOCKET_PATH = priorSocket else delete process.env.SOCKET_PATH delete globalThis.__ripHandler + +test! 'hub registers a Bam bridge that admits authenticated sessions', -> + resetGlobals() + admitted = [] + hub + bridge: 'hub' + auth: -> @req.header('x-auth') is 'yes' + open: -> + admitted.push 'open' + { '+': ['/hub'] } + text: -> { '@': ['/hub'], ping: {} } + denied = handle! Request.new('http://test/hub', { + method: 'POST' + headers: { 'sec-websocket-frame': 'open' } + }) + eq denied.status, 401 + opened = handle! Request.new('http://test/hub', { + method: 'POST' + headers: { 'sec-websocket-frame': 'open', 'x-auth': 'yes' } + }) + eq opened.status, 200 + eq opened.json!, { '+': ['/hub'] } + eq admitted, ['open'] + texted = handle! Request.new('http://test/hub', { + method: 'POST' + headers: { 'sec-websocket-frame': 'text', 'x-auth': 'yes' } + body: '{}' + }) + eq texted.json!, { '@': ['/hub'], ping: {} } + quiet = handle! Request.new('http://test/hub', { + method: 'POST' + headers: { 'sec-websocket-frame': 'close', 'x-auth': 'yes' } + }) + eq quiet.status, 204 diff --git a/packages/server/test/hello-api/README.md b/packages/sites/test/hello-api/README.md similarity index 100% rename from packages/server/test/hello-api/README.md rename to packages/sites/test/hello-api/README.md diff --git a/packages/server/test/hello-api/entry.rip b/packages/sites/test/hello-api/entry.rip similarity index 100% rename from packages/server/test/hello-api/entry.rip rename to packages/sites/test/hello-api/entry.rip diff --git a/packages/server/test/hello-api/index.rip b/packages/sites/test/hello-api/index.rip similarity index 50% rename from packages/server/test/hello-api/index.rip rename to packages/sites/test/hello-api/index.rip index b2edc7ae..bc3b6684 100644 --- a/packages/server/test/hello-api/index.rip +++ b/packages/sites/test/hello-api/index.rip @@ -1,4 +1,4 @@ -import { get, start } from '@rip-lang/server' +import { get, start } from '@rip-lang/sites' get '/hello' -> { message: 'hello' } diff --git a/packages/server/test/hello-api/test.rip b/packages/sites/test/hello-api/test.rip similarity index 99% rename from packages/server/test/hello-api/test.rip rename to packages/sites/test/hello-api/test.rip index b0ec3eae..a5d4106e 100644 --- a/packages/server/test/hello-api/test.rip +++ b/packages/sites/test/hello-api/test.rip @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' PRELOAD = process.execArgv.find (arg) -> arg.startsWith '--preload=' GENERATOR = join(import.meta.dir, '..', '..', 'generate.rip') WORKER = join(import.meta.dir, '..', '..', 'worker.rip') -SERVER = join(import.meta.dir, '..', '..', 'server.rip') +SERVER = join(import.meta.dir, '..', '..', 'site.rip') generate = (entry, artifact, result, task) -> proc = Bun.spawn [process.execPath, PRELOAD, GENERATOR, entry, artifact, result, task, '0'], diff --git a/packages/server/test/hello-app/README.md b/packages/sites/test/hello-app/README.md similarity index 100% rename from packages/server/test/hello-app/README.md rename to packages/sites/test/hello-app/README.md diff --git a/packages/server/test/hello-app/app/routes/index.rip b/packages/sites/test/hello-app/app/routes/index.rip similarity index 100% rename from packages/server/test/hello-app/app/routes/index.rip rename to packages/sites/test/hello-app/app/routes/index.rip diff --git a/packages/server/test/hello-app/app/styles.css b/packages/sites/test/hello-app/app/styles.css similarity index 100% rename from packages/server/test/hello-app/app/styles.css rename to packages/sites/test/hello-app/app/styles.css diff --git a/packages/server/test/hello-app/app/template.html b/packages/sites/test/hello-app/app/template.html similarity index 100% rename from packages/server/test/hello-app/app/template.html rename to packages/sites/test/hello-app/app/template.html diff --git a/packages/server/test/hello-app/test.rip b/packages/sites/test/hello-app/test.rip similarity index 98% rename from packages/server/test/hello-app/test.rip rename to packages/sites/test/hello-app/test.rip index 62b7f8b2..e08fb875 100644 --- a/packages/server/test/hello-app/test.rip +++ b/packages/sites/test/hello-app/test.rip @@ -7,7 +7,7 @@ import { check, rash } from '@rip-lang/app/rash' import { appChangeKind, appEventPublication, updateAppModules } from '../../manager.rip' PRELOAD = process.execArgv.find (arg) -> arg.startsWith '--preload=' -SERVER = join(import.meta.dir, '..', '..', 'server.rip') +SERVER = join(import.meta.dir, '..', '..', 'site.rip') waitFor = (fn, timeout = 5000) -> deadline = Date.now() + timeout @@ -18,7 +18,7 @@ waitFor = (fn, timeout = 5000) -> sleep! 10 test! 'App watcher reports configured files and reconciles directory ambiguity', -> - dir = mkdtempSync join(tmpdir(), 'rip-app-watch-filter-') + dir = mkdtempSync join(tmpdir(), 'rip-sites-watch-filter-') try mkdirSync join(dir, 'assets') graph = { directories: ['assets', 'removed'] } @@ -44,7 +44,7 @@ test! 'App watcher reports configured files and reconciles directory ambiguity', rmSync dir, { recursive: true, force: true } test! 'App publication rehashes only watcher-reported configured files', -> - dir = mkdtempSync join(tmpdir(), 'rip-app-dirty-files-') + dir = mkdtempSync join(tmpdir(), 'rip-sites-dirty-files-') try mkdirSync join(dir, 'routes') route = "export Index = component\n render\n h1 'Stable'\n" @@ -205,7 +205,7 @@ test! 'watch publication commits one App hash then batches confirmed changes', - proc = Bun.spawn [process.execPath, PRELOAD, SERVER, project, '--watch', '--host', 'watch.test', '--control', controlSocket, '--access-log=off'], stdin: 'ignore' stdout: 'ignore' - stderr: 'inherit' + stderr: 'ignore' waitFor! -> registration? dist = join project, 'dist' bundle = JSON.parse readFileSync(join(dist, 'bundle.json'), 'utf8') diff --git a/packages/server/test/janus/README.md b/packages/sites/test/janus/README.md similarity index 76% rename from packages/server/test/janus/README.md rename to packages/sites/test/janus/README.md index 6db8a6fc..a4958d9b 100644 --- a/packages/server/test/janus/README.md +++ b/packages/sites/test/janus/README.md @@ -1,7 +1,7 @@ # janus -`test.rip` runs Rip Server through a Janus-enabled Caddy binary built from the -published `v1.5.0` module. It rejects a local module replacement and certifies: +`test.rip` runs Rip Sites through a Janus-enabled Caddy binary built from the +published `v1.6.1` module. It rejects a local module replacement and certifies: - registration, heartbeat survival, doorbell activation, and upstream swap; - generated shell, bundle, App CSS, weak ETag revalidation, and SPA routing; @@ -15,11 +15,11 @@ Run it with: bun run test:janus ``` -The test builds and caches a Caddy binary from the published Janus `v1.5.0` +The test builds and caches a Caddy binary from the published Janus `v1.6.1` module with `xcaddy`. `JANUS_CADDY=/path/to/released/caddy` may supply an existing binary instead. In both cases the test rejects a local module replacement. Directory browsing is a Janus-owned process lease and access observation is a -separate Rip Server client; their tests belong to the capabilities +separate Rip Sites client; their tests belong to the capabilities that consume those interfaces. diff --git a/packages/server/test/janus/test.rip b/packages/sites/test/janus/test.rip similarity index 94% rename from packages/server/test/janus/test.rip rename to packages/sites/test/janus/test.rip index 75734819..3de89a3c 100644 --- a/packages/server/test/janus/test.rip +++ b/packages/sites/test/janus/test.rip @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { AccessFramer } from '../../monitor.rip' PRELOAD = process.execArgv.find (arg) -> arg.startsWith('--preload=') -SERVER = join(import.meta.dir, '..', '..', 'server.rip') +SERVER = join(import.meta.dir, '..', '..', 'site.rip') PROJECT = join(import.meta.dir, '..', 'reloads', 'project') releasedCaddy = -> @@ -13,17 +13,17 @@ releasedCaddy = -> xcaddy = Bun.which 'xcaddy' throw Error.new('test:janus requires xcaddy or JANUS_CADDY') unless xcaddy? cache = join tmpdir(), 'rip-janus-release' - binary = join cache, 'caddy-v1.5.0-access' + binary = join cache, 'caddy-v1.6.1-access' return binary if existsSync binary mkdirSync cache, { recursive: true } buildDir = mkdtempSync join(cache, 'build-') candidate = join buildDir, 'caddy' try - result = Bun.spawnSync [xcaddy, 'build', '--with', 'github.com/shreeve/janus@v1.5.0', '--output', candidate], + result = Bun.spawnSync [xcaddy, 'build', '--with', 'github.com/shreeve/janus@v1.6.1', '--output', candidate], cwd: buildDir stdout: 'pipe' stderr: 'pipe' - throw Error.new("cannot build published Janus v1.5.0:\n#{result.stderr.toString()}") unless result.exitCode is 0 + throw Error.new("cannot build published Janus v1.6.1:\n#{result.stderr.toString()}") unless result.exitCode is 0 renameSync candidate, binary finally rmSync buildDir, { recursive: true, force: true } @@ -45,14 +45,14 @@ test! 'released Janus carries registration, public-file isolation, sendfile, rel info = Bun.spawnSync [CADDY, 'build-info'] build = info.stdout.toString() eq info.exitCode, 0, info.stderr.toString() - ok build.includes("github.com/shreeve/janus\tv1.5.0"), build - ok not build.includes("github.com/shreeve/janus\tv1.5.0 =>"), 'integration requires the published module, not a local replacement' + ok build.includes("github.com/shreeve/janus\tv1.6.1"), build + ok not build.includes("github.com/shreeve/janus\tv1.6.1 =>"), 'integration requires the published module, not a local replacement' dir = mkdtempSync join(tmpdir(), 'rip-janus-release-') project = join dir, 'project' cpSync PROJECT, project, { recursive: true } mkdirSync join(project, 'node_modules', '@rip-lang'), { recursive: true } - symlinkSync join(import.meta.dir, '..', '..'), join(project, 'node_modules', '@rip-lang', 'server'), 'dir' + symlinkSync join(import.meta.dir, '..', '..'), join(project, 'node_modules', '@rip-lang', 'sites'), 'dir' privateFile = join project, 'private.txt' writeFileSync privateFile, 'authorized bytes' mkdirSync join(project, 'data') @@ -62,7 +62,7 @@ test! 'released Janus carries registration, public-file isolation, sendfile, rel writeFileSync join(project, 'app', 'styles.css'), "body { color: navy; }\n" writeFileSync join(project, 'index.rip'), """ # api-source-secret - import { get, start } from '@rip-lang/server' + import { get, start } from '@rip-lang/sites' get '/api/version' -> { version: 'one' } get '/api/send' -> new Response null, { headers: { 'X-Sendfile': #{JSON.stringify(privateFile)} } } @@ -209,7 +209,7 @@ test! 'released Janus carries registration, public-file isolation, sendfile, rel eq alive.json!, { version: 'one' } writeFileSync join(project, 'index.rip'), """ - import { get, start } from '@rip-lang/server' + import { get, start } from '@rip-lang/sites' get '/api/version' -> { version: 'two' } get '/api/send' -> new Response null, { headers: { 'X-Sendfile': #{JSON.stringify(privateFile)} } } diff --git a/packages/server/test/manager-boundary/test.rip b/packages/sites/test/manager-boundary/test.rip similarity index 87% rename from packages/server/test/manager-boundary/test.rip rename to packages/sites/test/manager-boundary/test.rip index 121a8f7b..7baf9074 100644 --- a/packages/server/test/manager-boundary/test.rip +++ b/packages/sites/test/manager-boundary/test.rip @@ -1,10 +1,10 @@ import { test, eq, ok } from '@rip-lang/testing' -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' PRELOAD = process.execArgv.find (arg) -> arg.startsWith '--preload=' -SERVER = join(import.meta.dir, '..', '..', 'server.rip') +SERVER = join(import.meta.dir, '..', '..', 'site.rip') waitFor = (name, fn, timeout = 7000) -> deadline = Date.now() + timeout @@ -182,6 +182,63 @@ test! 'serve.rip selects a custom App root and change policy', -> control.stop true rmSync dir, { recursive: true, force: true } +test! 'serve.rip hub.bridge normalizes Bam names onto registration', -> + dir = mkdtempSync join(tmpdir(), 'rip-manager-hub-') + project = join dir, 'project' + mkdirSync join(project, 'app'), { recursive: true } + mkdirSync join(project, 'node_modules', '@rip-lang'), { recursive: true } + writeFileSync join(project, 'index.rip'), """ + import { get, start, hub } from '@rip-lang/sites' + hub bridge: 'hub', auth: false + get '/api/ok' -> 'ok' + start! + """ + mkdirSync join(project, 'app', 'routes'), { recursive: true } + writeFileSync join(project, 'app', 'routes', 'index.rip'), "export Index = component\n render\n h1 'Hub'\n" + writeFileSync join(project, 'serve.rip'), """ + export default + hub: + bridge: 'hub' + app: + root: 'app' + """ + symlinkSync join(import.meta.dir, '..', '..'), join(project, 'node_modules', '@rip-lang', 'sites'), 'dir' + calls = [] + controlSocket = join dir, 'janus.sock' + control = startControl controlSocket, calls + process = null + try + { proc: process } = run project, [project, '--control', controlSocket, '--no-watch', '--access-log=off', '--workers', '1', '--host', 'hub.example.test'] + waitFor! 'hub registration', -> + calls.some (call) -> call.method is 'POST' and call.path is '/1.0/apps' + body = calls.find((call) -> call.method is 'POST' and call.path is '/1.0/apps').body + eq body.bridge, '/hub' + ok body.files.proxy_first.includes('/api') + ok body.files.proxy_first.includes('/hub') + process.kill 'SIGTERM' + eq await process.exited, 143 + finally + try process?.kill 'SIGKILL' + await process.exited if process? + control.stop true + rmSync dir, { recursive: true, force: true } + +test! 'serve.rip rejects hub.bridge without an API entry', -> + dir = mkdtempSync join(tmpdir(), 'rip-manager-hub-app-only-') + project = join dir, 'project' + mkdirSync join(project, 'app'), { recursive: true } + writeFileSync join(project, 'app', 'index.rip'), "export default -> 'ok'\n" + writeFileSync join(project, 'serve.rip'), "export default { hub: { bridge: 'hub' }, app: { root: 'app' } }\n" + controlSocket = join dir, 'janus.sock' + control = startControl controlSocket, [] + try + { proc, err } = run project, [project, '--control', controlSocket, '--no-watch', '--access-log=off'] + eq await proc.exited, 1 + ok (await err).includes('hub.bridge requires an API entry') + finally + control.stop true + rmSync dir, { recursive: true, force: true } + test! 'serve.rip rejects a change pattern assigned to the wrong apply kind', -> dir = mkdtempSync join(tmpdir(), 'rip-manager-invalid-changes-') project = join dir, 'project' @@ -353,6 +410,10 @@ test! 'control commands reject every startup-only option', -> ['--control', join(dir, 'janus.sock')] ['--watch'] ['--no-watch'] + ['--watch-app'] + ['--no-watch-app'] + ['--watch-api'] + ['--no-watch-api'] ['--allow-watch'] ['--eager'] ['--workers', '1'] @@ -403,7 +464,7 @@ test! 'stop command gracefully deregisters and exits the canonical manager', -> operation: 'Active' stopping: true workers: 0 - watch: false + watch: { app: false, api: false } migration: null ok calls.some((call) -> call.method is 'DELETE' and call.path is '/1.0/apps/registration-1') finally diff --git a/packages/server/test/middleware/README.md b/packages/sites/test/middleware/README.md similarity index 100% rename from packages/server/test/middleware/README.md rename to packages/sites/test/middleware/README.md diff --git a/packages/server/test/middleware/test.rip b/packages/sites/test/middleware/test.rip similarity index 98% rename from packages/server/test/middleware/test.rip rename to packages/sites/test/middleware/test.rip index 1b821871..a582bd43 100644 --- a/packages/server/test/middleware/test.rip +++ b/packages/sites/test/middleware/test.rip @@ -2,7 +2,7 @@ import { test, eq, ok, throws } from '@rip-lang/testing' import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { cors, csrf, htmlJson, secureHeaders, sessions, timeout } from '@rip-lang/server/middleware' +import { cors, csrf, htmlJson, secureHeaders, sessions, timeout } from '@rip-lang/sites/middleware' PRELOAD = process.execArgv.find (arg) -> arg.startsWith('--preload=') GENERATOR = join(import.meta.dir, '..', '..', 'generate.rip') @@ -56,10 +56,10 @@ runMode = (encrypt) -> workerArtifact = join dir, 'worker.js' socket = join dir, 'worker.sock' mkdirSync join(dir, 'node_modules', '@rip-lang'), { recursive: true } - symlinkSync PACKAGE, join(dir, 'node_modules', '@rip-lang', 'server'), 'dir' + symlinkSync PACKAGE, join(dir, 'node_modules', '@rip-lang', 'sites'), 'dir' writeFileSync entry, """ - import { get, post, session, start, use } from '@rip-lang/server' - import { cors, csrf, sessions, timeout } from '@rip-lang/server/middleware' + import { get, post, session, start, use } from '@rip-lang/sites' + import { cors, csrf, sessions, timeout } from '@rip-lang/sites/middleware' use cors origin: 'https://client.test', credentials: true use sessions secret: 'session-secret', encrypt: #{encrypt} diff --git a/packages/server/test/monitor/README.md b/packages/sites/test/monitor/README.md similarity index 100% rename from packages/server/test/monitor/README.md rename to packages/sites/test/monitor/README.md diff --git a/packages/server/test/monitor/test.rip b/packages/sites/test/monitor/test.rip similarity index 99% rename from packages/server/test/monitor/test.rip rename to packages/sites/test/monitor/test.rip index 7cf2a5f9..6551ff5c 100644 --- a/packages/server/test/monitor/test.rip +++ b/packages/sites/test/monitor/test.rip @@ -12,7 +12,7 @@ import { encoder = TextEncoder.new() PRELOAD = process.execArgv.find (arg) -> arg.startsWith '--preload=' -SERVER = join(import.meta.dir, '..', '..', 'server.rip') +SERVER = join(import.meta.dir, '..', '..', 'site.rip') ENTRY = join(import.meta.dir, '..', 'hello-api', 'index.rip') record = (changes = {}) -> @@ -905,8 +905,8 @@ test! 'foreground raw stdout is pure NDJSON and off opens no subscription', -> raw = run! 'raw' eq raw.output, TextDecoder.new().decode(Uint8Array.new([...helloLine(), ...accessLine(sequence: '1')])) eq raw.accessCount, 1 - ok raw.diagnostic.includes 'rip-server: https://cart.test/' + ok raw.diagnostic.includes 'rip-site: https://cart.test/' disabled = run! 'off' eq disabled.accessCount, 0 - ok disabled.output.includes 'rip-server: https://cart.test/' + ok disabled.output.includes 'rip-site: https://cart.test/' diff --git a/packages/server/test/operations/README.md b/packages/sites/test/operations/README.md similarity index 100% rename from packages/server/test/operations/README.md rename to packages/sites/test/operations/README.md diff --git a/packages/server/test/operations/test.rip b/packages/sites/test/operations/test.rip similarity index 97% rename from packages/server/test/operations/test.rip rename to packages/sites/test/operations/test.rip index 40192d97..702eaa8c 100644 --- a/packages/server/test/operations/test.rip +++ b/packages/sites/test/operations/test.rip @@ -4,7 +4,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' PRELOAD = process.execArgv.find (arg) -> arg.startsWith '--preload=' -SERVER = join(import.meta.dir, '..', '..', 'server.rip') +SERVER = join(import.meta.dir, '..', '..', 'site.rip') RELOAD_PROJECT = join(import.meta.dir, '..', 'reloads', 'project') FILE_DB = ''' @@ -87,7 +87,7 @@ test! 'hold fences App publication, API replacement, and client messages', -> project = join dir, 'project' cpSync RELOAD_PROJECT, project, { recursive: true } mkdirSync join(project, 'node_modules', '@rip-lang'), { recursive: true } - symlinkSync join(import.meta.dir, '..', '..'), join(project, 'node_modules', '@rip-lang', 'server'), 'dir' + symlinkSync join(import.meta.dir, '..', '..'), join(project, 'node_modules', '@rip-lang', 'sites'), 'dir' controlSocket = join dir, 'janus.sock' registration = null updates = [] @@ -133,7 +133,7 @@ test! 'hold fences App publication, API replacement, and client messages', -> eq ((unixFetch!('/version', first)).json!), { version: 'one' } writeFileSync join(project, 'index.rip'), """ - import { get, start } from '@rip-lang/server' + import { get, start } from '@rip-lang/sites' get '/version' -> { version: 'held-change' } start! """ @@ -168,7 +168,7 @@ test! 'coordinated migration cuts admission and unresolved work recovers durably project = join dir, 'project' cpSync RELOAD_PROJECT, project, { recursive: true } mkdirSync join(project, 'node_modules', '@rip-lang'), { recursive: true } - symlinkSync join(import.meta.dir, '..', '..'), join(project, 'node_modules', '@rip-lang', 'server'), 'dir' + symlinkSync join(import.meta.dir, '..', '..'), join(project, 'node_modules', '@rip-lang', 'sites'), 'dir' writeFileSync join(project, 'filedb.mjs'), FILE_DB writeFileSync join(project, 'models.rip'), MODELS mkdirSync join(project, 'migrations') @@ -215,7 +215,7 @@ test! 'coordinated migration cuts admission and unresolved work recovers durably waitFor! -> registration? command! 'hold', project writeFileSync join(project, 'index.rip'), """ - import { get, start } from '@rip-lang/server' + import { get, start } from '@rip-lang/sites' get '/version' -> { version: 'migrated' } start! """ @@ -228,7 +228,7 @@ test! 'coordinated migration cuts admission and unresolved work recovers durably updates.some (update) -> update.upstreams.length is 0 eq migrationProc.exitCode, null writeFileSync join(project, 'index.rip'), """ - import { get, start } from '@rip-lang/server' + import { get, start } from '@rip-lang/sites' get '/version' -> { version: 'reconciled' } get '/crash' -> process.exit 7 start! @@ -257,7 +257,7 @@ test! 'coordinated migration cuts admission and unresolved work recovers durably command! 'hold', project writeFileSync join(project, 'index.rip'), """ - import { get, start } from '@rip-lang/server' + import { get, start } from '@rip-lang/sites' get '/version' -> { version: 'recovered' } start! """ diff --git a/packages/server/test/publication/test.rip b/packages/sites/test/publication/test.rip similarity index 100% rename from packages/server/test/publication/test.rip rename to packages/sites/test/publication/test.rip diff --git a/packages/server/test/reloads/README.md b/packages/sites/test/reloads/README.md similarity index 100% rename from packages/server/test/reloads/README.md rename to packages/sites/test/reloads/README.md diff --git a/packages/server/test/reloads/project/app/routes/index.rip b/packages/sites/test/reloads/project/app/routes/index.rip similarity index 100% rename from packages/server/test/reloads/project/app/routes/index.rip rename to packages/sites/test/reloads/project/app/routes/index.rip diff --git a/packages/sites/test/reloads/project/index.rip b/packages/sites/test/reloads/project/index.rip new file mode 100644 index 00000000..c9b3e1b1 --- /dev/null +++ b/packages/sites/test/reloads/project/index.rip @@ -0,0 +1,4 @@ +import { get, start } from '@rip-lang/sites' + +get '/version' -> { version: 'one' } +start! diff --git a/packages/sites/test/reloads/test.rip b/packages/sites/test/reloads/test.rip new file mode 100644 index 00000000..3e884c4e --- /dev/null +++ b/packages/sites/test/reloads/test.rip @@ -0,0 +1,223 @@ +import { test, eq, ok } from '@rip-lang/testing' +import { cpSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +PRELOAD = process.execArgv.find (arg) -> arg.startsWith '--preload=' +SERVER = join(import.meta.dir, '..', '..', 'site.rip') + +waitFor = (fn, timeout = 5000) -> + deadline = Date.now() + timeout + loop + value = fn! + return value if value + throw Error.new('reload test timed out') if Date.now() >= deadline + sleep! 10 + +unixFetch = (path, socket) -> + options = { unix: socket } + fetch "http://worker#{path}", options + +test! 'App edits preserve workers and valid API candidates swap through the doorbell', -> + dir = mkdtempSync join(tmpdir(), 'rip-reloads-') + project = join dir, 'project' + cpSync join(import.meta.dir, 'project'), project, { recursive: true } + mkdirSync join(project, 'node_modules', '@rip-lang'), { recursive: true } + symlinkSync join(import.meta.dir, '..', '..'), join(project, 'node_modules', '@rip-lang', 'sites'), 'dir' + controlSocket = join dir, 'janus.sock' + registration = null + updates = [] + messages = [] + control = Bun.serve + unix: controlSocket + fetch: (request) -> + url = URL.new request.url + if request.method is 'POST' and url.pathname is '/1.0/apps' + registration = request.json! + return Response.json({ id: 'reload-registration' }, { status: 201 }) + if request.method is 'PUT' and url.pathname is '/1.0/apps/reload-registration/upstreams' + body = request.json! + updates.push body.upstreams + return Response.new(null, { status: 204 }) + if request.method is 'POST' and url.pathname is '/1.0/apps/reload-registration/hub/publish' + messages.push ...(request.json!) + return Response.json({ deliveries: 0 }) + Response.new(null, { status: 204 }) + proc = null + diagnostic = null + try + proc = Bun.spawn [process.execPath, PRELOAD, SERVER, project, '--watch', '--workers', '1', '--host', 'reload.test', '--control', controlSocket, '--access-log=off'], + stdin: 'ignore' + stdout: 'ignore' + stderr: 'pipe' + diagnostic = Response.new(proc.stderr).text() + waitFor! -> registration? + first = registration.upstreams[0].path + eq ((unixFetch!('/version', first)).json!), { version: 'one' } + + writeFileSync join(project, 'app', 'routes', 'index.rip'), """ + export Index = component + render + h1 'Reload two' + """ + waitFor! -> messages.length > 0 + eq updates.length, 0 + eq ((unixFetch!('/version', first)).json!), { version: 'one' } + + writeFileSync join(project, 'index.rip'), 'this is not valid Rip @@@' + sleep! 300 + eq updates.length, 0 + eq ((unixFetch!('/version', first)).json!), { version: 'one' } + + writeFileSync join(project, 'index.rip'), """ + import { get, start } from '@rip-lang/sites' + sleep! 1000 + get '/version' -> { version: 'two' } + start! + """ + doorbell = waitFor! -> updates.find((list) -> list.length is 1 and list[0].doorbell is true) + ring = unixFetch '/ring', doorbell[0].path + sleep! 50 + writeFileSync join(project, 'index.rip'), """ + import { get, start } from '@rip-lang/sites' + get '/version' -> { version: 'three' } + start! + """ + fresh = waitFor! -> updates.find((list) -> list.length is 1 and not list[0].doorbell and list[0].path isnt first) + eq (await ring).status, 204 + eq ((unixFetch!('/version', fresh[0].path)).json!), { version: 'three' } + + proc.kill 'SIGTERM' + eq (await proc.exited), 143 + output = await diagnostic + ok output.includes 'API candidate rejected; keeping current workers' + ok output.includes 'this is not valid Rip @@@' + finally + try proc?.kill('SIGKILL') + await proc.exited if proc? + await diagnostic if diagnostic? + control.stop true + rmSync dir, { recursive: true, force: true } + +test! 'App-only watch publishes Hub changes without API upstream swaps', -> + dir = mkdtempSync join(tmpdir(), 'rip-reloads-app-only-') + project = join dir, 'project' + cpSync join(import.meta.dir, 'project'), project, { recursive: true } + mkdirSync join(project, 'node_modules', '@rip-lang'), { recursive: true } + symlinkSync join(import.meta.dir, '..', '..'), join(project, 'node_modules', '@rip-lang', 'sites'), 'dir' + controlSocket = join dir, 'janus.sock' + registration = null + updates = [] + messages = [] + control = Bun.serve + unix: controlSocket + fetch: (request) -> + url = URL.new request.url + if request.method is 'POST' and url.pathname is '/1.0/apps' + registration = request.json! + return Response.json({ id: 'app-only-registration' }, { status: 201 }) + if request.method is 'PUT' and url.pathname is '/1.0/apps/app-only-registration/upstreams' + updates.push request.json!.upstreams + return Response.new(null, { status: 204 }) + if request.method is 'POST' and url.pathname is '/1.0/apps/app-only-registration/hub/publish' + messages.push ...(request.json!) + return Response.json({ deliveries: 0 }) + Response.new(null, { status: 204 }) + proc = null + try + proc = Bun.spawn [process.execPath, PRELOAD, SERVER, project, '--watch-app', '--no-watch-api', '--workers', '1', '--host', 'app-only.test', '--control', controlSocket, '--access-log=off'], + stdin: 'ignore' + stdout: 'ignore' + stderr: 'inherit' + waitFor! -> registration? + first = registration.upstreams[0].path + eq ((unixFetch!('/version', first)).json!), { version: 'one' } + + writeFileSync join(project, 'app', 'routes', 'index.rip'), """ + export Index = component + render + h1 'App only' + """ + waitFor! -> messages.some (message) -> message.change? + eq updates.length, 0 + + writeFileSync join(project, 'index.rip'), """ + import { get, start } from '@rip-lang/sites' + get '/version' -> { version: 'two' } + start! + """ + sleep! 300 + eq updates.length, 0 + eq ((unixFetch!('/version', first)).json!), { version: 'one' } + + proc.kill 'SIGTERM' + eq (await proc.exited), 143 + finally + try proc?.kill('SIGKILL') + await proc.exited if proc? + control.stop true + rmSync dir, { recursive: true, force: true } + +test! 'API-only watch swaps workers without App Hub publication', -> + dir = mkdtempSync join(tmpdir(), 'rip-reloads-api-only-') + project = join dir, 'project' + cpSync join(import.meta.dir, 'project'), project, { recursive: true } + mkdirSync join(project, 'node_modules', '@rip-lang'), { recursive: true } + symlinkSync join(import.meta.dir, '..', '..'), join(project, 'node_modules', '@rip-lang', 'sites'), 'dir' + controlSocket = join dir, 'janus.sock' + registration = null + updates = [] + messages = [] + control = Bun.serve + unix: controlSocket + fetch: (request) -> + url = URL.new request.url + if request.method is 'POST' and url.pathname is '/1.0/apps' + registration = request.json! + return Response.json({ id: 'api-only-registration' }, { status: 201 }) + if request.method is 'PUT' and url.pathname is '/1.0/apps/api-only-registration/upstreams' + updates.push request.json!.upstreams + return Response.new(null, { status: 204 }) + if request.method is 'POST' and url.pathname is '/1.0/apps/api-only-registration/hub/publish' + messages.push ...(request.json!) + return Response.json({ deliveries: 0 }) + Response.new(null, { status: 204 }) + proc = null + try + proc = Bun.spawn [process.execPath, PRELOAD, SERVER, project, '--watch-api', '--no-watch-app', '--workers', '1', '--host', 'api-only.test', '--control', controlSocket, '--access-log=off'], + stdin: 'ignore' + stdout: 'ignore' + stderr: 'inherit' + waitFor! -> registration? + first = registration.upstreams[0].path + eq ((unixFetch!('/version', first)).json!), { version: 'one' } + + writeFileSync join(project, 'app', 'routes', 'index.rip'), """ + export Index = component + render + h1 'API only' + """ + sleep! 300 + eq messages.length, 0 + eq updates.length, 0 + + writeFileSync join(project, 'index.rip'), """ + import { get, start } from '@rip-lang/sites' + sleep! 1000 + get '/version' -> { version: 'two' } + start! + """ + doorbell = waitFor! -> updates.find((list) -> list.length is 1 and list[0].doorbell is true) + ring = unixFetch '/ring', doorbell[0].path + fresh = waitFor! -> updates.find((list) -> list.length is 1 and not list[0].doorbell and list[0].path isnt first) + eq (await ring).status, 204 + eq ((unixFetch!('/version', fresh[0].path)).json!), { version: 'two' } + eq messages.length, 0 + + proc.kill 'SIGTERM' + eq (await proc.exited), 143 + finally + try proc?.kill('SIGKILL') + await proc.exited if proc? + control.stop true + rmSync dir, { recursive: true, force: true } diff --git a/packages/server/test/test.rip b/packages/sites/test/test.rip similarity index 100% rename from packages/server/test/test.rip rename to packages/sites/test/test.rip diff --git a/packages/server/test/workers/README.md b/packages/sites/test/workers/README.md similarity index 100% rename from packages/server/test/workers/README.md rename to packages/sites/test/workers/README.md diff --git a/packages/server/test/workers/crash.rip b/packages/sites/test/workers/crash.rip similarity index 83% rename from packages/server/test/workers/crash.rip rename to packages/sites/test/workers/crash.rip index a9d94f90..a86d6c0b 100644 --- a/packages/server/test/workers/crash.rip +++ b/packages/sites/test/workers/crash.rip @@ -1,5 +1,5 @@ import { existsSync, writeFileSync } from 'node:fs' -import { get, start } from '@rip-lang/server' +import { get, start } from '@rip-lang/sites' marker = process.env.RIP_TEST_CRASH_ONCE if marker? and not existsSync(marker) diff --git a/packages/server/test/workers/failed.rip b/packages/sites/test/workers/failed.rip similarity index 100% rename from packages/server/test/workers/failed.rip rename to packages/sites/test/workers/failed.rip diff --git a/packages/server/test/workers/hanging.rip b/packages/sites/test/workers/hanging.rip similarity index 100% rename from packages/server/test/workers/hanging.rip rename to packages/sites/test/workers/hanging.rip diff --git a/packages/server/test/workers/index.rip b/packages/sites/test/workers/index.rip similarity index 77% rename from packages/server/test/workers/index.rip rename to packages/sites/test/workers/index.rip index 4d7c0479..2e4ffd06 100644 --- a/packages/server/test/workers/index.rip +++ b/packages/sites/test/workers/index.rip @@ -1,4 +1,4 @@ -import { get, start } from '@rip-lang/server' +import { get, start } from '@rip-lang/sites' get '/hello' -> { message: 'hello' } get '/slow' -> diff --git a/packages/server/test/workers/parent.mjs b/packages/sites/test/workers/parent.mjs similarity index 100% rename from packages/server/test/workers/parent.mjs rename to packages/sites/test/workers/parent.mjs diff --git a/packages/server/test/workers/test.rip b/packages/sites/test/workers/test.rip similarity index 99% rename from packages/server/test/workers/test.rip rename to packages/sites/test/workers/test.rip index 0c6cfc18..d66beaea 100644 --- a/packages/server/test/workers/test.rip +++ b/packages/sites/test/workers/test.rip @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' PRELOAD = process.execArgv.find (arg) -> arg.startsWith '--preload=' GENERATOR = join(import.meta.dir, '..', '..', 'generate.rip') WORKER = join(import.meta.dir, '..', '..', 'worker.rip') -SERVER = join(import.meta.dir, '..', '..', 'server.rip') +SERVER = join(import.meta.dir, '..', '..', 'site.rip') generate = (entry, artifact, result, task) -> proc = Bun.spawn [process.execPath, PRELOAD, GENERATOR, entry, artifact, result, task, '0'], @@ -40,7 +40,7 @@ temporaryProject = (dir, fixture) -> project = join dir, 'project' mkdirSync join(project, 'node_modules', '@rip-lang'), { recursive: true } copyFileSync join(import.meta.dir, fixture), join(project, 'index.rip') - symlinkSync join(import.meta.dir, '..', '..'), join(project, 'node_modules', '@rip-lang', 'server'), 'dir' + symlinkSync join(import.meta.dir, '..', '..'), join(project, 'node_modules', '@rip-lang', 'sites'), 'dir' project spawnWorker = (built, socket, env = {}) -> diff --git a/packages/server/worker.rip b/packages/sites/worker.rip similarity index 95% rename from packages/server/worker.rip rename to packages/sites/worker.rip index 6ca8de9a..f8ed0ff9 100644 --- a/packages/server/worker.rip +++ b/packages/sites/worker.rip @@ -1,5 +1,5 @@ -artifact = process.env.APP_ARTIFACT or abort 'rip-server worker: APP_ARTIFACT is required' -socket = process.env.SOCKET_PATH or abort 'rip-server worker: SOCKET_PATH is required' +artifact = process.env.APP_ARTIFACT or abort 'rip-site worker: APP_ARTIFACT is required' +socket = process.env.SOCKET_PATH or abort 'rip-site worker: SOCKET_PATH is required' workerId = process.env.WORKER_ID or '?' RECYCLE = :rip.server.recycle diff --git a/packages/tray/README.md b/packages/tray/README.md index 42327177..bcf738b5 100644 --- a/packages/tray/README.md +++ b/packages/tray/README.md @@ -191,10 +191,10 @@ files read by provider code. This package ships [`rip-color.svg`](assets/rip-color.svg) and [`rip-template.svg`](assets/rip-template.svg) as ready-to-use Rip artwork. -## Rip Apps Tray +## Rip Sites Tray The package's executable [`tray.rip`](tray.rip) mode is the first complete tray -app. It uses only the public `rip edge` and `rip app` commands to: +app. It uses only the public `rip edge` and `rip sites` commands to: - show whether the shared Caddy/Janus edge is stopped, external, or Rip-owned - start, stop, or reload the edge when Rip owns it @@ -225,7 +225,7 @@ executable: an accessory application with no Dock icon and one SwiftUI `MenuBarExtra` using the native window presentation. The host resolves and launches `rip`, decodes the provider's panel, renders it, presents native directory pickers and URLs, and returns action messages. It contains no -knowledge of Rip Server or the included provider. +knowledge of Rip Sites or the included provider. The built application still locates the machine's `rip` executable at runtime; `~/.bun/rip` and `~/.bun/bin/rip` are checked automatically. Run diff --git a/packages/tray/test.rip b/packages/tray/test.rip index 31db34a5..57c0fb22 100644 --- a/packages/tray/test.rip +++ b/packages/tray/test.rip @@ -20,7 +20,7 @@ test 'has no runtime dependencies and keeps the native host in the package', -> ok existsSync "#{import.meta.dir}/assets/rip-template.svg" ok readFileSync("#{import.meta.dir}/macos/Package.swift", 'utf8').includes('RipTrayHost') -test 'the macOS host is generic and contains no Rip Apps policy', -> +test 'the macOS host is generic and contains no Rip Sites policy', -> manifest = readFileSync "#{import.meta.dir}/macos/Package.swift", 'utf8' source = readFileSync "#{import.meta.dir}/macos/Sources/RipTrayHost/RipTrayHost.swift", 'utf8' kit = readFileSync "#{import.meta.dir}/macos/Sources/TrayKit/TrayKit.swift", 'utf8' @@ -37,7 +37,7 @@ test 'the macOS host is generic and contains no Rip Apps policy', -> ok kit.includes 'PanelLeadingIcon(icon: item.icon, reservesSpace: reservesLeadingIconSpace)' ok kit.includes 'if let logo = provider.tray.logo {' ok not kit.includes 'provider.tray.logo ?? provider.tray.icon' - ok not /rip app|rip edge|Caddy|Janus/.test(source) + ok not /rip sites|rip edge|Caddy|Janus/.test(source) test 'the native host builds and exercises its protocol on macOS', -> if process.platform isnt 'darwin' @@ -196,6 +196,6 @@ test! 'the persistent Rip provider handles an action and publishes the new menu' finally rmSync root, { recursive: true, force: true } -test 'the framework and its executable Rip Apps mode compile as one ordinary Rip file', -> +test 'the framework and its executable Rip Sites mode compile as one ordinary Rip file', -> result = Bun.spawnSync ['rip', '-c', "#{import.meta.dir}/tray.rip"], { stdout: 'ignore', stderr: 'pipe' } eq result.exitCode, 0, result.stderr.toString() diff --git a/packages/tray/tray.rip b/packages/tray/tray.rip index 4ea37918..47dc88f2 100644 --- a/packages/tray/tray.rip +++ b/packages/tray/tray.rip @@ -213,8 +213,8 @@ export serve = (definition) -> clearInterval timer if timer? # Imported, this file is the reusable tray framework. Run directly, the same -# file is the Rip Apps tray backed only by the public `rip app`/`rip edge` CLI. -ripApps = -> +# file is the Rip Sites tray backed only by the public `rip sites`/`rip edge` CLI. +ripSites = -> rip = process.env.RIP_TRAY_RIP or 'rip' command = (args) -> @@ -230,8 +230,8 @@ ripApps = -> json = (...args) -> JSON.parse command! args capitalize = (text) -> text[0].toUpperCase() + text.slice(1) - openLog = (app) -> - log = json! 'app', 'logs', app.name, '--lines', '1', '--json' + openLog = (site) -> + log = json! 'sites', 'logs', site.name, '--lines', '1', '--json' result = Bun.spawnSync ['open', log.path], { stdout: 'ignore', stderr: 'pipe' } throw Error.new(result.stderr.toString().trim() or "cannot open #{log.path}") unless result.exitCode is 0 @@ -248,30 +248,30 @@ ripApps = -> items.push action 'Start', id: 'edge.start', icon: 'play.fill', run: -> json! 'edge', 'start', '--json' submenu 'Edge', items, id: 'edge', icon: 'network' - appMenu = (app) -> - running = app.state is 'running' - items = [label app.root, subtitle: app.state] + siteMenu = (site) -> + running = site.state is 'running' + items = [label site.root, subtitle: site.state] if running - items.push link 'Open', app.url, id: "#{app.id}.open", icon: 'safari' if app.url? - items.push action 'Stop', id: "#{app.id}.stop", icon: 'stop.fill', run: -> json! 'app', 'stop', app.id, '--json' - items.push action 'Restart', id: "#{app.id}.restart", icon: 'arrow.clockwise', run: -> json! 'app', 'restart', app.id, '--json' + items.push link 'Open', site.url, id: "#{site.id}.open", icon: 'safari' if site.url? + items.push action 'Stop', id: "#{site.id}.stop", icon: 'stop.fill', run: -> json! 'sites', 'stop', site.id, '--json' + items.push action 'Restart', id: "#{site.id}.restart", icon: 'arrow.clockwise', run: -> json! 'sites', 'restart', site.id, '--json' else - items.push action 'Start', id: "#{app.id}.start", icon: 'play.fill', run: -> json! 'app', 'start', app.id, '--json' - items.push action 'Open Log', id: "#{app.id}.logs", icon: 'doc.text', run: -> openLog! app - submenu app.name, items, id: app.id, icon: if running then 'circle.fill' else 'circle' + items.push action 'Start', id: "#{site.id}.start", icon: 'play.fill', run: -> json! 'sites', 'start', site.id, '--json' + items.push action 'Open Log', id: "#{site.id}.logs", icon: 'doc.text', run: -> openLog! site + submenu site.name, items, id: site.id, icon: if running then 'circle.fill' else 'circle' definition = tray title: 'Rip' icon: svgFile "#{import.meta.dir}/assets/rip-template.svg" logo: svgFile "#{import.meta.dir}/assets/rip-color.svg", template: false - tooltip: 'Rip Apps' + tooltip: 'Rip Sites' refresh: 2 menu: -> edge = json! 'edge', 'status', '--json' - apps = json! 'app', 'status', '--json' + sites = json! 'sites', 'status', '--json' items = [edgeMenu(edge), separator()] - items.push appMenu(app) for app in apps - items.push directory 'Add App…', id: 'apps.add', icon: 'plus', prompt: 'Add', run: (path) -> json! 'app', 'add', path, '--json' + items.push siteMenu(site) for site in sites + items.push directory 'Add Site…', id: 'sites.add', icon: 'plus', prompt: 'Add', run: (path) -> json! 'sites', 'add', path, '--json' items.push separator() items.push action 'Refresh', id: 'tray.refresh', icon: 'arrow.clockwise', run: -> null items.push quit 'Quit', icon: 'power' @@ -279,4 +279,4 @@ ripApps = -> serve! definition -ripApps! if import.meta.main +ripSites! if import.meta.main diff --git a/scripts/test-all.mjs b/scripts/test-all.mjs index 18b467a6..c4e776e7 100644 --- a/scripts/test-all.mjs +++ b/scripts/test-all.mjs @@ -170,7 +170,7 @@ const planLanes = () => { // start cannot stretch the wall clock past the root suite. Correctness // does not depend on the order. const weight = (lane) => - lane.label === ROOT_LANE ? 0 : lane.label === 'packages/vscode' ? 1 : lane.label === 'packages/server' ? 2 : 3; + lane.label === ROOT_LANE ? 0 : lane.label === 'packages/vscode' ? 1 : lane.label === 'packages/sites' ? 2 : 3; lanes.sort((a, b) => weight(a) - weight(b)); return { lanes, excluded }; diff --git a/src/browser-boot.js b/src/browser-boot.js index 9f8dc773..3779aece 100644 --- a/src/browser-boot.js +++ b/src/browser-boot.js @@ -1,4 +1,4 @@ -// Browser publication consumer. Rip Server publishes {hash,list}; this layer +// Browser publication consumer. Rip Sites publishes {hash,list}; this layer // validates and compiles that complete Rip program, launches App from prepared // state, and optionally applies ordered watch changes. HTTP caching remains the // browser's job—boot does not keep its own ETag/body cache. diff --git a/test/toolchain/browser-boot.test.js b/test/toolchain/browser-boot.test.js index 557cadab..6f06ccb6 100644 --- a/test/toolchain/browser-boot.test.js +++ b/test/toolchain/browser-boot.test.js @@ -5,7 +5,7 @@ import { beforeAll, describe, expect, test } from 'bun:test'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { bootApp, fetchBundle } from '../../src/browser.js'; -import { assembleRipBundle } from '../../packages/server/bundle.rip'; +import { assembleRipBundle } from '../../packages/sites/bundle.rip'; import { installRecordingDOM } from '../support/recording-dom.js'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); diff --git a/test/toolchain/browser-modules.test.js b/test/toolchain/browser-modules.test.js index fdf4073a..8d0fc8ff 100644 --- a/test/toolchain/browser-modules.test.js +++ b/test/toolchain/browser-modules.test.js @@ -8,7 +8,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createModuleLoader } from '../../src/browser-modules.js'; -import { assembleBundle, assembleRipBundle } from '../../packages/server/bundle.rip'; +import { assembleBundle, assembleRipBundle } from '../../packages/sites/bundle.rip'; // The store comes from its own module, not the package entry: the // entry evaluates renderer.rip, which claims the process's one // render-gate construction capability — and that claim belongs to the diff --git a/test/toolchain/cli.test.js b/test/toolchain/cli.test.js index 86d43e22..08e4bea9 100644 --- a/test/toolchain/cli.test.js +++ b/test/toolchain/cli.test.js @@ -101,14 +101,14 @@ describe('cli: compile surface', () => { const h = rip(['--help']); expect(h.status).toBe(0); expect(h.stdout).toContain('Usage:'); - expect(h.stdout).toContain('rip app '); + expect(h.stdout).toContain('rip sites '); expect(h.stdout).toContain('rip edge '); }); - test('the app and edge appliance help is cwd-independent', () => { - const app = rip(['app', '--help']); - expect(app.status).toBe(0); - expect(app.stdout).toContain('rip app '); + test('the sites and edge appliance help is cwd-independent', () => { + const sites = rip(['sites', '--help']); + expect(sites.status).toBe(0); + expect(sites.stdout).toContain('rip sites '); const edge = rip(['edge', '--help']); expect(edge.status).toBe(0); expect(edge.stdout).toContain('rip edge '); diff --git a/test/toolchain/package-rip-style.test.js b/test/toolchain/package-rip-style.test.js index 6cab0af2..3e419ebc 100644 --- a/test/toolchain/package-rip-style.test.js +++ b/test/toolchain/package-rip-style.test.js @@ -27,7 +27,6 @@ test('authored Rip uses dammit calls, Ruby construction, and awaits only stored const violations = []; const files = [ ...ripFiles(join(ROOT, 'packages')), - ...ripFiles(join(ROOT, 'examples')), join(ROOT, 'src/grammar/solar.rip'), ];