@megabudino/stack-utils 1.5.0 → 2.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,292 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { buildProxyIslandsClientEntryModule, extractProxyIslandsMetadata, extractViteCssFromJs, injectAutoWiredIslandsConfig, isCssLikeModuleId, needsAutoWiredAssets, toPublicAssetUrl } from './vite-plugin-core.js';
5
+ const GENERATED_CLIENT_ENTRY_RELATIVE_PATH = '.svelte-kit/generated/stack-utils/proxy-islands.generated.ts';
6
+ const AUTO_ENTRY_PLACEHOLDER = '__STACK_UTILS_PROXY_ISLANDS_ENTRY__';
7
+ const AUTO_STYLES_PLACEHOLDER = '__STACK_UTILS_PROXY_ISLANDS_STYLES__';
8
+ /**
9
+ * Dev URL for the combined islands stylesheet.
10
+ *
11
+ * In dev, `svelte/server`'s `render()` does not emit scoped component CSS into
12
+ * its `head` output and `vite-plugin-svelte` injects styles via a JS-runtime
13
+ * `<style>` append. That produces a flash because the styles arrive after the
14
+ * proxied HTML has already painted. We bridge the gap by serving a real
15
+ * stylesheet at this URL, sourced from the islands client entry's CSS deps,
16
+ * so the browser blocks render until styles are ready.
17
+ */
18
+ const DEV_ISLANDS_CSS_URL = '/@stack-utils/proxy-islands.css';
19
+ export function proxyIslands() {
20
+ let config;
21
+ let hooksFilePath = null;
22
+ let generatedClientEntryPath = null;
23
+ let latestMetadata = null;
24
+ let clientEntryRef = null;
25
+ const updateGeneratedClientEntry = async (hooksCode) => {
26
+ if (!hooksFilePath || !generatedClientEntryPath) {
27
+ return null;
28
+ }
29
+ const code = hooksCode ?? (await readFile(hooksFilePath, 'utf8'));
30
+ const metadata = extractProxyIslandsMetadata(code, hooksFilePath);
31
+ latestMetadata = metadata;
32
+ if (!metadata || !needsAutoWiredAssets(metadata)) {
33
+ return metadata;
34
+ }
35
+ const generatedClientEntry = buildProxyIslandsClientEntryModule(metadata, hooksFilePath, generatedClientEntryPath);
36
+ await mkdir(path.dirname(generatedClientEntryPath), { recursive: true });
37
+ await writeFile(generatedClientEntryPath, generatedClientEntry, 'utf8');
38
+ return metadata;
39
+ };
40
+ return {
41
+ name: 'proxy-islands',
42
+ enforce: 'pre',
43
+ configResolved(resolvedConfig) {
44
+ config = resolvedConfig;
45
+ hooksFilePath = findHooksServerFile(config.root);
46
+ generatedClientEntryPath = path.join(config.root, GENERATED_CLIENT_ENTRY_RELATIVE_PATH);
47
+ },
48
+ async buildStart() {
49
+ if (!hooksFilePath || !generatedClientEntryPath) {
50
+ return;
51
+ }
52
+ const metadata = await updateGeneratedClientEntry();
53
+ if (!metadata || !needsAutoWiredAssets(metadata)) {
54
+ return;
55
+ }
56
+ if (config.command === 'build' && !config.build.ssr) {
57
+ clientEntryRef = this.emitFile({
58
+ type: 'chunk',
59
+ id: generatedClientEntryPath,
60
+ name: 'stack-utils-proxy-islands'
61
+ });
62
+ }
63
+ },
64
+ configureServer(server) {
65
+ registerHooksWatcher(server, async () => {
66
+ await updateGeneratedClientEntry();
67
+ }, hooksFilePath);
68
+ registerIslandsCssMiddleware(server, () => generatedClientEntryPath);
69
+ registerIslandsHmrFullReload(server, () => latestMetadata);
70
+ },
71
+ async transform(code, id) {
72
+ if (!hooksFilePath) {
73
+ return null;
74
+ }
75
+ const cleanId = id.split('?', 1)[0];
76
+ if (path.normalize(cleanId) !== path.normalize(hooksFilePath)) {
77
+ return null;
78
+ }
79
+ const metadata = await updateGeneratedClientEntry(code);
80
+ if (!metadata || !needsAutoWiredAssets(metadata)) {
81
+ return null;
82
+ }
83
+ const clientEntryExpression = config.command === 'serve' && generatedClientEntryPath
84
+ ? JSON.stringify(toDevFsUrl(generatedClientEntryPath))
85
+ : JSON.stringify(AUTO_ENTRY_PLACEHOLDER);
86
+ const clientStylesheetsExpression = config.command === 'serve'
87
+ ? JSON.stringify([DEV_ISLANDS_CSS_URL])
88
+ : AUTO_STYLES_PLACEHOLDER;
89
+ return {
90
+ code: injectAutoWiredIslandsConfig(code, metadata, {
91
+ clientEntryExpression,
92
+ clientStylesheetsExpression
93
+ }),
94
+ map: null
95
+ };
96
+ },
97
+ async generateBundle(_outputOptions, bundle) {
98
+ if (config.build.ssr || !clientEntryRef || !latestMetadata || !needsAutoWiredAssets(latestMetadata)) {
99
+ return;
100
+ }
101
+ const clientEntryFileName = this.getFileName(clientEntryRef);
102
+ const clientChunk = bundle[clientEntryFileName];
103
+ if (!clientChunk || clientChunk.type !== 'chunk') {
104
+ throw new Error('[stack-utils proxy] failed to locate the generated islands client chunk.');
105
+ }
106
+ const clientEntryUrl = toPublicAssetUrl(clientEntryFileName, config.base);
107
+ const clientStylesheetUrls = extractClientStylesheetUrls(clientChunk, config.base);
108
+ await patchServerOutputFiles(config.root, clientEntryUrl, clientStylesheetUrls);
109
+ }
110
+ };
111
+ }
112
+ function registerHooksWatcher(server, refreshGeneratedEntry, hooksFilePath) {
113
+ if (!hooksFilePath) {
114
+ return;
115
+ }
116
+ server.watcher.add(hooksFilePath);
117
+ server.watcher.on('change', (changedPath) => {
118
+ if (path.normalize(changedPath) !== path.normalize(hooksFilePath)) {
119
+ return;
120
+ }
121
+ void refreshGeneratedEntry().then(() => {
122
+ server.ws.send({ type: 'full-reload' });
123
+ });
124
+ });
125
+ }
126
+ /**
127
+ * Serve the combined SSR-blocking stylesheet for the islands client entry.
128
+ *
129
+ * The middleware forces the generated client entry through Vite's transform
130
+ * pipeline so vite-plugin-svelte populates the module graph with each
131
+ * component's scoped CSS module, then walks the graph and reassembles the
132
+ * actual CSS that those JS-wrapped style modules carry.
133
+ */
134
+ function registerIslandsCssMiddleware(server, getGeneratedClientEntryPath) {
135
+ server.middlewares.use(DEV_ISLANDS_CSS_URL, async (req, res, next) => {
136
+ try {
137
+ const generatedClientEntryPath = getGeneratedClientEntryPath();
138
+ if (!generatedClientEntryPath || !existsSync(generatedClientEntryPath)) {
139
+ res.statusCode = 404;
140
+ res.end('/* no proxy islands bootstrap generated */');
141
+ return;
142
+ }
143
+ const css = await collectIslandsDevCss(server, generatedClientEntryPath);
144
+ res.setHeader('Content-Type', 'text/css; charset=utf-8');
145
+ res.setHeader('Cache-Control', 'no-store');
146
+ res.end(css);
147
+ }
148
+ catch (error) {
149
+ next(error);
150
+ }
151
+ });
152
+ }
153
+ /**
154
+ * Trigger a full page reload whenever a registered island component changes.
155
+ *
156
+ * The browser caches the combined stylesheet served above as a regular CSS
157
+ * resource, so HMR alone is not enough to flush the styles for an updated
158
+ * island. A full reload forces both the proxied HTML and the islands CSS to
159
+ * be refetched. We only react to files that are actually used by an island
160
+ * so editing unrelated components keeps Vite's normal HMR path.
161
+ */
162
+ function registerIslandsHmrFullReload(server, getMetadata) {
163
+ server.watcher.on('change', (changedPath) => {
164
+ const metadata = getMetadata();
165
+ if (!metadata || metadata.components.length === 0) {
166
+ return;
167
+ }
168
+ const normalizedChanged = path.normalize(changedPath);
169
+ const componentPaths = collectComponentAbsolutePaths(metadata, server);
170
+ if (componentPaths.some((file) => path.normalize(file) === normalizedChanged)) {
171
+ server.ws.send({ type: 'full-reload' });
172
+ }
173
+ });
174
+ }
175
+ function collectComponentAbsolutePaths(metadata, server) {
176
+ const roots = new Set();
177
+ for (const node of server.moduleGraph.idToModuleMap.values()) {
178
+ if (node.file && node.file.endsWith('.svelte')) {
179
+ roots.add(node.file);
180
+ }
181
+ }
182
+ const matchedByName = new Set();
183
+ for (const component of metadata.components) {
184
+ const tail = component.source.split('/').pop() ?? component.source;
185
+ for (const file of roots) {
186
+ if (file.endsWith(`/${tail}`) || file.endsWith(`\\${tail}`)) {
187
+ matchedByName.add(file);
188
+ }
189
+ }
190
+ }
191
+ return [...matchedByName];
192
+ }
193
+ async function collectIslandsDevCss(server, generatedClientEntryPath) {
194
+ const url = toDevFsUrl(generatedClientEntryPath);
195
+ // Force the bootstrap through the client transform pipeline so each
196
+ // `.svelte` import's CSS counterpart is materialised in the module graph.
197
+ await server.transformRequest(url);
198
+ const bootstrapNode = await server.moduleGraph.getModuleByUrl(url, false);
199
+ if (!bootstrapNode) {
200
+ return '';
201
+ }
202
+ const cssNodes = new Set();
203
+ const visited = new Set();
204
+ const walk = (node) => {
205
+ if (!node || visited.has(node))
206
+ return;
207
+ visited.add(node);
208
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
209
+ const n = node;
210
+ const id = n.id ?? null;
211
+ const moduleUrl = n.url ?? null;
212
+ if (isCssLikeModuleId(id) || isCssLikeModuleId(moduleUrl)) {
213
+ cssNodes.add({ id, url: moduleUrl });
214
+ }
215
+ const imported = n.importedModules ?? [];
216
+ for (const child of imported) {
217
+ walk(child);
218
+ }
219
+ };
220
+ walk(bootstrapNode);
221
+ const chunks = [];
222
+ const seenCssIds = new Set();
223
+ for (const node of cssNodes) {
224
+ const requestId = node.url ?? node.id;
225
+ if (!requestId)
226
+ continue;
227
+ const dedupeKey = node.id ?? node.url ?? requestId;
228
+ if (seenCssIds.has(dedupeKey))
229
+ continue;
230
+ seenCssIds.add(dedupeKey);
231
+ const result = await server.transformRequest(requestId);
232
+ if (!result?.code)
233
+ continue;
234
+ const css = extractViteCssFromJs(result.code);
235
+ if (css) {
236
+ chunks.push(css);
237
+ }
238
+ }
239
+ return chunks.join('\n');
240
+ }
241
+ function findHooksServerFile(root) {
242
+ for (const extension of ['ts', 'js', 'mts', 'mjs']) {
243
+ const candidate = path.join(root, 'src', `hooks.server.${extension}`);
244
+ if (existsSync(candidate)) {
245
+ return path.normalize(candidate);
246
+ }
247
+ }
248
+ return null;
249
+ }
250
+ function toDevFsUrl(filePath) {
251
+ return `/@fs/${filePath.split(path.sep).join('/')}`;
252
+ }
253
+ function extractClientStylesheetUrls(chunk, base) {
254
+ const urls = new Set();
255
+ const importedCss = Array.from(
256
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
257
+ (chunk.viteMetadata?.importedCss ?? []));
258
+ for (const fileName of importedCss) {
259
+ urls.add(toPublicAssetUrl(fileName, base));
260
+ }
261
+ return [...urls];
262
+ }
263
+ async function patchServerOutputFiles(root, clientEntryUrl, clientStylesheetUrls) {
264
+ const serverOutputDirectory = path.join(root, '.svelte-kit', 'output', 'server');
265
+ const serverFiles = await listJavaScriptFiles(serverOutputDirectory);
266
+ await Promise.all(serverFiles.map(async (filePath) => {
267
+ const source = await readFile(filePath, 'utf8');
268
+ if (!source.includes(AUTO_ENTRY_PLACEHOLDER) &&
269
+ !source.includes(AUTO_STYLES_PLACEHOLDER)) {
270
+ return;
271
+ }
272
+ const patched = source
273
+ .replaceAll(JSON.stringify(AUTO_ENTRY_PLACEHOLDER), JSON.stringify(clientEntryUrl))
274
+ .replaceAll(AUTO_STYLES_PLACEHOLDER, JSON.stringify(clientStylesheetUrls));
275
+ await writeFile(filePath, patched, 'utf8');
276
+ }));
277
+ }
278
+ async function listJavaScriptFiles(directory) {
279
+ const entries = await readdir(directory, { withFileTypes: true });
280
+ const files = [];
281
+ for (const entry of entries) {
282
+ const fullPath = path.join(directory, entry.name);
283
+ if (entry.isDirectory()) {
284
+ files.push(...(await listJavaScriptFiles(fullPath)));
285
+ continue;
286
+ }
287
+ if (entry.name.endsWith('.js')) {
288
+ files.push(fullPath);
289
+ }
290
+ }
291
+ return files;
292
+ }
@@ -0,0 +1 @@
1
+ export { proxyIslands } from './vite-plugin.js';
@@ -0,0 +1 @@
1
+ export { proxyIslands } from './vite-plugin.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@megabudino/stack-utils",
3
- "version": "1.5.0",
3
+ "version": "2.0.0",
4
4
  "description": "Reusable utilities for a SvelteKit stack, including a reverse proxy and static image pipeline",
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,6 +12,14 @@
12
12
  "types": "./dist/proxy/index.d.ts",
