Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ jobs:
uses: actions/upload-pages-artifact@v5
with:
path: projects/pages/dist
include-hidden-files: true

lighthouse:
runs-on: ubuntu-latest
Expand Down
26 changes: 25 additions & 1 deletion projects/internals/tools/src/playground/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { loadTools, type ToolMethod, type ToolOutput } from '../internal/tools.js';
import { PlaygroundService } from './service.js';
import { createPlaygroundURL } from './utils.js';
import { createPlaygroundURL, MAX_PLAYGROUND_URL_LENGTH } from './utils.js';

// when ELEMENTS_PLAYGROUND_BASE_URL is not configured, createPlaygroundURL returns ''
const hasPlaygroundBaseURL = createPlaygroundURL('test', []).length > 0;
Expand Down Expand Up @@ -148,6 +148,30 @@ describe('PlaygroundService', () => {
);
});

it('should handle content that would exceed the supported playground URL length', async () => {
process.env.ELEMENTS_ENV = 'browser';
const tools = loadTools(PlaygroundService);
const createTool = tools.find(tool => tool.metadata.name === 'create');

const result = (await createTool?.({
template: '<nve-button>valid</nve-button>',
name: 'x'.repeat(MAX_PLAYGROUND_URL_LENGTH),
start: false
})) as ToolOutput<string>;

if (!hasPlaygroundBaseURL) {
expect(result.status).toBe('complete');
expect(result.result).toBe('');
return;
}

expect(result.status).toBe('error');
expect(result.message).toBe(
`Playground content produces a URL that exceeds the ${MAX_PLAYGROUND_URL_LENGTH}-character limit.`
);
expect(result.result).toBeUndefined();
});

it('should skip validation and return URL when not in mcp or cli environment', async () => {
process.env.ELEMENTS_ENV = 'browser';
const result = await PlaygroundService.create({
Expand Down
44 changes: 43 additions & 1 deletion projects/internals/tools/src/playground/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { describe, it, expect } from 'vitest';
import type { ProjectElement } from '@internals/metadata';
import { ToolError } from '../internal/tools.js';
import {
createPlaygroundURL,
createAngularFiles,
Expand All @@ -12,6 +13,7 @@ import {
createVueFiles,
createDefaultFiles,
formatTemplate,
MAX_PLAYGROUND_URL_LENGTH,
playgroundTypes
} from './utils.js';

Expand Down Expand Up @@ -558,7 +560,7 @@ describe('createImportMap with different frameworks', () => {

describe('serialize function behavior', () => {
it('should compress and encode data by default', () => {
// The serialize function is called internally, so we test its effect
// Test the effect because createPlaygroundURL invokes serialize internally.
const result = createPlaygroundURL('<nve-button></nve-button>', [], {});

if (hasPlaygroundBaseURL) {
Expand All @@ -569,6 +571,46 @@ describe('serialize function behavior', () => {
expect(result).toBe('');
}
});

it('should reject playground URLs that exceed the supported length', () => {
// Use enough source bytes to produce compressed data above V8's argument-count limit.
const bytes = new Uint8Array(200_000);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Use a fixed seed so the generated high-entropy test data is reproducible.
let state = 0x12345678;

// Fill the array with deterministic pseudorandom bytes that resist gzip compression.
for (let index = 0; index < bytes.length; index += 1) {
// Apply the three mixing steps of the xorshift32 pseudorandom number generator.
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
bytes[index] = state;
}

// Base64 creates a large value using only printable characters.
const base64 = Buffer.from(bytes).toString('base64');
// Replace base64 punctuation so the value remains safe in an unquoted URL and an HTML attribute.
const attributeValue = base64.replace(/[+/=]/g, 'A');
const template = `<div data-value="${attributeValue}">content</div>`;

if (!hasPlaygroundBaseURL) {
expect(createPlaygroundURL(template, [])).toBe('');
return;
}

let thrown: unknown;
try {
createPlaygroundURL(template, []);
} catch (error) {
thrown = error;
}

expect(thrown).toBeInstanceOf(ToolError);
expect(thrown).toHaveProperty(
'message',
`Playground content produces a URL that exceeds the ${MAX_PLAYGROUND_URL_LENGTH}-character limit.`
);
});
});

describe('Edge cases and error handling', () => {
Expand Down
37 changes: 31 additions & 6 deletions projects/internals/tools/src/playground/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
import { gzipSync } from 'fflate';
import format from 'html-format';
import type { Element } from '@internals/metadata';
import { ToolError } from '../internal/tools.js';
import { getElementImports } from '../internal/utils.js';
import { validateTemplate } from '../internal/validate.js';

declare const __ELEMENTS_ESM_CDN_BASE_URL__: string;

const ELEMENTS_PLAYGROUND_BASE_URL = process.env.ELEMENTS_PLAYGROUND_BASE_URL ?? '';
const ELEMENTS_ESM_CDN_BASE_URL = __ELEMENTS_ESM_CDN_BASE_URL__;
export const MAX_PLAYGROUND_URL_LENGTH = 32_768;

interface PlaygroundOptions {
type?: PlaygroundType;
Expand Down Expand Up @@ -147,12 +149,22 @@ export function createVueFiles(content: string, elements: Element[], options: Pl
}

function createURL(files: string, options: PlaygroundOptions) {
if (ELEMENTS_PLAYGROUND_BASE_URL.length === 0) {
return '';
}

const defaultOptions = { openFile: 'index.html', ...options };
return ELEMENTS_PLAYGROUND_BASE_URL.length > 0
? encodeURI(
`${ELEMENTS_PLAYGROUND_BASE_URL}/?version=1&layout=vertical-split${defaultOptions.name ? `&name=${defaultOptions.name.trim()}` : ''}${defaultOptions.theme ? `&theme=${defaultOptions.theme}` : ''}&file=${defaultOptions.openFile}${defaultOptions.referer ? `&ref=${defaultOptions.referer}` : ''}&files=${files}`
)
: '';
const url = encodeURI(
`${ELEMENTS_PLAYGROUND_BASE_URL}/?version=1&layout=vertical-split${defaultOptions.name ? `&name=${defaultOptions.name.trim()}` : ''}${defaultOptions.theme ? `&theme=${defaultOptions.theme}` : ''}&file=${defaultOptions.openFile}${defaultOptions.referer ? `&ref=${defaultOptions.referer}` : ''}&files=${files}`
);

if (url.length > MAX_PLAYGROUND_URL_LENGTH) {
throw new ToolError(
`Playground content produces a URL that exceeds the ${MAX_PLAYGROUND_URL_LENGTH}-character limit.`
);
}

return url;
}

function createLayoutStyles() {
Expand Down Expand Up @@ -193,9 +205,22 @@ nve-logo.large {
}

function serialize(data: Record<string, { content: string }>, compress = true) {
// Turn the playground file map into the byte sequence that the URL encoder uses.
const encoded = new TextEncoder().encode(JSON.stringify(data));
const array = compress ? gzipSync(encoded) : encoded;
const base64 = globalThis.btoa(String.fromCharCode(...array));
// Limit each function call to 32,768 arguments, safely below engine limits.
const chunkSize = 0x8000;
let binary = '';

// Convert every byte without spreading the entire, potentially large array into one call.
for (let offset = 0; offset < array.length; offset += chunkSize) {
// subarray creates a lightweight view containing no more than chunkSize bytes.
binary += String.fromCharCode(...array.subarray(offset, offset + chunkSize));
}

// Encode the complete binary string using the browser-compatible base64 API.
const base64 = globalThis.btoa(binary);
// Escape base64 characters that have special meaning inside a URL query parameter.
return encodeURIComponent(base64);
}

Expand Down