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.
- package/api/framework/devDepsOptimizer.js +335 -0
- package/api/framework/precompressedStatic.js +93 -0
- package/api/framework/securityMiddleware.js +1 -1
- package/api/framework/server.js +86 -15
- package/api/tests/dev-deps-optimizer.test.js +137 -0
- package/api/tests/dev-server-external.test.js +56 -0
- package/api/tests/precompressed-static.test.js +119 -0
- package/package.json +5 -2
- package/sliceConfig.schema.json +0 -4
- package/src/sliceConfig.json +1 -7
- /package/src/{Styles → public/Styles}/sliceStyles.css +0 -0
- /package/src/{Themes → public/Themes}/Dark.css +0 -0
- /package/src/{Themes → public/Themes}/Light.css +0 -0
- /package/src/{Themes → public/Themes}/Slice.css +0 -0
- /package/src/{images → public/images}/favicon.ico +0 -0
- /package/src/{manifest.json → public/manifest.json} +0 -0
- /package/src/{robots.txt → public/robots.txt} +0 -0
- /package/src/{sitemap.xml → public/sitemap.xml} +0 -0
|
@@ -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 (
|
|
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)
|
package/api/framework/server.js
CHANGED
|
@@ -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 =
|
|
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(
|
|
342
|
+
res.sendFile(resolveDeployedFile('robots.txt'));
|
|
272
343
|
});
|
|
273
344
|
|
|
274
345
|
app.get('/sitemap.xml', (req, res) => {
|
|
275
|
-
res.sendFile(
|
|
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
|
+
"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": {
|
package/sliceConfig.schema.json
CHANGED
package/src/sliceConfig.json
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|