chocola 2.0.0-next.11 → 2.0.0-next.12

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