slicejs-web-framework 4.0.2 ā 4.1.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/LICENSE +21 -21
- package/README.md +97 -97
- package/Slice/Components/Structural/ContextManager/ContextManager.js +422 -422
- package/Slice/Components/Structural/ContextManager/ContextManagerDebugger.js +470 -470
- package/Slice/Components/Structural/Controller/Controller.js +1406 -1406
- package/Slice/Components/Structural/Controller/allowedValuesValidation.js +52 -52
- package/Slice/Components/Structural/Debugger/Debugger.html +72 -72
- package/Slice/Components/Structural/Debugger/Debugger.js +1505 -1505
- package/Slice/Components/Structural/EventManager/EventManager.js +651 -651
- package/Slice/Components/Structural/EventManager/EventManagerDebugger.js +985 -985
- package/Slice/Components/Structural/LeakInspector/LeakInspector.js +296 -296
- package/Slice/Components/Structural/Logger/Log.js +10 -10
- package/Slice/Components/Structural/Logger/LogViewer/LogViewer.js +662 -662
- package/Slice/Components/Structural/Logger/Logger.js +225 -225
- package/Slice/Components/Structural/Router/Router.js +820 -810
- package/Slice/Components/Structural/Router/applyRouteMetadata.js +39 -0
- package/Slice/Components/Structural/StylesManager/StylesManager.js +82 -82
- package/Slice/Components/Structural/StylesManager/ThemeManager/ThemeManager.js +175 -175
- package/Slice/Slice.js +711 -710
- package/api/framework/devDepsOptimizer.js +335 -335
- package/api/framework/express.js +3 -3
- package/api/framework/precompressedStatic.js +102 -93
- package/api/framework/publicEnvResolver.js +123 -123
- package/api/framework/securityMiddleware.js +197 -197
- package/api/framework/seo.js +387 -0
- package/api/framework/server.js +777 -410
- package/api/index.js +14 -4
- package/package.json +6 -2
- package/sliceConfig.schema.json +122 -122
- package/src/App/index.html +69 -69
- package/src/App/index.js +20 -20
- package/src/App/style.css +11 -11
- package/src/Components/AppComponents/AboutSection/AboutSection.css +9 -9
- package/src/Components/AppComponents/AboutSection/AboutSection.html +8 -8
- package/src/Components/AppComponents/AboutSection/AboutSection.js +12 -12
- package/src/Components/AppComponents/AppShell/AppShell.css +10 -10
- package/src/Components/AppComponents/AppShell/AppShell.html +4 -4
- package/src/Components/AppComponents/AppShell/AppShell.js +36 -36
- package/src/Components/AppComponents/HomeSection/HomeSection.css +20 -20
- package/src/Components/AppComponents/HomeSection/HomeSection.html +10 -10
- package/src/Components/AppComponents/HomeSection/HomeSection.js +19 -19
- package/src/Components/components.js +8 -8
- package/src/public/Styles/sliceStyles.css +34 -34
- package/src/public/Themes/Dark.css +42 -42
- package/src/public/Themes/Light.css +31 -31
- package/src/public/Themes/Slice.css +47 -47
- package/src/public/manifest.json +15 -15
- package/src/public/robots.txt +4 -4
- package/src/public/sitemap.xml +8 -8
- package/src/routes.js +9 -9
- package/src/sliceConfig.json +72 -72
- package/types/index.d.ts +219 -219
- package/.opencode/opencode.json +0 -15
- package/Slice/tests/build-bundled-component-without-category.test.js +0 -103
- package/Slice/tests/build-js-only-visual-components.test.js +0 -144
- package/Slice/tests/build-singleton.test.js +0 -244
- package/Slice/tests/bundle-v2-runtime-contract.test.js +0 -747
- package/Slice/tests/context-patch-use.test.js +0 -95
- package/Slice/tests/destroy-cascade.test.js +0 -215
- package/Slice/tests/event-emitters.test.js +0 -79
- package/Slice/tests/event-registry.test.js +0 -141
- package/Slice/tests/fixtures/real-runtime-loader.mjs +0 -21
- package/Slice/tests/leak-orphans.test.js +0 -131
- package/Slice/tests/props-allowed-values-validation.test.js +0 -119
- package/Slice/tests/public-env-runtime-accessors.test.js +0 -44
- package/Slice/tests/public-env-typed-accessors.test.js +0 -66
- package/Slice/tests/router-loading-finally.test.js +0 -68
- package/api/tests/dev-deps-optimizer.test.js +0 -137
- package/api/tests/dev-server-external.test.js +0 -56
- package/api/tests/precompressed-static.test.js +0 -119
- package/api/tests/public-env-resolver.test.js +0 -193
- package/tests/router-build-failure.test.js +0 -125
- package/tests/router-emit-ordering.test.js +0 -63
package/api/framework/server.js
CHANGED
|
@@ -1,411 +1,778 @@
|
|
|
1
|
-
// api/framework/server.js - Servidor Express encapsulado del framework
|
|
2
|
-
import express from 'express';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import fs from 'fs';
|
|
5
|
-
import compression from 'compression';
|
|
6
|
-
import {
|
|
7
|
-
securityMiddleware,
|
|
8
|
-
sliceFrameworkProtection,
|
|
9
|
-
suspiciousRequestLogger
|
|
10
|
-
} from './securityMiddleware.js';
|
|
11
|
-
import { createPublicEnvProvider } from './publicEnvResolver.js';
|
|
12
|
-
import { createDevDepsOptimizer } from './devDepsOptimizer.js';
|
|
13
|
-
import { createPrecompressedStatic } from './precompressedStatic.js';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
res.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
1
|
+
// api/framework/server.js - Servidor Express encapsulado del framework
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import compression from 'compression';
|
|
6
|
+
import {
|
|
7
|
+
securityMiddleware,
|
|
8
|
+
sliceFrameworkProtection,
|
|
9
|
+
suspiciousRequestLogger
|
|
10
|
+
} from './securityMiddleware.js';
|
|
11
|
+
import { createPublicEnvProvider } from './publicEnvResolver.js';
|
|
12
|
+
import { createDevDepsOptimizer } from './devDepsOptimizer.js';
|
|
13
|
+
import { createPrecompressedStatic } from './precompressedStatic.js';
|
|
14
|
+
import { pathToFileURL } from 'node:url';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import zlib from 'node:zlib';
|
|
17
|
+
import {
|
|
18
|
+
injectHead,
|
|
19
|
+
loadSeoManifest,
|
|
20
|
+
matchPathPattern,
|
|
21
|
+
normalizeOrigin,
|
|
22
|
+
pickRouteEntry,
|
|
23
|
+
renderHead,
|
|
24
|
+
resolveMetadata,
|
|
25
|
+
routePaths
|
|
26
|
+
} from './seo.js';
|
|
27
|
+
|
|
28
|
+
// āā Shared runtime context āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
29
|
+
// `createSliceServer` and the standalone `sliceFallback` must agree on where the
|
|
30
|
+
// app lives and which folder it serves, without one having to call the other.
|
|
31
|
+
|
|
32
|
+
function resolveRuntimeContext(options = {}) {
|
|
33
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
34
|
+
const runMode = options.mode || (process.env.NODE_ENV === 'production' ? 'production' : 'development');
|
|
35
|
+
const folderDeployed = runMode === 'production' ? 'dist' : 'src';
|
|
36
|
+
return { projectRoot, runMode, folderDeployed };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Component URL prefixes, from sliceConfig. Used by the development carve-outs. */
|
|
40
|
+
function readComponentPrefixes(projectRoot) {
|
|
41
|
+
for (const folder of ['src', 'dist']) {
|
|
42
|
+
const file = path.join(projectRoot, folder, 'sliceConfig.json');
|
|
43
|
+
if (!fs.existsSync(file)) continue;
|
|
44
|
+
try {
|
|
45
|
+
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
46
|
+
return Object.values(config.paths?.components || {})
|
|
47
|
+
.map((entry) => entry?.path)
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
} catch {
|
|
50
|
+
// unreadable config: fall through to "no prefixes" (no carve-out, as before)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True for `/Components/<Category>/<Name>/<Name>.css` when the component's `.js`
|
|
58
|
+
* exists but it has no stylesheet. JS-only Visual components (MultiRoute,
|
|
59
|
+
* DemoButtonā¦) legitimately ship no CSS, and a 404 there makes `slice.build`
|
|
60
|
+
* return null in development.
|
|
61
|
+
*/
|
|
62
|
+
function isComponentWithoutStylesheet(pathname, projectRoot, folderDeployed, prefixes) {
|
|
63
|
+
if (!pathname.endsWith('.css') || pathname.includes('..')) return false;
|
|
64
|
+
if (!prefixes.some((prefix) => pathname.startsWith(`${prefix}/`))) return false;
|
|
65
|
+
const file = path.join(projectRoot, folderDeployed, pathname);
|
|
66
|
+
return !fs.existsSync(file) && fs.existsSync(file.replace(/\.css$/, '.js'));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The terminal middleware: everything the app did not answer lands here.
|
|
71
|
+
*
|
|
72
|
+
* Mounted by `start()` ā and exported so an app that never calls `start()` (a
|
|
73
|
+
* serverless adapter, a supertest harness) can close the chain by hand. It must
|
|
74
|
+
* be the last middleware; mounting it twice is harmless (the first one ends the
|
|
75
|
+
* response) but pointless, and `start()` skips it when it is already there.
|
|
76
|
+
*
|
|
77
|
+
* `options.seo` is the **prepared** context from `prepareSeo()`, not the config.
|
|
78
|
+
*/
|
|
79
|
+
export function sliceFallback(options = {}) {
|
|
80
|
+
const { projectRoot, runMode, folderDeployed } = resolveRuntimeContext(options);
|
|
81
|
+
const isDev = runMode === 'development';
|
|
82
|
+
const shellPath = path.join(projectRoot, folderDeployed, 'App', 'index.html');
|
|
83
|
+
const prefixes = options.componentPrefixes || readComponentPrefixes(projectRoot);
|
|
84
|
+
const seo = options.seo?.resolve ? options.seo : null;
|
|
85
|
+
|
|
86
|
+
const sendShell = (req, res) => {
|
|
87
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
88
|
+
res.sendFile(shellPath, (error) => {
|
|
89
|
+
if (!error) return;
|
|
90
|
+
if (res.headersSent) return;
|
|
91
|
+
console.error(`[SPA Fallback] Error sending ${shellPath}:`, error);
|
|
92
|
+
res.status(500).send('<h1>500 - Internal Server Error</h1><p>Could not serve application file</p>');
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Precomputed HTML, or the shell: same encoding dance either way. */
|
|
97
|
+
const sendRendered = (req, res, page, status) => {
|
|
98
|
+
res.status(status);
|
|
99
|
+
res.setHeader('Cache-Control', status === 200 ? 'no-cache' : 'no-store');
|
|
100
|
+
if (page.etag) {
|
|
101
|
+
res.setHeader('ETag', page.etag);
|
|
102
|
+
if (req.headers['if-none-match'] === page.etag) return res.status(304).end();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const accept = String(req.headers['accept-encoding'] || '');
|
|
106
|
+
let body = page.html;
|
|
107
|
+
if (page.br && /\bbr\b/.test(accept)) {
|
|
108
|
+
res.setHeader('Content-Encoding', 'br');
|
|
109
|
+
body = page.br;
|
|
110
|
+
} else if (page.gzip && /\bgzip\b/.test(accept)) {
|
|
111
|
+
res.setHeader('Content-Encoding', 'gzip');
|
|
112
|
+
body = page.gzip;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
116
|
+
res.setHeader('Vary', 'Accept-Encoding');
|
|
117
|
+
res.setHeader('Content-Length', String(body.length));
|
|
118
|
+
return res.end(body);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const fallback = (req, res, next) => {
|
|
122
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
|
|
123
|
+
|
|
124
|
+
const pathname = decodeURI(req.path).replace(/\/+$/, '') || '/';
|
|
125
|
+
|
|
126
|
+
// Unknown /api paths answer JSON, never the shell. This used to live in the
|
|
127
|
+
// factory, where it swallowed every API router the app mounted afterwards.
|
|
128
|
+
if (pathname === '/api' || pathname.startsWith('/api/')) {
|
|
129
|
+
return res.status(404).json({ error: 'API route not found', path: req.originalUrl });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isDev) {
|
|
133
|
+
// Development serves components unbundled: the framework fetches
|
|
134
|
+
// `<Component>.html` and `.css` over HTTP, and a JS-only component has
|
|
135
|
+
// neither. A 404 here makes `slice.build` return null and the page dies.
|
|
136
|
+
if (pathname.endsWith('.html')) return sendShell(req, res);
|
|
137
|
+
if (isComponentWithoutStylesheet(pathname, projectRoot, folderDeployed, prefixes)) {
|
|
138
|
+
res.status(200);
|
|
139
|
+
res.setHeader('Content-Type', 'text/css; charset=utf-8');
|
|
140
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
141
|
+
return res.end('/* component ships no stylesheet */');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// With SEO configured the route set is known, so this is also where the
|
|
146
|
+
// 200-vs-404 decision happens.
|
|
147
|
+
if (seo) {
|
|
148
|
+
const { status, page } = seo.resolve(pathname);
|
|
149
|
+
return sendRendered(req, res, page, status);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return sendShell(req, res);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
fallback.__sliceFallback = true;
|
|
156
|
+
return fallback;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const encodeSeoPage = (html) => {
|
|
160
|
+
const raw = Buffer.from(html, 'utf8');
|
|
161
|
+
return {
|
|
162
|
+
html: raw,
|
|
163
|
+
gzip: zlib.gzipSync(raw, { level: 9 }),
|
|
164
|
+
br: zlib.brotliCompressSync(raw, {
|
|
165
|
+
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 9 }
|
|
166
|
+
}),
|
|
167
|
+
etag: `W/"${createHash('sha1').update(raw).digest('base64url')}"`
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Resolve a manifest (or image manifest) path. A relative path is tried at the
|
|
173
|
+
* project root first (small apps keep `seo.json` there), then inside the
|
|
174
|
+
* deployed `public/` folder, which is where generated artifacts live.
|
|
175
|
+
*/
|
|
176
|
+
function resolveSeoFile(projectRoot, folderDeployed, file) {
|
|
177
|
+
const candidates = [
|
|
178
|
+
path.resolve(projectRoot, file),
|
|
179
|
+
path.join(projectRoot, folderDeployed, 'public', file),
|
|
180
|
+
path.join(projectRoot, folderDeployed, file)
|
|
181
|
+
];
|
|
182
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function loadRouteDefinitions(projectRoot, folderDeployed) {
|
|
186
|
+
const candidates = [
|
|
187
|
+
path.join(projectRoot, folderDeployed, 'routes.js'),
|
|
188
|
+
path.join(projectRoot, 'src', 'routes.js')
|
|
189
|
+
];
|
|
190
|
+
for (const candidate of candidates) {
|
|
191
|
+
if (!fs.existsSync(candidate)) continue;
|
|
192
|
+
const module = await import(pathToFileURL(candidate).href);
|
|
193
|
+
const routes = module.default ?? module.routes ?? [];
|
|
194
|
+
return (Array.isArray(routes) ? routes : []).map((route) => ({
|
|
195
|
+
path: String(route?.path ?? '').replace(/\/+$/, '') || '/',
|
|
196
|
+
metadata: route?.metadata || {}
|
|
197
|
+
})).filter((route) => route.path);
|
|
198
|
+
}
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Reads the manifest and `routes.js`, renders every literal route once and
|
|
204
|
+
* returns the resolver `sliceFallback` uses. Async because `routes.js` is an
|
|
205
|
+
* ESM import ā `start()` awaits it, so the first request never pays for it.
|
|
206
|
+
*
|
|
207
|
+
* `config` is the `seo` block from `createSliceServer({ seo })`: a JSON path (or
|
|
208
|
+
* an already-loaded object) plus an optional origin override.
|
|
209
|
+
*/
|
|
210
|
+
export async function prepareSeo(options = {}) {
|
|
211
|
+
const { projectRoot, runMode, folderDeployed } = resolveRuntimeContext(options);
|
|
212
|
+
const config = options.seo || {};
|
|
213
|
+
if (!config || (typeof config === 'object' && !config.manifest && !config.routes)) return null;
|
|
214
|
+
|
|
215
|
+
const manifest = typeof config.manifest === 'object'
|
|
216
|
+
? config.manifest
|
|
217
|
+
: loadSeoManifest(resolveSeoFile(projectRoot, folderDeployed, config.manifest || 'seo.json'));
|
|
218
|
+
if (!manifest) return null;
|
|
219
|
+
|
|
220
|
+
// Social cards are generated after the manifest (they need a browser), so the
|
|
221
|
+
// app points at that file instead of inlining 70 image paths. An entry that
|
|
222
|
+
// declares its own image always wins.
|
|
223
|
+
const imageManifest = typeof config.images === 'object'
|
|
224
|
+
? config.images
|
|
225
|
+
: config.images
|
|
226
|
+
? loadSeoManifest(resolveSeoFile(projectRoot, folderDeployed, config.images))
|
|
227
|
+
: null;
|
|
228
|
+
const cards = imageManifest?.images || {};
|
|
229
|
+
|
|
230
|
+
const port = options.port || process.env.PORT || 3001;
|
|
231
|
+
const origin = normalizeOrigin(config.origin)
|
|
232
|
+
|| normalizeOrigin(process.env.SLICE_ORIGIN)
|
|
233
|
+
|| normalizeOrigin(manifest.origin)
|
|
234
|
+
|| (runMode === 'development' ? `http://localhost:${port}` : null);
|
|
235
|
+
|
|
236
|
+
if (!origin) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
'seo: an absolute origin is required in production. Set seo.origin, SLICE_ORIGIN or seo.json origin.'
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const shellPath = path.join(projectRoot, folderDeployed, 'App', 'index.html');
|
|
243
|
+
const shell = fs.readFileSync(shellPath, 'utf8');
|
|
244
|
+
const siteName = config.siteName || manifest.defaults?.siteName || null;
|
|
245
|
+
const locale = config.locale || manifest.defaults?.locale || null;
|
|
246
|
+
const routes = await loadRouteDefinitions(projectRoot, folderDeployed);
|
|
247
|
+
|
|
248
|
+
const render = (pathname, routeMetadata) => {
|
|
249
|
+
const meta = resolveMetadata({ pathname, routeMetadata, manifest, origin });
|
|
250
|
+
|
|
251
|
+
// Precedence: an image declared on the route entry wins, then the generated
|
|
252
|
+
// card, then `defaults.image` (the generic banner). Comparing against the raw
|
|
253
|
+
// entry keeps the framework from guessing where the image came from.
|
|
254
|
+
const explicitImage = pickRouteEntry(pathname, manifest)?.entry?.image;
|
|
255
|
+
const card = cards[pathname];
|
|
256
|
+
if (card && !explicitImage && !routeMetadata?.image) {
|
|
257
|
+
meta.image = /^https?:\/\//i.test(card.path) ? card.path : `${origin}${card.path}`;
|
|
258
|
+
meta.imageWidth = card.width ?? null;
|
|
259
|
+
meta.imageHeight = card.height ?? null;
|
|
260
|
+
meta.imageType = card.type || null;
|
|
261
|
+
meta.imageAlt = meta.imageAlt || meta.title;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return encodeSeoPage(injectHead(shell, renderHead(meta, { siteName, locale })));
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const pages = new Map();
|
|
268
|
+
const patterns = [];
|
|
269
|
+
for (const route of routes) {
|
|
270
|
+
if (/\$\{[^}]+\}/.test(route.path)) {
|
|
271
|
+
patterns.push(route);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
pages.set(route.path, render(route.path, route.metadata));
|
|
275
|
+
}
|
|
276
|
+
// Longest pattern first so `/p/${id}/detail` wins over `/p/${id}`.
|
|
277
|
+
patterns.sort((a, b) => b.path.length - a.path.length);
|
|
278
|
+
|
|
279
|
+
const notFound = encodeSeoPage(injectHead(
|
|
280
|
+
shell,
|
|
281
|
+
'<title>Not found</title>\n <meta name="robots" content="noindex, nofollow" />'
|
|
282
|
+
));
|
|
283
|
+
|
|
284
|
+
// A `${param}` route cannot be enumerated, so it is rendered per path. The
|
|
285
|
+
// cache keeps repeated visits of the same product from recompressing.
|
|
286
|
+
const dynamicPages = new Map();
|
|
287
|
+
const DYNAMIC_LIMIT = 200;
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
origin,
|
|
291
|
+
manifest,
|
|
292
|
+
routes,
|
|
293
|
+
prerendered: pages.size,
|
|
294
|
+
resolve(pathname) {
|
|
295
|
+
const exact = pages.get(pathname);
|
|
296
|
+
if (exact) return { status: 200, page: exact };
|
|
297
|
+
|
|
298
|
+
const dynamic = dynamicPages.get(pathname);
|
|
299
|
+
if (dynamic) return { status: 200, page: dynamic };
|
|
300
|
+
|
|
301
|
+
for (const route of patterns) {
|
|
302
|
+
if (!matchPathPattern(route.path, pathname)) continue;
|
|
303
|
+
const page = render(pathname, route.metadata);
|
|
304
|
+
if (dynamicPages.size >= DYNAMIC_LIMIT) {
|
|
305
|
+
dynamicPages.delete(dynamicPages.keys().next().value);
|
|
306
|
+
}
|
|
307
|
+
dynamicPages.set(pathname, page);
|
|
308
|
+
return { status: 200, page };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { status: 404, page: notFound };
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The error handler that has to sit after the fallback (Express only routes an
|
|
318
|
+
* error to middleware registered later in the stack). Exported for the same
|
|
319
|
+
* no-`start()` case as `sliceFallback`.
|
|
320
|
+
*/
|
|
321
|
+
export function sliceErrorHandler(options = {}) {
|
|
322
|
+
const { runMode } = resolveRuntimeContext(options);
|
|
323
|
+
return (error, req, res, next) => {
|
|
324
|
+
console.error('[Server Error]', error.stack || error.message || error);
|
|
325
|
+
if (res.headersSent) return next(error);
|
|
326
|
+
res.status(error.status || 500).json({
|
|
327
|
+
error: 'Internal Server Error',
|
|
328
|
+
message: runMode === 'production' ? 'An unexpected error occurred' : error.message
|
|
329
|
+
});
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function createSliceServer(options = {}) {
|
|
334
|
+
const { projectRoot, runMode, folderDeployed } = resolveRuntimeContext(options);
|
|
335
|
+
|
|
336
|
+
let sliceConfig = {};
|
|
337
|
+
try {
|
|
338
|
+
const configPath = path.join(projectRoot, 'src', 'sliceConfig.json');
|
|
339
|
+
if (fs.existsSync(configPath)) {
|
|
340
|
+
sliceConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
341
|
+
}
|
|
342
|
+
} catch {
|
|
343
|
+
// usa defaults si no se puede leer la config
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let frameworkVersion = '';
|
|
347
|
+
try {
|
|
348
|
+
const pkgPath = new URL('../../package.json', import.meta.url);
|
|
349
|
+
frameworkVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version;
|
|
350
|
+
} catch {
|
|
351
|
+
frameworkVersion = 'unknown';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const PORT = options.port ?? process.env.PORT ?? sliceConfig.server?.port ?? 3001;
|
|
355
|
+
|
|
356
|
+
const publicEnvProvider = createPublicEnvProvider({
|
|
357
|
+
mode: runMode,
|
|
358
|
+
envFilePath: path.join(projectRoot, '.env')
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const app = express();
|
|
362
|
+
|
|
363
|
+
// ==============================================
|
|
364
|
+
// MIDDLEWARES DE SEGURIDAD (APLICAR PRIMERO)
|
|
365
|
+
// ==============================================
|
|
366
|
+
|
|
367
|
+
// 1. Logger de peticiones sospechosas (solo observación, no bloquea)
|
|
368
|
+
app.use(suspiciousRequestLogger());
|
|
369
|
+
|
|
370
|
+
// 2. Protección del framework - TOTALMENTE AUTOMĆTICA
|
|
371
|
+
app.use(sliceFrameworkProtection({ port: PORT }));
|
|
372
|
+
|
|
373
|
+
app.use(securityMiddleware());
|
|
374
|
+
|
|
375
|
+
// ==============================================
|
|
376
|
+
// MIDDLEWARES DE APLICACIĆN
|
|
377
|
+
// ==============================================
|
|
378
|
+
|
|
379
|
+
// Compresión gzip/brotli para respuestas (threshold 0 = comprimir todo)
|
|
380
|
+
app.use(compression({ threshold: 0 }));
|
|
381
|
+
|
|
382
|
+
// Security headers bƔsicos
|
|
383
|
+
app.use((req, res, next) => {
|
|
384
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
385
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
386
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
387
|
+
next();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Middleware global para archivos JavaScript con MIME types correctos
|
|
391
|
+
app.use((req, res, next) => {
|
|
392
|
+
if (req.path.endsWith('.js') || req.path.endsWith('.mjs') || req.path.endsWith('.cjs')) {
|
|
393
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
394
|
+
}
|
|
395
|
+
next();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// Middleware para parsear JSON y formularios
|
|
399
|
+
app.use(express.json());
|
|
400
|
+
app.use(express.urlencoded({ extended: true }));
|
|
401
|
+
|
|
402
|
+
// Configurar headers de CORS
|
|
403
|
+
app.use((req, res, next) => {
|
|
404
|
+
res.header('Access-Control-Allow-Origin', '*');
|
|
405
|
+
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
406
|
+
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
|
|
407
|
+
|
|
408
|
+
if (req.method === 'OPTIONS') {
|
|
409
|
+
res.sendStatus(200);
|
|
410
|
+
} else {
|
|
411
|
+
next();
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// ==============================================
|
|
416
|
+
// RUNTIME MODE ENDPOINT
|
|
417
|
+
// ==============================================
|
|
418
|
+
|
|
419
|
+
app.get('/slice-env.json', (req, res) => {
|
|
420
|
+
const payload = publicEnvProvider.getPayload();
|
|
421
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
422
|
+
res.setHeader('Pragma', 'no-cache');
|
|
423
|
+
res.setHeader('Expires', '0');
|
|
424
|
+
res.json(payload);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// ==============================================
|
|
428
|
+
// PWA ā manifest + service worker (dev y prod)
|
|
429
|
+
// ==============================================
|
|
430
|
+
|
|
431
|
+
// Resolve a deployed asset preferring the public/ folder (the convention),
|
|
432
|
+
// falling back to the deploy root for pre-`public/` projects.
|
|
433
|
+
const resolveDeployedFile = (fileName) => {
|
|
434
|
+
const inPublic = path.join(projectRoot, folderDeployed, 'public', fileName);
|
|
435
|
+
return fs.existsSync(inPublic) ? inPublic : path.join(projectRoot, folderDeployed, fileName);
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const servePwaFile = (res, fileName, contentType, extraHeaders = {}) => {
|
|
439
|
+
const filePath = resolveDeployedFile(fileName);
|
|
440
|
+
try {
|
|
441
|
+
if (fs.existsSync(filePath)) {
|
|
442
|
+
res.setHeader('Content-Type', contentType);
|
|
443
|
+
for (const [key, value] of Object.entries(extraHeaders)) res.setHeader(key, value);
|
|
444
|
+
return res.send(fs.readFileSync(filePath, 'utf8'));
|
|
445
|
+
}
|
|
446
|
+
} catch (error) {
|
|
447
|
+
console.error(`Error reading ${fileName}:`, error);
|
|
448
|
+
return res.status(500).send(`Error reading ${fileName}`);
|
|
449
|
+
}
|
|
450
|
+
return res.status(404).send(`${fileName} not found`);
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
app.get('/service-worker.js', (req, res) => {
|
|
454
|
+
servePwaFile(res, 'service-worker.js', 'application/javascript; charset=utf-8', {
|
|
455
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
456
|
+
'Service-Worker-Allowed': '/',
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
app.get('/manifest.json', (req, res) => {
|
|
461
|
+
servePwaFile(res, 'manifest.json', 'application/manifest+json; charset=utf-8');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// ==============================================
|
|
465
|
+
// ARCHIVOS ESTĆTICOS (DESPUĆS DE SEGURIDAD)
|
|
466
|
+
// ==============================================
|
|
467
|
+
|
|
468
|
+
if (runMode === 'production') {
|
|
469
|
+
app.get('/Slice/Slice.js', (req, res) => {
|
|
470
|
+
// Prefer the self-contained copy emitted into dist by `slice build`: it is
|
|
471
|
+
// the only location guaranteed to ship inside a serverless function (Vercel
|
|
472
|
+
// bundles dist/** via includeFiles, but prunes node_modules that aren't
|
|
473
|
+
// statically imported ā and under pnpm the package sits behind a symlink).
|
|
474
|
+
// Fall back to node_modules for non-serverless production (`slice start`).
|
|
475
|
+
const distSlice = path.join(projectRoot, folderDeployed, 'Slice', 'Slice.js');
|
|
476
|
+
const nodeModulesSlice = path.join(projectRoot, 'node_modules', 'slicejs-web-framework', 'Slice', 'Slice.js');
|
|
477
|
+
const slicePath = fs.existsSync(distSlice) ? distSlice : nodeModulesSlice;
|
|
478
|
+
try {
|
|
479
|
+
if (fs.existsSync(slicePath)) {
|
|
480
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
481
|
+
return res.send(fs.readFileSync(slicePath, 'utf8'));
|
|
482
|
+
}
|
|
483
|
+
} catch (error) {
|
|
484
|
+
console.error(`Error reading Slice.js:`, error);
|
|
485
|
+
return res.status(500).send('Error loading framework');
|
|
486
|
+
}
|
|
487
|
+
return res.status(404).send('Slice.js not found');
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
app.use('/Slice', (req, res) => res.status(404).send('Not found'));
|
|
491
|
+
app.use('/Components', (req, res) => res.status(404).send('Not found'));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Bundles are unversioned build output: never cache them. Set before every
|
|
495
|
+
// handler that can answer a bundle (precompressed, the .js handler, static
|
|
496
|
+
// files and the /dist mirror) so the policy cannot depend on which one wins.
|
|
497
|
+
const noStoreBundlePaths = ['/bundles/', '/dist/bundles/'];
|
|
498
|
+
app.use(noStoreBundlePaths, (req, res, next) => {
|
|
499
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
500
|
+
res.setHeader('Pragma', 'no-cache');
|
|
501
|
+
res.setHeader('Expires', '0');
|
|
502
|
+
next();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// Serve precompressed .br/.gz siblings (from `slice build --compress`) when the
|
|
506
|
+
// client accepts them. Mounted before the bundle handler and static serves so
|
|
507
|
+
// it wins, and its Content-Encoding makes the runtime `compression` middleware
|
|
508
|
+
// skip re-compressing. Production only (dist holds the precompressed files).
|
|
509
|
+
if (runMode === 'production') {
|
|
510
|
+
const distRoot = path.join(projectRoot, folderDeployed);
|
|
511
|
+
app.use(createPrecompressedStatic([path.join(distRoot, 'public'), distRoot], {
|
|
512
|
+
noStorePrefixes: noStoreBundlePaths
|
|
513
|
+
}));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Middleware personalizado para archivos de bundles con MIME types correctos
|
|
517
|
+
app.use('/bundles/', (req, res, next) => {
|
|
518
|
+
if (req.path.endsWith('.js') || req.path.endsWith('.mjs')) {
|
|
519
|
+
const cleanedPath = req.path.replace(/^\//, '');
|
|
520
|
+
let filePath = path.join(projectRoot, folderDeployed, 'bundles', cleanedPath);
|
|
521
|
+
|
|
522
|
+
// Bundler v2 emits this import relative to /bundles even when the source
|
|
523
|
+
// file lives under Components/AppComponents/ComponentsPage. Without this,
|
|
524
|
+
// a production build 404s on a module it imported itself.
|
|
525
|
+
if (cleanedPath === 'documentationRoutes.generated.js' || cleanedPath === 'docsIndex.js') {
|
|
526
|
+
filePath = path.join(
|
|
527
|
+
projectRoot,
|
|
528
|
+
folderDeployed,
|
|
529
|
+
'Components',
|
|
530
|
+
'AppComponents',
|
|
531
|
+
'ComponentsPage',
|
|
532
|
+
cleanedPath
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
console.log(`š Processing bundle: ${req.path} -> ${filePath}`);
|
|
536
|
+
|
|
537
|
+
if (fs.existsSync(filePath)) {
|
|
538
|
+
try {
|
|
539
|
+
const fileContent = fs.readFileSync(filePath, 'utf8');
|
|
540
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
541
|
+
console.log(`ā
Serving bundle: ${req.path} (${fileContent.length} bytes, ${Buffer.byteLength(fileContent, 'utf8')} bytes UTF-8)`);
|
|
542
|
+
return res.send(fileContent);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
console.error(`Error reading bundle file:`, error);
|
|
545
|
+
return res.status(500).send('Error reading bundle file');
|
|
546
|
+
}
|
|
547
|
+
} else {
|
|
548
|
+
console.log(`ā Bundle file not found: ${filePath}`);
|
|
549
|
+
return res.status(404).send('Bundle file not found');
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
next();
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
app.use('/bundles/', express.static(path.join(projectRoot, folderDeployed, 'bundles')));
|
|
557
|
+
console.log(`š¦ Serving bundles from /${folderDeployed}/bundles`);
|
|
558
|
+
|
|
559
|
+
// Servir framework Slice.js (solo development)
|
|
560
|
+
if (runMode === 'development') {
|
|
561
|
+
app.use('/Slice/', express.static(path.join(projectRoot, 'node_modules', 'slicejs-web-framework', 'Slice')));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (runMode === 'development') {
|
|
565
|
+
// External (node_modules) dependency support in dev ā always on, no app
|
|
566
|
+
// bundling. Rewrites bare imports in served src modules to /@slice-modules/ā¦
|
|
567
|
+
// and serves each package pre-bundled on demand with esbuild (same resolver
|
|
568
|
+
// as the production build ā dev/prod parity for CommonJS and ESM packages).
|
|
569
|
+
const devDeps = createDevDepsOptimizer({ projectRoot });
|
|
570
|
+
|
|
571
|
+
if (devDeps.enabled) {
|
|
572
|
+
const srcRoot = path.join(projectRoot, folderDeployed);
|
|
573
|
+
|
|
574
|
+
// Serve a node_modules package pre-bundled as a single ESM module.
|
|
575
|
+
app.get(/^\/@slice-modules\/(.+)$/, async (req, res) => {
|
|
576
|
+
const spec = req.params[0];
|
|
577
|
+
try {
|
|
578
|
+
const { code } = await devDeps.bundlePackage(spec);
|
|
579
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
580
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
581
|
+
return res.send(code);
|
|
582
|
+
} catch (error) {
|
|
583
|
+
const message = `Failed to load external dependency "${spec}": ${error.message}`;
|
|
584
|
+
console.error(`[slice:dev] ${message}`);
|
|
585
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
586
|
+
return res
|
|
587
|
+
.status(502)
|
|
588
|
+
.send(`throw new Error(${JSON.stringify(message)});`);
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// Rewrite bare imports in served src JavaScript to the virtual endpoint.
|
|
593
|
+
app.use(async (req, res, next) => {
|
|
594
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
|
|
595
|
+
const reqPath = req.path;
|
|
596
|
+
if (!(reqPath.endsWith('.js') || reqPath.endsWith('.mjs'))) return next();
|
|
597
|
+
// Framework internals, generated bundles and the virtual endpoint are
|
|
598
|
+
// served elsewhere and must not be rewritten.
|
|
599
|
+
if (reqPath.startsWith('/@slice-modules/') || reqPath.startsWith('/bundles/') || reqPath.startsWith('/Slice/')) {
|
|
600
|
+
return next();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const filePath = path.join(srcRoot, decodeURIComponent(reqPath));
|
|
604
|
+
const normalized = path.normalize(filePath);
|
|
605
|
+
if (normalized !== srcRoot && !normalized.startsWith(srcRoot + path.sep)) {
|
|
606
|
+
return next(); // path traversal guard
|
|
607
|
+
}
|
|
608
|
+
if (!fs.existsSync(normalized) || !fs.statSync(normalized).isFile()) {
|
|
609
|
+
return next();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
const original = fs.readFileSync(normalized, 'utf8');
|
|
614
|
+
const rewritten = await devDeps.rewriteBareImports(original);
|
|
615
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
616
|
+
return res.send(rewritten);
|
|
617
|
+
} catch (error) {
|
|
618
|
+
console.error(`[slice:dev] Failed to rewrite ${reqPath}: ${error.message}`);
|
|
619
|
+
return next();
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Centralized public/ folder served at the root URL (Themes, Styles,
|
|
625
|
+
// imagesā¦). Mounted before the general src static so its files win.
|
|
626
|
+
app.use(express.static(path.join(projectRoot, folderDeployed, 'public')));
|
|
627
|
+
app.use(express.static(path.join(projectRoot, folderDeployed)));
|
|
628
|
+
} else {
|
|
629
|
+
// Serve the built public/ assets at the root URL.
|
|
630
|
+
app.use(express.static(path.join(projectRoot, folderDeployed, 'public')));
|
|
631
|
+
app.use('/App', express.static(path.join(projectRoot, folderDeployed, 'App')));
|
|
632
|
+
const serveStaticFile = (req, res, filePath, contentType, fileName) => {
|
|
633
|
+
try {
|
|
634
|
+
if (fs.existsSync(filePath)) {
|
|
635
|
+
res.setHeader('Content-Type', contentType);
|
|
636
|
+
return res.send(fs.readFileSync(filePath, 'utf8'));
|
|
637
|
+
}
|
|
638
|
+
} catch (error) {
|
|
639
|
+
console.error(`Error reading ${fileName}:`, error);
|
|
640
|
+
return res.status(500).send(`Error reading ${fileName}`);
|
|
641
|
+
}
|
|
642
|
+
return res.status(404).send(`${fileName} not found`);
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
app.get('/routes.js', (req, res) => {
|
|
646
|
+
const routesPath = path.join(projectRoot, folderDeployed, 'routes.js');
|
|
647
|
+
serveStaticFile(req, res, routesPath, 'application/javascript; charset=utf-8', 'routes.js');
|
|
648
|
+
});
|
|
649
|
+
app.get('/sliceConfig.json', (req, res) => {
|
|
650
|
+
const configPath = path.join(projectRoot, folderDeployed, 'sliceConfig.json');
|
|
651
|
+
serveStaticFile(req, res, configPath, 'application/json; charset=utf-8', 'sliceConfig.json');
|
|
652
|
+
});
|
|
653
|
+
app.use('/bundles/', express.static(path.join(projectRoot, folderDeployed, 'bundles')));
|
|
654
|
+
app.use('/dist/', express.static(path.join(projectRoot, 'dist')));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ==============================================
|
|
658
|
+
// RUTAS DE API
|
|
659
|
+
// ==============================================
|
|
660
|
+
|
|
661
|
+
// Ruta de ejemplo para API
|
|
662
|
+
app.get('/api/status', (req, res) => {
|
|
663
|
+
res.json({
|
|
664
|
+
status: 'ok',
|
|
665
|
+
mode: runMode,
|
|
666
|
+
folder: folderDeployed,
|
|
667
|
+
timestamp: new Date().toISOString(),
|
|
668
|
+
framework: 'Slice.js',
|
|
669
|
+
version: frameworkVersion,
|
|
670
|
+
security: {
|
|
671
|
+
enabled: true,
|
|
672
|
+
mode: 'automatic',
|
|
673
|
+
description: 'Zero-config security - works with any domain'
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
// ==============================================
|
|
679
|
+
// SEO: robots.txt & sitemap.xml
|
|
680
|
+
// ==============================================
|
|
681
|
+
|
|
682
|
+
// `sendFile` on a missing artifact routes to the error handler and answers
|
|
683
|
+
// 500; the files are optional, so a missing one is a 404.
|
|
684
|
+
const sendDeployedFile = (res, fileName) => {
|
|
685
|
+
const file = resolveDeployedFile(fileName);
|
|
686
|
+
if (!fs.existsSync(file)) return res.status(404).send(`${fileName} not found`);
|
|
687
|
+
return res.sendFile(file);
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
app.get('/robots.txt', (req, res) => sendDeployedFile(res, 'robots.txt'));
|
|
691
|
+
app.get('/sitemap.xml', (req, res) => sendDeployedFile(res, 'sitemap.xml'));
|
|
692
|
+
|
|
693
|
+
// ==============================================
|
|
694
|
+
// FIN DEL STACK ā el fallback y el error handler los monta start()
|
|
695
|
+
// ==============================================
|
|
696
|
+
// Nothing terminal is registered here on purpose: a catch-all placed inside
|
|
697
|
+
// the factory makes every `app.use()` the app adds afterwards dead code.
|
|
698
|
+
// `sliceFallback()` owns "nobody answered" (shell, dev carve-outs, /api 404).
|
|
699
|
+
|
|
700
|
+
let started = false;
|
|
701
|
+
|
|
702
|
+
const hasFallbackMounted = () => {
|
|
703
|
+
// Express 4 exposes `app._router`; Express 5 exposes `app.router` (and keeps
|
|
704
|
+
// `_router`). On 4 the public getter throws when accessed, so both are read
|
|
705
|
+
// defensively: worst case the answer is "not mounted" and the fallback is
|
|
706
|
+
// added once more, which is harmless (the first one ends the response).
|
|
707
|
+
let stack;
|
|
124
708
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
app.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const fileContent = fs.readFileSync(filePath, 'utf8');
|
|
196
|
-
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
197
|
-
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
198
|
-
res.setHeader('Pragma', 'no-cache');
|
|
199
|
-
res.setHeader('Expires', '0');
|
|
200
|
-
console.log(`ā
Serving bundle: ${req.path} (${fileContent.length} bytes, ${Buffer.byteLength(fileContent, 'utf8')} bytes UTF-8)`);
|
|
201
|
-
return res.send(fileContent);
|
|
202
|
-
} catch (error) {
|
|
203
|
-
console.error(`Error reading bundle file:`, error);
|
|
204
|
-
return res.status(500).send('Error reading bundle file');
|
|
205
|
-
}
|
|
206
|
-
} else {
|
|
207
|
-
console.log(`ā Bundle file not found: ${filePath}`);
|
|
208
|
-
return res.status(404).send('Bundle file not found');
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
next();
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
app.use('/bundles/', express.static(path.join(projectRoot, folderDeployed, 'bundles')));
|
|
216
|
-
console.log(`š¦ Serving bundles from /${folderDeployed}/bundles`);
|
|
217
|
-
|
|
218
|
-
// Servir framework Slice.js (solo development)
|
|
219
|
-
if (runMode === 'development') {
|
|
220
|
-
app.use('/Slice/', express.static(path.join(projectRoot, 'node_modules', 'slicejs-web-framework', 'Slice')));
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
if (runMode === 'development') {
|
|
224
|
-
// External (node_modules) dependency support in dev ā always on, no app
|
|
225
|
-
// bundling. Rewrites bare imports in served src modules to /@slice-modules/ā¦
|
|
226
|
-
// and serves each package pre-bundled on demand with esbuild (same resolver
|
|
227
|
-
// as the production build ā dev/prod parity for CommonJS and ESM packages).
|
|
228
|
-
const devDeps = createDevDepsOptimizer({ projectRoot });
|
|
229
|
-
|
|
230
|
-
if (devDeps.enabled) {
|
|
231
|
-
const srcRoot = path.join(projectRoot, folderDeployed);
|
|
232
|
-
|
|
233
|
-
// Serve a node_modules package pre-bundled as a single ESM module.
|
|
234
|
-
app.get(/^\/@slice-modules\/(.+)$/, async (req, res) => {
|
|
235
|
-
const spec = req.params[0];
|
|
236
|
-
try {
|
|
237
|
-
const { code } = await devDeps.bundlePackage(spec);
|
|
238
|
-
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
239
|
-
res.setHeader('Cache-Control', 'no-cache');
|
|
240
|
-
return res.send(code);
|
|
241
|
-
} catch (error) {
|
|
242
|
-
const message = `Failed to load external dependency "${spec}": ${error.message}`;
|
|
243
|
-
console.error(`[slice:dev] ${message}`);
|
|
244
|
-
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
245
|
-
return res
|
|
246
|
-
.status(502)
|
|
247
|
-
.send(`throw new Error(${JSON.stringify(message)});`);
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
// Rewrite bare imports in served src JavaScript to the virtual endpoint.
|
|
252
|
-
app.use(async (req, res, next) => {
|
|
253
|
-
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
|
|
254
|
-
const reqPath = req.path;
|
|
255
|
-
if (!(reqPath.endsWith('.js') || reqPath.endsWith('.mjs'))) return next();
|
|
256
|
-
// Framework internals, generated bundles and the virtual endpoint are
|
|
257
|
-
// served elsewhere and must not be rewritten.
|
|
258
|
-
if (reqPath.startsWith('/@slice-modules/') || reqPath.startsWith('/bundles/') || reqPath.startsWith('/Slice/')) {
|
|
259
|
-
return next();
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const filePath = path.join(srcRoot, decodeURIComponent(reqPath));
|
|
263
|
-
const normalized = path.normalize(filePath);
|
|
264
|
-
if (normalized !== srcRoot && !normalized.startsWith(srcRoot + path.sep)) {
|
|
265
|
-
return next(); // path traversal guard
|
|
266
|
-
}
|
|
267
|
-
if (!fs.existsSync(normalized) || !fs.statSync(normalized).isFile()) {
|
|
268
|
-
return next();
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
try {
|
|
272
|
-
const original = fs.readFileSync(normalized, 'utf8');
|
|
273
|
-
const rewritten = await devDeps.rewriteBareImports(original);
|
|
274
|
-
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
275
|
-
return res.send(rewritten);
|
|
276
|
-
} catch (error) {
|
|
277
|
-
console.error(`[slice:dev] Failed to rewrite ${reqPath}: ${error.message}`);
|
|
278
|
-
return next();
|
|
279
|
-
}
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Centralized public/ folder served at the root URL (Themes, Styles,
|
|
284
|
-
// imagesā¦). Mounted before the general src static so its files win.
|
|
285
|
-
app.use(express.static(path.join(projectRoot, folderDeployed, 'public')));
|
|
286
|
-
app.use(express.static(path.join(projectRoot, folderDeployed)));
|
|
287
|
-
} else {
|
|
288
|
-
// Serve the built public/ assets at the root URL.
|
|
289
|
-
app.use(express.static(path.join(projectRoot, folderDeployed, 'public')));
|
|
290
|
-
app.use('/App', express.static(path.join(projectRoot, folderDeployed, 'App')));
|
|
291
|
-
const serveStaticFile = (req, res, filePath, contentType, fileName) => {
|
|
292
|
-
try {
|
|
293
|
-
if (fs.existsSync(filePath)) {
|
|
294
|
-
res.setHeader('Content-Type', contentType);
|
|
295
|
-
return res.send(fs.readFileSync(filePath, 'utf8'));
|
|
296
|
-
}
|
|
297
|
-
} catch (error) {
|
|
298
|
-
console.error(`Error reading ${fileName}:`, error);
|
|
299
|
-
return res.status(500).send(`Error reading ${fileName}`);
|
|
300
|
-
}
|
|
301
|
-
return res.status(404).send(`${fileName} not found`);
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
app.get('/routes.js', (req, res) => {
|
|
305
|
-
const routesPath = path.join(projectRoot, folderDeployed, 'routes.js');
|
|
306
|
-
serveStaticFile(req, res, routesPath, 'application/javascript; charset=utf-8', 'routes.js');
|
|
307
|
-
});
|
|
308
|
-
app.get('/sliceConfig.json', (req, res) => {
|
|
309
|
-
const configPath = path.join(projectRoot, folderDeployed, 'sliceConfig.json');
|
|
310
|
-
serveStaticFile(req, res, configPath, 'application/json; charset=utf-8', 'sliceConfig.json');
|
|
311
|
-
});
|
|
312
|
-
app.use('/bundles/', express.static(path.join(projectRoot, folderDeployed, 'bundles')));
|
|
313
|
-
app.use('/dist/', express.static(path.join(projectRoot, 'dist')));
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// ==============================================
|
|
317
|
-
// RUTAS DE API
|
|
318
|
-
// ==============================================
|
|
319
|
-
|
|
320
|
-
// Ruta de ejemplo para API
|
|
321
|
-
app.get('/api/status', (req, res) => {
|
|
322
|
-
res.json({
|
|
323
|
-
status: 'ok',
|
|
324
|
-
mode: runMode,
|
|
325
|
-
folder: folderDeployed,
|
|
326
|
-
timestamp: new Date().toISOString(),
|
|
327
|
-
framework: 'Slice.js',
|
|
328
|
-
version: frameworkVersion,
|
|
329
|
-
security: {
|
|
330
|
-
enabled: true,
|
|
331
|
-
mode: 'automatic',
|
|
332
|
-
description: 'Zero-config security - works with any domain'
|
|
333
|
-
}
|
|
334
|
-
});
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
// ==============================================
|
|
338
|
-
// SEO: robots.txt & sitemap.xml
|
|
339
|
-
// ==============================================
|
|
340
|
-
|
|
341
|
-
app.get('/robots.txt', (req, res) => {
|
|
342
|
-
res.sendFile(resolveDeployedFile('robots.txt'));
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
app.get('/sitemap.xml', (req, res) => {
|
|
346
|
-
res.sendFile(resolveDeployedFile('sitemap.xml'));
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
// ==============================================
|
|
350
|
-
// API 404 ā las rutas /api/* irreconocibles devuelven JSON, no HTML
|
|
351
|
-
// ==============================================
|
|
352
|
-
|
|
353
|
-
app.use('/api', (req, res) => {
|
|
354
|
-
res.status(404).json({ error: 'API route not found', path: req.originalUrl });
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
// ==============================================
|
|
358
|
-
// SPA FALLBACK
|
|
359
|
-
// ==============================================
|
|
360
|
-
|
|
361
|
-
app.get('*', (req, res) => {
|
|
362
|
-
const indexPath = path.join(projectRoot, folderDeployed, 'App', 'index.html');
|
|
363
|
-
res.sendFile(indexPath, (err) => {
|
|
364
|
-
if (err) {
|
|
365
|
-
console.error(`[SPA Fallback] Error sending ${indexPath}:`, err);
|
|
366
|
-
res.status(500).send(`<h1>500 - Internal Server Error</h1><p>Could not serve application file</p>`);
|
|
367
|
-
}
|
|
368
|
-
});
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
// ==============================================
|
|
372
|
-
// ERROR HANDLER CENTRALIZADO
|
|
373
|
-
// ==============================================
|
|
374
|
-
|
|
375
|
-
app.use((err, req, res, next) => {
|
|
376
|
-
console.error('[Server Error]', err.stack || err.message || err);
|
|
377
|
-
if (res.headersSent) return next(err);
|
|
378
|
-
res.status(err.status || 500).json({
|
|
379
|
-
error: 'Internal Server Error',
|
|
380
|
-
message: process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : err.message
|
|
381
|
-
});
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
// ==============================================
|
|
385
|
-
// INICIO DEL SERVIDOR
|
|
386
|
-
// ==============================================
|
|
387
|
-
|
|
388
|
-
function startServer() {
|
|
389
|
-
const server = app.listen(PORT, () => {
|
|
390
|
-
console.log(`š Security middleware: active (zero-config, automatic)`);
|
|
391
|
-
console.log(`š Slice.js server running on port ${PORT}`);
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
process.on('SIGINT', () => {
|
|
395
|
-
console.log('\nš Slice server stopped');
|
|
396
|
-
process.exit(0);
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
process.on('SIGTERM', () => {
|
|
400
|
-
console.log('\nš Server terminated');
|
|
401
|
-
process.exit(0);
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
return server;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
return {
|
|
408
|
-
app,
|
|
409
|
-
start: startServer
|
|
410
|
-
};
|
|
411
|
-
}
|
|
709
|
+
stack = app._router?.stack || [];
|
|
710
|
+
} catch {
|
|
711
|
+
stack = [];
|
|
712
|
+
}
|
|
713
|
+
if (!stack.length) {
|
|
714
|
+
try {
|
|
715
|
+
stack = app.router?.stack || [];
|
|
716
|
+
} catch {
|
|
717
|
+
stack = [];
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return stack.some((layer) => layer?.handle?.__sliceFallback === true);
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Finishes the app and starts listening. Async because the SEO context is
|
|
725
|
+
* prepared (manifest read, `routes.js` imported, every literal route
|
|
726
|
+
* prerendered) before the first request can arrive.
|
|
727
|
+
* @param {{ port?: number, host?: string, signals?: boolean }} [startOptions]
|
|
728
|
+
* @returns {Promise<import('http').Server>}
|
|
729
|
+
*/
|
|
730
|
+
async function start(startOptions = {}) {
|
|
731
|
+
if (started) throw new Error('Slice server: start() was already called');
|
|
732
|
+
started = true;
|
|
733
|
+
|
|
734
|
+
const { port = PORT, host, signals = true } = startOptions;
|
|
735
|
+
|
|
736
|
+
let seo = null;
|
|
737
|
+
if (options.seo) {
|
|
738
|
+
seo = await prepareSeo({ ...options, port });
|
|
739
|
+
if (seo) {
|
|
740
|
+
console.log(`š SEO: ${seo.prerendered} routes prerendered (${seo.origin})`);
|
|
741
|
+
} else {
|
|
742
|
+
console.warn('[slice] seo options were given but no manifest was found; SEO is off.');
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (!hasFallbackMounted()) {
|
|
747
|
+
app.use(sliceFallback({ ...options, seo }));
|
|
748
|
+
app.use(sliceErrorHandler(options));
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (port !== 0 && Number(port) !== Number(PORT)) {
|
|
752
|
+
console.warn(
|
|
753
|
+
`[slice] starting on port ${port}, but the security middleware was configured for ${PORT}. ` +
|
|
754
|
+
'Pass `port` to createSliceServer() as well, or set PORT, if the local app is refused.'
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const server = app.listen(port, host, () => {
|
|
759
|
+
console.log('š Security middleware: active (zero-config, automatic)');
|
|
760
|
+
console.log(`š Slice.js server running on port ${server.address()?.port ?? port}`);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
if (signals) {
|
|
764
|
+
const shutdown = (label) => () => {
|
|
765
|
+
console.log(label);
|
|
766
|
+
const force = setTimeout(() => process.exit(0), 5000);
|
|
767
|
+
force.unref?.();
|
|
768
|
+
server.close(() => process.exit(0));
|
|
769
|
+
};
|
|
770
|
+
process.once('SIGINT', shutdown('\nš Slice server stopped'));
|
|
771
|
+
process.once('SIGTERM', shutdown('\nš Server terminated'));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return server;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
return { app, start };
|
|
778
|
+
}
|