Skip to content

Commit d9d842a

Browse files
fix(platform): gate SSR renderer + add Nitro externals/sanitizer for non-SSR and edge cases
Three small additions to analogNitroPlugin found while migrating the other demo apps: 1. Gate the analog-owned SSR renderer registration behind options.ssr. When ssr is false, leave Nitro's auto-detected template-serving renderer in place — serving the raw index.html for every HTML request is exactly the desired behavior, and our renderer virtual would otherwise try to dispatch to an SSR service that was never built. Lets tailwind-debug-app (ssr: false) build cleanly. 2. Carry over Analog's Nitro externals list. rxjs's facade subpaths confuse Nitro/Rolldown's resolver, node-fetch-native's polyfill rewrites global fetch, and sharp ships platform-specific binaries under @img/sharp-* whose unused symlinks crash Nitro's externals plugin with ENOENT during realpath(). All three were externalized by the legacy @analogjs/vite-plugin-nitro orchestrator; they need to be restored under the new plugin chain or blog-app's nitro env build fails on the sharp symlink walk. 3. Carry over the Nitro/Rolldown bundler-config sanitizer. Nitro's Rollup config sets output.codeSplitting (rejected as unknown by Rolldown), output.manualChunks (crashes Nitro's prerender rebundle), and a chunkFileNames function that emits route-derived [token] patterns Rollup/Rolldown treats as placeholders. The sanitizer strips the first two and rewrites non-standard [token] patterns to _token_. All three apply in the rollup:before hook on the resolved Nitro bundler config so they survive into both the main bundle and the prerender bundle. Refs #2035 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 16e73ab commit d9d842a

1 file changed

Lines changed: 114 additions & 23 deletions

File tree

packages/platform/src/lib/nitro/analog-nitro-plugin.ts

Lines changed: 114 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -210,31 +210,38 @@ export function analogNitroPlugin(options: Options = {}): Plugin {
210210
if (Array.isArray(rollupConfig.plugins)) {
211211
rollupConfig.plugins.push(pageEndpointsPlugin());
212212
}
213+
applyAnalogNitroExternals(rollupConfig);
214+
sanitizeNitroBundlerConfig(rollupConfig);
213215
});
214216

215-
// Override Nitro's auto-detected template-serving renderer with one
216-
// that routes HTML requests to our SSR service. Nitro's
217-
// `resolveRendererOptions` finds `index.html` at the project root
218-
// and installs `internal/routes/renderer-template[.dev]`, which
219-
// just serves the raw template. nitro/vite's own SSR-routing
220-
// renderer only auto-installs when both `renderer.handler` and
221-
// `renderer.template` are empty (vite.mjs:574), which never holds
222-
// for a typical app — so we install our own renderer virtual
223-
// explicitly here.
224-
//
225-
// `#analog/ssr` is a Nitro virtual (not a Vite virtual) so it
226-
// resolves under both Vite-built bundles (main) and Rolldown-built
227-
// bundles (Nitro's prerender, which forces builder: 'rolldown' —
228-
// see nitro/dist/_chunks/nitro.mjs:769). That sidesteps nitro/vite's
229-
// prodSetup polyfill, which is Vite-only and leaves `__nitro_vite_envs__`
230-
// unset in the prerender bundle.
231-
nitro.options.virtual['#analog/ssr'] = () =>
232-
generateSsrServiceVirtual(nitro);
233-
nitro.options.virtual['#analog/ssr-renderer'] =
234-
generateSsrRendererVirtual(readIndexHtml());
235-
nitro.options.renderer ??= {};
236-
nitro.options.renderer.handler = '#analog/ssr-renderer';
237-
delete nitro.options.renderer.template;
217+
if (ssr) {
218+
// Override Nitro's auto-detected template-serving renderer with one
219+
// that routes HTML requests to our SSR service. Nitro's
220+
// `resolveRendererOptions` finds `index.html` at the project root
221+
// and installs `internal/routes/renderer-template[.dev]`, which
222+
// just serves the raw template. nitro/vite's own SSR-routing
223+
// renderer only auto-installs when both `renderer.handler` and
224+
// `renderer.template` are empty (vite.mjs:574), which never holds
225+
// for a typical app — so we install our own renderer virtual
226+
// explicitly here.
227+
//
228+
// `#analog/ssr` is a Nitro virtual (not a Vite virtual) so it
229+
// resolves under both Vite-built bundles (main) and Rolldown-built
230+
// bundles (Nitro's prerender, which forces builder: 'rolldown' —
231+
// see nitro/dist/_chunks/nitro.mjs:769). That sidesteps nitro/vite's
232+
// prodSetup polyfill, which is Vite-only and leaves
233+
// `__nitro_vite_envs__` unset in the prerender bundle.
234+
nitro.options.virtual['#analog/ssr'] = () =>
235+
generateSsrServiceVirtual(nitro);
236+
nitro.options.virtual['#analog/ssr-renderer'] =
237+
generateSsrRendererVirtual(readIndexHtml());
238+
nitro.options.renderer ??= {};
239+
nitro.options.renderer.handler = '#analog/ssr-renderer';
240+
delete nitro.options.renderer.template;
241+
}
242+
// When ssr === false, Nitro's auto-detected template-serving
243+
// renderer is exactly what we want (serve the raw index.html for
244+
// every HTML request) — leave it in place.
238245

239246
injectAnalogRouteRuleHeaders(nitro);
240247

@@ -321,6 +328,90 @@ export default {
321328
return `export { default } from ${JSON.stringify(entryPath)};`;
322329
}
323330

331+
/**
332+
* Packages Analog forces external in the Nitro server bundle. Each entry is
333+
* here for a specific reason — see comments.
334+
*/
335+
const ANALOG_NITRO_EXTERNALS = [
336+
// rxjs ships per-entry CJS/ESM facades that confuse the Nitro/Rolldown
337+
// resolver during bundling.
338+
'rxjs',
339+
// node-fetch-native's polyfill subpath rewrites global fetch and isn't
340+
// safe to inline into the Nitro bundle.
341+
'node-fetch-native/dist/polyfill',
342+
// sharp ships platform-specific native binaries under @img/sharp-*. pnpm
343+
// creates symlinks for ALL optional platform deps but only installs the
344+
// matching one, leaving broken symlinks that crash Nitro's externals
345+
// plugin with ENOENT during realpath(). Externalizing sharp avoids
346+
// bundling it; the user's app resolves it from node_modules at runtime.
347+
'sharp',
348+
];
349+
350+
function applyAnalogNitroExternals(rollupConfig: { external?: unknown }): void {
351+
// Rolldown's `external` only accepts `Array<string | RegExp>`; promote
352+
// whatever shape Nitro gave us (regex, single string, undefined) to an
353+
// array and append Analog's entries as regex patterns that also match
354+
// sub-paths (e.g. `sharp` matches `sharp/lib/foo`).
355+
const prev = rollupConfig.external;
356+
const existing: Array<string | RegExp> =
357+
prev === undefined
358+
? []
359+
: Array.isArray(prev)
360+
? (prev as Array<string | RegExp>)
361+
: prev instanceof RegExp
362+
? [prev]
363+
: typeof prev === 'string'
364+
? [prev]
365+
: [];
366+
367+
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
368+
369+
for (const entry of ANALOG_NITRO_EXTERNALS) {
370+
const pattern = new RegExp(`^${escapeRegExp(entry)}(?:/|$)`);
371+
if (!existing.some((p) => String(p) === String(pattern))) {
372+
existing.push(pattern);
373+
}
374+
}
375+
376+
rollupConfig.external = existing;
377+
}
378+
379+
/**
380+
* Workarounds for Nitro v3 + Rolldown bundler interaction quirks. Each
381+
* is narrowly scoped and can be removed once the upstream bug is fixed:
382+
*
383+
* 1. `output.codeSplitting` — Nitro 3.0.x sets this; Rolldown rejects it
384+
* as an unknown key.
385+
* 2. `output.manualChunks` — Nitro's default manual chunking crashes
386+
* Nitro's prerender rebundle.
387+
* 3. `output.chunkFileNames` — Nitro's chunk-name function produces
388+
* route-derived `[token]` patterns which Rollup/Rolldown interprets as
389+
* placeholders; we rewrite non-standard tokens to `_token_`.
390+
*/
391+
function sanitizeNitroBundlerConfig(rollupConfig: { output?: unknown }): void {
392+
const output = rollupConfig.output;
393+
if (!output || Array.isArray(output) || typeof output !== 'object') return;
394+
const out = output as Record<string, unknown>;
395+
396+
if ('codeSplitting' in out) delete out['codeSplitting'];
397+
if ('manualChunks' in out) delete out['manualChunks'];
398+
399+
const VALID_ROLLUP_PLACEHOLDER = /^\[(?:name|hash|format|ext)\]$/;
400+
const chunkFileNames = out['chunkFileNames'];
401+
if (typeof chunkFileNames === 'function') {
402+
const originalFn = chunkFileNames as (...args: unknown[]) => unknown;
403+
out['chunkFileNames'] = (...args: unknown[]) => {
404+
const result = originalFn(...args);
405+
if (typeof result !== 'string') return result;
406+
return result.replace(/\[[^\]]+\]/g, (match: string) =>
407+
VALID_ROLLUP_PLACEHOLDER.test(match)
408+
? match
409+
: `_${match.slice(1, -1)}_`,
410+
);
411+
};
412+
}
413+
}
414+
324415
/**
325416
* Walks Nitro's resolved routeRules and stamps `x-analog-no-ssr: true` onto
326417
* any rule with `ssr: false`. Kept as a response-header hint for downstream

0 commit comments

Comments
 (0)