sliccy 6.114.0 → 6.115.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/dist/node-server/hostfs.d.ts +57 -1
  2. package/dist/node-server/hostfs.js +245 -20
  3. package/dist/ui/.vite/manifest.json +168 -168
  4. package/dist/ui/assets/SKILL-DJAvKejL.js +1 -0
  5. package/dist/ui/assets/{account-store-DUgD_kvO.js → account-store-C76tqAhk.js} +1 -1
  6. package/dist/ui/assets/{account-store-yJyOKARk.js → account-store-De9BVnZg.js} +1 -1
  7. package/dist/ui/assets/{adobe-DYYPM6b4.js → adobe-CVSIyirw.js} +1 -1
  8. package/dist/ui/assets/{adobe-BNv_hEyr.js → adobe-Dnw_s_1W.js} +1 -1
  9. package/dist/ui/assets/{agent-bridge-DcQKwvad.js → agent-bridge-DNXEF-CF.js} +1 -1
  10. package/dist/ui/assets/{agent-panels-DTJG_NAA.js → agent-panels-BfdWU9wd.js} +1 -1
  11. package/dist/ui/assets/{apply-layout-BhSo_KsU.js → apply-layout-BY2k_QOi.js} +1 -1
  12. package/dist/ui/assets/{apps-CCBn8rOP.js → apps-C2mla_tP.js} +1 -1
  13. package/dist/ui/assets/{auto-mount-table-DnB3cBQZ.js → auto-mount-table-CU4bEF4U.js} +1 -1
  14. package/dist/ui/assets/{azure-openai-BO3ItIMu.js → azure-openai-CHibmja0.js} +1 -1
  15. package/dist/ui/assets/{azure-openai-Ce4kSb-i.js → azure-openai-CbozGsE6.js} +1 -1
  16. package/dist/ui/assets/backend-hostfs-BasSBAje.js +1 -0
  17. package/dist/ui/assets/backend-hostfs-Cp--BRHS.js +1 -0
  18. package/dist/ui/assets/{cdp-CkdbE1bi.js → cdp-CzH791qX.js} +3 -1
  19. package/dist/ui/assets/{cerebras-BddMD78H.js → cerebras-CbAinbX-.js} +1 -1
  20. package/dist/ui/assets/{cerebras-C82ZeHXj.js → cerebras-DF94_YMV.js} +1 -1
  21. package/dist/ui/assets/{connect-surface-t8Lw2jy-.js → connect-surface-6Tf9VRLg.js} +1 -1
  22. package/dist/ui/assets/{directed-approval-Bbr4Intc.js → directed-approval-Bes-d6NV.js} +1 -1
  23. package/dist/ui/assets/fs-B-2A9-Hh.js +1 -0
  24. package/dist/ui/assets/fs-CQUmIZZr.js +2 -0
  25. package/dist/ui/assets/github-BIOil5nW.js +1 -0
  26. package/dist/ui/assets/{github-B55mT0Ll.js → github-D4j3Unvm.js} +2 -2
  27. package/dist/ui/assets/{github-copilot-CcV7jqzZ.js → github-copilot-Dl7CD601.js} +1 -1
  28. package/dist/ui/assets/{github-copilot-YpT6Fjog.js → github-copilot-DlXfI4qg.js} +1 -1
  29. package/dist/ui/assets/{github-install-DPp-NeSW.js → github-install-CLUb1jI1.js} +1 -1
  30. package/dist/ui/assets/{github-zip-ClyU0Jlk.js → github-zip-U2Shc3VA.js} +1 -1
  31. package/dist/ui/assets/help-DXkaM0Ek.js +156 -0
  32. package/dist/ui/assets/{js-realm-worker-Cn4F1hWw.js → js-realm-worker-D_qCD94a.js} +1 -1
  33. package/dist/ui/assets/kernel-worker-CcoBTnWU.js +4970 -0
  34. package/dist/ui/assets/{legacy-styles-5GUwx9VK.js → legacy-styles-B3nS5QUo.js} +1 -1
  35. package/dist/ui/assets/{lick-ws-bridge-CBO4U0Rd.js → lick-ws-bridge-BUAhV4Ba.js} +1 -1
  36. package/dist/ui/assets/{local-llm-D-FjD_j4.js → local-llm-B2VRmaJG.js} +1 -1
  37. package/dist/ui/assets/{local-llm-rKNvMMNG.js → local-llm-o8WLwZ7B.js} +1 -1
  38. package/dist/ui/assets/{main-Cc8jHrkd.js → main-CZNHEKLS.js} +3 -3
  39. package/dist/ui/assets/main-cherry-6y-bnTnc.js +1 -0
  40. package/dist/ui/assets/{mount-BWhViGlH.js → mount-BVIBh0ue.js} +3 -3
  41. package/dist/ui/assets/{mount-CYFFDG7S.js → mount-bmglG5RM.js} +1 -1
  42. package/dist/ui/assets/{mount-recovery-Be7zNvwm.js → mount-recovery--CI8QLRE.js} +1 -1
  43. package/dist/ui/assets/{new-session-B6iYJrWp.js → new-session-Drh7lD4Z.js} +2 -2
  44. package/dist/ui/assets/{oauth-bootstrap-Bdttw_Li.js → oauth-bootstrap-B9vcZlm6.js} +2 -2
  45. package/dist/ui/assets/{openai-codex-CyrMg927.js → openai-codex-BoHImVoD.js} +1 -1
  46. package/dist/ui/assets/{openai-codex-Dzs1IuJE.js → openai-codex-DTrZ2t4u.js} +1 -1
  47. package/dist/ui/assets/{openrouter-BhJTdm57.js → openrouter-DgHvG-PL.js} +1 -1
  48. package/dist/ui/assets/{openrouter-oauth-D4H55xM1.js → openrouter-oauth-CVlhkZ2U.js} +1 -1
  49. package/dist/ui/assets/{openrouter-oauth-nv7J-nsg.js → openrouter-oauth-CePfynCi.js} +1 -1
  50. package/dist/ui/assets/{openrouter-rRPnBNPQ.js → openrouter-uWtCD-lL.js} +1 -1
  51. package/dist/ui/assets/{panel-rpc-handlers-DGQBTjqD.js → panel-rpc-handlers-BG13NFel.js} +2 -2
  52. package/dist/ui/assets/{panelize-shell-CVZHkIQ0.js → panelize-shell-DT_q04ws.js} +1 -1
  53. package/dist/ui/assets/{provider-BZAqAZqU.js → provider-CPwea4mZ.js} +1 -1
  54. package/dist/ui/assets/{provider-cNAsxrn1.js → provider-CXpXmHET.js} +2 -2
  55. package/dist/ui/assets/{provider-settings-BsZnNNiB.js → provider-settings-c1_8fxRb.js} +1 -1
  56. package/dist/ui/assets/{provider-settings-D1hjeXwI.js → provider-settings-g595uT_R.js} +1 -1
  57. package/dist/ui/assets/provider-store-access-BagBeRQS.js +1 -0
  58. package/dist/ui/assets/{provider-store-access-Crj0fH1T.js → provider-store-access-urlSkaF2.js} +2 -2
  59. package/dist/ui/assets/{providers-CbmErjR5.js → providers-BlezQGEl.js} +2 -2
  60. package/dist/ui/assets/{providers-rbKM1j5b.js → providers-BmNaegCb.js} +1 -1
  61. package/dist/ui/assets/{quick-llm-o5qc8h7j.js → quick-llm-C6OXJ_cx.js} +1 -1
  62. package/dist/ui/assets/realm-browser-fetch-V3uggGbM.js +1 -0
  63. package/dist/ui/assets/{recovery-screen-BKhXCBSW.js → recovery-screen-CI2-Oa5I.js} +1 -1
  64. package/dist/ui/assets/run-0shPd-OO.js +81 -0
  65. package/dist/ui/assets/{run-B2H2TPPO.js → run-CK2keBOh.js} +2 -2
  66. package/dist/ui/assets/secret-env-D8gWM-DI.js +1 -0
  67. package/dist/ui/assets/{session-freezer-CY_xPA5l.js → session-freezer-CTa8DV3J.js} +3 -3
  68. package/dist/ui/assets/setup-feature-flags-remote-D3WMLjqb.js +1 -0
  69. package/dist/ui/assets/{setup-standalone-prelude-BhiV3xF3.js → setup-standalone-prelude-JqeD1jxB.js} +2 -2
  70. package/dist/ui/assets/{setup-sudo-Bc6Bf58N.js → setup-sudo-Bw1YRjpP.js} +2 -2
  71. package/dist/ui/assets/{skills-DCjxX0pQ.js → skills-OBqwR4a3.js} +1 -1
  72. package/dist/ui/assets/slicc-version-IzQvr10z.js +1 -0
  73. package/dist/ui/assets/snapshot-DUtr8tyy.js +5 -0
  74. package/dist/ui/assets/snapshot-features-DzaOp3Ox.js +12 -0
  75. package/dist/ui/assets/state-C1AcjT8y.js +42 -0
  76. package/dist/ui/assets/{store-BzXotyG-.js → store-BHXaz3Oe.js} +1 -1
  77. package/dist/ui/assets/{store-CWbq6Z70.js → store-DIHor0rH.js} +1 -1
  78. package/dist/ui/assets/{style-guide-BXma5vDN.js → style-guide-HRBOnsqo.js} +1 -1
  79. package/dist/ui/assets/{sudo-D0IZzQR1.js → sudo-CNkQZSrq.js} +1 -1
  80. package/dist/ui/assets/{tokens-FjEJwUyJ.css → tokens-C0tWnXL8.css} +1 -1
  81. package/dist/ui/assets/{tray-leader-BkvHMG47.js → tray-leader-BfzB60pu.js} +1 -1
  82. package/dist/ui/assets/{tray-leave-runtime-OxVT1X8O.js → tray-leave-runtime-Da8DNP8G.js} +1 -1
  83. package/dist/ui/assets/tray-runtime-config-CI5NvXNM.js +1 -0
  84. package/dist/ui/assets/{tray-sidecar-DlsvQaxt.js → tray-sidecar-DELq6UY1.js} +1 -1
  85. package/dist/ui/assets/tray-storage-keys-DbThbnIh.js +1 -0
  86. package/dist/ui/assets/{upgrade-detection-Dy861Ih0.js → upgrade-detection-DmHO9Bp6.js} +1 -1
  87. package/dist/ui/assets/validate-args-BcxRh84E.js +1 -0
  88. package/dist/ui/assets/{wc-attach-CvtvBZxh.js → wc-attach-XfgAjtG0.js} +2 -2
  89. package/dist/ui/assets/{wc-extension-LUihh2_O.js → wc-extension-B-v-hOO4.js} +2 -2
  90. package/dist/ui/assets/{wc-float-label-B0P4cXwA.js → wc-float-label-D4-PuZNM.js} +1 -1
  91. package/dist/ui/assets/{wc-floatbar-online-DqwcQ-LR.js → wc-floatbar-online-B2qo6uFe.js} +1 -1
  92. package/dist/ui/assets/{wc-follower-DBt1mJLE.js → wc-follower-7eowqxqH.js} +4 -4
  93. package/dist/ui/assets/{wc-follower-oauth-BFDZk48X.js → wc-follower-oauth-CEneP2VZ.js} +1 -1
  94. package/dist/ui/assets/wc-live-YWSPN0o9.js +8 -0
  95. package/dist/ui/assets/{wc-nav-DgNpoj0A.js → wc-nav-DlbOO24x.js} +2 -2
  96. package/dist/ui/assets/{wc-onboarding-BXpQRHps.js → wc-onboarding-Cl-bUqt2.js} +2 -2
  97. package/dist/ui/assets/{wc-placeholder-BFrFqUzJ.js → wc-placeholder-DmYGoiiJ.js} +2 -2
  98. package/dist/ui/assets/{wc-settings-DSjRXt1s.js → wc-settings--L-kVcwL.js} +2 -2
  99. package/dist/ui/assets/{wc-shell-CG8vEcg2.js → wc-shell-Bi2tByJc.js} +157 -157
  100. package/dist/ui/assets/{wc-shortcut-config-DNhmJoYa.js → wc-shortcut-config-gbLNrtpc.js} +1 -1
  101. package/dist/ui/assets/{wc-sprinkles-ZAKCHOG3.js → wc-sprinkles-sGOJ3VtZ.js} +2 -2
  102. package/dist/ui/assets/{wc-tray-C8O2iFpI.js → wc-tray-DQ5QgfW8.js} +4 -4
  103. package/dist/ui/assets/{xai-grok-BiTbdSYT.js → xai-grok-CgZXGMtP.js} +1 -1
  104. package/dist/ui/assets/{xai-grok-BZhevKpE.js → xai-grok-O17sYtbM.js} +1 -1
  105. package/dist/ui/index.html +7 -7
  106. package/dist/ui/packages/webapp/index.html +7 -7
  107. package/package.json +1 -1
  108. package/dist/ui/assets/SKILL-ezqO98T2.js +0 -1
  109. package/dist/ui/assets/backend-hostfs-67ZrRvDd.js +0 -1
  110. package/dist/ui/assets/backend-hostfs-BRPUUyZC.js +0 -1
  111. package/dist/ui/assets/fs-DLGqD-9G.js +0 -1
  112. package/dist/ui/assets/fs-jpXwyUNl.js +0 -2
  113. package/dist/ui/assets/github-BLcSCBUw.js +0 -1
  114. package/dist/ui/assets/kernel-worker-DI9EgRiZ.js +0 -5097
  115. package/dist/ui/assets/main-cherry-CrGX6WdB.js +0 -1
  116. package/dist/ui/assets/provider-store-access-G966LEnc.js +0 -1
  117. package/dist/ui/assets/run-DVeTczxh.js +0 -81
  118. package/dist/ui/assets/secret-env-CvkpMxjz.js +0 -1
  119. package/dist/ui/assets/setup-feature-flags-remote-CLt9xNCP.js +0 -1
  120. package/dist/ui/assets/slicc-version-Dg1lQq86.js +0 -1
  121. package/dist/ui/assets/state-CYPHKY6Z.js +0 -42
  122. package/dist/ui/assets/tray-runtime-config-CluPgaPA.js +0 -1
  123. package/dist/ui/assets/tray-url-config-Cx0gvYq6.js +0 -1
  124. package/dist/ui/assets/validate-args-DEw-pWqJ.js +0 -1
  125. package/dist/ui/assets/wc-live-Dr7iYZOR.js +0 -8
