@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.
- package/.github/workflows/publish.yml +48 -0
- package/README.md +28 -17
- package/bin/cerver.js +10 -0
- package/lib/assets/compress.js +55 -0
- package/lib/assets/embed.js +57 -4
- package/lib/commands/build.js +3 -2
- package/lib/commands/dev.js +146 -0
- package/lib/config.js +1 -1
- package/package.json +6 -1
- package/runtime/cerver.h +6 -0
- package/runtime/router.c +1 -1
- package/runtime/static.c +78 -15
- package/test/run.js +353 -0
|
@@ -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., `/
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm i @velox0/cerver@latest
|
|
21
|
+
```
|
|
21
22
|
|
|
22
23
|
2. Create a new project:
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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,
|
|
98
|
-
embed: true,
|
|
99
|
-
minify: true,
|
|
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 };
|
package/lib/assets/embed.js
CHANGED
|
@@ -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
|
-
|
|
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}",
|
|
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("};");
|
package/lib/commands/build.js
CHANGED
|
@@ -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.
|
|
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="/
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
})();
|