slicejs-web-framework 4.0.2 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +97 -97
  3. package/Slice/Components/Structural/ContextManager/ContextManager.js +422 -422
  4. package/Slice/Components/Structural/ContextManager/ContextManagerDebugger.js +470 -470
  5. package/Slice/Components/Structural/Controller/Controller.js +1406 -1406
  6. package/Slice/Components/Structural/Controller/allowedValuesValidation.js +52 -52
  7. package/Slice/Components/Structural/Debugger/Debugger.html +72 -72
  8. package/Slice/Components/Structural/Debugger/Debugger.js +1505 -1505
  9. package/Slice/Components/Structural/EventManager/EventManager.js +651 -651
  10. package/Slice/Components/Structural/EventManager/EventManagerDebugger.js +985 -985
  11. package/Slice/Components/Structural/LeakInspector/LeakInspector.js +296 -296
  12. package/Slice/Components/Structural/Logger/Log.js +10 -10
  13. package/Slice/Components/Structural/Logger/LogViewer/LogViewer.js +662 -662
  14. package/Slice/Components/Structural/Logger/Logger.js +225 -225
  15. package/Slice/Components/Structural/Router/Router.js +820 -810
  16. package/Slice/Components/Structural/Router/applyRouteMetadata.js +39 -0
  17. package/Slice/Components/Structural/StylesManager/StylesManager.js +82 -82
  18. package/Slice/Components/Structural/StylesManager/ThemeManager/ThemeManager.js +175 -175
  19. package/Slice/Slice.js +711 -710
  20. package/api/framework/devDepsOptimizer.js +335 -335
  21. package/api/framework/express.js +3 -3
  22. package/api/framework/precompressedStatic.js +102 -93
  23. package/api/framework/publicEnvResolver.js +123 -123
  24. package/api/framework/securityMiddleware.js +197 -197
  25. package/api/framework/seo.js +387 -0
  26. package/api/framework/server.js +777 -410
  27. package/api/index.js +14 -4
  28. package/package.json +6 -2
  29. package/sliceConfig.schema.json +122 -122
  30. package/src/App/index.html +69 -69
  31. package/src/App/index.js +20 -20
  32. package/src/App/style.css +11 -11
  33. package/src/Components/AppComponents/AboutSection/AboutSection.css +9 -9
  34. package/src/Components/AppComponents/AboutSection/AboutSection.html +8 -8
  35. package/src/Components/AppComponents/AboutSection/AboutSection.js +12 -12
  36. package/src/Components/AppComponents/AppShell/AppShell.css +10 -10
  37. package/src/Components/AppComponents/AppShell/AppShell.html +4 -4
  38. package/src/Components/AppComponents/AppShell/AppShell.js +36 -36
  39. package/src/Components/AppComponents/HomeSection/HomeSection.css +20 -20
  40. package/src/Components/AppComponents/HomeSection/HomeSection.html +10 -10
  41. package/src/Components/AppComponents/HomeSection/HomeSection.js +19 -19
  42. package/src/Components/components.js +8 -8
  43. package/src/public/Styles/sliceStyles.css +34 -34
  44. package/src/public/Themes/Dark.css +42 -42
  45. package/src/public/Themes/Light.css +31 -31
  46. package/src/public/Themes/Slice.css +47 -47
  47. package/src/public/manifest.json +15 -15
  48. package/src/public/robots.txt +4 -4
  49. package/src/public/sitemap.xml +8 -8
  50. package/src/routes.js +9 -9
  51. package/src/sliceConfig.json +72 -72
  52. package/types/index.d.ts +219 -219
  53. package/.opencode/opencode.json +0 -15
  54. package/Slice/tests/build-bundled-component-without-category.test.js +0 -103
  55. package/Slice/tests/build-js-only-visual-components.test.js +0 -144
  56. package/Slice/tests/build-singleton.test.js +0 -244
  57. package/Slice/tests/bundle-v2-runtime-contract.test.js +0 -747
  58. package/Slice/tests/context-patch-use.test.js +0 -95
  59. package/Slice/tests/destroy-cascade.test.js +0 -215
  60. package/Slice/tests/event-emitters.test.js +0 -79
  61. package/Slice/tests/event-registry.test.js +0 -141
  62. package/Slice/tests/fixtures/real-runtime-loader.mjs +0 -21
  63. package/Slice/tests/leak-orphans.test.js +0 -131
  64. package/Slice/tests/props-allowed-values-validation.test.js +0 -119
  65. package/Slice/tests/public-env-runtime-accessors.test.js +0 -44
  66. package/Slice/tests/public-env-typed-accessors.test.js +0 -66
  67. package/Slice/tests/router-loading-finally.test.js +0 -68
  68. package/api/tests/dev-deps-optimizer.test.js +0 -137
  69. package/api/tests/dev-server-external.test.js +0 -56
  70. package/api/tests/precompressed-static.test.js +0 -119
  71. package/api/tests/public-env-resolver.test.js +0 -193
  72. package/tests/router-build-failure.test.js +0 -125
  73. package/tests/router-emit-ordering.test.js +0 -63
@@ -1,335 +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
- }
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
+ }