worker: fix assets binding and SPA routes
This commit is contained in:
parent
339aef50e0
commit
2e5fb0880f
9 changed files with 510 additions and 11 deletions
|
|
@ -11,7 +11,7 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: codeberg-medium
|
||||
runs-on: codeberg-medium-lazy
|
||||
steps:
|
||||
- name: Fetch Source (no git required)
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ on:
|
|||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: codeberg-medium
|
||||
runs-on: codeberg-medium-lazy
|
||||
steps:
|
||||
- name: Basic runner + secret smoke test
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -115,17 +115,28 @@ export default {
|
|||
}
|
||||
|
||||
// Serve static assets from the Worker Assets binding.
|
||||
// SPA fallback: unknown paths serve the app shell (`/index.html`).
|
||||
// SPA routing: serve the app shell for "route-like" paths (e.g. `/watch`).
|
||||
//
|
||||
// Cloudflare's Assets binding may otherwise issue a redirect for unknown paths (e.g. `/watch` -> `/`),
|
||||
// which breaks stable share links.
|
||||
const assets = (env as unknown as { ASSETS?: Fetcher }).ASSETS;
|
||||
if (!assets || typeof (assets as any).fetch !== "function") {
|
||||
return new Response("Assets binding not configured", { status: 500 });
|
||||
}
|
||||
|
||||
const res = await assets.fetch(request);
|
||||
if (res.status !== 404) return res;
|
||||
const method = request.method.toUpperCase();
|
||||
const is_nav = method === "GET" || method === "HEAD";
|
||||
const is_likely_asset = url.pathname.includes(".");
|
||||
|
||||
url.pathname = "/index.html";
|
||||
return assets.fetch(new Request(url.toString(), request));
|
||||
// Use `/` as the app shell. Some asset handlers redirect `/index.html` -> `/`,
|
||||
// which turns SPA routes into 307s instead of serving the HTML.
|
||||
if (is_nav && !is_likely_asset && url.pathname !== "/") {
|
||||
const u = new URL(request.url);
|
||||
u.pathname = "/";
|
||||
return assets.fetch(new Request(u.toString(), request));
|
||||
}
|
||||
|
||||
return assets.fetch(request);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ routes = [
|
|||
{ pattern = "www.every.channel", custom_domain = true },
|
||||
]
|
||||
|
||||
# Static assets built by Trunk (apps/tauri/ui -> apps/tauri/dist)
|
||||
[assets]
|
||||
directory = "../../apps/web/dist"
|
||||
# Static assets built by Trunk (apps/web -> apps/web/dist).
|
||||
#
|
||||
# Note: Wrangler v4 expects `assets = { ... }` (not a `[assets]` table). If misconfigured,
|
||||
# `env.ASSETS` will be missing in production and the worker will 500 on SPA routes like `/watch`.
|
||||
assets = { directory = "../../apps/web/dist", binding = "ASSETS", run_worker_first = ["/*"] }
|
||||
|
||||
[[durable_objects.bindings]]
|
||||
name = "EC_API"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
# ECP-0064: NixOS Module For `ec-node` WebTransport Publisher (Tower)
|
||||
|
||||
Status: Draft
|
||||
|
||||
## Decision
|
||||
|
||||
Ship a first-party NixOS module in this repo that runs `ec-node wt-publish` as one or more `systemd` services.
|
||||
|
||||
The module:
|
||||
|
||||
- Lives in-repo and is exported from the flake as `nixosModules.ec-node`.
|
||||
- Builds `ec-node` from this repo via Nix (no mutable checkout required on the target host).
|
||||
- Accepts a read-only configuration (in Nix) for:
|
||||
- HDHomeRun identity (either `host` IP/DNS, or `deviceId` with optional LAN discovery).
|
||||
- A list of broadcasts (name + channel) to publish.
|
||||
- Relay URL and a small set of `wt-publish` toggles (transcode/passthrough/TLS verify).
|
||||
|
||||
`~/Projects/nix` will consume this module as a flake input and enable it on the `conrad-tower` host, deploying with the existing `deploy-flake` workflow.
|
||||
|
||||
## Motivation
|
||||
|
||||
Tower should run publishing continuously, reproducibly, and without "tmux as an orchestration layer".
|
||||
|
||||
NixOS + systemd gives:
|
||||
|
||||
- Immutable configuration for the HDHR/relay/channel list.
|
||||
- Easy deployment/rollback via the existing host flake.
|
||||
- Restart and journald logs for long-running publishers.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- Nix packaging for `ec-node` sufficient to run `wt-publish`.
|
||||
- A module that instantiates a `systemd` unit per broadcast.
|
||||
- Optional HDHR host resolution via device-id + local network discovery (best-effort).
|
||||
|
||||
Out of scope (defer):
|
||||
|
||||
- ABR/multi-variant ladders from Nix config.
|
||||
- Automatic lineup-based channel selection by callsign.
|
||||
- Secrets management (publisher doesn't require secrets for Cloudflare relay preview).
|
||||
|
||||
## Rollout / Reversibility
|
||||
|
||||
- Enabling the module is per-host.
|
||||
- Reversible by removing the module import and disabling the service(s); roll back with the existing deployment tooling.
|
||||
|
||||
11
flake.nix
11
flake.nix
|
|
@ -9,7 +9,14 @@
|
|||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, rust-overlay, agenix }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
nixosModules = rec {
|
||||
ec-node = import ./nix/modules/ec-node.nix;
|
||||
default = ec-node;
|
||||
};
|
||||
in
|
||||
{ inherit nixosModules; }
|
||||
// flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
|
|
@ -40,6 +47,8 @@
|
|||
packages = {
|
||||
agenix = agenixPkg;
|
||||
fj = pkgs.forgejo-cli;
|
||||
ec-node = pkgs.callPackage ./nix/pkgs/ec-node.nix { };
|
||||
ec-cli = pkgs.callPackage ./nix/pkgs/ec-cli.nix { };
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
|
|
|
|||
335
nix/modules/ec-node.nix
Normal file
335
nix/modules/ec-node.nix
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
{ lib, config, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.services.every-channel.ec-node;
|
||||
|
||||
ecNodePkgDefault = pkgs.callPackage ../pkgs/ec-node.nix { };
|
||||
ecCliPkgDefault = pkgs.callPackage ../pkgs/ec-cli.nix { };
|
||||
|
||||
# Minimal normalization for host strings.
|
||||
normalizeHost = host:
|
||||
let
|
||||
h = lib.strings.removeSuffix "/" host;
|
||||
in
|
||||
if lib.strings.hasPrefix "http://" h || lib.strings.hasPrefix "https://" h then h else "http://${h}";
|
||||
|
||||
sanitizeUnitName = s:
|
||||
lib.concatStringsSep "-" (lib.filter (x: x != "") (lib.splitString "/" (lib.replaceStrings [ " " ":" "." ] [ "-" "-" "-" ] s)));
|
||||
|
||||
hdhrBase = if cfg.hdhomerun.host != null then normalizeHost cfg.hdhomerun.host else null;
|
||||
|
||||
mkInputUrl = broadcast:
|
||||
let
|
||||
base =
|
||||
if hdhrBase != null then hdhrBase
|
||||
else if cfg.hdhomerun.deviceId != null then "http://${cfg.hdhomerun.deviceId}.local"
|
||||
else null;
|
||||
in
|
||||
"${base}/auto/v${broadcast.channel}";
|
||||
|
||||
in
|
||||
{
|
||||
options.services.every-channel.ec-node = {
|
||||
enable = lib.mkEnableOption "every.channel ec-node WebTransport publisher services";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = ecNodePkgDefault;
|
||||
defaultText = "pkgs.callPackage /path/to/every.channel/nix/pkgs/ec-node.nix {}";
|
||||
description = "The ec-node package to run.";
|
||||
};
|
||||
|
||||
discoveryPackage = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = ecCliPkgDefault;
|
||||
defaultText = "pkgs.callPackage /path/to/every.channel/nix/pkgs/ec-cli.nix {}";
|
||||
description = "Package used for HDHomeRun discovery when `hdhomerun.autoDiscover = true`.";
|
||||
};
|
||||
|
||||
relayUrl = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "https://relay.cloudflare.mediaoverquic.com/";
|
||||
description = "MoQ relay URL for ec-node wt-publish.";
|
||||
};
|
||||
|
||||
transcode = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether ec-node should transcode to H.264/AAC before fragmenting.";
|
||||
};
|
||||
|
||||
passthrough = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to transmit fMP4 fragments directly (moq-mux passthrough).";
|
||||
};
|
||||
|
||||
tlsDisableVerify = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Danger: disable TLS verification for the relay.";
|
||||
};
|
||||
|
||||
extraArgs = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
description = "Extra arguments appended to each ec-node wt-publish invocation.";
|
||||
};
|
||||
|
||||
environment = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = {
|
||||
RUST_LOG = "info";
|
||||
};
|
||||
description = "Environment variables for the publisher services.";
|
||||
};
|
||||
|
||||
hdhomerun = {
|
||||
host = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "HDHomeRun host/IP. When set, inputs are built as http://<host>/auto/v<channel>.";
|
||||
};
|
||||
|
||||
deviceId = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "HDHomeRun device id (used as http://<deviceId>.local when host is unset).";
|
||||
};
|
||||
|
||||
autoDiscover = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "If true and host is unset, attempt LAN discovery for the device id (best-effort).";
|
||||
};
|
||||
};
|
||||
|
||||
broadcasts = lib.mkOption {
|
||||
type = lib.types.listOf (lib.types.submodule {
|
||||
options = {
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Broadcast name published to the relay (used in watch links).";
|
||||
};
|
||||
channel = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "HDHomeRun guide number (e.g. 4.1, 8.1). Required unless `input` is set.";
|
||||
};
|
||||
input = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Optional explicit ffmpeg input URL/file. When set, HDHomeRun settings are ignored for this broadcast.";
|
||||
};
|
||||
};
|
||||
});
|
||||
default = [ ];
|
||||
description = "List of broadcasts (name + channel, or explicit input) to publish.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.broadcasts != [ ];
|
||||
message = "services.every-channel.ec-node.broadcasts must be non-empty when enabled";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
let
|
||||
needsHdhr = builtins.any (b: b.input == null) cfg.broadcasts;
|
||||
in
|
||||
(!needsHdhr) || (cfg.hdhomerun.host != null) || (cfg.hdhomerun.deviceId != null);
|
||||
message = "Set services.every-channel.ec-node.hdhomerun.host or .deviceId (required when any broadcast omits `input`)";
|
||||
}
|
||||
{
|
||||
assertion = !(cfg.hdhomerun.autoDiscover && cfg.hdhomerun.host != null);
|
||||
message = "hdhomerun.autoDiscover only applies when hdhomerun.host is unset";
|
||||
}
|
||||
{
|
||||
assertion = builtins.all (b: (b.input != null) || (b.channel != null)) cfg.broadcasts;
|
||||
message = "Each broadcast must set either `input` or `channel`";
|
||||
}
|
||||
];
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /run/every-channel 0755 root root - -"
|
||||
];
|
||||
|
||||
systemd.services =
|
||||
lib.listToAttrs (map
|
||||
(b:
|
||||
let
|
||||
unit = "every-channel-wt-publish-${sanitizeUnitName b.name}";
|
||||
runner = pkgs.writeShellApplication {
|
||||
name = unit;
|
||||
runtimeInputs =
|
||||
[
|
||||
pkgs.coreutils
|
||||
pkgs.curl
|
||||
pkgs.ffmpeg
|
||||
pkgs.findutils
|
||||
pkgs.gawk
|
||||
pkgs.iproute2
|
||||
cfg.package
|
||||
]
|
||||
++ lib.optionals cfg.hdhomerun.autoDiscover [ pkgs.jq cfg.discoveryPackage ];
|
||||
text =
|
||||
let
|
||||
fixedHost = if cfg.hdhomerun.host != null then normalizeHost cfg.hdhomerun.host else "";
|
||||
deviceId = cfg.hdhomerun.deviceId or "";
|
||||
extraArgsLine =
|
||||
if cfg.extraArgs == [ ] then
|
||||
""
|
||||
else
|
||||
"cmd+=(${lib.concatStringsSep " " (map lib.escapeShellArg cfg.extraArgs)})";
|
||||
explicitInputStr = if b.input == null then "" else b.input;
|
||||
channelStr = if b.channel == null then "" else b.channel;
|
||||
in
|
||||
''
|
||||
set -euo pipefail
|
||||
|
||||
input=""
|
||||
explicit_input=${lib.escapeShellArg explicitInputStr}
|
||||
if [[ -n "$explicit_input" ]]; then
|
||||
input="$explicit_input"
|
||||
else
|
||||
ch=${lib.escapeShellArg channelStr}
|
||||
if [[ -z "$ch" ]]; then
|
||||
echo "ec-node: broadcast missing both input and channel" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
base="${lib.escapeShellArg fixedHost}"
|
||||
if [[ -z "$base" ]]; then
|
||||
dev_id="${lib.escapeShellArg deviceId}"
|
||||
if [[ -z "$dev_id" ]]; then
|
||||
echo "ec-node: missing hdhomerun.host and hdhomerun.deviceId" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
try_ip() {
|
||||
local ip="$1"
|
||||
local json id base_url
|
||||
json="$(curl -fsS --connect-timeout 0.10 --max-time 0.20 "http://$ip/discover.json" 2>/dev/null || true)"
|
||||
if [[ -z "$json" ]]; then
|
||||
return 1
|
||||
fi
|
||||
id="$(printf '%s' "$json" | jq -r '.DeviceID // empty' 2>/dev/null || true)"
|
||||
if [[ "$id" != "$dev_id" ]]; then
|
||||
return 1
|
||||
fi
|
||||
base_url="$(printf '%s' "$json" | jq -r '.BaseURL // empty' 2>/dev/null || true)"
|
||||
if [[ -z "$base_url" ]]; then
|
||||
base_url="http://$ip"
|
||||
fi
|
||||
printf '%s\n' "$base_url"
|
||||
return 0
|
||||
}
|
||||
|
||||
if ${lib.boolToString cfg.hdhomerun.autoDiscover}; then
|
||||
# Primary: UDP broadcast discover.
|
||||
base="$(${cfg.discoveryPackage}/bin/ec-cli discover | jq -r --arg id "$dev_id" '.[] | select(.id == $id) | .base_url // empty' | head -n1 || true)"
|
||||
|
||||
# Fallback: probe known neighbors for /discover.json (fast; avoids full /24 scan).
|
||||
if [[ -z "$base" ]]; then
|
||||
while read -r ip; do
|
||||
found="$(try_ip "$ip" || true)"
|
||||
if [[ -n "$found" ]]; then
|
||||
base="$found"
|
||||
break
|
||||
fi
|
||||
done < <(ip neigh | awk '{print $1}' | sort -u)
|
||||
fi
|
||||
|
||||
# Fallback: scan local /24 subnets for /discover.json (slow; worst-case ~50s).
|
||||
if [[ -z "$base" ]]; then
|
||||
while read -r cidr; do
|
||||
ip_addr="''${cidr%/*}"
|
||||
prefix="''${cidr#*/}"
|
||||
if [[ "$prefix" != "24" ]]; then
|
||||
continue
|
||||
fi
|
||||
net="''${ip_addr%.*}"
|
||||
for i in $(seq 1 254); do
|
||||
found="$(try_ip "$net.$i" || true)"
|
||||
if [[ -n "$found" ]]; then
|
||||
base="$found"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -n "$base" ]]; then
|
||||
break
|
||||
fi
|
||||
done < <(ip -o -4 addr show scope global | awk '{print $4}')
|
||||
fi
|
||||
|
||||
if [[ -z "$base" ]]; then
|
||||
echo "ec-node: HDHomeRun deviceId not found: $dev_id" >&2
|
||||
exit 2
|
||||
fi
|
||||
else
|
||||
# Best-effort mDNS convention.
|
||||
base="http://$dev_id.local"
|
||||
fi
|
||||
fi
|
||||
|
||||
base="''${base%/}"
|
||||
if [[ "$base" != http://* && "$base" != https://* ]]; then
|
||||
base="http://$base"
|
||||
fi
|
||||
|
||||
input="$base/auto/v$ch"
|
||||
fi
|
||||
|
||||
cmd=(
|
||||
${lib.escapeShellArg "${cfg.package}/bin/ec-node"}
|
||||
wt-publish
|
||||
--url ${lib.escapeShellArg cfg.relayUrl}
|
||||
--name ${lib.escapeShellArg b.name}
|
||||
--input "$input"
|
||||
)
|
||||
${lib.optionalString (!cfg.transcode) "cmd+=(--transcode=false)"}
|
||||
${lib.optionalString (!cfg.passthrough) "cmd+=(--passthrough=false)"}
|
||||
${lib.optionalString cfg.tlsDisableVerify "cmd+=(--tls-disable-verify)"}
|
||||
${extraArgsLine}
|
||||
|
||||
exec "''${cmd[@]}"
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
name = unit;
|
||||
value = {
|
||||
description = "every.channel WebTransport publish (${b.name} -> ${cfg.relayUrl})";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
ExecStart = "${runner}/bin/${unit}";
|
||||
Restart = "always";
|
||||
RestartSec = 2;
|
||||
|
||||
DynamicUser = true;
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectControlGroups = true;
|
||||
LockPersonality = true;
|
||||
MemoryDenyWriteExecute = true;
|
||||
RestrictSUIDSGID = true;
|
||||
RestrictRealtime = true;
|
||||
SystemCallArchitectures = "native";
|
||||
};
|
||||
|
||||
environment = cfg.environment;
|
||||
};
|
||||
})
|
||||
cfg.broadcasts);
|
||||
};
|
||||
}
|
||||
46
nix/pkgs/ec-cli.nix
Normal file
46
nix/pkgs/ec-cli.nix
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{ lib
|
||||
, rustPlatform
|
||||
, stdenv
|
||||
, pkg-config
|
||||
, openssl
|
||||
}:
|
||||
|
||||
let
|
||||
src = lib.cleanSourceWith {
|
||||
src = ../../.;
|
||||
filter = path: type:
|
||||
let
|
||||
base = baseNameOf path;
|
||||
in
|
||||
!(base == "target" || base == ".git" || base == ".direnv" || base == "tmp" || base == "node_modules");
|
||||
};
|
||||
in
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = "ec-cli";
|
||||
version = "0.0.0";
|
||||
inherit src;
|
||||
|
||||
cargoLock = {
|
||||
lockFile = ../../Cargo.lock;
|
||||
};
|
||||
|
||||
cargoBuildFlags = [ "-p" "ec-cli" ];
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkg-config
|
||||
];
|
||||
|
||||
buildInputs =
|
||||
[
|
||||
openssl
|
||||
];
|
||||
|
||||
doCheck = false;
|
||||
|
||||
meta = with lib; {
|
||||
description = "every.channel CLI (HDHomeRun discovery + tooling)";
|
||||
mainProgram = "ec-cli";
|
||||
platforms = platforms.unix;
|
||||
license = licenses.agpl3Only;
|
||||
};
|
||||
}
|
||||
48
nix/pkgs/ec-node.nix
Normal file
48
nix/pkgs/ec-node.nix
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{ lib
|
||||
, rustPlatform
|
||||
, stdenv
|
||||
, pkg-config
|
||||
, openssl
|
||||
}:
|
||||
|
||||
let
|
||||
# Keep the build input stable and small; avoid copying `target/`, `tmp/`, etc. into the Nix store.
|
||||
src = lib.cleanSourceWith {
|
||||
src = ../../.;
|
||||
filter = path: type:
|
||||
let
|
||||
base = baseNameOf path;
|
||||
in
|
||||
# Skip typical build outputs and large scratch dirs.
|
||||
!(base == "target" || base == ".git" || base == ".direnv" || base == "tmp" || base == "node_modules");
|
||||
};
|
||||
in
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = "ec-node";
|
||||
version = "0.0.0";
|
||||
inherit src;
|
||||
|
||||
cargoLock = {
|
||||
lockFile = ../../Cargo.lock;
|
||||
};
|
||||
|
||||
cargoBuildFlags = [ "-p" "ec-node" ];
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkg-config
|
||||
];
|
||||
|
||||
buildInputs =
|
||||
[
|
||||
openssl
|
||||
];
|
||||
|
||||
doCheck = false;
|
||||
|
||||
meta = with lib; {
|
||||
description = "every.channel node runner (ingest + chunk + MoQ publish)";
|
||||
mainProgram = "ec-node";
|
||||
platforms = platforms.unix;
|
||||
license = licenses.agpl3Only;
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue