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

Support Server-Sent Events (SSE) with adapter-node #887

Closed
babeard opened this issue Apr 5, 2021 · 13 comments
Closed

Support Server-Sent Events (SSE) with adapter-node #887

babeard opened this issue Apr 5, 2021 · 13 comments

Comments

@babeard
Copy link

babeard commented Apr 5, 2021

Describe the bug
While migrating a sapper app to svelte-kit / adapter-node, I came across a feature that seems to be missing. Namely, the ability to create custom middleware that can handle Server-Sent Events or other long-lived connections that send data to the response stream without closing the connection. Since the handle hook requires returning Response | Promise<Response>, thus closing the connection, it does not support this. Nor have I found any other way to res.write(); using endpoints.

Information about your SvelteKit Installation:

Diagnostics
System:
    OS: Linux 4.4 Ubuntu 20.04.1 LTS (Focal Fossa)
    CPU: (8) x64 Intel(R) Core(TM) i7-4770 CPU @ 3.40GHz
    Memory: 7.97 GB / 15.94 GB
    Container: Yes
    Shell: 5.0.17 - /bin/bash
Binaries:
    Node: 14.5.0 - ~/.nvm/versions/node/v14.5.0/bin/node
    npm: 6.14.5 - ~/.nvm/versions/node/v14.5.0/bin/npm
npmPackages:
    @sveltejs/kit: ^1.0.0-next.68 => 1.0.0-next.68
    svelte: ^3.37.0 => 3.37.0
    vite: ^2.1.5 => 2.1.5
  • Using node-adapter

