@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,523 @@
|
|
|
1
|
+
// The worker's client loop: one session at a time, dialled by the worker and
|
|
2
|
+
// never by Home. Everything the two say to each other rides it.
|
|
3
|
+
//
|
|
4
|
+
// The loop is deliberately dumb — dial, hello, announce, pull, keep alive,
|
|
5
|
+
// dial again — with exactly three ways to stop for good, each of them a thing
|
|
6
|
+
// an operator has to fix: a Home whose static key is not the pinned one, an
|
|
7
|
+
// enrollment token Home refuses, and a worker whose row is gone with no token
|
|
8
|
+
// to re-arm it. Everything else is a reconnect.
|
|
9
|
+
//
|
|
10
|
+
// A Home on another `PROTOCOL_VERSION` is not one of the three. The hello
|
|
11
|
+
// succeeded, so the session is real and Home can send `update` over it; the
|
|
12
|
+
// loop simply holds it without announcing, and this worker stays offline to
|
|
13
|
+
// everything else until the box has been moved.
|
|
14
|
+
|
|
15
|
+
import { log } from "@local/logger";
|
|
16
|
+
import type { Keypair } from "@yourrealm/noise";
|
|
17
|
+
import type { WorkerCapability } from "../permissions.ts";
|
|
18
|
+
import { readNodeVersion, shortNodeVersion } from "../worker-protocol/index.ts";
|
|
19
|
+
import {
|
|
20
|
+
type AppApplyResult,
|
|
21
|
+
BACKOFF_BASE_MS,
|
|
22
|
+
BACKOFF_CAP_MS,
|
|
23
|
+
callJson,
|
|
24
|
+
connectUplink,
|
|
25
|
+
HomeKeyMismatch,
|
|
26
|
+
MethodError,
|
|
27
|
+
type Params,
|
|
28
|
+
PING_INTERVAL_MS,
|
|
29
|
+
PONG_TIMEOUT_MS,
|
|
30
|
+
PROTOCOL_VERSION,
|
|
31
|
+
pullState,
|
|
32
|
+
REKEY_INTERVAL_MS,
|
|
33
|
+
REKEY_JITTER_MS,
|
|
34
|
+
type Result,
|
|
35
|
+
type RpcHandler,
|
|
36
|
+
type StatePull,
|
|
37
|
+
type UpdateStatus,
|
|
38
|
+
type UplinkSession,
|
|
39
|
+
} from "../worker-protocol/uplink/index.ts";
|
|
40
|
+
import type { Enrollment } from "./identity.ts";
|
|
41
|
+
import { currentPublicIp, setPublicIpEcho } from "./public-ip.ts";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Every uplink call rejects with this while there is no live session, rather
|
|
45
|
+
* than queueing behind a reconnect: the verify relay turns it into its 502 and
|
|
46
|
+
* the dashboard shows the rest as failures (`todo/ideas/uplink.md` § 4 RPC).
|
|
47
|
+
*
|
|
48
|
+
* `reason` is what the caller gets told instead of "the uplink to Home is
|
|
49
|
+
* down", for the case where that sentence would be a lie: a session held on a
|
|
50
|
+
* protocol mismatch is up and talking, and the 502 should say so.
|
|
51
|
+
*/
|
|
52
|
+
export class WorkerOffline extends Error {
|
|
53
|
+
constructor(method: string, reason = "the uplink to Home is down") {
|
|
54
|
+
super(`${reason}; ${method} cannot be answered`);
|
|
55
|
+
this.name = "WorkerOffline";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The three lines `worker.sh` greps this worker's container log for while it
|
|
61
|
+
* waits out an install: one for a session that opened, and the two failures an
|
|
62
|
+
* operator can act on. They live here, next to the `log` calls that emit them,
|
|
63
|
+
* so the installer reads the same strings the loop prints and changing one is a
|
|
64
|
+
* type error rather than an install that parks for four minutes.
|
|
65
|
+
*/
|
|
66
|
+
export const UPLINK_LOG = {
|
|
67
|
+
open: "Uplink open as",
|
|
68
|
+
tokenRefused: "Home refused this enrollment token",
|
|
69
|
+
keyMismatch: "Uplink key mismatch",
|
|
70
|
+
} as const;
|
|
71
|
+
|
|
72
|
+
/** The cadences the loop runs on. Defaulted from the protocol's constants. */
|
|
73
|
+
export interface UplinkTiming {
|
|
74
|
+
pingIntervalMs: number;
|
|
75
|
+
pongTimeoutMs: number;
|
|
76
|
+
rekeyIntervalMs: number;
|
|
77
|
+
rekeyJitterMs: number;
|
|
78
|
+
backoffBaseMs: number;
|
|
79
|
+
backoffCapMs: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const DEFAULT_TIMING: UplinkTiming = {
|
|
83
|
+
pingIntervalMs: PING_INTERVAL_MS,
|
|
84
|
+
pongTimeoutMs: PONG_TIMEOUT_MS,
|
|
85
|
+
rekeyIntervalMs: REKEY_INTERVAL_MS,
|
|
86
|
+
rekeyJitterMs: REKEY_JITTER_MS,
|
|
87
|
+
backoffBaseMs: BACKOFF_BASE_MS,
|
|
88
|
+
backoffCapMs: BACKOFF_CAP_MS,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
interface UplinkOptions {
|
|
92
|
+
/** `wss://{dashboardHost}/uplink`, or `ws://realm-home:3100/uplink`. */
|
|
93
|
+
url: string;
|
|
94
|
+
identity: Keypair;
|
|
95
|
+
/** The pin. A responder that answers with anything else is refused. */
|
|
96
|
+
homePublicKey: Uint8Array;
|
|
97
|
+
/** The OTP half of `REALM_TOKEN`, when there is one. */
|
|
98
|
+
otp: string | null;
|
|
99
|
+
/** Display name for a first enroll; the installer defaults it to the hostname. */
|
|
100
|
+
name: string | null;
|
|
101
|
+
/** What a previous boot enrolled as, or null. */
|
|
102
|
+
enrolled: Enrollment | null;
|
|
103
|
+
capabilities: () => Promise<WorkerCapability[]>;
|
|
104
|
+
/**
|
|
105
|
+
* What this box's ingress container is running, or null where it has none.
|
|
106
|
+
* Read on every announce for the same reason `capabilities` is: it is a fact
|
|
107
|
+
* about the box the worker can only observe, and a self-update moves it
|
|
108
|
+
* without the worker restarting.
|
|
109
|
+
*/
|
|
110
|
+
ingressImage: () => Promise<{ imageRef: string } | null>;
|
|
111
|
+
/**
|
|
112
|
+
* What the box's updater-driven projects are doing, read on every announce
|
|
113
|
+
* for the same reason `ingressImage` is: the files move under a live
|
|
114
|
+
* session (the updater writes them), and `announce` is where Home resets its
|
|
115
|
+
* whole picture of this box.
|
|
116
|
+
*/
|
|
117
|
+
updates: () => Promise<UpdateStatus[]>;
|
|
118
|
+
/** The Home → worker methods. */
|
|
119
|
+
handlers: Record<string, RpcHandler>;
|
|
120
|
+
/** The etag this worker is actually running, for `announce`. */
|
|
121
|
+
appliedEtag: () => string | null;
|
|
122
|
+
/** Runs once per connect, after the announce: connect is pull. */
|
|
123
|
+
onConnected: () => void;
|
|
124
|
+
/** Persists what `enroll` handed back. */
|
|
125
|
+
onEnrolled: (enrollment: Enrollment) => Promise<void>;
|
|
126
|
+
timing?: Partial<UplinkTiming>;
|
|
127
|
+
/** Test seam: the dial. */
|
|
128
|
+
connect?: typeof connectUplink;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface UplinkHandle {
|
|
132
|
+
/** True while a session is open and announced. */
|
|
133
|
+
readonly online: boolean;
|
|
134
|
+
/** Resolves with the reason once the loop has given up for good. */
|
|
135
|
+
readonly stopped: Promise<string>;
|
|
136
|
+
verify(params: Params<"verify">): Promise<Result<"verify">>;
|
|
137
|
+
apiAccess(params: Params<"apiAccess">): Promise<Result<"apiAccess">>;
|
|
138
|
+
pull(params: Params<"state.pull">): Promise<StatePull>;
|
|
139
|
+
applied(etag: string, apps: AppApplyResult[]): Promise<void>;
|
|
140
|
+
/** `update.status` for one project; rejects with `WorkerOffline` like the rest. */
|
|
141
|
+
updateStatus(status: UpdateStatus): Promise<void>;
|
|
142
|
+
/**
|
|
143
|
+
* Drop the live session so the loop redials. A no-op when none is open.
|
|
144
|
+
*
|
|
145
|
+
* Not `close()`, which stops the loop for good. This exists for the one class
|
|
146
|
+
* of change nothing else carries: a fact about the box that `announce`
|
|
147
|
+
* reports and that moves while the session is up. The box ingress is that
|
|
148
|
+
* fact - the updater sidecar replaces its container without restarting this
|
|
149
|
+
* process - and the announce only ever runs on connect, so without a redial
|
|
150
|
+
* Home holds the old image ref until the 24h rekey.
|
|
151
|
+
*
|
|
152
|
+
* A redial is the whole repair rather than half of it, because connect is
|
|
153
|
+
* pull: the fresh `announce` carries the moved fact AND `onConnected` re-runs
|
|
154
|
+
* the idempotent reconcilers, which is what re-attaches a replaced ingress to
|
|
155
|
+
* every routed app's network. It costs one sub-second gap (the backoff is
|
|
156
|
+
* re-armed from zero after a successful hello).
|
|
157
|
+
*
|
|
158
|
+
* A held session is dropped too: it is a session, and the redial is the only
|
|
159
|
+
* way one ever stops being held.
|
|
160
|
+
*/
|
|
161
|
+
reconnect(reason: string): void;
|
|
162
|
+
/** Stops the loop and closes the session. Resolves once both are done. */
|
|
163
|
+
close(): Promise<void>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* What the announce decided: keep this session, hold it unannounced, redial,
|
|
168
|
+
* or stop for good.
|
|
169
|
+
*/
|
|
170
|
+
type Announced = "open" | "held" | "retry" | { stop: string };
|
|
171
|
+
|
|
172
|
+
export function startUplink(opts: UplinkOptions): UplinkHandle {
|
|
173
|
+
const timing = { ...DEFAULT_TIMING, ...opts.timing };
|
|
174
|
+
const dial = opts.connect ?? connectUplink;
|
|
175
|
+
|
|
176
|
+
/** The announced session every helper rides. */
|
|
177
|
+
let session: UplinkSession | null = null;
|
|
178
|
+
/**
|
|
179
|
+
* A session whose hello Home answered on another `PROTOCOL_VERSION`. Kept
|
|
180
|
+
* open so Home can send `update` over it, and used for nothing else; `reason`
|
|
181
|
+
* is what every helper rejects with meanwhile.
|
|
182
|
+
*/
|
|
183
|
+
let held: { live: UplinkSession; reason: string } | null = null;
|
|
184
|
+
/**
|
|
185
|
+
* Whatever the loop currently holds, announced or not, so `close()` can end
|
|
186
|
+
* it and `keepPinging` can tell "still mine" from "replaced".
|
|
187
|
+
*/
|
|
188
|
+
let dialled: UplinkSession | null = null;
|
|
189
|
+
let enrolled = opts.enrolled;
|
|
190
|
+
let closing = false;
|
|
191
|
+
/** Wakes the backoff sleep early so `close()` never waits it out. */
|
|
192
|
+
let wake: (() => void) | null = null;
|
|
193
|
+
|
|
194
|
+
let settle: (reason: string) => void = () => {};
|
|
195
|
+
const stopped = new Promise<string>((resolve) => {
|
|
196
|
+
settle = resolve;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
function requireSession(method: string): UplinkSession {
|
|
200
|
+
if (session === null) throw new WorkerOffline(method, held?.reason);
|
|
201
|
+
return session;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function sleep(ms: number): Promise<void> {
|
|
205
|
+
return new Promise((resolve) => {
|
|
206
|
+
const id = setTimeout(() => {
|
|
207
|
+
wake = null;
|
|
208
|
+
resolve();
|
|
209
|
+
}, ms);
|
|
210
|
+
wake = () => {
|
|
211
|
+
clearTimeout(id);
|
|
212
|
+
wake = null;
|
|
213
|
+
resolve();
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Full jitter, doubling from the base to the cap. `attempt` is reset by
|
|
219
|
+
// every session that announced, so a flapping Home is retried hard and a
|
|
220
|
+
// gone one is retried slowly.
|
|
221
|
+
function backoff(attempt: number): number {
|
|
222
|
+
const ceiling = Math.min(timing.backoffCapMs, timing.backoffBaseMs * 2 ** (attempt - 1));
|
|
223
|
+
return Math.random() * ceiling;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function readCapabilities(): Promise<WorkerCapability[]> {
|
|
227
|
+
// A bad probe must not brick boot: the worker announces nothing rather
|
|
228
|
+
// than refusing to announce, and apps needing a host kind land nowhere
|
|
229
|
+
// until the operator fixes the probe and restarts.
|
|
230
|
+
try {
|
|
231
|
+
return await opts.capabilities();
|
|
232
|
+
} catch (err) {
|
|
233
|
+
log.warn(`capabilities probe threw (${message(err)}); announcing []`);
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function readIngressImage(): Promise<{ imageRef: string } | null> {
|
|
239
|
+
// Same reasoning as the capabilities probe, and the same answer: a docker
|
|
240
|
+
// socket that will not answer must cost this box its version line, not its
|
|
241
|
+
// session. Null is already the shape for "no ingress here", so a failure
|
|
242
|
+
// reads as one - which is why the log line is what tells them apart.
|
|
243
|
+
try {
|
|
244
|
+
return await opts.ingressImage();
|
|
245
|
+
} catch (err) {
|
|
246
|
+
log.warn(`ingress image lookup threw (${message(err)}); announcing none`);
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function readUpdates(): Promise<UpdateStatus[]> {
|
|
252
|
+
// Same again. `[]` is the shape for a box with no `etc/` tree, so a read
|
|
253
|
+
// that threw reads as one, and the log line is what tells them apart.
|
|
254
|
+
try {
|
|
255
|
+
return await opts.updates();
|
|
256
|
+
} catch (err) {
|
|
257
|
+
log.warn(`update status read threw (${message(err)}); announcing []`);
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function enroll(
|
|
263
|
+
live: UplinkSession,
|
|
264
|
+
capabilities: WorkerCapability[],
|
|
265
|
+
publicIp: string | undefined,
|
|
266
|
+
): Promise<Announced> {
|
|
267
|
+
const otp = opts.otp;
|
|
268
|
+
if (otp === null) {
|
|
269
|
+
return {
|
|
270
|
+
stop:
|
|
271
|
+
"this worker has not enrolled and REALM_TOKEN is unset, so there is nothing to " +
|
|
272
|
+
"enroll with. Add a worker in the dashboard and restart this box with the token " +
|
|
273
|
+
"it hands you.",
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const answer = await callJson(live.rpc, "enroll", {
|
|
278
|
+
otp,
|
|
279
|
+
...(opts.name === null ? {} : { name: opts.name }),
|
|
280
|
+
capabilities,
|
|
281
|
+
version: readNodeVersion(),
|
|
282
|
+
ingress: await readIngressImage(),
|
|
283
|
+
...(publicIp === undefined ? {} : { publicIp }),
|
|
284
|
+
});
|
|
285
|
+
enrolled = { workerId: answer.workerId, slug: answer.slug };
|
|
286
|
+
await opts.onEnrolled(enrolled);
|
|
287
|
+
setPublicIpEcho(answer.publicIpUrl);
|
|
288
|
+
log(`${UPLINK_LOG.open} ${answer.slug} (enrolled as ${answer.workerId})`);
|
|
289
|
+
return "open";
|
|
290
|
+
} catch (err) {
|
|
291
|
+
// A burnt, expired or wrong OTP is not retried: every retry would be
|
|
292
|
+
// the same refusal, and the fix is a fresh token from the dashboard.
|
|
293
|
+
if (err instanceof MethodError && (err.status === 404 || err.status === 410)) {
|
|
294
|
+
return { stop: `${UPLINK_LOG.tokenRefused} (${err.status}): ${err.body}` };
|
|
295
|
+
}
|
|
296
|
+
log.warn(`enroll failed (${message(err)})`);
|
|
297
|
+
return "retry";
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function announce(live: UplinkSession): Promise<Announced> {
|
|
302
|
+
const capabilities = await readCapabilities();
|
|
303
|
+
const publicIp = (await currentPublicIp()) ?? undefined;
|
|
304
|
+
if (enrolled === null) return await enroll(live, capabilities, publicIp);
|
|
305
|
+
|
|
306
|
+
const version = readNodeVersion();
|
|
307
|
+
let answer: Result<"hello">;
|
|
308
|
+
try {
|
|
309
|
+
answer = await callJson(live.rpc, "hello", { protocol: PROTOCOL_VERSION, version });
|
|
310
|
+
} catch (err) {
|
|
311
|
+
if (err instanceof MethodError && err.status === 404) {
|
|
312
|
+
// The row is gone. A token in the environment is how an operator
|
|
313
|
+
// re-arms a revoked box: edit the .env, restart, enroll afresh.
|
|
314
|
+
if (opts.otp === null) {
|
|
315
|
+
return {
|
|
316
|
+
stop:
|
|
317
|
+
"Home has no worker with this box's key. It was removed, or this box was " +
|
|
318
|
+
"restored from another realm's backup. Add a worker in the dashboard and " +
|
|
319
|
+
"restart this box with the token it hands you.",
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
log.warn("Home does not know this worker; enrolling with the token in REALM_TOKEN");
|
|
323
|
+
return await enroll(live, capabilities, publicIp);
|
|
324
|
+
}
|
|
325
|
+
log.warn(`hello failed (${message(err)})`);
|
|
326
|
+
return "retry";
|
|
327
|
+
}
|
|
328
|
+
setPublicIpEcho(answer.publicIpUrl);
|
|
329
|
+
|
|
330
|
+
if (answer.protocol !== PROTOCOL_VERSION) {
|
|
331
|
+
// Nothing this worker says past the door would be understood, so it says
|
|
332
|
+
// nothing. The session stays up because it is the only thing that can
|
|
333
|
+
// carry the `update` that fixes this.
|
|
334
|
+
log.warn(
|
|
335
|
+
`Home speaks uplink protocol ${answer.protocol} and this worker speaks ` +
|
|
336
|
+
`${PROTOCOL_VERSION}; holding the session and waiting for Home to update this box`,
|
|
337
|
+
);
|
|
338
|
+
held = {
|
|
339
|
+
live,
|
|
340
|
+
reason:
|
|
341
|
+
`Home speaks uplink protocol ${answer.protocol} and this worker speaks ` +
|
|
342
|
+
`${PROTOCOL_VERSION}; waiting to be updated`,
|
|
343
|
+
};
|
|
344
|
+
return "held";
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
await callJson(live.rpc, "announce", {
|
|
349
|
+
capabilities,
|
|
350
|
+
ingress: await readIngressImage(),
|
|
351
|
+
appliedEtag: opts.appliedEtag(),
|
|
352
|
+
updates: await readUpdates(),
|
|
353
|
+
...(publicIp === undefined ? {} : { publicIp }),
|
|
354
|
+
});
|
|
355
|
+
} catch (err) {
|
|
356
|
+
log.warn(`announce failed (${message(err)})`);
|
|
357
|
+
return "retry";
|
|
358
|
+
}
|
|
359
|
+
const label = shortNodeVersion(version);
|
|
360
|
+
log(`${UPLINK_LOG.open} ${answer.slug}${label === null ? "" : ` (version ${label})`}`);
|
|
361
|
+
return "open";
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Pings on the cadence and closes the session when a pong is late. Keyed on
|
|
366
|
+
* the session the loop holds rather than on the announced one, because a held
|
|
367
|
+
* session has to stay alive to receive its `update`.
|
|
368
|
+
*/
|
|
369
|
+
function keepPinging(live: UplinkSession): void {
|
|
370
|
+
void (async () => {
|
|
371
|
+
// One continuation on `closed`, not one per tick: the wait below is cut
|
|
372
|
+
// short through `wakeTick` so teardown never sits out a whole interval.
|
|
373
|
+
let ended = false;
|
|
374
|
+
let wakeTick: (() => void) | null = null;
|
|
375
|
+
void live.closed.then(() => {
|
|
376
|
+
ended = true;
|
|
377
|
+
wakeTick?.();
|
|
378
|
+
});
|
|
379
|
+
while (dialled === live && !closing && !ended) {
|
|
380
|
+
await new Promise<void>((resolve) => {
|
|
381
|
+
const id = setTimeout(resolve, timing.pingIntervalMs);
|
|
382
|
+
wakeTick = () => {
|
|
383
|
+
clearTimeout(id);
|
|
384
|
+
resolve();
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
wakeTick = null;
|
|
388
|
+
if (dialled !== live || closing || ended) return;
|
|
389
|
+
try {
|
|
390
|
+
await withDeadline(live.ping(), timing.pongTimeoutMs);
|
|
391
|
+
} catch {
|
|
392
|
+
live.close(`no pong inside ${timing.pongTimeoutMs}ms`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
})();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function run(): Promise<string> {
|
|
400
|
+
let attempt = 0;
|
|
401
|
+
while (!closing) {
|
|
402
|
+
let live: UplinkSession;
|
|
403
|
+
try {
|
|
404
|
+
live = await dial({
|
|
405
|
+
url: opts.url,
|
|
406
|
+
identity: opts.identity,
|
|
407
|
+
homePublicKey: opts.homePublicKey,
|
|
408
|
+
handlers: opts.handlers,
|
|
409
|
+
});
|
|
410
|
+
} catch (err) {
|
|
411
|
+
if (err instanceof HomeKeyMismatch) {
|
|
412
|
+
log.error(
|
|
413
|
+
`${UPLINK_LOG.keyMismatch}: ${opts.url} answered with ${err.actual}, ` +
|
|
414
|
+
`not the pinned ${err.expected}. Refusing to speak to it and not retrying; ` +
|
|
415
|
+
`this worker's apps keep running. Either something is impersonating Home, ` +
|
|
416
|
+
`or this box holds a stale pin.`,
|
|
417
|
+
);
|
|
418
|
+
return "Home's static key is not the pinned one";
|
|
419
|
+
}
|
|
420
|
+
attempt++;
|
|
421
|
+
const delay = backoff(attempt);
|
|
422
|
+
log.warn(
|
|
423
|
+
`Uplink to ${opts.url} failed (${message(err)}); retrying in ${Math.round(delay)}ms`,
|
|
424
|
+
);
|
|
425
|
+
await sleep(delay);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
dialled = live;
|
|
429
|
+
|
|
430
|
+
const outcome = await announce(live);
|
|
431
|
+
if (outcome !== "open" && outcome !== "held") {
|
|
432
|
+
live.close(outcome === "retry" ? "announce failed" : "stopping");
|
|
433
|
+
await live.closed;
|
|
434
|
+
if (outcome !== "retry") {
|
|
435
|
+
log.error(outcome.stop);
|
|
436
|
+
return outcome.stop;
|
|
437
|
+
}
|
|
438
|
+
attempt++;
|
|
439
|
+
await sleep(backoff(attempt));
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// The hello landed, so the next dial starts at full speed whether or not
|
|
444
|
+
// the announce followed it.
|
|
445
|
+
attempt = 0;
|
|
446
|
+
if (outcome === "open") session = live;
|
|
447
|
+
keepPinging(live);
|
|
448
|
+
// Rekey by reconnecting, so no session holds one key for a day.
|
|
449
|
+
const rekeyAt = timing.rekeyIntervalMs + (Math.random() * 2 - 1) * timing.rekeyJitterMs;
|
|
450
|
+
const rekey = setTimeout(() => live.close("rekey"), rekeyAt);
|
|
451
|
+
// Connect is pull: the reconcile Home dropped while this worker was
|
|
452
|
+
// down is subsumed by the one every connect runs. A held session pulls
|
|
453
|
+
// nothing — it does not know what Home would send it.
|
|
454
|
+
if (outcome === "open") opts.onConnected();
|
|
455
|
+
|
|
456
|
+
const reason = await live.closed;
|
|
457
|
+
clearTimeout(rekey);
|
|
458
|
+
session = null;
|
|
459
|
+
held = null;
|
|
460
|
+
dialled = null;
|
|
461
|
+
if (closing) break;
|
|
462
|
+
attempt++;
|
|
463
|
+
const delay = backoff(attempt);
|
|
464
|
+
log.warn(`Uplink closed (${reason}); reconnecting in ${Math.round(delay)}ms`);
|
|
465
|
+
await sleep(delay);
|
|
466
|
+
}
|
|
467
|
+
return "the worker stopped the uplink";
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const loop = run().then((reason) => {
|
|
471
|
+
session = null;
|
|
472
|
+
held = null;
|
|
473
|
+
dialled = null;
|
|
474
|
+
settle(reason);
|
|
475
|
+
return reason;
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
get online() {
|
|
480
|
+
return session !== null;
|
|
481
|
+
},
|
|
482
|
+
stopped,
|
|
483
|
+
verify: async (params) => await callJson(requireSession("verify").rpc, "verify", params),
|
|
484
|
+
apiAccess: async (params) =>
|
|
485
|
+
await callJson(requireSession("apiAccess").rpc, "apiAccess", params),
|
|
486
|
+
pull: async (params) => await pullState(requireSession("state.pull").rpc, params),
|
|
487
|
+
applied: async (etag, apps) => {
|
|
488
|
+
await callJson(requireSession("state.applied").rpc, "state.applied", { etag, apps });
|
|
489
|
+
},
|
|
490
|
+
updateStatus: async (status) => {
|
|
491
|
+
await callJson(requireSession("update.status").rpc, "update.status", status);
|
|
492
|
+
},
|
|
493
|
+
reconnect: (reason) => {
|
|
494
|
+
// Only a session past the door is worth dropping. Mid-dial there is
|
|
495
|
+
// nothing to replace, and the announce the loop is about to run already
|
|
496
|
+
// carries whatever moved.
|
|
497
|
+
(session ?? held?.live)?.close(reason);
|
|
498
|
+
},
|
|
499
|
+
close: async () => {
|
|
500
|
+
closing = true;
|
|
501
|
+
wake?.();
|
|
502
|
+
dialled?.close("the worker is stopping");
|
|
503
|
+
await loop;
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function withDeadline<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
509
|
+
promise.catch(() => {});
|
|
510
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
511
|
+
const expiry = new Promise<never>((_, reject) => {
|
|
512
|
+
timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
|
|
513
|
+
});
|
|
514
|
+
try {
|
|
515
|
+
return await Promise.race([promise, expiry]);
|
|
516
|
+
} finally {
|
|
517
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function message(err: unknown): string {
|
|
522
|
+
return err instanceof Error ? err.message : String(err);
|
|
523
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { WorkerState } from "../worker-protocol/index.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Defense-in-depth check on the deserialized wire payload before the
|
|
5
|
+
* renderer runs. Home already validates its inputs end-to-end, but a
|
|
6
|
+
* mid-flight tarball corruption or a buggy Home commit shouldn't
|
|
7
|
+
* surface as a confusing renderer crash on the worker.
|
|
8
|
+
*
|
|
9
|
+
* Today: every depends_on edge points at an app+service that actually
|
|
10
|
+
* exists in the same payload.
|
|
11
|
+
*/
|
|
12
|
+
export function validateWorkerState(state: WorkerState): void {
|
|
13
|
+
const services = new Map<string, Set<string>>();
|
|
14
|
+
for (const app of state.apps) {
|
|
15
|
+
services.set(app.appId, new Set(Object.keys(app.services)));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for (const app of state.apps) {
|
|
19
|
+
for (const [serviceName, edges] of Object.entries(app.dependsOn)) {
|
|
20
|
+
if (!services.get(app.appId)?.has(serviceName)) {
|
|
21
|
+
throw new Error(`dependsOn references missing service: ${app.appId}/${serviceName}`);
|
|
22
|
+
}
|
|
23
|
+
for (const edge of edges) {
|
|
24
|
+
const providerSvcs = services.get(edge.providerAppId);
|
|
25
|
+
if (!providerSvcs || !providerSvcs.has(edge.providerServiceName)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`dependsOn edge in ${app.appId}/${serviceName} ` +
|
|
28
|
+
`points at missing producer ${edge.providerAppId}/${edge.providerServiceName}`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Worker-side ForwardAuth relay. The ingress gates every authenticated app
|
|
2
|
+
// subdomain with a sub-request to Home's `/api/access/verify`, but the ingress
|
|
3
|
+
// holds no uplink identity and must not: the worker is the one process on the
|
|
4
|
+
// box that does. So the ingress dials `realm-worker:6021` by container name on
|
|
5
|
+
// the management bridge and this listener opens a `verify` stream to Home.
|
|
6
|
+
//
|
|
7
|
+
// The neighbours are whoever the bind lets in. Told a `hostname` - the worker's
|
|
8
|
+
// own address on management-net, which is what platform/worker resolves and
|
|
9
|
+
// passes - that is the ingress and nothing else. Told none, the listener is on
|
|
10
|
+
// every interface, which includes each UI app's `realm_<appId>` bridge, because
|
|
11
|
+
// the worker joins those for the surface proxy; every such app can then dial
|
|
12
|
+
// it.
|
|
13
|
+
//
|
|
14
|
+
// Either way only the single verify path is relayed, and that bound is
|
|
15
|
+
// load-bearing: it is the whole of what a neighbour may ask Home through this
|
|
16
|
+
// worker's session. A raw splice would hand the ingress - and anything else
|
|
17
|
+
// that reaches this port - the worker's own credential.
|
|
18
|
+
|
|
19
|
+
import { log } from "@local/logger";
|
|
20
|
+
import type { Params, Result } from "../worker-protocol/uplink/index.ts";
|
|
21
|
+
|
|
22
|
+
const VERIFY_PATH = "/api/access/verify";
|
|
23
|
+
|
|
24
|
+
// A verify is one session lookup — anything slow is Home being down, and
|
|
25
|
+
// the client is blocked on this hop, so fail fast rather than pile up.
|
|
26
|
+
const UPSTREAM_TIMEOUT_MS = 10_000;
|
|
27
|
+
|
|
28
|
+
interface VerifyRelayOptions {
|
|
29
|
+
port: number;
|
|
30
|
+
/**
|
|
31
|
+
* The one address to listen on: the worker's own on management-net. Omitted
|
|
32
|
+
* where the box could not resolve it, which binds every interface instead -
|
|
33
|
+
* see the warning below for why that is a fallback and not a default.
|
|
34
|
+
*/
|
|
35
|
+
hostname?: string;
|
|
36
|
+
/** The `verify` method on the live session; rejects at once while it is down. */
|
|
37
|
+
verify: (params: Params<"verify">) => Promise<Result<"verify">>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function startVerifyRelay(opts: VerifyRelayOptions): Deno.HttpServer {
|
|
41
|
+
if (opts.hostname === undefined) {
|
|
42
|
+
// Not fatal: a worker off management-net has no reachable relay whichever
|
|
43
|
+
// way this binds, and refusing to start would hide that behind a second
|
|
44
|
+
// failure. But say what the wide bind costs.
|
|
45
|
+
log.warn(
|
|
46
|
+
`verify relay: no management-net address, binding every interface - every UI app whose bridge this worker joined can reach :${opts.port}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return Deno.serve({ hostname: opts.hostname ?? "0.0.0.0", port: opts.port }, async (req) => {
|
|
50
|
+
// Caddy's forward_auth rewrite sends every gate check as
|
|
51
|
+
// `GET /api/access/verify` (the original method rides
|
|
52
|
+
// X-Forwarded-Method), so anything else is not a gate check.
|
|
53
|
+
const url = new URL(req.url);
|
|
54
|
+
if (req.method !== "GET" || url.pathname !== VERIFY_PATH) {
|
|
55
|
+
return new Response("not found", { status: 404 });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let answer: Result<"verify">;
|
|
59
|
+
try {
|
|
60
|
+
answer = await withDeadline(
|
|
61
|
+
opts.verify({ search: url.search, headers: [...req.headers] }),
|
|
62
|
+
UPSTREAM_TIMEOUT_MS,
|
|
63
|
+
);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
log.warn(`verify relay failed: ${(err as Error).message}`);
|
|
66
|
+
// Non-2xx is a deny, which Caddy relays to the client. Immediate while
|
|
67
|
+
// the uplink is down: waiting out a reconnect would hang every gated
|
|
68
|
+
// request on the box.
|
|
69
|
+
return new Response("verify upstream unreachable", { status: 502 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Status + headers verbatim: Caddy's copy_headers lifts the brokered
|
|
73
|
+
// identity headers off THIS response on 2xx, and a bounce needs its
|
|
74
|
+
// Location. Framing headers are dropped because Deno.serve re-frames the
|
|
75
|
+
// body it is handed.
|
|
76
|
+
const headers = new Headers();
|
|
77
|
+
for (const [name, value] of answer.headers) {
|
|
78
|
+
const key = name.toLowerCase();
|
|
79
|
+
if (key === "content-encoding" || key === "content-length") continue;
|
|
80
|
+
headers.append(name, value);
|
|
81
|
+
}
|
|
82
|
+
return new Response(decodeBody(answer.body), { status: answer.status, headers });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function decodeBody(body: string): Uint8Array<ArrayBuffer> | null {
|
|
87
|
+
if (body === "") return null;
|
|
88
|
+
return Uint8Array.fromBase64(body);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function withDeadline<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
92
|
+
promise.catch(() => {});
|
|
93
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
94
|
+
const expiry = new Promise<never>((_, reject) => {
|
|
95
|
+
timer = setTimeout(() => reject(new Error(`verify timed out after ${ms}ms`)), ms);
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
return await Promise.race([promise, expiry]);
|
|
99
|
+
} finally {
|
|
100
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
}
|