@norriq/nuxt-api-proxy 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,8 @@
1
+ Copyright (c) 2026 NORRIQ. All rights reserved.
2
+
3
+ This software and associated documentation files (the "Software") are the
4
+ proprietary property of NORRIQ. Unauthorized use, copying, modification,
5
+ distribution, or any other exploitation of the Software, in whole or in part,
6
+ is strictly prohibited without the prior written consent of NORRIQ.
7
+
8
+ For licensing inquiries, contact: info@norriq.com
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # @norriq/nuxt-api-proxy
2
+
3
+ Nuxt layer that adds a catch-all `/api/proxy/*` server route. Forwards requests to a backend API with the user's auth token automatically injected.
4
+
5
+ Supports multipart uploads, binary responses, and distributed tracing (traceparent header forwarding).
6
+
7
+ ## Usage
8
+
9
+ Requires `@norriq/nuxt-auth` (or `@sidebase/nuxt-auth`) to be configured in the consuming app — the proxy reads the session token from the auth module.
10
+
11
+ ```ts
12
+ // nuxt.config.ts
13
+ export default defineNuxtConfig({
14
+ extends: ['@norriq/nuxt-auth', '@norriq/nuxt-api-proxy'],
15
+ })
16
+ ```
17
+
18
+ ## Environment variables
19
+
20
+ | Variable | Scope | Required | Description |
21
+ |----------|-------|----------|-------------|
22
+ | `NUXT_API_URI` | Server | Yes | Target API base URL |
23
+ | `NUXT_PUBLIC_API_URI` | Public | No | Fallback if `NUXT_API_URI` not set |
24
+
25
+ ## How it works
26
+
27
+ All requests to `/api/proxy/*` are forwarded to the target API:
28
+
29
+ - HTTP method, headers, query params, and body are preserved
30
+ - `Authorization: Bearer <token>` is injected from the user's session
31
+ - Multipart uploads are detected and forwarded as binary
32
+ - JSON responses are parsed; binary responses are streamed through
33
+ - `traceparent` header is forwarded for distributed tracing
package/nuxt.config.ts ADDED
@@ -0,0 +1,5 @@
1
+ export default defineNuxtConfig({
2
+ runtimeConfig: {
3
+ apiUri: '', // Backend API URL (server-only, set via NUXT_API_URI)
4
+ },
5
+ })
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@norriq/nuxt-api-proxy",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "./nuxt.config.ts",
6
+ "exports": {
7
+ ".": "./nuxt.config.ts"
8
+ },
9
+ "files": [
10
+ "nuxt.config.ts",
11
+ "server"
12
+ ],
13
+ "devDependencies": {
14
+ "vitest": "^3.2.4"
15
+ },
16
+ "license": "SEE LICENSE IN LICENSE",
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "peerDependencies": {
21
+ "nuxt": ">=3.0.0",
22
+ "@sidebase/nuxt-auth": ">=1.0.0"
23
+ },
24
+ "scripts": {
25
+ "test": "vitest run"
26
+ }
27
+ }
@@ -0,0 +1,92 @@
1
+ import { getToken } from '#auth'
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ const config = useRuntimeConfig()
5
+ // Server-only NUXT_API_URI, falls back to NUXT_PUBLIC_API_URI for backward compatibility
6
+ const apiTarget = String(config.apiUri || config.public.apiUri || '')
7
+
8
+ if (!apiTarget) {
9
+ setResponseStatus(event, 500)
10
+ return { message: 'API proxy target not configured (set NUXT_API_URI)' }
11
+ }
12
+
13
+ // Auth: read token from httpOnly session cookie
14
+ const token = await getToken({ event })
15
+ const rawToken = token?.impersonateToken || token?.accessToken
16
+ const accessToken = typeof rawToken === 'string' ? rawToken : undefined
17
+
18
+ // Reconstruct target URL
19
+ const path = getRouterParam(event, 'path') ?? ''
20
+ const query = getQuery(event)
21
+ const qs = new URLSearchParams(
22
+ Object.fromEntries(Object.entries(query).map(([k, v]) => [k, String(v)]))
23
+ ).toString()
24
+ const targetUrl = `${apiTarget}/${path}${qs ? `?${qs}` : ''}`
25
+
26
+ // Detect request mode from content-type
27
+ const requestContentType = getRequestHeader(event, 'content-type') || ''
28
+ const isMultipartUpload = requestContentType.startsWith('multipart/')
29
+
30
+ const method = event.method
31
+ const headers: Record<string, string> = {}
32
+
33
+ // Read request body
34
+ let body: BodyInit | undefined
35
+ if (isMultipartUpload) {
36
+ // Raw buffer preserves binary/multipart integrity
37
+ body = (await readRawBody(event, false)) as BodyInit | undefined
38
+ headers['Content-Type'] = requestContentType
39
+ } else if (['POST', 'PUT', 'PATCH'].includes(method)) {
40
+ body = (await readRawBody(event)) as BodyInit | undefined
41
+ headers['Content-Type'] = 'application/json'
42
+ }
43
+
44
+ if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`
45
+
46
+ // Forward APM traceparent for distributed tracing
47
+ try {
48
+ const apm = await import('elastic-apm-node').then((m) => m.default)
49
+ const traceparent = apm.currentTransaction?.traceparent
50
+ if (traceparent) headers['traceparent'] = traceparent
51
+ } catch {
52
+ // APM not available
53
+ }
54
+
55
+ try {
56
+ // Multipart uploads need native fetch to avoid body corruption
57
+ if (isMultipartUpload) {
58
+ const response = await fetch(targetUrl, { method, headers, body })
59
+ setResponseStatus(event, response.status)
60
+ const data = await response.json().catch(() => null)
61
+ return data
62
+ }
63
+
64
+ // Use native fetch for all requests so we can inspect the response
65
+ // content-type before deciding how to return the body
66
+ const response = await fetch(targetUrl, { method, headers, body })
67
+ setResponseStatus(event, response.status)
68
+
69
+ const responseContentType = response.headers.get('content-type') || ''
70
+
71
+ // Binary response: stream body back with original headers
72
+ if (response.ok && !responseContentType.includes('application/json')) {
73
+ const contentDisposition = response.headers.get('content-disposition')
74
+ if (responseContentType) setResponseHeader(event, 'content-type', responseContentType)
75
+ if (contentDisposition) setResponseHeader(event, 'content-disposition', contentDisposition)
76
+ return response.body
77
+ }
78
+
79
+ // JSON response (or error): parse and return
80
+ const data = await response.json().catch(() => null)
81
+
82
+ if (import.meta.dev && response.status >= 400) {
83
+ console.log(`[api-proxy] ${method} ${response.status} ${targetUrl}`)
84
+ }
85
+
86
+ return data
87
+ } catch (err) {
88
+ console.error('[api-proxy] Proxy error:', err)
89
+ setResponseStatus(event, 502)
90
+ return { message: 'Proxy error', error: String(err) }
91
+ }
92
+ })
@@ -0,0 +1,197 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import type { EventHandler } from 'h3'
3
+ import { fakeEvent, fetchCallUrl, fetchCallHeaders } from '../../test-helpers'
4
+
5
+ const mockGetToken = vi.fn()
6
+
7
+ vi.mock('#auth', () => ({
8
+ getToken: (...args: unknown[]) => mockGetToken(...args),
9
+ }))
10
+
11
+ let capturedConfig = {
12
+ apiUri: 'https://api.example.com',
13
+ public: { apiUri: '' },
14
+ }
15
+
16
+ let mockPath = 'users/123'
17
+ let mockQuery: Record<string, string> = {}
18
+ let mockHeaders: Record<string, string> = {}
19
+
20
+ vi.stubGlobal('defineEventHandler', <T>(fn: T): T => fn)
21
+ vi.stubGlobal('useRuntimeConfig', () => capturedConfig)
22
+ vi.stubGlobal('getRouterParam', (_event: unknown, param: string) => {
23
+ if (param === 'path') return mockPath
24
+ return undefined
25
+ })
26
+ vi.stubGlobal('getQuery', () => mockQuery)
27
+ vi.stubGlobal('getRequestHeader', (_event: unknown, header: string) => {
28
+ return mockHeaders[header.toLowerCase()]
29
+ })
30
+ vi.stubGlobal('setResponseStatus', vi.fn())
31
+ vi.stubGlobal('setResponseHeader', vi.fn())
32
+ vi.stubGlobal('readRawBody', vi.fn().mockResolvedValue('{"key":"value"}'))
33
+
34
+ const mockFetch = vi.fn()
35
+ vi.stubGlobal('fetch', mockFetch)
36
+
37
+ function jsonResponse(status: number, data: unknown, headers: Record<string, string> = {}) {
38
+ return {
39
+ status,
40
+ ok: status >= 200 && status < 300,
41
+ headers: new Headers({ 'content-type': 'application/json', ...headers }),
42
+ json: () => Promise.resolve(data),
43
+ }
44
+ }
45
+
46
+ describe('api-proxy handler', () => {
47
+ let handler: EventHandler
48
+
49
+ beforeEach(async () => {
50
+ vi.clearAllMocks()
51
+ mockPath = 'users/123'
52
+ mockQuery = {}
53
+ mockHeaders = {}
54
+ capturedConfig = {
55
+ apiUri: 'https://api.example.com',
56
+ public: { apiUri: '' },
57
+ }
58
+
59
+ vi.resetModules()
60
+ const mod = await import('./[...path]')
61
+ handler = mod.default
62
+ })
63
+
64
+ it('returns 500 when API target is not configured', async () => {
65
+ capturedConfig = { apiUri: '', public: { apiUri: '' } }
66
+
67
+ const result = await handler(fakeEvent())
68
+
69
+ expect(result).toEqual({ message: 'API proxy target not configured (set NUXT_API_URI)' })
70
+ expect(setResponseStatus).toHaveBeenCalledWith(fakeEvent(), 500)
71
+ })
72
+
73
+ it('forwards GET request to target URL', async () => {
74
+ mockGetToken.mockResolvedValue({ accessToken: 'test-token' })
75
+ mockFetch.mockResolvedValue(jsonResponse(200, { data: 'response' }))
76
+
77
+ const result = await handler(fakeEvent())
78
+
79
+ expect(mockFetch).toHaveBeenCalledWith(
80
+ 'https://api.example.com/users/123',
81
+ expect.objectContaining({
82
+ method: 'GET',
83
+ headers: expect.objectContaining({ Authorization: 'Bearer test-token' }),
84
+ })
85
+ )
86
+ expect(result).toEqual({ data: 'response' })
87
+ })
88
+
89
+ it('appends query string to target URL', async () => {
90
+ mockQuery = { page: '2', limit: '10' }
91
+ mockGetToken.mockResolvedValue({ accessToken: 'tok' })
92
+ mockFetch.mockResolvedValue(jsonResponse(200, {}))
93
+
94
+ await handler(fakeEvent())
95
+
96
+ const url = fetchCallUrl(mockFetch)
97
+ expect(url).toContain('page=2')
98
+ expect(url).toContain('limit=10')
99
+ })
100
+
101
+ it('uses impersonateToken over accessToken when present', async () => {
102
+ mockGetToken.mockResolvedValue({ accessToken: 'user-token', impersonateToken: 'admin-token' })
103
+ mockFetch.mockResolvedValue(jsonResponse(200, {}))
104
+
105
+ await handler(fakeEvent())
106
+
107
+ expect(fetchCallHeaders(mockFetch)['Authorization']).toBe('Bearer admin-token')
108
+ })
109
+
110
+ it('does not set Authorization header when no token exists', async () => {
111
+ mockGetToken.mockResolvedValue(null)
112
+ mockFetch.mockResolvedValue(jsonResponse(200, {}))
113
+
114
+ await handler(fakeEvent())
115
+
116
+ expect(fetchCallHeaders(mockFetch)['Authorization']).toBeUndefined()
117
+ })
118
+
119
+ it('forwards POST body as JSON', async () => {
120
+ mockGetToken.mockResolvedValue(null)
121
+ mockFetch.mockResolvedValue(jsonResponse(201, { id: 1 }))
122
+
123
+ await handler(fakeEvent('POST'))
124
+
125
+ expect(mockFetch).toHaveBeenCalledWith(
126
+ expect.any(String),
127
+ expect.objectContaining({
128
+ method: 'POST',
129
+ headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
130
+ body: '{"key":"value"}',
131
+ })
132
+ )
133
+ })
134
+
135
+ it('detects multipart uploads and preserves content-type', async () => {
136
+ mockHeaders = { 'content-type': 'multipart/form-data; boundary=----abc' }
137
+ mockGetToken.mockResolvedValue(null)
138
+ mockFetch.mockResolvedValue({ status: 200, json: () => Promise.resolve({ uploaded: true }) })
139
+
140
+ await handler(fakeEvent('POST'))
141
+
142
+ expect(fetchCallHeaders(mockFetch)['Content-Type']).toBe('multipart/form-data; boundary=----abc')
143
+ })
144
+
145
+ it('streams binary responses with correct headers', async () => {
146
+ mockGetToken.mockResolvedValue({ accessToken: 'tok' })
147
+ const mockBody = new ReadableStream()
148
+ mockFetch.mockResolvedValue({
149
+ status: 200,
150
+ ok: true,
151
+ headers: new Headers({
152
+ 'content-type': 'application/pdf',
153
+ 'content-disposition': 'attachment; filename="report.pdf"',
154
+ }),
155
+ body: mockBody,
156
+ })
157
+
158
+ const event = fakeEvent()
159
+ const result = await handler(event)
160
+
161
+ expect(result).toBe(mockBody)
162
+ expect(setResponseHeader).toHaveBeenCalledWith(event, 'content-type', 'application/pdf')
163
+ expect(setResponseHeader).toHaveBeenCalledWith(event, 'content-disposition', 'attachment; filename="report.pdf"')
164
+ })
165
+
166
+ it('returns 502 on fetch error', async () => {
167
+ mockGetToken.mockResolvedValue(null)
168
+ mockFetch.mockRejectedValue(new Error('ECONNREFUSED'))
169
+
170
+ const event = fakeEvent()
171
+ const result = await handler(event)
172
+
173
+ expect(setResponseStatus).toHaveBeenCalledWith(event, 502)
174
+ expect(result).toEqual({ message: 'Proxy error', error: 'Error: ECONNREFUSED' })
175
+ })
176
+
177
+ it('falls back to public apiUri when server apiUri is not set', async () => {
178
+ capturedConfig = { apiUri: '', public: { apiUri: 'https://public-api.example.com' } }
179
+ mockGetToken.mockResolvedValue(null)
180
+ mockFetch.mockResolvedValue(jsonResponse(200, {}))
181
+
182
+ await handler(fakeEvent())
183
+
184
+ expect(fetchCallUrl(mockFetch).startsWith('https://public-api.example.com/')).toBe(true)
185
+ })
186
+
187
+ it('forwards upstream status code', async () => {
188
+ mockGetToken.mockResolvedValue(null)
189
+ mockFetch.mockResolvedValue(jsonResponse(404, { error: 'Not found' }))
190
+
191
+ const event = fakeEvent()
192
+ const result = await handler(event)
193
+
194
+ expect(setResponseStatus).toHaveBeenCalledWith(event, 404)
195
+ expect(result).toEqual({ error: 'Not found' })
196
+ })
197
+ })
@@ -0,0 +1,14 @@
1
+ import { vi } from 'vitest'
2
+ import type { H3Event } from 'h3'
3
+
4
+ export function fakeEvent(method = 'GET'): H3Event {
5
+ return { method } as H3Event
6
+ }
7
+
8
+ export function fetchCallUrl(mock: ReturnType<typeof vi.fn>): string {
9
+ return mock.mock.calls[0][0]
10
+ }
11
+
12
+ export function fetchCallHeaders(mock: ReturnType<typeof vi.fn>): Record<string, string> {
13
+ return mock.mock.calls[0][1].headers
14
+ }