Skip to content

Commit a335c54

Browse files
committed
feat(nuxt): handle user context on the server and use LRU cache for apps
1 parent e0b2b5a commit a335c54

14 files changed

+213
-83
lines changed

Diff for: packages/nuxt/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"dev:prepare": "nuxt-module-build --stub"
3232
},
3333
"dependencies": {
34-
"@nuxt/kit": "^3.0.0"
34+
"@nuxt/kit": "^3.0.0",
35+
"lru-cache": "^7.14.1"
3536
},
3637
"peerDependencies": {
3738
"@firebase/app-types": ">=0.8.1",

Diff for: packages/nuxt/playground/middleware/vuefire-auth.ts

+1-5
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
import { getCurrentUser } from 'vuefire'
22
export default defineNuxtRouteMiddleware(async (to, from) => {
33
const app = useNuxtApp().$firebaseApp
4-
// TODO: handle
5-
if (process.server) {
6-
return
7-
}
4+
console.log('app name', app.name)
85
const user = await getCurrentUser(app.name)
9-
console.log('user', user)
106

117
if (!user) {
128
return navigateTo('/authentication')
+16-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { doc } from 'firebase/firestore'
2+
import { doc, setDoc } from 'firebase/firestore'
33
import { useCurrentUser, useFirestore, usePendingPromises } from 'vuefire'
44
55
definePageMeta({
@@ -8,10 +8,16 @@ definePageMeta({
88
99
const db = useFirestore()
1010
const user = useCurrentUser()
11-
console.log(user.value?.uid)
1211
const secretRef = computed(() => user.value ? doc(db, 'secrets', user.value.uid) : null)
1312
1413
const secret = useDocument(secretRef)
14+
15+
const textSecret = ref('')
16+
function setSecret() {
17+
if (secretRef.value) {
18+
setDoc(secretRef.value, { text: textSecret.value })
19+
}
20+
}
1521
</script>
1622

1723
<template>
@@ -21,7 +27,14 @@ const secret = useDocument(secretRef)
2127
</p>
2228
<template v-else>
2329
<p>Secret Data for user {{ user.displayName }} ({{ user.uid }})</p>
24-
<pre>{{ secret }}</pre>
30+
<pre v-if="secret">{{ secret }}</pre>
31+
<div v-else>
32+
<p>You have no secret. Do you want to create one?</p>
33+
<form @submit.prevent="setSecret()">
34+
<input v-model="textSecret" type="text">
35+
<button>Set the secret</button>
36+
</form>
37+
</div>
2538
</template>
2639
</div>
2740
</template>

Diff for: packages/nuxt/src/module.ts

+22-14
Original file line numberDiff line numberDiff line change
@@ -143,20 +143,12 @@ const VueFire: NuxtModule<VueFireNuxtModuleOptions> =
143143
])
144144
}
145145

146-
if (options.admin) {
147-
if (!nuxt.options.ssr) {
148-
console.warn(
149-
'[VueFire]: The "admin" option is only used during SSR. You should reenable ssr to use it.'
150-
)
151-
}
152-
addPlugin(resolve(runtimeDir, 'admin/plugin.server'))
153-
}
154-
155146
if (options.appCheck) {
156-
addPlugin(resolve(runtimeDir, 'app-check/plugin'))
147+
addPlugin(resolve(runtimeDir, 'app-check/plugin.client'))
148+
addPlugin(resolve(runtimeDir, 'app-check/plugin.server'))
157149
}
158150

159-
// plugin are added in reverse order
151+
// this adds the VueFire plugin and handle SSR state serialization and hydration
160152
addPluginTemplate({
161153
src: normalize(resolve(templatesDir, 'plugin.ejs')),
162154

@@ -167,7 +159,22 @@ const VueFire: NuxtModule<VueFireNuxtModuleOptions> =
167159
})
168160

169161
// adds the firebase app to each application
170-
addPlugin(resolve(runtimeDir, 'app/plugin'))
162+
addPlugin(resolve(runtimeDir, 'app/plugin.client'))
163+
addPlugin(resolve(runtimeDir, 'app/plugin.server'))
164+
165+
// we start the admin app first so we can have access to the user uid everywhere
166+
if (options.admin) {
167+
if (!nuxt.options.ssr) {
168+
console.warn(
169+
'[VueFire]: The "admin" option is only used during SSR. You should reenable ssr to use it.'
170+
)
171+
}
172+
// this plugin adds the user so it's accessible directly in the app as well
173+
if (options.auth) {
174+
addPlugin(resolve(runtimeDir, 'admin/plugin-auth-user.server'))
175+
}
176+
addPlugin(resolve(runtimeDir, 'admin/plugin.server'))
177+
}
171178

172179
addVueFireImports([
173180
// app
@@ -186,6 +193,7 @@ const VueFire: NuxtModule<VueFireNuxtModuleOptions> =
186193
},
187194
})
188195

196+
// just to have autocomplete and errors
189197
type VueFireModuleExportKeys = keyof Awaited<typeof import('vuefire')>
190198
function addVueFireImports(
191199
imports: Array<{
@@ -231,12 +239,12 @@ declare module '@nuxt/schema' {
231239
declare module '#app' {
232240
interface NuxtApp {
233241
$firebaseApp: FirebaseApp
234-
$adminApp: FirebaseAdminApp
242+
$firebaseAdminApp: FirebaseAdminApp
235243
}
236244
}
237245
declare module '@vue/runtime-core' {
238246
interface ComponentCustomProperties {
239247
$firebaseApp: FirebaseApp
240-
$adminApp: FirebaseAdminApp
248+
$firebaseAdminApp: FirebaseAdminApp
241249
}
242250
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { App as AdminApp } from 'firebase-admin/app'
2+
import type { User } from 'firebase/auth'
3+
import { getAuth as getAdminAuth, UserRecord } from 'firebase-admin/auth'
4+
import { createServerUser } from 'vuefire/server'
5+
import { getCookie } from 'h3'
6+
// FirebaseError is an interface here but is a class in firebase/app
7+
import type { FirebaseError } from 'firebase-admin'
8+
import { AUTH_COOKIE_NAME } from '../auth/api.session'
9+
import { defineNuxtPlugin, useRequestEvent } from '#app'
10+
11+
/**
12+
* Check if there is a cookie and if it is valid, extracts the user from it.
13+
*/
14+
export default defineNuxtPlugin(async (nuxtApp) => {
15+
const event = useRequestEvent()
16+
const token = getCookie(event, AUTH_COOKIE_NAME)
17+
let user: UserRecord | undefined
18+
19+
if (token) {
20+
const adminApp = nuxtApp.$firebaseAdminApp as AdminApp
21+
const auth = getAdminAuth(adminApp)
22+
23+
try {
24+
// TODO: should we check for the revoked status of the token here?
25+
const decodedToken = await auth.verifyIdToken(token)
26+
user = await auth.getUser(decodedToken.uid)
27+
} catch (err) {
28+
// TODO: some errors should probably go higher
29+
// ignore the error and consider the user as not logged in
30+
if (isFirebaseError(err) && err.code === 'auth/id-token-expired') {
31+
// the error is fine, the user is not logged in
32+
} else {
33+
// ignore the error and consider the user as not logged in
34+
console.error(err)
35+
}
36+
}
37+
}
38+
39+
nuxtApp.payload.vuefireUser = user?.toJSON()
40+
41+
// @ts-expect-error: We cannot provide with a Symbol in nuxt and it cannot be typed
42+
nuxtApp[UserSymbol] = createServerUser(user)
43+
})
44+
45+
/**
46+
* Gets access to the user within the application. This is a symbol to keep it private for the moment.
47+
* @internal
48+
*/
49+
export const UserSymbol = Symbol('user')
50+
51+
function isFirebaseError(err: any): err is FirebaseError {
52+
return err != null && 'code' in err
53+
}

Diff for: packages/nuxt/src/runtime/admin/plugin.server.ts

+3-16
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
11
import { initializeApp, cert, getApp, getApps } from 'firebase-admin/app'
2-
import { VueFireAppCheckServer } from 'vuefire/server'
32
import type { FirebaseApp } from '@firebase/app-types'
43
import { defineNuxtPlugin, useAppConfig } from '#app'
54

65
export default defineNuxtPlugin((nuxtApp) => {
76
const appConfig = useAppConfig()
87

9-
const { firebaseConfig, firebaseAdmin, vuefireOptions } = appConfig
8+
const { firebaseAdmin } = appConfig
109

1110
// the admin sdk is not always needed, skip if not provided
1211
if (!firebaseAdmin?.config) {
1312
return
1413
}
1514

16-
const firebaseApp = nuxtApp.$firebaseApp as FirebaseApp
17-
1815
// only initialize the admin sdk once
1916
if (!getApps().length) {
2017
// this is specified when deployed on Firebase and automatically picks up the credentials from env variables
@@ -29,21 +26,11 @@ export default defineNuxtPlugin((nuxtApp) => {
2926
}
3027
}
3128

32-
const adminApp = getApp()
33-
if (vuefireOptions.appCheck) {
34-
// NOTE: necessary in VueFireAppCheckServer
35-
if (!firebaseApp.options.appId) {
36-
throw new Error(
37-
'[VueFire]: Missing "appId" in firebase config. This is necessary to use the app-check module on the server.'
38-
)
39-
}
40-
41-
VueFireAppCheckServer(adminApp, firebaseApp)
42-
}
29+
const firebaseAdminApp = getApp()
4330

4431
return {
4532
provide: {
46-
adminApp,
33+
firebaseAdminApp,
4734
},
4835
}
4936
})

Diff for: packages/nuxt/src/runtime/app-check/plugin.ts renamed to packages/nuxt/src/runtime/app-check/plugin.client.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,21 @@ import { VueFireAppCheck } from 'vuefire'
99
import { defineNuxtPlugin, useAppConfig } from '#app'
1010

1111
/**
12-
* Plugin to initialize the appCheck module.
12+
* Plugin to initialize the appCheck module. Must be added before the server version. TODO: verify it changes anything.
1313
*/
1414
export default defineNuxtPlugin((nuxtApp) => {
1515
const appConfig = useAppConfig()
1616
// NOTE: appCheck is present because of the check in module.ts
1717
const options = appConfig.vuefireOptions.appCheck!
1818
const firebaseApp = nuxtApp.$firebaseApp as FirebaseApp
1919

20-
// default provider for server
20+
// Add a default provider for production
21+
// TODO: make this a dev only warning
2122
let provider: AppCheckOptions['provider'] = new CustomProvider({
2223
getToken: () =>
2324
Promise.reject(
2425
process.env.NODE_ENV !== 'production'
25-
? new Error("[VueFire]: This shouldn't be called on server.")
26+
? new Error(`[VueFire]: Unknown Provider "${options.provider}".`)
2627
: new Error('app-check/invalid-provider')
2728
),
2829
})

Diff for: packages/nuxt/src/runtime/app-check/plugin.server.ts

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { App as FirebaseAdminApp } from 'firebase-admin/app'
2+
import { VueFireAppCheck } from 'vuefire'
3+
import { VueFireAppCheckServer } from 'vuefire/server'
4+
import type { FirebaseApp } from '@firebase/app-types'
5+
import { CustomProvider } from 'firebase/app-check'
6+
import { defineNuxtPlugin, useAppConfig } from '#app'
7+
8+
/**
9+
* Makes AppCheck work on the server. This requires SSR and the admin SDK to be available
10+
*/
11+
export default defineNuxtPlugin((nuxtApp) => {
12+
const firebaseApp = nuxtApp.$firebaseApp as FirebaseApp
13+
const adminApp = nuxtApp.$firebaseAdminApp as FirebaseAdminApp
14+
15+
// NOTE: necessary in VueFireAppCheckServer
16+
if (!firebaseApp.options.appId) {
17+
throw new Error(
18+
'[VueFire]: Missing "appId" in firebase config. This is necessary to use the app-check module on the server.'
19+
)
20+
}
21+
22+
const appConfig = useAppConfig()
23+
const options = appConfig.vuefireOptions.appCheck!
24+
25+
VueFireAppCheckServer(adminApp, firebaseApp)
26+
27+
// This will fail if used in the server
28+
const provider = new CustomProvider({
29+
getToken: () =>
30+
Promise.reject(
31+
new Error("[VueFire]: This shouldn't be called on server.")
32+
),
33+
})
34+
35+
// injects the empty token symbol
36+
VueFireAppCheck({
37+
...options,
38+
provider,
39+
})(firebaseApp, nuxtApp.vueApp)
40+
})

Diff for: packages/nuxt/src/runtime/app/plugin.server.ts

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { deleteApp, FirebaseApp, initializeApp } from 'firebase/app'
2+
import { User } from 'firebase/auth'
3+
import LRU from 'lru-cache'
4+
import { UserSymbol } from '../admin/plugin-auth-user.server'
5+
import { defineNuxtPlugin, useAppConfig } from '#app'
6+
7+
// TODO: allow customizing
8+
// TODO: find sensible defaults
9+
export const LRU_MAX_INSTANCES = 100
10+
export const LRU_TTL = 1_000 * 60 * 5
11+
const appCache = new LRU<string, FirebaseApp>({
12+
max: LRU_MAX_INSTANCES,
13+
ttl: LRU_TTL,
14+
allowStale: true,
15+
updateAgeOnGet: true,
16+
dispose: (value) => {
17+
deleteApp(value)
18+
},
19+
})
20+
21+
/**
22+
* Initializes the app and provides it to others.
23+
*/
24+
export default defineNuxtPlugin(async (nuxtApp) => {
25+
const appConfig = useAppConfig()
26+
27+
// @ts-expect-error: this is a private symbol
28+
const user = nuxtApp[UserSymbol] as User | undefined | null
29+
const uid = user?.uid
30+
31+
let firebaseApp: FirebaseApp
32+
33+
if (uid) {
34+
if (!appCache.has(uid)) {
35+
const randomId = Math.random().toString(36).slice(2)
36+
const appName = `auth:${user.uid}:${randomId}`
37+
38+
console.log('✅ creating new app', appName)
39+
40+
appCache.set(uid, initializeApp(appConfig.firebaseConfig, appName))
41+
}
42+
firebaseApp = appCache.get(uid)!
43+
} else {
44+
// anonymous session, just create a new app
45+
firebaseApp = initializeApp(appConfig.firebaseConfig)
46+
}
47+
48+
return {
49+
provide: {
50+
firebaseApp,
51+
},
52+
}
53+
})

0 commit comments

Comments
 (0)