@topik/cli 0.1.0-alpha.4 → 0.1.0-alpha.6
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/dist/cli.d.mts +1 -1
- package/dist/cli.mjs +737 -181
- package/package.json +9 -9
package/dist/cli.d.mts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {}
|
package/dist/cli.mjs
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { boolean, command, positional, run, string } from "@drizzle-team/brocli";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, renameSync, rmdirSync, unlinkSync } from "node:fs";
|
|
5
|
+
import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
6
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
7
|
+
import { ManifestSourceError, PublicCompileError, TOPIK_PREVIEW_PROTOCOL, compile, isErrorDiagnostic, lint, parseStrictTopikJson, publicCompileErrorMessage, serializeTopikJson, validateResources, validateTopikMaterializationRecord, watch } from "@topik/core";
|
|
8
|
+
import { sanitizeTopikContentDiagnostic } from "@topik/content";
|
|
7
9
|
import { createServer } from "node:http";
|
|
8
10
|
import { parseAllDocuments } from "yaml";
|
|
9
11
|
//#region src/diagnostics.ts
|
|
10
12
|
function formatDiagnostic(diagnostic) {
|
|
11
|
-
|
|
13
|
+
const sanitized = sanitizeTopikContentDiagnostic(diagnostic);
|
|
14
|
+
return `${sanitized.file ?? "content"}${sanitized.lines.length > 0 ? `:${sanitized.lines.join(",")}` : ""} ${sanitized.level} ${sanitized.id}: ${sanitized.message}`;
|
|
12
15
|
}
|
|
13
16
|
function printDiagnostics(diagnostics) {
|
|
14
17
|
for (const diagnostic of diagnostics) {
|
|
@@ -25,6 +28,39 @@ var CliError = class extends Error {
|
|
|
25
28
|
this.name = "CliError";
|
|
26
29
|
}
|
|
27
30
|
};
|
|
31
|
+
const PUBLIC_CLI_ERROR_MESSAGES = Object.freeze({
|
|
32
|
+
"browser-origin-invalid": "Browser origin configuration is invalid",
|
|
33
|
+
"compilation-publication-failed": "Compilation output publication failed after moving the existing output aside. For manual recovery, first stop concurrent writers, then inspect .topik-compilation-prior-* directories beside the output path. Verify the backup contents and do not overwrite an existing output path.",
|
|
34
|
+
"resource-access-failed": "Resource input could not be accessed.",
|
|
35
|
+
"resource-format-unsupported": "Resource input format is unsupported.",
|
|
36
|
+
"resource-json-invalid": "JSON resource input could not be parsed.",
|
|
37
|
+
"resource-jsonl-invalid": "JSONL resource input could not be parsed.",
|
|
38
|
+
"resource-read-failed": "Resource input could not be read.",
|
|
39
|
+
"resource-validation-failed": "Resource validation failed.",
|
|
40
|
+
"resource-yaml-invalid": "YAML resource input could not be parsed."
|
|
41
|
+
});
|
|
42
|
+
/** CLI failure admitted to presentation only through fixed catalog wording. */
|
|
43
|
+
var PublicCliError = class extends CliError {
|
|
44
|
+
id;
|
|
45
|
+
constructor(id, options) {
|
|
46
|
+
super(publicCliErrorMessage(id) ?? "Topik command failed.", options);
|
|
47
|
+
this.name = "PublicCliError";
|
|
48
|
+
this.id = id;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
function publicCliErrorMessage(id) {
|
|
52
|
+
return typeof id === "string" && Object.hasOwn(PUBLIC_CLI_ERROR_MESSAGES, id) ? PUBLIC_CLI_ERROR_MESSAGES[id] : void 0;
|
|
53
|
+
}
|
|
54
|
+
function formatPublicCliError(error) {
|
|
55
|
+
if (error instanceof PublicCliError) return publicCliErrorMessage(error.id) ?? "Topik command failed.";
|
|
56
|
+
if (error instanceof ManifestSourceError) {
|
|
57
|
+
const message = publicCompileErrorMessage(error.id) ?? "Topik command failed.";
|
|
58
|
+
const location = new PublicCompileError(error.id, error.location).location;
|
|
59
|
+
return `.topik.yaml ${Number.isSafeInteger(error.sourceIndex) && error.sourceIndex >= 0 ? `sources[${error.sourceIndex}]` : "source"}${error.kind === "wiki" || error.kind === "collection" || error.kind === "course" ? ` (${error.kind})` : ""}${location ? ` ${location}` : ""}: ${message}`;
|
|
60
|
+
}
|
|
61
|
+
if (error instanceof PublicCompileError) return publicCompileErrorMessage(error.id) ?? "Topik command failed.";
|
|
62
|
+
return "Topik command failed.";
|
|
63
|
+
}
|
|
28
64
|
//#endregion
|
|
29
65
|
//#region src/validation-output.ts
|
|
30
66
|
function formatValidationFailure(errors, resourceCount, context) {
|
|
@@ -35,40 +71,28 @@ function formatValidationFailure(errors, resourceCount, context) {
|
|
|
35
71
|
}
|
|
36
72
|
//#endregion
|
|
37
73
|
//#region src/compile/index.ts
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
async function toYaml(value) {
|
|
44
|
-
if (process.versions.bun) return Bun.YAML.stringify(value);
|
|
45
|
-
const { stringify } = await import("yaml");
|
|
46
|
-
return stringify(value);
|
|
47
|
-
}
|
|
48
|
-
function serialize(resource, format) {
|
|
49
|
-
switch (format) {
|
|
50
|
-
case "json": return Promise.resolve(JSON.stringify(resource, null, 2) + "\n");
|
|
51
|
-
case "jsonl": return Promise.resolve(JSON.stringify(resource) + "\n");
|
|
52
|
-
case "yaml": return toYaml(resource);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
74
|
+
const COMPILATION_GENERATION_PREFIX = ".topik-compilation-generation-";
|
|
75
|
+
const COMPILATION_PRIOR_PREFIX = ".topik-compilation-prior-";
|
|
76
|
+
const COMPILATION_DIRECTORIES = ["blobs", "resources"];
|
|
77
|
+
const ownedDescriptorDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
78
|
+
const ownedDescriptorEncoder = new TextEncoder();
|
|
55
79
|
const compile$1 = command({
|
|
56
80
|
name: "compile",
|
|
57
|
-
desc: "Compile
|
|
81
|
+
desc: "Compile project content into Topik resource files",
|
|
58
82
|
options: {
|
|
59
|
-
dir: positional("dir").desc("
|
|
83
|
+
dir: positional("dir").desc("Project directory containing .topik.yaml").default("."),
|
|
60
84
|
outDir: string("out-dir").alias("o").desc("Output directory for compiled resources"),
|
|
61
|
-
format: string("format").alias("f").desc("Output format").enum("json"
|
|
85
|
+
format: string("format").alias("f").desc("Output format (canonical JSON only)").enum("json").default("json"),
|
|
62
86
|
dryRun: boolean("dry-run").desc("Show what would be compiled without writing files").default(false),
|
|
63
|
-
clean: boolean("clean").desc("Remove existing output before compiling").default(false),
|
|
64
87
|
validate: boolean("validate").desc("Validate compiled resources against schemas").default(false),
|
|
65
88
|
links: string("links").desc("How unresolved wiki links and local guide fragments are handled").enum("error", "warning", "off").default("error")
|
|
66
89
|
},
|
|
67
90
|
handler: async (options) => {
|
|
68
91
|
const dir = resolve(options.dir);
|
|
69
|
-
const format = options.format;
|
|
70
92
|
const links = options.links;
|
|
71
|
-
const
|
|
93
|
+
const outDir = options.outDir ? resolve(options.outDir) : join(dir, ".topik");
|
|
94
|
+
await assertCompilationOutputScope(dir, outDir);
|
|
95
|
+
const { diagnostics, materialization, payloads, resources, semantic } = await compile({
|
|
72
96
|
dir,
|
|
73
97
|
validation: { links }
|
|
74
98
|
});
|
|
@@ -77,55 +101,478 @@ const compile$1 = command({
|
|
|
77
101
|
const { valid, errors } = validateResources(resources);
|
|
78
102
|
if (!valid) throw new CliError(formatValidationFailure(errors, resources.length, "validating compiled output"));
|
|
79
103
|
}
|
|
80
|
-
const ext = formatExtensions[format];
|
|
81
104
|
if (options.dryRun) {
|
|
82
|
-
|
|
83
|
-
|
|
105
|
+
const paths = [
|
|
106
|
+
...resources.map((resource) => `resources/${resource.type}/${resource.name}.json`),
|
|
107
|
+
...payloads.map((payload) => payload.path),
|
|
108
|
+
"materialization.json",
|
|
109
|
+
"semantic.json"
|
|
110
|
+
].sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
111
|
+
for (const path of paths) console.log(path);
|
|
112
|
+
console.log(`\n${resources.length} resources and ${payloads.length} payloads (dry run)`);
|
|
84
113
|
return;
|
|
85
114
|
}
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
115
|
+
const materializedResources = new Map(materialization.resources.map((resource) => [resource.resource, resource]));
|
|
116
|
+
const files = resources.map((resource) => {
|
|
117
|
+
const materialized = materializedResources.get(`${resource.type}/${resource.name}`);
|
|
118
|
+
if (materialized === void 0) throw new CliError("Compiled materialization omits a resource descriptor");
|
|
119
|
+
return {
|
|
120
|
+
path: materialized.path,
|
|
121
|
+
bytes: serializeTopikJson(resource)
|
|
122
|
+
};
|
|
90
123
|
});
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
124
|
+
files.push(...payloads.map((payload) => ({
|
|
125
|
+
path: payload.path,
|
|
126
|
+
bytes: payload.bytes
|
|
127
|
+
})), {
|
|
128
|
+
path: "materialization.json",
|
|
129
|
+
bytes: serializeTopikJson(materialization)
|
|
130
|
+
}, {
|
|
131
|
+
path: "semantic.json",
|
|
132
|
+
bytes: serializeTopikJson(semantic)
|
|
133
|
+
});
|
|
134
|
+
await replaceCompilationTree(outDir, files);
|
|
135
|
+
console.log(`Compiled ${resources.length} resources and ${payloads.length} payloads to ${outDir}`);
|
|
95
136
|
}
|
|
96
137
|
});
|
|
138
|
+
async function replaceCompilationTree(absolutePath, files) {
|
|
139
|
+
const stagedFiles = snapshotCompilationFiles(files);
|
|
140
|
+
const target = basename(absolutePath);
|
|
141
|
+
if (target.length === 0 || target === "." || target === "..") throw new CliError("Compilation output root is invalid");
|
|
142
|
+
const anchoredParent = await openAnchoredOutputDirectory(dirname(absolutePath), true);
|
|
143
|
+
if (anchoredParent === void 0) throw new CliError("Compilation output parent could not be created");
|
|
144
|
+
const parent = anchoredParent.handle;
|
|
145
|
+
let existing;
|
|
146
|
+
let generation;
|
|
147
|
+
let published = false;
|
|
148
|
+
let existingRemoved = false;
|
|
149
|
+
let operationError;
|
|
150
|
+
try {
|
|
151
|
+
const targetPath = procFdChild(parent.fd, target);
|
|
152
|
+
existing = await openOwnedCompilationOutput(parent, target);
|
|
153
|
+
generation = await createOwnedTemporaryDirectory(parent, COMPILATION_GENERATION_PREFIX);
|
|
154
|
+
for (const directory of COMPILATION_DIRECTORIES) {
|
|
155
|
+
const handle = await openAnchoredChildDirectory(generation.handle, directory, true);
|
|
156
|
+
if (handle === void 0) throw new CliError("Compilation output directory is unavailable");
|
|
157
|
+
await handle.close();
|
|
158
|
+
}
|
|
159
|
+
for (const file of stagedFiles) await writeAnchoredFile(generation.handle, file.path, file.bytes);
|
|
160
|
+
generation.tree = bindCompleteCompilationTreeSync(generation.handle);
|
|
161
|
+
assertExpectedStagedTree(generation.tree, stagedFiles);
|
|
162
|
+
if (existing !== void 0) {
|
|
163
|
+
const priorName = `${COMPILATION_PRIOR_PREFIX}${randomUUID()}`;
|
|
164
|
+
assertOutputPathAbsent(procFdChild(parent.fd, priorName));
|
|
165
|
+
renameSync(targetPath, procFdChild(parent.fd, priorName));
|
|
166
|
+
existing.name = priorName;
|
|
167
|
+
}
|
|
168
|
+
renameSync(generation.path, targetPath);
|
|
169
|
+
generation.path = targetPath;
|
|
170
|
+
generation.name = target;
|
|
171
|
+
published = true;
|
|
172
|
+
if (existing !== void 0) {
|
|
173
|
+
cleanupCompilationDirectory(parent, existing);
|
|
174
|
+
existingRemoved = true;
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
operationError = error;
|
|
178
|
+
}
|
|
179
|
+
if (!published && existing !== void 0 && existing.name !== target) operationError = new PublicCliError("compilation-publication-failed", { cause: operationError });
|
|
180
|
+
if (generation !== void 0 && !published) try {
|
|
181
|
+
generation.tree = bindCompleteCompilationTreeSync(generation.handle);
|
|
182
|
+
cleanupCompilationDirectory(parent, generation);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
operationError ??= error;
|
|
185
|
+
}
|
|
186
|
+
if (published && existing !== void 0 && !existingRemoved && existing.name !== target) try {
|
|
187
|
+
cleanupCompilationDirectory(parent, existing);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
operationError ??= error;
|
|
190
|
+
}
|
|
191
|
+
await generation?.handle.close().catch(() => void 0);
|
|
192
|
+
await existing?.handle.close().catch(() => void 0);
|
|
193
|
+
await parent.close().catch(() => void 0);
|
|
194
|
+
if (operationError !== void 0) throw operationError;
|
|
195
|
+
}
|
|
196
|
+
async function createOwnedTemporaryDirectory(parent, prefix) {
|
|
197
|
+
const path = await mkdtemp(procFdChild(parent.fd, prefix));
|
|
198
|
+
let handle;
|
|
199
|
+
try {
|
|
200
|
+
handle = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
201
|
+
if (!(await handle.stat({ bigint: true })).isDirectory()) throw new CliError("Compilation staging identity is invalid");
|
|
202
|
+
return {
|
|
203
|
+
name: basename(path),
|
|
204
|
+
path,
|
|
205
|
+
handle,
|
|
206
|
+
tree: { entries: [] }
|
|
207
|
+
};
|
|
208
|
+
} catch (error) {
|
|
209
|
+
await handle?.close().catch(() => void 0);
|
|
210
|
+
try {
|
|
211
|
+
rmdirSync(procFdChild(parent.fd, basename(path)));
|
|
212
|
+
} catch {}
|
|
213
|
+
if (error instanceof CliError) throw error;
|
|
214
|
+
throw new CliError("Compilation staging directory could not be opened safely");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async function openOwnedCompilationOutput(parent, target) {
|
|
218
|
+
const targetPath = procFdChild(parent.fd, target);
|
|
219
|
+
let stat;
|
|
220
|
+
try {
|
|
221
|
+
stat = await lstat(targetPath);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (error.code === "ENOENT") return void 0;
|
|
224
|
+
throw new CliError("Compilation output target could not be inspected safely");
|
|
225
|
+
}
|
|
226
|
+
if (!stat.isDirectory()) throw new CliError("Compilation output contains a link or non-directory collision");
|
|
227
|
+
const directory = await openAnchoredChildDirectory(parent, target, false);
|
|
228
|
+
if (directory === void 0) throw new CliError("Compilation output directory could not be opened safely");
|
|
229
|
+
try {
|
|
230
|
+
const tree = bindCompleteCompilationTreeSync(directory);
|
|
231
|
+
assertOwnedCompilationTree(directory, tree);
|
|
232
|
+
return {
|
|
233
|
+
handle: directory,
|
|
234
|
+
name: target,
|
|
235
|
+
path: targetPath,
|
|
236
|
+
tree
|
|
237
|
+
};
|
|
238
|
+
} catch (error) {
|
|
239
|
+
await directory.close().catch(() => void 0);
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function cleanupCompilationDirectory(parent, directory) {
|
|
244
|
+
for (const entry of [...directory.tree.entries].reverse()) {
|
|
245
|
+
if (entry.path.length === 0) continue;
|
|
246
|
+
const path = `${procFd(directory.handle.fd)}/${entry.path}`;
|
|
247
|
+
if (entry.kind === "file") unlinkSync(path);
|
|
248
|
+
else rmdirSync(path);
|
|
249
|
+
}
|
|
250
|
+
rmdirSync(procFdChild(parent.fd, directory.name));
|
|
251
|
+
}
|
|
252
|
+
function assertOutputPathAbsent(path) {
|
|
253
|
+
try {
|
|
254
|
+
lstatSync(path);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (error.code === "ENOENT") return;
|
|
257
|
+
throw new CliError("Compilation replacement storage could not be inspected safely");
|
|
258
|
+
}
|
|
259
|
+
throw new CliError("Compilation replacement storage is already occupied");
|
|
260
|
+
}
|
|
261
|
+
async function assertCompilationOutputScope(sourceDir, outDir) {
|
|
262
|
+
let canonicalOutput = outDir;
|
|
263
|
+
try {
|
|
264
|
+
canonicalOutput = await realpath(outDir);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error.code !== "ENOENT") throw new CliError("Compilation output scope could not be proven safely");
|
|
267
|
+
}
|
|
268
|
+
let canonicalSource;
|
|
269
|
+
try {
|
|
270
|
+
canonicalSource = await realpath(sourceDir);
|
|
271
|
+
} catch {
|
|
272
|
+
throw new CliError("Compilation source scope could not be proven safely");
|
|
273
|
+
}
|
|
274
|
+
const relation = relative(canonicalOutput, canonicalSource);
|
|
275
|
+
if (relation === "" || !relation.startsWith("..") && !isAbsolute(relation)) throw new CliError("Compilation output cannot equal or contain the source directory");
|
|
276
|
+
}
|
|
277
|
+
function assertOwnedCompilationTree(directory, tree) {
|
|
278
|
+
try {
|
|
279
|
+
const materialization = parseBoundCanonicalJson(directory, tree, "materialization.json");
|
|
280
|
+
const semantic = parseBoundCanonicalJson(directory, tree, "semantic.json");
|
|
281
|
+
if (!isRecord(materialization) || !Array.isArray(materialization.resources)) throw new TypeError("Materialization resource inventory is unavailable");
|
|
282
|
+
const resources = materialization.resources.map((entry) => {
|
|
283
|
+
if (!isRecord(entry) || typeof entry.path !== "string") throw new TypeError("Materialization resource inventory is malformed");
|
|
284
|
+
return parseBoundCanonicalJson(directory, tree, entry.path);
|
|
285
|
+
});
|
|
286
|
+
if (!isValidResourceSet(resources)) throw new TypeError("Compiled resource inventory is invalid");
|
|
287
|
+
const closure = validateTopikMaterializationRecord(materialization, resources, semantic);
|
|
288
|
+
if (!closure.ok) throw new TypeError("Compilation identity records do not close");
|
|
289
|
+
assertBoundTreeMatchesInventory(tree, closure.value);
|
|
290
|
+
} catch {
|
|
291
|
+
throw new CliError("Existing compilation output is populated but is not recognized as owned");
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function parseBoundCanonicalJson(root, tree, path) {
|
|
295
|
+
const bytes = readBoundCompilationFileSync(root, tree, path);
|
|
296
|
+
const text = ownedDescriptorDecoder.decode(bytes);
|
|
297
|
+
const value = parseStrictTopikJson(text, Number.POSITIVE_INFINITY);
|
|
298
|
+
const canonicalBytes = ownedDescriptorEncoder.encode(serializeTopikJson(value));
|
|
299
|
+
if (!Buffer.from(bytes).equals(canonicalBytes)) throw new TypeError("Compilation descriptor is not canonical");
|
|
300
|
+
return value;
|
|
301
|
+
}
|
|
302
|
+
function readBoundCompilationFileSync(root, tree, path) {
|
|
303
|
+
safeOutputComponents(path);
|
|
304
|
+
const expected = tree.entries.find((entry) => entry.kind === "file" && entry.path === path);
|
|
305
|
+
if (expected === void 0) throw new TypeError("Compilation descriptor is unavailable");
|
|
306
|
+
let fileFd;
|
|
307
|
+
try {
|
|
308
|
+
const anchoredPath = `${procFd(root.fd)}/${path}`;
|
|
309
|
+
fileFd = openSync(anchoredPath, constants.O_RDONLY | constants.O_NOFOLLOW | (typeof constants.O_NONBLOCK === "number" ? constants.O_NONBLOCK : 0));
|
|
310
|
+
const before = fstatSync(fileFd, { bigint: true });
|
|
311
|
+
if (!before.isFile() || !sameStoredOutputIdentity(toBoundOutputIdentity(before), expected.identity)) throw new TypeError("Compilation descriptor identity changed");
|
|
312
|
+
const bytes = readFileSync(fileFd);
|
|
313
|
+
const after = fstatSync(fileFd, { bigint: true });
|
|
314
|
+
if (!sameBoundOutputIdentity(before, after) || sha256OutputBytes(bytes) !== expected.digest) throw new TypeError("Compilation descriptor bytes changed");
|
|
315
|
+
assertPathStillBindsIdentitySync(anchoredPath, after, "file");
|
|
316
|
+
return Uint8Array.from(bytes);
|
|
317
|
+
} finally {
|
|
318
|
+
if (fileFd !== void 0) closeSync(fileFd);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function assertBoundTreeMatchesInventory(tree, materialization) {
|
|
322
|
+
const expectedDirectories = /* @__PURE__ */ new Set(["", ...COMPILATION_DIRECTORIES]);
|
|
323
|
+
const expectedFiles = /* @__PURE__ */ new Map([["materialization.json", {}], ["semantic.json", {}]]);
|
|
324
|
+
for (const entry of [...materialization.resources, ...materialization.payloads]) {
|
|
325
|
+
safeOutputComponents(entry.path);
|
|
326
|
+
if (expectedFiles.has(entry.path)) throw new TypeError("Compilation inventory repeats a path");
|
|
327
|
+
expectedFiles.set(entry.path, {
|
|
328
|
+
sha256: entry.sha256,
|
|
329
|
+
size: entry.size
|
|
330
|
+
});
|
|
331
|
+
const components = entry.path.split("/");
|
|
332
|
+
for (let index = 1; index < components.length; index++) expectedDirectories.add(components.slice(0, index).join("/"));
|
|
333
|
+
}
|
|
334
|
+
if (tree.entries.length !== expectedDirectories.size + expectedFiles.size) throw new TypeError("Compilation tree contains an unexpected path");
|
|
335
|
+
for (const entry of tree.entries) {
|
|
336
|
+
if (entry.kind === "directory") {
|
|
337
|
+
if (!expectedDirectories.has(entry.path)) throw new TypeError("Compilation tree contains an unexpected directory");
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const expected = expectedFiles.get(entry.path);
|
|
341
|
+
if (expected === void 0 || expected.size !== void 0 && entry.identity.size !== BigInt(expected.size) || expected.sha256 !== void 0 && entry.digest !== expected.sha256) throw new TypeError("Compilation tree does not match its inventory");
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function isRecord(value) {
|
|
345
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
346
|
+
}
|
|
347
|
+
function isValidResourceSet(resources) {
|
|
348
|
+
return validateResources(resources).valid;
|
|
349
|
+
}
|
|
350
|
+
async function openAnchoredOutputDirectory(absolutePath, create) {
|
|
351
|
+
if (process.platform !== "linux" || typeof constants.O_NOFOLLOW !== "number" || typeof constants.O_DIRECTORY !== "number") throw new CliError("This platform cannot prove descriptor-anchored output containment");
|
|
352
|
+
const normalized = resolve(absolutePath);
|
|
353
|
+
if (normalized === "/") throw new CliError("Compilation output root cannot be the filesystem root");
|
|
354
|
+
const components = normalized.split("/").filter(Boolean);
|
|
355
|
+
let current = await open("/", constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
356
|
+
try {
|
|
357
|
+
for (const component of components) {
|
|
358
|
+
const child = await openAnchoredChildDirectory(current, component, create);
|
|
359
|
+
if (child === void 0) {
|
|
360
|
+
await current.close();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
await current.close();
|
|
364
|
+
current = child;
|
|
365
|
+
}
|
|
366
|
+
return { handle: current };
|
|
367
|
+
} catch (error) {
|
|
368
|
+
await current.close().catch(() => void 0);
|
|
369
|
+
if (error instanceof CliError) throw error;
|
|
370
|
+
throw new CliError("Compilation output has an unsafe or unresolvable ancestor");
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function snapshotCompilationFiles(files) {
|
|
374
|
+
const paths = /* @__PURE__ */ new Set();
|
|
375
|
+
const snapshots = files.map((file) => {
|
|
376
|
+
safeOutputComponents(file.path);
|
|
377
|
+
if (paths.has(file.path)) throw new CliError("Compilation output repeats a staged file path");
|
|
378
|
+
paths.add(file.path);
|
|
379
|
+
const bytes = typeof file.bytes === "string" ? Uint8Array.from(Buffer.from(file.bytes, "utf8")) : Uint8Array.from(file.bytes);
|
|
380
|
+
return {
|
|
381
|
+
path: file.path,
|
|
382
|
+
bytes,
|
|
383
|
+
digest: sha256OutputBytes(bytes)
|
|
384
|
+
};
|
|
385
|
+
});
|
|
386
|
+
for (const path of paths) {
|
|
387
|
+
const components = safeOutputComponents(path);
|
|
388
|
+
for (let index = 1; index < components.length; index++) if (paths.has(components.slice(0, index).join("/"))) throw new CliError("Compilation output has a parent/file collision");
|
|
389
|
+
}
|
|
390
|
+
return snapshots;
|
|
391
|
+
}
|
|
392
|
+
function bindCompleteCompilationTreeSync(root) {
|
|
393
|
+
const entries = [];
|
|
394
|
+
try {
|
|
395
|
+
inspectBoundDirectorySync(root.fd, "", entries);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (error instanceof CliError) throw error;
|
|
398
|
+
throw new CliError("Compilation generation contents could not be inspected safely");
|
|
399
|
+
}
|
|
400
|
+
entries.sort((left, right) => Buffer.compare(Buffer.from(left.path, "utf8"), Buffer.from(right.path, "utf8")));
|
|
401
|
+
return { entries };
|
|
402
|
+
}
|
|
403
|
+
function inspectBoundDirectorySync(directoryFd, prefix, entries) {
|
|
404
|
+
const before = fstatSync(directoryFd, { bigint: true });
|
|
405
|
+
if (!before.isDirectory()) throw new CliError("Compilation generation contains an unsafe directory entry");
|
|
406
|
+
const children = readdirSync(procFd(directoryFd), { withFileTypes: true }).sort((left, right) => Buffer.compare(Buffer.from(left.name, "utf8"), Buffer.from(right.name, "utf8")));
|
|
407
|
+
for (const child of children) {
|
|
408
|
+
const path = prefix.length === 0 ? child.name : `${prefix}/${child.name}`;
|
|
409
|
+
const anchoredPath = procFdChild(directoryFd, child.name);
|
|
410
|
+
if (child.isDirectory()) {
|
|
411
|
+
let childFd;
|
|
412
|
+
try {
|
|
413
|
+
childFd = openSync(anchoredPath, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
414
|
+
inspectBoundDirectorySync(childFd, path, entries);
|
|
415
|
+
assertPathStillBindsIdentitySync(anchoredPath, fstatSync(childFd, { bigint: true }), "directory");
|
|
416
|
+
} finally {
|
|
417
|
+
if (childFd !== void 0) closeSync(childFd);
|
|
418
|
+
}
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (!child.isFile()) throw new CliError("Compilation generation contains a link or special-node entry");
|
|
422
|
+
let fileFd;
|
|
423
|
+
try {
|
|
424
|
+
fileFd = openSync(anchoredPath, constants.O_RDONLY | constants.O_NOFOLLOW | (typeof constants.O_NONBLOCK === "number" ? constants.O_NONBLOCK : 0));
|
|
425
|
+
const fileBefore = fstatSync(fileFd, { bigint: true });
|
|
426
|
+
if (!fileBefore.isFile() || fileBefore.nlink !== 1n) throw new CliError("Compilation generation contains a hard link or unsafe file entry");
|
|
427
|
+
const bytes = readFileSync(fileFd);
|
|
428
|
+
const fileAfter = fstatSync(fileFd, { bigint: true });
|
|
429
|
+
if (!sameBoundOutputIdentity(fileBefore, fileAfter)) throw new CliError("Compilation generation file changed while it was inspected");
|
|
430
|
+
assertPathStillBindsIdentitySync(anchoredPath, fileAfter, "file");
|
|
431
|
+
entries.push({
|
|
432
|
+
path,
|
|
433
|
+
kind: "file",
|
|
434
|
+
identity: toBoundOutputIdentity(fileAfter),
|
|
435
|
+
digest: sha256OutputBytes(bytes)
|
|
436
|
+
});
|
|
437
|
+
} finally {
|
|
438
|
+
if (fileFd !== void 0) closeSync(fileFd);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const after = fstatSync(directoryFd, { bigint: true });
|
|
442
|
+
if (!sameBoundOutputIdentity(before, after)) throw new CliError("Compilation generation directory changed while it was inspected");
|
|
443
|
+
entries.push({
|
|
444
|
+
path: prefix,
|
|
445
|
+
kind: "directory",
|
|
446
|
+
identity: toBoundOutputIdentity(after)
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
function assertPathStillBindsIdentitySync(path, expected, kind) {
|
|
450
|
+
const actual = lstatSync(path, { bigint: true });
|
|
451
|
+
if ((kind === "directory" ? !actual.isDirectory() : !actual.isFile()) || !sameBoundOutputIdentity(actual, expected)) throw new CliError("Compilation generation path binding changed while it was inspected");
|
|
452
|
+
}
|
|
453
|
+
function assertExpectedStagedTree(tree, files) {
|
|
454
|
+
const expectedDirectories = /* @__PURE__ */ new Set([""]);
|
|
455
|
+
for (const directory of COMPILATION_DIRECTORIES) expectedDirectories.add(directory);
|
|
456
|
+
const expectedFiles = new Map(files.map((file) => [file.path, file]));
|
|
457
|
+
for (const file of files) {
|
|
458
|
+
const components = safeOutputComponents(file.path);
|
|
459
|
+
for (let index = 1; index < components.length; index++) expectedDirectories.add(components.slice(0, index).join("/"));
|
|
460
|
+
}
|
|
461
|
+
if (tree.entries.length !== expectedDirectories.size + expectedFiles.size) throw new CliError("Compilation staged generation contains an unexpected path");
|
|
462
|
+
for (const entry of tree.entries) {
|
|
463
|
+
if (entry.kind === "directory") {
|
|
464
|
+
if (!expectedDirectories.has(entry.path)) throw new CliError("Compilation staged generation contains an unexpected directory");
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const expected = expectedFiles.get(entry.path);
|
|
468
|
+
if (expected === void 0 || entry.identity.size !== BigInt(expected.bytes.byteLength) || entry.digest !== expected.digest) throw new CliError("Compilation staged generation bytes do not match expected output");
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
function sameBoundOutputIdentity(left, right) {
|
|
472
|
+
return sameStoredOutputIdentity(toBoundOutputIdentity(left), toBoundOutputIdentity(right));
|
|
473
|
+
}
|
|
474
|
+
function sameStoredOutputIdentity(left, right) {
|
|
475
|
+
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.nlink === right.nlink && left.size === right.size && left.ctimeNs === right.ctimeNs && left.mtimeNs === right.mtimeNs;
|
|
476
|
+
}
|
|
477
|
+
function toBoundOutputIdentity(stat) {
|
|
478
|
+
return {
|
|
479
|
+
dev: stat.dev,
|
|
480
|
+
ino: stat.ino,
|
|
481
|
+
mode: stat.mode,
|
|
482
|
+
nlink: stat.nlink,
|
|
483
|
+
size: stat.size,
|
|
484
|
+
ctimeNs: stat.ctimeNs,
|
|
485
|
+
mtimeNs: stat.mtimeNs
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
function sha256OutputBytes(bytes) {
|
|
489
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
490
|
+
}
|
|
491
|
+
async function openAnchoredChildDirectory(parent, component, create) {
|
|
492
|
+
if (component.length === 0 || component === "." || component === ".." || component.includes("/")) throw new CliError("Compilation output path is invalid");
|
|
493
|
+
const path = procFdChild(parent.fd, component);
|
|
494
|
+
try {
|
|
495
|
+
return await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
if (error.code !== "ENOENT" || !create) {
|
|
498
|
+
if (error.code === "ENOENT") return void 0;
|
|
499
|
+
throw new CliError("Compilation output contains a link or non-directory collision");
|
|
500
|
+
}
|
|
501
|
+
await mkdir(path, { mode: 448 }).catch((mkdirError) => {
|
|
502
|
+
if (mkdirError.code !== "EEXIST") throw mkdirError;
|
|
503
|
+
});
|
|
504
|
+
try {
|
|
505
|
+
return await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
506
|
+
} catch {
|
|
507
|
+
throw new CliError("Compilation output directory identity could not be proven");
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
async function writeAnchoredFile(root, relativePath, bytes) {
|
|
512
|
+
const components = safeOutputComponents(relativePath);
|
|
513
|
+
const directories = [];
|
|
514
|
+
let parent = root;
|
|
515
|
+
let output;
|
|
516
|
+
try {
|
|
517
|
+
for (const component of components.slice(0, -1)) {
|
|
518
|
+
const child = await openAnchoredChildDirectory(parent, component, true);
|
|
519
|
+
if (child === void 0) throw new CliError("Compilation output directory is unavailable");
|
|
520
|
+
directories.push(child);
|
|
521
|
+
parent = child;
|
|
522
|
+
}
|
|
523
|
+
const path = procFdChild(parent.fd, components.at(-1) ?? "");
|
|
524
|
+
output = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
|
|
525
|
+
await output.writeFile(bytes);
|
|
526
|
+
await output.chmod(420);
|
|
527
|
+
} finally {
|
|
528
|
+
await output?.close().catch(() => void 0);
|
|
529
|
+
await Promise.all(directories.map((handle) => handle.close().catch(() => void 0)));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function safeOutputComponents(relativePath) {
|
|
533
|
+
const components = relativePath.split("/");
|
|
534
|
+
if (relativePath.startsWith("/") || components.some((component) => component.length === 0 || component === "." || component === ".." || component.includes("\\"))) throw new CliError("Compilation output path is not safely relative");
|
|
535
|
+
return components;
|
|
536
|
+
}
|
|
537
|
+
function procFd(fd) {
|
|
538
|
+
return `/proc/self/fd/${fd}`;
|
|
539
|
+
}
|
|
540
|
+
function procFdChild(fd, component) {
|
|
541
|
+
return `${procFd(fd)}/${component}`;
|
|
542
|
+
}
|
|
543
|
+
//#endregion
|
|
544
|
+
//#region package.json
|
|
545
|
+
var version = "0.1.0-alpha.6";
|
|
97
546
|
//#endregion
|
|
98
547
|
//#region src/dev/index.ts
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
".jpeg": "image/jpeg",
|
|
103
|
-
".gif": "image/gif",
|
|
104
|
-
".svg": "image/svg+xml",
|
|
105
|
-
".webp": "image/webp",
|
|
106
|
-
".avif": "image/avif",
|
|
107
|
-
".ico": "image/x-icon",
|
|
108
|
-
".mp4": "video/mp4",
|
|
109
|
-
".webm": "video/webm"
|
|
110
|
-
};
|
|
548
|
+
const DEV_HOST = "127.0.0.1";
|
|
549
|
+
const DEFAULT_ALLOWED_ORIGIN = "https://write.subtopik.com";
|
|
550
|
+
const REQUEST_VARY_HEADERS = "Origin, Sec-Fetch-Site";
|
|
111
551
|
function sendSSE(res, event, data) {
|
|
112
552
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
113
553
|
}
|
|
114
|
-
function handleResources(watcher,
|
|
554
|
+
function handleResources(watcher, res, corsHeaders) {
|
|
115
555
|
res.writeHead(200, {
|
|
116
556
|
"Content-Type": "application/json",
|
|
117
|
-
"
|
|
557
|
+
"Cache-Control": "no-store",
|
|
558
|
+
...corsHeaders
|
|
118
559
|
});
|
|
119
|
-
res.end(JSON.stringify(
|
|
560
|
+
res.end(JSON.stringify(watcher.state));
|
|
120
561
|
}
|
|
121
|
-
function handleEvents(watcher,
|
|
562
|
+
function handleEvents(watcher, res, corsHeaders) {
|
|
122
563
|
res.writeHead(200, {
|
|
123
564
|
"Content-Type": "text/event-stream",
|
|
124
565
|
"Cache-Control": "no-cache",
|
|
125
566
|
Connection: "keep-alive",
|
|
126
|
-
|
|
567
|
+
...corsHeaders
|
|
568
|
+
});
|
|
569
|
+
sendSSE(res, "preview-state", watcher.state);
|
|
570
|
+
const onState = (state) => sendSSE(res, "preview-state", state);
|
|
571
|
+
watcher.on("preview-state", onState);
|
|
572
|
+
sendSSE(res, "sync", {
|
|
573
|
+
resources: Object.fromEntries(watcher.resources),
|
|
574
|
+
payloads: Object.fromEntries([...watcher.payloads].map(([path, { bytes: _bytes, ...payload }]) => [path, payload]))
|
|
127
575
|
});
|
|
128
|
-
sendSSE(res, "sync", { resources: Object.fromEntries(watcher.resources) });
|
|
129
576
|
const onUpdate = (key, resource) => {
|
|
130
577
|
sendSSE(res, "update", {
|
|
131
578
|
key,
|
|
@@ -135,123 +582,234 @@ function handleEvents(watcher, _req, res) {
|
|
|
135
582
|
watcher.on("update", onUpdate);
|
|
136
583
|
res.on("close", () => {
|
|
137
584
|
watcher.off("update", onUpdate);
|
|
585
|
+
watcher.off("preview-state", onState);
|
|
138
586
|
});
|
|
139
587
|
}
|
|
140
|
-
function
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (!url.pathname.startsWith("/assets/")) return false;
|
|
146
|
-
let name;
|
|
147
|
-
try {
|
|
148
|
-
name = decodeURIComponent(url.pathname.slice(8));
|
|
149
|
-
} catch {
|
|
150
|
-
res.writeHead(404, { "Access-Control-Allow-Origin": "*" });
|
|
151
|
-
res.end();
|
|
152
|
-
return true;
|
|
153
|
-
}
|
|
154
|
-
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
|
|
155
|
-
res.writeHead(404, { "Access-Control-Allow-Origin": "*" });
|
|
588
|
+
function handleAssetPayload(watcher, rawPathname, method, res, corsHeaders) {
|
|
589
|
+
if (!rawPathname.startsWith("/blobs/")) return false;
|
|
590
|
+
const relativePath = rawPathname.slice(1);
|
|
591
|
+
if (!/^blobs\/[0-9a-f]{64}$/u.test(relativePath)) {
|
|
592
|
+
res.writeHead(404, corsHeaders);
|
|
156
593
|
res.end();
|
|
157
594
|
return true;
|
|
158
595
|
}
|
|
159
|
-
const
|
|
160
|
-
if (
|
|
161
|
-
res.writeHead(404,
|
|
596
|
+
const payload = watcher.payloads.get(relativePath);
|
|
597
|
+
if (payload === void 0) {
|
|
598
|
+
res.writeHead(404, corsHeaders);
|
|
162
599
|
res.end();
|
|
163
600
|
return true;
|
|
164
601
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
602
|
+
const contentType = payload.mediaType;
|
|
603
|
+
res.writeHead(200, {
|
|
604
|
+
"Content-Type": contentType,
|
|
605
|
+
"Content-Length": String(payload.size),
|
|
606
|
+
...corsHeaders,
|
|
607
|
+
"Cache-Control": "no-cache",
|
|
608
|
+
"X-Content-Type-Options": "nosniff",
|
|
609
|
+
...isDownloadMediaType(contentType) ? { "Content-Disposition": "attachment" } : {}
|
|
610
|
+
});
|
|
611
|
+
res.end(method === "HEAD" ? void 0 : payload.bytes);
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
function isDownloadMediaType(mediaType) {
|
|
615
|
+
return [
|
|
616
|
+
"application/octet-stream",
|
|
617
|
+
"text/html",
|
|
618
|
+
"image/svg+xml",
|
|
619
|
+
"application/javascript",
|
|
620
|
+
"text/javascript",
|
|
621
|
+
"application/x-executable",
|
|
622
|
+
"application/wasm",
|
|
623
|
+
"application/x-topik-active-content"
|
|
624
|
+
].includes(mediaType);
|
|
625
|
+
}
|
|
626
|
+
function normalizeAllowedOrigin(value) {
|
|
627
|
+
let url;
|
|
168
628
|
try {
|
|
169
|
-
|
|
170
|
-
realDir = realpathSync(dir);
|
|
171
|
-
realFilePath = realpathSync(filePath);
|
|
629
|
+
url = new URL(value);
|
|
172
630
|
} catch {
|
|
173
|
-
|
|
174
|
-
res.end();
|
|
175
|
-
return true;
|
|
176
|
-
}
|
|
177
|
-
const relativeAssetPath = relative(realDir, realFilePath);
|
|
178
|
-
if (relativeAssetPath !== "" && (relativeAssetPath.startsWith("..") || isAbsolute(relativeAssetPath)) || !existsSync(realFilePath)) {
|
|
179
|
-
res.writeHead(404, { "Access-Control-Allow-Origin": "*" });
|
|
180
|
-
res.end();
|
|
181
|
-
return true;
|
|
631
|
+
throw new PublicCliError("browser-origin-invalid");
|
|
182
632
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
633
|
+
if (url.protocol !== "http:" && url.protocol !== "https:" || url.origin === "null" || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "") throw new PublicCliError("browser-origin-invalid");
|
|
634
|
+
return url.origin;
|
|
635
|
+
}
|
|
636
|
+
function isAllowedHost(host, port) {
|
|
637
|
+
if (!host) return false;
|
|
638
|
+
const normalizedHost = host.toLowerCase();
|
|
639
|
+
if (normalizedHost === `localhost:${port}` || normalizedHost === `127.0.0.1:${port}` || normalizedHost === `[::1]:${port}`) return true;
|
|
640
|
+
return port === 80 && (normalizedHost === "localhost" || normalizedHost === "127.0.0.1" || normalizedHost === "[::1]");
|
|
641
|
+
}
|
|
642
|
+
function isLoopbackAddress(address) {
|
|
643
|
+
return address === "127.0.0.1" || address === "::1";
|
|
644
|
+
}
|
|
645
|
+
function getCorsHeaders(origin, allowedOrigin) {
|
|
646
|
+
return origin === allowedOrigin ? {
|
|
647
|
+
"Access-Control-Allow-Origin": allowedOrigin,
|
|
648
|
+
Vary: REQUEST_VARY_HEADERS
|
|
649
|
+
} : { Vary: REQUEST_VARY_HEADERS };
|
|
650
|
+
}
|
|
651
|
+
function rejectRequest(res) {
|
|
652
|
+
res.writeHead(403, {
|
|
653
|
+
"Content-Type": "text/plain",
|
|
654
|
+
Vary: REQUEST_VARY_HEADERS
|
|
655
|
+
});
|
|
656
|
+
res.end("Forbidden");
|
|
657
|
+
}
|
|
658
|
+
function createRequestHandler(watcher, getPort, allowedOrigin) {
|
|
659
|
+
return (req, res) => {
|
|
660
|
+
if (!isAllowedHost(req.headers.host, getPort())) {
|
|
661
|
+
rejectRequest(res);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
const origin = req.headers.origin;
|
|
665
|
+
const fetchSite = req.headers["sec-fetch-site"];
|
|
666
|
+
if (origin !== void 0 && origin !== allowedOrigin || origin === void 0 && fetchSite !== void 0 && fetchSite !== "same-origin" && fetchSite !== "none") {
|
|
667
|
+
rejectRequest(res);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
const corsHeaders = getCorsHeaders(origin, allowedOrigin);
|
|
671
|
+
if (req.method === "OPTIONS") {
|
|
672
|
+
res.writeHead(204, {
|
|
673
|
+
...corsHeaders,
|
|
674
|
+
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
|
|
675
|
+
"Access-Control-Allow-Headers": "Content-Type"
|
|
676
|
+
});
|
|
677
|
+
res.end();
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const requestTarget = req.url ?? "/";
|
|
681
|
+
let url;
|
|
682
|
+
try {
|
|
683
|
+
url = new URL(requestTarget, `http://localhost:${getPort()}`);
|
|
684
|
+
} catch {
|
|
685
|
+
res.writeHead(400, {
|
|
686
|
+
"Content-Type": "text/plain",
|
|
687
|
+
...corsHeaders
|
|
688
|
+
});
|
|
689
|
+
res.end("Bad Request");
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
693
|
+
res.writeHead(200, {
|
|
694
|
+
"Content-Type": "application/json",
|
|
695
|
+
"Cache-Control": "no-store",
|
|
696
|
+
...corsHeaders
|
|
697
|
+
});
|
|
698
|
+
res.end(JSON.stringify({
|
|
699
|
+
status: "ok",
|
|
700
|
+
version,
|
|
701
|
+
capabilities: [TOPIK_PREVIEW_PROTOCOL],
|
|
702
|
+
sessionId: watcher.state.sessionId
|
|
703
|
+
}));
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (req.method === "GET" && url.pathname === "/resources") {
|
|
707
|
+
handleResources(watcher, res, corsHeaders);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (req.method === "GET" && url.pathname === "/events") {
|
|
711
|
+
handleEvents(watcher, res, corsHeaders);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (url.pathname.startsWith("/blobs/") && (url.searchParams.has("sessionId") || url.searchParams.has("generation"))) {
|
|
715
|
+
if (url.searchParams.getAll("sessionId").length !== 1 || url.searchParams.getAll("generation").length !== 1 || url.searchParams.get("sessionId") !== watcher.state.sessionId || url.searchParams.get("generation") !== String(watcher.state.generation)) {
|
|
716
|
+
res.writeHead(409, {
|
|
717
|
+
...corsHeaders,
|
|
718
|
+
"Cache-Control": "no-store"
|
|
719
|
+
});
|
|
720
|
+
res.end();
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
const queryOffset = requestTarget.indexOf("?");
|
|
725
|
+
const rawPathname = queryOffset === -1 ? requestTarget : requestTarget.slice(0, queryOffset);
|
|
726
|
+
if ((req.method === "GET" || req.method === "HEAD") && handleAssetPayload(watcher, rawPathname, req.method, res, corsHeaders)) return;
|
|
727
|
+
res.writeHead(404, {
|
|
728
|
+
"Content-Type": "text/plain",
|
|
729
|
+
...corsHeaders
|
|
188
730
|
});
|
|
189
|
-
res.end(
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
|
|
731
|
+
res.end("Not Found");
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
/** @internal */
|
|
735
|
+
async function startDevServer(options) {
|
|
736
|
+
const dir = resolve(options.dir);
|
|
737
|
+
const allowedOrigin = normalizeAllowedOrigin(options.allowOrigin ?? DEFAULT_ALLOWED_ORIGIN);
|
|
738
|
+
console.log("Watching content for changes...");
|
|
739
|
+
const watcher = await watch({ dir });
|
|
740
|
+
if (watcher.state.status === "error") console.error("Compile error:", watcher.state.diagnostics.map((diagnostic) => diagnostic.message).join(" "));
|
|
741
|
+
else console.log(`Compiled ${watcher.resources.size} resources`);
|
|
742
|
+
watcher.on("error", (error) => {
|
|
743
|
+
console.error("Compile error:", formatPublicCliError(error));
|
|
193
744
|
});
|
|
194
|
-
|
|
745
|
+
watcher.on("update", (key) => {
|
|
746
|
+
console.log(`Updated ${key}`);
|
|
747
|
+
});
|
|
748
|
+
let activePort = options.port;
|
|
749
|
+
const server = createServer(createRequestHandler(watcher, () => activePort, allowedOrigin));
|
|
750
|
+
try {
|
|
751
|
+
await new Promise((resolveListening, rejectListening) => {
|
|
752
|
+
const onError = (error) => {
|
|
753
|
+
server.off("listening", onListening);
|
|
754
|
+
rejectListening(error);
|
|
755
|
+
};
|
|
756
|
+
const onListening = () => {
|
|
757
|
+
server.off("error", onError);
|
|
758
|
+
const address = server.address();
|
|
759
|
+
if (!address || typeof address === "string" || !isLoopbackAddress(address.address)) {
|
|
760
|
+
server.close(() => {
|
|
761
|
+
rejectListening(/* @__PURE__ */ new Error("Dev server did not bind to a loopback address"));
|
|
762
|
+
});
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
activePort = address.port;
|
|
766
|
+
resolveListening();
|
|
767
|
+
};
|
|
768
|
+
server.once("error", onError);
|
|
769
|
+
server.once("listening", onListening);
|
|
770
|
+
server.listen(options.port, DEV_HOST);
|
|
771
|
+
});
|
|
772
|
+
} catch (error) {
|
|
773
|
+
await watcher.close();
|
|
774
|
+
throw error;
|
|
775
|
+
}
|
|
776
|
+
let closePromise;
|
|
777
|
+
return {
|
|
778
|
+
port: activePort,
|
|
779
|
+
server,
|
|
780
|
+
watcher,
|
|
781
|
+
close: () => {
|
|
782
|
+
closePromise ??= (async () => {
|
|
783
|
+
const serverClosed = server.listening ? new Promise((resolveClosed, rejectClosed) => {
|
|
784
|
+
server.close((error) => {
|
|
785
|
+
if (error) rejectClosed(error);
|
|
786
|
+
else resolveClosed();
|
|
787
|
+
});
|
|
788
|
+
server.closeAllConnections();
|
|
789
|
+
}) : Promise.resolve();
|
|
790
|
+
await Promise.all([serverClosed, watcher.close()]);
|
|
791
|
+
})();
|
|
792
|
+
return closePromise;
|
|
793
|
+
}
|
|
794
|
+
};
|
|
195
795
|
}
|
|
196
796
|
const dev = command({
|
|
197
797
|
name: "dev",
|
|
198
798
|
desc: "Start a development server that watches for content changes",
|
|
199
799
|
options: {
|
|
200
|
-
dir: positional("dir").desc("
|
|
201
|
-
port: string("port").alias("p").desc("Port to listen on").default("50001")
|
|
800
|
+
dir: positional("dir").desc("Project directory containing .topik.yaml").default("."),
|
|
801
|
+
port: string("port").alias("p").desc("Port to listen on").default("50001"),
|
|
802
|
+
allowOrigin: string("allow-origin").desc(`Browser origin allowed to connect (default: ${DEFAULT_ALLOWED_ORIGIN})`)
|
|
202
803
|
},
|
|
203
804
|
handler: async (options) => {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
console.log(`Compiled ${watcher.resources.size} resources`);
|
|
209
|
-
watcher.on("error", (error) => {
|
|
210
|
-
console.error("Compile error:", error.message);
|
|
211
|
-
});
|
|
212
|
-
watcher.on("update", (key) => {
|
|
213
|
-
console.log(`Updated ${key}`);
|
|
214
|
-
});
|
|
215
|
-
const server = createServer((req, res) => {
|
|
216
|
-
if (req.method === "OPTIONS") {
|
|
217
|
-
res.writeHead(204, {
|
|
218
|
-
"Access-Control-Allow-Origin": "*",
|
|
219
|
-
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
220
|
-
"Access-Control-Allow-Headers": "Content-Type"
|
|
221
|
-
});
|
|
222
|
-
res.end();
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
226
|
-
if (req.method === "GET" && url.pathname === "/health") {
|
|
227
|
-
res.writeHead(200, {
|
|
228
|
-
"Content-Type": "application/json",
|
|
229
|
-
"Access-Control-Allow-Origin": "*"
|
|
230
|
-
});
|
|
231
|
-
res.end(JSON.stringify({ status: "ok" }));
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
if (req.method === "GET" && url.pathname === "/resources") {
|
|
235
|
-
handleResources(watcher, req, res);
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
if (req.method === "GET" && url.pathname === "/events") {
|
|
239
|
-
handleEvents(watcher, req, res);
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
if (req.method === "GET" && handleAsset(watcher, dir, url, res)) return;
|
|
243
|
-
res.writeHead(404, {
|
|
244
|
-
"Content-Type": "text/plain",
|
|
245
|
-
"Access-Control-Allow-Origin": "*"
|
|
246
|
-
});
|
|
247
|
-
res.end("Not Found");
|
|
248
|
-
});
|
|
249
|
-
server.listen(port, () => {
|
|
250
|
-
console.log(`Dev server listening on http://localhost:${port}`);
|
|
805
|
+
const runningServer = await startDevServer({
|
|
806
|
+
dir: resolve(options.dir),
|
|
807
|
+
port: parseInt(options.port, 10),
|
|
808
|
+
allowOrigin: options.allowOrigin
|
|
251
809
|
});
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
await
|
|
810
|
+
console.log(`Dev server listening on http://localhost:${runningServer.port}`);
|
|
811
|
+
process.once("SIGINT", async () => {
|
|
812
|
+
await runningServer.close();
|
|
255
813
|
process.exit(0);
|
|
256
814
|
});
|
|
257
815
|
}
|
|
@@ -262,7 +820,7 @@ const lint$1 = command({
|
|
|
262
820
|
name: "lint",
|
|
263
821
|
desc: "Lint Topik source content without writing compiled resources",
|
|
264
822
|
options: {
|
|
265
|
-
dir: positional("dir").desc("
|
|
823
|
+
dir: positional("dir").desc("Project directory containing .topik.yaml").default("."),
|
|
266
824
|
links: string("links").desc("How unresolved wiki links and local guide fragments are handled").enum("error", "warning", "off").default("error")
|
|
267
825
|
},
|
|
268
826
|
handler: async (options) => {
|
|
@@ -280,52 +838,53 @@ const lint$1 = command({
|
|
|
280
838
|
});
|
|
281
839
|
//#endregion
|
|
282
840
|
//#region src/validate/index.ts
|
|
283
|
-
function parseJson(
|
|
841
|
+
function parseJson(content) {
|
|
284
842
|
try {
|
|
285
843
|
return JSON.parse(content);
|
|
286
|
-
} catch
|
|
287
|
-
throw new
|
|
844
|
+
} catch {
|
|
845
|
+
throw new PublicCliError("resource-json-invalid");
|
|
288
846
|
}
|
|
289
847
|
}
|
|
290
|
-
function parseJsonl(
|
|
291
|
-
return content.split("\n").
|
|
292
|
-
line,
|
|
293
|
-
index: index + 1
|
|
294
|
-
})).filter(({ line }) => line.trim().length > 0).map(({ line, index }) => {
|
|
848
|
+
function parseJsonl(content) {
|
|
849
|
+
return content.split("\n").filter((line) => line.trim().length > 0).map((line) => {
|
|
295
850
|
try {
|
|
296
851
|
return JSON.parse(line);
|
|
297
|
-
} catch
|
|
298
|
-
throw new
|
|
852
|
+
} catch {
|
|
853
|
+
throw new PublicCliError("resource-jsonl-invalid");
|
|
299
854
|
}
|
|
300
855
|
});
|
|
301
856
|
}
|
|
302
|
-
function parseYaml(
|
|
857
|
+
function parseYaml(content) {
|
|
303
858
|
try {
|
|
304
859
|
return parseAllDocuments(content).filter((document) => document.contents !== null).map((document) => {
|
|
305
860
|
if (document.errors.length > 0) throw document.errors[0];
|
|
306
861
|
return document.toJS();
|
|
307
862
|
});
|
|
308
|
-
} catch
|
|
309
|
-
throw new
|
|
863
|
+
} catch {
|
|
864
|
+
throw new PublicCliError("resource-yaml-invalid");
|
|
310
865
|
}
|
|
311
866
|
}
|
|
312
867
|
async function readResourceFile(path) {
|
|
313
|
-
const content = await readFile(path, "utf-8")
|
|
868
|
+
const content = await readFile(path, "utf-8").catch(() => {
|
|
869
|
+
throw new PublicCliError("resource-read-failed");
|
|
870
|
+
});
|
|
314
871
|
switch (extname(path).toLowerCase()) {
|
|
315
|
-
case ".json": return [parseJson(
|
|
316
|
-
case ".jsonl": return parseJsonl(
|
|
872
|
+
case ".json": return [parseJson(content)];
|
|
873
|
+
case ".jsonl": return parseJsonl(content);
|
|
317
874
|
case ".yaml":
|
|
318
|
-
case ".yml": return parseYaml(
|
|
319
|
-
default: throw new
|
|
875
|
+
case ".yml": return parseYaml(content);
|
|
876
|
+
default: throw new PublicCliError("resource-format-unsupported");
|
|
320
877
|
}
|
|
321
878
|
}
|
|
322
879
|
async function loadResources(path) {
|
|
323
|
-
if ((await stat(path).catch((
|
|
324
|
-
throw new
|
|
880
|
+
if ((await stat(path).catch(() => {
|
|
881
|
+
throw new PublicCliError("resource-access-failed");
|
|
325
882
|
})).isFile()) return readResourceFile(path);
|
|
326
883
|
const resourceFiles = (await readdir(path, {
|
|
327
884
|
withFileTypes: true,
|
|
328
885
|
recursive: true
|
|
886
|
+
}).catch(() => {
|
|
887
|
+
throw new PublicCliError("resource-read-failed");
|
|
329
888
|
})).filter((entry) => {
|
|
330
889
|
if (!entry.isFile()) return false;
|
|
331
890
|
const extension = extname(entry.name).toLowerCase();
|
|
@@ -343,9 +902,9 @@ const validate = command({
|
|
|
343
902
|
console.log("No resource files found.");
|
|
344
903
|
return;
|
|
345
904
|
}
|
|
346
|
-
const { valid
|
|
905
|
+
const { valid } = validateResources(resources);
|
|
347
906
|
if (valid) console.log(`Validated ${resources.length} resources`);
|
|
348
|
-
else throw new
|
|
907
|
+
else throw new PublicCliError("resource-validation-failed");
|
|
349
908
|
}
|
|
350
909
|
});
|
|
351
910
|
//#endregion
|
|
@@ -361,11 +920,8 @@ try {
|
|
|
361
920
|
description: "Topik CLI"
|
|
362
921
|
});
|
|
363
922
|
} catch (error) {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
process.exit(1);
|
|
367
|
-
}
|
|
368
|
-
throw error;
|
|
923
|
+
console.error(formatPublicCliError(error));
|
|
924
|
+
process.exitCode = 1;
|
|
369
925
|
}
|
|
370
926
|
//#endregion
|
|
371
927
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@topik/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.6",
|
|
4
4
|
"description": "Topik CLI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -24,16 +24,16 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@drizzle-team/brocli": "^0.12.0",
|
|
27
|
-
"yaml": "^2.9.
|
|
28
|
-
"@topik/content
|
|
29
|
-
"@topik/core": "0.1.0-alpha.
|
|
27
|
+
"yaml": "^2.9.1",
|
|
28
|
+
"@topik/content": "0.1.0-alpha.6",
|
|
29
|
+
"@topik/core": "0.1.0-alpha.6"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@types/bun": "^1.3
|
|
33
|
-
"@types/node": "22.20.
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"vite-plus": "
|
|
32
|
+
"@types/bun": "^1.4.3",
|
|
33
|
+
"@types/node": "22.20.4",
|
|
34
|
+
"typescript": "^7.0.2",
|
|
35
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.2.0",
|
|
36
|
+
"vite-plus": "1.2.0"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "vp pack",
|