@megabudino/stack-utils 2.0.2 → 2.2.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.
@@ -1,6 +1,7 @@
1
1
  import { validateIslandsConfigAtStartup } from './islands.js';
2
2
  import { injectIntoSitemapIndex, normalizeSitemapPath, removeExcludedUrlsFromSitemap, rewriteHtml } from './rewrite.js';
3
3
  import { transformProxiedHtml } from './transform-html.js';
4
+ import { fetchViaTransport } from './upstream-fetch.js';
4
5
  /**
5
6
  * Create a SvelteKit `Handle` hook that reverse-proxies requests to an origin.
6
7
  *
@@ -10,6 +11,11 @@ import { transformProxiedHtml } from './transform-html.js';
10
11
  export function createProxyHandle(config) {
11
12
  assertNoRemovedConfig(config);
12
13
  const { originUrl, siteUrl } = config;
14
+ const logicalOrigin = new URL(originUrl);
15
+ const upstreamUrlTrimmed = config.upstreamUrl?.trim();
16
+ const useTransport = Boolean(upstreamUrlTrimmed);
17
+ const transport = new URL(upstreamUrlTrimmed || originUrl);
18
+ const forwardedProto = new URL(siteUrl).protocol.replace(':', '');
13
19
  const sitemap = config.sitemap ?? {};
14
20
  const sitemapIndexPath = sitemap.indexPath ? normalizeSitemapPath(sitemap.indexPath) : undefined;
15
21
  const sitemapIncludeFiles = sitemap.includeFiles ?? [];
@@ -34,21 +40,38 @@ export function createProxyHandle(config) {
34
40
  }
35
41
  const headers = new Headers(event.request.headers);
36
42
  headers.delete('accept-encoding');
37
- headers.set('host', siteHost);
43
+ if (useTransport) {
44
+ headers.set('host', logicalOrigin.host);
45
+ headers.set('x-forwarded-proto', forwardedProto);
46
+ if (!headers.has('x-forwarded-host')) {
47
+ headers.set('x-forwarded-host', siteHost);
48
+ }
49
+ }
50
+ else {
51
+ headers.set('host', siteHost);
52
+ }
38
53
  headers.delete('content-length');
54
+ const connectionBase = useTransport ? transport.origin : originUrl;
55
+ const requestBody = event.request.method === 'GET' || event.request.method === 'HEAD'
56
+ ? undefined
57
+ : event.request.body;
39
58
  let currentPath = pathname + search;
40
59
  for (let hops = 0;; hops++) {
41
- const upstream = new URL(currentPath, originUrl);
60
+ const upstream = new URL(currentPath, connectionBase);
42
61
  let upstreamResponse;
43
62
  try {
44
- upstreamResponse = await fetch(upstream, {
45
- method: event.request.method,
46
- headers,
47
- redirect: 'manual',
48
- body: event.request.method === 'GET' || event.request.method === 'HEAD'
49
- ? undefined
50
- : event.request.body
51
- });
63
+ upstreamResponse = useTransport
64
+ ? await fetchViaTransport(upstream, {
65
+ method: event.request.method,
66
+ headers,
67
+ body: requestBody
68
+ })
69
+ : await fetch(upstream, {
70
+ method: event.request.method,
71
+ headers,
72
+ redirect: 'manual',
73
+ body: requestBody
74
+ });
52
75
  }
53
76
  catch {
54
77
  return new Response('Bad Gateway', { status: 502 });
@@ -56,7 +79,7 @@ export function createProxyHandle(config) {
56
79
  if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) {
57
80
  const location = upstreamResponse.headers.get('location');
58
81
  if (location) {
59
- const nextUrl = new URL(location, upstream);
82
+ const nextUrl = new URL(location, useTransport ? logicalOrigin : upstream);
60
83
  const isOrigin = originHosts.has(nextUrl.hostname);
61
84
  const isSite = nextUrl.hostname === siteHost;
62
85
  if ((isOrigin || isSite) && hops < maxRedirects) {
@@ -39,6 +39,11 @@ export function rewriteHtml(html, originUrl, siteUrl, proxyAssets = false, addit
39
39
  html = html.replace(originPattern(origin), '');
40
40
  }
41
41
  }
42
+ // SvelteKit origins emit relative `./_app/...` assets. Browsers resolve those to
43
+ // `/_app/...` on the proxy host, which collides with the local SvelteKit build.
44
+ // Point them at the origin so CSS/JS load from the upstream app (CORS permitting).
45
+ const originBase = originUrl.replace(/\/$/, '');
46
+ html = html.replace(/(["'(])\.\/_app\//g, `$1${originBase}/_app/`);
42
47
  return html;
43
48
  }
44
49
  /** Normalize URL-like values to comparable sitemap pathnames. */
