Ship

Old Docs · `ship dev`
On this page · 7 sections

ship dev

Watched development for Ship projects. One command starts the app locally and rebuilds when app Ship sources change (entry points, shared src/, client and server packages). The Core stdlib is consumed from the compiler's external checked artifact — ordinary app edits do not re-scan or re-parse ship/stdlib.

Day-to-day iteration uses the ship-dev wrapper (root just dev, just qa, and app just dev recipes) alongside the persistent compiler service:

# Start one watcher only when status reports none.
just compiler-watch-status
just compiler-watch
just dev

Root, app, and example recipes use scripts/ship-compiler; do not invoke cargo run -p ship-cli or preface dev with ship check. just dev waits for the compiler generation it needs. --no-compiler-watch keeps one published compiler for the app-only loop instead of restarting when a newer compiler is published.

Compiler, runtime, and stdlib reload

The compiler watcher publishes each generation as described in 07-testing.md. A generation with unchanged toolchain inputs is adopted without restarting app processes. Otherwise ship-dev runs the newly published compiler as a child, and long-running Rust-owned native/static services use the runtime workers published with that same generation.

  • Ship app edits (.shp / .shc): ship dev re-emits artifacts. Browser programs hot-swap in place and keep the running model. Native server changes start a fresh backend on a private port, switch the stable public proxy only after readiness, then drain and stop the old backend.

  • Compiler, runtime, or JS/HTML template edits: ship-dev waits for the next successful generation and probes the project with ship dev --once before replacing its watcher. Failed project initialization keeps the prior generation running. Generation-owned Vite processes and native server backends still restart; persistent public listeners remain available and --open is passed only on the first spawn.

  • Static DEV servers: the stable ship-dev supervisor owns the server process. Replacement DEV graphs adopt the same checked root/host/port contract, so compiler handoff never releases the listening socket.

  • Failed compiler or stdlib edits: the service reports the failed generation without replacing the published toolchain, and ship-dev keeps the previous app process running.

  • Failed DEV initialization: the generation-specific child may exit, but the stable supervisor remains active, keeps persistent services alive, and watches project/docs sources. The next source or successful compiler change starts a fresh child instead of terminating the just dev command.

    Keeping Vite or a native application backend alive across a compiler replace would leave the next app edit compiling with the old ship binary, so those generation-specific processes still restart. The stable supervisor owns the public native-server proxy, while each backend runs the matching published ship-native-server worker and checked manifest. The Rust static server executes no app code and safely remains outside that boundary.

Generated static sites

A server(Static) owns generated static output through Ship's Rust runner:

server(Static) {
  port = 4173;
  generator = "bin/main.shp";
  static_root = "site";
  content_root = "../../docs";
}

The DEV graph starts the static runner independently from generation, so an existing static_root is served while project checking, content tests, and the native generator run. Independent service-lifecycle nodes are scheduled before finite initialization; this is graph semantics rather than a static-site special case. A missing root is created and begins serving as soon as files appear. Changes to .shp, .shc, or .md rebuild and rerun the generator. The runner watches static_root and signals connected pages to full-reload after output settles. Static docs have no client model to preserve, so a full page reload is the correct hot-reload behavior.

just site-bench repeatedly starts the ready Ship site DEV graph and measures process launch through the first successful HTTP response. It refuses to run when port 4173 is already serving so an existing process cannot produce a false near-zero result.

For watched compiler handoff, the child DEV graph publishes a typed persistent static-service request to its stable ship-dev parent. The parent owns the server process and accepts later generations only when root, host, port, and reload semantics match. A changed contract requires an explicit DEV restart; ordinary compiler generations reuse the same process and socket.

Content-root language tests are file-incremental. Each successful .shp report is cached by exact compiler/Core identity, canonical path, and bytes. DEV runs only changed chapter files and merges their reports; generator or production --once runs reuse unchanged reports without weakening clean-build coverage.

ship plan dev shows generation, serving, watch, rebuild, and regeneration as one typed graph. Justfiles stay thin compiler-wrapper aliases; do not compose a generator, file watcher, and ad hoc HTTP server in shell.

The default: fullstack local dev

From an app root with one HTTP server and one browser client (the standard src/ + server/<App>/ + client/src/ + client/<App>/ layout):

ship dev

compiles the project declarations into a runner graph, preflights every requirement, then runs one Ship supervisor. Inspect the graph without executing:

ship plan dev
ship plan dev --json

The graph is executable truth shared with ship dev, not an inspection-only summary; see 25-cli-workflows.md.

