From cc04cc99a594cdace44b258980200241ec419c27 Mon Sep 17 00:00:00 2001 From: rahim Date: Thu, 19 Mar 2026 02:12:13 -0700 Subject: [PATCH] fix(html): extended media not working over cdn (#1019) --- packages/html/package.json | 13 +- packages/html/src/cdn/background.ts | 1 + packages/html/tsdown.cdn.config.ts | 107 ++++++++------ packages/sandbox/app/constants.ts | 2 +- packages/sandbox/app/shell/app.tsx | 10 +- packages/sandbox/app/shell/navbar.tsx | 3 +- packages/sandbox/app/shell/preview.tsx | 9 +- packages/sandbox/templates/cdn/index.html | 14 ++ packages/sandbox/templates/cdn/main.ts | 162 ++++++++++++++++++++++ turbo.json | 2 +- 10 files changed, 272 insertions(+), 51 deletions(-) create mode 100644 packages/sandbox/templates/cdn/index.html create mode 100644 packages/sandbox/templates/cdn/main.ts diff --git a/packages/html/package.json b/packages/html/package.json index cc7beb90..d09936a8 100644 --- a/packages/html/package.json +++ b/packages/html/package.json @@ -13,7 +13,8 @@ "module": "dist/default/index.js", "types": "dist/dev/index.d.ts", "sideEffects": [ - "./dist/*/define/**/*.js" + "./dist/*/define/**/*.js", + "./cdn/**/*.js" ], "files": [ "dist", @@ -73,6 +74,16 @@ "development": "./dist/dev/define/media/*.js", "default": "./dist/default/define/media/*.js" }, + "./cdn/media/*": { + "types": "./cdn/media/*.dev.d.ts", + "development": "./cdn/media/*.dev.js", + "default": "./cdn/media/*.js" + }, + "./cdn/*": { + "types": "./cdn/*.dev.d.ts", + "development": "./cdn/*.dev.js", + "default": "./cdn/*.js" + }, "./*.css": "./dist/default/define/*.css", "./*": { "types": "./dist/dev/define/*.d.ts", diff --git a/packages/html/src/cdn/background.ts b/packages/html/src/cdn/background.ts index ac1ccf74..a590e197 100644 --- a/packages/html/src/cdn/background.ts +++ b/packages/html/src/cdn/background.ts @@ -1,2 +1,3 @@ import '../define/background/player'; import '../define/background/skin'; +import '../define/background/video'; diff --git a/packages/html/tsdown.cdn.config.ts b/packages/html/tsdown.cdn.config.ts index d682a1e6..a517f541 100644 --- a/packages/html/tsdown.cdn.config.ts +++ b/packages/html/tsdown.cdn.config.ts @@ -1,3 +1,4 @@ +import { readdirSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { UserConfig } from 'tsdown'; @@ -12,7 +13,7 @@ const skinsDir = resolve(dirname(fileURLToPath(import.meta.url)), '../skins/src' const buildModes: BuildMode[] = ['dev', 'prod']; const presets = ['video', 'video-minimal', 'audio', 'audio-minimal', 'background']; -const media = ['hls-video', 'simple-hls-video']; +const media = ['hls-video', 'simple-hls-video', 'dash-video']; const entries = [ ...presets.map((name) => ({ src: `src/cdn/${name}.ts`, name })), @@ -20,50 +21,78 @@ const entries = [ ]; /** - * One config per entry per mode → each output is fully self-contained. - * Multiple entries in a single config causes rolldown to code-split shared - * modules into chunks, which breaks the single-file CDN bundle requirement. + * Rolldown plugin that generates empty `.d.ts` stubs for dev CDN entry points. + * CDN entries are side-effect-only modules with no exports — the stubs let + * TypeScript resolve `import '@videojs/html/cdn/...'` without errors. + */ +function dtsStubsPlugin(outDir: string) { + function generate(dir: string) { + for (const file of readdirSync(dir, { withFileTypes: true })) { + if (file.isDirectory()) { + generate(resolve(dir, file.name)); + } else if (file.name.endsWith('.dev.js') && !file.name.endsWith('.dev.js.map')) { + writeFileSync(resolve(dir, file.name.replace('.dev.js', '.dev.d.ts')), 'export {};\n'); + } + } + } + + return { + name: 'cdn-dts-stubs', + writeBundle() { + generate(outDir); + }, + }; +} + +/** + * One config per mode with all entries grouped together. + * This lets rolldown extract shared modules (store, element, core, hls.js, etc.) + * into shared chunks instead of duplicating them across every bundle. + * The ES module loader handles chunk deduplication transparently. */ const configs: UserConfig[] = []; +const outDir = 'cdn'; + for (const mode of buildModes) { - for (const { src, name } of entries) { - const outName = mode === 'dev' ? `${name}.dev` : name; + const isProd = mode === 'prod'; - const isProd = mode === 'prod'; + const entryMap = Object.fromEntries(entries.map(({ src, name }) => [isProd ? name : `${name}.dev`, src])); - configs.push({ - entry: { [outName]: src }, - platform: 'browser', - format: 'es', - target: 'es2022', - sourcemap: true, - clean: false, - dts: false, - minify: isProd, - noExternal: [/.*/], - inlineOnly: false, - treeshake: { - moduleSideEffects: [{ test: /\/define\//, sideEffects: true }], - }, - outDir: 'cdn', - alias: { - '@': new URL('./src', import.meta.url).pathname, - }, - define: { - __DEV__: isProd ? 'false' : 'true', - }, - plugins: [inlineCssPlugin({ skinsDir, minify: isProd }), inlineTemplatePlugin({ minify: isProd })], - inputOptions: - mode === 'dev' - ? { - resolve: { - conditionNames: ['development', 'import', 'browser', 'default'], - }, - } - : undefined, - }); - } + configs.push({ + entry: entryMap, + platform: 'browser', + format: 'es', + target: 'es2022', + sourcemap: true, + clean: mode === 'dev', + dts: false, + minify: isProd, + noExternal: [/.*/], + inlineOnly: false, + treeshake: { + moduleSideEffects: [{ test: /\/define\//, sideEffects: true }], + }, + outDir, + alias: { + '@': new URL('./src', import.meta.url).pathname, + }, + define: { + __DEV__: isProd ? 'false' : 'true', + }, + plugins: [ + inlineCssPlugin({ skinsDir, minify: isProd }), + inlineTemplatePlugin({ minify: isProd }), + ...(!isProd ? [dtsStubsPlugin(outDir)] : []), + ], + inputOptions: !isProd + ? { + resolve: { + conditionNames: ['development', 'import', 'browser', 'default'], + }, + } + : undefined, + }); } export default defineConfig(configs); diff --git a/packages/sandbox/app/constants.ts b/packages/sandbox/app/constants.ts index a9b3a51e..a9bb3d91 100644 --- a/packages/sandbox/app/constants.ts +++ b/packages/sandbox/app/constants.ts @@ -1,4 +1,4 @@ export const SKINS = ['default', 'minimal'] as const; -export const PLATFORMS = ['html', 'react'] as const; +export const PLATFORMS = ['html', 'react', 'cdn'] as const; export const STYLINGS = ['css', 'tailwind'] as const; export const PRESETS = ['video', 'hls-video', 'simple-hls-video', 'dash-video', 'audio', 'background-video'] as const; diff --git a/packages/sandbox/app/shell/app.tsx b/packages/sandbox/app/shell/app.tsx index 4f91ac78..6e7cbdec 100644 --- a/packages/sandbox/app/shell/app.tsx +++ b/packages/sandbox/app/shell/app.tsx @@ -14,6 +14,7 @@ import { Navbar } from './navbar'; import { Preview } from './preview'; function getPagePath(platform: Platform, preset: Preset): string { + if (platform === 'cdn') return '/cdn/'; if (preset === 'background-video') return `/${platform}-background-video/`; return `/${platform}-${preset}/`; } @@ -68,12 +69,12 @@ export function App() { } }, [preset, source, setSource]); - // Background video does not have a Tailwind skin variant. + // CDN and background video do not have a Tailwind skin variant. useEffect(() => { - if (preset === 'background-video' && styling === 'tailwind') { + if ((platform === 'cdn' || preset === 'background-video') && styling === 'tailwind') { setStyling('css'); } - }, [preset, styling]); + }, [platform, preset, styling]); const availableSources = preset === 'audio' ? MP4_SOURCE_IDS : preset === 'dash-video' ? DASH_SOURCE_IDS : SOURCE_IDS; @@ -101,9 +102,10 @@ export function App() { sources={SOURCES} /> = { html: 'HTML', react: 'React', + cdn: 'CDN', }; const PRESET_LABELS: Record = { @@ -80,7 +81,7 @@ export function Navbar({ options={stylings.map((s) => ({ value: s, label: s === 'css' ? 'CSS' : 'Tailwind', - disabled: s === 'tailwind' && isBackgroundVideo, + disabled: s === 'tailwind' && (isBackgroundVideo || platform === 'cdn'), }))} /> diff --git a/packages/sandbox/app/shell/preview.tsx b/packages/sandbox/app/shell/preview.tsx index 9442ed16..28691ba1 100644 --- a/packages/sandbox/app/shell/preview.tsx +++ b/packages/sandbox/app/shell/preview.tsx @@ -1,24 +1,25 @@ import type { SourceId } from '@app/shared/sources'; -import type { Skin, Styling } from '@app/types'; +import type { Preset, Skin, Styling } from '@app/types'; import { forwardRef, useState } from 'react'; type PreviewProps = { pagePath: string; + preset: Preset; skin: Skin; styling: Styling; source: SourceId; }; export const Preview = forwardRef(function Preview( - { pagePath, skin, styling, source }, + { pagePath, preset, skin, styling, source }, ref ) { const [iframeUrl] = useState( () => - `${pagePath}?skin=${encodeURIComponent(skin)}&styling=${encodeURIComponent(styling)}&source=${encodeURIComponent(source)}` + `${pagePath}?preset=${encodeURIComponent(preset)}&skin=${encodeURIComponent(skin)}&styling=${encodeURIComponent(styling)}&source=${encodeURIComponent(source)}` ); const openUrl = - `${pagePath}?skin=${encodeURIComponent(skin)}&styling=${encodeURIComponent(styling)}` + + `${pagePath}?preset=${encodeURIComponent(preset)}&skin=${encodeURIComponent(skin)}&styling=${encodeURIComponent(styling)}` + `&source=${encodeURIComponent(source)}`; return ( diff --git a/packages/sandbox/templates/cdn/index.html b/packages/sandbox/templates/cdn/index.html new file mode 100644 index 00000000..1db8639e --- /dev/null +++ b/packages/sandbox/templates/cdn/index.html @@ -0,0 +1,14 @@ + + + + + + Sandbox — CDN + + + + +
+ + + diff --git a/packages/sandbox/templates/cdn/main.ts b/packages/sandbox/templates/cdn/main.ts new file mode 100644 index 00000000..6e4ca7bf --- /dev/null +++ b/packages/sandbox/templates/cdn/main.ts @@ -0,0 +1,162 @@ +import '@app/styles.css'; +import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state'; +import { CSS_SKIN_TAGS } from '@app/shared/html/skin-tags'; +import { renderStoryboard } from '@app/shared/html/storyboard'; +import { loadAudioStylesheets, loadVideoStylesheets } from '@app/shared/html/stylesheets'; +import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener'; +import { BACKGROUND_VIDEO_SRC, getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources'; +import type { Preset, Skin } from '@app/types'; + +const html = String.raw; + +const params = new URLSearchParams(location.search); +const preset = (params.get('preset') ?? 'video') as Preset; + +const state = createHtmlSandboxState(preset === 'audio'); +const loadLatest = createLatestLoader(); + +// --------------------------------------------------------------------------- +// CDN module loading — mirrors the exact import graph of each CDN bundle. +// --------------------------------------------------------------------------- + +async function loadCdnPreset(preset: Preset, skin: Skin) { + switch (preset) { + case 'video': + case 'hls-video': + case 'simple-hls-video': + case 'dash-video': + if (skin === 'minimal') await import('@videojs/html/cdn/video-minimal'); + else await import('@videojs/html/cdn/video'); + break; + case 'audio': + if (skin === 'minimal') await import('@videojs/html/cdn/audio-minimal'); + else await import('@videojs/html/cdn/audio'); + break; + case 'background-video': + await import('@videojs/html/background/skin.css'); + await import('@videojs/html/cdn/background'); + break; + } +} + +async function loadCdnMedia(preset: Preset) { + switch (preset) { + case 'hls-video': + await import('@videojs/html/cdn/media/hls-video'); + break; + case 'simple-hls-video': + await import('@videojs/html/cdn/media/simple-hls-video'); + break; + case 'dash-video': + await import('@videojs/html/cdn/media/dash-video'); + break; + } +} + +// --------------------------------------------------------------------------- +// Rendering — produces the exact HTML markup the installation builder generates. +// --------------------------------------------------------------------------- + +function getPlayerTag(preset: Preset): string { + if (preset === 'background-video') return 'background-video-player'; + if (preset === 'audio') return 'audio-player'; + return 'video-player'; +} + +function getSkinTag(preset: Preset, skin: Skin): string { + if (preset === 'background-video') return 'background-video-skin'; + if (preset === 'audio') return CSS_SKIN_TAGS[skin].audio; + return CSS_SKIN_TAGS[skin].video; +} + +function getMediaTag(preset: Preset): string { + const tags: Partial> = { + 'hls-video': 'hls-video', + 'simple-hls-video': 'simple-hls-video', + 'dash-video': 'dash-video', + audio: 'audio', + 'background-video': 'background-video', + }; + + return tags[preset] ?? 'video'; +} + +function loadStylesheets(preset: Preset, skin: Skin) { + if (preset === 'audio') loadAudioStylesheets(skin); + else if (preset !== 'background-video') loadVideoStylesheets(skin); + // Background CSS is loaded via dynamic import in loadCdnPreset. +} + +function isVideoPreset(preset: Preset): boolean { + return preset === 'video' || preset === 'hls-video' || preset === 'simple-hls-video' || preset === 'dash-video'; +} + +async function render() { + await loadLatest(async () => { + await loadCdnPreset(preset, state.skin); + await loadCdnMedia(preset); + }); + + loadStylesheets(preset, state.skin); + + const root = document.getElementById('root')!; + const playerTag = getPlayerTag(preset); + const skinTag = getSkinTag(preset, state.skin); + const mediaTag = getMediaTag(preset); + const src = preset === 'background-video' ? BACKGROUND_VIDEO_SRC : SOURCES[state.source].url; + const storyboard = isVideoPreset(preset) ? getStoryboardSrc(state.source) : undefined; + const poster = isVideoPreset(preset) ? getPosterSrc(state.source) : undefined; + + // Background video needs viewport dimensions instead of flex centering. + if (preset === 'background-video') { + root.className = ''; + root.style.cssText = 'width: 100vw; height: 100vh;'; + } + + if (preset === 'background-video') { + root.innerHTML = html` + <${playerTag}> + <${skinTag}> + <${mediaTag} src="${src}"> + + + `; + return; + } + + if (preset === 'audio') { + root.innerHTML = html` +
+ <${playerTag}> + <${skinTag}> + <${mediaTag} src="${src}"> + + +
+ `; + return; + } + + root.innerHTML = html` + <${playerTag}> + <${skinTag} class="w-full aspect-video max-w-4xl mx-auto"> + <${mediaTag} src="${src}" playsinline crossorigin="anonymous"> + ${renderStoryboard(storyboard)} + + ${poster ? html`Video poster` : ''} + + + `; +} + +render(); + +onSkinChange((skin) => { + state.skin = skin; + render(); +}); + +onSourceChange((source) => { + state.source = source; + render(); +}); diff --git a/turbo.json b/turbo.json index e4032f1c..9c7edde3 100644 --- a/turbo.json +++ b/turbo.json @@ -5,7 +5,7 @@ "globalEnv": ["CONTEXT", "DEPLOY_PRIME_URL"], "tasks": { "build": { - "dependsOn": ["^build"], + "dependsOn": ["^build", "^build:cdn"], "outputs": ["dist/**", ".netlify/**"] }, "build:cdn": {