mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
fix(html): HTML SSR safety and sandbox skin chunking (#1155)
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
tooltipState,
|
||||
} from '@videojs/skins/minimal/tailwind/audio.tailwind';
|
||||
import { cn } from '@videojs/utils/style';
|
||||
import { safeDefine } from '../safe-define';
|
||||
import { SkinMixin } from '../skin-mixin';
|
||||
|
||||
// Side-effect imports: register all custom elements used in the template.
|
||||
@@ -127,7 +128,7 @@ export class MinimalAudioSkinTailwindElement extends SkinMixin(ReactiveElement)
|
||||
static getTemplateHTML = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define(MinimalAudioSkinTailwindElement.tagName, MinimalAudioSkinTailwindElement);
|
||||
safeDefine(MinimalAudioSkinTailwindElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactiveElement } from '@videojs/element';
|
||||
import { renderIcon } from '@videojs/icons/render/minimal';
|
||||
import { safeDefine } from '../safe-define';
|
||||
import { createStyles, SkinMixin } from '../skin-mixin';
|
||||
import styles from './minimal-skin.css?inline';
|
||||
|
||||
@@ -110,7 +111,7 @@ export class MinimalAudioSkinElement extends SkinMixin(ReactiveElement) {
|
||||
static getTemplateHTML = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define(MinimalAudioSkinElement.tagName, MinimalAudioSkinElement);
|
||||
safeDefine(MinimalAudioSkinElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
tooltipState,
|
||||
} from '@videojs/skins/default/tailwind/audio.tailwind';
|
||||
import { cn } from '@videojs/utils/style';
|
||||
import { safeDefine } from '../safe-define';
|
||||
import { SkinMixin } from '../skin-mixin';
|
||||
|
||||
// Side-effect imports: register all custom elements used in the template.
|
||||
@@ -122,7 +123,7 @@ export class AudioSkinTailwindElement extends SkinMixin(ReactiveElement) {
|
||||
static getTemplateHTML = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define(AudioSkinTailwindElement.tagName, AudioSkinTailwindElement);
|
||||
safeDefine(AudioSkinTailwindElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactiveElement } from '@videojs/element';
|
||||
import { renderIcon } from '@videojs/icons/render';
|
||||
import { safeDefine } from '../safe-define';
|
||||
import { createStyles, SkinMixin } from '../skin-mixin';
|
||||
import styles from './skin.css?inline';
|
||||
|
||||
@@ -105,7 +106,7 @@ export class AudioSkinElement extends SkinMixin(ReactiveElement) {
|
||||
static getTemplateHTML = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define(AudioSkinElement.tagName, AudioSkinElement);
|
||||
safeDefine(AudioSkinElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { SimpleHlsVideo } from '../../media/simple-hls-video';
|
||||
import { safeDefine } from '../safe-define';
|
||||
|
||||
export class SimpleHlsVideoElement extends SimpleHlsVideo {
|
||||
static readonly tagName = 'simple-hls-video';
|
||||
}
|
||||
|
||||
customElements.define(SimpleHlsVideoElement.tagName, SimpleHlsVideoElement);
|
||||
safeDefine(SimpleHlsVideoElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
@@ -2,7 +2,8 @@ type DefinableElement = CustomElementConstructor & { tagName: string };
|
||||
|
||||
/** Define a custom element only if not already registered. */
|
||||
export function safeDefine(element: DefinableElement): void {
|
||||
if (!customElements.get(element.tagName)) {
|
||||
customElements.define(element.tagName, element);
|
||||
}
|
||||
const registry = globalThis.customElements;
|
||||
if (!registry || registry.get(element.tagName)) return;
|
||||
|
||||
registry.define(element.tagName, element);
|
||||
}
|
||||
|
||||
@@ -4,17 +4,45 @@ import rootStyles from './base.css?inline';
|
||||
import sharedStyles from './shared.css?inline';
|
||||
|
||||
const STYLES_ID = '__media-styles';
|
||||
type SkinStyles = CSSStyleSheet | string;
|
||||
|
||||
function ensureRootStyles(): void {
|
||||
if (document.getElementById(STYLES_ID)) return;
|
||||
const style = document.createElement('style');
|
||||
const doc = globalThis.document;
|
||||
if (!doc || doc.getElementById(STYLES_ID)) return;
|
||||
|
||||
const style = doc.createElement('style');
|
||||
style.id = STYLES_ID;
|
||||
style.textContent = rootStyles;
|
||||
document.head.appendChild(style);
|
||||
doc.head.appendChild(style);
|
||||
}
|
||||
|
||||
const sharedSheet = new CSSStyleSheet();
|
||||
sharedSheet.replaceSync(sharedStyles);
|
||||
function isConstructableStyleSheet(value: SkinStyles): value is CSSStyleSheet {
|
||||
return typeof globalThis.CSSStyleSheet !== 'undefined' && value instanceof globalThis.CSSStyleSheet;
|
||||
}
|
||||
|
||||
function getStyleText(style: SkinStyles): string {
|
||||
if (typeof style === 'string') return style;
|
||||
|
||||
return Array.from(style.cssRules)
|
||||
.map((rule) => rule.cssText)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function applyShadowStyles(shadowRoot: ShadowRoot, styles: SkinStyles[]): void {
|
||||
if (styles.every(isConstructableStyleSheet) && 'adoptedStyleSheets' in shadowRoot) {
|
||||
shadowRoot.adoptedStyleSheets = styles;
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = shadowRoot.ownerDocument;
|
||||
for (const styleText of styles.map(getStyleText)) {
|
||||
const style = doc.createElement('style');
|
||||
style.textContent = styleText;
|
||||
shadowRoot.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
const sharedSheet = createStyles(sharedStyles);
|
||||
|
||||
/**
|
||||
* Mixin for skin elements that renders the template from a static
|
||||
@@ -26,10 +54,10 @@ sharedSheet.replaceSync(sharedStyles);
|
||||
*/
|
||||
export function SkinMixin<Base extends Constructor<ReactiveElement>>(
|
||||
BaseClass: Base
|
||||
): Base & { shadowRootOptions: ShadowRootInit; styles?: CSSStyleSheet } {
|
||||
): Base & { shadowRootOptions: ShadowRootInit; styles?: SkinStyles } {
|
||||
class SkinElement extends (BaseClass as Constructor<ReactiveElement>) {
|
||||
static shadowRootOptions: ShadowRootInit = { mode: 'open' };
|
||||
static styles?: CSSStyleSheet;
|
||||
static styles?: SkinStyles;
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
@@ -40,25 +68,29 @@ export function SkinMixin<Base extends Constructor<ReactiveElement>>(
|
||||
const ctor = this.constructor as typeof SkinElement & { getTemplateHTML?: () => string };
|
||||
this.attachShadow(ctor.shadowRootOptions);
|
||||
|
||||
const sheets: CSSStyleSheet[] = [sharedSheet];
|
||||
if (ctor.styles) {
|
||||
sheets.push(ctor.styles);
|
||||
}
|
||||
this.shadowRoot!.adoptedStyleSheets = sheets;
|
||||
|
||||
if (ctor.getTemplateHTML) {
|
||||
this.shadowRoot!.innerHTML = ctor.getTemplateHTML();
|
||||
}
|
||||
|
||||
const sheets: SkinStyles[] = [sharedSheet];
|
||||
if (ctor.styles) {
|
||||
sheets.push(ctor.styles);
|
||||
}
|
||||
applyShadowStyles(this.shadowRoot!, sheets);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SkinElement as unknown as Base & { shadowRootOptions: ShadowRootInit; styles?: CSSStyleSheet };
|
||||
return SkinElement as unknown as Base & { shadowRootOptions: ShadowRootInit; styles?: SkinStyles };
|
||||
}
|
||||
|
||||
/** Create a shared `CSSStyleSheet` from a CSS string. */
|
||||
export function createStyles(css: string): CSSStyleSheet {
|
||||
const sheet = new CSSStyleSheet();
|
||||
/** Create a constructable stylesheet when available, otherwise return raw CSS. */
|
||||
export function createStyles(css: string): SkinStyles {
|
||||
if (typeof globalThis.CSSStyleSheet === 'undefined') {
|
||||
return css;
|
||||
}
|
||||
|
||||
const sheet = new globalThis.CSSStyleSheet();
|
||||
sheet.replaceSync(css);
|
||||
return sheet;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { safeDefine } from '../safe-define';
|
||||
|
||||
describe('safeDefine', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('registers a custom element', () => {
|
||||
class TestElement extends HTMLElement {
|
||||
static tagName = 'test-sd-register';
|
||||
@@ -33,4 +37,14 @@ describe('safeDefine', () => {
|
||||
safeDefine(Replacement);
|
||||
expect(customElements.get('test-sd-no-replace')).toBe(Original);
|
||||
});
|
||||
|
||||
it('does nothing when customElements is unavailable', () => {
|
||||
vi.stubGlobal('customElements', undefined);
|
||||
|
||||
class TestElement extends HTMLElement {
|
||||
static tagName = 'test-sd-ssr';
|
||||
}
|
||||
|
||||
expect(() => safeDefine(TestElement)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('SSR-safe define imports', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('imports video skin without browser-only globals', async () => {
|
||||
vi.stubGlobal('customElements', undefined);
|
||||
vi.stubGlobal('CSSStyleSheet', undefined);
|
||||
|
||||
await expect(import('../video/skin')).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('imports simple-hls-video without customElements', async () => {
|
||||
vi.stubGlobal('customElements', undefined);
|
||||
|
||||
await expect(import('../media/simple-hls-video')).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element';
|
||||
import { safeDefine } from '../safe-define';
|
||||
|
||||
customElements.define(CaptionsButtonElement.tagName, CaptionsButtonElement);
|
||||
safeDefine(CaptionsButtonElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
tooltipState,
|
||||
} from '@videojs/skins/minimal/tailwind/video.tailwind';
|
||||
import { cn } from '@videojs/utils/style';
|
||||
import { safeDefine } from '../safe-define';
|
||||
import { SkinMixin } from '../skin-mixin';
|
||||
|
||||
// Side-effect imports: register all custom elements used in the template.
|
||||
@@ -189,7 +190,7 @@ export class MinimalVideoSkinTailwindElement extends SkinMixin(ReactiveElement)
|
||||
static getTemplateHTML = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define(MinimalVideoSkinTailwindElement.tagName, MinimalVideoSkinTailwindElement);
|
||||
safeDefine(MinimalVideoSkinTailwindElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactiveElement } from '@videojs/element';
|
||||
import { renderIcon } from '@videojs/icons/render/minimal';
|
||||
import { safeDefine } from '../safe-define';
|
||||
import { createStyles, SkinMixin } from '../skin-mixin';
|
||||
import styles from './minimal-skin.css?inline';
|
||||
|
||||
@@ -158,7 +159,7 @@ export class MinimalVideoSkinElement extends SkinMixin(ReactiveElement) {
|
||||
static getTemplateHTML = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define(MinimalVideoSkinElement.tagName, MinimalVideoSkinElement);
|
||||
safeDefine(MinimalVideoSkinElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
tooltipState,
|
||||
} from '@videojs/skins/default/tailwind/video.tailwind';
|
||||
import { cn } from '@videojs/utils/style';
|
||||
import { safeDefine } from '../safe-define';
|
||||
import { SkinMixin } from '../skin-mixin';
|
||||
|
||||
// Side-effect imports: register all custom elements used in the template.
|
||||
@@ -184,7 +185,7 @@ export class VideoSkinTailwindElement extends SkinMixin(ReactiveElement) {
|
||||
static getTemplateHTML = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define(VideoSkinTailwindElement.tagName, VideoSkinTailwindElement);
|
||||
safeDefine(VideoSkinTailwindElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactiveElement } from '@videojs/element';
|
||||
import { renderIcon } from '@videojs/icons/render';
|
||||
import { safeDefine } from '../safe-define';
|
||||
import { createStyles, SkinMixin } from '../skin-mixin';
|
||||
import styles from './skin.css?inline';
|
||||
|
||||
@@ -156,7 +157,7 @@ export class VideoSkinElement extends SkinMixin(ReactiveElement) {
|
||||
static getTemplateHTML = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define(VideoSkinElement.tagName, VideoSkinElement);
|
||||
safeDefine(VideoSkinElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
Reference in New Issue
Block a user