@megabudino/stack-utils 1.4.1 → 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.
- package/README.md +17 -5
- package/dist/proxy/client.d.ts +33 -0
- package/dist/proxy/client.js +101 -0
- package/dist/proxy/define-islands.d.ts +16 -0
- package/dist/proxy/define-islands.js +21 -0
- package/dist/proxy/handle.js +53 -8
- package/dist/proxy/index.d.ts +2 -1
- package/dist/proxy/index.js +1 -0
- package/dist/proxy/islands.d.ts +51 -0
- package/dist/proxy/islands.js +105 -0
- package/dist/proxy/rewrite.d.ts +6 -2
- package/dist/proxy/rewrite.js +37 -3
- package/dist/proxy/transform-html.d.ts +20 -0
- package/dist/proxy/transform-html.js +80 -0
- package/dist/proxy/types.d.ts +152 -41
- package/dist/proxy/vite-plugin-core.d.ts +57 -0
- package/dist/proxy/vite-plugin-core.js +288 -0
- package/dist/proxy/vite-plugin.d.ts +2 -0
- package/dist/proxy/vite-plugin.js +292 -0
- package/dist/proxy/vite.d.ts +1 -0
- package/dist/proxy/vite.js +1 -0
- package/package.json +9 -1
- package/dist/proxy/dom-actions.d.ts +0 -9
- package/dist/proxy/dom-actions.js +0 -54
package/README.md
CHANGED
|
@@ -43,25 +43,37 @@ export const handle = createProxyHandle({
|
|
|
43
43
|
});
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
If you want client-side proxy islands with auto-generated browser wiring, also install the Vite plugin once:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { sveltekit } from '@sveltejs/kit/vite';
|
|
50
|
+
import { defineConfig } from 'vite';
|
|
51
|
+
import { proxyIslands } from '@megabudino/stack-utils/proxy/vite';
|
|
52
|
+
|
|
53
|
+
export default defineConfig({
|
|
54
|
+
plugins: [proxyIslands(), sveltekit()]
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
46
58
|
Proxy config:
|
|
47
59
|
|
|
48
60
|
| Option | Type | Default | Description |
|
|
49
61
|
| --- | --- | --- | --- |
|
|
50
62
|
| `originUrl` | `string` | required | Origin server URL |
|
|
51
63
|
| `siteUrl` | `string` | required | Public site URL |
|
|
52
|
-
| `
|
|
53
|
-
| `sitemapCacheSeconds` | `number` | `3600` | Cache max-age for sitemap responses |
|
|
64
|
+
| `sitemap` | `SitemapConfig` | `{}` | Sitemap index injection and origin URL filtering |
|
|
54
65
|
| `proxyAssets` | `boolean` | `false` | Rewrite origin asset URLs to relative paths |
|
|
55
66
|
| `additionalOrigins` | `string[]` | `[]` | Additional absolute origins to rewrite like `originUrl` |
|
|
56
|
-
| `
|
|
57
|
-
| `
|
|
67
|
+
| `transformDom` | `TransformDomHook` | — | `($, ctx)` cheerio hook against the proxied document |
|
|
68
|
+
| `islands` | `ProxyIslandsConfig` | — | Svelte component registry used by `transformDom`'s `ctx.renderIsland(...)` |
|
|
69
|
+
| `textReplacements` | `Record<string, string>` | `{}` | Final string replacements applied after `transformDom` |
|
|
58
70
|
|
|
59
71
|
What it does:
|
|
60
72
|
|
|
61
73
|
- lets SvelteKit routes resolve normally when a route exists
|
|
62
74
|
- proxies unmatched requests to the configured origin
|
|
63
75
|
- rewrites redirects back to the public domain
|
|
64
|
-
- rewrites HTML,
|
|
76
|
+
- rewrites HTML, runs the optional `transformDom` hook against cheerio, and rewrites sitemap XML when configured
|
|
65
77
|
|
|
66
78
|
### `images`
|
|
67
79
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side island bootstrap.
|
|
3
|
+
*
|
|
4
|
+
* Lives in a separate subpath (`@megabudino/stack-utils/proxy/client`) so the
|
|
5
|
+
* server proxy can keep depending on `svelte/server` while this entry point
|
|
6
|
+
* resolves to the browser build of `svelte` (for `mount` / `hydrate`).
|
|
7
|
+
*/
|
|
8
|
+
import type { IslandComponent, IslandMode } from './types.js';
|
|
9
|
+
export interface MountIslandsConfig {
|
|
10
|
+
/** Registry of components keyed by the same names used on the server. */
|
|
11
|
+
components: Record<string, IslandComponent>;
|
|
12
|
+
/**
|
|
13
|
+
* Override the default mode used when an island has no
|
|
14
|
+
* `data-stack-island-mode` attribute. Defaults to `'mount'`.
|
|
15
|
+
*/
|
|
16
|
+
defaultMode?: Exclude<IslandMode, 'serverOnly'>;
|
|
17
|
+
/**
|
|
18
|
+
* Root element to search. Defaults to `document`. Pass a specific node
|
|
19
|
+
* when re-mounting a subtree after client-side navigation.
|
|
20
|
+
*/
|
|
21
|
+
root?: ParentNode;
|
|
22
|
+
/**
|
|
23
|
+
* Optional logger for missing-component or malformed-props warnings.
|
|
24
|
+
* Defaults to `console.warn`. Pass `null` to silence.
|
|
25
|
+
*/
|
|
26
|
+
onWarn?: ((message: string) => void) | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Walk the DOM and activate every island the server tagged with
|
|
30
|
+
* `data-stack-island`. Safe to call more than once: already-activated islands
|
|
31
|
+
* carry a marker and are skipped on subsequent calls.
|
|
32
|
+
*/
|
|
33
|
+
export declare function mountIslands(config: MountIslandsConfig): void;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side island bootstrap.
|
|
3
|
+
*
|
|
4
|
+
* Lives in a separate subpath (`@megabudino/stack-utils/proxy/client`) so the
|
|
5
|
+
* server proxy can keep depending on `svelte/server` while this entry point
|
|
6
|
+
* resolves to the browser build of `svelte` (for `mount` / `hydrate`).
|
|
7
|
+
*/
|
|
8
|
+
import { hydrate, mount } from 'svelte';
|
|
9
|
+
const DATA_ISLAND_ATTR = 'data-stack-island';
|
|
10
|
+
const DATA_ISLAND_MODE_ATTR = 'data-stack-island-mode';
|
|
11
|
+
const DATA_ISLAND_ID_ATTR = 'data-stack-island-id';
|
|
12
|
+
const DATA_PROPS_FOR_ATTR = 'data-stack-island-for';
|
|
13
|
+
/**
|
|
14
|
+
* Walk the DOM and activate every island the server tagged with
|
|
15
|
+
* `data-stack-island`. Safe to call more than once: already-activated islands
|
|
16
|
+
* carry a marker and are skipped on subsequent calls.
|
|
17
|
+
*/
|
|
18
|
+
export function mountIslands(config) {
|
|
19
|
+
const root = config.root ?? document;
|
|
20
|
+
const warn = config.onWarn === undefined ? defaultWarn : config.onWarn;
|
|
21
|
+
const defaultMode = config.defaultMode ?? 'mount';
|
|
22
|
+
const elements = root.querySelectorAll(`[${DATA_ISLAND_ATTR}]`);
|
|
23
|
+
for (const el of elements) {
|
|
24
|
+
activateIsland(el, config.components, defaultMode, warn);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const ACTIVATED = Symbol.for('stack-utils.island.activated');
|
|
28
|
+
function activateIsland(el, components, defaultMode, warn) {
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
+
if (el[ACTIVATED]) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const name = el.getAttribute(DATA_ISLAND_ATTR);
|
|
34
|
+
if (!name) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const rawMode = el.getAttribute(DATA_ISLAND_MODE_ATTR);
|
|
38
|
+
const mode = rawMode ?? defaultMode;
|
|
39
|
+
if (mode === 'serverOnly') {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const Component = components[name];
|
|
43
|
+
if (!Component) {
|
|
44
|
+
warn?.(`[stack-utils] island component "${name}" is not registered on the client.`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const props = readProps(el, name, warn);
|
|
48
|
+
try {
|
|
49
|
+
if (mode === 'hydrate') {
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
51
|
+
hydrate(Component, { target: el, props, recover: true });
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
el.replaceChildren();
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
|
+
mount(Component, { target: el, props });
|
|
57
|
+
}
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
59
|
+
el[ACTIVATED] = true;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
warn?.(`[stack-utils] island "${name}" failed to ${mode}: ${stringifyError(error)}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function readProps(el, name, warn) {
|
|
66
|
+
const islandId = el.getAttribute(DATA_ISLAND_ID_ATTR);
|
|
67
|
+
if (!islandId) {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
const script = document.querySelector(`script[type="application/json"][${DATA_PROPS_FOR_ATTR}="${cssEscape(islandId)}"]`);
|
|
71
|
+
if (!script) {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(script.textContent ?? '{}');
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
warn?.(`[stack-utils] failed to parse props for island "${name}": ${stringifyError(error)}`);
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function defaultWarn(message) {
|
|
83
|
+
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
|
|
84
|
+
console.warn(message);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function stringifyError(error) {
|
|
88
|
+
if (error instanceof Error) {
|
|
89
|
+
return error.message;
|
|
90
|
+
}
|
|
91
|
+
return String(error);
|
|
92
|
+
}
|
|
93
|
+
/** Minimal CSS.escape fallback (covers the id strings we emit). */
|
|
94
|
+
function cssEscape(value) {
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
96
|
+
const native = globalThis.CSS?.escape;
|
|
97
|
+
if (typeof native === 'function') {
|
|
98
|
+
return native(value);
|
|
99
|
+
}
|
|
100
|
+
return value.replace(/[^a-zA-Z0-9\-_]/g, (ch) => `\\${ch}`);
|
|
101
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ProxyIslandsConfig } from './types.js';
|
|
2
|
+
declare const AUTO_WIRED_PROXY_ISLANDS: unique symbol;
|
|
3
|
+
type AutoWiredProxyIslandsConfig = ProxyIslandsConfig & {
|
|
4
|
+
readonly [AUTO_WIRED_PROXY_ISLANDS]?: true;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Declare a proxy islands registry in a form the `proxyIslands()` Vite plugin
|
|
8
|
+
* can recognize and auto-wire for the browser.
|
|
9
|
+
*
|
|
10
|
+
* Runtime behavior stays minimal: the helper returns the original config
|
|
11
|
+
* object with a non-enumerable internal marker so the proxy can produce a
|
|
12
|
+
* helpful error when client islands are used without the build plugin.
|
|
13
|
+
*/
|
|
14
|
+
export declare function defineProxyIslands<T extends ProxyIslandsConfig>(config: T): T;
|
|
15
|
+
export declare function isAutoWiredProxyIslandsConfig(config: ProxyIslandsConfig | undefined): config is AutoWiredProxyIslandsConfig;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const AUTO_WIRED_PROXY_ISLANDS = Symbol.for('stack-utils.proxy.auto-wired-islands');
|
|
2
|
+
/**
|
|
3
|
+
* Declare a proxy islands registry in a form the `proxyIslands()` Vite plugin
|
|
4
|
+
* can recognize and auto-wire for the browser.
|
|
5
|
+
*
|
|
6
|
+
* Runtime behavior stays minimal: the helper returns the original config
|
|
7
|
+
* object with a non-enumerable internal marker so the proxy can produce a
|
|
8
|
+
* helpful error when client islands are used without the build plugin.
|
|
9
|
+
*/
|
|
10
|
+
export function defineProxyIslands(config) {
|
|
11
|
+
Object.defineProperty(config, AUTO_WIRED_PROXY_ISLANDS, {
|
|
12
|
+
value: true,
|
|
13
|
+
enumerable: false,
|
|
14
|
+
configurable: false,
|
|
15
|
+
writable: false
|
|
16
|
+
});
|
|
17
|
+
return config;
|
|
18
|
+
}
|
|
19
|
+
export function isAutoWiredProxyIslandsConfig(config) {
|
|
20
|
+
return Boolean(config && config[AUTO_WIRED_PROXY_ISLANDS]);
|
|
21
|
+
}
|
package/dist/proxy/handle.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { injectIntoSitemapIndex, rewriteHtml } from './rewrite.js';
|
|
1
|
+
import { validateIslandsConfigAtStartup } from './islands.js';
|
|
2
|
+
import { injectIntoSitemapIndex, normalizeSitemapPath, removeExcludedUrlsFromSitemap, rewriteHtml } from './rewrite.js';
|
|
3
|
+
import { transformProxiedHtml } from './transform-html.js';
|
|
3
4
|
/**
|
|
4
5
|
* Create a SvelteKit `Handle` hook that reverse-proxies requests to an origin.
|
|
5
6
|
*
|
|
@@ -7,13 +8,19 @@ import { injectIntoSitemapIndex, rewriteHtml } from './rewrite.js';
|
|
|
7
8
|
* everything else is forwarded to the origin.
|
|
8
9
|
*/
|
|
9
10
|
export function createProxyHandle(config) {
|
|
11
|
+
assertNoRemovedConfig(config);
|
|
10
12
|
const { originUrl, siteUrl } = config;
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
+
const sitemap = config.sitemap ?? {};
|
|
14
|
+
const sitemapIndexPath = sitemap.indexPath ? normalizeSitemapPath(sitemap.indexPath) : undefined;
|
|
15
|
+
const sitemapIncludeFiles = sitemap.includeFiles ?? [];
|
|
16
|
+
const sitemapExcludePaths = sitemap.excludePaths ?? [];
|
|
17
|
+
const sitemapCache = `public, max-age=${sitemap.cacheSeconds ?? 3600}`;
|
|
13
18
|
const proxyAssets = config.proxyAssets ?? false;
|
|
14
19
|
const additionalOrigins = config.additionalOrigins ?? [];
|
|
15
20
|
const maxRedirects = config.maxInternalRedirects ?? 5;
|
|
16
|
-
const
|
|
21
|
+
const transformDom = config.transformDom;
|
|
22
|
+
const islandsConfig = config.islands;
|
|
23
|
+
validateIslandsConfigAtStartup(islandsConfig);
|
|
17
24
|
const textReplacements = config.textReplacements ?? {};
|
|
18
25
|
const siteHost = new URL(siteUrl).hostname;
|
|
19
26
|
const originHosts = new Set([
|
|
@@ -68,12 +75,25 @@ export function createProxyHandle(config) {
|
|
|
68
75
|
cleanHeaders.delete('content-encoding');
|
|
69
76
|
cleanHeaders.delete('content-length');
|
|
70
77
|
cleanHeaders.delete('transfer-encoding');
|
|
78
|
+
if (isNullBodyStatus(upstreamResponse.status)) {
|
|
79
|
+
return new Response(null, {
|
|
80
|
+
status: upstreamResponse.status,
|
|
81
|
+
statusText: upstreamResponse.statusText,
|
|
82
|
+
headers: cleanHeaders
|
|
83
|
+
});
|
|
84
|
+
}
|
|
71
85
|
const contentType = upstreamResponse.headers.get('content-type') ?? '';
|
|
72
86
|
if (contentType.includes('text/xml') || contentType.includes('application/xml')) {
|
|
73
87
|
let xml = await upstreamResponse.text();
|
|
74
88
|
xml = xml.replace(/<\?xml-stylesheet[^?]*\?>\s*/g, '');
|
|
75
|
-
|
|
76
|
-
|
|
89
|
+
const isConfiguredSitemapIndex = sitemapIndexPath !== undefined && normalizeSitemapPath(pathname) === sitemapIndexPath;
|
|
90
|
+
if (isConfiguredSitemapIndex &&
|
|
91
|
+
sitemapIncludeFiles.length > 0 &&
|
|
92
|
+
xml.includes('</sitemapindex>')) {
|
|
93
|
+
xml = injectIntoSitemapIndex(xml, siteUrl, sitemapIncludeFiles);
|
|
94
|
+
}
|
|
95
|
+
else if (sitemapExcludePaths.length > 0 && xml.includes('</urlset>')) {
|
|
96
|
+
xml = removeExcludedUrlsFromSitemap(xml, sitemapExcludePaths);
|
|
77
97
|
}
|
|
78
98
|
cleanHeaders.set('cache-control', sitemapCache);
|
|
79
99
|
return new Response(xml, {
|
|
@@ -85,7 +105,12 @@ export function createProxyHandle(config) {
|
|
|
85
105
|
if (contentType.includes('text/html')) {
|
|
86
106
|
const html = await upstreamResponse.text();
|
|
87
107
|
let result = rewriteHtml(html, originUrl, siteUrl, proxyAssets, additionalOrigins);
|
|
88
|
-
result =
|
|
108
|
+
result = await transformProxiedHtml(result, {
|
|
109
|
+
transformDom,
|
|
110
|
+
islandsConfig,
|
|
111
|
+
event,
|
|
112
|
+
url: event.url
|
|
113
|
+
});
|
|
89
114
|
for (const [search, replace] of Object.entries(textReplacements)) {
|
|
90
115
|
result = result.replaceAll(search, replace);
|
|
91
116
|
}
|
|
@@ -103,3 +128,23 @@ export function createProxyHandle(config) {
|
|
|
103
128
|
}
|
|
104
129
|
};
|
|
105
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Surface removed config options at startup with a migration-friendly error
|
|
133
|
+
* instead of silently ignoring them or — worse — applying half of the legacy
|
|
134
|
+
* behavior. The TypeScript types already reject these via
|
|
135
|
+
* `DeprecatedProxyOption` sentinels; this is the JS-runtime safety net.
|
|
136
|
+
*/
|
|
137
|
+
function assertNoRemovedConfig(config) {
|
|
138
|
+
if ('sitemaps' in config) {
|
|
139
|
+
throw new Error('`sitemaps` is deprecated and no longer supported. Use `sitemap.includeFiles` with `sitemap.indexPath` instead.');
|
|
140
|
+
}
|
|
141
|
+
if ('sitemapCacheSeconds' in config) {
|
|
142
|
+
throw new Error('`sitemapCacheSeconds` is deprecated and no longer supported. Use `sitemap.cacheSeconds` instead.');
|
|
143
|
+
}
|
|
144
|
+
if ('domActions' in config) {
|
|
145
|
+
throw new Error('`domActions` was removed in v2. Migrate every transformation to `transformDom: ($, ctx) => { ... }` and call `ctx.renderIsland(...)` for islands.');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function isNullBodyStatus(status) {
|
|
149
|
+
return status === 101 || status === 103 || status === 204 || status === 205 || status === 304;
|
|
150
|
+
}
|
package/dist/proxy/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
+
export { defineProxyIslands } from './define-islands.js';
|
|
1
2
|
export { createProxyHandle } from './handle.js';
|
|
2
|
-
export type {
|
|
3
|
+
export type { IslandComponent, IslandMode, IslandSpec, ProxyConfig, ProxyIslandsConfig, SitemapConfig, TransformDomContext, TransformDomHook, TransformDomTarget } from './types.js';
|
package/dist/proxy/index.js
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side island rendering helpers.
|
|
3
|
+
*
|
|
4
|
+
* Kept in its own module so `svelte/server` is only imported when the proxy
|
|
5
|
+
* actually needs to render an island. The first call lazily resolves the
|
|
6
|
+
* `render` export and caches it for the lifetime of the process.
|
|
7
|
+
*/
|
|
8
|
+
import type { IslandMode, IslandSpec, ProxyIslandsConfig } from './types.js';
|
|
9
|
+
export declare const ISLAND_ATTRS: Readonly<{
|
|
10
|
+
island: "data-stack-island";
|
|
11
|
+
mode: "data-stack-island-mode";
|
|
12
|
+
id: "data-stack-island-id";
|
|
13
|
+
props: "data-stack-island-props";
|
|
14
|
+
propsFor: "data-stack-island-for";
|
|
15
|
+
}>;
|
|
16
|
+
export interface RenderedIsland {
|
|
17
|
+
name: string;
|
|
18
|
+
mode: IslandMode;
|
|
19
|
+
headHtml: string;
|
|
20
|
+
bodyHtml: string;
|
|
21
|
+
serializedProps: string | null;
|
|
22
|
+
}
|
|
23
|
+
/** Render an island spec into SSR HTML plus optional serialized props. */
|
|
24
|
+
export declare function renderIsland(spec: IslandSpec, islandsConfig: ProxyIslandsConfig): Promise<RenderedIsland>;
|
|
25
|
+
/**
|
|
26
|
+
* Escape a JSON payload for safe embedding in a `<script>` tag.
|
|
27
|
+
* Prevents premature `</script>` termination and HTML comment hijacks.
|
|
28
|
+
*/
|
|
29
|
+
export declare function escapeJsonForScript(json: string): string;
|
|
30
|
+
/** Build the `<script>` tag that carries serialized props for a rendered island. */
|
|
31
|
+
export declare function buildPropsScript(islandId: string, serializedProps: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Validate the islands config at startup.
|
|
34
|
+
*
|
|
35
|
+
* Whether or not a request actually renders a client island is a runtime
|
|
36
|
+
* decision (`transformDom` is dynamic), so the per-island checks
|
|
37
|
+
* (unknown name, invalid mode, JSON-serializable props) happen lazily in
|
|
38
|
+
* {@link renderIsland}. Still, when the registry was declared with
|
|
39
|
+
* `defineProxyIslands(...)` we know the consumer expects the
|
|
40
|
+
* `proxyIslands()` Vite plugin to inject `clientEntry`. If it didn't, the
|
|
41
|
+
* plugin is missing from `vite.config.ts` and every client island would fail
|
|
42
|
+
* at the first request — surface that mistake at startup instead.
|
|
43
|
+
*/
|
|
44
|
+
export declare function validateIslandsConfigAtStartup(islandsConfig: ProxyIslandsConfig | undefined): void;
|
|
45
|
+
/**
|
|
46
|
+
* Build the `<script type="module">` tag that loads the consumer's client
|
|
47
|
+
* bootstrap. Returns an empty string when no client entry is configured.
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildBootstrapScript(clientEntry: string): string;
|
|
50
|
+
export declare function buildModulePreloadLink(clientEntry: string): string;
|
|
51
|
+
export declare function buildStylesheetLink(stylesheetUrl: string): string;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side island rendering helpers.
|
|
3
|
+
*
|
|
4
|
+
* Kept in its own module so `svelte/server` is only imported when the proxy
|
|
5
|
+
* actually needs to render an island. The first call lazily resolves the
|
|
6
|
+
* `render` export and caches it for the lifetime of the process.
|
|
7
|
+
*/
|
|
8
|
+
import { isAutoWiredProxyIslandsConfig } from './define-islands.js';
|
|
9
|
+
const DATA_ISLAND_ATTR = 'data-stack-island';
|
|
10
|
+
const DATA_ISLAND_MODE_ATTR = 'data-stack-island-mode';
|
|
11
|
+
const DATA_ISLAND_ID_ATTR = 'data-stack-island-id';
|
|
12
|
+
const DATA_PROPS_ATTR = 'data-stack-island-props';
|
|
13
|
+
const DATA_PROPS_FOR_ATTR = 'data-stack-island-for';
|
|
14
|
+
export const ISLAND_ATTRS = Object.freeze({
|
|
15
|
+
island: DATA_ISLAND_ATTR,
|
|
16
|
+
mode: DATA_ISLAND_MODE_ATTR,
|
|
17
|
+
id: DATA_ISLAND_ID_ATTR,
|
|
18
|
+
props: DATA_PROPS_ATTR,
|
|
19
|
+
propsFor: DATA_PROPS_FOR_ATTR
|
|
20
|
+
});
|
|
21
|
+
let cachedRender = null;
|
|
22
|
+
async function getSvelteRender() {
|
|
23
|
+
if (cachedRender) {
|
|
24
|
+
return cachedRender;
|
|
25
|
+
}
|
|
26
|
+
let mod;
|
|
27
|
+
try {
|
|
28
|
+
mod = (await import('svelte/server'));
|
|
29
|
+
}
|
|
30
|
+
catch (cause) {
|
|
31
|
+
throw new Error('[stack-utils proxy] failed to load `svelte/server`. Islands require Svelte 5 as a peer dependency.', { cause: cause });
|
|
32
|
+
}
|
|
33
|
+
cachedRender = mod.render;
|
|
34
|
+
return cachedRender;
|
|
35
|
+
}
|
|
36
|
+
/** Render an island spec into SSR HTML plus optional serialized props. */
|
|
37
|
+
export async function renderIsland(spec, islandsConfig) {
|
|
38
|
+
const component = islandsConfig.components[spec.name];
|
|
39
|
+
if (!component) {
|
|
40
|
+
throw new Error(`[stack-utils proxy] island component "${spec.name}" is not registered.`);
|
|
41
|
+
}
|
|
42
|
+
const mode = spec.mode ?? 'serverOnly';
|
|
43
|
+
const props = spec.props ?? {};
|
|
44
|
+
const render = await getSvelteRender();
|
|
45
|
+
const output = render(component, { props });
|
|
46
|
+
const headHtml = output.head ?? '';
|
|
47
|
+
const bodyHtml = output.body ?? output.html ?? '';
|
|
48
|
+
let serializedProps = null;
|
|
49
|
+
if (mode !== 'serverOnly') {
|
|
50
|
+
try {
|
|
51
|
+
serializedProps = escapeJsonForScript(JSON.stringify(props));
|
|
52
|
+
}
|
|
53
|
+
catch (cause) {
|
|
54
|
+
throw new Error(`[stack-utils proxy] island "${spec.name}" props must be JSON-serializable for mode "${mode}".`, { cause: cause });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { name: spec.name, mode, headHtml, bodyHtml, serializedProps };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Escape a JSON payload for safe embedding in a `<script>` tag.
|
|
61
|
+
* Prevents premature `</script>` termination and HTML comment hijacks.
|
|
62
|
+
*/
|
|
63
|
+
export function escapeJsonForScript(json) {
|
|
64
|
+
return json.replace(/<\/(script)/gi, '<\\/$1').replace(/<!--/g, '<\\!--');
|
|
65
|
+
}
|
|
66
|
+
/** Build the `<script>` tag that carries serialized props for a rendered island. */
|
|
67
|
+
export function buildPropsScript(islandId, serializedProps) {
|
|
68
|
+
return `<script type="application/json" ${DATA_PROPS_ATTR} ${DATA_PROPS_FOR_ATTR}="${islandId}">${serializedProps}</script>`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Validate the islands config at startup.
|
|
72
|
+
*
|
|
73
|
+
* Whether or not a request actually renders a client island is a runtime
|
|
74
|
+
* decision (`transformDom` is dynamic), so the per-island checks
|
|
75
|
+
* (unknown name, invalid mode, JSON-serializable props) happen lazily in
|
|
76
|
+
* {@link renderIsland}. Still, when the registry was declared with
|
|
77
|
+
* `defineProxyIslands(...)` we know the consumer expects the
|
|
78
|
+
* `proxyIslands()` Vite plugin to inject `clientEntry`. If it didn't, the
|
|
79
|
+
* plugin is missing from `vite.config.ts` and every client island would fail
|
|
80
|
+
* at the first request — surface that mistake at startup instead.
|
|
81
|
+
*/
|
|
82
|
+
export function validateIslandsConfigAtStartup(islandsConfig) {
|
|
83
|
+
if (!islandsConfig) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (isAutoWiredProxyIslandsConfig(islandsConfig) && !islandsConfig.clientEntry) {
|
|
87
|
+
throw new Error('[stack-utils proxy] `islands` was declared with `defineProxyIslands(...)` but no `clientEntry` was injected. Add `proxyIslands()` to your `vite.config.ts` plugins.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Build the `<script type="module">` tag that loads the consumer's client
|
|
92
|
+
* bootstrap. Returns an empty string when no client entry is configured.
|
|
93
|
+
*/
|
|
94
|
+
export function buildBootstrapScript(clientEntry) {
|
|
95
|
+
const escaped = clientEntry.replace(/"/g, '"');
|
|
96
|
+
return `<script type="module" src="${escaped}"></script>`;
|
|
97
|
+
}
|
|
98
|
+
export function buildModulePreloadLink(clientEntry) {
|
|
99
|
+
const escaped = clientEntry.replace(/"/g, '"');
|
|
100
|
+
return `<link rel="modulepreload" href="${escaped}">`;
|
|
101
|
+
}
|
|
102
|
+
export function buildStylesheetLink(stylesheetUrl) {
|
|
103
|
+
const escaped = stylesheetUrl.replace(/"/g, '"');
|
|
104
|
+
return `<link rel="stylesheet" href="${escaped}">`;
|
|
105
|
+
}
|
package/dist/proxy/rewrite.d.ts
CHANGED
|
@@ -9,5 +9,9 @@
|
|
|
9
9
|
* - When `proxyAssets` is false, absolute origin URLs are left as-is.
|
|
10
10
|
*/
|
|
11
11
|
export declare function rewriteHtml(html: string, originUrl: string, siteUrl: string, proxyAssets?: boolean, additionalOrigins?: string[]): string;
|
|
12
|
-
/**
|
|
13
|
-
export declare function
|
|
12
|
+
/** Normalize URL-like values to comparable sitemap pathnames. */
|
|
13
|
+
export declare function normalizeSitemapPath(value: string): string;
|
|
14
|
+
/** Inject local sitemap references into a configured `<sitemapindex>`. */
|
|
15
|
+
export declare function injectIntoSitemapIndex(xml: string, siteUrl: string, includeFiles: string[]): string;
|
|
16
|
+
/** Remove `<url>` entries whose `<loc>` pathname is listed in `excludePaths`. */
|
|
17
|
+
export declare function removeExcludedUrlsFromSitemap(xml: string, excludePaths: string[]): string;
|
package/dist/proxy/rewrite.js
CHANGED
|
@@ -41,10 +41,44 @@ export function rewriteHtml(html, originUrl, siteUrl, proxyAssets = false, addit
|
|
|
41
41
|
}
|
|
42
42
|
return html;
|
|
43
43
|
}
|
|
44
|
-
/**
|
|
45
|
-
export function
|
|
46
|
-
|
|
44
|
+
/** Normalize URL-like values to comparable sitemap pathnames. */
|
|
45
|
+
export function normalizeSitemapPath(value) {
|
|
46
|
+
let pathname = value.trim();
|
|
47
|
+
try {
|
|
48
|
+
pathname = new URL(pathname, 'https://stack-utils.local').pathname;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Fall back to the raw value below; invalid URL-like values should not crash rewriting.
|
|
52
|
+
}
|
|
53
|
+
if (!pathname.startsWith('/')) {
|
|
54
|
+
pathname = `/${pathname}`;
|
|
55
|
+
}
|
|
56
|
+
if (pathname === '/') {
|
|
57
|
+
return pathname;
|
|
58
|
+
}
|
|
59
|
+
return pathname.replace(/\/+$/g, '') || '/';
|
|
60
|
+
}
|
|
61
|
+
/** Inject local sitemap references into a configured `<sitemapindex>`. */
|
|
62
|
+
export function injectIntoSitemapIndex(xml, siteUrl, includeFiles) {
|
|
63
|
+
if (includeFiles.length === 0) {
|
|
64
|
+
return xml;
|
|
65
|
+
}
|
|
66
|
+
const entries = includeFiles
|
|
47
67
|
.map((url) => `\t<sitemap>\n\t\t<loc>${siteUrl}${url}</loc>\n\t</sitemap>`)
|
|
48
68
|
.join('\n');
|
|
49
69
|
return xml.replace('</sitemapindex>', `${entries}\n</sitemapindex>`);
|
|
50
70
|
}
|
|
71
|
+
/** Remove `<url>` entries whose `<loc>` pathname is listed in `excludePaths`. */
|
|
72
|
+
export function removeExcludedUrlsFromSitemap(xml, excludePaths) {
|
|
73
|
+
if (excludePaths.length === 0) {
|
|
74
|
+
return xml;
|
|
75
|
+
}
|
|
76
|
+
const excluded = new Set(excludePaths.map(normalizeSitemapPath));
|
|
77
|
+
return xml.replace(/<url\b[\s\S]*?<\/url>/g, (entry) => {
|
|
78
|
+
const loc = entry.match(/<loc\b[^>]*>\s*([\s\S]*?)\s*<\/loc>/i)?.[1];
|
|
79
|
+
if (!loc) {
|
|
80
|
+
return entry;
|
|
81
|
+
}
|
|
82
|
+
return excluded.has(normalizeSitemapPath(loc)) ? '' : entry;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ProxyIslandsConfig, TransformDomHook } from './types.js';
|
|
2
|
+
export interface TransformProxiedHtmlOptions {
|
|
3
|
+
/** Power-user hook. Receives the cheerio root for the proxied document. */
|
|
4
|
+
transformDom?: TransformDomHook;
|
|
5
|
+
/** Island registry and bootstrap config. Required for any island rendering. */
|
|
6
|
+
islandsConfig?: ProxyIslandsConfig;
|
|
7
|
+
/** SvelteKit request event, forwarded to `transformDom`. */
|
|
8
|
+
event: import('@sveltejs/kit').RequestEvent;
|
|
9
|
+
/** Request URL, forwarded to `transformDom`. Usually `event.url`. */
|
|
10
|
+
url: URL;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Run the `transformDom` hook against the proxied document and finalize the
|
|
14
|
+
* island bootstrap (SSR head dedup, modulepreload, stylesheets, module
|
|
15
|
+
* script).
|
|
16
|
+
*
|
|
17
|
+
* Async because rendering Svelte islands lazily loads `svelte/server` on the
|
|
18
|
+
* first call.
|
|
19
|
+
*/
|
|
20
|
+
export declare function transformProxiedHtml(html: string, options: TransformProxiedHtmlOptions): Promise<string>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { load } from 'cheerio';
|
|
2
|
+
import { ISLAND_ATTRS, buildBootstrapScript, buildModulePreloadLink, buildPropsScript, buildStylesheetLink, renderIsland } from './islands.js';
|
|
3
|
+
/**
|
|
4
|
+
* Run the `transformDom` hook against the proxied document and finalize the
|
|
5
|
+
* island bootstrap (SSR head dedup, modulepreload, stylesheets, module
|
|
6
|
+
* script).
|
|
7
|
+
*
|
|
8
|
+
* Async because rendering Svelte islands lazily loads `svelte/server` on the
|
|
9
|
+
* first call.
|
|
10
|
+
*/
|
|
11
|
+
export async function transformProxiedHtml(html, options) {
|
|
12
|
+
const { transformDom, islandsConfig, event, url } = options;
|
|
13
|
+
if (!transformDom) {
|
|
14
|
+
return html;
|
|
15
|
+
}
|
|
16
|
+
const $ = load(html);
|
|
17
|
+
const tracker = {
|
|
18
|
+
islandCounter: 0,
|
|
19
|
+
clientIslandsRendered: false,
|
|
20
|
+
headSnippets: new Set()
|
|
21
|
+
};
|
|
22
|
+
await transformDom($, {
|
|
23
|
+
event,
|
|
24
|
+
url,
|
|
25
|
+
renderIsland: (target, spec) => renderIslandInto($, target, spec, islandsConfig, tracker)
|
|
26
|
+
});
|
|
27
|
+
finalizeIslandBootstrap($, tracker, islandsConfig);
|
|
28
|
+
return $.html();
|
|
29
|
+
}
|
|
30
|
+
async function renderIslandInto($, target, spec, islandsConfig, tracker) {
|
|
31
|
+
if (target.length === 0) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!islandsConfig?.components) {
|
|
35
|
+
throw new Error('[stack-utils proxy] `renderIsland(...)` was called but no `islands.components` registry was provided on `ProxyConfig.islands`.');
|
|
36
|
+
}
|
|
37
|
+
const rendered = await renderIsland(spec, islandsConfig);
|
|
38
|
+
if (rendered.headHtml) {
|
|
39
|
+
tracker.headSnippets.add(rendered.headHtml);
|
|
40
|
+
}
|
|
41
|
+
target.each((_, raw) => {
|
|
42
|
+
const $el = $(raw);
|
|
43
|
+
const islandId = `stack-island-${++tracker.islandCounter}`;
|
|
44
|
+
$el.html(rendered.bodyHtml);
|
|
45
|
+
$el.attr(ISLAND_ATTRS.island, rendered.name);
|
|
46
|
+
$el.attr(ISLAND_ATTRS.mode, rendered.mode);
|
|
47
|
+
if (rendered.serializedProps !== null) {
|
|
48
|
+
$el.attr(ISLAND_ATTRS.id, islandId);
|
|
49
|
+
$el.after(buildPropsScript(islandId, rendered.serializedProps));
|
|
50
|
+
tracker.clientIslandsRendered = true;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function finalizeIslandBootstrap($, tracker, islandsConfig) {
|
|
55
|
+
if (tracker.headSnippets.size > 0) {
|
|
56
|
+
const head = $('head');
|
|
57
|
+
if (head.length > 0) {
|
|
58
|
+
for (const snippet of tracker.headSnippets) {
|
|
59
|
+
head.append(`\n${snippet}\n`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!tracker.clientIslandsRendered) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!islandsConfig?.clientEntry) {
|
|
67
|
+
throw new Error('[stack-utils proxy] client islands were rendered but `islands.clientEntry` is not configured. Either provide `islands.clientEntry` directly or wrap the config with `defineProxyIslands(...)` and install the `proxyIslands()` Vite plugin.');
|
|
68
|
+
}
|
|
69
|
+
const head = $('head');
|
|
70
|
+
if (head.length > 0 && !islandsConfig.clientEntry.startsWith('data:')) {
|
|
71
|
+
head.append(`\n${buildModulePreloadLink(islandsConfig.clientEntry)}\n`);
|
|
72
|
+
}
|
|
73
|
+
if (head.length > 0) {
|
|
74
|
+
const stylesheets = islandsConfig.clientStylesheets ?? [];
|
|
75
|
+
for (const stylesheetUrl of stylesheets) {
|
|
76
|
+
head.append(`\n${buildStylesheetLink(stylesheetUrl)}\n`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
$('body').append(`\n${buildBootstrapScript(islandsConfig.clientEntry)}\n`);
|
|
80
|
+
}
|