Skip to content

Commit 3cf08f6

Browse files
authored
Merge pull request #114 from manchenkoff/106-interceptors
feat(interceptors): added support for custom user-defined ofetch interceptors
2 parents 623e4ee + 5829aed commit 3cf08f6

File tree

8 files changed

+334
-133
lines changed

8 files changed

+334
-133
lines changed

playground/app.config.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export default defineAppConfig({
2+
echo: {
3+
interceptors: {
4+
async onRequest(_app, ctx, logger) {
5+
const tenant = 'random-string'
6+
7+
ctx.options.headers.set('X-Echo-Tenant', tenant)
8+
logger.debug('Updated tenant header', tenant)
9+
}
10+
},
11+
}
12+
})

src/runtime/factories/echo.ts

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { ConsolaInstance } from 'consola'
2+
import Echo from 'laravel-echo'
3+
import type { Channel, ChannelAuthorizationCallback, Options } from 'pusher-js'
4+
import type { ChannelAuthorizationData } from 'pusher-js/types/src/core/auth/options'
5+
import type { Authentication, ModuleOptions } from '../types/options'
6+
import { createFetchClient } from './http'
7+
import { createError, type NuxtApp } from '#app'
8+
9+
/**
10+
* Creates an authorizer function for the Echo instance.
11+
* @param app The Nuxt application instance
12+
* @param authentication The authentication options
13+
* @param logger The logger instance
14+
*/
15+
function createAuthorizer(
16+
app: NuxtApp,
17+
authentication: Required<Authentication>,
18+
logger: ConsolaInstance
19+
) {
20+
const client = createFetchClient(app, authentication, logger)
21+
22+
return (channel: Channel, _: Options) => {
23+
return {
24+
authorize: (socketId: string, callback: ChannelAuthorizationCallback) => {
25+
const payload = JSON.stringify({
26+
socket_id: socketId,
27+
channel_name: channel.name,
28+
})
29+
30+
client<ChannelAuthorizationData>(authentication.authEndpoint, {
31+
method: 'post',
32+
body: payload,
33+
})
34+
.then((response: ChannelAuthorizationData) =>
35+
callback(null, response)
36+
)
37+
.catch((error: Error | null) => callback(error, null))
38+
},
39+
}
40+
}
41+
}
42+
43+
/**
44+
* Prepares the options for the Echo instance.
45+
* Returns Pusher or Reverb options based on the broadcaster.
46+
* @param app The Nuxt application instance
47+
* @param config The module options
48+
* @param logger The logger instance
49+
*/
50+
function prepareEchoOptions(app: NuxtApp, config: ModuleOptions, logger: ConsolaInstance) {
51+
const forceTLS = config.scheme === 'https'
52+
const additionalOptions = config.properties || {}
53+
54+
const authorizer = config.authentication
55+
? createAuthorizer(
56+
app,
57+
config.authentication as Required<Authentication>,
58+
logger
59+
)
60+
: undefined
61+
62+
// Create a Pusher instance
63+
if (config.broadcaster === 'pusher') {
64+
if (!forceTLS) {
65+
throw createError('Pusher requires a secure connection (schema: "https")')
66+
}
67+
68+
return {
69+
broadcaster: config.broadcaster,
70+
key: config.key,
71+
cluster: config.cluster,
72+
forceTLS,
73+
authorizer,
74+
...additionalOptions,
75+
}
76+
}
77+
78+
// Create a Reverb instance
79+
return {
80+
broadcaster: config.broadcaster,
81+
key: config.key,
82+
wsHost: config.host,
83+
wsPort: config.port,
84+
wssPort: config.port,
85+
forceTLS,
86+
enabledTransports: config.transports,
87+
authorizer,
88+
...additionalOptions,
89+
}
90+
}
91+
92+
/**
93+
* Returns a new instance of Echo with configured authentication.
94+
* @param app The Nuxt application instance
95+
* @param config The module options
96+
* @param logger The logger instance
97+
*/
98+
export function createEcho(app: NuxtApp, config: ModuleOptions, logger: ConsolaInstance) {
99+
const options = prepareEchoOptions(app, config, logger)
100+
101+
return new Echo(options)
102+
}

