@1agh/maude 0.58.3 → 0.60.0
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/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/bin/_import-asset.mjs +18 -0
- package/apps/studio/bin/_import-figma.mjs +1180 -242
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +17 -4
- package/apps/studio/bin/import-figma.sh +10 -1
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +152 -37
- package/apps/studio/client/panels/SyncPanel.jsx +320 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/styles/3-shell-maude.css +40 -0
- package/apps/studio/client/styles/4-components.css +4 -4
- package/apps/studio/context.ts +4 -0
- package/apps/studio/dist/client.bundle.js +772 -772
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/video-encode-lib.ts +8 -5
- package/apps/studio/exporters/video.ts +10 -0
- package/apps/studio/figma/assets.test.ts +92 -0
- package/apps/studio/figma/assets.ts +63 -9
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/endpoints.ts +73 -0
- package/apps/studio/figma/fig-decode.test.ts +788 -0
- package/apps/studio/figma/fig-decode.ts +839 -0
- package/apps/studio/figma/fig-differential.test.ts +182 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-translator.test.ts +192 -0
- package/apps/studio/figma/fig-vector.test.ts +113 -0
- package/apps/studio/figma/fig-vector.ts +145 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +69 -0
- package/apps/studio/figma/sanitize.ts +146 -47
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-artboard.ts +41 -1
- package/apps/studio/figma/to-render.ts +25 -3
- package/apps/studio/figma/types.ts +6 -1
- package/apps/studio/http.ts +94 -0
- package/apps/studio/sync/asset-push-worker.ts +84 -0
- package/apps/studio/sync/asset-push.ts +441 -39
- package/apps/studio/sync/asset-sweep.ts +262 -0
- package/apps/studio/sync/connection-state.ts +71 -3
- package/apps/studio/sync/index.ts +39 -6
- package/apps/studio/sync/presentation.ts +21 -0
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/import-figma.test.ts +192 -4
- package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
- package/apps/studio/test/sync-asset-push.test.ts +639 -47
- package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
- package/apps/studio/test/sync-connection-state.test.ts +66 -0
- package/apps/studio/test/sync-panel-surface.test.ts +123 -0
- package/apps/studio/test/sync-resync-routes.test.ts +125 -0
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/sync-supervisor.test.ts +46 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +81 -1
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +51 -0
- package/apps/studio/whats-new.json +87 -0
- package/cli/commands/design.mjs +7 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- package/package.json +8 -8
|
@@ -1,32 +1,67 @@
|
|
|
1
|
-
// Desktop→cell asset push — DDR-217
|
|
1
|
+
// Desktop→cell asset push — DDR-217 + the 2026-08-11 addendum (fix 6 of the
|
|
2
|
+
// 2026-08-10 sync RCA, completed).
|
|
2
3
|
//
|
|
3
4
|
// The sync lanes are text-only (`html`/`css`/`meta`/`syncMeta`), so a
|
|
4
|
-
// desktop-linked project's
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// route: `HEAD /assets/<key>` to skip what the cloud already holds, then a
|
|
9
|
-
// streamed `PUT /assets/<key>` for the rest. The hub writes into its checkout
|
|
10
|
-
// and mirrors to the bucket (the browser-upload precedent) — see
|
|
11
|
-
// `apps/hub/src/assets.mjs`.
|
|
5
|
+
// desktop-linked project's binary assets never reached the cell — the grey
|
|
6
|
+
// boxes. The desktop is the one peer that HAS the bytes and already holds an
|
|
7
|
+
// authenticated channel to the hub, so it pushes them. There are TWO asset
|
|
8
|
+
// classes, served two different ways, so they push to two different routes:
|
|
12
9
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
10
|
+
// 1. TOP-LEVEL content-addressed uploads (`<designRoot>/assets/<sha8>.<ext>`)
|
|
11
|
+
// — referenced by the `/assets/<key>` shortcut, served on the cloud from
|
|
12
|
+
// the BUCKET proxy. Push → `PUT /assets/<key>` (bucket + checkout mirror).
|
|
13
|
+
// 2. DS / BRAND assets (`<designRoot>/system/<ds>/assets/logos/x.svg`, fonts,
|
|
14
|
+
// photos) — referenced by their FULL designRoot path
|
|
15
|
+
// (`/.design/system/<ds>/assets/…`) and served from the CHECKOUT by the
|
|
16
|
+
// studio child, never the bucket. The original fix only swept class 1, so
|
|
17
|
+
// these stayed grey (alligators has 93 of them). Push → `PUT
|
|
18
|
+
// /_asset-file/<designRoot-rel>` (checkout only, no bucket).
|
|
19
|
+
//
|
|
20
|
+
// Both are HEAD-first (skip what the cloud already holds) and streamed. The
|
|
21
|
+
// HUB's validation is the authoritative gate at each trust boundary; the
|
|
22
|
+
// filters here are the courtesy layer that keeps junk off the wire.
|
|
18
23
|
|
|
19
24
|
import { type Dirent, readdirSync, statSync } from 'node:fs';
|
|
20
25
|
import path from 'node:path';
|
|
21
26
|
|
|
22
|
-
/** One path segment
|
|
23
|
-
const
|
|
27
|
+
/** One path segment charset — matches the hub's component regexes. */
|
|
28
|
+
const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/;
|
|
29
|
+
|
|
30
|
+
/** Max designRoot-relative depth (matches the hub's 8-segment cap). */
|
|
31
|
+
const MAX_SEGMENTS = 8;
|
|
32
|
+
|
|
33
|
+
/** Max relative-path length (matches the hub's 512 cap). */
|
|
34
|
+
const MAX_REL_LEN = 512;
|
|
24
35
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
36
|
+
/**
|
|
37
|
+
* The binary asset extensions that actually render — images, fonts, media.
|
|
38
|
+
* Deliberately NOT `.json`/`.meta.json`/`.photo.json`/`.tsx`/`.css`: a
|
|
39
|
+
* `.photo.json` sidecar is edit metadata, not a served asset, and the checkout
|
|
40
|
+
* route refuses non-asset extensions anyway (so pushing them would just waste
|
|
41
|
+
* the wire and 400). Case-insensitive — a DS ships `…P1020428.JPG`.
|
|
42
|
+
*/
|
|
43
|
+
const ASSET_EXTS = new Set([
|
|
44
|
+
'png',
|
|
45
|
+
'jpg',
|
|
46
|
+
'jpeg',
|
|
47
|
+
'gif',
|
|
48
|
+
'webp',
|
|
49
|
+
'avif',
|
|
50
|
+
'svg',
|
|
51
|
+
'mp4',
|
|
52
|
+
'webm',
|
|
53
|
+
'mov',
|
|
54
|
+
'mp3',
|
|
55
|
+
'wav',
|
|
56
|
+
'm4a',
|
|
57
|
+
'ogg',
|
|
58
|
+
'woff2',
|
|
59
|
+
'woff',
|
|
60
|
+
'ttf',
|
|
61
|
+
'otf',
|
|
62
|
+
]);
|
|
27
63
|
|
|
28
|
-
/**
|
|
29
|
-
* a mistake, and paying to move it silently is the wrong response. */
|
|
64
|
+
/** A 2 GB file in an assets dir is a mistake — don't move it silently. */
|
|
30
65
|
const MAX_PUSH_BYTES = 512 * 1024 * 1024;
|
|
31
66
|
|
|
32
67
|
export interface AssetPushResult {
|
|
@@ -35,11 +70,132 @@ export interface AssetPushResult {
|
|
|
35
70
|
failed: { key: string; reason: string }[];
|
|
36
71
|
}
|
|
37
72
|
|
|
38
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* feature-sync-progress-modal — incremental asset-push progress, emitted onto
|
|
75
|
+
* the sync bus so the Sync panel can show assets moving instead of a silent
|
|
76
|
+
* gap between "canvases synced" and a log line at the end. Keys are LOCAL
|
|
77
|
+
* designRoot-relative paths (never hub-supplied); `failures` is capped at
|
|
78
|
+
* MAX_LISTED_FAILURES with `failedCount` carrying the true number.
|
|
79
|
+
*/
|
|
80
|
+
export interface AssetPushProgress {
|
|
81
|
+
/** Total pushable assets found this boot. */
|
|
82
|
+
total: number;
|
|
83
|
+
/** Files settled so far (pushed + skipped + failed). */
|
|
84
|
+
done: number;
|
|
85
|
+
pushed: number;
|
|
86
|
+
skipped: number;
|
|
87
|
+
failedCount: number;
|
|
88
|
+
/** First MAX_LISTED_FAILURES failures — enough to name the broken paths. */
|
|
89
|
+
failures: { key: string; reason: string }[];
|
|
90
|
+
/** The designRoot-relative path on the wire right now, null when finished. */
|
|
91
|
+
active: string | null;
|
|
92
|
+
/** True exactly once, on the final emit (also fires when total is 0). */
|
|
93
|
+
finished: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Cap on `failures` in a progress emit (same spirit as MAX_REJECTED_SLUGS —
|
|
97
|
+
* the payload reaches `_sync.json` + every open tab, so it stays bounded). */
|
|
98
|
+
export const MAX_LISTED_FAILURES = 20;
|
|
99
|
+
|
|
100
|
+
/** Longest single pause the hub can ask for. Matches the hub's rate-limit
|
|
101
|
+
* window (60 s) — a `Retry-After` larger than that is either a typo or a hub
|
|
102
|
+
* we should not be blocking a boot sweep on. */
|
|
103
|
+
const MAX_RETRY_DELAY_MS = 60_000;
|
|
104
|
+
|
|
105
|
+
/** Where an absent/unparsable `Retry-After` lands. Old hubs (pre-fix) send a
|
|
106
|
+
* bare 429 with no header, and their window is the same 60 s. */
|
|
107
|
+
const DEFAULT_RETRY_DELAY_MS = 60_000;
|
|
108
|
+
|
|
109
|
+
/** Total time ONE sweep may spend waiting out 429s. The paced retry exists to
|
|
110
|
+
* keep an un-upgraded hub livable, not to turn a boot into an hour-long
|
|
111
|
+
* background stall — past this, the remaining refusals fail fast and the
|
|
112
|
+
* next-boot backstop takes them. */
|
|
113
|
+
const MAX_SWEEP_BACKOFF_MS = 5 * 60_000;
|
|
114
|
+
|
|
115
|
+
/** How much of an error body reaches `failed[].reason`. Enough to tell "rate
|
|
116
|
+
* limit exceeded" from a Cloudflare error page — the distinction the 2026-08-11
|
|
117
|
+
* RCA had to reconstruct from edge logs because the client kept only a status
|
|
118
|
+
* code. Hub-supplied text ⇒ bounded and stripped before it reaches the UI. */
|
|
119
|
+
const ERROR_SNIPPET_CHARS = 80;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Every upload closes its connection. NOT an optimization — a correctness
|
|
123
|
+
* requirement, learned the expensive way (2026-08-11, second pass).
|
|
124
|
+
*
|
|
125
|
+
* A peer that refuses a PUT **before reading the body** (the cloud studio door
|
|
126
|
+
* answering 401, the edge answering 503) leaves unread request bytes in an
|
|
127
|
+
* HTTP/1.1 keep-alive socket. The connection is then desynchronized: the next
|
|
128
|
+
* request Bun sends over it NEVER gets a response. With no retry that stayed
|
|
129
|
+
* invisible — the refusal was reported and the sweep moved on. The moment a
|
|
130
|
+
* retry re-sent on that same pooled socket, the sweep wedged forever and the
|
|
131
|
+
* dev-server sidecar died with it (Bun segfault, 4 crash-loops, alligators).
|
|
132
|
+
* Measured: `connection: close` on the retry ALONE does not help (the retry is
|
|
133
|
+
* handed the already-poisoned socket) — it has to be on the request that may be
|
|
134
|
+
* refused, i.e. every PUT. One TLS handshake per asset against multi-MB bodies
|
|
135
|
+
* is not a cost worth reasoning about.
|
|
136
|
+
*/
|
|
137
|
+
const UPLOAD_CONNECTION_HEADERS = { connection: 'close' } as const;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* THE PER-REQUEST TIME BUDGETS BELOW STAY — reviewed and kept, RCA step 3
|
|
141
|
+
* (feature-sync-resync-and-out-of-process-sweep, Task 7).
|
|
142
|
+
*
|
|
143
|
+
* They were suspects: the crash reports went from `abort_signal(2)` to
|
|
144
|
+
* `abort_signal(79)` in the same change that introduced them, which reads like
|
|
145
|
+
* a cause. Two things settle it the other way.
|
|
146
|
+
*
|
|
147
|
+
* First, the count is what a bounded sweep LOOKS like — 79 in-flight budgets
|
|
148
|
+
* over a 182-file sweep is one per request, not a leak. Second, the actual
|
|
149
|
+
* fault was isolated elsewhere and fixed: an HTTP/1.1 keep-alive desync after a
|
|
150
|
+
* peer refused a PUT before draining its body (see UPLOAD_CONNECTION_HEADERS).
|
|
151
|
+
* The sweep now also runs in its own process, so whatever these do or do not
|
|
152
|
+
* contribute costs the sweep and not the editor.
|
|
153
|
+
*
|
|
154
|
+
* Removing them would trade a suspicion for a certainty: a request with no
|
|
155
|
+
* budget is how a sweep hangs forever with nothing to report — the exact
|
|
156
|
+
* invisibility this whole feature exists to end. So they stay.
|
|
157
|
+
*/
|
|
158
|
+
|
|
159
|
+
/** HEAD is a small, bodyless probe — a hub that has not answered in 30 s is not
|
|
160
|
+
* about to. */
|
|
161
|
+
const HEAD_TIMEOUT_MS = 30_000;
|
|
162
|
+
|
|
163
|
+
/** The batch probe asks about a whole project at once, and the hub may have to
|
|
164
|
+
* reach the object store for a few hundred keys — but it is still one small
|
|
165
|
+
* request, so a minute is generous. */
|
|
166
|
+
const PROBE_TIMEOUT_MS = 60_000;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* How long one upload may take before the sweep abandons it: a fixed floor plus
|
|
170
|
+
* an allowance for the bytes at a deliberately pessimistic 100 kB/s, capped.
|
|
171
|
+
* The backstop for anything that wedges a connection the way the keep-alive
|
|
172
|
+
* desync above did — a sweep that hangs forever takes the whole dev-server with
|
|
173
|
+
* it, and "this asset failed, next boot retries it" is always the better end.
|
|
174
|
+
*/
|
|
175
|
+
export function putTimeoutMs(bytes: number): number {
|
|
176
|
+
return Math.min(10 * 60_000, 60_000 + (Number.isFinite(bytes) ? bytes : 0) / 100);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Min ms between mid-flight progress emits. A 90-file DS at LAN speed would
|
|
180
|
+
* otherwise broadcast 90 payloads in a couple of seconds; failures and the
|
|
181
|
+
* final emit always go out regardless. */
|
|
182
|
+
const PROGRESS_INTERVAL_MS = 200;
|
|
183
|
+
|
|
184
|
+
function extOf(name: string): string {
|
|
185
|
+
const dot = name.lastIndexOf('.');
|
|
186
|
+
return dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Every pushable binary asset under designRoot, as a designRoot-relative path.
|
|
191
|
+
* Walks into any directory named `assets` at any level (top-level `assets/`,
|
|
192
|
+
* `system/<ds>/assets/`, …) and collects the asset-extension files inside it.
|
|
193
|
+
* Skips runtime-state (`_*`), `.git`, `node_modules`. Missing root → [].
|
|
194
|
+
*/
|
|
39
195
|
export function listPushableAssets(designRoot: string): string[] {
|
|
40
|
-
const root = path.join(designRoot, 'assets');
|
|
41
196
|
const out: string[] = [];
|
|
42
|
-
|
|
197
|
+
// Walk the tree; once inside an `assets` dir, collect asset files below it.
|
|
198
|
+
const walk = (dir: string, rel: string, insideAssets: boolean): void => {
|
|
43
199
|
let entries: Dirent[];
|
|
44
200
|
try {
|
|
45
201
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
@@ -47,24 +203,169 @@ export function listPushableAssets(designRoot: string): string[] {
|
|
|
47
203
|
return;
|
|
48
204
|
}
|
|
49
205
|
for (const entry of entries) {
|
|
50
|
-
|
|
51
|
-
|
|
206
|
+
const name = entry.name;
|
|
207
|
+
if (name.startsWith('_') || name === '.git' || name === 'node_modules') continue;
|
|
208
|
+
if (!SEGMENT.test(name)) continue; // dotfiles + odd charset
|
|
209
|
+
const childRel = rel ? `${rel}/${name}` : name;
|
|
210
|
+
if (childRel.length > MAX_REL_LEN || childRel.split('/').length > MAX_SEGMENTS) continue;
|
|
52
211
|
if (entry.isDirectory()) {
|
|
53
|
-
|
|
212
|
+
walk(path.join(dir, name), childRel, insideAssets || name === 'assets');
|
|
54
213
|
} else if (entry.isFile()) {
|
|
214
|
+
if (!insideAssets) continue; // only files under some assets/ dir
|
|
215
|
+
if (!ASSET_EXTS.has(extOf(name))) continue;
|
|
55
216
|
try {
|
|
56
|
-
if (statSync(path.join(dir,
|
|
217
|
+
if (statSync(path.join(dir, name)).size > MAX_PUSH_BYTES) continue;
|
|
57
218
|
} catch {
|
|
58
219
|
continue;
|
|
59
220
|
}
|
|
60
|
-
out.push(
|
|
221
|
+
out.push(childRel);
|
|
61
222
|
}
|
|
62
223
|
}
|
|
63
224
|
};
|
|
64
|
-
walk(
|
|
225
|
+
walk(designRoot, '', false);
|
|
65
226
|
return out.sort();
|
|
66
227
|
}
|
|
67
228
|
|
|
229
|
+
/** Where a given asset pushes: the bucket-backed route (top-level `assets/`) or
|
|
230
|
+
* the checkout route (a nested `…/assets/…` served from disk). */
|
|
231
|
+
function routeFor(rel: string): { url: string } {
|
|
232
|
+
const parts = rel.split('/');
|
|
233
|
+
if (parts[0] === 'assets') {
|
|
234
|
+
// Top-level content-addressed → the bucket `/assets/<key>` route.
|
|
235
|
+
return { url: `/assets/${parts.slice(1).join('/')}` };
|
|
236
|
+
}
|
|
237
|
+
// DS / brand asset served from the checkout → the checkout-file route, keyed
|
|
238
|
+
// by its FULL designRoot-relative path.
|
|
239
|
+
return { url: `/_asset-file/${rel.split('/').map(encodeURIComponent).join('/')}` };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Ask the hub, in ONE request, which of these it already holds — or null when
|
|
244
|
+
* this hub cannot answer (then the caller falls back to per-file probes).
|
|
245
|
+
*
|
|
246
|
+
* WHY THIS EXISTS (RCA 2026-08-11 part 2). The sweep used to ask per file with
|
|
247
|
+
* `HEAD`, and on a Cloud cell a HEAD never arrives as a HEAD — it is converted
|
|
248
|
+
* to GET before it reaches the hub. So the DS half's probe answered `405` and
|
|
249
|
+
* the sweep re-uploaded that project's ENTIRE asset set on every boot, while
|
|
250
|
+
* the bucket half's converted probe took the hub's GET branch and pulled whole
|
|
251
|
+
* objects out of R2 to answer an existence question. `POST` survives the trip,
|
|
252
|
+
* and one request replaces N.
|
|
253
|
+
*
|
|
254
|
+
* A hub that does not know this route answers 404/405 — that is NOT "it holds
|
|
255
|
+
* nothing", it is "ask the old way", so it returns null rather than an empty
|
|
256
|
+
* set. Getting that backwards would skip every upload against every hub older
|
|
257
|
+
* than this change.
|
|
258
|
+
*/
|
|
259
|
+
async function probePresent(ctx: {
|
|
260
|
+
fetchImpl: typeof fetch;
|
|
261
|
+
base: string;
|
|
262
|
+
headers: Record<string, string>;
|
|
263
|
+
paths: string[];
|
|
264
|
+
}): Promise<Set<string> | null> {
|
|
265
|
+
try {
|
|
266
|
+
const res = await ctx.fetchImpl(`${ctx.base}/_asset-probe`, {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers: { ...ctx.headers, 'content-type': 'application/json' },
|
|
269
|
+
body: JSON.stringify({ paths: ctx.paths }),
|
|
270
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
271
|
+
});
|
|
272
|
+
if (!res.ok) return null;
|
|
273
|
+
const data = (await res.json()) as { present?: unknown };
|
|
274
|
+
// Hub-supplied (DDR-054): keep only strings we actually asked about, so a
|
|
275
|
+
// malformed or hostile answer can never make us skip a file we never named.
|
|
276
|
+
if (!Array.isArray(data?.present)) return null;
|
|
277
|
+
const asked = new Set(ctx.paths);
|
|
278
|
+
return new Set(data.present.filter((p): p is string => typeof p === 'string' && asked.has(p)));
|
|
279
|
+
} catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** `Retry-After: <seconds>` → ms, clamped. Only the delta-seconds form is
|
|
285
|
+
* parsed; the HTTP-date form is not something our hub emits. */
|
|
286
|
+
function retryAfterMs(header: string | null): number {
|
|
287
|
+
const secs = Number(String(header ?? '').trim());
|
|
288
|
+
if (!Number.isFinite(secs) || secs <= 0) return DEFAULT_RETRY_DELAY_MS;
|
|
289
|
+
return Math.min(secs * 1000, MAX_RETRY_DELAY_MS);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Why an upload was refused, in words — status PLUS a bounded snippet of the
|
|
294
|
+
* body. The hub says `{"error":"rate limit exceeded"}`; an edge that never
|
|
295
|
+
* reached the hub says HTML. Those are different bugs and the Sync panel should
|
|
296
|
+
* not make a person read logs to tell them apart.
|
|
297
|
+
*
|
|
298
|
+
* The body is hub-supplied ⇒ untrusted (DDR-054): control characters stripped,
|
|
299
|
+
* whitespace collapsed, hard length cap, and it only ever renders as text.
|
|
300
|
+
*/
|
|
301
|
+
async function failureReason(res: Response): Promise<string> {
|
|
302
|
+
let snippet = '';
|
|
303
|
+
try {
|
|
304
|
+
snippet = (await res.text())
|
|
305
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point.
|
|
306
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
307
|
+
.replace(/\s+/g, ' ')
|
|
308
|
+
.trim()
|
|
309
|
+
.slice(0, ERROR_SNIPPET_CHARS);
|
|
310
|
+
} catch {
|
|
311
|
+
/* a body we cannot read tells us nothing — the status still does */
|
|
312
|
+
}
|
|
313
|
+
return snippet ? `HTTP ${res.status} — ${snippet}` : `HTTP ${res.status}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* One upload, with the two retries that are worth having in-boot.
|
|
318
|
+
*
|
|
319
|
+
* 429 — the hub tells us when to come back (`Retry-After`), so come back then,
|
|
320
|
+
* ONCE. Before the 2026-08-11 fix the write lane sat in a 5/min per-IP bucket
|
|
321
|
+
* and the sweep ignored the header entirely, so a 182-asset project burned the
|
|
322
|
+
* window and moved ~5 files per boot. The hub half of the fix is the real
|
|
323
|
+
* one — this half is what keeps a not-yet-upgraded hub (the fleet rolls only on
|
|
324
|
+
* a release tag) finishing a sweep instead of grinding.
|
|
325
|
+
*
|
|
326
|
+
* 5xx — one immediate retry, because a transient edge/proxy hiccup on a 30 MB
|
|
327
|
+
* body should not need a whole new boot to get past.
|
|
328
|
+
*
|
|
329
|
+
* A second refusal is a real failure: report it and move on (the next-boot
|
|
330
|
+
* backstop is unchanged).
|
|
331
|
+
*/
|
|
332
|
+
async function putWithRetry(ctx: {
|
|
333
|
+
fetchImpl: typeof fetch;
|
|
334
|
+
url: string;
|
|
335
|
+
headers: Record<string, string>;
|
|
336
|
+
file: string;
|
|
337
|
+
sleep: (ms: number) => Promise<void>;
|
|
338
|
+
/** Mutable per-sweep pause budget, shared across every asset. */
|
|
339
|
+
backoff: { remainingMs: number };
|
|
340
|
+
timeoutFor: (bytes: number) => number;
|
|
341
|
+
}): Promise<Response> {
|
|
342
|
+
const send = (): Promise<Response> => {
|
|
343
|
+
const body = Bun.file(ctx.file);
|
|
344
|
+
return ctx.fetchImpl(ctx.url, {
|
|
345
|
+
method: 'PUT',
|
|
346
|
+
headers: {
|
|
347
|
+
...ctx.headers,
|
|
348
|
+
...UPLOAD_CONNECTION_HEADERS,
|
|
349
|
+
// Bun derives this from the file anyway (measured) — stated explicitly
|
|
350
|
+
// so a body length is never something a future body type has to guess.
|
|
351
|
+
'content-length': String(body.size),
|
|
352
|
+
},
|
|
353
|
+
body,
|
|
354
|
+
signal: AbortSignal.timeout(ctx.timeoutFor(body.size)),
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
const first = await send();
|
|
358
|
+
if (first.status === 429) {
|
|
359
|
+
const wait = retryAfterMs(first.headers?.get?.('retry-after') ?? null);
|
|
360
|
+
if (wait > ctx.backoff.remainingMs) return first;
|
|
361
|
+
ctx.backoff.remainingMs -= wait;
|
|
362
|
+
await ctx.sleep(wait);
|
|
363
|
+
return send();
|
|
364
|
+
}
|
|
365
|
+
if (first.status >= 500) return send();
|
|
366
|
+
return first;
|
|
367
|
+
}
|
|
368
|
+
|
|
68
369
|
/**
|
|
69
370
|
* Mirror local assets up to the hub. Idempotent and skip-first (one HEAD per
|
|
70
371
|
* asset per boot; upload only on a miss), sequential on purpose — assets run
|
|
@@ -79,33 +380,134 @@ export async function pushAssets(opts: {
|
|
|
79
380
|
token: () => string;
|
|
80
381
|
fetchImpl?: typeof fetch;
|
|
81
382
|
log?: Pick<Console, 'log' | 'warn'>;
|
|
383
|
+
/** feature-sync-progress-modal — incremental progress (throttled; failures
|
|
384
|
+
* and the final emit always fire). Never throws into the push loop. */
|
|
385
|
+
onProgress?: (progress: AssetPushProgress) => void;
|
|
386
|
+
/** Injectable clock for the throttle (tests). */
|
|
387
|
+
now?: () => number;
|
|
388
|
+
/** Injectable pause for the 429 backoff (tests — a fake clock, not a wait). */
|
|
389
|
+
sleep?: (ms: number) => Promise<void>;
|
|
390
|
+
/** Injectable per-request time budget (tests — seconds, not minutes). */
|
|
391
|
+
timeoutFor?: (bytes: number) => number;
|
|
82
392
|
}): Promise<AssetPushResult> {
|
|
83
393
|
const { designRoot, hubUrl } = opts;
|
|
84
394
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
85
395
|
const log = opts.log ?? console;
|
|
396
|
+
const now = opts.now ?? Date.now;
|
|
397
|
+
const sleep =
|
|
398
|
+
opts.sleep ??
|
|
399
|
+
((ms: number) =>
|
|
400
|
+
new Promise<void>((res) => {
|
|
401
|
+
setTimeout(res, ms);
|
|
402
|
+
}));
|
|
403
|
+
const timeoutFor = opts.timeoutFor ?? putTimeoutMs;
|
|
404
|
+
// Shared across the whole sweep — see MAX_SWEEP_BACKOFF_MS.
|
|
405
|
+
const backoff = { remainingMs: MAX_SWEEP_BACKOFF_MS };
|
|
86
406
|
const base = hubUrl.replace(/\/+$/, '');
|
|
87
407
|
const out: AssetPushResult = { pushed: [], skipped: 0, failed: [] };
|
|
88
408
|
|
|
89
|
-
|
|
90
|
-
|
|
409
|
+
const assets = listPushableAssets(designRoot);
|
|
410
|
+
// -Infinity seeds the throttle open, so the first emit always passes.
|
|
411
|
+
let lastEmit = -Infinity;
|
|
412
|
+
const emitProgress = (active: string | null, finished: boolean, force = false): void => {
|
|
413
|
+
if (!opts.onProgress) return;
|
|
414
|
+
const t = now();
|
|
415
|
+
if (!force && t - lastEmit < PROGRESS_INTERVAL_MS) return;
|
|
416
|
+
lastEmit = t;
|
|
417
|
+
try {
|
|
418
|
+
opts.onProgress({
|
|
419
|
+
total: assets.length,
|
|
420
|
+
done: out.pushed.length + out.skipped + out.failed.length,
|
|
421
|
+
pushed: out.pushed.length,
|
|
422
|
+
skipped: out.skipped,
|
|
423
|
+
failedCount: out.failed.length,
|
|
424
|
+
failures: out.failed.slice(0, MAX_LISTED_FAILURES),
|
|
425
|
+
active,
|
|
426
|
+
finished,
|
|
427
|
+
});
|
|
428
|
+
} catch {
|
|
429
|
+
/* a broken listener must never break the push */
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// One question for the whole set, when the hub can answer it. Null = this hub
|
|
434
|
+
// predates the route, so every file falls back to its own probe below.
|
|
435
|
+
const known =
|
|
436
|
+
assets.length > 0
|
|
437
|
+
? await probePresent({
|
|
438
|
+
fetchImpl,
|
|
439
|
+
base,
|
|
440
|
+
headers: { authorization: `Bearer ${opts.token()}` },
|
|
441
|
+
paths: assets,
|
|
442
|
+
})
|
|
443
|
+
: null;
|
|
444
|
+
|
|
445
|
+
for (const rel of assets) {
|
|
446
|
+
emitProgress(rel, false);
|
|
447
|
+
const url = `${base}${routeFor(rel).url}`;
|
|
91
448
|
const headers = { authorization: `Bearer ${opts.token()}` };
|
|
449
|
+
if (known) {
|
|
450
|
+
if (known.has(rel)) {
|
|
451
|
+
out.skipped += 1;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
// The batch answered, and it said this one is missing — no per-file probe
|
|
455
|
+
// can add anything, so go straight to the upload.
|
|
456
|
+
}
|
|
92
457
|
try {
|
|
93
|
-
const head =
|
|
94
|
-
|
|
458
|
+
const head = known
|
|
459
|
+
? null
|
|
460
|
+
: await fetchImpl(url, {
|
|
461
|
+
method: 'HEAD',
|
|
462
|
+
headers,
|
|
463
|
+
signal: AbortSignal.timeout(HEAD_TIMEOUT_MS),
|
|
464
|
+
});
|
|
465
|
+
if (head?.ok) {
|
|
95
466
|
out.skipped += 1;
|
|
96
467
|
continue;
|
|
97
468
|
}
|
|
98
|
-
|
|
99
|
-
|
|
469
|
+
// A hub that refuses the PROBE refuses the upload — pushing the body
|
|
470
|
+
// anyway just streams megabytes at a door that already said no. The
|
|
471
|
+
// cloud studio door answers exactly this for a route the deployed hub
|
|
472
|
+
// does not have yet, once per asset, for the whole DS asset set.
|
|
473
|
+
//
|
|
474
|
+
// Only 401/403 mean that. Every OTHER refusal — notably the 405 a cell
|
|
475
|
+
// returns when it turned our HEAD into a GET — means "I cannot answer",
|
|
476
|
+
// NOT "I do not have it", so it falls through to the upload rather than
|
|
477
|
+
// being read as a refusal.
|
|
478
|
+
if (head && (head.status === 401 || head.status === 403)) {
|
|
479
|
+
out.failed.push({ key: rel, reason: await failureReason(head) });
|
|
480
|
+
emitProgress(rel, false, true);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const put = await putWithRetry({
|
|
484
|
+
fetchImpl,
|
|
485
|
+
url,
|
|
100
486
|
headers,
|
|
101
|
-
|
|
487
|
+
file: path.join(designRoot, rel),
|
|
488
|
+
sleep,
|
|
489
|
+
backoff,
|
|
490
|
+
timeoutFor,
|
|
102
491
|
});
|
|
103
|
-
if (put.ok) out.pushed.push(
|
|
104
|
-
else
|
|
492
|
+
if (put.ok) out.pushed.push(rel);
|
|
493
|
+
else {
|
|
494
|
+
out.failed.push({ key: rel, reason: await failureReason(put) });
|
|
495
|
+
emitProgress(rel, false, true);
|
|
496
|
+
}
|
|
105
497
|
} catch (err) {
|
|
106
|
-
|
|
498
|
+
const e = err as Error;
|
|
499
|
+
out.failed.push({
|
|
500
|
+
key: rel,
|
|
501
|
+
// "TimeoutError: The operation timed out" tells a person nothing about
|
|
502
|
+
// which limit fired; name the budget instead.
|
|
503
|
+
reason: e.name === 'TimeoutError' ? 'timed out — the hub stopped answering' : e.message,
|
|
504
|
+
});
|
|
505
|
+
emitProgress(rel, false, true);
|
|
107
506
|
}
|
|
108
507
|
}
|
|
508
|
+
// No assets → no emits at all: a project without an assets/ dir should not
|
|
509
|
+
// grow an empty assets section in its Sync panel.
|
|
510
|
+
if (assets.length > 0) emitProgress(null, true, true);
|
|
109
511
|
|
|
110
512
|
if (out.pushed.length > 0) {
|
|
111
513
|
log.log?.(
|