Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f8e1f39
use `$env/dynamic/private` from SvelteKit for convenience: people don…
dummdidumm Feb 21, 2025
b2d40d6
allow flag to be called outside of the lifecycle of the handle hook, …
dummdidumm Feb 21, 2025
dbc6e33
add support for identifiers
dummdidumm Feb 21, 2025
cf65a02
fix types
dummdidumm Feb 21, 2025
09a969d
new function for managing precomputed flags
dummdidumm Feb 24, 2025
f084706
align with next.js API instead
dummdidumm Feb 24, 2025
02bb483
update example app
dummdidumm Mar 7, 2025
9cb6a6e
lockfile
dummdidumm Mar 7, 2025
bbe2fa3
test
dummdidumm Mar 7, 2025
af6825e
fix example
dummdidumm Mar 10, 2025
e91af14
restructure examples app
dummdidumm Mar 11, 2025
29714d3
make it two flags to show power of precompute + code
dummdidumm Mar 11, 2025
424cbd9
add manual approach
dummdidumm Mar 11, 2025
340a807
lets see if preview deployment is picked up
dummdidumm Mar 12, 2025
a7309ed
change crypto usage to be usable in middleware
dummdidumm Mar 12, 2025
7f5ac4c
make sveltekit flags pkg usable within edge middleware
dummdidumm Mar 12, 2025
ba65165
use ISR
dummdidumm Mar 12, 2025
d199103
changeset
dummdidumm Mar 12, 2025
34e5805
Merge remote-tracking branch 'origin/main' into sveltekit-flags-enhan…
dummdidumm Mar 13, 2025
25d0c5c
Merge branch 'main' into sveltekit-flags-enhancements
dferber90 Mar 20, 2025
e7c826a
enhance error message
dummdidumm Mar 20, 2025
a5f740f
use static env instead of dynamic env
dummdidumm Mar 20, 2025
67d7fbc
bump kit
dummdidumm Mar 21, 2025
08b5b8b
use vercel adapter
dummdidumm Mar 21, 2025
3db4af5
make sure Vite tooling is used for sveltekit entry point
dummdidumm Mar 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thirty-pants-sell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'flags': minor
---

feat: provide precompute patterns for SvelteKit
65 changes: 65 additions & 0 deletions examples/sveltekit-example/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { rewrite } from '@vercel/edge';
import { parse } from 'cookie';
import { normalizeUrl } from '@sveltejs/kit';
import { computeInternalRoute, createVisitorId } from './src/lib/precomputed-flags';
import { marketingABTestManualApproach } from './src/lib/flags';

export const config = {
// Either run middleware on all but the internal asset paths ...
// matcher: '/((?!_app/|favicon.ico|favicon.png).*)'
// ... or only run it where you actually need it (more performant).
matcher: [
'/examples/marketing-pages-manual-approach',
'/examples/marketing-pages'
// add more paths here if you want to run A/B tests on other pages, e.g.
// '/something-else'
]
};

