@someonegg/codex-hub 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/client/assets/{index-fx0H9Qr4.css → index-CODhVb8z.css} +1 -1
- package/dist/client/assets/index-CrCuCnqn.js +306 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/config.js +25 -3
- package/dist/server/server/hosting/diff-store.js +102 -0
- package/dist/server/server/hosting/lock.js +65 -0
- package/dist/server/server/hosting/routes.js +152 -0
- package/dist/server/server/hosting/store.js +158 -0
- package/dist/server/server/http/create-server.js +39 -6
- package/dist/server/server/http/router.js +1 -1
- package/dist/server/server/main.js +55 -0
- package/dist/server/shared/diff.js +158 -0
- package/package.json +3 -2
- package/dist/client/assets/index-5lzZNvZX.js +0 -304
package/dist/client/index.html
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
|
|
14
14
|
<link rel="manifest" href="/manifest.webmanifest" />
|
|
15
15
|
<title>Codex Hub</title>
|
|
16
|
-
<script type="module" crossorigin src="/assets/index-
|
|
17
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
16
|
+
<script type="module" crossorigin src="/assets/index-CrCuCnqn.js"></script>
|
|
17
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CODhVb8z.css">
|
|
18
18
|
</head>
|
|
19
19
|
<body>
|
|
20
20
|
<div id="root"></div>
|
|
@@ -38,10 +38,14 @@ export function loadConfig(args = process.argv.slice(2)) {
|
|
|
38
38
|
help: { type: "boolean" },
|
|
39
39
|
"tmux-socket": { type: "string" },
|
|
40
40
|
"upload-dir": { type: "string" },
|
|
41
|
+
hosting: { type: "boolean" },
|
|
42
|
+
"hosting-dir": { type: "string" },
|
|
41
43
|
},
|
|
42
44
|
});
|
|
43
45
|
if (values.help)
|
|
44
46
|
return { kind: "help" };
|
|
47
|
+
if (values["hosting-dir"] !== undefined && !values.hosting)
|
|
48
|
+
throw new Error("--hosting-dir requires --hosting");
|
|
45
49
|
const host = nonEmptyOption("host", values.host) ?? LOOPBACK_HOST;
|
|
46
50
|
const homes = parseHomes(values["codex-home"] ?? ["default=~/.codex"]);
|
|
47
51
|
const certificatePath = nonEmptyOption("ssl-cert", values["ssl-cert"]);
|
|
@@ -76,8 +80,15 @@ export function loadConfig(args = process.argv.slice(2)) {
|
|
|
76
80
|
clientDirectory: fileURLToPath(new URL("../../client/", import.meta.url)),
|
|
77
81
|
tls,
|
|
78
82
|
interactionEnabled: true,
|
|
83
|
+
...(values.hosting
|
|
84
|
+
? {
|
|
85
|
+
hostingDirectory: values["hosting-dir"] !== undefined
|
|
86
|
+
? resolveDirectoryOption("hosting-dir", values["hosting-dir"])
|
|
87
|
+
: resolve(homedir(), ".codex-hub/hosting"),
|
|
88
|
+
}
|
|
89
|
+
: {}),
|
|
79
90
|
...(values["upload-dir"] !== undefined
|
|
80
|
-
? { uploadDirectory:
|
|
91
|
+
? { uploadDirectory: resolveDirectoryOption("upload-dir", values["upload-dir"]) }
|
|
81
92
|
: {}),
|
|
82
93
|
...(values["tmux-socket"] !== undefined
|
|
83
94
|
? { tmuxSocket: resolve(nonEmptyOption("tmux-socket", values["tmux-socket"])) }
|
|
@@ -88,7 +99,18 @@ export function loadConfig(args = process.argv.slice(2)) {
|
|
|
88
99
|
export function commandLineHelp() {
|
|
89
100
|
return `Usage: codex-hub [options]
|
|
90
101
|
|
|
102
|
+
Resources:
|
|
103
|
+
codex-hub page list
|
|
104
|
+
codex-hub page delete <id>
|
|
105
|
+
codex-hub diff list
|
|
106
|
+
codex-hub diff delete <uuid>
|
|
107
|
+
All resource commands accept --hosting-dir <directory> and --json.
|
|
108
|
+
Diff: codex-hub diff publish <file> [--title <title>] [--hosting-dir <directory>] [--json]
|
|
109
|
+
Pages: codex-hub page publish <directory> --project <directory> [--name <name>] [--title <title>] [--hosting-dir <directory>] [--json]
|
|
110
|
+
|
|
91
111
|
Options:
|
|
112
|
+
--hosting Enable trusted same-origin static web hosting
|
|
113
|
+
--hosting-dir <path> Hosting storage (requires --hosting; default: ~/.codex-hub/hosting)
|
|
92
114
|
--codex-home <nickname=path>
|
|
93
115
|
Codex home; repeat for multiple homes (default: default=~/.codex)
|
|
94
116
|
--host <host> Listen host (default: 127.0.0.1)
|
|
@@ -138,8 +160,8 @@ function parseHomes(values) {
|
|
|
138
160
|
return { nickname, path };
|
|
139
161
|
});
|
|
140
162
|
}
|
|
141
|
-
function
|
|
142
|
-
const path = nonEmptyOption(
|
|
163
|
+
function resolveDirectoryOption(name, value) {
|
|
164
|
+
const path = nonEmptyOption(name, value);
|
|
143
165
|
return resolve(path.startsWith("~/") ? resolve(homedir(), path.slice(2)) : path);
|
|
144
166
|
}
|
|
145
167
|
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { HostingStore } from "./store.js";
|
|
5
|
+
export const DIFF_ID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
6
|
+
export class DiffStore {
|
|
7
|
+
hosting;
|
|
8
|
+
constructor(root) {
|
|
9
|
+
this.hosting = new HostingStore(root);
|
|
10
|
+
}
|
|
11
|
+
directory(id) {
|
|
12
|
+
return join(this.hosting.root, "diffs", id);
|
|
13
|
+
}
|
|
14
|
+
async metadata(id) {
|
|
15
|
+
if (!DIFF_ID.test(id))
|
|
16
|
+
return null;
|
|
17
|
+
try {
|
|
18
|
+
const directory = this.directory(id);
|
|
19
|
+
const metadata = JSON.parse(await readFile(join(directory, "metadata.json"), "utf8"));
|
|
20
|
+
if (metadata.id !== id ||
|
|
21
|
+
metadata.url !== `/diffs/${id}` ||
|
|
22
|
+
typeof metadata.title !== "string" ||
|
|
23
|
+
!Number.isFinite(Date.parse(metadata.createdAt)))
|
|
24
|
+
throw new Error("Invalid diff metadata");
|
|
25
|
+
return metadata;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error.code === "ENOENT")
|
|
29
|
+
return null;
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async exists(id) {
|
|
34
|
+
if (!(await this.metadata(id)))
|
|
35
|
+
return false;
|
|
36
|
+
try {
|
|
37
|
+
return (await lstat(join(this.directory(id), "raw.diff"))).isFile();
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code === "ENOENT")
|
|
41
|
+
return false;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async get(id) {
|
|
46
|
+
const metadata = await this.metadata(id);
|
|
47
|
+
if (!metadata)
|
|
48
|
+
return null;
|
|
49
|
+
try {
|
|
50
|
+
return { ...metadata, raw: await readFile(join(this.directory(id), "raw.diff"), "utf8") };
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error.code === "ENOENT")
|
|
54
|
+
return null;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async list() {
|
|
59
|
+
let names;
|
|
60
|
+
try {
|
|
61
|
+
names = await readdir(join(this.hosting.root, "diffs"));
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error.code === "ENOENT")
|
|
65
|
+
return [];
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
const entries = await Promise.all(names.filter((id) => DIFF_ID.test(id)).map((id) => this.metadata(id)));
|
|
69
|
+
return entries.filter((entry) => entry !== null);
|
|
70
|
+
}
|
|
71
|
+
async publish(file, title) {
|
|
72
|
+
if (!(await lstat(file)).isFile())
|
|
73
|
+
throw new Error("Diff source must be a regular file");
|
|
74
|
+
const raw = await readFile(file);
|
|
75
|
+
return this.hosting.locked(async () => {
|
|
76
|
+
const id = randomUUID();
|
|
77
|
+
const metadata = {
|
|
78
|
+
id,
|
|
79
|
+
title: title ?? basename(file),
|
|
80
|
+
createdAt: new Date().toISOString(),
|
|
81
|
+
url: `/diffs/${id}`,
|
|
82
|
+
};
|
|
83
|
+
const temporary = this.directory(`.tmp-${id}`);
|
|
84
|
+
await mkdir(temporary, { recursive: true });
|
|
85
|
+
try {
|
|
86
|
+
await writeFile(join(temporary, "raw.diff"), raw);
|
|
87
|
+
await writeFile(join(temporary, "metadata.json"), JSON.stringify(metadata));
|
|
88
|
+
await rename(temporary, this.directory(id));
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await rm(temporary, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
return metadata;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async delete(id) {
|
|
97
|
+
if (!DIFF_ID.test(id))
|
|
98
|
+
throw new Error("Invalid diff UUID");
|
|
99
|
+
await this.hosting.locked(() => rm(this.directory(id), { recursive: true, force: true }));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=diff-store.js.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/** A complete owner directory is atomically installed; stale owners move to permanent tombstones.
|
|
5
|
+
* Keeping tombstones prevents a second reclaimer from renaming a new owner's lock. */
|
|
6
|
+
export class HostingLock {
|
|
7
|
+
token = randomUUID();
|
|
8
|
+
attempt;
|
|
9
|
+
target;
|
|
10
|
+
held = false;
|
|
11
|
+
constructor(root, name) {
|
|
12
|
+
mkdirSync(root, { recursive: true });
|
|
13
|
+
this.target = join(root, name);
|
|
14
|
+
this.attempt = join(root, `${name}-attempt-${this.token}`);
|
|
15
|
+
mkdirSync(this.attempt);
|
|
16
|
+
writeFileSync(join(this.attempt, "owner.json"), JSON.stringify({ pid: process.pid, token: this.token }));
|
|
17
|
+
}
|
|
18
|
+
acquire() {
|
|
19
|
+
for (;;) {
|
|
20
|
+
try {
|
|
21
|
+
renameSync(this.attempt, this.target);
|
|
22
|
+
this.held = true;
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (!["EEXIST", "ENOTEMPTY"].includes(String(error.code)))
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
let owner;
|
|
30
|
+
try {
|
|
31
|
+
owner = JSON.parse(readFileSync(join(this.target, "owner.json"), "utf8"));
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error.code === "ENOENT")
|
|
35
|
+
continue;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
if (!Number.isInteger(owner.pid) || owner.pid <= 0 || !/^[a-f0-9-]{36}$/.test(owner.token))
|
|
39
|
+
throw new Error("Invalid hosting lock owner");
|
|
40
|
+
try {
|
|
41
|
+
process.kill(owner.pid, 0);
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error.code !== "ESRCH")
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
renameSync(this.target, `${this.target}-dead-${owner.token}`);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (!["ENOENT", "EEXIST", "ENOTEMPTY"].includes(String(error.code)))
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
close() {
|
|
58
|
+
if (this.held) {
|
|
59
|
+
renameSync(this.target, this.attempt);
|
|
60
|
+
this.held = false;
|
|
61
|
+
}
|
|
62
|
+
rmSync(this.attempt, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=lock.js.map
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { extname, join, resolve, sep } from "node:path";
|
|
4
|
+
import { sendJson } from "../http/router.js";
|
|
5
|
+
import { DiffStore } from "./diff-store.js";
|
|
6
|
+
import { HostingStore } from "./store.js";
|
|
7
|
+
const types = {
|
|
8
|
+
".html": "text/html; charset=utf-8",
|
|
9
|
+
".css": "text/css; charset=utf-8",
|
|
10
|
+
".js": "text/javascript; charset=utf-8",
|
|
11
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
12
|
+
".json": "application/json",
|
|
13
|
+
".svg": "image/svg+xml",
|
|
14
|
+
".png": "image/png",
|
|
15
|
+
".jpg": "image/jpeg",
|
|
16
|
+
".jpeg": "image/jpeg",
|
|
17
|
+
".gif": "image/gif",
|
|
18
|
+
".webp": "image/webp",
|
|
19
|
+
".avif": "image/avif",
|
|
20
|
+
".ico": "image/x-icon",
|
|
21
|
+
".woff": "font/woff",
|
|
22
|
+
".woff2": "font/woff2",
|
|
23
|
+
".ttf": "font/ttf",
|
|
24
|
+
".otf": "font/otf",
|
|
25
|
+
".wasm": "application/wasm",
|
|
26
|
+
".xml": "application/xml",
|
|
27
|
+
".pdf": "application/pdf",
|
|
28
|
+
".webmanifest": "application/manifest+json",
|
|
29
|
+
".wav": "audio/wav",
|
|
30
|
+
".txt": "text/plain; charset=utf-8",
|
|
31
|
+
".mp4": "video/mp4",
|
|
32
|
+
".webm": "video/webm",
|
|
33
|
+
".mp3": "audio/mpeg",
|
|
34
|
+
};
|
|
35
|
+
export function hostingRoutes(directory) {
|
|
36
|
+
const store = directory === undefined ? undefined : new HostingStore(directory);
|
|
37
|
+
const diffs = directory === undefined ? undefined : new DiffStore(directory);
|
|
38
|
+
return async (request, response) => {
|
|
39
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
40
|
+
const path = url.pathname;
|
|
41
|
+
const head = request.method === "HEAD";
|
|
42
|
+
if (path === "/api/v1/capabilities" && request.method === "GET") {
|
|
43
|
+
sendJson(response, 200, { hosting: !!store });
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
if (path.startsWith("/api/v1/diffs")) {
|
|
47
|
+
response.setHeader("Cache-Control", "private, no-store");
|
|
48
|
+
const match = /^\/api\/v1\/diffs\/([^/]+)$/.exec(path);
|
|
49
|
+
const entry = diffs && match && request.method === "GET" ? await diffs.get(match[1]) : null;
|
|
50
|
+
if (entry) {
|
|
51
|
+
sendJson(response, 200, entry);
|
|
52
|
+
}
|
|
53
|
+
else if (diffs && match && request.method === "DELETE" && (await diffs.exists(match[1]))) {
|
|
54
|
+
await diffs.delete(match[1]);
|
|
55
|
+
response.statusCode = 204;
|
|
56
|
+
response.end();
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
sendJson(response, 404, {
|
|
60
|
+
error: { code: "not_found", message: "Diff not found or already cleaned up" },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
if (!path.startsWith("/web/") && !path.startsWith("/api/v1/artifacts"))
|
|
66
|
+
return false;
|
|
67
|
+
const missing = () => sendJson(response, 404, { error: { code: "not_found", message: "Artifact not found" } }, head);
|
|
68
|
+
if (!store) {
|
|
69
|
+
missing();
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (path === "/api/v1/artifacts" && request.method === "GET") {
|
|
73
|
+
const groups = new Map();
|
|
74
|
+
for (const { artifact } of await store.entries()) {
|
|
75
|
+
const list = groups.get(artifact.project) ?? [];
|
|
76
|
+
list.push(artifact);
|
|
77
|
+
groups.set(artifact.project, list);
|
|
78
|
+
}
|
|
79
|
+
sendJson(response, 200, {
|
|
80
|
+
projects: [...groups].map(([directory, artifacts]) => ({ directory, artifacts })),
|
|
81
|
+
});
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
const deletion = /^\/api\/v1\/artifacts\/([a-f0-9]{32})$/.exec(path);
|
|
85
|
+
if (deletion && request.method === "DELETE") {
|
|
86
|
+
if (await store.delete(deletion[1])) {
|
|
87
|
+
response.statusCode = 204;
|
|
88
|
+
response.end();
|
|
89
|
+
}
|
|
90
|
+
else
|
|
91
|
+
missing();
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
const match = /^\/web\/([a-f0-9]{32})\/(?:([a-f0-9]{32})\/(.*))?$/.exec(path);
|
|
95
|
+
if (!match || (request.method !== "GET" && !head)) {
|
|
96
|
+
missing();
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
response.removeHeader("Content-Security-Policy");
|
|
100
|
+
response.setHeader("Cache-Control", "private, no-store");
|
|
101
|
+
const entry = await store.get(match[1]);
|
|
102
|
+
if (!entry) {
|
|
103
|
+
missing();
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
if (!match[2]) {
|
|
107
|
+
response.statusCode = 302;
|
|
108
|
+
response.setHeader("Location", `/web/${entry.artifact.id}/${entry.artifact.snapshotId}/${url.search}`);
|
|
109
|
+
response.end();
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const relative = decodeURIComponent(match[3]);
|
|
114
|
+
if (relative.split(/[\\/]/).some((part) => part === "..") || relative.includes("\0"))
|
|
115
|
+
throw new Error("Invalid path");
|
|
116
|
+
const snapshotRoot = resolve(entry.directory, "snapshots", match[2]);
|
|
117
|
+
const root = await realpath(snapshotRoot);
|
|
118
|
+
if (root !== snapshotRoot)
|
|
119
|
+
throw new Error("Snapshot aliases are not allowed");
|
|
120
|
+
let file = await realpath(resolve(root, relative || "index.html"));
|
|
121
|
+
if (!file.startsWith(root + sep))
|
|
122
|
+
throw new Error("Invalid path");
|
|
123
|
+
if ((await stat(file)).isDirectory()) {
|
|
124
|
+
if (!path.endsWith("/")) {
|
|
125
|
+
response.statusCode = 302;
|
|
126
|
+
response.setHeader("Location", path + "/" + url.search);
|
|
127
|
+
response.end();
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
file = await realpath(join(file, "index.html"));
|
|
131
|
+
}
|
|
132
|
+
if (!file.startsWith(root + sep))
|
|
133
|
+
throw new Error("Invalid path");
|
|
134
|
+
const info = await stat(file);
|
|
135
|
+
if (!info.isFile())
|
|
136
|
+
throw new Error("Not a file");
|
|
137
|
+
response.setHeader("Content-Type", types[extname(file).toLowerCase()] ?? "application/octet-stream");
|
|
138
|
+
response.setHeader("Content-Length", info.size);
|
|
139
|
+
if (head)
|
|
140
|
+
response.end();
|
|
141
|
+
else
|
|
142
|
+
createReadStream(file)
|
|
143
|
+
.on("error", () => response.destroy())
|
|
144
|
+
.pipe(response);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
missing();
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=routes.js.map
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { copyFile, lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile, } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
6
|
+
import { HostingLock } from "./lock.js";
|
|
7
|
+
export const ID = /^[a-f0-9]{32}$/;
|
|
8
|
+
export const defaultHostingDirectory = join(homedir(), ".codex-hub", "hosting");
|
|
9
|
+
function parseArtifact(json) {
|
|
10
|
+
const value = JSON.parse(json);
|
|
11
|
+
if (!value ||
|
|
12
|
+
!ID.test(value.id) ||
|
|
13
|
+
!ID.test(value.snapshotId) ||
|
|
14
|
+
typeof value.project !== "string" ||
|
|
15
|
+
typeof value.source !== "string" ||
|
|
16
|
+
typeof value.name !== "string" ||
|
|
17
|
+
typeof value.title !== "string" ||
|
|
18
|
+
!Number.isFinite(Date.parse(value.createdAt)) ||
|
|
19
|
+
!Number.isFinite(Date.parse(value.updatedAt)) ||
|
|
20
|
+
value.url !== `/web/${value.id}/`)
|
|
21
|
+
throw new Error("Invalid artifact metadata");
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
async function copySnapshot(from, to) {
|
|
25
|
+
const info = await lstat(from);
|
|
26
|
+
if (info.isDirectory()) {
|
|
27
|
+
await mkdir(to);
|
|
28
|
+
for (const item of await readdir(from)) {
|
|
29
|
+
await copySnapshot(join(from, item), join(to, item));
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!info.isFile())
|
|
34
|
+
throw new Error(`Only regular files and directories can be published: ${from}`);
|
|
35
|
+
await copyFile(from, to, constants.COPYFILE_EXCL);
|
|
36
|
+
}
|
|
37
|
+
function inside(child, parent) {
|
|
38
|
+
return child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
|
|
39
|
+
}
|
|
40
|
+
export class HostingStore {
|
|
41
|
+
root;
|
|
42
|
+
constructor(root = defaultHostingDirectory) {
|
|
43
|
+
if (!root.trim())
|
|
44
|
+
throw new Error("Hosting directory must not be empty");
|
|
45
|
+
this.root = resolve(root.startsWith("~/") ? join(homedir(), root.slice(2)) : root);
|
|
46
|
+
}
|
|
47
|
+
async locked(action) {
|
|
48
|
+
const lock = new HostingLock(this.root, ".write-lock");
|
|
49
|
+
try {
|
|
50
|
+
while (!lock.acquire())
|
|
51
|
+
await new Promise((done) => setTimeout(done, 30));
|
|
52
|
+
return await action();
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
lock.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async get(id) {
|
|
59
|
+
if (!ID.test(id))
|
|
60
|
+
return undefined;
|
|
61
|
+
const directory = join(this.root, "artifacts", id);
|
|
62
|
+
let directoryExists = false;
|
|
63
|
+
try {
|
|
64
|
+
const canonicalDirectory = join(await realpath(this.root), "artifacts", id);
|
|
65
|
+
if ((await realpath(directory)) !== canonicalDirectory)
|
|
66
|
+
throw new Error("Artifact aliases are not allowed");
|
|
67
|
+
if (!(await lstat(directory)).isDirectory())
|
|
68
|
+
throw new Error("Invalid artifact directory");
|
|
69
|
+
directoryExists = true;
|
|
70
|
+
const artifact = parseArtifact(await readFile(join(directory, "artifact.json"), "utf8"));
|
|
71
|
+
if (artifact.id !== id)
|
|
72
|
+
throw new Error("Artifact identity does not match directory");
|
|
73
|
+
return { artifact, directory: canonicalDirectory };
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (directoryExists || error.code !== "ENOENT")
|
|
77
|
+
console.warn(`Skipping damaged hosting entry ${directory}: ${String(error)}`);
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async entries() {
|
|
82
|
+
let children;
|
|
83
|
+
try {
|
|
84
|
+
children = await readdir(join(this.root, "artifacts"), { withFileTypes: true });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error.code === "ENOENT")
|
|
88
|
+
return [];
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
const results = [];
|
|
92
|
+
for (const child of children) {
|
|
93
|
+
if (!child.isDirectory() || !ID.test(child.name))
|
|
94
|
+
continue;
|
|
95
|
+
const entry = await this.get(child.name);
|
|
96
|
+
if (entry)
|
|
97
|
+
results.push(entry);
|
|
98
|
+
}
|
|
99
|
+
return results;
|
|
100
|
+
}
|
|
101
|
+
async publish(sourcePath, projectPathInput, nameInput, title) {
|
|
102
|
+
const source = await realpath(resolve(sourcePath));
|
|
103
|
+
const project = await realpath(resolve(projectPathInput));
|
|
104
|
+
if (!(await lstat(source)).isDirectory() || !(await lstat(project)).isDirectory())
|
|
105
|
+
throw new Error("Source and project must be directories");
|
|
106
|
+
await mkdir(this.root, { recursive: true });
|
|
107
|
+
const root = await realpath(this.root);
|
|
108
|
+
if (inside(source, root) || inside(root, source))
|
|
109
|
+
throw new Error("Source must not overlap hosting storage");
|
|
110
|
+
const name = nameInput ?? basename(source);
|
|
111
|
+
if (!name.trim())
|
|
112
|
+
throw new Error("Artifact name must not be empty");
|
|
113
|
+
if (!(await lstat(join(source, "index.html"))).isFile())
|
|
114
|
+
throw new Error("Source requires index.html");
|
|
115
|
+
return this.locked(async () => {
|
|
116
|
+
const previous = (await this.entries()).find(({ artifact }) => artifact.project === project && artifact.name === name)?.artifact;
|
|
117
|
+
const id = previous?.id ?? randomUUID().replaceAll("-", "");
|
|
118
|
+
const directory = join(root, "artifacts", id);
|
|
119
|
+
await mkdir(join(directory, "snapshots"), { recursive: true });
|
|
120
|
+
const snapshotId = randomUUID().replaceAll("-", "");
|
|
121
|
+
const temp = join(directory, "snapshots", `.tmp-${snapshotId}`);
|
|
122
|
+
const metadataTemp = join(directory, `.artifact-${snapshotId}.json`);
|
|
123
|
+
try {
|
|
124
|
+
await copySnapshot(source, temp);
|
|
125
|
+
await rename(temp, join(directory, "snapshots", snapshotId));
|
|
126
|
+
const now = new Date().toISOString();
|
|
127
|
+
const artifact = {
|
|
128
|
+
id,
|
|
129
|
+
name,
|
|
130
|
+
title: title ?? previous?.title ?? name,
|
|
131
|
+
project,
|
|
132
|
+
source,
|
|
133
|
+
snapshotId,
|
|
134
|
+
createdAt: previous?.createdAt ?? now,
|
|
135
|
+
updatedAt: now,
|
|
136
|
+
url: `/web/${id}/`,
|
|
137
|
+
};
|
|
138
|
+
await writeFile(metadataTemp, JSON.stringify(artifact));
|
|
139
|
+
await rename(metadataTemp, join(directory, "artifact.json"));
|
|
140
|
+
return artifact;
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
await rm(temp, { recursive: true, force: true });
|
|
144
|
+
await rm(metadataTemp, { force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
async delete(id) {
|
|
149
|
+
return this.locked(async () => {
|
|
150
|
+
const entry = await this.get(id);
|
|
151
|
+
if (!entry)
|
|
152
|
+
return false;
|
|
153
|
+
await rm(entry.directory, { recursive: true });
|
|
154
|
+
return true;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -3,6 +3,9 @@ import { realpath, stat } from "node:fs/promises";
|
|
|
3
3
|
import { createServer as createHttpServer, } from "node:http";
|
|
4
4
|
import { createServer as createHttpsServer } from "node:https";
|
|
5
5
|
import { extname, resolve, sep } from "node:path";
|
|
6
|
+
import { DiffStore } from "../hosting/diff-store.js";
|
|
7
|
+
import { HostingLock } from "../hosting/lock.js";
|
|
8
|
+
import { hostingRoutes } from "../hosting/routes.js";
|
|
6
9
|
import { emptyApiRouter, sendJson } from "./router.js";
|
|
7
10
|
import { applySecurityHeaders } from "./security.js";
|
|
8
11
|
const CONTENT_TYPES = {
|
|
@@ -21,12 +24,12 @@ function sendText(response, status, message, headOnly) {
|
|
|
21
24
|
response.setHeader("Content-Length", Buffer.byteLength(message));
|
|
22
25
|
response.end(headOnly ? undefined : message);
|
|
23
26
|
}
|
|
24
|
-
async function serveFile(response, filePath, headOnly) {
|
|
27
|
+
async function serveFile(response, filePath, headOnly, status = 200) {
|
|
25
28
|
try {
|
|
26
29
|
const info = await stat(filePath);
|
|
27
30
|
if (!info.isFile())
|
|
28
31
|
return false;
|
|
29
|
-
response.statusCode =
|
|
32
|
+
response.statusCode = status;
|
|
30
33
|
response.setHeader("Content-Type", CONTENT_TYPES[extname(filePath)] ?? "application/octet-stream");
|
|
31
34
|
response.setHeader("Content-Length", info.size);
|
|
32
35
|
if (headOnly) {
|
|
@@ -45,11 +48,34 @@ async function serveFile(response, filePath, headOnly) {
|
|
|
45
48
|
}
|
|
46
49
|
export function createServer(config, apiRouter = emptyApiRouter, closeServices = () => { }, upgrades) {
|
|
47
50
|
const clientRoot = realpathSync(config.clientDirectory);
|
|
51
|
+
const finish = (server) => {
|
|
52
|
+
const lock = config.hostingDirectory
|
|
53
|
+
? new HostingLock(config.hostingDirectory, ".server-lock")
|
|
54
|
+
: undefined;
|
|
55
|
+
try {
|
|
56
|
+
if (lock && !lock.acquire())
|
|
57
|
+
throw new Error("Hosting directory is already used by another Hub server");
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
lock?.close();
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
server.once("close", () => lock?.close());
|
|
64
|
+
server.once("error", () => {
|
|
65
|
+
if (!server.listening)
|
|
66
|
+
lock?.close();
|
|
67
|
+
});
|
|
68
|
+
return attachLifecycle(server, closeServices, upgrades);
|
|
69
|
+
};
|
|
70
|
+
const hosting = hostingRoutes(config.hostingDirectory);
|
|
71
|
+
const diffs = config.hostingDirectory ? new DiffStore(config.hostingDirectory) : undefined;
|
|
48
72
|
const clientRootPrefix = `${clientRoot}${sep}`;
|
|
49
73
|
const requestListener = async (request, response) => {
|
|
50
74
|
applySecurityHeaders(response, request);
|
|
51
75
|
const headOnly = request.method === "HEAD";
|
|
52
76
|
try {
|
|
77
|
+
if (await hosting(request, response))
|
|
78
|
+
return;
|
|
53
79
|
if (await apiRouter(request, response))
|
|
54
80
|
return;
|
|
55
81
|
if (request.method !== "GET" && !headOnly) {
|
|
@@ -62,7 +88,14 @@ export function createServer(config, apiRouter = emptyApiRouter, closeServices =
|
|
|
62
88
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
63
89
|
const decodedPath = decodeURIComponent(url.pathname);
|
|
64
90
|
const sessionPage = /^\/sessions\/[A-Za-z0-9_-]{20,100}\/?$/.test(decodedPath);
|
|
65
|
-
const
|
|
91
|
+
const diffPage = /^\/diffs\/([a-f0-9-]{36})$/.exec(decodedPath);
|
|
92
|
+
const appPage = decodedPath === "/" ||
|
|
93
|
+
sessionPage ||
|
|
94
|
+
((decodedPath === "/artifacts" || diffPage) && config.hostingDirectory);
|
|
95
|
+
const pageStatus = diffPage && diffs && !(await diffs.exists(diffPage[1])) ? 404 : 200;
|
|
96
|
+
if (diffPage)
|
|
97
|
+
response.setHeader("Cache-Control", "private, no-store");
|
|
98
|
+
const relativePath = appPage ? "index.html" : decodedPath.slice(1);
|
|
66
99
|
const requestedPath = resolve(clientRoot, relativePath);
|
|
67
100
|
let canonicalPath;
|
|
68
101
|
try {
|
|
@@ -73,7 +106,7 @@ export function createServer(config, apiRouter = emptyApiRouter, closeServices =
|
|
|
73
106
|
return;
|
|
74
107
|
}
|
|
75
108
|
if (canonicalPath.startsWith(clientRootPrefix) &&
|
|
76
|
-
(await serveFile(response, canonicalPath, headOnly))) {
|
|
109
|
+
(await serveFile(response, canonicalPath, headOnly, pageStatus))) {
|
|
77
110
|
return;
|
|
78
111
|
}
|
|
79
112
|
sendText(response, 404, "Not found", headOnly);
|
|
@@ -84,7 +117,7 @@ export function createServer(config, apiRouter = emptyApiRouter, closeServices =
|
|
|
84
117
|
};
|
|
85
118
|
if (!config.tls.enabled) {
|
|
86
119
|
const server = createHttpServer(requestListener);
|
|
87
|
-
return
|
|
120
|
+
return finish(server);
|
|
88
121
|
}
|
|
89
122
|
const requireClientCertificate = config.tls.certificateAuthorityPath !== undefined;
|
|
90
123
|
const server = createHttpsServer({
|
|
@@ -97,7 +130,7 @@ export function createServer(config, apiRouter = emptyApiRouter, closeServices =
|
|
|
97
130
|
requestCert: requireClientCertificate,
|
|
98
131
|
rejectUnauthorized: requireClientCertificate,
|
|
99
132
|
}, requestListener);
|
|
100
|
-
return
|
|
133
|
+
return finish(server);
|
|
101
134
|
}
|
|
102
135
|
/** Own startup cleanup as well as the server's pre-drain shutdown hook. */
|
|
103
136
|
export async function startServer(config, apiRouter, closeServices, upgrades) {
|
|
@@ -2,7 +2,7 @@ export function sendJson(response, status, body, headOnly = false) {
|
|
|
2
2
|
const json = JSON.stringify(body);
|
|
3
3
|
response.statusCode = status;
|
|
4
4
|
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
5
|
-
response.setHeader("Cache-Control", "no-store");
|
|
5
|
+
response.setHeader("Cache-Control", response.getHeader("Cache-Control") ?? "no-store");
|
|
6
6
|
response.setHeader("Content-Length", Buffer.byteLength(json));
|
|
7
7
|
response.end(headOnly ? undefined : json);
|
|
8
8
|
}
|