@deckbuilderkit/server-mods 0.0.0-stage → 0.1.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/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "@deckbuilderkit/server-mods",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/adrianogpena/deckbuilder.git",
8
+ "directory": "packages/server-mods"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/*.test.ts"
16
+ ],
17
+ "type": "module",
18
+ "exports": {
19
+ ".": "./src/index.ts",
20
+ "./archive-worker": "./src/archive-worker.ts"
21
+ },
22
+ "dependencies": {
23
+ "7z-wasm": "^1.2.0",
24
+ "@deckbuilderkit/platform": "0.1.1",
25
+ "@deckbuilderkit/server-core": "0.1.1"
26
+ }
27
+ }
@@ -0,0 +1,13 @@
1
+ import { run, type SevenZipJob } from './archive.ts';
2
+
3
+ declare const self: Worker;
4
+
5
+ // One 7z run per worker: the caller posts a job, gets the result and terminates the worker.
6
+ self.onmessage = async (e: MessageEvent<SevenZipJob>) => {
7
+ try {
8
+ const { args, mounts, cwd } = e.data;
9
+ self.postMessage(await run(args, mounts, cwd));
10
+ } catch (err) {
11
+ self.postMessage({ error: (err as Error).message ?? String(err) });
12
+ }
13
+ };
package/src/archive.ts ADDED
@@ -0,0 +1,205 @@
1
+ /// <reference path="./wasm.d.ts" />
2
+ import { mkdtemp, rm, stat } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, dirname, join } from 'node:path';
5
+ import SevenZip from '7z-wasm';
6
+ import wasmFile from '7z-wasm/7zz.wasm' with { type: 'file' };
7
+ import { invalid } from '@deckbuilderkit/server-core';
8
+
9
+ export interface ArchiveEntry {
10
+ /** `/`-separated path inside the archive. */
11
+ path: string;
12
+ dir: boolean;
13
+ /** Bytes; 0 for folders. */
14
+ size: number;
15
+ /** Epoch ms, or 0 when the archive has none. */
16
+ mtime: number;
17
+ encrypted: boolean;
18
+ }
19
+
20
+ export interface ArchiveListing {
21
+ entries: ArchiveEntry[];
22
+ /** More entries than `MAX_ENTRIES`: the rest are left out. */
23
+ truncated: boolean;
24
+ }
25
+
26
+ const MAX_ENTRIES = 20_000;
27
+ // A .tar.gz lists as one .tar; one this small is unpacked in memory to list the files inside.
28
+ const MAX_NESTED_TAR_BYTES = 256_000_000;
29
+
30
+ let wasm: Promise<ArrayBuffer> | undefined;
31
+
32
+ export interface SevenZipJob {
33
+ args: string[];
34
+ /** Emscripten mount point -> host folder, mounted through NODEFS. */
35
+ mounts: Record<string, string>;
36
+ cwd?: string;
37
+ }
38
+
39
+ export interface SevenZipResult {
40
+ out: string[];
41
+ err: string[];
42
+ code: number;
43
+ }
44
+
45
+ export async function run(args: string[], mounts: Record<string, string>, cwd?: string): Promise<SevenZipResult> {
46
+ wasm ??= Bun.file(wasmFile).arrayBuffer();
47
+ const out: string[] = [];
48
+ const err: string[] = [];
49
+ const m = await SevenZip({ wasmBinary: await wasm, print: (s) => out.push(s), printErr: (s) => err.push(s) });
50
+ for (const [point, root] of Object.entries(mounts)) {
51
+ m.FS.mkdir(point);
52
+ m.FS.mount(m.NODEFS, { root }, point);
53
+ }
54
+ if (cwd) m.FS.chdir(cwd);
55
+ let code = 0;
56
+ try {
57
+ m.callMain(args);
58
+ } catch (e) {
59
+ code = (e as { status?: number }).status ?? 1;
60
+ }
61
+ return { out, err, code };
62
+ }
63
+
64
+ /** Parses `7z l -slt -ba`: blocks of `Key = value` lines; the first key's line may carry progress text before it. */
65
+ export function parseListing(lines: string[]): ArchiveEntry[] {
66
+ const entries: ArchiveEntry[] = [];
67
+ let cur: Record<string, string> = {};
68
+ const flush = () => {
69
+ if (cur.Path !== undefined) {
70
+ const mtime = Date.parse(`${(cur.Modified ?? '').slice(0, 19).replace(' ', 'T')}Z`);
71
+ entries.push({
72
+ path: cur.Path.replaceAll('\\', '/'),
73
+ dir: cur.Folder === '+' || (cur.Attributes ?? '').startsWith('D') || /^d/.test(cur.Attributes ?? ''),
74
+ size: Number(cur.Size) || 0,
75
+ mtime: Number.isNaN(mtime) ? 0 : mtime,
76
+ encrypted: cur.Encrypted === '+',
77
+ });
78
+ }
79
+ cur = {};
80
+ };
81
+ for (const raw of lines) {
82
+ const line = raw.includes('Path = ') ? raw.slice(raw.indexOf('Path = ')) : raw;
83
+ if (line.trim() === '') {
84
+ flush();
85
+ continue;
86
+ }
87
+ const i = line.indexOf(' = ');
88
+ if (i > 0) {
89
+ const key = line.slice(0, i);
90
+ if (key === 'Path' && cur.Path !== undefined) flush();
91
+ cur[key] = line.slice(i + 3);
92
+ }
93
+ }
94
+ flush();
95
+ return entries;
96
+ }
97
+
98
+ async function list(abs: string, extra: string[] = []): Promise<ArchiveEntry[]> {
99
+ const { out, err, code } = await run(['l', '-slt', '-ba', '-bd', ...extra, `/host/${basename(abs)}`], { '/host': dirname(abs) });
100
+ const entries = parseListing(out);
101
+ if (entries.length === 0 && (code !== 0 || err.length > 0)) {
102
+ throw invalid('badArchive', err.find((l) => l.trim()) ?? 'This file is not a supported archive.');
103
+ }
104
+ return entries;
105
+ }
106
+
107
+ /** Lists an archive (7z, rar, zip, tar, gz, xz, bz2, iso, cab, ...), looking inside a lone .tar as well. */
108
+ export async function listArchive(abs: string): Promise<ArchiveListing> {
109
+ let entries = await list(abs);
110
+ const only = entries.length === 1 ? entries[0]! : undefined;
111
+ if (only && !only.dir && /\.tar$/i.test(only.path) && only.size <= MAX_NESTED_TAR_BYTES) {
112
+ const dir = await mkdtemp(join(tmpdir(), 'deckbuilder-arc-'));
113
+ try {
114
+ const { code } = await run(['e', '-y', '-bd', `-o/out`, `/host/${basename(abs)}`], { '/host': dirname(abs), '/out': dir });
115
+ if (code === 0 && (await stat(join(dir, basename(only.path))).catch(() => null))) entries = await list(join(dir, basename(only.path)));
116
+ } finally {
117
+ await rm(dir, { recursive: true, force: true });
118
+ }
119
+ }
120
+ return { entries: entries.slice(0, MAX_ENTRIES), truncated: entries.length > MAX_ENTRIES };
121
+ }
122
+
123
+ /**
124
+ * `callMain` is synchronous and blocks its thread for the whole run, so extract and compress run
125
+ * 7z in a Worker; the server keeps answering meanwhile.
126
+ */
127
+ function runInWorker(job: SevenZipJob): Promise<SevenZipResult> {
128
+ // In the exe this resolves next to the bundled entry, where `deckbuilder build` must put the worker entrypoint.
129
+ const worker = new Worker(new URL('./archive-worker.ts', import.meta.url).href);
130
+ return new Promise<SevenZipResult>((resolve, reject) => {
131
+ worker.addEventListener('message', (e: MessageEvent<SevenZipResult | { error: string }>) => {
132
+ if ('error' in e.data) reject(new Error(e.data.error));
133
+ else resolve(e.data);
134
+ });
135
+ worker.addEventListener('error', (e) => reject(new Error(e.message)));
136
+ worker.postMessage(job);
137
+ }).finally(() => worker.terminate());
138
+ }
139
+
140
+ // 7z asks for a password on stdin when it needs one; a dummy one makes it fail instead.
141
+ const NO_PASSWORD = '-pdeckbuilder-no-password';
142
+ const encrypted = () => invalid('archiveEncrypted', 'The archive is encrypted; password-protected archives are not supported.');
143
+ const wrongPassword = (lines: string[]) => lines.some((l) => /wrong password|encrypted|can ?not open encrypted/i.test(l));
144
+
145
+ const unsafe = () => invalid('archiveUnsafe', 'The archive has entries that lead outside the target folder.');
146
+ /** An entry name that climbs out (`..`) or is absolute; 7z would quietly strip that, so it is refused instead. */
147
+ const unsafePath = (path: string) => /^([\\/]|[a-zA-Z]:)/.test(path) || path.split(/[\\/]/).includes('..');
148
+
149
+ /** 7z's exit status isn't passed back by `callMain`, so errors are read from its output too. */
150
+ function check(r: SevenZipResult): void {
151
+ const lines = [...r.err, ...r.out];
152
+ if (r.code === 0 && !lines.some((l) => /ERROR|Errors:/.test(l))) return;
153
+ if (wrongPassword(lines)) throw encrypted();
154
+ if (lines.some((l) => /dangerous (link )?path/i.test(l))) throw unsafe();
155
+ throw invalid('badArchive', lines.find((l) => /ERROR/.test(l))?.trim() ?? r.err.find((l) => l.trim()) ?? 'The archive could not be extracted.');
156
+ }
157
+
158
+ /** Lists `abs` for extraction: encrypted entries or entry names leading out are refused. */
159
+ async function checkedList(abs: string): Promise<ArchiveEntry[]> {
160
+ const entries = await list(abs, [NO_PASSWORD]).catch((err) => {
161
+ throw wrongPassword([(err as Error).message ?? '']) ? encrypted() : err;
162
+ });
163
+ if (entries.some((e) => e.encrypted)) throw encrypted();
164
+ if (entries.some((e) => unsafePath(e.path))) throw unsafe();
165
+ return entries;
166
+ }
167
+
168
+ const extract = (archive: string, to: string, mode: 'x' | 'e' = 'x') =>
169
+ runInWorker({ args: [mode, '-y', '-bd', NO_PASSWORD, '-o/out', `/host/${basename(archive)}`], mounts: { '/host': dirname(archive), '/out': to } }).then(check);
170
+
171
+ /**
172
+ * Extracts `abs` into the existing empty folder `to` (7z `x`, folders kept). A lone .tar inside a
173
+ * .gz/.xz/.bz2 is unpacked too, like `listArchive` lists it. Encrypted archives and entries that
174
+ * lead out of `to` (`..`, absolute names, links out) are refused.
175
+ */
176
+ export async function extractArchive(abs: string, to: string): Promise<void> {
177
+ const entries = await checkedList(abs);
178
+ const only = entries.length === 1 ? entries[0]! : undefined;
179
+ if (only && !only.dir && /\.tar$/i.test(only.path)) {
180
+ const dir = await mkdtemp(join(tmpdir(), 'deckbuilder-arc-'));
181
+ try {
182
+ await extract(abs, dir, 'e');
183
+ const tar = join(dir, basename(only.path));
184
+ await checkedList(tar);
185
+ await extract(tar, to);
186
+ } finally {
187
+ await rm(dir, { recursive: true, force: true });
188
+ }
189
+ return;
190
+ }
191
+ await extract(abs, to);
192
+ }
193
+
194
+ /**
195
+ * Writes the archive `file` (zip or 7z) of the entries `names` of the folder `dir`, stored by name.
196
+ * Links are stored as links (`-snl`), never followed.
197
+ */
198
+ export async function compressArchive(dir: string, names: string[], file: string, format: 'zip' | '7z'): Promise<void> {
199
+ const r = await runInWorker({
200
+ args: ['a', `-t${format}`, '-snl', '-bd', '-y', `/out/${basename(file)}`, '--', ...names],
201
+ mounts: { '/host': dir, '/out': dirname(file) },
202
+ cwd: '/host',
203
+ });
204
+ if (r.code !== 0) throw invalid('compressFailed', r.err.find((l) => l.trim()) ?? 'The archive could not be written.');
205
+ }
package/src/backup.ts ADDED
@@ -0,0 +1,139 @@
1
+ import { pickFile, saveFileAs, writeJsonAtomic } from '@deckbuilderkit/platform';
2
+ import { type BackupInspect, invalid, isRecord, type Routes } from '@deckbuilderkit/server-core';
3
+ import { fromPlatform } from './platform-errors.ts';
4
+ import type { ImportResult, RecordStore, Rec } from './records.ts';
5
+ import type { SettingsStore } from './settings.ts';
6
+
7
+ export interface BackupRoutesOptions {
8
+ app: { id: string; version: string };
9
+ settings: SettingsStore<any>;
10
+ /** Record stores a backup may hold, by the name used in the file. */
11
+ stores: Record<string, RecordStore<any>>;
12
+ /** Settings keys left out of exports and imports (machine-specific ones). */
13
+ excludeSettings?: string[];
14
+ save?: typeof saveFileAs;
15
+ pick?: typeof pickFile;
16
+ }
17
+
18
+ /** What an export writes. Secret fields are left out. */
19
+ export interface BackupFile {
20
+ format: 'deckbuilder-backup';
21
+ version: 1;
22
+ app: string;
23
+ appVersion: string;
24
+ exportedAt: string;
25
+ settings?: Record<string, unknown>;
26
+ records?: Record<string, Rec<object>[]>;
27
+ }
28
+
29
+ const MAX_FILE_BYTES = 20_000_000;
30
+ const MAX_INSPECTED = 5;
31
+ const INSPECTED_TTL_MS = 600_000;
32
+ const JSON_FILTER = [{ name: 'JSON', patterns: ['*.json'] }];
33
+
34
+ const badRequest = () => invalid('badRequest', 'Invalid request.');
35
+ const badBackup = () => invalid('badBackup', 'This file is not a valid backup.');
36
+ const isStringArray = (v: unknown): v is string[] => Array.isArray(v) && v.every((s) => typeof s === 'string');
37
+
38
+ const localDate = (d = new Date()) =>
39
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
40
+
41
+ /**
42
+ * `GET /api/backup/parts`, `POST /api/backup/export`, `POST /api/backup/inspect` and
43
+ * `POST /api/backup/import`. Both files come from server-side dialogs; inspect keeps the parsed
44
+ * file under a one-shot token that import takes, so the client never sends a path.
45
+ */
46
+ export function backupRoutes(opts: BackupRoutesOptions): Routes {
47
+ const { app, settings, stores, excludeSettings = [], save = saveFileAs, pick = pickFile } = opts;
48
+ const inspected = new Map<string, { file: BackupFile; at: number }>();
49
+
50
+ const withoutExcluded = (values: Record<string, unknown>) => {
51
+ const copy = { ...values };
52
+ for (const key of excludeSettings) delete copy[key];
53
+ return copy;
54
+ };
55
+
56
+ const parts = (body: unknown) => {
57
+ if (!isRecord(body) || typeof body.settings !== 'boolean' || !isStringArray(body.stores)) throw badRequest();
58
+ return { settings: body.settings, stores: body.stores };
59
+ };
60
+
61
+ async function read(path: string): Promise<BackupFile> {
62
+ const handle = Bun.file(path);
63
+ if (handle.size > MAX_FILE_BYTES) throw invalid('backupTooLarge', 'The backup file is too large.');
64
+ let file: unknown;
65
+ try {
66
+ file = JSON.parse(await handle.text());
67
+ } catch {
68
+ throw badBackup();
69
+ }
70
+ if (!isRecord(file) || file.format !== 'deckbuilder-backup' || file.version !== 1 || typeof file.app !== 'string') throw badBackup();
71
+ if (file.app !== app.id) throw invalid('backupOtherApp', 'This backup belongs to another app ({app}).', { app: file.app.slice(0, 100) });
72
+ if (file.settings !== undefined && !isRecord(file.settings)) throw badBackup();
73
+ if (file.records !== undefined && !(isRecord(file.records) && Object.values(file.records).every(Array.isArray))) throw badBackup();
74
+ return file as unknown as BackupFile;
75
+ }
76
+
77
+ return {
78
+ 'GET /api/backup/parts': () => ({ stores: Object.keys(stores) }),
79
+
80
+ 'POST /api/backup/export': async ({ json }) => {
81
+ const chosen = parts(await json());
82
+ if (chosen.stores.some((name) => !Object.hasOwn(stores, name))) throw badRequest();
83
+ if (!chosen.settings && chosen.stores.length === 0) throw invalid('backupEmpty', 'Choose something to export.');
84
+ const path = await save({ suggestedName: `${app.id}-backup-${localDate()}.json`, filters: JSON_FILTER }).catch(fromPlatform);
85
+ if (path === null) return { path: null };
86
+ const file: BackupFile = { format: 'deckbuilder-backup', version: 1, app: app.id, appVersion: app.version, exportedAt: new Date().toISOString() };
87
+ if (chosen.settings) file.settings = withoutExcluded(await settings.load());
88
+ const counts: Record<string, number> = {};
89
+ if (chosen.stores.length) {
90
+ file.records = {};
91
+ for (const name of new Set(chosen.stores)) {
92
+ file.records[name] = await stores[name]!.exportItems();
93
+ counts[name] = file.records[name].length;
94
+ }
95
+ }
96
+ await writeJsonAtomic(path, file);
97
+ return { path, counts };
98
+ },
99
+
100
+ 'POST /api/backup/inspect': async (): Promise<BackupInspect> => {
101
+ const path = await pick({ filters: JSON_FILTER }).catch(fromPlatform);
102
+ if (path === null) return { token: null };
103
+ const file = await read(path);
104
+ const now = Date.now();
105
+ for (const [key, entry] of inspected) if (now - entry.at >= INSPECTED_TTL_MS) inspected.delete(key);
106
+ const token = crypto.randomUUID();
107
+ inspected.set(token, { file, at: now });
108
+ while (inspected.size > MAX_INSPECTED) inspected.delete(inspected.keys().next().value as string);
109
+ return {
110
+ token,
111
+ appVersion: typeof file.appVersion === 'string' ? file.appVersion : '',
112
+ exportedAt: typeof file.exportedAt === 'string' ? file.exportedAt : '',
113
+ settings: file.settings !== undefined,
114
+ stores: Object.entries(file.records ?? {}).map(([name, items]) => ({ name, count: items.length, known: Object.hasOwn(stores, name) })),
115
+ };
116
+ },
117
+
118
+ 'POST /api/backup/import': async ({ json }) => {
119
+ const body = await json();
120
+ const chosen = parts(body);
121
+ const mode = (body as Record<string, unknown>).mode;
122
+ const token = (body as Record<string, unknown>).token;
123
+ if (mode !== 'merge' && mode !== 'replace') throw badRequest();
124
+ const entry = typeof token === 'string' ? inspected.get(token) : undefined;
125
+ if (entry) inspected.delete(token as string);
126
+ if (!entry || Date.now() - entry.at >= INSPECTED_TTL_MS) throw invalid('backupExpired', 'The backup file was read too long ago. Choose it again.');
127
+ const { file } = entry;
128
+ const applied = chosen.settings && file.settings !== undefined;
129
+ if (applied) await settings.save(withoutExcluded(file.settings!));
130
+ const results: Record<string, ImportResult> = {};
131
+ for (const name of new Set(chosen.stores)) {
132
+ if (Object.hasOwn(stores, name) && file.records && Object.hasOwn(file.records, name)) {
133
+ results[name] = await stores[name]!.importItems(file.records[name]!, mode);
134
+ }
135
+ }
136
+ return { settings: applied, stores: results };
137
+ },
138
+ };
139
+ }
package/src/bytes.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** True when the first 8 KiB holds a NUL byte, which text files don't. */
2
+ export const isBinary = (bytes: Uint8Array) => bytes.subarray(0, 8192).includes(0);
package/src/dialogs.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { type DialogFilter, pickFile, pickFolder, saveFileAs } from '@deckbuilderkit/platform';
2
+ import type { Routes } from '@deckbuilderkit/server-core';
3
+ import { fromPlatform } from './platform-errors.ts';
4
+
5
+ const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
6
+
7
+ /** The well-typed filters of a body; malformed ones are dropped. */
8
+ function filters(v: unknown): DialogFilter[] | undefined {
9
+ if (!Array.isArray(v)) return undefined;
10
+ return v
11
+ .filter((f): f is { name: unknown; patterns: unknown[] } => typeof f === 'object' && f !== null && Array.isArray(f.patterns))
12
+ .map((f) => ({ name: str(f.name) ?? '', patterns: f.patterns.filter((p): p is string => typeof p === 'string') }));
13
+ }
14
+
15
+ const body = async (json: () => Promise<unknown>) => (await json()) as Record<string, unknown>;
16
+
17
+ /** Paths a server dialog returned that a later route may accept once. */
18
+ export interface PickedPaths {
19
+ add(path: string): void;
20
+ /** True once for a path added less than `ttlMs` ago (exact match); removes it. */
21
+ take(path: string): boolean;
22
+ }
23
+
24
+ /** A one-shot set of dialog results; adding beyond `max` drops the oldest entry. */
25
+ export function pickedPaths(opts: { max?: number; ttlMs?: number; now?: () => number } = {}): PickedPaths {
26
+ const { max = 20, ttlMs = 600_000, now = Date.now } = opts;
27
+ const added = new Map<string, number>();
28
+ return {
29
+ add(path) {
30
+ added.delete(path);
31
+ added.set(path, now());
32
+ while (added.size > max) added.delete(added.keys().next().value as string);
33
+ },
34
+ take(path) {
35
+ const at = added.get(path);
36
+ if (at === undefined) return false;
37
+ added.delete(path);
38
+ return now() - at < ttlMs;
39
+ },
40
+ };
41
+ }
42
+
43
+ /**
44
+ * `POST /api/dialog/folder|file|save` with the dialog options → `{ path: string | null }` (null =
45
+ * cancelled). The path is absolute: a route that takes it back from the client must check it with
46
+ * `picked.take` (folder and file results are added to `picked`; save targets need their own route).
47
+ */
48
+ export function dialogRoutes(opts: { picked?: PickedPaths } = {}): Routes {
49
+ const result = (path: Promise<string | null>, pick = true) =>
50
+ path.then((p) => {
51
+ if (pick && p !== null) opts.picked?.add(p);
52
+ return { path: p };
53
+ }, fromPlatform);
54
+ return {
55
+ 'POST /api/dialog/folder': async ({ json }) => {
56
+ const b = await body(json);
57
+ return result(pickFolder({ title: str(b.title) }));
58
+ },
59
+ 'POST /api/dialog/file': async ({ json }) => {
60
+ const b = await body(json);
61
+ return result(pickFile({ title: str(b.title), filters: filters(b.filters) }));
62
+ },
63
+ 'POST /api/dialog/save': async ({ json }) => {
64
+ const b = await body(json);
65
+ return result(saveFileAs({ title: str(b.title), suggestedName: str(b.suggestedName), filters: filters(b.filters) }), false);
66
+ },
67
+ };
68
+ }
package/src/editors.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import {
4
+ EDITOR_NAMES,
5
+ type EditorId,
6
+ findEditor,
7
+ openInEditor,
8
+ pickFile,
9
+ readJsonFile,
10
+ writeJsonAtomic,
11
+ } from '@deckbuilderkit/platform';
12
+ import { type EditorInfo, invalid, isRecord, type Routes } from '@deckbuilderkit/server-core';
13
+ import { fromPlatform } from './platform-errors.ts';
14
+ import type { RootedFs } from './rooted-fs.ts';
15
+
16
+ export type { EditorInfo };
17
+
18
+ export interface EditorRoutesOptions {
19
+ /** Folder of editors.json (the overrides), usually `appHome(APP.id)`. */
20
+ dir: string;
21
+ fs: RootedFs;
22
+ pick?: typeof pickFile;
23
+ find?: typeof findEditor;
24
+ open?: typeof openInEditor;
25
+ }
26
+
27
+ type Overrides = Partial<Record<EditorId, string>>;
28
+
29
+ const badRequest = () => invalid('badRequest', 'Invalid request.');
30
+ const badEditor = () => invalid('badEditor', 'Unknown editor.');
31
+ const isEditor = (v: unknown): v is EditorId => typeof v === 'string' && Object.hasOwn(EDITOR_NAMES, v);
32
+ const editorOf = (v: unknown): EditorId => {
33
+ if (!isEditor(v)) throw badEditor();
34
+ return v;
35
+ };
36
+
37
+ /**
38
+ * `GET /api/editors`, `POST /api/editors/pick`, `DELETE /api/editors/override?editor=` and
39
+ * `POST /api/editors/open`. The program to run comes from detection or from a server-side file
40
+ * dialog saved in `<dir>/editors.json`, never from the client; `.cmd` scripts are refused because
41
+ * they would go through cmd.exe.
42
+ */
43
+ export function editorRoutes(opts: EditorRoutesOptions): Routes {
44
+ const { dir, fs, pick = pickFile, find = findEditor, open = openInEditor } = opts;
45
+ const file = join(dir, 'editors.json');
46
+
47
+ const readOverrides = async (): Promise<Overrides> => {
48
+ const stored = (await readJsonFile(file, isRecord)) as Record<string, unknown> | undefined;
49
+ const overrides: Overrides = {};
50
+ for (const id of Object.keys(EDITOR_NAMES) as EditorId[]) {
51
+ const value = stored?.[id];
52
+ if (typeof value === 'string' && value !== '') overrides[id] = value;
53
+ }
54
+ return overrides;
55
+ };
56
+
57
+ const list = async (): Promise<EditorInfo[]> => {
58
+ const overrides = await readOverrides();
59
+ return Promise.all(
60
+ (Object.keys(EDITOR_NAMES) as EditorId[]).map(async (id) => {
61
+ const custom = overrides[id] !== undefined;
62
+ return { id, name: EDITOR_NAMES[id], path: overrides[id] ?? (await find(id)), custom };
63
+ }),
64
+ );
65
+ };
66
+
67
+ return {
68
+ 'GET /api/editors': () => list(),
69
+
70
+ 'POST /api/editors/pick': async ({ json }) => {
71
+ const editor = editorOf(((await json()) as Record<string, unknown>).editor);
72
+ const picked = await pick({
73
+ title: `Choose the ${EDITOR_NAMES[editor]} program`,
74
+ filters: [{ name: 'Programs', patterns: ['*.exe'] }],
75
+ }).catch(fromPlatform);
76
+ if (picked === null) return list();
77
+ if (process.platform === 'win32' && !/\.exe$/i.test(picked)) throw invalid('editorNotExe', 'The editor program must be an existing .exe file.');
78
+ await writeJsonAtomic(file, { ...(await readOverrides()), [editor]: picked });
79
+ return list();
80
+ },
81
+
82
+ 'DELETE /api/editors/override': async ({ query }) => {
83
+ const editor = editorOf(query.get('editor'));
84
+ const { [editor]: _removed, ...rest } = await readOverrides();
85
+ await writeJsonAtomic(file, rest);
86
+ return list();
87
+ },
88
+
89
+ 'POST /api/editors/open': async ({ json }) => {
90
+ const b = (await json()) as Record<string, unknown>;
91
+ const editor = editorOf(b.editor);
92
+ if (typeof b.root !== 'string' || (b.path !== undefined && typeof b.path !== 'string')) throw badRequest();
93
+ const line = b.line;
94
+ if (line !== undefined && (typeof line !== 'number' || !Number.isInteger(line) || line < 1)) throw badRequest();
95
+
96
+ const folder = (await fs.resolve(b.root, '', { mustExist: true })).real;
97
+ let target: string | undefined;
98
+ if (typeof b.path === 'string' && b.path !== '') {
99
+ const real = (await fs.resolve(b.root, b.path, { mustExist: true })).real;
100
+ if (!(await stat(real)).isDirectory()) target = real;
101
+ }
102
+
103
+ const exe = (await readOverrides())[editor] ?? (await find(editor));
104
+ if (!exe) throw invalid('editorNotFound', 'The editor was not found. Choose its program in Settings.');
105
+ await open(editor, exe, { folder, file: target, line: target ? line : undefined }).catch(fromPlatform);
106
+ return { ok: true };
107
+ },
108
+ };
109
+ }
@@ -0,0 +1,87 @@
1
+ import { invalid } from '@deckbuilderkit/server-core';
2
+
3
+ /** The text encodings the file routes read and write; a file keeps its own on save. */
4
+ export type TextEncodingId = 'utf8' | 'utf8bom' | 'utf16le' | 'utf16be' | 'windows1252';
5
+
6
+ export const TEXT_ENCODINGS: readonly TextEncodingId[] = ['utf8', 'utf8bom', 'utf16le', 'utf16be', 'windows1252'];
7
+
8
+ const BOMS: Array<[TextEncodingId, number[]]> = [
9
+ ['utf8bom', [0xef, 0xbb, 0xbf]],
10
+ ['utf16le', [0xff, 0xfe]],
11
+ ['utf16be', [0xfe, 0xff]],
12
+ ];
13
+
14
+ const bomOf = (enc: TextEncodingId) => BOMS.find(([id]) => id === enc)?.[1] ?? [];
15
+
16
+ /** The encoding its byte order mark names, or undefined without one. */
17
+ export function encodingByBom(bytes: Uint8Array): TextEncodingId | undefined {
18
+ return BOMS.find(([, bom]) => bom.every((b, i) => bytes[i] === b))?.[0];
19
+ }
20
+
21
+ /**
22
+ * The encoding of a text file's first bytes: its BOM, else UTF-8 when the bytes are valid UTF-8,
23
+ * else Windows-1252 (what Notepad calls ANSI on Western systems). The bytes of a `truncated` read
24
+ * may end inside a character.
25
+ */
26
+ export function detectEncoding(bytes: Uint8Array, truncated = false): TextEncodingId {
27
+ const byBom = encodingByBom(bytes);
28
+ if (byBom) return byBom;
29
+ try {
30
+ new TextDecoder('utf-8', { fatal: true }).decode(bytes, { stream: truncated });
31
+ return 'utf8';
32
+ } catch {
33
+ return 'windows1252';
34
+ }
35
+ }
36
+
37
+ const LABELS: Record<TextEncodingId, string> = {
38
+ utf8: 'utf-8',
39
+ utf8bom: 'utf-8',
40
+ utf16le: 'utf-16le',
41
+ utf16be: 'utf-16be',
42
+ windows1252: 'windows-1252',
43
+ };
44
+
45
+ /** `bytes` as text in `enc`, without its BOM. */
46
+ export function decodeText(bytes: Uint8Array, enc: TextEncodingId): string {
47
+ return new TextDecoder(LABELS[enc] as ConstructorParameters<typeof TextDecoder>[0]).decode(bytes.subarray(bomOf(enc).length));
48
+ }
49
+
50
+ let cp1252: Map<number, number> | undefined;
51
+
52
+ /** Code point → byte, built from the decoder so it round-trips exactly. */
53
+ function cp1252Bytes(): Map<number, number> {
54
+ if (!cp1252) {
55
+ const all = new TextDecoder('windows-1252').decode(Uint8Array.from({ length: 256 }, (_, i) => i));
56
+ cp1252 = new Map([...all].map((ch, i) => [ch.codePointAt(0)!, i]));
57
+ }
58
+ return cp1252;
59
+ }
60
+
61
+ /** `text` as bytes in `enc`, with the BOM the encoding has. A character Windows-1252 can't hold is refused, never replaced. */
62
+ export function encodeText(text: string, enc: TextEncodingId): Uint8Array {
63
+ const bom = bomOf(enc);
64
+ if (enc === 'utf8' || enc === 'utf8bom') {
65
+ const body = new TextEncoder().encode(text);
66
+ const out = new Uint8Array(bom.length + body.length);
67
+ out.set(bom);
68
+ out.set(body, bom.length);
69
+ return out;
70
+ }
71
+ if (enc === 'windows1252') {
72
+ const map = cp1252Bytes();
73
+ const out = new Uint8Array(text.length);
74
+ let n = 0;
75
+ for (const ch of text) {
76
+ const b = map.get(ch.codePointAt(0)!);
77
+ if (b === undefined) throw invalid('unencodable', `"${ch}" can't be saved in Windows-1252. Remove it or change the encoding.`);
78
+ out[n++] = b;
79
+ }
80
+ return out.subarray(0, n);
81
+ }
82
+ const out = new Uint8Array(bom.length + text.length * 2);
83
+ out.set(bom);
84
+ const view = new DataView(out.buffer);
85
+ for (let i = 0; i < text.length; i++) view.setUint16(bom.length + i * 2, text.charCodeAt(i), enc === 'utf16le');
86
+ return out;
87
+ }