export default async function middleware(request: Request) {
const { url, denormalize } = normalizeUrl(request.url);

if (url.pathname === '/examples/marketing-pages-manual-approach') {
// Retrieve cookies which contain the feature flags.
let flag = parse(request.headers.get('cookie') ?? '').marketingManual || '';

if (!flag) {
flag = String(Math.random() < 0.5);
request.headers.set('x-marketingManual', flag); // cookie is not available on the initial request
}

return rewrite(
// Get destination URL based on the feature flag
denormalize(
(await marketingABTestManualApproach(request))
? '/examples/marketing-pages-variant-a'
: '/examples/marketing-pages-variant-b'
),
{
headers: {
'Set-Cookie': `marketingManual=${flag}; Path=/`
}
}
);
}

if (url.pathname === '/examples/marketing-pages') {
// Retrieve cookies which contain the feature flags.
let visitorId = parse(request.headers.get('cookie') ?? '').visitorId || '';

if (!visitorId) {
visitorId = createVisitorId();
request.headers.set('x-visitorId', visitorId); // cookie is not available on the initial request
}

return rewrite(
// Get destination URL based on the feature flag
denormalize(await computeInternalRoute(url.pathname, request)),
{
headers: {
'Set-Cookie': `visitorId=${visitorId}; Path=/`
}
}
);
}
}
21 changes: 11 additions & 10 deletions examples/sveltekit-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,21 @@
"format": "prettier --write ."
},
"dependencies": {
"@vercel/edge": "^1.2.1",
"@vercel/toolbar": "0.1.15",
"flags": "workspace:*",
"@vercel/toolbar": "0.1.15"
"cookie": "^0.6.0"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@sveltejs/adapter-vercel": "^5.6.0",
"@sveltejs/kit": "^2.20.2",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^5.0.3"
"prettier-plugin-svelte": "^3.2.6",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.5.0",
"vite": "^5.4.4"
},
"type": "module"
}
25 changes: 25 additions & 0 deletions examples/sveltekit-example/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// `reroute` is called on both the server and client during dev, because `middleware.ts` is unknown to SvelteKit.
// In production it's called on the client only because `middleware.ts` will handle the first page visit.
// As a result, when visiting a page you'll get rerouted accordingly in all situations in both dev and prod.
export async function reroute({ url, fetch }) {
if (url.pathname === '/examples/marketing-pages-manual-approach') {
const destination = new URL('/api/reroute-manual', url);

// Since `reroute` runs on the client and the cookie with the flag info is not available to it,
// we do a server request to get the internal route.
return fetch(destination).then((response) => response.text());
}

if (
url.pathname === '/examples/marketing-pages'
// add more paths here if you want to run A/B tests on other pages, e.g.
// || url.pathname === '/something-else'
) {
const destination = new URL('/api/reroute', url);
destination.searchParams.set('pathname', url.pathname);

// Since `reroute` runs on the client and the cookie with the flag info is not available to it,
// we do a server request to get the internal route.
return fetch(destination).then((response) => response.text());
}
}
69 changes: 62 additions & 7 deletions examples/sveltekit-example/src/lib/flags.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,67 @@
import type { ReadonlyHeaders, ReadonlyRequestCookies } from 'flags';
import { flag } from 'flags/sveltekit';

