gornostay25 / svelte-adapter-bun

A SvelteKit adapter for Bun
MIT License
534 stars 33 forks source link

Make the adaptor also work in development mode #25

Open SirMorfield opened 1 year ago

SirMorfield commented 1 year ago

The adapter's WebSocket server currently does not work when running in vite dev

adapter-node-ws can already do this.

gevera commented 1 year ago

Wow.. I thought the example from README regarding websockets doesnt work. Only after looking through the issues and finding this one, I've build the project and it turned out that it works. It's a bummer that right now its not possible to have a real time feedback in dev mode. Hopefully this is just a temporary issue

thiagomagro commented 1 year ago

Hello team, any plans to make the WS work in dev mode?

eslym commented 8 months ago

prove of concept

// dev.ts

import { createServer } from 'vite';
import { join } from 'path';
import { EventEmitter } from 'events';
import { IncomingMessage, ServerResponse } from 'http';
import type { Server, WebSocketHandler } from 'bun';
const fakeServer = new EventEmitter();

const vite = await createServer({
    ...(await import(join(process.cwd(), 'vite.config.ts'))),
    server: {
        hmr: {
            server: fakeServer as any
        },
        middlewareMode: true
    },
    appType: 'custom'
});

let bunternal = (socket: any) => {
    for (const prop of Object.getOwnPropertySymbols(socket)) {
        if (prop.toString().includes('bunternal')) {
            bunternal = () => prop;
            return prop as any;
        }
    }
};

Bun.serve({
    port: 5173,
    async fetch(request: Request, server: Server) {
        let pendingResponse: Response | undefined;
        let pendingError: Error | undefined;

        let resolve: (response: Response) => void;
        let reject: (error: Error) => void;

        function raise(err: any) {
            if (pendingError) return;
            reject?.((pendingError = err));
        }

        function respond(res: Response) {
            if (pendingResponse) return;
            resolve?.((pendingResponse = res));
        }

        const req = new IncomingMessage(request as any);
        const res = new (ServerResponse as any)(req, respond);

        const socket = req.socket as any;
        socket[bunternal(socket)] = [server, res, request];

        req.once('error', raise);
        res.once('error', raise);

        const promise = new Promise<Response | undefined>((res, rej) => {
            resolve = res;
            reject = rej;
        });

        if (request.headers.get('upgrade')) {
            if (request.headers.get('sec-websocket-protocol') === 'vite-hmr') {
                fakeServer.emit('upgrade', req, socket, Buffer.alloc(0));
                return;
            }
            const hooks = (await vite.ssrLoadModule('src/hooks.server.ts')) as any;
            if ('handleWebsocket' in hooks && hooks.handleWebsocket.upgrade(request, server)) {
                return;
            }
        }

        vite.middlewares(req, res, (err: any) => {
            if (err) {
                vite.ssrFixStacktrace(err);
                raise(err);
            }
        });

        return promise;
    },
    // this is required for bun internal ws package to work, so the hooks.server.ts must match with this format.
    // ex: server.upgrade(req, { data: { message(ws, msg) { ... } } });
    websocket: {
        open(ws) {
            return ws.data.open?.(ws);
        },
        message(ws, message) {
            return ws.data.message(ws, message);
        },
        drain(ws) {
            return ws.data.drain?.(ws);
        },
        close(ws, code, reason) {
            return ws.data.close?.(ws, code, reason);
        },
        ping(ws, buffer) {
            return ws.data.ping?.(ws, buffer);
        },
        pong(ws, buffer) {
            return ws.data.pong?.(ws, buffer);
        }
    } as WebSocketHandler<Pick<WebSocketHandler<any>, 'open' | 'message' | 'drain' | 'close' | 'ping' | 'pong'>>
});

console.log('Server running at http://localhost:5173');

then use bun dev.ts to start the dev server instead

p/s: this hack involve few bun internal stuffs, so it might broken after bun upgrade

timootten commented 5 months ago

I update the dev.ts

dev.ts:

// dev.ts

import { createServer } from 'vite';
import { join } from 'path';
import { EventEmitter } from 'events';
import { IncomingMessage, ServerResponse } from 'http';
import type { Server, WebSocketHandler } from 'bun';
const fakeServer = new EventEmitter();

