├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── general-issue.md ├── .gitignore ├── .npmrc ├── .nuxtrc ├── CHANGELOG.md ├── README.md ├── package-lock.json ├── package.json ├── playground ├── app.vue ├── examples │ ├── filtered.ts │ ├── named.ts │ └── utils.ts ├── nuxt.config.ts └── package.json ├── src ├── module.ts └── runtime │ ├── client │ ├── index.ts │ └── socket.ts │ ├── queue.ts │ ├── server │ ├── index.ts │ └── socket.ts │ └── types.ts ├── test ├── basic.test.ts ├── fixtures │ └── basic │ │ ├── app.vue │ │ ├── nuxt.config.ts │ │ └── package.json └── unit │ └── handlers.spec.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "@nuxt/eslint-config" 5 | ], 6 | "rules": { 7 | "vue/multiline-html-element-content-newline": "off", 8 | "vue/max-attributes-per-line": "off", 9 | "vue/singleline-html-element-content-newline": "off" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a potential bug 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Description 11 | 12 | A clear and concise description of the bug 13 | 14 | Include any steps to reproduce 15 | 16 | 17 | ## Expected 18 | 19 | A clear and concise description of what you expected to happen 20 | 21 | 22 | ## Notes 23 | 24 | Any other context, links, screenshots, etc 25 | 26 | ### Environment info 27 | 28 | Run the code below and replace with the output: 29 | 30 | ``` 31 | echo -e "\nNode `node -v`\n" && npm list nuxt @nuxt/content nuxt-content-assets --depth=0 32 | ``` 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a feature for the project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Background 11 | 12 | A bit of background to provide context 13 | 14 | E.g. Right now this happens, and... 15 | 16 | 17 | ## Proposal 18 | 19 | A clear and concise description of what you think would be better 20 | 21 | 22 | ### Notes 23 | 24 | Any alternative solutions or ideas you've considered 25 | 26 | Any other context, links, screenshots, etc 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/general-issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: General issue 3 | about: Any other question, comment or issue 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Background 11 | 12 | A bit of background to provide context 13 | 14 | 15 | ## Issue 16 | 17 | Your question, comment, or issue 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Logs 5 | *.log* 6 | 7 | # Temp directories 8 | .temp 9 | .tmp 10 | .cache 11 | 12 | # Yarn 13 | **/.yarn/cache 14 | **/.yarn/*state* 15 | 16 | # Generated dirs 17 | dist 18 | 19 | # Nuxt 20 | .nuxt 21 | .output 22 | .vercel_build_output 23 | .build-* 24 | .env 25 | .netlify 26 | 27 | # Env 28 | .env 29 | 30 | # Testing 31 | reports 32 | coverage 33 | *.lcov 34 | .nyc_output 35 | 36 | # VSCode 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json 42 | !.vscode/*.code-snippets 43 | 44 | # Intellij idea 45 | *.iml 46 | .idea 47 | 48 | # OSX 49 | .DS_Store 50 | .AppleDouble 51 | .LSOverride 52 | .AppleDB 53 | .AppleDesktop 54 | Network Trash Folder 55 | Temporary Items 56 | .apdisk 57 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /.nuxtrc: -------------------------------------------------------------------------------- 1 | imports.autoImport=false 2 | typescript.includeWorkspace=true 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.1.0 4 | 5 | Initial release 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nuxt Sockets 2 | 3 | > WebSockets solution for Nuxt 4 | 5 | [![npm version][npm-version-src]][npm-version-href] 6 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 7 | [![License][license-src]][license-href] 8 | [![Nuxt][nuxt-src]][nuxt-href] 9 | 10 | ## Overview 11 | 12 | Nuxt Sockets implements bidirectional sockets communication between Nitro and Nuxt. 13 | 14 | It supports named channels making it ideal for plugin authors. 15 | 16 | ```ts 17 | // server 18 | const socket = useSocketServer('my-plugin') 19 | socket.send('ping!') 20 | ``` 21 | 22 | ```ts 23 | // client 24 | const socket = useSocketClient('my-plugin', ({ channel: string, data: any }) => { 25 | console.log(data) // ping! 26 | }) 27 | ``` 28 | 29 | ## Demo 30 | 31 | To view the demo live on StackBlitz: 32 | 33 | - https://stackblitz.com/github/davestewart/nuxt-sockets?file=playground%2Fapp.vue 34 | 35 | To run the demo locally: 36 | 37 | ``` 38 | npm run dev 39 | ``` 40 | 41 | ## Quick Setup 42 | 43 | Installation: 44 | 45 | ```bash 46 | npm install --save @davestewart/nuxt-sockets 47 | ``` 48 | 49 | Configuration: 50 | 51 | ```js 52 | export default defineNuxtConfig({ 53 | modules: [ 54 | '@davestewart/nuxt-sockets' 55 | ], 56 | }) 57 | ``` 58 | 59 | ## Usage 60 | 61 | ### Server 62 | 63 | Here's how you might set up the server to watch some files, then report them to the frontend: 64 | 65 | ```ts 66 | // module.ts 67 | import { createStorage } from 'unstorage' 68 | import { useSocketServer } from '@davestewart/nuxt-sockets' 69 | 70 | export default function (options, nuxt) { 71 | // create the server 72 | const socket = useSocketServer('my-plugin') 73 | 74 | // watch files for changes 75 | const storage = createStorage(dirname) 76 | storage.watch(function (event, key) => { 77 | socket.send({ event, key }) 78 | }) 79 | 80 | // handle incoming messages 81 | socket.onMessage(({ data }) => { 82 | console.log('message:', data) 83 | }) 84 | } 85 | ``` 86 | 87 | ### Client 88 | 89 | The client should take the same name as the server, so calls are sent between the two, and they don't clash with any other services using sockets. 90 | 91 | ```ts 92 | export default defineNuxtPlugin(async () => { 93 | if (process.client) { 94 | // receive a message 95 | const socket = await useSocketClient('my-plugin', ({ data }) => { 96 | console.log('file changed', data) 97 | }) 98 | 99 | // send a message 100 | window.addEventListener('click', () => { 101 | socket.send('user clicked') 102 | }) 103 | } 104 | }) 105 | ``` 106 | 107 | ### Alternative setups 108 | 109 | You can create a Socket instance in several ways: 110 | 111 | ```ts 112 | // generic server (not recommended) 113 | const socket = useSocketServer() 114 | 115 | // named server 116 | const socket = useSocketServer('some-name') 117 | 118 | // named server and default handler 119 | const socket = useSocketServer('some-name', ({ channel, data }) => { 120 | console.log({ channel, data }) 121 | }) 122 | 123 | // named server and filter handler 124 | const socket = useSocketServer('some-name').addHandler('foo', ({ data }) => { 125 | console.log(data.baz) 126 | }) 127 | ``` 128 | 129 | The library also has some generic typing, so you can hint the return data type: 130 | 131 | ```ts 132 | // example types 133 | type Foo = { foo: string } 134 | type Bar = { bar: string } 135 | type Baz = { baz: string } 136 | 137 | // hint the composable 138 | const socket = useSocketServer('plugin', ({ data }) => { 139 | console.log(data.foo) 140 | }) 141 | 142 | // hint the handler 143 | const socket = useSocketServer('plugin', ({ data }) => { 144 | console.log(data.bar) 145 | }) 146 | 147 | 148 | // hint the handler 149 | const socket = useSocketServer('plugin').addHandler('foo', ({ data }) => { 150 | console.log(data.baz) 151 | }) 152 | ``` 153 | 154 | ### Filtering 155 | 156 | The module supports basic filtering, but this may be taken out in the next version. 157 | 158 | ## Development 159 | 160 | To develop the module: 161 | 162 | ```bash 163 | # develop the module using the demo 164 | npm run dev 165 | 166 | # build and release (make sure to update version and changelog first) 167 | npm run release 168 | ``` 169 | 170 | 171 | [npm-version-src]: https://img.shields.io/npm/v/@davestewart/nuxt-sockets/latest.svg?style=flat&colorA=18181B&colorB=28CF8D 172 | [npm-version-href]: https://npmjs.com/package/@davestewart/nuxt-sockets 173 | 174 | [npm-downloads-src]: https://img.shields.io/npm/dm/@davestewart/nuxt-sockets.svg?style=flat&colorA=18181B&colorB=28CF8D 175 | [npm-downloads-href]: https://npmjs.com/package/@davestewart/nuxt-sockets 176 | 177 | [license-src]: https://img.shields.io/npm/l/@davestewart/nuxt-sockets.svg?style=flat&colorA=18181B&colorB=28CF8D 178 | [license-href]: https://npmjs.com/package/@davestewart/nuxt-sockets 179 | 180 | [nuxt-src]: https://img.shields.io/badge/Nuxt-18181B?logo=nuxt.js 181 | [nuxt-href]: https://nuxt.com 182 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@davestewart/nuxt-sockets", 3 | "description": "WebSockets solution for Nuxt", 4 | "version": "0.1.0", 5 | "repository": "davestewart/nuxt-sockets", 6 | "license": "MIT", 7 | "type": "module", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/types.d.ts", 11 | "import": "./dist/module.mjs", 12 | "require": "./dist/module.cjs" 13 | } 14 | }, 15 | "main": "./dist/module.cjs", 16 | "types": "./dist/types.d.ts", 17 | "files": [ 18 | "dist" 19 | ], 20 | "scripts": { 21 | "dev": "nuxi dev playground", 22 | "dev:build": "nuxi build playground", 23 | "dev:prepare": "nuxt-module-build --stub && nuxi prepare playground", 24 | "lint": "eslint .", 25 | "test": "vitest run", 26 | "test:watch": "vitest watch", 27 | "build": "nuxt-module-build", 28 | "release": "npm run lint && npm run test && npm run build && npm publish --access public" 29 | }, 30 | "dependencies": { 31 | "@nuxt/kit": "^3.3.3", 32 | "listhen": "^1.0.4", 33 | "ws": "^8.13.0" 34 | }, 35 | "devDependencies": { 36 | "@nuxt/eslint-config": "^0.1.1", 37 | "@nuxt/module-builder": "^0.2.1", 38 | "@nuxt/schema": "^3.3.3", 39 | "@nuxt/test-utils": "^3.3.3", 40 | "@types/ws": "^8.5.4", 41 | "eslint": "^8.37.0", 42 | "nuxt": "^3.3.3", 43 | "vitest": "^0.29.8" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /playground/app.vue: -------------------------------------------------------------------------------- 1 | 2 | 22 | 23 | 56 | 57 | 72 | -------------------------------------------------------------------------------- /playground/examples/filtered.ts: -------------------------------------------------------------------------------- 1 | import { Nuxt } from '@nuxt/schema' 2 | import { defineNuxtModule } from '@nuxt/kit' 3 | import { useSocketServer, } from '../../src/runtime/server' 4 | import { getTime, getCount } from './utils' 5 | 6 | /** 7 | * Create a socket that will respond only to item:one and item:two 8 | */ 9 | export default defineNuxtModule({ 10 | setup (options: any, nuxt: Nuxt) { 11 | const itemsSocket = useSocketServer(nuxt, 'item:*', function (message: any) { 12 | // log message 13 | console.log({ handler: 'item:*', ...message }) 14 | 15 | // send message back 16 | setTimeout(() => { 17 | itemsSocket.send({ 18 | message: 'Hello to items from server!', 19 | time: getTime(), 20 | count: getCount(), 21 | }) 22 | }, 1000) 23 | }) 24 | .addHandler('one', () => { 25 | console.log({ handler: 'item:one' }) 26 | }) 27 | .addHandler('two', () => { 28 | console.log({ handler: 'item:two' }) 29 | }) 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /playground/examples/named.ts: -------------------------------------------------------------------------------- 1 | import { Nuxt } from '@nuxt/schema' 2 | import { defineNuxtModule } from '@nuxt/kit' 3 | import { useSocketServer, } from '../../src/runtime/server' 4 | import { getTime, getCount } from './utils' 5 | 6 | /** 7 | * Create a socket that will respond only to its given name 8 | */ 9 | export default defineNuxtModule({ 10 | setup (options: any, nuxt: Nuxt) { 11 | const namedSocket = useSocketServer(nuxt, 'named', function (message: any) { 12 | // log message 13 | console.log({ handler: 'named', ...message }) 14 | 15 | // send message back 16 | setTimeout(() => { 17 | namedSocket.send({ 18 | message: 'Hello to name from server!', 19 | time: getTime(), 20 | count: getCount(), 21 | }) 22 | }, 1000) 23 | }) 24 | } 25 | }) 26 | -------------------------------------------------------------------------------- /playground/examples/utils.ts: -------------------------------------------------------------------------------- 1 | export function getTime () { 2 | return (new Date()).toISOString().substring(11, 19) 3 | } 4 | 5 | let number = 1 6 | export function getCount () { 7 | return number++ 8 | } 9 | -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { createResolver } from '@nuxt/kit' 2 | 3 | const { resolve } = createResolver(import.meta.url) 4 | 5 | export default defineNuxtConfig({ 6 | app: { 7 | head: { 8 | link: [ 9 | { rel: 'stylesheet', href: 'https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma-rtl.min.css' } 10 | ] 11 | } 12 | }, 13 | 14 | modules: [ 15 | '../src/module', 16 | resolve('examples/named'), 17 | resolve('examples/filtered'), 18 | ], 19 | }) 20 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "nuxt-sockets-playground" 4 | } 5 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtModule, createResolver, addImports } from '@nuxt/kit' 2 | 3 | const { resolve } = createResolver(import.meta.url) 4 | 5 | export interface ModuleOptions { 6 | port: { 7 | port: 4001, 8 | portRange: [4001, 4040] 9 | }, 10 | hostname: 'localhost', 11 | showURL: false 12 | } 13 | 14 | export default defineNuxtModule({ 15 | meta: { 16 | name: 'nuxt-sockets', 17 | configKey: 'sockets', 18 | }, 19 | 20 | async setup () { 21 | addImports([ 22 | { 23 | name: 'useSocketServer', 24 | from: resolve('runtime/server'), 25 | }, 26 | { 27 | name: 'useSocketClient', 28 | from: resolve('runtime/client'), 29 | }, 30 | ]) 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /src/runtime/client/index.ts: -------------------------------------------------------------------------------- 1 | import { useRuntimeConfig } from '#imports' 2 | import { Callback, SocketInstance } from '../types' 3 | 4 | export function useSocketClient (channel: string = '*', callback?: Callback): Promise { 5 | const url = useRuntimeConfig().public.sockets?.wsUrl 6 | return new Promise(function (resolve) { 7 | if (process.client && url) { 8 | import('./socket').then(({ useSocket }) => { 9 | resolve(useSocket(channel, callback)) 10 | }) 11 | } 12 | else { 13 | resolve(null) 14 | } 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /src/runtime/client/socket.ts: -------------------------------------------------------------------------------- 1 | import { useRuntimeConfig } from '#app' 2 | import { Callback, SocketInstance } from '../types' 3 | import { HandlerQueue } from '../queue' 4 | 5 | function createWebSocket () { 6 | const plugin = '[Nuxt Sockets]' 7 | const logger = { 8 | // eslint-disable-next-line no-console 9 | log: (...args: any[]) => console.log(plugin, ...args), 10 | // eslint-disable-next-line no-console 11 | warn: (...args: any[]) => console.warn(plugin, ...args) 12 | } 13 | 14 | // bail if not supported 15 | if (!window.WebSocket) { 16 | logger.warn('Unable to hot-reload images, your browser does not support WebSocket') 17 | return null 18 | } 19 | 20 | const onError = (e: any) => { 21 | switch (e.code) { 22 | case 'ECONNREFUSED': 23 | connect(true) 24 | break 25 | default: 26 | logger.warn('Error:', e) 27 | break 28 | } 29 | } 30 | 31 | const onClose = (e: any) => { 32 | // https://tools.ietf.org/html/rfc6455#section-11.7 33 | if (e.code === 1000 || e.code === 1005) { 34 | // normal close 35 | logger.log('Closed') 36 | } 37 | else { 38 | // unknown error 39 | connect(true) 40 | } 41 | } 42 | 43 | const send = (channel: string, data: any) => { 44 | if (ws) { 45 | ws.send(JSON.stringify({ channel, data })) 46 | } 47 | } 48 | 49 | const queue = new HandlerQueue() 50 | const addHandler = (channel: string, callback: Callback) => { 51 | queue.add({ channel, callback }) 52 | } 53 | 54 | // websocket 55 | let ws: WebSocket 56 | 57 | // connect 58 | const connect = (retry = false) => { 59 | // if retrying, wait for a second 60 | if (retry) { 61 | logger.log('Reconnecting...') 62 | setTimeout(connect, 1000) 63 | return 64 | } 65 | 66 | // otherwise, create the socket 67 | const url = useRuntimeConfig().public.sockets?.wsUrl 68 | const wsUrl = `${url}ws` 69 | ws = new WebSocket(wsUrl) 70 | 71 | // setup 72 | ws.onopen = () => logger.log(`Connected on ${wsUrl}`) 73 | ws.onmessage = queue.handle 74 | ws.onerror = onError 75 | ws.onclose = onClose 76 | } 77 | connect() 78 | 79 | return { 80 | send, 81 | addHandler, 82 | } 83 | } 84 | 85 | let ws: ReturnType 86 | 87 | export function useSocket (channel: string, callback?: Callback): SocketInstance | null { 88 | // create on first use 89 | if (!ws) { 90 | ws = createWebSocket() 91 | } 92 | 93 | // instance 94 | const instance = { 95 | send (data: any) { 96 | ws?.send(channel, data) 97 | return this 98 | }, 99 | addHandler (filter: string, callback: Callback) { 100 | ws?.addHandler(filter ? channel.replace(/(:\*)?$/, `:${filter}`) : channel, callback) 101 | return this 102 | } 103 | } 104 | 105 | if (callback) { 106 | instance.addHandler('', callback) 107 | } 108 | 109 | // return 110 | return instance 111 | } 112 | -------------------------------------------------------------------------------- /src/runtime/queue.ts: -------------------------------------------------------------------------------- 1 | import { Callback, Handler, MessageEvent } from './types' 2 | 3 | export class HandlerQueue { 4 | public handlers: Handler[] 5 | 6 | constructor () { 7 | this.handlers = [] 8 | this.handle = this.handle.bind(this) 9 | } 10 | 11 | add (handler: Callback | Handler): HandlerQueue { 12 | if (typeof handler === 'function') { 13 | handler = { channel: '*', callback: handler } 14 | } 15 | if (typeof handler.callback === 'function') { 16 | this.handlers.push(handler as Handler) 17 | } 18 | return this 19 | } 20 | 21 | remove (callback: Callback): HandlerQueue { 22 | this.handlers = this.handlers.filter(handler => handler.callback !== callback) 23 | return this 24 | } 25 | 26 | reset (): HandlerQueue { 27 | this.handlers = [] 28 | return this 29 | } 30 | 31 | handle (event: MessageEvent) { 32 | const { channel, data } = parseMessage(event) 33 | if (channel) { 34 | this.handlers 35 | .filter(handler => matchChannel(channel!, handler.channel)) 36 | .forEach(handler => handler.callback({ channel, data })) 37 | } 38 | } 39 | } 40 | 41 | function parseMessage (event: MessageEvent) { 42 | try { 43 | return JSON.parse(event.data || '{}') 44 | } 45 | catch (err) { 46 | console.error('Could not parse socket message data') 47 | return {} 48 | } 49 | } 50 | 51 | export function matchChannel (source: string, target: string) { 52 | if (target === '*') { 53 | return true 54 | } 55 | if (target.includes('*')) { 56 | const rx = makeMatcher(target) 57 | return rx.test(source) 58 | } 59 | if (source.includes('*')) { 60 | const rx = makeMatcher(source) 61 | return rx.test(target) 62 | } 63 | return target === source 64 | } 65 | 66 | function makeMatcher (value: string): RegExp { 67 | return new RegExp(`^${value.replace(/\*/g, '[^:]+')}$`) 68 | } 69 | -------------------------------------------------------------------------------- /src/runtime/server/index.ts: -------------------------------------------------------------------------------- 1 | import { listen, ListenOptions } from 'listhen' 2 | import { Nuxt } from '@nuxt/schema' 3 | import { Callback, SocketInstance } from '../types' 4 | import { createWebSocket } from './socket' 5 | 6 | const ws = createWebSocket() 7 | let initialized = false 8 | 9 | /** 10 | * Core sockets / nitro functionality lifted from 11 | * @see https://github.com/nuxt/content/blob/4dd4cb9b7fe657a63e493d63e2c19a534739206b/src/module.ts#L676 12 | */ 13 | export function useSocketServer (nuxt: Nuxt, channel: string, callback ?: Callback): SocketInstance { 14 | nuxt.hook('nitro:init', async (nitro) => { 15 | if (!initialized) { 16 | // set initialized 17 | initialized = true 18 | 19 | // options 20 | // @ts-ignore 21 | const options: Partial = nuxt.options.sockets || {} 22 | 23 | // listen dev server 24 | const { server, url } = await listen(() => 'Nuxt Sockets', options) 25 | 26 | // start server 27 | server.on('upgrade', ws.serve) 28 | 29 | // share URL 30 | nitro.options.runtimeConfig.public.sockets = { 31 | wsUrl: url.replace('http', 'ws') 32 | } 33 | 34 | // close on nuxt close 35 | nitro.hooks.hook('close', async () => { 36 | await ws.close() 37 | await server.close() 38 | }) 39 | } 40 | }) 41 | 42 | // return 43 | const instance = { 44 | send (data: any) { 45 | ws.broadcast(channel, data) 46 | return this 47 | }, 48 | addHandler (filter: string, callback: Callback) { 49 | ws.addHandler(filter ? channel.replace(/(:\*)?$/, `:${filter}`) : channel, callback) 50 | return this 51 | } 52 | } 53 | 54 | // optional handler 55 | if (callback) { 56 | instance.addHandler('', callback) 57 | } 58 | 59 | // return 60 | return instance 61 | } 62 | -------------------------------------------------------------------------------- /src/runtime/server/socket.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage } from 'http' 2 | import { WebSocketServer } from 'ws' 3 | import { Callback } from '../types' 4 | import { HandlerQueue } from '../queue' 5 | 6 | /** 7 | * Core socket functionality lifted from Nuxt Content 8 | * @see https://github.com/nuxt/content/blob/4dd4cb9b7fe657a63e493d63e2c19a534739206b/src/utils.ts#L126 9 | */ 10 | export function createWebSocket () { 11 | const wss = new WebSocketServer({ noServer: true }) 12 | 13 | const serve = (req: IncomingMessage, socket = req.socket, head: any = '') => 14 | wss.handleUpgrade(req, socket, head, (client: any) => wss.emit('connection', client, req)) 15 | 16 | const broadcast = (channel: string, data: any) => { 17 | data = JSON.stringify({ channel, data }) 18 | for (const client of wss.clients) { 19 | try { 20 | client.send(data) 21 | } 22 | catch (err) { 23 | /* Ignore error (if client not ready to receive event) */ 24 | } 25 | } 26 | } 27 | 28 | // queue 29 | const queue = new HandlerQueue() 30 | const addHandler = (channel: string, callback: Callback) => { 31 | queue.add({ channel, callback }) 32 | } 33 | 34 | wss.on('connection', (client: any) => { 35 | client.addEventListener('message', queue.handle) 36 | }) 37 | 38 | return { 39 | wss, 40 | serve, 41 | broadcast, 42 | addHandler, 43 | close () { 44 | wss.clients.forEach((client: any) => client.close()) 45 | return new Promise(resolve => wss.close(resolve)) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/runtime/types.ts: -------------------------------------------------------------------------------- 1 | export type Callback = (message: SocketMessage) => void 2 | 3 | export interface SocketInstance { 4 | send: (data: T) => SocketInstance 5 | addHandler: (filter: string, handler: Callback) => SocketInstance 6 | } 7 | 8 | export interface SocketMessage { 9 | channel: string 10 | data: T 11 | } 12 | 13 | export type MessageEvent = { 14 | data: string 15 | } 16 | 17 | export type Handler = { 18 | channel: string 19 | callback: Callback 20 | } 21 | -------------------------------------------------------------------------------- /test/basic.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { fileURLToPath } from 'node:url' 3 | import { setup, $fetch } from '@nuxt/test-utils' 4 | 5 | describe('ssr', async () => { 6 | await setup({ 7 | rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)), 8 | }) 9 | 10 | it('renders the index page', async () => { 11 | // Get response to a server-rendered page with `$fetch`. 12 | const html = await $fetch('/') 13 | expect(html).toContain('
basic
') 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /test/fixtures/basic/app.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 7 | -------------------------------------------------------------------------------- /test/fixtures/basic/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import MyModule from '../../../src/module' 2 | 3 | export default defineNuxtConfig({ 4 | modules: [ 5 | MyModule 6 | ] 7 | }) 8 | -------------------------------------------------------------------------------- /test/fixtures/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "basic", 4 | "type": "module" 5 | } 6 | -------------------------------------------------------------------------------- /test/unit/handlers.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { HandlerQueue, matchChannel } from '../../src/runtime/queue' 3 | 4 | /** 5 | * Dummy Message class 6 | */ 7 | class Message { 8 | data: string 9 | constructor (channel: string = '*', data = 'test') { 10 | this.data = JSON.stringify({ channel, data }) 11 | } 12 | } 13 | 14 | describe('queue', function () { 15 | it('should call events', function () { 16 | const queue = new HandlerQueue() 17 | let called = false 18 | const test = () => { 19 | called = true 20 | } 21 | queue.add(test) 22 | queue.handle(new Message()) 23 | expect(called).toBe(true) 24 | }) 25 | }) 26 | 27 | 28 | describe('matcher', function () { 29 | it('should match word', function () { 30 | expect(matchChannel('named', 'named')).toBe(true) 31 | expect(matchChannel('named', 'test')).toBe(false) 32 | }) 33 | it('should match star', function () { 34 | expect(matchChannel('*', 'named')).toBe(true) 35 | expect(matchChannel('*', '*')).toBe(true) 36 | }) 37 | it('should match segment and star', function () { 38 | expect(matchChannel('named:*', 'named:test')).toBe(true) 39 | expect(matchChannel('*:*', 'named:test')).toBe(true) 40 | expect(matchChannel('*', 'named:test')).toBe(false) 41 | expect(matchChannel('named:*', 'named:')).toBe(false) 42 | expect(matchChannel('named:*', 'blah')).toBe(false) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./playground/.nuxt/tsconfig.json" 3 | } 4 | --------------------------------------------------------------------------------