@zhinnx/server 2.0.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/index.js +2 -0
- package/package.json +20 -0
- package/src/handler.js +183 -0
- package/src/ssr.js +213 -0
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhinnx/server",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Server logic for zhinnx framework",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@zhinnx/core": "^2.0.0"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["zhinnx", "server", "ssr"],
|
|
19
|
+
"license": "MIT"
|
|
20
|
+
}
|
package/src/handler.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { renderPageStream } from './ssr.js';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
const ROOT_DIR = process.cwd();
|
|
9
|
+
|
|
10
|
+
const MIME_TYPES = {
|
|
11
|
+
'.html': 'text/html',
|
|
12
|
+
'.js': 'text/javascript',
|
|
13
|
+
'.css': 'text/css',
|
|
14
|
+
'.json': 'application/json',
|
|
15
|
+
'.png': 'image/png',
|
|
16
|
+
'.jpg': 'image/jpeg',
|
|
17
|
+
'.svg': 'image/svg+xml',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// --- File-Based Routing Logic ---
|
|
21
|
+
function scanRoutes(dir, baseRoute = '') {
|
|
22
|
+
const routes = {};
|
|
23
|
+
if (!fs.existsSync(dir)) return routes;
|
|
24
|
+
|
|
25
|
+
const items = fs.readdirSync(dir);
|
|
26
|
+
|
|
27
|
+
for (const item of items) {
|
|
28
|
+
const fullPath = path.join(dir, item);
|
|
29
|
+
const stat = fs.statSync(fullPath);
|
|
30
|
+
|
|
31
|
+
if (stat.isDirectory()) {
|
|
32
|
+
Object.assign(routes, scanRoutes(fullPath, `${baseRoute}/${item}`));
|
|
33
|
+
} else if (item.endsWith('.js')) {
|
|
34
|
+
const name = path.basename(item, '.js');
|
|
35
|
+
let routePath = baseRoute;
|
|
36
|
+
|
|
37
|
+
if (name === 'index') {
|
|
38
|
+
if (routePath === '') routePath = '/';
|
|
39
|
+
} else if (name.startsWith('[') && name.endsWith(']')) {
|
|
40
|
+
const paramName = name.slice(1, -1);
|
|
41
|
+
routePath = `${baseRoute}/:${paramName}`;
|
|
42
|
+
} else if (['layout', 'error', 'loading'].includes(name)) {
|
|
43
|
+
continue;
|
|
44
|
+
} else {
|
|
45
|
+
routePath = `${baseRoute}/${name.toLowerCase()}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const paramNames = [];
|
|
49
|
+
const regexPath = routePath.replace(/:([^/]+)/g, (_, key) => {
|
|
50
|
+
paramNames.push(key);
|
|
51
|
+
return '([^/]+)';
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const importPath = './' + path.relative(ROOT_DIR, fullPath).replace(/\\/g, '/');
|
|
55
|
+
|
|
56
|
+
routes[routePath] = {
|
|
57
|
+
path: routePath,
|
|
58
|
+
regex: `^${regexPath}$`,
|
|
59
|
+
params: paramNames,
|
|
60
|
+
importPath: importPath
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return routes;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const PAGES_DIR = path.join(ROOT_DIR, 'src', 'pages');
|
|
68
|
+
export const ROUTE_MAP = scanRoutes(PAGES_DIR);
|
|
69
|
+
|
|
70
|
+
export async function handleRequest(req, res) {
|
|
71
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
72
|
+
let pathname = url.pathname;
|
|
73
|
+
|
|
74
|
+
// API Route Handling
|
|
75
|
+
if (pathname.startsWith('/api/') && !pathname.endsWith('index.js')) {
|
|
76
|
+
const apiName = pathname.replace('/api/', '');
|
|
77
|
+
// In serverless, API might be handled differently, but here we dynamic import from api/ folder
|
|
78
|
+
const modulePath = path.join(ROOT_DIR, 'api', apiName + '.js');
|
|
79
|
+
|
|
80
|
+
if (fs.existsSync(modulePath)) {
|
|
81
|
+
try {
|
|
82
|
+
const module = await import(modulePath);
|
|
83
|
+
if (module.default) {
|
|
84
|
+
await module.default(req, res);
|
|
85
|
+
} else {
|
|
86
|
+
res.statusCode = 500;
|
|
87
|
+
res.end(JSON.stringify({ error: 'Invalid API Module' }));
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error('API Execution Error:', err);
|
|
91
|
+
res.statusCode = 500;
|
|
92
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
res.statusCode = 404;
|
|
96
|
+
res.end(JSON.stringify({ error: 'Endpoint not found' }));
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Static File Serving
|
|
102
|
+
if (path.extname(pathname)) {
|
|
103
|
+
if (pathname === '/server.js' || pathname === '/package.json' || pathname.startsWith('/verification') || pathname.startsWith('/.git')) {
|
|
104
|
+
res.statusCode = 403;
|
|
105
|
+
res.end('Forbidden');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const safePath = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, '');
|
|
110
|
+
let filePath = path.join(ROOT_DIR, safePath);
|
|
111
|
+
|
|
112
|
+
// Check if file exists in root, if not check public/
|
|
113
|
+
if (!fs.existsSync(filePath)) {
|
|
114
|
+
const publicPath = path.join(ROOT_DIR, 'public', safePath);
|
|
115
|
+
if (fs.existsSync(publicPath)) {
|
|
116
|
+
filePath = publicPath;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fs.readFile(filePath, (err, data) => {
|
|
121
|
+
if (err) {
|
|
122
|
+
if (err.code === 'ENOENT') {
|
|
123
|
+
res.statusCode = 404;
|
|
124
|
+
res.end('Not Found');
|
|
125
|
+
} else {
|
|
126
|
+
res.statusCode = 500;
|
|
127
|
+
res.end('Server Error');
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
const ext = path.extname(filePath);
|
|
131
|
+
res.setHeader('Content-Type', MIME_TYPES[ext] || 'text/plain');
|
|
132
|
+
res.end(data);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// SSR Logic
|
|
139
|
+
// Find matching route
|
|
140
|
+
let matchedRoute = null;
|
|
141
|
+
let params = {};
|
|
142
|
+
|
|
143
|
+
for (const route of Object.values(ROUTE_MAP)) {
|
|
144
|
+
const re = new RegExp(route.regex);
|
|
145
|
+
const match = pathname.match(re);
|
|
146
|
+
if (match) {
|
|
147
|
+
matchedRoute = route;
|
|
148
|
+
route.params.forEach((key, index) => {
|
|
149
|
+
params[key] = match[index + 1];
|
|
150
|
+
});
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (matchedRoute) {
|
|
156
|
+
try {
|
|
157
|
+
// Import relative to ROOT_DIR
|
|
158
|
+
const modulePath = path.join(ROOT_DIR, matchedRoute.importPath);
|
|
159
|
+
const module = await import(modulePath);
|
|
160
|
+
const PageComponent = module.default;
|
|
161
|
+
|
|
162
|
+
res.setHeader('Content-Type', 'text/html');
|
|
163
|
+
const stream = renderPageStream(
|
|
164
|
+
PageComponent,
|
|
165
|
+
{ params },
|
|
166
|
+
pathname,
|
|
167
|
+
{ routes: ROUTE_MAP }
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
stream.pipe(res);
|
|
171
|
+
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.error('SSR Error:', err);
|
|
174
|
+
if (!res.headersSent) {
|
|
175
|
+
res.statusCode = 500;
|
|
176
|
+
res.end('Internal Server Error');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
res.statusCode = 404;
|
|
181
|
+
res.end('<h1>404 - Page Not Found</h1>');
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/ssr.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Zhinnx SSR Engine
|
|
4
|
+
* Server-Side Rendering with Streaming Support
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Component } from '@zhinnx/core';
|
|
8
|
+
import { Readable } from 'stream';
|
|
9
|
+
|
|
10
|
+
function escapeHtml(text) {
|
|
11
|
+
if (text === undefined || text === null) return '';
|
|
12
|
+
return String(text)
|
|
13
|
+
.replace(/&/g, "&")
|
|
14
|
+
.replace(/</g, "<")
|
|
15
|
+
.replace(/>/g, ">")
|
|
16
|
+
.replace(/"/g, """)
|
|
17
|
+
.replace(/'/g, "'");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generator function that yields HTML chunks from a VNode or SSR Object.
|
|
22
|
+
*/
|
|
23
|
+
export function* renderToStream(vnode) {
|
|
24
|
+
if (vnode === null || vnode === undefined || vnode === false) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (Array.isArray(vnode)) {
|
|
29
|
+
for (const child of vnode) {
|
|
30
|
+
yield* renderToStream(child);
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Handle primitive values (strings, numbers)
|
|
36
|
+
if (typeof vnode === 'string' || typeof vnode === 'number') {
|
|
37
|
+
yield escapeHtml(String(vnode));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Handle Text VNodes
|
|
42
|
+
if (vnode.text !== undefined) {
|
|
43
|
+
yield escapeHtml(vnode.text);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Handle VNodes from h() factory (if used manually on server)
|
|
48
|
+
if (vnode.tag) {
|
|
49
|
+
yield `<${vnode.tag}`;
|
|
50
|
+
if (vnode.props) {
|
|
51
|
+
for (const key in vnode.props) {
|
|
52
|
+
const val = vnode.props[key];
|
|
53
|
+
if (key === 'key') continue;
|
|
54
|
+
if (key.startsWith('on')) continue; // Skip events
|
|
55
|
+
if (val === true) yield ` ${key}`;
|
|
56
|
+
else if (val !== false && val != null) yield ` ${key}="${escapeHtml(String(val))}"`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
yield `>`;
|
|
60
|
+
|
|
61
|
+
// Handle children
|
|
62
|
+
if (vnode.children) {
|
|
63
|
+
for (const child of vnode.children) {
|
|
64
|
+
if (child.text) yield escapeHtml(child.text);
|
|
65
|
+
else yield* renderToStream(child);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
yield `</${vnode.tag}>`;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Handle html`` tagged template results (SSR Object)
|
|
74
|
+
if (vnode && vnode.isSSR) {
|
|
75
|
+
for (let i = 0; i < vnode.strings.length; i++) {
|
|
76
|
+
yield vnode.strings[i];
|
|
77
|
+
if (i < vnode.values.length) {
|
|
78
|
+
const val = vnode.values[i];
|
|
79
|
+
yield* renderToStream(val);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Renders a VNode to string (for legacy support or small snippets).
|
|
88
|
+
*/
|
|
89
|
+
export function renderToString(vnode) {
|
|
90
|
+
let result = '';
|
|
91
|
+
for (const chunk of renderToStream(vnode)) {
|
|
92
|
+
result += chunk;
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Generates the full HTML page via Stream.
|
|
99
|
+
* @param {Component} PageComponent - The page component class or instance
|
|
100
|
+
* @param {Object} props - Initial props
|
|
101
|
+
* @param {string} url - Current URL
|
|
102
|
+
* @returns {Readable} - Node.js Readable Stream
|
|
103
|
+
*/
|
|
104
|
+
export function renderPageStream(PageComponent, props = {}, url = '/', injections = {}) {
|
|
105
|
+
// Instantiate Page to get metadata
|
|
106
|
+
const page = new PageComponent(props);
|
|
107
|
+
const meta = PageComponent.meta || {};
|
|
108
|
+
const title = meta.title || 'Zhinnx App';
|
|
109
|
+
const description = meta.description || 'Built with zhinnx';
|
|
110
|
+
const image = meta.image || '/zhinnx_nobg.png';
|
|
111
|
+
|
|
112
|
+
// We use an async iterator approach wrapped in a Readable
|
|
113
|
+
const iterator = (async function* () {
|
|
114
|
+
// Handle Injections (e.g. __ROUTES__)
|
|
115
|
+
let scripts = '';
|
|
116
|
+
if (injections.routes) {
|
|
117
|
+
scripts += `<script>window.__ROUTES__ = ${JSON.stringify(injections.routes)};</script>`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Chunk 1: Head (Push Immediately for TTFB)
|
|
121
|
+
yield `<!DOCTYPE html>
|
|
122
|
+
<html lang="en">
|
|
123
|
+
<head>
|
|
124
|
+
<meta charset="UTF-8">
|
|
125
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
126
|
+
|
|
127
|
+
<!-- Import Map for Bare Modules -->
|
|
128
|
+
<script type="importmap">
|
|
129
|
+
{
|
|
130
|
+
"imports": {
|
|
131
|
+
"@zhinnx/core": "/node_modules/@zhinnx/core/index.js",
|
|
132
|
+
"@zhinnx/server": "/node_modules/@zhinnx/server/index.js"
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
</script>
|
|
136
|
+
|
|
137
|
+
<title>${escapeHtml(title)}</title>
|
|
138
|
+
${scripts}
|
|
139
|
+
<meta name="description" content="${escapeHtml(description)}">
|
|
140
|
+
|
|
141
|
+
<!-- OpenGraph -->
|
|
142
|
+
<meta property="og:type" content="website">
|
|
143
|
+
<meta property="og:url" content="${url}">
|
|
144
|
+
<meta property="og:title" content="${escapeHtml(title)}">
|
|
145
|
+
<meta property="og:description" content="${escapeHtml(description)}">
|
|
146
|
+
<meta property="og:image" content="${image}">
|
|
147
|
+
|
|
148
|
+
<!-- Twitter -->
|
|
149
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
150
|
+
<meta name="twitter:title" content="${escapeHtml(title)}">
|
|
151
|
+
<meta name="twitter:description" content="${escapeHtml(description)}">
|
|
152
|
+
<meta name="twitter:image" content="${image}">
|
|
153
|
+
|
|
154
|
+
<!-- Preload Critical Assets -->
|
|
155
|
+
<link rel="modulepreload" href="/src/app.js">
|
|
156
|
+
|
|
157
|
+
<!-- Styles -->
|
|
158
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
159
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
|
160
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
|
|
161
|
+
<style>
|
|
162
|
+
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap');
|
|
163
|
+
html { scroll-behavior: smooth; }
|
|
164
|
+
body {
|
|
165
|
+
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
166
|
+
background-color: #ffffff;
|
|
167
|
+
color: #000000;
|
|
168
|
+
}
|
|
169
|
+
.comic-border { border: 2px solid #000000; }
|
|
170
|
+
.comic-border-t { border-top: 2px solid #000000; }
|
|
171
|
+
.comic-border-b { border-bottom: 2px solid #000000; }
|
|
172
|
+
.comic-border-r { border-right: 2px solid #000000; }
|
|
173
|
+
.comic-shadow { box-shadow: 4px 4px 0px 0px #000000; }
|
|
174
|
+
.comic-shadow-sm { box-shadow: 2px 2px 0px 0px #000000; }
|
|
175
|
+
.comic-shadow-hover:hover { box-shadow: 6px 6px 0px 0px #000000; transform: translate(-2px, -2px); }
|
|
176
|
+
.comic-shadow-active:active { box-shadow: 2px 2px 0px 0px #000000; transform: translate(2px, 2px); }
|
|
177
|
+
::selection { background-color: #000; color: #fff; }
|
|
178
|
+
</style>
|
|
179
|
+
|
|
180
|
+
<!-- JSON-LD -->
|
|
181
|
+
<script type="application/ld+json">
|
|
182
|
+
{
|
|
183
|
+
"@context": "https://schema.org",
|
|
184
|
+
"@type": "WebPage",
|
|
185
|
+
"name": "${escapeHtml(title)}",
|
|
186
|
+
"description": "${escapeHtml(description)}"
|
|
187
|
+
}
|
|
188
|
+
</script>
|
|
189
|
+
</head>
|
|
190
|
+
<body>
|
|
191
|
+
<div id="app">`;
|
|
192
|
+
|
|
193
|
+
// Chunk 2: App Content (Streaming)
|
|
194
|
+
try {
|
|
195
|
+
const appVNode = page.render();
|
|
196
|
+
// renderToStream is synchronous generator, but we wrap it in async generator for Readable.from
|
|
197
|
+
for (const chunk of renderToStream(appVNode)) {
|
|
198
|
+
yield chunk;
|
|
199
|
+
}
|
|
200
|
+
} catch (e) {
|
|
201
|
+
console.error('SSR Render Error:', e);
|
|
202
|
+
yield '<h1>Internal Server Error</h1>';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Chunk 3: Footer & Scripts
|
|
206
|
+
yield `</div>
|
|
207
|
+
<script type="module" src="/src/app.js"></script>
|
|
208
|
+
</body>
|
|
209
|
+
</html>`;
|
|
210
|
+
})();
|
|
211
|
+
|
|
212
|
+
return Readable.from(iterator);
|
|
213
|
+
}
|