Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-web-router-env-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@web-widget/flags-kit': patch
---

Fix web-router environment check to only inject flag values script tags in Vercel environments, matching Next.js/SvelteKit behavior.
49 changes: 45 additions & 4 deletions packages/flags/src/web-router/html-transform.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createFlagScriptInjectionTransform } from './html-transform';

// Mock process.env for testing
const originalEnv = process.env;

describe('createFlagScriptInjectionTransform', () => {
beforeEach(() => {
// Reset environment variables before each test
process.env = { ...originalEnv };
});

afterEach(() => {
// Restore original environment variables
process.env = originalEnv;
});

it('should inject script before </body> tag', async () => {
const html = '<html><body><h1>Hello</h1></body></html>';
const scriptContent = async () => '{"flag1":true}';
Expand Down Expand Up @@ -60,8 +73,35 @@ describe('createFlagScriptInjectionTransform', () => {
);
});

it('should not inject when no </body> tag', async () => {
const html = '<html><body><h1>Hello</h1></html>';
it('should handle empty script content', async () => {
const html = '<html><body><h1>Hello</h1></body></html>';
const scriptContent = async () => '';

const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(html));
controller.close();
},
});

const transform = createFlagScriptInjectionTransform(scriptContent);
const transformedStream = stream.pipeThrough(transform);

const reader = transformedStream.getReader();
let result = '';

while (true) {
const { done, value } = await reader.read();
if (done) break;
result += new TextDecoder().decode(value);
}

// Should not inject script tag when content is empty
expect(result).toBe('<html><body><h1>Hello</h1></body></html>');
});

it('should handle HTML without body tag', async () => {
const html = '<html><head><title>Test</title></head></html>';
const scriptContent = async () => '{"flag3":true}';

const stream = new ReadableStream({
Expand All @@ -83,6 +123,7 @@ describe('createFlagScriptInjectionTransform', () => {
result += new TextDecoder().decode(value);
}

expect(result).toBe(html);
// Should not inject script tag when there's no body tag
expect(result).toBe('<html><head><title>Test</title></head></html>');
});
});
73 changes: 44 additions & 29 deletions packages/flags/src/web-router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,44 +666,59 @@ export function createHandle({
if (result && result instanceof Response && result.body) {
const contentType = result.headers.get('content-type')?.toLowerCase();
if (contentType && contentType.includes('text/html')) {
const transformStream = createFlagScriptInjectionTransform(async () => {
const store = context().state._flag;
if (!store || Object.keys(store.usedFlags).length === 0) return '';

// This is for reporting which flags were used when this page was generated,
// so the value shows up in Vercel Toolbar, without the client ever being
// aware of this feature flag.
const flagValues = await resolveObjectPromises(store.usedFlags);

// Create a serialized representation of the flag values using the same
// signing mechanism as Next.js, rather than encryption. This ensures
// deterministic output across server restarts.
const flagsArray = Object.keys(flagValues).map((key) => ({
key,
options: undefined,
}));
const serializedFlagValues = await serialize(
flagValues,
flagsArray,
secret!, // secret is guaranteed to be defined at this point
// Only inject flag values script in Vercel environments for consistency with other frameworks
if (isVercelEnvironment()) {
const transformStream = createFlagScriptInjectionTransform(
async () => {
const store = context().state._flag;
if (!store || Object.keys(store.usedFlags).length === 0)
return '';

// This is for reporting which flags were used when this page was generated,
// so the value shows up in Vercel Toolbar, without the client ever being
// aware of this feature flag.
const flagValues = await resolveObjectPromises(store.usedFlags);

// Create a serialized representation of the flag values using the same
// signing mechanism as Next.js, rather than encryption. This ensures
// deterministic output across server restarts.
const flagsArray = Object.keys(flagValues).map((key) => ({
key,
options: undefined,
}));
const serializedFlagValues = await serialize(
flagValues,
flagsArray,
secret!, // secret is guaranteed to be defined at this point
);

return safeJsonStringify(serializedFlagValues);
},
);
const modifiedBody = result.body.pipeThrough(transformStream);

return safeJsonStringify(serializedFlagValues);
});
const modifiedBody = result.body.pipeThrough(transformStream);

return new Response(modifiedBody, {
status: result.status,
statusText: result.statusText,
headers: result.headers,
});
return new Response(modifiedBody, {
status: result.status,
statusText: result.statusText,
headers: result.headers,
});
}
}
}

return result;
};
}

/**
* Checks if the current environment is Vercel (preview or production)
* This ensures consistency with other frameworks that only inject flag values in Vercel environments
*/
function isVercelEnvironment(): boolean {
// Check for Vercel-specific environment variables
return !!(process.env.VERCEL || process.env.VERCEL_ENV);
}

async function handleWellKnownFlagsRoute(
headers: Headers,
secret: string,
Expand Down
Loading