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

Follow redirects in the loader #1099

Merged
merged 3 commits into from
Mar 21, 2024
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
90 changes: 1 addition & 89 deletions packages/host/tests/helpers/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { findAll, waitUntil, waitFor, click } from '@ember/test-helpers';
import { buildWaiter } from '@ember/test-waiters';
import GlimmerComponent from '@glimmer/component';

import { formatRFC7231, parse } from 'date-fns';
import { parse } from 'date-fns';

import ms from 'ms';

Expand All @@ -18,8 +18,6 @@ import {
RealmInfo,
RealmPermissions,
Deferred,
executableExtensions,
SupportedMimeType,
type TokenClaims,
} from '@cardstack/runtime-common';

Expand All @@ -35,7 +33,6 @@ import {
type EntrySetter,
type SearchEntryWithErrors,
} from '@cardstack/runtime-common/search-index';
import { getFileWithFallbacks } from '@cardstack/runtime-common/stream';

import CardPrerender from '@cardstack/host/components/card-prerender';

Expand Down Expand Up @@ -557,10 +554,6 @@ async function setupTestRealm({
permissions,
realmSecretSeed: testRealmSecretSeed,
});
loader.prependURLHandlers([
(req) => sourceFetchRedirectHandle(req, adapter, realm),
(req) => sourceFetchReturnUrlHandle(req, realm.maybeHandle.bind(realm)),
]);

await realm.ready;
return { realm, adapter };
Expand Down Expand Up @@ -890,87 +883,6 @@ export function diff(
};
}

function isCardSourceFetch(request: Request) {
return (
request.method === 'GET' &&
request.headers.get('Accept') === SupportedMimeType.CardSource &&
request.url.includes(testRealmURL)
);
}

export async function sourceFetchReturnUrlHandle(
request: Request,
defaultHandle: (req: Request) => Promise<Response | null>,
) {
if (isCardSourceFetch(request)) {
let r = await defaultHandle(request);
if (r) {
return new MockRedirectedResponse(r.body, r, request.url) as Response;
}
}
return null;
}

export async function sourceFetchRedirectHandle(
request: Request,
adapter: RealmAdapter,
realm: Realm,
) {
let urlParts = new URL(request.url).pathname.split('.');
if (
isCardSourceFetch(request) &&
urlParts.length === 1 //has no extension
) {
const realmPaths = new RealmPaths(realm.url);
const localPath = realmPaths.local(request.url);
const ref = await getFileWithFallbacks(
localPath,
adapter.openFile.bind(adapter),
executableExtensions,
);
let maybeExtension = ref?.path.split('.').pop();
let responseUrl = maybeExtension
? `${request.url}.${maybeExtension}`
: request.url;

if (
ref &&
(ref.content instanceof ReadableStream ||
ref.content instanceof Uint8Array ||
typeof ref.content === 'string')
) {
let r = createResponse(realm, ref.content, {
headers: {
'last-modified': formatRFC7231(ref.lastModified),
},
});
return new MockRedirectedResponse(r.body, r, responseUrl) as Response;
}
}
return null;
}

export class MockRedirectedResponse extends Response {
private _mockUrl: string;

constructor(
body?: BodyInit | null | undefined,
init?: ResponseInit,
url?: string,
) {
super(body, init);
this._mockUrl = url || '';
}

get redirected() {
return true;
}

get url() {
return this._mockUrl;
}
}

export async function elementIsVisible(element: Element) {
return new Promise((resolve) => {
let intersectionObserver = new IntersectionObserver(function (entries) {
Expand Down
24 changes: 24 additions & 0 deletions packages/realm-server/tests/loader-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,28 @@ module('loader', function (hooks) {
let testingLoader = Loader.getLoaderFor(card);
assert.strictEqual(testingLoader, loader, 'the loaders are the same');
});

test('is able to follow redirects', async function (assert) {
loader.prependURLHandlers([
async (request) => {
if (request.url.includes('node-b.abc')) {
Copy link
Contributor

@tintinthong tintinthong Mar 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whats the 'c' in abc. Is it clearer if its node-b.ab??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.abc is just a url domain I made up.

return new Response('final redirection url');
}
return null;
},
async (request) => {
if (!request.url.includes('node-a.abc')) {
return null;
}
return new Response('redirected', {
status: 301,
headers: new Headers({ Location: `http://node-b.abc` }),
});
},
]);

let response = await loader.fetch(`http://node-a.abc`);
assert.strictEqual(response.url, 'http://node-b.abc/');
assert.true(response.redirected);
});
});
44 changes: 40 additions & 4 deletions packages/runtime-common/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,17 +495,53 @@ export class Loader {
}
}

// For following redirects of responses returned by loader's urlHandlers
private async simulateFetch(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this is good in that it removes the assumptions of the mocked response. Tho the mocked response was contained in tests.

Does this code path actually get passed other than tests?

Copy link
Contributor Author

@jurgenwerk jurgenwerk Mar 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is currently no url handler in the non-test code that would result in a redirect.

Tests use maybeHandle and that one does result in a redirect in some cases (e.g. /sth/person -> sth/person.gts when the mime type indicates code source)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is currently no url handler in the non-test code that would result in a redirect.

Not in tests, I think most redirects occur when clicking the pills. The url fetched is /sth/person. I think maybeHandle does handle that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, I would think that in that case the native fetch is used which follows redirections automatically (by default)

request: Request,
result: Response,
): Promise<Response> {
const urlString = request.url;
let redirectedHeaderKey = 'simulated-fetch-redirected'; // Temporary header to track if the request was redirected in the redirection chain

if (result.status >= 300 && result.status < 400) {
const location = result.headers.get('location');
if (location) {
request.headers.set(redirectedHeaderKey, 'true');
return await this.fetch(new URL(location, urlString), request);
}
}

// We are using Object.defineProperty because `url` and `redirected`
// response properties are read-only. We are overriding these properties to
// conform to the Fetch API specification where the `url` property is set to
// the final URL and the `redirected` property is set to true if the request
// was redirected. Normally, when using a native fetch, these properties are
// set automatically by the client, but in this case, we are simulating the
// fetch and need to set these properties manually.
Comment on lines +514 to +520
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super useful


if (request.url && !result.url) {
Object.defineProperty(result, 'url', { value: urlString });

if (request.headers.get(redirectedHeaderKey) === 'true') {
Object.defineProperty(result, 'redirected', { value: true });
request.headers.delete(redirectedHeaderKey);
}
}

return result;
}

async fetch(
urlOrRequest: string | URL | Request,
init?: RequestInit,
): Promise<Response> {
try {
for (let handler of this.urlHandlers) {
let result = await handler(
this.asUnresolvedRequest(urlOrRequest, init),
);
let request = this.asUnresolvedRequest(urlOrRequest, init);

let result = await handler(request);
if (result) {
return result;
return await this.simulateFetch(request, result);
}
}
return await getNativeFetch()(this.asResolvedRequest(urlOrRequest, init));
Expand Down
Loading