13
13
  "default": "./dist/proxy/index.js"
14
14
  },
15
+ "./proxy/client": {
16
+ "types": "./dist/proxy/client.d.ts",
17
+ "default": "./dist/proxy/client.js"
18
+ },
19
+ "./proxy/vite": {
20
+ "types": "./dist/proxy/vite.d.ts",
21
+ "default": "./dist/proxy/vite.js"
22
+ },
15
23
  "./images": {
16
24
  "types": "./dist/images/index.d.ts",
17
25
  "default": "./dist/images/index.js"
@@ -1,9 +0,0 @@
1
- import type { DomAction } from './types.js';
2
- /**
3
- * Apply declarative DOM actions in array order.
4
- *
5
- * Actions run after the proxy's standard HTML rewrites so selectors see the
6
- * final proxied markup shape, and before `textReplacements` so the legacy
7
- * string-based pass remains the last low-level override.
8
- */
9
- export declare function applyDomActions(html: string, actions: DomAction[]): string;
@@ -1,54 +0,0 @@
1
- import { load } from 'cheerio';
2
- /**
3
- * Apply declarative DOM actions in array order.
4
- *
5
- * Actions run after the proxy's standard HTML rewrites so selectors see the
6
- * final proxied markup shape, and before `textReplacements` so the legacy
7
- * string-based pass remains the last low-level override.
8
- */
9
- export function applyDomActions(html, actions) {
10
- if (actions.length === 0) {
11
- return html;
12
- }
13
- const $ = load(html);
14
- for (const action of actions) {
15
- const elements = $(action.selector);
16
- if (elements.length === 0) {
17
- continue;
18
- }
19
- if (action.remove) {
20
- elements.remove();
21
- continue;
22
- }
23
- if (action.setAttributes) {
24
- for (const [name, value] of Object.entries(action.setAttributes)) {
25
- elements.attr(name, value);
26
- }
27
- }
28
- if (action.removeAttributes) {
29
- for (const name of action.removeAttributes) {
30
- elements.removeAttr(name);
31
- }
32
- }
33
- if (action.addClass && action.addClass.length > 0) {
34
- elements.addClass(action.addClass.join(' '));
35
- }
36
- if (action.removeClass && action.removeClass.length > 0) {
37
- elements.removeClass(action.removeClass.join(' '));
38
- }
39
- if (action.setText !== undefined) {
40
- elements.text(action.setText);
41
- }
42
- if (action.setHtml !== undefined) {
43
- elements.html(action.setHtml);
44
- }
45
- // Insertions compose with content replacement in the same action.
46
- if (action.prependHtml !== undefined) {
47
- elements.prepend(action.prependHtml);
48
- }
49
- if (action.appendHtml !== undefined) {
50
- elements.append(action.appendHtml);
51
- }
52
- }
53
- return $.html();
54
- }