@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,462 @@
|
|
|
1
|
+
// The RPC catalogue: one schema pair per method in `todo/ideas/uplink.md`
|
|
2
|
+
// § 4 RPC, plus the typed helpers that call and answer them, so neither end
|
|
3
|
+
// ever hand-encodes JSON.
|
|
4
|
+
//
|
|
5
|
+
// Shapes only. Who may call what, and what a caller does while the session is
|
|
6
|
+
// down, are Home's and the worker's decisions — this file has no opinion.
|
|
7
|
+
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { HEADER_NAME_RE, HEADER_VALUE_RE } from "../../http-header.ts";
|
|
10
|
+
import { WORKER_CAPABILITIES, type WorkerCapability } from "../../permissions.ts";
|
|
11
|
+
import {
|
|
12
|
+
homeProxyRequestSchema,
|
|
13
|
+
homeProxyResponseSchema,
|
|
14
|
+
} from "../../runtime/docker/home-proxy-server/protocol.ts";
|
|
15
|
+
import { nodeVersionSchema } from "../node-version.ts";
|
|
16
|
+
import type { SiloOrphan } from "../silo-paths.ts";
|
|
17
|
+
import { UPDATE_ERROR_MAX_CHARS } from "../update-files.ts";
|
|
18
|
+
import { UplinkError, UplinkProtocolError } from "./errors.ts";
|
|
19
|
+
import { decodeUtf8, encodeUtf8 } from "./frame.ts";
|
|
20
|
+
import { jsonResponse, type Rpc, type RpcHandler } from "./rpc.ts";
|
|
21
|
+
|
|
22
|
+
/** The response header `state.pull` answers a 200 with. */
|
|
23
|
+
export const ETAG_HEADER = "etag";
|
|
24
|
+
|
|
25
|
+
// --- shared pieces ------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const capabilitiesSchema = z.array(
|
|
28
|
+
z.enum(WORKER_CAPABILITIES as readonly [WorkerCapability, ...WorkerCapability[]]),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// The worker's own view of its public address, from the acme-dns echo. Home
|
|
32
|
+
// cross-checks it against the socket's `X-Forwarded-For` but stores this one
|
|
33
|
+
// (`todo/ideas/uplink.md` § Identity). A junk filter, not a trust boundary.
|
|
34
|
+
const publicIpSchema = z.union([z.ipv4(), z.ipv6()]);
|
|
35
|
+
|
|
36
|
+
/** What both `enroll` and `hello` hand back once the worker row is known. */
|
|
37
|
+
const workerIdentitySchema = z.object({
|
|
38
|
+
workerId: z.string().min(1),
|
|
39
|
+
slug: z.string().min(1),
|
|
40
|
+
// Null when the realm has no Realm DNS at all, which is what keeps a
|
|
41
|
+
// self-hosted install from quietly calling yourrealm.eu.
|
|
42
|
+
publicIpUrl: z.string().nullable(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const okSchema = z.object({ ok: z.literal(true) });
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What the box's `realm-ingress` container is running, or null where there is
|
|
49
|
+
* no such container.
|
|
50
|
+
*
|
|
51
|
+
* Read off the container itself rather than off `etc/ingress/.env`, and the
|
|
52
|
+
* difference is the whole point: the `.env` says what this box is *configured*
|
|
53
|
+
* to run, while this says what it *is* running, and the two differ for exactly
|
|
54
|
+
* as long as an update is in flight or has failed. That gap is what the
|
|
55
|
+
* `applied` signal exists to see. It is still the pinned `name@sha256:` ref,
|
|
56
|
+
* because compose interpolates the `.env` pin into the service's `image:` and
|
|
57
|
+
* the container records that string verbatim.
|
|
58
|
+
*
|
|
59
|
+
* The ingress holds no session of its own - it is a proxy, not a node - so this
|
|
60
|
+
* is the only way its version reaches Home.
|
|
61
|
+
*/
|
|
62
|
+
const ingressVersionSchema = z.object({ imageRef: z.string().min(1) }).nullable();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The platform compose projects a worker will write a `.update` for.
|
|
66
|
+
*
|
|
67
|
+
* Two, and neither is `home` or `updater`: Home writes its own box's
|
|
68
|
+
* `etc/home/.update` directly, and the updater cannot apply an update to
|
|
69
|
+
* itself. Restricting it here rather than in the worker is what makes a wrong
|
|
70
|
+
* project a 400 on the wire instead of a path traversal argument.
|
|
71
|
+
*/
|
|
72
|
+
export const UPDATE_PROJECTS = ["worker", "ingress"] as const;
|
|
73
|
+
export type UpdateProject = (typeof UPDATE_PROJECTS)[number];
|
|
74
|
+
|
|
75
|
+
/** Longest `message` a per-app apply result carries: the tail of compose's stderr. */
|
|
76
|
+
export const APPLY_MESSAGE_MAX_CHARS = 2048;
|
|
77
|
+
/** Longest `error` an `update.status` carries: the file cap, declared beside its reader. */
|
|
78
|
+
export { UPDATE_ERROR_MAX_CHARS };
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One app's result from a reconcile. `message` is the tail of compose's stderr
|
|
82
|
+
* or the spawn error - what the box said, so Home can show it rather than an
|
|
83
|
+
* "applied" that means only "the etag landed".
|
|
84
|
+
*/
|
|
85
|
+
export const appApplyResultSchema = z.discriminatedUnion("ok", [
|
|
86
|
+
z.object({ appId: z.string().min(1), ok: z.literal(true) }),
|
|
87
|
+
z.object({
|
|
88
|
+
appId: z.string().min(1),
|
|
89
|
+
ok: z.literal(false),
|
|
90
|
+
message: z.string().min(1).max(APPLY_MESSAGE_MAX_CHARS),
|
|
91
|
+
}),
|
|
92
|
+
]);
|
|
93
|
+
export type AppApplyResult = z.infer<typeof appApplyResultSchema>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What one of the box's updater-driven compose projects is doing. `pending` is
|
|
97
|
+
* "`etc/<project>/.update` exists"; `error` is the trimmed content of
|
|
98
|
+
* `etc/<project>/.update.error`, or null when there is none. Independent of
|
|
99
|
+
* `version.imageRef`: a project can be pending with no error, failed with the
|
|
100
|
+
* token still present (the updater keeps `.update` as its retry token), or
|
|
101
|
+
* idle with a stale error file after a hand `rm`.
|
|
102
|
+
*/
|
|
103
|
+
export const updateStatusSchema = z.object({
|
|
104
|
+
project: z.enum(UPDATE_PROJECTS),
|
|
105
|
+
pending: z.boolean(),
|
|
106
|
+
error: z.string().min(1).max(UPDATE_ERROR_MAX_CHARS).nullable(),
|
|
107
|
+
});
|
|
108
|
+
export type UpdateStatus = z.infer<typeof updateStatusSchema>;
|
|
109
|
+
|
|
110
|
+
// A `[name, value]` list rather than a record, in both directions: the
|
|
111
|
+
// ForwardAuth hop relays request headers wholesale and a response can carry
|
|
112
|
+
// more than one `set-cookie`, which a `Record<string, string>` would lose.
|
|
113
|
+
const headerPairsSchema = z.array(z.tuple([z.string(), z.string()]));
|
|
114
|
+
|
|
115
|
+
// Mirrors the worker's `/ui-proxy` body. The port bound against the app's own
|
|
116
|
+
// declared ports is the worker's check, not this schema's.
|
|
117
|
+
const uiProxyRequestSchema = z.object({
|
|
118
|
+
appId: z.string().min(1),
|
|
119
|
+
service: z.string().min(1),
|
|
120
|
+
port: z.number().int().positive().max(65535),
|
|
121
|
+
path: z.string().startsWith("/"),
|
|
122
|
+
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
|
123
|
+
query: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
124
|
+
headers: z
|
|
125
|
+
.record(z.string(), z.string())
|
|
126
|
+
.refine(
|
|
127
|
+
(h) => Object.entries(h).every(([k, v]) => HEADER_NAME_RE.test(k) && HEADER_VALUE_RE.test(v)),
|
|
128
|
+
"invalid header",
|
|
129
|
+
)
|
|
130
|
+
.optional(),
|
|
131
|
+
body: z.unknown().optional(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Typed against the interface so the two cannot drift.
|
|
135
|
+
const siloOrphanSchema: z.ZodType<SiloOrphan> = z.object({
|
|
136
|
+
id: z.string().min(1),
|
|
137
|
+
mtime: z.string().nullable(),
|
|
138
|
+
sizeBytes: z.number().nullable(),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// --- the catalogue ------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Every method, by name. An entry with a `result` is JSON both ways and goes
|
|
145
|
+
* through `callJson`/`jsonHandler`; an entry with only `params` is
|
|
146
|
+
* byte-bodied and has its own helper below.
|
|
147
|
+
*/
|
|
148
|
+
export const methods = {
|
|
149
|
+
/** Worker → Home, on an unenrolled session. The only method one accepts. */
|
|
150
|
+
enroll: {
|
|
151
|
+
params: z.object({
|
|
152
|
+
otp: z.string().min(1),
|
|
153
|
+
/** Display name; the installer defaults it to the box's hostname. */
|
|
154
|
+
name: z.string().trim().max(64).optional(),
|
|
155
|
+
capabilities: capabilitiesSchema,
|
|
156
|
+
version: nodeVersionSchema,
|
|
157
|
+
/** What this box's ingress is running, or null if it has none. */
|
|
158
|
+
ingress: ingressVersionSchema,
|
|
159
|
+
publicIp: publicIpSchema.optional(),
|
|
160
|
+
}),
|
|
161
|
+
result: workerIdentitySchema,
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Worker → Home, first call on every connect: the door. 404 means the row is
|
|
166
|
+
* gone.
|
|
167
|
+
*
|
|
168
|
+
* This shape never changes. Every fact about the box goes in `announce`
|
|
169
|
+
* instead, precisely so that a worker and a Home on different builds can
|
|
170
|
+
* still open a session - which is the only way Home can tell that box to
|
|
171
|
+
* update. A `hello` Home could refuse strands the box until an operator
|
|
172
|
+
* writes the digest into `etc/worker/.update` by hand.
|
|
173
|
+
*
|
|
174
|
+
* The answer carries Home's own `protocol`. A worker that reads back a
|
|
175
|
+
* number other than its own holds the session and waits: it announces
|
|
176
|
+
* nothing, pulls nothing, and serves only what Home asks of it - `update`,
|
|
177
|
+
* which is what moves it onto Home's number.
|
|
178
|
+
*/
|
|
179
|
+
hello: {
|
|
180
|
+
params: z.object({
|
|
181
|
+
/**
|
|
182
|
+
* The catalogue version this worker speaks. Absent means 0: a worker from
|
|
183
|
+
* before the door existed says nothing here, and 0 is never a version a
|
|
184
|
+
* door-aware worker announces.
|
|
185
|
+
*
|
|
186
|
+
* The default is part of the door's definition rather than a shim to
|
|
187
|
+
* remove later. The door is frozen, so the rule that reads a silent
|
|
188
|
+
* worker as 0 is frozen with it.
|
|
189
|
+
*/
|
|
190
|
+
protocol: z.number().int().nonnegative().default(0),
|
|
191
|
+
version: nodeVersionSchema,
|
|
192
|
+
}),
|
|
193
|
+
result: workerIdentitySchema.extend({ protocol: z.number().int().nonnegative() }),
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Worker → Home, once per connect, right after a `hello` whose answer carried
|
|
198
|
+
* a matching `protocol`. Everything the box knows about itself, replacing
|
|
199
|
+
* whatever Home held for this worker.
|
|
200
|
+
*
|
|
201
|
+
* Free to change in a way `hello` is not: a worker only ever sends it to a
|
|
202
|
+
* Home that agreed on the catalogue version first.
|
|
203
|
+
*/
|
|
204
|
+
announce: {
|
|
205
|
+
params: z.object({
|
|
206
|
+
capabilities: capabilitiesSchema,
|
|
207
|
+
/** What this box's ingress is running, or null if it has none. */
|
|
208
|
+
ingress: ingressVersionSchema,
|
|
209
|
+
/** What this worker is actually running, not what it was last sent. */
|
|
210
|
+
appliedEtag: z.string().nullable(),
|
|
211
|
+
/**
|
|
212
|
+
* The box's whole update picture, replacing whatever Home held for this
|
|
213
|
+
* worker. A project absent here is unknown on this box (no
|
|
214
|
+
* `etc/<project>` dir); a worker with no `etc/` at all sends `[]`.
|
|
215
|
+
*/
|
|
216
|
+
updates: z.array(updateStatusSchema),
|
|
217
|
+
publicIp: publicIpSchema.optional(),
|
|
218
|
+
}),
|
|
219
|
+
result: okSchema,
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
/** Worker → Home. 304 unchanged, 503 pending, 200 the tar.gz as bytes. */
|
|
223
|
+
"state.pull": {
|
|
224
|
+
params: z.object({
|
|
225
|
+
etag: z.string().nullable(),
|
|
226
|
+
publicIp: publicIpSchema.optional(),
|
|
227
|
+
}),
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Worker → Home, after a reconcile lands. `apps` is one entry per app the
|
|
232
|
+
* worker ran `compose up` for, so the etag says what was pulled and the
|
|
233
|
+
* entries say what actually came up.
|
|
234
|
+
*/
|
|
235
|
+
"state.applied": {
|
|
236
|
+
params: z.object({ etag: z.string().min(1), apps: z.array(appApplyResultSchema) }),
|
|
237
|
+
result: okSchema,
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
/** Worker → Home, one stream per gated request. The hot path. */
|
|
241
|
+
verify: {
|
|
242
|
+
params: z.object({
|
|
243
|
+
/** The original request's query string, `?` included, or empty. */
|
|
244
|
+
search: z.string(),
|
|
245
|
+
headers: headerPairsSchema,
|
|
246
|
+
}),
|
|
247
|
+
result: z.object({
|
|
248
|
+
status: z.number().int(),
|
|
249
|
+
headers: headerPairsSchema,
|
|
250
|
+
/** Base64: the relayed body is whatever the gate answered with. */
|
|
251
|
+
body: z.base64(),
|
|
252
|
+
}),
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
/** Worker → Home, the `home.apiAccess` broker, with the calling app id. */
|
|
256
|
+
apiAccess: {
|
|
257
|
+
params: homeProxyRequestSchema.extend({ appId: z.string().min(1) }),
|
|
258
|
+
result: homeProxyResponseSchema,
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Worker → Home, when a project's `.update` / `.update.error` changes on
|
|
263
|
+
* disk. One row, upserted; `announce.updates` is the whole set.
|
|
264
|
+
*/
|
|
265
|
+
"update.status": {
|
|
266
|
+
params: updateStatusSchema,
|
|
267
|
+
result: okSchema,
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
/** Home → worker. Zero payload; dropped while the session is down. */
|
|
271
|
+
reconcile: {
|
|
272
|
+
params: z.object({}),
|
|
273
|
+
result: okSchema,
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
/** Home → worker. The response status is the proxied one, body the bytes. */
|
|
277
|
+
uiProxy: {
|
|
278
|
+
params: uiProxyRequestSchema,
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Home → worker. The worker writes `etc/<project>/.update`; its sidecar
|
|
283
|
+
* applies it. One method for both roles the worker can move, because the box
|
|
284
|
+
* has one sidecar and one etc/ tree - the project names which of them.
|
|
285
|
+
*/
|
|
286
|
+
update: {
|
|
287
|
+
params: z.object({
|
|
288
|
+
imageRef: z.string().min(1),
|
|
289
|
+
project: z.enum(UPDATE_PROJECTS),
|
|
290
|
+
}),
|
|
291
|
+
result: okSchema,
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
/** Home → worker. `live` is every Silo id that still has a row. */
|
|
295
|
+
siloOrphans: {
|
|
296
|
+
params: z.object({ live: z.array(z.string()) }),
|
|
297
|
+
result: z.array(siloOrphanSchema),
|
|
298
|
+
},
|
|
299
|
+
} as const;
|
|
300
|
+
|
|
301
|
+
type Methods = typeof methods;
|
|
302
|
+
|
|
303
|
+
export type MethodName = keyof Methods;
|
|
304
|
+
|
|
305
|
+
/** The methods that are JSON in both directions. */
|
|
306
|
+
export type JsonMethod = {
|
|
307
|
+
[M in MethodName]: Methods[M] extends { result: z.ZodType } ? M : never;
|
|
308
|
+
}[MethodName];
|
|
309
|
+
|
|
310
|
+
export type Params<M extends MethodName> = z.infer<Methods[M]["params"]>;
|
|
311
|
+
export type Result<M extends JsonMethod> = z.infer<Methods[M]["result"]>;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* A method answered — or is being answered — with a non-2xx status.
|
|
315
|
+
*
|
|
316
|
+
* Both directions on purpose: `callJson` throws it with what the peer said,
|
|
317
|
+
* and a handler throws it to say what to answer (`hello` → 404 for a worker
|
|
318
|
+
* whose row is gone). `status` is the contract; `body` is diagnostic.
|
|
319
|
+
*/
|
|
320
|
+
export class MethodError extends UplinkError {
|
|
321
|
+
readonly method: string;
|
|
322
|
+
readonly status: number;
|
|
323
|
+
readonly body: string;
|
|
324
|
+
|
|
325
|
+
constructor(method: string, status: number, body: string) {
|
|
326
|
+
super(`${method} answered ${status}${body === "" ? "" : `: ${body.slice(0, 200)}`}`);
|
|
327
|
+
this.name = "MethodError";
|
|
328
|
+
this.method = method;
|
|
329
|
+
this.status = status;
|
|
330
|
+
this.body = body;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --- calling ------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/** Calls a JSON method and validates the answer against its result schema. */
|
|
337
|
+
export async function callJson<M extends JsonMethod>(
|
|
338
|
+
rpc: Rpc,
|
|
339
|
+
method: M,
|
|
340
|
+
params: Params<M>,
|
|
341
|
+
): Promise<Result<M>> {
|
|
342
|
+
const spec = methods[method] as { params: z.ZodType; result: z.ZodType };
|
|
343
|
+
const res = await rpc.call(method, {}, encodeJson(spec.params, params));
|
|
344
|
+
const text = decodeUtf8(await readAll(res.body));
|
|
345
|
+
if (res.status < 200 || res.status >= 300) throw new MethodError(method, res.status, text);
|
|
346
|
+
return parseResult(method, spec.result, text) as Result<M>;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export interface StatePull {
|
|
350
|
+
/** 200 with a body, 304 unchanged, 503 while the first recompute pends. */
|
|
351
|
+
status: number;
|
|
352
|
+
/** Present on a 200; what the next pull sends back as `etag`. */
|
|
353
|
+
etag?: string;
|
|
354
|
+
/** The tar.gz, unread. The caller drains or cancels it. */
|
|
355
|
+
body: ReadableStream<Uint8Array>;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** `state.pull`, whose 200 body is the per-worker bundle rather than JSON. */
|
|
359
|
+
export async function pullState(rpc: Rpc, params: Params<"state.pull">): Promise<StatePull> {
|
|
360
|
+
const res = await rpc.call("state.pull", {}, encodeJson(methods["state.pull"].params, params));
|
|
361
|
+
const etag = res.headers[ETAG_HEADER];
|
|
362
|
+
return etag === undefined
|
|
363
|
+
? { status: res.status, body: res.body }
|
|
364
|
+
: { status: res.status, etag, body: res.body };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export interface UiProxyResult {
|
|
368
|
+
/** The status the container answered the worker with. */
|
|
369
|
+
status: number;
|
|
370
|
+
headers: Record<string, string>;
|
|
371
|
+
body: Uint8Array;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** `uiProxy`, whose response body is bytes the worker relayed verbatim. */
|
|
375
|
+
export async function callUiProxy(rpc: Rpc, payload: Params<"uiProxy">): Promise<UiProxyResult> {
|
|
376
|
+
const res = await rpc.call("uiProxy", {}, encodeJson(methods.uiProxy.params, payload));
|
|
377
|
+
return { status: res.status, headers: res.headers, body: await readAll(res.body) };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// --- answering ----------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Wraps a handler for one JSON method: the body is parsed against its params
|
|
384
|
+
* schema (400 on a mismatch) and the return value is answered as JSON. Throw
|
|
385
|
+
* `MethodError` to answer any other status.
|
|
386
|
+
*/
|
|
387
|
+
export function jsonHandler<M extends JsonMethod>(
|
|
388
|
+
method: M,
|
|
389
|
+
fn: (params: Params<M>) => Promise<Result<M>> | Result<M>,
|
|
390
|
+
): RpcHandler {
|
|
391
|
+
const spec = methods[method] as { params: z.ZodType };
|
|
392
|
+
return async (req) => {
|
|
393
|
+
let raw: unknown;
|
|
394
|
+
try {
|
|
395
|
+
raw = JSON.parse(decodeUtf8(await readAll(req.body)));
|
|
396
|
+
} catch {
|
|
397
|
+
return jsonResponse(400, { error: `${method}: body is not JSON` });
|
|
398
|
+
}
|
|
399
|
+
const parsed = spec.params.safeParse(raw);
|
|
400
|
+
if (!parsed.success) {
|
|
401
|
+
return jsonResponse(400, { error: `${method}: ${issueText(parsed.error)}` });
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
return jsonResponse(200, await fn(parsed.data as Params<M>));
|
|
405
|
+
} catch (e) {
|
|
406
|
+
// Anything else is a bug, and rpc.ts frames it as a 500.
|
|
407
|
+
if (e instanceof MethodError) return jsonResponse(e.status, { error: e.body });
|
|
408
|
+
throw e;
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// --- helpers ------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
function encodeJson(schema: z.ZodType, params: unknown): Uint8Array {
|
|
416
|
+
// Parsed on the way out too: the refinements a type cannot express — a
|
|
417
|
+
// `path` that is safe, a header value that is printable — are exactly the
|
|
418
|
+
// ones worth catching before they reach the peer.
|
|
419
|
+
return encodeUtf8(JSON.stringify(schema.parse(params)));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function parseResult(method: string, schema: z.ZodType, text: string): unknown {
|
|
423
|
+
let value: unknown;
|
|
424
|
+
try {
|
|
425
|
+
value = JSON.parse(text);
|
|
426
|
+
} catch {
|
|
427
|
+
throw new UplinkProtocolError(`${method} answered with a body that is not JSON`);
|
|
428
|
+
}
|
|
429
|
+
const parsed = schema.safeParse(value);
|
|
430
|
+
if (!parsed.success) {
|
|
431
|
+
throw new UplinkProtocolError(
|
|
432
|
+
`${method} answered an unexpected shape: ${issueText(parsed.error)}`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
return parsed.data;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function issueText(error: z.ZodError): string {
|
|
439
|
+
const issue = error.issues[0];
|
|
440
|
+
if (issue === undefined) return "invalid";
|
|
441
|
+
const path = issue.path.join(".");
|
|
442
|
+
return path === "" ? issue.message : `${path}: ${issue.message}`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function readAll(body: ReadableStream<Uint8Array>): Promise<Uint8Array> {
|
|
446
|
+
const reader = body.getReader();
|
|
447
|
+
const chunks: Uint8Array[] = [];
|
|
448
|
+
let size = 0;
|
|
449
|
+
for (;;) {
|
|
450
|
+
const { value, done } = await reader.read();
|
|
451
|
+
if (done) break;
|
|
452
|
+
chunks.push(value);
|
|
453
|
+
size += value.length;
|
|
454
|
+
}
|
|
455
|
+
const out = new Uint8Array(size);
|
|
456
|
+
let offset = 0;
|
|
457
|
+
for (const chunk of chunks) {
|
|
458
|
+
out.set(chunk, offset);
|
|
459
|
+
offset += chunk.length;
|
|
460
|
+
}
|
|
461
|
+
return out;
|
|
462
|
+
}
|