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
- 210