starfederation / datastar

A real-time hypermedia framework.
https://data-star.dev/
MIT License
732 stars 41 forks source link

Shoelace #117

Closed janbkrejci closed 2 months ago

janbkrejci commented 3 months ago

Hello,

I tried the getting started example and dressed it to Shoelace. When I run it, the server feed starts, and after a while, it slows down until the browser tab freezes. Maybe I am doing something wrong. It seems that the heap grows indefinitely. Would you please take a look at the code?

Thanks and thumbs up for this awesome library.

Jan

...and here is the code:

const express = require('express');
const { randomBytes } = require('crypto');

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const backendData = {};

function indexPage() {
    const indexPage = `
    <!doctype html><html>
      <head>
        <title>Node/Express + Datastar Example</title>
        <link
            rel="stylesheet"
            media="(prefers-color-scheme:light)"
            href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.16.0/cdn/themes/light.css"
        />
        <link
            rel="stylesheet"
            media="(prefers-color-scheme:dark)"
            href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.16.0/cdn/themes/dark.css"
            onload="document.documentElement.classList.add('sl-theme-dark');"
        />
        <script
            type="module"
            src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.16.0/cdn/shoelace.js"
        ></script>
        <style>
            body {
                opacity: 0;
            }
            body.ready {
                opacity: 1;
                transition: 0.25s opacity;
            }
        </style>
        <script type="module">
            await Promise.allSettled([
                customElements.whenDefined('sl-alert'),
                customElements.whenDefined('sl-icon'),
                customElements.whenDefined('sl-input'),
                customElements.whenDefined('sl-button'),
            ]);
            document.body.classList.add('ready');
        </script>
        <script type="module" defer src="https://cdn.jsdelivr.net/npm/@sudodevnull/datastar"></script></head>
        <body style="font-family: var(--sl-font-sans)">
        <main style="max-width: 1024px; margin: auto; display: flex; flex-direction: column; gap: 1em" id="main" data-store='{ input: "", show: false }'>
        <h2>Node/Express + Datastar Example</h2>
        <sl-alert variant="warning" open>
          <sl-icon slot="icon" name="exclamation-triangle"></sl-icon>
          Under construction...
        </sl-alert>
        <div style="display: flex; gap: 1em">
        <sl-input style="flex: 1" type="text" placeholder="Type here!" data-model="input"></sl-input>
        <sl-button data-on-click="$$put('/put')">Send State</sl-button>
        </div>
        <div id="output"></div>
        <sl-button data-on-click="$$get('/get')">Get Backend State</sl-button>
        <div id="output2"></div>
        <sl-button data-on-click="$show=!$show">Toggle</sl-button>
        <div data-show="$show">
          <span>Hello From Datastar!</span>
        </div>
        <div>
          <span>Feed from server: </span>
          <span id="feed" data-on-load="$$get('/feed')"></span>
        </div>
        </main>
      </body>
    </html>`;
    return indexPage;
}

app.get('/', (req, res) => {
    res.send(indexPage()).end();
});

function setHeaders(res) {
    res.set({
        'Cache-Control': 'no-cache',
        'Content-Type': 'text/event-stream',
        Connection: 'keep-alive',
    });
    res.flushHeaders();
}

function sendSSE({ res, frag, selector, merge, mergeType, end }) {
    res.write('event: datastar-fragment\n');
    if (selector) res.write(`data: selector ${selector}\n`);
    if (merge) res.write(`data: merge ${mergeType}\n`);
    res.write(`data: fragment ${frag}\n\n`);
    if (end) res.end();
}

app.put('/put', (req, res) => {
    setHeaders(res);
    const { input } = req.body;
    backendData.input = input;
    const output = `Your input: ${input}, is ${input.length} long.`;
    let frag = `<div id="output">${output}</div>`;
    sendSSE({
        res,
        frag,
        selector: null,
        merge: true,
        mergeType: 'morph_element',
        end: true,
    });
});

app.get('/get', (req, res) => {
    setHeaders(res);

    const output = `Backend State: ${JSON.stringify(backendData)}.`;
    let frag = `<div id="output2">${output}</div>`;

    sendSSE({
        res,
        frag,
        selector: null,
        merge: true,
        mergeType: 'morph_element',
        end: false,
    });
    frag = `<div id="output3">Check this out!</div>;`;
    sendSSE({
        res,
        frag,
        selector: '#main',
        merge: true,
        mergeType: 'prepend_element',
        end: true,
    });
});

app.get('/feed', async (req, res) => {
    setHeaders(res);
    while (res.writable) {
        const rand = randomBytes(8).toString('hex');
        const frag = `<span id="feed">${rand}</span>`;
        sendSSE({
            res,
            frag,
            selector: null,
            merge: false,
            mergeType: null,
            end: false,
        });
        await new Promise((resolve) => setTimeout(resolve, 1000));
    }
    res.end();
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
    console.log(`Server is running on http://localhost:${PORT}`);
});
delaneyj commented 3 months ago

I'm not a Node dev any more but I'm sure its on the Node side. What does profiling and flame graphs tell you about the backend?

matt-dale commented 3 months ago

Yeah! This library is really neat! I'd like to get the NodeJS demo fixed up to allow us "normal" JS devs to come onboard with this refreshing approach.

I'm looking into why the default NodeJS example causes this lockup. It's all related to the /feed demo endpoint in the browser. If that is not invoked on the page, the demo works as expected.

The code repo is here: https://github.com/matt-dale/datastar-node-example

Profiling the node process shows no issues at all, but I guess it could be related to how we are using the sendSSE method? Screenshot 2024-08-20 at 11 33 53 AM

Meanwhile, the browser slowly get's very bogged down. Screenshot 2024-08-20 at 11 55 43 AM

Inspecting the flamegraph here shows that applyPlugins > walkDownDOM normally completes extremely fast, but can take almost 1 second during the lockup period.

The comparison between the different calls are below. Screenshot 2024-08-20 at 12 01 40 PM

Screenshot 2024-08-20 at 12 03 44 PM

delaneyj commented 3 months ago

Profiling the node process shows no issues at all, but I guess it could be related to how we are using the sendSSE method?

:100: it could be the problem. That code was provide by the community. It's either that or that writeable section of the /feed handler is leaking memory. I don't know enough to be useful but wonder if trying deno or bun would help figure out what the underlying issue is. I know that my Go helpers are pretty solid.

Definitely want useable tooling for JS/TS devs and would love the help.

delaneyj commented 2 months ago

@matt-dale any luck with this?

matt-dale commented 2 months ago

I ran out of time looking into it. 😔

delaneyj commented 2 months ago

I'll close for now, I really think it's a backend issue. Please reopen if you can create a reproducible code example