@cfbender/cesium 0.5.2 → 0.6.1
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/CHANGELOG.md +84 -0
- package/README.md +13 -4
- package/package.json +3 -1
- package/src/cli/commands/export.ts +143 -0
- package/src/cli/commands/ls.ts +62 -65
- package/src/cli/commands/open.ts +47 -62
- package/src/cli/commands/prune.ts +59 -71
- package/src/cli/commands/restart.ts +100 -12
- package/src/cli/commands/serve.ts +118 -114
- package/src/cli/commands/stop.ts +51 -84
- package/src/cli/commands/theme.ts +54 -92
- package/src/cli/index.ts +18 -70
- package/src/render/theme.ts +18 -0
- package/src/render/wrap.ts +9 -5
- package/src/server/api.ts +112 -124
- package/src/server/favicon.ts +8 -16
- package/src/server/http.ts +101 -106
- package/src/server/lifecycle.ts +7 -5
- package/src/storage/assets.ts +8 -10
- package/src/storage/index-gen.ts +11 -9
- package/src/storage/theme-write.ts +17 -3
- package/src/tools/wait.ts +1 -0
- package/src/render/fallback.ts +0 -18
package/src/server/api.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
// API
|
|
1
|
+
// API routes for interactive artifact submissions and state queries, exposed
|
|
2
|
+
// as a Hono sub-app. Mounted by lifecycle.ts via `handle.app.route("/", apiApp)`.
|
|
2
3
|
//
|
|
3
4
|
// Routes:
|
|
4
5
|
// POST /api/sessions/:projectSlug/:filename/answers/:questionId
|
|
5
6
|
// GET /api/sessions/:projectSlug/:filename/state
|
|
6
7
|
//
|
|
7
|
-
//
|
|
8
|
+
// Any other /api/* path returns a JSON 404 (rather than falling through to the
|
|
9
|
+
// static file handler, which would return the HTML 404 page).
|
|
8
10
|
|
|
9
11
|
import { join, resolve, relative } from "node:path";
|
|
12
|
+
import { Hono } from "hono";
|
|
10
13
|
import { submitAnswer, getState } from "../storage/mutate.ts";
|
|
11
14
|
import type { AnswerValue } from "../render/validate.ts";
|
|
12
15
|
|
|
@@ -19,148 +22,133 @@ export interface ApiHandlerOptions {
|
|
|
19
22
|
const FILENAME_RE = /^[^/\\]+\.html$/;
|
|
20
23
|
const DANGEROUS_RE = /[/\\]|\.\./;
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
interface ResolvedArtifact {
|
|
26
|
+
/** Absolute path to the artifact file. */
|
|
27
|
+
artifactPath: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validate the slug/filename pair and resolve the artifact's absolute path,
|
|
32
|
+
* enforcing containment under <stateDir>/projects/<slug>/artifacts/. Returns
|
|
33
|
+
* a Hono `Response` on validation failure, or the resolved path on success.
|
|
34
|
+
*/
|
|
35
|
+
function resolveArtifact(
|
|
36
|
+
stateDir: string,
|
|
37
|
+
projectSlug: string,
|
|
38
|
+
filename: string,
|
|
39
|
+
): ResolvedArtifact | Response {
|
|
40
|
+
if (DANGEROUS_RE.test(projectSlug) || DANGEROUS_RE.test(filename)) {
|
|
41
|
+
return Response.json({ ok: false, error: "invalid path component" }, { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
if (!FILENAME_RE.test(filename)) {
|
|
44
|
+
return Response.json({ ok: false, error: "filename must end with .html" }, { status: 400 });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const artifactsDir = join(stateDir, "projects", projectSlug, "artifacts");
|
|
48
|
+
const artifactPath = join(artifactsDir, filename);
|
|
49
|
+
const resolvedArtifactsDir = resolve(artifactsDir);
|
|
50
|
+
const resolvedArtifact = resolve(artifactPath);
|
|
51
|
+
const rel = relative(resolvedArtifactsDir, resolvedArtifact);
|
|
52
|
+
if (rel.startsWith("..") || rel.includes("/")) {
|
|
53
|
+
return Response.json({ ok: false, error: "invalid path" }, { status: 400 });
|
|
54
|
+
}
|
|
55
|
+
return { artifactPath: resolvedArtifact };
|
|
30
56
|
}
|
|
31
57
|
|
|
32
|
-
export function
|
|
33
|
-
options: ApiHandlerOptions,
|
|
34
|
-
): (req: Request) => Promise<Response | undefined> {
|
|
58
|
+
export function createApiApp(options: ApiHandlerOptions): Hono {
|
|
35
59
|
const { stateDir } = options;
|
|
60
|
+
const app = new Hono();
|
|
36
61
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
62
|
+
// All API responses are dynamic — never let intermediaries cache them.
|
|
63
|
+
app.use("/api/*", async (c, next) => {
|
|
64
|
+
await next();
|
|
65
|
+
c.header("Cache-Control", "no-store");
|
|
66
|
+
});
|
|
40
67
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
68
|
+
// POST /api/sessions/:projectSlug/:filename/answers/:questionId
|
|
69
|
+
app.post("/api/sessions/:projectSlug/:filename/answers/:questionId", async (c) => {
|
|
70
|
+
const { projectSlug, filename, questionId } = c.req.param();
|
|
45
71
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const answerMatch = /^\/api\/sessions\/([^/]+)\/([^/]+)\/answers\/([^/]+)$/.exec(pathname);
|
|
49
|
-
// GET /api/sessions/:projectSlug/:filename/state
|
|
50
|
-
const stateMatch = /^\/api\/sessions\/([^/]+)\/([^/]+)\/state$/.exec(pathname);
|
|
72
|
+
const resolved = resolveArtifact(stateDir, projectSlug, filename);
|
|
73
|
+
if (resolved instanceof Response) return resolved;
|
|
51
74
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
75
|
+
let body: unknown;
|
|
76
|
+
try {
|
|
77
|
+
body = await c.req.json();
|
|
78
|
+
} catch {
|
|
79
|
+
return c.json({ ok: false, error: "invalid JSON body" }, 400);
|
|
55
80
|
}
|
|
56
81
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return
|
|
82
|
+
if (
|
|
83
|
+
body === null ||
|
|
84
|
+
typeof body !== "object" ||
|
|
85
|
+
Array.isArray(body) ||
|
|
86
|
+
!("value" in (body as Record<string, unknown>))
|
|
87
|
+
) {
|
|
88
|
+
return c.json({ ok: false, error: 'body must contain a "value" field' }, 400);
|
|
64
89
|
}
|
|
65
90
|
|
|
66
|
-
|
|
67
|
-
return jsonResponse({ ok: false, error: "filename must end with .html" }, 400);
|
|
68
|
-
}
|
|
91
|
+
const value = (body as Record<string, unknown>)["value"] as AnswerValue;
|
|
69
92
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
93
|
+
const outcome = await submitAnswer({
|
|
94
|
+
artifactPath: resolved.artifactPath,
|
|
95
|
+
questionId,
|
|
96
|
+
value,
|
|
97
|
+
});
|
|
73
98
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
99
|
+
if (outcome.ok) {
|
|
100
|
+
return c.json(
|
|
101
|
+
{
|
|
102
|
+
ok: true,
|
|
103
|
+
status: outcome.status,
|
|
104
|
+
remaining: outcome.remaining,
|
|
105
|
+
replacementHtml: outcome.replacementHtml,
|
|
106
|
+
},
|
|
107
|
+
200,
|
|
108
|
+
);
|
|
79
109
|
}
|
|
80
110
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
let body: unknown;
|
|
93
|
-
try {
|
|
94
|
-
body = await req.json();
|
|
95
|
-
} catch {
|
|
96
|
-
return jsonResponse({ ok: false, error: "invalid JSON body" }, 400);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
body === null ||
|
|
101
|
-
typeof body !== "object" ||
|
|
102
|
-
Array.isArray(body) ||
|
|
103
|
-
!("value" in (body as Record<string, unknown>))
|
|
104
|
-
) {
|
|
105
|
-
return jsonResponse({ ok: false, error: 'body must contain a "value" field' }, 400);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const value = (body as Record<string, unknown>)["value"] as AnswerValue;
|
|
109
|
-
|
|
110
|
-
const outcome = await submitAnswer({ artifactPath: resolvedArtifact, questionId, value });
|
|
111
|
-
|
|
112
|
-
if (outcome.ok) {
|
|
113
|
-
return jsonResponse(
|
|
114
|
-
{
|
|
115
|
-
ok: true,
|
|
116
|
-
status: outcome.status,
|
|
117
|
-
remaining: outcome.remaining,
|
|
118
|
-
replacementHtml: outcome.replacementHtml,
|
|
119
|
-
},
|
|
120
|
-
200,
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
switch (outcome.reason) {
|
|
125
|
-
case "not-found":
|
|
126
|
-
case "not-interactive":
|
|
127
|
-
case "unknown-question":
|
|
128
|
-
return jsonResponse({ ok: false, reason: outcome.reason }, 404);
|
|
129
|
-
case "session-ended":
|
|
130
|
-
return jsonResponse({ ok: false, status: outcome.status }, 410);
|
|
131
|
-
case "expired":
|
|
132
|
-
return jsonResponse({ ok: false, status: "expired" }, 410);
|
|
133
|
-
case "invalid-value":
|
|
134
|
-
return jsonResponse({ ok: false, message: outcome.message }, 422);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Fallback (should not reach)
|
|
138
|
-
return jsonResponse({ ok: false, error: "internal error" }, 500);
|
|
111
|
+
switch (outcome.reason) {
|
|
112
|
+
case "not-found":
|
|
113
|
+
case "not-interactive":
|
|
114
|
+
case "unknown-question":
|
|
115
|
+
return c.json({ ok: false, reason: outcome.reason }, 404);
|
|
116
|
+
case "session-ended":
|
|
117
|
+
return c.json({ ok: false, status: outcome.status }, 410);
|
|
118
|
+
case "expired":
|
|
119
|
+
return c.json({ ok: false, status: "expired" }, 410);
|
|
120
|
+
case "invalid-value":
|
|
121
|
+
return c.json({ ok: false, message: outcome.message }, 422);
|
|
139
122
|
}
|
|
140
123
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (req.method !== "GET") {
|
|
144
|
-
return jsonResponse({ ok: false, error: "method not allowed" }, 404);
|
|
145
|
-
}
|
|
124
|
+
return c.json({ ok: false, error: "internal error" }, 500);
|
|
125
|
+
});
|
|
146
126
|
|
|
147
|
-
|
|
127
|
+
// GET /api/sessions/:projectSlug/:filename/state
|
|
128
|
+
app.get("/api/sessions/:projectSlug/:filename/state", async (c) => {
|
|
129
|
+
const { projectSlug, filename } = c.req.param();
|
|
148
130
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
131
|
+
const resolved = resolveArtifact(stateDir, projectSlug, filename);
|
|
132
|
+
if (resolved instanceof Response) return resolved;
|
|
152
133
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
answers: outcome.answers,
|
|
157
|
-
remaining: outcome.remaining,
|
|
158
|
-
},
|
|
159
|
-
200,
|
|
160
|
-
);
|
|
134
|
+
const outcome = await getState(resolved.artifactPath);
|
|
135
|
+
if (!outcome.ok) {
|
|
136
|
+
return c.json({ ok: false, reason: outcome.reason }, 404);
|
|
161
137
|
}
|
|
162
138
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
139
|
+
return c.json(
|
|
140
|
+
{
|
|
141
|
+
status: outcome.status,
|
|
142
|
+
answers: outcome.answers,
|
|
143
|
+
remaining: outcome.remaining,
|
|
144
|
+
},
|
|
145
|
+
200,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Catch-all under /api/* — keeps unmatched API paths as JSON 404 instead of
|
|
150
|
+
// falling through to the static file handler.
|
|
151
|
+
app.all("/api/*", (c) => c.json({ ok: false, error: "not found" }, 404));
|
|
152
|
+
|
|
153
|
+
return app;
|
|
166
154
|
}
|
package/src/server/favicon.ts
CHANGED
|
@@ -7,22 +7,14 @@
|
|
|
7
7
|
// (written by writeFaviconSvg on every publish). This shim covers the .ico
|
|
8
8
|
// fallback so users don't see a 404 in DevTools.
|
|
9
9
|
|
|
10
|
+
import { Hono } from "hono";
|
|
10
11
|
import { FAVICON_SVG } from "../render/favicon.ts";
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return
|
|
19
|
-
const url = new URL(req.url);
|
|
20
|
-
if (url.pathname !== "/favicon.ico") {
|
|
21
|
-
return undefined;
|
|
22
|
-
}
|
|
23
|
-
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
24
|
-
return undefined;
|
|
25
|
-
}
|
|
26
|
-
return new Response(FAVICON_SVG, { status: 200, headers: SVG_RESPONSE_HEADERS });
|
|
27
|
-
};
|
|
13
|
+
export function createFaviconApp(): Hono {
|
|
14
|
+
const app = new Hono();
|
|
15
|
+
app.on(["GET", "HEAD"], "/favicon.ico", (c) => {
|
|
16
|
+
c.header("Cache-Control", "public, max-age=86400");
|
|
17
|
+
return c.body(FAVICON_SVG, 200, { "Content-Type": "image/svg+xml; charset=utf-8" });
|
|
18
|
+
});
|
|
19
|
+
return app;
|
|
28
20
|
}
|
package/src/server/http.ts
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
// Bun HTTP server bound to 127.0.0.1 (default), serving the cesium state directory.
|
|
2
|
+
//
|
|
3
|
+
// Routing is owned by a Hono app exposed on the returned ServerHandle. Callers
|
|
4
|
+
// (e.g. lifecycle.ts) mount sub-apps for /api/* and /favicon.ico via
|
|
5
|
+
// `handle.app.route("/", subApp)`. Any path that does not match a registered
|
|
6
|
+
// route falls through to the static file handler installed here via
|
|
7
|
+
// `app.notFound` — this preserves the cesium-specific behavior (custom 404 page,
|
|
8
|
+
// 1MB streaming threshold, MIME table) without forcing callers to think about
|
|
9
|
+
// registration order.
|
|
2
10
|
|
|
3
11
|
import { resolve, extname } from "node:path";
|
|
4
12
|
import { readFile } from "node:fs/promises";
|
|
13
|
+
import { Hono } from "hono";
|
|
5
14
|
|
|
6
15
|
export interface ServerHandle {
|
|
7
16
|
port: number;
|
|
8
17
|
url: string; // "http://127.0.0.1:<port>"
|
|
18
|
+
/** Hono app — register routes via `handle.app.route(...)` before requests arrive. */
|
|
19
|
+
app: Hono;
|
|
9
20
|
stop(): Promise<void>;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
addHandler(handler: (req: Request) => Promise<Response | undefined>): void;
|
|
21
|
+
/** Register an idle-tracker callback fired on every request. */
|
|
22
|
+
onRequest(handler: () => void): void;
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
export interface StartServerArgs {
|
|
@@ -57,119 +67,106 @@ const FORBIDDEN_HTML = `<!doctype html>
|
|
|
57
67
|
<body><div class="box"><h1>403</h1><p>forbidden</p></div></body>
|
|
58
68
|
</html>`;
|
|
59
69
|
|
|
70
|
+
const ONE_MB = 1024 * 1024;
|
|
71
|
+
|
|
60
72
|
function mimeFor(filePath: string): string {
|
|
61
73
|
const ext = extname(filePath).toLowerCase();
|
|
62
74
|
return MIME_TYPES[ext] ?? "application/octet-stream";
|
|
63
75
|
}
|
|
64
76
|
|
|
77
|
+
function htmlResponse(body: string, status: number): Response {
|
|
78
|
+
return new Response(body, {
|
|
79
|
+
status,
|
|
80
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Static file handler: resolves the request path under stateDir, enforces
|
|
85
|
+
// traversal containment, falls back to index.html for directories, streams
|
|
86
|
+
// files >= 1MB, and returns the cesium 404 page on miss.
|
|
87
|
+
async function serveStatic(req: Request, stateDirResolved: string): Promise<Response> {
|
|
88
|
+
if (req.method !== "GET") {
|
|
89
|
+
return new Response("Method Not Allowed", { status: 405 });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const url = new URL(req.url);
|
|
93
|
+
let reqPath: string;
|
|
94
|
+
try {
|
|
95
|
+
reqPath = decodeURIComponent(url.pathname);
|
|
96
|
+
} catch {
|
|
97
|
+
return htmlResponse(NOT_FOUND_HTML, 404);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const joined = resolve(stateDirResolved, "." + reqPath);
|
|
101
|
+
|
|
102
|
+
// Path traversal defense: resolved path must be rooted at stateDir
|
|
103
|
+
if (!joined.startsWith(stateDirResolved + "/") && joined !== stateDirResolved) {
|
|
104
|
+
return htmlResponse(FORBIDDEN_HTML, 403);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const filePath = joined;
|
|
108
|
+
const trailingSlash = reqPath.endsWith("/");
|
|
109
|
+
const indexPath = filePath.endsWith("/") ? filePath + "index.html" : filePath + "/index.html";
|
|
110
|
+
|
|
111
|
+
let fileToServe = filePath;
|
|
112
|
+
let isDir = false;
|
|
113
|
+
if (trailingSlash || extname(filePath) === "") {
|
|
114
|
+
isDir = true;
|
|
115
|
+
fileToServe = filePath.endsWith("/") ? filePath + "index.html" : filePath + "/index.html";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const bunFile = Bun.file(fileToServe);
|
|
119
|
+
let exists = await bunFile.exists();
|
|
120
|
+
|
|
121
|
+
if (!exists && !isDir) {
|
|
122
|
+
// Try as directory index (e.g. /sub → /sub/index.html)
|
|
123
|
+
fileToServe = indexPath;
|
|
124
|
+
exists = await Bun.file(fileToServe).exists();
|
|
125
|
+
if (!exists) return htmlResponse(NOT_FOUND_HTML, 404);
|
|
126
|
+
} else if (!exists) {
|
|
127
|
+
return htmlResponse(NOT_FOUND_HTML, 404);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const contentType = mimeFor(fileToServe);
|
|
131
|
+
const finalFile = Bun.file(fileToServe);
|
|
132
|
+
const size = finalFile.size;
|
|
133
|
+
|
|
134
|
+
// Large files: stream; small files: read fully.
|
|
135
|
+
if (size >= ONE_MB) {
|
|
136
|
+
return new Response(finalFile.stream(), {
|
|
137
|
+
status: 200,
|
|
138
|
+
headers: { "Content-Type": contentType },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const buf = await readFile(fileToServe);
|
|
143
|
+
return new Response(buf, { status: 200, headers: { "Content-Type": contentType } });
|
|
144
|
+
}
|
|
145
|
+
|
|
65
146
|
export async function startServer(args: StartServerArgs): Promise<ServerHandle> {
|
|
66
147
|
const { stateDir, port, hostname = "127.0.0.1" } = args;
|
|
67
148
|
const stateDirResolved = resolve(stateDir);
|
|
68
149
|
const requestHandlers: Array<() => void> = [];
|
|
69
|
-
|
|
150
|
+
|
|
151
|
+
const app = new Hono();
|
|
152
|
+
|
|
153
|
+
// Idle-tracker middleware: fires registered callbacks on every request before
|
|
154
|
+
// dispatching to routes. Used by lifecycle.ts to reset the idle-shutdown timer.
|
|
155
|
+
app.use("*", async (_c, next) => {
|
|
156
|
+
for (const h of requestHandlers) {
|
|
157
|
+
h();
|
|
158
|
+
}
|
|
159
|
+
await next();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Anything that doesn't match a registered route falls through to static
|
|
163
|
+
// file serving rooted at stateDir.
|
|
164
|
+
app.notFound((c) => serveStatic(c.req.raw, stateDirResolved));
|
|
70
165
|
|
|
71
166
|
const server = Bun.serve({
|
|
72
167
|
hostname,
|
|
73
168
|
port,
|
|
74
|
-
|
|
75
|
-
// Notify idle tracker
|
|
76
|
-
for (const h of requestHandlers) {
|
|
77
|
-
h();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Run pre-static handlers (e.g. API routes) before serving static files
|
|
81
|
-
for (const handler of preHandlers) {
|
|
82
|
-
const result = await handler(req);
|
|
83
|
-
if (result !== undefined) {
|
|
84
|
-
return result;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (req.method !== "GET") {
|
|
89
|
-
return new Response("Method Not Allowed", { status: 405 });
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const url = new URL(req.url);
|
|
93
|
-
// Decode and normalize the request path
|
|
94
|
-
let reqPath: string;
|
|
95
|
-
try {
|
|
96
|
-
reqPath = decodeURIComponent(url.pathname);
|
|
97
|
-
} catch {
|
|
98
|
-
return new Response(NOT_FOUND_HTML, {
|
|
99
|
-
status: 404,
|
|
100
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Resolve the absolute path under stateDir
|
|
105
|
-
// Use resolve with stateDir as root to prevent traversal
|
|
106
|
-
const joined = resolve(stateDirResolved, "." + reqPath);
|
|
107
|
-
|
|
108
|
-
// Path traversal defense: resolved path must be rooted at stateDir
|
|
109
|
-
if (!joined.startsWith(stateDirResolved + "/") && joined !== stateDirResolved) {
|
|
110
|
-
return new Response(FORBIDDEN_HTML, {
|
|
111
|
-
status: 403,
|
|
112
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Determine final file path: if directory, try index.html
|
|
117
|
-
let filePath = joined;
|
|
118
|
-
const trailingSlash = reqPath.endsWith("/");
|
|
119
|
-
|
|
120
|
-
// Check if it's a directory by trying index.html
|
|
121
|
-
const indexPath = filePath.endsWith("/") ? filePath + "index.html" : filePath + "/index.html";
|
|
122
|
-
|
|
123
|
-
// Try as-is first, then as directory index
|
|
124
|
-
let fileToServe = filePath;
|
|
125
|
-
let isDir = false;
|
|
126
|
-
|
|
127
|
-
// If path has no extension or trailing slash, it might be a directory
|
|
128
|
-
if (trailingSlash || extname(filePath) === "") {
|
|
129
|
-
isDir = true;
|
|
130
|
-
fileToServe = filePath.endsWith("/") ? filePath + "index.html" : filePath + "/index.html";
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const bunFile = Bun.file(fileToServe);
|
|
134
|
-
let exists = await bunFile.exists();
|
|
135
|
-
|
|
136
|
-
if (!exists && !isDir) {
|
|
137
|
-
// Try as directory index (e.g. /sub → /sub/index.html)
|
|
138
|
-
fileToServe = indexPath;
|
|
139
|
-
const bunFileIdx = Bun.file(fileToServe);
|
|
140
|
-
exists = await bunFileIdx.exists();
|
|
141
|
-
if (!exists) {
|
|
142
|
-
return new Response(NOT_FOUND_HTML, {
|
|
143
|
-
status: 404,
|
|
144
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
} else if (!exists) {
|
|
148
|
-
return new Response(NOT_FOUND_HTML, {
|
|
149
|
-
status: 404,
|
|
150
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const contentType = mimeFor(fileToServe);
|
|
155
|
-
const finalFile = Bun.file(fileToServe);
|
|
156
|
-
const size = finalFile.size;
|
|
157
|
-
|
|
158
|
-
// Large files (>= 1MB): stream; small files: read fully
|
|
159
|
-
const ONE_MB = 1024 * 1024;
|
|
160
|
-
if (size >= ONE_MB) {
|
|
161
|
-
return new Response(finalFile.stream(), {
|
|
162
|
-
status: 200,
|
|
163
|
-
headers: { "Content-Type": contentType },
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const buf = await readFile(fileToServe);
|
|
168
|
-
return new Response(buf, {
|
|
169
|
-
status: 200,
|
|
170
|
-
headers: { "Content-Type": contentType },
|
|
171
|
-
});
|
|
172
|
-
},
|
|
169
|
+
fetch: app.fetch,
|
|
173
170
|
});
|
|
174
171
|
|
|
175
172
|
const actualPort = server.port;
|
|
@@ -182,14 +179,12 @@ export async function startServer(args: StartServerArgs): Promise<ServerHandle>
|
|
|
182
179
|
return {
|
|
183
180
|
port: actualPort,
|
|
184
181
|
url: serverUrl,
|
|
182
|
+
app,
|
|
185
183
|
stop: async () => {
|
|
186
184
|
await server.stop();
|
|
187
185
|
},
|
|
188
186
|
onRequest: (handler: () => void) => {
|
|
189
187
|
requestHandlers.push(handler);
|
|
190
188
|
},
|
|
191
|
-
addHandler: (handler: (req: Request) => Promise<Response | undefined>) => {
|
|
192
|
-
preHandlers.push(handler);
|
|
193
|
-
},
|
|
194
189
|
};
|
|
195
190
|
}
|
package/src/server/lifecycle.ts
CHANGED
|
@@ -8,8 +8,8 @@ import { dirname } from "node:path";
|
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { startServer, type ServerHandle } from "./http.ts";
|
|
10
10
|
import { acquireLock } from "../storage/lock.ts";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { createApiApp } from "./api.ts";
|
|
12
|
+
import { createFaviconApp } from "./favicon.ts";
|
|
13
13
|
import { ensureThemeCss } from "../storage/assets.ts";
|
|
14
14
|
import { defaultTheme, type ThemeTokens } from "../render/theme.ts";
|
|
15
15
|
|
|
@@ -279,11 +279,11 @@ export async function runServerForeground(cfg: LifecycleConfig): Promise<Running
|
|
|
279
279
|
// Materialize theme.css before serving — self-heals on plugin upgrade
|
|
280
280
|
await ensureThemeCss(stateDir, theme);
|
|
281
281
|
|
|
282
|
-
//
|
|
283
|
-
handle.
|
|
282
|
+
// Mount API routes; unmatched paths fall through to the static handler
|
|
283
|
+
handle.app.route("/", createApiApp({ stateDir }));
|
|
284
284
|
// /favicon.ico shim — browsers auto-request this even when the page
|
|
285
285
|
// declares an SVG favicon. Serve the SVG bytes inline so we don't 404.
|
|
286
|
-
handle.
|
|
286
|
+
handle.app.route("/", createFaviconApp());
|
|
287
287
|
|
|
288
288
|
const startedAt = new Date().toISOString();
|
|
289
289
|
|
|
@@ -452,11 +452,13 @@ export async function ensureServerRunning(cfg: LifecycleConfig): Promise<Running
|
|
|
452
452
|
while (Date.now() < deadline) {
|
|
453
453
|
const waitMs = POLL_SCHEDULE[scheduleIdx] ?? 1000;
|
|
454
454
|
scheduleIdx = Math.min(scheduleIdx + 1, POLL_SCHEDULE.length - 1);
|
|
455
|
+
// eslint-disable-next-line no-await-in-loop -- poll-with-backoff requires sequential sleeps
|
|
455
456
|
await sleep(waitMs);
|
|
456
457
|
|
|
457
458
|
const pidContent = readPidFile(pidFilePath);
|
|
458
459
|
if (pidContent !== null && isAlive(pidContent.pid)) {
|
|
459
460
|
const probeUrl = `http://${pidContent.hostname}:${pidContent.port}/`;
|
|
461
|
+
// eslint-disable-next-line no-await-in-loop -- probe runs per-iteration after sleep; cannot parallelize
|
|
460
462
|
const alive = await httpProbe(probeUrl);
|
|
461
463
|
if (alive) {
|
|
462
464
|
return {
|
package/src/storage/assets.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
// Materializes /theme.css in the state directory, atomically and idempotently.
|
|
2
2
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
frameworkRulesCss,
|
|
7
|
-
themeTokensCss,
|
|
8
|
-
defaultTheme,
|
|
9
|
-
type ThemeTokens,
|
|
10
|
-
} from "../render/theme.ts";
|
|
4
|
+
import { defaultTheme, type ThemeTokens } from "../render/theme.ts";
|
|
11
5
|
import { atomicWrite } from "./write.ts";
|
|
6
|
+
import { buildThemeCss, themeCssPath } from "./theme-write.ts";
|
|
12
7
|
import { readFile } from "node:fs/promises";
|
|
13
8
|
|
|
14
9
|
/** Per-theme CSS cache: built CSS string keyed by theme content hash. */
|
|
@@ -19,19 +14,22 @@ function themeKey(theme: ThemeTokens): string {
|
|
|
19
14
|
return createHash("sha256").update(JSON.stringify(theme)).digest("hex");
|
|
20
15
|
}
|
|
21
16
|
|
|
22
|
-
/** Build the full theme.css string for a given theme (tokens + framework rules).
|
|
17
|
+
/** Build the full theme.css string for a given theme (tokens + framework rules).
|
|
18
|
+
* Delegates to buildThemeCss so cesium-theme-apply and ensureThemeCss agree on
|
|
19
|
+
* byte-exact output. Caches by theme identity to avoid re-string-concat on the
|
|
20
|
+
* hot publish path. */
|
|
23
21
|
function buildCss(theme: ThemeTokens): string {
|
|
24
22
|
const key = themeKey(theme);
|
|
25
23
|
const cached = cssCache.get(key);
|
|
26
24
|
if (cached !== undefined) return cached;
|
|
27
|
-
const css =
|
|
25
|
+
const css = buildThemeCss(theme);
|
|
28
26
|
cssCache.set(key, css);
|
|
29
27
|
return css;
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
/** Returns the absolute path to theme.css in stateDir. */
|
|
33
31
|
export function themeCssAssetPath(stateDir: string): string {
|
|
34
|
-
return
|
|
32
|
+
return themeCssPath(stateDir);
|
|
35
33
|
}
|
|
36
34
|
|
|
37
35
|
/**
|