Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix server-side fetch URL replacement #1953

Merged
merged 3 commits into from
Jul 19, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tall-pens-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

Fix URL resolution for server-side fetch
188 changes: 88 additions & 100 deletions packages/kit/src/runtime/server/page/load_node.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import { resolve } from './resolve.js';

const s = JSON.stringify;

const hasScheme = (/** @type {string} */ url) => /^[a-zA-Z]+:/.test(url);

/**
*
* @param {{
Expand Down Expand Up @@ -86,24 +84,92 @@ export async function load_node({
};
}

if (options.read && url.startsWith(options.paths.assets)) {
// when running `start`, or prerendering, `assets` should be
// config.kit.paths.assets, but we should still be able to fetch
// assets directly from `static`
url = url.replace(options.paths.assets, '');
}

if (url.startsWith('//')) {
throw new Error(`Cannot request protocol-relative URL (${url}) in server-side fetch`);
}
const resolved = resolve(request.path, url.split('?')[0]);

let response;

if (hasScheme(url)) {
// possibly external fetch
// handle fetch requests for static assets. e.g. prebaked data, etc.
// we need to support everything the browser's fetch supports
const filename = resolved.replace(options.paths.assets, '').slice(1);
const filename_html = `${filename}/index.html`; // path may also match path/index.html
const asset = options.manifest.assets.find(
(d) => d.file === filename || d.file === filename_html
);

if (asset) {
response = options.read
? new Response(options.read(asset.file), {
headers: {
'content-type': asset.type
}
})
: await fetch(
// TODO we need to know what protocol to use
`http://${page.host}/${asset.file}`,
/** @type {RequestInit} */ (opts)
);
} else if (resolved.startsWith(options.paths.base)) {
const relative = resolved.replace(options.paths.base, '');

const headers = /** @type {import('types/helper').Headers} */ ({ ...opts.headers });

// TODO: fix type https://github.com/node-fetch/node-fetch/issues/1113
if (opts.credentials !== 'omit') {
uses_credentials = true;

headers.cookie = request.headers.cookie;

if (!headers.authorization) {
headers.authorization = request.headers.authorization;
}
}

if (opts.body && typeof opts.body !== 'string') {
// per https://developer.mozilla.org/en-US/docs/Web/API/Request/Request, this can be a
// Blob, BufferSource, FormData, URLSearchParams, USVString, or ReadableStream object.
// non-string bodies are irksome to deal with, but luckily aren't particularly useful
// in this context anyway, so we take the easy route and ban them
throw new Error('Request body must be a string');
}

const search = url.includes('?') ? url.slice(url.indexOf('?') + 1) : '';

const rendered = await respond(
{
host: request.host,
method: opts.method || 'GET',
headers,
path: relative,
rawBody: /** @type {string} */ (opts.body),
query: new URLSearchParams(search)
},
options,
{
fetched: url,
initiator: route
}
);

if (rendered) {
if (state.prerender) {
state.prerender.dependencies.set(relative, rendered);
}

response = new Response(rendered.body, {
status: rendered.status,
headers: rendered.headers
});
}
} else {
// external
if (resolved.startsWith('//')) {
throw new Error(`Cannot request protocol-relative URL (${url}) in server-side fetch`);
}

// external fetch
if (typeof request.host !== 'undefined') {
const { hostname: fetchHostname } = new URL(url);
const [serverHostname] = request.host.split(':');
const { hostname: fetch_hostname } = new URL(url);
const [server_hostname] = request.host.split(':');

// allow cookie passthrough for "same-origin"
// if SvelteKit is serving my.domain.com:
Expand All @@ -113,7 +179,10 @@ export async function load_node({
// - sub.my.domain.com WILL receive cookies
// ports do not affect the resolution
// leading dot prevents mydomain.com matching domain.com
if (`.${fetchHostname}`.endsWith(`.${serverHostname}`) && opts.credentials !== 'omit') {
if (
`.${fetch_hostname}`.endsWith(`.${server_hostname}`) &&
opts.credentials !== 'omit'
) {
uses_credentials = true;

opts.headers = {
Expand All @@ -123,89 +192,8 @@ export async function load_node({
}
}

const externalRequest = new Request(url, /** @type {RequestInit} */ (opts));
response = await options.hooks.serverFetch.call(null, externalRequest);
} else {
const [path, search] = url.split('?');

// otherwise we're dealing with an internal fetch
const resolved = resolve(request.path, path);

// handle fetch requests for static assets. e.g. prebaked data, etc.
// we need to support everything the browser's fetch supports
const filename = resolved.slice(1);
const filename_html = `${filename}/index.html`; // path may also match path/index.html
const asset = options.manifest.assets.find(
(d) => d.file === filename || d.file === filename_html
);

if (asset) {
// we don't have a running server while prerendering because jumping between
// processes would be inefficient so we have options.read instead
if (options.read) {
response = new Response(options.read(asset.file), {
headers: {
'content-type': asset.type
}
});
} else {
// TODO we need to know what protocol to use
response = await fetch(
`http://${page.host}/${asset.file}`,
/** @type {RequestInit} */ (opts)
);
}
}

if (!response) {
const headers = /** @type {import('types/helper').Headers} */ ({ ...opts.headers });

// TODO: fix type https://github.com/node-fetch/node-fetch/issues/1113
if (opts.credentials !== 'omit') {
uses_credentials = true;

headers.cookie = request.headers.cookie;

if (!headers.authorization) {
headers.authorization = request.headers.authorization;
}
}

if (opts.body && typeof opts.body !== 'string') {
// per https://developer.mozilla.org/en-US/docs/Web/API/Request/Request, this can be a
// Blob, BufferSource, FormData, URLSearchParams, USVString, or ReadableStream object.
// non-string bodies are irksome to deal with, but luckily aren't particularly useful
// in this context anyway, so we take the easy route and ban them
throw new Error('Request body must be a string');
}

const rendered = await respond(
{
host: request.host,
method: opts.method || 'GET',
headers,
path: resolved,
rawBody: /** @type {string} */ (opts.body),
query: new URLSearchParams(search)
},
options,
{
fetched: url,
initiator: route
}
);

if (rendered) {
if (state.prerender) {
state.prerender.dependencies.set(resolved, rendered);
}

response = new Response(rendered.body, {
status: rendered.status,
headers: rendered.headers
});
}
}
const external_request = new Request(url, /** @type {RequestInit} */ (opts));
response = await options.hooks.serverFetch.call(null, external_request);
}

if (response) {
Expand Down
13 changes: 10 additions & 3 deletions packages/kit/src/runtime/server/page/resolve.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
const absolute = /^([a-z]+:)?\/?\//;

/**
* @param {string} base
* @param {string} path
*/
export function resolve(base, path) {
const baseparts = path[0] === '/' ? [] : base.slice(1).split('/');
const pathparts = path[0] === '/' ? path.slice(1).split('/') : path.split('/');
const base_match = absolute.exec(base);
const path_match = absolute.exec(path);

const baseparts = path_match ? [] : base.slice(base_match[0].length).split('/');
const pathparts = path_match ? path.slice(path_match[0].length).split('/') : path.split('/');

baseparts.pop();

Expand All @@ -15,5 +20,7 @@ export function resolve(base, path) {
else baseparts.push(part);
}

return `/${baseparts.join('/')}`;
const prefix = (path_match && path_match[0]) || (base_match && base_match[0]) || '';

return `${prefix}${baseparts.join('/')}`;
}
8 changes: 8 additions & 0 deletions packages/kit/src/runtime/server/page/resolve.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,12 @@ test('resolves a root-relative path with .', () => {
assert.equal(resolve('/a/b/c', '/x/./y/../z'), '/x/z');
});

test('resolves a protocol-relative path', () => {
assert.equal(resolve('/a/b/c', '//example.com/foo'), '//example.com/foo');
});

test('resolves an absolute path', () => {
assert.equal(resolve('/a/b/c', 'https://example.com/foo'), 'https://example.com/foo');
});

test.run();