Skip to content

Virtual Modules

As covered in Project Structure, @fastify/vue relies on virtual modules to save your project from having too many boilerplate files. Virtual modules used in @fastify/vue are fully ejectable. For instance, the starter template relies on the /:root.vue virtual module to provide the Vue shell of your application. If you copy the root.vue file from the @fastify/vue package and place it your Vite project root, that copy of the file is used instead.

/:root.vue

This is the root Vue component. It's used internally by /:create.js. You can either use the default version provided by the smart import or provide your own.

Source from packages/fastify-vue/virtual/root.vue:

vue
<script>
export { default } from '/:router.vue'

export function configure ({ app, router }) {
  // Use this to configure/extend your Vue app and router instance
}
</script>
<script>
export { default } from '/:router.vue'

export function configure ({ app, router }) {
  // Use this to configure/extend your Vue app and router instance
}
</script>

/:router.vue

This is the root Vue Router component. Loaded by /:root.vue.

Source from packages/fastify-vue/virtual/router.vue:

vue
<script setup>
import Layout from '/:layout.vue'
</script>

<template>
  <router-view v-slot="{ Component }">
    <template v-if="$isServer">
      <Layout>
        <component
          :is="Component"
          :key="$route.path"
        />
      </Layout>
    </template>
    <Suspense v-else>
      <Layout>
        <component
          :is="Component"
          :key="$route.path"
        />
      </Layout>
    </Suspense>
  </router-view>
</template>
<script setup>
import Layout from '/:layout.vue'
</script>

<template>
  <router-view v-slot="{ Component }">
    <template v-if="$isServer">
      <Layout>
        <component
          :is="Component"
          :key="$route.path"
        />
      </Layout>
    </template>
    <Suspense v-else>
      <Layout>
        <component
          :is="Component"
          :key="$route.path"
        />
      </Layout>
    </Suspense>
  </router-view>
</template>

Note that a top-level <Suspense> wrapper is necessary because @fastify/vue has code-splitting enabled at the route-level. You can opt out of code-splitting by providing your own routes.js file, but that's very unlikely to be ever required for any reason.

/:routes.js

@fastify/vue has code-splitting out of the box. It does that by eagerly loading all route data on the server, and then hydrating any missing metadata on the client. That's why the routes module default export is conditioned to import.meta.env.SSR, and different helper functions are called for each rendering environment.

Source from packages/fastify-vue/virtual/routes.js:

js
/* global $paramPattern */

const routeModules = import.meta.glob('$globPattern')

export default getRoutes()

function getRoutes () {
  if (import.meta.env.SSR) {
    return createRoutes(routeModules)
  } else {
    return hydrateRoutes(routeModules)
  }
}

async function createRoutes (from, { param } = { param: $paramPattern }) {
  // Otherwise we get a ReferenceError, but since
  // this function is only ran once, there's no overhead
  class Routes extends Array {
    toJSON () {
      return this.map((route) => {
        return {
          id: route.id,
          path: route.path,
          layout: route.layout,
          getData: !!route.getData,
          getMeta: !!route.getMeta,
          onEnter: !!route.onEnter,
        }
      })
    }
  }
  const importPaths = Object.keys(from)
  const promises = []
  if (Array.isArray(from)) {
    for (const routeDef of from) {
      promises.push(
        getRouteModule(routeDef.path, routeDef.component)
          .then((routeModule) => {
            return {
              id: routeDef.path,
              path: routeDef.path ?? routeModule.path,
              ...routeModule,
            }
          }),
      )
    }
  } else {
    // Ensure that static routes have precedence over the dynamic ones
    for (const path of importPaths.sort((a, b) => a > b ? -1 : 1)) {
      promises.push(
        getRouteModule(path, from[path])
          .then((routeModule) => {
            return {
              id: path,
              layout: routeModule.layout,
              path: routeModule.path ?? path
                // Remove /pages and .jsx extension
                .slice(6, -4)
                // Replace [id] with :id
                .replace(param, (_, m) => `:${m}`)
                // Replace '/index' with '/'
                .replace(/\/index$/, '/')
                // Remove trailing slashs
                .replace(/(.+)\/+$/, (...m) => m[1]),
              ...routeModule,
            }
          }),
      )
    }
  }
  return new Routes(...await Promise.all(promises))
}

