Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sveltekit flags enhancements #75

Draft
wants to merge 17 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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-auto": "^4.0.0",
"@sveltejs/kit": "^2.19.0",
"@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