Severity
Adds a bit of friction for migrating from Sapper, reduces feature parity, and will require me to either
a) regress to polling the server from the client, or
b) write a custom adapter for every project that needs SSEs. (which I'm not sure that this even helps for the development environment anyways)

Additional context
Issue #334 seems relevant.

Also originally asked within discord chat but no one seems to know the answer, which is why I am submitting this issue.

@benmccann benmccann changed the title Support Server-sent events with node-adapter Support Server-Sent Events (SSE) with adapter-node Apr 6, 2021
@cayter
Copy link

cayter commented Apr 11, 2021

@babeard After going through https://vitejs.dev/guide/api-plugin.html#configureserver, I believe we can try the below:

  1. Come up with a middleware with the method signature function (req, res, next) => {}.
  2. Configure the middleware in svelte.config.cjs's vite.plugins. (this is for svelte-kit dev)
  3. Come up with a forked adapter-node that allows polka to use the same middleware. (this is for node build/index.js after svelte-kit build with the forked adapter-node)

I haven't tested it but I think it should work. There's a drawback to this approach though, we'd have to match the SSE routes ourselves.

Update

I had tested it, the approach above works though I'm still trying to figure out how we can pass in the middleware to adapter-node so that it will configure polka to use them during svelte-kit build step.

svelte.config.cjs

const sveltePreprocess = require('svelte-preprocess');
const node = require('@sveltejs/adapter-node');
const pkg = require('./package.json');
const bodyParser = require('body-parser');
const sseMiddleware = require('./src/middlewares/sse.cjs');

/** @type {import('@sveltejs/kit').Config} */
module.exports = {
	// Consult https://github.com/sveltejs/svelte-preprocess
	// for more information about preprocessors
	preprocess: sveltePreprocess(),
	kit: {
		adapter: node({
                   out: 'build',

                   // TODO: We probably can update `adapter-node` to provide this callback which 
                   // is to ensure `node build/index.js` works. As this isn't implemented yet, you can 
                   // simply run `svelte-kit build` and manually add the middleware to polka instance's 
                   // use method at the end of `build/index.js` file.
                   configureServer(polkaInstance) {
                     polkaInstance.use(bodyParser.json());
                     polkaInstance.use(sseMiddleware);
                   }
                 }),

                ...

		vite: {
                        ...

                         // This is to ensure `svelte-kit dev` works.
			plugins: [
				(() => ({
					name: 'configure-server',
					configureServer(server) {
						server.middlewares.use(bodyParser.json());
						server.middlewares.use(sseMiddleware);
					}
				}))()
			],

                         ...
		}
	}
};

./src/middlewares/sse.cjs

let clients = [];
const messages = [];

module.exports = (req, res, next) => {
	if (req.url === '/messages') {
		switch (req.method) {
			case 'GET':
				const headers = {
					'Content-Type': 'text/event-stream',
					Connection: 'keep-alive',
					'Cache-Control': 'no-cache'
				};
				res.writeHead(200, headers);

				const data = `messages: ${JSON.stringify(messages)}\n\n`;
				res.write(data);

				const clientId = Date.now();
				const newClient = {
					id: clientId,
					res
				};

				clients.push(newClient);

				req.on('close', () => {
					console.log(`${clientId} Connection closed`);
					clients = clients.filter((client) => client.id !== clientId);
				});

				break;

			case 'POST':
				const newMessage = req.body;
				messages.push(newMessage);
				res.end(`${JSON.stringify(newMessage)}\n`);

				clients.forEach((client) =>
					client.res.write(`newMessage: ${JSON.stringify(newMessage)}\n`)
				);

				break;
		}

		return;
	}

	next();
};

@mromanuk
Copy link

@cayter Do you have any recommendation on how to patch build/index.js to append the SSE middleware?

@cayter
Copy link

cayter commented Jun 13, 2021

@cayter Do you have any recommendation on how to patch build/index.js to append the SSE middleware?

I checked the src, it isn't possible at all which means we will have to change how adapter-node works. For now, you can only write a postbuild npm script to find and replace the pattern in build/index.js.

@mromanuk
Copy link

yes, thank you! It's not pretty, but it works.

@mromanuk
Copy link

I ended up adding this to package.json, sse is the function exported by vite, because it's imported in hook.js

    "postbuild": "sed -i 's/polka().use(/polka().use(sse, /g' build/index.js",

@cayter
Copy link

cayter commented Jul 10, 2021

@mromanuk

I had come up with a better solution and it's gonna be a monolith like Rails, the toolkit source code is here. Note that this isn't production ready but I plan to continue adding more capabilities to make it easier for ppl to build and deploy app easily just like how Rails/Laravel is.

Project Demo: https://codesandbox.io/s/appistkit-demo-4w58d
Project Folder Structure:

.
├── README.md
├── app (this is a shell script where we will use to run all the app commands, you can run `./app --help` to see what commands are available)
├── configs (12factor app, running `KIT_ENV=staging ./app server` would lead the app to load `configs/.env.staging`, I plan to add the encrypt/decrypt capability with KIT_MASTER_KEY and separate the server/client as client is restricted to use `VITE_` prefix)
├── package-lock.json
├── package.json
├── src
│   ├── client (this is the SvelteKit folder)
│   │   ├── app.html
│   │   ├── global.d.ts
│   │   ├── pages
│   │   │   ├── __error.svelte
│   │   │   ├── __layout.svelte
│   │   │   └── index.svelte
│   │   └── static
│   │       └── favicon.png
│   ├── cmd (this is the command folder which allows you to easily add new commands to `./app`)
│   │   └── now.ts
│   ├── db (this is the DB migrate/seed folder like what Rails have)
│   │   ├── migrate
│   │   │   └── primary
│   │   │       └── 20210422155916_create_users.ts
│   │   └── seed
│   │       └── primary
│   │           └── index.ts
│   ├── server (this is the server folder that is also file-based routing like SvelteKit but for server-side, you can write your SSE handler in any path you want, I'm planning to add `export const ws` to support websocket and `export const graphql` to support GraphQL and probably `export const grpc` to support GRPC too)
│   │   └── api.ts
│   └── worker (this is the worker folder where we would store the BullMQ worker logic, WIP)
├── svelte.config.js
└── tsconfig.json

The Polka instance used in:

  • ./app dev (for local development)
  • NODE_ENV=production KIT_ENV=staging ./app server (for running on staging/production environments, this command is only usable after you run ./app build to generate the dist folder)

are exactly the same instance.

Originally, I thought of including the server logic as part of the client (SvelteKit) folder but had bumped into lots of bundling issues that Vite has yet to resolve. So to keep things separated in cleaner way, I decided to just have a client and server folder. The current solution is like seamlessly combining your traditional NodeJS server with the best out of what SvelteKit is providing (i.e. great frontend DX, SSR, SSG and etc).

Let me know what you think. Thanks.

@jsprog
Copy link

jsprog commented Jul 31, 2021

Thanks to both @cayter and @mromanuk for the information provided above.

For development mode, I already followed the instructions from @cayter, but for builds, I had to avoid external scripts (postbuild) and stick to javascript only.

src/hooks.js

// you many not use 'sse_middleware' inside src/hooks.js, but
// - it has to land in the final bundle during builds.
// - we need to ensure automated server's reload during development
// - the re-export is necessary to avoid dead code removal during builds
export { default as sse_middleware } from '$lib/rtc/sse_middleware'

svelte.config.js

import adapter_node from '@sveltejs/adapter-node'
import fs from 'fs'
import path, { resolve } from 'path'
import preprocess from 'svelte-preprocess';
import sse_middleware from './src/lib/rtc/sse_middleware.js'

/** @type {import('@sveltejs/kit').Config} */
const config = {
    preprocess: preprocess(),
    
    kit: {
        // hydrate the <div id="svelte"> element in src/app.html
        target: '#svelte',

        adapter: {
            async adapt (opts) {
                const adapter = adapter_node()
                await adapter.adapt(opts)

                // build/index.js: attach the middleware to polka instance (text replacement)
                let content = fs.readFileSync(path.resolve('./build/index.js'), 'utf-8')
                content = content.replace(`polka().use(`, `polka().use(sse_middleware).use(`)
                fs.writeFileSync('./build/index.js', content, 'utf-8')
            }
        }, 

        vite:  {
            // temporary fix for using broadcast-channel
            define: { 'process.browser': true },

            plugins: [
                { name: 'attach_middlewares', configureServer (server) {
                    server.middlewares.use(sse_middleware)
                }}
            ],

            build: {
                rollupOptions: {
                    plugins: [
                        {
                            name: 'postbuild-fixes',
                            writeBundle () {
                                // at first attempt, I tried to attach the middleware to polka instance here,
                                // unfortunately, build/index.js isn't available at this stage
                            }
                        }
                    ]
                }
            }

        },

    }
};

export default config;

@cayter
Copy link

cayter commented Aug 1, 2021

Thanks to both @cayter and @mromanuk for the information provided above.

For development mode, I already followed the instructions from @cayter, but for builds, I had to avoid external scripts (postbuild) and stick to javascript only.

src/hooks.js

// you many not use 'sse_middleware' inside src/hooks.js, but
// - it has to land in the final bundle during builds.
// - we need to ensure automated server's reload during development
// - the re-export is necessary to avoid dead code removal during builds
export { default as sse_middleware } from '$lib/rtc/sse_middleware'

svelte.config.js

import adapter_node from '@sveltejs/adapter-node'
import fs from 'fs'
import path, { resolve } from 'path'
import preprocess from 'svelte-preprocess';
import sse_middleware from './src/lib/rtc/sse_middleware.js'

/** @type {import('@sveltejs/kit').Config} */
const config = {
    preprocess: preprocess(),
    
    kit: {
        // hydrate the <div id="svelte"> element in src/app.html
        target: '#svelte',

        adapter: {
            async adapt (opts) {
                const adapter = adapter_node()
                await adapter.adapt(opts)

                // build/index.js: attach the middleware to polka instance (text replacement)
                let content = fs.readFileSync(path.resolve('./build/index.js'), 'utf-8')
                content = content.replace(`polka().use(`, `polka().use(sse_middleware).use(`)
                fs.writeFileSync('./build/index.js', content, 'utf-8')
            }
        }, 

        vite:  {
            // temporary fix for using broadcast-channel
            define: { 'process.browser': true },

            plugins: [
                { name: 'attach_middlewares', configureServer (server) {
                    server.middlewares.use(sse_middleware)
                }}
            ],

            build: {
                rollupOptions: {
                    plugins: [
                        {
                            name: 'postbuild-fixes',
                            writeBundle () {
                                // at first attempt, I tried to attach the middleware to polka instance here,
                                // unfortunately, build/index.js isn't available at this stage
                            }
                        }
                    ]
                }
            }

        },

    }
};

export default config;

@jsprog The postbuild script mentioned here is the scripts.postbuild in package.json which will run after scripts.build.

@jsprog
Copy link

jsprog commented Aug 3, 2021

The modules imported by svelte.config.js, are not part of the flow receiving all kinds of transformations by sveltekit. You'll miss aliases, HMR, and even access to imports without providing the extensions (even for index.js).

Example of a file imported by svelte.config.js

import db from '$lib/database'     // fails
import db from './lib/database'    // fails
import db from './lib/database.js' // works

Instead, I had to resort to global

src/lib/rtc/server/middlewares/rtc_sse

// global.rtc_sse will be used by configuration for vite (svelte.config.js)
// this also ensure that no dead code is removed for builds
global.rtc_sse = rtc_sse

export default async function rtc_sse (req, res, next) {
    // ...
}

src/hooks.js

import '$lib/rtc/server/middlewares/rtc_sse'

svelte.config.js

  vite: {
    plugins: [
      { name: 'use-middlewares', configureServer (server) {
        server.middlewares.use((req, res, next) => {
          global.rtc_sse ? global.rtc_sse(req, res, next) : next()
        })
      }}
    ]
  }

@christophemacabiau
Copy link

christophemacabiau commented Jan 4, 2022

Struggling also with this issue :-)
Thanks to @cayter @mromanuk & @jsprog for their suggestions

When we add the sse middleware before the polka middleware, the body is not parsed, we have to do it manually in the sse middleware.

I wonder if any of you has a better solution?

@mustofa-id
Copy link

as this #3384 changes, are now possible to implement SSE through endpoint?

@mromanuk
Copy link

as this #3384 changes, are now possible to implement SSE through endpoint?

Is that now possible?

@Rich-Harris
Copy link
Member

It's not possible today, but it's definitely something we want to get fixed for 1.0. This is basically just a special case of #3419. It should be possible to do something like this, I think...

const controllers = new Set();

export function get() {
  let controller;

  return {
    body: new ReadableStream({
      start: (_) => {
        controller = _;
        controllers.add(controller);
      },
      cancel: () => {
        controllers.delete(controller);
      }
    })
  };
}

export async function post({ request }) {
  const message = await request.text();

  // importantly, this use case wouldn't work in serverless environments —
  // for these sorts of use cases we need things like this:
  //   https://deno.com/deploy/docs/runtime-broadcast-channel
  //
  // ideally Kit would have some kind of abstraction that leveraged
  // BroadcastChannel or WebSockets or whatever under the hood
  for (const controller of controllers) {
    controller.enqueue(message);
  }

  return { status: 204 };
}

...but node-fetch breaks spec by using stream.Readable instead of ReadableStream. Given that Node itself is gradually embracing fetch and web streams, supporting stream.Readable is an evolutionary dead end (it almost works today in SvelteKit, but it's buggy — doesn't support request cancellation — and only works locally and in Node-like production environments, whereas we want these things to work cross-platform), so I suspect the answer is to polyfill web streams in Node.

I'll close this in favour of #3419.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

8 participants