@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,16 @@
|
|
|
1
|
+
// @yourrealm/sdk/runtime/docker/ingress — the Docker worker's ingress renderer.
|
|
2
|
+
//
|
|
3
|
+
// Transforms `WorkerState.ingress` (from @yourrealm/sdk/worker-protocol) into
|
|
4
|
+
// a Caddy JSON config document. The sibling of `runtime/docker/compose`: same
|
|
5
|
+
// wire, same injection point in `platform/worker`, one renderer per half of
|
|
6
|
+
// the protocol. Caddy is this implementation's choice of proxy, not a
|
|
7
|
+
// Realm-wide one - a different worker satisfies the same `ingress` field with
|
|
8
|
+
// whatever its runtime routes traffic through.
|
|
9
|
+
//
|
|
10
|
+
// No I/O, no docker socket, no network, and nothing at module load: this entry
|
|
11
|
+
// is on the SDK's exports map, so an author's realm.ts may import it and have
|
|
12
|
+
// it evaluated inside QuickJS, which `worker-protocol/quickjs-safe.test.ts`
|
|
13
|
+
// keeps honest.
|
|
14
|
+
|
|
15
|
+
export { ACME_CA_ROOT_PATH, type BuildInput, buildCaddyConfig } from "./build-config.ts";
|
|
16
|
+
export type { CaddyConfig } from "./types.ts";
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The shape of Caddy's config document — what `caddy run --config` reads.
|
|
2
|
+
// Types only; `build-config.ts` is what fills them in.
|
|
3
|
+
|
|
4
|
+
export interface CaddyConfig {
|
|
5
|
+
admin: { listen: string };
|
|
6
|
+
apps: {
|
|
7
|
+
http: { servers: { main: CaddyServer } };
|
|
8
|
+
tls: {
|
|
9
|
+
// `certificates.automate` is what actually makes Caddy *obtain* a cert
|
|
10
|
+
// for a name; the automation policy's `subjects` only selects which
|
|
11
|
+
// issuer to use. Without the wildcard listed here Caddy manages a
|
|
12
|
+
// per-host cert for each route's exact Host (e.g. the dashboard host),
|
|
13
|
+
// whose DNS-01 challenge (`_acme-challenge.<host>`) the acme-dns shim
|
|
14
|
+
// never writes - it writes only the wildcard challenge at the workerId
|
|
15
|
+
// level.
|
|
16
|
+
certificates?: { automate: string[] };
|
|
17
|
+
automation: { policies: CaddyTlsPolicy[] };
|
|
18
|
+
};
|
|
19
|
+
// Present iff acme-dns is configured: keeps the worker's wildcard A
|
|
20
|
+
// (`*.{workerId}.{baseDomain}`) pointed at its own public IP.
|
|
21
|
+
dynamic_dns?: CaddyDynamicDns;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// caddy-dynamicdns app config. `domains` maps a zone to the subdomain labels
|
|
26
|
+
// to keep updated - here the single worker-id label under the slug base. The
|
|
27
|
+
// `realm` provider POSTs each to the shim's /a; the shim records the request
|
|
28
|
+
// source IP and ignores any supplied value, so the `simple_http` ip_source
|
|
29
|
+
// (the shim's /ip echo) only decides *when* to write. A-only (ipv4).
|
|
30
|
+
export interface CaddyDynamicDns {
|
|
31
|
+
domains: Record<string, string[]>;
|
|
32
|
+
dns_provider: { name: "realm"; server_url: string; username: string; api_key: string };
|
|
33
|
+
ip_sources: Array<{ source: "simple_http"; endpoints: string[] }>;
|
|
34
|
+
check_interval: string;
|
|
35
|
+
versions: { ipv4: boolean; ipv6: boolean };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type CaddyProvider =
|
|
39
|
+
// Module IDs are the no-hyphen form (`dns.providers.acmedns`) matching
|
|
40
|
+
// the plugin import paths under github.com/caddy-dns/<id>. The acme-dns
|
|
41
|
+
// config field names come from libdns/acmedns's Provider struct json
|
|
42
|
+
// tags (server_url, username, password, subdomain).
|
|
43
|
+
| { name: "acmedns"; server_url: string; username: string; password: string; subdomain: string }
|
|
44
|
+
// BYO public-DNS tokens, inlined: Home decrypts the operator's stored token
|
|
45
|
+
// and puts the plaintext on the per-worker encrypted wire, because the
|
|
46
|
+
// caddy-dns modules read their libdns struct fields verbatim and never run
|
|
47
|
+
// Caddy's placeholder replacer, so an `{env.X}` form would reach the provider
|
|
48
|
+
// as a literal string.
|
|
49
|
+
| { name: "cloudflare"; api_token: string }
|
|
50
|
+
| { name: "hetzner"; api_token: string };
|
|
51
|
+
|
|
52
|
+
export type CaddyIssuer =
|
|
53
|
+
| {
|
|
54
|
+
module: "acme";
|
|
55
|
+
challenges: {
|
|
56
|
+
dns: {
|
|
57
|
+
provider: CaddyProvider;
|
|
58
|
+
// Hard wait after writing the TXT before Caddy asks the CA to
|
|
59
|
+
// validate, so a Let's Encrypt perspective can't query before the
|
|
60
|
+
// write has reached every authoritative NS ("During secondary
|
|
61
|
+
// validation: No TXT record found"). Only emitted for the acme-dns
|
|
62
|
+
// issuer.
|
|
63
|
+
propagation_delay?: string;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
// Omitted = Caddy's default ACME endpoint (Let's Encrypt prod).
|
|
67
|
+
ca?: string;
|
|
68
|
+
// Roots to trust for the ACME endpoint, for a non-public test CA.
|
|
69
|
+
trusted_roots_pem_files?: string[];
|
|
70
|
+
}
|
|
71
|
+
// Caddy's built-in self-signed CA. Bootstrap fallback when no real
|
|
72
|
+
// DNS-01 provider is configured (dev, e2e, first-boot before installer).
|
|
73
|
+
| { module: "internal" };
|
|
74
|
+
|
|
75
|
+
export interface CaddyTlsPolicy {
|
|
76
|
+
// Omitted = catch-all (matches any host not covered by an earlier policy).
|
|
77
|
+
subjects?: string[];
|
|
78
|
+
issuers: CaddyIssuer[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface CaddyServer {
|
|
82
|
+
listen: string[];
|
|
83
|
+
routes: CaddyRoute[];
|
|
84
|
+
// Server-level real-client-IP resolution. Present iff at least one CDN is
|
|
85
|
+
// declared in front. `source: "cdn"` is Realm's own ip_source module baked
|
|
86
|
+
// into the ingress image, which holds each provider's list URLs and refreshes
|
|
87
|
+
// them on a timer, so no range ever appears in this config. Caddy takes one
|
|
88
|
+
// ip_source per server, so several CDNs mean one module trusting the union.
|
|
89
|
+
// `trusted_proxies_strict` forces right-to-left XFF parsing;
|
|
90
|
+
// `client_ip_headers` is the header set `{http.vars.client_ip}` reads from.
|
|
91
|
+
trusted_proxies?: { source: "cdn"; providers: string[] };
|
|
92
|
+
trusted_proxies_strict?: 1;
|
|
93
|
+
client_ip_headers?: string[];
|
|
94
|
+
// Error handler chain. Reached when a handler fails rather than answers —
|
|
95
|
+
// notably a reverse_proxy that cannot dial its upstream, which is how a
|
|
96
|
+
// route to a not-yet-running container surfaces.
|
|
97
|
+
errors?: { routes: CaddyErrorRoute[] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A matcher with no `host` matches every host the server answers - the asset
|
|
101
|
+
// links route is the one that does, and a path-only matcher adds nothing to
|
|
102
|
+
// Caddy's managed-certificate set.
|
|
103
|
+
export interface CaddyRoute {
|
|
104
|
+
match: Array<{ host?: string[]; path?: string[] }>;
|
|
105
|
+
handle: CaddyHandler[];
|
|
106
|
+
terminal: true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// `not` excludes hosts that have their own meaning for an error (the dashboard:
|
|
110
|
+
// a 502 there means Home is down, not that an app is starting). `expression` is
|
|
111
|
+
// CEL over the error, narrowing to dial/timeout failures so a 413 or a 429 from
|
|
112
|
+
// a healthy app doesn't get reported as a container that isn't up.
|
|
113
|
+
export interface CaddyErrorRoute {
|
|
114
|
+
match: Array<{ host: string[]; not?: Array<{ host: string[] }>; expression?: string }>;
|
|
115
|
+
handle: CaddyHandler[];
|
|
116
|
+
terminal: true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A header rewrite (request or response). The forward_auth hop sets request
|
|
120
|
+
// headers; the security-header handler uses `response`; the Cookie-strip uses
|
|
121
|
+
// `request.replace` to snip just the Home session cookie out of the header.
|
|
122
|
+
export interface CaddyHeaderRewrite {
|
|
123
|
+
set?: Record<string, string[]>;
|
|
124
|
+
delete?: string[];
|
|
125
|
+
replace?: Record<string, Array<{ search_regexp: string; replace: string }>>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type CaddyHandler =
|
|
129
|
+
| { handler: "encode"; encodings: { gzip: Record<string, never> }; prefer: string[] }
|
|
130
|
+
| {
|
|
131
|
+
handler: "headers";
|
|
132
|
+
response?: CaddyHeaderRewrite;
|
|
133
|
+
request?: CaddyHeaderRewrite;
|
|
134
|
+
}
|
|
135
|
+
| { handler: "request_body"; max_size: number }
|
|
136
|
+
| {
|
|
137
|
+
handler: "rate_limit";
|
|
138
|
+
rate_limits: Record<string, { key: string; window: string; max_events: number }>;
|
|
139
|
+
}
|
|
140
|
+
// Sets request-scoped variables. Used inside the forward_auth 2xx branch,
|
|
141
|
+
// where it both keeps the chain going (it answers nothing, so handling falls
|
|
142
|
+
// through to the app proxy) and records that Home approved — the error routes
|
|
143
|
+
// read that back to tell a dead app from an unreachable Home.
|
|
144
|
+
| { handler: "vars"; realm_auth_ok?: string }
|
|
145
|
+
// The plain app proxy is just `upstreams`. The forward_auth hop reuses
|
|
146
|
+
// the same handler with `rewrite` (uri → verify path), `headers.request`
|
|
147
|
+
// (X-Forwarded-*), and `handle_response` (on 2xx run a no-op route so the
|
|
148
|
+
// chain continues to the app; relay non-2xx verbatim).
|
|
149
|
+
//
|
|
150
|
+
// `headers.response` needs no `deferred` flag here: reverse_proxy applies
|
|
151
|
+
// response ops directly to the upstream's header map before copying it out,
|
|
152
|
+
// unlike the standalone `headers` handler, which has to wrap the
|
|
153
|
+
// ResponseWriter and therefore defers.
|
|
154
|
+
| {
|
|
155
|
+
handler: "reverse_proxy";
|
|
156
|
+
upstreams: Array<{ dial: string }>;
|
|
157
|
+
rewrite?: { method: string; uri: string };
|
|
158
|
+
headers?: { request?: CaddyHeaderRewrite; response?: CaddyHeaderRewrite };
|
|
159
|
+
// A sub-route may carry a `not vars` guard: the brokered-header sets
|
|
160
|
+
// run only when the verify response actually carried the header, so
|
|
161
|
+
// an anonymous admit (2xx, no identity) hands the app nothing rather
|
|
162
|
+
// than the unresolved placeholder text.
|
|
163
|
+
handle_response?: Array<{
|
|
164
|
+
match: { status_code: number[] };
|
|
165
|
+
routes: Array<{
|
|
166
|
+
match?: Array<{ not: Array<{ vars: Record<string, string[]> }> }>;
|
|
167
|
+
handle: CaddyHandler[];
|
|
168
|
+
}>;
|
|
169
|
+
}>;
|
|
170
|
+
// How long an upgraded stream survives a config reload, as a Go duration
|
|
171
|
+
// string (`caddy.Duration`). Only the dashboard route sets it: remote
|
|
172
|
+
// workers' uplinks ride it, and every deploy on Home's box reloads Caddy.
|
|
173
|
+
stream_close_delay?: string;
|
|
174
|
+
}
|
|
175
|
+
| {
|
|
176
|
+
handler: "static_response";
|
|
177
|
+
headers: Record<string, string[]>;
|
|
178
|
+
status_code: number;
|
|
179
|
+
// Response body. Caddy runs this through its placeholder replacer, so a
|
|
180
|
+
// literal `{` opens a placeholder — see NOT_ROUTED_PAGE in build-config.
|
|
181
|
+
body?: string;
|
|
182
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Shared Docker stream-frame demux. Docker's streaming endpoints
|
|
2
|
+
// (`/containers/X/logs?follow=true`, `/events`) respond with HTTP chunked
|
|
3
|
+
// transfer encoding wrapping an inner format. `dechunkedReader` peels off
|
|
4
|
+
// the chunk framing; `streamDockerLogFrames` parses the multiplexed
|
|
5
|
+
// stdout/stderr frames inside a logs body.
|
|
6
|
+
//
|
|
7
|
+
// Used by both the logs server (tails every managed container) and the
|
|
8
|
+
// spawn broker (relays a single box's output back over `spawn.sock`).
|
|
9
|
+
|
|
10
|
+
const decoder = new TextDecoder();
|
|
11
|
+
|
|
12
|
+
interface DockerLogFrame {
|
|
13
|
+
stream: "stdout" | "stderr";
|
|
14
|
+
timestamp: number;
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const STREAM_TYPES: Record<number, "stdout" | "stderr"> = {
|
|
19
|
+
1: "stdout",
|
|
20
|
+
2: "stderr",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function parseTimestamp(rfc3339: string): number {
|
|
24
|
+
if (!rfc3339) return 0;
|
|
25
|
+
const ms = Date.parse(rfc3339);
|
|
26
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// HTTP chunked transfer encoding decoder. Yields the body bytes between
|
|
30
|
+
// chunk-size headers. Stops at the terminator chunk (`0\r\n\r\n`) or EOF.
|
|
31
|
+
export async function* dechunkedReader(conn: Deno.Conn): AsyncIterable<Uint8Array> {
|
|
32
|
+
let buf = new Uint8Array(0);
|
|
33
|
+
const readBuf = new Uint8Array(8192);
|
|
34
|
+
|
|
35
|
+
async function ensure(min: number): Promise<boolean> {
|
|
36
|
+
while (buf.length < min) {
|
|
37
|
+
const n = await conn.read(readBuf).catch(() => null);
|
|
38
|
+
if (n === null) return false;
|
|
39
|
+
if (n === 0) continue;
|
|
40
|
+
const next = new Uint8Array(buf.length + n);
|
|
41
|
+
next.set(buf);
|
|
42
|
+
next.set(readBuf.slice(0, n), buf.length);
|
|
43
|
+
buf = next;
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
while (true) {
|
|
49
|
+
// Find the next \r\n that terminates the chunk-size line.
|
|
50
|
+
let nlIdx = -1;
|
|
51
|
+
while (nlIdx === -1) {
|
|
52
|
+
for (let i = 0; i < buf.length - 1; i++) {
|
|
53
|
+
if (buf[i] === 0x0d && buf[i + 1] === 0x0a) {
|
|
54
|
+
nlIdx = i;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (nlIdx !== -1) break;
|
|
59
|
+
const more = await conn.read(readBuf).catch(() => null);
|
|
60
|
+
if (more === null) return;
|
|
61
|
+
if (more === 0) continue;
|
|
62
|
+
const next = new Uint8Array(buf.length + more);
|
|
63
|
+
next.set(buf);
|
|
64
|
+
next.set(readBuf.slice(0, more), buf.length);
|
|
65
|
+
buf = next;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const sizeLine = decoder.decode(buf.slice(0, nlIdx));
|
|
69
|
+
const size = parseInt(sizeLine.split(";")[0]!, 16);
|
|
70
|
+
buf = buf.slice(nlIdx + 2);
|
|
71
|
+
|
|
72
|
+
if (!Number.isFinite(size) || size < 0) return;
|
|
73
|
+
if (size === 0) return; // end-of-chunks
|
|
74
|
+
|
|
75
|
+
if (!(await ensure(size + 2))) return;
|
|
76
|
+
yield buf.slice(0, size);
|
|
77
|
+
// Skip the trailing \r\n after the chunk data.
|
|
78
|
+
buf = buf.slice(size + 2);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Parse docker's multiplexed-stream frames out of a dechunked body.
|
|
83
|
+
// Each frame: 1 byte stream-type, 3 bytes pad, 4 bytes BE size, then
|
|
84
|
+
// `size` bytes of payload. With timestamps=true the payload is
|
|
85
|
+
// "<RFC3339> <message>\n".
|
|
86
|
+
export async function* streamDockerLogFrames(
|
|
87
|
+
body: AsyncIterable<Uint8Array>,
|
|
88
|
+
): AsyncIterable<DockerLogFrame> {
|
|
89
|
+
// Per-instance, streaming decoder so a multi-byte UTF-8 sequence
|
|
90
|
+
// straddling two frame payloads survives the boundary.
|
|
91
|
+
const decoder = new TextDecoder("utf-8");
|
|
92
|
+
let buf = new Uint8Array(0);
|
|
93
|
+
for await (const chunk of body) {
|
|
94
|
+
const next = new Uint8Array(buf.length + chunk.length);
|
|
95
|
+
next.set(buf);
|
|
96
|
+
next.set(chunk, buf.length);
|
|
97
|
+
buf = next;
|
|
98
|
+
|
|
99
|
+
while (buf.length >= 8) {
|
|
100
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
101
|
+
const streamType = STREAM_TYPES[buf[0]!] ?? "stdout";
|
|
102
|
+
const size = view.getUint32(4);
|
|
103
|
+
if (buf.length < 8 + size) break;
|
|
104
|
+
const payload = decoder.decode(buf.slice(8, 8 + size), { stream: true });
|
|
105
|
+
buf = buf.slice(8 + size);
|
|
106
|
+
for (const line of payload.split("\n")) {
|
|
107
|
+
if (!line) continue;
|
|
108
|
+
const sp = line.indexOf(" ");
|
|
109
|
+
if (sp === -1) {
|
|
110
|
+
yield { stream: streamType, timestamp: 0, message: line };
|
|
111
|
+
} else {
|
|
112
|
+
yield {
|
|
113
|
+
stream: streamType,
|
|
114
|
+
timestamp: parseTimestamp(line.slice(0, sp)),
|
|
115
|
+
message: line.slice(sp + 1),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// Worker-side logs server. Binds a unix socket; consumers connect, send
|
|
2
|
+
// a single line `{"since":<unix-ms>}\n` (or just `\n`), and receive a
|
|
3
|
+
// JSON Lines stream of `LogLine` records — one line per stdout/stderr
|
|
4
|
+
// line from any `realm.managed=true` container on the host.
|
|
5
|
+
//
|
|
6
|
+
// Discovery: list all matching containers at connect time; subscribe to
|
|
7
|
+
// docker events for new starts. Container exits stop their tail; the
|
|
8
|
+
// stream stays open for live ones.
|
|
9
|
+
//
|
|
10
|
+
// Docker's streaming endpoints (`/containers/X/logs?follow=true`, `/events`)
|
|
11
|
+
// respond with HTTP chunked transfer encoding, so `dechunkedReader` peels that
|
|
12
|
+
// off before the inner multiplexed-frame (logs) or JSON Lines (events) format
|
|
13
|
+
// is parsed.
|
|
14
|
+
|
|
15
|
+
import { dirname } from "@std/path";
|
|
16
|
+
import { LABELS, ode } from "@local/docker";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { REALM_LOGS_SOCKET } from "../../../worker-protocol/logs-socket-path.ts";
|
|
19
|
+
import { type LogLine } from "../../log-line.ts";
|
|
20
|
+
import { dechunkedReader, streamDockerLogFrames } from "../log-frames.ts";
|
|
21
|
+
|
|
22
|
+
export { REALM_LOGS_SOCKET };
|
|
23
|
+
export type { LogLine };
|
|
24
|
+
|
|
25
|
+
// Request line a consumer may send before the stream starts: {"since":<unix-ms>}.
|
|
26
|
+
const sinceRequestSchema = z.object({ since: z.number().optional() });
|
|
27
|
+
// Docker `/events` JSON Lines: each line is an arbitrary JSON object.
|
|
28
|
+
const jsonObjectSchema = z.record(z.string(), z.unknown());
|
|
29
|
+
|
|
30
|
+
interface StartOpts {
|
|
31
|
+
/** Defaults to {@link REALM_LOGS_SOCKET}. Override for tests. */
|
|
32
|
+
socketPath?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ServerHandle {
|
|
36
|
+
/** Stop the server: close listener, drop all active connections, remove the socket file. */
|
|
37
|
+
stop(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const encoder = new TextEncoder();
|
|
41
|
+
const decoder = new TextDecoder();
|
|
42
|
+
|
|
43
|
+
export async function startLogsServer(opts: StartOpts = {}): Promise<ServerHandle> {
|
|
44
|
+
const socketPath = opts.socketPath ?? REALM_LOGS_SOCKET;
|
|
45
|
+
// The socket lives alone in its own directory (apps mount the dir, not the
|
|
46
|
+
// socket file — a file bind-mount of a unix socket doesn't wire across
|
|
47
|
+
// containers on macOS Docker). Ensure the dir exists before binding.
|
|
48
|
+
await Deno.mkdir(dirname(socketPath), { recursive: true });
|
|
49
|
+
await Deno.remove(socketPath).catch(() => {});
|
|
50
|
+
|
|
51
|
+
const listener = Deno.listen({ transport: "unix", path: socketPath });
|
|
52
|
+
const sessions = new Set<Session>();
|
|
53
|
+
|
|
54
|
+
void acceptLoop(listener, sessions);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
async stop() {
|
|
58
|
+
try {
|
|
59
|
+
listener.close();
|
|
60
|
+
} catch {}
|
|
61
|
+
for (const s of sessions) s.close();
|
|
62
|
+
sessions.clear();
|
|
63
|
+
await Deno.remove(socketPath).catch(() => {});
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function acceptLoop(listener: Deno.Listener, sessions: Set<Session>): Promise<void> {
|
|
69
|
+
try {
|
|
70
|
+
for await (const conn of listener) {
|
|
71
|
+
const session = new Session(conn as Deno.UnixConn);
|
|
72
|
+
sessions.add(session);
|
|
73
|
+
session.run().finally(() => sessions.delete(session));
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Listener was closed via stop().
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* How far a container's log has already been forwarded. `count` is how
|
|
82
|
+
* many frames carried that exact millisecond, which is what lets a
|
|
83
|
+
* resume be exact despite docker's second-granularity `?since=`.
|
|
84
|
+
*/
|
|
85
|
+
interface ResumeMark {
|
|
86
|
+
ts: number;
|
|
87
|
+
count: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface TailHandle {
|
|
91
|
+
state: { stopped: boolean };
|
|
92
|
+
/** Set once the docker logs conn is established. Lets session.close() preempt the pending read. */
|
|
93
|
+
conn?: Deno.Conn;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
class Session {
|
|
97
|
+
private readonly conn: Deno.UnixConn;
|
|
98
|
+
private readonly tails = new Map<string, TailHandle>();
|
|
99
|
+
// Per-container resume point. A container keeps its id across a
|
|
100
|
+
// restart, so this is what a re-tail resumes from instead of replaying
|
|
101
|
+
// the whole retained json log. Entries outlive their tail on purpose:
|
|
102
|
+
// the tail is gone precisely while the container is between restarts,
|
|
103
|
+
// which is exactly when the mark is needed.
|
|
104
|
+
private readonly resume = new Map<string, ResumeMark>();
|
|
105
|
+
private eventsClose?: () => void;
|
|
106
|
+
private writer?: WritableStreamDefaultWriter<Uint8Array>;
|
|
107
|
+
private closed = false;
|
|
108
|
+
|
|
109
|
+
constructor(conn: Deno.UnixConn) {
|
|
110
|
+
this.conn = conn;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async run(): Promise<void> {
|
|
114
|
+
try {
|
|
115
|
+
const since = await readSinceLine(this.conn);
|
|
116
|
+
this.writer = this.conn.writable.getWriter();
|
|
117
|
+
|
|
118
|
+
// Subscribe to docker events FIRST so the subscription is live before
|
|
119
|
+
// the enumeration below: anything started after this point lands in the
|
|
120
|
+
// events stream, anything earlier in the snapshot, and the overlap is
|
|
121
|
+
// deduped by `tails.has(id)` in startTail.
|
|
122
|
+
await this.startEvents(since);
|
|
123
|
+
|
|
124
|
+
// Initial enumeration: every realm-managed container, running and
|
|
125
|
+
// exited. `all: true` includes exited ones; their tail returns the
|
|
126
|
+
// historical chunk and exits.
|
|
127
|
+
const containers = await ode.listContainers({
|
|
128
|
+
all: true,
|
|
129
|
+
filters: { label: [`${LABELS.MANAGED}=true`] },
|
|
130
|
+
});
|
|
131
|
+
for (const c of containers) {
|
|
132
|
+
const id = c.Id;
|
|
133
|
+
const labels = c.Labels ?? {};
|
|
134
|
+
if (!id) continue;
|
|
135
|
+
this.startTail(id, labelsOf(labels), since);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Block until the client closes.
|
|
139
|
+
await drainConn(this.conn);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error("logs-server session error:", err);
|
|
142
|
+
} finally {
|
|
143
|
+
this.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private startTail(id: string, meta: ContainerMeta, since: number | undefined): void {
|
|
148
|
+
if (this.tails.has(id)) return;
|
|
149
|
+
const handle: TailHandle = { state: { stopped: false } };
|
|
150
|
+
this.tails.set(id, handle);
|
|
151
|
+
// Never rewind behind what this container already emitted. `since` is
|
|
152
|
+
// the session's connect-time cursor and stays fixed for the session's
|
|
153
|
+
// whole life, so on its own it replays the container's entire retained
|
|
154
|
+
// log on every restart — a container keeps its id across one, and
|
|
155
|
+
// runTail drops it from `tails` on exit, so nothing else stops it.
|
|
156
|
+
const mark = this.resume.get(id);
|
|
157
|
+
const from = mark !== undefined ? Math.max(since ?? 0, mark.ts) : since;
|
|
158
|
+
void this.runTail(id, meta, from, handle);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private async runTail(
|
|
162
|
+
id: string,
|
|
163
|
+
meta: ContainerMeta,
|
|
164
|
+
since: number | undefined,
|
|
165
|
+
handle: TailHandle,
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
try {
|
|
168
|
+
// The wire's `since` is unix ms (matches LogLine.timestamp);
|
|
169
|
+
// docker's `?since=` is unix seconds. Floor so that the boundary
|
|
170
|
+
// line at exactly the cursor stays inclusive. `undefined` means
|
|
171
|
+
// "from epoch" — docker returns whatever's still in retention
|
|
172
|
+
// (capped per default-hardening at 10m × 3 files per container).
|
|
173
|
+
const sinceSeconds = since !== undefined ? Math.floor(since / 1000) : 0;
|
|
174
|
+
const { conn } = await ode.followContainerLogs(id, {
|
|
175
|
+
stdout: true,
|
|
176
|
+
stderr: true,
|
|
177
|
+
timestamps: true,
|
|
178
|
+
since: sinceSeconds,
|
|
179
|
+
});
|
|
180
|
+
handle.conn = conn;
|
|
181
|
+
// session.close() may have run while followContainerLogs was in flight.
|
|
182
|
+
if (handle.state.stopped || this.closed) {
|
|
183
|
+
try {
|
|
184
|
+
conn.close();
|
|
185
|
+
} catch {}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
// Docker's `?since=` is second-granularity, so the request above
|
|
189
|
+
// always re-delivers up to a second of lines we already wrote.
|
|
190
|
+
// Drop them: everything below the mark is written, and at the mark's
|
|
191
|
+
// own millisecond the first `count` frames are the ones we saw.
|
|
192
|
+
// Docker replays a container's log in order, so the prefix matches.
|
|
193
|
+
const mark = this.resume.get(id);
|
|
194
|
+
let remaining = mark?.count ?? 0;
|
|
195
|
+
let skipping = mark !== undefined;
|
|
196
|
+
try {
|
|
197
|
+
for await (const frame of streamDockerLogFrames(dechunkedReader(conn))) {
|
|
198
|
+
if (handle.state.stopped || this.closed) break;
|
|
199
|
+
if (skipping && mark) {
|
|
200
|
+
if (frame.timestamp < mark.ts) continue;
|
|
201
|
+
if (frame.timestamp === mark.ts && remaining > 0) {
|
|
202
|
+
remaining--;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
skipping = false;
|
|
206
|
+
}
|
|
207
|
+
const prev = this.resume.get(id);
|
|
208
|
+
if (prev && prev.ts === frame.timestamp) prev.count++;
|
|
209
|
+
else this.resume.set(id, { ts: frame.timestamp, count: 1 });
|
|
210
|
+
this.write({
|
|
211
|
+
appId: meta.appId,
|
|
212
|
+
service: meta.service,
|
|
213
|
+
containerId: id,
|
|
214
|
+
timestamp: frame.timestamp,
|
|
215
|
+
stream: frame.stream,
|
|
216
|
+
line: frame.message,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
try {
|
|
221
|
+
conn.close();
|
|
222
|
+
} catch {}
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// Container may have been removed between list and follow.
|
|
226
|
+
} finally {
|
|
227
|
+
handle.state.stopped = true;
|
|
228
|
+
this.tails.delete(id);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Awaits subscription establishment (the streamEvents promise resolves
|
|
233
|
+
// once docker's response headers are in), then consumes events in the
|
|
234
|
+
// background. Returning means the subscription is live, so any
|
|
235
|
+
// subsequent listContainers race is closed.
|
|
236
|
+
private async startEvents(since: number | undefined): Promise<void> {
|
|
237
|
+
const { conn } = await ode.streamEvents({
|
|
238
|
+
filters: {
|
|
239
|
+
type: ["container"],
|
|
240
|
+
event: ["start"],
|
|
241
|
+
label: [`${LABELS.MANAGED}=true`],
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
// session.close() may have run while streamEvents was in flight.
|
|
245
|
+
if (this.closed) {
|
|
246
|
+
try {
|
|
247
|
+
conn.close();
|
|
248
|
+
} catch {}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
this.eventsClose = () => {
|
|
252
|
+
try {
|
|
253
|
+
conn.close();
|
|
254
|
+
} catch {}
|
|
255
|
+
};
|
|
256
|
+
void this.consumeEvents(conn, since);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private async consumeEvents(conn: Deno.Conn, since: number | undefined): Promise<void> {
|
|
260
|
+
try {
|
|
261
|
+
for await (const event of streamDockerJsonLines(dechunkedReader(conn))) {
|
|
262
|
+
if (this.closed) break;
|
|
263
|
+
const id = (event.Actor as { ID?: string } | undefined)?.ID;
|
|
264
|
+
const attrs = (event.Actor as { Attributes?: Record<string, string> } | undefined)
|
|
265
|
+
?.Attributes;
|
|
266
|
+
if (!id || !attrs) continue;
|
|
267
|
+
this.startTail(id, labelsOf(attrs), since);
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
// Events stream died — new containers won't be picked up. Live
|
|
271
|
+
// tails continue working until they exit.
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private write(env: LogLine): void {
|
|
276
|
+
if (this.closed || !this.writer) return;
|
|
277
|
+
const bytes = encoder.encode(JSON.stringify(env) + "\n");
|
|
278
|
+
this.writer.write(bytes).catch(() => {
|
|
279
|
+
// Client closed mid-write.
|
|
280
|
+
this.close();
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
close(): void {
|
|
285
|
+
if (this.closed) return;
|
|
286
|
+
this.closed = true;
|
|
287
|
+
for (const handle of this.tails.values()) {
|
|
288
|
+
handle.state.stopped = true;
|
|
289
|
+
// Closing the docker conn preempts the pending read in
|
|
290
|
+
// dechunkedReader so the runTail loop unwinds promptly.
|
|
291
|
+
try {
|
|
292
|
+
handle.conn?.close();
|
|
293
|
+
} catch {}
|
|
294
|
+
}
|
|
295
|
+
this.tails.clear();
|
|
296
|
+
this.resume.clear();
|
|
297
|
+
this.eventsClose?.();
|
|
298
|
+
try {
|
|
299
|
+
this.writer?.releaseLock();
|
|
300
|
+
} catch {}
|
|
301
|
+
try {
|
|
302
|
+
this.conn.close();
|
|
303
|
+
} catch {}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
interface ContainerMeta {
|
|
308
|
+
appId: string;
|
|
309
|
+
service: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function labelsOf(labels: Record<string, string>): ContainerMeta {
|
|
313
|
+
return {
|
|
314
|
+
appId: labels[LABELS.APP] ?? "",
|
|
315
|
+
service: labels[LABELS.SERVICE] ?? "",
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function readSinceLine(conn: Deno.UnixConn): Promise<number | undefined> {
|
|
320
|
+
const buf = new Uint8Array(1);
|
|
321
|
+
const collected: number[] = [];
|
|
322
|
+
while (collected.length < 4096) {
|
|
323
|
+
const n = await conn.read(buf);
|
|
324
|
+
if (n === null) break;
|
|
325
|
+
if (n === 0) continue;
|
|
326
|
+
if (buf[0] === 0x0a) break;
|
|
327
|
+
collected.push(buf[0]!);
|
|
328
|
+
}
|
|
329
|
+
const raw = decoder.decode(new Uint8Array(collected)).trim();
|
|
330
|
+
if (!raw) return undefined;
|
|
331
|
+
try {
|
|
332
|
+
const parsed = sinceRequestSchema.parse(JSON.parse(raw));
|
|
333
|
+
if (parsed.since !== undefined && Number.isFinite(parsed.since)) return parsed.since;
|
|
334
|
+
} catch {}
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function drainConn(conn: Deno.Conn): Promise<void> {
|
|
339
|
+
const buf = new Uint8Array(1024);
|
|
340
|
+
while (true) {
|
|
341
|
+
const n = await conn.read(buf).catch(() => null);
|
|
342
|
+
if (n === null) return;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Parse newline-delimited JSON from a dechunked body.
|
|
347
|
+
async function* streamDockerJsonLines(
|
|
348
|
+
body: AsyncIterable<Uint8Array>,
|
|
349
|
+
): AsyncIterable<Record<string, unknown>> {
|
|
350
|
+
// Per-instance, streaming decoder so multi-byte UTF-8 across HTTP
|
|
351
|
+
// chunk boundaries doesn't get corrupted into U+FFFD.
|
|
352
|
+
const decoder = new TextDecoder("utf-8");
|
|
353
|
+
let remainder = "";
|
|
354
|
+
for await (const chunk of body) {
|
|
355
|
+
remainder += decoder.decode(chunk, { stream: true });
|
|
356
|
+
let nl: number;
|
|
357
|
+
while ((nl = remainder.indexOf("\n")) !== -1) {
|
|
358
|
+
const line = remainder.slice(0, nl).trim();
|
|
359
|
+
remainder = remainder.slice(nl + 1);
|
|
360
|
+
if (!line) continue;
|
|
361
|
+
try {
|
|
362
|
+
yield jsonObjectSchema.parse(JSON.parse(line));
|
|
363
|
+
} catch {}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|