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.
Front-end tech lead @Mago
- Board games geek
- Former american football player
- Cat enthusiast
- Mantainer of angular-google-tag-manager
Front-end tech lead @Mago
@mzuccaroli
@marco-zuccaroli
@MarcoZuccaroli
- Board games geek
- Former american football player
- Cat enthusiast
- Mantainer of angular-google-tag-manager
Front-end tech lead @Mago
@mzuccaroli
@marco-zuccaroli
@MarcoZuccaroli
@ziozucca@livellosegreto.it
- Board games geek
- Former american football player
- Cat enthusiast
- Mantainer of angular-google-tag-manager
"Multitenancy refers to the ability of a single software application to serve multiple tenants or users while isolating their data, configurations, and experiences."
"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"
"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?
Dedicated pipelines for CI/CD
Single codebase
Multiple "tenants"
Isolate business logic from tenant handling
there are 5 of us, add something!
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