@@ -32,6 +32,22 @@
32
32
  * max-age. `read` deliberately stays a `GET` on a per-file URL so the
33
33
  * browser HTTP cache can still revalidate large blobs with a 304.
34
34
  *
35
+ * `read` also speaks `Range` (single `bytes=` window, 206 + `Content-Range`,
36
+ * 416 when the window is outside the file) and streams every body straight
37
+ * off disk. Before that, one `git` command against a `--mount`ed checkout
38
+ * buffered the whole 92 MB packfile in express AND in the kernel worker, and
39
+ * a pack over `HOSTFS_MAX_BODY_BYTES` was simply unreachable — every git
40
+ * command in such a repo died with isomorphic-git's `InternalError`
41
+ * (issue #2711). The whole-file cap still guards an unranged read; a ranged
42
+ * read is bounded by the window it names, so it is exempt.
43
+ *
44
+ * Streaming cost the route the ETag express used to derive from the buffered
45
+ * body, so `cacheValidator` rebuilds one from the stat and `read` answers
46
+ * `If-None-Match` / `If-Modified-Since` with a `304` and honors `If-Range`.
47
+ * That is not a nicety: a pack URL the browser cannot revalidate is
48
+ * re-transferred on every object lookup, and 220,310 of the 385,033 hostfs
49
+ * GETs in the #2707 baseline were exactly those `304`s.
50
+ *
35
51
  * The hostfs surface owns its own body parsing end to end. `index.ts` skips
36
52
  * the stable dispatcher in its global `express.json()` filter (via
37
53
  * `isHostFsStableBodyRequest`) so the bounded 1 MiB parser here is the one
@@ -42,7 +58,15 @@
42
58
  */
43
59
  import type { Express } from 'express';
44
60
  import type { HostMountMapping } from './runtime-flags.js';
45
- /** Matches the webapp's hostfs body cap (backend-hostfs.ts). */
61
+ /**
62
+ * Cap on a WHOLE-FILE `GET /api/hostfs/read` (and on `PUT /write`). It exists
63
+ * because the caller has to hold the entire body in the kernel worker; it is
64
+ * NOT a limit on what the bridge is willing to serve. A ranged read is exempt:
65
+ * the window it asks for is its own bound, the server streams it, and neither
66
+ * side ever materializes more than that window. See `parseByteRange`.
67
+ *
68
+ * Matches the webapp's hostfs body cap (backend-hostfs.ts).
69
+ */
46
70
  export declare const HOSTFS_MAX_BODY_BYTES: number;
47
71
  /**
48
72
  * Resolve `relPath` against `root`, rejecting anything that leaves the root
@@ -58,6 +82,38 @@ interface HostMountRoot {
58
82
  /** realpath()'d OS root. */
59
83
  root: string;
60
84
  }
