diff --git a/playground/app.config.ts b/playground/app.config.ts new file mode 100644 index 0000000..741c2b6 --- /dev/null +++ b/playground/app.config.ts @@ -0,0 +1,12 @@ +export default defineAppConfig({ + echo: { + interceptors: { + async onRequest(_app, ctx, logger) { + const tenant = 'random-string' + + ctx.options.headers.set('X-Echo-Tenant', tenant) + logger.debug('Updated tenant header', tenant) + } + }, + } +}) diff --git a/src/runtime/factories/echo.ts b/src/runtime/factories/echo.ts new file mode 100644 index 0000000..7017bda --- /dev/null +++ b/src/runtime/factories/echo.ts @@ -0,0 +1,102 @@ +import type { ConsolaInstance } from 'consola' +import Echo from 'laravel-echo' +import type { Channel, ChannelAuthorizationCallback, Options } from 'pusher-js' +import type { ChannelAuthorizationData } from 'pusher-js/types/src/core/auth/options' +import type { Authentication, ModuleOptions } from '../types/options' +import { createFetchClient } from './http' +import { createError, type NuxtApp } from '#app' + +/** + * Creates an authorizer function for the Echo instance. + * @param app The Nuxt application instance + * @param authentication The authentication options + * @param logger The logger instance + */ +function createAuthorizer( + app: NuxtApp, + authentication: Required, + logger: ConsolaInstance +) { + const client = createFetchClient(app, authentication, logger) + + return (channel: Channel, _: Options) => { + return { + authorize: (socketId: string, callback: ChannelAuthorizationCallback) => { + const payload = JSON.stringify({ + socket_id: socketId, + channel_name: channel.name, + }) + + client(authentication.authEndpoint, { + method: 'post', + body: payload, + }) + .then((response: ChannelAuthorizationData) => + callback(null, response) + ) + .catch((error: Error | null) => callback(error, null)) + }, + } + } +} + +/** + * Prepares the options for the Echo instance. + * Returns Pusher or Reverb options based on the broadcaster. + * @param app The Nuxt application instance + * @param config The module options + * @param logger The logger instance + */ +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 + ) + : undefined + + // Create a Pusher instance + if (config.broadcaster === 'pusher') { + if (!forceTLS) { + throw createError('Pusher requires a secure connection (schema: "https")') + } + + return { + broadcaster: config.broadcaster, + key: config.key, + cluster: config.cluster, + forceTLS, + authorizer, + ...additionalOptions, + } + } + + // Create a Reverb instance + return { + broadcaster: config.broadcaster, + key: config.key, + wsHost: config.host, + wsPort: config.port, + wssPort: config.port, + forceTLS, + enabledTransports: config.transports, + authorizer, + ...additionalOptions, + } +} + +/** + * Returns a new instance of Echo with configured authentication. + * @param app The Nuxt application instance + * @param config The module options + * @param logger The logger instance + */ +export function createEcho(app: NuxtApp, config: ModuleOptions, logger: ConsolaInstance) { + const options = prepareEchoOptions(app, config, logger) + + return new Echo(options) +} diff --git a/src/runtime/factories/http.ts b/src/runtime/factories/http.ts new file mode 100644 index 0000000..397779a --- /dev/null +++ b/src/runtime/factories/http.ts @@ -0,0 +1,76 @@ +import type { ConsolaInstance } from 'consola' +import type { FetchContext, FetchOptions } from 'ofetch' +import type { EchoAppConfig, EchoInterceptor } from '../types/config' +import handleCsrfCookie from '../interceptors/csrf' +import handleAuthToken from '../interceptors/token' +import type { Authentication } from '../types/options' +import { useEchoAppConfig } from '../composables/useEchoAppConfig' +import type { NuxtApp } from '#app' + +/** + * Returns a tuple of request and response interceptors. + * Includes both module and user-defined interceptors. + * @param appConfig The Echo application configuration. + */ +function useClientInterceptors(appConfig: EchoAppConfig): [EchoInterceptor[], EchoInterceptor[]] { + const [request, response] = [ + [ + handleCsrfCookie, + handleAuthToken, + ] as EchoInterceptor[], + [] as EchoInterceptor[], + ] + + if (appConfig.interceptors?.onRequest) { + request.push(appConfig.interceptors.onRequest) + } + + if (appConfig.interceptors?.onResponse) { + response.push(appConfig.interceptors.onResponse) + } + + return [request, response] +} + +/** + * Creates a fetch client with interceptors for handling authentication and CSRF tokens. + * @param app The Nuxt application instance. + * @param authentication The authentication configuration. + * @param logger The logger instance. + */ +export function createFetchClient( + app: NuxtApp, + authentication: Required, + logger: ConsolaInstance +) { + const appConfig = useEchoAppConfig() + + const [ + requestInterceptors, + responseInterceptors, + ] = useClientInterceptors(appConfig) + + const fetchOptions: FetchOptions = { + baseURL: authentication.baseUrl, + credentials: 'include', + retry: false, + + async onRequest(context) { + for (const interceptor of requestInterceptors) { + await app.runWithContext(async () => { + await interceptor(app, context, logger) + }) + } + }, + + async onResponse(context: FetchContext): Promise { + for (const interceptor of responseInterceptors) { + await app.runWithContext(async () => { + await interceptor(app, context, logger) + }) + } + }, + } + + return $fetch.create(fetchOptions) +} diff --git a/src/runtime/interceptors/csrf.ts b/src/runtime/interceptors/csrf.ts new file mode 100644 index 0000000..7cba888 --- /dev/null +++ b/src/runtime/interceptors/csrf.ts @@ -0,0 +1,58 @@ +import type { FetchContext } from 'ofetch' +import type { ConsolaInstance } from 'consola' +import type { ModuleOptions } from '../types/options' +import type { NuxtApp } from '#app' +import { useCookie } from '#app' + +const readCsrfCookie = (name: string) => useCookie(name, { readonly: true }) + +/** + * Sets the CSRF token header for the request if the CSRF cookie is present. + * @param app Nuxt application instance + * @param ctx Fetch context + * @param logger Module logger instance + */ +export default async function handleCsrfCookie( + app: NuxtApp, + ctx: FetchContext, + logger: ConsolaInstance, +): Promise { + const config = app.$config.public.echo as ModuleOptions + + if (config.authentication?.mode !== 'cookie') { + return + } + + const { authentication } = config + + if (authentication.csrfCookie === undefined) { + throw new Error(`'echo.authentication.csrfCookie' is not defined`) + } + + let csrfToken = readCsrfCookie(authentication.csrfCookie) + + if (!csrfToken.value) { + if (authentication.csrfEndpoint === undefined) { + throw new Error(`'echo.authentication.csrfCookie' is not defined`) + } + + await $fetch(authentication.csrfEndpoint, { + baseURL: authentication.baseUrl, + credentials: 'include', + retry: false, + }) + + csrfToken = readCsrfCookie(authentication.csrfCookie) + } + + if (!csrfToken.value) { + logger.warn(`${authentication.csrfCookie} cookie is missing, unable to set ${authentication.csrfHeader} header`) + return + } + + if (authentication.csrfHeader === undefined) { + throw new Error(`'echo.authentication.csrfHeader' is not defined`) + } + + ctx.options.headers.set(authentication.csrfHeader, csrfToken.value) +} diff --git a/src/runtime/interceptors/token.ts b/src/runtime/interceptors/token.ts new file mode 100644 index 0000000..969a847 --- /dev/null +++ b/src/runtime/interceptors/token.ts @@ -0,0 +1,38 @@ +import type { FetchContext } from 'ofetch' +import type { ConsolaInstance } from 'consola' +import type { ModuleOptions } from '../types/options' +import { useEchoAppConfig } from '../composables/useEchoAppConfig' +import { createError, type NuxtApp } from '#app' + +/** + * Sets Authorization header for the request if the token is present. + * @param app Nuxt application instance + * @param ctx Fetch context + * @param logger Module logger instance + */ +export default async function handleAuthToken( + app: NuxtApp, + ctx: FetchContext, + logger: ConsolaInstance, +): Promise { + const config = app.$config.public.echo as ModuleOptions + + if (config.authentication?.mode !== 'token') { + return + } + + 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 + } + + ctx.options.headers.set('Authorization', `Bearer ${token}`) +} diff --git a/src/runtime/plugin.client.ts b/src/runtime/plugin.client.ts index da33643..72c208c 100644 --- a/src/runtime/plugin.client.ts +++ b/src/runtime/plugin.client.ts @@ -1,12 +1,11 @@ -import Echo from 'laravel-echo' -import PusherPkg, { type Channel, type ChannelAuthorizationCallback, type Options } from 'pusher-js' -import type { ChannelAuthorizationData } from 'pusher-js/types/src/core/auth/options' +import type Echo from 'laravel-echo' +import PusherPkg from 'pusher-js' import { type ConsolaInstance, createConsola } from 'consola' -import type { FetchOptions } from 'ofetch' import { useEchoConfig } from './composables/useEchoConfig' -import type { Authentication, ModuleOptions, SupportedBroadcaster } from './types/options' +import type { SupportedBroadcaster } from './types/options' import { useEchoAppConfig } from './composables/useEchoAppConfig' -import { createError, defineNuxtPlugin, useCookie, updateAppConfig, type NuxtApp } from '#app' +import { createEcho } from './factories/echo' +import { defineNuxtPlugin, updateAppConfig, type NuxtApp } from '#app' // eslint-disable-next-line @typescript-eslint/no-explicit-any const Pusher = (PusherPkg as any).default || PusherPkg @@ -20,136 +19,19 @@ declare global { const MODULE_NAME = 'nuxt-laravel-echo' +/** + * Create a logger instance for the Echo module + * @param logLevel + */ function createEchoLogger(logLevel: number) { return createConsola({ level: logLevel }).withTag(MODULE_NAME) } -const readCsrfCookie = (name: string) => useCookie(name, { readonly: true }) - -function createFetchClient( - app: NuxtApp, - authentication: Required, - logger: ConsolaInstance -) { - const fetchOptions: FetchOptions = { - baseURL: authentication.baseUrl, - credentials: 'include', - retry: false, - - async onRequest(context) { - if (authentication.mode === 'cookie') { - let csrfToken = readCsrfCookie(authentication.csrfCookie) - - if (!csrfToken.value) { - await $fetch(authentication.csrfEndpoint, { - baseURL: authentication.baseUrl, - credentials: 'include', - retry: false, - }) - - csrfToken = readCsrfCookie(authentication.csrfCookie) - } - - 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) - } - - 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}`) - } - }, - } - - return $fetch.create(fetchOptions) -} - -function createAuthorizer( - app: NuxtApp, - authentication: Required, - logger: ConsolaInstance -) { - const client = createFetchClient(app, authentication, logger) - - return (channel: Channel, _: Options) => { - return { - authorize: (socketId: string, callback: ChannelAuthorizationCallback) => { - const payload = JSON.stringify({ - socket_id: socketId, - channel_name: channel.name, - }) - - client(authentication.authEndpoint, { - method: 'post', - body: payload, - }) - .then((response: ChannelAuthorizationData) => - callback(null, response) - ) - .catch((error: Error | null) => callback(error, null)) - }, - } - } -} - -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 - ) - : undefined - - // Create a Pusher instance - if (config.broadcaster === 'pusher') { - if (!forceTLS) { - throw createError('Pusher requires a secure connection (schema: "https")') - } - - return { - broadcaster: config.broadcaster, - key: config.key, - cluster: config.cluster, - forceTLS, - authorizer, - ...additionalOptions, - } - } - - // Create a Reverb instance - return { - broadcaster: config.broadcaster, - key: config.key, - wsHost: config.host, - wsPort: config.port, - wssPort: config.port, - forceTLS, - enabledTransports: config.transports, - authorizer, - ...additionalOptions, - } -} - +/** + * Setup default token storage if not defined by the user + * @param nuxtApp The Nuxt application instance + * @param logger The logger instance + */ async function setupDefaultTokenStorage(nuxtApp: NuxtApp, logger: ConsolaInstance) { logger.debug( 'Token storage is not defined, switch to default cookie storage', @@ -177,7 +59,7 @@ export default defineNuxtPlugin(async (_nuxtApp) => { } window.Pusher = Pusher - window.Echo = new Echo(prepareEchoOptions(nuxtApp, config, logger)) + window.Echo = createEcho(nuxtApp, config, logger) logger.debug('Laravel Echo client initialized') diff --git a/src/runtime/types/config.ts b/src/runtime/types/config.ts index 07d0c0e..661a1c8 100644 --- a/src/runtime/types/config.ts +++ b/src/runtime/types/config.ts @@ -1,3 +1,5 @@ +import type { FetchContext } from 'ofetch' +import type { ConsolaInstance } from 'consola' import type { NuxtApp } from '#app' /** @@ -14,6 +16,29 @@ export interface TokenStorage { set: (app: NuxtApp, token?: string) => Promise } +/** + * Request interceptor. + */ +export type EchoInterceptor = ( + app: NuxtApp, + ctx: FetchContext, + logger: ConsolaInstance +) => Promise + +/** + * Interceptors to be used by the ofetch client. + */ +export interface EchoInterceptors { + /** + * Function to execute before sending a request. + */ + onRequest?: EchoInterceptor + /** + * Function to execute after receiving a response. + */ + onResponse?: EchoInterceptor +} + /** * Echo configuration for the application side with user-defined handlers. */ @@ -22,4 +47,8 @@ export interface EchoAppConfig { * Token storage handlers to be used by the client. */ tokenStorage?: TokenStorage + /** + * Request interceptors to be used by the client. + */ + interceptors?: EchoInterceptors } diff --git a/src/templates.ts b/src/templates.ts index 6708cf4..56120f5 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -1,5 +1,9 @@ import { addTypeTemplate, type Resolver } from '@nuxt/kit' +/** + * Defines module's type augmentation for Nuxt build + * @param resolver + */ export const registerTypeTemplates = (resolver: Resolver) => { addTypeTemplate({ filename: 'types/echo.d.ts',