async function hydrateRoutes (from) {
  if (Array.isArray(from)) {
    from = Object.fromEntries(
      from.map((route) => [route.path, route]),
    )
  }
  return window.routes.map((route) => {
    route.loader = memoImport(from[route.id])
    route.component = () => route.loader()
    return route
  })
}

function getRouteModuleExports (routeModule) {
  return {
    // The Route component (default export)
    component: routeModule.default,
    // The Layout Route component
    layout: routeModule.layout,
    // Route-level hooks
    getData: routeModule.getData,
    getMeta: routeModule.getMeta,
    onEnter: routeModule.onEnter,
    // Other Route-level settings
    streaming: routeModule.streaming,
    clientOnly: routeModule.clientOnly,
    serverOnly: routeModule.serverOnly,
  }
}

async function getRouteModule (path, routeModule) {
  // const isServer = typeof process !== 'undefined'
  if (typeof routeModule === 'function') {
    routeModule = await routeModule()
    return getRouteModuleExports(routeModule)
  } else {
    return getRouteModuleExports(routeModule)
  }
}

function memoImport (func) {
  // Otherwise we get a ReferenceError, but since this function
  // is only ran once for each route, there's no overhead
  const kFuncExecuted = Symbol('kFuncExecuted')
  const kFuncValue = Symbol('kFuncValue')
  func[kFuncExecuted] = false
  return async function () {
    if (!func[kFuncExecuted]) {
      func[kFuncValue] = await func()
      func[kFuncExecuted] = true
    }
    return func[kFuncValue]
  }
}
/* global $paramPattern */

const routeModules = import.meta.glob('$globPattern')

export default getRoutes()

function getRoutes () {
  if (import.meta.env.SSR) {
    return createRoutes(routeModules)
  } else {
    return hydrateRoutes(routeModules)
  }
}

async function createRoutes (from, { param } = { param: $paramPattern }) {
  // Otherwise we get a ReferenceError, but since
  // this function is only ran once, there's no overhead
  class Routes extends Array {
    toJSON () {
      return this.map((route) => {
        return {
          id: route.id,
          path: route.path,
          layout: route.layout,
          getData: !!route.getData,
          getMeta: !!route.getMeta,
          onEnter: !!route.onEnter,
        }
      })
    }
  }
  const importPaths = Object.keys(from)
  const promises = []
  if (Array.isArray(from)) {
    for (const routeDef of from) {
      promises.push(
        getRouteModule(routeDef.path, routeDef.component)
          .then((routeModule) => {
            return {
              id: routeDef.path,
              path: routeDef.path ?? routeModule.path,
              ...routeModule,
            }
          }),
      )
    }
  } else {
    // Ensure that static routes have precedence over the dynamic ones
    for (const path of importPaths.sort((a, b) => a > b ? -1 : 1)) {
      promises.push(
        getRouteModule(path, from[path])
          .then((routeModule) => {
            return {
              id: path,
              layout: routeModule.layout,
              path: routeModule.path ?? path
                // Remove /pages and .jsx extension
                .slice(6, -4)
                // Replace [id] with :id
                .replace(param, (_, m) => `:${m}`)
                // Replace '/index' with '/'
                .replace(/\/index$/, '/')
                // Remove trailing slashs
                .replace(/(.+)\/+$/, (...m) => m[1]),
              ...routeModule,
            }
          }),
      )
    }
  }
  return new Routes(...await Promise.all(promises))
}

async function hydrateRoutes (from) {
  if (Array.isArray(from)) {
    from = Object.fromEntries(
      from.map((route) => [route.path, route]),
    )
  }
  return window.routes.map((route) => {
    route.loader = memoImport(from[route.id])
    route.component = () => route.loader()
    return route
  })
}