85
+ /**
86
+ * What `Range:` asked for, resolved against the file's actual size.
87
+ *
88
+ * - `none` — no (or unusable) Range header: serve the whole file.
89
+ * - `range` — a satisfiable window, INCLUSIVE on both ends, exactly
90
+ * as `Content-Range` and `createReadStream` want it.
91
+ * - `unsatisfiable` — syntactically fine but outside the file: 416.
92
+ */
93
+ export type ParsedByteRange = {
94
+ kind: 'none';
95
+ } | {
96
+ kind: 'range';
97
+ start: number;
98
+ end: number;
99
+ } | {
100
+ kind: 'unsatisfiable';
101
+ };
102
+ /**
103
+ * Parse a single-range `Range: bytes=…` header against a known file size.
104
+ *
105
+ * Deliberately narrow: one range, `bytes` unit only. RFC 9110 §14.2 says a
106
+ * recipient that cannot make sense of a Range header MUST ignore it and serve
107
+ * the whole representation, so anything malformed — a different unit, a
108
+ * multi-range list, non-digits — returns `none` rather than an error. Only a
109
+ * well-formed range that lies outside the file is a 416, because answering
110
+ * that one with the whole file would silently hand the caller bytes it did
111
+ * not ask for (and a git pack reader would then parse garbage).
112
+ *
113
+ * Both ends of the result are inclusive and clamped into `[0, size - 1]`.
114
+ * A zero-length file has no satisfiable range at all.
115
+ */
116
+ export declare function parseByteRange(header: string | undefined, size: number): ParsedByteRange;
61
117
  /**
62
118
  * Resolve the configured mappings to realpath'd roots. Mappings whose OS
63
119
  * path does not exist (or is not a directory) are dropped with a warning —
@@ -32,6 +32,22 @@
32
32
  * max-age. `read` deliberately stays a `GET` on a per-file URL so the
33
33
  * browser HTTP cache can still revalidate large blobs with a 304.
34
34
  *
35
+ * `read` also speaks `Range` (single `bytes=` window, 206 + `Content-Range`,
36
+ * 416 when the window is outside the file) and streams every body straight
37
+ * off disk. Before that, one `git` command against a `--mount`ed checkout
38
+ * buffered the whole 92 MB packfile in express AND in the kernel worker, and
39
+ * a pack over `HOSTFS_MAX_BODY_BYTES` was simply unreachable — every git
40
+ * command in such a repo died with isomorphic-git's `InternalError`
41
+ * (issue #2711). The whole-file cap still guards an unranged read; a ranged
42
+ * read is bounded by the window it names, so it is exempt.
43
+ *
44
+ * Streaming cost the route the ETag express used to derive from the buffered
45
+ * body, so `cacheValidator` rebuilds one from the stat and `read` answers
46
+ * `If-None-Match` / `If-Modified-Since` with a `304` and honors `If-Range`.
47
+ * That is not a nicety: a pack URL the browser cannot revalidate is
48
+ * re-transferred on every object lookup, and 220,310 of the 385,033 hostfs
49
+ * GETs in the #2707 baseline were exactly those `304`s.
50
+ *
35
51
  * The hostfs surface owns its own body parsing end to end. `index.ts` skips
36
52
  * the stable dispatcher in its global `express.json()` filter (via
37
53
  * `isHostFsStableBodyRequest`) so the bounded 1 MiB parser here is the one
@@ -41,9 +57,18 @@
41
57
  * HTML and the webapp cannot rethrow a faithful `FsError`.
42
58
  */
