Skip to content

Commit 701f3c2

Browse files
Sveltekit flags enhancements (#75)
* use `$env/dynamic/private` from SvelteKit for convenience: people don't have to pass FLAGS_SECRET manually anymore then * allow flag to be called outside of the lifecycle of the handle hook, with a request object being the key for deduplication etc * add support for identifiers * fix types * new function for managing precomputed flags * align with next.js API instead * update example app * lockfile * test * fix example * restructure examples app * make it two flags to show power of precompute + code * add manual approach * lets see if preview deployment is picked up * change crypto usage to be usable in middleware * make sveltekit flags pkg usable within edge middleware * use ISR * changeset * enhance error message * use static env instead of dynamic env * bump kit * use vercel adapter * make sure Vite tooling is used for sveltekit entry point --------- Co-authored-by: Dominik Ferber <[email protected]>
1 parent f427d86 commit 701f3c2

File tree

41 files changed

+1869
-376
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1869
-376
lines changed

.changeset/thirty-pants-sell.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'flags': minor
3+
---
4+
5+
feat: provide precompute patterns for SvelteKit
+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { rewrite } from '@vercel/edge';
2+
import { parse } from 'cookie';
3+
import { normalizeUrl } from '@sveltejs/kit';
4+
import { computeInternalRoute, createVisitorId } from './src/lib/precomputed-flags';
5+
import { marketingABTestManualApproach } from './src/lib/flags';
6+
7+
export const config = {
8+
// Either run middleware on all but the internal asset paths ...
9+
// matcher: '/((?!_app/|favicon.ico|favicon.png).*)'
10+
// ... or only run it where you actually need it (more performant).
11+
matcher: [
12+
'/examples/marketing-pages-manual-approach',
13+
'/examples/marketing-pages'
14+
// add more paths here if you want to run A/B tests on other pages, e.g.
15+
// '/something-else'
16+
]
17+
};
18+
19+
export default async function middleware(request: Request) {
20+
const { url, denormalize } = normalizeUrl(request.url);
21+
22+
if (url.pathname === '/examples/marketing-pages-manual-approach') {
23+
// Retrieve cookies which contain the feature flags.
24+
let flag = parse(request.headers.get('cookie') ?? '').marketingManual || '';
25+
26+
if (!flag) {
27+
flag = String(Math.random() < 0.5);
28+
request.headers.set('x-marketingManual', flag); // cookie is not available on the initial request
29+
}
30+
31+
return rewrite(
32+
// Get destination URL based on the feature flag
33+
denormalize(
34+
(await marketingABTestManualApproach(request))
35+
? '/examples/marketing-pages-variant-a'
36+
: '/examples/marketing-pages-variant-b'
37+
),
38+
{
39+
headers: {
40+
'Set-Cookie': `marketingManual=${flag}; Path=/`
41+
}
42+
}
43+
);
44+
}
45+
46+
if (url.pathname === '/examples/marketing-pages') {
47+
// Retrieve cookies which contain the feature flags.
48+
let visitorId = parse(request.headers.get('cookie') ?? '').visitorId || '';
49+
50+
if (!visitorId) {
51+
visitorId = createVisitorId();
52+
request.headers.set('x-visitorId', visitorId); // cookie is not available on the initial request
53+
}
54+
55+
return rewrite(
56+
// Get destination URL based on the feature flag
57+
denormalize(await computeInternalRoute(url.pathname, request)),
58+
{
59+
headers: {
60+
'Set-Cookie': `visitorId=${visitorId}; Path=/`
61+
}
62+
}
63+
);
64+
}
65+
}

examples/sveltekit-example/package.json

+11-10
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,21 @@
1212
"format": "prettier --write ."
1313
},
1414
"dependencies": {
15+
"@vercel/edge": "^1.2.1",
16+
"@vercel/toolbar": "0.1.15",
1517
"flags": "workspace:*",
16-
"@vercel/toolbar": "0.1.15"
18+
"cookie": "^0.6.0"
1719
},
1820
"devDependencies": {
19-
"@sveltejs/adapter-auto": "^3.0.0",
20-
"@sveltejs/kit": "^2.0.0",
21-
"@sveltejs/vite-plugin-svelte": "^3.0.0",
21+
"@sveltejs/adapter-vercel": "^5.6.0",
22+
"@sveltejs/kit": "^2.20.2",
23+
"@sveltejs/vite-plugin-svelte": "^4.0.0",
2224
"prettier": "^3.1.1",
23-
"prettier-plugin-svelte": "^3.1.2",
24-
"svelte": "^4.2.7",
25-
"svelte-check": "^3.6.0",
26-
"tslib": "^2.4.1",
27-
"typescript": "^5.0.0",
28-
"vite": "^5.0.3"
25+
"prettier-plugin-svelte": "^3.2.6",
26+
"svelte": "^5.0.0",
27+
"svelte-check": "^4.0.0",
28+
"typescript": "^5.5.0",
29+
"vite": "^5.4.4"
2930
},
3031
"type": "module"
3132
}
+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// `reroute` is called on both the server and client during dev, because `middleware.ts` is unknown to SvelteKit.
2+
// In production it's called on the client only because `middleware.ts` will handle the first page visit.
3+
// As a result, when visiting a page you'll get rerouted accordingly in all situations in both dev and prod.
4+
export async function reroute({ url, fetch }) {
5+
if (url.pathname === '/examples/marketing-pages-manual-approach') {
6+
const destination = new URL('/api/reroute-manual', url);
7+
8+
// Since `reroute` runs on the client and the cookie with the flag info is not available to it,
9+
// we do a server request to get the internal route.
10+
return fetch(destination).then((response) => response.text());
11+
}
12+
13+
if (
14+
url.pathname === '/examples/marketing-pages'
15+
// add more paths here if you want to run A/B tests on other pages, e.g.
16+
// || url.pathname === '/something-else'
17+
) {
18+
const destination = new URL('/api/reroute', url);
19+
destination.searchParams.set('pathname', url.pathname);
20+
21+
// Since `reroute` runs on the client and the cookie with the flag info is not available to it,
22+
// we do a server request to get the internal route.
23+
return fetch(destination).then((response) => response.text());
24+
}
25+
}
+62-7
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,67 @@
1+
import type { ReadonlyHeaders, ReadonlyRequestCookies } from 'flags';
12
import { flag } from 'flags/sveltekit';
23

