slicejs-web-framework 4.0.1 → 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.
Files changed (29) hide show
  1. package/Slice/Components/Structural/Router/Router.js +27 -0
  2. package/Slice/Components/Structural/Router/applyRouteMetadata.js +39 -0
  3. package/Slice/Components/Structural/StylesManager/ThemeManager/ThemeManager.js +15 -2
  4. package/Slice/Slice.js +31 -7
  5. package/api/framework/precompressedStatic.js +10 -1
  6. package/api/framework/seo.js +387 -0
  7. package/api/framework/server.js +432 -65
  8. package/api/index.js +17 -7
  9. package/package.json +53 -49
  10. package/.opencode/opencode.json +0 -15
  11. package/Slice/tests/build-bundled-component-without-category.test.js +0 -103
  12. package/Slice/tests/build-js-only-visual-components.test.js +0 -144
  13. package/Slice/tests/build-singleton.test.js +0 -244
  14. package/Slice/tests/bundle-v2-runtime-contract.test.js +0 -738
  15. package/Slice/tests/context-patch-use.test.js +0 -95
  16. package/Slice/tests/destroy-cascade.test.js +0 -215
  17. package/Slice/tests/event-emitters.test.js +0 -79
  18. package/Slice/tests/event-registry.test.js +0 -141
  19. package/Slice/tests/fixtures/real-runtime-loader.mjs +0 -21
  20. package/Slice/tests/leak-orphans.test.js +0 -131
  21. package/Slice/tests/props-allowed-values-validation.test.js +0 -119
  22. package/Slice/tests/public-env-runtime-accessors.test.js +0 -44
  23. package/Slice/tests/public-env-typed-accessors.test.js +0 -66
  24. package/Slice/tests/router-loading-finally.test.js +0 -68
  25. package/api/tests/dev-deps-optimizer.test.js +0 -137
  26. package/api/tests/dev-server-external.test.js +0 -56
  27. package/api/tests/precompressed-static.test.js +0 -119
  28. package/api/tests/public-env-resolver.test.js +0 -193
  29. package/tests/router-emit-ordering.test.js +0 -63