@@ -133,8 +133,31 @@ export interface SitemapConfig {
133
133
  }
134
134
  /** Configuration for the reverse proxy. */
135
135
  export interface ProxyConfig {
136
- /** The origin URL (not publicly exposed). */
136
+ /**
137
+ * Logical origin URL: the host and scheme WordPress (or the upstream CMS) should
138
+ * believe it is serving. Used for the `Host` header when `upstreamUrl` is set,
139
+ * and as the source domain for response rewriting (`originUrl` → `siteUrl`).
140
+ *
141
+ * When `upstreamUrl` is omitted, this URL is also the connection target.
142
+ */
137
143
  originUrl: string;
144
+ /**
145
+ * Optional transport URL for the actual TCP/HTTP connection to the origin.
146
+ *
147
+ * Use this to reach an internal service name (e.g. a Docker overlay hostname)
148
+ * while still presenting the public `originUrl` identity to the CMS. When
149
+ * absent or blank, the proxy connects to `originUrl` and behavior is unchanged.
150
+ *
151
+ * @example
152
+ * ```ts
153
+ * createProxyHandle({
154
+ * originUrl: 'https://wordpress.example.com',
155
+ * upstreamUrl: process.env.WP_UPSTREAM, // e.g. http://wordpress-service:80
156
+ * siteUrl: 'https://www.example.com'
157
+ * });
158
+ * ```
159
+ */
160
+ upstreamUrl?: string;
138
161
  /** The public site URL (used in sitemaps and link rewriting). */
139
162
  siteUrl: string;
140
163
  /** Explicit sitemap rewriting configuration for proxied XML responses. */
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Forward a request to the origin using undici so a custom `Host` header is
3
+ * honored. Node's built-in `fetch` derives `Host` from the destination URL and
4
+ * ignores `headers.set('host', ...)`, which breaks transport/logical-origin splits.
5
+ */
6
+ export declare function fetchViaTransport(url: URL, init: {
7
+ method: string;
8
+ headers: Headers;
9
+ body?: BodyInit | null;
10
+ }): Promise<Response>;
@@ -0,0 +1,62 @@
1
+ import { request as undiciRequest } from 'undici';
2
+ /**
3
+ * Forward a request to the origin using undici so a custom `Host` header is
4
+ * honored. Node's built-in `fetch` derives `Host` from the destination URL and
5
+ * ignores `headers.set('host', ...)`, which breaks transport/logical-origin splits.
6
+ */
7
+ export async function fetchViaTransport(url, init) {
8
+ const { statusCode, headers, body } = await undiciRequest(url, {
9
+ method: init.method,
10
+ headers: headersRecord(init.headers),
11
+ body: init.method === 'GET' || init.method === 'HEAD'
12
+ ? undefined
13
+ : init.body
14
+ });
15
+ const responseHeaders = new Headers();
16
+ for (const [key, value] of Object.entries(headers)) {
17
+ if (value === undefined) {
18
+ continue;
19
+ }
20
+ if (Array.isArray(value)) {
21
+ for (const entry of value) {
22
+ responseHeaders.append(key, entry);
23
+ }
24
+ continue;
25
+ }
26
+ responseHeaders.set(key, value);
27
+ }
28
+ return new Response(body, {
29
+ status: statusCode,
30
+ headers: responseHeaders
31
+ });
32
+ }
33
+ /** Hop-by-hop and other headers undici rejects when forwarding a client request. */
34
+ const UNDICI_STRIPPED_HEADERS = new Set([
35
+ 'connection',
36
+ 'keep-alive',
37
+ 'proxy-authenticate',
38
+ 'proxy-authorization',
39
+ 'proxy-connection',
40
+ 'te',
41
+ 'trailer',
42
+ 'transfer-encoding',
43
+ 'upgrade',
44
+ 'http2-settings'
45
+ ]);
46
+ function headersRecord(headers) {
47
+ const skip = new Set(UNDICI_STRIPPED_HEADERS);
48
+ const connection = headers.get('connection');
49
+ if (connection) {
50
+ for (const token of connection.split(',')) {
51
+ skip.add(token.trim().toLowerCase());
52
+ }
53
+ }
54
+ const record = {};
55
+ for (const [key, value] of headers.entries()) {
56
+ if (skip.has(key.toLowerCase())) {
57
+ continue;
58
+ }
59
+ record[key] = value;
60
+ }
61
+ return record;
62
+ }
@@ -1,2 +1,16 @@
1
- import type { Plugin } from 'vite';
1
+ import type { Plugin, Rollup } from 'vite';
2
2
  export declare function proxyIslands(): Plugin;
3
+ /**
4
+ * Collect every client stylesheet that must load for the islands bootstrap to
5
+ * paint correctly.
6
+ *
7
+ * Vite/Rollup code-splitting frequently hoists an island component into a
8
+ * shared chunk (for example when the same component is also imported by a
9
+ * normal SvelteKit route). In that case the component's `importedCss` is
10
+ * attributed to the shared chunk, not to the bootstrap entry chunk, so reading
11
+ * only the entry chunk's own `importedCss` misses the styles. We therefore walk
12
+ * the chunk graph transitively from the entry chunk, following each chunk's
13
+ * STATIC `imports` (dynamic imports are not required for first paint and could
14
+ * over-inject unrelated CSS), unioning the `importedCss` we find along the way.
15
+ */
16
+ export declare function extractClientStylesheetUrls(entryChunk: Rollup.OutputChunk, bundle: Rollup.OutputBundle, base: string): string[];
@@ -104,7 +104,7 @@ export function proxyIslands() {
104
104
  throw new Error('[stack-utils proxy] failed to locate the generated islands client chunk.');
105
105
  }
106
106
  const clientEntryUrl = toPublicAssetUrl(clientEntryFileName, config.base);
107
- const clientStylesheetUrls = extractClientStylesheetUrls(clientChunk, config.base);
107
+ const clientStylesheetUrls = extractClientStylesheetUrls(clientChunk, bundle, config.base);
108
108
  await patchServerOutputFiles(config.root, clientEntryUrl, clientStylesheetUrls);
109
109
  }