src/runtime/factories/http.ts

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { ConsolaInstance } from 'consola'
2+
import type { FetchContext, FetchOptions } from 'ofetch'
3+
import type { EchoAppConfig, EchoInterceptor } from '../types/config'
4+
import handleCsrfCookie from '../interceptors/csrf'
5+
import handleAuthToken from '../interceptors/token'
6+
import type { Authentication } from '../types/options'
7+
import { useEchoAppConfig } from '../composables/useEchoAppConfig'
8+
import type { NuxtApp } from '#app'
9+
10+
/**
11+
* Returns a tuple of request and response interceptors.
12+
* Includes both module and user-defined interceptors.
13+
* @param appConfig The Echo application configuration.
14+
*/
15+
function useClientInterceptors(appConfig: EchoAppConfig): [EchoInterceptor[], EchoInterceptor[]] {
16+
const [request, response] = [
17+
[
18+
handleCsrfCookie,
19+
handleAuthToken,
20+
] as EchoInterceptor[],
21+
[] as EchoInterceptor[],
22+
]
23+
24+
if (appConfig.interceptors?.onRequest) {
25+
request.push(appConfig.interceptors.onRequest)
26+
}
27+
28+
if (appConfig.interceptors?.onResponse) {
29+
response.push(appConfig.interceptors.onResponse)
30+
}
31+
32+
return [request, response]
33+
}
34+
35+
/**
36+
* Creates a fetch client with interceptors for handling authentication and CSRF tokens.
37+
* @param app The Nuxt application instance.
38+
* @param authentication The authentication configuration.
39+
* @param logger The logger instance.
40+
*/
41+
export function createFetchClient(
42+
app: NuxtApp,
43+
authentication: Required<Authentication>,
44+
logger: ConsolaInstance
45+
) {
46+
const appConfig = useEchoAppConfig()
47+
48+
const [
49+
requestInterceptors,
50+
responseInterceptors,
51+
] = useClientInterceptors(appConfig)
52+
53+
const fetchOptions: FetchOptions = {
54+
baseURL: authentication.baseUrl,
55+
credentials: 'include',
56+
retry: false,
57+
58+
async onRequest(context) {
59+
for (const interceptor of requestInterceptors) {
60+
await app.runWithContext(async () => {
61+
await interceptor(app, context, logger)
62+
})
63+
}
64+
},
65+
66+
async onResponse(context: FetchContext): Promise<void> {
67+
for (const interceptor of responseInterceptors) {
68+
await app.runWithContext(async () => {
69+
await interceptor(app, context, logger)
70+
})
71+
}
72+
},
73+
}
74+
75+
return $fetch.create(fetchOptions)
76+
}

src/runtime/interceptors/csrf.ts

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { FetchContext } from 'ofetch'
2+
import type { ConsolaInstance } from 'consola'
3+
import type { ModuleOptions } from '../types/options'
4+
import type { NuxtApp } from '#app'
5+
import { useCookie } from '#app'
6+
7+
const readCsrfCookie = (name: string) => useCookie(name, { readonly: true })
8+
9+
/**
10+
* Sets the CSRF token header for the request if the CSRF cookie is present.
11+
* @param app Nuxt application instance
12+
* @param ctx Fetch context
13+
* @param logger Module logger instance
14+
*/
15+
export default async function handleCsrfCookie(
16+
app: NuxtApp,
17+
ctx: FetchContext,
18+
logger: ConsolaInstance,
19+
): Promise<void> {
20+
const config = app.$config.public.echo as ModuleOptions
21+
22+
if (config.authentication?.mode !== 'cookie') {
23+
return
24+
}
25+
26+
const { authentication } = config
27+
28+
if (authentication.csrfCookie === undefined) {
29+
throw new Error(`'echo.authentication.csrfCookie' is not defined`)
30+
}
31+
32+
let csrfToken = readCsrfCookie(authentication.csrfCookie)
33+
34+
if (!csrfToken.value) {
35+
if (authentication.csrfEndpoint === undefined) {
36+
throw new Error(`'echo.authentication.csrfCookie' is not defined`)
37+
}
38+
39+
await $fetch(authentication.csrfEndpoint, {
40+
baseURL: authentication.baseUrl,
41+
credentials: 'include',
42+
retry: false,
43+
})
44+
45+
csrfToken = readCsrfCookie(authentication.csrfCookie)
46+
}
47+
48+
if (!csrfToken.value) {
49+
logger.warn(`${authentication.csrfCookie} cookie is missing, unable to set ${authentication.csrfHeader} header`)
50+
return
51+
}
52+
53+
if (authentication.csrfHeader === undefined) {
54+
throw new Error(`'echo.authentication.csrfHeader' is not defined`)
55+
}
56+
57+
ctx.options.headers.set(authentication.csrfHeader, csrfToken.value)
58+
}

src/runtime/interceptors/token.ts

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { FetchContext } from 'ofetch'
2+
import type { ConsolaInstance } from 'consola'
3+
import type { ModuleOptions } from '../types/options'
4+
import { useEchoAppConfig } from '../composables/useEchoAppConfig'
5+
import { createError, type NuxtApp } from '#app'
6+
7+
/**
8+
* Sets Authorization header for the request if the token is present.
9+
* @param app Nuxt application instance
10+
* @param ctx Fetch context
11+
* @param logger Module logger instance
12+
*/
13+
export default async function handleAuthToken(
14+
app: NuxtApp,
15+
ctx: FetchContext,
16+
logger: ConsolaInstance,
17+
): Promise<void> {
18+
const config = app.$config.public.echo as ModuleOptions
19+
20+
if (config.authentication?.mode !== 'token') {
21+
return
22+
}
23+
24+
const { tokenStorage } = useEchoAppConfig()
25+
26+
if (!tokenStorage) {
27+
throw createError('Token storage is not defined')
28+
}
29+
30+
const token = await tokenStorage.get(app)
31+
32+
if (!token) {
33+
logger.debug('Authorization token is missing, unable to set header')
34+
return
35+
}
36+
37+
ctx.options.headers.set('Authorization', `Bearer ${token}`)
38+
}

0 commit comments

Comments
 (0)