diff --git a/src/module.ts b/src/module.ts index 02e2fdd..724688a 100644 --- a/src/module.ts +++ b/src/module.ts @@ -6,7 +6,8 @@ import { addImportsDir, } from '@nuxt/kit' import { defu } from 'defu' -import type { ModuleOptions } from './runtime/types' +import type { ModuleOptions } from './runtime/types/options' +import { registerTypeTemplates } from './templates' const MODULE_NAME = 'nuxt-laravel-echo' @@ -19,6 +20,7 @@ const defaultModuleOptions: ModuleOptions = { scheme: 'https', transports: ['ws', 'wss'], authentication: { + mode: 'cookie', baseUrl: 'http://localhost:80', authEndpoint: '/broadcasting/auth', csrfEndpoint: '/sanctum/csrf-cookie', @@ -50,6 +52,8 @@ export default defineNuxtModule({ addPlugin(resolver.resolve('./runtime/plugin.client')) addImportsDir(resolver.resolve('./runtime/composables')) + registerTypeTemplates(resolver) + logger.info('Laravel Echo module initialized!') }, }) diff --git a/src/runtime/composables/useEcho.ts b/src/runtime/composables/useEcho.ts index 1250c5c..ed9a79c 100644 --- a/src/runtime/composables/useEcho.ts +++ b/src/runtime/composables/useEcho.ts @@ -1,5 +1,5 @@ import type Echo from 'laravel-echo' -import type { SupportedBroadcaster } from '../types' +import type { SupportedBroadcaster } from '../types/options' import { useNuxtApp } from '#app' export const useEcho = (): Echo => { diff --git a/src/runtime/composables/useEchoAppConfig.ts b/src/runtime/composables/useEchoAppConfig.ts new file mode 100644 index 0000000..7208ec9 --- /dev/null +++ b/src/runtime/composables/useEchoAppConfig.ts @@ -0,0 +1,6 @@ +import type { EchoAppConfig } from '../types/config' +import { useAppConfig } from '#app' + +export const useEchoAppConfig = (): EchoAppConfig => { + return (useAppConfig().echo ?? {}) as EchoAppConfig +} diff --git a/src/runtime/composables/useEchoConfig.ts b/src/runtime/composables/useEchoConfig.ts index f1eeb22..4ebc8f8 100644 --- a/src/runtime/composables/useEchoConfig.ts +++ b/src/runtime/composables/useEchoConfig.ts @@ -1,4 +1,4 @@ -import type { ModuleOptions } from '../types' +import type { ModuleOptions } from '../types/options' import { useRuntimeConfig } from '#imports' export const useEchoConfig = (): ModuleOptions => { diff --git a/src/runtime/plugin.client.ts b/src/runtime/plugin.client.ts index 1600564..da33643 100644 --- a/src/runtime/plugin.client.ts +++ b/src/runtime/plugin.client.ts @@ -4,8 +4,9 @@ import type { ChannelAuthorizationData } from 'pusher-js/types/src/core/auth/opt import { type ConsolaInstance, createConsola } from 'consola' import type { FetchOptions } from 'ofetch' import { useEchoConfig } from './composables/useEchoConfig' -import type { Authentication, ModuleOptions, SupportedBroadcaster } from './types' -import { createError, defineNuxtPlugin, useCookie } from '#app' +import type { Authentication, ModuleOptions, SupportedBroadcaster } from './types/options' +import { useEchoAppConfig } from './composables/useEchoAppConfig' +import { createError, defineNuxtPlugin, useCookie, updateAppConfig, type NuxtApp } from '#app' // eslint-disable-next-line @typescript-eslint/no-explicit-any const Pusher = (PusherPkg as any).default || PusherPkg @@ -26,6 +27,7 @@ function createEchoLogger(logLevel: number) { const readCsrfCookie = (name: string) => useCookie(name, { readonly: true }) function createFetchClient( + app: NuxtApp, authentication: Required, logger: ConsolaInstance ) { @@ -35,24 +37,43 @@ function createFetchClient( retry: false, async onRequest(context) { - let csrfToken = readCsrfCookie(authentication.csrfCookie) + if (authentication.mode === 'cookie') { + let csrfToken = readCsrfCookie(authentication.csrfCookie) - if (!csrfToken.value) { - await $fetch(authentication.csrfEndpoint, { - baseURL: authentication.baseUrl, - credentials: 'include', - retry: false, - }) + if (!csrfToken.value) { + await $fetch(authentication.csrfEndpoint, { + baseURL: authentication.baseUrl, + credentials: 'include', + retry: false, + }) - csrfToken = readCsrfCookie(authentication.csrfCookie) - } + csrfToken = readCsrfCookie(authentication.csrfCookie) + } - if (!csrfToken.value) { - logger.warn(`${authentication.csrfCookie} cookie is missing, unable to set ${authentication.csrfHeader} header`) - return + if (!csrfToken.value) { + logger.warn(`${authentication.csrfCookie} cookie is missing, unable to set ${authentication.csrfHeader} header`) + return + } + + context.options.headers.set(authentication.csrfHeader, csrfToken.value) } - context.options.headers.set(authentication.csrfHeader, csrfToken.value) + if (authentication.mode === 'token') { + const { tokenStorage } = useEchoAppConfig() + + if (!tokenStorage) { + throw createError('Token storage is not defined') + } + + const token = await tokenStorage.get(app) + + if (!token) { + logger.debug('Authorization token is missing, unable to set header') + return + } + + context.options.headers.set('Authorization', `Bearer ${token}`) + } }, } @@ -60,10 +81,11 @@ function createFetchClient( } function createAuthorizer( + app: NuxtApp, authentication: Required, logger: ConsolaInstance ) { - const client = createFetchClient(authentication, logger) + const client = createFetchClient(app, authentication, logger) return (channel: Channel, _: Options) => { return { @@ -86,12 +108,13 @@ function createAuthorizer( } } -function prepareEchoOptions(config: ModuleOptions, logger: ConsolaInstance) { +function prepareEchoOptions(app: NuxtApp, config: ModuleOptions, logger: ConsolaInstance) { const forceTLS = config.scheme === 'https' const additionalOptions = config.properties || {} const authorizer = config.authentication ? createAuthorizer( + app, config.authentication as Required, logger ) @@ -127,12 +150,34 @@ function prepareEchoOptions(config: ModuleOptions, logger: ConsolaInstance) { } } -export default defineNuxtPlugin((_nuxtApp) => { +async function setupDefaultTokenStorage(nuxtApp: NuxtApp, logger: ConsolaInstance) { + logger.debug( + 'Token storage is not defined, switch to default cookie storage', + ) + + const defaultStorage = await import('./storages/cookieTokenStorage') + + nuxtApp.runWithContext(() => { + updateAppConfig({ + echo: { + tokenStorage: defaultStorage.cookieTokenStorage, + }, + }) + }) +} + +export default defineNuxtPlugin(async (_nuxtApp) => { + const nuxtApp = _nuxtApp as NuxtApp const config = useEchoConfig() + const appConfig = useEchoAppConfig() const logger = createEchoLogger(config.logLevel) + if (config.authentication?.mode === 'token' && !appConfig.tokenStorage) { + await setupDefaultTokenStorage(nuxtApp, logger) + } + window.Pusher = Pusher - window.Echo = new Echo(prepareEchoOptions(config, logger)) + window.Echo = new Echo(prepareEchoOptions(nuxtApp, config, logger)) logger.debug('Laravel Echo client initialized') diff --git a/src/runtime/storages/cookieTokenStorage.ts b/src/runtime/storages/cookieTokenStorage.ts new file mode 100644 index 0000000..6312480 --- /dev/null +++ b/src/runtime/storages/cookieTokenStorage.ts @@ -0,0 +1,25 @@ +import { unref } from 'vue' +import type { TokenStorage } from '../types/config' +import { useCookie, type NuxtApp } from '#app' + +const cookieTokenKey = 'sanctum.token.cookie' + +/** + * Token storage using a secure cookie. + * Works with both CSR/SSR modes. + */ +export const cookieTokenStorage: TokenStorage = { + async get(app: NuxtApp) { + return app.runWithContext(() => { + const cookie = useCookie(cookieTokenKey, { readonly: true }) + return unref(cookie.value) ?? undefined + }) + }, + + async set(app: NuxtApp, token?: string) { + await app.runWithContext(() => { + const cookie = useCookie(cookieTokenKey, { secure: true }) + cookie.value = token + }) + }, +} diff --git a/src/runtime/types/config.ts b/src/runtime/types/config.ts new file mode 100644 index 0000000..07d0c0e --- /dev/null +++ b/src/runtime/types/config.ts @@ -0,0 +1,25 @@ +import type { NuxtApp } from '#app' + +/** + * Handlers to work with authentication token. + */ +export interface TokenStorage { + /** + * Function to load a token from the storage. + */ + get: (app: NuxtApp) => Promise + /** + * Function to save a token to the storage. + */ + set: (app: NuxtApp, token?: string) => Promise +} + +/** + * Echo configuration for the application side with user-defined handlers. + */ +export interface EchoAppConfig { + /** + * Token storage handlers to be used by the client. + */ + tokenStorage?: TokenStorage +} diff --git a/src/runtime/types.ts b/src/runtime/types/options.ts similarity index 94% rename from src/runtime/types.ts rename to src/runtime/types/options.ts index 70a461d..79e5b54 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types/options.ts @@ -1,6 +1,11 @@ export type SupportedBroadcaster = 'reverb' | 'pusher' export interface Authentication { + /** + * Authentication mode 'cookie' or 'token' + * @default 'cookie' + */ + mode: 'cookie' | 'token' /** * The base URL of Laravel application. * @default 'http://localhost:80' diff --git a/src/templates.ts b/src/templates.ts new file mode 100644 index 0000000..6708cf4 --- /dev/null +++ b/src/templates.ts @@ -0,0 +1,29 @@ +import { addTypeTemplate, type Resolver } from '@nuxt/kit' + +export const registerTypeTemplates = (resolver: Resolver) => { + addTypeTemplate({ + filename: 'types/echo.d.ts', + getContents: () => `// Generated by nuxt-laravel-echo module +import type { EchoAppConfig } from '${resolver.resolve('./runtime/types/config.ts')}'; + +declare module 'nuxt/schema' { + interface AppConfig { + echo?: EchoAppConfig; + } + interface AppConfigInput { + echo?: EchoAppConfig; + } +} + +declare module '@nuxt/schema' { + interface AppConfig { + echo?: EchoAppConfig; + } + interface AppConfigInput { + echo?: EchoAppConfig; + } +} + +export {};`, + }) +}