fundus 0.28.1 → 0.29.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.
Files changed (190) hide show
  1. package/README.md +41 -33
  2. package/build/client/_app/immutable/chunks/{apKHI4vq.js → C0uU_Rhe.js} +2 -2
  3. package/build/client/_app/immutable/chunks/C0uU_Rhe.js.br +0 -0
  4. package/build/client/_app/immutable/chunks/C0uU_Rhe.js.gz +0 -0
  5. package/build/client/_app/immutable/chunks/{Bsj1ssvc.js → CE3cWlK6.js} +1 -1
  6. package/build/client/_app/immutable/chunks/CE3cWlK6.js.br +0 -0
  7. package/build/client/_app/immutable/chunks/CE3cWlK6.js.gz +0 -0
  8. package/build/client/_app/immutable/chunks/{g2rma9Ym.js → CMJ10zbJ.js} +1 -1
  9. package/build/client/_app/immutable/chunks/CMJ10zbJ.js.br +0 -0
  10. package/build/client/_app/immutable/chunks/CMJ10zbJ.js.gz +0 -0
  11. package/build/client/_app/immutable/chunks/{QlQ-jeIO.js → Cq3PJxJG.js} +1 -1
  12. package/build/client/_app/immutable/chunks/Cq3PJxJG.js.br +2 -0
  13. package/build/client/_app/immutable/chunks/Cq3PJxJG.js.gz +0 -0
  14. package/build/client/_app/immutable/chunks/{OL1Dh1oP.js → Wk7WcyQU.js} +1 -1
  15. package/build/client/_app/immutable/chunks/Wk7WcyQU.js.br +0 -0
  16. package/build/client/_app/immutable/chunks/Wk7WcyQU.js.gz +0 -0
  17. package/build/client/_app/immutable/entry/{app.DpZM49_s.js → app.Dit62aNJ.js} +2 -2
  18. package/build/client/_app/immutable/entry/app.Dit62aNJ.js.br +0 -0
  19. package/build/client/_app/immutable/entry/app.Dit62aNJ.js.gz +0 -0
  20. package/build/client/_app/immutable/entry/start.CCBu6ABN.js +1 -0
  21. package/build/client/_app/immutable/entry/start.CCBu6ABN.js.br +0 -0
  22. package/build/client/_app/immutable/entry/start.CCBu6ABN.js.gz +0 -0
  23. package/build/client/_app/immutable/nodes/{0.Wqvn8XD-.js → 0.DnyKXqLr.js} +1 -1
  24. package/build/client/_app/immutable/nodes/0.DnyKXqLr.js.br +0 -0
  25. package/build/client/_app/immutable/nodes/0.DnyKXqLr.js.gz +0 -0
  26. package/build/client/_app/immutable/nodes/{1.Z-UvvLkP.js → 1.Cz-4S6LF.js} +1 -1
  27. package/build/client/_app/immutable/nodes/1.Cz-4S6LF.js.br +0 -0
  28. package/build/client/_app/immutable/nodes/1.Cz-4S6LF.js.gz +0 -0
  29. package/build/client/_app/immutable/nodes/{2.lu89W_qJ.js → 2.C75-hsCY.js} +1 -1
  30. package/build/client/_app/immutable/nodes/2.C75-hsCY.js.br +0 -0
  31. package/build/client/_app/immutable/nodes/2.C75-hsCY.js.gz +0 -0
  32. package/build/client/_app/immutable/nodes/{3.Dt32EAoq.js → 3.4pSddugU.js} +1 -1
  33. package/build/client/_app/immutable/nodes/3.4pSddugU.js.br +0 -0
  34. package/build/client/_app/immutable/nodes/3.4pSddugU.js.gz +0 -0
  35. package/build/client/_app/immutable/nodes/{4.WipzcX8_.js → 4.KoQxJEWQ.js} +1 -1
  36. package/build/client/_app/immutable/nodes/4.KoQxJEWQ.js.br +0 -0
  37. package/build/client/_app/immutable/nodes/4.KoQxJEWQ.js.gz +0 -0
  38. package/build/client/_app/version.json +1 -1
  39. package/build/client/_app/version.json.br +0 -0
  40. package/build/client/_app/version.json.gz +0 -0
  41. package/build/handler.js +7 -7
  42. package/build/index.js +7 -7
  43. package/build/server/chunks/chunks/{context.js-BZSZNuAd.js → context.js-BR2IRl9z.js} +205 -115
  44. package/build/server/chunks/chunks/context.js-BR2IRl9z.js.map +1 -0
  45. package/build/server/chunks/chunks/{contract.js-B4JdXFgm.js → contract.js-DHeIVVv8.js} +46 -12
  46. package/build/server/chunks/chunks/contract.js-DHeIVVv8.js.map +1 -0
  47. package/build/server/chunks/chunks/{core.js-BHxa3VBo.js → core.js-AGKlECJ0.js} +32 -32
  48. package/build/server/chunks/chunks/core.js-AGKlECJ0.js.map +1 -0
  49. package/build/server/chunks/chunks/{exports.js-D0dALD2Q.js → exports.js-CNZgoxR0.js} +2 -2
  50. package/build/server/chunks/chunks/{exports.js-D0dALD2Q.js.map → exports.js-CNZgoxR0.js.map} +1 -1
  51. package/build/server/chunks/chunks/{http.js-DYYKvppy.js → http.js-dk5jbOFm.js} +2 -2
  52. package/build/server/chunks/chunks/{http.js-DYYKvppy.js.map → http.js-dk5jbOFm.js.map} +1 -1
  53. package/build/server/chunks/chunks/{internal.js-CJkB_gzV.js → internal.js-DlRiYJji.js} +3 -3
  54. package/build/server/chunks/chunks/{internal.js-CJkB_gzV.js.map → internal.js-DlRiYJji.js.map} +1 -1
  55. package/build/server/chunks/chunks/internal2.js-BlXt2rfE.js.map +1 -1
  56. package/build/server/chunks/chunks/{manifest-membership.js-BkrPsM_r.js → manifest-membership.js-DkHaPk2F.js} +2 -2
  57. package/build/server/chunks/chunks/{manifest-membership.js-BkrPsM_r.js.map → manifest-membership.js-DkHaPk2F.js.map} +1 -1
  58. package/build/server/chunks/chunks/{raw-reconciliation2.js-DKOpJ8if.js → raw-reconciliation.js-Cco4r8lr.js} +52 -18
  59. package/build/server/chunks/chunks/raw-reconciliation.js-Cco4r8lr.js.map +1 -0
  60. package/build/server/chunks/chunks/raw-reconciliation2.js-CuYSsYTP.js +11 -0
  61. package/build/server/chunks/chunks/raw-reconciliation2.js-CuYSsYTP.js.map +1 -0
  62. package/build/server/chunks/chunks/{shared.js-D3ZDVUlh.js → shared.js-C6qC0jnA.js} +2 -2
  63. package/build/server/chunks/chunks/{shared.js-D3ZDVUlh.js.map → shared.js-C6qC0jnA.js.map} +1 -1
  64. package/build/server/chunks/chunks/{utils.js-CqWW_v-5.js → utils.js-BZ32hXFI.js} +3 -3
  65. package/build/server/chunks/chunks/{utils.js-CqWW_v-5.js.map → utils.js-BZ32hXFI.js.map} +1 -1
  66. package/build/server/chunks/entries/endpoints/api/_...path_/{_server.ts.js-Dw2MW5Tm.js → _server.ts.js-8jGLP2Aj.js} +6 -6
  67. package/build/server/chunks/entries/endpoints/api/_...path_/{_server.ts.js-Dw2MW5Tm.js.map → _server.ts.js-8jGLP2Aj.js.map} +1 -1
  68. package/build/server/chunks/entries/endpoints/api/assets/_id_/{_server.ts.js-GQDuJGug.js → _server.ts.js-BOW9Ejim.js} +7 -7
  69. package/build/server/chunks/entries/endpoints/api/assets/_id_/{_server.ts.js-GQDuJGug.js.map → _server.ts.js-BOW9Ejim.js.map} +1 -1
  70. package/build/server/chunks/entries/endpoints/api/assets/_id_/raw/{_server.ts.js-JbLSv7Xx.js → _server.ts.js-Clac8atJ.js} +7 -7
  71. package/build/server/chunks/entries/endpoints/api/assets/_id_/raw/{_server.ts.js-JbLSv7Xx.js.map → _server.ts.js-Clac8atJ.js.map} +1 -1
  72. package/build/server/chunks/entries/endpoints/api/assets/_id_/reimport/{_server.ts.js-BPae-BYF.js → _server.ts.js-EdHTA-SO.js} +7 -7
  73. package/build/server/chunks/entries/endpoints/api/assets/_id_/reimport/{_server.ts.js-BPae-BYF.js.map → _server.ts.js-EdHTA-SO.js.map} +1 -1
  74. package/build/server/chunks/entries/endpoints/api/assets/_id_/retag/{_server.ts.js-BafrrUxu.js → _server.ts.js-ZBCRgRYi.js} +7 -7
  75. package/build/server/chunks/entries/endpoints/api/assets/_id_/retag/{_server.ts.js-BafrrUxu.js.map → _server.ts.js-ZBCRgRYi.js.map} +1 -1
  76. package/build/server/chunks/entries/endpoints/api/assets/{_server.ts.js-UOlSNEi9.js → _server.ts.js-DY_e5K6d.js} +7 -7
  77. package/build/server/chunks/entries/endpoints/api/assets/{_server.ts.js-UOlSNEi9.js.map → _server.ts.js-DY_e5K6d.js.map} +1 -1
  78. package/build/server/chunks/entries/endpoints/api/assets/delete/{_server.ts.js-QE1vUuAx.js → _server.ts.js-Cl2jxJNi.js} +7 -7
  79. package/build/server/chunks/entries/endpoints/api/assets/delete/{_server.ts.js-QE1vUuAx.js.map → _server.ts.js-Cl2jxJNi.js.map} +1 -1
  80. package/build/server/chunks/entries/endpoints/api/assets/move/{_server.ts.js-DIVfppGt.js → _server.ts.js-DrrZWUbd.js} +7 -7
  81. package/build/server/chunks/entries/endpoints/api/assets/move/{_server.ts.js-DIVfppGt.js.map → _server.ts.js-DrrZWUbd.js.map} +1 -1
  82. package/build/server/chunks/entries/endpoints/api/events/{_server.ts.js-B6R_kqP8.js → _server.ts.js-Bnw0HbEk.js} +7 -7
  83. package/build/server/chunks/entries/endpoints/api/events/{_server.ts.js-B6R_kqP8.js.map → _server.ts.js-Bnw0HbEk.js.map} +1 -1
  84. package/build/server/chunks/entries/endpoints/api/folders/_path_/{_server.ts.js-CertbDsH.js → _server.ts.js-NeVn2ap9.js} +7 -7
  85. package/build/server/chunks/entries/endpoints/api/folders/_path_/{_server.ts.js-CertbDsH.js.map → _server.ts.js-NeVn2ap9.js.map} +1 -1
  86. package/build/server/chunks/entries/endpoints/api/folders/{_server.ts.js-DEIF3Hu2.js → _server.ts.js-ENGDgoLy.js} +7 -7
  87. package/build/server/chunks/entries/endpoints/api/folders/{_server.ts.js-DEIF3Hu2.js.map → _server.ts.js-ENGDgoLy.js.map} +1 -1
  88. package/build/server/chunks/entries/endpoints/api/imports/{_server.ts.js-DNFsNGr4.js → _server.ts.js-BQVGCzBR.js} +7 -7
  89. package/build/server/chunks/entries/endpoints/api/imports/{_server.ts.js-DNFsNGr4.js.map → _server.ts.js-BQVGCzBR.js.map} +1 -1
  90. package/build/server/chunks/entries/endpoints/api/manifests/_name_/{_server.ts.js-DDW8XRPe.js → _server.ts.js-Pxver0Y-.js} +7 -7
  91. package/build/server/chunks/entries/endpoints/api/manifests/_name_/{_server.ts.js-DDW8XRPe.js.map → _server.ts.js-Pxver0Y-.js.map} +1 -1
  92. package/build/server/chunks/entries/endpoints/api/manifests/{_server.ts.js-C222-xli.js → _server.ts.js-DYtiqzJq.js} +7 -7
  93. package/build/server/chunks/entries/endpoints/api/manifests/{_server.ts.js-C222-xli.js.map → _server.ts.js-DYtiqzJq.js.map} +1 -1
  94. package/build/server/chunks/entries/endpoints/api/raw/adopt/{_server.ts.js-CI1P1uyd.js → _server.ts.js-lBgmEZHV.js} +7 -7
  95. package/build/server/chunks/entries/endpoints/api/raw/adopt/{_server.ts.js-CI1P1uyd.js.map → _server.ts.js-lBgmEZHV.js.map} +1 -1
  96. package/build/server/chunks/entries/endpoints/api/raw/edited/accept/{_server.ts.js-DpiUwaSh.js → _server.ts.js-BJHyONFH.js} +7 -7
  97. package/build/server/chunks/entries/endpoints/api/raw/edited/accept/{_server.ts.js-DpiUwaSh.js.map → _server.ts.js-BJHyONFH.js.map} +1 -1
  98. package/build/server/chunks/entries/endpoints/api/raw/edited/dismiss/{_server.ts.js-DT0VPqPT.js → _server.ts.js-Bm5XO80W.js} +7 -7
  99. package/build/server/chunks/entries/endpoints/api/raw/edited/dismiss/{_server.ts.js-DT0VPqPT.js.map → _server.ts.js-Bm5XO80W.js.map} +1 -1
  100. package/build/server/chunks/entries/endpoints/api/reconcile/{_server.ts.js-DBpHM42g.js → _server.ts.js-B7Z20kYf.js} +7 -7
  101. package/build/server/chunks/entries/endpoints/api/reconcile/{_server.ts.js-DBpHM42g.js.map → _server.ts.js-B7Z20kYf.js.map} +1 -1
  102. package/build/server/chunks/entries/endpoints/api/reveal/{_server.ts.js-Cxu5b6Au.js → _server.ts.js-Cg-f37jc.js} +7 -7
  103. package/build/server/chunks/entries/endpoints/api/reveal/{_server.ts.js-Cxu5b6Au.js.map → _server.ts.js-Cg-f37jc.js.map} +1 -1
  104. package/build/server/chunks/entries/endpoints/api/state/{_server.ts.js-W4dhMo8Y.js → _server.ts.js-dIXZiq5l.js} +7 -7
  105. package/build/server/chunks/entries/endpoints/api/state/{_server.ts.js-W4dhMo8Y.js.map → _server.ts.js-dIXZiq5l.js.map} +1 -1
  106. package/build/server/chunks/entries/endpoints/api/usage/{_server.ts.js-DJ_adxbT.js → _server.ts.js-CnTrS5pT.js} +7 -7
  107. package/build/server/chunks/entries/endpoints/api/usage/{_server.ts.js-DJ_adxbT.js.map → _server.ts.js-CnTrS5pT.js.map} +1 -1
  108. package/build/server/chunks/entries/endpoints/files/_...path_/{_server.ts.js-BisoHihD.js → _server.ts.js-B7TcFRuL.js} +6 -6
  109. package/build/server/chunks/entries/endpoints/files/_...path_/{_server.ts.js-BisoHihD.js.map → _server.ts.js-B7TcFRuL.js.map} +1 -1
  110. package/build/server/chunks/entries/endpoints/files/proxy/_...path_/{_server.ts.js-D4jLLuko.js → _server.ts.js-BkUQInqW.js} +7 -7
  111. package/build/server/chunks/entries/endpoints/files/proxy/_...path_/{_server.ts.js-D4jLLuko.js.map → _server.ts.js-BkUQInqW.js.map} +1 -1
  112. package/build/server/chunks/entries/endpoints/files/raw/_...path_/{_server.ts.js-Dfv2ItjT.js → _server.ts.js-CshyvqOq.js} +7 -7
  113. package/build/server/chunks/entries/endpoints/files/raw/_...path_/{_server.ts.js-Dfv2ItjT.js.map → _server.ts.js-CshyvqOq.js.map} +1 -1
  114. package/build/server/chunks/entries/{hooks.server.js-oHJX5INm.js → hooks.server.js-D4DECjz_.js} +8 -7
  115. package/build/server/chunks/entries/hooks.server.js-D4DECjz_.js.map +1 -0
  116. package/build/server/chunks/{handler-BAe0c9yw.js → handler-Cna54zf6.js} +5 -5
  117. package/build/server/chunks/{handler-BAe0c9yw.js.map → handler-Cna54zf6.js.map} +1 -1
  118. package/build/server/chunks/{index.js-DUYD4JB0.js → index.js-BnpHWDec.js} +5 -5
  119. package/build/server/chunks/{index.js-DUYD4JB0.js.map → index.js-BnpHWDec.js.map} +1 -1
  120. package/build/server/chunks/{manifest.js-zwun-T7O.js → manifest.js-9WMEOpxH.js} +31 -31
  121. package/build/server/chunks/{manifest.js-zwun-T7O.js.map → manifest.js-9WMEOpxH.js.map} +1 -1
  122. package/build/server/chunks/nodes/{0.js-BmkXwDRY.js → 0.js-C2b1a672.js} +2 -2
  123. package/build/server/chunks/nodes/{0.js-BmkXwDRY.js.map → 0.js-C2b1a672.js.map} +1 -1
  124. package/build/server/chunks/nodes/1.js-P3Ai28oj.js +7 -0
  125. package/build/server/chunks/nodes/{1.js-xbOwEHCW.js.map → 1.js-P3Ai28oj.js.map} +1 -1
  126. package/build/server/chunks/nodes/{2.js-BuNw1vTZ.js → 2.js-sM3j5BqB.js} +2 -2
  127. package/build/server/chunks/nodes/{2.js-BuNw1vTZ.js.map → 2.js-sM3j5BqB.js.map} +1 -1
  128. package/build/server/chunks/nodes/3.js-G0jV-ymM.js +7 -0
  129. package/build/server/chunks/nodes/{3.js-Dpr_DvNU.js.map → 3.js-G0jV-ymM.js.map} +1 -1
  130. package/build/server/chunks/nodes/4.js-BUTPrI45.js +7 -0
  131. package/build/server/chunks/nodes/{4.js-14Lypd4o.js.map → 4.js-BUTPrI45.js.map} +1 -1
  132. package/dist/cli.js +3 -4
  133. package/dist/core/figma-tag-links.d.ts +66 -0
  134. package/dist/core/figma-tag-links.js +97 -0
  135. package/dist/core/index.d.ts +2 -2
  136. package/dist/core/index.js +1 -1
  137. package/dist/server/cli-commands.d.ts +1 -1
  138. package/dist/server/cli-commands.js +1 -1
  139. package/dist/server/cli-figma-tags.d.ts +1 -1
  140. package/dist/server/cli-figma-tags.js +149 -88
  141. package/dist/server/concurrency.d.ts +12 -0
  142. package/dist/server/concurrency.js +23 -0
  143. package/dist/server/figma-tags.d.ts +72 -24
  144. package/dist/server/figma-tags.js +130 -79
  145. package/dist/server/image-duplicates.js +6 -4
  146. package/dist/server/importers/figma.d.ts +20 -15
  147. package/dist/server/importers/figma.js +172 -92
  148. package/dist/server/operations.d.ts +7 -0
  149. package/dist/server/operations.js +53 -26
  150. package/dist/server/processing/processor.d.ts +29 -1
  151. package/dist/server/processing/processor.js +42 -5
  152. package/dist/server/raw-reconciliation.js +26 -10
  153. package/package.json +1 -1
  154. package/build/client/_app/immutable/chunks/Bsj1ssvc.js.br +0 -0
  155. package/build/client/_app/immutable/chunks/Bsj1ssvc.js.gz +0 -0
  156. package/build/client/_app/immutable/chunks/OL1Dh1oP.js.br +0 -0
  157. package/build/client/_app/immutable/chunks/OL1Dh1oP.js.gz +0 -0
  158. package/build/client/_app/immutable/chunks/QlQ-jeIO.js.br +0 -2
  159. package/build/client/_app/immutable/chunks/QlQ-jeIO.js.gz +0 -0
  160. package/build/client/_app/immutable/chunks/apKHI4vq.js.br +0 -0
  161. package/build/client/_app/immutable/chunks/apKHI4vq.js.gz +0 -0
  162. package/build/client/_app/immutable/chunks/g2rma9Ym.js.br +0 -0
  163. package/build/client/_app/immutable/chunks/g2rma9Ym.js.gz +0 -0
  164. package/build/client/_app/immutable/entry/app.DpZM49_s.js.br +0 -0
  165. package/build/client/_app/immutable/entry/app.DpZM49_s.js.gz +0 -0
  166. package/build/client/_app/immutable/entry/start.B3EMVPEN.js +0 -1
  167. package/build/client/_app/immutable/entry/start.B3EMVPEN.js.br +0 -0
  168. package/build/client/_app/immutable/entry/start.B3EMVPEN.js.gz +0 -0
  169. package/build/client/_app/immutable/nodes/0.Wqvn8XD-.js.br +0 -0
  170. package/build/client/_app/immutable/nodes/0.Wqvn8XD-.js.gz +0 -0
  171. package/build/client/_app/immutable/nodes/1.Z-UvvLkP.js.br +0 -0
  172. package/build/client/_app/immutable/nodes/1.Z-UvvLkP.js.gz +0 -0
  173. package/build/client/_app/immutable/nodes/2.lu89W_qJ.js.br +0 -0
  174. package/build/client/_app/immutable/nodes/2.lu89W_qJ.js.gz +0 -0
  175. package/build/client/_app/immutable/nodes/3.Dt32EAoq.js.br +0 -0
  176. package/build/client/_app/immutable/nodes/3.Dt32EAoq.js.gz +0 -0
  177. package/build/client/_app/immutable/nodes/4.WipzcX8_.js.br +0 -0
  178. package/build/client/_app/immutable/nodes/4.WipzcX8_.js.gz +0 -0
  179. package/build/server/chunks/chunks/context.js-BZSZNuAd.js.map +0 -1
  180. package/build/server/chunks/chunks/contract.js-B4JdXFgm.js.map +0 -1
  181. package/build/server/chunks/chunks/core.js-BHxa3VBo.js.map +0 -1
  182. package/build/server/chunks/chunks/raw-reconciliation.js-DPnNXe0b.js +0 -11
  183. package/build/server/chunks/chunks/raw-reconciliation.js-DPnNXe0b.js.map +0 -1
  184. package/build/server/chunks/chunks/raw-reconciliation2.js-DKOpJ8if.js.map +0 -1
  185. package/build/server/chunks/entries/hooks.server.js-oHJX5INm.js.map +0 -1
  186. package/build/server/chunks/nodes/1.js-xbOwEHCW.js +0 -7
  187. package/build/server/chunks/nodes/3.js-Dpr_DvNU.js +0 -7
  188. package/build/server/chunks/nodes/4.js-14Lypd4o.js +0 -7
  189. package/dist/core/figma-tag-script.d.ts +0 -154
  190. package/dist/core/figma-tag-script.js +0 -248