43
59
  import express from 'express';
44
- import { lstat, mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile, } from 'fs/promises';
60
+ import { createReadStream } from 'fs';
61
+ import { lstat, mkdir, readdir, realpath, rename, rm, stat, writeFile } from 'fs/promises';
45
62
  import { dirname, resolve, sep } from 'path';
46
- /** Matches the webapp's hostfs body cap (backend-hostfs.ts). */
63
+ /**
64
+ * Cap on a WHOLE-FILE `GET /api/hostfs/read` (and on `PUT /write`). It exists
65
+ * because the caller has to hold the entire body in the kernel worker; it is
66
+ * NOT a limit on what the bridge is willing to serve. A ranged read is exempt:
67
+ * the window it asks for is its own bound, the server streams it, and neither
68
+ * side ever materializes more than that window. See `parseByteRange`.
69
+ *
70
+ * Matches the webapp's hostfs body cap (backend-hostfs.ts).
71
+ */
47
72
  export const HOSTFS_MAX_BODY_BYTES = 100 * 1024 * 1024;
48
73
  function errnoCode(err) {
49
74
  const code = err?.code;
@@ -69,6 +94,12 @@ function toFsCodeError(err) {
69
94
  }
70
95
  }
71
96
  function sendFsError(res, err) {
97
+ // A streamed body may already have committed the status line; the only
98
+ // honest signal left is a broken connection.
99
+ if (res.headersSent) {
100
+ res.destroy();
101
+ return;
102
+ }
72
103
  const mapped = toFsCodeError(err);
73
104
  res.status(mapped.status).json({ code: mapped.code, message: mapped.message });
74
105
  }
