* chore(deps): upgrade express 4.22.1 -> 5.2.1 and @types/express
Breaking changes addressed:
- Renamed all bare wildcard route segments from * to *splat across
src/server.ts, src/static-resource-routes.ts, src/project-routes.ts,
src/import-export-routes.ts, and all three test stubs that define
app.get/options/delete routes using /raw/* or /raw/* patterns
- Updated wildcard param access from (req.params as any)[0] / req.params[0]
to Array.isArray(req.params.splat) ? req.params.splat.join('/') : String(...)
to handle the Express 5 / path-to-regexp v8 change where wildcard params
are now string[] instead of string
- Updated app.get('*') SPA fallback to app.get('/*splat') in server.ts
- Annotated five connector route handlers with Request<{ connectorId: string }>
so the typed param resolves as string, not string | string[], fixing the
10 TS2345 / TS2322 errors that surfaced when @types/express moved to 5.0.6
- Fixed two app.listen() beforeAll callbacks in origin-validation.test.ts to
accept and propagate the optional Error argument Express 5 now passes to
the listen callback, resolving TS2769 overload mismatch
* chore(nix): refresh daemonHash for rebased lockfile
* fix(daemon): await res.sendFile() in async route handlers for Express 5 compatibility
Express 5 res.sendFile() returns a Promise. Without await, async route
handlers return before the response is sent, causing Express to call
next() and fall through to a 404. Add await to all res.sendFile() calls
in async handlers in static-resource-routes.ts and server.ts.
* fix(daemon): use readFile+send for spritesheet route instead of sendFile
Express 5 res.sendFile() returns undefined (not a Promise). ENOENT errors
call next() asynchronously after the route handler's try/catch has returned,
causing unhandled 404 responses. Replacing with fs.promises.readFile + res.send
keeps the error path fully within the handler's try/catch.
---------
Co-authored-by: Patrick A <259201958+eefynet@users.noreply.github.com>
|
||
|---|---|---|
| .. | ||
| home-manager.nix | ||
| module-common.nix | ||
| nixos.nix | ||
| package-daemon.nix | ||
| package-web.nix | ||
| pnpm-deps.nix | ||
| README.md | ||
Open Design — Nix flake
This flake exposes Open Design as a reproducible package, a nix run entry
point, a dev shell, and Home Manager / NixOS modules. The architecture
mirrors the runtime: the daemon (od CLI, Express API on /api/*)
and the web frontend (Next.js static SPA at apps/web/out/) are
separate packages and separate services — you can run either or
both.
Outputs
| Output | What it is |
|---|---|
packages.<system>.daemon |
The @open-design/daemon package — produces bin/od. Default output. |
packages.<system>.web |
The Next.js static export (apps/web/out/) ready to drop into any static file server. |
apps.<system>.default |
nix run github:nexu-io/open-design — boots the daemon. |
devShells.<system>.default |
Node 24 + Corepack-pinned pnpm 10.33 — reproduces pnpm install locally. |
homeManagerModules.{default,open-design} |
Home Manager module — primary individual-developer interface. |
nixosModules.{default,open-design} |
NixOS module — secondary, for shared/server installs. |
Try it without installing
nix run github:nexu-io/open-design # boots the daemon on :7457
nix develop github:nexu-io/open-design # drop into the dev shell
(1) Home Manager — the recommended path
For an individual workstation, add the flake as an input and import the default module:
{
inputs.open-design.url = "github:nexu-io/open-design";
outputs = { self, home-manager, open-design, ... }: {
homeConfigurations.you = home-manager.lib.homeManagerConfiguration {
modules = [
open-design.homeManagerModules.default
{
services.open-design = {
enable = true;
autoStart = true; # systemd --user / launchd agent
webFrontend.enable = true; # also run the static SPA on :5174
};
}
];
};
};
}
What this wires up:
- Linux:
systemd --userunitsopen-design.serviceand (optionally)open-design-web.service.systemctl --user status open-design. - macOS:
launchdagentsio.nexu.open-designand (optionally)io.nexu.open-design-web.launchctl print gui/$UID/io.nexu.open-design. - Data lives in
$HOME/.od/by default — overridedataDirto relocate.
(2) NixOS — for shared/server installs
{
imports = [ inputs.open-design.nixosModules.default ];
services.open-design = {
enable = true;
autoStart = true;
openFirewall = true;
webFrontend.enable = true;
user = "open-design";
group = "open-design";
};
}
This creates a system user, drops a tmpfiles rule for /var/lib/open-design,
and runs the daemon under hardened systemd (ProtectSystem=strict,
PrivateTmp, ReadWritePaths scoped to the data directory). Use this
when you want a single shared instance — for individual user
configuration prefer the Home Manager module.
(3) webFrontend — when to use it, when to bring your own server
Open Design's frontend is a static SPA that issues relative /api/*,
/artifacts/*, and /frames/* requests. Three serving options:
| Option | When |
|---|---|
webFrontend.enable = true |
You want one-line setup. The module spawns a tiny Caddy file server on webFrontend.port (default 5174) that serves the SPA and reverse-proxies the three path prefixes to the daemon. |
webFrontend.enable = false (default) |
You're running nginx / Caddy / Apache / Traefik yourself. Point your server's document root at ${pkgs.open-design.web} (or the packages.<system>.web output) and replicate the proxy contract in section (4). |
| Skip the frontend entirely | You only need the daemon's API for headless agent dispatch. |
The two services are independent. autoStart controls the daemon;
webFrontend.enable controls the static server. Mix freely.
Bring-your-own-server gotcha: if your proxy listens on any origin that differs from the daemon's bind (different host or different port — even loopback split-port like
http://127.0.0.1:8080while the daemon stays on:7457), the daemon's same-origin gate will 403 the SPA's writes until you tell it about that origin. Either setservices.open-design.webFrontend.allowedOrigins = [ "<your-proxy-origin>" ](which feedsOD_ALLOWED_ORIGINS) or, for the loopback-only split-port case, setextraEnv.OD_WEB_PORT = "<proxy-port>". See section (4) for the full decision tree.
Exposing the bundled frontend on a non-loopback host
By default webFrontend.host = "127.0.0.1" so enabling the bundled
caddy does not publish anything beyond loopback. To intentionally
share with a LAN, two settings must be widened together — the
modules assert at eval time that the second is set whenever the
first is widened:
services.open-design.webFrontend = {
enable = true;
host = "0.0.0.0"; # caddy listener
# Every external origin browsers will load the SPA from. The daemon
# matches each entry against the browser's `Origin` header AND adds
# its host:port to the `Host`-header allowlist (Caddy v2 reverse_proxy
# preserves the original Host upstream by default), so list each
# scheme + hostname combo you actually use.
allowedOrigins = [
"http://laptop.local:5174"
"https://laptop.local:5174"
];
};
# On NixOS you also need:
services.open-design.openFirewall = true;
Under the hood allowedOrigins is forwarded to the daemon as the
OD_ALLOWED_ORIGINS environment variable (comma-separated). If you
run the daemon outside the modules — for example, behind your own
nginx/caddy — set OD_ALLOWED_ORIGINS directly in the daemon's
environment with the same shape:
OD_ALLOWED_ORIGINS=http://host1:port,https://host1:port,http://host2:port
Each entry must be a bare origin (scheme://host[:port]); only
http:// and https:// schemes are accepted, and the daemon refuses
to start if any entry fails to parse. The variable widens only the
general /api/* same-origin gate — connector-credential and
live-artifact preview/refresh routes stay strictly loopback-only by
design.
(4) Same-origin proxying contract
The web package is built with OD_DAEMON_URL = "" so the bundled JS
issues relative requests — /api/*, /artifacts/*, /frames/* —
instead of baking a daemon URL into the export. There is no runtime
config endpoint; the SPA does not read OD_DAEMON_URL from the
serving environment.
The serving contract is therefore: the static export must be served
same-origin with a reverse proxy to the daemon. The bundled caddy
service does exactly this — webFrontend listens on
webFrontend.port and reverse-proxies the three path prefixes above
to 127.0.0.1:<cfg.port>, with flush_interval -1 and no encode on
/api/* so SSE streams flush immediately (gzip would buffer chunked
responses for ~80s and surface as ERR_INCOMPLETE_CHUNKED_ENCODING).
If you serve the static bundle yourself, replicate that shape:
- Document root →
${pkgs.open-design.web}(orpackages.<system>.web). - Reverse-proxy
/api/*,/artifacts/*,/frames/*to the daemon's bind address;/api/*must stream chunks immediately and skip response compression. - SPA fallback for unmatched paths →
index.html.
The static-server's environment does not need any Open Design env
vars — but the daemon's environment usually does, because its
same-origin gate is built from OD_BIND_HOST:port (loopback hosts
included). The browser's Origin and Host are whatever your proxy
exposes, so unless that matches 127.0.0.1:<daemon-port> exactly,
the daemon will 403 every PUT/POST until told otherwise:
| Your custom-server setup | What to set on the daemon |
|---|---|
Proxy at http://127.0.0.1:<daemon-port> (same host, same port — unusual) |
Nothing. |
Proxy at a loopback host but different port (e.g. http://127.0.0.1:8080 while daemon is on :7457) |
Either extraEnv.OD_WEB_PORT = "8080" (whitelists 8080 on every loopback host) or services.open-design.webFrontend.allowedOrigins. |
Proxy on any non-loopback host (LAN IP, mDNS name, Tailscale name, public domain — https://od.example.com, http://laptop.local:5174, …) |
services.open-design.webFrontend.allowedOrigins = [ "<full origin>" ]. List every scheme + host[:port] combo a browser might load the SPA from. |
webFrontend.allowedOrigins is forwarded to the daemon as
OD_ALLOWED_ORIGINS; if you run the daemon outside the modules,
export OD_ALLOWED_ORIGINS directly with the same shape (see
section (3)). The variable widens only the general /api/* gate —
connector-credential and live-artifact preview/refresh routes stay
strictly loopback-only by design.
(5) Secrets — DO NOT put them in your Nix config
The environmentFile option takes a path to a KEY=VALUE file that the
service unit reads. Use it for BYOK API keys (Anthropic, OpenAI, Gemini),
provider tokens, and anything else you do not want world-readable in
/nix/store.
Recommended secret managers:
- sops-nix — age- or PGP-encrypted YAML, decrypted into runtime files at activation.
- agenix — age-encrypted single
files, dropped into
/run/agenix/at boot.
Either renders to a file like /run/secrets/open-design.env; pass that
path:
services.open-design.environmentFile = "/run/secrets/open-design.env";
Never inline a secret with pkgs.writeText or home.file.
First-build hash pinning
nix/pnpm-deps.nix is the single source of truth for the vendored pnpm
store hash used by both nix/package-daemon.nix and
nix/package-web.nix. If pnpm-lock.yaml changes, run:
pnpm nix:update-hash
The script temporarily swaps one consumer to lib.fakeHash, runs
nix build .#web --print-build-logs, extracts the expected hash from the
failure output, writes it back into nix/pnpm-deps.nix, and restores the
consumer file.
CI
.github/workflows/nix-check.yml runs nix flake check on pushes to
main and can also be started manually with workflow_dispatch.
Pull requests that touch Nix or dependency inputs are validated earlier in
.github/workflows/ci.yml via the required Validate workspace gate.
That PR path runs nix flake check when pnpm-lock.yaml, package
manifests, flake.*, nix/**, or the Nix workflows change, so fixed-
output hash drift is caught before merge while keeping unrelated PRs off
the slower Nix path.