feat: instant banner refresh on L4 mutations + master-slave L4 sync

Banner (L4PortsApplyBanner):
- Accept refreshSignal prop; re-fetch /api/l4-ports when it changes
- Signal fires immediately after create/edit/delete/toggle in L4ProxyHostsClient
  without waiting for a page reload

Master-slave replication (instance-sync):
- Add l4ProxyHosts to SyncPayload.data (optional for backward compat
  with older master instances that don't include it)
- buildSyncPayload: query and include l4ProxyHosts, sanitize ownerUserId
- applySyncPayload: clear and re-insert l4ProxyHosts in transaction;
  call applyL4Ports() if port diff requires it so the slave's sidecar
  recreates caddy with the correct ports
- Sync route: add isL4ProxyHost validator; backfill missing field from
  old masters; validate array when present

Tests (25 new tests):
- instance-sync.test.ts: buildSyncPayload includes L4 data, sanitizes ownerUserId;
  applySyncPayload replaces L4 hosts, handles missing field, writes trigger
  when ports differ, skips trigger when ports already match
- l4-ports-apply-banner.test.ts: banner refreshSignal contract + client
  increments counter on all mutation paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-03-22 00:22:44 +01:00
parent 3a4a4d51cf
commit 00c9bff8b4
6 changed files with 378 additions and 11 deletions
+35 -1
View File
@@ -180,6 +180,27 @@ function isProxyHost(value: unknown): value is SyncPayload["data"]["proxyHosts"]
);
}
function isL4ProxyHost(value: unknown): value is SyncPayload["data"]["l4ProxyHosts"][number] {
if (!isRecord(value)) return false;
return (
isNumber(value.id) &&
isString(value.name) &&
isString(value.protocol) &&
isString(value.listenAddress) &&
isString(value.upstreams) &&
isString(value.matcherType) &&
isNullableString(value.matcherValue) &&
isBoolean(value.tlsTermination) &&
isNullableString(value.proxyProtocolVersion) &&
isBoolean(value.proxyProtocolReceive) &&
isNullableNumber(value.ownerUserId) &&
isNullableString(value.meta) &&
isBoolean(value.enabled) &&
isString(value.createdAt) &&
isString(value.updatedAt)
);
}
/**
* Validates that the payload has the expected structure for syncing
*/
@@ -212,6 +233,11 @@ function isValidSyncPayload(payload: unknown): payload is SyncPayload {
const d = data as Record<string, unknown>;
// l4ProxyHosts is optional for backward compatibility with older master instances
if (d.l4ProxyHosts !== undefined && !validateArray(d.l4ProxyHosts, isL4ProxyHost)) {
return false;
}
return (
validateArray(d.certificates, isCertificate) &&
validateArray(d.caCertificates, isCaCertificate) &&
@@ -265,7 +291,15 @@ export async function POST(request: NextRequest) {
}
try {
await applySyncPayload(payload);
// Backfill l4ProxyHosts for payloads from older master instances that don't include it
const normalizedPayload: SyncPayload = {
...payload,
data: {
...payload.data,
l4ProxyHosts: payload.data.l4ProxyHosts ?? [],
},
};
await applySyncPayload(normalizedPayload);
await applyCaddyConfig();
await setSlaveLastSync({ ok: true });
return NextResponse.json({ ok: true });