@tinkerlandai/indiana-iam 0.6.0 → 0.7.1

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 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 ingestion separately limits each upload to 8 MiB (8,388,608 bytes); a large local artifact is not automatically uploadable. Signed GCS upload URLs expire within ten minutes.
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
- server-signed URL and asks Indiana to validate it; the validated version becomes its client's current version.
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 node = id => [...document.querySelectorAll("[data-iam-node]")].find(e => e.dataset.iamNode === id);
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 node = id => [...document.querySelectorAll("[data-iam-node]")].find(e => e.dataset.iamNode === id);
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; }
@@ -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"'); target.replaceChildren();
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) { target.append(text("p", variable.label + " = " + JSON.stringify(value))); continue; }
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); target.append(label);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinkerlandai/indiana-iam",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Build-time Indiana App Manifest (IAM) generator for Next.js, Vite and React Native / Expo source.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -59,6 +59,7 @@
59
59
  "micromark-extension-gfm": "3.0.0",
60
60
  "micromark-extension-mdxjs": "3.0.0",
61
61
  "pako": "3.0.2",
62
+ "parse5": "7.3.0",
62
63
  "sharp": "0.35.5",
63
64
  "tailwind-merge": "3.7.0",
64
65
  "tailwindcss": "4.3.3",
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),
@@ -1,4 +1,4 @@
1
- import { documentNodes } from "./node-hashes.mjs";
1
+ import { documentStructure } from "./node-hashes.mjs";
2
2
  import { documentDom } from "./dom-payload.mjs";
3
3
  import { sha256, canonical } from "./integrity.mjs";
4
4
  const resourceHashes = new WeakMap();
@@ -36,7 +36,7 @@ export function documentMaterial(
36
36
  (document) => document.documentKey === render.documentKey,
37
37
  );
38
38
  if (!document) throw Error("Missing IAM preview document");
39
- const concreteNodes = documentNodes(document);
39
+ const concreteNodes = documentStructure(document);
40
40
  const elementKeys = new Set(concreteNodes.map((node) => node.elementKey));
41
41
  const ui = manifest.routeTree.ui;
42
42
  const collections =
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
- ): { enabled: boolean; apiKey: string; baseUrl: string; client: string; missingKey: boolean };
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>,
@@ -7,6 +7,7 @@ import {privateUrl}from'./privacy.mjs';
7
7
  import{renderGuardFor}from'./render-index.mjs';
8
8
  import { documentScript } from "./shared-scripts.mjs";
9
9
  import { documentNodes } from './node-hashes.mjs';
10
+ export { documentStructure as iamDocumentStructure } from './node-hashes.mjs';
10
11
  export { documentNodes as iamDocumentNodes } from './node-hashes.mjs';
11
12
  export { documentDom as iamDocumentDom } from './dom-payload.mjs';
12
13
  export { rebindIamSource } from './rebind.mjs';
@@ -1,7 +1,7 @@
1
1
  import { documentDom } from "./dom-payload.mjs";
2
2
  import { canonical, identity, sealNodes, sealDocument } from './integrity.mjs';
3
- import { dependency } from './dependencies.mjs';
4
3
  import { sourceMappingsHash } from './compact-dom.mjs';
4
+ import { parseFragment } from 'parse5';
5
5
 
6
6
  /** HTML already owns the DOM values and ordering. Store only the source map
7
7
  * in preorder, rather than repeating attributes, text and every tree edge. */
@@ -20,15 +20,32 @@ export function expandDomNodes(document, body, deriveTextId) {
20
20
  if(body.childNodes.length!==1||visit(body.firstChild,null,0)!==document.rootNodeId||index!==document.nodes.length)throw Error('HTML-derived DOM coverage differs');
21
21
  return result;
22
22
  }