@@ -11,11 +11,327 @@ import {
11
11
  import { createPublicEnvProvider } from './publicEnvResolver.js';
12
12
  import { createDevDepsOptimizer } from './devDepsOptimizer.js';
13
13
  import { createPrecompressedStatic } from './precompressedStatic.js';
14
-
15
- export function createSliceServer(options = {}) {
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 = {}) {
16
33
  const projectRoot = options.projectRoot || process.cwd();
17
- const runMode = process.env.NODE_ENV === 'production' ? 'production' : 'development';
34
+ const runMode = options.mode || (process.env.NODE_ENV === 'production' ? 'production' : 'development');
18
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);
19
335
 
20
336
  let sliceConfig = {};
21
337
  try {
@@ -35,7 +351,7 @@ export function createSliceServer(options = {}) {
35
351
  frameworkVersion = 'unknown';
36
352
  }
37
353
 
38
- const PORT = process.env.PORT || sliceConfig.server?.port || 3001;
354
+ const PORT = options.port ?? process.env.PORT ?? sliceConfig.server?.port ?? 3001;
39
355
 
40
356
  const publicEnvProvider = createPublicEnvProvider({
41
357
  mode: runMode,
@@ -175,28 +491,53 @@ export function createSliceServer(options = {}) {
175
491
  app.use('/Components', (req, res) => res.status(404).send('Not found'));
176
492
  }
177
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
+
178
505
  // Serve precompressed .br/.gz siblings (from `slice build --compress`) when the
179
506
  // client accepts them. Mounted before the bundle handler and static serves so
180
507
  // it wins, and its Content-Encoding makes the runtime `compression` middleware
181
508
  // skip re-compressing. Production only (dist holds the precompressed files).
182
509
  if (runMode === 'production') {
183
510
  const distRoot = path.join(projectRoot, folderDeployed);
184
- app.use(createPrecompressedStatic([path.join(distRoot, 'public'), distRoot]));
511
+ app.use(createPrecompressedStatic([path.join(distRoot, 'public'), distRoot], {
512
+ noStorePrefixes: noStoreBundlePaths
513
+ }));
185
514
  }
186
515
 
187
516
  // Middleware personalizado para archivos de bundles con MIME types correctos
188
517
  app.use('/bundles/', (req, res, next) => {
189
- if (req.path.endsWith('.js')) {
190
- const filePath = path.join(projectRoot, folderDeployed, 'bundles', req.path);
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
+ }
191
535
  console.log(`📂 Processing bundle: ${req.path} -> ${filePath}`);
192
536
 
193
537
  if (fs.existsSync(filePath)) {
194
538
  try {
195
539
  const fileContent = fs.readFileSync(filePath, 'utf8');
196
540
  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
541
  console.log(`✅ Serving bundle: ${req.path} (${fileContent.length} bytes, ${Buffer.byteLength(fileContent, 'utf8')} bytes UTF-8)`);
201
542
  return res.send(fileContent);
202
543
  } catch (error) {
@@ -338,74 +679,100 @@ export function createSliceServer(options = {}) {
338
679
  // SEO: robots.txt & sitemap.xml
339
680
  // ==============================================
340
681
 
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
- // ==============================================
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
+ };
352
689
 
353
- app.use('/api', (req, res) => {
354
- res.status(404).json({ error: 'API route not found', path: req.originalUrl });
355
- });
690
+ app.get('/robots.txt', (req, res) => sendDeployedFile(res, 'robots.txt'));
691
+ app.get('/sitemap.xml', (req, res) => sendDeployedFile(res, 'sitemap.xml'));
356
692
 
357
693
  // ==============================================
358
- // SPA FALLBACK
694
+ // FIN DEL STACK — el fallback y el error handler los monta start()
359
695
  // ==============================================
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>`);
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;
708
+ try {
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 = [];
367
718
  }
368
- });
369
- });
370
-
371
- // ==============================================
372
- // ERROR HANDLER CENTRALIZADO
373
- // ==============================================
719
+ }
720
+ return stack.some((layer) => layer?.handle?.__sliceFallback === true);
721
+ };
374
722
 
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
- });
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
+ }
383
745
 
384
- // ==============================================
385
- // INICIO DEL SERVIDOR
386
- // ==============================================
746
+ if (!hasFallbackMounted()) {
747
+ app.use(sliceFallback({ ...options, seo }));
748
+ app.use(sliceErrorHandler(options));
749
+ }
387
750
 
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
- });
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
+ }
393
757
 
394
- process.on('SIGINT', () => {
395
- console.log('\n🛑 Slice server stopped');
396
- process.exit(0);
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}`);
397
761
  });
398
762
 
399
- process.on('SIGTERM', () => {
400
- console.log('\n🛑 Server terminated');
401
- process.exit(0);
402
- });
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
+ }
403
773
 
404
774
  return server;
405
775
  }
406
776
 
407
- return {
408
- app,
409
- start: startServer
410
- };
777
+ return { app, start };
411
778
  }
package/api/index.js CHANGED
@@ -1,7 +1,17 @@
1
- // api/index.js - Punto de entrada del servidor
2
- import { createSliceServer } from 'slicejs-web-framework/api/framework/server.js';
3
-
4
- const server = createSliceServer();
5
- server.start();
6
-
7
- export default server.app;
1
+ // api/index.js — server entrypoint.
2
+ //
3
+ // Everything you add goes between createSliceServer() and start(): ordinary
4
+ // Express middleware, an /api router, auth, request logging. start() mounts the
5
+ // terminal fallback (the only middleware that must be last), prepares SEO when
6
+ // it is configured, and then listens.
7
+ //
8
+ // const { app, start } = createSliceServer();
9
+ // app.use('/api/hooks', hooks);
10
+ // await start();
11
+ import { createSliceServer } from 'slicejs-web-framework/api/framework/server.js';
12
+
13
+ const { app, start } = createSliceServer();
14
+
15
+ await start();
16
+
17
+ export default app;