export const showDashboard = flag<boolean>({
key: 'showDashboard',
description: 'Show the dashboard', // optional
origin: 'https://example.com/#showdashbord', // optional
export const showNewDashboard = flag<boolean>({
key: 'showNewDashboard',
description: 'Show the new dashboard', // optional
origin: 'https://example.com/#shownewdashbord', // optional
options: [{ value: true }, { value: false }], // optional
// can be async and has access to the event
decide(_event) {
return false;
// can be async and has access to entities (see below for an example), headers and cookies
decide({ cookies }) {
return cookies.get('showNewDashboard')?.value === 'true';
}
});

export const marketingABTestManualApproach = flag<boolean>({
key: 'marketingABTestManualApproach',
description: 'Marketing AB Test Manual Approach',
decide({ cookies, headers }) {
return (cookies.get('marketingManual')?.value ?? headers.get('x-marketingManual')) === 'true';
}
});

interface Entities {
visitorId?: string;
}

function identify({
cookies,
headers
}: {
cookies: ReadonlyRequestCookies;
headers: ReadonlyHeaders;
}): Entities {
const visitorId = cookies.get('visitorId')?.value ?? headers.get('x-visitorId');

if (!visitorId) {
throw new Error(
'Visitor ID not found - should have been set by middleware or within api/reroute'
);
}

return { visitorId };
}

export const firstMarketingABTest = flag<boolean, Entities>({
key: 'firstMarketingABTest',
description: 'Example of a precomputed flag',
identify,
decide({ entities }) {
if (!entities?.visitorId) return false;

// Use any kind of deterministic method that runs on the visitorId
return /^[a-n0-5]/i.test(entities?.visitorId);
}
});

export const secondMarketingABTest = flag<boolean, Entities>({
key: 'secondMarketingABTest',
description: 'Example of a precomputed flag',
identify,
decide({ entities }) {
if (!entities?.visitorId) return false;

// Use any kind of deterministic method that runs on the visitorId
return /[a-n0-5]$/i.test(entities.visitorId);
}
});
21 changes: 21 additions & 0 deletions examples/sveltekit-example/src/lib/precomputed-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { precompute } from 'flags/sveltekit';
import { firstMarketingABTest, secondMarketingABTest } from './flags';

export const marketingFlags = [firstMarketingABTest, secondMarketingABTest];

/**
* Given a user-visible pathname, precompute the internal route using the flags used on that page
*
* e.g. /marketing -> /marketing/asd-qwe-123
*/
export async function computeInternalRoute(pathname: string, request: Request) {
if (pathname === '/examples/marketing-pages') {
return '/examples/marketing-pages/' + (await precompute(marketingFlags, request));
}

return pathname;
}

export function createVisitorId() {
return crypto.randomUUID().replace(/-/g, '');
}
7 changes: 0 additions & 7 deletions examples/sveltekit-example/src/routes/+layout.server.ts

This file was deleted.

19 changes: 13 additions & 6 deletions examples/sveltekit-example/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
<script lang="ts">
import type { LayoutData } from './$types';

import { mountVercelToolbar } from '@vercel/toolbar/vite';
import { onMount } from 'svelte';
import type { LayoutProps } from './$types';
import { page } from '$app/state';

onMount(() => mountVercelToolbar());

export let data: LayoutData;
let { children }: LayoutProps = $props();
</script>

{#if page.url.pathname !== '/'}
<header>
<nav>
<a href="/">Back to homepage</a>
</nav>
</header>
{/if}

<main>
{data.title}
<!-- +page.svelte is rendered in this <slot> -->
<slot />
{@render children()}
</main>
13 changes: 0 additions & 13 deletions examples/sveltekit-example/src/routes/+page.server.ts

This file was deleted.

41 changes: 35 additions & 6 deletions examples/sveltekit-example/src/routes/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,37 @@
<script lang="ts">
import type { PageData } from './$types';
<h1>Flags SDK</h1>

export let data: PageData;
</script>
<p>This page contains example snippets for the Flags SDK using SvelteKit</p>

<h1>{data.post.title}</h1>
<div>{@html data.post.content}</div>
<p>
See <a href="https://flags-sdk.dev">flags-sdk.dev</a> for the full documentation, or
<a href="https://github.com/vercel/flags/tree/main/examples/sveltekit-example">GitHub</a> for the source
code.
</p>

<a class="tile" href="/examples/dashboard-pages">
<h3>Dashboard Pages</h3>
<p>Using feature flags on dynamic pages</p>
</a>

<a class="tile" href="/examples/marketing-pages-manual-approach">
<h3>Marketing Pages (manual approach)</h3>
<p>Simple but not scalable approach to feature flags on static pages</p>
</a>

<a class="tile" href="/examples/marketing-pages">
<h3>Marketing Pages</h3>
<p>Using feature flags on static pages</p>
</a>

<style>
.tile {
display: block;
padding: 1rem;
margin: 1rem 0;
border: 1px solid #ccc;
border-radius: 0.5rem;
text-decoration: none;
color: inherit;
max-width: 30rem;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { marketingABTestManualApproach } from '$lib/flags.js';
import { text } from '@sveltejs/kit';

export async function GET({ request, cookies }) {
let flag = cookies.get('marketingManual');

if (!flag) {
flag = String(Math.random() < 0.5);
cookies.set('marketingManual', flag, {
path: '/',
httpOnly: false // So that we can reset the visitor Id on the client in the examples
});
request.headers.set('x-marketingManual', flag); // cookie is not available on the initial request
}

return text(
(await marketingABTestManualApproach())
? '/examples/marketing-pages-variant-a'
: '/examples/marketing-pages-variant-b'
);
}
20 changes: 20 additions & 0 deletions examples/sveltekit-example/src/routes/api/reroute/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { text } from '@sveltejs/kit';
import { computeInternalRoute, createVisitorId } from '$lib/precomputed-flags';

export async function GET({ url, request, cookies, setHeaders }) {
let visitorId = cookies.get('visitorId');

if (!visitorId) {
visitorId = createVisitorId();
cookies.set('visitorId', visitorId, {
path: '/',
httpOnly: false // So that we can reset the visitor Id on the client in the examples
});
request.headers.set('x-visitorId', visitorId); // cookie is not available on the initial request
}

// Add cache headers to not request the API as much (as the visitor id is not changing)
setHeaders({ 'Cache-Control': 'private, max-age=300, stale-while-revalidate=600' });

return text(await computeInternalRoute(url.searchParams.get('pathname')!, request));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<p>Marketing page (manual approach) variant A</p>

<div>
<button
onclick={() => {
document.cookie = 'marketingManual=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT';
window.location.reload();
}}>Reset cookie</button
>
<span
>(will automatically assign a new visitor id, which depending on the value will opt you into one
of two variants)</span
>
</div>

<style>
div {
max-width: 30rem;
}
</style>
Loading
Loading