@scoutmesh/viewer 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 +534 -0
- package/dist/cli.js +430 -0
- package/dist/external-browser.js +62 -0
- package/dist/input-error.js +8 -0
- package/dist/launcher.js +39 -0
- package/dist/public/app.js +1157 -0
- package/dist/public/auth.js +55 -0
- package/dist/public/data.js +105 -0
- package/dist/public/index.html +153 -0
- package/dist/public/styles.css +619 -0
- package/dist/server.js +339 -0
- package/dist/state-file.js +48 -0
- package/dist/store.js +437 -0
- package/dist/update-manager.js +448 -0
- package/dist/version.js +1 -0
- package/package.json +20 -0
- package/skills/local-candidate-viewer/SKILL.md +200 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
import { externalUrl, openExternalBrowser } from "./external-browser.js";
|
|
7
|
+
import { InputError } from "./input-error.js";
|
|
8
|
+
import { defaultStateFile } from "./state-file.js";
|
|
9
|
+
import { ViewStore } from "./store.js";
|
|
10
|
+
import { viewerVersion } from "./version.js";
|
|
11
|
+
|
|
12
|
+
const assets = new Map ([
|
|
13
|
+
["/", ["index.html", "text/html; charset=utf-8"]],
|
|
14
|
+
["/app.js", ["app.js", "text/javascript; charset=utf-8"]],
|
|
15
|
+
["/data.js", ["data.js", "text/javascript; charset=utf-8"]],
|
|
16
|
+
["/auth.js", ["auth.js", "text/javascript; charset=utf-8"]],
|
|
17
|
+
["/styles.css", ["styles.css", "text/css; charset=utf-8"]],
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function json(response , status , value ) {
|
|
21
|
+
response.writeHead(status, { "Content-Type": "application/json" });
|
|
22
|
+
response.end(JSON.stringify(value));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function body(request ) {
|
|
26
|
+
if (request.headers["content-type"]?.split(";")[0] !== "application/json") {
|
|
27
|
+
throw new InputError("Use Content-Type: application/json.", 415);
|
|
28
|
+
}
|
|
29
|
+
const parts = [];
|
|
30
|
+
let size = 0;
|
|
31
|
+
for await (const part of request) {
|
|
32
|
+
const chunk = part;
|
|
33
|
+
if (!(chunk instanceof Uint8Array)) {
|
|
34
|
+
throw new InputError("Invalid request body.");
|
|
35
|
+
}
|
|
36
|
+
const buffer = Buffer.from(chunk);
|
|
37
|
+
size += buffer.length;
|
|
38
|
+
if (size > 2_000_000) {
|
|
39
|
+
throw new InputError("Request exceeds the 2 MB prototype limit.", 413);
|
|
40
|
+
}
|
|
41
|
+
parts.push(buffer);
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(Buffer.concat(parts).toString("utf-8"));
|
|
45
|
+
} catch {
|
|
46
|
+
throw new InputError("Invalid JSON.");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function verifyOrigin(
|
|
51
|
+
request ,
|
|
52
|
+
server
|
|
53
|
+
) {
|
|
54
|
+
const address = server.address();
|
|
55
|
+
const port = address && typeof address !== "string" ? address.port : 0;
|
|
56
|
+
const hosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
|
|
57
|
+
const host = request.headers.host ?? "";
|
|
58
|
+
if (
|
|
59
|
+
!hosts.has(host) ||
|
|
60
|
+
(request.headers.origin && request.headers.origin !== `http://${host}`) ||
|
|
61
|
+
request.headers["sec-fetch-site"] === "cross-site"
|
|
62
|
+
) {
|
|
63
|
+
throw new InputError(
|
|
64
|
+
"Only requests from this local viewer are allowed.",
|
|
65
|
+
403
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return host;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createViewerServer(
|
|
72
|
+
openBrowser = openExternalBrowser,
|
|
73
|
+
store = new ViewStore(),
|
|
74
|
+
options = {}
|
|
75
|
+
) {
|
|
76
|
+
function matches(value ) {
|
|
77
|
+
if (!options.token) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return (
|
|
81
|
+
typeof value === "string" &&
|
|
82
|
+
Buffer.byteLength(value) === Buffer.byteLength(options.token) &&
|
|
83
|
+
timingSafeEqual(Buffer.from(value), Buffer.from(options.token))
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function authorized(request , cookieName ) {
|
|
88
|
+
const cookie = request.headers.cookie
|
|
89
|
+
?.split(";")
|
|
90
|
+
.map((part) => part.trim())
|
|
91
|
+
.find((part) => part.startsWith(`${cookieName}=`))
|
|
92
|
+
?.slice(cookieName.length + 1);
|
|
93
|
+
return (
|
|
94
|
+
matches(request.headers.authorization?.replace(/^Bearer /u, "")) ||
|
|
95
|
+
matches(cookie)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const listeners = new Map ();
|
|
99
|
+
const notify = (id ) => {
|
|
100
|
+
for (const client of listeners.get(id) ?? []) {
|
|
101
|
+
client.write('data: {"changed":true}\n\n');
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
function subscribe(id , response ) {
|
|
106
|
+
store.get(id);
|
|
107
|
+
response.writeHead(200, {
|
|
108
|
+
Connection: "keep-alive",
|
|
109
|
+
"Content-Type": "text/event-stream",
|
|
110
|
+
});
|
|
111
|
+
const clients = listeners.get(id) ?? new Set ();
|
|
112
|
+
clients.add(response);
|
|
113
|
+
listeners.set(id, clients);
|
|
114
|
+
response.write('data: {"connected":true}\n\n');
|
|
115
|
+
const heartbeat = setInterval(() => {
|
|
116
|
+
response.write(": heartbeat\n\n");
|
|
117
|
+
}, 20_000);
|
|
118
|
+
response.on("close", () => {
|
|
119
|
+
clearInterval(heartbeat);
|
|
120
|
+
clients.delete(response);
|
|
121
|
+
if (clients.size === 0) {
|
|
122
|
+
listeners.delete(id);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function routeView(
|
|
128
|
+
request ,
|
|
129
|
+
response ,
|
|
130
|
+
id ,
|
|
131
|
+
section
|
|
132
|
+
) {
|
|
133
|
+
const { method } = request;
|
|
134
|
+
if (section === "events" && method === "GET") {
|
|
135
|
+
subscribe(id, response);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (section === "state" && method === "GET") {
|
|
139
|
+
const view = store.get(id);
|
|
140
|
+
json(response, 200, {
|
|
141
|
+
...view.state,
|
|
142
|
+
connectedBrowsers: listeners.get(id)?.size ?? 0,
|
|
143
|
+
recordCount: view.records.length,
|
|
144
|
+
revision: view.revision,
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (section === "state" && method === "PUT") {
|
|
149
|
+
const view = store.select(id, await body(request));
|
|
150
|
+
notify(id);
|
|
151
|
+
json(response, 200, view.state);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (!section && method === "GET") {
|
|
155
|
+
json(response, 200, store.get(id));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!section && method === "PATCH") {
|
|
159
|
+
const view = store.update(id, await body(request));
|
|
160
|
+
notify(id);
|
|
161
|
+
json(response, 200, {
|
|
162
|
+
...view,
|
|
163
|
+
connectedBrowsers: listeners.get(id)?.size ?? 0,
|
|
164
|
+
delivery: "saved",
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!section && method === "DELETE") {
|
|
169
|
+
store.delete(id);
|
|
170
|
+
notify(id);
|
|
171
|
+
for (const client of listeners.get(id) ?? []) {
|
|
172
|
+
client.end();
|
|
173
|
+
}
|
|
174
|
+
json(response, 200, { deleted: true });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
throw new InputError("Method not allowed.", 405);
|
|
178
|
+
}
|
|
179
|
+
async function access(
|
|
180
|
+
request ,
|
|
181
|
+
response ,
|
|
182
|
+
url
|
|
183
|
+
) {
|
|
184
|
+
const { method } = request;
|
|
185
|
+
const address = server.address();
|
|
186
|
+
const cookieName = `scoutmesh_${address && typeof address !== "string" ? address.port : 0}`;
|
|
187
|
+
if (url.pathname === "/api/session") {
|
|
188
|
+
if (method === "GET") {
|
|
189
|
+
json(response, 200, { authorized: authorized(request, cookieName) });
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
if (method === "POST") {
|
|
193
|
+
const input = await body(request);
|
|
194
|
+
if (
|
|
195
|
+
!input ||
|
|
196
|
+
typeof input !== "object" ||
|
|
197
|
+
!("token" in input) ||
|
|
198
|
+
!matches(input.token)
|
|
199
|
+
) {
|
|
200
|
+
throw new InputError("Invalid local access token.", 401);
|
|
201
|
+
}
|
|
202
|
+
if (options.token) {
|
|
203
|
+
response.setHeader(
|
|
204
|
+
"Set-Cookie",
|
|
205
|
+
`${cookieName}=${options.token}; HttpOnly; SameSite=Strict; Path=/`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
json(response, 200, { authorized: true });
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
throw new InputError("Method not allowed.", 405);
|
|
212
|
+
}
|
|
213
|
+
if (url.pathname.startsWith("/api/") && !authorized(request, cookieName)) {
|
|
214
|
+
throw new InputError(
|
|
215
|
+
"Unlock this viewer with its local access token.",
|
|
216
|
+
401
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (url.pathname === "/api/health" && method === "GET") {
|
|
220
|
+
json(response, 200, {
|
|
221
|
+
pid: process.pid,
|
|
222
|
+
service: "scoutmesh-viewer",
|
|
223
|
+
version: viewerVersion,
|
|
224
|
+
});
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
if (
|
|
228
|
+
url.pathname === "/api/shutdown" &&
|
|
229
|
+
method === "POST" &&
|
|
230
|
+
options.shutdown
|
|
231
|
+
) {
|
|
232
|
+
if (!matches(request.headers.authorization?.replace(/^Bearer /u, ""))) {
|
|
233
|
+
throw new InputError("Shutdown requires the local CLI.", 401);
|
|
234
|
+
}
|
|
235
|
+
json(response, 200, { stopping: true });
|
|
236
|
+
setImmediate(options.shutdown);
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
async function route(request , response ) {
|
|
242
|
+
response.setHeader("Cache-Control", "no-store");
|
|
243
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
244
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
245
|
+
response.setHeader(
|
|
246
|
+
"Content-Security-Policy",
|
|
247
|
+
"default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' https:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
|
|
248
|
+
);
|
|
249
|
+
try {
|
|
250
|
+
const host = verifyOrigin(request, server);
|
|
251
|
+
const url = new URL(request.url ?? "/", `http://${host}`);
|
|
252
|
+
const { method } = request;
|
|
253
|
+
if (await access(request, response, url)) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (url.pathname === "/api/open-external") {
|
|
257
|
+
if (method !== "POST") {
|
|
258
|
+
throw new InputError("Method not allowed.", 405);
|
|
259
|
+
}
|
|
260
|
+
const input = await body(request);
|
|
261
|
+
const destination = externalUrl(
|
|
262
|
+
input && typeof input === "object" && "url" in input
|
|
263
|
+
? input.url
|
|
264
|
+
: undefined
|
|
265
|
+
);
|
|
266
|
+
await openBrowser(destination);
|
|
267
|
+
json(response, 200, { opened: true });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const asset = assets.get(url.pathname);
|
|
271
|
+
if (method === "GET" && asset) {
|
|
272
|
+
const [file, contentType] = asset;
|
|
273
|
+
response.writeHead(200, { "Content-Type": contentType });
|
|
274
|
+
response.end(
|
|
275
|
+
await readFile(new URL(`public/${file}`, import.meta.url))
|
|
276
|
+
);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (url.pathname === "/api/views" && method === "GET") {
|
|
280
|
+
json(response, 200, store.list());
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (url.pathname === "/api/views" && method === "POST") {
|
|
284
|
+
const view = store.create(await body(request));
|
|
285
|
+
json(response, 201, {
|
|
286
|
+
...view,
|
|
287
|
+
url: `http://${host}/?view=${view.id}`,
|
|
288
|
+
});
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const match =
|
|
292
|
+
/^\/api\/views\/(?<id>[\w-]+)(?:\/(?<section>events|state))?$/u.exec(
|
|
293
|
+
url.pathname
|
|
294
|
+
);
|
|
295
|
+
if (!match?.groups?.id) {
|
|
296
|
+
throw new InputError("Route not found.", 404);
|
|
297
|
+
}
|
|
298
|
+
const { id, section } = match.groups;
|
|
299
|
+
await routeView(request, response, id, section);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
const known = error instanceof InputError;
|
|
302
|
+
json(response, known ? error.status : 500, {
|
|
303
|
+
error: known ? error.message : "Local viewer request failed.",
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const server = createServer((request, response) => {
|
|
308
|
+
void route(request, response);
|
|
309
|
+
});
|
|
310
|
+
server.on("close", () => {
|
|
311
|
+
for (const clients of listeners.values()) {
|
|
312
|
+
for (const client of clients) {
|
|
313
|
+
client.end();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
return server;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (process.argv[1] === import.meta.filename) {
|
|
321
|
+
const port = Number(process.env.VIEWER_PORT ?? 4317);
|
|
322
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65_535) {
|
|
323
|
+
throw new Error("VIEWER_PORT must be an integer between 1024 and 65535.");
|
|
324
|
+
}
|
|
325
|
+
const server = createViewerServer(
|
|
326
|
+
openExternalBrowser,
|
|
327
|
+
new ViewStore(defaultStateFile)
|
|
328
|
+
);
|
|
329
|
+
server.on("error", (error) => {
|
|
330
|
+
console.error(`Cannot start local viewer: ${error.message}`);
|
|
331
|
+
process.exitCode = 1;
|
|
332
|
+
});
|
|
333
|
+
server.listen(port, "127.0.0.1", () => {
|
|
334
|
+
console.info(`Scout Mesh local viewer: http://127.0.0.1:${port}`);
|
|
335
|
+
console.info(
|
|
336
|
+
`State saved locally to ${defaultStateFile}. Restarting restores saved views.`
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
fsyncSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
export const defaultStateFile = process.env.VIEWER_STATE_FILE
|
|
16
|
+
? path.resolve(process.env.VIEWER_STATE_FILE)
|
|
17
|
+
: fileURLToPath(new URL(".data/state.json", import.meta.url));
|
|
18
|
+
|
|
19
|
+
export function readStateFile(file ) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(readFileSync(file, "utf-8"));
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Cannot load viewer state from ${file}. The file has not been changed.`,
|
|
28
|
+
{ cause: error }
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function writeStateFile(file , value ) {
|
|
34
|
+
mkdirSync(path.dirname(file), { mode: 0o700, recursive: true });
|
|
35
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
36
|
+
try {
|
|
37
|
+
const descriptor = openSync(temporary, "wx", 0o600);
|
|
38
|
+
try {
|
|
39
|
+
writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`);
|
|
40
|
+
fsyncSync(descriptor);
|
|
41
|
+
} finally {
|
|
42
|
+
closeSync(descriptor);
|
|
43
|
+
}
|
|
44
|
+
renameSync(temporary, file);
|
|
45
|
+
} finally {
|
|
46
|
+
rmSync(temporary, { force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|