diff --git a/lib/http-outgoing.js b/lib/http-outgoing.js index 85b1cade..aa47e1ab 100644 --- a/lib/http-outgoing.js +++ b/lib/http-outgoing.js @@ -70,6 +70,7 @@ export default class PodletClientHttpOutgoing extends PassThrough { #uri; #js; #css; + #hintsReceived = false; /** * @constructor @@ -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. * @@ -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 = () => {}) { diff --git a/lib/resolver.content.js b/lib/resolver.content.js index bf3ebdc6..73d1f633 100644 --- a/lib/resolver.content.js +++ b/lib/resolver.content.js @@ -139,8 +139,6 @@ export default class PodletClientContentResolver { outgoing.contentUri, ); - let hintsReceived = false; - /** @type {import('./http.js').PodiumHttpClientRequestOptions} */ const reqOptions = { rejectUnauthorized: outgoing.rejectUnauthorized, @@ -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( @@ -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; } }, }; diff --git a/lib/resource.js b/lib/resource.js index 7e959ea6..43f985d2 100644 --- a/lib/resource.js +++ b/lib/resource.js @@ -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) { diff --git a/package.json b/package.json index 09f46f68..e6c3e96a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/integration.basic.test.js b/tests/integration.basic.test.js index 0974659b..e665e434 100644 --- a/tests/integration.basic.test.js +++ b/tests/integration.basic.test.js @@ -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 = ` + + + ${css.map((style) => style.toHTML()).join('')} + ${js.map((script) => script.toHTML()).join('')} + + + `; + + t.equal( + documentHead.trim().replace(/>\s*<'), + ` + + + + + + + ` + .trim() + .replace(/>\s*<'), + ); + t.end(); + }); + + await Promise.all([ + headerClient.fetch(incoming), + footerClient.fetch(incoming), + ]); + + await Promise.all([header.close(), footer.close()]); + }, +); diff --git a/tests/resource.test.js b/tests/resource.test.js index 5d2b4c23..c7af9c2a 100644 --- a/tests/resource.test.js +++ b/tests/resource.test.js @@ -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(); + }, +);