3-
export const showDashboard = flag<boolean>({
4-
key: 'showDashboard',
5-
description: 'Show the dashboard', // optional
6-
origin: 'https://example.com/#showdashbord', // optional
4+
export const showNewDashboard = flag<boolean>({
5+
key: 'showNewDashboard',
6+
description: 'Show the new dashboard', // optional
7+
origin: 'https://example.com/#shownewdashbord', // optional
78
options: [{ value: true }, { value: false }], // optional
8-
// can be async and has access to the event
9-
decide(_event) {
10-
return false;
9+
// can be async and has access to entities (see below for an example), headers and cookies
10+
decide({ cookies }) {
11+
return cookies.get('showNewDashboard')?.value === 'true';
12+
}
13+
});
14+
15+
export const marketingABTestManualApproach = flag<boolean>({
16+
key: 'marketingABTestManualApproach',
17+
description: 'Marketing AB Test Manual Approach',
18+
decide({ cookies, headers }) {
19+
return (cookies.get('marketingManual')?.value ?? headers.get('x-marketingManual')) === 'true';
20+
}
21+
});
22+
23+
interface Entities {
24+
visitorId?: string;
25+
}
26+
27+
function identify({
28+
cookies,
29+
headers
30+
}: {
31+
cookies: ReadonlyRequestCookies;
32+
headers: ReadonlyHeaders;
33+
}): Entities {
34+
const visitorId = cookies.get('visitorId')?.value ?? headers.get('x-visitorId');
35+
36+
if (!visitorId) {
37+
throw new Error(
38+
'Visitor ID not found - should have been set by middleware or within api/reroute'
39+
);
40+
}
41+
42+
return { visitorId };
43+
}
44+
45+
export const firstMarketingABTest = flag<boolean, Entities>({
46+
key: 'firstMarketingABTest',
47+
description: 'Example of a precomputed flag',
48+
identify,
49+
decide({ entities }) {
50+
if (!entities?.visitorId) return false;
51+
52+
// Use any kind of deterministic method that runs on the visitorId
53+
return /^[a-n0-5]/i.test(entities?.visitorId);
54+
}
55+
});
56+
57+
export const secondMarketingABTest = flag<boolean, Entities>({
58+
key: 'secondMarketingABTest',
59+
description: 'Example of a precomputed flag',
60+
identify,
61+
decide({ entities }) {
62+
if (!entities?.visitorId) return false;
63+
64+
// Use any kind of deterministic method that runs on the visitorId
65+
return /[a-n0-5]$/i.test(entities.visitorId);
1166
}
1267
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { precompute } from 'flags/sveltekit';
2+
import { firstMarketingABTest, secondMarketingABTest } from './flags';
3+
4+
export const marketingFlags = [firstMarketingABTest, secondMarketingABTest];
5+
6+
/**
7+
* Given a user-visible pathname, precompute the internal route using the flags used on that page
8+
*
9+
* e.g. /marketing -> /marketing/asd-qwe-123
10+
*/
11+
export async function computeInternalRoute(pathname: string, request: Request) {
12+
if (pathname === '/examples/marketing-pages') {
13+
return '/examples/marketing-pages/' + (await precompute(marketingFlags, request));
14+
}
15+
16+
return pathname;
17+
}
18+
19+
export function createVisitorId() {
20+
return crypto.randomUUID().replace(/-/g, '');
21+
}

examples/sveltekit-example/src/routes/+layout.server.ts

-7
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
<script lang="ts">
2-
import type { LayoutData } from './$types';
3-
42
import { mountVercelToolbar } from '@vercel/toolbar/vite';
53
import { onMount } from 'svelte';
4+
import type { LayoutProps } from './$types';
5+
import { page } from '$app/state';
6+
67
onMount(() => mountVercelToolbar());
78
8-
export let data: LayoutData;
9+
let { children }: LayoutProps = $props();
910
</script>
1011

12+
{#if page.url.pathname !== '/'}
13+
<header>
14+
<nav>
15+
<a href="/">Back to homepage</a>
16+
</nav>
17+
</header>
18+
{/if}
19+
1120
<main>
12-
{data.title}
13-
<!-- +page.svelte is rendered in this <slot> -->
14-
<slot />
21+
{@render children()}
1522
</main>

examples/sveltekit-example/src/routes/+page.server.ts

-13
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,37 @@
1-
<script lang="ts">
2-
import type { PageData } from './$types';
1+
<h1>Flags SDK</h1>
32

4-
export let data: PageData;
5-
</script>
3+
<p>This page contains example snippets for the Flags SDK using SvelteKit</p>
64

7-
<h1>{data.post.title}</h1>
8-
<div>{@html data.post.content}</div>
5+
<p>
6+
See <a href="https://flags-sdk.dev">flags-sdk.dev</a> for the full documentation, or
7+
<a href="https://github.com/vercel/flags/tree/main/examples/sveltekit-example">GitHub</a> for the source
8+
code.
9+
</p>
10+
11+
<a class="tile" href="/examples/dashboard-pages">
12+
<h3>Dashboard Pages</h3>
13+
<p>Using feature flags on dynamic pages</p>
14+
</a>
15+
16+
<a class="tile" href="/examples/marketing-pages-manual-approach">
17+
<h3>Marketing Pages (manual approach)</h3>
18+
<p>Simple but not scalable approach to feature flags on static pages</p>
19+
</a>
20+
21+
<a class="tile" href="/examples/marketing-pages">
22+
<h3>Marketing Pages</h3>
23+
<p>Using feature flags on static pages</p>
24+
</a>
25+
26+
<style>
27+
.tile {
28+
display: block;
29+
padding: 1rem;
30+
margin: 1rem 0;
31+
border: 1px solid #ccc;
32+
border-radius: 0.5rem;
33+
text-decoration: none;
34+
color: inherit;
35+
max-width: 30rem;
36+
}
37+
</style>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { marketingABTestManualApproach } from '$lib/flags.js';
2+
import { text } from '@sveltejs/kit';
3+
4+
export async function GET({ request, cookies }) {
5+
let flag = cookies.get('marketingManual');
6+
7+
if (!flag) {
8+
flag = String(Math.random() < 0.5);
9+
cookies.set('marketingManual', flag, {
10+
path: '/',
11+
httpOnly: false // So that we can reset the visitor Id on the client in the examples
12+
});
13+
request.headers.set('x-marketingManual', flag); // cookie is not available on the initial request
14+
}
15+
16+
return text(
17+
(await marketingABTestManualApproach())
18+
? '/examples/marketing-pages-variant-a'
19+
: '/examples/marketing-pages-variant-b'
20+
);
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { text } from '@sveltejs/kit';
2+
import { computeInternalRoute, createVisitorId } from '$lib/precomputed-flags';
3+
4+
export async function GET({ url, request, cookies, setHeaders }) {
5+
let visitorId = cookies.get('visitorId');
6+
7+
if (!visitorId) {
8+
visitorId = createVisitorId();
9+
cookies.set('visitorId', visitorId, {
10+
path: '/',
11+
httpOnly: false // So that we can reset the visitor Id on the client in the examples
12+
});
13+
request.headers.set('x-visitorId', visitorId); // cookie is not available on the initial request
14+
}
15+
16+
// Add cache headers to not request the API as much (as the visitor id is not changing)
17+
setHeaders({ 'Cache-Control': 'private, max-age=300, stale-while-revalidate=600' });
18+
19+
return text(await computeInternalRoute(url.searchParams.get('pathname')!, request));
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<p>Marketing page (manual approach) variant A</p>
2+
3+
<div>
4+
<button
5+
onclick={() => {
6+
document.cookie = 'marketingManual=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT';
7+
window.location.reload();
8+
}}>Reset cookie</button
9+
>
10+
<span
11+
>(will automatically assign a new visitor id, which depending on the value will opt you into one
12+
of two variants)</span
13+
>
14+
</div>
15+
16+
<style>
17+
div {
18+
max-width: 30rem;
19+
}
20+
</style>

0 commit comments

Comments
 (0)