Skip to content

Djed Local Development

Park your application directory, then open the project over trusted HTTPS:

Terminal window
djed park ~/Sites
djed open blog
# https://blog.test

djed gives every Maat application a real hostname while you develop it: https://<name>.test, trusted by Safari and Chrome, so there is no http://localhost:8000 and no certificate warning to click through. It starts your app on its first request, restarts it when the code changes, and stops it once it has sat idle for a while — the shape of Laravel Valet and Herd, written in pure Dart with nothing from Homebrew to install.

djed is a standalone package with its own djed executable and no dependency on the rest of Maat. It works with any application shaped like the ones maat new scaffolds: a bin/server.dart that honours APP_HOST and APP_PORT.

Install

djed supports macOS only in this version.

Terminal window
dart compile exe packages/djed_dev/bin/djed.dart -o ~/bin/djed
djed install

Compile it rather than dart pub global activate: the daemon is launched by launchd from the plist install writes, and a path-activated package resolves its dependencies on every single invocation — as root, in your repository. A self-contained binary has no pub cache, no package_config.json and no SDK path to keep valid, and every djed command returns in milliseconds instead of seconds. Put it anywhere on your PATH; install records wherever it actually is.

Trust and TLS

install needs sudo three times, and announces each step before it prompts you:

  1. writing /etc/resolver/test, which tells macOS to resolve *.test to your machine;
  2. trusting djed’s local certificate authority in the System keychain;
  3. installing /Library/LaunchDaemons/com.maat.djed.plist and loading it, so the daemon comes back after a reboot without you doing anything.

uninstall reverses all three. Re-running install boots the daemon out of launchd before loading the new job, so every application it was running stops with it — harmless, but worth knowing before you re-run it mid-afternoon.

If Herd or Valet is already running, install refuses and tells you to run herd stop or valet stop first: both already hold ports 80 and 443, and djed needs the same two.

Once installed, point it at your applications:

Terminal window
djed park ~/Sites # every child directory with bin/server.dart becomes <dir>.test
djed link blog # or serve one directory under a name you choose
djed status

Commands

Command Does
install / uninstall [--purge] Set up or remove DNS, the certificate authority and the LaunchDaemon. --purge also deletes configuration and logs.
start / stop / restart [site] Start or stop the daemon. restart alone restarts the daemon; restart <site> restarts just that app.
status The daemon’s pid, its ports, a live DNS check, certificate expiry, and every running app with its port and idle time.
park [dir] / forget [dir] Add or remove a parked directory.
link [name] [dir] / unlink [name] / links Serve, stop serving, or list a single application under a chosen name.
log [site] [-f] Show the daemon log, or one site’s log. -f follows it.
open [site] Open a site in your default browser.
secure [--restart] Regenerate the certificate — after adding a site, or before it expires — and re-trust it. --restart also restarts the daemon.
daemon Run the daemon in the foreground. This is what launchd runs, as root.

Root, and what still belongs to you

That third step installs a LaunchDaemon, not a per-user LaunchAgent, and a LaunchDaemon runs as root. It has to: on macOS only root may bind a port below 1024, and https://blog.test — no port number, no :8443 — is the whole reason this tool exists. Laravel Valet and Herd take exactly the same route for exactly the same reason.

Your applications are not root, and neither is anything they touch. The plist carries your username in DJED_USER, and the daemon uses it to give everything back:

  • ~/.config/djed — its configuration, certificates and logs — is chowned to you the moment the daemon starts, so you can read and edit it without sudo. Which is also why the daemon does not believe all of it: a root process reading a file you own is reading untrusted input. It ignores openssl entirely (it uses the path recorded in the root-owned plist, or the one on PATH), and it refuses any port below 1024 other than httpPort: 80 and httpsPort: 443 — checked both when it starts and on every reload, so djed link cannot move it onto :22 either. Everything else in config.json — your parked paths, links, tld, idle timeout and any port at or above 1024 — it acts on as written, because none of that needs root to do.
  • The control socket the djed command talks to is yours as well, mode 0600: an unprivileged CLI can still drive a root daemon, and nobody else on the machine can.
  • Every application starts as sudo -u <you> -H dart run bin/server.dart, so your project files, SQLite databases, .dart_tool caches and pub cache stay owned by you and never become root’s.

One thing does not come back: because launchd starts the daemon in the system bootstrap rather than your login session, your applications have no access to your login keychain and are never granted the per-app privacy permissions a GUI app would ask for — no TCC prompt for Documents, Desktop or Downloads, just a denial. Valet’s apps run the same way, for the same reason. Anything that needs a login-session credential should read it from the project’s .env instead.

Behind Another Proxy

djed can sit behind nginx instead of owning ports 80 and 443 itself — useful when Herd or Valet already has them:

Terminal window
herd proxy todo http://127.0.0.1:18080 --secure

