Repository navigation
Expand file tree
/
Copy pathserver.ts
More file actions
154 lines (143 loc) · 7.47 KB
/
Copy pathserver.ts
File metadata and controls
154 lines (143 loc) · 7.47 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
// bun run dev (SPEC.md: bun run dev): serves the viewer and the data files on 127.0.0.1, pushes live-reload events,
// and records an undo version of every changed data file in .history/. render.ts and the tests use startServer().
import type { Server } from "bun";
import { existsSync, mkdirSync, readdirSync, readFileSync, watch } from "node:fs";
import { join, resolve, sep } from "node:path";
import { ROOT, dataDir, textureFile, isVersioned, listLayouts, listRooms, loadAssetEntry, loadBundle, readJson, recordVersion } from "./src/disk.ts";
import { type Config, ID_RE, validateConfig } from "./src/model.ts";
import index from "./app/index.html";
const text = (status: number, body: string) => new Response(`${body}\n`, { status });
/** A 400 for the first name that isn't a valid id, else undefined. */
const badName = (...names: string[]) => {
const bad = names.find(n => !ID_RE.test(n));
return bad === undefined ? undefined : text(400, `bad name "${bad}": must match ${ID_RE.source}`);
};
// Static files: first URL segment -> directory. /config.json is the one file served from the repo root.
const DIRS = new Map([
["draco", join(ROOT, "node_modules/three/examples/jsm/libs/draco/gltf")],
]);
const DATA_TOPS = ["rooms", "assets", "private/rooms", "private/assets"];
async function serveFile(req: Request): Promise<Response> {
if (req.method !== "GET" && req.method !== "HEAD") return text(405, "method not allowed");
let path: string;
try { path = decodeURIComponent(new URL(req.url).pathname); } catch { return text(400, "bad path"); }
const segments = path.split("/");
if (segments.includes("..") || /[\\\0]/.test(path)) return text(400, "bad path");
const [, top, ...rest] = segments;
let dir = DIRS.get(top), file: string | undefined;
if (top === "rooms" || top === "assets") {
if (!ID_RE.test(rest[0] ?? "")) return text(400, "bad name");
dir = dataDir(top, rest.shift()!);
} else if (top === "textures" && rest.length === 1) {
file = textureFile(rest[0]);
}
file ??= path === "/config.json" ? join(ROOT, "config.json") : dir && resolve(dir, ...rest);
if (!file) return text(404, "not found");
if (dir && !file.startsWith(dir + sep)) return text(403, "forbidden");
const f = Bun.file(file); // exists() is false for directories
return (await f.exists()) ? new Response(f, { headers: { "Cache-Control": "no-store" } }) : text(404, "not found");
}
/** A room's photoreal images (rooms/<room>/renders/photoreal/), sorted; none when the folder is missing. */
const photoFiles = (room: string) => {
const dir = join(dataDir("rooms", room), "renders", "photoreal");
return existsSync(dir) ? readdirSync(dir).filter(f => /\.(png|jpe?g|webp)$/i.test(f)).sort() : [];
};
/** Every versioned data file under rooms/ and assets/, repo-relative. */
const versionedFiles = () => DATA_TOPS.flatMap(top => existsSync(join(ROOT, top))
? readdirSync(join(ROOT, top), { recursive: true, encoding: "utf8" }).map(p => `${top}/${p}`).filter(isVersioned) : []);
/** Saves a version of a data file; false when it isn't versioned, is missing or equals the newest version. */
function record(rel: string): boolean {
const abs = join(ROOT, rel);
try {
return isVersioned(rel) && existsSync(abs) && recordVersion(ROOT, rel, readFileSync(abs, "utf8"));
} catch (e) {
console.error(`could not save a version of ${rel}: ${e}`); // keep serving: one bad file must not stop the history
return false;
}
}
/** Changes that never reload the viewer: renders, photos, history, and dot, preview-* and editor temp files. */
const ignored = (rel: string) => /\/(renders|photos|\.history)\//.test(rel) || /(^|\/)(\.|preview-)[^/]*$|(~|\.tmp|\.swp)$/.test(rel);
export function startServer(opts: { port: number; development: boolean; watch: boolean }):
{ server: Server<undefined>; stop(): Promise<void> } {
const clients = new Set<ReadableStreamDefaultController>();
const server = Bun.serve({
hostname: "127.0.0.1",
port: opts.port,
development: opts.development,
routes: {
"/": index,
"/api/bundle/:room/:layout": { GET: async ({ params: { room, layout } }) =>
badName(room, layout) ?? Response.json(await loadBundle(room, layout)) },
"/api/asset/:id": { GET: async ({ params: { id } }) => badName(id) ?? Response.json(await loadAssetEntry(id)) },
"/api/rooms/:room/layouts": { GET: ({ params: { room } }) =>
badName(room) ?? (listRooms().includes(room) ? Response.json(listLayouts(room)) : text(404, `no room "${room}"`)) },
"/api/rooms/:room/photos": { GET: ({ params: { room } }) =>
badName(room) ?? (listRooms().includes(room) ? Response.json(photoFiles(room)) : text(404, `no room "${room}"`)) },
// Server-Sent Events (watch only): ": hello" on connect, then one "change" event per debounced batch.
"/api/events": { GET: (req, srv) => {
if (!opts.watch) return text(404, "not found");
srv.timeout(req, 0); // no idle timeout for this long-lived response
return new Response(new ReadableStream({ start(c) {
clients.add(c);
req.signal.addEventListener("abort", () => clients.delete(c));
c.enqueue(": hello\n\n");
} }), { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-store" } });
} },
},
fetch: serveFile,
});
let pending = new Set<string>(), timer: ReturnType<typeof setTimeout> | undefined;
const flush = () => {
const paths = [...pending];
pending = new Set();
for (const rel of paths.includes("*") ? versionedFiles() : paths)
if (record(rel)) console.log(`changed ${rel} (saved version)`);
const msg = `event: change\ndata: ${JSON.stringify({ paths: paths.map(p => p.replace(/^private\//, "")) })}\n\n`;
for (const c of clients) try { c.enqueue(msg); } catch { clients.delete(c); }
};
const watchers = !opts.watch ? [] : DATA_TOPS.map(top => {
mkdirSync(join(ROOT, top), { recursive: true });
// macOS reports every write, atomic temp-and-rename included, as "rename", so the event type is ignored.
return watch(join(ROOT, top), { recursive: true }, (_event, name) => {
const rel = name == null ? "*" : `${top}/${name}`; // no filename: rescan every versioned file, client reloads
if (rel !== "*" && ignored(rel)) return;
pending.add(rel);
clearTimeout(timer);
timer = setTimeout(flush, 150);
});
});
if (opts.watch) for (const rel of versionedFiles()) record(rel); // baseline, so the first edit can be undone
return {
server,
async stop() {
for (const w of watchers) w.close();
clearTimeout(timer);
await server.stop(true);
},
};
}
if (import.meta.main) {
let config: Config;
try {
config = await readJson<Config>(join(ROOT, "config.json"));
} catch (e) {
console.error((e as Error).message);
process.exit(1);
}
const errors = validateConfig(config);
if (errors.length) {
console.error(errors.join("\n"));
process.exit(1);
}
try {
startServer({ port: config.port, development: true, watch: true });
} catch (e) {
if ((e as { code?: string }).code !== "EADDRINUSE") throw e;
console.error(`port ${config.port} is in use; change "port" in config.json`);
process.exit(1);
}
const rooms = listRooms();
console.log(`Room planner: http://127.0.0.1:${config.port}/?room=${rooms[0] ?? "<room>"}`);
console.log(`Rooms: ${rooms.join(", ") || "none yet (bun run import-scan <room> <roomplan.json>)"}`);
console.log("Watching public and private rooms/assets (undo history in private/.history/). Ctrl-C to stop.");
}