@sveltejs/kit
Version:
SvelteKit is the fastest way to build Svelte apps
284 lines (241 loc) • 8.1 kB
JavaScript
/** @import { NextHandleFunction } from 'connect' */
/** @import { PreviewServer } from 'vite' */
/** @import { ValidatedConfig, ServerModule } from 'types' */
import fs from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { lookup } from '../../../utils/mime.js';
import sirv from 'sirv';
import { loadEnv, normalizePath } from 'vite';
import { createReadableStream, getRequest, setResponse } from '../../../exports/node/index.js';
import { SVELTE_KIT_ASSETS } from '../../../constants.js';
import { relative_pathname } from '../../../utils/url.js';
import { is_chrome_devtools_request, not_found } from '../utils.js';
import { set_error_stack } from '../../../utils/error.js';
import * as e from '../../../messages/build-errors.js';
/**
* @param {PreviewServer} vite
* @param {ValidatedConfig} svelte_config
*/
export async function preview(vite, svelte_config) {
const { paths } = svelte_config;
const base = paths.base;
const assets = paths.assets ? SVELTE_KIT_ASSETS : paths.base;
const protocol = vite.config.preview.https ? 'https' : 'http';
const etag = `"${Date.now()}"`;
const dir = join(svelte_config.outDir, 'output/server');
if (!fs.existsSync(`${dir}/manifest.js`)) {
e.preview_build_missing({ dir }, { stackless: true });
}
const instrumentation = join(dir, 'instrumentation.server.js');
if (fs.existsSync(instrumentation)) {
await import(pathToFileURL(instrumentation).href);
}
/** @type {ServerModule} */
const { configure } = await import(pathToFileURL(join(dir, 'index.js')).href);
/** @type {{ manifest: import('types').SSRManifest }} */
const { manifest } = await import(pathToFileURL(join(dir, 'manifest.js')).href);
/** @type {import('types').ServerInstance} */
let server;
try {
server = await configure({
manifest,
env: loadEnv(vite.config.mode, svelte_config.env.dir, ''),
read: (file) => createReadableStream(`${dir}/${file}`),
assets
});
await server.init();
} catch (error) {
// Vite erases the error message when starting the preview server so we store
// it in the stack instead. This ensures errors thrown using `stackless`
// are still readable
if (error instanceof Error) set_error_stack(error, error.message);
throw error;
}
const emulator = await svelte_config.adapter?.emulate?.();
return () => {
// 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(
scoped(
assets,
sirv(join(svelte_config.outDir, 'output/client'), {
setHeaders: (res, pathname) => {
// only apply to immutable directory, not e.g. version.json
if (pathname.startsWith(`/${svelte_config.appDir}/immutable`)) {
res.setHeader('cache-control', 'public,max-age=31536000,immutable');
}
}
})
)
);
vite.middlewares.use((req, res, next) => {
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;
}
if (is_chrome_devtools_request(pathname, res)) {
return;
}
if (pathname.startsWith(base)) {
next();
} else {
res.statusCode = 404;
not_found(req, res, base);
}
});
// prerendered dependencies
vite.middlewares.use(
scoped(base, mutable(join(svelte_config.outDir, 'output/prerendered/dependencies')))
);
// 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'];
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;
res.end();
return;
}
const { pathname, search } = new URL(/** @type {string} */ (req.url), 'http://dummy');
const dir = pathname.startsWith(`/${svelte_config.appDir}/remote/`) ? 'data' : 'pages';
const root = join(svelte_config.outDir, `output/prerendered/${dir}`);
let decoded = pathname;
try {
decoded = decodeURI(pathname);
} catch {
// malformed URI
}
let filename = normalizePath(join(root, decoded));
let prerendered = is_file(filename, root);
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, root)) {
filename = html_filename;
prerendered = true;
} else if (has_trailing_slash) {
if (is_file(filename.slice(0, -1) + '.html', root)) {
redirect = pathname.slice(0, -1);
}
} else if (is_file(filename + '/index.html', root)) {
redirect = pathname + '/';
}
if (redirect) {
res.writeHead(308, {
// relative so (possibly invisible) path prefixes are preserved
location: relative_pathname(pathname, redirect) + search
});
res.end();
return;
}
}
if (prerendered) {
res.writeHead(200, {
'content-type': lookup(pathname) || 'text/html',
etag
});
fs.createReadStream(filename).pipe(res);
} else {
next();
}
})
);
// SSR
vite.middlewares.use(async (req, res) => {
const host = req.headers[':authority'] || req.headers.host;
const request = (svelte_config.adapter?.vite?.getRequest ?? getRequest)({
base: `${protocol}://${host}`,
request: req,
response: res
});
(svelte_config.adapter?.vite?.setResponse ?? setResponse)(
res,
await server.respond(request, {
getClientAddress: () => {
const { remoteAddress } = req.socket;
if (remoteAddress) return remoteAddress;
throw new Error('Could not determine clientAddress');
},
read: (file) => {
if (file in manifest.server_assets) {
return fs.readFileSync(join(dir, file));
}
return fs.readFileSync(join(svelte_config.files.assets, file));
},
emulator
})
);
});
};
}
/**
* @param {string} dir
* @returns {NextHandleFunction}
*/
const mutable = (dir) =>
fs.existsSync(dir)
? sirv(dir, {
etag: true,
maxAge: 0
})
: (_req, _res, next) => next();
/**
* @param {string} scope
* @param {NextHandleFunction} handler
* @returns {NextHandleFunction}
*/
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
* @param {string} root
* @returns {boolean}
*/
function is_file(path, root) {
return (
fs.existsSync(root) &&
fs.existsSync(path) &&
!fs.statSync(path).isDirectory() &&
// Decoding can introduce path separators on Windows. Check containment after resolving them.
normalizePath(fs.realpathSync(path)).startsWith(`${normalizePath(fs.realpathSync(root))}/`)
);
}