@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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.28",
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. Calls an optional `postProcessHtml` hook for app-specific transforms
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);