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
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
42 changes: 40 additions & 2 deletions 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 All @@ -26,6 +28,20 @@ function expectURL(result: string, expected: string) {
}
}

function createDeterministicPseudorandomBytes(length: number) {
const bytes = new Uint8Array(length);
let state = 0x12345678;

for (let index = 0; index < bytes.length; index += 1) {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
bytes[index] = state;
}

return bytes;
}

describe('createPlaygroundURL', () => {
const elements: ProjectElement[] = [
{
Expand Down Expand Up @@ -558,17 +574,39 @@ 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
const result = createPlaygroundURL('<nve-button></nve-button>', [], {});

if (hasPlaygroundBaseURL) {
// Should contain encoded files data
expect(result).toContain('&files=');
expect(result.length).toBeGreaterThan(100); // Should be reasonably long due to compression
} else {
expect(result).toBe('');
}
});

it('should reject playground URLs that exceed the supported V8 argument-count limit', () => {
const bytes = createDeterministicPseudorandomBytes(200_000);
const attributeValue = Buffer.from(bytes).toString('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
33 changes: 26 additions & 7 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 @@ -195,8 +207,15 @@ nve-logo.large {
function serialize(data: Record<string, { content: string }>, compress = true) {
const encoded = new TextEncoder().encode(JSON.stringify(data));
const array = compress ? gzipSync(encoded) : encoded;
const base64 = globalThis.btoa(String.fromCharCode(...array));
return encodeURIComponent(base64);
// Limit each function call to 32,768 arguments, safely below engine limits.
const chunkSize = 0x8000;
let binary = '';

for (let offset = 0; offset < array.length; offset += chunkSize) {
binary += String.fromCharCode(...array.subarray(offset, offset + chunkSize));
}

return encodeURIComponent(globalThis.btoa(binary));
}

function createIndexHTML(content: string, options: PlaygroundOptions) {
Expand Down