const vite = await createServer({
  ...(await import(join(process.cwd(), 'vite.config.ts'))),
  server: {
    hmr: {
      server: fakeServer as any
    },
    middlewareMode: true
  },
  appType: 'custom'
});

let bunternal = (socket: any) => {
  for (const prop of Object.getOwnPropertySymbols(socket)) {
    if (prop.toString().includes('bunternal')) {
      bunternal = () => prop;
      return prop as any;
    }
  }
};

const hooks = (await vite.ssrLoadModule('src/hooks.server.ts')) as any;

Bun.serve({
  port: 5173,
  async fetch(request: Request, server: Server) {
    let pendingResponse: Response | undefined;
    let pendingError: Error | undefined;

    let resolve: (response: Response) => void;
    let reject: (error: Error) => void;

    function raise(err: any) {
      if (pendingError) return;
      reject?.((pendingError = err));
    }

    function respond(res: Response) {
      if (pendingResponse) return;
      resolve?.((pendingResponse = res));
    }

    const req = new IncomingMessage(request as any);
    const res = new (ServerResponse as any)(req, respond);

    const socket = req.socket as any;
    socket[bunternal(socket)] = [server, res, request];

    req.once('error', raise);
    res.once('error', raise);

    const promise = new Promise<Response | undefined>((res, rej) => {
      resolve = res;
      reject = rej;
    });

    if (request.headers.get('upgrade')) {
      if (request.headers.get('sec-websocket-protocol') === 'vite-hmr') {
        fakeServer.emit('upgrade', req, socket, Buffer.alloc(0));
        return;
      }
      const hooks = (await vite.ssrLoadModule('src/hooks.server.ts')) as any;
      const upgradeMethod = server.upgrade.bind(server);
      if ('handleWebsocket' in hooks && hooks.handleWebsocket.upgrade(request, upgradeMethod)) {
        return;
      }
    }

    vite.middlewares(req, res, (err: any) => {
      if (err) {
        vite.ssrFixStacktrace(err);
        raise(err);
      }
    });

    return promise;
  },
  // this is required for bun internal ws package to work, so the hooks.server.ts must match with this format.
  // ex: server.upgrade(req, { data: { message(ws, msg) { ... } } });
  websocket: {
    open(ws) {
      if (ws?.data?.open) return ws.data.open?.(ws);
      return hooks?.handleWebsocket?.open(ws);
    },
    message(ws, message) {
      if (ws?.data?.message) return ws.data.message(ws, message);
      return hooks?.handleWebsocket?.message(ws, message);
    },
    drain(ws) {
      if (ws?.data?.drain) return ws.data.drain?.(ws);
      return hooks?.handleWebsocket?.drain?.(ws);
    },
    close(ws, code, reason) {
      if (ws?.data?.close) return ws.data.close?.(ws, code, reason);
      return hooks?.handleWebsocket?.drain?.(ws, code, reason);
    },
    ping(ws, buffer) {
      if (ws?.data?.ping) return ws.data.ping?.(ws, buffer);
      return hooks?.handleWebsocket?.ping?.(ws, buffer);
    },
    pong(ws, buffer) {
      if (ws?.data?.pong) return ws.data.pong?.(ws, buffer);
      return hooks?.handleWebsocket?.pong?.(ws, buffer);
    }
  } as WebSocketHandler<Pick<WebSocketHandler<any>, 'open' | 'message' | 'drain' | 'close' | 'ping' | 'pong'>>
});

console.log('Server running at http://localhost:5173');

hooks.server.ts:

export const handleWebsocket: WebSocketHandler = {
  open(ws) {
    ws.send("test");
    console.log("ws opened");
  },
  upgrade(request, upgrade) {
    const url = request.url
    console.log(url)
    console.log(upgrade)
    return upgrade(request);
  },
  message(ws, message) {
    ws.send(message);
    console.log("ws message", message);
  },
};
eslym commented 3 months ago