@@ -1,7 +1,7 @@
1
1
  import { lookup } from 'node:dns/promises';
2
2
  import { isIP } from 'node:net';
3
3
  import sharp from 'sharp';
4
- import { isInstanceSublayerId, isValidAssetId } from '../../core/index.js';
4
+ import { figmaLinksFromRest, figmaTagEntries, fundusTagLink, isValidAssetId } from '../../core/index.js';
5
5
  import { SourceImportError, invalidInput } from './contract.js';
6
6
  import { requestPinnedHttps } from './pinned-https.js';
7
7
  const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
@@ -9,6 +9,9 @@ const MAX_PIXELS = 32_000_000;
9
9
  const MAX_REDIRECTS = 3;
10
10
  const REQUEST_TIMEOUT_MS = 30_000;
11
11
  const PNG_SIGNATURE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]);
12
+ /** Worst case per API call: 3 attempts × 30 s timeout + 2 waits × 10 s = 110 s. */
13
+ const FIGMA_RATE_LIMIT_ATTEMPTS = 3;
14
+ const FIGMA_RATE_LIMIT_MAX_WAIT_SECONDS = 10;
12
15
  function record(value) {
13
16
  return typeof value === 'object' && value !== null && !Array.isArray(value)
14
17
  ? value
@@ -130,17 +133,19 @@ function retryAfter(response) {
130
133
  const seconds = Number(raw);
131
134
  return Number.isSafeInteger(seconds) ? seconds : undefined;
132
135
  }
133
- function upstreamStatusError(response) {
136
+ function upstreamStatusError(response, scope) {
134
137
  if (response.status === 401)
135
138
  return new SourceImportError('unauthorized', 'Figma authentication failed.');
136
139
  if (response.status === 403)
137
- return new SourceImportError('forbidden', 'Figma authentication or access failed. The token may be invalid or expired, or it may not have access to this file.');
140
+ return new SourceImportError('forbidden', scope
141
+ ? `Figma authentication or access failed. The token may be invalid or expired, may lack the ${scope} scope, or may not have access to this file.`
142
+ : 'Figma authentication or access failed. The token may be invalid or expired, or it may not have access to this file.');
138
143
  if (response.status === 404)
139
144
  return new SourceImportError('not-found', 'The Figma file or node was not found.');
140
145
  if (response.status === 429) {
141
146
  return new SourceImportError('rate-limited', 'Figma rate-limited this request.', retryAfter(response));
142
147
  }
143
- return new SourceImportError('upstream-failed', `Figma export failed with status ${response.status}.`);
148
+ return new SourceImportError('upstream-failed', `Figma request failed with status ${response.status}.`);
144
149
  }
145
150
  function isForbiddenIpv4(address) {
146
151
  const parts = address.split('.').map(Number);
@@ -248,22 +253,22 @@ export async function resolveSafeDownloadTarget(value) {
248
253
  export async function assertSafeDownloadUrl(value) {
249
254
  return (await resolveSafeDownloadTarget(value)).url;
250
255
  }
251
- async function fetchWithDeadline(fetcher, url, init, consume) {
256
+ async function fetchWithDeadline(fetcher, url, init, scope) {
252
257
  const controller = new AbortController();
253
258
  const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
254
259
  try {
255
260
  const response = await fetcher(url, { ...init, signal: controller.signal });
256
261
  if (!response.ok)
257
- throw upstreamStatusError(response);
258
- return await consume(response);
262
+ throw upstreamStatusError(response, scope);
263
+ return new Uint8Array(await response.arrayBuffer());
259
264
  }
260
265
  catch (cause) {
261
266
  if (controller.signal.aborted || (cause instanceof Error && cause.name === 'AbortError')) {
262
- throw new SourceImportError('download-timeout', 'Figma export timed out.');
267
+ throw new SourceImportError('download-timeout', 'Figma request timed out.');
263
268
  }
264
269
  if (cause instanceof SourceImportError)
265
270
  throw cause;
266
- throw new SourceImportError('upstream-failed', 'Figma export request failed.');
271
+ throw new SourceImportError('upstream-failed', 'Figma request failed.');
267
272
  }
268
273
  finally {
269
274
  clearTimeout(timeout);
@@ -383,50 +388,40 @@ async function validatePng(bytes) {
383
388
  throw new SourceImportError('image-too-large', 'Figma export exceeds the 32 megapixel limit.');
384
389
  }
385
390
  }
386
- /**
387
- * Every taggable node in a REST document tree (`plugin_data=shared`), in
388
- * document order. Nodes inside instances are skipped: they echo the main
389
- * component node's tag, and Fundus imports only plain `12:34` node ids.
390
- */
391
- export function figmaDocumentNodes(document) {
392
- const nodes = [];
393
- const visit = (value, page) => {
394
- const current = record(value);
395
- if (!current)
396
- return;
397
- const name = typeof current.name === 'string' ? current.name : '';
398
- const pageName = current.type === 'CANVAS' ? name : page;
399
- if (typeof current.id === 'string' &&
400
- current.type !== 'DOCUMENT' &&
401
- current.type !== 'CANVAS' &&
402
- !isInstanceSublayerId(current.id)) {
403
- const shared = record(current.sharedPluginData);
404
- const fundus = shared ? record(shared.fundus) : null;
405
- const tag = fundus && typeof fundus.assetId === 'string' ? fundus.assetId : '';
406
- nodes.push({
407
- nodeId: current.id,
408
- nodeName: name,
409
- page: pageName,
410
- tag: tag === '' ? null : tag
411
- });
391
+ async function fetchFigmaApiBytes(endpoint, init, scope, context) {
392
+ for (let attempt = 1;; attempt++) {
393
+ try {
394
+ return await fetchWithDeadline(context.fetch, endpoint, init, scope);
412
395
  }
413
- if (Array.isArray(current.children)) {
414
- for (const child of current.children)
415
- visit(child, pageName);
396
+ catch (cause) {
397
+ const wait = cause instanceof SourceImportError && cause.code === 'rate-limited'
398
+ ? cause.retryAfterSeconds
399
+ : undefined;
400
+ if (wait === undefined ||
401
+ wait > FIGMA_RATE_LIMIT_MAX_WAIT_SECONDS ||
402
+ attempt >= FIGMA_RATE_LIMIT_ATTEMPTS) {
403
+ throw cause;
404
+ }
405
+ await new Promise((resolve) => setTimeout(resolve, wait * 1000));
416
406
  }
417
- };
418
- visit(record(document), '');
419
- return nodes;
407
+ }
420
408
  }
421
- /** The whole document tree of a Figma file, including every node's shared plugin data. */
422
- export async function fetchFigmaDocument(fileKey, context) {
409
+ async function requestFigmaApi(endpoint, context, request) {
423
410
  const token = context.config.import.figma.token;
424
411
  if (!token) {
425
412
  throw new SourceImportError('not-configured', 'Figma access is not configured.');
426
413
  }
427
- const endpoint = new URL(`https://api.figma.com/v1/files/${encodeURIComponent(fileKey)}`);
428
- endpoint.searchParams.set('plugin_data', 'shared');
429
- const jsonBytes = await fetchWithDeadline(context.fetch, endpoint, { redirect: 'error', headers: { 'X-Figma-Token': token } }, async (response) => new Uint8Array(await response.arrayBuffer()));
414
+ const jsonBytes = await fetchFigmaApiBytes(endpoint, {
415
+ method: request.method ?? 'GET',
416
+ redirect: 'error',
417
+ headers: {
418
+ 'X-Figma-Token': token,
419
+ ...(request.body !== undefined ? { 'Content-Type': 'application/json' } : {})
420
+ },
421
+ ...(request.body !== undefined ? { body: JSON.stringify(request.body) } : {})
422
+ }, request.scope, context);
423
+ if (jsonBytes.byteLength === 0)
424
+ return {};
430
425
  let payload;
431
426
  try {
432
427
  payload = JSON.parse(new TextDecoder().decode(jsonBytes));
@@ -435,56 +430,144 @@ export async function fetchFigmaDocument(fileKey, context) {
435
430
  throw new SourceImportError('invalid-response', 'Figma returned an invalid response.');
436
431
  }
437
432
  const body = record(payload);
438
- if (!body || body.err || (typeof body.status === 'number' && body.status >= 400)) {
433
+ if (!body ||
434
+ body.err ||
435
+ body.error === true ||
436
+ (typeof body.status === 'number' && body.status >= 400)) {
437
+ throw new SourceImportError('invalid-response', request.errorMessage);
438
+ }
439
+ // Figma reports rejected batch entries (e.g. "Url already exists") in `errors` with HTTP 200.
440
+ if (Array.isArray(body.errors) && body.errors.length > 0) {
441
+ const reasons = body.errors.map((entry) => {
442
+ const reason = record(entry)?.error;
443
+ return typeof reason === 'string' ? reason : 'unknown error';
444
+ });
445
+ throw new SourceImportError('upstream-failed', `Figma rejected the request: ${reasons.join('; ')}`);
446
+ }
447
+ return body;
448
+ }
449
+ function figmaFileEndpoint(fileKey, path) {
450
+ return new URL(`https://api.figma.com/v1/files/${encodeURIComponent(fileKey)}${path}`);
451
+ }
452
+ /** Every Fundus tag link in the file (orphaned ones included); each entry has its `linkId`. */
453
+ export async function listFigmaTagEntries(fileKey, context) {
454
+ const body = await requestFigmaApi(figmaFileEndpoint(fileKey, '/dev_resources'), context, {
455
+ scope: 'file_dev_resources:read',
456
+ errorMessage: 'Figma returned an invalid response.'
457
+ });
458
+ const links = figmaLinksFromRest(body);
459
+ if (!links) {
460
+ throw new SourceImportError('invalid-response', 'Figma returned an invalid Dev Resources list.');
461
+ }
462
+ return figmaTagEntries(links).filter((entry) => entry.linkId !== undefined);
463
+ }
464
+ /** The requested nodes that exist; Figma answers deleted or unknown ids with `null` (dropped). */
465
+ export async function fetchFigmaNodeSummaries(fileKey, nodeIds, context) {
466
+ if (nodeIds.length === 0)
467
+ return [];
468
+ const endpoint = figmaFileEndpoint(fileKey, '/nodes');
469
+ endpoint.searchParams.set('ids', nodeIds.join(','));
470
+ endpoint.searchParams.set('depth', '1');
471
+ const body = await requestFigmaApi(endpoint, context, {
472
+ scope: 'file_content:read',
473
+ errorMessage: 'Figma returned an invalid response.'
474
+ });
475
+ const nodes = record(body.nodes);
476
+ if (!nodes)
439
477
  throw new SourceImportError('invalid-response', 'Figma returned an invalid response.');
478
+ const summaries = [];
479
+ for (const nodeId of nodeIds) {
480
+ if (nodes[nodeId] === null)
481
+ continue;
482
+ const document = record(record(nodes[nodeId])?.document);
483
+ if (!document || typeof document.name !== 'string' || typeof document.type !== 'string') {
484
+ throw new SourceImportError('invalid-response', 'Figma did not return the requested node.');
485
+ }
486
+ summaries.push({ nodeId, nodeName: document.name, nodeType: document.type });
440
487
  }
441
- return body.document;
488
+ return summaries;
442
489
  }
443
- export async function resolveTagToNode(fileKey, tag, context) {
444
- const document = await fetchFigmaDocument(fileKey, context);
445
- const matches = figmaDocumentNodes(document).filter((node) => node.tag === tag);
446
- if (matches.length === 0) {
447
- throw new SourceImportError('not-found', `No Figma node is tagged "${tag}" in this file.`);
448
- }
449
- if (matches.length > 1) {
450
- throw new SourceImportError('ambiguous-tag', `More than one Figma node is tagged "${tag}" in this file.`);
451
- }
452
- return matches[0].nodeId;
490
+ export async function createFigmaTagLink(fileKey, nodeId, tag, context) {
491
+ await requestFigmaApi(new URL('https://api.figma.com/v1/dev_resources'), context, {
492
+ scope: 'file_dev_resources:write',
493
+ errorMessage: 'Figma could not create the tag link.',
494
+ method: 'POST',
495
+ body: { dev_resources: [{ ...fundusTagLink(tag), file_key: fileKey, node_id: nodeId }] }
496
+ });
453
497
  }
454
- async function materialize(source, context) {
455
- const token = context.config.import.figma.token;
456
- if (!token) {
457
- throw new SourceImportError('not-configured', 'Figma access is not configured.');
458
- }
459
- const endpoint = new URL(`https://api.figma.com/v1/images/${encodeURIComponent(source.fileKey)}`);
460
- endpoint.searchParams.set('ids', source.nodeId);
461
- endpoint.searchParams.set('format', source.format);
462
- endpoint.searchParams.set('scale', String(source.scale));
463
- const jsonBytes = await fetchWithDeadline(context.fetch, endpoint, { redirect: 'error', headers: { 'X-Figma-Token': token } }, async (response) => new Uint8Array(await response.arrayBuffer()));
464
- let payload;
465
- try {
466
- payload = JSON.parse(new TextDecoder().decode(jsonBytes));
467
- }
468
- catch {
469
- throw new SourceImportError('invalid-response', 'Figma returned an invalid response.');
498
+ export async function deleteFigmaDevResource(fileKey, linkId, context) {
499
+ await requestFigmaApi(figmaFileEndpoint(fileKey, `/dev_resources/${encodeURIComponent(linkId)}`), context, {
500
+ scope: 'file_dev_resources:write',
501
+ errorMessage: 'Figma could not delete the tag link.',
502
+ method: 'DELETE'
503
+ });
504
+ }
505
+ /** Export URLs for the nodes in one `/images` call; `null` for a node Figma cannot render. */
506
+ async function exportImageUrls(fileKey, nodeIds, scale, context) {
507
+ const endpoint = new URL(`https://api.figma.com/v1/images/${encodeURIComponent(fileKey)}`);
508
+ endpoint.searchParams.set('ids', nodeIds.join(','));
509
+ endpoint.searchParams.set('format', 'png');
510
+ endpoint.searchParams.set('scale', String(scale));
511
+ const body = await requestFigmaApi(endpoint, context, {
512
+ scope: 'file_content:read',
513
+ errorMessage: 'Figma could not render this node.'
514
+ });
515
+ const images = record(body.images);
516
+ const urls = {};
517
+ for (const nodeId of nodeIds) {
518
+ if (!images || !Object.hasOwn(images, nodeId)) {
519
+ throw new SourceImportError('invalid-response', 'Figma did not return the requested node.');
520
+ }
521
+ const imageUrl = images[nodeId];
522
+ urls[nodeId] = typeof imageUrl === 'string' && imageUrl.length > 0 ? imageUrl : null;
470
523
  }
471
- const body = record(payload);
472
- if (!body || body.err || (typeof body.status === 'number' && body.status >= 400)) {
473
- throw new SourceImportError('invalid-response', 'Figma could not render this node.');
524
+ return urls;
525
+ }
526
+ /**
527
+ * The one renderable node carrying `tag`. All candidates go into a single
528
+ * `/images` call, which answers deleted nodes with `null`; a live but
529
+ * unrenderable second candidate therefore goes unnoticed.
530
+ */
531
+ export async function resolveTaggedNode(fileKey, tag, scale, context) {
532
+ const candidates = [
533
+ ...new Set((await listFigmaTagEntries(fileKey, context))
534
+ .filter((entry) => entry.tag === tag)
535
+ .map((entry) => entry.nodeId))
536
+ ];
537
+ if (candidates.length === 0) {
538
+ throw new SourceImportError('not-found', `No Figma node is tagged "${tag}" in this file.`);
474
539
  }
475
- const images = record(body.images);
476
- if (!images || !Object.hasOwn(images, source.nodeId)) {
477
- throw new SourceImportError('invalid-response', 'Figma did not return the requested node.');
540
+ const urls = await exportImageUrls(fileKey, candidates, scale, context);
541
+ const live = candidates.flatMap((nodeId) => {
542
+ const imageUrl = urls[nodeId];
543
+ return imageUrl ? [{ nodeId, imageUrl }] : [];
544
+ });
545
+ if (live.length === 0) {
546
+ throw new SourceImportError('not-found', `Every Figma node tagged "${tag}" (${candidates.join(', ')}) is deleted or cannot be rendered.`);
478
547
  }
479
- const imageUrl = images[source.nodeId];
480
- if (typeof imageUrl !== 'string' || imageUrl.length === 0) {
481
- throw new SourceImportError('invalid-response', 'Figma could not render the requested node.');
548
+ if (live.length > 1) {
549
+ throw new SourceImportError('ambiguous-tag', `More than one Figma node is tagged "${tag}" in this file (${live.map((node) => node.nodeId).join(', ')}).`);
482
550
  }
551
+ return live[0];
552
+ }
553
+ async function downloadRaw(source, imageUrl, context) {
483
554
  const bytes = await downloadExport(context.httpsTransport ?? requestPinnedHttps, imageUrl);
484
555
  await validatePng(bytes);
485
556
  const importSource = { importer: 'figma', parameters: source };
486
557
  return { bytes, extension: 'png', importSource };
487
558
  }
559
+ async function importNode(source, context) {
560
+ const urls = await exportImageUrls(source.fileKey, [source.nodeId], source.scale, context);
561
+ const imageUrl = urls[source.nodeId];
562
+ if (!imageUrl) {
563
+ throw new SourceImportError('invalid-response', 'Figma could not render the requested node.');
564
+ }
565
+ return downloadRaw(source, imageUrl, context);
566
+ }
567
+ async function importTag(source, context) {
568
+ const { nodeId, imageUrl } = await resolveTaggedNode(source.fileKey, source.fundusTag, source.scale, context);
569
+ return downloadRaw({ ...source, nodeId }, imageUrl, context);
570
+ }
488
571
  export const figmaSourceImporter = {
489
572
  kind: 'figma',
490
573
  label: 'Figma',
@@ -495,18 +578,16 @@ export const figmaSourceImporter = {
495
578
  if (!fileKey) {
496
579
  throw new SourceImportError('not-configured', 'Figma tag import needs a fileKey or import.figma.defaultFileKey in fundus.config.');
497
580
  }
498
- const nodeId = await resolveTagToNode(fileKey, input.tag, context);
499
- return materialize({
581
+ return importTag({
500
582
  mode: 'tag',
501
583
  fileKey,
502
584
  fundusTag: input.tag,
503
- nodeId,
504
585
  format: 'png',
505
586
  scale: input.scale ?? context.config.import.figma.defaults.scale
506
587
  }, context);
507
588
  }
508
589
  const parsed = parseFigmaSelectionUrl(input.url);
509
- return materialize({
590
+ return importNode({
510
591
  fileKey: parsed.fileKey,
511
592
  nodeId: parsed.nodeId,
512
593
  format: 'png',
@@ -515,10 +596,9 @@ export const figmaSourceImporter = {
515
596
  },
516
597
  validateSourceParameters: validateFigmaSourceParameters,
517
598
  async reimportRaw(source, context) {
518
- if (source.mode === 'tag') {
519
- const nodeId = await resolveTagToNode(source.fileKey, source.fundusTag, context);
520
- return materialize({ ...source, nodeId }, context);
521
- }
522
- return materialize(source, context);
599
+ // The stored nodeId is not consulted: the tag link decides where the tag lives now.
600
+ if (source.mode === 'tag')
601
+ return importTag(source, context);
602
+ return importNode(source, context);
523
603
  }
524
604
  };
@@ -42,6 +42,13 @@ export declare class FundusOperations {
42
42
  openUsageLocation(token: unknown): Promise<void>;
43
43
  /** Read-only state snapshot, serialized with mutations but never processing or writing. */
44
44
  state(): Promise<FundusStateDto>;
45
+ /**
46
+ * Fill the image-fingerprint and proxy-probe caches without scanning raws or
47
+ * joining the operation queue, so an early editor reconcile shares this
48
+ * in-flight work instead of waiting behind it. Both caches are keyed by
49
+ * content, so a concurrent mutation can only make an entry unused.
50
+ */
51
+ warmCaches(): Promise<void>;
45
52
  /** Reconcile only changes that are safe without a user decision: unique moves and deletions. */
46
53
  reconcileRawState(): Promise<FundusStateDto>;
47
54
  /** Adopt an already-existing raw file without copying, renaming, or deleting it. */
@@ -11,7 +11,7 @@ import { assetIdError, allDeliveredAssetIds, canonicalizeParameters, collectAllR
11
11
  import { LibraryStore } from './library-store.js';
12
12
  import { generateManifestModules, locateGeneratedAssetDeclaration, manifestModuleFileName } from './codegen.js';
13
13
  import { descriptorsByType, serverPluginForType, serverPluginsByType } from './plugins.js';
14
- import { processAssets, readCurrentProxy } from './processing/processor.js';
14
+ import { processAssets, ProxyInfoCache, readCurrentProxy } from './processing/processor.js';
15
15
  import { encodeRelativePathForUrl } from './folder-filesystem.js';
16
16
  import { classifyRawDirectory, verifyStableRawFile } from './raw-reconciliation.js';
17
17
  import { rawSafetyIssues } from './raw-safety.js';
@@ -19,6 +19,7 @@ import { serverImporters } from './importers.js';
19
19
  import { SourceImportError } from './importers/contract.js';
20
20
  import { validateFigmaSourceParameters } from './importers/figma.js';
21
21
  import { currentGitBranch, gitAssetChanges } from './git.js';
22
+ import { IO_CONCURRENCY, mapConcurrent } from './concurrency.js';
22
23
  import { FundusUsageAnalyzer, usageSnapshotRevision } from './usage.js';
23
24
  import { ImageDuplicateDetector } from './image-duplicates.js';
24
25
  /**
@@ -71,6 +72,7 @@ export class FundusOperations {
71
72
  #importers;
72
73
  #usageAnalyzer;
73
74
  #duplicateDetector = new ImageDuplicateDetector();
75
+ #proxyInfoCache = new ProxyInfoCache();
74
76
  #operationQueue = Promise.resolve();
75
77
  #pipelineQueue = Promise.resolve();
76
78
  constructor(config, registry = serverPluginsByType, importerRegistry = serverImporters) {
@@ -180,6 +182,20 @@ export class FundusOperations {
180
182
  const classification = await this.#classifyRawDirectory(library);
181
183
  return this.#snapshotState(library, classification);
182
184
  }
185
+ /**
186
+ * Fill the image-fingerprint and proxy-probe caches without scanning raws or
187
+ * joining the operation queue, so an early editor reconcile shares this
188
+ * in-flight work instead of waiting behind it. Both caches are keyed by
189
+ * content, so a concurrent mutation can only make an entry unused.
190
+ */
191
+ async warmCaches() {
192
+ const library = await this.#store.read();
193
+ const delivered = allDeliveredAssetIds(computeLibraryMembership(library, collectAllReferences(library, descriptorsByType(this.#registry))));
194
+ await Promise.all([
195
+ this.#duplicateDetector.find(library, this.#config.rawDir),
196
+ mapConcurrent([...delivered].sort(compareCodeUnits), IO_CONCURRENCY, (assetId) => this.#proxyState(assetId, library.assets[assetId], delivered))
197
+ ]);
198
+ }
183
199
  /** Reconcile only changes that are safe without a user decision: unique moves and deletions. */
184
200
  reconcileRawState() {
185
201
  return this.#enqueueOperation(async () => {
@@ -921,15 +937,42 @@ export class FundusOperations {
921
937
  state: await this.#snapshotState(latestLibrary, latestClassification, pipelineIssues)
922
938
  };
923
939
  }
940
+ async #proxyState(assetId, record, delivered) {
941
+ if (!delivered.has(assetId))
942
+ return { proxy: null };
943
+ try {
944
+ const currentProxy = await readCurrentProxy(assetId, record, this.#config, this.#registry, this.#proxyInfoCache);
945
+ if (!currentProxy) {
946
+ return {
947
+ proxy: null,
948
+ warning: 'Proxy is stale or missing — it will be (re)processed on the next save.'
949
+ };
950
+ }
951
+ return {
952
+ proxy: {
953
+ url: `/files/proxy/${encodeRelativePathForUrl(currentProxy.relativePath)}`,
954
+ upToDate: true,
955
+ ...currentProxy
956
+ }
957
+ };
958
+ }
959
+ catch (cause) {
960
+ return { proxy: null, warning: cause instanceof Error ? cause.message : String(cause) };
961
+ }
962
+ }
924
963
  async #buildState(library, issues, classification) {
925
- const [gitBranch, changedGitAssets] = await Promise.all([
926
- currentGitBranch(this.#config.projectRoot),
927
- gitAssetChanges(this.#config.projectRoot, this.#config.library, library)
928
- ]);
929
964
  const referencesByAsset = collectAllReferences(library, descriptorsByType(this.#registry));
930
- const duplicatesByAsset = await this.#duplicateDetector.find(library, this.#config.rawDir);
931
965
  const membershipByManifest = computeLibraryMembership(library, referencesByAsset);
932
966
  const delivered = allDeliveredAssetIds(membershipByManifest);
967
+ const assetIds = Object.keys(library.assets).sort(compareCodeUnits);
968
+ // Independent I/O-bound reads; run together so large libraries load in parallel.
969
+ const [gitBranch, changedGitAssets, duplicatesByAsset, proxies] = await Promise.all([
970
+ currentGitBranch(this.#config.projectRoot),
971
+ gitAssetChanges(this.#config.projectRoot, this.#config.library, library),
972
+ this.#duplicateDetector.find(library, this.#config.rawDir),
973
+ mapConcurrent(assetIds, IO_CONCURRENCY, (assetId) => this.#proxyState(assetId, library.assets[assetId], delivered))
974
+ ]);
975
+ this.#proxyInfoCache.retain(delivered);
933
976
  // Per asset: the manifests it is a passive member of, with attribution.
934
977
  const passiveManifestsByAsset = new Map();
935
978
  for (const manifestName of Object.keys(library.manifests).sort(compareCodeUnits)) {
@@ -954,7 +997,7 @@ export class FundusOperations {
954
997
  requiredByAsset.set(dependency.id, [...dependency.sources]);
955
998
  }
956
999
  const assets = [];
957
- for (const assetId of Object.keys(library.assets).sort(compareCodeUnits)) {
1000
+ for (const [index, assetId] of assetIds.entries()) {
958
1001
  const record = library.assets[assetId];
959
1002
  const warnings = [];
960
1003
  for (const issue of issues) {
@@ -971,25 +1014,9 @@ export class FundusOperations {
971
1014
  if (record.importSource && !this.#importers.has(record.importSource.importer)) {
972
1015
  warnings.push(`Importer "${record.importSource.importer}" is unavailable; the local raw file remains usable.`);
973
1016
  }
974
- let proxy = null;
975
- if (delivered.has(assetId)) {
976
- try {
977
- const currentProxy = await readCurrentProxy(assetId, record, this.#config, this.#registry);
978
- if (currentProxy) {
979
- proxy = {
980
- url: `/files/proxy/${encodeRelativePathForUrl(currentProxy.relativePath)}`,
981
- upToDate: true,
982
- ...currentProxy
983
- };
984
- }
985
- else {
986
- warnings.push('Proxy is stale or missing — it will be (re)processed on the next save.');
987
- }
988
- }
989
- catch (cause) {
990
- warnings.push(cause instanceof Error ? cause.message : String(cause));
991
- }
992
- }
1017
+ const { proxy, warning: proxyWarning } = proxies[index];
1018
+ if (proxyWarning)
1019
+ warnings.push(proxyWarning);
993
1020
  assets.push({
994
1021
  id: assetId,
995
1022
  type: record.type,
@@ -1,3 +1,19 @@
1
+ /**
2
+ * The processing pipeline: every delivered asset gets exactly one
3
+ * proxy named `<assetId>.<jobHash>.<extension>`. Delivered = explicit or
4
+ * passive member of ≥ 1 manifest (codegen inlines referenced
5
+ * entries, so a passive member's proxy must exist even when the asset is in
6
+ * no manifest itself). Because the name is computable without running the job,
7
+ * staleness is a name check: expected name exists → up to date, else →
8
+ * process. Afterwards, files in the proxy directory that no asset expects are
9
+ * pruned.
10
+ *
11
+ * Type-agnostic: every per-type decision (preset resolution, extension, the
12
+ * sharp/ffmpeg step, proxy probing) lives on the server plugins; this module
13
+ * only consults the registry — which it takes as a parameter, so tests can
14
+ * drive the pipeline with fixture asset types.
15
+ */
16
+ import { type Stats } from 'node:fs';
1
17
  import { type AssetId, type AssetRecord, type Library } from '../../core/index.js';
2
18
  import type { ResolvedFundusConfig } from '../config-loader.js';
3
19
  import type { AssetProxyInfo, ProxyOutputInfo, ServerPluginRegistry } from '../plugins/contract.js';
@@ -35,8 +51,20 @@ export declare function expectedProxyRelativePath(assetId: string, record: Asset
35
51
  * strategy: image-backed proxies via sharp, media proxies via ffprobe.
36
52
  */
37
53
  export declare function readProxyOutputInfo(record: AssetRecord, proxyPath: string, config: ResolvedFundusConfig, registry?: ServerPluginRegistry): Promise<ProxyOutputInfo>;
54
+ /**
55
+ * Probed proxy output info, reused across editor snapshots so unchanged proxies
56
+ * skip sharp and ffprobe. Proxy names are content-addressed, so an entry stays
57
+ * valid while the proxy and the raw source handed to the probe keep their stat
58
+ * identity. In-flight probes are shared, so concurrent readers never probe twice.
59
+ */
60
+ export declare class ProxyInfoCache {
61
+ #private;
62
+ read(assetId: AssetId, proxyPath: string, proxyStat: Stats, rawPath: string, probe: () => Promise<ProxyOutputInfo>): Promise<ProxyOutputInfo>;
63
+ /** Forget assets that no longer exist or are no longer delivered. */
64
+ retain(assetIds: ReadonlySet<AssetId>): void;
65
+ }
38
66
  /** Read one asset's current proxy projection, or null when its expected file is absent. */
39
- export declare function readCurrentProxy(assetId: string, record: AssetRecord, config: ResolvedFundusConfig, registry?: ServerPluginRegistry): Promise<AssetProxyInfo | null>;
67
+ export declare function readCurrentProxy(assetId: string, record: AssetRecord, config: ResolvedFundusConfig, registry?: ServerPluginRegistry, cache?: ProxyInfoCache): Promise<AssetProxyInfo | null>;
40
68
  /**
41
69
  * The delivered set: every explicit or passive member of ≥ 1 manifest —
42
70
  * derived by the shared membership computation. Assets outside
@@ -65,17 +65,54 @@ export function expectedProxyRelativePath(assetId, record, config, registry = se
65
65
  export async function readProxyOutputInfo(record, proxyPath, config, registry = serverPluginsByType) {
66
66
  return serverPluginForType(record.type, registry).readProxyInfo(proxyPath, config, join(config.rawDir, record.raw));
67
67
  }
68
+ function statIdentity(value) {
69
+ return `${value.dev}:${value.ino}:${value.size}:${value.mtimeMs}:${value.ctimeMs}`;
70
+ }
71
+ /**
72
+ * Probed proxy output info, reused across editor snapshots so unchanged proxies
73
+ * skip sharp and ffprobe. Proxy names are content-addressed, so an entry stays
74
+ * valid while the proxy and the raw source handed to the probe keep their stat
75
+ * identity. In-flight probes are shared, so concurrent readers never probe twice.
76
+ */
77
+ export class ProxyInfoCache {
78
+ #entries = new Map();
79
+ async read(assetId, proxyPath, proxyStat, rawPath, probe) {
80
+ const rawIdentity = await stat(rawPath).then(statIdentity, () => 'missing');
81
+ const identity = `${proxyPath}|${statIdentity(proxyStat)}|${rawIdentity}`;
82
+ const cached = this.#entries.get(assetId);
83
+ if (cached?.identity === identity)
84
+ return cached.info;
85
+ const entry = { identity, info: probe() };
86
+ this.#entries.set(assetId, entry);
87
+ try {
88
+ return await entry.info;
89
+ }
90
+ catch (cause) {
91
+ if (this.#entries.get(assetId) === entry)
92
+ this.#entries.delete(assetId);
93
+ throw cause;
94
+ }
95
+ }
96
+ /** Forget assets that no longer exist or are no longer delivered. */
97
+ retain(assetIds) {
98
+ for (const assetId of this.#entries.keys()) {
99
+ if (!assetIds.has(assetId))
100
+ this.#entries.delete(assetId);
101
+ }
102
+ }
103
+ }
68
104
  /** Read one asset's current proxy projection, or null when its expected file is absent. */
69
- export async function readCurrentProxy(assetId, record, config, registry = serverPluginsByType) {
105
+ export async function readCurrentProxy(assetId, record, config, registry = serverPluginsByType, cache) {
70
106
  const fileName = expectedProxyFileName(assetId, record, config, registry);
71
107
  const relativePath = expectedProxyRelativePath(assetId, record, config, registry);
72
108
  const proxyPath = join(config.proxyDir, relativePath);
73
109
  if (!existsSync(proxyPath))
74
110
  return null;
75
- const [outputInfo, proxyStat] = await Promise.all([
76
- readProxyOutputInfo(record, proxyPath, config, registry),
77
- stat(proxyPath)
78
- ]);
111
+ const proxyStat = await stat(proxyPath);
112
+ const probe = () => readProxyOutputInfo(record, proxyPath, config, registry);
113
+ const outputInfo = cache
114
+ ? await cache.read(assetId, proxyPath, proxyStat, join(config.rawDir, record.raw), probe)
115
+ : await probe();
79
116
  return { assetId, fileName, relativePath, ...outputInfo, fileSize: proxyStat.size };
80
117
  }
81
118
  /**