From 80b02c6f66bbe0b6d25011a3406a89fdc0f3b3ee Mon Sep 17 00:00:00 2001 From: Richard Walker Date: Wed, 25 Sep 2024 13:10:53 +1200 Subject: [PATCH 01/15] feat: html streaming --- example/streaming/podlets/content.js | 45 +++++++ example/streaming/podlets/footer.js | 45 +++++++ example/streaming/podlets/header.js | 50 ++++++++ example/streaming/podlets/menu.js | 62 ++++++++++ example/streaming/server.js | 169 +++++++++++++++++++++++++++ example/streaming/views/template.js | 86 ++++++++++++++ lib/layout.js | 15 +++ lib/response-stream.js | 37 ++++++ lib/template.js | 86 ++++++++++++++ package.json | 6 +- 10 files changed, 598 insertions(+), 3 deletions(-) create mode 100644 example/streaming/podlets/content.js create mode 100644 example/streaming/podlets/footer.js create mode 100644 example/streaming/podlets/header.js create mode 100644 example/streaming/podlets/menu.js create mode 100644 example/streaming/server.js create mode 100644 example/streaming/views/template.js create mode 100644 lib/response-stream.js create mode 100644 lib/template.js diff --git a/example/streaming/podlets/content.js b/example/streaming/podlets/content.js new file mode 100644 index 00000000..5ebc8418 --- /dev/null +++ b/example/streaming/podlets/content.js @@ -0,0 +1,45 @@ +import Podlet from '@podium/podlet'; +import express from 'express'; + +const podlet = new Podlet({ + name: 'content', + version: Date.now().toString(), + pathname: '/', +}); + +podlet.css({ value: 'http://localhost:6103/css' }); + +const app = express(); + +app.use(podlet.middleware()); + +app.get('/manifest.json', (req, res) => { + res.send(podlet); +}); + +app.get('/css', (req, res) => { + res.set('Content-Type', 'text/css'); + res.send(` + .content { + border: 1px solid black; + border-radius: 5px; + width: 100%; + padding: 20px; + margin: 0; + margin-bottom: 20px; + box-sizing: border-box; + } + `); +}); + +app.get('/', (req, res) => { + res.send(` +
+ main content goes here +
+ `); +}); + +app.listen(6103, () => { + console.log(`content podlet server running at http://localhost:6103`); +}); diff --git a/example/streaming/podlets/footer.js b/example/streaming/podlets/footer.js new file mode 100644 index 00000000..f556e1db --- /dev/null +++ b/example/streaming/podlets/footer.js @@ -0,0 +1,45 @@ +import Podlet from '@podium/podlet'; +import express from 'express'; + +const podlet = new Podlet({ + name: 'footer', + version: Date.now().toString(), + pathname: '/', +}); + +podlet.css({ value: 'http://localhost:6104/css' }); + +const app = express(); + +app.use(podlet.middleware()); + +app.get('/manifest.json', (req, res) => { + res.send(podlet); +}); + +app.get('/css', (req, res) => { + res.set('Content-Type', 'text/css'); + res.send(` + footer { + border: 1px solid black; + border-radius: 5px; + width: 100%; + padding: 20px; + margin: 0; + margin-bottom: 20px; + box-sizing: border-box; + } + `); +}); + +app.get('/', (req, res) => { + res.send(` + + `); +}); + +app.listen(6104, () => { + console.log(`footer podlet server running at http://localhost:6104`); +}); diff --git a/example/streaming/podlets/header.js b/example/streaming/podlets/header.js new file mode 100644 index 00000000..b2c98eb9 --- /dev/null +++ b/example/streaming/podlets/header.js @@ -0,0 +1,50 @@ +import Podlet from '@podium/podlet'; +import express from 'express'; + +const podlet = new Podlet({ + name: 'header', + version: Date.now().toString(), + pathname: '/', +}); + +podlet.css({ value: 'http://localhost:6101/css' }); + +const app = express(); + +app.use(podlet.middleware()); + +app.get('/manifest.json', (req, res) => { + res.send(podlet); +}); + +app.get('/css', (req, res) => { + res.set('Content-Type', 'text/css'); + res.send(` + header { + border: 1px solid black; + border-radius: 5px; + width: 100%; + padding: 20px; + margin: 0; + margin-bottom: 20px; + box-sizing: border-box; + } + header h1 { + text-align: center; + margin: 0; + padding: 0; + } + `); +}); + +app.get('/', (req, res) => { + res.send(` +
+

Header

+
+ `); +}); + +app.listen(6101, () => { + console.log(`header podlet server running at http://localhost:6101`); +}); diff --git a/example/streaming/podlets/menu.js b/example/streaming/podlets/menu.js new file mode 100644 index 00000000..d86db66e --- /dev/null +++ b/example/streaming/podlets/menu.js @@ -0,0 +1,62 @@ +import Podlet from '@podium/podlet'; +import express from 'express'; + +const podlet = new Podlet({ + name: 'menu', + version: Date.now().toString(), + pathname: '/', +}); + +podlet.css({ value: 'http://localhost:6102/css' }); + +const app = express(); + +app.use(podlet.middleware()); + +app.get('/manifest.json', (req, res) => { + res.send(podlet); +}); + +app.get('/css', (req, res) => { + res.set('Content-Type', 'text/css'); + res.send(` + menu { + border: 1px solid black; + border-radius: 5px; + width: 100%; + padding: 10px; + margin: 0; + margin-bottom: 20px; + box-sizing: border-box; + } + menu ul { + list-style: none; + padding: 0; + margin: 0; + display: flex; + justify-content: space-evenly; + align-items: center; + } + menu ul li { + margin: 0; + padding: 0; + } + `); +}); + +app.get('/', (req, res) => { + res.send(` + + + + `); +}); + +app.listen(6102, () => { + console.log(`menu podlet server running at http://localhost:6102`); +}); diff --git a/example/streaming/server.js b/example/streaming/server.js new file mode 100644 index 00000000..0894a6b0 --- /dev/null +++ b/example/streaming/server.js @@ -0,0 +1,169 @@ +import express from 'express'; +import Layout from '../../lib/layout.js'; +import { template } from './views/template.js'; + +const layout = new Layout({ + pathname: '/foo', + logger: console, + name: 'demo', +}); + +// use our custom streaming template +layout.view(template); + +const content = layout.client.register({ + name: 'content', + uri: 'http://localhost:6103/manifest.json', +}); + +const header = layout.client.register({ + name: 'header', + uri: 'http://localhost:6101/manifest.json', +}); + +const menu = layout.client.register({ + name: 'menu', + uri: 'http://localhost:6102/manifest.json', +}); + +const footer = layout.client.register({ + name: 'footer', + uri: 'http://localhost:6104/manifest.json', +}); + +layout.css({ value: '/css' }); + +const app = express(); + +app.use(layout.pathname(), layout.middleware()); + +app.get('/foo/css', (req, res) => { + res.set('Content-Type', 'text/css'); + res.send(` + @keyframes pulse { + 0% { + background-color: #e0e0e0; + } + 50% { + background-color: #f0f0f0; + } + 100% { + background-color: #e0e0e0; + } + } + .skeleton { + width: 100%; + background-color: #e0e0e0; + border-radius: 5px; + animation: pulse 1.5s infinite ease-in-out; + margin: 0; + margin-bottom: 20px; + box-sizing: border-box; + } + .skeleton.header { + height:79px; + } + .skeleton.menu { + height:40px; + } + .skeleton.content { + height:60px; + } + .skeleton.footer { + height:60px; + } + `); +}); + +app.get(layout.pathname(), async (req, res) => { + const incoming = res.locals.podium; + + incoming.view = { + title: 'Example streaming application', + }; + + const headerFetch = header.fetch(incoming); + const menuFetch = menu.fetch(incoming); + const contentFetch = content.fetch(incoming); + const footerFetch = footer.fetch(incoming); + + incoming.hints.on('complete', async ({ js, css }) => { + // set the assets on httpincoming so that they are available in the document template + incoming.js = [...incoming.js, ...js]; + incoming.css = [...incoming.css, ...css]; + + // set up the stream which will send the document template head + const stream = res.podiumStream(); + + // stream in the document body with slot placeholders for podlets + stream.send(` + + `); + + // fake 1 second delay + await new Promise((res) => setTimeout(res, 1000)); + + // stream in podlet content when available... + headerFetch.then((content) => { + stream.send(`
${content}
`); + }); + + await new Promise((res) => setTimeout(res, 1000)); + + menuFetch.then((content) => { + stream.send(`
${content}
`); + }); + + await new Promise((res) => setTimeout(res, 1000)); + + contentFetch.then((content) => { + stream.send(`
${content}
`); + }); + + await new Promise((res) => setTimeout(res, 1000)); + + footerFetch.then((content) => { + stream.send(`
${content}
`); + }); + + // close out the dom and the stream + await Promise.all([headerFetch, menuFetch, contentFetch, footerFetch]); + stream.done(); + }); +}); + +app.use(`${layout.pathname()}/assets`, express.static('assets')); + +// eslint-disable-next-line no-unused-vars +app.use((error, req, res, next) => { + console.error(error); + res.status(500).send( + '

Internal server error

', + ); +}); + +app.listen(6123, () => { + console.log(`layout server running at http://localhost:6123`); +}); diff --git a/example/streaming/views/template.js b/example/streaming/views/template.js new file mode 100644 index 00000000..6236b227 --- /dev/null +++ b/example/streaming/views/template.js @@ -0,0 +1,86 @@ +import * as utils from '@podium/utils'; +import { ResponseStream } from '../../../lib/response-stream.js'; + +/** + * @param {import('@podium/utils').PodiumHttpIncoming} incoming Typically res.local.podium + * @param {string | import('./response-stream.js')} [body=''] HTML content for or a writeable stream to write the template to + * @param {string} [head=''] HTML content for + * @returns {string | undefined} HTML document as a string + */ +export const template = (incoming, body = '', head = '') => { + let scripts = incoming.js; + let styles = incoming.css; + const lang = + incoming.view.locale || + incoming.context['podium-locale'] || + incoming.context.locale || + incoming.params?.locale || + 'en-US'; + + // backwards compatibility for scripts and styles + if (typeof incoming.js === 'string') + scripts = [{ type: 'default', value: incoming.js }]; + if (typeof incoming.css === 'string') + styles = [{ type: 'text/css', value: incoming.css, rel: 'stylesheet' }]; + + const documentHeaders = /* html */ ` + + + + + + ${styles.map(utils.buildLinkElement).join('\n ')} + ${scripts + .filter( + (script) => + typeof script !== 'string' && + script.strategy === 'beforeInteractive', + ) + .map(utils.buildScriptElement) + .join('\n ')} + ${incoming.view.title ? incoming.view.title : ''} + ${head} + + `; + + const documentTrailers = `${scripts + .filter( + (script) => + typeof script === 'string' || + script.strategy === 'afterInteractive' || + !script.strategy, + ) + .map(utils.buildScriptElement) + .join('\n ')} + ${scripts + .filter( + (script) => + typeof script !== 'string' && script.strategy === 'lazy', + ) + .map( + (script) => + ``, + ) + .join('\n ')} + +`; + + // If body is a string, template behaves basically as before + if (!(body instanceof ResponseStream)) { + return `${documentHeaders}${body}${documentTrailers}`; + } + + // If body is a response stream, template does streaming + // first wait for assets to be ready and then send the document head + body.send(documentHeaders); + + // Once the developer hands back control to Podium, we send the document closing html + body.on('done', () => { + body.send(documentTrailers); + body.end(); + }); +}; diff --git a/lib/layout.js b/lib/layout.js index 761aea0d..02da3a7f 100644 --- a/lib/layout.js +++ b/lib/layout.js @@ -17,6 +17,7 @@ import Proxy from '@podium/proxy'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import fs from 'fs'; +import { ResponseStream } from './response-stream.js'; // Re-export these types from @podium/client so folks can avoid // installing it just for types (and then getting out of sync @@ -615,6 +616,20 @@ export default class PodiumLayout { res.podiumSend = (data, ...args) => res.send(this.render(incoming, data, ...args)); + // we add a new podiumStream method that sets up streaming and returns the stream + // object for the developer to work with + res.podiumStream = (...args) => { + const responseStream = new ResponseStream(); + // pipe the readable response stream into the express writeable res stream + // to set up streaming + responseStream.pipe(res); + // html template changed to accept body as either a string as before + // or as in this case, a response stream object. + this.#view(incoming, responseStream, ...args); + // return the stream for the developer to work with + return responseStream; + }; + next(); } catch (error) { next(error); diff --git a/lib/response-stream.js b/lib/response-stream.js new file mode 100644 index 00000000..1d45638b --- /dev/null +++ b/lib/response-stream.js @@ -0,0 +1,37 @@ +import { Readable } from 'node:stream'; + +export class ResponseStream extends Readable { + constructor(options) { + super(options); + this.buffer = []; + this.isReading = false; + } + + // Method to add data to the internal buffer + // This method simply adds data; the stream will request it when needed + send(data) { + this.buffer.push(data); + if (this.isReading) { + this.isReading = false; + this._read(); + } + } + + // _read method, automatically called by the stream when it wants more data + _read() { + if (this.buffer.length > 0) { + const chunk = this.buffer.shift(); // Get the next chunk from the buffer + this.push(chunk); // Push the chunk into the stream + } else { + this.isReading = true; + } + } + + done() { + this.emit('done'); + } + + end() { + setTimeout(() => this.push(null), 0); + } +} diff --git a/lib/template.js b/lib/template.js new file mode 100644 index 00000000..73e66841 --- /dev/null +++ b/lib/template.js @@ -0,0 +1,86 @@ +import * as utils from '@podium/utils'; +import { ResponseStream } from './response-stream.js'; + +/** + * @param {import('@podium/utils').PodiumHttpIncoming} incoming Typically res.local.podium + * @param {string | import('./response-stream.js')} [body=''] HTML content for or a writeable stream to write the template to + * @param {string} [head=''] HTML content for + * @returns {string | undefined} HTML document as a string + */ +export const document = (incoming, body = '', head = '') => { + let scripts = incoming.js; + let styles = incoming.css; + const lang = + incoming.view.locale || + incoming.context['podium-locale'] || + incoming.context.locale || + incoming.params?.locale || + 'en-US'; + + // backwards compatibility for scripts and styles + if (typeof incoming.js === 'string') + scripts = [{ type: 'default', value: incoming.js }]; + if (typeof incoming.css === 'string') + styles = [{ type: 'text/css', value: incoming.css, rel: 'stylesheet' }]; + + const documentHeaders = /* html */ ` + + + + + + ${styles.map(utils.buildLinkElement).join('\n ')} + ${scripts + .filter( + (script) => + typeof script !== 'string' && + script.strategy === 'beforeInteractive', + ) + .map(utils.buildScriptElement) + .join('\n ')} + ${incoming.view.title ? incoming.view.title : ''} + ${head} + + `; + + const documentTrailers = `${scripts + .filter( + (script) => + typeof script === 'string' || + script.strategy === 'afterInteractive' || + !script.strategy, + ) + .map(utils.buildScriptElement) + .join('\n ')} + ${scripts + .filter( + (script) => + typeof script !== 'string' && script.strategy === 'lazy', + ) + .map( + (script) => + ``, + ) + .join('\n ')} + +`; + + // If body is a string, template behaves basically as before + if (!(body instanceof ResponseStream)) { + return `${documentHeaders}${body}${documentTrailers}`; + } + + // If body is a response stream, template does streaming + // first wait for assets to be ready and then send the document head + body.send(documentHeaders); + + // Once the developer hands back control to Podium, we send the document closing html + body.on('done', () => { + body.send(documentTrailers); + body.end(); + }); +}; diff --git a/package.json b/package.json index 56a07d4c..bc6f20ff 100644 --- a/package.json +++ b/package.json @@ -41,18 +41,18 @@ }, "dependencies": { "@metrics/client": "2.5.3", - "@podium/client": "5.2.0-next.2", + "@podium/client": "5.2.0-next.4", "@podium/context": "5.0.27", "@podium/proxy": "5.0.26", "@podium/schemas": "5.0.6", - "@podium/utils": "5.2.0", + "@podium/utils": "5.3.1", "abslog": "2.4.4", "ajv": "8.17.1", "lodash.merge": "4.6.2", "objobj": "1.0.0" }, "devDependencies": { - "@podium/podlet": "5.2.0-next.3", + "@podium/podlet": "5.2.0-next.4", "@podium/test-utils": "3.0.9", "@semantic-release/changelog": "6.0.3", "@semantic-release/commit-analyzer": "11.1.0", From 2c2b292539bd27bb31df1ed4eb0746afd43e2f1e Mon Sep 17 00:00:00 2001 From: Richard Walker Date: Wed, 25 Sep 2024 16:50:16 +1200 Subject: [PATCH 02/15] refactor: remove the need to rewrite html templates --- example/streaming/server.js | 4 -- example/streaming/views/template.js | 86 ----------------------------- lib/layout.js | 18 +++++- lib/template.js | 86 ----------------------------- 4 files changed, 17 insertions(+), 177 deletions(-) delete mode 100644 example/streaming/views/template.js delete mode 100644 lib/template.js diff --git a/example/streaming/server.js b/example/streaming/server.js index 0894a6b0..f10843bf 100644 --- a/example/streaming/server.js +++ b/example/streaming/server.js @@ -1,6 +1,5 @@ import express from 'express'; import Layout from '../../lib/layout.js'; -import { template } from './views/template.js'; const layout = new Layout({ pathname: '/foo', @@ -8,9 +7,6 @@ const layout = new Layout({ name: 'demo', }); -// use our custom streaming template -layout.view(template); - const content = layout.client.register({ name: 'content', uri: 'http://localhost:6103/manifest.json', diff --git a/example/streaming/views/template.js b/example/streaming/views/template.js deleted file mode 100644 index 6236b227..00000000 --- a/example/streaming/views/template.js +++ /dev/null @@ -1,86 +0,0 @@ -import * as utils from '@podium/utils'; -import { ResponseStream } from '../../../lib/response-stream.js'; - -/** - * @param {import('@podium/utils').PodiumHttpIncoming} incoming Typically res.local.podium - * @param {string | import('./response-stream.js')} [body=''] HTML content for or a writeable stream to write the template to - * @param {string} [head=''] HTML content for - * @returns {string | undefined} HTML document as a string - */ -export const template = (incoming, body = '', head = '') => { - let scripts = incoming.js; - let styles = incoming.css; - const lang = - incoming.view.locale || - incoming.context['podium-locale'] || - incoming.context.locale || - incoming.params?.locale || - 'en-US'; - - // backwards compatibility for scripts and styles - if (typeof incoming.js === 'string') - scripts = [{ type: 'default', value: incoming.js }]; - if (typeof incoming.css === 'string') - styles = [{ type: 'text/css', value: incoming.css, rel: 'stylesheet' }]; - - const documentHeaders = /* html */ ` - - - - - - ${styles.map(utils.buildLinkElement).join('\n ')} - ${scripts - .filter( - (script) => - typeof script !== 'string' && - script.strategy === 'beforeInteractive', - ) - .map(utils.buildScriptElement) - .join('\n ')} - ${incoming.view.title ? incoming.view.title : ''} - ${head} - - `; - - const documentTrailers = `${scripts - .filter( - (script) => - typeof script === 'string' || - script.strategy === 'afterInteractive' || - !script.strategy, - ) - .map(utils.buildScriptElement) - .join('\n ')} - ${scripts - .filter( - (script) => - typeof script !== 'string' && script.strategy === 'lazy', - ) - .map( - (script) => - ``, - ) - .join('\n ')} - -`; - - // If body is a string, template behaves basically as before - if (!(body instanceof ResponseStream)) { - return `${documentHeaders}${body}${documentTrailers}`; - } - - // If body is a response stream, template does streaming - // first wait for assets to be ready and then send the document head - body.send(documentHeaders); - - // Once the developer hands back control to Podium, we send the document closing html - body.on('done', () => { - body.send(documentTrailers); - body.end(); - }); -}; diff --git a/lib/layout.js b/lib/layout.js index 02da3a7f..04f32a5b 100644 --- a/lib/layout.js +++ b/lib/layout.js @@ -625,7 +625,23 @@ export default class PodiumLayout { responseStream.pipe(res); // html template changed to accept body as either a string as before // or as in this case, a response stream object. - this.#view(incoming, responseStream, ...args); + const splitToken = ``; + const html = this.#view(incoming, splitToken, ...args); + const templatePieces = html.split(splitToken); + const header = templatePieces[0]; + const footer = templatePieces[1]; + + console.log(header); + console.log(footer); + + + responseStream.send(header); + + // Once the developer hands back control to Podium, we send the document closing html + responseStream.on('done', () => { + responseStream.send(footer); + responseStream.end(); + }); // return the stream for the developer to work with return responseStream; }; diff --git a/lib/template.js b/lib/template.js deleted file mode 100644 index 73e66841..00000000 --- a/lib/template.js +++ /dev/null @@ -1,86 +0,0 @@ -import * as utils from '@podium/utils'; -import { ResponseStream } from './response-stream.js'; - -/** - * @param {import('@podium/utils').PodiumHttpIncoming} incoming Typically res.local.podium - * @param {string | import('./response-stream.js')} [body=''] HTML content for or a writeable stream to write the template to - * @param {string} [head=''] HTML content for - * @returns {string | undefined} HTML document as a string - */ -export const document = (incoming, body = '', head = '') => { - let scripts = incoming.js; - let styles = incoming.css; - const lang = - incoming.view.locale || - incoming.context['podium-locale'] || - incoming.context.locale || - incoming.params?.locale || - 'en-US'; - - // backwards compatibility for scripts and styles - if (typeof incoming.js === 'string') - scripts = [{ type: 'default', value: incoming.js }]; - if (typeof incoming.css === 'string') - styles = [{ type: 'text/css', value: incoming.css, rel: 'stylesheet' }]; - - const documentHeaders = /* html */ ` - - - - - - ${styles.map(utils.buildLinkElement).join('\n ')} - ${scripts - .filter( - (script) => - typeof script !== 'string' && - script.strategy === 'beforeInteractive', - ) - .map(utils.buildScriptElement) - .join('\n ')} - ${incoming.view.title ? incoming.view.title : ''} - ${head} - - `; - - const documentTrailers = `${scripts - .filter( - (script) => - typeof script === 'string' || - script.strategy === 'afterInteractive' || - !script.strategy, - ) - .map(utils.buildScriptElement) - .join('\n ')} - ${scripts - .filter( - (script) => - typeof script !== 'string' && script.strategy === 'lazy', - ) - .map( - (script) => - ``, - ) - .join('\n ')} - -`; - - // If body is a string, template behaves basically as before - if (!(body instanceof ResponseStream)) { - return `${documentHeaders}${body}${documentTrailers}`; - } - - // If body is a response stream, template does streaming - // first wait for assets to be ready and then send the document head - body.send(documentHeaders); - - // Once the developer hands back control to Podium, we send the document closing html - body.on('done', () => { - body.send(documentTrailers); - body.end(); - }); -}; From fa708b2d526ae0a5dcd5131dc9ccd6672b1a6b02 Mon Sep 17 00:00:00 2001 From: Richard Walker Date: Wed, 25 Sep 2024 16:53:16 +1200 Subject: [PATCH 03/15] chore: remove console logs and add comments --- lib/layout.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/layout.js b/lib/layout.js index 04f32a5b..4391adf6 100644 --- a/lib/layout.js +++ b/lib/layout.js @@ -623,18 +623,16 @@ export default class PodiumLayout { // pipe the readable response stream into the express writeable res stream // to set up streaming responseStream.pipe(res); - // html template changed to accept body as either a string as before - // or as in this case, a response stream object. + + // call our document template, injecting a token we can use to split the template + // into header and footer const splitToken = ``; const html = this.#view(incoming, splitToken, ...args); const templatePieces = html.split(splitToken); const header = templatePieces[0]; const footer = templatePieces[1]; - console.log(header); - console.log(footer); - - + // send the header right away responseStream.send(header); // Once the developer hands back control to Podium, we send the document closing html From 5b91cb7f379a30f75702d818408d48f222436b93 Mon Sep 17 00:00:00 2001 From: Richard Walker Date: Wed, 25 Sep 2024 17:14:52 +1200 Subject: [PATCH 04/15] chore: add simple server example --- .../{server.js => server-advanced.js} | 0 example/streaming/server-simple.js | 91 +++++++++++++++++++ 2 files changed, 91 insertions(+) rename example/streaming/{server.js => server-advanced.js} (100%) create mode 100644 example/streaming/server-simple.js diff --git a/example/streaming/server.js b/example/streaming/server-advanced.js similarity index 100% rename from example/streaming/server.js rename to example/streaming/server-advanced.js diff --git a/example/streaming/server-simple.js b/example/streaming/server-simple.js new file mode 100644 index 00000000..32a3af03 --- /dev/null +++ b/example/streaming/server-simple.js @@ -0,0 +1,91 @@ +import express from 'express'; +import Layout from '../../lib/layout.js'; + +const layout = new Layout({ + pathname: '/foo', + logger: console, + name: 'demo', +}); + +const content = layout.client.register({ + name: 'content', + uri: 'http://localhost:6103/manifest.json', +}); + +const header = layout.client.register({ + name: 'header', + uri: 'http://localhost:6101/manifest.json', +}); + +const menu = layout.client.register({ + name: 'menu', + uri: 'http://localhost:6102/manifest.json', +}); + +const footer = layout.client.register({ + name: 'footer', + uri: 'http://localhost:6104/manifest.json', +}); + +layout.css({ value: '/css' }); + +const app = express(); + +app.use(layout.pathname(), layout.middleware()); + +app.get(layout.pathname(), async (req, res) => { + const incoming = res.locals.podium; + + incoming.view = { + title: 'Example streaming application', + }; + + const headerFetch = header.fetch(incoming); + const menuFetch = menu.fetch(incoming); + const contentFetch = content.fetch(incoming); + const footerFetch = footer.fetch(incoming); + + incoming.hints.on('complete', async ({ js, css }) => { + // set the assets on httpincoming so that they are available in the document template + incoming.js = js; + incoming.css = css; + + // set up the stream which will send the document template head + const stream = res.podiumStream(); + + // pretend the podlets are slow to load + await new Promise((res) => setTimeout(res, 3000)); + + const [header, menu, content, footer] = await Promise.all([ + headerFetch, + menuFetch, + contentFetch, + footerFetch, + ]); + + // stream in the document body with slot placeholders for podlets + stream.send(` +
+
${header}
+
+
${menu}
+
${content}
+
+
${footer}
+
+ `); + stream.done(); + }); +}); + +// eslint-disable-next-line no-unused-vars +app.use((error, req, res, next) => { + console.error(error); + res.status(500).send( + '

Internal server error

', + ); +}); + +app.listen(6123, () => { + console.log(`layout server running at http://localhost:6123`); +}); From 4618d552867ddbc867eb7952840ec8fbd9dee573 Mon Sep 17 00:00:00 2001 From: Richard Walker Date: Wed, 25 Sep 2024 17:18:51 +1200 Subject: [PATCH 05/15] test: fix test snapshot --- tap-snapshots/tests/layout.test.js.test.cjs | 28 ++++++++++----------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/tap-snapshots/tests/layout.test.js.test.cjs b/tap-snapshots/tests/layout.test.js.test.cjs index c57230aa..b6a95f6e 100644 --- a/tap-snapshots/tests/layout.test.js.test.cjs +++ b/tap-snapshots/tests/layout.test.js.test.cjs @@ -7,21 +7,19 @@ 'use strict' exports[`tests/layout.test.js > TAP > Layout() - rendering using a string - with assets > must match snapshot 1`] = ` - - - - - - - - awesome page - extra head stuff - - -
should be wrapped in a doc
- - - + + + + + + + + awesome page + extra head stuff + +
should be wrapped in a doc
+ + ` From 5a575a851a3f0af27f582f0ba8f3a091a9f56856 Mon Sep 17 00:00:00 2001 From: Richard Walker Date: Wed, 25 Sep 2024 17:24:37 +1200 Subject: [PATCH 06/15] test: set snapshot back again --- tap-snapshots/tests/layout.test.js.test.cjs | 28 +++++++++++---------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/tap-snapshots/tests/layout.test.js.test.cjs b/tap-snapshots/tests/layout.test.js.test.cjs index b6a95f6e..c57230aa 100644 --- a/tap-snapshots/tests/layout.test.js.test.cjs +++ b/tap-snapshots/tests/layout.test.js.test.cjs @@ -7,19 +7,21 @@ 'use strict' exports[`tests/layout.test.js > TAP > Layout() - rendering using a string - with assets > must match snapshot 1`] = ` - - - - - - - - awesome page - extra head stuff - -
should be wrapped in a doc
- - + + + + + + + + awesome page + extra head stuff + + +
should be wrapped in a doc
+ + + ` From 858a50f5ec531479bc5286f0d5da77f51997ba74 Mon Sep 17 00:00:00 2001 From: Richard Walker Date: Wed, 25 Sep 2024 17:26:22 +1200 Subject: [PATCH 07/15] chore: ignore type errors --- lib/layout.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/layout.js b/lib/layout.js index 4391adf6..8b469017 100644 --- a/lib/layout.js +++ b/lib/layout.js @@ -575,7 +575,9 @@ export default class PodiumLayout { incoming.css = [...this.cssRoute]; incoming.js = [...this.jsRoute]; + // @ts-ignore if (context) await this.context.process(incoming); + // @ts-ignore if (proxy) await this.httpProxy.process(incoming); return incoming; From ada68129e0fa2fe97182de9403ef1013b569f786 Mon Sep 17 00:00:00 2001 From: Richard Walker Date: Thu, 26 Sep 2024 18:28:35 +1200 Subject: [PATCH 08/15] docs: update docs and add tests --- README.md | 38 +++++++++++++ fixup.js | 2 + tests/layout.test.js | 130 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+) diff --git a/README.md b/README.md index 260e9a05..b8fa120c 100644 --- a/README.md +++ b/README.md @@ -613,6 +613,44 @@ app.get(layout.pathname(), (req, res) => { }); ``` +### res.podiumStream(...templateArguments) + +Method on the `http.ServerResponse` object for streaming HTML to the browser. This function returns a `ResponseStream` object that can then be used to push out HTML to the browser in chunks using its .send() function. Once streaming is finished, the .done() function must be called to close the stream. + +```js +const stream = res.podiumStream(); +stream.send(`
HTML chunk 1
`); +stream.send(`
HTML chunk 2
`); +stream.done(); +``` + +The Podium document template will still be used. When you call res.podiumStream(), the document head will be sent to the browser immediately. Once the .done() function is called, the closing part of the template will be sent before the stream is closed out. + +Note that any arguments passed to .podiumStream(...args) will be passed on to the layout's document template. + +**Working with assets** + +When working with assets, its important to wait for podlets to have sent their assets to the layout via 103 early hints. Use the incoming.hints `complete` event for this. Wait for assets to be ready, set assets on the incoming object and then call res.podiumStream. + +```js +const incoming = res.locals.podium; +const headerFetch = p1Client.fetch(incoming); +const footerFetch = p2Client.fetch(incoming); + +incoming.hints.on('complete', async ({ js, css }) => { + incoming.js = js; + incoming.css = css; + + const stream = res.podiumStream(); + const [header, footer] = await Promise.all([ + headerFetch, + footerFetch, + ]); + stream.send(`
${header}
...
${footer}
`); + stream.done(); +}); +``` + ### .client A property that exposes an instance of the [@podium/client] for fetching content diff --git a/fixup.js b/fixup.js index 581e41fe..0bd1c290 100755 --- a/fixup.js +++ b/fixup.js @@ -6,10 +6,12 @@ let module = path.join(process.cwd(), 'types', 'layout.d.ts'); fs.writeFileSync( module, /* ts */ ` +import { ResponseStream } from './lib/response-stream.js'; declare global { namespace Express { export interface Response { podiumSend(fragment: string, ...args: unknown[]): Response; + podiumStream(...args: unknown[]): ResponseStream; } } } diff --git a/tests/layout.test.js b/tests/layout.test.js index cd1fa3fe..4bd7b29f 100644 --- a/tests/layout.test.js +++ b/tests/layout.test.js @@ -731,3 +731,133 @@ tap.test('Proxy - builds correct proxy url', async (t) => { s1.stop(); s2.stop(); }); + +const podlet = (name, port, assets) => { + const app = express(); + const podlet = new Podlet({ + name, + version: '1.0.0', + pathname: '/', + }); + if (assets && assets.js) { + podlet.js({ value: assets.js, type: 'module' }); + } + if (assets && assets.css) { + podlet.css({ value: assets.css, rel: 'stylesheet', type: 'text/css' }); + } + app.use(podlet.middleware()); + app.get('/manifest.json', (req, res) => res.send(podlet)); + app.get(podlet.content(), (req, res) => res.send(`
${name}
`)); + return stoppable(app.listen(port), 0); +}; + +tap.test('HTTP Streaming', async (t) => { + const p1 = podlet('podlet-registered-name-1', 5053); + const p2 = podlet('podlet-registered-name-2', 5054); + + const app = express(); + const layout = new Layout({ name: 'my-layout', pathname: '/' }); + const p1Client = layout.client.register({ + name: 'podlet-registered-name-1', + uri: 'http://0.0.0.0:5053/manifest.json', + }); + const p2Client = layout.client.register({ + name: 'podlet-registered-name-2', + uri: 'http://0.0.0.0:5054/manifest.json', + }); + app.use(layout.middleware()); + app.get(layout.pathname(), async (req, res) => { + const incoming = res.locals.podium; + const p1fetch = p1Client.fetch(incoming); + const p2fetch = p2Client.fetch(incoming); + + const stream = res.podiumStream(); + + const [p1Content, p2Content] = await Promise.all([p1fetch, p2fetch]); + + stream.send(`
${p1Content}
${p2Content}
`); + + stream.done(); + }); + const l1 = stoppable(app.listen(5064), 0); + + const result = await fetch('http://0.0.0.0:5064'); + const html = await result.text(); + t.match(html, //); + t.match(html, /<\/html>/); + t.match( + html, + /
podlet-registered-name-1<\/div><\/div>
podlet-registered-name-2<\/div><\/div>/, + '', + ); + + p1.stop(); + p2.stop(); + l1.stop(); +}); + +tap.test('HTTP Streaming - with assets', async (t) => { + const p1 = podlet('podlet-registered-name-1', 5073, { + js: '/podlet-registered-name-1.js', + css: '/podlet-registered-name-1.css', + }); + const p2 = podlet('podlet-registered-name-2', 5074, { + js: '/podlet-registered-name-2.js', + css: '/podlet-registered-name-2.css', + }); + + const app = express(); + const layout = new Layout({ name: 'my-layout', pathname: '/' }); + const p1Client = layout.client.register({ + name: 'podlet-registered-name-1', + uri: 'http://0.0.0.0:5073/manifest.json', + }); + const p2Client = layout.client.register({ + name: 'podlet-registered-name-2', + uri: 'http://0.0.0.0:5074/manifest.json', + }); + app.use(layout.middleware()); + app.get(layout.pathname(), async (req, res) => { + const incoming = res.locals.podium; + const p1fetch = p1Client.fetch(incoming); + const p2fetch = p2Client.fetch(incoming); + + incoming.hints.on('complete', async ({ js, css }) => { + incoming.js = js; + incoming.css = css; + const stream = res.podiumStream(); + const [p1Content, p2Content] = await Promise.all([ + p1fetch, + p2fetch, + ]); + stream.send(`
${p1Content}
${p2Content}
`); + stream.done(); + }); + }); + const l1 = stoppable(app.listen(5075), 0); + + const result = await fetch('http://0.0.0.0:5075'); + const html = await result.text(); + + t.match(html, //); + t.match( + html, + //, + ); + t.match( + html, + //, + ); + t.match( + html, + /
podlet-registered-name-1<\/div><\/div>
podlet-registered-name-2<\/div><\/div>/, + '', + ); + t.match(html, /