the work around will break in bun 1.1.25 due to the bun's internal changes, here is the changes need to apply for it to work in the latest bun

  1. Bun.serve({... -> const bunServer = Bun.serve({...
  2. socket[bunternal(socket)] = [server, res, request]; -> socket[bunternal(socket)] = [fakeServer, res, request];
  3. add (fakeServer as any)[bunternal(fakeServer)] = bunServer; to the last line
timootten commented 3 months ago

Hi @eslym ,

I'm having some trouble applying the necessary changes for the latest Bun 1.1.25 update. I couldn't quite get it to work with the workaround mentioned. Would you mind sharing the entire code with the necessary changes included?

Thanks a lot for your help!

Best regards,
Timo

eslym commented 3 months ago

@timootten this will work

import { createServer } from 'vite';
import { join } from 'path';
import { EventEmitter } from 'events';
import { IncomingMessage, ServerResponse } from 'http';
import type { Server, WebSocketHandler } from 'bun';
const fakeServer = new EventEmitter();

const vite = await createServer({
  ...(await import(join(process.cwd(), 'vite.config.ts'))),
  server: {
    hmr: {
      server: fakeServer as any
    },
    middlewareMode: true
  },
  appType: 'custom'
});

const bunternal = Symbol.for('::bunternal::');

const hooks = (await vite.ssrLoadModule('src/hooks.server.ts')) as any;

const server = Bun.serve({
  port: 5173,
  async fetch(request: Request, server: Server) {
    let pendingResponse: Response | undefined;
    let pendingError: Error | undefined;

    let resolve: (response: Response) => void;
    let reject: (error: Error) => void;

    function raise(err: any) {
      if (pendingError) return;
      reject?.((pendingError = err));
    }

    function respond(res: Response) {
      if (pendingResponse) return;
      resolve?.((pendingResponse = res));
    }

    const req = new IncomingMessage(request as any);
    const res = new (ServerResponse as any)(req, respond);

    const socket = req.socket as any;
    socket[bunternal] = [fakeServer, res, request];

    req.once('error', raise);
    res.once('error', raise);

    const promise = new Promise<Response | undefined>((res, rej) => {
      resolve = res;
      reject = rej;
    });

    if (request.headers.get('upgrade')) {
      if (request.headers.get('sec-websocket-protocol') === 'vite-hmr') {
        fakeServer.emit('upgrade', req, socket, Buffer.alloc(0));
        return;
      }
      const hooks = (await vite.ssrLoadModule('src/hooks.server.ts')) as any;
      const upgradeMethod = server.upgrade.bind(server);
      if ('handleWebsocket' in hooks && hooks.handleWebsocket.upgrade(request, upgradeMethod)) {
        return;
      }
    }

    vite.middlewares(req, res, (err: any) => {
      if (err) {
        vite.ssrFixStacktrace(err);
        raise(err);
      }
    });

    return promise;
  },
  // this is required for bun internal ws package to work, so the hooks.server.ts must match with this format.
  // ex: server.upgrade(req, { data: { message(ws, msg) { ... } } });
  websocket: {
    open(ws) {
      if (ws?.data?.open) return ws.data.open?.(ws);
      return hooks?.handleWebsocket?.open(ws);
    },
    message(ws, message) {
      if (ws?.data?.message) return ws.data.message(ws, message);
      return hooks?.handleWebsocket?.message(ws, message);
    },
    drain(ws) {
      if (ws?.data?.drain) return ws.data.drain?.(ws);
      return hooks?.handleWebsocket?.drain?.(ws);
    },
    close(ws, code, reason) {
      if (ws?.data?.close) return ws.data.close?.(ws, code, reason);
      return hooks?.handleWebsocket?.drain?.(ws, code, reason);
    },
    ping(ws, buffer) {
      if (ws?.data?.ping) return ws.data.ping?.(ws, buffer);
      return hooks?.handleWebsocket?.ping?.(ws, buffer);
    },
    pong(ws, buffer) {
      if (ws?.data?.pong) return ws.data.pong?.(ws, buffer);
      return hooks?.handleWebsocket?.pong?.(ws, buffer);
    }
  } as WebSocketHandler<Pick<WebSocketHandler<any>, 'open' | 'message' | 'drain' | 'close' | 'ping' | 'pong'>>
});

fakeServer[bunternal] = server;

console.log('Server running at http://localhost:5173');
timootten commented 3 months ago

Thank you :)

MahmoodKhalil57 commented 2 months ago

any update on this?

MahmoodKhalil57 commented 2 months ago

@eslym any updates on your package?? can you provide instructions on how to use it?

KyleFontenot commented 1 month ago

@eslym any updates on your package?? can you provide instructions on how to use it?

Hi @MahmoodKhalil57. To use eslym's solution, copy all that code into a vitebuncustomserver.ts (or similarly named) file and place it into your project at the root. Then run bun run ./vitebuncustomserver.ts. You'd use that command for your dev environment instead of vite dev, (which you can swap in your package.json's dev script)

KyleFontenot commented 1 month ago

For some reason I couldn't get @eslym 's solution to work appropriately. it might be because of this Bun issue with net.sockets

Here is another option for anyone interested which I'm resulting to until a more thorough solution. Uses's node-adapter-ws's approach to websockets just changing hmr ports. Unfortunately client-side you'll have to connect to an alternate port. like

const ws = new Websocket(`ws://localhost${dev ? ':10234' : ''}`) 

(using 10234 being the plugin's default). This is a vite plugin so import this into vite.config.ts and stick it in the plugins.

import type { Server, WebSocketHandler } from 'bun';
import type { Plugin, ViteDevServer } from 'vite';
export let bunserverinst: undefined | Server;

export interface ViteBunSimpleHMRPluginOptions {
  ws: WebSocketHandler,
  wsdevport: number
}

const bunWSPlugin = (pluginoptions?: ViteBunSimpleHMRPluginOptions): Plugin => 
({
  name: 'bun-adapter-websockets',
  async configureServer(server: ViteDevServer) {
    const portToUse = pluginoptions?.wsdevport || process.env?.DEVWSPORT || 10234;
    server.config.server.hmr = Object.assign(
      {
        protocol: 'ws',
        clientPort: portToUse,
      },
      server.config.server.hmr,
    );
    // Use and prefer hooks handle
    const hooksHandler = (await import("./src/hooks.server.ts")).handleWebSocket;
    const mergedwebsocketconfig = {
      port: portToUse,
      fetch: ((req: Request, server: Server) => {
        if (
          req.headers.get('connection')?.toLowerCase().includes('upgrade') &&
          req.headers.get('upgrade')?.toLowerCase() === 'websocket'
        ) {
          server.upgrade(req, {
            data: {
              url: req.url,
              headers: req.headers,
            },
          });
        }
      }),
      websocket: pluginoptions?.ws || hooksHandler || {
        open() {
          console.log('Opened default websocket');
        },
        message(ws: ServerWebSocket, msg: string | Buffer) {
          console.log(msg.toString());
        },
      },
    };
    try {
      if (!bunserverinst) {
        bunserverinst = Bun.serve(mergedwebsocketconfig);
      }
    } catch (e) {
      console.warn(e);
    }
  },
});
export default bunWSPlugin;

Like said, not ideal because of the conditional client-side addresses, but it works. Until we come up with a win-win solution

MahmoodKhalil57 commented 1 month ago

https://github.com/gornostay25/svelte-adapter-bun/issues/25#issuecomment-2395196452 @KyleFontenot Thanks a lot, I came to a similar conclusion but my implementation was really bad compared to yours.

MahmoodKhalil57 commented 1 month ago

@KyleFontenot I actually ended up splitting the repo to an npm mono repo with sveltekit for frontend and bun for backend, with nginx properly routing everthing

maietta commented 1 month ago

@KyleFontenot ...... SvelteKit for the frontend and bun for the backend?

Don't you mean an app or website built with SvelteKit and deployed using Bun server runtime?

MahmoodKhalil57 commented 1 month ago

Don't you mean an app or website built with SvelteKit and deployed using Bun server runtime?

Nope, its an npm workspace https://docs.npmjs.com/cli/v8/using-npm/workspaces They are hosted on two different ports, 3200 for frontend and 3300 for backend, the directory looks something like this

package.json node_modules./ frontend./

backend./

then I have any request sent to /api redirect to port 3300 and anything else redirect to port 3200