@velox0/cerver 0.1.0 → 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.
@@ -0,0 +1,48 @@
1
+ name: Publish package
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ release:
7
+ types: [published]
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ contents: read
12
+ id-token: write
13
+
14
+ jobs:
15
+ publish:
16
+ name: Publish to npm
17
+ runs-on: ubuntu-latest
18
+
19
+ steps:
20
+ - name: Checkout
21
+ uses: actions/checkout@v4
22
+
23
+ - name: Setup pnpm
24
+ uses: pnpm/action-setup@v4
25
+ with:
26
+ version: 10
27
+ run_install: false
28
+
29
+ - name: Setup Node.js
30
+ uses: actions/setup-node@v4
31
+ with:
32
+ node-version: 20
33
+ registry-url: https://registry.npmjs.org
34
+ cache: pnpm
35
+
36
+ - name: Install dependencies
37
+ run: pnpm install --frozen-lockfile
38
+
39
+ - name: Run tests
40
+ run: pnpm test
41
+
42
+ - name: Verify package contents
43
+ run: npm pack --dry-run
44
+
45
+ - name: Publish to npm
46
+ run: npm publish --access public --provenance
47
+ env:
48
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/README.md CHANGED
@@ -10,32 +10,36 @@ Cerver takes a Next.js-style file-based routing structure (written in a strict s
10
10
  - **Microscopic Footprint**: Generated executables are typically ~50KB and start in milliseconds.
11
11
  - **Single-Binary Deployment**: Static assets (HTML, CSS, JS, images) are automatically minified and embedded directly into the executable as C byte arrays.
12
12
  - **Native Performance**: Uses `kqueue` (macOS) or `epoll` (Linux) event loops for high-performance non-blocking I/O.
13
- - **File-Based Routing**: Intuitive `app/routes/` directory structure, supporting dynamic segments (e.g., `/art/[key].js`).
13
+ - **File-Based Routing**: Intuitive `app/routes/` directory structure, supporting dynamic segments (e.g., `/item/[id].js`).
14
14
 
15
15
  ## Getting Started
16
16
 
17
17
  1. Install globally (requires `gcc` or `clang` on your system):
18
- ```bash
19
- npm install -g cerver
20
- ```
18
+
19
+ ```bash
20
+ npm i @velox0/cerver@latest
21
+ ```
21
22
 
22
23
  2. Create a new project:
23
- ```bash
24
- cerver new my-fast-api
25
- cd my-fast-api
26
- ```
24
+
25
+ ```bash
26
+ cerver new my-fast-api
27
+ cd my-fast-api
28
+ ```
27
29
 
28
30
  3. Build and Run:
29
- ```bash
30
- cerver build
31
- cerver run
32
- ```
31
+
32
+ ```bash
33
+ cerver build
34
+ cerver run
35
+ ```
33
36
 
34
37
  ## Routing
35
38
 
36
39
  Routes are defined in the `app/routes/` directory.
37
40
 
38
41
  `app/routes/index.js` (maps to `/`)
42
+
39
43
  ```javascript
40
44
  export function GET(req, res) {
41
45
  return res.html(200, "<h1>Hello World!</h1>");
@@ -43,6 +47,7 @@ export function GET(req, res) {
43
47
  ```
44
48
 
45
49
  `app/routes/api/status.js` (maps to `/api/status`)
50
+
46
51
  ```javascript
47
52
  export function GET(req, res) {
48
53
  return res.json(200, '{"status": "online"}');
@@ -50,6 +55,7 @@ export function GET(req, res) {
50
55
  ```
51
56
 
52
57
  `app/routes/users/[id].js` (maps to `/users/:id`)
58
+
53
59
  ```javascript
54
60
  export function GET(req, res) {
55
61
  const userId = req.params.id;
@@ -62,6 +68,7 @@ export function GET(req, res) {
62
68
  Because Cerver compiles to C, the API surface is restricted.
63
69
 
64
70
  **Request (`req`)**
71
+
65
72
  - `req.path` — The request URL path
66
73
  - `req.method` — The HTTP method
67
74
  - `req.headers["user-agent"]` — Access request headers
@@ -69,6 +76,7 @@ Because Cerver compiles to C, the API surface is restricted.
69
76
  - `req.params.id` — Access dynamic path segments
70
77
 
71
78
  **Response (`res`)**
79
+
72
80
  - `res.text(status, string)` — Send plain text
73
81
  - `res.json(status, string)` — Send JSON
74
82
  - `res.html(status, string)` — Send HTML
@@ -76,6 +84,7 @@ Because Cerver compiles to C, the API surface is restricted.
76
84
  ## Supported JavaScript
77
85
 
78
86
  Cerver supports a strict, synchronous subset of JavaScript suitable for C code generation:
87
+
79
88
  - `if`/`else` statements
80
89
  - `const` / `let` variable declarations
81
90
  - String and Number literals
@@ -83,6 +92,7 @@ Cerver supports a strict, synchronous subset of JavaScript suitable for C code g
83
92
  - Basic comparisons (`===`, `!==`, `<`, `>`)
84
93
 
85
94
  **Not Supported (Compile-Time Errors):**
95
+
86
96
  - `async`/`await` and Promises
87
97
  - Loops (`for`, `while`)
88
98
  - Classes and the `new` keyword
@@ -92,13 +102,14 @@ Cerver supports a strict, synchronous subset of JavaScript suitable for C code g
92
102
  ## Configuration
93
103
 
94
104
  `cerver.config.js`:
105
+
95
106
  ```javascript
96
107
  export default {
97
- port: 8080, // Default port
98
- embed: true, // Embed assets from public/ into the binary
99
- minify: true, // Minify HTML/CSS/JS before embedding
100
- compression: "none" // Future: pre-compress assets
101
- }
108
+ port: 8080, // Default port
109
+ embed: true, // Embed assets from public/ into the binary
110
+ minify: true, // Minify HTML/CSS/JS before embedding
111
+ compression: "none", // Future: pre-compress assets
112
+ };
102
113
  ```
103
114
 
104
115
  ## How It Works
package/bin/cerver.js CHANGED
@@ -32,6 +32,16 @@ program
32
32
  build(opts);
33
33
  });
34
34
 
35
+ program
36
+ .command("dev")
37
+ .description("Watch for changes, auto-rebuild, and restart the server")
38
+ .option("-p, --port <port>", "Override the port")
39
+ .option("--no-embed", "Serve static assets from the filesystem")
40
+ .action((opts) => {
41
+ const { dev } = require("../lib/commands/dev");
42
+ dev(opts);
43
+ });
44
+
35
45
  program
36
46
  .command("run")
37
47
  .description("Run the compiled binary")
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+
3
+ const zlib = require("zlib");
4
+
5
+ /**
6
+ * Compress content using gzip or brotli.
7
+ *
8
+ * Uses Node.js built-in zlib — no external dependencies needed.
9
+ *
10
+ * @param {Buffer} content - Raw content to compress
11
+ * @param {"gzip"|"brotli"} algorithm - Compression algorithm
12
+ * @returns {Promise<Buffer>} - Compressed content
13
+ */
14
+ function compressContent(content, algorithm) {
15
+ return new Promise((resolve, reject) => {
16
+ if (algorithm === "gzip") {
17
+ zlib.gzip(content, { level: 9 }, (err, result) => {
18
+ if (err) reject(err);
19
+ else resolve(result);
20
+ });
21
+ } else if (algorithm === "brotli") {
22
+ zlib.brotliCompress(
23
+ content,
24
+ {
25
+ params: {
26
+ [zlib.constants.BROTLI_PARAM_QUALITY]:
27
+ zlib.constants.BROTLI_MAX_QUALITY,
28
+ },
29
+ },
30
+ (err, result) => {
31
+ if (err) reject(err);
32
+ else resolve(result);
33
+ }
34
+ );
35
+ } else {
36
+ resolve(content);
37
+ }
38
+ });
39
+ }
40
+
41
+ /**
42
+ * Check if a MIME type is worth compressing.
43
+ * Binary formats like PNG, JPEG, WOFF2 are already compressed.
44
+ */
45
+ function isCompressible(mime) {
46
+ return (
47
+ mime.startsWith("text/") ||
48
+ mime.includes("javascript") ||
49
+ mime.includes("json") ||
50
+ mime.includes("xml") ||
51
+ mime.includes("svg")
52
+ );
53
+ }
54
+
55
+ module.exports = { compressContent, isCompressible };
@@ -3,6 +3,7 @@
3
3
  const fs = require("fs");
4
4
  const path = require("path");
5
5
  const { minifyContent } = require("./minify");
6
+ const { compressContent, isCompressible } = require("./compress");
6
7
 
7
8
  /**
8
9
  * MIME type lookup for embedding.
@@ -73,10 +74,12 @@ function bufferToHexArray(buf) {
73
74
  *
74
75
  * @param {Array<{ filePath: string, servePath: string, ext: string }>} assets
75
76
  * @param {boolean} shouldMinify - Whether to minify text assets
77
+ * @param {string} compression - "none" | "gzip" | "brotli" | "both"
76
78
  * @returns {Promise<string>} - C source code
77
79
  */
78
- async function generateEmbeddedAssets(assets, shouldMinify) {
80
+ async function generateEmbeddedAssets(assets, shouldMinify, compression) {
79
81
  const lines = [];
82
+ const algo = compression || "none";
80
83
 
81
84
  lines.push("/* Auto-generated embedded assets — do not edit */");
82
85
  lines.push("");
@@ -94,7 +97,7 @@ async function generateEmbeddedAssets(assets, shouldMinify) {
94
97
  const name = varName(asset.servePath);
95
98
  const mime = mimeFromExt(asset.ext);
96
99
 
97
- /* Generate the byte array */
100
+ /* Generate the raw byte array */
98
101
  lines.push(`static const unsigned char ${name}[] = {`);
99
102
  lines.push(bufferToHexArray(content));
100
103
  lines.push("};");
@@ -103,14 +106,64 @@ async function generateEmbeddedAssets(assets, shouldMinify) {
103
106
  );
104
107
  lines.push("");
105
108
 
106
- assetEntries.push({ name, servePath: asset.servePath, mime });
109
+ /* Generate compressed variants if the content is compressible */
110
+ let gzName = "NULL";
111
+ let gzLen = "0";
112
+ let brName = "NULL";
113
+ let brLen = "0";
114
+
115
+ if (isCompressible(mime) && algo !== "none" && content.length > 256) {
116
+ if (algo === "gzip" || algo === "both") {
117
+ const gzData = await compressContent(content, "gzip");
118
+ /* Only embed if compression actually saves space */
119
+ if (gzData.length < content.length * 0.9) {
120
+ gzName = `${name}_gz`;
121
+ gzLen = `${name}_gz_len`;
122
+ lines.push(`static const unsigned char ${gzName}[] = {`);
123
+ lines.push(bufferToHexArray(gzData));
124
+ lines.push("};");
125
+ lines.push(
126
+ `static const unsigned int ${gzLen} = ${gzData.length};`
127
+ );
128
+ lines.push("");
129
+ }
130
+ }
131
+
132
+ if (algo === "brotli" || algo === "both") {
133
+ const brData = await compressContent(content, "brotli");
134
+ if (brData.length < content.length * 0.9) {
135
+ brName = `${name}_br`;
136
+ brLen = `${name}_br_len`;
137
+ lines.push(`static const unsigned char ${brName}[] = {`);
138
+ lines.push(bufferToHexArray(brData));
139
+ lines.push("};");
140
+ lines.push(
141
+ `static const unsigned int ${brLen} = ${brData.length};`
142
+ );
143
+ lines.push("");
144
+ }
145
+ }
146
+ }
147
+
148
+ assetEntries.push({
149
+ name,
150
+ servePath: asset.servePath,
151
+ mime,
152
+ gzName,
153
+ gzLen,
154
+ brName,
155
+ brLen,
156
+ });
107
157
  }
108
158
 
109
159
  /* Generate the asset table */
110
160
  lines.push("static cerver_asset_t cerver_embedded_assets[] = {");
111
161
  for (const entry of assetEntries) {
112
162
  lines.push(
113
- ` { "${entry.servePath}", "${entry.mime}", ${entry.name}, ${entry.name}_len },`
163
+ ` { "${entry.servePath}", "${entry.mime}", ` +
164
+ `${entry.name}, ${entry.name}_len, ` +
165
+ `${entry.gzName}, ${entry.gzLen}, ` +
166
+ `${entry.brName}, ${entry.brLen} },`
114
167
  );
115
168
  }
116
169
  lines.push("};");
@@ -78,9 +78,10 @@ async function build(opts) {
78
78
  );
79
79
  }
80
80
 
81
- assetsCode = await generateEmbeddedAssets(assets, config.minify);
81
+ assetsCode = await generateEmbeddedAssets(assets, config.minify, config.compression);
82
82
  console.log(
83
- ` ${assets.length} asset(s), ${(totalSize / 1024).toFixed(1)} KB total`
83
+ ` ${assets.length} asset(s), ${(totalSize / 1024).toFixed(1)} KB total` +
84
+ (config.compression !== "none" ? ` (${config.compression} compressed)` : "")
84
85
  );
85
86
  } else {
86
87
  console.log(" no assets found in public/");
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { spawn } = require("child_process");
6
+ const chokidar = require("chokidar");
7
+ const { build } = require("./build");
8
+
9
+ /**
10
+ * Dev mode: watch for changes, auto-rebuild, auto-restart.
11
+ */
12
+ async function dev(opts) {
13
+ const projectDir = process.cwd();
14
+ const binaryPath = path.join(projectDir, "dist", "server");
15
+
16
+ let serverProcess = null;
17
+ let building = false;
18
+ let pendingRebuild = false;
19
+
20
+ const port = opts.port || null;
21
+
22
+ /* ---- Initial build ---- */
23
+ async function rebuild() {
24
+ if (building) {
25
+ pendingRebuild = true;
26
+ return;
27
+ }
28
+
29
+ building = true;
30
+
31
+ /* Kill existing server */
32
+ if (serverProcess) {
33
+ console.log("\n ↻ restarting...\n");
34
+ serverProcess.kill("SIGTERM");
35
+ serverProcess = null;
36
+ /* Small delay for port release */
37
+ await new Promise((r) => setTimeout(r, 200));
38
+ }
39
+
40
+ try {
41
+ await build({
42
+ embed: opts.embed !== undefined ? opts.embed : true,
43
+ minify: false, /* Skip minification in dev for speed */
44
+ static: false,
45
+ });
46
+
47
+ /* Start the server */
48
+ startServer();
49
+ } catch (err) {
50
+ console.error(`\n ✗ build failed: ${err.message}\n`);
51
+ }
52
+
53
+ building = false;
54
+
55
+ /* If changes came in during build, rebuild again */
56
+ if (pendingRebuild) {
57
+ pendingRebuild = false;
58
+ await rebuild();
59
+ }
60
+ }
61
+
62
+ function startServer() {
63
+ if (!fs.existsSync(binaryPath)) return;
64
+
65
+ const env = { ...process.env };
66
+ if (port) env.CERVER_PORT = port;
67
+
68
+ serverProcess = spawn(binaryPath, [], {
69
+ stdio: "inherit",
70
+ env,
71
+ cwd: projectDir,
72
+ });
73
+
74
+ serverProcess.on("error", (err) => {
75
+ console.error(` ✗ server error: ${err.message}`);
76
+ serverProcess = null;
77
+ });
78
+
79
+ serverProcess.on("exit", (code, signal) => {
80
+ if (signal !== "SIGTERM" && signal !== "SIGINT") {
81
+ console.error(` ✗ server exited (code: ${code}, signal: ${signal})`);
82
+ }
83
+ serverProcess = null;
84
+ });
85
+ }
86
+
87
+ /* ---- Watch for changes ---- */
88
+ const watchPaths = [
89
+ path.join(projectDir, "app"),
90
+ path.join(projectDir, "public"),
91
+ path.join(projectDir, "cerver.config.js"),
92
+ ].filter((p) => fs.existsSync(p));
93
+
94
+ const watcher = chokidar.watch(watchPaths, {
95
+ ignored: [
96
+ /(^|[\/\\])\./, /* dotfiles */
97
+ /node_modules/,
98
+ /dist/,
99
+ ],
100
+ persistent: true,
101
+ ignoreInitial: true,
102
+ awaitWriteFinish: {
103
+ stabilityThreshold: 100,
104
+ pollInterval: 50,
105
+ },
106
+ });
107
+
108
+ /* Debounce: collect changes for 300ms before rebuilding */
109
+ let debounceTimer = null;
110
+
111
+ function scheduleRebuild(changedPath) {
112
+ const rel = path.relative(projectDir, changedPath);
113
+ console.log(` ⟐ changed: ${rel}`);
114
+
115
+ if (debounceTimer) clearTimeout(debounceTimer);
116
+ debounceTimer = setTimeout(() => {
117
+ debounceTimer = null;
118
+ rebuild();
119
+ }, 300);
120
+ }
121
+
122
+ watcher
123
+ .on("change", scheduleRebuild)
124
+ .on("add", scheduleRebuild)
125
+ .on("unlink", scheduleRebuild);
126
+
127
+ /* ---- Graceful shutdown ---- */
128
+ function shutdown() {
129
+ console.log("\n cerver dev: shutting down...");
130
+ watcher.close();
131
+ if (debounceTimer) clearTimeout(debounceTimer);
132
+ if (serverProcess) {
133
+ serverProcess.kill("SIGTERM");
134
+ }
135
+ process.exit(0);
136
+ }
137
+
138
+ process.on("SIGINT", shutdown);
139
+ process.on("SIGTERM", shutdown);
140
+
141
+ /* ---- Start ---- */
142
+ console.log("\n cerver dev — watching for changes\n");
143
+ await rebuild();
144
+ }
145
+
146
+ module.exports = { dev };
package/lib/config.js CHANGED
@@ -42,7 +42,7 @@ function loadConfig(projectDir) {
42
42
  if (typeof config.port !== "number" || config.port < 1 || config.port > 65535) {
43
43
  throw new Error(`cerver: invalid port ${config.port}`);
44
44
  }
45
- if (!["none", "gzip", "brotli"].includes(config.compression)) {
45
+ if (!["none", "gzip", "brotli", "both"].includes(config.compression)) {
46
46
  throw new Error(`cerver: unsupported compression "${config.compression}"`);
47
47
  }
48
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velox0/cerver",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Compile restricted JavaScript server logic into optimized native C binaries",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -18,11 +18,16 @@
18
18
  "binary",
19
19
  "framework"
20
20
  ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/velox0/cerver.git"
24
+ },
21
25
  "author": "Velox0",
22
26
  "license": "MIT",
23
27
  "dependencies": {
24
28
  "acorn": "^8.14.0",
25
29
  "acorn-walk": "^8.3.4",
30
+ "chokidar": "^3.6.0",
26
31
  "commander": "^13.1.0"
27
32
  }
28
33
  }
package/runtime/cerver.h CHANGED
@@ -125,6 +125,12 @@ typedef struct {
125
125
  const char *mime_type; /* e.g. "text/html" */
126
126
  const unsigned char *data;
127
127
  size_t data_len;
128
+
129
+ /* Pre-compressed variants (NULL if not available) */
130
+ const unsigned char *data_gz;
131
+ size_t data_gz_len;
132
+ const unsigned char *data_br;
133
+ size_t data_br_len;
128
134
  } cerver_asset_t;
129
135
 
130
136
  /* ------------------------------------------------------------------ */
package/runtime/router.c CHANGED
@@ -51,7 +51,7 @@ const char *cerver_req_header(const cerver_request_t *req, const char *key) {
51
51
  * Pattern segments starting with ':' are dynamic and extract values.
52
52
  *
53
53
  * Examples:
54
- * pattern="/art/:key" path="/art/sunset" → match, key="sunset"
54
+ * pattern="/items/:id" path="/items/123" → match, id="123"
55
55
  * pattern="/" path="/" → match
56
56
  * pattern="/api/data" path="/api/data" → match
57
57
  * pattern="/api/data" path="/api/other" → no match
package/runtime/static.c CHANGED
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * In embedded mode, serves from the compiled-in asset array.
5
5
  * In external mode, serves from the filesystem (public/ directory).
6
+ * Supports pre-compressed gzip/brotli variants and cache headers.
6
7
  */
7
8
 
8
9
  #include "cerver.h"
@@ -29,6 +30,40 @@ static int path_is_safe(const char *path) {
29
30
  return 1;
30
31
  }
31
32
 
33
+ /* ------------------------------------------------------------------ */
34
+ /* Accept-Encoding parsing */
35
+ /* ------------------------------------------------------------------ */
36
+
37
+ typedef struct {
38
+ int accepts_gzip;
39
+ int accepts_br;
40
+ } encoding_prefs_t;
41
+
42
+ static encoding_prefs_t parse_accept_encoding(const cerver_request_t *req) {
43
+ encoding_prefs_t prefs = { 0, 0 };
44
+ const char *ae = cerver_req_header(req, "Accept-Encoding");
45
+ if (!ae) return prefs;
46
+
47
+ if (strstr(ae, "br")) prefs.accepts_br = 1;
48
+ if (strstr(ae, "gzip")) prefs.accepts_gzip = 1;
49
+
50
+ return prefs;
51
+ }
52
+
53
+ /* ------------------------------------------------------------------ */
54
+ /* Cache header helper */
55
+ /* ------------------------------------------------------------------ */
56
+
57
+ static void add_cache_headers(cerver_response_t *res, const char *path) {
58
+ /* Hashed/versioned assets (in /static/) get long cache */
59
+ if (strstr(path, "/static/") || strstr(path, "/assets/")) {
60
+ cerver_res_header(res, "Cache-Control", "public, max-age=31536000, immutable");
61
+ } else {
62
+ /* HTML and other top-level files get short cache with revalidation */
63
+ cerver_res_header(res, "Cache-Control", "public, max-age=3600, must-revalidate");
64
+ }
65
+ }
66
+
32
67
  /* ------------------------------------------------------------------ */
33
68
  /* Serve from embedded assets */
34
69
  /* ------------------------------------------------------------------ */
@@ -38,33 +73,59 @@ static int serve_embedded(cerver_server_t *srv, cerver_request_t *req,
38
73
  if (!srv->assets || srv->asset_count == 0) return -1;
39
74
 
40
75
  const char *path = req->path;
76
+ const cerver_asset_t *found = NULL;
41
77
 
42
78
  /* Try exact match first */
43
79
  for (int i = 0; i < srv->asset_count; i++) {
44
80
  if (strcmp(srv->assets[i].path, path) == 0) {
45
- cerver_res_file(res, 200, srv->assets[i].mime_type,
46
- srv->assets[i].data, srv->assets[i].data_len);
47
- return 0;
81
+ found = &srv->assets[i];
82
+ break;
48
83
  }
49
84
  }
50
85
 
51
86
  /* Try with /index.html appended (for directory-like paths) */
52
- char index_path[CERVER_MAX_PATH];
53
- if (path[strlen(path) - 1] == '/') {
54
- snprintf(index_path, sizeof(index_path), "%sindex.html", path);
55
- } else {
56
- snprintf(index_path, sizeof(index_path), "%s/index.html", path);
57
- }
87
+ if (!found) {
88
+ char index_path[CERVER_MAX_PATH];
89
+ if (path[strlen(path) - 1] == '/') {
90
+ snprintf(index_path, sizeof(index_path), "%sindex.html", path);
91
+ } else {
92
+ snprintf(index_path, sizeof(index_path), "%s/index.html", path);
93
+ }
58
94
 
59
- for (int i = 0; i < srv->asset_count; i++) {
60
- if (strcmp(srv->assets[i].path, index_path) == 0) {
61
- cerver_res_file(res, 200, srv->assets[i].mime_type,
62
- srv->assets[i].data, srv->assets[i].data_len);
63
- return 0;
95
+ for (int i = 0; i < srv->asset_count; i++) {
96
+ if (strcmp(srv->assets[i].path, index_path) == 0) {
97
+ found = &srv->assets[i];
98
+ break;
99
+ }
64
100
  }
65
101
  }
66
102
 
67
- return -1;
103
+ if (!found) return -1;
104
+
105
+ /* Check for pre-compressed variants */
106
+ encoding_prefs_t enc = parse_accept_encoding(req);
107
+
108
+ if (enc.accepts_br && found->data_br && found->data_br_len > 0) {
109
+ /* Serve brotli */
110
+ cerver_res_file(res, 200, found->mime_type,
111
+ found->data_br, found->data_br_len);
112
+ cerver_res_header(res, "Content-Encoding", "br");
113
+ cerver_res_header(res, "Vary", "Accept-Encoding");
114
+ } else if (enc.accepts_gzip && found->data_gz && found->data_gz_len > 0) {
115
+ /* Serve gzip */
116
+ cerver_res_file(res, 200, found->mime_type,
117
+ found->data_gz, found->data_gz_len);
118
+ cerver_res_header(res, "Content-Encoding", "gzip");
119
+ cerver_res_header(res, "Vary", "Accept-Encoding");
120
+ } else {
121
+ /* Serve uncompressed */
122
+ cerver_res_file(res, 200, found->mime_type,
123
+ found->data, found->data_len);
124
+ }
125
+
126
+ add_cache_headers(res, found->path);
127
+
128
+ return 0;
68
129
  }
69
130
 
70
131
  /* ------------------------------------------------------------------ */
@@ -123,6 +184,8 @@ static int serve_filesystem(cerver_server_t *srv, cerver_request_t *req,
123
184
  res->body_len = file_size;
124
185
  res->_body_owned = 1; /* We malloc'd this */
125
186
 
187
+ add_cache_headers(res, path);
188
+
126
189
  return 0;
127
190
  }
128
191
 
package/test/run.js ADDED
@@ -0,0 +1,353 @@
1
+ "use strict";
2
+
3
+ const assert = require("assert/strict");
4
+ const fs = require("fs");
5
+ const os = require("os");
6
+ const path = require("path");
7
+ const zlib = require("zlib");
8
+
9
+ const { discoverAssets } = require("../lib/assets/discover");
10
+ const { compressContent, isCompressible } = require("../lib/assets/compress");
11
+ const { generateEmbeddedAssets, mimeFromExt, varName } = require("../lib/assets/embed");
12
+ const { minifyContent } = require("../lib/assets/minify");
13
+ const {
14
+ cString,
15
+ emitExpression,
16
+ emitStatement,
17
+ handlerName,
18
+ } = require("../lib/codegen/emit");
19
+ const { generateRouteTable } = require("../lib/codegen/route_table");
20
+ const { loadConfig } = require("../lib/config");
21
+ const IR = require("../lib/ir/types");
22
+ const { transformFile } = require("../lib/ir/transform");
23
+ const { discoverRoutes } = require("../lib/parser/discover");
24
+ const { parseSource } = require("../lib/parser/parse");
25
+ const { validate } = require("../lib/validator/validate");
26
+
27
+ const tests = [];
28
+
29
+ function test(name, fn) {
30
+ tests.push({ name, fn });
31
+ }
32
+
33
+ function tempDir() {
34
+ return fs.mkdtempSync(path.join(os.tmpdir(), "cerver-test-"));
35
+ }
36
+
37
+ function writeFile(filePath, content) {
38
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
39
+ fs.writeFileSync(filePath, content);
40
+ }
41
+
42
+ function cleanup(dir) {
43
+ fs.rmSync(dir, { recursive: true, force: true });
44
+ }
45
+
46
+ function parseAndValidate(source, filename = "route.js") {
47
+ const { ast } = parseSource(source, filename);
48
+ validate(ast, filename, source);
49
+ return ast;
50
+ }
51
+
52
+ test("discoverRoutes maps file routes and sorts dynamic routes last", () => {
53
+ const dir = tempDir();
54
+ try {
55
+ const routesDir = path.join(dir, "app", "routes");
56
+ writeFile(path.join(routesDir, "index.js"), "export function GET(req, res) {}");
57
+ writeFile(path.join(routesDir, "about.js"), "export function GET(req, res) {}");
58
+ writeFile(path.join(routesDir, "blog", "index.js"), "export function GET(req, res) {}");
59
+ writeFile(path.join(routesDir, "blog", "[slug].js"), "export function GET(req, res) {}");
60
+ writeFile(path.join(routesDir, "blog", "draft.txt"), "ignored");
61
+
62
+ const routes = discoverRoutes(routesDir).map((route) => ({
63
+ filePath: path.relative(routesDir, route.filePath),
64
+ urlPath: route.urlPath,
65
+ }));
66
+
67
+ assert.deepEqual(routes, [
68
+ { filePath: "index.js", urlPath: "/" },
69
+ { filePath: "about.js", urlPath: "/about" },
70
+ { filePath: path.join("blog", "index.js"), urlPath: "/blog" },
71
+ { filePath: path.join("blog", "[slug].js"), urlPath: "/blog/:slug" },
72
+ ]);
73
+ } finally {
74
+ cleanup(dir);
75
+ }
76
+ });
77
+
78
+ test("discoverRoutes returns an empty list when the routes directory is missing", () => {
79
+ const dir = tempDir();
80
+ try {
81
+ assert.deepEqual(discoverRoutes(path.join(dir, "missing")), []);
82
+ } finally {
83
+ cleanup(dir);
84
+ }
85
+ });
86
+
87
+ test("validate accepts supported handler syntax", () => {
88
+ const source = `
89
+ export function GET(req, res) {
90
+ const id = req.params.id;
91
+ if (id === "42") {
92
+ return res.json(200, '{"ok":true}');
93
+ }
94
+ return res.text(404, "missing");
95
+ }
96
+ `;
97
+
98
+ assert.doesNotThrow(() => parseAndValidate(source));
99
+ });
100
+
101
+ test("validate rejects unsupported HTTP methods and async handlers", () => {
102
+ const badMethod = parseSource(
103
+ "export function PUT(req, res) { return res.text(200, 'no'); }",
104
+ "bad-method.js"
105
+ );
106
+ assert.throws(
107
+ () => validate(badMethod.ast, "bad-method.js", badMethod.source),
108
+ /exported function "PUT" is not a valid HTTP method/
109
+ );
110
+
111
+ const asyncHandler = parseSource(
112
+ "export async function GET(req, res) { await work(); return res.text(200, 'ok'); }",
113
+ "async-route.js"
114
+ );
115
+ assert.throws(
116
+ () => validate(asyncHandler.ast, "async-route.js", asyncHandler.source),
117
+ /async functions are not supported[\s\S]*async\/await is not supported/
118
+ );
119
+ });
120
+
121
+ test("transformFile produces route IR for params, query, headers, and template returns", () => {
122
+ const source = `
123
+ export function GET(req, res) {
124
+ const userId = req.params.id;
125
+ if (req.query.preview === "true" && req.headers["x-mode"] !== "off") {
126
+ return res.html(200, \`<h1>\${userId}</h1>\`);
127
+ }
128
+ return res.text(404, "missing");
129
+ }
130
+ `;
131
+ const ast = parseAndValidate(source, "users.js");
132
+ const routes = transformFile(ast, "/users/:id");
133
+
134
+ assert.equal(routes.length, 1);
135
+ assert.equal(routes[0].method, "GET");
136
+ assert.equal(routes[0].urlPath, "/users/:id");
137
+ assert.deepEqual(routes[0].params, ["id"]);
138
+
139
+ const variable = routes[0].handler.variables[0];
140
+ assert.equal(variable.name, "userId");
141
+ assert.equal(variable.initExpr.type, "ParamAccess");
142
+ assert.equal(variable.initExpr.paramName, "id");
143
+
144
+ const [ifStmt, fallback] = routes[0].handler.body;
145
+ assert.equal(ifStmt.type, "If");
146
+ assert.equal(ifStmt.condition.type, "Logical");
147
+ assert.equal(ifStmt.condition.left.left.type, "QueryAccess");
148
+ assert.equal(ifStmt.condition.right.left.type, "HeaderAccess");
149
+ assert.equal(ifStmt.thenBody[0].responseType, "html");
150
+ assert.equal(ifStmt.thenBody[0].value.type, "Concat");
151
+ assert.equal(fallback.status, 404);
152
+ });
153
+
154
+ test("emit helpers escape C strings and map IR expressions", () => {
155
+ assert.equal(cString('a"b\\c\n'), '"a\\"b\\\\c\\n"');
156
+ assert.equal(handlerName("GET", "/"), "handle_GET_index");
157
+ assert.equal(handlerName("POST", "/users/:id"), "handle_POST_users_id");
158
+
159
+ const comparison = IR.IRComparison(
160
+ "===",
161
+ IR.IRParamAccess("id"),
162
+ IR.IRStringLiteral("42")
163
+ );
164
+ assert.equal(
165
+ emitExpression(comparison),
166
+ '(strcmp(cerver_req_param(req, "id"), "42") == 0)'
167
+ );
168
+
169
+ assert.deepEqual(
170
+ emitStatement(IR.IRReturn("text", 201, IR.IRStringLiteral("created")), 1),
171
+ [
172
+ ' cerver_res_text(res, 201, "created");',
173
+ " return;",
174
+ ]
175
+ );
176
+ });
177
+
178
+ test("generateRouteTable emits forward declarations, entries, and count", () => {
179
+ const routes = [
180
+ IR.IRRoute("GET", "/", [], IR.IRHandler([], [])),
181
+ IR.IRRoute("POST", "/users/:id", ["id"], IR.IRHandler([], [])),
182
+ ];
183
+
184
+ const code = generateRouteTable(routes);
185
+
186
+ assert.match(code, /static void handle_GET_index\(cerver_request_t \*req, cerver_response_t \*res\);/);
187
+ assert.match(code, /static cerver_route_t cerver_routes\[\] = \{/);
188
+ assert.match(code, /\{ "GET", "\/", handle_GET_index \},/);
189
+ assert.match(code, /\{ "POST", "\/users\/:id", handle_POST_users_id \},/);
190
+ assert.match(code, /static const int cerver_route_count = 2;/);
191
+ });
192
+
193
+ test("loadConfig merges defaults and supports export default configs", () => {
194
+ const dir = tempDir();
195
+ try {
196
+ assert.deepEqual(loadConfig(dir), {
197
+ port: 8080,
198
+ embed: true,
199
+ minify: true,
200
+ compression: "none",
201
+ });
202
+
203
+ writeFile(
204
+ path.join(dir, "cerver.config.js"),
205
+ 'export default { port: 3001, embed: false, minify: false, compression: "gzip" };\n'
206
+ );
207
+
208
+ assert.deepEqual(loadConfig(dir), {
209
+ port: 3001,
210
+ embed: false,
211
+ minify: false,
212
+ compression: "gzip",
213
+ });
214
+ } finally {
215
+ cleanup(dir);
216
+ }
217
+ });
218
+
219
+ test("loadConfig rejects invalid ports and compression values", () => {
220
+ const dir = tempDir();
221
+ try {
222
+ writeFile(path.join(dir, "cerver.config.js"), "module.exports = { port: 70000 };\n");
223
+ assert.throws(() => loadConfig(dir), /invalid port 70000/);
224
+
225
+ writeFile(path.join(dir, "cerver.config.js"), 'module.exports = { compression: "zip" };\n');
226
+ assert.throws(() => loadConfig(dir), /unsupported compression "zip"/);
227
+ } finally {
228
+ cleanup(dir);
229
+ }
230
+ });
231
+
232
+ test("discoverAssets maps public files and skips dotfiles", () => {
233
+ const dir = tempDir();
234
+ try {
235
+ const publicDir = path.join(dir, "public");
236
+ writeFile(path.join(publicDir, "index.html"), "<h1>Hello</h1>");
237
+ writeFile(path.join(publicDir, ".secret"), "ignore me");
238
+ writeFile(path.join(publicDir, "css", "app.css"), "body { color: red; }");
239
+
240
+ const assets = discoverAssets(publicDir)
241
+ .map((asset) => ({
242
+ servePath: asset.servePath,
243
+ ext: asset.ext,
244
+ size: asset.size,
245
+ }))
246
+ .sort((a, b) => a.servePath.localeCompare(b.servePath));
247
+
248
+ assert.deepEqual(assets, [
249
+ { servePath: "/css/app.css", ext: ".css", size: Buffer.byteLength("body { color: red; }") },
250
+ { servePath: "/index.html", ext: ".html", size: Buffer.byteLength("<h1>Hello</h1>") },
251
+ ]);
252
+ } finally {
253
+ cleanup(dir);
254
+ }
255
+ });
256
+
257
+ test("asset helpers generate stable names and MIME types", () => {
258
+ assert.equal(varName("/static/app.min.css"), "asset_static_app_min_css");
259
+ assert.equal(mimeFromExt(".json"), "application/json; charset=utf-8");
260
+ assert.equal(mimeFromExt(".unknown"), "application/octet-stream");
261
+ });
262
+
263
+ test("minifyContent minifies CSS content", async () => {
264
+ const source = Buffer.from("/* comment */\nbody { color: red; margin: 0; }\n", "utf8");
265
+ const minified = await minifyContent(source, ".css");
266
+ const text = minified.toString("utf8");
267
+
268
+ assert.ok(text.length < source.length);
269
+ assert.doesNotMatch(text, /comment/);
270
+ assert.match(text, /color:red/);
271
+ });
272
+
273
+ test("compression helpers identify compressible MIME types and round-trip gzip", async () => {
274
+ const source = Buffer.from("hello cerver ".repeat(80), "utf8");
275
+
276
+ assert.equal(isCompressible("text/css; charset=utf-8"), true);
277
+ assert.equal(isCompressible("application/json; charset=utf-8"), true);
278
+ assert.equal(isCompressible("image/png"), false);
279
+
280
+ const compressed = await compressContent(source, "gzip");
281
+ assert.ok(compressed.length < source.length);
282
+ assert.equal(zlib.gunzipSync(compressed).toString("utf8"), source.toString("utf8"));
283
+ });
284
+
285
+ test("generateEmbeddedAssets emits C arrays and asset table entries", async () => {
286
+ const dir = tempDir();
287
+ try {
288
+ const filePath = path.join(dir, "public", "css", "app.css");
289
+ const content = "body { color: red; }";
290
+ writeFile(filePath, content);
291
+
292
+ const code = await generateEmbeddedAssets(
293
+ [{ filePath, servePath: "/css/app.css", ext: ".css" }],
294
+ false
295
+ );
296
+
297
+ assert.match(code, /static const unsigned char asset_css_app_css\[\] = \{/);
298
+ assert.match(
299
+ code,
300
+ new RegExp(`static const unsigned int asset_css_app_css_len = ${Buffer.byteLength(content)};`)
301
+ );
302
+ assert.match(
303
+ code,
304
+ /\{ "\/css\/app\.css", "text\/css; charset=utf-8", asset_css_app_css, asset_css_app_css_len, .* \},/
305
+ );
306
+ assert.match(code, /static const int cerver_embedded_asset_count = 1;/);
307
+ } finally {
308
+ cleanup(dir);
309
+ }
310
+ });
311
+
312
+ test("generateEmbeddedAssets can emit gzip variants for compressible assets", async () => {
313
+ const dir = tempDir();
314
+ try {
315
+ const filePath = path.join(dir, "public", "css", "app.css");
316
+ const content = "body { color: red; }\n".repeat(80);
317
+ writeFile(filePath, content);
318
+
319
+ const code = await generateEmbeddedAssets(
320
+ [{ filePath, servePath: "/css/app.css", ext: ".css" }],
321
+ false,
322
+ "gzip"
323
+ );
324
+
325
+ assert.match(code, /static const unsigned char asset_css_app_css_gz\[\] = \{/);
326
+ assert.match(code, /static const unsigned int asset_css_app_css_gz_len = \d+;/);
327
+ assert.match(
328
+ code,
329
+ /\{ "\/css\/app\.css", "text\/css; charset=utf-8", asset_css_app_css, asset_css_app_css_len, asset_css_app_css_gz, asset_css_app_css_gz_len, NULL, 0 \},/
330
+ );
331
+ } finally {
332
+ cleanup(dir);
333
+ }
334
+ });
335
+
336
+ (async () => {
337
+ let passed = 0;
338
+
339
+ for (const { name, fn } of tests) {
340
+ try {
341
+ await fn();
342
+ passed += 1;
343
+ console.log(`ok ${passed} - ${name}`);
344
+ } catch (err) {
345
+ console.error(`not ok ${passed + 1} - ${name}`);
346
+ console.error(err && err.stack ? err.stack : err);
347
+ process.exitCode = 1;
348
+ return;
349
+ }
350
+ }
351
+
352
+ console.log(`\n${passed} test(s) passed`);
353
+ })();