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

feat: keep track of which resources have emitted early hints and emit complete event once all resources have emitted #421

Merged
merged 6 commits into from
Sep 24, 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
17 changes: 17 additions & 0 deletions lib/http-outgoing.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export default class PodletClientHttpOutgoing extends PassThrough {
#uri;
#js;
#css;
#hintsReceived = false;

/**
* @constructor
Expand Down Expand Up @@ -300,6 +301,20 @@ export default class PodletClientHttpOutgoing extends PassThrough {
this.#redirect = value;
}

get hintsReceived() {
return this.#hintsReceived;
}

set hintsReceived(value) {
this.#hintsReceived = value;
if (this.#hintsReceived) {
this.#incoming?.hints?.addReceivedHint(this.#name, {
js: this.js,
css: this.css,
});
}
}

/**
* Whether the podlet can signal redirects to the layout.
*
Expand Down Expand Up @@ -337,6 +352,8 @@ export default class PodletClientHttpOutgoing extends PassThrough {
this.css = this.#manifest._css;
this.push(null);
this.#isFallback = true;
// assume the hints from the podlet have failed and fallback assets will be used
this.hintsReceived = true;
}

writeEarlyHints(cb = () => {}) {
Expand Down
6 changes: 2 additions & 4 deletions lib/resolver.content.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,6 @@ export default class PodletClientContentResolver {
outgoing.contentUri,
);

let hintsReceived = false;

/** @type {import('./http.js').PodiumHttpClientRequestOptions} */
const reqOptions = {
rejectUnauthorized: outgoing.rejectUnauthorized,
Expand All @@ -149,7 +147,7 @@ export default class PodletClientContentResolver {
query: outgoing.reqOptions.query,
headers,
onInfo: ({ statusCode, headers }) => {
if (statusCode === 103 && !hintsReceived) {
if (statusCode === 103 && !outgoing.hintsReceived) {
const parsedAssetObjects = parseLinkHeaders(headers.link);

const scriptObjects = parsedAssetObjects.filter(
Expand All @@ -165,7 +163,7 @@ export default class PodletClientContentResolver {
// write the early hints to the browser
if (this.#earlyHints) outgoing.writeEarlyHints();

hintsReceived = true;
outgoing.hintsReceived = true;
}
},
};
Expand Down
4 changes: 4 additions & 0 deletions lib/resource.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ export default class PodiumClientResource {
throw new TypeError(
'you must pass an instance of "HttpIncoming" as the first argument to the .fetch() method',
);
// add the name of this resource as expecting a hint to be received
// we use this to track across resources and emit a hint completion event once
// all hints from all resources have been received.
incoming.hints.addExpectedHint(this.#options.name);
const outgoing = new HttpOutgoing(this.#options, reqOptions, incoming);

if (this.#options.excludeBy) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"@hapi/boom": "10.0.1",
"@metrics/client": "2.5.3",
"@podium/schemas": "5.0.6",
"@podium/utils": "5.2.1",
"@podium/utils": "5.3.1",
"abslog": "2.4.4",
"http-cache-semantics": "^4.0.3",
"lodash.clonedeep": "^4.5.0",
Expand Down
63 changes: 63 additions & 0 deletions tests/integration.basic.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -624,3 +624,66 @@ tap.test('integration - 103 early hints disabled', async (t) => {

await server.close();
});

tap.test(
'integration - 103 early hints used to build a document head',
async (t) => {
t.plan(1);
const header = new PodletServer({
name: 'header',
assets: {
js: '/header/bar.js',
css: '/header/bar.css',
},
});
const footer = new PodletServer({
name: 'footer',
assets: {
js: '/footer/bar.js',
css: '/footer/bar.css',
},
});

const service = await Promise.all([header.listen(), footer.listen()]);

const client = new Client({ name: 'podiumClient' });

const headerClient = client.register(service[0].options);
const footerClient = client.register(service[1].options);

const incoming = new HttpIncoming({ headers });

incoming.hints.once('complete', ({ js, css }) => {
const documentHead = `
<html>
<head>
${css.map((style) => style.toHTML()).join('')}
${js.map((script) => script.toHTML()).join('')}
</head>
<body>
`;

t.equal(
documentHead.trim().replace(/>\s*</g, '><'),
`<html>
<head>
<link href="/header/bar.css" type="text/css" rel="stylesheet">
<link href="/footer/bar.css" type="text/css" rel="stylesheet">
<script src="/header/bar.js"></script>
<script src="/footer/bar.js"></script>
</head>
<body>`
.trim()
.replace(/>\s*</g, '><'),
);
t.end();
});

await Promise.all([
headerClient.fetch(incoming),
footerClient.fetch(incoming),
]);

await Promise.all([header.close(), footer.close()]);
},
);
119 changes: 119 additions & 0 deletions tests/resource.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -822,3 +822,122 @@ tap.test(
t.end();
},
);

tap.test(
'Resource().fetch - hints complete event emitted once all early hints received - single resource component',
async (t) => {
t.plan(1);
const server = new PodletServer({
version: '1.0.0',
assets: {
js: '/foo/bar.js',
css: '/foo/bar.css',
},
});
const service = await server.listen();

const client = new Client({ name: 'podiumClient' });
const component = client.register(service.options);

const incoming = new HttpIncoming({ headers: {} });

incoming.hints.on('complete', () => {
t.ok(true);
t.end();
});

await component.fetch(incoming);

await server.close();
},
);

tap.test(
'Resource().fetch - hints complete event emitted once all early hints received - resource is failing',
async (t) => {
t.plan(3);
const server = new PodletServer({
version: '1.0.0',
assets: {
js: '/foo/bar.js',
css: '/foo/bar.css',
},
content: '/does/not/exist',
});
const service = await server.listen();

const client = new Client({ name: 'podiumClient' });
const component = client.register(service.options);

const incoming = new HttpIncoming({ headers: {} });

incoming.hints.on('complete', (assets) => {
t.ok(true);
t.equal(assets.js.length, 1);
t.equal(assets.css.length, 1);
t.end();
});

await component.fetch(incoming);

await server.close();
},
);

tap.test(
'Resource().fetch - hints complete event emitted once all early hints received - multiple resource components',
async (t) => {
t.plan(3);
const server1 = new PodletServer({
name: 'one',
version: '1.0.0',
assets: {
js: '/foo/bar.js',
css: '/foo/bar.css',
},
});
const service1 = await server1.listen();
const server2 = new PodletServer({
name: 'two',
version: '1.0.0',
assets: {
js: '/foo/bar.js',
css: '/foo/bar.css',
},
});
const service2 = await server2.listen();
const server3 = new PodletServer({
name: 'three',
version: '1.0.0',
assets: {
js: '/foo/bar.js',
css: '/foo/bar.css',
},
});
const service3 = await server3.listen();

const client = new Client({ name: 'podiumClient' });
const component1 = client.register(service1.options);
const component2 = client.register(service2.options);
const component3 = client.register(service3.options);

const incoming = new HttpIncoming({ headers: {} });

incoming.hints.on('complete', (assets) => {
t.equal(assets.js.length, 3);
t.equal(assets.css.length, 3);
t.ok(true);
t.end();
});

await Promise.all([
component1.fetch(incoming),
component2.fetch(incoming),
component3.fetch(incoming),
]);

await server1.close();
await server2.close();
await server3.close();
},
);