@@ -114,6 +145,147 @@ export async function resolveWithinRoot(root, relPath) {
114
145
  }
115
146
  return target;
116
147
  }
148
+ const NO_RANGE = { kind: 'none' };
149
+ const UNSATISFIABLE = { kind: 'unsatisfiable' };
150
+ /**
151
+ * Parse a single-range `Range: bytes=…` header against a known file size.
152
+ *
153
+ * Deliberately narrow: one range, `bytes` unit only. RFC 9110 §14.2 says a
154
+ * recipient that cannot make sense of a Range header MUST ignore it and serve
155
+ * the whole representation, so anything malformed — a different unit, a
156
+ * multi-range list, non-digits — returns `none` rather than an error. Only a
157
+ * well-formed range that lies outside the file is a 416, because answering
158
+ * that one with the whole file would silently hand the caller bytes it did
159
+ * not ask for (and a git pack reader would then parse garbage).
160
+ *
161
+ * Both ends of the result are inclusive and clamped into `[0, size - 1]`.
162
+ * A zero-length file has no satisfiable range at all.
163
+ */
164
+ export function parseByteRange(header, size) {
165
+ if (!header)
166
+ return NO_RANGE;
167
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
168
+ if (!match)
169
+ return NO_RANGE;
170
+ const [, rawStart, rawEnd] = match;
171
+ if (rawStart === '' && rawEnd === '')
172
+ return NO_RANGE;
173
+ if (size === 0)
174
+ return UNSATISFIABLE;
175
+ if (rawStart === '') {
176
+ // Suffix form `bytes=-N`: the last N bytes. `-0` names nothing.
177
+ const suffix = Number(rawEnd);
178
+ if (suffix === 0)
179
+ return UNSATISFIABLE;
180
+ return { kind: 'range', start: Math.max(0, size - suffix), end: size - 1 };
181
+ }
182
+ const start = Number(rawStart);
183
+ if (start >= size)
184
+ return UNSATISFIABLE;
185
+ // An open-ended `bytes=N-` runs to EOF; an explicit end past EOF clamps.
186
+ const end = rawEnd === '' ? size - 1 : Math.min(Number(rawEnd), size - 1);
187
+ if (end < start)
188
+ return UNSATISFIABLE;
189
+ return { kind: 'range', start, end };
190
+ }
191
+ /**
192
+ * Stream a file (or a window of it) to the response instead of buffering it.
193
+ *
194
+ * `readFile` + `res.send` held the whole body in the server process — 92 MB
195
+ * per pack read in the benchmark of issue #2707 — for no benefit: express is
196
+ * already writing to a socket. Headers are committed on the stream's `open`,
197
+ * so a failure to open (a raced unlink, EACCES) still rejects BEFORE anything
198
+ * is on the wire and is mapped to the usual errno JSON by the caller. After
199
+ * that point the status line is spent, so a mid-body failure can only be
200
+ * reported by destroying the connection — a truncated body the client's
201
+ * `fetch` rejects on, which its retry path already handles.
202
+ */
203
+ async function streamFileBody(res, target, status, headers, window) {
204
+ const stream = createReadStream(target, window);
205
+ await new Promise((resolveStream, rejectStream) => {
206
+ let committed = false;
207
+ stream.once('open', () => {
208
+ committed = true;
209
+ res.status(status);
210
+ for (const [name, value] of Object.entries(headers))
211
+ res.setHeader(name, value);
212
+ stream.pipe(res);
213
+ });
214
+ stream.once('error', (err) => {
215
+ if (!committed) {
216
+ rejectStream(err);
217
+ return;
218
+ }
219
+ res.destroy();
220
+ resolveStream();
221
+ });
222
+ // The client hung up (or the pipe finished): either way we are done, and
223
+ // an aborted response must not leave the read fd open.
224
+ res.once('close', () => {
225
+ stream.destroy();
226
+ resolveStream();
227
+ });
228
+ res.once('finish', resolveStream);
229
+ });
230
+ }
231
+ function cacheValidator(s) {
232
+ const mtimeSeconds = Math.floor(s.mtimeMs / 1000);
233
+ return {
234
+ etag: `"${s.size.toString(16)}-${s.mtimeMs.toString(16)}-${Number(s.ino).toString(16)}"`,
235
+ lastModified: new Date(mtimeSeconds * 1000).toUTCString(),
236
+ mtimeSeconds,
237
+ };
238
+ }
239
+ /** Drop a `W/` prefix so the weak comparison treats `W/"x"` and `"x"` alike. */
240
+ function stripWeak(tag) {
241
+ return tag.startsWith('W/') ? tag.slice(2) : tag;
242
+ }
243
+ /**
244
+ * True when the client already holds this exact representation, so the answer
245
+ * is `304` with no body.
246
+ *
247
+ * RFC 9110 §13.2.1 fixes the precedence: `If-None-Match` decides on its own
248
+ * whenever it is present, and `If-Modified-Since` is only consulted in its
249
+ * absence. Comparison for `If-None-Match` is the WEAK one, so a cached weak
250
+ * form of our tag still matches.
251
+ */
252
+ function isNotModified(req, v) {
253
+ const ifNoneMatch = req.header('if-none-match');
254
+ if (ifNoneMatch !== undefined) {
255
+ if (ifNoneMatch.trim() === '*')
256
+ return true;
257
+ return ifNoneMatch.split(',').some((tag) => stripWeak(tag.trim()) === stripWeak(v.etag));
258
+ }
259
+ const ifModifiedSince = req.header('if-modified-since');
260
+ if (ifModifiedSince === undefined)
261
+ return false;
262
+ const since = Date.parse(ifModifiedSince);
263
+ // An unparseable date is not a claim about anything — serve the file.
264
+ if (Number.isNaN(since))
265
+ return false;
266
+ return v.mtimeSeconds * 1000 <= since;
267
+ }
268
+ /**
269
+ * Whether a `Range` may still be honored. `If-Range` says "send me the window
270
+ * only if the representation is still the one I have; otherwise send the
271
+ * whole thing" — so a mismatch downgrades to a plain 200 rather than erroring,
272
+ * which is what keeps a client from stitching a window of the NEW file into a
273
+ * buffer holding the old one. Absent header: nothing to disagree with.
274
+ *
275
+ * Comparison here is STRONG (RFC 9110 §13.1.5): a weak tag never satisfies it.
276
+ */
277
+ function ifRangeAllowsRange(req, v) {
278
+ const ifRange = req.header('if-range');
279
+ if (ifRange === undefined)
280
+ return true;
281
+ const value = ifRange.trim();
282
+ if (value.startsWith('"') || value.startsWith('W/'))
283
+ return value === v.etag;
284
+ const asDate = Date.parse(value);
285
+ if (Number.isNaN(asDate))
286
+ return false;
287
+ return v.mtimeSeconds * 1000 === asDate;
288
+ }
117
289
  /**
118
290
  * Identity/permission fields of a host `fs.Stats`, as the webapp needs them.
119
291
  *
@@ -269,6 +441,76 @@ async function listOp(target) {
269
441
  async function statOp(target) {
270
442
  return statPayload(await stat(target));
271
443
  }
444
+ /**
445
+ * `GET /api/hostfs/read` — the whole file, or the `Range` window it asked for,
446
+ * or `304` when the client already holds it.
447
+ *
448
+ * Writes the response itself rather than returning a payload, because the body
449
+ * is streamed: buffering a 92 MB packfile in express to `res.send` it was pure
450
+ * overhead, and a pack over `HOSTFS_MAX_BODY_BYTES` had no way through at all
451
+ * (issue #2711). The cap survives only for the unranged read, whose caller has
452
+ * to hold the whole body.
453
+ *
454
+ * Conditional handling is not optional garnish here. Streaming cost us the
455
+ * ETag express derived from the buffered body, and a pack URL that cannot be
456
+ * revalidated is re-transferred on every object lookup — the opposite of the
457
+ * fix. See {@link cacheValidator}.
458
+ */
459
+ async function readOp(target, req, res) {
460
+ const s = await stat(target);
461
+ if (s.isDirectory()) {
462
+ sendFsError(res, Object.assign(new Error(`is a directory: ${target}`), { code: 'EISDIR' }));
463
+ return;
464
+ }
465
+ const validator = cacheValidator(s);
466
+ // Every answer carries the validators, 304 included — that is how the client
467
+ // refreshes its stored response metadata.
468
+ const common = {
469
+ 'Accept-Ranges': 'bytes',
470
+ ETag: validator.etag,
471
+ 'Last-Modified': validator.lastModified,
472
+ };
473
+ if (isNotModified(req, validator)) {
474
+ // 304 MUST NOT carry a body; `end()` (not `json()`) keeps it that way and
475
+ // stops express from attaching a Content-Type for content that isn't there.
476
+ res.status(304).set(common).end();
477
+ return;
478
+ }
479
+ const range = ifRangeAllowsRange(req, validator)
480
+ ? parseByteRange(req.header('range'), s.size)
481
+ : NO_RANGE;
482
+ if (range.kind === 'unsatisfiable') {
483
+ res
484
+ .status(416)
485
+ .set({ ...common, 'Content-Range': `bytes */${s.size}` })
486
+ .json({ code: 'EINVAL', message: `range not satisfiable for a ${s.size} byte file` });
487
+ return;
488
+ }
489
+ const bodyHeaders = { ...common, 'Content-Type': 'application/octet-stream' };
490
+ if (range.kind === 'range') {
491
+ // No size cap here on purpose: the window IS the bound, and the body is
492
+ // streamed, so neither the bridge nor the caller ever holds more than the
493
+ // bytes that were asked for. This is what lets git reach a pack larger
494
+ // than HOSTFS_MAX_BODY_BYTES at all.
495
+ await streamFileBody(res, target, 206, {
496
+ ...bodyHeaders,
497
+ 'Content-Range': `bytes ${range.start}-${range.end}/${s.size}`,
498
+ 'Content-Length': String(range.end - range.start + 1),
499
+ }, range);
500
+ return;
501
+ }
502
+ if (s.size > HOSTFS_MAX_BODY_BYTES) {
503
+ res
504
+ .status(413)
505
+ .set(common)
506
+ .json({
507
+ code: 'EFBIG',
508
+ message: `file exceeds the ${HOSTFS_MAX_BODY_BYTES} byte hostfs whole-file cap; read it with a Range request`,
509
+ });
510
+ return;
511
+ }
512
+ await streamFileBody(res, target, 200, { ...bodyHeaders, 'Content-Length': String(s.size) });
513
+ }
272
514
  async function mkdirOp(target) {
273
515
  await mkdir(target, { recursive: true });
274
516
  return { ok: true };
@@ -386,24 +628,7 @@ export function registerHostFsRoutes(app, roots) {
386
628
  app.get('/api/hostfs/stat', withTarget(async (target, _req, res) => {
387
629
  res.json(await statOp(target));
388
630
  }));
389
- app.get('/api/hostfs/read', withTarget(async (target, _req, res) => {
390
- // lstat first so a dangling symlink is ENOENT, a directory is EISDIR.
391
- const s = await stat(target);
392
- if (s.isDirectory()) {
393
- sendFsError(res, Object.assign(new Error(`is a directory: ${target}`), { code: 'EISDIR' }));
394
- return;
395
- }
396
- if (s.size > HOSTFS_MAX_BODY_BYTES) {
397
- res.status(413).json({
398
- code: 'EFBIG',
399
- message: `file exceeds the ${HOSTFS_MAX_BODY_BYTES} byte hostfs cap`,
400
- });
401
- return;
402
- }
403
- const body = await readFile(target);
404
- res.setHeader('Content-Type', 'application/octet-stream');
405
- res.send(body);
406
- }));
631
+ app.get('/api/hostfs/read', withTarget(readOp));
407
632
  app.put('/api/hostfs/write', express.raw({ type: () => true, limit: HOSTFS_MAX_BODY_BYTES }), withTarget(async (target, req, res) => {
408
633
  // Refuse to clobber a directory with a file (fs would throw EISDIR on
409
634
  // open, but check explicitly so the error is deterministic).