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.
- package/README.md +41 -33
- package/build/client/_app/immutable/chunks/{apKHI4vq.js → C0uU_Rhe.js} +2 -2
- package/build/client/_app/immutable/chunks/C0uU_Rhe.js.br +0 -0
- package/build/client/_app/immutable/chunks/C0uU_Rhe.js.gz +0 -0
- package/build/client/_app/immutable/chunks/{Bsj1ssvc.js → CE3cWlK6.js} +1 -1
- package/build/client/_app/immutable/chunks/CE3cWlK6.js.br +0 -0
- package/build/client/_app/immutable/chunks/CE3cWlK6.js.gz +0 -0
- package/build/client/_app/immutable/chunks/{g2rma9Ym.js → CMJ10zbJ.js} +1 -1
- package/build/client/_app/immutable/chunks/CMJ10zbJ.js.br +0 -0
- package/build/client/_app/immutable/chunks/CMJ10zbJ.js.gz +0 -0
- package/build/client/_app/immutable/chunks/{QlQ-jeIO.js → Cq3PJxJG.js} +1 -1
- package/build/client/_app/immutable/chunks/Cq3PJxJG.js.br +2 -0
- package/build/client/_app/immutable/chunks/Cq3PJxJG.js.gz +0 -0
- package/build/client/_app/immutable/chunks/{OL1Dh1oP.js → Wk7WcyQU.js} +1 -1
- package/build/client/_app/immutable/chunks/Wk7WcyQU.js.br +0 -0
- package/build/client/_app/immutable/chunks/Wk7WcyQU.js.gz +0 -0
- package/build/client/_app/immutable/entry/{app.DpZM49_s.js → app.Dit62aNJ.js} +2 -2
- package/build/client/_app/immutable/entry/app.Dit62aNJ.js.br +0 -0
- package/build/client/_app/immutable/entry/app.Dit62aNJ.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.CCBu6ABN.js +1 -0
- package/build/client/_app/immutable/entry/start.CCBu6ABN.js.br +0 -0
- package/build/client/_app/immutable/entry/start.CCBu6ABN.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{0.Wqvn8XD-.js → 0.DnyKXqLr.js} +1 -1
- package/build/client/_app/immutable/nodes/0.DnyKXqLr.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.DnyKXqLr.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{1.Z-UvvLkP.js → 1.Cz-4S6LF.js} +1 -1
- package/build/client/_app/immutable/nodes/1.Cz-4S6LF.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.Cz-4S6LF.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{2.lu89W_qJ.js → 2.C75-hsCY.js} +1 -1
- package/build/client/_app/immutable/nodes/2.C75-hsCY.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.C75-hsCY.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{3.Dt32EAoq.js → 3.4pSddugU.js} +1 -1
- package/build/client/_app/immutable/nodes/3.4pSddugU.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.4pSddugU.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{4.WipzcX8_.js → 4.KoQxJEWQ.js} +1 -1
- package/build/client/_app/immutable/nodes/4.KoQxJEWQ.js.br +0 -0
- package/build/client/_app/immutable/nodes/4.KoQxJEWQ.js.gz +0 -0
- package/build/client/_app/version.json +1 -1
- package/build/client/_app/version.json.br +0 -0
- package/build/client/_app/version.json.gz +0 -0
- package/build/handler.js +7 -7
- package/build/index.js +7 -7
- package/build/server/chunks/chunks/{context.js-BZSZNuAd.js → context.js-BR2IRl9z.js} +205 -115
- package/build/server/chunks/chunks/context.js-BR2IRl9z.js.map +1 -0
- package/build/server/chunks/chunks/{contract.js-B4JdXFgm.js → contract.js-DHeIVVv8.js} +46 -12
- package/build/server/chunks/chunks/contract.js-DHeIVVv8.js.map +1 -0
- package/build/server/chunks/chunks/{core.js-BHxa3VBo.js → core.js-AGKlECJ0.js} +32 -32
- package/build/server/chunks/chunks/core.js-AGKlECJ0.js.map +1 -0
- package/build/server/chunks/chunks/{exports.js-D0dALD2Q.js → exports.js-CNZgoxR0.js} +2 -2
- package/build/server/chunks/chunks/{exports.js-D0dALD2Q.js.map → exports.js-CNZgoxR0.js.map} +1 -1
- package/build/server/chunks/chunks/{http.js-DYYKvppy.js → http.js-dk5jbOFm.js} +2 -2
- package/build/server/chunks/chunks/{http.js-DYYKvppy.js.map → http.js-dk5jbOFm.js.map} +1 -1
- package/build/server/chunks/chunks/{internal.js-CJkB_gzV.js → internal.js-DlRiYJji.js} +3 -3
- package/build/server/chunks/chunks/{internal.js-CJkB_gzV.js.map → internal.js-DlRiYJji.js.map} +1 -1
- package/build/server/chunks/chunks/internal2.js-BlXt2rfE.js.map +1 -1
- package/build/server/chunks/chunks/{manifest-membership.js-BkrPsM_r.js → manifest-membership.js-DkHaPk2F.js} +2 -2
- package/build/server/chunks/chunks/{manifest-membership.js-BkrPsM_r.js.map → manifest-membership.js-DkHaPk2F.js.map} +1 -1
- package/build/server/chunks/chunks/{raw-reconciliation2.js-DKOpJ8if.js → raw-reconciliation.js-Cco4r8lr.js} +52 -18
- package/build/server/chunks/chunks/raw-reconciliation.js-Cco4r8lr.js.map +1 -0
- package/build/server/chunks/chunks/raw-reconciliation2.js-CuYSsYTP.js +11 -0
- package/build/server/chunks/chunks/raw-reconciliation2.js-CuYSsYTP.js.map +1 -0
- package/build/server/chunks/chunks/{shared.js-D3ZDVUlh.js → shared.js-C6qC0jnA.js} +2 -2
- package/build/server/chunks/chunks/{shared.js-D3ZDVUlh.js.map → shared.js-C6qC0jnA.js.map} +1 -1
- package/build/server/chunks/chunks/{utils.js-CqWW_v-5.js → utils.js-BZ32hXFI.js} +3 -3
- package/build/server/chunks/chunks/{utils.js-CqWW_v-5.js.map → utils.js-BZ32hXFI.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/_...path_/{_server.ts.js-Dw2MW5Tm.js → _server.ts.js-8jGLP2Aj.js} +6 -6
- package/build/server/chunks/entries/endpoints/api/_...path_/{_server.ts.js-Dw2MW5Tm.js.map → _server.ts.js-8jGLP2Aj.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/assets/_id_/{_server.ts.js-GQDuJGug.js → _server.ts.js-BOW9Ejim.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/assets/_id_/{_server.ts.js-GQDuJGug.js.map → _server.ts.js-BOW9Ejim.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/assets/_id_/raw/{_server.ts.js-JbLSv7Xx.js → _server.ts.js-Clac8atJ.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/assets/_id_/raw/{_server.ts.js-JbLSv7Xx.js.map → _server.ts.js-Clac8atJ.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/assets/_id_/reimport/{_server.ts.js-BPae-BYF.js → _server.ts.js-EdHTA-SO.js} +7 -7
- 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
- package/build/server/chunks/entries/endpoints/api/assets/_id_/retag/{_server.ts.js-BafrrUxu.js → _server.ts.js-ZBCRgRYi.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/assets/_id_/retag/{_server.ts.js-BafrrUxu.js.map → _server.ts.js-ZBCRgRYi.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/assets/{_server.ts.js-UOlSNEi9.js → _server.ts.js-DY_e5K6d.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/assets/{_server.ts.js-UOlSNEi9.js.map → _server.ts.js-DY_e5K6d.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/assets/delete/{_server.ts.js-QE1vUuAx.js → _server.ts.js-Cl2jxJNi.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/assets/delete/{_server.ts.js-QE1vUuAx.js.map → _server.ts.js-Cl2jxJNi.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/assets/move/{_server.ts.js-DIVfppGt.js → _server.ts.js-DrrZWUbd.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/assets/move/{_server.ts.js-DIVfppGt.js.map → _server.ts.js-DrrZWUbd.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/events/{_server.ts.js-B6R_kqP8.js → _server.ts.js-Bnw0HbEk.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/events/{_server.ts.js-B6R_kqP8.js.map → _server.ts.js-Bnw0HbEk.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/folders/_path_/{_server.ts.js-CertbDsH.js → _server.ts.js-NeVn2ap9.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/folders/_path_/{_server.ts.js-CertbDsH.js.map → _server.ts.js-NeVn2ap9.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/folders/{_server.ts.js-DEIF3Hu2.js → _server.ts.js-ENGDgoLy.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/folders/{_server.ts.js-DEIF3Hu2.js.map → _server.ts.js-ENGDgoLy.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/imports/{_server.ts.js-DNFsNGr4.js → _server.ts.js-BQVGCzBR.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/imports/{_server.ts.js-DNFsNGr4.js.map → _server.ts.js-BQVGCzBR.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/manifests/_name_/{_server.ts.js-DDW8XRPe.js → _server.ts.js-Pxver0Y-.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/manifests/_name_/{_server.ts.js-DDW8XRPe.js.map → _server.ts.js-Pxver0Y-.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/manifests/{_server.ts.js-C222-xli.js → _server.ts.js-DYtiqzJq.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/manifests/{_server.ts.js-C222-xli.js.map → _server.ts.js-DYtiqzJq.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/raw/adopt/{_server.ts.js-CI1P1uyd.js → _server.ts.js-lBgmEZHV.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/raw/adopt/{_server.ts.js-CI1P1uyd.js.map → _server.ts.js-lBgmEZHV.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/raw/edited/accept/{_server.ts.js-DpiUwaSh.js → _server.ts.js-BJHyONFH.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/raw/edited/accept/{_server.ts.js-DpiUwaSh.js.map → _server.ts.js-BJHyONFH.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/raw/edited/dismiss/{_server.ts.js-DT0VPqPT.js → _server.ts.js-Bm5XO80W.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/raw/edited/dismiss/{_server.ts.js-DT0VPqPT.js.map → _server.ts.js-Bm5XO80W.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/reconcile/{_server.ts.js-DBpHM42g.js → _server.ts.js-B7Z20kYf.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/reconcile/{_server.ts.js-DBpHM42g.js.map → _server.ts.js-B7Z20kYf.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/reveal/{_server.ts.js-Cxu5b6Au.js → _server.ts.js-Cg-f37jc.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/reveal/{_server.ts.js-Cxu5b6Au.js.map → _server.ts.js-Cg-f37jc.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/state/{_server.ts.js-W4dhMo8Y.js → _server.ts.js-dIXZiq5l.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/state/{_server.ts.js-W4dhMo8Y.js.map → _server.ts.js-dIXZiq5l.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/api/usage/{_server.ts.js-DJ_adxbT.js → _server.ts.js-CnTrS5pT.js} +7 -7
- package/build/server/chunks/entries/endpoints/api/usage/{_server.ts.js-DJ_adxbT.js.map → _server.ts.js-CnTrS5pT.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/files/_...path_/{_server.ts.js-BisoHihD.js → _server.ts.js-B7TcFRuL.js} +6 -6
- package/build/server/chunks/entries/endpoints/files/_...path_/{_server.ts.js-BisoHihD.js.map → _server.ts.js-B7TcFRuL.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/files/proxy/_...path_/{_server.ts.js-D4jLLuko.js → _server.ts.js-BkUQInqW.js} +7 -7
- package/build/server/chunks/entries/endpoints/files/proxy/_...path_/{_server.ts.js-D4jLLuko.js.map → _server.ts.js-BkUQInqW.js.map} +1 -1
- package/build/server/chunks/entries/endpoints/files/raw/_...path_/{_server.ts.js-Dfv2ItjT.js → _server.ts.js-CshyvqOq.js} +7 -7
- package/build/server/chunks/entries/endpoints/files/raw/_...path_/{_server.ts.js-Dfv2ItjT.js.map → _server.ts.js-CshyvqOq.js.map} +1 -1
- package/build/server/chunks/entries/{hooks.server.js-oHJX5INm.js → hooks.server.js-D4DECjz_.js} +8 -7
- package/build/server/chunks/entries/hooks.server.js-D4DECjz_.js.map +1 -0
- package/build/server/chunks/{handler-BAe0c9yw.js → handler-Cna54zf6.js} +5 -5
- package/build/server/chunks/{handler-BAe0c9yw.js.map → handler-Cna54zf6.js.map} +1 -1
- package/build/server/chunks/{index.js-DUYD4JB0.js → index.js-BnpHWDec.js} +5 -5
- package/build/server/chunks/{index.js-DUYD4JB0.js.map → index.js-BnpHWDec.js.map} +1 -1
- package/build/server/chunks/{manifest.js-zwun-T7O.js → manifest.js-9WMEOpxH.js} +31 -31
- package/build/server/chunks/{manifest.js-zwun-T7O.js.map → manifest.js-9WMEOpxH.js.map} +1 -1
- package/build/server/chunks/nodes/{0.js-BmkXwDRY.js → 0.js-C2b1a672.js} +2 -2
- package/build/server/chunks/nodes/{0.js-BmkXwDRY.js.map → 0.js-C2b1a672.js.map} +1 -1
- package/build/server/chunks/nodes/1.js-P3Ai28oj.js +7 -0
- package/build/server/chunks/nodes/{1.js-xbOwEHCW.js.map → 1.js-P3Ai28oj.js.map} +1 -1
- package/build/server/chunks/nodes/{2.js-BuNw1vTZ.js → 2.js-sM3j5BqB.js} +2 -2
- package/build/server/chunks/nodes/{2.js-BuNw1vTZ.js.map → 2.js-sM3j5BqB.js.map} +1 -1
- package/build/server/chunks/nodes/3.js-G0jV-ymM.js +7 -0
- package/build/server/chunks/nodes/{3.js-Dpr_DvNU.js.map → 3.js-G0jV-ymM.js.map} +1 -1
- package/build/server/chunks/nodes/4.js-BUTPrI45.js +7 -0
- package/build/server/chunks/nodes/{4.js-14Lypd4o.js.map → 4.js-BUTPrI45.js.map} +1 -1
- package/dist/cli.js +3 -4
- package/dist/core/figma-tag-links.d.ts +66 -0
- package/dist/core/figma-tag-links.js +97 -0
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.js +1 -1
- package/dist/server/cli-commands.d.ts +1 -1
- package/dist/server/cli-commands.js +1 -1
- package/dist/server/cli-figma-tags.d.ts +1 -1
- package/dist/server/cli-figma-tags.js +149 -88
- package/dist/server/concurrency.d.ts +12 -0
- package/dist/server/concurrency.js +23 -0
- package/dist/server/figma-tags.d.ts +72 -24
- package/dist/server/figma-tags.js +130 -79
- package/dist/server/image-duplicates.js +6 -4
- package/dist/server/importers/figma.d.ts +20 -15
- package/dist/server/importers/figma.js +172 -92
- package/dist/server/operations.d.ts +7 -0
- package/dist/server/operations.js +53 -26
- package/dist/server/processing/processor.d.ts +29 -1
- package/dist/server/processing/processor.js +42 -5
- package/dist/server/raw-reconciliation.js +26 -10
- package/package.json +1 -1
- package/build/client/_app/immutable/chunks/Bsj1ssvc.js.br +0 -0
- package/build/client/_app/immutable/chunks/Bsj1ssvc.js.gz +0 -0
- package/build/client/_app/immutable/chunks/OL1Dh1oP.js.br +0 -0
- package/build/client/_app/immutable/chunks/OL1Dh1oP.js.gz +0 -0
- package/build/client/_app/immutable/chunks/QlQ-jeIO.js.br +0 -2
- package/build/client/_app/immutable/chunks/QlQ-jeIO.js.gz +0 -0
- package/build/client/_app/immutable/chunks/apKHI4vq.js.br +0 -0
- package/build/client/_app/immutable/chunks/apKHI4vq.js.gz +0 -0
- package/build/client/_app/immutable/chunks/g2rma9Ym.js.br +0 -0
- package/build/client/_app/immutable/chunks/g2rma9Ym.js.gz +0 -0
- package/build/client/_app/immutable/entry/app.DpZM49_s.js.br +0 -0
- package/build/client/_app/immutable/entry/app.DpZM49_s.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.B3EMVPEN.js +0 -1
- package/build/client/_app/immutable/entry/start.B3EMVPEN.js.br +0 -0
- package/build/client/_app/immutable/entry/start.B3EMVPEN.js.gz +0 -0
- package/build/client/_app/immutable/nodes/0.Wqvn8XD-.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.Wqvn8XD-.js.gz +0 -0
- package/build/client/_app/immutable/nodes/1.Z-UvvLkP.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.Z-UvvLkP.js.gz +0 -0
- package/build/client/_app/immutable/nodes/2.lu89W_qJ.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.lu89W_qJ.js.gz +0 -0
- package/build/client/_app/immutable/nodes/3.Dt32EAoq.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.Dt32EAoq.js.gz +0 -0
- package/build/client/_app/immutable/nodes/4.WipzcX8_.js.br +0 -0
- package/build/client/_app/immutable/nodes/4.WipzcX8_.js.gz +0 -0
- package/build/server/chunks/chunks/context.js-BZSZNuAd.js.map +0 -1
- package/build/server/chunks/chunks/contract.js-B4JdXFgm.js.map +0 -1
- package/build/server/chunks/chunks/core.js-BHxa3VBo.js.map +0 -1
- package/build/server/chunks/chunks/raw-reconciliation.js-DPnNXe0b.js +0 -11
- package/build/server/chunks/chunks/raw-reconciliation.js-DPnNXe0b.js.map +0 -1
- package/build/server/chunks/chunks/raw-reconciliation2.js-DKOpJ8if.js.map +0 -1
- package/build/server/chunks/entries/hooks.server.js-oHJX5INm.js.map +0 -1
- package/build/server/chunks/nodes/1.js-xbOwEHCW.js +0 -7
- package/build/server/chunks/nodes/3.js-Dpr_DvNU.js +0 -7
- package/build/server/chunks/nodes/4.js-14Lypd4o.js +0 -7
- package/dist/core/figma-tag-script.d.ts +0 -154
- 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 {
|
|
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',
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
|
428
|
-
|
|
429
|
-
|
|
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 ||
|
|
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
|
|
488
|
+
return summaries;
|
|
442
489
|
}
|
|
443
|
-
export async function
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
return
|
|
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
|
|
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
|
-
|
|
975
|
-
if (
|
|
976
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
/**
|