@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/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
+ }