110
110
  };
@@ -250,14 +250,35 @@ function findHooksServerFile(root) {
250
250
  function toDevFsUrl(filePath) {
251
251
  return `/@fs/${filePath.split(path.sep).join('/')}`;
252
252
  }
253
- function extractClientStylesheetUrls(chunk, base) {
253
+ /**
254
+ * Collect every client stylesheet that must load for the islands bootstrap to
255
+ * paint correctly.
256
+ *
257
+ * Vite/Rollup code-splitting frequently hoists an island component into a
258
+ * shared chunk (for example when the same component is also imported by a
259
+ * normal SvelteKit route). In that case the component's `importedCss` is
260
+ * attributed to the shared chunk, not to the bootstrap entry chunk, so reading
261
+ * only the entry chunk's own `importedCss` misses the styles. We therefore walk
262
+ * the chunk graph transitively from the entry chunk, following each chunk's
263
+ * STATIC `imports` (dynamic imports are not required for first paint and could
264
+ * over-inject unrelated CSS), unioning the `importedCss` we find along the way.
265
+ */
266
+ export function extractClientStylesheetUrls(entryChunk, bundle, base) {
254
267
  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
- }
268
+ const visited = new Set();
269
+ const visit = (chunk) => {
270
+ if (!chunk || chunk.type !== 'chunk' || visited.has(chunk.fileName)) {
271
+ return;
272
+ }
273
+ visited.add(chunk.fileName);
274
+ for (const fileName of chunk.viteMetadata?.importedCss ?? []) {
275
+ urls.add(toPublicAssetUrl(fileName, base));
276
+ }
277
+ for (const importFileName of chunk.imports ?? []) {
278
+ visit(bundle[importFileName]);
279
+ }
280
+ };
281
+ visit(entryChunk);
261
282
  return [...urls];
262
283
  }
263
284
  async function patchServerOutputFiles(root, clientEntryUrl, clientStylesheetUrls) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@megabudino/stack-utils",
3
- "version": "2.0.2",
3
+ "version": "2.2.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": {
@@ -41,6 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "cheerio": "^1.2.0",
44
+ "undici": "^7.24.0",
44
45
  "node-addon-api": "^8.8.0",
45
46
  "node-gyp": "^12.3.0",
46
47
  "sharp": "^0.34.5"