Skip to content

feat(interceptors): added support for custom user-defined ofetch interceptors #114

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

Merged
merged 2 commits into from
Apr 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions playground/app.config.ts
Original file line number Diff line number Diff line change
@@ -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)
}
},
}
})
102 changes: 102 additions & 0 deletions src/runtime/factories/echo.ts
Original file line number Diff line number Diff line change
@@ -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<Authentication>,
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<ChannelAuthorizationData>(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<Authentication>,
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)
}
76 changes: 76 additions & 0 deletions src/runtime/factories/http.ts
Original file line number Diff line number Diff line change
@@ -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<Authentication>,
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<void> {
for (const interceptor of responseInterceptors) {
await app.runWithContext(async () => {
await interceptor(app, context, logger)
})
}
},
}

return $fetch.create(fetchOptions)
}
58 changes: 58 additions & 0 deletions src/runtime/interceptors/csrf.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
38 changes: 38 additions & 0 deletions src/runtime/interceptors/token.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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}`)
}
Loading