diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 38b9e74be..17942bb6a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/projects/internals/tools/src/playground/service.test.ts b/projects/internals/tools/src/playground/service.test.ts
index 66e4ea009..bcc6c243c 100644
--- a/projects/internals/tools/src/playground/service.test.ts
+++ b/projects/internals/tools/src/playground/service.test.ts
@@ -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;
@@ -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: 'valid',
+ name: 'x'.repeat(MAX_PLAYGROUND_URL_LENGTH),
+ start: false
+ })) as ToolOutput;
+
+ 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({
diff --git a/projects/internals/tools/src/playground/utils.test.ts b/projects/internals/tools/src/playground/utils.test.ts
index 95f90b891..5c327049f 100644
--- a/projects/internals/tools/src/playground/utils.test.ts
+++ b/projects/internals/tools/src/playground/utils.test.ts
@@ -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,
@@ -12,6 +13,7 @@ import {
createVueFiles,
createDefaultFiles,
formatTemplate,
+ MAX_PLAYGROUND_URL_LENGTH,
playgroundTypes
} from './utils.js';
@@ -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[] = [
{
@@ -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('', [], {});
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 = `content
`;
+
+ 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', () => {
diff --git a/projects/internals/tools/src/playground/utils.ts b/projects/internals/tools/src/playground/utils.ts
index 3c07ade9e..949a12669 100644
--- a/projects/internals/tools/src/playground/utils.ts
+++ b/projects/internals/tools/src/playground/utils.ts
@@ -4,6 +4,7 @@
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';
@@ -11,6 +12,7 @@ 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;
@@ -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() {
@@ -195,8 +207,15 @@ nve-logo.large {
function serialize(data: Record, 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) {