@everystack/server 0.2.28 → 0.2.29
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/package.json +1 -1
- package/src/ssr.ts +74 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.29",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
package/src/ssr.ts
CHANGED
|
@@ -69,6 +69,15 @@ export interface WebHandlerOptions {
|
|
|
69
69
|
/** Inject __EXPO_ROUTER_HYDRATE__ flag before </head>. Default: true. */
|
|
70
70
|
injectHydrateFlag?: boolean;
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Opaque the expo-router loader-data blob so search crawlers can't harvest
|
|
74
|
+
* URL-shaped strings (route keys like `/stories/index`, image/link values)
|
|
75
|
+
* out of the inline hydration script. Default: true. Runtime behaviour is
|
|
76
|
+
* unchanged — the decoded globalThis value is byte-identical. See
|
|
77
|
+
* deharvestLoaderData.
|
|
78
|
+
*/
|
|
79
|
+
deharvestLoaderData?: boolean;
|
|
80
|
+
|
|
72
81
|
/**
|
|
73
82
|
* App-specific HTML post-processing. Called after built-in processing
|
|
74
83
|
* (param stripping, hydrate flag). Receives the processed HTML and the
|
|
@@ -82,6 +91,59 @@ export interface WebHandlerOptions {
|
|
|
82
91
|
const DEFAULT_STRIP_PARAMS = ['_v', '_a'];
|
|
83
92
|
const HYDRATE_SCRIPT = '<script>globalThis.__EXPO_ROUTER_HYDRATE__=true;</script>\n';
|
|
84
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Matches expo-router's `<PreloadedDataScript />` output. The renderer emits
|
|
96
|
+
* exactly:
|
|
97
|
+
*
|
|
98
|
+
* <script id="expo-router-data" type="module">
|
|
99
|
+
* globalThis.__EXPO_ROUTER_LOADER_DATA__ = JSON.parse("<escaped-json>");
|
|
100
|
+
* </script>
|
|
101
|
+
*
|
|
102
|
+
* Group 1 = the opening tag, group 2 = the double-quoted JS string literal
|
|
103
|
+
* argument. The literal is HTML-safe (expo escapes <, >, & to \uXXXX), so a
|
|
104
|
+
* `</script>` terminator never appears inside it.
|
|
105
|
+
*/
|
|
106
|
+
const LOADER_DATA_SCRIPT_RE =
|
|
107
|
+
/(<script id="expo-router-data"[^>]*>globalThis\.__EXPO_ROUTER_LOADER_DATA__\s*=\s*JSON\.parse\()("(?:[^"\\]|\\.)*")(\);<\/script>)/;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Rewrite the expo-router loader-data script so its payload is opaque in the
|
|
111
|
+
* raw HTML while the executed value is byte-identical.
|
|
112
|
+
*
|
|
113
|
+
* expo-router keys the loader map by the route FILE path, so index routes get a
|
|
114
|
+
* `/index` suffix (`/stories/index`). Googlebot harvests URL-shaped strings
|
|
115
|
+
* from inline JS, resolves those same-origin, crawls `/stories/index`, and
|
|
116
|
+
* 404s. The key cannot be renamed: expo-router's client resolver
|
|
117
|
+
* (useLoaderData) looks the data up by the identical `/index` key, so any
|
|
118
|
+
* SSR-only rename mismatches on hydration (white flash).
|
|
119
|
+
*
|
|
120
|
+
* The one server-only fix that removes the harvestable text AND preserves the
|
|
121
|
+
* exact key: base64-encode the JSON payload. No URL-shaped substring survives
|
|
122
|
+
* in the source for the crawler, and at runtime `atob` + TextDecoder + JSON.parse
|
|
123
|
+
* reproduces the same object with the same keys — the client is untouched. This
|
|
124
|
+
* also opaques URL-shaped *values* (image/CDN links inside loader data), killing
|
|
125
|
+
* the whole harvest class, not just `/index` keys.
|
|
126
|
+
*
|
|
127
|
+
* Fail-safe: if the script is absent or doesn't match the expected shape (e.g.
|
|
128
|
+
* an expo-router format change), the HTML is returned unchanged. Idempotent —
|
|
129
|
+
* the rewritten script no longer matches the string-literal form.
|
|
130
|
+
*/
|
|
131
|
+
export function deharvestLoaderData(html: string): string {
|
|
132
|
+
return html.replace(LOADER_DATA_SCRIPT_RE, (full, open: string, literal: string, close: string) => {
|
|
133
|
+
let jsonText: string;
|
|
134
|
+
try {
|
|
135
|
+
// The literal is valid JSON string syntax; parsing yields the JSON text
|
|
136
|
+
// the client would otherwise receive verbatim.
|
|
137
|
+
jsonText = JSON.parse(literal) as string;
|
|
138
|
+
} catch {
|
|
139
|
+
return full; // unexpected shape — leave untouched
|
|
140
|
+
}
|
|
141
|
+
const b64 = Buffer.from(jsonText, 'utf8').toString('base64');
|
|
142
|
+
const decode = `new TextDecoder().decode(Uint8Array.from(atob(${JSON.stringify(b64)}),c=>c.charCodeAt(0)))`;
|
|
143
|
+
return `${open}${decode}${close}`;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
85
147
|
/**
|
|
86
148
|
* Wraps an SSR request handler with framework-level fixes:
|
|
87
149
|
*
|
|
@@ -91,7 +153,10 @@ const HYDRATE_SCRIPT = '<script>globalThis.__EXPO_ROUTER_HYDRATE__=true;</script
|
|
|
91
153
|
* Native Web uses `hydrateRoot()` instead of `createRoot()`.
|
|
92
154
|
* (React 19 suppresses <script> tags from SSR output, so this must
|
|
93
155
|
* be done via string replacement after rendering.)
|
|
94
|
-
* 3.
|
|
156
|
+
* 3. Opaques the expo-router loader-data blob so crawlers can't harvest its
|
|
157
|
+
* URL-shaped route keys (`/{path}/index`) and 404 on them. See
|
|
158
|
+
* deharvestLoaderData.
|
|
159
|
+
* 4. Calls an optional `postProcessHtml` hook for app-specific transforms
|
|
95
160
|
* (e.g., JSON-LD injection, status code override from meta tags).
|
|
96
161
|
*
|
|
97
162
|
* @param innerHandler - The expo-server request handler
|
|
@@ -100,11 +165,12 @@ const HYDRATE_SCRIPT = '<script>globalThis.__EXPO_ROUTER_HYDRATE__=true;</script
|
|
|
100
165
|
*/
|
|
101
166
|
export function wrapSsrHandler(
|
|
102
167
|
innerHandler: (request: Request) => Promise<Response>,
|
|
103
|
-
options?: Pick<WebHandlerOptions, 'stripQueryParams' | 'injectHydrateFlag' | 'postProcessHtml'>,
|
|
168
|
+
options?: Pick<WebHandlerOptions, 'stripQueryParams' | 'injectHydrateFlag' | 'deharvestLoaderData' | 'postProcessHtml'>,
|
|
104
169
|
metadata?: ReleaseMetadata,
|
|
105
170
|
): (request: Request) => Promise<Response> {
|
|
106
171
|
const stripParams = options?.stripQueryParams ?? DEFAULT_STRIP_PARAMS;
|
|
107
172
|
const injectHydrate = options?.injectHydrateFlag !== false;
|
|
173
|
+
const deharvest = options?.deharvestLoaderData !== false;
|
|
108
174
|
const postProcess = options?.postProcessHtml;
|
|
109
175
|
|
|
110
176
|
return async (request: Request): Promise<Response> => {
|
|
@@ -149,6 +215,12 @@ export function wrapSsrHandler(
|
|
|
149
215
|
html = html.replace('</head>', `${HYDRATE_SCRIPT}</head>`);
|
|
150
216
|
}
|
|
151
217
|
|
|
218
|
+
// Opaque the loader-data blob so crawlers can't harvest its URL-shaped
|
|
219
|
+
// route keys (index routes leak `/{path}/index`). No-op if absent.
|
|
220
|
+
if (deharvest) {
|
|
221
|
+
html = deharvestLoaderData(html);
|
|
222
|
+
}
|
|
223
|
+
|
|
152
224
|
// 3. App-specific post-processing
|
|
153
225
|
if (postProcess) {
|
|
154
226
|
const result = await postProcess(html, request);
|