chocola 1.6.0 → 2.0.0-next.10

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.
@@ -0,0 +1,312 @@
1
+ const LBRACE_PH = "_%%CHOCOLA-LBRACE%%_";
2
+ const RBRACE_PH = "_%%CHOCOLA-RBRACE%%_";
3
+
4
+ const exprCache = new Map();
5
+
6
+ function compileExp(expr, useCtx) {
7
+ expr = expr.replace(/_%%CHOCOLA-(?:AMP|LT|GT)\d+%%_/g, char =>
8
+ char.includes("AMP") ? "&" : char.includes("LT") ? "<" : ">"
9
+ );
10
+ const key = useCtx ? "ctx:" + expr : "raw:" + expr;
11
+ let fn = exprCache.get(key);
12
+ if (!fn) {
13
+ fn = useCtx
14
+ ? new Function("ctx", "with(ctx) { return (" + expr + "); }")
15
+ : new Function('"use strict"; return (' + expr + ")");
16
+ exprCache.set(key, fn);
17
+ }
18
+ return fn;
19
+ }
20
+
21
+ function interpolateNode(root, ctx) {
22
+ const stack = [root];
23
+ while (stack.length) {
24
+ const node = stack.pop();
25
+ if (node.nodeType === 3) {
26
+ const text = node.textContent
27
+ .replace(new RegExp(LBRACE_PH, "g"), "{")
28
+ .replace(new RegExp(RBRACE_PH, "g"), "}");
29
+ node.textContent = text.replace(/\{([^}]+)\}/g, (_, expr) => {
30
+ try {
31
+ return String(compileExp(expr, true)(ctx));
32
+ } catch {
33
+ return "";
34
+ }
35
+ });
36
+ } else {
37
+ for (let i = node.childNodes.length - 1; i >= 0; i--) {
38
+ stack.push(node.childNodes[i]);
39
+ }
40
+ }
41
+ }
42
+ }
43
+
44
+ function applyDirectiveToEl(child, ctx, chain, hasIf, hasMountIf, hasElif, hasElse) {
45
+ if (hasIf) {
46
+ const expr = child.getAttribute("if").slice(1, -1);
47
+ chain.active = true;
48
+ if (compileExp(expr, true)(ctx)) {
49
+ chain.rendered = true;
50
+ } else {
51
+ child.style.display = "none";
52
+ chain.rendered = false;
53
+ }
54
+ child.removeAttribute("if");
55
+ } else if (hasMountIf) {
56
+ const expr = child.getAttribute("mount:if").slice(1, -1);
57
+ chain.active = true;
58
+ if (compileExp(expr, true)(ctx)) {
59
+ chain.rendered = true;
60
+ } else {
61
+ child.remove();
62
+ chain.rendered = false;
63
+ }
64
+ child.removeAttribute("mount:if");
65
+ } else if (hasElif) {
66
+ const expr = child.getAttribute("elif").slice(1, -1);
67
+ if (compileExp(expr, true)(ctx)) {
68
+ chain.rendered = true;
69
+ } else {
70
+ child.remove();
71
+ }
72
+ child.removeAttribute("elif");
73
+ } else if (hasElse) {
74
+ chain.rendered = true;
75
+ chain.active = false;
76
+ child.removeAttribute("else");
77
+ } else {
78
+ chain.active = false;
79
+ chain.rendered = false;
80
+ }
81
+ }
82
+
83
+ function interpolateAttributes(element, ctx) {
84
+ for (const el of [element, ...element.querySelectorAll("*")]) {
85
+ for (const attr of [...el.attributes]) {
86
+ const val = attr.value;
87
+ if (!val.includes("{") && !val.includes(LBRACE_PH)) continue;
88
+ const raw = val
89
+ .replace(new RegExp(LBRACE_PH, "g"), "{")
90
+ .replace(new RegExp(RBRACE_PH, "g"), "}");
91
+ attr.value = raw.replace(/\{([^}]+)\}/g, (_, expr) => {
92
+ try {
93
+ return String(compileExp(expr, true)(ctx));
94
+ } catch {
95
+ return "";
96
+ }
97
+ });
98
+ }
99
+ }
100
+ }
101
+
102
+ function processDirectives(element, ctx, chain) {
103
+ for (const child of [...element.children]) {
104
+ const hasIf = child.hasAttribute("if");
105
+ const hasMountIf = child.hasAttribute("mount:if");
106
+ const hasElif = child.hasAttribute("elif");
107
+ const hasElse = child.hasAttribute("else");
108
+
109
+ if ((hasElif || hasElse) && chain.rendered) {
110
+ child.remove();
111
+ if (hasElse) chain.active = false;
112
+ continue;
113
+ }
114
+
115
+ applyDirectiveToEl(child, ctx, chain, hasIf, hasMountIf, hasElif, hasElse);
116
+
117
+ if (child.parentNode) {
118
+ processDirectives(child, ctx, chain);
119
+ }
120
+ }
121
+ }
122
+
123
+ function processSlots(el, projected) {
124
+ const slots = el.querySelectorAll("slot");
125
+ for (const slot of slots) {
126
+ const clone = projected.map(c => c.cloneNode(true));
127
+ slot.replaceWith(...clone);
128
+ }
129
+ }
130
+
131
+ let bindCounter = 0;
132
+
133
+ function processBindings(el) {
134
+ const bindings = [];
135
+ for (const attr of [...el.attributes]) {
136
+ if (attr.name.startsWith("bind:")) {
137
+ const prop = attr.name.slice(5);
138
+ const varName = attr.value;
139
+ const bindId = "b" + (bindCounter++);
140
+ el.setAttribute("data-chbind-" + bindId, "");
141
+ bindings.push({ prop, varName, bindId });
142
+ el.removeAttribute(attr.name);
143
+ }
144
+ }
145
+ return bindings;
146
+ }
147
+
148
+ function resolveBindings(el) {
149
+ const ids = [];
150
+ for (const attr of [...el.attributes]) {
151
+ const match = attr.name.match(/^data-chbind-(b\d+)$/);
152
+ if (match) {
153
+ ids.push(match[1]);
154
+ el.removeAttribute(attr.name);
155
+ }
156
+ }
157
+ return ids;
158
+ }
159
+
160
+ function collectBindings(root) {
161
+ const bindingDefs = [];
162
+ const idToEl = {};
163
+
164
+ for (const el of [root, ...root.querySelectorAll("*")]) {
165
+ bindingDefs.push(...processBindings(el));
166
+ for (const id of resolveBindings(el)) {
167
+ idToEl[id] = el;
168
+ }
169
+ }
170
+ return { bindingDefs, idToEl };
171
+ }
172
+
173
+ class ChocolaComponent {
174
+ #template;
175
+ #hash;
176
+ #runtime;
177
+ #defaultProps;
178
+ #bakedComponent;
179
+ #ctx;
180
+ #eventListeners = [];
181
+ #children;
182
+ #childComponents;
183
+
184
+ constructor(config = {}) {
185
+ this.#template = config.template || "";
186
+ this.#hash = config.hash || "";
187
+ this.#runtime = config.runtime || null;
188
+ this.#defaultProps = config.props || {};
189
+ this.#childComponents = config.children || [];
190
+ }
191
+
192
+ #collectListeners(root, fn) {
193
+ const allEls = [root, ...root.querySelectorAll("*")];
194
+ const patches = [];
195
+
196
+ for (const el of allEls) {
197
+ const original = el.addEventListener;
198
+ el.addEventListener = (event, handler, options) => {
199
+ this.#eventListeners.push({ el, event, handler, options });
200
+ original.call(el, event, handler, options);
201
+ };
202
+ patches.push({ el, original });
203
+ }
204
+
205
+ try {
206
+ fn(root, this.#ctx);
207
+ } finally {
208
+ for (const { el, original } of patches) {
209
+ el.addEventListener = original;
210
+ }
211
+ }
212
+ }
213
+
214
+ #init(ctx) {
215
+ const mergedCtx = { ...this.#defaultProps, ...ctx };
216
+
217
+ const container = document.createElement("template");
218
+ container.innerHTML = this.#template;
219
+ const fragment = container.content;
220
+ const root = fragment.firstElementChild;
221
+
222
+ if (!root) {
223
+ console.error(`${this.#hash}: component template must have a single root element`);
224
+ return null;
225
+ }
226
+
227
+ processSlots(root, this.#children || []);
228
+ processDirectives(root, mergedCtx, { active: false, rendered: false });
229
+ interpolateAttributes(root, mergedCtx);
230
+ interpolateNode(root, mergedCtx);
231
+ root.classList.add(this.#hash);
232
+
233
+ const { bindingDefs, idToEl } = collectBindings(root);
234
+ this.#ctx = { ...mergedCtx };
235
+ for (const { prop, varName, bindId } of bindingDefs) {
236
+ const el = idToEl[bindId];
237
+ if (el) {
238
+ this.#ctx[varName] = prop === "self" ? el : el[prop];
239
+ }
240
+ }
241
+
242
+ if (this.#runtime) {
243
+ this.#collectListeners(root, (self, ctx) => {
244
+ this.#runtime(self, ctx);
245
+ });
246
+ }
247
+
248
+ this.#bakedComponent = root;
249
+ container.remove();
250
+ return root;
251
+ }
252
+
253
+ #childInstances = [];
254
+
255
+ #mountChildren(root) {
256
+ for (const { tag, compClass } of this.#childComponents) {
257
+ const els = root.querySelectorAll(tag);
258
+ for (const el of els) {
259
+ const props = {};
260
+ for (const attr of [...el.attributes]) {
261
+ if (attr.name.startsWith("data-ch") || attr.name === "if" || attr.name === "mount:if" || attr.name === "elif" || attr.name === "else") continue;
262
+ props[attr.name] = attr.value;
263
+ }
264
+ const instance = new compClass();
265
+ instance.mount(el.parentNode, props);
266
+ this.#childInstances.push(instance);
267
+ el.remove();
268
+ }
269
+ }
270
+ }
271
+
272
+ mount(target, ctx = {}) {
273
+ if (target.nodeType !== 1) {
274
+ console.error(`${this.#hash}: mount target is not a valid DOM node`);
275
+ return;
276
+ }
277
+ this.#children = [...target.children];
278
+ this.#init(ctx);
279
+ if (this.#bakedComponent) {
280
+ target.appendChild(this.#bakedComponent);
281
+ this.#mountChildren(this.#bakedComponent);
282
+ }
283
+ }
284
+
285
+ remove() {
286
+ for (const { el, event, handler, options } of this.#eventListeners) {
287
+ el.removeEventListener(event, handler, options);
288
+ }
289
+ this.#eventListeners = [];
290
+ for (const child of this.#childInstances) {
291
+ child.remove();
292
+ }
293
+ this.#childInstances = [];
294
+ if (this.#bakedComponent) {
295
+ this.#bakedComponent.remove();
296
+ this.#bakedComponent = null;
297
+ }
298
+ }
299
+
300
+ update(ctx = {}) {
301
+ const mergedCtx = { ...this.#defaultProps, ...ctx };
302
+ const parent = this.#bakedComponent?.parentNode;
303
+ parent && this.remove();
304
+ this.#init(mergedCtx);
305
+ if (parent && this.#bakedComponent) {
306
+ parent.appendChild(this.#bakedComponent);
307
+ this.#mountChildren(this.#bakedComponent);
308
+ }
309
+ }
310
+ }
311
+
312
+ export { ChocolaComponent };
@@ -0,0 +1,402 @@
1
+ import http from "http";
2
+ import path from "path";
3
+ import { promises as fs } from "fs";
4
+ import { createHash } from "crypto";
5
+ import zlib from "zlib";
6
+ import { pathToFileURL } from "url";
7
+
8
+ import { buildModuleGraph } from "../compiler/module-graph.js";
9
+ import { renderPage } from "../compiler/render.js";
10
+ import { loadConfig, resolvePaths } from "../compiler/config.js";
11
+ import { getConfig } from "../utils.js";
12
+
13
+ const MIME_TYPES = {
14
+ ".html": "text/html; charset=utf-8",
15
+ ".htm": "text/html; charset=utf-8",
16
+ ".js": "text/javascript; charset=utf-8",
17
+ ".mjs": "text/javascript; charset=utf-8",
18
+ ".css": "text/css; charset=utf-8",
19
+ ".csv": "text/csv",
20
+ ".json": "application/json",
21
+ ".png": "image/png",
22
+ ".jpg": "image/jpeg",
23
+ ".jpeg": "image/jpeg",
24
+ ".bmp": "image/bmp",
25
+ ".gif": "image/gif",
26
+ ".svg": "image/svg+xml",
27
+ ".ico": "image/vnd.microsoft.icon",
28
+ ".icon": "image/vnd.microsoft.icon",
29
+ ".pdf": "application/pdf",
30
+ ".mp3": "audio/mpeg",
31
+ ".md": "text/markdown",
32
+ ".mp4": "video/mp4",
33
+ ".mpeg": "video/mpeg",
34
+ ".oga": "audio/ogg",
35
+ ".ogv": "video/ogg",
36
+ ".tar": "application/x-tar",
37
+ ".ttf": "font/ttf",
38
+ ".txt": "text/plain",
39
+ ".wav": "audio/wav",
40
+ ".weba": "audio/webm",
41
+ ".webm": "video/webm",
42
+ ".webmanifest": "application/manifest+json",
43
+ ".webp": "image/webp",
44
+ ".xhtml": "application/xhtml+xml",
45
+ ".xml": "application/xml",
46
+ ".zip": "application/zip",
47
+ };
48
+
49
+ function getMimeType(filePath) {
50
+ const ext = path.extname(filePath).toLowerCase();
51
+ return MIME_TYPES[ext] || "application/octet-stream";
52
+ }
53
+
54
+ function generateETag(buf) {
55
+ const hash = createHash("sha256").update(buf).digest("hex").slice(0, 32);
56
+ return `W/"${hash}"`;
57
+ }
58
+
59
+ function parseCookies(cookieHeader = "") {
60
+ const out = {};
61
+ if (!cookieHeader) return out;
62
+ for (const part of cookieHeader.split(";")) {
63
+ const idx = part.indexOf("=");
64
+ if (idx === -1) continue;
65
+ const key = part.slice(0, idx).trim();
66
+ const val = part.slice(idx + 1).trim();
67
+ if (key) out[key] = decodeURIComponent(val);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ function acceptsGzip(req) {
73
+ const ae = req.headers["accept-encoding"] || "";
74
+ return ae.includes("gzip");
75
+ }
76
+
77
+ function shouldCompress(mime) {
78
+ return /^(text\/|application\/(javascript|json|xml|manifest\+json))/.test(mime);
79
+ }
80
+
81
+ async function loadMiddleware(rootDir, middlewarePath) {
82
+ if (!middlewarePath) return [];
83
+ const resolved = path.isAbsolute(middlewarePath)
84
+ ? middlewarePath
85
+ : path.join(rootDir, middlewarePath);
86
+ try {
87
+ await fs.access(resolved);
88
+ } catch {
89
+ throw new Error(`[chocola/server] middleware file not found: ${resolved}`);
90
+ }
91
+ const imported = await import(pathToFileURL(resolved).href);
92
+ const raw = imported.default ?? imported;
93
+ if (Array.isArray(raw)) return raw.filter((fn) => typeof fn === "function");
94
+ if (typeof raw === "function") return [raw];
95
+ if (raw && typeof raw === "object") {
96
+ // handle named exports
97
+ const fns = Object.values(raw).filter((v) => typeof v === "function");
98
+ if (fns.length) return fns;
99
+ }
100
+ return [];
101
+ }
102
+
103
+ function buildRouteTable(graph) {
104
+ const table = new Map();
105
+ // Single page support: index.html
106
+ if (graph.page) {
107
+ table.set("/", graph.page);
108
+ table.set("/index.html", graph.page);
109
+ table.set("/index", graph.page);
110
+ }
111
+ return table;
112
+ }
113
+
114
+ function sendNotModified(res) {
115
+ res.writeHead(304);
116
+ res.end();
117
+ }
118
+
119
+ function serveBuffer(req, res, buffer, contentType, etag, mtime) {
120
+ const headers = {
121
+ "Content-Type": contentType,
122
+ "ETag": etag,
123
+ "Last-Modified": mtime.toUTCString(),
124
+ "Cache-Control": "public, max-age=0, must-revalidate",
125
+ "Content-Length": buffer.length,
126
+ };
127
+
128
+ const inm = req.headers["if-none-match"];
129
+ if (inm && inm === etag) {
130
+ // Weak comparison is okay for demo; exact match suffices
131
+ sendNotModified(res);
132
+ return;
133
+ }
134
+
135
+ const ims = req.headers["if-modified-since"];
136
+ if (ims) {
137
+ const imsTime = new Date(ims).getTime();
138
+ if (!Number.isNaN(imsTime) && mtime.getTime() <= imsTime) {
139
+ sendNotModified(res);
140
+ return;
141
+ }
142
+ }
143
+
144
+ const doGzip = acceptsGzip(req) && shouldCompress(contentType);
145
+ if (doGzip) {
146
+ const gz = zlib.gzipSync(buffer);
147
+ headers["Content-Encoding"] = "gzip";
148
+ headers["Vary"] = "Accept-Encoding";
149
+ headers["Content-Length"] = gz.length;
150
+ if (req.method === "HEAD") {
151
+ res.writeHead(200, headers);
152
+ res.end();
153
+ return;
154
+ }
155
+ res.writeHead(200, headers);
156
+ res.end(gz);
157
+ return;
158
+ }
159
+
160
+ if (req.method === "HEAD") {
161
+ res.writeHead(200, headers);
162
+ res.end();
163
+ return;
164
+ }
165
+ res.writeHead(200, headers);
166
+ res.end(buffer);
167
+ }
168
+
169
+ function normalizeRootDir(arg, opts) {
170
+ if (typeof arg === "object" && arg !== null && !opts) {
171
+ // createServer({ rootDir }) form
172
+ const o = arg;
173
+ const rd = o.rootDir || o.root || o.dir || process.cwd();
174
+ return { rootDir: rd, opts: o };
175
+ }
176
+ if (typeof arg === "string") {
177
+ return { rootDir: arg, opts: opts || {} };
178
+ }
179
+ // fallback
180
+ return { rootDir: String(arg), opts: opts || {} };
181
+ }
182
+
183
+ export async function createHandler(rootDirArg, optsArg) {
184
+ const { rootDir, opts } = normalizeRootDir(rootDirArg, optsArg);
185
+
186
+ const fullConfig = await getConfig(rootDir).catch(() => ({}));
187
+ const config = await loadConfig(rootDir);
188
+ const paths = resolvePaths(rootDir, config);
189
+
190
+ const graph = await buildModuleGraph(rootDir);
191
+
192
+ const middlewarePath = opts.middleware ?? fullConfig.server?.middleware ?? null;
193
+ const middlewares = await loadMiddleware(rootDir, middlewarePath);
194
+
195
+ const routeTable = buildRouteTable(graph);
196
+
197
+ // virtual file store for dynamically generated assets (run-*.js, sc-*.css, css-*.css, js-*.js)
198
+ const virtualFiles = new Map(); // pathname -> { buffer, etag, mtime, type }
199
+
200
+ function ingest(result) {
201
+ for (const file of result.files) {
202
+ const key = "/" + file.path;
203
+ const buf = Buffer.from(file.content, "utf-8");
204
+ const etag = generateETag(buf);
205
+ virtualFiles.set(key, {
206
+ buffer: buf,
207
+ etag,
208
+ mtime: new Date(),
209
+ type: getMimeType(file.path),
210
+ });
211
+ }
212
+ }
213
+
214
+ // Prime virtual files with initial render (ensures assets exist even before first SSR)
215
+ try {
216
+ const initial = await renderPage(graph, {});
217
+ ingest(initial);
218
+ } catch (e) {
219
+ // If initial render fails, continue; per-request render will surface error
220
+ console.warn("[chocola/server] initial render failed:", e?.message || e);
221
+ }
222
+
223
+ async function handler(req, res) {
224
+ try {
225
+ const host = req.headers.host || "localhost";
226
+ const url = new URL(req.url, `http://${host}`);
227
+ let pathname = decodeURIComponent(url.pathname);
228
+
229
+ // Normalize pathname: remove duplicate slashes
230
+ pathname = pathname.replace(/\/+/g, "/");
231
+ // Keep as is; do not strip trailing slash except for route lookup
232
+
233
+ const query = Object.fromEntries(url.searchParams.entries());
234
+ const cookies = parseCookies(req.headers.cookie || "");
235
+
236
+ // 1) SSR route?
237
+ // Try exact, then fallback with/without trailing slash
238
+ let page = routeTable.get(pathname);
239
+ if (!page && pathname.endsWith("/") && pathname.length > 1) {
240
+ page = routeTable.get(pathname.slice(0, -1));
241
+ }
242
+ if (!page && !pathname.endsWith("/")) {
243
+ page = routeTable.get(pathname + "/");
244
+ }
245
+
246
+ if (page) {
247
+ // Build per-request ctx via middleware
248
+ let ctx = { ...query };
249
+ // Also merge opts.ctx if provided (for programmatic use)
250
+ if (opts.ctx && typeof opts.ctx === "object") Object.assign(ctx, opts.ctx);
251
+
252
+ for (const mw of middlewares) {
253
+ let result;
254
+ try {
255
+ if (mw.length >= 2) {
256
+ result = await mw(req, res);
257
+ } else {
258
+ result = await mw({
259
+ req,
260
+ res,
261
+ params: {},
262
+ query,
263
+ cookies,
264
+ headers: req.headers,
265
+ url,
266
+ pathname,
267
+ searchParams: url.searchParams,
268
+ });
269
+ }
270
+ } catch (e) {
271
+ console.error("[chocola/server] middleware error:", e);
272
+ if (!res.headersSent && !res.writableEnded) {
273
+ res.writeHead(500, { "Content-Type": "text/plain" });
274
+ res.end("Internal middleware error");
275
+ }
276
+ return;
277
+ }
278
+ if (res.writableEnded || res.headersSent) {
279
+ return;
280
+ }
281
+ if (result && typeof result === "object" && !Array.isArray(result) && !(result instanceof Buffer)) {
282
+ // Merge returned object into ctx
283
+ Object.assign(ctx, result);
284
+ }
285
+ }
286
+
287
+ let result;
288
+ try {
289
+ result = await renderPage(graph, ctx);
290
+ } catch (e) {
291
+ if (!res.headersSent && !res.writableEnded) {
292
+ res.writeHead(500, { "Content-Type": "text/plain" });
293
+ res.end("Render error: " + (e?.message || e));
294
+ }
295
+ return;
296
+ }
297
+
298
+ ingest(result);
299
+
300
+ const htmlBuf = Buffer.from(result.html, "utf-8");
301
+ const etag = generateETag(htmlBuf);
302
+ const mtime = new Date();
303
+ const ctype = "text/html; charset=utf-8";
304
+ serveBuffer(req, res, htmlBuf, ctype, etag, mtime);
305
+ return;
306
+ }
307
+
308
+ // 2) Virtual files (bundled css, js, runtime, scoped css)
309
+ if (virtualFiles.has(pathname)) {
310
+ const entry = virtualFiles.get(pathname);
311
+ serveBuffer(req, res, entry.buffer, entry.type, entry.etag, entry.mtime);
312
+ return;
313
+ }
314
+
315
+ // Also handle "./" prefix that html emits: href="./css-xxx.css" leads to request "/css-xxx.css" after browser resolves, but also handle "./" stripping
316
+ // Already handled by virtualFiles via "/" prefix. For safety, try without leading slash variations
317
+ // Next: try static file from src
318
+ // Remove leading "/" and resolve against src dir
319
+ const relative = pathname.replace(/^\/+/, "");
320
+ // Prevent directory traversal
321
+ const safe = path.normalize(relative).replace(/^(\.\.(\/|\\|$))+/g, "");
322
+ const candidatePaths = [
323
+ path.join(paths.src, safe),
324
+ path.join(paths.src, safe + ".html"),
325
+ ];
326
+
327
+ for (const cand of candidatePaths) {
328
+ try {
329
+ const stat = await fs.stat(cand);
330
+ if (stat.isFile()) {
331
+ const buf = await fs.readFile(cand);
332
+ const etag = generateETag(buf);
333
+ const mtime = stat.mtime;
334
+ const ctype = getMimeType(cand);
335
+ serveBuffer(req, res, buf, ctype, etag, mtime);
336
+ return;
337
+ }
338
+ if (stat.isDirectory()) {
339
+ // try index.html inside
340
+ const idx = path.join(cand, "index.html");
341
+ try {
342
+ const s2 = await fs.stat(idx);
343
+ if (s2.isFile()) {
344
+ const buf = await fs.readFile(idx);
345
+ const etag = generateETag(buf);
346
+ serveBuffer(req, res, buf, getMimeType(idx), etag, s2.mtime);
347
+ return;
348
+ }
349
+ } catch {}
350
+ }
351
+ } catch {}
352
+ }
353
+
354
+ // Fallback 404
355
+ if (!res.headersSent && !res.writableEnded) {
356
+ res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
357
+ res.end("<h1>404 Not Found</h1><p>The requested resource was not found.</p>");
358
+ }
359
+ } catch (err) {
360
+ console.error("[chocola/server] handler error:", err);
361
+ if (!res.headersSent && !res.writableEnded) {
362
+ res.writeHead(500, { "Content-Type": "text/plain" });
363
+ res.end("Internal server error");
364
+ }
365
+ }
366
+ }
367
+
368
+ // Attach helpers for testing / introspection
369
+ handler.graph = graph;
370
+ handler.virtualFiles = virtualFiles;
371
+ handler.routeTable = buildRouteTable(graph);
372
+ handler.paths = paths;
373
+
374
+ return handler;
375
+ }
376
+
377
+ // Alias per lead: createServer({ rootDir }) returns bare http handler
378
+ export const createServer = createHandler;
379
+ export const createServerRenderer = createHandler; // future ssr escape hatch alias
380
+
381
+ export async function serve(rootDirArg, optsArg) {
382
+ const { rootDir } = normalizeRootDir(rootDirArg, optsArg);
383
+ const fullConfig = await getConfig(rootDir).catch(() => ({}));
384
+ const serverCfg = fullConfig.server || {};
385
+ const port = serverCfg.port ?? optsArg?.port ?? 8080;
386
+ const hostname = serverCfg.hostname ?? serverCfg.host ?? optsArg?.hostname ?? optsArg?.host ?? "localhost";
387
+
388
+ const handler = await createHandler(rootDir, optsArg);
389
+
390
+ const server = http.createServer(handler);
391
+ await new Promise((resolve, reject) => {
392
+ server.once("error", reject);
393
+ server.listen(port, hostname, () => {
394
+ console.log(`Chocola server running at http://${hostname}:${port}/`);
395
+ resolve();
396
+ });
397
+ });
398
+ return server;
399
+ }
400
+
401
+ // also export default for convenience
402
+ export default { createHandler, createServer, serve };