slicejs-web-framework 3.5.4 → 4.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.
@@ -0,0 +1,335 @@
1
+ // api/framework/devDepsOptimizer.js
2
+ //
3
+ // Development-only resolution of bare package imports (node_modules) for the
4
+ // Slice.js dev server. There is NO app bundling in dev: component files are
5
+ // still served and executed as native ES modules. This optimizer only does two
6
+ // small, Vite-style things so `import dayjs from 'dayjs'` works in the browser:
7
+ //
8
+ // 1. rewriteBareImports(code): rewrites bare specifiers in a served source
9
+ // file to a virtual URL under `/@slice-modules/…` that the browser can
10
+ // fetch as a normal ES module. Relative ('./'), absolute ('/…', served
11
+ // from src/public/), and URL/data imports are left untouched.
12
+ //
13
+ // 2. bundlePackage(spec): on demand, pre-bundles a single node_modules
14
+ // package with esbuild into a self-contained ESM file (CommonJS interop +
15
+ // transitive third-party graph resolved by esbuild) and caches it. This is
16
+ // the same esbuild resolver the production build uses, so a dependency
17
+ // behaves identically in dev and in `slice build` / `slice start`.
18
+ //
19
+ // This module is the ONLY place in the framework runtime allowed to import
20
+ // esbuild / es-module-lexer (team rule: external libraries stay behind an
21
+ // internal abstraction).
22
+
23
+ import { init as lexerInit, parse as lexerParse } from 'es-module-lexer';
24
+ import { build as esbuildBuild } from 'esbuild';
25
+ import fsp from 'node:fs/promises';
26
+ import path from 'node:path';
27
+
28
+ export const MODULES_PREFIX = '/@slice-modules/';
29
+ const URL_SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
30
+
31
+ // Non-JS assets a package may import are inlined as data URLs so each served
32
+ // package module stays a single self-contained ESM file (matches the build).
33
+ const ASSET_LOADERS = {
34
+ '.wasm': 'dataurl',
35
+ '.png': 'dataurl',
36
+ '.jpg': 'dataurl',
37
+ '.jpeg': 'dataurl',
38
+ '.gif': 'dataurl',
39
+ '.svg': 'dataurl',
40
+ '.webp': 'dataurl',
41
+ '.avif': 'dataurl',
42
+ '.woff': 'dataurl',
43
+ '.woff2': 'dataurl',
44
+ '.ttf': 'dataurl',
45
+ '.eot': 'dataurl',
46
+ // Text assets some packages import as strings (GLSL shaders, templates).
47
+ // Kept identical to the production bundler so dev and build behave the same.
48
+ '.txt': 'text',
49
+ '.glsl': 'text',
50
+ '.vert': 'text',
51
+ '.frag': 'text',
52
+ '.vs': 'text',
53
+ '.fs': 'text'
54
+ };
55
+
56
+ /**
57
+ * Minimal browser shims for the Node globals browser-targeted packages still
58
+ * touch at runtime: `process` (env/platform/nextTick) and `global`. NOT a Node
59
+ * polyfill (no Buffer/stream/fs) — it only prevents a ReferenceError on a
60
+ * dynamic `process.platform` / `process.env[key]` / free `global`. Idempotent
61
+ * and guarded; kept byte-identical to the production bundler's banner so a
62
+ * package behaves the same in dev and after `slice build`.
63
+ * @param {string} nodeEnv value exposed as process.env.NODE_ENV at runtime.
64
+ */
65
+ function nodeGlobalsBanner(nodeEnv) {
66
+ return [
67
+ '(function(){',
68
+ ' if (typeof globalThis === "undefined") return;',
69
+ ' if (typeof globalThis.global === "undefined") globalThis.global = globalThis;',
70
+ ' if (typeof globalThis.process === "undefined") {',
71
+ ` globalThis.process = { env: { NODE_ENV: ${JSON.stringify(nodeEnv)} }, argv: [], platform: "browser", browser: true, version: "", versions: {}, cwd: function(){ return "/"; }, nextTick: function(cb){ Promise.resolve().then(cb); } };`,
72
+ ' } else if (typeof globalThis.process.env === "undefined") {',
73
+ ` globalThis.process.env = { NODE_ENV: ${JSON.stringify(nodeEnv)} };`,
74
+ ' }',
75
+ '})();'
76
+ ].join('\n');
77
+ }
78
+
79
+ function cssInjectionKey(css) {
80
+ let h = 5381;
81
+ for (let i = 0; i < css.length; i += 1) {
82
+ h = ((h << 5) + h + css.charCodeAt(i)) >>> 0;
83
+ }
84
+ return `ext-${h.toString(36)}`;
85
+ }
86
+
87
+ function buildCssInjectionSnippet(css) {
88
+ const key = cssInjectionKey(css);
89
+ return [
90
+ '(function(){',
91
+ ' if (typeof document === "undefined") return;',
92
+ ` var __k = ${JSON.stringify(key)};`,
93
+ " if (document.querySelector('style[data-slice-external=\"' + __k + '\"]')) return;",
94
+ ' var __sliceStyle = document.createElement("style");',
95
+ ' __sliceStyle.setAttribute("data-slice-external", __k);',
96
+ ` __sliceStyle.textContent = ${JSON.stringify(css)};`,
97
+ ' document.head.appendChild(__sliceStyle);',
98
+ '})();'
99
+ ].join('\n');
100
+ }
101
+
102
+ function combineEsbuildOutputs(outputFiles = []) {
103
+ const jsOut = outputFiles.find((f) => /\.(js|mjs|cjs)$/.test(f.path)) || outputFiles[0];
104
+ const cssOut = outputFiles.find((f) => f.path.endsWith('.css'));
105
+ let code = jsOut?.text || '';
106
+ if (cssOut?.text && cssOut.text.trim()) {
107
+ code += `\n${buildCssInjectionSnippet(cssOut.text)}`;
108
+ }
109
+ return code;
110
+ }
111
+
112
+ function isBareSpecifier(spec) {
113
+ if (typeof spec !== 'string' || spec.length === 0) return false;
114
+ if (spec.startsWith('.')) return false;
115
+ if (spec.startsWith('/')) return false;
116
+ if (spec.startsWith('#')) return false;
117
+ if (URL_SCHEME_RE.test(spec)) return false;
118
+ return true;
119
+ }
120
+
121
+ /**
122
+ * @param {object} options
123
+ * @param {string} options.projectRoot Root esbuild resolves node_modules from.
124
+ * @param {boolean} [options.enabled] Whether the feature is active.
125
+ * @param {string} [options.target] esbuild target (default 'es2020').
126
+ */
127
+ export function createDevDepsOptimizer({ projectRoot, enabled = true, target = 'es2020' }) {
128
+ // spec -> Promise<{ code }>. Store the promise so concurrent first requests
129
+ // for the same package share a single esbuild run.
130
+ const cache = new Map();
131
+ let lexerReady = false;
132
+ const lexerReadyPromise = lexerInit
133
+ .then(() => { lexerReady = true; })
134
+ .catch(() => { /* lexer failed to init; rewrite becomes a no-op */ });
135
+
136
+ /**
137
+ * Rewrites bare import specifiers in `code` to `/@slice-modules/<spec>`.
138
+ * Returns the original code unchanged when there is nothing to rewrite.
139
+ * @param {string} code
140
+ * @returns {Promise<string>}
141
+ */
142
+ async function rewriteBareImports(code) {
143
+ if (!enabled || typeof code !== 'string' || code.length === 0) return code;
144
+ if (!lexerReady) await lexerReadyPromise;
145
+ if (!lexerReady) return code;
146
+
147
+ let imports;
148
+ try {
149
+ [imports] = lexerParse(code);
150
+ } catch {
151
+ return code; // unparseable → serve as-is
152
+ }
153
+ if (!imports || imports.length === 0) return code;
154
+
155
+ let out = '';
156
+ let cursor = 0;
157
+ for (const imp of imports) {
158
+ const spec = imp.n;
159
+ // imp.s/imp.e delimit the specifier. For STATIC imports the range is the
160
+ // inner text without quotes; for DYNAMIC imports (imp.d > -1) it includes
161
+ // the surrounding quotes/backticks. imp.s is -1 when the specifier is not
162
+ // a static string (e.g. import(variable)) — skip those.
163
+ if (imp.s < 0 || imp.e < 0) continue;
164
+ if (!spec || !isBareSpecifier(spec)) continue;
165
+
166
+ out += code.slice(cursor, imp.s);
167
+ if (imp.d > -1) {
168
+ // Dynamic import: preserve the original quote character.
169
+ const quote = code[imp.s];
170
+ out += `${quote}${MODULES_PREFIX}${spec}${quote}`;
171
+ } else {
172
+ out += `${MODULES_PREFIX}${spec}`;
173
+ }
174
+ cursor = imp.e;
175
+ }
176
+ out += code.slice(cursor);
177
+ return out;
178
+ }
179
+
180
+ async function runEsbuild(entryContents) {
181
+ const result = await esbuildBuild({
182
+ stdin: {
183
+ contents: entryContents,
184
+ resolveDir: projectRoot,
185
+ sourcefile: 'slice-dev-external-entry.js',
186
+ loader: 'js'
187
+ },
188
+ bundle: true,
189
+ format: 'esm',
190
+ platform: 'browser',
191
+ target,
192
+ write: false,
193
+ metafile: false,
194
+ legalComments: 'none',
195
+ logLevel: 'silent',
196
+ // Standard browser-build substitutions (matches the production bundler):
197
+ // packages that gate on NODE_ENV or reference `global` work without a
198
+ // polyfill. esbuild respects local shadowing.
199
+ define: {
200
+ 'process.env.NODE_ENV': JSON.stringify('development'),
201
+ global: 'globalThis'
202
+ },
203
+ // Runtime shim for the Node globals `define` cannot fold statically; runs
204
+ // once, guarded, before the package body. Matches the production bundler.
205
+ banner: { js: nodeGlobalsBanner('development') },
206
+ // Required for esbuild to emit a CSS output file when a package imports
207
+ // CSS; nothing is written to disk (write:false).
208
+ outdir: 'slice-external-out',
209
+ // Inline WASM/images/fonts as data URLs and bundle imported CSS, so each
210
+ // served package is a single self-contained ESM file.
211
+ loader: ASSET_LOADERS
212
+ });
213
+ return combineEsbuildOutputs(result.outputFiles);
214
+ }
215
+
216
+ const diskCacheDir = path.join(projectRoot, 'node_modules', '.slice-deps');
217
+
218
+ async function esbuildPackage(spec) {
219
+ const quoted = JSON.stringify(spec);
220
+ // Try re-exporting the default too (works for CJS and ESM-with-default).
221
+ // Packages without a default export make esbuild fail on the explicit
222
+ // default re-export, so fall back to a namespace-only bundle.
223
+ try {
224
+ return await runEsbuild(`export * from ${quoted};\nexport { default } from ${quoted};`);
225
+ } catch {
226
+ return await runEsbuild(`export * from ${quoted};`);
227
+ }
228
+ }
229
+
230
+ /** Reads the installed version of a package so the disk cache invalidates on upgrade. */
231
+ async function packageVersion(spec) {
232
+ const root = spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0];
233
+ try {
234
+ const pkg = JSON.parse(await fsp.readFile(path.join(projectRoot, 'node_modules', root, 'package.json'), 'utf8'));
235
+ return pkg.version || '0.0.0';
236
+ } catch {
237
+ return '0.0.0';
238
+ }
239
+ }
240
+
241
+ /** Sanitized `<spec>@` prefix shared by every cached version of a package. */
242
+ function specCachePrefix(spec) {
243
+ return `${spec.replace(/[^a-zA-Z0-9._@-]/g, '_')}@`;
244
+ }
245
+
246
+ async function diskCacheFileFor(spec) {
247
+ const version = await packageVersion(spec);
248
+ const safe = `${spec}@${version}`.replace(/[^a-zA-Z0-9._@-]/g, '_');
249
+ return path.join(diskCacheDir, `${safe}.js`);
250
+ }
251
+
252
+ /**
253
+ * Removes stale cache files for a package after a new version is written, so
254
+ * `.slice-deps` doesn't accumulate one file per version across upgrades. The
255
+ * `<spec>@` prefix is version-independent and the trailing `@` prevents a
256
+ * package from matching another whose name is a prefix of it. Best-effort.
257
+ * @param {string} spec bare specifier
258
+ * @param {string} keepFileName basename of the just-written cache file
259
+ */
260
+ async function pruneOldCacheVersions(spec, keepFileName) {
261
+ const prefix = specCachePrefix(spec);
262
+ let entries;
263
+ try {
264
+ entries = await fsp.readdir(diskCacheDir);
265
+ } catch {
266
+ return; // dir missing → nothing to prune
267
+ }
268
+ await Promise.all(entries.map(async (name) => {
269
+ if (name === keepFileName) return;
270
+ if (!name.startsWith(prefix) || !name.endsWith('.js')) return;
271
+ try {
272
+ await fsp.unlink(path.join(diskCacheDir, name));
273
+ } catch { /* ignore: another process may have removed it */ }
274
+ }));
275
+ }
276
+
277
+ /**
278
+ * Pre-bundles a single node_modules package to a self-contained ESM string.
279
+ * Result is cached in memory (per process) and on disk (across dev-server
280
+ * restarts), keyed by the installed package version.
281
+ * @param {string} spec bare specifier (e.g. 'dayjs', '@scope/pkg', 'lodash/fp')
282
+ * @returns {Promise<{ code: string }>}
283
+ */
284
+ function bundlePackage(spec) {
285
+ if (!isBareSpecifier(spec)) {
286
+ return Promise.reject(new Error(`Not a bare package specifier: ${spec}`));
287
+ }
288
+ if (cache.has(spec)) return cache.get(spec);
289
+
290
+ const promise = (async () => {
291
+ const cacheFile = await diskCacheFileFor(spec);
292
+
293
+ // 1) Disk cache hit — skip esbuild entirely.
294
+ try {
295
+ const code = await fsp.readFile(cacheFile, 'utf8');
296
+ return { code };
297
+ } catch { /* miss → build */ }
298
+
299
+ // 2) Build, then populate the disk cache (best-effort) and drop any
300
+ // previously-cached versions of this same package.
301
+ const code = await esbuildPackage(spec);
302
+ try {
303
+ await fsp.mkdir(diskCacheDir, { recursive: true });
304
+ await fsp.writeFile(cacheFile, code);
305
+ await pruneOldCacheVersions(spec, path.basename(cacheFile));
306
+ } catch { /* cache is an optimization; ignore write failures */ }
307
+ return { code };
308
+ })();
309
+
310
+ // Cache the promise; on failure, evict so a later request can retry.
311
+ promise.catch(() => cache.delete(spec));
312
+ cache.set(spec, promise);
313
+ return promise;
314
+ }
315
+
316
+ /**
317
+ * Extracts the package specifier from a `/@slice-modules/…` request path.
318
+ * @param {string} reqPath
319
+ * @returns {string|null}
320
+ */
321
+ function specifierFromRequest(reqPath) {
322
+ if (typeof reqPath !== 'string' || !reqPath.startsWith(MODULES_PREFIX)) return null;
323
+ const spec = reqPath.slice(MODULES_PREFIX.length);
324
+ return spec.length > 0 ? spec : null;
325
+ }
326
+
327
+ return {
328
+ enabled,
329
+ MODULES_PREFIX,
330
+ rewriteBareImports,
331
+ bundlePackage,
332
+ specifierFromRequest,
333
+ isBareSpecifier
334
+ };
335
+ }
@@ -0,0 +1,93 @@
1
+ // api/framework/precompressedStatic.js
2
+ //
3
+ // Serves a precompressed `.br`/`.gz` sibling (produced by `slice build --compress`)
4
+ // when the client accepts that encoding and the file exists, instead of
5
+ // compressing on every request. Falls through to the normal static handlers
6
+ // when there is no precompressed variant or the client doesn't accept one.
7
+ //
8
+ // Mounted before the runtime `compression` middleware short-circuits it: the
9
+ // response ends here with Content-Encoding already set, so compression never
10
+ // re-compresses an already-encoded body.
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ const ENCODING_EXTENSION = { br: '.br', gzip: '.gz' };
16
+
17
+ // Only known text types are served pre-encoded; the Content-Type is taken from
18
+ // the ORIGINAL extension (the browser must not see `.br` as the type).
19
+ const CONTENT_TYPES = {
20
+ '.js': 'application/javascript; charset=utf-8',
21
+ '.mjs': 'application/javascript; charset=utf-8',
22
+ '.cjs': 'application/javascript; charset=utf-8',
23
+ '.css': 'text/css; charset=utf-8',
24
+ '.html': 'text/html; charset=utf-8',
25
+ '.htm': 'text/html; charset=utf-8',
26
+ '.json': 'application/json; charset=utf-8',
27
+ '.svg': 'image/svg+xml',
28
+ '.xml': 'application/xml; charset=utf-8',
29
+ '.txt': 'text/plain; charset=utf-8',
30
+ '.map': 'application/json; charset=utf-8',
31
+ '.webmanifest': 'application/manifest+json; charset=utf-8'
32
+ };
33
+
34
+ /** Encodings the client accepts, most-preferred first (brotli over gzip). */
35
+ function preferredEncodings(acceptEncoding) {
36
+ const accepts = String(acceptEncoding || '').toLowerCase();
37
+ const list = [];
38
+ if (accepts.includes('br')) list.push('br');
39
+ if (accepts.includes('gzip')) list.push('gzip');
40
+ return list;
41
+ }
42
+
43
+ /**
44
+ * @param {string[]} roots Absolute directories to resolve `req.path` against,
45
+ * in priority order (e.g. [dist/public, dist]).
46
+ * @returns {import('express').RequestHandler}
47
+ */
48
+ export function createPrecompressedStatic(roots = []) {
49
+ const resolvedRoots = roots.filter(Boolean).map((r) => path.resolve(r));
50
+
51
+ return function precompressedStatic(req, res, next) {
52
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next();
53
+
54
+ const encodings = preferredEncodings(req.headers['accept-encoding']);
55
+ if (encodings.length === 0) return next();
56
+
57
+ let rel;
58
+ try {
59
+ rel = decodeURIComponent(req.path).replace(/^\/+/, '');
60
+ } catch {
61
+ return next();
62
+ }
63
+ if (!rel || rel.includes('\0')) return next();
64
+
65
+ const ext = path.extname(rel).toLowerCase();
66
+ const contentType = CONTENT_TYPES[ext];
67
+ if (!contentType) return next();
68
+
69
+ for (const root of resolvedRoots) {
70
+ const target = path.normalize(path.join(root, rel));
71
+ // Path traversal guard: the resolved file must stay under the root.
72
+ if (target !== root && !target.startsWith(root + path.sep)) continue;
73
+
74
+ for (const enc of encodings) {
75
+ const encodedPath = target + ENCODING_EXTENSION[enc];
76
+ try {
77
+ if (fs.existsSync(encodedPath) && fs.statSync(encodedPath).isFile()) {
78
+ res.setHeader('Content-Type', contentType);
79
+ res.setHeader('Content-Encoding', enc);
80
+ res.setHeader('Vary', 'Accept-Encoding');
81
+ return res.send(fs.readFileSync(encodedPath));
82
+ }
83
+ } catch {
84
+ // Unreadable candidate — try the next encoding/root.
85
+ }
86
+ }
87
+ }
88
+
89
+ return next();
90
+ };
91
+ }
92
+
93
+ export default createPrecompressedStatic;
@@ -46,7 +46,7 @@ export function securityMiddleware(options = {}) {
46
46
  });
