@yourrealm/sdk 0.0.0-alpha.1378.ce03c8dc
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-EXCEPTION +26 -0
- package/README.md +34 -0
- package/dist/chunk-3R6KEFOI.js +22 -0
- package/dist/chunk-67BDAMYX.js +376 -0
- package/dist/chunk-AITGS27R.js +139 -0
- package/dist/chunk-CMAEHQ4F.js +6 -0
- package/dist/chunk-MLKGABMK.js +9 -0
- package/dist/chunk-O3UIK34B.js +1430 -0
- package/dist/chunk-VHTUSZUX.js +176 -0
- package/dist/cli.js +9006 -0
- package/dist/docker.js +54 -0
- package/dist/index.js +1053 -0
- package/dist/ingress.js +436 -0
- package/dist/jsx-runtime.js +2 -0
- package/dist/preact.js +22 -0
- package/dist/service-url.js +11 -0
- package/dist/services.js +213 -0
- package/dist/test.js +5069 -0
- package/dist/ui.js +254 -0
- package/dist/worker-protocol.js +89 -0
- package/package.json +112 -0
- package/src/app-icon.ts +74 -0
- package/src/apply.ts +52 -0
- package/src/bundle-ui.ts +211 -0
- package/src/bundle.ts +546 -0
- package/src/cli/build-dev-landing.ts +84 -0
- package/src/cli/build-install-panel.ts +90 -0
- package/src/cli/build-runtime.ts +133 -0
- package/src/cli/build.ts +63 -0
- package/src/cli/dev.ts +107 -0
- package/src/cli/down.ts +60 -0
- package/src/cli/main.ts +56 -0
- package/src/cli/test.ts +61 -0
- package/src/cli/validate.ts +37 -0
- package/src/compile.ts +100 -0
- package/src/create-app.ts +982 -0
- package/src/deep-merge.ts +33 -0
- package/src/dev/fixture.ts +48 -0
- package/src/dev/install-handler.ts +383 -0
- package/src/dev/server.ts +968 -0
- package/src/dev/stack.ts +108 -0
- package/src/dev/state.ts +159 -0
- package/src/docker.ts +96 -0
- package/src/errors.ts +61 -0
- package/src/factory.ts +481 -0
- package/src/garage.ts +56 -0
- package/src/generator/hash.ts +6 -0
- package/src/generator/resolve-refs.ts +207 -0
- package/src/grants-builder.ts +100 -0
- package/src/grants.ts +260 -0
- package/src/home-api.ts +172 -0
- package/src/home-cookie.ts +10 -0
- package/src/home-registry.ts +120 -0
- package/src/home.ts +187 -0
- package/src/host-registry.ts +143 -0
- package/src/http-header.ts +20 -0
- package/src/identifiers.ts +49 -0
- package/src/index.ts +66 -0
- package/src/info.ts +64 -0
- package/src/ini.ts +50 -0
- package/src/json.ts +14 -0
- package/src/jsx-runtime/index.ts +5 -0
- package/src/keys.ts +29 -0
- package/src/mysql.ts +56 -0
- package/src/pack-build.ts +108 -0
- package/src/pack.ts +115 -0
- package/src/peer-registry.ts +121 -0
- package/src/peer-world.ts +117 -0
- package/src/permission-meta.ts +116 -0
- package/src/permissions.ts +108 -0
- package/src/pkg-root.ts +34 -0
- package/src/postgres.ts +79 -0
- package/src/preact/index.ts +27 -0
- package/src/redis.ts +43 -0
- package/src/ref.ts +271 -0
- package/src/remote-registry.ts +33 -0
- package/src/required-host-kinds.ts +110 -0
- package/src/resolve-context.ts +111 -0
- package/src/resolve.ts +471 -0
- package/src/runtime/docker/compose/compose-schema.ts +118 -0
- package/src/runtime/docker/compose/convert-to-docker-compose.ts +278 -0
- package/src/runtime/docker/compose/default-hardening.ts +21 -0
- package/src/runtime/docker/compose/effects.ts +107 -0
- package/src/runtime/docker/compose/index.ts +17 -0
- package/src/runtime/docker/compose/labels.ts +39 -0
- package/src/runtime/docker/compose/types.ts +78 -0
- package/src/runtime/docker/home-proxy-server/index.ts +177 -0
- package/src/runtime/docker/home-proxy-server/protocol.ts +53 -0
- package/src/runtime/docker/ingress/build-config.ts +819 -0
- package/src/runtime/docker/ingress/index.ts +16 -0
- package/src/runtime/docker/ingress/types.ts +182 -0
- package/src/runtime/docker/log-frames.ts +121 -0
- package/src/runtime/docker/logs-server/index.ts +366 -0
- package/src/runtime/docker/spawn-server/context-key.ts +82 -0
- package/src/runtime/docker/spawn-server/index.ts +492 -0
- package/src/runtime/docker/spawn-server/protocol.ts +70 -0
- package/src/runtime/docker/spawn-server/spec.ts +122 -0
- package/src/runtime/docker/stats-server/disks.ts +161 -0
- package/src/runtime/docker/stats-server/index.ts +322 -0
- package/src/runtime/home-client/index.ts +71 -0
- package/src/runtime/log-line.ts +24 -0
- package/src/runtime/logs-client/index.ts +175 -0
- package/src/runtime/spawn-client/index.ts +93 -0
- package/src/runtime/spawn-client/kaniko.ts +79 -0
- package/src/runtime/stats-client/index.ts +65 -0
- package/src/runtime/stats-sample.ts +95 -0
- package/src/safe-json.ts +37 -0
- package/src/sandbox.ts +1209 -0
- package/src/schema/build-apps.ts +38 -0
- package/src/schema/home.ts +217 -0
- package/src/schema/host.ts +792 -0
- package/src/schema/inputs.ts +26 -0
- package/src/schema/nodes.ts +75 -0
- package/src/schema/peer.ts +400 -0
- package/src/schema/public-domain.ts +116 -0
- package/src/schema/remote.ts +74 -0
- package/src/security.ts +594 -0
- package/src/serialize.ts +21 -0
- package/src/service-definition.ts +284 -0
- package/src/service-url.ts +64 -0
- package/src/service.ts +39 -0
- package/src/services/index.ts +5 -0
- package/src/shared.ts +65 -0
- package/src/speculative-silo.ts +96 -0
- package/src/stable-secret.ts +14 -0
- package/src/surfaces.ts +264 -0
- package/src/tailwind-bin.ts +56 -0
- package/src/test/boot.ts +192 -0
- package/src/test/compile.ts +501 -0
- package/src/test/describe.ts +117 -0
- package/src/test/index.ts +11 -0
- package/src/test/ingress-fixture.ts +37 -0
- package/src/test/types.ts +318 -0
- package/src/trusted-headers.ts +79 -0
- package/src/types.ts +126 -0
- package/src/ui/components/aside.tsx +96 -0
- package/src/ui/components/badge.tsx +42 -0
- package/src/ui/components/button.tsx +59 -0
- package/src/ui/components/card.tsx +106 -0
- package/src/ui/components/checkbox.tsx +37 -0
- package/src/ui/components/code-block.tsx +29 -0
- package/src/ui/components/combobox.tsx +119 -0
- package/src/ui/components/config-field.tsx +284 -0
- package/src/ui/components/dialog.tsx +137 -0
- package/src/ui/components/domain-editor.tsx +317 -0
- package/src/ui/components/domain-list.ts +205 -0
- package/src/ui/components/dropdown-menu.tsx +73 -0
- package/src/ui/components/empty-state.tsx +51 -0
- package/src/ui/components/highlight.ts +59 -0
- package/src/ui/components/icons.tsx +101 -0
- package/src/ui/components/image-posture.tsx +85 -0
- package/src/ui/components/input.tsx +28 -0
- package/src/ui/components/label.tsx +16 -0
- package/src/ui/components/line-chart.tsx +113 -0
- package/src/ui/components/page.tsx +39 -0
- package/src/ui/components/permission-row.tsx +262 -0
- package/src/ui/components/permission-section.tsx +51 -0
- package/src/ui/components/pie-chart.tsx +245 -0
- package/src/ui/components/schema-block.tsx +60 -0
- package/src/ui/components/separator.tsx +42 -0
- package/src/ui/components/sheet.tsx +130 -0
- package/src/ui/components/sidebar.tsx +650 -0
- package/src/ui/components/silo-picker.tsx +510 -0
- package/src/ui/components/skeleton.tsx +13 -0
- package/src/ui/components/sonner.tsx +33 -0
- package/src/ui/components/switch.tsx +58 -0
- package/src/ui/components/table-view.ts +219 -0
- package/src/ui/components/table.tsx +506 -0
- package/src/ui/components/tabs.tsx +59 -0
- package/src/ui/components/timeago.tsx +84 -0
- package/src/ui/components/tooltip.tsx +56 -0
- package/src/ui/components.json +21 -0
- package/src/ui/dev/dev-shell.tsx +145 -0
- package/src/ui/home/home-grid.tsx +177 -0
- package/src/ui/hooks/use-mobile.ts +19 -0
- package/src/ui/index.ts +189 -0
- package/src/ui/install/adapter.ts +193 -0
- package/src/ui/install/deploy-targets-picker.tsx +185 -0
- package/src/ui/install/install-app-panel.tsx +912 -0
- package/src/ui/install/install-error.tsx +62 -0
- package/src/ui/install/perms-inactive.ts +50 -0
- package/src/ui/install/required-fields.ts +37 -0
- package/src/ui/install/tabs-form.tsx +557 -0
- package/src/ui/tokens.css +279 -0
- package/src/ui/utils.ts +78 -0
- package/src/ui-error.ts +139 -0
- package/src/ui-runtime/dev-landing-entry.tsx +258 -0
- package/src/ui-runtime/index.ts +83 -0
- package/src/ui-runtime/install-entry.tsx +119 -0
- package/src/ui-runtime/mount.ts +367 -0
- package/src/ui-runtime/runtime.css +56 -0
- package/src/ui-surface.ts +451 -0
- package/src/valkey.ts +43 -0
- package/src/worker/extract-tar.ts +125 -0
- package/src/worker/identity.ts +183 -0
- package/src/worker/index.ts +16 -0
- package/src/worker/public-ip.ts +92 -0
- package/src/worker/queue.ts +54 -0
- package/src/worker/reconcile.ts +127 -0
- package/src/worker/start-worker.ts +227 -0
- package/src/worker/ui-proxy.ts +335 -0
- package/src/worker/uplink.ts +523 -0
- package/src/worker/validate.ts +34 -0
- package/src/worker/verify-relay.ts +102 -0
- package/src/worker-protocol/app-dirs.ts +94 -0
- package/src/worker-protocol/home-proxy-paths.ts +76 -0
- package/src/worker-protocol/index.ts +55 -0
- package/src/worker-protocol/ingress.ts +166 -0
- package/src/worker-protocol/logs-socket-path.ts +50 -0
- package/src/worker-protocol/networks.ts +39 -0
- package/src/worker-protocol/node-version.ts +70 -0
- package/src/worker-protocol/silo-paths.ts +114 -0
- package/src/worker-protocol/spawn-paths.ts +165 -0
- package/src/worker-protocol/stats-socket-path.ts +40 -0
- package/src/worker-protocol/update-files.ts +77 -0
- package/src/worker-protocol/uplink/constants.ts +86 -0
- package/src/worker-protocol/uplink/errors.ts +61 -0
- package/src/worker-protocol/uplink/frame.ts +114 -0
- package/src/worker-protocol/uplink/identity.ts +40 -0
- package/src/worker-protocol/uplink/index.ts +63 -0
- package/src/worker-protocol/uplink/methods.ts +462 -0
- package/src/worker-protocol/uplink/mux.ts +646 -0
- package/src/worker-protocol/uplink/protocol.ts +11 -0
- package/src/worker-protocol/uplink/rpc.ts +309 -0
- package/src/worker-protocol/uplink/session.ts +540 -0
- package/src/worker-protocol/uplink/test-support.ts +188 -0
- package/src/worker-protocol/worker-mount.ts +27 -0
- package/src/worker-protocol/worker-state.ts +190 -0
- package/src/xml.ts +49 -0
- package/src/yaml.ts +15 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { ComponentProps } from "preact";
|
|
2
|
+
import { Dialog as DialogPrimitive } from "@ark-ui/react/dialog";
|
|
3
|
+
|
|
4
|
+
import { cn, type ElementProps } from "../utils.ts";
|
|
5
|
+
import { Button } from "./button.tsx";
|
|
6
|
+
import { XBoldIcon } from "./icons.tsx";
|
|
7
|
+
|
|
8
|
+
function Dialog(props: ComponentProps<typeof DialogPrimitive.Root>) {
|
|
9
|
+
return <DialogPrimitive.Root {...props} />;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function DialogTrigger({ ...props }: ComponentProps<typeof DialogPrimitive.Trigger>) {
|
|
13
|
+
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function DialogClose({ ...props }: ComponentProps<typeof DialogPrimitive.CloseTrigger>) {
|
|
17
|
+
return <DialogPrimitive.CloseTrigger data-slot="dialog-close" {...props} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function DialogOverlay({ className, ...props }: ComponentProps<typeof DialogPrimitive.Backdrop>) {
|
|
21
|
+
return (
|
|
22
|
+
<DialogPrimitive.Backdrop
|
|
23
|
+
data-slot="dialog-overlay"
|
|
24
|
+
className={cn(
|
|
25
|
+
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
|
26
|
+
className,
|
|
27
|
+
)}
|
|
28
|
+
{...props}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function DialogContent({
|
|
34
|
+
className,
|
|
35
|
+
children,
|
|
36
|
+
showCloseButton = true,
|
|
37
|
+
...props
|
|
38
|
+
}: ComponentProps<typeof DialogPrimitive.Content> & {
|
|
39
|
+
showCloseButton?: boolean;
|
|
40
|
+
}) {
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
<DialogOverlay />
|
|
44
|
+
<DialogPrimitive.Positioner className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
45
|
+
<DialogPrimitive.Content
|
|
46
|
+
data-slot="dialog-content"
|
|
47
|
+
className={cn(
|
|
48
|
+
"relative grid w-full max-w-[calc(100%-2rem)] gap-4 rounded-2xl bg-background p-4 text-sm shadow-lg duration-100 outline-none sm:max-w-sm data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
|
49
|
+
className,
|
|
50
|
+
)}
|
|
51
|
+
{...props}
|
|
52
|
+
>
|
|
53
|
+
{children}
|
|
54
|
+
{showCloseButton && (
|
|
55
|
+
<DialogPrimitive.CloseTrigger asChild data-slot="dialog-close">
|
|
56
|
+
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
|
|
57
|
+
<XBoldIcon />
|
|
58
|
+
<span className="sr-only">Close</span>
|
|
59
|
+
</Button>
|
|
60
|
+
</DialogPrimitive.CloseTrigger>
|
|
61
|
+
)}
|
|
62
|
+
</DialogPrimitive.Content>
|
|
63
|
+
</DialogPrimitive.Positioner>
|
|
64
|
+
</>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function DialogHeader({ className, ...props }: ElementProps<"div">) {
|
|
69
|
+
return (
|
|
70
|
+
<div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function DialogFooter({
|
|
75
|
+
className,
|
|
76
|
+
showCloseButton = false,
|
|
77
|
+
children,
|
|
78
|
+
...props
|
|
79
|
+
}: ElementProps<"div"> & {
|
|
80
|
+
showCloseButton?: boolean;
|
|
81
|
+
}) {
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
data-slot="dialog-footer"
|
|
85
|
+
className={cn(
|
|
86
|
+
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-2xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
|
87
|
+
className,
|
|
88
|
+
)}
|
|
89
|
+
{...props}
|
|
90
|
+
>
|
|
91
|
+
{children}
|
|
92
|
+
{showCloseButton && (
|
|
93
|
+
<DialogPrimitive.CloseTrigger asChild>
|
|
94
|
+
<Button variant="outline">Close</Button>
|
|
95
|
+
</DialogPrimitive.CloseTrigger>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function DialogTitle({ className, ...props }: ComponentProps<typeof DialogPrimitive.Title>) {
|
|
102
|
+
return (
|
|
103
|
+
<DialogPrimitive.Title
|
|
104
|
+
data-slot="dialog-title"
|
|
105
|
+
className={cn("text-base leading-none font-medium", className)}
|
|
106
|
+
{...props}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function DialogDescription({
|
|
112
|
+
className,
|
|
113
|
+
...props
|
|
114
|
+
}: ComponentProps<typeof DialogPrimitive.Description>) {
|
|
115
|
+
return (
|
|
116
|
+
<DialogPrimitive.Description
|
|
117
|
+
data-slot="dialog-description"
|
|
118
|
+
className={cn(
|
|
119
|
+
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
|
120
|
+
className,
|
|
121
|
+
)}
|
|
122
|
+
{...props}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export {
|
|
128
|
+
Dialog,
|
|
129
|
+
DialogClose,
|
|
130
|
+
DialogContent,
|
|
131
|
+
DialogDescription,
|
|
132
|
+
DialogFooter,
|
|
133
|
+
DialogHeader,
|
|
134
|
+
DialogOverlay,
|
|
135
|
+
DialogTitle,
|
|
136
|
+
DialogTrigger,
|
|
137
|
+
};
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// The operator's half of `home.publicDomain`: which hostnames the app is
|
|
2
|
+
// served on, and whether the extras redirect to the first or serve alongside
|
|
3
|
+
// it. Like `silo-picker.tsx`, the author declares the leaf, the operator
|
|
4
|
+
// supplies the elevated value, and the control lives in the permission's row.
|
|
5
|
+
//
|
|
6
|
+
// `pushRoutes` short circuits on a single host, so with one domain both modes
|
|
7
|
+
// emit byte-identical Caddy config; the mode checkbox appears at two, where the
|
|
8
|
+
// choice starts to mean something, and the stored mode rides through hidden.
|
|
9
|
+
//
|
|
10
|
+
// This does not know the Realm base domain: the reservation rule ("no host
|
|
11
|
+
// under the Realm domain") and cross-app uniqueness are both server-side, where
|
|
12
|
+
// the messages are complete sentences naming the fix, and a client copy would
|
|
13
|
+
// be a second rule to keep in step.
|
|
14
|
+
|
|
15
|
+
import { useEffect, useId, useRef } from "preact/hooks";
|
|
16
|
+
import { useSignal } from "@preact/signals";
|
|
17
|
+
import type { DomainMode, PublicDomainGrantData } from "../../schema/public-domain.ts";
|
|
18
|
+
import { Badge } from "./badge.tsx";
|
|
19
|
+
import { Button } from "./button.tsx";
|
|
20
|
+
import { Checkbox } from "./checkbox.tsx";
|
|
21
|
+
import { Input } from "./input.tsx";
|
|
22
|
+
import { Label } from "./label.tsx";
|
|
23
|
+
import { PlusIcon, TrashIcon } from "./icons.tsx";
|
|
24
|
+
import {
|
|
25
|
+
addHosts,
|
|
26
|
+
dedupeHosts,
|
|
27
|
+
modeSentence,
|
|
28
|
+
moveToFront,
|
|
29
|
+
removeAt,
|
|
30
|
+
wwwSuggestions,
|
|
31
|
+
} from "./domain-list.ts";
|
|
32
|
+
|
|
33
|
+
interface Props {
|
|
34
|
+
/** The stored grant record. Undefined until the operator touches it. */
|
|
35
|
+
value: PublicDomainGrantData | undefined;
|
|
36
|
+
onChange: (next: PublicDomainGrantData) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Turn the permission on, called when a hostname is added.
|
|
39
|
+
*
|
|
40
|
+
* Adding a domain you own IS the grant, so making the operator find the
|
|
41
|
+
* switch first is asking them to say the same thing twice - and a hostname
|
|
42
|
+
* typed under an off switch is dropped at submit by `descriptorsToGrants`
|
|
43
|
+
* with nothing to show for it. Only adding does this. Removing a row leaves
|
|
44
|
+
* the switch alone, so tidying up a parked list can't silently re-grant.
|
|
45
|
+
*/
|
|
46
|
+
onEnable?: () => void;
|
|
47
|
+
/**
|
|
48
|
+
* Whether the permission's switch is on. Not a lock - adding is live either
|
|
49
|
+
* way, because adding is what turns it on. It greys the rows and the mode
|
|
50
|
+
* checkbox, which is the only cue that a list under an off switch publishes
|
|
51
|
+
* nothing and is dropped at submit by `descriptorsToGrants`.
|
|
52
|
+
*/
|
|
53
|
+
enabled?: boolean;
|
|
54
|
+
disabled?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Text sits in the add field that hasn't been turned into a row. The panel
|
|
57
|
+
* gates Install/Save on it, so "typed a domain, hit Install, got an app with
|
|
58
|
+
* no domain" can't happen. Never true for an empty list: blank is a perfectly
|
|
59
|
+
* valid grant.
|
|
60
|
+
*/
|
|
61
|
+
onPendingChange?: (pending: boolean) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function DomainEditor({
|
|
65
|
+
value,
|
|
66
|
+
onChange,
|
|
67
|
+
onEnable,
|
|
68
|
+
enabled = true,
|
|
69
|
+
disabled,
|
|
70
|
+
onPendingChange,
|
|
71
|
+
}: Props) {
|
|
72
|
+
const draft = useSignal("");
|
|
73
|
+
const errors = useSignal<string[]>([]);
|
|
74
|
+
// Blur-commit has to know whether focus went somewhere inside this control:
|
|
75
|
+
// clicking Add, a trash button or the mode checkbox blurs the field first,
|
|
76
|
+
// and committing there would apply the click to a list one edit behind.
|
|
77
|
+
const root = useRef<HTMLDivElement>(null);
|
|
78
|
+
// `relatedTarget` alone doesn't answer that. Safari and Firefox don't focus
|
|
79
|
+
// a <button> on mousedown, so it arrives null for exactly the clicks this
|
|
80
|
+
// guard exists for. Track the pointer as well: focus clears the flag, a
|
|
81
|
+
// pointerdown inside sets it, and the blur that follows reads and clears it.
|
|
82
|
+
const pointerInside = useRef(false);
|
|
83
|
+
// Not a literal: an app may declare two publicDomain leaves, and duplicate
|
|
84
|
+
// ids would point every label at the first control's inputs.
|
|
85
|
+
const uid = useId();
|
|
86
|
+
|
|
87
|
+
// The parent owns identity, so keep the latest callback in a ref rather than
|
|
88
|
+
// in an effect's dependency list - the panels pass an inline arrow.
|
|
89
|
+
const notify = useRef(onPendingChange);
|
|
90
|
+
notify.current = onPendingChange;
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
notify.current?.(!disabled && draft.value.trim().length > 0);
|
|
93
|
+
}, [disabled, draft.value]);
|
|
94
|
+
// A leaf can leave the tree with text still in it (step back, optional
|
|
95
|
+
// section switched off), and a gate nobody can clear is worse than no gate.
|
|
96
|
+
useEffect(() => () => notify.current?.(false), []);
|
|
97
|
+
|
|
98
|
+
// Seeded duplicates are rendered as-is: silently rewriting an untouched form
|
|
99
|
+
// is its own bug, and both rows are removable.
|
|
100
|
+
const hosts = value?.hosts ?? [];
|
|
101
|
+
const mode: DomainMode = value?.domainMode ?? "redirect";
|
|
102
|
+
|
|
103
|
+
function emit(nextHosts: string[], nextMode: DomainMode) {
|
|
104
|
+
// Any change to the list retires the last error. `commit` re-sets its own
|
|
105
|
+
// errors after calling through here, so its message survives; a remove, a
|
|
106
|
+
// reorder or a mode flip clears a line about a list that no longer exists.
|
|
107
|
+
errors.value = [];
|
|
108
|
+
// The mode is carried through, never derived from the list length. Writing
|
|
109
|
+
// `nextHosts.length >= 2 ? nextMode : "redirect"` would silently downgrade
|
|
110
|
+
// a stored "all" the moment the operator pulls a host out to retype it.
|
|
111
|
+
onChange({ hosts: dedupeHosts(nextHosts), domainMode: nextMode });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Adding is the one mutation that grants. See `onEnable`.
|
|
115
|
+
function add(nextHosts: string[]) {
|
|
116
|
+
onEnable?.();
|
|
117
|
+
emit(nextHosts, mode);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function commit() {
|
|
121
|
+
const raw = draft.value;
|
|
122
|
+
// Nothing typed is nothing to answer for. Deliberately leaves `errors`
|
|
123
|
+
// alone: this runs on every blur of an empty field, and clearing here would
|
|
124
|
+
// wipe the line explaining why the last commit produced no row.
|
|
125
|
+
if (!raw.trim()) {
|
|
126
|
+
draft.value = "";
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const result = addHosts(hosts, raw);
|
|
130
|
+
// What failed to parse stays in the field - it is a typo to fix, and
|
|
131
|
+
// clearing it would leave an error line with nothing to correct. A
|
|
132
|
+
// duplicate leaves nothing behind: that name is already on the list.
|
|
133
|
+
draft.value = result.rejected.join(" ");
|
|
134
|
+
if (result.added.length > 0) add(result.hosts);
|
|
135
|
+
// Last, because `add` goes through `emit`, which clears errors.
|
|
136
|
+
errors.value = result.errors;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Purely additive: offered only while a sibling is missing, and gone once it
|
|
140
|
+
// lands. Not a checkbox - that reads as a stored link between the two names,
|
|
141
|
+
// and `hosts` is a flat list.
|
|
142
|
+
const siblings = wwwSuggestions(hosts);
|
|
143
|
+
const sentence = modeSentence(hosts, mode);
|
|
144
|
+
// Occurrence ordinal, so a key survives a reorder. A bare host collides when
|
|
145
|
+
// a seeded grant holds the same name twice; a bare index changes for every
|
|
146
|
+
// row after a removal, which makes Preact tear down and rebuild the tail.
|
|
147
|
+
const ordinals = new Map<string, number>();
|
|
148
|
+
const rows = hosts.map((host, i) => {
|
|
149
|
+
const nth = ordinals.get(host) ?? 0;
|
|
150
|
+
ordinals.set(host, nth + 1);
|
|
151
|
+
return { host, i, key: `${host}#${nth}` };
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div
|
|
156
|
+
ref={root}
|
|
157
|
+
class="mt-2 flex flex-col gap-2"
|
|
158
|
+
data-slot="domain-editor"
|
|
159
|
+
onPointerDown={() => (pointerInside.current = true)}
|
|
160
|
+
>
|
|
161
|
+
<Label htmlFor={`${uid}-add`} className="text-xs font-medium text-text-muted">
|
|
162
|
+
Hostnames
|
|
163
|
+
</Label>
|
|
164
|
+
|
|
165
|
+
{rows.length > 0 && (
|
|
166
|
+
// Greyed, not hidden, while the permission is off: the list is still
|
|
167
|
+
// the operator's and flipping the switch back on returns it, but
|
|
168
|
+
// `descriptorsToGrants` drops it at submit, so it must not read as live.
|
|
169
|
+
<ul class={`flex flex-col gap-1 ${enabled ? "" : "opacity-50"}`}>
|
|
170
|
+
{rows.map(({ host, i, key }) => (
|
|
171
|
+
<li
|
|
172
|
+
key={key}
|
|
173
|
+
data-testid="domain-row"
|
|
174
|
+
data-host={host}
|
|
175
|
+
class="flex items-center gap-2 rounded-xl border border-border bg-surface-tinted px-2.5 py-1"
|
|
176
|
+
>
|
|
177
|
+
<span class="min-w-0 flex-1 truncate font-mono text-xs text-text-dark">{host}</span>
|
|
178
|
+
{i === 0 && rows.length > 1 && (
|
|
179
|
+
<Badge variant="outline" className="shrink-0">
|
|
180
|
+
Primary
|
|
181
|
+
</Badge>
|
|
182
|
+
)}
|
|
183
|
+
{i > 0 && (
|
|
184
|
+
<Button
|
|
185
|
+
size="xs"
|
|
186
|
+
variant="ghost"
|
|
187
|
+
disabled={disabled || !enabled}
|
|
188
|
+
data-testid={`domain-make-primary-${host}`}
|
|
189
|
+
onClick={() => emit(moveToFront(hosts, i), mode)}
|
|
190
|
+
>
|
|
191
|
+
Make primary
|
|
192
|
+
</Button>
|
|
193
|
+
)}
|
|
194
|
+
<Button
|
|
195
|
+
size="icon-xs"
|
|
196
|
+
variant="ghost"
|
|
197
|
+
disabled={disabled || !enabled}
|
|
198
|
+
aria-label={`Remove ${host}`}
|
|
199
|
+
data-testid={`domain-remove-${host}`}
|
|
200
|
+
onClick={() => emit(removeAt(hosts, i), mode)}
|
|
201
|
+
>
|
|
202
|
+
<TrashIcon />
|
|
203
|
+
</Button>
|
|
204
|
+
</li>
|
|
205
|
+
))}
|
|
206
|
+
</ul>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{hosts.length > 0 && !enabled && (
|
|
210
|
+
<p class="text-xs text-perm leading-snug" data-testid="domain-parked">
|
|
211
|
+
Not published while this permission is off. Turn it on, or add a hostname to turn it on.
|
|
212
|
+
</p>
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
{sentence && enabled && <p class="text-xs text-muted-foreground leading-snug">{sentence}</p>}
|
|
216
|
+
|
|
217
|
+
<div class="flex items-center gap-2">
|
|
218
|
+
<Input
|
|
219
|
+
id={`${uid}-add`}
|
|
220
|
+
data-slot="domain-input"
|
|
221
|
+
// The placeholder is the discovery mechanism once a row exists: an
|
|
222
|
+
// empty field sitting under a list is what says "list", without
|
|
223
|
+
// helper text nobody reads.
|
|
224
|
+
placeholder={hosts.length === 0 ? "photos.example.com" : "Add another hostname…"}
|
|
225
|
+
value={draft.value}
|
|
226
|
+
disabled={disabled}
|
|
227
|
+
onFocus={() => (pointerInside.current = false)}
|
|
228
|
+
onValueChange={(v) => {
|
|
229
|
+
draft.value = v;
|
|
230
|
+
// Typing is the operator answering the error; keep it on screen no
|
|
231
|
+
// longer than that. `commit` sets `errors` by writing the signal
|
|
232
|
+
// directly, which doesn't come back through here, so it survives.
|
|
233
|
+
if (errors.value.length > 0) errors.value = [];
|
|
234
|
+
}}
|
|
235
|
+
onKeyDown={(e: KeyboardEvent) => {
|
|
236
|
+
// Comma too: it is how a list is written, and `splitHosts` already
|
|
237
|
+
// treats it as a separator on paste.
|
|
238
|
+
if (e.key !== "Enter" && e.key !== ",") return;
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
commit();
|
|
241
|
+
}}
|
|
242
|
+
// Committing on the way out is what stops a typed domain from being
|
|
243
|
+
// silently dropped at Install. Skipped when the click landed inside
|
|
244
|
+
// the control, so Add, a row button or the mode checkbox acts on the
|
|
245
|
+
// list the operator was looking at. Both signals are needed: focus
|
|
246
|
+
// covers keyboard traversal, the pointer flag the browsers that leave
|
|
247
|
+
// `relatedTarget` null on a button click.
|
|
248
|
+
onBlur={(e: FocusEvent) => {
|
|
249
|
+
const next = e.relatedTarget as Node | null;
|
|
250
|
+
const inside = pointerInside.current || (next != null && root.current?.contains(next));
|
|
251
|
+
pointerInside.current = false;
|
|
252
|
+
if (inside) return;
|
|
253
|
+
commit();
|
|
254
|
+
}}
|
|
255
|
+
/>
|
|
256
|
+
{/* Enter alone is a secret. The button is what tells an operator who
|
|
257
|
+
has never seen this screen that names get added one at a time. */}
|
|
258
|
+
<Button
|
|
259
|
+
size="sm"
|
|
260
|
+
variant="outline"
|
|
261
|
+
disabled={disabled || draft.value.trim().length === 0}
|
|
262
|
+
data-testid="domain-add"
|
|
263
|
+
onClick={commit}
|
|
264
|
+
>
|
|
265
|
+
Add
|
|
266
|
+
</Button>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
{errors.value.map((message) => (
|
|
270
|
+
<p key={message} class="text-xs text-danger">
|
|
271
|
+
{message}
|
|
272
|
+
</p>
|
|
273
|
+
))}
|
|
274
|
+
|
|
275
|
+
{siblings.length > 0 && (
|
|
276
|
+
<div class="flex flex-wrap items-center gap-1">
|
|
277
|
+
{siblings.map((sibling) => (
|
|
278
|
+
<Button
|
|
279
|
+
key={sibling}
|
|
280
|
+
size="xs"
|
|
281
|
+
variant="ghost"
|
|
282
|
+
disabled={disabled}
|
|
283
|
+
data-testid={`domain-add-www-${sibling}`}
|
|
284
|
+
onClick={() => add([...hosts, sibling])}
|
|
285
|
+
>
|
|
286
|
+
<PlusIcon />
|
|
287
|
+
Add {sibling}
|
|
288
|
+
</Button>
|
|
289
|
+
))}
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
|
|
293
|
+
{hosts.length > 1 && (
|
|
294
|
+
<div class="flex items-center gap-2">
|
|
295
|
+
<Checkbox
|
|
296
|
+
id={`${uid}-mode`}
|
|
297
|
+
checked={mode === "all"}
|
|
298
|
+
disabled={disabled || !enabled}
|
|
299
|
+
data-testid="domain-mode-all"
|
|
300
|
+
onCheckedChange={(details: { checked: boolean | "indeterminate" }) =>
|
|
301
|
+
emit(hosts, details.checked === true ? "all" : "redirect")
|
|
302
|
+
}
|
|
303
|
+
/>
|
|
304
|
+
<Label htmlFor={`${uid}-mode`} className="text-xs text-muted-foreground">
|
|
305
|
+
Serve the app at every domain
|
|
306
|
+
</Label>
|
|
307
|
+
</div>
|
|
308
|
+
)}
|
|
309
|
+
|
|
310
|
+
<p class="text-xs text-muted-foreground leading-snug">
|
|
311
|
+
{hosts.length === 0
|
|
312
|
+
? "Point this name at this worker. Leave it blank to keep the app on its Realm subdomain."
|
|
313
|
+
: "Point each name at this worker."}
|
|
314
|
+
</p>
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// The list rules behind the domain editor, with no JSX and no Preact in them,
|
|
2
|
+
// so `deno test` can cover the parsing and ordering the control depends on.
|
|
3
|
+
//
|
|
4
|
+
// Every host that leaves here has been through `publicDomainHostSchema` - the
|
|
5
|
+
// same schema the grant write parses against - so the editor never invents a
|
|
6
|
+
// second hostname rule, and an error line is verbatim what the server would
|
|
7
|
+
// have said.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
dedupeHosts,
|
|
11
|
+
publicDomainHostSchema,
|
|
12
|
+
type DomainMode,
|
|
13
|
+
} from "../../schema/public-domain.ts";
|
|
14
|
+
|
|
15
|
+
export { dedupeHosts };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Split one field's contents into candidate hostnames.
|
|
19
|
+
*
|
|
20
|
+
* Whitespace, commas and semicolons all separate, which covers a list pasted
|
|
21
|
+
* out of a registrar's UI as well as one typed by hand. There is no `onPaste`
|
|
22
|
+
* handler: the field holds the blob and committing splits it, so both routes
|
|
23
|
+
* are the same code path.
|
|
24
|
+
*/
|
|
25
|
+
export function splitHosts(raw: string): string[] {
|
|
26
|
+
return raw.split(/[\s,;]+/).filter((part) => part.length > 0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ParsedHost = { ok: true; host: string } | { ok: false; error: string };
|
|
30
|
+
|
|
31
|
+
// Scheme, per RFC 3986: a letter then letters, digits, +, - or dot.
|
|
32
|
+
const URL_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The hostname out of something pasted from a browser's address bar.
|
|
36
|
+
*
|
|
37
|
+
* Only fires when there is an actual `scheme://` in front. A bare
|
|
38
|
+
* `example.com/photos` is left alone and rejected by the schema, because
|
|
39
|
+
* without a scheme there is nothing that reliably marks where the host ends,
|
|
40
|
+
* and quietly discarding half of what someone typed is worse than saying no.
|
|
41
|
+
*
|
|
42
|
+
* This is a convenience layer, never a security one: whatever comes out still
|
|
43
|
+
* goes through `publicDomainHostSchema`, which is the DNS-charset gate.
|
|
44
|
+
* Stripping in the schema itself would widen what the server accepts on
|
|
45
|
+
* the API path, which is exactly what that gate exists to prevent.
|
|
46
|
+
*/
|
|
47
|
+
export function stripUrlNoise(raw: string): string {
|
|
48
|
+
const trimmed = raw.trim();
|
|
49
|
+
if (!URL_SCHEME.test(trimmed)) return trimmed;
|
|
50
|
+
let rest = trimmed.replace(URL_SCHEME, "");
|
|
51
|
+
// The authority ends at the first path, query or fragment delimiter.
|
|
52
|
+
const end = rest.search(/[/?#]/);
|
|
53
|
+
if (end !== -1) rest = rest.slice(0, end);
|
|
54
|
+
// `user:pass@host` - the host is what follows the last `@`, which is also
|
|
55
|
+
// how a browser reads it. Taking the front half would name the wrong host.
|
|
56
|
+
const at = rest.lastIndexOf("@");
|
|
57
|
+
if (at !== -1) rest = rest.slice(at + 1);
|
|
58
|
+
return rest.replace(/:\d+$/, "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One candidate, normalized and validated by the grant schema itself. The
|
|
63
|
+
* failure message is the schema's own, so the operator reads the same sentence
|
|
64
|
+
* whether the host was rejected here or at the API.
|
|
65
|
+
*/
|
|
66
|
+
export function parseHost(raw: string): ParsedHost {
|
|
67
|
+
const parsed = publicDomainHostSchema.safeParse(stripUrlNoise(raw));
|
|
68
|
+
if (parsed.success) return { ok: true, host: parsed.data };
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: parsed.error.issues[0]?.message ?? "is not a hostname",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AddResult {
|
|
76
|
+
/** The list to emit: `current` plus everything that landed, in order. */
|
|
77
|
+
hosts: string[];
|
|
78
|
+
/** What actually landed. Empty means nothing changed. */
|
|
79
|
+
added: string[];
|
|
80
|
+
/** Candidates that failed to parse, verbatim, so the field can keep them. */
|
|
81
|
+
rejected: string[];
|
|
82
|
+
/** One complete sentence per thing the operator should know about. */
|
|
83
|
+
errors: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Add a field's worth of candidates to an existing list.
|
|
88
|
+
*
|
|
89
|
+
* Normalize-then-compare against the already-normalized list is exactly the
|
|
90
|
+
* comparison `domain_claim`'s primary key makes, so what the form calls a
|
|
91
|
+
* duplicate is what the server would have called one.
|
|
92
|
+
*/
|
|
93
|
+
export function addHosts(current: readonly string[], raw: string): AddResult {
|
|
94
|
+
const candidates = splitHosts(raw);
|
|
95
|
+
const seen = new Set(current);
|
|
96
|
+
const added: string[] = [];
|
|
97
|
+
const rejected: string[] = [];
|
|
98
|
+
const duplicates: string[] = [];
|
|
99
|
+
const errors: string[] = [];
|
|
100
|
+
for (const candidate of candidates) {
|
|
101
|
+
const parsed = parseHost(candidate);
|
|
102
|
+
if (!parsed.ok) {
|
|
103
|
+
// The same bad name twice is one thing to fix, not two. Saying it twice
|
|
104
|
+
// is noise, and it also collides on the message-keyed error rows the
|
|
105
|
+
// editor renders.
|
|
106
|
+
if (!rejected.includes(candidate)) {
|
|
107
|
+
rejected.push(candidate);
|
|
108
|
+
errors.push(`"${candidate}" ${parsed.error}`);
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (seen.has(parsed.host)) {
|
|
113
|
+
if (!duplicates.includes(parsed.host)) duplicates.push(parsed.host);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
seen.add(parsed.host);
|
|
117
|
+
added.push(parsed.host);
|
|
118
|
+
}
|
|
119
|
+
// A duplicate alongside names that DID land is noise: the operator asked for
|
|
120
|
+
// "these domains", and these domains is what they have. A commit where
|
|
121
|
+
// nothing landed at all is the opposite - the field empties, no row appears,
|
|
122
|
+
// and silence there is indistinguishable from the paste being swallowed.
|
|
123
|
+
if (duplicates.length > 0 && added.length === 0) {
|
|
124
|
+
errors.push(
|
|
125
|
+
duplicates.length === 1
|
|
126
|
+
? `"${duplicates[0]}" is already listed.`
|
|
127
|
+
: "Those hostnames are already listed.",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return { hosts: [...current, ...added], added, rejected, errors };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The `www.` sibling worth offering as a one-click shortcut, or null.
|
|
135
|
+
*
|
|
136
|
+
* Two labels is the entire rule, which makes it wrong for `example.co.uk` -
|
|
137
|
+
* that is an apex to its owner and a three-label name to this function, so no
|
|
138
|
+
* shortcut is offered. Telling the two apart needs a public-suffix list, and
|
|
139
|
+
* shipping (or fetching) one to decorate a checkbox is not a trade worth
|
|
140
|
+
* making. Nothing is lost: `www.example.co.uk` can still be typed into the
|
|
141
|
+
* field like any other host.
|
|
142
|
+
*/
|
|
143
|
+
export function wwwSibling(host: string): string | null {
|
|
144
|
+
if (host.startsWith("www.")) return null;
|
|
145
|
+
if (host.split(".").length !== 2) return null;
|
|
146
|
+
return `www.${host}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Every `www.` variant the list is missing, in list order.
|
|
151
|
+
*
|
|
152
|
+
* Scans the whole list rather than just the canonical host: a second brand
|
|
153
|
+
* domain wants its `www.` exactly as much as the first one does, and keying
|
|
154
|
+
* the offer to `hosts[0]` silently drops it for every name added after. One
|
|
155
|
+
* button each, so what is offered is always what is missing.
|
|
156
|
+
*/
|
|
157
|
+
export function wwwSuggestions(hosts: readonly string[]): string[] {
|
|
158
|
+
const have = new Set(hosts);
|
|
159
|
+
const out: string[] = [];
|
|
160
|
+
for (const host of hosts) {
|
|
161
|
+
const sibling = wwwSibling(host);
|
|
162
|
+
if (sibling === null || have.has(sibling) || out.includes(sibling)) continue;
|
|
163
|
+
out.push(sibling);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* What the current list and mode actually do, in the operator's own hostnames.
|
|
170
|
+
*
|
|
171
|
+
* Naming both ends is the point. "Other domains redirect here" leaves the
|
|
172
|
+
* direction to be inferred from row order, and getting it backwards (www
|
|
173
|
+
* canonical, apex redirecting to it) is the single most likely mistake here -
|
|
174
|
+
* it is what you get by typing `www.` first. Spelled out, a backwards setup
|
|
175
|
+
* reads wrong at a glance.
|
|
176
|
+
*
|
|
177
|
+
* Null below two hosts: `pushRoutes` short-circuits there, so both modes emit
|
|
178
|
+
* identical config and there is nothing true to say.
|
|
179
|
+
*/
|
|
180
|
+
export function modeSentence(hosts: readonly string[], mode: DomainMode): string | null {
|
|
181
|
+
// Deduped first: the editor renders a seeded duplicate as two rows on
|
|
182
|
+
// purpose, and counting those would produce "a.com redirects to a.com."
|
|
183
|
+
// The routes are built from the deduped list, so this describes them.
|
|
184
|
+
const unique = dedupeHosts([...hosts]);
|
|
185
|
+
if (unique.length < 2) return null;
|
|
186
|
+
const [primary, ...rest] = unique;
|
|
187
|
+
if (mode === "all") return `All ${unique.length} hostnames serve the app.`;
|
|
188
|
+
if (rest.length === 1) return `${rest[0]} redirects to ${primary}.`;
|
|
189
|
+
return `The other ${rest.length} hostnames redirect to ${primary}.`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Promote one host to canonical. Out-of-range indexes leave the list alone. */
|
|
193
|
+
export function moveToFront(hosts: readonly string[], index: number): string[] {
|
|
194
|
+
if (index <= 0 || index >= hosts.length) return [...hosts];
|
|
195
|
+
const next = [...hosts];
|
|
196
|
+
const [moved] = next.splice(index, 1);
|
|
197
|
+
next.unshift(moved!);
|
|
198
|
+
return next;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Drop one row. Out-of-range indexes leave the list alone. */
|
|
202
|
+
export function removeAt(hosts: readonly string[], index: number): string[] {
|
|
203
|
+
if (index < 0 || index >= hosts.length) return [...hosts];
|
|
204
|
+
return hosts.filter((_, i) => i !== index);
|
|
205
|
+
}
|