dsh-folder-path 0.1.0 → 0.2.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/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
- "$ErrorActionPreference = 'Stop'",
64
- 'Add-Type -AssemblyName System.Windows.Forms',
65
- '$items = @()',
66
- 'foreach ($raw in [System.Windows.Forms.Clipboard]::GetFileDropList()) {',
67
- ' $full = [string]$raw',
68
- ' if ([string]::IsNullOrWhiteSpace($full)) { continue }',
69
- ' $trimmed = $full.TrimEnd([char]92, [char]47)',
70
- ' $name = if ($trimmed.Length -gt 0) { [System.IO.Path]::GetFileName($trimmed) } else { $full }',
71
- ' $items += [pscustomobject]@{',
72
- ' path = $full',
73
- ' name = $name',
74
- ' isDirectory = [System.IO.Directory]::Exists($full)',
75
- ' }',
76
- '}',
77
- '$payload = [pscustomobject]@{ ok = $true; items = @($items) }',
78
- '$json = ConvertTo-Json -InputObject $payload -Compress -Depth 4',
79
- '[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($json))',
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
- "ObjC.import('AppKit')",
99
- 'var pasteboard = $.NSPasteboard.generalPasteboard',
100
- 'var paths = []',
101
- 'function remember(value) {',
102
- " if (typeof value !== 'string' || value.length === 0) return",
103
- ' if (paths.indexOf(value) >= 0) return',
104
- ' paths.push(value)',
105
- '}',
106
- 'var urls = pasteboard.readObjectsForClassesOptions($.NSArray.arrayWithObject($.NSURL), $.NSDictionary.dictionary)',
107
- 'if (urls && urls.count > 0) {',
108
- ' for (var index = 0; index < urls.count; index += 1) {',
109
- ' var url = urls.objectAtIndex(index)',
110
- ' if (url.isFileURL === true) remember(ObjC.unwrap(url.path))',
111
- ' }',
112
- '}',
113
- 'try {',
114
- " var legacy = pasteboard.propertyListForType('NSFilenamesPboardType')",
115
- ' if (legacy && legacy.count > 0) {',
116
- ' for (var other = 0; other < legacy.count; other += 1) remember(ObjC.unwrap(legacy.objectAtIndex(other)))',
117
- ' }',
118
- '} catch (error) {',
119
- ' /* a pasteboard without the legacy flavour simply has nothing to add */',
120
- '}',
121
- 'JSON.stringify(paths)',
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
- if (config?.enabled === false) return
142
- const probe = {
143
- timeoutMs: Number(config?.timeoutMs) > 0 ? Number(config.timeoutMs) : DEFAULT_TIMEOUT_MS,
144
- powershell:
145
- firstString(config?.powershell) || firstString(process.env.DSH_FOLDER_PATH_POWERSHELL) || 'powershell.exe',
146
- osascript:
147
- firstString(config?.osascript) || firstString(process.env.DSH_FOLDER_PATH_OSASCRIPT) || '/usr/bin/osascript',
148
- }
149
- const allowNonLoopback = config?.allowNonLoopback === true
150
-
151
- ctx.inject(['webServer'], (host) => {
152
- host.effect(
153
- () =>
154
- host.webServer.register({
155
- kind: 'exact',
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
- return async (request, response) => {
173
- if (request.method !== 'POST') {
174
- sendJson(response, 405, { ok: false, reason: 'method-not-allowed' }, { allow: 'POST' })
175
- return
176
- }
177
- if (!sameOrigin(request)) {
178
- sendJson(response, 403, { ok: false, reason: 'cross-origin' })
179
- return
180
- }
181
- if (!allowNonLoopback && !isLoopback(request)) {
182
- // The clipboard belongs to the machine running the host; a browser on
183
- // another machine would be handed this machine's paths instead of its own.
184
- sendJson(response, 403, { ok: false, reason: 'remote-not-supported', paths: [] })
185
- return
186
- }
187
-
188
- let names
189
- try {
190
- const body = await readJsonBody(request)
191
- names = Array.isArray(body?.names)
192
- ? body.names.filter((entry) => typeof entry === 'string' && entry.length > 0)
193
- : []
194
- } catch (error) {
195
- sendJson(response, 400, { ok: false, reason: 'bad-request', message: messageOf(error) })
196
- return
197
- }
198
-
199
- if (!DESKTOP_PLATFORMS.has(process.platform)) {
200
- sendJson(response, 200, { ok: false, reason: 'unsupported-platform', platform: process.platform, paths: [] })
201
- return
202
- }
203
-
204
- let entries
205
- try {
206
- entries = await readClipboardEntries(probe)
207
- } catch (error) {
208
- ctx.logger?.warn?.('dsh-folder-path: clipboard probe failed: %o', error)
209
- sendJson(response, 200, { ok: false, reason: 'clipboard-error', message: messageOf(error), paths: [] })
210
- return
211
- }
212
-
213
- const directories = entries.filter((entry) => entry.isDirectory === true)
214
- const matches = matchDirectories(directories, names)
215
- if (names.length > 0 && matches.length === 0) {
216
- // The browser pasted something the clipboard no longer describes (or the
217
- // name it reports differs from the on-disk name). Answering "no match" is
218
- // the point: a plausible-looking wrong path would be worse than none.
219
- sendJson(response, 200, {
220
- ok: false,
221
- reason: 'no-match',
222
- paths: [],
223
- clipboard: { directories: directories.length, total: entries.length },
224
- })
225
- return
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
- if (names.length === 0) return directories
246
- const wanted = new Set(names.map((entry) => entry.toLowerCase()))
247
- return directories.filter((entry) => wanted.has(entry.name.toLowerCase()))
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
- const text = String(stdout ?? '').trim()
258
- if (text === '') return []
259
- let value
260
- try {
261
- value = JSON.parse(text)
262
- } catch (cause) {
263
- throw new Error('clipboard probe returned unreadable output', { cause })
264
- }
265
- if (!Array.isArray(value)) throw new Error('clipboard probe returned an unexpected shape')
266
- return value.filter((entry) => typeof entry === 'string' && entry.length > 0)
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
- const executable = process.platform === 'darwin' ? probe.osascript : probe.powershell
280
- const key = `${process.platform}\u0000${executable}\u0000${String(probe.timeoutMs)}`
281
- if (inflight !== null && inflightKey === key) return inflight
282
-
283
- const run = process.platform === 'darwin' ? probeMacClipboard(probe) : probeWindowsClipboard(probe)
284
- const pending = run.finally(() => {
285
- if (inflight === pending) {
286
- inflight = null
287
- inflightKey = null
288
- }
289
- })
290
- inflight = pending
291
- inflightKey = key
292
- return pending
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
- return new Promise((resolve, reject) => {
304
- execFile(
305
- executable,
306
- args,
307
- { windowsHide: true, timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 },
308
- (error, stdout, stderr) => {
309
- if (error) {
310
- reject(new Error(`clipboard probe failed: ${firstLine(stderr) || messageOf(error)}`, { cause: error }))
311
- return
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
- const stdout = await runProbe(
326
- probe.powershell,
327
- ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_PROBE_SCRIPT],
328
- probe.timeoutMs,
329
- )
330
- let payload
331
- try {
332
- payload = JSON.parse(Buffer.from(stdout.trim(), 'base64').toString('utf8'))
333
- } catch (cause) {
334
- throw new Error('clipboard probe returned unreadable output', { cause })
335
- }
336
- return Array.isArray(payload?.items) ? payload.items : []
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
- const stdout = await runProbe(probe.osascript, ['-l', 'JavaScript', '-e', DARWIN_PROBE_SCRIPT], probe.timeoutMs)
350
- const paths = parseProbePaths(stdout)
351
- const entries = []
352
- for (const path of paths) {
353
- let isDirectory = false
354
- try {
355
- isDirectory = (await stat(path)).isDirectory()
356
- } catch {
357
- isDirectory = false
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.push({ path, name: basename(path), isDirectory })
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
- const chunks = []
371
- let size = 0
372
- for await (const chunk of request) {
373
- size += chunk.length
374
- if (size > MAX_REQUEST_BYTES) throw new Error('request body too large')
375
- chunks.push(chunk)
376
- }
377
- const text = Buffer.concat(chunks).toString('utf8').trim()
378
- return text === '' ? {} : JSON.parse(text)
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
- const origin = request.headers.origin
392
- if (origin === undefined) return true
393
- try {
394
- return new URL(origin).host === request.headers.host
395
- } catch {
396
- return false
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
- const address = request.socket?.remoteAddress ?? ''
407
- return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
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
- const body = JSON.stringify(value)
419
- response.writeHead(status, {
420
- 'content-type': 'application/json; charset=utf-8',
421
- 'content-length': Buffer.byteLength(body),
422
- 'cache-control': 'no-store',
423
- ...headers,
424
- })
425
- response.end(body)
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
- return typeof value === 'string' && value.trim().length > 0 ? value.trim() : ''
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
- const line = String(text ?? '')
444
- .split('\n')
445
- .map((entry) => entry.trim())
446
- .find((entry) => entry.length > 0)
447
- return line ?? ''
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
- return error instanceof Error ? error.message : String(error)
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
  }