From 3331fdaf25c8a89ea6d36c2972631df589fc0ad3 Mon Sep 17 00:00:00 2001 From: rahim Date: Thu, 16 Apr 2026 00:59:14 -0700 Subject: [PATCH] fix(packages): add server-only bundles (#1349) --- .github/scripts/bundle-size.js | 11 +- .github/scripts/ssr-check.js | 170 ++++++++++++++++++ .github/workflows/ci.yml | 3 + CLAUDE.md | 3 + build/scripts/check-workspace.mjs | 70 ++++++++ package.json | 6 +- packages/core/package.json | 9 +- packages/core/src/dom/globals.d.ts | 1 + .../{index.ts => browser.ts} | 0 .../dom/media/custom-media-element/server.ts | 7 + .../tests/custom-media-element.test.ts | 2 +- .../dom/media/dash/{index.ts => browser.ts} | 0 packages/core/src/dom/media/dash/server.ts | 6 + .../dom/media/dash/tests/dash-media.test.ts | 2 +- .../dom/media/hls/{index.ts => browser.ts} | 32 ++-- packages/core/src/dom/media/hls/server.ts | 13 ++ .../src/dom/media/hls/tests/hls-media.test.ts | 2 +- packages/core/src/dom/media/hls/types.ts | 21 +++ .../dom/media/mux/{index.ts => browser.ts} | 2 +- packages/core/src/dom/media/mux/mux-data.ts | 4 +- packages/core/src/dom/media/mux/server.ts | 9 + .../src/dom/media/mux/tests/index.test.ts | 2 +- .../media/native-hls/{index.ts => browser.ts} | 0 .../core/src/dom/media/native-hls/server.ts | 7 + .../tests/native-hls-custom-media.test.ts | 2 +- .../media/simple-hls/{index.ts => browser.ts} | 0 .../core/src/dom/media/simple-hls/server.ts | 6 + packages/core/src/dom/ui/dismiss-layer.ts | 2 +- packages/core/src/globals.d.ts | 1 + packages/core/tsdown.config.ts | 30 ++-- packages/core/vitest.config.ts | 1 + packages/element/src/reactive-element.ts | 7 +- packages/html/package.json | 77 +++++--- packages/html/src/define/safe-define.ts | 6 +- .../html/src/define/tests/safe-define.test.ts | 16 +- .../html/src/define/tests/ssr-safety.test.ts | 21 --- packages/html/src/globals.d.ts | 1 + packages/html/tsdown.cdn.config.ts | 1 + packages/html/tsdown.config.ts | 65 +++++-- packages/html/vitest.config.ts | 4 +- packages/react/package.json | 56 ++++-- packages/react/src/globals.d.ts | 1 + packages/react/tsdown.config.ts | 16 +- packages/react/vitest.config.ts | 3 +- packages/spf/src/globals.d.ts | 1 + packages/store/src/core/globals.d.ts | 1 + packages/store/src/globals.d.ts | 1 + packages/store/src/html/globals.d.ts | 1 + packages/store/src/react/globals.d.ts | 1 + packages/utils/package.json | 3 +- packages/utils/src/dom/globals.d.ts | 1 + packages/utils/src/dom/platform.ts | 2 +- packages/utils/src/dom/shadow-styles.ts | 14 +- packages/utils/src/dom/supports.ts | 8 +- packages/utils/src/dom/template.ts | 5 +- .../utils/src/dom/tests/shadow-styles.test.ts | 6 - packages/utils/src/dom/tests/template.test.ts | 11 +- packages/utils/tsdown.config.ts | 48 +++-- packages/utils/vitest.config.ts | 3 + pnpm-lock.yaml | 65 ++++--- 60 files changed, 654 insertions(+), 215 deletions(-) create mode 100644 .github/scripts/ssr-check.js rename packages/core/src/dom/media/custom-media-element/{index.ts => browser.ts} (100%) create mode 100644 packages/core/src/dom/media/custom-media-element/server.ts rename packages/core/src/dom/media/dash/{index.ts => browser.ts} (100%) create mode 100644 packages/core/src/dom/media/dash/server.ts rename packages/core/src/dom/media/hls/{index.ts => browser.ts} (84%) create mode 100644 packages/core/src/dom/media/hls/server.ts rename packages/core/src/dom/media/mux/{index.ts => browser.ts} (91%) create mode 100644 packages/core/src/dom/media/mux/server.ts rename packages/core/src/dom/media/native-hls/{index.ts => browser.ts} (100%) create mode 100644 packages/core/src/dom/media/native-hls/server.ts rename packages/core/src/dom/media/simple-hls/{index.ts => browser.ts} (100%) create mode 100644 packages/core/src/dom/media/simple-hls/server.ts delete mode 100644 packages/html/src/define/tests/ssr-safety.test.ts diff --git a/.github/scripts/bundle-size.js b/.github/scripts/bundle-size.js index 16dcc5e2..3366dbd6 100644 --- a/.github/scripts/bundle-size.js +++ b/.github/scripts/bundle-size.js @@ -163,6 +163,7 @@ async function measure(entryPoints, external = []) { write: false, outdir: '/tmp/bundle-size-out', external, + conditions: ['browser'], logLevel: 'silent', }); @@ -199,6 +200,7 @@ async function measureVirtual(code, resolveDir, external = []) { write: false, outdir: '/tmp/bundle-size-out', external, + conditions: ['browser'], logLevel: 'silent', }); @@ -213,12 +215,17 @@ async function measureVirtual(code, resolveDir, external = []) { } /** - * Resolve the `default` condition from an export value. - * Handles both `{ default: "./dist/..." }` objects and plain string values. + * Resolve the browser production path from an export value. + * + * Prefers `browser.default` (browser prod bundle) over top-level `default` + * (server bundle) so we measure the code consumers actually ship. */ function resolveDefault(exportValue) { if (typeof exportValue === 'string') return exportValue; if (typeof exportValue === 'object' && exportValue !== null) { + if (typeof exportValue.browser === 'object' && exportValue.browser !== null) { + return exportValue.browser.default ?? exportValue.default ?? null; + } return exportValue.default ?? null; } return null; diff --git a/.github/scripts/ssr-check.js b/.github/scripts/ssr-check.js new file mode 100644 index 00000000..3e884ea3 --- /dev/null +++ b/.github/scripts/ssr-check.js @@ -0,0 +1,170 @@ +/** + * Validates that all server entry points are safe to import in a non-browser + * environment. + * + * Auto-discovers packages from `packages/`, reads their `exports` field to find + * entries with a `browser`/`default` condition split, resolves the `default` + * (server) path, and dynamically imports each one. Any module that throws + * during evaluation (e.g. due to missing browser globals) is reported. + * + * Requires packages to be built first (`pnpm build:packages`). + * + * Usage: node .github/scripts/ssr-check.js + */ + +import { + existsSync, + readFileSync, + readdirSync, +} from 'node:fs'; +import { resolve, join } from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const ROOT = resolve(__dirname, '../..'); +const PACKAGES_DIR = join(ROOT, 'packages'); + +const SKIP_PACKAGES = new Set([ + 'react-native', + 'skins', + 'icons', +]); + +/** + * Resolve the server path from an export value. + * + * Only returns a path for entries with a `browser`/`default` split (these are + * the ones that have separate server builds). The top-level `default` is the + * server entry point. + */ +function resolveServerPath(exportValue) { + if (typeof exportValue !== 'object' || exportValue === null) return null; + if (!('browser' in exportValue)) return null; + return typeof exportValue.default === 'string' ? exportValue.default : null; +} + +/** + * Resolve a wildcard export key to actual server files on disk. + */ +function resolveWildcard(pkgDir, exportKey, exportValue) { + const serverPath = resolveServerPath(exportValue); + if (!serverPath) return []; + + const fullPattern = resolve(pkgDir, serverPath); + const starIdx = fullPattern.indexOf('*'); + if (starIdx === -1) return []; + + const lastSlash = fullPattern.lastIndexOf('/', starIdx); + const scanDir = fullPattern.slice(0, lastSlash); + const prefix = fullPattern.slice(lastSlash + 1, starIdx); + const suffix = fullPattern.slice(starIdx + 1); + + if (!existsSync(scanDir)) return []; + + const isDirectoryPattern = suffix.startsWith('/'); + + return readdirSync(scanDir, { withFileTypes: true }) + .filter((d) => { + if (!d.name.startsWith(prefix)) return false; + return isDirectoryPattern ? d.isDirectory() : d.isFile(); + }) + .filter((d) => { + if (isDirectoryPattern) return true; + return d.name.endsWith(suffix); + }) + .map((d) => { + const stem = isDirectoryPattern + ? d.name.slice(prefix.length) + : d.name.slice(prefix.length, d.name.length - suffix.length); + const fullPath = fullPattern.replace('*', stem); + return { stem, fullPath }; + }) + .filter(({ fullPath }) => { + if (!existsSync(fullPath)) return false; + return readFileSync(fullPath, 'utf8').trim().length > 0; + }) + .sort((a, b) => a.stem.localeCompare(b.stem)) + .map(({ stem, fullPath }) => ({ + label: `${exportKey.replace('*', stem)}`, + path: fullPath, + })); +} + +/** Discover all server entry points across packages. */ +function discoverServerEntries() { + const entries = []; + + for (const dirName of readdirSync(PACKAGES_DIR).sort()) { + if (SKIP_PACKAGES.has(dirName)) continue; + + const pkgJsonPath = join(PACKAGES_DIR, dirName, 'package.json'); + if (!existsSync(pkgJsonPath)) continue; + + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')); + if (!pkgJson.exports) continue; + + const pkgName = pkgJson.name; + const pkgDir = join(PACKAGES_DIR, dirName); + + for (const [key, value] of Object.entries(pkgJson.exports)) { + if (key.endsWith('.css')) continue; + + if (key.includes('*')) { + for (const r of resolveWildcard(pkgDir, key, value)) { + entries.push({ + name: `${pkgName}/${r.label.slice(2)}`, + path: r.path, + }); + } + continue; + } + + const serverPath = resolveServerPath(value); + if (!serverPath) continue; + + const absolutePath = resolve(pkgDir, serverPath); + if (!existsSync(absolutePath)) continue; + if (readFileSync(absolutePath, 'utf8').trim().length === 0) continue; + + const label = key === '.' ? pkgName : `${pkgName}/${key.slice(2)}`; + entries.push({ name: label, path: absolutePath }); + } + } + + return entries; +} + +const entries = discoverServerEntries(); + +if (entries.length === 0) { + console.error('No server entry points found. Are packages built?'); + process.exit(1); +} + +console.log(`Checking ${entries.length} server entry points...\n`); + +const failures = []; + +for (const entry of entries) { + const url = pathToFileURL(entry.path).href; + try { + await import(url); + console.log(` ✓ ${entry.name}`); + } catch (error) { + console.log(` ✗ ${entry.name}`); + failures.push({ name: entry.name, error }); + } +} + +console.log(); + +if (failures.length > 0) { + console.error(`${failures.length} entry point(s) failed:\n`); + for (const { name, error } of failures) { + console.error(` ${name}:`); + console.error(` ${error.message}\n`); + } + process.exit(1); +} + +console.log(`All ${entries.length} server entry points imported successfully.`); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c771e28..12abefd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,3 +203,6 @@ jobs: - name: Build CDN bundles run: pnpm build:cdn + + - name: Check SSR safety + run: pnpm check:ssr diff --git a/CLAUDE.md b/CLAUDE.md index 4b91f64c..1cbecc49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,6 +113,9 @@ pnpm clean # Validate workspace consistency (CI coverage, scopes, define imports, etc.) pnpm check:workspace +# Validate all server entry points are SSR-safe (requires build first) +pnpm check:ssr + # Measure bundle size (SPF only) pnpm -F @videojs/spf size # Public API (minified + gzipped) pnpm -F @videojs/spf size:all # All exports (minified + gzipped) diff --git a/build/scripts/check-workspace.mjs b/build/scripts/check-workspace.mjs index 34c59469..904ae8b9 100644 --- a/build/scripts/check-workspace.mjs +++ b/build/scripts/check-workspace.mjs @@ -11,6 +11,7 @@ * 4. Package metadata — non-private packages have required fields * 5. Release-please config — every versioned package is registered * 6. Define imports — no bare side-effect imports from relative paths + * 7. Server bundles — browser/default export conditions match tsdown config */ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; @@ -298,6 +299,74 @@ function checkDefineImports() { return { ok: warnings.length === 0, warnings }; } +// ── Check 7: Server bundles ────────────────────────────────────────────────── + +/** + * Packages with `browser` export conditions must have a matching server build + * in tsdown (build mode 'server', `__BROWSER__` define, `platform: 'node'`). + * + * For each export entry with a `browser` condition: + * - The sibling `default` path must point to `dist/server/` + * - The tsdown config must declare a `'server'` build mode + * - The tsdown config must define `__BROWSER__` + */ +function checkServerBundles() { + const warnings = []; + + for (const dir of getPackageDirs()) { + const pkg = readPackageJson(dir); + if (pkg.private) continue; + + const exports = pkg.exports; + if (!exports) continue; + + // Collect export keys that have a `browser` condition. + const browserExports = []; + + for (const [key, value] of Object.entries(exports)) { + if (typeof value !== 'object' || value === null) continue; + if ('browser' in value) { + browserExports.push(key); + + // The `default` sibling must resolve to dist/server/. + const serverPath = value.default; + if (typeof serverPath === 'string' && !serverPath.includes('dist/server/')) { + warnings.push( + `${pkg.name}: export "${key}" has \`browser\` condition but \`default\` path "${serverPath}" does not point to dist/server/` + ); + } + } + } + + if (browserExports.length === 0) continue; + + // Validate tsdown config has server build mode and __BROWSER__ define. + const tsdownPath = join(PACKAGES_DIR, dir, 'tsdown.config.ts'); + if (!existsSync(tsdownPath)) { + warnings.push(`${pkg.name}: has \`browser\` exports but no tsdown.config.ts`); + continue; + } + + const tsdownText = readText(tsdownPath); + + if (!/'server'/.test(tsdownText)) { + warnings.push(`${pkg.name}: has \`browser\` exports but tsdown.config.ts is missing 'server' build mode`); + } + + if (!/__BROWSER__/.test(tsdownText)) { + warnings.push(`${pkg.name}: has \`browser\` exports but tsdown.config.ts does not define __BROWSER__`); + } + + if (!/platform.*['"]node['"]/.test(tsdownText)) { + warnings.push( + `${pkg.name}: has \`browser\` exports but tsdown.config.ts does not set platform to 'node' for server builds` + ); + } + } + + return { ok: warnings.length === 0, warnings }; +} + // ── Main ──────────────────────────────────────────────────────────────────── const checks = [ @@ -307,6 +376,7 @@ const checks = [ { name: 'Package metadata', fn: checkPackageMetadata }, { name: 'Release-please config', fn: checkReleasePleaseConfig }, { name: 'Define imports', fn: checkDefineImports }, + { name: 'Server bundles', fn: checkServerBundles }, ]; let failed = 0; diff --git a/package.json b/package.json index f0936b6f..2f866daf 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "format:astro": "prettier --write 'site/src/**/*.astro'", "typecheck": "tsc --build", "changelog": "git-cliff --config cliff.toml --output CHANGELOG.md", - "check:workspace": "node build/scripts/check-workspace.mjs" + "check:workspace": "node build/scripts/check-workspace.mjs", + "check:ssr": "node .github/scripts/ssr-check.js" }, "devDependencies": { "@actions/core": "^3.0.0", @@ -51,6 +52,7 @@ "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", "@commitlint/format": "^20.0.0", + "@edge-runtime/vm": "^5.0.0", "@types/node": "^22.18.6", "esbuild": "^0.27.3", "git-cliff": "^2.12.0", @@ -60,9 +62,9 @@ "prettier-plugin-astro": "^0.14.1", "prettier-plugin-tailwindcss": "^0.7.2", "react": "^18.0.0", - "rimraf": "^6.1.3", "react-compiler-runtime": "^1.0.0", "react-dom": "^18.0.0", + "rimraf": "^6.1.3", "simple-git-hooks": "^2.13.1", "tsx": "^4.21.0", "turbo": "^2.9.0", diff --git a/packages/core/package.json b/packages/core/package.json index 48a8853b..ac30b51e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,9 +28,12 @@ "default": "./dist/default/dom.js" }, "./dom/media/*": { - "types": "./dist/dev/dom/media/*/index.d.ts", - "development": "./dist/dev/dom/media/*/index.js", - "default": "./dist/default/dom/media/*/index.js" + "types": "./dist/dev/dom/media/*.d.ts", + "browser": { + "development": "./dist/dev/dom/media/*.js", + "default": "./dist/default/dom/media/*.js" + }, + "default": "./dist/server/dom/media/*.js" } }, "main": "dist/default/index.js", diff --git a/packages/core/src/dom/globals.d.ts b/packages/core/src/dom/globals.d.ts index b867229b..20f7f377 100644 --- a/packages/core/src/dom/globals.d.ts +++ b/packages/core/src/dom/globals.d.ts @@ -1 +1,2 @@ declare const __DEV__: boolean; +declare const __BROWSER__: boolean; diff --git a/packages/core/src/dom/media/custom-media-element/index.ts b/packages/core/src/dom/media/custom-media-element/browser.ts similarity index 100% rename from packages/core/src/dom/media/custom-media-element/index.ts rename to packages/core/src/dom/media/custom-media-element/browser.ts diff --git a/packages/core/src/dom/media/custom-media-element/server.ts b/packages/core/src/dom/media/custom-media-element/server.ts new file mode 100644 index 00000000..c1f66201 --- /dev/null +++ b/packages/core/src/dom/media/custom-media-element/server.ts @@ -0,0 +1,7 @@ +export const VideoCSSVars = {} as const; + +export const AudioCSSVars = {} as const; + +export function CustomMediaElement(_tag: string, _MediaHost: any): any { + return class {}; +} diff --git a/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts b/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts index 791b12bb..900fbd26 100644 --- a/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts +++ b/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { HTMLAudioElementHost } from '../../audio-host'; import { HTMLVideoElementHost } from '../../video-host'; -import { CustomMediaElement } from '../index'; +import { CustomMediaElement } from '../browser'; afterEach(() => { document.body.innerHTML = ''; diff --git a/packages/core/src/dom/media/dash/index.ts b/packages/core/src/dom/media/dash/browser.ts similarity index 100% rename from packages/core/src/dom/media/dash/index.ts rename to packages/core/src/dom/media/dash/browser.ts diff --git a/packages/core/src/dom/media/dash/server.ts b/packages/core/src/dom/media/dash/server.ts new file mode 100644 index 00000000..8afc279f --- /dev/null +++ b/packages/core/src/dom/media/dash/server.ts @@ -0,0 +1,6 @@ +import { HTMLVideoElementHost } from '../video-host'; + +export class DashMedia extends HTMLVideoElementHost { + engine = null; + destroy(): void {} +} diff --git a/packages/core/src/dom/media/dash/tests/dash-media.test.ts b/packages/core/src/dom/media/dash/tests/dash-media.test.ts index 19c54f4e..4e7b91af 100644 --- a/packages/core/src/dom/media/dash/tests/dash-media.test.ts +++ b/packages/core/src/dom/media/dash/tests/dash-media.test.ts @@ -12,7 +12,7 @@ vi.mock('dashjs', () => { return { MediaPlayer: () => ({ create }), default: { MediaPlayer: () => ({ create }) } }; }); -import { DashMedia } from '../index'; +import { DashMedia } from '../browser'; afterEach(() => { document.body.innerHTML = ''; diff --git a/packages/core/src/dom/media/hls/index.ts b/packages/core/src/dom/media/hls/browser.ts similarity index 84% rename from packages/core/src/dom/media/hls/index.ts rename to packages/core/src/dom/media/hls/browser.ts index 67a28b9e..0a843e34 100644 --- a/packages/core/src/dom/media/hls/index.ts +++ b/packages/core/src/dom/media/hls/browser.ts @@ -1,26 +1,20 @@ import { shallowEqual } from '@videojs/utils/object'; import Hls from 'hls.js'; import { bridgeEvents } from '../../../core/utils/bridge-events'; -import { NativeHlsMedia } from '../native-hls'; +import { NativeHlsMedia } from '../native-hls/browser'; import { HTMLVideoElementHost } from '../video-host'; import { HlsJsMedia } from './hlsjs'; - -export type PreloadType = '' | 'none' | 'metadata' | 'auto'; +import { + inferSourceType, + type PlaybackType, + PlaybackTypes, + type PreloadType, + type SourceType, + SourceTypes, +} from './types'; export { Hls }; - -export type PlaybackType = (typeof PlaybackTypes)[keyof typeof PlaybackTypes]; -export type SourceType = (typeof SourceTypes)[keyof typeof SourceTypes]; - -export const PlaybackTypes = { - MSE: 'mse', - NATIVE: 'native', -}; - -export const SourceTypes = { - M3U8: 'application/vnd.apple.mpegurl', - MP4: 'video/mp4', -}; +export * from './types'; export class HlsMedia extends HTMLVideoElementHost { #delegate: HlsJsMedia | NativeHlsMedia | null = null; @@ -169,9 +163,3 @@ export class HlsMedia extends HTMLVideoElementHost { this.#loadRequested = null; } } - -function inferSourceType(src: string): SourceType { - const path = src.split(/[?#]/)[0] ?? ''; - if (path.endsWith('.mp4')) return SourceTypes.MP4; - return SourceTypes.M3U8; -} diff --git a/packages/core/src/dom/media/hls/server.ts b/packages/core/src/dom/media/hls/server.ts new file mode 100644 index 00000000..0c5f0a10 --- /dev/null +++ b/packages/core/src/dom/media/hls/server.ts @@ -0,0 +1,13 @@ +import { HTMLVideoElementHost } from '../video-host'; + +export * from './types'; + +export class HlsMedia extends HTMLVideoElementHost { + engine = null; + preferPlayback: string | undefined = 'mse'; + config: Record = {}; + debug = false; + preload = 'metadata'; + load(): void {} + destroy(): void {} +} diff --git a/packages/core/src/dom/media/hls/tests/hls-media.test.ts b/packages/core/src/dom/media/hls/tests/hls-media.test.ts index 0cc3320e..05c10899 100644 --- a/packages/core/src/dom/media/hls/tests/hls-media.test.ts +++ b/packages/core/src/dom/media/hls/tests/hls-media.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { MediaError } from '../../../../core/media/media-error'; -import { HlsMedia, SourceTypes } from '../index'; +import { HlsMedia, SourceTypes } from '../browser'; afterEach(() => { document.body.innerHTML = ''; diff --git a/packages/core/src/dom/media/hls/types.ts b/packages/core/src/dom/media/hls/types.ts index 1eef9b13..fe41ea42 100644 --- a/packages/core/src/dom/media/hls/types.ts +++ b/packages/core/src/dom/media/hls/types.ts @@ -4,3 +4,24 @@ export interface HlsEngineHost extends EventTarget { readonly engine?: Hls | null; readonly target?: HTMLMediaElement | null; } + +export type PreloadType = '' | 'none' | 'metadata' | 'auto'; + +export type PlaybackType = (typeof PlaybackTypes)[keyof typeof PlaybackTypes]; +export type SourceType = (typeof SourceTypes)[keyof typeof SourceTypes]; + +export const PlaybackTypes = { + MSE: 'mse', + NATIVE: 'native', +}; + +export const SourceTypes = { + M3U8: 'application/vnd.apple.mpegurl', + MP4: 'video/mp4', +}; + +export function inferSourceType(src: string): SourceType { + const path = src.split(/[?#]/)[0] ?? ''; + if (path.endsWith('.mp4')) return SourceTypes.MP4; + return SourceTypes.M3U8; +} diff --git a/packages/core/src/dom/media/mux/index.ts b/packages/core/src/dom/media/mux/browser.ts similarity index 91% rename from packages/core/src/dom/media/mux/index.ts rename to packages/core/src/dom/media/mux/browser.ts index e7e424eb..05e66cfe 100644 --- a/packages/core/src/dom/media/mux/index.ts +++ b/packages/core/src/dom/media/mux/browser.ts @@ -1,4 +1,4 @@ -import { HlsMedia } from '../hls'; +import { HlsMedia } from '../hls/browser'; import { MuxDataMediaMixin } from './mux-data'; export class MuxVideoMedia extends MuxDataMediaMixin(HlsMedia) { diff --git a/packages/core/src/dom/media/mux/mux-data.ts b/packages/core/src/dom/media/mux/mux-data.ts index 69157a59..4e4c3e19 100644 --- a/packages/core/src/dom/media/mux/mux-data.ts +++ b/packages/core/src/dom/media/mux/mux-data.ts @@ -1,7 +1,7 @@ import type { Mixin } from '@videojs/utils/types'; import Mux from 'mux-embed'; import type { MediaEngineHost } from '../../../core/media/types'; -import { Hls, type HlsMedia } from '../hls'; +import { Hls, type HlsMedia } from '../hls/browser'; import { getPlayerVersion } from './env'; import type { MuxDataOptions, MuxDataSdk } from './types'; @@ -198,7 +198,7 @@ export function toPlaybackIdFromSrc(src: string): string | undefined { export function isMuxVideoSrc({ src }: MuxVideoIdProps): boolean { if (typeof src !== 'string') return false; - const base = window?.location.href; + const base = __BROWSER__ ? window.location.href : undefined; const hostname = new URL(src, base).hostname.toLocaleLowerCase(); return hostname.includes(MUX_VIDEO_DOMAIN); } diff --git a/packages/core/src/dom/media/mux/server.ts b/packages/core/src/dom/media/mux/server.ts new file mode 100644 index 00000000..392a2a39 --- /dev/null +++ b/packages/core/src/dom/media/mux/server.ts @@ -0,0 +1,9 @@ +import { HlsMedia } from '../hls/server'; + +export class MuxVideoMedia extends HlsMedia { + static PLAYER_SOFTWARE_NAME = 'mux-video'; +} + +export class MuxAudioMedia extends HlsMedia { + static PLAYER_SOFTWARE_NAME = 'mux-audio'; +} diff --git a/packages/core/src/dom/media/mux/tests/index.test.ts b/packages/core/src/dom/media/mux/tests/index.test.ts index 41b56ccd..a25ddbb5 100644 --- a/packages/core/src/dom/media/mux/tests/index.test.ts +++ b/packages/core/src/dom/media/mux/tests/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { MuxVideoMedia } from '..'; +import { MuxVideoMedia } from '../browser'; describe('MuxVideoMedia', () => { it('accepts src directly', () => { diff --git a/packages/core/src/dom/media/native-hls/index.ts b/packages/core/src/dom/media/native-hls/browser.ts similarity index 100% rename from packages/core/src/dom/media/native-hls/index.ts rename to packages/core/src/dom/media/native-hls/browser.ts diff --git a/packages/core/src/dom/media/native-hls/server.ts b/packages/core/src/dom/media/native-hls/server.ts new file mode 100644 index 00000000..0462f30e --- /dev/null +++ b/packages/core/src/dom/media/native-hls/server.ts @@ -0,0 +1,7 @@ +import { HTMLVideoElementHost } from '../video-host'; + +export class NativeHlsMedia extends HTMLVideoElementHost { + engine = null; + preload = 'metadata'; + destroy(): void {} +} diff --git a/packages/core/src/dom/media/native-hls/tests/native-hls-custom-media.test.ts b/packages/core/src/dom/media/native-hls/tests/native-hls-custom-media.test.ts index a38f9c07..20c7cad0 100644 --- a/packages/core/src/dom/media/native-hls/tests/native-hls-custom-media.test.ts +++ b/packages/core/src/dom/media/native-hls/tests/native-hls-custom-media.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { MediaError } from '../../../../core/media/media-error'; -import { NativeHlsMedia } from '../index'; +import { NativeHlsMedia } from '../browser'; afterEach(() => { document.body.innerHTML = ''; diff --git a/packages/core/src/dom/media/simple-hls/index.ts b/packages/core/src/dom/media/simple-hls/browser.ts similarity index 100% rename from packages/core/src/dom/media/simple-hls/index.ts rename to packages/core/src/dom/media/simple-hls/browser.ts diff --git a/packages/core/src/dom/media/simple-hls/server.ts b/packages/core/src/dom/media/simple-hls/server.ts new file mode 100644 index 00000000..5020d767 --- /dev/null +++ b/packages/core/src/dom/media/simple-hls/server.ts @@ -0,0 +1,6 @@ +import { HTMLVideoElementHost } from '../video-host'; + +export class SimpleHlsMedia extends HTMLVideoElementHost { + engine = null; + destroy(): void {} +} diff --git a/packages/core/src/dom/ui/dismiss-layer.ts b/packages/core/src/dom/ui/dismiss-layer.ts index 4f0e0f52..f937f993 100644 --- a/packages/core/src/dom/ui/dismiss-layer.ts +++ b/packages/core/src/dom/ui/dismiss-layer.ts @@ -63,7 +63,7 @@ export function createDismissLayer(options: DismissLayerOptions): DismissLayerAp function setupDocumentListeners(): void { cleanupDocumentListeners(); - if (typeof document === 'undefined') return; + if (!__BROWSER__) return; docAbort = new AbortController(); const { signal } = docAbort; diff --git a/packages/core/src/globals.d.ts b/packages/core/src/globals.d.ts index b867229b..20f7f377 100644 --- a/packages/core/src/globals.d.ts +++ b/packages/core/src/globals.d.ts @@ -1 +1,2 @@ declare const __DEV__: boolean; +declare const __BROWSER__: boolean; diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index ceece40d..cc179312 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -1,31 +1,41 @@ +import { globSync } from 'node:fs'; import type { UserConfig } from 'tsdown'; import { defineConfig } from 'tsdown'; import packageJson from './package.json' with { type: 'json' }; -type BuildMode = 'dev' | 'default'; +type BuildMode = 'dev' | 'default' | 'server'; -const buildModes: BuildMode[] = ['dev', 'default']; +const buildModes: BuildMode[] = ['dev', 'default', 'server']; + +const isServer = (mode: BuildMode) => mode === 'server'; + +const mediaEntry = (mode: BuildMode) => { + const suffix = isServer(mode) ? 'server' : 'browser'; + return Object.fromEntries( + globSync(`src/dom/media/*/${suffix}.ts`).map((file) => { + const dir = file.split('/').at(-2)!; + return [`dom/media/${dir}`, `./${file}`]; + }) + ); +}; const createConfig = (mode: BuildMode): UserConfig => ({ entry: { index: './src/core/index.ts', dom: './src/dom/index.ts', - 'dom/media/dash/index': './src/dom/media/dash/index.ts', - 'dom/media/hls/index': './src/dom/media/hls/index.ts', - 'dom/media/custom-media-element/index': './src/dom/media/custom-media-element/index.ts', - 'dom/media/mux/index': './src/dom/media/mux/index.ts', - 'dom/media/native-hls/index': './src/dom/media/native-hls/index.ts', - 'dom/media/simple-hls/index': './src/dom/media/simple-hls/index.ts', + ...mediaEntry(mode), }, - platform: 'neutral', + platform: isServer(mode) ? 'node' : 'neutral', format: 'es', sourcemap: true, clean: true, hash: false, unbundle: true, + outExtensions: isServer(mode) ? () => ({ js: '.js', dts: '.d.ts' }) : undefined, outDir: `dist/${mode}`, define: { - __DEV__: mode === 'dev' ? 'true' : 'false', + __DEV__: mode === 'dev' || isServer(mode) ? 'true' : 'false', + __BROWSER__: isServer(mode) ? 'false' : 'true', __PLAYER_VERSION__: JSON.stringify(packageJson.version), }, dts: diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index b87d2035..dd9701bf 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ define: { __DEV__: 'true', + __BROWSER__: 'true', }, test: { projects: [ diff --git a/packages/element/src/reactive-element.ts b/packages/element/src/reactive-element.ts index e6cb965b..49a5c409 100644 --- a/packages/element/src/reactive-element.ts +++ b/packages/element/src/reactive-element.ts @@ -50,7 +50,12 @@ const propertyKeys = new Map(); * } * ``` */ -export class ReactiveElement extends HTMLElement { +// Fallback to an empty class when `HTMLElement` is not available (SSR / Node). +// This allows the class definition to evaluate without throwing; instances +// are never created on the server. +const BaseElement = globalThis.HTMLElement ?? (class {} as unknown as typeof HTMLElement); + +export class ReactiveElement extends BaseElement { /** * User-supplied object that maps property names to * {@linkcode PropertyDeclaration} objects containing options for configuring diff --git a/packages/html/package.json b/packages/html/package.json index 5e60fced..e8c6526f 100644 --- a/packages/html/package.json +++ b/packages/html/package.json @@ -23,56 +23,86 @@ "exports": { ".": { "types": "./dist/dev/index.d.ts", - "development": "./dist/dev/index.js", - "default": "./dist/default/index.js" + "browser": { + "development": "./dist/dev/index.js", + "default": "./dist/default/index.js" + }, + "default": "./dist/server/index.js" }, "./video": { "types": "./dist/dev/presets/video.d.ts", - "development": "./dist/dev/presets/video.js", - "default": "./dist/default/presets/video.js" + "browser": { + "development": "./dist/dev/presets/video.js", + "default": "./dist/default/presets/video.js" + }, + "default": "./dist/server/presets/video.js" }, "./video/*.css": "./dist/default/define/video/*.css", "./video/*": { "types": "./dist/dev/define/video/*.d.ts", - "development": "./dist/dev/define/video/*.js", - "default": "./dist/default/define/video/*.js" + "browser": { + "development": "./dist/dev/define/video/*.js", + "default": "./dist/default/define/video/*.js" + }, + "default": "./dist/server/define/video/*.js" }, "./audio": { "types": "./dist/dev/presets/audio.d.ts", - "development": "./dist/dev/presets/audio.js", - "default": "./dist/default/presets/audio.js" + "browser": { + "development": "./dist/dev/presets/audio.js", + "default": "./dist/default/presets/audio.js" + }, + "default": "./dist/server/presets/audio.js" }, "./audio/*.css": "./dist/default/define/audio/*.css", "./audio/*": { "types": "./dist/dev/define/audio/*.d.ts", - "development": "./dist/dev/define/audio/*.js", - "default": "./dist/default/define/audio/*.js" + "browser": { + "development": "./dist/dev/define/audio/*.js", + "default": "./dist/default/define/audio/*.js" + }, + "default": "./dist/server/define/audio/*.js" }, "./background": { "types": "./dist/dev/presets/background.d.ts", - "development": "./dist/dev/presets/background.js", - "default": "./dist/default/presets/background.js" + "browser": { + "development": "./dist/dev/presets/background.js", + "default": "./dist/default/presets/background.js" + }, + "default": "./dist/server/presets/background.js" }, "./background/*.css": "./dist/default/define/background/*.css", "./background/*": { "types": "./dist/dev/define/background/*.d.ts", - "development": "./dist/dev/define/background/*.js", - "default": "./dist/default/define/background/*.js" + "browser": { + "development": "./dist/dev/define/background/*.js", + "default": "./dist/default/define/background/*.js" + }, + "default": "./dist/server/define/background/*.js" }, "./ui/*": { "types": "./dist/dev/define/ui/*.d.ts", - "development": "./dist/dev/define/ui/*.js", - "default": "./dist/default/define/ui/*.js" + "browser": { + "development": "./dist/dev/define/ui/*.js", + "default": "./dist/default/define/ui/*.js" + }, + "default": "./dist/server/define/ui/*.js" }, "./feature/*": { "types": "./dist/dev/define/feature/*.d.ts", - "development": "./dist/dev/define/feature/*.js", - "default": "./dist/default/define/feature/*.js" + "browser": { + "development": "./dist/dev/define/feature/*.js", + "default": "./dist/default/define/feature/*.js" + }, + "default": "./dist/server/define/feature/*.js" }, "./media/*": { "types": "./dist/dev/define/media/*.d.ts", - "development": "./dist/dev/define/media/*.js", - "default": "./dist/default/define/media/*.js" + "browser": { + "development": "./dist/dev/define/media/*.js", + "default": "./dist/default/define/media/*.js" + }, + "default": "./dist/server/define/media/*.js" }, "./cdn/media/*": { "types": "./cdn/media/*.dev.d.ts", @@ -87,8 +117,11 @@ "./*.css": "./dist/default/define/*.css", "./*": { "types": "./dist/dev/define/*.d.ts", - "development": "./dist/dev/define/*.js", - "default": "./dist/default/define/*.js" + "browser": { + "development": "./dist/dev/define/*.js", + "default": "./dist/default/define/*.js" + }, + "default": "./dist/server/define/*.js" } }, "scripts": { diff --git a/packages/html/src/define/safe-define.ts b/packages/html/src/define/safe-define.ts index 975fc2de..56138a1e 100644 --- a/packages/html/src/define/safe-define.ts +++ b/packages/html/src/define/safe-define.ts @@ -2,8 +2,8 @@ type DefinableElement = CustomElementConstructor & { tagName: string }; /** Define a custom element only if not already registered. */ export function safeDefine(element: DefinableElement): void { - const registry = globalThis.customElements; - if (!registry || registry.get(element.tagName)) return; + if (!__BROWSER__) return; + if (customElements.get(element.tagName)) return; - registry.define(element.tagName, element); + customElements.define(element.tagName, element); } diff --git a/packages/html/src/define/tests/safe-define.test.ts b/packages/html/src/define/tests/safe-define.test.ts index 2e87ac9b..646f3f66 100644 --- a/packages/html/src/define/tests/safe-define.test.ts +++ b/packages/html/src/define/tests/safe-define.test.ts @@ -1,11 +1,7 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } 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'; @@ -37,14 +33,4 @@ 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(); - }); }); diff --git a/packages/html/src/define/tests/ssr-safety.test.ts b/packages/html/src/define/tests/ssr-safety.test.ts deleted file mode 100644 index 2c4a7dd3..00000000 --- a/packages/html/src/define/tests/ssr-safety.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -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(); - }); -}); diff --git a/packages/html/src/globals.d.ts b/packages/html/src/globals.d.ts index 175e0a85..bc438011 100644 --- a/packages/html/src/globals.d.ts +++ b/packages/html/src/globals.d.ts @@ -1,4 +1,5 @@ declare const __DEV__: boolean; +declare const __BROWSER__: boolean; declare module '*.css' { const content: string; diff --git a/packages/html/tsdown.cdn.config.ts b/packages/html/tsdown.cdn.config.ts index eb7a1ce3..e9952838 100644 --- a/packages/html/tsdown.cdn.config.ts +++ b/packages/html/tsdown.cdn.config.ts @@ -89,6 +89,7 @@ for (const mode of buildModes) { }, define: { __DEV__: isProd ? 'false' : 'true', + __BROWSER__: 'true', }, plugins: [ inlineCssPlugin({ skinsDir, minify: isProd }), diff --git a/packages/html/tsdown.config.ts b/packages/html/tsdown.config.ts index 8a63b024..081dcc81 100644 --- a/packages/html/tsdown.config.ts +++ b/packages/html/tsdown.config.ts @@ -1,4 +1,4 @@ -import { globSync } from 'node:fs'; +import { globSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { UserConfig } from 'tsdown'; @@ -7,12 +7,51 @@ import { copyCssPlugin } from '../../build/plugins/copy-css-plugin.ts'; import { inlineCssPlugin } from '../../build/plugins/inline-css-plugin.ts'; import { inlineTemplatePlugin } from '../../build/plugins/inline-template-plugin.ts'; -type BuildMode = 'dev' | 'default'; +type BuildMode = 'dev' | 'default' | 'server'; -const buildModes: BuildMode[] = ['dev', 'default']; +const buildModes: BuildMode[] = ['dev', 'default', 'server']; + +const isServer = (mode: BuildMode) => mode === 'server'; const skinsDir = resolve(dirname(fileURLToPath(import.meta.url)), '../skins/src'); +/** Stub `*.css?inline` imports with empty strings for the server build. */ +function stubCssInlinePlugin() { + return { + name: 'stub-css-inline', + resolveId(source: string) { + if (source.endsWith('.css?inline')) return { id: `\0stub-css:${source}`, external: false }; + }, + load(id: string) { + if (id.startsWith('\0stub-css:')) return 'export default ""'; + }, + }; +} + +/** + * Stub all `define/*` modules for the server build. + * + * Reads each source file, extracts `export class` names, and emits stub + * classes so that preset modules can still re-export them by name. + * Everything else (custom element registration, templates, styles) is dropped. + */ +function stubDefinePlugin() { + const defineDir = resolve(dirname(fileURLToPath(import.meta.url)), 'src/define'); + return { + name: 'stub-define', + load(id: string) { + if (!id.startsWith(defineDir) || id === defineDir) return; + + const source = readFileSync(id, 'utf8'); + const classNames = [...source.matchAll(/export class (\w+)/g)].map((m) => m[1]); + + if (classNames.length === 0) return 'export {}'; + + return classNames.map((name) => `export class ${name} {}`).join('\n'); + }, + }; +} + const defineEntries = Object.fromEntries( globSync('src/define/**/*.ts') .filter((file) => !file.includes('.test.')) @@ -35,32 +74,36 @@ const createConfig = (mode: BuildMode): UserConfig => ({ ...defineEntries, ...presetEntries, }, - platform: 'browser', + platform: isServer(mode) ? 'node' : 'browser', format: 'es', sourcemap: true, clean: true, hash: false, unbundle: true, + outExtensions: isServer(mode) ? () => ({ js: '.js', dts: '.d.ts' }) : undefined, treeshake: { // The sideEffects field in package.json uses dist paths, but the build // runs against source. Ensure define/* modules (which register custom // elements as a side effect) are never tree-shaken from skin bundles. moduleSideEffects: [{ test: /\/define\//, sideEffects: true }], }, - noExternal: [/^@videojs\/icons/, /^@videojs\/skins/], + noExternal: isServer(mode) ? [] : [/^@videojs\/icons/, /^@videojs\/skins/], alias: { '@': new URL('./src', import.meta.url).pathname, }, outDir: `dist/${mode}`, define: { - __DEV__: mode === 'dev' ? 'true' : 'false', + __DEV__: mode === 'dev' || isServer(mode) ? 'true' : 'false', + __BROWSER__: isServer(mode) ? 'false' : 'true', }, dts: mode === 'dev', - plugins: [ - copyCssPlugin({ skinsDir, outDir: `dist/${mode}` }), - inlineCssPlugin({ skinsDir, minify: mode !== 'dev' }), - inlineTemplatePlugin({ minify: mode !== 'dev' }), - ], + plugins: isServer(mode) + ? [stubCssInlinePlugin(), stubDefinePlugin()] + : [ + copyCssPlugin({ skinsDir, outDir: `dist/${mode}` }), + inlineCssPlugin({ skinsDir, minify: mode !== 'dev' }), + inlineTemplatePlugin({ minify: mode !== 'dev' }), + ], }); export default defineConfig(buildModes.map((mode) => createConfig(mode))); diff --git a/packages/html/vitest.config.ts b/packages/html/vitest.config.ts index 6fc9357e..a20b2493 100644 --- a/packages/html/vitest.config.ts +++ b/packages/html/vitest.config.ts @@ -3,10 +3,12 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ define: { __DEV__: 'true', + __BROWSER__: 'true', }, test: { + include: ['src/**/*.test.ts'], passWithNoTests: true, - onConsoleLog: (log) => !log.includes('Lit is in dev mode'), + onConsoleLog: (log: string) => !log.includes('Lit is in dev mode'), environment: 'happy-dom', }, }); diff --git a/packages/react/package.json b/packages/react/package.json index 172a4448..88566855 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -19,46 +19,70 @@ "exports": { ".": { "types": "./dist/dev/index.d.ts", - "development": "./dist/dev/index.js", - "default": "./dist/default/index.js" + "browser": { + "development": "./dist/dev/index.js", + "default": "./dist/default/index.js" + }, + "default": "./dist/server/index.js" }, "./media/*": { "types": "./dist/dev/media/*/index.d.ts", - "development": "./dist/dev/media/*/index.js", - "default": "./dist/default/media/*/index.js" + "browser": { + "development": "./dist/dev/media/*/index.js", + "default": "./dist/default/media/*/index.js" + }, + "default": "./dist/server/media/*/index.js" }, "./video": { "types": "./dist/dev/presets/video/index.d.ts", - "development": "./dist/dev/presets/video/index.js", - "default": "./dist/default/presets/video/index.js" + "browser": { + "development": "./dist/dev/presets/video/index.js", + "default": "./dist/default/presets/video/index.js" + }, + "default": "./dist/server/presets/video/index.js" }, "./video/*.css": "./dist/default/presets/video/*.css", "./video/*": { "types": "./dist/dev/presets/video/*.d.ts", - "development": "./dist/dev/presets/video/*.js", - "default": "./dist/default/presets/video/*.js" + "browser": { + "development": "./dist/dev/presets/video/*.js", + "default": "./dist/default/presets/video/*.js" + }, + "default": "./dist/server/presets/video/*.js" }, "./audio": { "types": "./dist/dev/presets/audio/index.d.ts", - "development": "./dist/dev/presets/audio/index.js", - "default": "./dist/default/presets/audio/index.js" + "browser": { + "development": "./dist/dev/presets/audio/index.js", + "default": "./dist/default/presets/audio/index.js" + }, + "default": "./dist/server/presets/audio/index.js" }, "./audio/*.css": "./dist/default/presets/audio/*.css", "./audio/*": { "types": "./dist/dev/presets/audio/*.d.ts", - "development": "./dist/dev/presets/audio/*.js", - "default": "./dist/default/presets/audio/*.js" + "browser": { + "development": "./dist/dev/presets/audio/*.js", + "default": "./dist/default/presets/audio/*.js" + }, + "default": "./dist/server/presets/audio/*.js" }, "./background": { "types": "./dist/dev/presets/background/index.d.ts", - "development": "./dist/dev/presets/background/index.js", - "default": "./dist/default/presets/background/index.js" + "browser": { + "development": "./dist/dev/presets/background/index.js", + "default": "./dist/default/presets/background/index.js" + }, + "default": "./dist/server/presets/background/index.js" }, "./background/*.css": "./dist/default/presets/background/*.css", "./background/*": { "types": "./dist/dev/presets/background/*.d.ts", - "development": "./dist/dev/presets/background/*.js", - "default": "./dist/default/presets/background/*.js" + "browser": { + "development": "./dist/dev/presets/background/*.js", + "default": "./dist/default/presets/background/*.js" + }, + "default": "./dist/server/presets/background/*.js" } }, "scripts": { diff --git a/packages/react/src/globals.d.ts b/packages/react/src/globals.d.ts index b867229b..20f7f377 100644 --- a/packages/react/src/globals.d.ts +++ b/packages/react/src/globals.d.ts @@ -1 +1,2 @@ declare const __DEV__: boolean; +declare const __BROWSER__: boolean; diff --git a/packages/react/tsdown.config.ts b/packages/react/tsdown.config.ts index 26f43e44..b9725550 100644 --- a/packages/react/tsdown.config.ts +++ b/packages/react/tsdown.config.ts @@ -4,30 +4,34 @@ import type { UserConfig } from 'tsdown'; import { defineConfig } from 'tsdown'; import { copyCssPlugin } from '../../build/plugins/copy-css-plugin.ts'; -type BuildMode = 'dev' | 'default'; +type BuildMode = 'dev' | 'default' | 'server'; -const buildModes: BuildMode[] = ['dev', 'default']; +const buildModes: BuildMode[] = ['dev', 'default', 'server']; + +const isServer = (mode: BuildMode) => mode === 'server'; const skinsDir = resolve(dirname(fileURLToPath(import.meta.url)), '../skins/src'); const createConfig = (mode: BuildMode): UserConfig => ({ entry: 'src/**/index.{ts,tsx}', - platform: 'browser', + platform: isServer(mode) ? 'node' : 'browser', format: 'es', sourcemap: true, clean: true, hash: false, unbundle: true, - noExternal: [/^@videojs\/skins/], + outExtensions: isServer(mode) ? () => ({ js: '.js', dts: '.d.ts' }) : undefined, + noExternal: isServer(mode) ? [] : [/^@videojs\/skins/], alias: { '@': new URL('./src', import.meta.url).pathname, }, outDir: `dist/${mode}`, define: { - __DEV__: mode === 'dev' ? 'true' : 'false', + __DEV__: mode === 'dev' || isServer(mode) ? 'true' : 'false', + __BROWSER__: isServer(mode) ? 'false' : 'true', }, dts: mode === 'dev', - plugins: [copyCssPlugin({ skinsDir, outDir: `dist/${mode}` })], + plugins: isServer(mode) ? [] : [copyCssPlugin({ skinsDir, outDir: `dist/${mode}` })], }); export default defineConfig(buildModes.map((mode) => createConfig(mode))); diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index 4f425be6..07a2d14f 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -3,9 +3,10 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ define: { __DEV__: 'true', + __BROWSER__: 'true', }, test: { - environment: 'jsdom', include: ['src/**/*.test.{ts,tsx}'], + environment: 'jsdom', }, }); diff --git a/packages/spf/src/globals.d.ts b/packages/spf/src/globals.d.ts index b867229b..20f7f377 100644 --- a/packages/spf/src/globals.d.ts +++ b/packages/spf/src/globals.d.ts @@ -1 +1,2 @@ declare const __DEV__: boolean; +declare const __BROWSER__: boolean; diff --git a/packages/store/src/core/globals.d.ts b/packages/store/src/core/globals.d.ts index b867229b..20f7f377 100644 --- a/packages/store/src/core/globals.d.ts +++ b/packages/store/src/core/globals.d.ts @@ -1 +1,2 @@ declare const __DEV__: boolean; +declare const __BROWSER__: boolean; diff --git a/packages/store/src/globals.d.ts b/packages/store/src/globals.d.ts index b867229b..20f7f377 100644 --- a/packages/store/src/globals.d.ts +++ b/packages/store/src/globals.d.ts @@ -1 +1,2 @@ declare const __DEV__: boolean; +declare const __BROWSER__: boolean; diff --git a/packages/store/src/html/globals.d.ts b/packages/store/src/html/globals.d.ts index b867229b..20f7f377 100644 --- a/packages/store/src/html/globals.d.ts +++ b/packages/store/src/html/globals.d.ts @@ -1 +1,2 @@ declare const __DEV__: boolean; +declare const __BROWSER__: boolean; diff --git a/packages/store/src/react/globals.d.ts b/packages/store/src/react/globals.d.ts index b867229b..20f7f377 100644 --- a/packages/store/src/react/globals.d.ts +++ b/packages/store/src/react/globals.d.ts @@ -1 +1,2 @@ declare const __DEV__: boolean; +declare const __BROWSER__: boolean; diff --git a/packages/utils/package.json b/packages/utils/package.json index c221c13d..d26c4890 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -17,7 +17,8 @@ }, "./dom": { "types": "./dist/dom.d.ts", - "default": "./dist/dom.js" + "browser": "./dist/dom.js", + "default": "./dist/server/dom.js" }, "./events": { "types": "./dist/events.d.ts", diff --git a/packages/utils/src/dom/globals.d.ts b/packages/utils/src/dom/globals.d.ts index b867229b..20f7f377 100644 --- a/packages/utils/src/dom/globals.d.ts +++ b/packages/utils/src/dom/globals.d.ts @@ -1 +1,2 @@ declare const __DEV__: boolean; +declare const __BROWSER__: boolean; diff --git a/packages/utils/src/dom/platform.ts b/packages/utils/src/dom/platform.ts index 34b56a1a..f6f0be91 100644 --- a/packages/utils/src/dom/platform.ts +++ b/packages/utils/src/dom/platform.ts @@ -1,3 +1,3 @@ export function isMacOS(): boolean { - return typeof navigator !== 'undefined' && /mac/i.test(navigator.userAgent); + return __BROWSER__ && /mac/i.test(navigator.userAgent); } diff --git a/packages/utils/src/dom/shadow-styles.ts b/packages/utils/src/dom/shadow-styles.ts index 717e19a6..1d7439df 100644 --- a/packages/utils/src/dom/shadow-styles.ts +++ b/packages/utils/src/dom/shadow-styles.ts @@ -2,17 +2,17 @@ export type ShadowStyle = CSSStyleSheet | string; /** Inject a `