A multisite Vue3 application for a multitenant enviromnent

A survival guide

Marco Zuccaroli @marcozuccaroli

 vueday 2023

Handle and deploy multiple assets for different applications from a single codebase.

Marco Zuccaroli @marcozuccaroli

 vueday 2023

Handle and deploy multiple assets for different applications from a single codebase.

A multisite Vue3 application for a multitenant enviromnent

A survival guide

Front-end tech lead @Mago

Marco Zuccaroli

- Board games geek

- Former american football player

- Cat enthusiast

- Mantainer of angular-google-tag-manager

Front-end tech lead @Mago

Marco Zuccaroli

@mzuccaroli

@marco-zuccaroli

@MarcoZuccaroli

- Board games geek

- Former american football player

- Cat enthusiast

- Mantainer of angular-google-tag-manager

Front-end tech lead @Mago

Marco Zuccaroli

@mzuccaroli

@marco-zuccaroli

@MarcoZuccaroli

@ziozucca@livellosegreto.it

- Board games geek

- Former american football player

- Cat enthusiast

- Mantainer of angular-google-tag-manager

Quick disclaimer about multitenancy

Multitenancy definitions:

"Multitenancy refers to the ability of a single software application to serve multiple tenants or users while isolating their data, configurations, and experiences."

Multitenancy definitions:

"a single instance of the software and its supporting infrastructure serves multiple customers. Each customer shares the software application and also shares a single database. Each tenant's data is isolated and remains invisible to other tenants"

Multitenancy definitions:

"a single instance of the software and its supporting infrastructure serves multiple customers. Each customer shares the software application and also shares a single database. Each tenant's data is isolated and remains invisible to other tenants"

We need a rebranded product

We need it quickly

Let's fork it!

We need to deliver an on premise version of the architecture

Let's add a feature required by the new client

A huge contract with a flagship product is coming!

QUICK!
lets deploy a copy of the architecture!

it will be FUN!

let's add some feature to the main product....

why the feature X is in frontend 1 and 3 and the feature Y is only in 4?

LET'S FIX IT!

I AM A BAD PROGRAMMER

all fork and no play makes Marco a dull boy

Wich version of the frontend is running?

WHO WROTE THAT?

A possible solution

"A" possible solution

  • Dedicated pipelines for CI/CD
  • Single codebase
  • Multiple "tenants"
  • Isolate business logic from tenant handling

Dedicated pipelines for CI/CD

Single codebase

Multiple "tenants"

Isolate business logic from tenant handling

there are 5 of us, add something!

Tenant Service

Split the problem

  • Styles
  • API calls
  • Assets
  • Locales

Split the problem

  • Styles
  • API calls
  • Assets
  • Locales
  • Images

Javascript is fun!
But it's time to be more professional

API calls

// .env
VITE_TENANT=Vue
// .env.mago
VITE_TENANT=Mago 

// tenantService.ts
export const getTenant = (): string => {
  return process.env.VITE_TENANT || "Mago";
};


// ------------------------------------------------

// stores/app.ts
export const useAppStore = defineStore({
  id: "app",
  state: (): AppState => ({
    tenant: tenantService.getTenant(),
  }),
  getters: {},
  actions: {},
}

API calls

// .env
VITE_TENANT=Vue
// .env.mago
VITE_TENANT=Mago 

// tenantService.ts
export const getTenant = (): string => {
  return import.meta.env.VITE_TENANT
  return process.env.VITE_TENANT || "Mago";
};


// ------------------------------------------------

// stores/app.ts
export const useAppStore = defineStore({
  id: "app",
  state: (): AppState => ({
    tenant: tenantService.getTenant(),
  }),
  getters: {},
  actions: {},
}
// vite.config.ts
import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
import { fileURLToPath, URL } from "url";

export default ({ mode }) => {
  // expose import.meta.env to classical process.env
  const env = loadEnv(mode, process.cwd());

  env.VITE_APP_VERSION = require("./package.json").version;
  env.VITE_APP_NAME = require("./package.json").name;
  env.VITE_APP_DESCRIPTION = require("./package.json").description;
  
  const envWithProcessPrefix = Object.entries(env).reduce(
    (prev, [key, val]) => {
      return { ...prev, ["process.env." + key]: `"${val}"`};
    },
    {}
  );

  return defineConfig({
    plugins: [vue()],
    define: envWithProcessPrefix,
    resolve: {
      alias: {
        "@": fileURLToPath(new URL("./src", import.meta.url)),
      },
    },
    test: {
      //...
    },
  });
};

API calls

// tenantService.ts
export const getTenant = (): string => {
  return process.env.VITE_TENANT || "Mago";
};

API calls

//plugins/axiosInterceptor.ts

import axios, { InternalAxiosRequestConfig } from "axios";
import loggerService from "@/services/loggerService.ts";
import { useAppStore } from "@/stores/app.ts";

const requestHandler = async (
  config: InternalAxiosRequestConfig
): Promise<InternalAxiosRequestConfig> => {
  const appStore = useAppStore();
  config.headers["tenant_code"] = appStore.tenant;
  //here you can handle auth headers ;)
  return config;
};

export default function start(): void {
  axios.interceptors.request.use(
    (request) => requestHandler(request),
    (error) => {
      loggerService.error(error);
      return Promise.reject(error);
    }
  );
}

API calls

//plugins/axiosInterceptor.ts

import axios, { InternalAxiosRequestConfig } from "axios";
import loggerService from "@/services/loggerService.ts";
import { useAppStore } from "@/stores/app.ts";

const requestHandler = async (
  config: InternalAxiosRequestConfig
): Promise<InternalAxiosRequestConfig> => {
  const appStore = useAppStore();
  config.headers["tenant_code"] = appStore.tenant;
  //here you can handle auth headers ;)
  return config;
};

export default function start(): void {
  axios.interceptors.request.use(
    (request) => requestHandler(request),
    (error) => {
      loggerService.error(error);
      return Promise.reject(error);
    }
  );
}

API calls

The rest of the code

API calls

API calls

// stores/data.ts

export const useDataStore = defineStore({
  id: "data",
  state: (): DataState => ({
    isLoading: false,
    users: [],
  }),
  getters: {},
  actions: {
    async fetchUsers() {
      try {
        this.isLoading = true;
        this.users = await geUsers();
      } catch (e) {
        loggerService.error(`Error fetching users`);
      } finally {
        this.isLoading = false;
      }
    },
  },
});


// services/dataservice.ts

export async function geUsers(): Promise<User[]> {
  const res = await axios.get(`https://jsonplaceholder.typicode.com/users`);
  return res.data.map(userInterface.deserialize);
}

API calls

Assets

Assets - translations

Assets - translations

// tenantService.ts
export const getTenant = (): string => {
  return process.env.VITE_TENANT || "Mago";
};

Assets - translations

// tenantService.ts
export const getTenant = (): string => {
  return process.env.VITE_TENANT || "Mago";
};

export const getLocaleAsset = async (lang: string) => {
  try {
    const res = await import(
      `../assets/tenants/${process.env.VITE_TENANT}/locales/${lang}.json`
      );
    return res.default;
  } catch (e) {
    loggerService.warn("Unable to retrieve tenant translations, using default");
  }
};

Assets- translations

// services/translationsService.ts

import { LocaleMessageObject } from "vue-i18n";
import * as tenantService from "@/services/tenantService.ts";

export const getLocaleFromBrowser = (): string => {
  const loc = navigator.language || "en-EN";
  return loc.split("-")[0];
};

export const getTranslation = async (
  lang: string
): Promise<LocaleMessageObject> => {
  return getFromFs(lang);
  // return getFromCMS(lang);
};

const getFromFs = async (lang: string): Promise<LocaleMessageObject> => {
  // this should be validated
  return await tenantService.getLocaleAsset(lang);
};

Assets- translations

// maint.ts

import { createApp } from "vue";
import { createPinia } from "pinia";
import "@/styles/main.scss";
import App from "@/App.vue";
import router from "@/router";
import { sentryInit } from "@/plugins/sentry.ts";
import i18n from "@/plugins/i18n";
import * as translationsService from "@/services/translationsService.ts";
import loggerService from "@/services/loggerService.ts";
import axiosInterceptor from "@/plugins/axiosInterceptor";

const app = createApp(App);
const pinia = createPinia();
sentryInit(app);

app.use(pinia);
app.use(router);
app.use(i18n);
app.mount("#app");

axiosInterceptor();

// -------- tenant handling --------
try {
  const locale = i18n.global.locale.value;
  translationsService.getTranslation(locale).then((translations) => {
    i18n.global.mergeLocaleMessage(locale, translations);
  });
} catch (e) {
  loggerService.warn("Unable to retrieve tenant translations, using default");
}
// -------- -------- --------

Assets- translations

Assets- translations

Assets - styles

Assets - styles

// tenantService.ts
export const getTenant = (): string => {
  return process.env.VITE_TENANT || "Mago";
};

Assets - styles

// tenantService.ts
export const getTenant = (): string => {
  return process.env.VITE_TENANT || "Mago";
};

const getStyleAsset = async () => {
  try {
    const res = await import(
      `../assets/tenants/${process.env.VITE_TENANT}/styles/stile.scss`
      );
    return res.default;
  } catch (e) {
    loggerService.warn("Unable to load tenant css, skipping");
  }
};

export const applyCss = async () => {
  const css = await getStyleAsset();
  if (css) {
    const styleElem = document.createElement("style");
    styleElem.textContent = css;
    document.head.appendChild(styleElem);
  }
};

Assets - styles

// tenantService.ts
export const getTenant = (): string => {
  return process.env.VITE_TENANT || "Mago";
};

const getStyleAsset = async () => {
  try {
    const res = await import(
      `../assets/tenants/${process.env.VITE_TENANT}/styles/stile.scss`
      );
    return res.default;
  } catch (e) {
    loggerService.warn("Unable to load tenant css, skipping");
  }
};

export const applyCss = async () => {
  const css = await getStyleAsset();
  if (css) {
    const styleElem = document.createElement("style");
    styleElem.textContent = css;
    document.head.appendChild(styleElem);
  }
};

Assets - styles

Assets - styles

Assets - styles

// tenantService.ts
export const getTenant = (): string => {
  return process.env.VITE_TENANT || "Mago";
};

const getStyleAsset = async () => {
  try {
    const res = await import(
      `../assets/tenants/${process.env.VITE_TENANT}/styles/stile.scss`
      );
    return res.default;
  } catch (e) {
    loggerService.warn("Unable to load tenant css, skipping");
  }
};

export const applyCss = async () => {
  const css = await getStyleAsset();
  if (css) {
    const styleElem = document.createElement("style");
    styleElem.textContent = css;
    document.head.appendChild(styleElem);
  }
};

Assets - styles

// tenantService.ts
export const getTenant = (): string => {
  return process.env.VITE_TENANT || "Mago";
};

const getStyleAsset = async () => {
  try {
    //glob import see: https://vitejs.dev/guide/features.html#glob-import
    const styles = import.meta.glob(
      "../assets/tenants/*/styles/stile.scss", 
      { query: "?inline" }
    );
    const res = await styles[
      `../assets/tenants/${process.env.VITE_TENANT}/styles/stile.scss`
      ]();
    return res.default;
  } catch (e) {
    loggerService.warn("Unable to load tenant css, skipping");
  }
};


export const applyCss = async () => {
  const css = await getStyleAsset();
  if (css) {
    const styleElem = document.createElement("style");
    styleElem.textContent = css;
    document.head.appendChild(styleElem);
  }
};

Assets - images

Assets - images

// tenantService.ts

export const getTenant = (): string => {
  return process.env.VITE_TENANT || "Mago";
};

const getStyleAsset = async () => {...};

export const applyCss = async () => {...};

Assets - images

// tenantService.ts

export const getTenant = (): string => {
  return process.env.VITE_TENANT || "Mago";
};

const getStyleAsset = async () => {...};

export const applyCss = async () => {...};

export const getImgAssetUrl = (asset: string): string => {
  return new URL(
    `../assets/tenants/${getTenant()}/img/${asset}`,
    import.meta.url
  ).href;
};

Assets - images

//homepage.vue

<script setup lang="ts">
import * as tenantService from "@/services/tenantService.ts";

const logoUrl = tenantService.getImgAssetUrl("logo.svg");
</script>

<template>
  <div>
    <img class="logo vue" alt="Logo" :src="logoUrl" />
  </div>
</template>

Assets - images

Assets - images with optimization

// vite.config.ts
// 
import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
import { fileURLToPath, URL } from "url";
import svgLoader from "vite-svg-loader";

// https://vitejs.dev/config/

export default ({ mode }) => {
  // expose import.meta.env to classical process.env
  const env = loadEnv(mode, process.cwd());
  env.VITE_APP_VERSION = require("./package.json").version;

  const envWithProcessPrefix = Object.entries(env).reduce(
    (prev, [key, val]) => {
      return {
        ...prev,
        ["process.env." + key]: `"${val}"`,
      };
    },
    {}
  );

  return defineConfig({
    plugins: [vue(), svgLoader()],
    define: envWithProcessPrefix,
    resolve: {
      alias: {
        "@": fileURLToPath(new URL("./src", import.meta.url)),
      },
    },
    test: {
      ...
    },
  });
};

Assets - images with optimization

// tenantService.ts

export const getTenant = (): string => {
  return process.env.VITE_TENANT || "Mago";
};

const getStyleAsset = async () => {...};

export const applyCss = async () => {...};

export const getImgAssetUrl = (asset: string): string => {
  return new URL(
    `../assets/tenants/${getTenant()}/img/${asset}`,
    import.meta.url
  ).href;
};

Assets - images with optimization

// tenantService.ts

export const getTenant = (): string => {
  return process.env.VITE_TENANT || "Mago";
};

const getStyleAsset = async () => {...};

export const applyCss = async () => {...};

export const getSvgAssetUrl = async (asset: string) => {
  try {
    //import optimized with vite-svg-loader see https://www.npmjs.com/package/vite-svg-loader
    const svg = await import(
      `../assets/tenants/${process.env.VITE_TENANT}/img/${asset}.svg?url`
      );
    return svg.default;
  } catch (e) {
    loggerService.warn("Unable to retrieve tenant svg asset");
  }
};

Assets - images with optimization

//homepage.vue

<script setup lang="ts">
import * as tenantService from "@/services/tenantService.ts";

const logoUrl = tenantService.getImgAssetUrl("logo.svg");
</script>

<template>
  <div>
    <img class="logo vue" alt="Logo" :src="logoUrl" />
  </div>
</template>

Assets - images with optimization

//homepage.vue

<script setup lang="ts">
import * as tenantService from "@/services/tenantService.ts";
import { ref, onBeforeMount } from "vue";

const logoUrl = ref("");
onBeforeMount(async () => {
  logoUrl.value = await tenantService.getSvgAssetUrl("logo");
});
</script>

<template>
  <div>
    <img class="logo vue" alt="Logo" :src="logoUrl" />
  </div>
</template>

Assets - images with optimization

//homepage.vue

<script setup lang="ts">
import * as tenantService from "@/services/tenantService.ts";
import { computedAsync } from "@vueuse/core";

const logoUrl = computedAsync(
  async () => tenantService.getSvgAssetUrl("logo")
);
</script>

<template>
  <div>
    <img class="logo vue" alt="Logo" :src="logoUrl" />
  </div>
</template>

Assets - images with optimization

//vite.config.ts

  return defineConfig({
    plugins: [vue(), viteSentry(sentryConfig), faviconsInject, svgLoader()],
    define: envWithProcessPrefix,
    resolve: {
      alias: {
        "@": fileURLToPath(new URL("./src", import.meta.url)),
      },
    },
    build: {
      rollupOptions: {
        // remove all assets that not belong to current tenant from final build
        external: new RegExp(`/assets/tenants/(?!${env.VITE_TENANT}/).*`),
      },
    },
  });

Assets - images - favicon

// vite.config.ts

import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
import { fileURLToPath, URL } from "url";
import svgLoader from "vite-svg-loader";

export default ({ mode }) => {
  // expose import.meta.env to classical process.env
  const env = loadEnv(mode, process.cwd());
  env.VITE_APP_VERSION = require("./package.json").version;
  env.VITE_APP_NAME = require("./package.json").name;
  env.VITE_APP_DESCRIPTION = require("./package.json").description;

  const envWithProcessPrefix = Object.entries(env).reduce(
    (prev, [key, val]) => {
      return {
        ...prev,
        ["process.env." + key]: `"${val}"`,
      };
    },
    {}
  );

  return defineConfig({
    plugins: [vue(), svgLoader()],
    define: envWithProcessPrefix,
    resolve: {
      alias: {
        "@": fileURLToPath(new URL("./src", import.meta.url)),
      },
    },
    test: {
      ...
    },
  });
};

Assets - images - favicon

// vite.config.ts
 
import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
import { fileURLToPath, URL } from "url";
import svgLoader from "vite-svg-loader";
import vitePluginFaviconsInject from "vite-plugin-favicons-inject";

export default ({ mode }) => {
  // expose import.meta.env to classical process.env
  const env = loadEnv(mode, process.cwd());
  env.VITE_APP_VERSION = require("./package.json").version;
  env.VITE_APP_NAME = require("./package.json").name;
  env.VITE_APP_DESCRIPTION = require("./package.json").description;

  const envWithProcessPrefix = Object.entries(env).reduce(
    (prev, [key, val]) => {
      return {
        ...prev,
        ["process.env." + key]: `"${val}"`,
      };
    },
    {}
  );
  
  const faviconsInject =
    env.VITE_RUNNING_CONTEXT !== "local"
      ?
        vitePluginFaviconsInject(
          `./src/assets/tenants/${env.VITE_TENANT}/img/logo.svg`,
          {
            background: "#fff",
            theme_color: "#fff",
            appName: `${env.VITE_TENANT} | ${env.VITE_APP_NAME}`,
            appDescription: `${env.VITE_TENANT} ${env.VITE_APP_DESCRIPTION}`,
            version: env.VITE_APP_VERSION,
          }
        )
      : undefined;

  return defineConfig({
    plugins: [vue(), faviconsInject, svgLoader()],
    define: envWithProcessPrefix,
    resolve: {
      alias: {
        "@": fileURLToPath(new URL("./src", faviconsInject, import.meta.url)),
      },
    },
    test: {
      ...
    },
  });
};

https://github.com/mzuccaroli/vue-multitenant-starter

@mzuccaroli

@marco-zuccaroli

@ziozucca@livellosegreto.it

marcozuccaroli@gmail.com

marco.zuccaroli@remago.com

A multisite Vue3 application for a multitenant enviromnent: a survival guide

By marco zuccaroli

A multisite Vue3 application for a multitenant enviromnent: a survival guide

A multisite Vue3 application for a multitenant enviromnent: a survival guide

  • 79