function getRouteModuleExports (routeModule) {
  return {
    // The Route component (default export)
    component: routeModule.default,
    // The Layout Route component
    layout: routeModule.layout,
    // Route-level hooks
    getData: routeModule.getData,
    getMeta: routeModule.getMeta,
    onEnter: routeModule.onEnter,
    // Other Route-level settings
    streaming: routeModule.streaming,
    clientOnly: routeModule.clientOnly,
    serverOnly: routeModule.serverOnly,
  }
}

async function getRouteModule (path, routeModule) {
  // const isServer = typeof process !== 'undefined'
  if (typeof routeModule === 'function') {
    routeModule = await routeModule()
    return getRouteModuleExports(routeModule)
  } else {
    return getRouteModuleExports(routeModule)
  }
}

function memoImport (func) {
  // Otherwise we get a ReferenceError, but since this function
  // is only ran once for each route, there's no overhead
  const kFuncExecuted = Symbol('kFuncExecuted')
  const kFuncValue = Symbol('kFuncValue')
  func[kFuncExecuted] = false
  return async function () {
    if (!func[kFuncExecuted]) {
      func[kFuncValue] = await func()
      func[kFuncExecuted] = true
    }
    return func[kFuncValue]
  }
}

/:core.js

Implements useRouteContext() and createBeforeEachHandler(), used by core.js.

DXApp is imported by root.vue and encapsulates @fastify/vue's route component API.

Vue Router's nested routes aren't supported yet.

Source from packages/fastify-vue/virtual/core.js:

js
import { inject } from 'vue'
import { useRoute, createMemoryHistory, createWebHistory } from 'vue-router'

export const isServer = import.meta.env.SSR
export const createHistory = isServer ? createMemoryHistory : createWebHistory
export const serverRouteContext = Symbol('serverRouteContext')
export const routeLayout = Symbol('routeLayout')

export function useRouteContext () {
  if (isServer) {
    return inject(serverRouteContext)
  } else {
    return useRoute().meta[serverRouteContext]
  }
}

export function createBeforeEachHandler ({ routeMap, ctxHydration, head }, layout) {
  return async function beforeCreate (to) {
    // The client-side route context
    const ctx = routeMap[to.matched[0].path]
    // Indicates whether or not this is a first render on the client
    ctx.firstRender = ctxHydration.firstRender

    ctx.state = ctxHydration.state
    ctx.actions = ctxHydration.actions

    // Update layoutRef
    layout.value = ctx.layout ?? 'default'

    // If it is, take server context data from hydration and return immediately
    if (ctx.firstRender) {
      ctx.data = ctxHydration.data
      ctx.head = ctxHydration.head
      // Ensure this block doesn't run again during client-side navigation
      ctxHydration.firstRender = false
      to.meta[serverRouteContext] = ctx
      return
    }

    // If we have a getData function registered for this route
    if (ctx.getData) {
      try {
        ctx.data = await jsonDataFetch(to.fullPath)
      } catch (error) {
        ctx.error = error
      }
    }
    // Note that ctx.loader() at this point will resolve the
    // memoized module, so there's barely any overhead
    const { getMeta, onEnter } = await ctx.loader()
    if (ctx.getMeta) {
      head.update(await getMeta(ctx))
    }
    if (ctx.onEnter) {
      const updatedData = await onEnter(ctx)
      if (updatedData) {
        if (!ctx.data) {
          ctx.data = {}
        }
        Object.assign(ctx.data, updatedData)
      }
    }
    to.meta[serverRouteContext] = ctx
  }
}

export async function jsonDataFetch (path) {
  const response = await fetch(`/-/data${path}`)
  let data
  let error
  try {
    data = await response.json()
  } catch (err) {
    error = err
  }
  if (data?.statusCode === 500) {
    throw new Error(data.message)
  }
  if (error) {
    throw error
  }
  return data
}
import { inject } from 'vue'
import { useRoute, createMemoryHistory, createWebHistory } from 'vue-router'

export const isServer = import.meta.env.SSR
export const createHistory = isServer ? createMemoryHistory : createWebHistory
export const serverRouteContext = Symbol('serverRouteContext')
export const routeLayout = Symbol('routeLayout')

