sliccy 6.114.1 → 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.
- package/dist/node-server/hostfs.d.ts +57 -1
- package/dist/node-server/hostfs.js +245 -20
- package/dist/ui/.vite/manifest.json +165 -165
- package/dist/ui/assets/SKILL-DJAvKejL.js +1 -0
- package/dist/ui/assets/{account-store-D5xHIv7t.js → account-store-C76tqAhk.js} +1 -1
- package/dist/ui/assets/{account-store-BUZZH4WG.js → account-store-De9BVnZg.js} +1 -1
- package/dist/ui/assets/{adobe-DaA2BIZ0.js → adobe-CVSIyirw.js} +1 -1
- package/dist/ui/assets/{adobe-C1nr_Kri.js → adobe-Dnw_s_1W.js} +1 -1
- package/dist/ui/assets/{agent-bridge-Dk6B56Ul.js → agent-bridge-DNXEF-CF.js} +1 -1
- package/dist/ui/assets/{agent-panels-D4YrrwY3.js → agent-panels-BfdWU9wd.js} +1 -1
- package/dist/ui/assets/{apply-layout-D64xyG3i.js → apply-layout-BY2k_QOi.js} +1 -1
- package/dist/ui/assets/{apps-csD4gKOU.js → apps-C2mla_tP.js} +1 -1
- package/dist/ui/assets/{auto-mount-table-DnB3cBQZ.js → auto-mount-table-CU4bEF4U.js} +1 -1
- package/dist/ui/assets/{azure-openai-aWAIt-V6.js → azure-openai-CHibmja0.js} +1 -1
- package/dist/ui/assets/{azure-openai-CXnsBAdj.js → azure-openai-CbozGsE6.js} +1 -1
- package/dist/ui/assets/backend-hostfs-BasSBAje.js +1 -0
- package/dist/ui/assets/backend-hostfs-Cp--BRHS.js +1 -0
- package/dist/ui/assets/{cdp-CkdbE1bi.js → cdp-CzH791qX.js} +3 -1
- package/dist/ui/assets/{cerebras-DSzkkIwV.js → cerebras-CbAinbX-.js} +1 -1
- package/dist/ui/assets/{cerebras-DrbeLc44.js → cerebras-DF94_YMV.js} +1 -1
- package/dist/ui/assets/{connect-surface-BP1pMJ6f.js → connect-surface-6Tf9VRLg.js} +1 -1
- package/dist/ui/assets/{directed-approval-XNE8LsSr.js → directed-approval-Bes-d6NV.js} +1 -1
- package/dist/ui/assets/fs-B-2A9-Hh.js +1 -0
- package/dist/ui/assets/fs-CQUmIZZr.js +2 -0
- package/dist/ui/assets/github-BIOil5nW.js +1 -0
- package/dist/ui/assets/{github-D-Ga9ckq.js → github-D4j3Unvm.js} +2 -2
- package/dist/ui/assets/{github-copilot-ASwhJ-L1.js → github-copilot-Dl7CD601.js} +1 -1
- package/dist/ui/assets/{github-copilot-DrVsjUft.js → github-copilot-DlXfI4qg.js} +1 -1
- package/dist/ui/assets/{github-install-BmNDDley.js → github-install-CLUb1jI1.js} +1 -1
- package/dist/ui/assets/{github-zip-D0MHFeGd.js → github-zip-U2Shc3VA.js} +1 -1
- package/dist/ui/assets/help-DXkaM0Ek.js +156 -0
- package/dist/ui/assets/{js-realm-worker-DS7RFqd1.js → js-realm-worker-D_qCD94a.js} +1 -1
- package/dist/ui/assets/kernel-worker-CcoBTnWU.js +4970 -0
- package/dist/ui/assets/{lick-ws-bridge-CBO4U0Rd.js → lick-ws-bridge-BUAhV4Ba.js} +1 -1
- package/dist/ui/assets/{local-llm-B6cr29Ar.js → local-llm-B2VRmaJG.js} +1 -1
- package/dist/ui/assets/{local-llm-DtFWi0cE.js → local-llm-o8WLwZ7B.js} +1 -1
- package/dist/ui/assets/{main-DFj2Kgkh.js → main-CZNHEKLS.js} +3 -3
- package/dist/ui/assets/{main-cherry-BBtbZwiX.js → main-cherry-6y-bnTnc.js} +1 -1
- package/dist/ui/assets/{mount-bPpMLXiK.js → mount-BVIBh0ue.js} +3 -3
- package/dist/ui/assets/{mount-DyzSo345.js → mount-bmglG5RM.js} +1 -1
- package/dist/ui/assets/{mount-recovery-C2ul-22h.js → mount-recovery--CI8QLRE.js} +1 -1
- package/dist/ui/assets/{new-session-C7Xe-3vV.js → new-session-Drh7lD4Z.js} +2 -2
- package/dist/ui/assets/{oauth-bootstrap-BakIYytP.js → oauth-bootstrap-B9vcZlm6.js} +2 -2
- package/dist/ui/assets/{openai-codex-CQmvZHts.js → openai-codex-BoHImVoD.js} +1 -1
- package/dist/ui/assets/{openai-codex-BYvIzr0O.js → openai-codex-DTrZ2t4u.js} +1 -1
- package/dist/ui/assets/{openrouter-C-sxMeYQ.js → openrouter-DgHvG-PL.js} +1 -1
- package/dist/ui/assets/{openrouter-oauth-C8QxmPAU.js → openrouter-oauth-CVlhkZ2U.js} +1 -1
- package/dist/ui/assets/{openrouter-oauth-DxPeTfOr.js → openrouter-oauth-CePfynCi.js} +1 -1
- package/dist/ui/assets/{openrouter-Cegt7Ntv.js → openrouter-uWtCD-lL.js} +1 -1
- package/dist/ui/assets/{panel-rpc-handlers-DYIwZP0p.js → panel-rpc-handlers-BG13NFel.js} +2 -2
- package/dist/ui/assets/{panelize-shell-DxUMnLtS.js → panelize-shell-DT_q04ws.js} +1 -1
- package/dist/ui/assets/{provider-DCtrzmak.js → provider-CPwea4mZ.js} +1 -1
- package/dist/ui/assets/{provider-DC9ds7Tu.js → provider-CXpXmHET.js} +2 -2
- package/dist/ui/assets/{provider-settings-CA6ToSzW.js → provider-settings-c1_8fxRb.js} +1 -1
- package/dist/ui/assets/{provider-settings-mnVBnYec.js → provider-settings-g595uT_R.js} +1 -1
- package/dist/ui/assets/provider-store-access-BagBeRQS.js +1 -0
- package/dist/ui/assets/{provider-store-access-DyX4kbsj.js → provider-store-access-urlSkaF2.js} +2 -2
- package/dist/ui/assets/{providers-Dt0OommI.js → providers-BlezQGEl.js} +2 -2
- package/dist/ui/assets/{providers-CIpvP31c.js → providers-BmNaegCb.js} +1 -1
- package/dist/ui/assets/{quick-llm-BTcIqOn-.js → quick-llm-C6OXJ_cx.js} +1 -1
- package/dist/ui/assets/realm-browser-fetch-V3uggGbM.js +1 -0
- package/dist/ui/assets/{recovery-screen-YOF7ouN9.js → recovery-screen-CI2-Oa5I.js} +1 -1
- package/dist/ui/assets/run-0shPd-OO.js +81 -0
- package/dist/ui/assets/{run-YMbLVMlo.js → run-CK2keBOh.js} +2 -2
- package/dist/ui/assets/{session-freezer-OwpCFV-X.js → session-freezer-CTa8DV3J.js} +3 -3
- package/dist/ui/assets/setup-feature-flags-remote-D3WMLjqb.js +1 -0
- package/dist/ui/assets/{setup-standalone-prelude-Dv4WUhsY.js → setup-standalone-prelude-JqeD1jxB.js} +2 -2
- package/dist/ui/assets/{setup-sudo-44gVFgmz.js → setup-sudo-Bw1YRjpP.js} +2 -2
- package/dist/ui/assets/{skills-BFS-QvzU.js → skills-OBqwR4a3.js} +1 -1
- package/dist/ui/assets/slicc-version-IzQvr10z.js +1 -0
- package/dist/ui/assets/snapshot-DUtr8tyy.js +5 -0
- package/dist/ui/assets/snapshot-features-DzaOp3Ox.js +12 -0
- package/dist/ui/assets/state-C1AcjT8y.js +42 -0
- package/dist/ui/assets/{store-C7_bgw90.js → store-BHXaz3Oe.js} +1 -1
- package/dist/ui/assets/{store-C-xxUbHl.js → store-DIHor0rH.js} +1 -1
- package/dist/ui/assets/{sudo-DBgAxxjz.js → sudo-CNkQZSrq.js} +1 -1
- package/dist/ui/assets/{tray-leader-BkvHMG47.js → tray-leader-BfzB60pu.js} +1 -1
- package/dist/ui/assets/{tray-leave-runtime-OxVT1X8O.js → tray-leave-runtime-Da8DNP8G.js} +1 -1
- package/dist/ui/assets/tray-runtime-config-CI5NvXNM.js +1 -0
- package/dist/ui/assets/{tray-sidecar-DlsvQaxt.js → tray-sidecar-DELq6UY1.js} +1 -1
- package/dist/ui/assets/tray-storage-keys-DbThbnIh.js +1 -0
- package/dist/ui/assets/{upgrade-detection-K3oKNWHg.js → upgrade-detection-DmHO9Bp6.js} +1 -1
- package/dist/ui/assets/validate-args-BcxRh84E.js +1 -0
- package/dist/ui/assets/{wc-attach-687Tofb-.js → wc-attach-XfgAjtG0.js} +2 -2
- package/dist/ui/assets/{wc-extension-CItQ0gi1.js → wc-extension-B-v-hOO4.js} +2 -2
- package/dist/ui/assets/{wc-float-label-CwanYCvQ.js → wc-float-label-D4-PuZNM.js} +1 -1
- package/dist/ui/assets/{wc-floatbar-online-CNiD76Po.js → wc-floatbar-online-B2qo6uFe.js} +1 -1
- package/dist/ui/assets/{wc-follower-BrUefc6k.js → wc-follower-7eowqxqH.js} +4 -4
- package/dist/ui/assets/{wc-follower-oauth-BLlDGkKK.js → wc-follower-oauth-CEneP2VZ.js} +1 -1
- package/dist/ui/assets/{wc-live-DOAKF7lU.js → wc-live-YWSPN0o9.js} +4 -4
- package/dist/ui/assets/{wc-nav-V3G8dYq1.js → wc-nav-DlbOO24x.js} +2 -2
- package/dist/ui/assets/{wc-onboarding-C9gpATwq.js → wc-onboarding-Cl-bUqt2.js} +2 -2
- package/dist/ui/assets/{wc-placeholder-CAE2J2eY.js → wc-placeholder-DmYGoiiJ.js} +2 -2
- package/dist/ui/assets/{wc-settings-qN0ruXiq.js → wc-settings--L-kVcwL.js} +2 -2
- package/dist/ui/assets/{wc-shell-PkQRaKct.js → wc-shell-Bi2tByJc.js} +2 -2
- package/dist/ui/assets/{wc-shortcut-config-DLKEytNd.js → wc-shortcut-config-gbLNrtpc.js} +1 -1
- package/dist/ui/assets/{wc-sprinkles-CRcYeUdF.js → wc-sprinkles-sGOJ3VtZ.js} +2 -2
- package/dist/ui/assets/{wc-tray-CghKZ1aN.js → wc-tray-DQ5QgfW8.js} +4 -4
- package/dist/ui/assets/{xai-grok-DCebTZ1i.js → xai-grok-CgZXGMtP.js} +1 -1
- package/dist/ui/assets/{xai-grok-CA-gOtTs.js → xai-grok-O17sYtbM.js} +1 -1
- package/dist/ui/index.html +7 -7
- package/dist/ui/packages/webapp/index.html +7 -7
- package/package.json +1 -1
- package/dist/ui/assets/SKILL-ezqO98T2.js +0 -1
- package/dist/ui/assets/backend-hostfs-67ZrRvDd.js +0 -1
- package/dist/ui/assets/backend-hostfs-BRPUUyZC.js +0 -1
- package/dist/ui/assets/fs-BtC5wzqb.js +0 -2
- package/dist/ui/assets/fs-Du9M7rhf.js +0 -1
- package/dist/ui/assets/github-Dbih_E8R.js +0 -1
- package/dist/ui/assets/kernel-worker-CQQgA-8i.js +0 -5097
- package/dist/ui/assets/provider-store-access-DAosoBqV.js +0 -1
- package/dist/ui/assets/run-BxWTQTkb.js +0 -81
- package/dist/ui/assets/setup-feature-flags-remote-bno_BOig.js +0 -1
- package/dist/ui/assets/slicc-version-O3gjvWEw.js +0 -1
- package/dist/ui/assets/state-CnD7SCIf.js +0 -42
- package/dist/ui/assets/tray-runtime-config-CluPgaPA.js +0 -1
- package/dist/ui/assets/tray-url-config-Cx0gvYq6.js +0 -1
- package/dist/ui/assets/validate-args-DEw-pWqJ.js +0 -1
|
@@ -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
|
-
/**
|
|
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 {
|
|
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
|
-
/**
|
|
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(
|
|
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).
|