@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 ADDED
@@ -0,0 +1,2 @@
1
+ export * from './src/ssr.js';
2
+ export * from './src/handler.js';
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, "&amp;")
14
+ .replace(/</g, "&lt;")
15
+ .replace(/>/g, "&gt;")
16
+ .replace(/"/g, "&quot;")
17
+ .replace(/'/g, "&#039;");
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
+ }