export function useRouteContext () {
  if (isServer) {
    return inject(serverRouteContext)
  } else {
    return useRoute().meta[serverRouteContext]
  }
}

export function createBeforeEachHandler ({ routeMap, ctxHydration, head }, layout) {
  return async function beforeCreate (to) {
    // The client-side route context
    const ctx = routeMap[to.matched[0].path]
    // Indicates whether or not this is a first render on the client
    ctx.firstRender = ctxHydration.firstRender

    ctx.state = ctxHydration.state
    ctx.actions = ctxHydration.actions

    // Update layoutRef
    layout.value = ctx.layout ?? 'default'

    // If it is, take server context data from hydration and return immediately
    if (ctx.firstRender) {
      ctx.data = ctxHydration.data
      ctx.head = ctxHydration.head
      // Ensure this block doesn't run again during client-side navigation
      ctxHydration.firstRender = false
      to.meta[serverRouteContext] = ctx
      return
    }

    // If we have a getData function registered for this route
    if (ctx.getData) {
      try {
        ctx.data = await jsonDataFetch(to.fullPath)
      } catch (error) {
        ctx.error = error
      }
    }
    // Note that ctx.loader() at this point will resolve the
    // memoized module, so there's barely any overhead
    const { getMeta, onEnter } = await ctx.loader()
    if (ctx.getMeta) {
      head.update(await getMeta(ctx))
    }
    if (ctx.onEnter) {
      const updatedData = await onEnter(ctx)
      if (updatedData) {
        if (!ctx.data) {
          ctx.data = {}
        }
        Object.assign(ctx.data, updatedData)
      }
    }
    to.meta[serverRouteContext] = ctx
  }
}

export async function jsonDataFetch (path) {
  const response = await fetch(`/-/data${path}`)
  let data
  let error
  try {
    data = await response.json()
  } catch (err) {
    error = err
  }
  if (data?.statusCode === 500) {
    throw new Error(data.message)
  }
  if (error) {
    throw error
  }
  return data
}

/:create.js

This virtual module creates your root Vue component.

This is where root.vue is imported.

Source from packages/fastify-vue/virtual/create.js:

js
import { createApp, createSSRApp, reactive, ref } from 'vue'
import { createRouter } from 'vue-router'
import {
  isServer,
  createHistory,
  serverRouteContext,
  routeLayout,
  createBeforeEachHandler,
} from '/:core.js'
import * as root from '/:root.vue'

export default async function create (ctx) {
  const { routes, ctxHydration } = ctx

  const instance = ctxHydration.clientOnly
    ? createApp(root.default)
    : createSSRApp(root.default)

  const history = createHistory()
  const router = createRouter({ history, routes })
  const layoutRef = ref(ctxHydration.layout ?? 'default')

  instance.config.globalProperties.$isServer = isServer

  instance.provide(routeLayout, layoutRef)
  if (!isServer && ctxHydration.state) {
    ctxHydration.state = reactive(ctxHydration.state)
  }

  if (isServer) {
    instance.provide(serverRouteContext, ctxHydration)
  } else {
    router.beforeEach(createBeforeEachHandler(ctx, layoutRef))
  }

  instance.use(router)

  if (typeof root.configure === 'function') {
    await root.configure({ app: instance, router })
  }

  if (ctx.url) {
    router.push(ctx.url)
    await router.isReady()
  }

  return { instance, ctx, router }
}
import { createApp, createSSRApp, reactive, ref } from 'vue'
import { createRouter } from 'vue-router'
import {
  isServer,
  createHistory,
  serverRouteContext,
  routeLayout,
  createBeforeEachHandler,
} from '/:core.js'
import * as root from '/:root.vue'

export default async function create (ctx) {
  const { routes, ctxHydration } = ctx

  const instance = ctxHydration.clientOnly
    ? createApp(root.default)
    : createSSRApp(root.default)

  const history = createHistory()
  const router = createRouter({ history, routes })
  const layoutRef = ref(ctxHydration.layout ?? 'default')

  instance.config.globalProperties.$isServer = isServer

  instance.provide(routeLayout, layoutRef)
  if (!isServer && ctxHydration.state) {
    ctxHydration.state = reactive(ctxHydration.state)
  }

  if (isServer) {
    instance.provide(serverRouteContext, ctxHydration)
  } else {
    router.beforeEach(createBeforeEachHandler(ctx, layoutRef))
  }

  instance.use(router)

  if (typeof root.configure === 'function') {
    await root.configure({ app: instance, router })
  }

  if (ctx.url) {
    router.push(ctx.url)
    await router.isReady()
  }

  return { instance, ctx, router }
}

/:layouts/default.js

This is used internally by /:core.jsx. If a project has no layouts/default.vue file, the default one from @fastify/vue is used.

Source from packages/fastify-vue/virtual/layouts/default.vue:

vue
<template>
  <div class="layout">
    <!-- eslint-disable-next-line vue/multi-word-component-names -->
    <slot />
  </div>
</template>

<script>
// This file serves as a placeholder if no
// layouts/default.vue file is provided
</script>
<template>
  <div class="layout">
    <!-- eslint-disable-next-line vue/multi-word-component-names -->
    <slot />
  </div>
</template>

<script>
// This file serves as a placeholder if no
// layouts/default.vue file is provided
</script>

/:mount.js

This is the file index.html links to by default. It sets up the application with an unihead instance for head management, the initial route context, and provides the conditional mounting logic to defer to CSR-only if clientOnly is enabled.

Source from packages/fastify-vue/virtual/mount.js:

js
import Head from 'unihead/client'
import create from '/:create.js'
import routesPromise from '/:routes.js'
import * as context from '/:context.js'
import * as root from '/:root.vue'

if (root.mount) {
  mount(root.mount)
} else {
  mount('#root', 'main')
}

async function mount (...targets) {
  const ctxHydration = await extendContext(window.route, context)
  const head = new Head(window.route.head, window.document)
  const resolvedRoutes = await routesPromise
  const routeMap = Object.fromEntries(
    resolvedRoutes.map((route) => [route.path, route]),
  )
  const { instance, router } = await create({
    head,
    ctxHydration,
    routes: window.routes,
    routeMap,
  })
  await router.isReady()
  let mountTargetFound = false
  for (const target of targets) {
    if (document.querySelector(target)) {
      mountTargetFound = true
      instance.mount(target)
      break
    }
  }
  if (!mountTargetFound) {
    throw new Error(`No mount element found from provided list of targets: ${targets}`)
  }
}

async function extendContext (ctx, {
  // The route context initialization function
  default: setter,
  // We destructure state here just to discard it from extra
  state,
  // Other named exports from context.js
  ...extra
}) {
  Object.assign(ctx, extra)
  if (setter) {
    await setter(ctx)
  }
  return ctx
}
import Head from 'unihead/client'
import create from '/:create.js'
import routesPromise from '/:routes.js'
import * as context from '/:context.js'
import * as root from '/:root.vue'

if (root.mount) {
  mount(root.mount)
} else {
  mount('#root', 'main')
}

async function mount (...targets) {
  const ctxHydration = await extendContext(window.route, context)
  const head = new Head(window.route.head, window.document)
  const resolvedRoutes = await routesPromise
  const routeMap = Object.fromEntries(
    resolvedRoutes.map((route) => [route.path, route]),
  )
  const { instance, router } = await create({
    head,
    ctxHydration,
    routes: window.routes,
    routeMap,
  })
  await router.isReady()
  let mountTargetFound = false
  for (const target of targets) {
    if (document.querySelector(target)) {
      mountTargetFound = true
      instance.mount(target)
      break
    }
  }
  if (!mountTargetFound) {
    throw new Error(`No mount element found from provided list of targets: ${targets}`)
  }
}

async function extendContext (ctx, {
  // The route context initialization function
  default: setter,
  // We destructure state here just to discard it from extra
  state,
  // Other named exports from context.js
  ...extra
}) {
  Object.assign(ctx, extra)
  if (setter) {
    await setter(ctx)
  }
  return ctx
}

Released under the MIT License.