Only required runners start:

  • the selected local server on the host (PORT, otherwise the server's declared port, then 8080), running the native Rust server engine;

  • the Vite dev server for the browser program, with /api, /sync, and /health proxied to the native server.

  • Android programs add Gradle, ADB/emulator, and Vite runners.

  • Bound Postgres without an explicit URL adds managed Docker Postgres.

    The supervisor emits both artifacts, installs generated JavaScript dependencies only when package.json changes, starts the server first, waits for its port, then starts Vite directly. It does not recursively spawn more ship dev or npm run dev processes. If either runtime exits—or the supervisor receives an interrupt or termination signal—Ship stops and reaps both before returning.

    For a bound SQLite database, ship dev also chooses .state/<migration-name>.sqlite when url_env is unset, exports that absolute path to the server, and applies checked-in pending migrations before startup. Explicit environment values still win.

    Bound Postgres follows the same migration rule: when url_env is unset, ship dev runs ship db dev up (Docker Postgres + pending migrations). When url_env is already set, that URL wins and pending migrations are still applied before runners start.

Output

  • Interactive terminal: Ratatui runner/event dashboard.

  • Press y to yank a Markdown diagnostic bundle for the selected runner (identity, reproduce command, focused event/HTTP details, bounded nearby logs, with secrets redacted) onto the system clipboard for pasting into chat.

  • --output lines / --no-tui: stable line output.

  • --output json: versioned NDJSON runner events.

    Vite, Playwright, Docker, Node, Gradle, and ADB remain the underlying tools. Ship's runner crates use their public APIs or structured output and own their lifecycle; Ship does not reimplement them.

Browser HMR benchmarks

just app-bench --samples 10 measures a real app source save through the rendered browser result. The harness copies a stateful browser fixture under .tmp, starts the published compiler's ship dev, opens Chromium with Playwright, mutates the model, and alternates a source marker. Every generation must render the new marker while retaining the mutated model; a reset is a correctness failure, not a successful timing sample.

The harness holds the compiler service's exclusive benchmark lock through scripts/ship-compiler bench-lock, so foreground repository tests wait instead of landing in browser latency samples. Do not edit another watched source or run a broad suite during measurement.

The generated browser module acknowledges HMR only after its awaited mountShipProgram call has painted. Reports under .tmp/ship-app-benchmark/reports/ contain the authoritative save-to-visible samples, compiler generation, Core artifact, git revision, measured/warmup counts, and these versioned ship dev --output json stage metrics:

  • ship.dev.watch_debounce_ms;

  • ship.dev.check_ms;

  • ship.dev.browser.emit_ms;

  • ship.dev.browser.artifact_ready_ms;

  • ship.dev.browser.vite_apply_ms.

    Reports also record ship.dev.invalidated_modules and ship.dev.total_modules. Invalidation comes from the checked module graph: physical source/contract paths map to typed module identities, then changes flow through reverse dependency edges. A new or deleted .shp/.shc file conservatively invalidates mutable project structure; unrelated files do not. ship.dev.browser.changed_artifacts proves how many files were rewritten, and ship.dev.browser.core_cache_hit records reuse of the exact-toolchain Core JavaScript module.

    Use a retained report as a performance gate:

just app-bench --samples 10 \
  --baseline latest \
  --max-regression-percent 10

One warmup generation is excluded by default. Change it with --warmup-samples; use --port when the default benchmark port is occupied. Report the exact stage, sample count, and min/p50/p95/max spread rather than a bare latency number.

The selected client/<App>/init.shp is a terminal, non-importable app entry containing exactly one program(Web) { ... }. Reusable program modules under client/src/ use plain program { ... } and canonical Client.* module paths.

Editing any Ship source re-emits the affected artifacts:

  • Browser program changes hot-swap in place. The generated entry is a self-accepting Vite HMR module. Stable runtime/Core definitions live in src/ship-core.js, cached by exact Core toolchain identity plus structural browser capabilities. Generation-specific app IR lives in src/main.js; ordinary view/update edits rewrite that file only. Checked IR derives a canonical runtime-shape contract for the final synthesized model (including embed/sync fields). Exact contracts preserve the running model; a changed, missing, or unsupported contract runs init before rendering. Render errors surface normally and never trigger state-loss fallback. Removed app globals are cleared before the new generation installs. Measure the current host/compiler generation with just app-bench; do not document an unmeasured latency promise.

  • Server changes rebuild the manifest and gracefully hand off the native backend. The public port stays bound; a failed replacement leaves the previous backend serving.

    On macOS, a bundled native-server build preserves the executable's code signature and writes its checked manifest beside it as <executable>.ship-bundle; the executable and sidecar are one artifact and must move together. Linux embeds the same manifest in the executable. DEV writes only build/ship/native-server/<App>/server.manifest for the published ship-native-server worker; no build leaves scratch directories behind.

    Watched rebuilds write dependencies before importers and only rewrite artifact files whose content changed, so ordinary program edits do not touch Core, runtime, stylesheet, config, or HTML files.

One target at a time

ship dev client/ScrumApp          # browser program only (Vite + HMR)
ship dev client/ScrumAndroid      # Android WebView through ADB + Vite HMR
ship dev server/Scrum             # native API server only
ship dev server/QuestionAnswer    # standalone server(Native) binary
ship dev server/Local             # standalone server(Native) binary
ship dev bin/main.shp             # native run, restarted on change

Target kinds are inferred from the path (client/, server/, bin/); --target overrides. ship dev --help documents the flags.

Environment

VariableMeaning
PORTnative/local server port (default 8080)
SHIP_BROWSER_DEV_PORTbrowser Vite port (default 5173)
SHIP_DEV_API_ORIGINwhere the browser dev server proxies /api, /sync, and /health (set automatically by fullstack ship dev)
SHIP_DEV_PROXY_PATHScomma-separated additional typed server routes proxied by Vite (derived automatically in fullstack dev)
SHIP_ANDROID_DEV_PORTAndroid Vite/ADB reverse port (default 5173)
database URL (for example PLANETSCALE_DATABASE_URL)read by the server per the database { url_env = ... } binding

Ship provisions bound local SQLite files and migrations automatically. When a bound Postgres URL is absent, ship db dev owns a named Docker Postgres service and applies migrations before app runners start. When the URL is present, Ship still applies pending migrations to that database before startup.

server(Native) emits one local executable with an embedded checked server manifest. It emits no Rust source and serves generated CSS/Datastar/bound-client assets itself. Node remains an external engine for Vite, Playwright, and package installation only.