47
47
  }
48
48
 
49
- // 2. Permitir acceso a assets públicos (según sliceConfig.publicFolders)
49
+ // 2. Permitir acceso a assets públicos (servidos desde src/public/)
50
50
  if (allowPublicAssets) {
51
51
  const isPublicAsset = publicPaths.some(publicPath =>
52
52
  requestPath.startsWith(publicPath)
@@ -9,6 +9,8 @@ import {
9
9
  suspiciousRequestLogger
10
10
  } from './securityMiddleware.js';
11
11
  import { createPublicEnvProvider } from './publicEnvResolver.js';
12
+ import { createDevDepsOptimizer } from './devDepsOptimizer.js';
13
+ import { createPrecompressedStatic } from './precompressedStatic.js';
12
14
 
13
15
  export function createSliceServer(options = {}) {
14
16
  const projectRoot = options.projectRoot || process.cwd();
@@ -35,12 +37,6 @@ export function createSliceServer(options = {}) {
35
37
 
36
38
  const PORT = process.env.PORT || sliceConfig.server?.port || 3001;
37
39
 
38
- const normalizedPublicFolders = (Array.isArray(sliceConfig.publicFolders) ? sliceConfig.publicFolders : [])
39
- .filter((entry) => typeof entry === 'string')
40
- .map((entry) => entry.trim())
41
- .filter((entry) => entry.length > 0)
42
- .map((entry) => (entry.startsWith('/') ? entry : `/${entry}`));
43
-
44
40
  const publicEnvProvider = createPublicEnvProvider({
45
41
  mode: runMode,
46
42
  envFilePath: path.join(projectRoot, '.env')
@@ -58,9 +54,7 @@ export function createSliceServer(options = {}) {
58
54
  // 2. Protección del framework - TOTALMENTE AUTOMÁTICA
59
55
  app.use(sliceFrameworkProtection({ port: PORT }));
60
56
 
61
- app.use(securityMiddleware({
62
- publicPaths: normalizedPublicFolders
63
- }));
57
+ app.use(securityMiddleware());
64
58
 
65
59
  // ==============================================
66
60
  // MIDDLEWARES DE APLICACIÓN
@@ -118,8 +112,15 @@ export function createSliceServer(options = {}) {
118
112
  // PWA — manifest + service worker (dev y prod)
119
113
  // ==============================================
120
114
 
115
+ // Resolve a deployed asset preferring the public/ folder (the convention),
116
+ // falling back to the deploy root for pre-`public/` projects.
117
+ const resolveDeployedFile = (fileName) => {
118
+ const inPublic = path.join(projectRoot, folderDeployed, 'public', fileName);
119
+ return fs.existsSync(inPublic) ? inPublic : path.join(projectRoot, folderDeployed, fileName);
120
+ };
121
+
121
122
  const servePwaFile = (res, fileName, contentType, extraHeaders = {}) => {
122
- const filePath = path.join(projectRoot, folderDeployed, fileName);
123
+ const filePath = resolveDeployedFile(fileName);
123
124
  try {
124
125
  if (fs.existsSync(filePath)) {
125
126
  res.setHeader('Content-Type', contentType);
@@ -174,6 +175,15 @@ export function createSliceServer(options = {}) {
174
175
  app.use('/Components', (req, res) => res.status(404).send('Not found'));
175
176
  }
176
177
 
178
+ // Serve precompressed .br/.gz siblings (from `slice build --compress`) when the
179
+ // client accepts them. Mounted before the bundle handler and static serves so
180
+ // it wins, and its Content-Encoding makes the runtime `compression` middleware
181
+ // skip re-compressing. Production only (dist holds the precompressed files).
182
+ if (runMode === 'production') {
183
+ const distRoot = path.join(projectRoot, folderDeployed);
184
+ app.use(createPrecompressedStatic([path.join(distRoot, 'public'), distRoot]));
185
+ }
186
+
177
187
  // Middleware personalizado para archivos de bundles con MIME types correctos
178
188
  app.use('/bundles/', (req, res, next) => {
179
189
  if (req.path.endsWith('.js')) {
@@ -211,8 +221,72 @@ export function createSliceServer(options = {}) {
211
221
  }
212
222
 
213
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')));
214
286
  app.use(express.static(path.join(projectRoot, folderDeployed)));
215
287
  } else {
288
+ // Serve the built public/ assets at the root URL.
289
+ app.use(express.static(path.join(projectRoot, folderDeployed, 'public')));
216
290
  app.use('/App', express.static(path.join(projectRoot, folderDeployed, 'App')));
217
291
  const serveStaticFile = (req, res, filePath, contentType, fileName) => {
218
292
  try {
@@ -235,9 +309,6 @@ export function createSliceServer(options = {}) {
235
309
  const configPath = path.join(projectRoot, folderDeployed, 'sliceConfig.json');
236
310
  serveStaticFile(req, res, configPath, 'application/json; charset=utf-8', 'sliceConfig.json');
237
311
  });
238
- for (const folder of normalizedPublicFolders) {
239
- app.use(folder, express.static(path.join(projectRoot, folderDeployed, folder)));
240
- }
241
312
  app.use('/bundles/', express.static(path.join(projectRoot, folderDeployed, 'bundles')));
242
313
  app.use('/dist/', express.static(path.join(projectRoot, 'dist')));
243
314
  }
@@ -268,11 +339,11 @@ export function createSliceServer(options = {}) {
268
339
  // ==============================================
269
340
 
270
341
  app.get('/robots.txt', (req, res) => {
271
- res.sendFile(path.join(projectRoot, folderDeployed, 'robots.txt'));
342
+ res.sendFile(resolveDeployedFile('robots.txt'));
272
343
  });
273
344
 
274
345
  app.get('/sitemap.xml', (req, res) => {
275
- res.sendFile(path.join(projectRoot, folderDeployed, 'sitemap.xml'));
346
+ res.sendFile(resolveDeployedFile('sitemap.xml'));
276
347
  });
277
348
 
278
349
  // ==============================================
@@ -0,0 +1,137 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fsp from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { createDevDepsOptimizer, MODULES_PREFIX } from '../framework/devDepsOptimizer.js';
7
+
8
+ // projectRoot for esbuild resolution: this framework package's own root, whose
9
+ // node_modules contains es-module-lexer (used here as a real ESM package to
10
+ // bundle). Two dirs up from api/tests/.
11
+ const projectRoot = new URL('../../', import.meta.url).pathname;
12
+
13
+ test('rewriteBareImports rewrites bare specifiers to the virtual endpoint', async () => {
14
+ const opt = createDevDepsOptimizer({ projectRoot, enabled: true });
15
+ const code = [
16
+ "import dayjs from 'dayjs';",
17
+ "import { a, b } from '@scope/pkg';",
18
+ "import './local.js';",
19
+ "import '/libs/x.js';",
20
+ "import x from 'https://cdn/x.js';",
21
+ "const d = import('lodash/fp');"
22
+ ].join('\n');
23
+
24
+ const out = await opt.rewriteBareImports(code);
25
+
26
+ assert.match(out, new RegExp(`from '${MODULES_PREFIX}dayjs'`));
27
+ assert.match(out, new RegExp(`from '${MODULES_PREFIX}@scope/pkg'`));
28
+ assert.match(out, new RegExp(`import\\('${MODULES_PREFIX}lodash/fp'\\)`));
29
+ // Relative, absolute (public/) and URL imports are untouched.
30
+ assert.match(out, /import '\.\/local\.js'/);
31
+ assert.match(out, /import '\/libs\/x\.js'/);
32
+ assert.match(out, /from 'https:\/\/cdn\/x\.js'/);
33
+ });
34
+
35
+ test('rewriteBareImports is a no-op when disabled', async () => {
36
+ const opt = createDevDepsOptimizer({ projectRoot, enabled: false });
37
+ const code = "import dayjs from 'dayjs';";
38
+ assert.equal(await opt.rewriteBareImports(code), code);
39
+ });
40
+
41
+ test('rewriteBareImports returns source unchanged when there is nothing bare', async () => {
42
+ const opt = createDevDepsOptimizer({ projectRoot, enabled: true });
43
+ const code = "import './a.js';\nexport const x = 1;\n";
44
+ assert.equal(await opt.rewriteBareImports(code), code);
45
+ });
46
+
47
+ test('specifierFromRequest extracts the package from the virtual path', () => {
48
+ const opt = createDevDepsOptimizer({ projectRoot, enabled: true });
49
+ assert.equal(opt.specifierFromRequest(`${MODULES_PREFIX}dayjs`), 'dayjs');
50
+ assert.equal(opt.specifierFromRequest(`${MODULES_PREFIX}@scope/pkg/sub`), '@scope/pkg/sub');
51
+ assert.equal(opt.specifierFromRequest('/Components/x.js'), null);
52
+ });
53
+
54
+ test('bundlePackage resolves a real ESM package to self-contained ESM', async () => {
55
+ const opt = createDevDepsOptimizer({ projectRoot, enabled: true });
56
+ // es-module-lexer is a named-export ESM package (no default) — exercises the
57
+ // namespace-only fallback path in bundlePackage.
58
+ const { code } = await opt.bundlePackage('es-module-lexer');
59
+ assert.ok(code.length > 0, 'expected bundled code');
60
+ // Self-contained: no bare imports left to resolve at runtime.
61
+ assert.doesNotMatch(code, /^\s*import\s+.*from\s+['"][^./]/m);
62
+ // Exposes the package's named API.
63
+ assert.match(code, /\bparse\b/);
64
+ assert.match(code, /export\s*\{/);
65
+ });
66
+
67
+ test('bundlePackage caches and shares a single build per specifier', async () => {
68
+ const opt = createDevDepsOptimizer({ projectRoot, enabled: true });
69
+ const [a, b] = await Promise.all([
70
+ opt.bundlePackage('es-module-lexer'),
71
+ opt.bundlePackage('es-module-lexer')
72
+ ]);
73
+ assert.equal(a, b, 'concurrent requests should share the same cached result');
74
+ });
75
+
76
+ test('bundlePackage persists a disk cache keyed by version, reused by a fresh optimizer', async () => {
77
+ const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'slice-devcache-'));
78
+ try {
79
+ // Minimal installed package in the temp project's node_modules.
80
+ const pkgDir = path.join(tmp, 'node_modules', 'mini-pkg');
81
+ await fsp.mkdir(pkgDir, { recursive: true });
82
+ await fsp.writeFile(path.join(pkgDir, 'package.json'),
83
+ JSON.stringify({ name: 'mini-pkg', version: '2.3.4', type: 'module', main: 'index.js' }));
84
+ await fsp.writeFile(path.join(pkgDir, 'index.js'), 'export const hi = () => "hi";');
85
+
86
+ const opt1 = createDevDepsOptimizer({ projectRoot: tmp, enabled: true });
87
+ const first = await opt1.bundlePackage('mini-pkg');
88
+ assert.match(first.code, /hi/);
89
+
90
+ // A cache file keyed by the installed version must now exist.
91
+ const cacheFile = path.join(tmp, 'node_modules', '.slice-deps', 'mini-pkg@2.3.4.js');
92
+ const cached = await fsp.readFile(cacheFile, 'utf8');
93
+ assert.equal(cached, first.code);
94
+
95
+ // A brand-new optimizer (empty in-memory cache) reads the same bytes from disk.
96
+ const opt2 = createDevDepsOptimizer({ projectRoot: tmp, enabled: true });
97
+ const second = await opt2.bundlePackage('mini-pkg');
98
+ assert.equal(second.code, first.code);
99
+ } finally {
100
+ await fsp.rm(tmp, { recursive: true, force: true });
101
+ }
102
+ });
103
+
104
+ test('bundlePackage prunes stale cache files when a package version changes', async () => {
105
+ const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'slice-devcache-gc-'));
106
+ try {
107
+ const pkgDir = path.join(tmp, 'node_modules', 'mini-pkg');
108
+ const pkgJson = path.join(pkgDir, 'package.json');
109
+ const cacheDir = path.join(tmp, 'node_modules', '.slice-deps');
110
+ await fsp.mkdir(pkgDir, { recursive: true });
111
+ await fsp.writeFile(pkgJson, JSON.stringify({ name: 'mini-pkg', version: '1.0.0', type: 'module', main: 'index.js' }));
112
+ await fsp.writeFile(path.join(pkgDir, 'index.js'), 'export const v = 1;');
113
+
114
+ // A sibling package must survive the prune (its name is not a prefix match).
115
+ const otherDir = path.join(tmp, 'node_modules', 'mini-pkg-extra');
116
+ await fsp.mkdir(otherDir, { recursive: true });
117
+ await fsp.writeFile(path.join(otherDir, 'package.json'), JSON.stringify({ name: 'mini-pkg-extra', version: '9.0.0', type: 'module', main: 'index.js' }));
118
+ await fsp.writeFile(path.join(otherDir, 'index.js'), 'export const e = 1;');
119
+
120
+ await createDevDepsOptimizer({ projectRoot: tmp, enabled: true }).bundlePackage('mini-pkg');
121
+ await createDevDepsOptimizer({ projectRoot: tmp, enabled: true }).bundlePackage('mini-pkg-extra');
122
+ assert.ok(await fsp.stat(path.join(cacheDir, 'mini-pkg@1.0.0.js')).then(() => true));
123
+
124
+ // Simulate an upgrade: bump the installed version, then rebundle.
125
+ await fsp.writeFile(pkgJson, JSON.stringify({ name: 'mini-pkg', version: '1.1.0', type: 'module', main: 'index.js' }));
126
+ await fsp.writeFile(path.join(pkgDir, 'index.js'), 'export const v = 2;');
127
+ await createDevDepsOptimizer({ projectRoot: tmp, enabled: true }).bundlePackage('mini-pkg');
128
+
129
+ const remaining = (await fsp.readdir(cacheDir)).sort();
130
+ // Old version gone, new version present, sibling untouched.
131
+ assert.ok(!remaining.includes('mini-pkg@1.0.0.js'), 'stale version should be pruned');
132
+ assert.ok(remaining.includes('mini-pkg@1.1.0.js'), 'new version should be cached');
133
+ assert.ok(remaining.includes('mini-pkg-extra@9.0.0.js'), 'sibling package must not be pruned');
134
+ } finally {
135
+ await fsp.rm(tmp, { recursive: true, force: true });
136
+ }
137
+ });
@@ -0,0 +1,56 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import fsp from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+
8
+ // Boots the real dev server against a temp project and checks the two moving
9
+ // parts of external-dependency support in development: bare-import rewriting of
10
+ // served src modules, and on-demand esbuild bundling at /@slice-modules/.
11
+ test('dev server rewrites bare imports and serves bundled packages', async () => {
12
+ process.env.NODE_ENV = 'development';
13
+ const frameworkRoot = new URL('../../', import.meta.url).pathname; // slice.js root
14
+ const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'slice-dev-'));
15
+
16
+ try {
17
+ await fsp.mkdir(path.join(tmp, 'src', 'Components', 'Demo'), { recursive: true });
18
+ await fsp.writeFile(
19
+ path.join(tmp, 'src', 'sliceConfig.json'),
20
+ JSON.stringify({ externalDependencies: { enabled: true } })
21
+ );
22
+ await fsp.writeFile(
23
+ path.join(tmp, 'src', 'Components', 'Demo', 'Demo.js'),
24
+ "import { parse } from 'es-module-lexer';\nimport './local.js';\nexport default class Demo { run(){ return typeof parse; } }\n"
25
+ );
26
+ // Make es-module-lexer resolvable from the temp project for esbuild.
27
+ fs.symlinkSync(path.join(frameworkRoot, 'node_modules'), path.join(tmp, 'node_modules'), 'dir');
28
+
29
+ const { createSliceServer } = await import('../framework/server.js');
30
+ const { app } = createSliceServer({ projectRoot: tmp });
31
+ const server = app.listen(0);
32
+ await new Promise((resolve) => server.once('listening', resolve));
33
+ const base = `http://127.0.0.1:${server.address().port}`;
34
+
35
+ try {
36
+ // 1. The component's bare import is rewritten; the relative one is not.
37
+ const compRes = await fetch(`${base}/Components/Demo/Demo.js`);
38
+ const compText = await compRes.text();
39
+ assert.equal(compRes.status, 200);
40
+ assert.match(compText, /from ['"]\/@slice-modules\/es-module-lexer['"]/);
41
+ assert.match(compText, /import ['"]\.\/local\.js['"]/);
42
+
43
+ // 2. The virtual endpoint returns a self-contained ESM bundle of the pkg.
44
+ const modRes = await fetch(`${base}/@slice-modules/es-module-lexer`);
45
+ const modText = await modRes.text();
46
+ assert.equal(modRes.status, 200);
47
+ assert.match(modRes.headers.get('content-type') || '', /javascript/);
48
+ assert.match(modText, /export\s*\{/);
49
+ assert.doesNotMatch(modText, /^\s*import\s+.*from\s+['"][^./]/m);
50
+ } finally {
51
+ await new Promise((resolve) => server.close(resolve));
52
+ }
53
+ } finally {
54
+ await fsp.rm(tmp, { recursive: true, force: true });
55
+ }
56
+ });
@@ -0,0 +1,119 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fsp from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { createPrecompressedStatic } from '../framework/precompressedStatic.js';
7
+
8
+ function fakeRes() {
9
+ return {
10
+ headers: {},
11
+ body: null,
12
+ sent: false,
13
+ setHeader(k, v) { this.headers[k.toLowerCase()] = v; },
14
+ send(body) { this.body = body; this.sent = true; return this; }
15
+ };
16
+ }
17
+
18
+ function run(mw, req) {
19
+ const res = fakeRes();
20
+ let nextCalled = false;
21
+ mw(req, res, () => { nextCalled = true; });
22
+ return { res, nextCalled };
23
+ }
24
+
25
+ async function withRoot(fn) {
26
+ const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'slice-precomp-'));
27
+ try {
28
+ await fn(tmp);
29
+ } finally {
30
+ await fsp.rm(tmp, { recursive: true, force: true });
31
+ }
32
+ }
33
+
34
+ test('serves the .br variant when the client accepts brotli', async () => {
35
+ await withRoot(async (root) => {
36
+ await fsp.mkdir(path.join(root, 'bundles'), { recursive: true });
37
+ await fsp.writeFile(path.join(root, 'bundles', 'app.js'), 'ORIGINAL');
38
+ await fsp.writeFile(path.join(root, 'bundles', 'app.js.br'), 'BROTLI-BYTES');
39
+ await fsp.writeFile(path.join(root, 'bundles', 'app.js.gz'), 'GZIP-BYTES');
40
+
41
+ const mw = createPrecompressedStatic([root]);
42
+ const { res, nextCalled } = run(mw, {
43
+ method: 'GET', path: '/bundles/app.js', headers: { 'accept-encoding': 'gzip, br' }
44
+ });
45
+
46
+ assert.equal(nextCalled, false);
47
+ assert.equal(res.sent, true);
48
+ assert.equal(res.body.toString(), 'BROTLI-BYTES'); // brotli preferred over gzip
49
+ assert.equal(res.headers['content-encoding'], 'br');
50
+ assert.equal(res.headers['content-type'], 'application/javascript; charset=utf-8');
51
+ assert.equal(res.headers['vary'], 'Accept-Encoding');
52
+ });
53
+ });
54
+
55
+ test('falls back to gzip when brotli is not accepted', async () => {
56
+ await withRoot(async (root) => {
57
+ await fsp.writeFile(path.join(root, 'style.css'), 'ORIGINAL');
58
+ await fsp.writeFile(path.join(root, 'style.css.gz'), 'GZIP-BYTES');
59
+
60
+ const mw = createPrecompressedStatic([root]);
61
+ const { res } = run(mw, { method: 'GET', path: '/style.css', headers: { 'accept-encoding': 'gzip' } });
62
+ assert.equal(res.headers['content-encoding'], 'gzip');
63
+ assert.equal(res.headers['content-type'], 'text/css; charset=utf-8');
64
+ assert.equal(res.body.toString(), 'GZIP-BYTES');
65
+ });
66
+ });
67
+
68
+ test('passes through when the client accepts no encoding', async () => {
69
+ await withRoot(async (root) => {
70
+ await fsp.writeFile(path.join(root, 'app.js'), 'ORIGINAL');
71
+ await fsp.writeFile(path.join(root, 'app.js.br'), 'BROTLI-BYTES');
72
+ const mw = createPrecompressedStatic([root]);
73
+ const { res, nextCalled } = run(mw, { method: 'GET', path: '/app.js', headers: {} });
74
+ assert.equal(nextCalled, true);
75
+ assert.equal(res.sent, false);
76
+ });
77
+ });
78
+
79
+ test('passes through when no precompressed variant exists', async () => {
80
+ await withRoot(async (root) => {
81
+ await fsp.writeFile(path.join(root, 'app.js'), 'ORIGINAL'); // no .br/.gz
82
+ const mw = createPrecompressedStatic([root]);
83
+ const { res, nextCalled } = run(mw, { method: 'GET', path: '/app.js', headers: { 'accept-encoding': 'br' } });
84
+ assert.equal(nextCalled, true);
85
+ assert.equal(res.sent, false);
86
+ });
87
+ });
88
+
89
+ test('passes through for non-text extensions', async () => {
90
+ await withRoot(async (root) => {
91
+ await fsp.writeFile(path.join(root, 'img.png.br'), 'X');
92
+ const mw = createPrecompressedStatic([root]);
93
+ const { nextCalled } = run(mw, { method: 'GET', path: '/img.png', headers: { 'accept-encoding': 'br' } });
94
+ assert.equal(nextCalled, true);
95
+ });
96
+ });
97
+
98
+ test('honors root priority (public over dist root)', async () => {
99
+ await withRoot(async (root) => {
100
+ const pub = path.join(root, 'public');
101
+ await fsp.mkdir(pub, { recursive: true });
102
+ await fsp.writeFile(path.join(pub, 'theme.css.br'), 'PUBLIC');
103
+ await fsp.writeFile(path.join(root, 'theme.css.br'), 'ROOT');
104
+ const mw = createPrecompressedStatic([pub, root]);
105
+ const { res } = run(mw, { method: 'GET', path: '/theme.css', headers: { 'accept-encoding': 'br' } });
106
+ assert.equal(res.body.toString(), 'PUBLIC');
107
+ });
108
+ });
109
+
110
+ test('blocks path traversal', async () => {
111
+ await withRoot(async (root) => {
112
+ const mw = createPrecompressedStatic([path.join(root, 'public')]);
113
+ const { nextCalled, res } = run(mw, {
114
+ method: 'GET', path: '/../secret.js', headers: { 'accept-encoding': 'br' }
115
+ });
116
+ assert.equal(nextCalled, true);
117
+ assert.equal(res.sent, false);
118
+ });
119
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slicejs-web-framework",
3
- "version": "3.5.4",
3
+ "version": "4.0.0",
4
4
  "description": "",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -26,11 +26,14 @@
26
26
  "exports": {
27
27
  ".": "./Slice/Slice.js",
28
28
  "./api/framework/express.js": "./api/framework/express.js",
29
- "./api/framework/server.js": "./api/framework/server.js"
29
+ "./api/framework/server.js": "./api/framework/server.js",
30
+ "./api/framework/devDepsOptimizer.js": "./api/framework/devDepsOptimizer.js"
30
31
  },
31
32
  "types": "./types/index.d.ts",
32
33
  "dependencies": {
33
34
  "compression": "^1.8.1",
35
+ "es-module-lexer": "^2.3.0",
36
+ "esbuild": "^0.28.1",
34
37
  "express": "^4.19.2"
35
38
  },
36
39
  "devDependencies": {
@@ -110,10 +110,6 @@
110
110
  "enabled": { "type": "boolean" }
111
111
  }
112
112
  },
113
- "publicFolders": {
114
- "type": "array",
115
- "items": { "type": "string" }
116
- },
117
113
  "server": {
118
114
  "type": "object",
119
115
  "additionalProperties": true,
@@ -68,11 +68,5 @@
68
68
  },
69
69
  "loading": {
70
70
  "enabled": true
71
- },
72
- "publicFolders": [
73
- "/Themes",
74
- "/Styles",
75
- "/assets",
76
- "/images"
77
- ]
71
+ }
78
72
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes