Waaard Logo
NimbusWS
Managed F/OSS services on Hetzner, OVH and LeaseWeb
Recently Launched 🚀🧑‍🚀
Waaard Logo
Unvalidated Ideas
3 fresh Startup/SaaS ideas in your inbox, every week.
Recently Launched 🚀🧑‍🚀

A Pattern for ENV in Sveltekit

Categories
SvelteKit logo

tl;dr - I use ENV in Sveltekit by loading .env/.envrc formatted files (thanks direnv!) and making sure to do filtering only for PUBLIC_* in hooks.ts before passing to the frontend, rather than using the usual VITE_* replacement approach

There’s a bit of confusion out there about how best to handle ENV (especially dynamic runtime ENV) in SvelteKit:

In the end, using process.env in your code is actually pretty scary @ build time, the generated code actually puts the entire contents of process.env as a serialized hash in (ex. {"HOME":"/home/user" ...}['THE_VAR_YOU_WANT']).

That’s a bit of yikes for me, but even if I was fine with that (I mean it’s not that bad), what I really want my ENV spliced in at runtime.

I’m running mostly 12 factor apps, and I’m not a huge fan of building 3 images (and then worrying about checking secrets into docker images) for 3 different environments.

Interestingly enough, Rich covers the lack of certainty around ENV in the recent “road to 1.0” talk

While the Svelte team is hard at work on getting to 1.0 I’ve found a way to handle it on my projects that’s pretty reasonable, so I figured I’d share.

I think my approach has the following properties which make it desirable:

  • It’s simple to understand
  • It works consistently and predicatbly
  • It’s hard to use incorrectly from the frontend

Hopefully this approach won’t be needed for long and there will be more official guidance, but for now I’m going to put it out there to hopefully motivate some feedback-givers and those who might be confused or want a suggestion.

Step 0: Use the Svelte node adapter

Obviously this isn’t really required, but for me I’m using the NodeJS based adapter (adapter-node), so obviously YMMV.

There are lots of ways to deploy sveltekit, I use adapter-node because I run my own containers/servers and need some dynamic stuff to happen on the backend.

Step 1: Add a backend-only dotenv load

Without further ado, the code for loading dotenv:

/**
 * NOTE: this file is written in JS as it is shared between non-SvelteKit and Sveltekit code
 */

import * as process from "process";
import * as path from "path";

let ENV;

/**
 * Load all environment variables available through dotenv
 *
 * @returns {Promise<Record<string,string>>}
 */
export async function loadENV() {
  // If env has already been loaded once, then pass it in
  if (ENV) { return ENV; }

  // Import and load .env (we use .envrc to match local env files, but it could easily be .env)
  const dotenv = await import("dotenv");
  const loaded = dotenv.config({
    path: path.resolve(process.cwd(), ".envrc"),
  });

  if (loaded.error) { throw loaded.error; }

  // Save the loaded ENV for later
  ENV = loaded.parsed;

  return ENV;
}

export async function extractPublicENV(env) {
  return Object.fromEntries(
    Object.entries(env).filter(e => e[0].startsWith("PUBLIC_")),
  );
}

/**
 * Load public environment only
 *
 * @returns {Promise<Record<string,string>>} A promise that resolves to only the 'PUBLIC_' prefixed env variables
 */
export async function loadPublicENV() {
  const env = await loadENV();
  const publicEnv = await extractPublicEnv();
  return publicENV;
}

The code isnt' very complicated – pretty standard use of dotenv.

Sidenote: You should be using direnv

I’ve already mentioned it a bit but just to make sure everyone knows – if you’re not already using direnv, you should be.

Note that .envrc and .env files are slightly separate – .envrc can do dynamic stuff, and is more like .bashrc/.profile (i.e. export VAR=value) whereas .env is simpler (i.e. lines look like VAR=value).

How do we know this is backend only?

One of the biggest issues with the “do it how VITE does it” approach is that it’s not clear how to make sure environment stays only on the backend. A few points that make it a bit hard to decide how to use VITE_ vars:

  • If you prefix private stuff with VITE_ (despite the warning), but only use the private vars from endpoints, you’re actually OK, things don’t get leaked into client-side code.
  • If you don’t prefix private stuff (so VITE_PUBLIC_VAR and PRIVATE_VAR), it doesn’t get loaded (I tried process.env['NAME'] and other tricks)

I want to make it as hard as possible to use the ENV from the backend on the frontend. Unfortunately this is the hard pill – we kind of can’t.

It’s up to you and your team to be disciplined (read: this should be fixed with tooling) and make sure to never attempt to use the ENV from the frontend.

My approach does help you though, in that attempting to call loadENV from the frontend should throw an error quite quickly as process.env is not present on the frontend.

One note though – make sure the file naming matches so your local environment and staging/prod environments use the same named file (.env/.envrc)

I do something that’s a little clever – since a valid .env file is generally a valid .envrc file, I name the file .envrc, and in the local environment it’s using the dynamic stuff (ex. export VAR=$(read path/to/file)), and in production it’s more .env like (VAR=value).

It’s a bit of a pain to properly place that file when you’re running in production (it’s relatively easy in my environment), but it leaves things wonderfully consistent (you could even download the file at startup).

Step 2: Pass ENV variables prefixed with ‘PUBLIC_’ variables through via hooks.js

Sometimes you need ENV variables defined on the client side:

  • ex. path to some external service
  • ex. ID for analytics

I’m a fan of just ignoring all the build-time import.meta.env bullshit,

We can use the ‘VITE_’ or whatever prefixing though, I prefer PUBLIC_ in front of the public env vars though, so I’ll use that.

export const handle: Handle = async ({ event, resolve }) => {
  const cookies = cookie.parse(event.request.headers.get('cookie') || '');

  // ... snip ...

  // I can just easily get the ENV and do whatever I want with it on the backend
  const env = await loadENV();
  const RSS_AUTH_COOKIE_NAME = env.PUBLIC_RSS_AUTH_COOKIE_NAME ?? DEFAULT_RSS_AUTH_COOKIE_NAME;

  // ... snip ...
  return response;
};

export const getSession: GetSession = async (event) => {
  const env = await loadENV();

  // Here is where we need to be very careful!
  // We must not pass the env containing ALL the variables through.
  // here I made a little function to extract only public ENV (prefixed with `PUBLIC_`).
  const session = {
    env: await extractPublicENV(env),
  };

  // ... snip ...

  return session;
}

Step 3: Use ENV from your components and pages, as an explicit input

So it’s pretty easy to use the ENV from the backend – either call await loadENV() or access the data stored on event.request that comes in.

How do you use ENV from the frontend?

<script context="module">
 import { onMount } from "svelte";
 import Header from '$lib/header/Header.svelte';
 import { DEFAULT_HELLO_EMAIL } from "$lib/constants";

 export async function load({ session }) {
   // Ensure user is present
   if (!session?.env) {
     return {
       status: 500,
       body: "Failed to load ENV",
     };
   }

   return {
     props: {
       env: session?.env,
     },
   };
 }
</script>

<script lang="ts">
 // ENV
 export let env: Record<string, string> = {};

 const helloEmail = env.PUBLIC_HELLO_EMAIL || DEFAULT_HELLO_EMAIL;

 const fathomTrackerURL = env.PUBLIC_FATHOM_TRACKER_URL;
 const fathomSiteID = env.PUBLIC_FATHOM_SITE_ID;

 onMount(async () => {
     const script = document.createElement("script");
     script.innerHTML = `
   (function(f, a, t, h, o, m){
     a[h]=a[h]||function(){
       (a[h].q=a[h].q||[]).push(arguments)
     };
     o=f.createElement('script'),
     m=f.getElementsByTagName('script')[0];
     o.async=1; o.src=t; o.id='fathom-script';
     m.parentNode.insertBefore(o,m);
   })(document, window, '${fathomTrackerURL}', 'fathom');
   fathom('set', 'siteId', '${fathomSiteID}');
   fathom('trackPageview');
`;
   document.getElementsByTagName('body')[0].appendChild(script);
 });
</script>

I personally like the idea of having the explicit env argument to the layout/page/component, but this example could be trimmed down by removing the <script context="module"> section.

What’s wrong with this approach?

OK so this approach is not perfect, there are a few drawbacks:

  • We have to be careful to avoid misusing ENV in hooks.ts
  • The code in hooks.ts is certainly not optimized, lots of unneeded calculation there we could memoize.
  • I’ve only tried it with adapter-node so it’s hard to say how useful it is across other adapters.
  • A completely statically pre-rendered codebases which use only public vars are faster anyway, so maybe you want that instead

See anything else wrong with this approach? I’d love to know, drop me an email.

As of now this works for me so I’m running with it.

BONUS: How to get analytics stuff working

Well I had quite a time trying to get analytics stuff working. I tried a few things:

Adding the code to app.html:

  • Issue #1: does templating work in there? seems not – I have a staging environment and prod environments with different strings/keys
  • Issue #2: the script tag isn’t there… half the time? I’m missing something fundamental about how svelte renders stuff, because the script tag just… isn’t there. is app.html considered similarly to app.svelte ?

Using import (as the docs say):

  • Issue #1: the script requires some pre-setup before the import happens and stuff after.
  • Issue #2: the fathom script can get imported, but it didn’t trigger properly

I went back and tried the import again just to be sure… and trying to import fathom and call the function just didn’t work, even though I thought it would:

Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'apply')
    at window.fathom (tracker.js:1:2672)
    at tracker.js:1:2723
    at Array.forEach (<anonymous>)
    at tracker.js:1:2689
    at tracker.js:1:2736

Basically there’s an error with all the other little pieces (the id of the script, etc) that I’m just not interested in dealing with.

Maybe I’m too lazy but after trying to essentially de-obfuscate the code and replicate what it was doing with window.fathom so that fathom would work, I just avoided that all together.

In the end, the solution that worked was this:

<script lang="ts">
 import { onMount } from "svelte";
 import UploadSteps from "$lib/UploadSteps.svelte";

 export let user;
 export let env: Record<string, string> = {};

 const fathomTrackerURL = env.PUBLIC_FATHOM_TRACKER_URL;
 const fathomSiteID = env.PUBLIC_FATHOM_SITE_ID;

 onMount(async () => {
     const script = document.createElement("script");
     script.innerHTML = `
   (function(f, a, t, h, o, m){
     a[h]=a[h]||function(){
       (a[h].q=a[h].q||[]).push(arguments)
     };
     o=f.createElement('script'),
     m=f.getElementsByTagName('script')[0];
     o.async=1; o.src=t; o.id='fathom-script';
     m.parentNode.insertBefore(o,m);
   })(document, window, '${fathomTrackerURL}', 'fathom');
   fathom('set', '${fathomSiteID}', 'XASJI');
   fathom('trackPageview');
`;
   document.getElementsByTagName('body')[0].appendChild(script);
 });
</script>

It’s not the cleanest, but it works, and is sufficiently dynamic. I moved on quite quickly after this!

Wrapup

Well, hopefully this small insight helps someone out there. This setup seems to work well for me and is pretty easy to understand so I’ve been enjoying getting back to my usual 12 factor ways.

If you’re Rich Harris or some other Svelte expert and I’ve committed a cardinal sin, please feel free to let me know by email.

Like what you're reading? Get it in your inbox