@yiln-dsh/dsh-plugin-file-explorer 0.5.1 → 0.6.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 (4) hide show
  1. package/README.md +6 -4
  2. package/client.js +15 -19
  3. package/index.js +37 -12
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -36,7 +36,7 @@ A DSH `dsh.bundle` that contributes a workspace file explorer page to the
36
36
 
37
37
  ## Install
38
38
 
39
- The package version is `@yiln-dsh/dsh-plugin-file-explorer@0.5.1`.
39
+ The package version is `@yiln-dsh/dsh-plugin-file-explorer@0.6.0`.
40
40
 
41
41
  The right-panel package must be installed in the same `web` profile:
42
42
 
@@ -53,7 +53,7 @@ pnpm pack
53
53
  ```
54
54
 
55
55
  ```bash
56
- dsh plugin --profile web add ./yiln-dsh-dsh-plugin-file-explorer-0.5.1.tgz
56
+ dsh plugin --profile web add ./yiln-dsh-dsh-plugin-file-explorer-0.6.0.tgz
57
57
  ```
58
58
 
59
59
  ### npm package
@@ -76,8 +76,10 @@ apply the new profile composition.
76
76
  - The Host falls back to `sandboxPolicy.workspaceRoot` when no path is passed,
77
77
  and returns `{ ok, path, parent, entries }` JSON — never live objects.
78
78
  - `read` previews up to 1 MiB of UTF-8 text or 16 MiB of recognized images;
79
- image previews return a data URL for inline rendering. `download` returns a
80
- base64 data URL (64 MiB cap) that the browser triggers with `<a download>`.
79
+ image previews return a data URL for inline rendering. `download` is a GET
80
+ route (`/_dsh/file-explorer/download?path=...`) that streams the file
81
+ natively (`Content-Disposition: attachment`) — no base64, no JSON body, no
82
+ size ceiling. The browser triggers it with a transient `<a download>` link.
81
83
  - `delete` removes regular files (`rm -f`) and directories recursively
82
84
  (`rm -rf`); both are called only after a second-click confirm in the UI.
83
85
  - Git routes are read-only. They resolve the repository root with
package/client.js CHANGED
@@ -1515,25 +1515,21 @@ window.__ModuleLoader__.load({
1515
1515
 
1516
1516
  const downloadFile = (entry) => {
1517
1517
  setPendingDelete(null)
1518
- api('download', { path: entry.path })
1519
- .then((raw) => {
1520
- if (!raw || raw.ok !== true) {
1521
- const message = raw && typeof raw.error === 'string' ? raw.error : t('files.downloadFailed')
1522
- setError(message)
1523
- return
1524
- }
1525
- try {
1526
- const link = document.createElement('a')
1527
- link.href = raw.dataUrl
1528
- link.download = entry.name
1529
- document.body.appendChild(link)
1530
- link.click()
1531
- link.remove()
1532
- } catch (err) {
1533
- setError(err && typeof err.message === 'string' ? err.message : String(err))
1534
- }
1535
- })
1536
- .catch((err) => setError(err && typeof err.message === 'string' ? err.message : String(err)))
1518
+ // Native streaming download: navigate to the Host route, which
1519
+ // streams the file with `Content-Disposition: attachment`. The
1520
+ // browser handles the download natively — no JSON data URL, no
1521
+ // base64, no full-file buffering in page memory.
1522
+ try {
1523
+ const query = new URLSearchParams({ path: entry.path })
1524
+ const link = document.createElement('a')
1525
+ link.href = `/_dsh/file-explorer/download?${query.toString()}`
1526
+ link.download = entry.name
1527
+ document.body.appendChild(link)
1528
+ link.click()
1529
+ link.remove()
1530
+ } catch (err) {
1531
+ setError(err && typeof err.message === 'string' ? err.message : String(err))
1532
+ }
1537
1533
  }
1538
1534
 
1539
1535
  const requestDelete = (entry) => {
package/index.js CHANGED
@@ -1,9 +1,11 @@
1
+ import { createReadStream } from 'node:fs'
2
+
1
3
  /**
2
4
  * dsh-plugin-file-explorer — Host half (static DSH bundle).
3
5
  *
4
6
  * Registers exact HTTP routes under /_dsh/file-explorer for the browser
5
- * bundle: list (with parent path), read (text/image preview), download (data URL),
6
- * and delete.
7
+ * bundle: list (with parent path), read (text/image preview), download
8
+ * (native streaming), and delete.
7
9
  */
8
10
 
9
11
  function parentOf(path) {
@@ -190,29 +192,52 @@ export function apply(ctx) {
190
192
  })
191
193
 
192
194
  addRoute('/_dsh/file-explorer/download', async (req, res) => {
193
- if (req.method === 'POST') req = await readJson(req)
195
+ const url = new URL(req.url || '/', 'http://dsh.local')
196
+ const requestedPath = url.searchParams.get('path')
194
197
  const fs = ctx.get('fs')
195
198
  if (fs === undefined) {
196
199
  sendJson(res, 200, { ok: false, error: 'filesystem service unavailable' })
197
200
  return
198
201
  }
199
- if (typeof req.path !== 'string') {
202
+ if (typeof requestedPath !== 'string' || requestedPath.trim() === '') {
200
203
  sendJson(res, 200, { ok: false, error: 'missing file path' })
201
204
  return
202
205
  }
203
206
 
204
207
  try {
205
- const target = await fs.resolve(req.path)
208
+ const target = await fs.resolve(requestedPath)
206
209
  const info = await fs.stat(target)
207
- const size = info && typeof info.size === 'number' ? info.size : null
208
- const limit = 64 * 1024 * 1024
209
- if (size !== null && size > limit) {
210
- sendJson(res, 200, { ok: false, error: 'file too large for explorer download' })
210
+ if (info === undefined || info.type !== 'file') {
211
+ sendJson(res, 200, { ok: false, error: 'file does not exist' })
211
212
  return
212
213
  }
213
- const bytes = await fs.readBytes(target, undefined, limit)
214
- const name = target.displayPath.split('/').pop()
215
- sendJson(res, 200, { ok: true, name, size, dataUrl: `data:${guessMime(name)};base64,${bytesToBase64(bytes)}` })
214
+ const size = typeof info.size === 'number' && Number.isFinite(info.size) && info.size >= 0 ? info.size : 0
215
+ const name = target.displayPath.split('/').pop() || 'download'
216
+ const safeName = String(name).replace(/[\r\n"\\]/gu, '_').replace(/[^\x20-\x7e]/gu, '_') || 'download'
217
+ const encodedName = encodeURIComponent(String(name)).replace(/['()]/gu, (value) => `%${value.charCodeAt(0).toString(16).toUpperCase()}`)
218
+
219
+ res.writeHead(200, {
220
+ 'content-type': guessMime(name),
221
+ 'content-disposition': `attachment; filename="${safeName}"; filename*=UTF-8''${encodedName}`,
222
+ 'content-length': size,
223
+ 'x-content-type-options': 'nosniff',
224
+ 'cache-control': 'no-store',
225
+ })
226
+
227
+ // Native streaming download: pipe the resolved host path straight to the
228
+ // HTTP response. No full-file buffering, no base64, no 64 MiB ceiling.
229
+ const stream = createReadStream(fs.processPath(target))
230
+ stream.on('error', (error) => {
231
+ if (res.headersSent) res.destroy(error)
232
+ else {
233
+ try {
234
+ sendJson(res, 500, { ok: false, error: `failed to stream file: ${error && typeof error.message === 'string' ? error.message : String(error)}` })
235
+ } catch {
236
+ res.destroy(error)
237
+ }
238
+ }
239
+ })
240
+ stream.pipe(res)
216
241
  } catch (error) {
217
242
  sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
218
243
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yiln-dsh/dsh-plugin-file-explorer",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },