diff --git a/.changeset/serious-pears-joke.md b/.changeset/serious-pears-joke.md new file mode 100644 index 000000000000..209cd02a8d49 --- /dev/null +++ b/.changeset/serious-pears-joke.md @@ -0,0 +1,5 @@ +--- +"@sveltejs/kit": patch +--- + +fix: ensure assets are served gzip in preview diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 95e11bfc6666..1c9cffe0a981 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -1,5 +1,5 @@ import fs from 'node:fs'; -import path, { join } from 'node:path'; +import path from 'node:path'; import { svelte } from '@sveltejs/vite-plugin-svelte'; import colors from 'kleur'; @@ -19,14 +19,12 @@ import { dev } from './dev/index.js'; import { is_illegal, module_guard, normalize_id } from './graph_analysis/index.js'; import { preview } from './preview/index.js'; import { get_config_aliases, get_env, strip_virtual_prefix } from './utils.js'; -import { SVELTE_KIT_ASSETS } from '../../constants.js'; import { write_client_manifest } from '../../core/sync/write_client_manifest.js'; import prerender from '../../core/postbuild/prerender.js'; import analyse from '../../core/postbuild/analyse.js'; import { s } from '../../utils/misc.js'; import { hash } from '../../runtime/hash.js'; import { dedent, isSvelte5Plus } from '../../core/sync/utils.js'; -import sirv from 'sirv'; import { env_dynamic_private, env_dynamic_public, @@ -622,31 +620,6 @@ function kit({ svelte_config }) { * @see https://vitejs.dev/guide/api-plugin.html#configurepreviewserver */ configurePreviewServer(vite) { - // generated client assets and the contents of `static` - // should we use Vite's built-in asset server for this? - // we would need to set the outDir to do so - const { paths } = svelte_config.kit; - const assets = paths.assets ? SVELTE_KIT_ASSETS : paths.base; - vite.middlewares.use( - scoped( - assets, - sirv(join(svelte_config.kit.outDir, 'output/client'), { - setHeaders: (res, pathname) => { - if (pathname.startsWith(`/${svelte_config.kit.appDir}/immutable`)) { - res.setHeader('cache-control', 'public,max-age=31536000,immutable'); - } - if (vite_config.preview.cors) { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader( - 'Access-Control-Allow-Headers', - 'Origin, Content-Type, Accept, Range' - ); - } - } - }) - ) - ); - return preview(vite, vite_config, svelte_config); }, @@ -939,25 +912,3 @@ const create_service_worker_module = (config) => dedent` export const prerendered = []; export const version = ${s(config.kit.version.name)}; `; - -/** - * @param {string} scope - * @param {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: () => void) => void} handler - * @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: () => void) => void} - */ -function scoped(scope, handler) { - if (scope === '') return handler; - - return (req, res, next) => { - if (req.url?.startsWith(scope)) { - const original_url = req.url; - req.url = req.url.slice(scope.length); - handler(req, res, () => { - req.url = original_url; - next(); - }); - } else { - next(); - } - }; -} diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index fe44e4524deb..997922bb5272 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -7,6 +7,7 @@ import { loadEnv, normalizePath } from 'vite'; import { getRequest, setResponse } from '../../../exports/node/index.js'; import { installPolyfills } from '../../../exports/node/polyfills.js'; import { SVELTE_KIT_ASSETS } from '../../../constants.js'; +import { not_found } from '../utils.js'; /** @typedef {import('http').IncomingMessage} Req */ /** @typedef {import('http').ServerResponse} Res */ @@ -21,6 +22,7 @@ export async function preview(vite, vite_config, svelte_config) { installPolyfills(); const { paths } = svelte_config.kit; + const base = paths.base; const assets = paths.assets ? SVELTE_KIT_ASSETS : paths.base; const protocol = vite_config.preview.https ? 'https' : 'http'; @@ -49,79 +51,131 @@ export async function preview(vite, vite_config, svelte_config) { }); return () => { - // prerendered dependencies + // Remove the base middleware. It screws with the URL. + // It also only lets through requests beginning with the base path, so that requests beginning + // with the assets URL never reach us. We could serve assets separately before the base + // middleware, but we'd need that to occur after the compression and cors middlewares, so would + // need to insert it manually into the stack, which would be at least as bad as doing this. + for (let i = vite.middlewares.stack.length - 1; i > 0; i--) { + // @ts-expect-error using internals + if (vite.middlewares.stack[i].handle.name === 'viteBaseMiddleware') { + vite.middlewares.stack.splice(i, 1); + } + } + + // generated client assets and the contents of `static` vite.middlewares.use( - mutable(join(svelte_config.kit.outDir, 'output/prerendered/dependencies')) + scoped( + assets, + sirv(join(svelte_config.kit.outDir, 'output/client'), { + setHeaders: (res, pathname) => { + // only apply to immutable directory, not e.g. version.json + if (pathname.startsWith(`/${svelte_config.kit.appDir}/immutable`)) { + res.setHeader('cache-control', 'public,max-age=31536000,immutable'); + } + } + }) + ) ); - // prerendered pages (we can't just use sirv because we need to - // preserve the correct trailingSlash behaviour) vite.middlewares.use((req, res, next) => { - let if_none_match_value = req.headers['if-none-match']; - - if (if_none_match_value?.startsWith('W/"')) { - if_none_match_value = if_none_match_value.substring(2); - } - - if (if_none_match_value === etag) { - res.statusCode = 304; + const original_url = /** @type {string} */ (req.url); + const { pathname, search } = new URL(original_url, 'http://dummy'); + + // if `paths.base === '/a/b/c`, then the root route is `/a/b/c/`, + // regardless of the `trailingSlash` route option + if (base.length > 1 && pathname === base) { + let location = base + '/'; + if (search) location += search; + res.writeHead(307, { + location + }); res.end(); return; } - const { pathname, search } = new URL(/** @type {string} */ (req.url), 'http://dummy'); + if (pathname.startsWith(base)) { + next(); + } else { + res.statusCode = 404; + not_found(req, res, base); + } + }); - let filename = normalizePath( - join(svelte_config.kit.outDir, 'output/prerendered/pages' + pathname) - ); - let prerendered = is_file(filename); + // prerendered dependencies + vite.middlewares.use( + scoped(base, mutable(join(svelte_config.kit.outDir, 'output/prerendered/dependencies'))) + ); - if (!prerendered) { - const has_trailing_slash = pathname.endsWith('/'); - const html_filename = `${filename}${has_trailing_slash ? 'index.html' : '.html'}`; + // prerendered pages (we can't just use sirv because we need to + // preserve the correct trailingSlash behaviour) + vite.middlewares.use( + scoped(base, (req, res, next) => { + let if_none_match_value = req.headers['if-none-match']; - /** @type {string | undefined} */ - let redirect; + if (if_none_match_value?.startsWith('W/"')) { + if_none_match_value = if_none_match_value.substring(2); + } - if (is_file(html_filename)) { - filename = html_filename; - prerendered = true; - } else if (has_trailing_slash) { - if (is_file(filename.slice(0, -1) + '.html')) { - redirect = pathname.slice(0, -1); - } - } else if (is_file(filename + '/index.html')) { - redirect = pathname + '/'; + if (if_none_match_value === etag) { + res.statusCode = 304; + res.end(); + return; } - if (redirect) { - if (search) redirect += search; - res.writeHead(307, { - location: redirect - }); + const { pathname, search } = new URL(/** @type {string} */ (req.url), 'http://dummy'); + + let filename = normalizePath( + join(svelte_config.kit.outDir, 'output/prerendered/pages' + pathname) + ); + let prerendered = is_file(filename); + + if (!prerendered) { + const has_trailing_slash = pathname.endsWith('/'); + const html_filename = `${filename}${has_trailing_slash ? 'index.html' : '.html'}`; + + /** @type {string | undefined} */ + let redirect; + + if (is_file(html_filename)) { + filename = html_filename; + prerendered = true; + } else if (has_trailing_slash) { + if (is_file(filename.slice(0, -1) + '.html')) { + redirect = pathname.slice(0, -1); + } + } else if (is_file(filename + '/index.html')) { + redirect = pathname + '/'; + } - res.end(); + if (redirect) { + if (search) redirect += search; + res.writeHead(307, { + location: redirect + }); - return; + res.end(); + + return; + } } - } - if (prerendered) { - res.writeHead(200, { - 'content-type': lookup(pathname) || 'text/html', - etag - }); + if (prerendered) { + res.writeHead(200, { + 'content-type': lookup(pathname) || 'text/html', + etag + }); - fs.createReadStream(filename).pipe(res); - } else { - next(); - } - }); + fs.createReadStream(filename).pipe(res); + } else { + next(); + } + }) + ); // SSR vite.middlewares.use(async (req, res) => { const host = req.headers['host']; - req.url = req.originalUrl; const request = await getRequest({ base: `${protocol}://${host}`, @@ -155,6 +209,28 @@ const mutable = (dir) => }) : (_req, _res, next) => next(); +/** + * @param {string} scope + * @param {Handler} handler + * @returns {Handler} + */ +function scoped(scope, handler) { + if (scope === '') return handler; + + return (req, res, next) => { + if (req.url?.startsWith(scope)) { + const original_url = req.url; + req.url = req.url.slice(scope.length); + handler(req, res, () => { + req.url = original_url; + next(); + }); + } else { + next(); + } + }; +} + /** @param {string} path */ function is_file(path) { return fs.existsSync(path) && !fs.statSync(path).isDirectory();