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}
...`); + stream.done(); +}); +``` + ### .client A property that exposes an instance of the [@podium/client] for fetching content diff --git a/example/streaming/podlets/content.js b/example/streaming/podlets/content.js new file mode 100644 index 00000000..c54d2a76 --- /dev/null +++ b/example/streaming/podlets/content.js @@ -0,0 +1,61 @@ +import Podlet from '@podium/podlet'; +import express from 'express'; + +const podlet = new Podlet({ + name: 'content-podlet', + version: Date.now().toString(), + pathname: '/', + useShadowDOM: true, +}); + +podlet.css({ value: 'http://localhost:6103/css', strategy: 'shadow-dom' }); + +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(` + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + .content { + width: 100%; + display: flex; + flex-direction: column; + gap: 1em; + font-family: Verdana, serif; + font-weight: 400; + font-style: normal; + } + h1 { + color: #136C72; + } + `); +}); + +app.get('/', async (req, res) => { + res.set('Content-Type', 'text/html'); + res.sendHeaders(); + + await new Promise((res) => setTimeout(res, 2200)); + + res.podiumSend(` +
+

Podlets fetched and composed, on demand, just for you

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.

+
+ `); +}); + +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..3a9698e1 --- /dev/null +++ b/example/streaming/podlets/footer.js @@ -0,0 +1,83 @@ +import Podlet from '@podium/podlet'; +import express from 'express'; + +const podlet = new Podlet({ + name: 'footer-podlet', + version: Date.now().toString(), + pathname: '/', + useShadowDOM: true, +}); + +podlet.css({ value: 'http://localhost:6104/css', strategy: 'shadow-dom' }); + +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(` + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + footer { + width: 100%; + background-color: #23424A; + color: white; + padding: 1em 0 6em 0; + font-family: Verdana, serif; + font-weight: 400; + font-style: normal; + } + .container { + width: 75%; + max-width: 1000px; + margin: 0 auto; + } + ul { + list-style: none; + display: flex; + justify-content: space-evenly; + align-items: center; + } + a { + text-transform: upper-case; + color: white; + text-decoration: none; + } + a:hover, a:active { + text-decoration: underline; + } + `); +}); + +app.get('/', async (req, res) => { + res.set('Content-Type', 'text/html'); + res.sendHeaders(); + + await new Promise((res) => setTimeout(res, 100)); + + res.podiumSend(` + + `); +}); + +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..ba14ac3e --- /dev/null +++ b/example/streaming/podlets/header.js @@ -0,0 +1,97 @@ +import Podlet from '@podium/podlet'; +import express from 'express'; + +const podlet = new Podlet({ + name: 'header-podlet', + version: Date.now().toString(), + pathname: '/', + useShadowDOM: true, +}); + +podlet.css({ value: 'http://localhost:6101/css', strategy: 'shadow-dom' }); + +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(` + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + header { + width: 100%; + padding: 6em 0; + background-color: #23424A; + font-size: 1.5rem; + font-family: "Verdana", sans-serif; + font-weight: 400; + font-style: normal; + color: white; + } + h1 { + font-size: 3rem; + color: white; + font-family: "Verdana", sans-serif; + font-weight: 900; + font-style: normal; + } + .container { + width: 75%; + max-width: 1000px; + margin: 0 auto; + } + .inner-container { + display: flex; + flex-direction: column; + gap: 1em; + } + .button { + color: black; + background-color: #38CFD9; + padding: 0.5em 1.25em; + border-radius: 1em; + text-decoration: none; + width: fit-content; + } + .button:hover, .button:active { + text-decoration: underline; + width: fit-content; + } + @media (min-width: 800px) { + .inner-container { + width: 70%; + } + } + `); +}); + +app.get('/', async (req, res) => { + res.set('Content-Type', 'text/html'); + res.sendHeaders(); + + await new Promise((res) => setTimeout(res, 100)); + + res.podiumSend(` +
+
+
+

Podium layouts can be composed using streaming

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.

+ I want to learn +
+
+
+ `); +}); + +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..70488556 --- /dev/null +++ b/example/streaming/podlets/menu.js @@ -0,0 +1,76 @@ +import Podlet from '@podium/podlet'; +import express from 'express'; + +const podlet = new Podlet({ + name: 'menu-podlet', + version: Date.now().toString(), + pathname: '/', + useShadowDOM: true, +}); + +podlet.css({ value: 'http://localhost:6102/css', strategy: 'shadow-dom' }); + +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(` + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + .menu { + width: 100%; + background-color: #136C72; + padding: 1em 0; + font-family: Verdana, serif; + font-weight: 400; + font-style: normal; + } + .menu ul { + list-style: none; + display: flex; + justify-content: space-evenly; + align-items: center; + } + a { + text-transform: upper-case; + color: white; + text-decoration: none; + } + a:hover, a:active { + text-decoration: underline; + } + `); +}); + +app.get('/', async (req, res) => { + res.set('Content-Type', 'text/html'); + res.sendHeaders(); + + // imagine this is your slow database call + await new Promise((res) => setTimeout(res, 100)); + + res.podiumSend(` + + `); +}); + +app.listen(6102, () => { + console.log(`menu podlet server running at http://localhost:6102`); +}); diff --git a/example/streaming/podlets/sidebar.js b/example/streaming/podlets/sidebar.js new file mode 100644 index 00000000..568afbb8 --- /dev/null +++ b/example/streaming/podlets/sidebar.js @@ -0,0 +1,76 @@ +import Podlet from '@podium/podlet'; +import express from 'express'; + +const podlet = new Podlet({ + name: 'sidebar-podlet', + version: Date.now().toString(), + pathname: '/', + useShadowDOM: true, +}); + +podlet.css({ value: 'http://localhost:6105/css', strategy: 'shadow-dom' }); + +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(` + * { + border-sizing: border-box; + margin: 0; + padding: 0; + } + .sidebar { + background-color: #136C72; + color: white; + width: 100%; + padding: 2.5em 1em; + text-align: center; + display: flex; + flex-direction: column; + gap: 2.5em; + font-family: Verdana, serif; + font-weight: 400; + font-style: normal; + } + .info-block { + display: flex; + flex-direction: column; + gap: 1em; + } + `); +}); + +app.get('/', async (req, res) => { + res.set('Content-Type', 'text/html'); + res.sendHeaders(); + + await new Promise((res) => setTimeout(res, 3200)); + + res.podiumSend(` + + `); +}); + +app.listen(6105, () => { + console.log(`content podlet server running at http://localhost:6105`); +}); diff --git a/example/streaming/server-advanced-2.js b/example/streaming/server-advanced-2.js new file mode 100644 index 00000000..188a3422 --- /dev/null +++ b/example/streaming/server-advanced-2.js @@ -0,0 +1,231 @@ +import express from 'express'; +import Layout from '../../lib/layout.js'; + +const layout = new Layout({ + pathname: '/foo', + logger: console, + name: 'demo', + client: { + timeout: 5000, + }, +}); + +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 content = layout.client.register({ + name: 'content', + uri: 'http://localhost:6103/manifest.json', +}); + +const footer = layout.client.register({ + name: 'footer', + uri: 'http://localhost:6104/manifest.json', +}); + +const sidebar = layout.client.register({ + name: 'sidebar', + uri: 'http://localhost:6105/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(` + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + @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; + box-sizing: border-box; + } + .skeleton--header { + height:79px; + } + .skeleton--menu { + height:40px; + } + .skeleton--content { + height:300px; + } + .skeleton--sidebar { + height:500px; + } + .skeleton--footer { + padding: 1em 0 6em 0; + } + .wrapper { + font-family: Verdana, serif; + font-weight: 400; + font-style: normal; + width: 100%; + display: flex; + flex-direction: column; + gap: 2em; + } + .container { + width: 75%; + max-width: 1000px; + margin: 0 auto; + } + .main { + display: flex; + gap: 1em; + flex-direction: column; + @media (min-width: 800px) { + flex-direction: row; + } + } + .content { + flex-grow: 2; + } + .sidebar { + flex-grow: 1; + } + + `); +}); + +app.get(layout.pathname(), async (req, res) => { + const incoming = res.locals.podium; + + // -------------------------------------------------------- + // Define any template variables such as the document title + // -------------------------------------------------------- + + 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); + const sidebarFetch = sidebar.fetch(incoming); + + // ----------------------------------------------------------- + // Start the stream and flush the document head to the browser + // ----------------------------------------------------------- + + // we kick off the stream, this automatically sends the document opening html including everything in the + // as well as the openning tag. + const stream = await res.podiumStream(); + + const [headerContent, menuContent] = await Promise.all([ + headerFetch, + menuFetch, + ]); + + // ----------------------------------------------------------------------- + // Immediately send the document structure and placeholders to the browser + // ----------------------------------------------------------------------- + + // stream in the document body with slot skeleton screen placeholders for podlets + // these will be replaced once the podlets are loaded + stream.send(` + + `); + + // --------------------------------------------------------- + // Option 1. Load podlets one at a time as they are fetched + // --------------------------------------------------------- + + const [contentData, footerData, sidebarData] = await Promise.all([ + contentFetch, + footerFetch, + sidebarFetch, + ]); + + stream.send(` +
${contentData}
+
${footerData}
+
${sidebarData}
+ `); + + // --------------------------------------------------------------------- + // Option 2. Load all podlets at once after everything has been fetched + // --------------------------------------------------------------------- + + // const prom1 = headerFetch.then((data) => { + // stream.send(`
${data}
`); + // }); + // const prom2 = menuFetch.then((data) => { + // stream.send(`
${data}
`); + // }); + // const prom3 = contentFetch.then((data) => { + // stream.send(`
${data}
`); + // }); + // const prom4 = footerFetch.then((data) => { + // stream.send(`
${data}
`); + // }); + + // await Promise.all([prom1, prom2, prom3, prom4]); + + // -------------------------- + // Close the stream when done + // -------------------------- + + 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/server-advanced.js b/example/streaming/server-advanced.js new file mode 100644 index 00000000..5006d207 --- /dev/null +++ b/example/streaming/server-advanced.js @@ -0,0 +1,235 @@ +import express from 'express'; +import Layout from '../../lib/layout.js'; + +const layout = new Layout({ + pathname: '/foo', + logger: console, + name: 'demo', + client: { + timeout: 5000, + }, +}); + +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 content = layout.client.register({ + name: 'content', + uri: 'http://localhost:6103/manifest.json', +}); + +const footer = layout.client.register({ + name: 'footer', + uri: 'http://localhost:6104/manifest.json', +}); + +const sidebar = layout.client.register({ + name: 'sidebar', + uri: 'http://localhost:6105/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(` + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + @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; + box-sizing: border-box; + } + .skeleton--header { + height:600px; + } + .skeleton--menu { + height:40px; + } + .skeleton--content { + height:300px; + } + .skeleton--sidebar { + height:500px; + } + .skeleton--footer { + padding: 1em 0 6em 0; + } + .wrapper { + font-family: Verdana, serif; + font-weight: 400; + font-style: normal; + width: 100%; + display: flex; + flex-direction: column; + gap: 2em; + } + .container { + width: 75%; + max-width: 1000px; + margin: 0 auto; + } + .main { + display: flex; + gap: 1em; + flex-direction: column; + @media (min-width: 800px) { + flex-direction: row; + } + } + .content { + flex-grow: 2; + } + .sidebar { + flex-grow: 1; + } + + `); +}); + +app.get(layout.pathname(), async (req, res) => { + const incoming = res.locals.podium; + + // -------------------------------------------------------- + // Define any template variables such as the document title + // -------------------------------------------------------- + + 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); + const sidebarFetch = sidebar.fetch(incoming); + + // ----------------------------------------------------------- + // Start the stream and flush the document head to the browser + // ----------------------------------------------------------- + + // we kick off the stream, this automatically sends the document opening html including everything in the + // as well as the openning tag. + const stream = await res.podiumStream(); + + // ----------------------------------------------------------------------- + // Immediately send the document structure and placeholders to the browser + // ----------------------------------------------------------------------- + + // stream in the document body with slot skeleton screen placeholders for podlets + // these will be replaced once the podlets are loaded + stream.send(` + + `); + + // --------------------------------------------------------- + // Option 1. Load podlets one at a time as they are fetched + // --------------------------------------------------------- + + const [headerData, menuData, contentData, footerData, sidebarData] = + await Promise.all([ + headerFetch, + menuFetch, + contentFetch, + footerFetch, + sidebarFetch, + ]); + + stream.send(` +
${headerData}
+
${menuData}
+
${contentData}
+
${footerData}
+
${sidebarData}
+ `); + + // --------------------------------------------------------------------- + // Option 2. Load all podlets at once after everything has been fetched + // --------------------------------------------------------------------- + + // const prom1 = headerFetch.then((data) => { + // stream.send(`
${data}
`); + // }); + // const prom2 = menuFetch.then((data) => { + // stream.send(`
${data}
`); + // }); + // const prom3 = contentFetch.then((data) => { + // stream.send(`
${data}
`); + // }); + // const prom4 = footerFetch.then((data) => { + // stream.send(`
${data}
`); + // }); + + // await Promise.all([prom1, prom2, prom3, prom4]); + + // -------------------------- + // Close the stream when done + // -------------------------- + + 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/server-simple.js b/example/streaming/server-simple.js new file mode 100644 index 00000000..03ae42ae --- /dev/null +++ b/example/streaming/server-simple.js @@ -0,0 +1,122 @@ +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', +}); + +const sidebar = layout.client.register({ + name: 'sidebar', + uri: 'http://localhost:6105/manifest.json', +}); + +layout.css({ value: '/foo/css', strategy: 'shadow-dom' }); + +const app = express(); + +app.use(layout.pathname(), layout.middleware()); + +app.get('/foo/css', (req, res) => { + res.set('Content-Type', 'text/css'); + res.send(` + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + .layout { + width: 100%; + display: flex; + flex-direction: column; + gap: 2em; + } + .content-area { + width: 75%; + max-width: 1000px; + margin: 0 auto; + display: flex; + gap: 1em; + } + `); +}); + +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); + const sidebarFetch = sidebar.fetch(incoming); + + const stream = await res.podiumStream(); + + const [ + headerResult, + menuResult, + contentResult, + footerResult, + sidebarResult, + ] = await Promise.all([ + headerFetch, + menuFetch, + contentFetch, + footerFetch, + sidebarFetch, + ]); + + // stream in the document body with slot placeholders for podlets + stream.send(` +
+ +
+
${menuResult}
+
${headerResult}
+
+
+ ${contentResult} + ${sidebarResult} +
+
${footerResult}
+
+ `); + 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`); +}); diff --git a/lib/layout.js b/lib/layout.js index 36f63bd6..d679b4ab 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 @@ -574,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; @@ -615,6 +618,38 @@ 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 = async (...args) => { + const { js, css } = await incoming.waitForAssets(); + incoming.js = js; + incoming.css = css; + + const responseStream = new ResponseStream(); + // pipe the readable response stream into the express writeable res stream + // to set up streaming + responseStream.pipe(res); + + // 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]; + + // send the header right away + 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; + }; + 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/package.json b/package.json index fd561dad..af537748 100644 --- a/package.json +++ b/package.json @@ -41,11 +41,11 @@ }, "dependencies": { "@metrics/client": "2.5.3", - "@podium/client": "5.2.0", + "@podium/client": "5.3.0-next.1", "@podium/context": "5.1.1", "@podium/proxy": "5.0.30", "@podium/schemas": "5.1.0", - "@podium/utils": "5.3.2", + "@podium/utils": "5.4.0", "abslog": "2.4.4", "ajv": "8.17.1", "lodash.merge": "4.6.2", diff --git a/tests/layout.test.js b/tests/layout.test.js index cd1fa3fe..a8eedbcf 100644 --- a/tests/layout.test.js +++ b/tests/layout.test.js @@ -731,3 +731,158 @@ 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.stream(incoming); + const p2fetch = p2Client.stream(incoming); + + p1fetch.once('beforeStream', ({ js, css }) => { + incoming.js.push(...js); + incoming.css.push(...css); + }); + p2fetch.once('beforeStream', ({ js, css }) => { + incoming.js.push(...js); + incoming.css.push(...css); + }); + + await new Promise((resolve) => { + function checkForAssets() { + if (incoming.js.length === 2 || incoming.css.length === 2) { + resolve(true); + } else { + setTimeout(checkForAssets, 100); + } + } + checkForAssets(); + }); + + const stream = res.podiumStream(); + + const chunks1 = []; + for await (const chunk of p1fetch) { + chunks1.push(chunk); + } + const p1Content = Buffer.concat(chunks1).toString(); + + const chunks2 = []; + for await (const chunk of p2fetch) { + chunks2.push(chunk); + } + const p2Content = Buffer.concat(chunks2).toString(); + + 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, /