23
- function domNodes(document){
24
- const decoded = {...document, ...documentDom(document)};
25
- const {JSDOM}=dependency('jsdom').value,dom=new JSDOM('<!doctype html><html><body>'+decoded.html+'</body></html>');
26
- try{return expandDomNodes(decoded,dom.window.document.body,(page,parentId,index)=>identity('node',{page,parentId,index,kind:3}));}finally{dom.window.close();}
23
+
24
+ /** Adapt the same HTML parser's inert tree to the existing source-map expansion.
25
+ * Maps need tree values, not a window, CSSOM, scripts or derived node hashes. */
26
+ export function documentStructure(document) {
27
+ const decoded = { ...document, ...documentDom(document) };
28
+ const adapt = node => ({
29
+ nodeType: node.tagName ? 1 : node.nodeName === '#text' ? 3 : 8,
30
+ tagName: node.tagName,
31
+ data: node.value,
32
+ get attributes() {
33
+ return (node.attrs ?? []).map(attr => ({
34
+ name: attr.prefix ? attr.prefix + ':' + attr.name : attr.name,
35
+ value: attr.value,
36
+ }));
37
+ },
38
+ getAttribute(name) { return this.attributes.find(attr => attr.name === name)?.value ?? null; },
39
+ get childNodes() { return (node.childNodes ?? []).map(adapt); },
40
+ get firstChild() { return this.childNodes[0]; },
41
+ });
42
+ return expandDomNodes(decoded, adapt(parseFragment(decoded.html)),
43
+ (page, parentId, index) => identity('node', { page, parentId, index, kind: 3 }));
27
44
  }
28
45
 
29
46
  /** Node hashes are pure derived values. IDs, source mappings and algorithms never change. */
30
47
  export function documentNodes(document) {
31
- const values=domNodes(document);
48
+ const values=documentStructure(document);
32
49
  sealNodes(values, document.rootNodeId);
33
50
  return values;
34
51
  }
@@ -1,7 +1,6 @@
1
- import { JSDOM, VirtualConsole } from "jsdom";
2
1
  import { documentDom } from "./dom-payload.mjs";
3
2
  import { createStateMachine } from "./ui-state-machine.mjs";
4
- import { previewUiDocument } from "./ui-preview.mjs";
3
+ import { inspectUiDocument, inertUiDocument } from "./ui-preview.mjs";
5
4
  import {graphSource} from './graph-sources.mjs';
6
5
 
7
6
  /** Build thumbnail states and edges by dispatching the IAM's actual scalar transitions. */
@@ -13,12 +12,9 @@ export function tabUiScenes(manifest, tabKey) {
13
12
  document = manifest.documents.find(
14
13
  (d) => d.documentKey === render.documentKey,
15
14
  );
16
- const dom = new JSDOM(documentDom(document).html, {
17
- virtualConsole: new VirtualConsole(),
18
- });
15
+ const doc = inertUiDocument(documentDom(document).html);
19
16
  try {
20
- const doc = dom.window.document,
21
- nodes = new Map(
17
+ const nodes = new Map(
22
18
  [...doc.querySelectorAll("[data-iam-node]")].map((n) => [
23
19
  n.getAttribute("data-iam-node"),
24
20
  n,
@@ -118,32 +114,31 @@ export function tabUiScenes(manifest, tabKey) {
118
114
  ([key, value]) => initial[key] !== value || key === tab.variableKey,
119
115
  ),
120
116
  );
121
- const options = { ...base, values: { ...values, ...changed } },
122
- preview = previewUiDocument(manifest, document, options),
123
- sceneDom = new JSDOM(preview.html, {
124
- virtualConsole: new VirtualConsole(),
125
- });
126
- const focused = [
127
- ...sceneDom.window.document.querySelectorAll("[data-iam-node]"),
128
- ]
129
- .find((n) => n.getAttribute("data-iam-node") === base.focusNodeId)
130
- ?.cloneNode(true);
131
- focused
132
- ?.querySelectorAll("[hidden],[role=tablist]")
133
- .forEach((n) => n.remove());
134
- const headings = focused
135
- ? [...focused.querySelectorAll("h1,h2,h3,h4,label,[role=columnheader]")]
136
- .map((n) => n.textContent.replace(/\s+/g, " ").trim())
137
- .filter(Boolean)
138
- : [];
139
- const content = focused?.textContent.replace(/\s+/g, " ").trim() ?? "";
140
- const fields = focused
141
- ? [...focused.querySelectorAll("input,textarea,select")].map((n) => ({
142
- tag: n.tagName.toLowerCase(),
143
- label: n.getAttribute("aria-label") ?? "",
144
- placeholder: n.getAttribute("placeholder") ?? "",
145
- }))
146
- : [];
117
+ const options = { ...base, values: { ...values, ...changed } };
118
+ const { preview, headings, content, fields } = inspectUiDocument(
119
+ manifest, document, options, (doc, preview) => {
120
+ const focused = [...doc.querySelectorAll("[data-iam-node]")]
121
+ .find((n) => n.getAttribute("data-iam-node") === base.focusNodeId)
122
+ ?.cloneNode(true);
123
+ focused?.querySelectorAll("[hidden],[role=tablist]")
124
+ .forEach((n) => n.remove());
125
+ return {
126
+ preview,
127
+ headings: focused
128
+ ? [...focused.querySelectorAll("h1,h2,h3,h4,label,[role=columnheader]")]
129
+ .map((n) => n.textContent.replace(/\s+/g, " ").trim()).filter(Boolean)
130
+ : [],
131
+ content: focused?.textContent.replace(/\s+/g, " ").trim() ?? "",
132
+ fields: focused
133
+ ? [...focused.querySelectorAll("input,textarea,select")].map((n) => ({
134
+ tag: n.tagName.toLowerCase(),
135
+ label: n.getAttribute("aria-label") ?? "",
136
+ placeholder: n.getAttribute("placeholder") ?? "",
137
+ }))
138
+ : [],
139
+ };
140
+ },
141
+ );
147
142
  const allowedIds = new Set([
148
143
  ...ancestors.map((n) => n.getAttribute("data-iam-node")),
149
144
  ...(scope
@@ -177,7 +172,6 @@ export function tabUiScenes(manifest, tabKey) {
177
172
  transitionKey: result.transitionKey,
178
173
  changedNodeIds: tab.contentNodeIds ?? [],
179
174
  });
180
- sceneDom.window.close();
181
175
  }
182
176
  const edges = [];
183
177
  for (const from of scenes)
@@ -209,6 +203,6 @@ export function tabUiScenes(manifest, tabKey) {
209
203
  }
210
204
  return { tabKey, scenes, edges };
211
205
  } finally {
212
- dom.window.close();
206
+ doc.body.replaceChildren();
213
207
  }
214
208
  }
@@ -1,6 +1,6 @@
1
- import { JSDOM, VirtualConsole } from "jsdom";
1
+ import { JSDOM } from "jsdom";
2
2
  import { documentDom } from "./dom-payload.mjs";
3
- import {documentNodes} from './node-hashes.mjs';
3
+ import {documentStructure} from './node-hashes.mjs';
4
4
  import { createStateMachine } from "./ui-state-machine.mjs";
5
5
  import { applySourceAttributes } from "./source-attributes.mjs";
6
6
  import {
@@ -10,14 +10,27 @@ import {
10
10
 
11
11
  /** Inert source alternatives for UI inspection. This never changes the admitted render or SDK targets. */
12
12
  export function previewUiDocument(manifest, document, options = {}) {
13
+ return inspectUiDocument(manifest, document, options, (doc, preview) => ({
14
+ html: doc.documentElement.outerHTML,
15
+ ...preview,
16
+ }));
17
+ }
18
+
19
+ /** An isolated inert document needs no browsing context, CSSOM or per-scene window. */
20
+ export function inertUiDocument(html) {
21
+ const fragment = JSDOM.fragment(html);
22
+ const doc = fragment.ownerDocument.implementation.createHTMLDocument();
23
+ doc.body.replaceChildren(fragment);
24
+ return doc;
25
+ }
26
+
27
+ /** Inspect an inert preview without serializing and reparsing it. */
28
+ export function inspectUiDocument(manifest, document, options, inspect) {
13
29
  const graph = manifest.routeTree,
14
30
  render = graph.renders.find((r) => r.documentKey === document.documentKey);
15
31
  if (!render) throw Error("Unknown UI preview render");
16
- const dom = new JSDOM(documentDom(document).html, {
17
- virtualConsole: new VirtualConsole(),
18
- });
32
+ const doc = inertUiDocument(documentDom(document).html);
19
33
  try {
20
- const doc = dom.window.document;
21
34
  const nodes = new Map(
22
35
  [...doc.querySelectorAll("[data-iam-node]")].map((n) => [
23
36
  n.getAttribute("data-iam-node"),
@@ -194,17 +207,21 @@ export function previewUiDocument(manifest, document, options = {}) {
194
207
  options.strategy === "content",
195
208
  selections,
196
209
  );
210
+ let mapped;
211
+ const sourceNode = id => {
212
+ mapped ??= new Map(documentStructure(document).map(n => [n.nodeId, n]));
213
+ return mapped.get(id);
214
+ };
197
215
  if(options.strategy==='content') {
198
- const mapped=new Map(documentNodes(document).map(n=>[n.nodeId,n]));
199
216
  const sources=new Map(manifest.elements.map(e=>[e.elementKey,e.source]));
200
217
  for(const field of doc.querySelectorAll('[data-iam-observation="remote"],[data-iam-observation="unobserved"],[data-iam-input-unobserved]')) {
201
218
  const hidden=field.closest('[hidden]');if(hidden&&(!focus||focus.contains(hidden)))continue;
202
- const id=field.getAttribute('data-iam-node'),node=mapped.get(id),source=node&&(node.source??sources.get(node.elementKey));if(source&&!unobserved.some(u=>u.groupKey===id))unobserved.push({groupKey:id,expression:'Data unobserved',source});
219
+ const id=field.getAttribute('data-iam-node'),node=sourceNode(id),source=node&&(node.source??sources.get(node.elementKey));if(source&&!unobserved.some(u=>u.groupKey===id))unobserved.push({groupKey:id,expression:'Data unobserved',source});
203
220
  }
204
221
  }
205
222
  if (focus) {
206
223
  const template=focus.closest('[data-iam-source-template="unobserved"]');
207
- if(template){const id=template.getAttribute('data-iam-node'),node=documentNodes(document).find(n=>n.nodeId===id),source=node&&(node.source??manifest.elements.find(e=>e.elementKey===node.elementKey)?.source);if(source)unobserved.push({groupKey:id,expression:'Source template · collection data unobserved',source});}
224
+ if(template){const id=template.getAttribute('data-iam-node'),node=sourceNode(id),source=node&&(node.source??manifest.elements.find(e=>e.elementKey===node.elementKey)?.source);if(source)unobserved.push({groupKey:id,expression:'Source template · collection data unobserved',source});}
208
225
  }
209
226
  const assumptions = [...groups].flatMap(([key, rows]) => {
210
227
  if (!selections.has(key)) return [];
@@ -225,15 +242,36 @@ export function previewUiDocument(manifest, document, options = {}) {
225
242
  ]
226
243
  : [];
227
244
  });
228
- return {
229
- html: doc.documentElement.outerHTML,
245
+ const regionParents = new Map(regions.flatMap(region => region.options.map(option => [
246
+ option.nodeId, { regionKey: region.regionKey, value: option.value },
247
+ ])));
248
+ const conditionChoices = [...groups].map(([groupKey, rows]) => {
249
+ const row = rows[0], requires = { conditions: [], regions: [] };
250
+ for (let parent = nodes.get(row.truthyNodeId)?.parentElement; parent; parent = parent.parentElement) {
251
+ const id = parent.getAttribute("data-iam-node"), region = regionParents.get(id);
252
+ if (region) requires.regions.unshift(region);
253
+ if (!parent.hasAttribute("data-iam-condition")) continue;
254
+ const truthy = parent.getAttribute("data-iam-alternative") === "true";
255
+ const root = truthy ? parent : parent.previousElementSibling;
256
+ const key = root?.getAttribute("data-iam-node");
257
+ if (groups.has(key) && root.getAttribute("data-iam-condition") === parent.getAttribute("data-iam-condition"))
258
+ requires.conditions.unshift({ groupKey: key, value: truthy });
259
+ }
260
+ return {
261
+ groupKey, expression: row.expression, source: row.source,
262
+ selected: selections.has(groupKey) ? selections.get(groupKey) : null,
263
+ requires,
264
+ };
265
+ });
266
+ return inspect(doc, {
230
267
  assumptions,
268
+ conditionChoices,
231
269
  values,
232
270
  componentChoices,
233
271
  collectionChoices,
234
272
  unobserved,
235
- };
273
+ });
236
274
  } finally {
237
- dom.window.close();
275
+ doc.body.replaceChildren();
238
276
  }
239
277
  }
@@ -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 { randomUUID } from "node:crypto";
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 SIGNED_UPLOAD_ORIGIN = "https://storage.googleapis.com";
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 Error(`IAM upload ${step} failed (${response.status}): ${message}`);
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
- digest,
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 !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(base.hostname))
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) throw new Error("IAM upload needs a manifest for exactly one platform");
67
- const bytes = Buffer.from(serialized);
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 registered = await fetch(new URL("/api/iam/versions", base), {
70
- method: "POST",
71
- headers: { ...authorization, "Content-Type": "application/json" },
72
- body: JSON.stringify({ requestId: randomUUID(), client, platform: platforms[0], label, sha256: digest, bytes: bytes.byteLength }),
73
- signal: AbortSignal.timeout(60_000),
74
- });
75
- if (!registered.ok) throw await failure(registered, "registration");
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
- if (!upload) return { version, uploaded: false };
78
- if (typeof upload.url !== "string" || !upload.url.startsWith(SIGNED_UPLOAD_ORIGIN + "/"))
79
- throw new Error("IAM upload received an unexpected storage address");
80
- const stored = await fetch(upload.url, {
81
- method: "PUT",
82
- headers: upload.headers,
83
- body: bytes,
84
- signal: AbortSignal.timeout(120_000),
85
- });
86
- // 412: an earlier attempt already stored this immutable object.
87
- if (!stored.ok && stored.status !== 412) throw new Error(`IAM upload storage failed (${stored.status})`);
88
- const completed = await fetch(new URL(`/api/iam/versions/${encodeURIComponent(version.id)}/complete`, base), {
89
- method: "POST",
90
- headers: authorization,
91
- signal: AbortSignal.timeout(150_000),
92
- });
93
- if (!completed.ok) throw await failure(completed, "validation");
94
- return { version: (await completed.json()).version, uploaded: true };
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
  }