with httpPort set to 18080 in config.json. nginx terminates TLS and serves the hostname; djed keeps starting the app on demand, stopping it when idle and restarting it on a change.

Set "trustForwardedHeaders": true in the same file. djed’s own listener is plain HTTP there, so without it every request reaches the application declaring X-Forwarded-Proto: http and each absolute URL the application builds — a pagination link, a redirect — sends an HTTPS client back to HTTP. It is off by default, and that default is the safe one: when djed owns 80 and 443 any client can set the header, and believing it would let a plaintext request present itself to the app as secure.

One thing to know if you write the nginx config yourself. The standard WebSocket recipe is

map $http_upgrade $connection_upgrade { default upgrade; '' close; }
proxy_set_header Connection $connection_upgrade;

and the '' close; line is not optional. Without it — Herd’s generated config is one example — nginx sends Connection: upgrade on every request, and dart:io stops delivering a request body the moment it sees that header, whatever the request is upgrading to. The bytes stay unread in the socket while contentLength still promises them, so every form submission arrives empty. djed handles this: a request marked for upgrade is tunnelled rather than re-issued, which is the only path that reaches those bytes. Any other Dart server behind such a config will silently lose POST bodies.

How it works

  • DNS. A UDP responder on 127.0.0.1:53535 answers *.test lookups — A records with 127.0.0.1, AAAA with ::1. /etc/resolver/test, written by install, is what tells macOS to send .test lookups there instead of the internet.
  • TLS. djed keeps its own certificate authority and trusts it once, in your System keychain. There is no single *.test wildcard leaf: curl, browsers and Dart’s own TLS stack all refuse a wildcard pattern with fewer than two dots, and a single-label tld like test can never have two. So the certificate lists every registered site by name instead — blog.test and *.blog.test, one pair per site — alongside test, localhost and 127.0.0.1. djed regenerates it, and reloads HTTPS with the new one, the moment the site list changes.
  • Proxy. HTTP on port 80 and HTTPS on port 443 route each request by its Host header to the matching app’s port, tunnel WebSocket upgrades, and add X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host.
  • Apps. Each site runs as dart run bin/server.dart — spelled with the absolute path to dart, because launchd hands the daemon a bare /usr/bin:/bin:/usr/sbin:/sbin PATH on which a plain dart does not resolve — with APP_HOST, APP_PORT and APP_URL set for it. When the daemon is root, that whole command goes through sudo -u <you> -H. Its log lands in ~/.config/djed/logs/<site>.log. An app idle for 15 minutes is stopped; saving a change under lib/, routes/, config/, bootstrap/ or pubspec.yaml restarts it a moment later.

Trying it with the todo example

The repository ships a small reference application at examples/todo. Copy it into a parked directory, then set it up:

Terminal window
mkdir -p ~/Sites
cp -r examples/todo ~/Sites/todo
cd ~/Sites/todo
cp .env.example .env
dart run bin/maat.dart migrate
djed park ~/Sites
open https://todo.test

The task list loads over HTTPS with no warning to dismiss. Edit ~/Sites/todo/routes/web.dart — a comment is enough — and reload the page a few seconds later: djed noticed the change, restarted the app, and the new version is already the one answering.

Limitations

  • macOS only. Linux (systemd, resolved) and Windows support are on the roadmap.
  • One wildcard label per site. api.blog.test is covered by blog’s certificate; a.b.blog.test still routes to the blog site, but no certificate covers it, so a browser will refuse the connection.
  • Maat-shaped apps only. A site needs a bin/server.dart that honours APP_PORT. Custom start commands for other kinds of applications are a follow-up.
  • No GUI. Everything goes through the djed command.

Verifying on your machine

install and uninstall change state outside this repository — DNS resolution, the System keychain and /Library/LaunchDaemons — so they are worth running by hand, watching each sudo prompt, rather than from an unattended script. Before you do, stop Herd or Valet: both run an nginx that already holds ports 80 and 443, the same two djed needs, and install refuses to start while either does.

Terminal window
herd stop # or: valet stop
dart compile exe packages/djed_dev/bin/djed.dart -o ~/bin/djed
djed install
mkdir -p ~/Sites
cp -r examples/todo ~/Sites/todo
cd ~/Sites/todo && cp .env.example .env && dart run bin/maat.dart migrate
djed park ~/Sites
djed status
curl -sS https://todo.test/ | head -c 200

Then edit ~/Sites/todo/routes/web.dart (a comment is enough), wait a few seconds, and run the curl and djed status commands again — the response is unchanged but status now shows a new pid for todo, because the file watcher restarted it. When you are finished:

Terminal window
djed uninstall --purge
herd start # or: valet start

uninstall reverses what install did: it boots the daemon out of launchd’s system domain and deletes /Library/LaunchDaemons/com.maat.djed.plist, restores /etc/resolver/test to whatever Herd or Valet left there (or deletes it if there was nothing), and untrusts the certificate authority. --purge also deletes ~/.config/djed, including its logs.