@deckbuilderkit/server-mods 0.0.0-stage → 0.1.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/src/http.ts ADDED
@@ -0,0 +1,443 @@
1
+ import { extname } from 'node:path';
2
+ import { saveFileAs, writeFileAtomic } from '@deckbuilderkit/platform';
3
+ import { AppError, invalid, isRecord, type Routes } from '@deckbuilderkit/server-core';
4
+ import {
5
+ HTTP_MAX_BODY_BYTES,
6
+ HTTP_MAX_BODY_CHARS,
7
+ HTTP_MAX_FORM_FIELDS,
8
+ HTTP_MAX_HEADERS,
9
+ HTTP_MAX_URL_CHARS,
10
+ HTTP_METHODS,
11
+ HTTP_TIMEOUT_MAX_MS,
12
+ HTTP_TIMEOUT_MIN_MS,
13
+ type HttpMethod,
14
+ type HttpRequestDraft,
15
+ type HttpRequestSpec,
16
+ type HttpResponseInfo,
17
+ type RestHistoryEntry,
18
+ } from '@deckbuilderkit/server-core/protocol';
19
+ import { isBinary } from './bytes.ts';
20
+ import { fromPlatform } from './platform-errors.ts';
21
+ import { variableResolver, type VariableResolver } from './rest-env.ts';
22
+
23
+ export interface HttpExecutorOptions {
24
+ /** Most body bytes read; the rest is cut and the response is marked `truncated`. */
25
+ maxBodyBytes?: number;
26
+ /** How many response bodies stay available to `body`/save; the oldest is dropped. */
27
+ keep?: number;
28
+ fetchImpl?: typeof fetch;
29
+ }
30
+
31
+ export interface KeptBody {
32
+ bytes: Uint8Array;
33
+ contentType: string;
34
+ /** The final URL. */
35
+ url: string;
36
+ }
37
+
38
+ export interface HttpExecutor {
39
+ send(spec: HttpRequestSpec, signal?: AbortSignal): Promise<HttpResponseInfo>;
40
+ body(id: string): KeptBody | undefined;
41
+ }
42
+
43
+ export interface HttpRoutesOptions {
44
+ /**
45
+ * Headers to add for a saved request's secret, for names the request doesn't have; the values never reach the client.
46
+ * `url` is the resolved URL being sent; `resolve` substitutes the environment in other text (unknown names stay as they are).
47
+ * The returned values are resolved by the route; an unknown name there is `unknownVariable`.
48
+ */
49
+ secretHeaders?: (ref: string, url: string, resolve: (text: string) => string) => Promise<Array<[string, string]>>;
50
+ /** The variables of environment `id` for `{{name}}` (secrets included); throws for an unknown id. */
51
+ environmentVars?: (id: string) => Promise<Record<string, string>>;
52
+ /** Called once per send, failures included (status 0 and `error`), with the unresolved request minus Authorization. */
53
+ onSend?: (entry: Omit<RestHistoryEntry, 'id'>) => unknown;
54
+ save?: typeof saveFileAs;
55
+ }
56
+
57
+ const MAX_HEADER_NAME_CHARS = 200;
58
+ const MAX_HEADER_VALUE_CHARS = 8192;
59
+ const DEFAULT_TIMEOUT_MS = 60_000;
60
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
61
+ const HEADER_VALUE_BAD = /[\r\n\0]/;
62
+
63
+ const badRequest = () => invalid('badRequest', 'Invalid request.');
64
+ const formChars = (rows: Array<{ name: string; value: string }>) => rows.reduce((n, r) => n + r.name.length + r.value.length, 0);
65
+ const validTimeout = (ms: unknown): ms is number => typeof ms === 'number' && Number.isInteger(ms) && ms >= HTTP_TIMEOUT_MIN_MS && ms <= HTTP_TIMEOUT_MAX_MS;
66
+
67
+ /** The request as a fresh, fully defaulted spec; throws an `invalid` AppError for anything out of range (never clamps). */
68
+ export function validateRequest(input: unknown): HttpRequestSpec {
69
+ if (!isRecord(input)) throw badRequest();
70
+ const { method, url, headers = [], body, bodyKind, form, timeoutMs = DEFAULT_TIMEOUT_MS, insecure = false, followRedirects = true } = input;
71
+ if (typeof method !== 'string') throw badRequest();
72
+ if (!(HTTP_METHODS as readonly string[]).includes(method)) throw invalid('badMethod', 'This HTTP method is not supported.');
73
+ if (typeof url !== 'string') throw badRequest();
74
+ let parsed: URL;
75
+ try {
76
+ parsed = new URL(url);
77
+ } catch {
78
+ throw invalid('badUrl', 'Enter a valid http:// or https:// URL.');
79
+ }
80
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw invalid('badUrl', 'Enter a valid http:// or https:// URL.');
81
+ if (!Array.isArray(headers)) throw badRequest();
82
+ const pairs: Array<[string, string]> = headers.map((pair) => {
83
+ if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== 'string' || typeof pair[1] !== 'string') throw badRequest();
84
+ return [pair[0], pair[1]];
85
+ });
86
+ if (pairs.length > HTTP_MAX_HEADERS || pairs.some(([name, value]) => !HEADER_NAME.test(name) || HEADER_VALUE_BAD.test(value))) {
87
+ throw invalid('badHeader', 'A header name or value is not valid.');
88
+ }
89
+ if (body !== undefined && (typeof body !== 'string' || body.length > HTTP_MAX_BODY_CHARS)) throw badRequest();
90
+ let fields: Array<[string, string]> = [];
91
+ if (bodyKind !== undefined) {
92
+ if ((bodyKind !== 'form' && bodyKind !== 'multipart') || body !== undefined || !Array.isArray(form) || form.length > HTTP_MAX_FORM_FIELDS) throw badRequest();
93
+ fields = form.map((pair) => {
94
+ if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== 'string' || typeof pair[1] !== 'string') throw badRequest();
95
+ return [pair[0], pair[1]];
96
+ });
97
+ if (formChars(fields.map(([name, value]) => ({ name, value }))) > HTTP_MAX_BODY_CHARS) throw badRequest();
98
+ } else if (form !== undefined) throw badRequest();
99
+ if ((body || fields.length) && (method === 'GET' || method === 'HEAD')) throw invalid('bodyNotAllowed', 'GET and HEAD requests cannot have a body.');
100
+ if (!validTimeout(timeoutMs)) throw badRequest();
101
+ if (typeof insecure !== 'boolean' || typeof followRedirects !== 'boolean') throw badRequest();
102
+ const spec: HttpRequestSpec = { method: method as HttpMethod, url, headers: pairs, timeoutMs, insecure, followRedirects };
103
+ if (body) spec.body = body;
104
+ if (fields.length) {
105
+ spec.bodyKind = bodyKind as 'form' | 'multipart';
106
+ spec.form = fields;
107
+ }
108
+ return spec;
109
+ }
110
+
111
+ const resolvePairs = (list: unknown, resolve: (text: string) => string, names: boolean) =>
112
+ Array.isArray(list)
113
+ ? list.map((pair) =>
114
+ Array.isArray(pair) && pair.length === 2 && typeof pair[0] === 'string' && typeof pair[1] === 'string'
115
+ ? [names ? resolve(pair[0]) : pair[0], resolve(pair[1])]
116
+ : pair,
117
+ )
118
+ : list;
119
+
120
+ /**
121
+ * Raw send input with `{{name}}` resolved in the URL, header values, body and form fields; other
122
+ * values are left for `validateRequest` to reject. Unknown names are collected in `vars.missing`.
123
+ */
124
+ export function resolveRequest(input: unknown, vars: VariableResolver): unknown {
125
+ if (!isRecord(input)) return input;
126
+ const out: Record<string, unknown> = { ...input };
127
+ if (typeof out.url === 'string') out.url = vars.resolve(out.url);
128
+ if (typeof out.body === 'string') out.body = vars.resolve(out.body);
129
+ out.headers = resolvePairs(out.headers, vars.resolve, false);
130
+ out.form = resolvePairs(out.form, vars.resolve, true);
131
+ if (out.headers === undefined) delete out.headers;
132
+ if (out.form === undefined) delete out.form;
133
+ return out;
134
+ }
135
+
136
+ const BODY_LANGS = ['json', 'xml', 'text', 'form', 'multipart'] as const;
137
+ const badSavedRequest = () => invalid('badSavedRequest', 'The request is not valid.');
138
+
139
+ /**
140
+ * A saved request draft as a fresh object; throws `badSavedRequest` for anything out of range.
141
+ * Looser than `validateRequest` (an empty or unfinished URL can be saved), but within its limits.
142
+ */
143
+ export function validateDraft(raw: unknown): HttpRequestDraft {
144
+ if (!isRecord(raw)) throw badSavedRequest();
145
+ const { method, url, headers, body, bodyLang, form, authKind, timeoutMs, insecure, followRedirects } = raw;
146
+ if (!(HTTP_METHODS as readonly unknown[]).includes(method)) throw badSavedRequest();
147
+ if (typeof url !== 'string' || url.length > HTTP_MAX_URL_CHARS) throw badSavedRequest();
148
+ if (!Array.isArray(headers) || headers.length > HTTP_MAX_HEADERS) throw badSavedRequest();
149
+ if (typeof body !== 'string' || body.length > HTTP_MAX_BODY_CHARS) throw badSavedRequest();
150
+ if (!(BODY_LANGS as readonly unknown[]).includes(bodyLang)) throw badSavedRequest();
151
+ const out: HttpRequestDraft = {
152
+ method: method as HttpMethod,
153
+ url,
154
+ headers: headers.map((h) => {
155
+ if (
156
+ !isRecord(h) ||
157
+ typeof h.name !== 'string' ||
158
+ h.name.length > MAX_HEADER_NAME_CHARS ||
159
+ typeof h.value !== 'string' ||
160
+ h.value.length > MAX_HEADER_VALUE_CHARS ||
161
+ typeof h.enabled !== 'boolean'
162
+ ) {
163
+ throw badSavedRequest();
164
+ }
165
+ return { name: h.name, value: h.value, enabled: h.enabled };
166
+ }),
167
+ body,
168
+ bodyLang: bodyLang as HttpRequestDraft['bodyLang'],
169
+ };
170
+ if (form !== undefined) {
171
+ if (!Array.isArray(form) || form.length > HTTP_MAX_FORM_FIELDS) throw badSavedRequest();
172
+ out.form = form.map((f) => {
173
+ if (!isRecord(f) || typeof f.name !== 'string' || typeof f.value !== 'string') throw badSavedRequest();
174
+ return { name: f.name, value: f.value };
175
+ });
176
+ if (formChars(out.form) > HTTP_MAX_BODY_CHARS) throw badSavedRequest();
177
+ }
178
+ if (authKind !== undefined) {
179
+ if (authKind !== 'basic' && authKind !== 'bearer') throw badSavedRequest();
180
+ out.authKind = authKind;
181
+ }
182
+ if (timeoutMs !== undefined) {
183
+ if (!validTimeout(timeoutMs)) throw badSavedRequest();
184
+ out.timeoutMs = timeoutMs;
185
+ }
186
+ if (insecure !== undefined) {
187
+ if (typeof insecure !== 'boolean') throw badSavedRequest();
188
+ out.insecure = insecure;
189
+ }
190
+ if (followRedirects !== undefined) {
191
+ if (typeof followRedirects !== 'boolean') throw badSavedRequest();
192
+ out.followRedirects = followRedirects;
193
+ }
194
+ return out;
195
+ }
196
+
197
+ const AUTH_HEADERS = new Set(['authorization', 'proxy-authorization']);
198
+
199
+ /** A copy of the draft without Authorization and Proxy-Authorization headers. */
200
+ export function withoutAuthorization(draft: HttpRequestDraft): HttpRequestDraft {
201
+ return { ...draft, headers: draft.headers.filter((h) => !AUTH_HEADERS.has(h.name.trim().toLowerCase())) };
202
+ }
203
+
204
+ const pairsOf = (list: unknown) =>
205
+ Array.isArray(list) ? list.filter((p): p is [string, string] => Array.isArray(p) && typeof p[0] === 'string' && typeof p[1] === 'string') : [];
206
+
207
+ /**
208
+ * The draft the history keeps for a send body: its `draft` when valid, else one built from the
209
+ * unresolved `request` (body language from Content-Type). Null when neither is usable.
210
+ */
211
+ export function historyDraft(body: Record<string, unknown>): HttpRequestDraft | null {
212
+ try {
213
+ if (body.draft !== undefined) return withoutAuthorization(validateDraft(body.draft));
214
+ } catch {
215
+ // fall back to the request
216
+ }
217
+ const req = body.request;
218
+ if (!isRecord(req)) return null;
219
+ const headers = pairsOf(req.headers);
220
+ const type = (headers.find(([n]) => n.toLowerCase() === 'content-type')?.[1] ?? '').toLowerCase();
221
+ const scheme = headers.find(([n]) => n.toLowerCase() === 'authorization')?.[1].trim().split(/\s+/)[0]?.toLowerCase();
222
+ const kind = req.bodyKind === 'form' || req.bodyKind === 'multipart' ? req.bodyKind : undefined;
223
+ const raw: Record<string, unknown> = {
224
+ method: req.method,
225
+ url: req.url,
226
+ headers: headers.map(([name, value]) => ({ name, value, enabled: true })),
227
+ body: typeof req.body === 'string' ? req.body : '',
228
+ bodyLang: kind ?? (type.includes('json') ? 'json' : type.includes('xml') ? 'xml' : 'text'),
229
+ timeoutMs: req.timeoutMs,
230
+ insecure: req.insecure,
231
+ followRedirects: req.followRedirects,
232
+ };
233
+ if (kind) raw.form = pairsOf(req.form).map(([name, value]) => ({ name, value }));
234
+ if (scheme === 'basic' || scheme === 'bearer') raw.authKind = scheme;
235
+ try {
236
+ return withoutAuthorization(validateDraft(raw));
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
242
+ const isTextType = (contentType: string) => {
243
+ const type = contentType.split(';')[0]!.trim().toLowerCase();
244
+ return (
245
+ type === '' ||
246
+ type.startsWith('text/') ||
247
+ type.includes('json') ||
248
+ type.includes('xml') ||
249
+ type.includes('javascript') ||
250
+ type === 'application/x-www-form-urlencoded'
251
+ );
252
+ };
253
+
254
+ /**
255
+ * The HTTP engine of the base: sends a validated request from the server (no CORS, and the
256
+ * session token never leaves) and keeps the last `keep` response bodies for saving.
257
+ */
258
+ export function httpExecutor(opts: HttpExecutorOptions = {}): HttpExecutor {
259
+ const { maxBodyBytes = HTTP_MAX_BODY_BYTES, keep = 10, fetchImpl = fetch } = opts;
260
+ const kept = new Map<string, KeptBody>();
261
+
262
+ async function read(res: Response): Promise<{ bytes: Uint8Array; truncated: boolean }> {
263
+ const chunks: Uint8Array[] = [];
264
+ let total = 0;
265
+ let truncated = false;
266
+ const reader = res.body?.getReader();
267
+ while (reader) {
268
+ const { done, value } = await reader.read();
269
+ if (done) break;
270
+ const room = maxBodyBytes - total;
271
+ if (value.length > room) {
272
+ chunks.push(value.subarray(0, room));
273
+ total += room;
274
+ truncated = true;
275
+ await reader.cancel().catch(() => {});
276
+ break;
277
+ }
278
+ chunks.push(value);
279
+ total += value.length;
280
+ }
281
+ const bytes = new Uint8Array(total);
282
+ let at = 0;
283
+ for (const chunk of chunks) {
284
+ bytes.set(chunk, at);
285
+ at += chunk.length;
286
+ }
287
+ return { bytes, truncated };
288
+ }
289
+
290
+ return {
291
+ async send(spec, signal) {
292
+ const timeout = AbortSignal.timeout(spec.timeoutMs ?? DEFAULT_TIMEOUT_MS);
293
+ const started = performance.now();
294
+ let headers = spec.headers;
295
+ let body: string | URLSearchParams | FormData | undefined = spec.body;
296
+ if (spec.bodyKind && spec.form) {
297
+ if (spec.bodyKind === 'multipart') {
298
+ const data = new FormData();
299
+ for (const [name, value] of spec.form) data.append(name, value);
300
+ body = data;
301
+ headers = headers.filter(([name]) => name.toLowerCase() !== 'content-type');
302
+ } else {
303
+ body = new URLSearchParams(spec.form);
304
+ if (!headers.some(([name]) => name.toLowerCase() === 'content-type')) {
305
+ headers = [...headers, ['Content-Type', 'application/x-www-form-urlencoded']];
306
+ }
307
+ }
308
+ }
309
+ try {
310
+ const res = await fetchImpl(spec.url, {
311
+ method: spec.method,
312
+ headers,
313
+ body,
314
+ redirect: spec.followRedirects === false ? 'manual' : 'follow',
315
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
316
+ tls: { rejectUnauthorized: !spec.insecure },
317
+ });
318
+ const ms = Math.round(performance.now() - started);
319
+ const { bytes, truncated } = await read(res);
320
+ const totalMs = Math.round(performance.now() - started);
321
+ const contentType = res.headers.get('content-type') ?? '';
322
+ const id = crypto.randomUUID();
323
+ kept.set(id, { bytes, contentType, url: res.url });
324
+ while (kept.size > keep) kept.delete(kept.keys().next().value as string);
325
+ const info: HttpResponseInfo = {
326
+ id,
327
+ status: res.status,
328
+ statusText: res.statusText,
329
+ url: res.url,
330
+ redirected: res.redirected,
331
+ headers: [...res.headers],
332
+ ms,
333
+ totalMs,
334
+ size: bytes.length,
335
+ truncated,
336
+ contentType,
337
+ };
338
+ if (isTextType(contentType) && !isBinary(bytes)) info.text = new TextDecoder().decode(bytes);
339
+ else info.binary = true;
340
+ return info;
341
+ } catch (err) {
342
+ if (signal?.aborted) throw err;
343
+ if (timeout.aborted) throw invalid('httpTimeout', 'The request timed out.');
344
+ if (err instanceof AppError) throw err;
345
+ throw new AppError('invalid', 'httpFailed', 'The request failed.', { detail: err instanceof Error ? err.message : String(err) });
346
+ }
347
+ },
348
+ body: (id) => kept.get(id),
349
+ };
350
+ }
351
+
352
+ const EXTENSIONS: Array<[string, string]> = [
353
+ ['json', '.json'],
354
+ ['html', '.html'],
355
+ ['xml', '.xml'],
356
+ ['text/', '.txt'],
357
+ ];
358
+
359
+ /** A file name for a saved response: the last segment of the URL path, made safe, with an extension from the content type when it has none. */
360
+ export function suggestedName(url: string, contentType: string): string {
361
+ let name = new URL(url).pathname.split('/').pop() ?? '';
362
+ try {
363
+ name = decodeURIComponent(name);
364
+ } catch {
365
+ // Not valid percent-encoding: keep the name as it is.
366
+ }
367
+ name = name
368
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
369
+ .replace(/[. ]+$/, '')
370
+ .slice(0, 100)
371
+ .replace(/[. ]+$/, '');
372
+ if (name === '') name = 'response';
373
+ if (extname(name) === '') {
374
+ const type = contentType.toLowerCase();
375
+ name += EXTENSIONS.find(([part]) => type.includes(part))?.[1] ?? '';
376
+ }
377
+ return name;
378
+ }
379
+
380
+ /** `POST /api/http/send` (`HttpSendInput`: `{ request, secretRef?, environment? }`, `{{name}}` resolved before sending) and `POST /api/http/save` (`{ id }`, asks for the target in a server-side dialog). */
381
+ export function httpRoutes(executor: HttpExecutor, opts: HttpRoutesOptions = {}): Routes {
382
+ const { secretHeaders, environmentVars, onSend, save = saveFileAs } = opts;
383
+
384
+ async function send(body: Record<string, unknown>, signal: AbortSignal): Promise<HttpResponseInfo> {
385
+ const values = typeof body.environment === 'string' && environmentVars ? await environmentVars(body.environment) : {};
386
+ const vars = variableResolver(values);
387
+ const request = resolveRequest(body.request, vars);
388
+ vars.check();
389
+ const spec = validateRequest(request);
390
+ if (typeof body.secretRef === 'string' && secretHeaders) {
391
+ const have = new Set(spec.headers.map(([name]) => name.toLowerCase()));
392
+ const loose = variableResolver(values);
393
+ for (const [name, raw] of await secretHeaders(body.secretRef, spec.url, loose.resolve)) {
394
+ if (have.has(name.toLowerCase())) continue;
395
+ const value = vars.resolve(raw);
396
+ vars.check();
397
+ if (HEADER_VALUE_BAD.test(value)) throw invalid('badHeader', 'A header name or value is not valid.');
398
+ have.add(name.toLowerCase());
399
+ spec.headers.push([name, value]);
400
+ }
401
+ }
402
+ return executor.send(spec, signal);
403
+ }
404
+
405
+ return {
406
+ 'POST /api/http/send': async ({ req, json }) => {
407
+ const body = await json();
408
+ if (!isRecord(body) || (body.environment !== undefined && typeof body.environment !== 'string')) throw badRequest();
409
+ if (!onSend) return send(body, req.signal);
410
+ const at = Date.now();
411
+ const record = (status: number, ms: number, error?: string) => {
412
+ const draft = historyDraft(body);
413
+ if (!draft) return;
414
+ const entry: Omit<RestHistoryEntry, 'id'> = { at, method: draft.method, url: draft.url, status, ms, draft };
415
+ if (error) entry.error = error;
416
+ try {
417
+ void Promise.resolve(onSend(entry)).catch(() => {});
418
+ } catch {
419
+ // history must never fail a send
420
+ }
421
+ };
422
+ try {
423
+ const res = await send(body, req.signal);
424
+ record(res.status, res.totalMs);
425
+ return res;
426
+ } catch (err) {
427
+ record(0, Date.now() - at, err instanceof AppError ? err.code : 'error');
428
+ throw err;
429
+ }
430
+ },
431
+
432
+ 'POST /api/http/save': async ({ json }) => {
433
+ const body = await json();
434
+ if (!isRecord(body) || typeof body.id !== 'string') throw badRequest();
435
+ const response = executor.body(body.id);
436
+ if (!response) throw invalid('responseGone', 'This response is no longer kept; send the request again.');
437
+ const path = await save({ suggestedName: suggestedName(response.url, response.contentType) }).catch(fromPlatform);
438
+ if (path === null) return { path: null };
439
+ await writeFileAtomic(path, response.bytes);
440
+ return { path, bytes: response.bytes.length };
441
+ },
442
+ };
443
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ // Opt-in route modules: an app spreads the ones it wants into its routes.
2
+ export * from './backup.ts';
3
+ export * from './dialogs.ts';
4
+ export * from './editors.ts';
5
+ export * from './encoding.ts';
6
+ export * from './git.ts';
7
+ export * from './http.ts';
8
+ export * from './info.ts';
9
+ export * from './platform-errors.ts';
10
+ export * from './records.ts';
11
+ export * from './rest-env.ts';
12
+ export * from './rest-history.ts';
13
+ export * from './rest-tabs.ts';
14
+ export * from './rooted-fs.ts';
15
+ export * from './settings.ts';
16
+ export type { SqliteContent } from './sqlite.ts';
17
+ export * from './theme-head.ts';
18
+ export * from './update.ts';
package/src/info.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { hostname } from 'node:os';
2
+ import type { AppIdentity, Routes } from '@deckbuilderkit/server-core';
3
+
4
+ export interface AppInfo {
5
+ name: string;
6
+ version: string;
7
+ platform: string;
8
+ arch: string;
9
+ hostname: string;
10
+ bun: string;
11
+ dataDir: string;
12
+ startedAt: string;
13
+ }
14
+
15
+ /** GET /api/info: app version and runtime facts, for an About or Home screen. */
16
+ export function infoRoutes(opts: { app: AppIdentity; version: string; dataDir: string }): Routes {
17
+ const startedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
18
+ return {
19
+ 'GET /api/info': (): AppInfo => ({
20
+ name: opts.app.name,
21
+ version: opts.version,
22
+ platform: process.platform,
23
+ arch: process.arch,
24
+ hostname: hostname(),
25
+ bun: Bun.version,
26
+ dataDir: opts.dataDir,
27
+ startedAt,
28
+ }),
29
+ };
30
+ }
@@ -0,0 +1,8 @@
1
+ import { PlatformError } from '@deckbuilderkit/platform';
2
+ import { AppError } from '@deckbuilderkit/server-core';
3
+
4
+ /** Rethrows a PlatformError as an `invalid` AppError the UI translates (`error.<code>`); anything else unchanged. */
5
+ export function fromPlatform(err: unknown): never {
6
+ if (err instanceof PlatformError) throw new AppError('invalid', err.code, err.message, { detail: err.detail });
7
+ throw err;
8
+ }