dsh-folder-path 0.1.0 → 0.2.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 +5 -2
- package/README_EN.md +2 -2
- package/client/globals.d.ts +30 -0
- package/client/{index.js → index.ts} +41 -24
- package/lib/client.js +196 -197
- package/lib/index.js +234 -258
- package/lib/types/index.d.ts +110 -0
- package/package.json +11 -4
- package/scripts/{build-client.mjs → build-client.mts} +9 -8
package/lib/index.js
CHANGED
|
@@ -28,29 +28,23 @@
|
|
|
28
28
|
*
|
|
29
29
|
* @module dsh-folder-path
|
|
30
30
|
*/
|
|
31
|
-
import { execFile } from 'node:child_process'
|
|
32
|
-
import { stat } from 'node:fs/promises'
|
|
33
|
-
import { basename } from 'node:path'
|
|
34
|
-
|
|
31
|
+
import { execFile } from 'node:child_process';
|
|
32
|
+
import { stat } from 'node:fs/promises';
|
|
33
|
+
import { basename } from 'node:path';
|
|
35
34
|
/** Cordis plugin name; the profile row's `id` is independent of it. */
|
|
36
|
-
export const name = 'folder-path'
|
|
37
|
-
|
|
35
|
+
export const name = 'folder-path';
|
|
38
36
|
/**
|
|
39
37
|
* The single endpoint the browser half calls. Hand-mirrored in
|
|
40
38
|
* `client/index.js`: the two halves ship as one package but cannot import each
|
|
41
39
|
* other (the browser bundle is a hand-written classic script).
|
|
42
40
|
*/
|
|
43
|
-
export const ROUTE_PATH = '/dsh-folder-path/api/v1/clipboard-paths'
|
|
44
|
-
|
|
41
|
+
export const ROUTE_PATH = '/dsh-folder-path/api/v1/clipboard-paths';
|
|
45
42
|
/** Clipboard probes are interactive: a wedged child process must not hold a paste. */
|
|
46
|
-
const DEFAULT_TIMEOUT_MS = 5000
|
|
47
|
-
|
|
43
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
48
44
|
/** A paste names a handful of folders; anything larger is not this request. */
|
|
49
|
-
const MAX_REQUEST_BYTES = 64 * 1024
|
|
50
|
-
|
|
45
|
+
const MAX_REQUEST_BYTES = 64 * 1024;
|
|
51
46
|
/** Hosts whose clipboard can answer this route. */
|
|
52
|
-
const DESKTOP_PLATFORMS = new Set(['win32', 'darwin'])
|
|
53
|
-
|
|
47
|
+
const DESKTOP_PLATFORMS = new Set(['win32', 'darwin']);
|
|
54
48
|
/**
|
|
55
49
|
* Windows PowerShell probe. Emits base64(UTF-8 JSON) on stdout so a Chinese path
|
|
56
50
|
* can never be mangled by the console code page of a redirected Windows
|
|
@@ -60,25 +54,24 @@ const DESKTOP_PLATFORMS = new Set(['win32', 'darwin'])
|
|
|
60
54
|
* a list), and `[char]92`/`[char]47` avoid backslash quoting hazards.
|
|
61
55
|
*/
|
|
62
56
|
const WINDOWS_PROBE_SCRIPT = [
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
].join('\n')
|
|
81
|
-
|
|
57
|
+
"$ErrorActionPreference = 'Stop'",
|
|
58
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
59
|
+
'$items = @()',
|
|
60
|
+
'foreach ($raw in [System.Windows.Forms.Clipboard]::GetFileDropList()) {',
|
|
61
|
+
' $full = [string]$raw',
|
|
62
|
+
' if ([string]::IsNullOrWhiteSpace($full)) { continue }',
|
|
63
|
+
' $trimmed = $full.TrimEnd([char]92, [char]47)',
|
|
64
|
+
' $name = if ($trimmed.Length -gt 0) { [System.IO.Path]::GetFileName($trimmed) } else { $full }',
|
|
65
|
+
' $items += [pscustomobject]@{',
|
|
66
|
+
' path = $full',
|
|
67
|
+
' name = $name',
|
|
68
|
+
' isDirectory = [System.IO.Directory]::Exists($full)',
|
|
69
|
+
' }',
|
|
70
|
+
'}',
|
|
71
|
+
'$payload = [pscustomobject]@{ ok = $true; items = @($items) }',
|
|
72
|
+
'$json = ConvertTo-Json -InputObject $payload -Compress -Depth 4',
|
|
73
|
+
'[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($json))',
|
|
74
|
+
].join('\n');
|
|
82
75
|
/**
|
|
83
76
|
* macOS JXA probe. Emits a JSON array of absolute paths on stdout.
|
|
84
77
|
*
|
|
@@ -95,32 +88,31 @@ const WINDOWS_PROBE_SCRIPT = [
|
|
|
95
88
|
* process that reports the path list is the smaller surface.
|
|
96
89
|
*/
|
|
97
90
|
const DARWIN_PROBE_SCRIPT = [
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
].join('\n')
|
|
123
|
-
|
|
91
|
+
"ObjC.import('AppKit')",
|
|
92
|
+
'var pasteboard = $.NSPasteboard.generalPasteboard',
|
|
93
|
+
'var paths = []',
|
|
94
|
+
'function remember(value) {',
|
|
95
|
+
" if (typeof value !== 'string' || value.length === 0) return",
|
|
96
|
+
' if (paths.indexOf(value) >= 0) return',
|
|
97
|
+
' paths.push(value)',
|
|
98
|
+
'}',
|
|
99
|
+
'var urls = pasteboard.readObjectsForClassesOptions($.NSArray.arrayWithObject($.NSURL), $.NSDictionary.dictionary)',
|
|
100
|
+
'if (urls && urls.count > 0) {',
|
|
101
|
+
' for (var index = 0; index < urls.count; index += 1) {',
|
|
102
|
+
' var url = urls.objectAtIndex(index)',
|
|
103
|
+
' if (url.isFileURL === true) remember(ObjC.unwrap(url.path))',
|
|
104
|
+
' }',
|
|
105
|
+
'}',
|
|
106
|
+
'try {',
|
|
107
|
+
" var legacy = pasteboard.propertyListForType('NSFilenamesPboardType')",
|
|
108
|
+
' if (legacy && legacy.count > 0) {',
|
|
109
|
+
' for (var other = 0; other < legacy.count; other += 1) remember(ObjC.unwrap(legacy.objectAtIndex(other)))',
|
|
110
|
+
' }',
|
|
111
|
+
'} catch (error) {',
|
|
112
|
+
' /* a pasteboard without the legacy flavour simply has nothing to add */',
|
|
113
|
+
'}',
|
|
114
|
+
'JSON.stringify(paths)',
|
|
115
|
+
].join('\n');
|
|
124
116
|
/**
|
|
125
117
|
* Install the clipboard route.
|
|
126
118
|
*
|
|
@@ -130,37 +122,25 @@ const DARWIN_PROBE_SCRIPT = [
|
|
|
130
122
|
*
|
|
131
123
|
* @param ctx - host plugin context.
|
|
132
124
|
* @param config - the profile row's `config` block.
|
|
133
|
-
* @param config.enabled - set `false` to mount the host half inert.
|
|
134
|
-
* @param config.timeoutMs - clipboard probe budget in milliseconds.
|
|
135
|
-
* @param config.powershell - PowerShell executable used for the Windows probe.
|
|
136
|
-
* @param config.osascript - `osascript` executable used for the macOS probe.
|
|
137
|
-
* @param config.allowNonLoopback - allow a browser on another machine to read this host's clipboard.
|
|
138
125
|
* @returns nothing; the route is registered once `webServer` appears.
|
|
139
126
|
*/
|
|
140
127
|
export function apply(ctx, config = {}) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
path: ROUTE_PATH,
|
|
157
|
-
handler: createHandler(ctx, probe, allowNonLoopback),
|
|
158
|
-
}),
|
|
159
|
-
'dsh-folder-path: clipboard route',
|
|
160
|
-
)
|
|
161
|
-
})
|
|
128
|
+
if (config?.enabled === false)
|
|
129
|
+
return;
|
|
130
|
+
const probe = {
|
|
131
|
+
timeoutMs: Number(config?.timeoutMs) > 0 ? Number(config.timeoutMs) : DEFAULT_TIMEOUT_MS,
|
|
132
|
+
powershell: firstString(config?.powershell) || firstString(process.env.DSH_FOLDER_PATH_POWERSHELL) || 'powershell.exe',
|
|
133
|
+
osascript: firstString(config?.osascript) || firstString(process.env.DSH_FOLDER_PATH_OSASCRIPT) || '/usr/bin/osascript',
|
|
134
|
+
};
|
|
135
|
+
const allowNonLoopback = config?.allowNonLoopback === true;
|
|
136
|
+
ctx.inject(['webServer'], (host) => {
|
|
137
|
+
host.effect(() => host.webServer.register({
|
|
138
|
+
kind: 'exact',
|
|
139
|
+
path: ROUTE_PATH,
|
|
140
|
+
handler: createHandler(ctx, probe, allowNonLoopback),
|
|
141
|
+
}), 'dsh-folder-path: clipboard route');
|
|
142
|
+
});
|
|
162
143
|
}
|
|
163
|
-
|
|
164
144
|
/**
|
|
165
145
|
* Build the request handler.
|
|
166
146
|
* @param ctx - host plugin context, for warnings.
|
|
@@ -169,66 +149,63 @@ export function apply(ctx, config = {}) {
|
|
|
169
149
|
* @returns the exact-route handler.
|
|
170
150
|
*/
|
|
171
151
|
function createHandler(ctx, probe, allowNonLoopback) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
sendJson(response, 200, { ok: true, paths: matches.map((entry) => entry.path), entries: matches })
|
|
229
|
-
}
|
|
152
|
+
return async (request, response) => {
|
|
153
|
+
if (request.method !== 'POST') {
|
|
154
|
+
sendJson(response, 405, { ok: false, reason: 'method-not-allowed' }, { allow: 'POST' });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!sameOrigin(request)) {
|
|
158
|
+
sendJson(response, 403, { ok: false, reason: 'cross-origin' });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (!allowNonLoopback && !isLoopback(request)) {
|
|
162
|
+
// The clipboard belongs to the machine running the host; a browser on
|
|
163
|
+
// another machine would be handed this machine's paths instead of its own.
|
|
164
|
+
sendJson(response, 403, { ok: false, reason: 'remote-not-supported', paths: [] });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
let names;
|
|
168
|
+
try {
|
|
169
|
+
const body = await readJsonBody(request);
|
|
170
|
+
const record = body !== null && typeof body === 'object' ? body : {};
|
|
171
|
+
names = Array.isArray(record.names)
|
|
172
|
+
? record.names.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
173
|
+
: [];
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
sendJson(response, 400, { ok: false, reason: 'bad-request', message: messageOf(error) });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!DESKTOP_PLATFORMS.has(process.platform)) {
|
|
180
|
+
sendJson(response, 200, { ok: false, reason: 'unsupported-platform', platform: process.platform, paths: [] });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
let entries;
|
|
184
|
+
try {
|
|
185
|
+
entries = await readClipboardEntries(probe);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
ctx.logger?.warn?.('dsh-folder-path: clipboard probe failed: %o', error);
|
|
189
|
+
sendJson(response, 200, { ok: false, reason: 'clipboard-error', message: messageOf(error), paths: [] });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const directories = entries.filter((entry) => entry.isDirectory === true);
|
|
193
|
+
const matches = matchDirectories(directories, names);
|
|
194
|
+
if (names.length > 0 && matches.length === 0) {
|
|
195
|
+
// The browser pasted something the clipboard no longer describes (or the
|
|
196
|
+
// name it reports differs from the on-disk name). Answering "no match" is
|
|
197
|
+
// the point: a plausible-looking wrong path would be worse than none.
|
|
198
|
+
sendJson(response, 200, {
|
|
199
|
+
ok: false,
|
|
200
|
+
reason: 'no-match',
|
|
201
|
+
paths: [],
|
|
202
|
+
clipboard: { directories: directories.length, total: entries.length },
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
sendJson(response, 200, { ok: true, paths: matches.map((entry) => entry.path), entries: matches });
|
|
207
|
+
};
|
|
230
208
|
}
|
|
231
|
-
|
|
232
209
|
/**
|
|
233
210
|
* Filter clipboard directories down to the ones the browser is pasting.
|
|
234
211
|
*
|
|
@@ -242,11 +219,11 @@ function createHandler(ctx, probe, allowNonLoopback) {
|
|
|
242
219
|
* @remarks Exported for the host test suite; the route is its only runtime caller.
|
|
243
220
|
*/
|
|
244
221
|
export function matchDirectories(directories, names) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
222
|
+
if (names.length === 0)
|
|
223
|
+
return directories;
|
|
224
|
+
const wanted = new Set(names.map((entry) => entry.toLowerCase()));
|
|
225
|
+
return directories.filter((entry) => wanted.has(entry.name.toLowerCase()));
|
|
248
226
|
}
|
|
249
|
-
|
|
250
227
|
/**
|
|
251
228
|
* Parse the macOS probe's stdout into absolute paths.
|
|
252
229
|
* @param stdout - the probe's stdout.
|
|
@@ -254,44 +231,44 @@ export function matchDirectories(directories, names) {
|
|
|
254
231
|
* @remarks Exported for the host test suite; the probe is its only runtime caller.
|
|
255
232
|
*/
|
|
256
233
|
export function parseProbePaths(stdout) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
234
|
+
const text = String(stdout ?? '').trim();
|
|
235
|
+
if (text === '')
|
|
236
|
+
return [];
|
|
237
|
+
let value;
|
|
238
|
+
try {
|
|
239
|
+
value = JSON.parse(text);
|
|
240
|
+
}
|
|
241
|
+
catch (cause) {
|
|
242
|
+
throw new Error('clipboard probe returned unreadable output', { cause });
|
|
243
|
+
}
|
|
244
|
+
if (!Array.isArray(value))
|
|
245
|
+
throw new Error('clipboard probe returned an unexpected shape');
|
|
246
|
+
return value.filter((entry) => typeof entry === 'string' && entry.length > 0);
|
|
267
247
|
}
|
|
268
|
-
|
|
269
248
|
/** The probe in flight, if any: several pastes must not fork a child process each. */
|
|
270
|
-
let inflight = null
|
|
271
|
-
let inflightKey = null
|
|
272
|
-
|
|
249
|
+
let inflight = null;
|
|
250
|
+
let inflightKey = null;
|
|
273
251
|
/**
|
|
274
252
|
* Read the platform clipboard's file list.
|
|
275
253
|
* @param probe - the probe's budget and executable overrides.
|
|
276
254
|
* @returns the clipboard items, each `{ path, name, isDirectory }`.
|
|
277
255
|
*/
|
|
278
256
|
function readClipboardEntries(probe) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
257
|
+
const executable = process.platform === 'darwin' ? probe.osascript : probe.powershell;
|
|
258
|
+
const key = `${process.platform}\u0000${executable}\u0000${String(probe.timeoutMs)}`;
|
|
259
|
+
if (inflight !== null && inflightKey === key)
|
|
260
|
+
return inflight;
|
|
261
|
+
const run = process.platform === 'darwin' ? probeMacClipboard(probe) : probeWindowsClipboard(probe);
|
|
262
|
+
const pending = run.finally(() => {
|
|
263
|
+
if (inflight === pending) {
|
|
264
|
+
inflight = null;
|
|
265
|
+
inflightKey = null;
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
inflight = pending;
|
|
269
|
+
inflightKey = key;
|
|
270
|
+
return pending;
|
|
293
271
|
}
|
|
294
|
-
|
|
295
272
|
/**
|
|
296
273
|
* Run one probe executable and return its stdout.
|
|
297
274
|
* @param executable - the executable to spawn.
|
|
@@ -300,42 +277,36 @@ function readClipboardEntries(probe) {
|
|
|
300
277
|
* @returns its stdout.
|
|
301
278
|
*/
|
|
302
279
|
function runProbe(executable, args, timeoutMs) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
313
|
-
resolve(String(stdout))
|
|
314
|
-
},
|
|
315
|
-
)
|
|
316
|
-
})
|
|
280
|
+
return new Promise((resolve, reject) => {
|
|
281
|
+
execFile(executable, args, { windowsHide: true, timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
282
|
+
if (error) {
|
|
283
|
+
reject(new Error(`clipboard probe failed: ${firstLine(stderr) || messageOf(error)}`, { cause: error }));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
resolve(String(stdout));
|
|
287
|
+
});
|
|
288
|
+
});
|
|
317
289
|
}
|
|
318
|
-
|
|
319
290
|
/**
|
|
320
291
|
* Read the Windows clipboard file-drop list.
|
|
321
292
|
* @param probe - the probe's budget and executable overrides.
|
|
322
293
|
* @returns the clipboard items, each `{ path, name, isDirectory }`.
|
|
323
294
|
*/
|
|
324
295
|
async function probeWindowsClipboard(probe) {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
296
|
+
const stdout = await runProbe(probe.powershell, ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_PROBE_SCRIPT], probe.timeoutMs);
|
|
297
|
+
let payload;
|
|
298
|
+
try {
|
|
299
|
+
payload = JSON.parse(Buffer.from(stdout.trim(), 'base64').toString('utf8'));
|
|
300
|
+
}
|
|
301
|
+
catch (cause) {
|
|
302
|
+
throw new Error('clipboard probe returned unreadable output', { cause });
|
|
303
|
+
}
|
|
304
|
+
if (!isRecord(payload) || !Array.isArray(payload.items))
|
|
305
|
+
return [];
|
|
306
|
+
// The PowerShell script builds `{ path, name, isDirectory }` objects itself;
|
|
307
|
+
// its output is trusted for shape, only the parse above can fail.
|
|
308
|
+
return payload.items;
|
|
337
309
|
}
|
|
338
|
-
|
|
339
310
|
/**
|
|
340
311
|
* Read the macOS clipboard's file URLs and describe them on disk.
|
|
341
312
|
*
|
|
@@ -346,38 +317,38 @@ async function probeWindowsClipboard(probe) {
|
|
|
346
317
|
* @returns the clipboard items, each `{ path, name, isDirectory }`.
|
|
347
318
|
*/
|
|
348
319
|
async function probeMacClipboard(probe) {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
320
|
+
const stdout = await runProbe(probe.osascript, ['-l', 'JavaScript', '-e', DARWIN_PROBE_SCRIPT], probe.timeoutMs);
|
|
321
|
+
const paths = parseProbePaths(stdout);
|
|
322
|
+
const entries = [];
|
|
323
|
+
for (const path of paths) {
|
|
324
|
+
let isDirectory = false;
|
|
325
|
+
try {
|
|
326
|
+
isDirectory = (await stat(path)).isDirectory();
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
isDirectory = false;
|
|
330
|
+
}
|
|
331
|
+
entries.push({ path, name: basename(path), isDirectory });
|
|
358
332
|
}
|
|
359
|
-
entries
|
|
360
|
-
}
|
|
361
|
-
return entries
|
|
333
|
+
return entries;
|
|
362
334
|
}
|
|
363
|
-
|
|
364
335
|
/**
|
|
365
336
|
* Read a JSON request body.
|
|
366
337
|
* @param request - the incoming request.
|
|
367
338
|
* @returns the parsed body, or `{}` for an empty one.
|
|
368
339
|
*/
|
|
369
340
|
async function readJsonBody(request) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
341
|
+
const chunks = [];
|
|
342
|
+
let size = 0;
|
|
343
|
+
for await (const chunk of request) {
|
|
344
|
+
size += chunk.length;
|
|
345
|
+
if (size > MAX_REQUEST_BYTES)
|
|
346
|
+
throw new Error('request body too large');
|
|
347
|
+
chunks.push(chunk);
|
|
348
|
+
}
|
|
349
|
+
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
350
|
+
return text === '' ? {} : JSON.parse(text);
|
|
379
351
|
}
|
|
380
|
-
|
|
381
352
|
/**
|
|
382
353
|
* Whether a request came from the page this host is serving.
|
|
383
354
|
*
|
|
@@ -388,25 +359,25 @@ async function readJsonBody(request) {
|
|
|
388
359
|
* @returns true when the request may read the clipboard.
|
|
389
360
|
*/
|
|
390
361
|
function sameOrigin(request) {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
362
|
+
const origin = request.headers.origin;
|
|
363
|
+
if (origin === undefined)
|
|
364
|
+
return true;
|
|
365
|
+
try {
|
|
366
|
+
return new URL(origin).host === request.headers.host;
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
398
371
|
}
|
|
399
|
-
|
|
400
372
|
/**
|
|
401
373
|
* Whether a request arrived over the loopback interface.
|
|
402
374
|
* @param request - the incoming request.
|
|
403
375
|
* @returns true for a loopback peer, including IPv4-mapped forms.
|
|
404
376
|
*/
|
|
405
377
|
function isLoopback(request) {
|
|
406
|
-
|
|
407
|
-
|
|
378
|
+
const address = request.socket?.remoteAddress ?? '';
|
|
379
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
408
380
|
}
|
|
409
|
-
|
|
410
381
|
/**
|
|
411
382
|
* Write a JSON response.
|
|
412
383
|
* @param response - the response to own.
|
|
@@ -415,43 +386,48 @@ function isLoopback(request) {
|
|
|
415
386
|
* @param headers - extra headers.
|
|
416
387
|
*/
|
|
417
388
|
function sendJson(response, status, value, headers = {}) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
389
|
+
const body = JSON.stringify(value);
|
|
390
|
+
response.writeHead(status, {
|
|
391
|
+
'content-type': 'application/json; charset=utf-8',
|
|
392
|
+
'content-length': Buffer.byteLength(body),
|
|
393
|
+
'cache-control': 'no-store',
|
|
394
|
+
...headers,
|
|
395
|
+
});
|
|
396
|
+
response.end(body);
|
|
426
397
|
}
|
|
427
|
-
|
|
428
398
|
/**
|
|
429
399
|
* First non-empty string of a value, for optional config knobs.
|
|
430
400
|
* @param value - the configured value.
|
|
431
401
|
* @returns the trimmed string, or an empty string.
|
|
432
402
|
*/
|
|
433
403
|
function firstString(value) {
|
|
434
|
-
|
|
404
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : '';
|
|
435
405
|
}
|
|
436
|
-
|
|
437
406
|
/**
|
|
438
407
|
* First non-empty line of a stream, for a compact error message.
|
|
439
408
|
* @param text - raw stderr text.
|
|
440
409
|
* @returns the first non-empty line, trimmed.
|
|
441
410
|
*/
|
|
442
411
|
function firstLine(text) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
412
|
+
const line = String(text ?? '')
|
|
413
|
+
.split('\n')
|
|
414
|
+
.map((entry) => entry.trim())
|
|
415
|
+
.find((entry) => entry.length > 0);
|
|
416
|
+
return line ?? '';
|
|
448
417
|
}
|
|
449
|
-
|
|
450
418
|
/**
|
|
451
419
|
* Human-readable message for an unknown thrown value.
|
|
452
420
|
* @param error - the caught value.
|
|
453
421
|
* @returns the message.
|
|
454
422
|
*/
|
|
455
423
|
function messageOf(error) {
|
|
456
|
-
|
|
424
|
+
return error instanceof Error ? error.message : String(error);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Whether a JSON-parsed value is an object, for narrowing `unknown` payloads.
|
|
428
|
+
* @param value - the value to test.
|
|
429
|
+
* @returns true for a non-null object.
|
|
430
|
+
*/
|
|
431
|
+
function isRecord(value) {
|
|
432
|
+
return typeof value === 'object' && value !== null;
|
|
457
433
|
}
|