@tinkerlandai/indiana-iam 0.6.0 → 0.7.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/README.md +17 -3
- package/assets/document-controller.js +51 -1
- package/assets/document-runtime.js +54 -1
- package/assets/viewer.css +9 -0
- package/assets/viewer.html +4 -0
- package/assets/viewer.js +63 -4
- package/package.json +1 -1
- package/src/build.mjs +3 -0
- package/src/cli.mjs +3 -0
- package/src/index.d.ts +14 -1
- package/src/ui-preview.mjs +22 -0
- package/src/upload-checkpoint.mjs +59 -0
- package/src/upload.mjs +168 -49
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ React Native / Expo integrations produce source previews; native visual-change e
|
|
|
7
7
|
Install it as a development dependency (Node.js 22 or later):
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
npm install --save-dev @tinkerlandai/indiana-iam
|
|
10
|
+
npm install --save-dev --save-exact @tinkerlandai/indiana-iam@0.7.0
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
## Next.js (15.4.1 or later)
|
|
@@ -228,7 +228,7 @@ Source-proven `useSyncExternalStore` matchMedia getters retain both typed viewpo
|
|
|
228
228
|
viewport. Subscriptions are not run. UI preference cookies with an explicit source SSR guard use an empty-cookie
|
|
229
229
|
preview; auth/session cookies and arbitrary stores stay unobserved. Pure lazy local-state initializers are interpreted
|
|
230
230
|
under the same bounded rules; merchant callbacks and browser storage are never executed or read.
|
|
231
|
-
Local IAM generation has no configured page, node or total artifact byte limit. The exporter retains every supported route. Online
|
|
231
|
+
Local IAM generation has no configured page, node or total artifact byte limit. The exporter retains every supported route. Online admission uses the environment's 512 MiB source storage budget, 256-version limit and build-key upload rate limit; it has no separate 8 MiB file cap.
|
|
232
232
|
Large artifacts store sizeable HTML/preorder-map pairs as `domPayload` (`gzip-json-v1`), while keeping `html: ""`
|
|
233
233
|
and `nodes: []` in that document's wire record. This lossless storage encoding preserves all source IDs, DOM values,
|
|
234
234
|
HTML/DOM hashes and targets. Route navigation stores the exact interaction-array index instead of repeating its ID; validation checks node, route, destination and parameters. Source files retain their full hashes; component/element records cover reachable route compositions and shared fragments. Text node IDs that follow the original parent/index identity formula are derived locally instead of repeated; explicit exceptions remain stored, and every resolved source-map hash is checked. The document hash pins its encoded storage body; decoding is local and checks the exact declared size and hashes. Exported page HTML is expanded.
|
|
@@ -246,11 +246,25 @@ Values that look like credentials, tokens, emails or private URLs are removed, b
|
|
|
246
246
|
|
|
247
247
|
## Upload
|
|
248
248
|
|
|
249
|
+
IAM 0.7.0 uses the matching Indiana resumable ingestion protocol. Upgrade the build package together with the receiver.
|
|
250
|
+
|
|
249
251
|
Create a Key with only the `app-manifests:write` permission (Console → developer settings) and set it as
|
|
250
252
|
`INDIANA_IAM_KEY` in your build environment. Each production build then registers the IAM, stores it through a
|
|
251
|
-
|
|
253
|
+
private GCS resumable session and asks Indiana to validate it; the validated version becomes its client's current version.
|
|
252
254
|
Rebuilding unchanged source only makes the existing version current again.
|
|
253
255
|
|
|
256
|
+
Interrupted uploads resume when the same command or build runs again on the same machine. The uploader keeps the
|
|
257
|
+
request ID, original label and private GCS session in `~/.cache/indiana/iam-uploads` (directory mode 0700, files 0600),
|
|
258
|
+
scoped by Indiana origin, key fingerprint, client, platform and exact content. Set `INDIANA_IAM_CACHE_DIR` to a private
|
|
259
|
+
directory to retain this state in a CI cache; CI workers with no shared cache start a new upload. Never publish this
|
|
260
|
+
directory or place it in source/public build output. It contains bearer upload capabilities, but no API keys.
|
|
261
|
+
On recovery, Indiana checks current key authority before the cached storage session is used. GCS's persisted offset
|
|
262
|
+
selects the next byte; chunks are at most 8 MiB. Transient failures retry up to three times, while completed transfers
|
|
263
|
+
retry validation without uploading again. Recovery retains the existing 15-minute SQL upload deadline; an expired
|
|
264
|
+
intent starts a new version. A successful validation removes the local checkpoint. Storage sessions can remain valid
|
|
265
|
+
longer than the SQL intent, but cannot make an expired or unauthorized version ready. The matching Indiana ingestion
|
|
266
|
+
and build package must be deployed/published together for this transfer protocol.
|
|
267
|
+
|
|
254
268
|
An App environment can hold several clients, each a named build with its own current version: for example `web` and
|
|
255
269
|
`web-dashboard`, or later `ios` and `android` apps. Set the `client` option (or `INDIANA_IAM_CLIENT`) to 1-40 lowercase
|
|
256
270
|
letters, digits or hyphens starting with a letter; the default is `web`. A client keeps the platform of its first
|
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
}
|
|
10
10
|
let machine = null, activeTabs = [],sourceUi=null,sourceRenderKey=null;
|
|
11
11
|
const patches = new Map();
|
|
12
|
-
const
|
|
12
|
+
const previewNodes = new Map([...document.querySelectorAll("[data-iam-node]")].map(el => [el.dataset.iamNode, el]));
|
|
13
|
+
const node = id => {
|
|
14
|
+
const cached = previewNodes.get(id);
|
|
15
|
+
if (cached?.isConnected) return cached;
|
|
16
|
+
const current = [...document.querySelectorAll("[data-iam-node]")].find(el => el.dataset.iamNode === id);
|
|
17
|
+
if (current) previewNodes.set(id, current);
|
|
18
|
+
return current;
|
|
19
|
+
};
|
|
13
20
|
function viewportValues(graph,routeKey,renderKey){
|
|
14
21
|
const values={};for(const variable of graph.variables??[]){
|
|
15
22
|
if(variable.routeKey!==routeKey||variable.renderKey!==(renderKey??graph.routes.find(r=>r.routeKey===routeKey)?.initialRenderKey)||variable.valueType!=='boolean')continue;
|
|
@@ -34,6 +41,49 @@
|
|
|
34
41
|
applySourceCollections(document,sourceUi,sourceRenderKey,values,true);
|
|
35
42
|
markUnobservedData(document,sourceUi,true);
|
|
36
43
|
for (const patch of patches.values()) applyPatch(patch);
|
|
44
|
+
reportProjection();
|
|
45
|
+
}
|
|
46
|
+
function reportProjection() {
|
|
47
|
+
const visibility = new WeakMap();
|
|
48
|
+
const visible = el => {
|
|
49
|
+
if (!el) return false;
|
|
50
|
+
if (visibility.has(el)) return visibility.get(el);
|
|
51
|
+
const style = getComputedStyle(el);
|
|
52
|
+
const result = !el.hidden && style.display !== "none" && style.visibility !== "hidden" &&
|
|
53
|
+
(!el.parentElement || visible(el.parentElement));
|
|
54
|
+
visibility.set(el, result);
|
|
55
|
+
return result;
|
|
56
|
+
};
|
|
57
|
+
const variables = new Set();
|
|
58
|
+
const reads = expression => {
|
|
59
|
+
if (!expression || typeof expression !== "object") return;
|
|
60
|
+
if (expression.op === "var") variables.add(expression.key);
|
|
61
|
+
for (const value of Object.values(expression)) {
|
|
62
|
+
if (Array.isArray(value)) value.forEach(reads);
|
|
63
|
+
else if (value && typeof value === "object") reads(value);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
for (const binding of viewportGraph.bindings) {
|
|
67
|
+
if (binding.routeKey !== viewportRoute || binding.renderKey !== sourceRenderKey) continue;
|
|
68
|
+
const el = node(binding.nodeId);
|
|
69
|
+
if (el && visible(binding.property === "visible" ? el.parentElement : el)) reads(binding.value);
|
|
70
|
+
}
|
|
71
|
+
for (const transition of activeTransitions) if (visible(node(transition.trigger.nodeId))) {
|
|
72
|
+
transition.stateReads.forEach(key => variables.add(key));
|
|
73
|
+
transition.stateWrites.forEach(write => variables.add(write.variableKey));
|
|
74
|
+
}
|
|
75
|
+
const unobserved = [...document.querySelectorAll("[data-iam-preview-note]")].some(visible);
|
|
76
|
+
let content = false;
|
|
77
|
+
if (unobserved) {
|
|
78
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
79
|
+
let text;
|
|
80
|
+
while ((text = walker.nextNode())) if (text.textContent.trim() &&
|
|
81
|
+
!text.parentElement.closest("script,style,[data-iam-preview-note],[data-iam-observation]") && visible(text.parentElement)) {
|
|
82
|
+
content = true; break;
|
|
83
|
+
}
|
|
84
|
+
if (!content) content = [...document.querySelectorAll("img,svg,canvas,input,textarea,select,button")].some(visible);
|
|
85
|
+
}
|
|
86
|
+
send("projection", { visibleVariableKeys: [...variables], dataUnavailable: unobserved && !content });
|
|
37
87
|
}
|
|
38
88
|
function applyPatch(data) {
|
|
39
89
|
const el = node(data.nodeId); if (!el) return;
|
|
@@ -245,6 +245,9 @@ function markUnobservedData(doc, ui, enabled, selections = new Map()) {
|
|
|
245
245
|
field.setAttribute("placeholder", "未观测");
|
|
246
246
|
field.setAttribute("title", "此字段的数据未观测");
|
|
247
247
|
}
|
|
248
|
+
for (const root of doc.querySelectorAll('[data-iam-source-template="unobserved"]')) {
|
|
249
|
+
const note=doc.createElement('div');note.setAttribute('data-iam-preview-note','unobserved');note.className='iam-observation-note';note.setAttribute('role','status');note.textContent='集合数据未观测 · 可检查源码组件结构';root.before(note);
|
|
250
|
+
}
|
|
248
251
|
return unobserved;
|
|
249
252
|
}
|
|
250
253
|
/* Indiana UI simulation runtime 0.2.0. No merchant code, requests, authentication or purchases. */
|
|
@@ -258,7 +261,14 @@ function markUnobservedData(doc, ui, enabled, selections = new Map()) {
|
|
|
258
261
|
}
|
|
259
262
|
let machine = null, activeTabs = [],sourceUi=null,sourceRenderKey=null;
|
|
260
263
|
const patches = new Map();
|
|
261
|
-
const
|
|
264
|
+
const previewNodes = new Map([...document.querySelectorAll("[data-iam-node]")].map(el => [el.dataset.iamNode, el]));
|
|
265
|
+
const node = id => {
|
|
266
|
+
const cached = previewNodes.get(id);
|
|
267
|
+
if (cached?.isConnected) return cached;
|
|
268
|
+
const current = [...document.querySelectorAll("[data-iam-node]")].find(el => el.dataset.iamNode === id);
|
|
269
|
+
if (current) previewNodes.set(id, current);
|
|
270
|
+
return current;
|
|
271
|
+
};
|
|
262
272
|
function viewportValues(graph,routeKey,renderKey){
|
|
263
273
|
const values={};for(const variable of graph.variables??[]){
|
|
264
274
|
if(variable.routeKey!==routeKey||variable.renderKey!==(renderKey??graph.routes.find(r=>r.routeKey===routeKey)?.initialRenderKey)||variable.valueType!=='boolean')continue;
|
|
@@ -283,6 +293,49 @@ function markUnobservedData(doc, ui, enabled, selections = new Map()) {
|
|
|
283
293
|
applySourceCollections(document,sourceUi,sourceRenderKey,values,true);
|
|
284
294
|
markUnobservedData(document,sourceUi,true);
|
|
285
295
|
for (const patch of patches.values()) applyPatch(patch);
|
|
296
|
+
reportProjection();
|
|
297
|
+
}
|
|
298
|
+
function reportProjection() {
|
|
299
|
+
const visibility = new WeakMap();
|
|
300
|
+
const visible = el => {
|
|
301
|
+
if (!el) return false;
|
|
302
|
+
if (visibility.has(el)) return visibility.get(el);
|
|
303
|
+
const style = getComputedStyle(el);
|
|
304
|
+
const result = !el.hidden && style.display !== "none" && style.visibility !== "hidden" &&
|
|
305
|
+
(!el.parentElement || visible(el.parentElement));
|
|
306
|
+
visibility.set(el, result);
|
|
307
|
+
return result;
|
|
308
|
+
};
|
|
309
|
+
const variables = new Set();
|
|
310
|
+
const reads = expression => {
|
|
311
|
+
if (!expression || typeof expression !== "object") return;
|
|
312
|
+
if (expression.op === "var") variables.add(expression.key);
|
|
313
|
+
for (const value of Object.values(expression)) {
|
|
314
|
+
if (Array.isArray(value)) value.forEach(reads);
|
|
315
|
+
else if (value && typeof value === "object") reads(value);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
for (const binding of viewportGraph.bindings) {
|
|
319
|
+
if (binding.routeKey !== viewportRoute || binding.renderKey !== sourceRenderKey) continue;
|
|
320
|
+
const el = node(binding.nodeId);
|
|
321
|
+
if (el && visible(binding.property === "visible" ? el.parentElement : el)) reads(binding.value);
|
|
322
|
+
}
|
|
323
|
+
for (const transition of activeTransitions) if (visible(node(transition.trigger.nodeId))) {
|
|
324
|
+
transition.stateReads.forEach(key => variables.add(key));
|
|
325
|
+
transition.stateWrites.forEach(write => variables.add(write.variableKey));
|
|
326
|
+
}
|
|
327
|
+
const unobserved = [...document.querySelectorAll("[data-iam-preview-note]")].some(visible);
|
|
328
|
+
let content = false;
|
|
329
|
+
if (unobserved) {
|
|
330
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
331
|
+
let text;
|
|
332
|
+
while ((text = walker.nextNode())) if (text.textContent.trim() &&
|
|
333
|
+
!text.parentElement.closest("script,style,[data-iam-preview-note],[data-iam-observation]") && visible(text.parentElement)) {
|
|
334
|
+
content = true; break;
|
|
335
|
+
}
|
|
336
|
+
if (!content) content = [...document.querySelectorAll("img,svg,canvas,input,textarea,select,button")].some(visible);
|
|
337
|
+
}
|
|
338
|
+
send("projection", { visibleVariableKeys: [...variables], dataUnavailable: unobserved && !content });
|
|
286
339
|
}
|
|
287
340
|
function applyPatch(data) {
|
|
288
341
|
const el = node(data.nodeId); if (!el) return;
|
package/assets/viewer.css
CHANGED
|
@@ -706,6 +706,15 @@ button:disabled:hover { background: transparent; }
|
|
|
706
706
|
.field input:disabled, .field textarea:disabled { color: var(--iam-ink-3); background: var(--iam-surface-sunk); }
|
|
707
707
|
.state-control { display: grid; gap: 8px; margin-block: 12px; }
|
|
708
708
|
.state-control select { width: 100%; min-height: 40px; border: 1px solid var(--iam-field-border); border-radius: 2px; background: var(--iam-surface); }
|
|
709
|
+
.state-group { border: 0; padding: 0; margin: 16px 0; min-width: 0; }
|
|
710
|
+
.state-group legend { color: var(--iam-ink); font-weight: 500; overflow-wrap: anywhere; }
|
|
711
|
+
.state-group .field { margin: 8px 0; }
|
|
712
|
+
.state-group[hidden], .state-group .field[hidden] { display: none; }
|
|
713
|
+
.state-other { margin-block: 12px; }
|
|
714
|
+
.state-other summary { cursor: pointer; color: var(--iam-ink-2); }
|
|
715
|
+
.projection-note { padding: 12px 16px; border-bottom: 1px solid var(--iam-line); background: var(--iam-surface-sunk); color: var(--iam-ink-2); }
|
|
716
|
+
.projection-note p { margin: 0 0 8px; max-width: 80ch; }
|
|
717
|
+
.projection-note[hidden] { display: none; }
|
|
709
718
|
#preview { color-scheme: normal; background: #ffffff; }
|
|
710
719
|
.frame-wrap { background: #ffffff; }
|
|
711
720
|
body.embedded #export { display: none; }
|
package/assets/viewer.html
CHANGED
|
@@ -63,6 +63,10 @@
|
|
|
63
63
|
<div class="breadcrumb">
|
|
64
64
|
<span id="route"></span><span id="document-status"></span>
|
|
65
65
|
</div>
|
|
66
|
+
<div id="projection-note" class="projection-note" role="status" hidden>
|
|
67
|
+
<p>此页面依赖未观测的运行时数据,当前无法展示完整内容。可查看未覆盖项,或从页面地图查看源码分支。</p>
|
|
68
|
+
<button id="projection-gaps">查看未覆盖项</button>
|
|
69
|
+
</div>
|
|
66
70
|
<div class="canvas" id="canvas">
|
|
67
71
|
<div class="frame-wrap" id="frame-wrap">
|
|
68
72
|
<iframe
|
package/assets/viewer.js
CHANGED
|
@@ -67,7 +67,9 @@ const iamTranslations = {
|
|
|
67
67
|
"界面状态": "UI states",
|
|
68
68
|
"分析范围与未覆盖项": "Coverage and gaps",
|
|
69
69
|
"DOM 映射 / SHA-256 / 源码证据": "DOM mappings / SHA-256 / source evidence",
|
|
70
|
-
"正在打开页面资料…": "Opening page data…"
|
|
70
|
+
"正在打开页面资料…": "Opening page data…",
|
|
71
|
+
"此页面依赖未观测的运行时数据,当前无法展示完整内容。可查看未覆盖项,或从页面地图查看源码分支。": "This page depends on runtime data that has not been observed. Its full content is unavailable. Review coverage gaps or inspect source branches in the page map.",
|
|
72
|
+
"查看未覆盖项": "Review coverage gaps"
|
|
71
73
|
};
|
|
72
74
|
const tr = value => iamEnglish ? iamTranslations[value] ?? value : value;
|
|
73
75
|
// Theme changes are scoped to the Console tool shell, never its merchant iframe.
|
|
@@ -107,6 +109,7 @@ async function mountIamViewer(m, consoleContext) {
|
|
|
107
109
|
nonce = "",
|
|
108
110
|
viewport = matchMedia("(max-width: 900px)").matches ? 390 : 1280,
|
|
109
111
|
pendingAnchor = null;
|
|
112
|
+
let projection = null, stateSearch = "";
|
|
110
113
|
function setMobilePanel(panel) {
|
|
111
114
|
document.body.dataset.panel = panel;
|
|
112
115
|
for (const button of document.querySelectorAll(".mobile-panels [data-panel]")) {
|
|
@@ -305,6 +308,8 @@ async function mountIamViewer(m, consoleContext) {
|
|
|
305
308
|
);
|
|
306
309
|
}
|
|
307
310
|
async function openPage(next, targetUrl = next.route, renderKey, historyMove=null) {
|
|
311
|
+
projection = null; stateSearch = "";
|
|
312
|
+
$("projection-note").hidden = true;
|
|
308
313
|
if (consoleContext) targetUrl = viewUrl(targetUrl);
|
|
309
314
|
retryOpen={pageKey:next.pageKey,targetUrl,renderKey,historyMove};
|
|
310
315
|
if(consoleContext)consoleContext.documentReady=false;
|
|
@@ -479,6 +484,11 @@ async function mountIamViewer(m, consoleContext) {
|
|
|
479
484
|
}
|
|
480
485
|
}
|
|
481
486
|
if (data.type === "selected") showSelection(data.element);
|
|
487
|
+
if (data.type === "projection" && Array.isArray(data.visibleVariableKeys)) {
|
|
488
|
+
projection = data;
|
|
489
|
+
$("projection-note").hidden = data.dataUnavailable !== true;
|
|
490
|
+
renderStates(uiValues.get(viewKey()) ?? {});
|
|
491
|
+
}
|
|
482
492
|
if (data.type === "stateChanged") {uiValues.set(viewKey(),data.values);renderStates(data.values);}
|
|
483
493
|
if (data.type === "navigate") {
|
|
484
494
|
const interaction = m.interactions.find(
|
|
@@ -507,23 +517,66 @@ async function mountIamViewer(m, consoleContext) {
|
|
|
507
517
|
if (data.type === "simulated") toast(data.message);
|
|
508
518
|
});
|
|
509
519
|
function renderStates(values = {}) {
|
|
510
|
-
const target = $("state-list"), canPreviewValues = scriptFor(doc).js.includes('type === "previewValues"');
|
|
520
|
+
const target = $("state-list"), canPreviewValues = scriptFor(doc).js.includes('type === "previewValues"');
|
|
521
|
+
const focused = document.activeElement?.dataset.variableKey;
|
|
522
|
+
const otherOpen = target.querySelector(".state-other")?.open ?? false;
|
|
523
|
+
target.replaceChildren();
|
|
511
524
|
const renders=m.routeTree.renders?.filter(row=>row.routeKey===page.pageKey)??[];
|
|
512
525
|
if(renders.length>1){const label=text('label',iamEnglish?'Page state':'页面状态','state-control'),select=document.createElement('select');select.setAttribute('aria-label',iamEnglish?'Page state':'页面状态');for(const [index,row]of renders.entries()){const option=document.createElement('option');option.value=row.renderKey;option.textContent=row.entryMode==='hard'?(iamEnglish?'Direct load':'直接打开'):(iamEnglish?'Soft navigation ':'软导航 ')+index;option.selected=row.renderKey===render?.renderKey;select.append(option);}select.addEventListener('change',()=>openPage(page,currentUrl,select.value));label.append(select);target.append(label);}
|
|
513
526
|
const variables = m.routeTree.variables.filter(v => v.routeKey === page.pageKey && (!render || v.renderKey===render.renderKey));
|
|
514
527
|
const stateSection = target.closest("details"), hasControls = String(variables.length > 0 || renders.length > 1);
|
|
515
528
|
if (consoleContext && stateSection.dataset.hasControls !== hasControls) { stateSection.open = hasControls === "true"; stateSection.dataset.hasControls = hasControls; }
|
|
529
|
+
const active = new Set(projection?.visibleVariableKeys ?? []);
|
|
530
|
+
const other = document.createElement("details"); other.className = "state-other"; other.open = otherOpen;
|
|
531
|
+
other.append(text("summary", (iamEnglish ? "Other source states" : "其他源码状态") + ` (${variables.filter(v => !active.has(v.variableKey)).length})`));
|
|
532
|
+
const search = document.createElement("input"); search.type = "search"; search.value = stateSearch;
|
|
533
|
+
search.placeholder = iamEnglish ? "Find states or components" : "查找状态或组件";
|
|
534
|
+
search.setAttribute("aria-label", search.placeholder);
|
|
535
|
+
const searchLabel = text("label", iamEnglish ? "Filter states" : "筛选状态", "field"); searchLabel.append(search);
|
|
536
|
+
if (variables.length > 8) target.append(searchLabel);
|
|
537
|
+
const groups = new Map();
|
|
538
|
+
const filter = () => {
|
|
539
|
+
stateSearch = search.value;
|
|
540
|
+
const query = stateSearch.trim().toLocaleLowerCase();
|
|
541
|
+
for (const group of groups.values()) {
|
|
542
|
+
let shown = false;
|
|
543
|
+
for (const field of group.querySelectorAll(".field")) {
|
|
544
|
+
field.hidden = !field.dataset.search.includes(query); shown ||= !field.hidden;
|
|
545
|
+
}
|
|
546
|
+
group.hidden = !shown;
|
|
547
|
+
}
|
|
548
|
+
if (query) other.open = true;
|
|
549
|
+
};
|
|
550
|
+
search.addEventListener("input", filter);
|
|
516
551
|
for (const variable of variables) {
|
|
552
|
+
const source = typeof variable.source === "number" ? m.routeTree.sourcePool?.[variable.source] : variable.source;
|
|
553
|
+
const owner = source?.symbol || source?.file?.split("/").at(-1) || (iamEnglish ? "Page" : "页面");
|
|
554
|
+
const isActive = active.has(variable.variableKey), groupKey = `${isActive}:${source?.file}:${owner}`;
|
|
555
|
+
if (!groups.has(groupKey)) {
|
|
556
|
+
const group = document.createElement("fieldset"); group.className = "state-group";
|
|
557
|
+
group.append(text("legend", owner)); groups.set(groupKey, group);
|
|
558
|
+
(isActive ? target : other).append(group);
|
|
559
|
+
}
|
|
560
|
+
const group = groups.get(groupKey);
|
|
517
561
|
const value = Object.hasOwn(values, variable.variableKey) ? values[variable.variableKey] : variable.initialValue;
|
|
518
|
-
if (!canPreviewValues) {
|
|
562
|
+
if (!canPreviewValues) {
|
|
563
|
+
const field = text("p", variable.label + " = " + JSON.stringify(value), "field");
|
|
564
|
+
field.dataset.search = `${variable.label} ${owner} ${source?.file ?? ""}`.toLocaleLowerCase();
|
|
565
|
+
group.append(field); continue;
|
|
566
|
+
}
|
|
519
567
|
const label = text("label", variable.label, "field"), input = document.createElement("input");
|
|
568
|
+
label.dataset.search = `${variable.label} ${owner} ${source?.file ?? ""}`.toLocaleLowerCase();
|
|
569
|
+
if (source) label.title = `${source.file}:${source.line}`;
|
|
520
570
|
input.dataset.variableKey = variable.variableKey;
|
|
521
571
|
input.type = variable.valueType === "boolean" ? "checkbox" : variable.valueType === "number" ? "number" : "text";
|
|
522
572
|
input.setAttribute("aria-label", variable.label); input.maxLength = 4000;
|
|
523
573
|
if (variable.valueType === "boolean") input.checked = value; else input.value = String(value);
|
|
524
574
|
input.addEventListener("change", () => message("previewValues", { values: { [variable.variableKey]: variable.valueType === "boolean" ? input.checked : variable.valueType === "number" ? Number(input.value) : input.value } }));
|
|
525
|
-
label.append(input);
|
|
575
|
+
label.append(input); group.append(label);
|
|
526
576
|
}
|
|
577
|
+
if (variables.some(v => !active.has(v.variableKey))) target.append(other);
|
|
578
|
+
filter();
|
|
579
|
+
if (focused) [...target.querySelectorAll("input")].find(input => input.dataset.variableKey === focused)?.focus();
|
|
527
580
|
if (variables.length && canPreviewValues) target.append(text("p", tr("仅模拟界面值,不执行业务操作,也不保存为线上修改。")));
|
|
528
581
|
const transitions = m.routeTree.transitions.filter(t => t.routeKey === page.pageKey && (!render || t.renderKey===render.renderKey));
|
|
529
582
|
target.append(text("p", transitions.length + tr(" 个可模拟变化;其他业务行为列于未覆盖项。")));
|
|
@@ -761,11 +814,17 @@ function expandDomNodes(document, body, deriveTextId) {
|
|
|
761
814
|
toast(tr("字号须为 8–120,已恢复当前值。"));
|
|
762
815
|
});
|
|
763
816
|
$("color").addEventListener("input", () => patch("color", $("color").value));
|
|
817
|
+
$("pick").setAttribute("aria-pressed", String(selecting));
|
|
764
818
|
$("pick").addEventListener("click", () => {
|
|
765
819
|
selecting = !selecting;
|
|
766
820
|
$("pick").classList.toggle("active", selecting);
|
|
821
|
+
$("pick").setAttribute("aria-pressed", String(selecting));
|
|
767
822
|
message("mode", { selecting });
|
|
768
823
|
});
|
|
824
|
+
$("projection-gaps").addEventListener("click", () => {
|
|
825
|
+
const section = $("gaps").closest("details"); section.open = true;
|
|
826
|
+
setMobilePanel("inspector"); section.querySelector("summary").focus();
|
|
827
|
+
});
|
|
769
828
|
let fitRequest = 0;
|
|
770
829
|
if (typeof ResizeObserver !== "undefined") new ResizeObserver(() => {
|
|
771
830
|
cancelAnimationFrame(fitRequest);
|
package/package.json
CHANGED
package/src/build.mjs
CHANGED
|
@@ -111,6 +111,9 @@ export async function finishBuildRelease({
|
|
|
111
111
|
apiKey: settings.apiKey,
|
|
112
112
|
baseUrl: settings.baseUrl,
|
|
113
113
|
fetch: upload.fetch,
|
|
114
|
+
onProgress: ({ phase, uploadedBytes, totalBytes }) => {
|
|
115
|
+
if (phase === "upload") report(logger, "log", `IAM upload ${Math.floor(uploadedBytes / totalBytes * 100)}% (${uploadedBytes}/${totalBytes} bytes)`);
|
|
116
|
+
},
|
|
114
117
|
});
|
|
115
118
|
report(
|
|
116
119
|
logger,
|
package/src/cli.mjs
CHANGED
|
@@ -112,6 +112,9 @@ if (command === "generate") {
|
|
|
112
112
|
client: settings.client,
|
|
113
113
|
apiKey: settings.apiKey,
|
|
114
114
|
baseUrl: settings.baseUrl,
|
|
115
|
+
onProgress: ({ phase, uploadedBytes, totalBytes }) => {
|
|
116
|
+
console.error(`[indiana-iam] ${phase}${phase === "upload" ? ` ${Math.floor(uploadedBytes / totalBytes * 100)}% (${uploadedBytes}/${totalBytes} bytes)` : ""}`);
|
|
117
|
+
},
|
|
115
118
|
});
|
|
116
119
|
console.log(
|
|
117
120
|
JSON.stringify({ uploaded, version: { id: version.id, client: version.clientKey, state: version.state, isCurrent: version.isCurrent } }, null, 2),
|
package/src/index.d.ts
CHANGED
|
@@ -144,6 +144,13 @@ export declare function uploadAppManifest(options: {
|
|
|
144
144
|
apiKey: string;
|
|
145
145
|
baseUrl?: string;
|
|
146
146
|
fetch?: typeof globalThis.fetch;
|
|
147
|
+
/** Private local recovery cache; defaults to INDIANA_IAM_CACHE_DIR or ~/.cache/indiana/iam-uploads. */
|
|
148
|
+
cacheDirectory?: string;
|
|
149
|
+
onProgress?: (progress: {
|
|
150
|
+
phase: "registration" | "upload" | "validation";
|
|
151
|
+
uploadedBytes: number;
|
|
152
|
+
totalBytes: number;
|
|
153
|
+
}) => void;
|
|
147
154
|
}): Promise<{
|
|
148
155
|
version: { id: string; state: string; isCurrent: boolean } & Record<
|
|
149
156
|
string,
|
|
@@ -159,7 +166,13 @@ export declare function uploadSettings(
|
|
|
159
166
|
client?: string;
|
|
160
167
|
},
|
|
161
168
|
env?: Record<string, string | undefined>,
|
|
162
|
-
): {
|
|
169
|
+
): {
|
|
170
|
+
enabled: boolean;
|
|
171
|
+
apiKey: string;
|
|
172
|
+
baseUrl: string;
|
|
173
|
+
client: string;
|
|
174
|
+
missingKey: boolean;
|
|
175
|
+
};
|
|
163
176
|
export declare function buildLabel(
|
|
164
177
|
root: string,
|
|
165
178
|
env?: Record<string, string | undefined>,
|
package/src/ui-preview.mjs
CHANGED
|
@@ -225,9 +225,31 @@ export function previewUiDocument(manifest, document, options = {}) {
|
|
|
225
225
|
]
|
|
226
226
|
: [];
|
|
227
227
|
});
|
|
228
|
+
const regionParents = new Map(regions.flatMap(region => region.options.map(option => [
|
|
229
|
+
option.nodeId, { regionKey: region.regionKey, value: option.value },
|
|
230
|
+
])));
|
|
231
|
+
const conditionChoices = [...groups].map(([groupKey, rows]) => {
|
|
232
|
+
const row = rows[0], requires = { conditions: [], regions: [] };
|
|
233
|
+
for (let parent = nodes.get(row.truthyNodeId)?.parentElement; parent; parent = parent.parentElement) {
|
|
234
|
+
const id = parent.getAttribute("data-iam-node"), region = regionParents.get(id);
|
|
235
|
+
if (region) requires.regions.unshift(region);
|
|
236
|
+
if (!parent.hasAttribute("data-iam-condition")) continue;
|
|
237
|
+
const truthy = parent.getAttribute("data-iam-alternative") === "true";
|
|
238
|
+
const root = truthy ? parent : parent.previousElementSibling;
|
|
239
|
+
const key = root?.getAttribute("data-iam-node");
|
|
240
|
+
if (groups.has(key) && root.getAttribute("data-iam-condition") === parent.getAttribute("data-iam-condition"))
|
|
241
|
+
requires.conditions.unshift({ groupKey: key, value: truthy });
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
groupKey, expression: row.expression, source: row.source,
|
|
245
|
+
selected: selections.has(groupKey) ? selections.get(groupKey) : null,
|
|
246
|
+
requires,
|
|
247
|
+
};
|
|
248
|
+
});
|
|
228
249
|
return {
|
|
229
250
|
html: doc.documentElement.outerHTML,
|
|
230
251
|
assumptions,
|
|
252
|
+
conditionChoices,
|
|
231
253
|
values,
|
|
232
254
|
componentChoices,
|
|
233
255
|
collectionChoices,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
const hash = value => createHash("sha256").update(value).digest("hex");
|
|
8
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
9
|
+
|
|
10
|
+
/** Local recovery state is a private bearer capability, never a manifest or source artifact. */
|
|
11
|
+
export async function uploadCheckpoint({ base, apiKey, client, platform, digest, bytes, label, directory }) {
|
|
12
|
+
directory ??= process.env.INDIANA_IAM_CACHE_DIR || path.join(os.homedir(), ".cache", "indiana", "iam-uploads");
|
|
13
|
+
directory = path.resolve(directory);
|
|
14
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
15
|
+
const folder = await fs.lstat(directory);
|
|
16
|
+
if (!folder.isDirectory() || folder.isSymbolicLink() || (folder.mode & 0o077))
|
|
17
|
+
throw new Error("IAM upload cache must be a private directory (mode 0700)");
|
|
18
|
+
const fingerprint = hash(JSON.stringify([base.origin, hash(apiKey), client, platform, digest, bytes]));
|
|
19
|
+
const file = path.join(directory, fingerprint + ".json");
|
|
20
|
+
async function read() {
|
|
21
|
+
let handle;
|
|
22
|
+
try {
|
|
23
|
+
handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
24
|
+
const stat = await handle.stat();
|
|
25
|
+
if (!stat.isFile() || stat.size > 32_768 || (stat.mode & 0o077)) throw new Error();
|
|
26
|
+
const saved = JSON.parse(await handle.readFile("utf8"));
|
|
27
|
+
if (saved.format !== 1 || saved.fingerprint !== fingerprint || !uuid.test(saved.requestId) ||
|
|
28
|
+
typeof saved.label !== "string" || !saved.label.length || saved.label.length > 100 ||
|
|
29
|
+
!Number.isFinite(saved.createdAt) || saved.createdAt > Date.now() + 60_000 ||
|
|
30
|
+
(saved.version && (!uuid.test(saved.version.id) || !Number.isFinite(Date.parse(saved.version.expiresAt))))) throw new Error();
|
|
31
|
+
return saved;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code === "ENOENT") return null;
|
|
34
|
+
throw new Error("IAM upload checkpoint is invalid; inspect the private upload cache before retrying");
|
|
35
|
+
} finally { await handle?.close(); }
|
|
36
|
+
}
|
|
37
|
+
async function write(saved, first = false) {
|
|
38
|
+
const temporary = file + "." + randomUUID();
|
|
39
|
+
try {
|
|
40
|
+
await fs.writeFile(temporary, JSON.stringify(saved), { flag: "wx", mode: 0o600 });
|
|
41
|
+
if (first) {
|
|
42
|
+
// Linking a fully written file reserves the request ID atomically across processes.
|
|
43
|
+
try { await fs.link(temporary, file); }
|
|
44
|
+
catch (error) { if (error.code !== "EEXIST") throw error; return await read(); }
|
|
45
|
+
} else await fs.rename(temporary, file);
|
|
46
|
+
return saved;
|
|
47
|
+
} finally { await fs.rm(temporary, { force: true }); }
|
|
48
|
+
}
|
|
49
|
+
const fresh = () => ({ format: 1, fingerprint, requestId: randomUUID(), label, createdAt: Date.now() });
|
|
50
|
+
const saved = await read() ?? await write(fresh(), true);
|
|
51
|
+
return {
|
|
52
|
+
saved,
|
|
53
|
+
write,
|
|
54
|
+
reset: () => write(fresh()),
|
|
55
|
+
async clear(requestId) {
|
|
56
|
+
if ((await read())?.requestId === requestId) await fs.rm(file, { force: true });
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
package/src/upload.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { repositoryCommit } from "./git-metadata.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { uploadCheckpoint } from "./upload-checkpoint.mjs";
|
|
3
5
|
|
|
4
6
|
export const DEFAULT_BASE_URL = "https://tinker.cash";
|
|
5
7
|
/** One App environment can hold several clients (named builds); each has its own current version. */
|
|
6
8
|
export const DEFAULT_CLIENT = "web";
|
|
7
9
|
const CLIENT_KEY = /^[a-z][a-z0-9-]{0,39}$/;
|
|
8
|
-
const
|
|
10
|
+
const UPLOAD_ORIGIN = "https://storage.googleapis.com";
|
|
9
11
|
|
|
10
12
|
/** The build's upload settings: the key, client and API origin come from options or the build environment. */
|
|
11
13
|
export function uploadSettings({ apiKey, baseUrl, upload = "production", client } = {}, env = process.env) {
|
|
@@ -32,64 +34,181 @@ export function buildLabel(root, env = process.env) {
|
|
|
32
34
|
return "build " + new Date().toISOString().slice(0, 16).replace("T", " ");
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
const CHUNK_BYTES = 8 * 1024 * 1024; // GCS requires non-final chunks to be multiples of 256 KiB.
|
|
38
|
+
const retryable = status => status === 408 || status === 429 || status >= 500;
|
|
39
|
+
class UploadError extends Error {
|
|
40
|
+
constructor(message, status = 0, retryAfter = 0) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.status = status;
|
|
43
|
+
this.retryAfter = retryAfter;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
35
46
|
async function failure(response, step) {
|
|
36
47
|
let message = response.statusText;
|
|
37
|
-
try {
|
|
48
|
+
if (step !== "storage") try {
|
|
38
49
|
const body = await response.json();
|
|
39
50
|
if (typeof body?.error?.message === "string") message = body.error.message;
|
|
40
|
-
} catch { /* Keep the HTTP status text. */ }
|
|
41
|
-
return new
|
|
51
|
+
} catch { /* Keep the HTTP status text; never include a storage capability. */ }
|
|
52
|
+
return new UploadError(`IAM upload ${step} failed (${response.status}): ${message}`, response.status,
|
|
53
|
+
Math.min(5000, Math.max(0, Number(response.headers.get("retry-after")) || 0) * 1000));
|
|
54
|
+
}
|
|
55
|
+
const pause = (attempt, error) => delay(Math.max(error.retryAfter || 0, 250 * 2 ** attempt));
|
|
56
|
+
|
|
57
|
+
/** API retries retain the identical body and request ID. Storage writes instead query the persisted offset. */
|
|
58
|
+
async function request(fetch, url, init, step, retry = true) {
|
|
59
|
+
for (let attempt = 0; ; attempt++) {
|
|
60
|
+
try {
|
|
61
|
+
let response;
|
|
62
|
+
try { response = await fetch(url, { ...init, redirect: step === "storage" ? "manual" : "error", signal: AbortSignal.timeout(step === "validation" ? 150_000 : 60_000) }); }
|
|
63
|
+
catch { throw new UploadError(`IAM upload ${step} connection interrupted; rerun the same upload to resume`); }
|
|
64
|
+
if (response.ok || (step === "storage" && [308, 404, 410, 412].includes(response.status))) return response;
|
|
65
|
+
throw await failure(response, step);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (!retry || attempt >= 2 || !(error instanceof UploadError) || (error.status && !retryable(error.status))) throw error;
|
|
68
|
+
await pause(attempt, error);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function checkedPlan(plan, version, size) {
|
|
74
|
+
try {
|
|
75
|
+
const url = new URL(plan.url), params = url.searchParams;
|
|
76
|
+
const allowed = ["uploadType", "upload_id", "name", "upload_protocol", "ifGenerationMatch"];
|
|
77
|
+
if (plan.kind !== "gcs-resumable" || plan.byteLength !== size ||
|
|
78
|
+
!["enterprise-internal-indiana-test", "tinkerland-indiana-prod"].includes(plan.bucket) ||
|
|
79
|
+
plan.objectKey !== `${version.projectId}/${version.environmentId}/${version.id}/manifest.json` ||
|
|
80
|
+
Date.parse(plan.expiresAt) !== Date.parse(version.expiresAt) || Date.parse(plan.expiresAt) <= Date.now() ||
|
|
81
|
+
url.origin !== UPLOAD_ORIGIN || url.username || url.password || url.hash ||
|
|
82
|
+
url.pathname !== `/upload/storage/v1/b/${plan.bucket}/o` ||
|
|
83
|
+
[...params.keys()].some(key => !allowed.includes(key) || params.getAll(key).length !== 1) ||
|
|
84
|
+
params.get("uploadType") !== "resumable" || !/^[A-Za-z0-9_-]{1,4096}$/.test(params.get("upload_id") ?? "") ||
|
|
85
|
+
(params.has("name") && params.get("name") !== `app-manifests/${plan.objectKey}`) ||
|
|
86
|
+
(params.has("upload_protocol") && params.get("upload_protocol") !== "resumable") ||
|
|
87
|
+
(params.has("ifGenerationMatch") && params.get("ifGenerationMatch") !== "0")) throw new Error();
|
|
88
|
+
return plan;
|
|
89
|
+
} catch { throw new Error("IAM upload received an unexpected storage address or upload capability"); }
|
|
90
|
+
}
|
|
91
|
+
function offset(response, size, minimum = 0, maximum = size) {
|
|
92
|
+
if (response.status === 200 || response.status === 201 || response.status === 412) return size;
|
|
93
|
+
if (response.status === 404 || response.status === 410) return null;
|
|
94
|
+
const range = response.headers.get("range");
|
|
95
|
+
const match = range?.match(/^bytes=0-(\d+)$/);
|
|
96
|
+
const next = range === null ? 0 : match ? Number(match[1]) + 1 : NaN;
|
|
97
|
+
if (response.status !== 308 || !Number.isSafeInteger(next) || next < minimum || next > maximum || next >= size)
|
|
98
|
+
throw new Error("IAM upload received an invalid storage offset");
|
|
99
|
+
return next;
|
|
100
|
+
}
|
|
101
|
+
async function transfer(fetch, plan, bytes, onProgress) {
|
|
102
|
+
const size = bytes.byteLength;
|
|
103
|
+
const probe = async () => offset(await request(fetch, plan.url, {
|
|
104
|
+
method: "PUT", headers: { "Content-Length": "0", "Content-Range": `bytes */${size}` }, body: Buffer.alloc(0),
|
|
105
|
+
}, "storage"), size);
|
|
106
|
+
let next = await probe();
|
|
107
|
+
if (next === null) return false;
|
|
108
|
+
onProgress?.({ phase: "upload", uploadedBytes: next, totalBytes: size });
|
|
109
|
+
let failures = 0;
|
|
110
|
+
while (next < size) {
|
|
111
|
+
if (Date.parse(plan.expiresAt) <= Date.now()) throw new Error("IAM upload expired; rerun the same build to start a new upload");
|
|
112
|
+
const end = Math.min(next + CHUNK_BYTES, size), start = next;
|
|
113
|
+
try {
|
|
114
|
+
const response = await request(fetch, plan.url, {
|
|
115
|
+
method: "PUT",
|
|
116
|
+
headers: { "Content-Type": "application/json", "Content-Length": String(end - start), "Content-Range": `bytes ${start}-${end - 1}/${size}` },
|
|
117
|
+
body: bytes.subarray(start, end),
|
|
118
|
+
}, "storage", false);
|
|
119
|
+
next = offset(response, size, start, end);
|
|
120
|
+
if (next === null) return false;
|
|
121
|
+
if (next === start) throw new UploadError("IAM upload storage made no progress");
|
|
122
|
+
if ((response.status === 200 || response.status === 201) && end !== size)
|
|
123
|
+
throw new Error("IAM upload storage completed before all bytes were sent");
|
|
124
|
+
failures = 0;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (!(error instanceof UploadError) || (error.status && !retryable(error.status)) || failures >= 2) throw error;
|
|
127
|
+
await pause(failures++, error);
|
|
128
|
+
const received = await probe();
|
|
129
|
+
if (received === null) return false;
|
|
130
|
+
if (received < start || received > end) throw new Error("IAM upload received an invalid storage offset");
|
|
131
|
+
next = received;
|
|
132
|
+
}
|
|
133
|
+
onProgress?.({ phase: "upload", uploadedBytes: next, totalBytes: size });
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
42
136
|
}
|
|
43
137
|
|
|
44
|
-
/**
|
|
45
|
-
* Uploads one generated IAM to Indiana: register, PUT the bytes to the server-signed URL,
|
|
46
|
-
* then let the server verify them. The key needs only the app-manifests:write permission;
|
|
47
|
-
* the uploaded (or redeployed) version becomes its client's current version.
|
|
48
|
-
*/
|
|
138
|
+
/** Registers one immutable version, resumes GCS bytes and retries server validation independently. */
|
|
49
139
|
export async function uploadAppManifest({
|
|
50
|
-
serialized,
|
|
51
|
-
|
|
52
|
-
label,
|
|
53
|
-
client = DEFAULT_CLIENT,
|
|
54
|
-
apiKey,
|
|
55
|
-
baseUrl = DEFAULT_BASE_URL,
|
|
56
|
-
fetch = globalThis.fetch,
|
|
140
|
+
serialized, digest, label, client = DEFAULT_CLIENT, apiKey, baseUrl = DEFAULT_BASE_URL,
|
|
141
|
+
fetch = globalThis.fetch, cacheDirectory, onProgress,
|
|
57
142
|
}) {
|
|
58
143
|
if (!apiKey) throw new Error("IAM upload needs a key with the app-manifests:write permission");
|
|
59
144
|
const base = new URL(baseUrl);
|
|
60
|
-
if (base.protocol
|
|
61
|
-
throw new Error("IAM upload needs an https Indiana origin");
|
|
62
|
-
if (!CLIENT_KEY.test(client))
|
|
63
|
-
throw new Error("IAM client must be 1-40 lowercase letters, digits or hyphens, starting with a letter");
|
|
64
|
-
// The server checks the stored file declares this same platform before the version becomes current.
|
|
145
|
+
if (!(base.protocol === "https:" || (base.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(base.hostname))) || base.username || base.password)
|
|
146
|
+
throw new Error("IAM upload needs an https Indiana origin without credentials");
|
|
147
|
+
if (!CLIENT_KEY.test(client)) throw new Error("IAM client must be 1-40 lowercase letters, digits or hyphens, starting with a letter");
|
|
65
148
|
const platforms = JSON.parse(serialized)?.app?.platforms;
|
|
66
|
-
if (!Array.isArray(platforms) || platforms.length !== 1
|
|
67
|
-
|
|
149
|
+
if (!Array.isArray(platforms) || platforms.length !== 1 || !["web", "ios", "android"].includes(platforms[0]))
|
|
150
|
+
throw new Error("IAM upload needs a manifest for exactly one platform");
|
|
151
|
+
const bytes = Buffer.from(serialized), size = bytes.byteLength;
|
|
152
|
+
if (createHash("sha256").update(bytes).digest("hex") !== digest) throw new Error("IAM upload digest does not match its bytes");
|
|
153
|
+
if (typeof label !== "string" || label.length < 1 || label.length > 100) throw new Error("IAM upload needs a label of 1-100 characters");
|
|
154
|
+
const checkpoint = await uploadCheckpoint({ base, apiKey, client, platform: platforms[0], digest, bytes: size, label, directory: cacheDirectory });
|
|
155
|
+
let state = checkpoint.saved;
|
|
68
156
|
const authorization = { Authorization: "Bearer " + apiKey };
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
157
|
+
const complete = async versionId => {
|
|
158
|
+
onProgress?.({ phase: "validation", uploadedBytes: size, totalBytes: size });
|
|
159
|
+
const response = await request(fetch, new URL(`/api/iam/versions/${encodeURIComponent(versionId)}/complete`, base), {
|
|
160
|
+
method: "POST", headers: authorization,
|
|
161
|
+
}, "validation");
|
|
162
|
+
const version = (await response.json()).version;
|
|
163
|
+
if (version?.id !== versionId || version.state !== "ready") throw new Error("IAM upload received an invalid completed version");
|
|
164
|
+
await checkpoint.clear(state.requestId);
|
|
165
|
+
return { version, uploaded: true };
|
|
166
|
+
};
|
|
167
|
+
// Reauthorize on Indiana before using a cached bearer session. A lost completion reply
|
|
168
|
+
// requires neither another registration nor another storage write.
|
|
169
|
+
if (state.version) {
|
|
170
|
+
try { return await complete(state.version.id); }
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (!(error instanceof UploadError) || error.status !== 409) throw error;
|
|
173
|
+
if (Date.parse(state.version.expiresAt) <= Date.now()) state = await checkpoint.reset();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
onProgress?.({ phase: "registration", uploadedBytes: 0, totalBytes: size });
|
|
177
|
+
const register = () => request(fetch, new URL("/api/iam/versions", base), {
|
|
178
|
+
method: "POST", headers: { ...authorization, "Content-Type": "application/json" },
|
|
179
|
+
body: JSON.stringify({ requestId: state.requestId, client, platform: platforms[0], label: state.label, sha256: digest, bytes: size }),
|
|
180
|
+
}, "registration");
|
|
181
|
+
let registered;
|
|
182
|
+
try { registered = await register(); }
|
|
183
|
+
catch (error) {
|
|
184
|
+
// A lost registration reply may also outlive its original 15-minute SQL intent.
|
|
185
|
+
if (!(error instanceof UploadError) || error.status !== 409 || Date.now() - state.createdAt < 900_000) throw error;
|
|
186
|
+
state = await checkpoint.reset();
|
|
187
|
+
registered = await register();
|
|
188
|
+
}
|
|
76
189
|
const { version, upload } = await registered.json();
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
190
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
191
|
+
if (!uuid.test(version?.id ?? "") || !["ready", "uploading"].includes(version.state) ||
|
|
192
|
+
(version.state === "uploading" && (!uuid.test(version.projectId) || !uuid.test(version.environmentId) || !Number.isFinite(Date.parse(version.expiresAt)))))
|
|
193
|
+
throw new Error("IAM upload received an invalid registered version");
|
|
194
|
+
if (version.state === "ready") {
|
|
195
|
+
await checkpoint.clear(state.requestId);
|
|
196
|
+
return { version, uploaded: false };
|
|
197
|
+
}
|
|
198
|
+
const previous = state.version?.id === version.id ? state.upload : null;
|
|
199
|
+
state = await checkpoint.write({ ...state, version });
|
|
200
|
+
if (upload) {
|
|
201
|
+
const fresh = checkedPlan(upload, version, size);
|
|
202
|
+
let plan = previous ? checkedPlan(previous, version, size) : fresh;
|
|
203
|
+
state = await checkpoint.write({ ...state, upload: plan });
|
|
204
|
+
if (!await transfer(fetch, plan, bytes, onProgress)) {
|
|
205
|
+
// The cached GCS session expired independently; the fresh, authorized session
|
|
206
|
+
// still belongs to this same SQL version and immutable object.
|
|
207
|
+
if (plan.url === fresh.url) throw new Error("IAM upload storage session expired; rerun the same upload to resume");
|
|
208
|
+
plan = fresh;
|
|
209
|
+
state = await checkpoint.write({ ...state, upload: plan });
|
|
210
|
+
if (!await transfer(fetch, plan, bytes, onProgress)) throw new Error("IAM upload storage session expired; rerun the same upload to resume");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return complete(version.id);
|
|
95
214
|
}
|