web: simplify service editors with single-source-of-truth state

Replace dueling parse/stringify $effects across service editors with
one-time top-level init plus a single write-back $effect. Remount
editors via {#key value} in ServiceEditor so children no longer need
inbound-sync logic.
This commit is contained in:
nemunaire 2026-04-27 09:23:09 +07:00
commit 7251d93619
14 changed files with 120 additions and 192 deletions

View file

@ -61,12 +61,14 @@
</div>
</div>
{:then EditorComponent}
<EditorComponent
{dn}
{origin}
{type}
bind:value={value}
/>
{#key value}
<EditorComponent
{dn}
{origin}
{type}
bind:value={value}
/>
{/key}
{:catch error}
<div class="alert alert-warning">
<p>Failed to load editor for type: {type}</p>

View file

@ -56,16 +56,11 @@
// GitHub verification code
let verificationCode = $state(value["txt"]?.Txt || "");
// Sync data back to TXT record
$effect(() => {
if (value["txt"]) {
// Construct subdomain from organization name
if (organizationName) {
value["txt"].Hdr.Name =
`_github-challenge-${organizationName.replace(/^_github-challenge-(.+?)-org(\..*)?/, "$1")}-org`;
}
value["txt"].Txt = verificationCode;
if (organizationName) {
value["txt"]!.Hdr.Name = `_github-challenge-${organizationName}-org`;
}
value["txt"]!.Txt = verificationCode;
});
</script>

View file

@ -48,9 +48,6 @@
value.username = "";
}
// Name hash state
let nameHash = $state("");
// Compute SHA-224 hash from username
async function computeHash(username: string): Promise<string> {
const encoder = new TextEncoder();
@ -64,7 +61,21 @@
.join("");
}
// When username changes, compute hash
// Initial name hash: extract from existing Hdr.Name if present, else empty
function initialNameHash(): string {
const existing = value["openpgpkey"]?.Hdr?.Name;
if (!value["username"] && existing) {
const parts = existing.split("._openpgpkey");
if (parts.length > 0 && parts[0]) {
return parts[0];
}
}
return "";
}
let nameHash = $state(initialNameHash());
// When username changes, recompute hash
$effect(() => {
if (value["username"]) {
computeHash(value["username"]).then((hash) => {
@ -73,16 +84,6 @@
}
});
// Extract name hash from existing domain name on load
$effect(() => {
if (!value["username"] && value["openpgpkey"]?.Hdr?.Name && !nameHash) {
const parts = value["openpgpkey"].Hdr.Name.split("._openpgpkey");
if (parts.length > 0 && parts[0]) {
nameHash = parts[0];
}
}
});
// When name hash changes, update the domain name
$effect(() => {
if (nameHash && value["openpgpkey"]?.Hdr) {

View file

@ -42,9 +42,6 @@
(value as any)["srv"] = [];
}
// Type-safe accessor for srv as array
const srvArray = $derived((value as any)["srv"] as Array<dnsTypeSRV>);
// Service type configurations with their prefixes
const services = [
{ key: "submission", prefix: "_submission._tcp", label: "Email Submission" },
@ -59,40 +56,26 @@
{ key: "pop3s", prefix: "_pop3s._tcp", label: "POP3 over TLS" },
];
// Initialize service arrays from srv (one-time, breaks circular dependency)
let initialized = $state(false);
// Initialize service arrays on mount (runs once regardless of whether srv is empty)
$effect(() => {
if (!initialized) {
for (const service of services) {
(value as any)[service.key] = (srvArray ?? []).filter(
(srv: dnsTypeSRV) => srv?.Hdr?.Name?.startsWith(service.prefix) || false,
);
}
initialized = true;
}
});
// Initialize empty arrays for services with no records
for (const service of services) {
if (!(value as any)[service.key]) {
(value as any)[service.key] = [];
// One-time init: split records from srv into per-service arrays
{
const srvArray = ((value as any)["srv"] ?? []) as Array<dnsTypeSRV>;
for (const service of services) {
(value as any)[service.key] = srvArray.filter(
(srv: dnsTypeSRV) => srv?.Hdr?.Name?.startsWith(service.prefix) || false,
);
}
}
// When records in service arrays change, sync back to main array (one-way sync)
$effect(() => {
if (initialized) {
const allRecords: Array<dnsTypeSRV> = [];
for (const service of services) {
const serviceArray = (value as any)[service.key] as Array<dnsTypeSRV>;
if (serviceArray) {
allRecords.push(...serviceArray);
}
const allRecords: Array<dnsTypeSRV> = [];
for (const service of services) {
const serviceArray = (value as any)[service.key] as Array<dnsTypeSRV>;
if (serviceArray) {
allRecords.push(...serviceArray);
}
(value as any)["srv"] = allRecords;
}
(value as any)["srv"] = allRecords;
});
</script>

View file

@ -48,8 +48,15 @@
value.username = "";
}
// Name hash state
let nameHash = $state("");
// One-time init: extract existing name hash from domain name if no username
let initialNameHash = "";
if (!value["username"] && value["smimea"]?.Hdr?.Name) {
const parts = value["smimea"].Hdr.Name.split("._smimecert");
if (parts.length > 0 && parts[0]) {
initialNameHash = parts[0];
}
}
let nameHash = $state(initialNameHash);
// Compute SHA-224 hash from username
async function computeHash(username: string): Promise<string> {
@ -73,16 +80,6 @@
}
});
// Extract name hash from existing domain name on load
$effect(() => {
if (!value["username"] && value["smimea"]?.Hdr?.Name && !nameHash) {
const parts = value["smimea"].Hdr.Name.split("._smimecert");
if (parts.length > 0 && parts[0]) {
nameHash = parts[0];
}
}
});
// When name hash changes, update the domain name
$effect(() => {
if (nameHash && value["smimea"]?.Hdr) {

View file

@ -38,11 +38,9 @@
let { dn, origin, value = $bindable({}) }: Props = $props();
$effect(() => {
if (!value["SSHFP"]) {
value["SSHFP"] = [];
}
});
if (!value["SSHFP"]) {
value["SSHFP"] = [];
}
const type = "abstract.Server";
</script>

View file

@ -39,35 +39,18 @@
let { dn, origin, value = $bindable({}) }: Props = $props();
// Initialize value["txt"] if it doesn't exist
if (!value["txt"]) {
value["txt"] = newRR("._domainkey", getRrtype("TXT")) as any;
}
let val = $state(parseDKIM(value["txt"]?.Txt || ""));
$effect(() => {
if (value["txt"]?.Txt !== undefined) {
val = parseDKIM(value["txt"].Txt);
}
});
$effect(() => {
if (!value["txt"]) {
value["txt"] = newRR(selector + "._domainkey", getRrtype("TXT")) as any;
}
if (value["txt"]) {
value["txt"].Txt = stringifyDKIM(val, value["txt"].Txt || "");
}
});
let val = $state(parseDKIM(value["txt"]!.Txt || ""));
let selector = $state(value["txt"]!.Hdr?.Name?.replace("._domainkey", "") || "");
let selector = $state(value["txt"]?.Hdr?.Name?.replace("._domainkey", "") || "");
$effect(() => {
if (value["txt"]?.Hdr?.Name) {
selector = value["txt"].Hdr.Name.replace("._domainkey", "");
}
});
$effect(() => {
if (value["txt"]?.Hdr) {
value["txt"].Hdr.Name = selector + "._domainkey";
const txt = value["txt"]!;
txt.Txt = stringifyDKIM(val, txt.Txt || "");
if (txt.Hdr) {
txt.Hdr.Name = selector + "._domainkey";
}
});

View file

@ -39,20 +39,14 @@
let { dn, origin, value = $bindable({}) }: Props = $props();
if (!value["txt"]) {
value["txt"] = newRR(dn, getRrtype("TXT")) as any;
}
let val = $state(parseDMARC(value["txt"]?.Txt || ""));
$effect(() => {
if (value["txt"]?.Txt !== undefined) {
val = parseDMARC(value["txt"].Txt);
}
});
$effect(() => {
if (!value["txt"]) {
value["txt"] = newRR(dn, getRrtype("TXT")) as any;
}
if (value["txt"]) {
value["txt"].Txt = stringifyDMARC(val, value["txt"]?.Txt || "");
}
value["txt"]!.Txt = stringifyDMARC(val, value["txt"]?.Txt || "");
});
const type = "svcs.DMARC";

View file

@ -36,20 +36,14 @@
let { dn, origin, value = $bindable({}) }: Props = $props();
if (!value["txt"]) {
value["txt"] = newRR(dn, getRrtype("TXT")) as any;
}
let val = $state(parseMTASTS(value["txt"]?.Txt || ""));
$effect(() => {
if (value["txt"]?.Txt !== undefined) {
val = parseMTASTS(value["txt"].Txt);
}
});
$effect(() => {
if (!value["txt"]) {
value["txt"] = newRR(dn, getRrtype("TXT")) as any;
}
if (value["txt"]) {
value["txt"].Txt = stringifyMTASTS(val, value["txt"]?.Txt || "");
}
value["txt"]!.Txt = stringifyMTASTS(val, value["txt"]?.Txt || "");
});
const type = "svcs.MTA_STS";

View file

@ -37,12 +37,9 @@
const type = "svcs.MXs";
// Ensure mx is always an array at runtime
$effect(() => {
if (value["mx"] && !Array.isArray(value["mx"])) {
value["mx"] = [value["mx"]];
}
});
if (value["mx"] && !Array.isArray(value["mx"])) {
value["mx"] = [value["mx"]];
}
</script>
<div>

View file

@ -39,33 +39,34 @@
let { dn, origin, value = $bindable({}) }: Props = $props();
function parseSPF(val: string) {
const fields = val.split(" ");
const DEFAULT_SPF = "v=spf1 -all";
const ALL_RE = /^[-~?+]?all$/;
return {
v: fields[0].replace(/^v=/, ""),
f: fields.slice(1),
};
function parseSPF(val: string): { v: string; f: string[] } {
const tokens = val.trim().split(/\s+/).filter(Boolean);
if (tokens.length === 0) return { v: "spf1", f: [] };
if (tokens[0].startsWith("v=")) {
return { v: tokens[0].slice(2), f: tokens.slice(1) };
}
return { v: "spf1", f: tokens };
}
function stringifySPF(val: { v?: string; f: string[] }) {
return "v=" + (val["v"] ? val["v"] : "spf1") + " " + val.f.join(" ");
function stringifySPF(v: string, f: string[]): string {
return "v=" + (v || "spf1") + (f.length ? " " + f.join(" ") : "");
}
let val = $state(parseSPF(value["txt"]?.Txt || "v=spf1 -all"));
if (!value["txt"]) {
const txtRecord = newRR(dn, 16) as dnsTypeTXT; // TXT record type is 16
txtRecord.Txt = DEFAULT_SPF;
value["txt"] = txtRecord;
}
const initial = parseSPF(value["txt"].Txt || DEFAULT_SPF);
let v = $state(initial.v);
let f = $state(initial.f);
$effect(() => {
if (value["txt"]?.Txt) {
val = parseSPF(value["txt"].Txt);
}
});
$effect(() => {
if (!value["txt"]) {
const txtRecord = newRR(dn, 16) as dnsTypeTXT; // TXT record type is 16
txtRecord.Txt = "v=spf1 -all";
value["txt"] = txtRecord;
}
if (value["txt"]) {
value["txt"].Txt = stringifySPF(val);
}
value["txt"]!.Txt = stringifySPF(v, f);
});
const type = "svcs.SPF";
@ -74,19 +75,19 @@
async function addDirective() {
let newIdx: number;
if (val.f.length >= 1 && val.f[val.f.length - 1].indexOf("all") >= 0) {
newIdx = val.f.length - 1;
val.f.splice(val.f.length - 1, 0, "");
if (f.length >= 1 && ALL_RE.test(f[f.length - 1])) {
newIdx = f.length - 1;
f.splice(newIdx, 0, "");
} else {
newIdx = val.f.length;
val.f.push("");
newIdx = f.length;
f.push("");
}
await tick();
inputRefs[newIdx]?.focus();
}
function delDirective(idx: number) {
val.f.splice(idx, 1);
f.splice(idx, 1);
}
</script>
@ -103,17 +104,17 @@
type: "string",
description: "Defines the version of SPF to use.",
}}
bind:value={val.v}
bind:value={v}
/>
<h5 class="text-primary pb-1 border-bottom border-1">Directives</h5>
<ListGroup>
{#each val.f as directive, i}
{#each f as _, i (i)}
<ListGroupItem class="p-0">
<InputGroup>
<input
class="form-control border-0"
bind:value={val.f[i]}
bind:value={f[i]}
bind:this={inputRefs[i]}
/>
<Button

View file

@ -39,22 +39,11 @@
let { dn, origin, value = $bindable({}) }: Props = $props();
// Initialize TXT record if it doesn't exist
$effect(() => {
if (!value["txt"]) {
value["txt"] = newRR(dn, getRrtype("TXT")) as dnsTypeTXT;
}
});
if (!value["txt"]) {
value["txt"] = newRR(dn, getRrtype("TXT")) as dnsTypeTXT;
}
// svelte-ignore state_referenced_locally
let val = $derived(
value["txt"]
? new TLSRPTPolicy(value["txt"])
: new TLSRPTPolicy({
Hdr: { Name: dn, Rrtype: 16, Class: 1, Ttl: 3600, Rdlength: 0 },
Txt: "",
}),
);
let val = $derived(new TLSRPTPolicy(value["txt"] as dnsTypeTXT));
const type = "svcs.TLS_RPT";
</script>

View file

@ -46,20 +46,12 @@
// Type-safe accessor for srv as array
const srvArray = $derived(value["srv"] as any as Array<dnsTypeSRV>);
// Extract service name and protocol from first record's domain name
let serviceName = $state<string>("http");
let protocol = $state<string>("tcp");
// Initialize serviceName and protocol from first SRV record
$effect(() => {
if (srvArray?.[0]?.Hdr?.Name) {
const match = srvArray[0].Hdr.Name.match(/^_([^.]+)\._(\w+)/);
if (match) {
serviceName = match[1];
protocol = match[2];
}
}
});
// One-time extract of service name and protocol from first SRV record's name
const initialMatch = (value["srv"] as any as Array<dnsTypeSRV>)?.[0]?.Hdr?.Name?.match(
/^_([^.]+)\._([^.]+)/,
);
let serviceName = $state<string>(initialMatch?.[1] ?? "http");
let protocol = $state<string>(initialMatch?.[2] ?? "tcp");
// Construct the full DN with service and protocol prefix
let fullDn = $derived(`_${serviceName}._${protocol}`);

View file

@ -167,12 +167,14 @@
<Spinner />
</div>
{:else}
<ServiceEditor
dn={service._domain}
origin={data.domain}
type={service._svctype}
bind:value={service.Service}
/>
{#key data.serviceid}
<ServiceEditor
dn={service._domain}
origin={data.domain}
type={service._svctype}
bind:value={service.Service}
/>
{/key}
{/if}
</form>