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
67 changes: 67 additions & 0 deletions npm/ui/media/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"name": "@vizeui/media",
"version": "0.298.0",
"description": "Accessible, type-safe media foundations for Vize applications",
"keywords": [
"accessibility",
"audio",
"media",
"pdf",
"video"
],
"homepage": "https://github.com/ubugeeei-prod/vize",
"bugs": {
"url": "https://github.com/ubugeeei-prod/vize/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ubugeeei-prod/vize.git",
"directory": "npm/ui/media"
},
"files": [
"dist"
],
"type": "module",
"sideEffects": false,
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./pdf": {
"types": "./dist/pdf.d.mts",
"import": "./dist/pdf.mjs",
"default": "./dist/pdf.mjs"
},
"./source": {
"types": "./dist/source.d.mts",
"import": "./dist/source.mjs",
"default": "./dist/source.mjs"
}
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "vp pack",
"dev": "vp pack --watch",
"pretest": "vp pack && pnpm check:size",
"test": "vp exec node --test 'src/**/*.test.ts'",
"check": "vp check src scripts vite.config.ts",
"check:fix": "vp check --fix src scripts vite.config.ts",
"check:size": "node scripts/check-size.mjs",
"fmt": "vp fmt --write src scripts vite.config.ts"
},
"devDependencies": {
"@types/node": "catalog:typescript",
"typescript": "catalog:typescript",
"vite-plus": "catalog:vite-stack"
},
"engines": {
"node": ">=24"
}
}
49 changes: 49 additions & 0 deletions npm/ui/media/scripts/check-size.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { readFile } from "node:fs/promises";
import { posix } from "node:path";
import { gzipSync } from "node:zlib";

const distributionDirectory = new URL("../dist/", import.meta.url);
const staticImportPattern = /\b(?:import|export)\s+(?:[^"']*?\s+from\s+)?["'](\.\.?\/[^"']+)["']/g;
const budgets = new Map([
["index.mjs", 1_850],
["pdf.mjs", 1_850],
["source.mjs", 1_400],
]);

async function collectStaticDependencies(file, collected = new Map()) {
if (collected.has(file)) return collected;

const source = await readFile(new URL(file, distributionDirectory));
collected.set(file, source);

for (const match of source.toString().matchAll(staticImportPattern)) {
const dependency = posix.normalize(posix.join(posix.dirname(file), match[1]));
if (dependency === ".." || dependency.startsWith("../")) {
throw new Error(`Output dependency escapes dist: ${dependency}`);
}
await collectStaticDependencies(dependency, collected);
}

return collected;
}

for (const [entry, maximumGzipBytes] of budgets) {
const files = await collectStaticDependencies(entry);
const gzipBytes = gzipSync(
[...files.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([file, source]) => `/* ${file} */\n${source}`)
.join("\n"),
).byteLength;

console.log(
JSON.stringify({
entry: `@vizeui/media/${entry.replace(/\.mjs$/, "")}`,
files: files.size,
gzipBytes,
maximumGzipBytes,
}),
);

if (gzipBytes > maximumGzipBytes) process.exitCode = 1;
}
4 changes: 4 additions & 0 deletions npm/ui/media/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { createPDFSource } from "./pdf-source.ts";
export type { CreatePDFSourceOptions } from "./pdf-source.ts";
export { normalizeMediaSource } from "./media-source.ts";
export type { MediaSourceKind, NormalizeMediaSourceOptions } from "./media-source.ts";
116 changes: 116 additions & 0 deletions npm/ui/media/src/media-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import { readFile, stat } from "node:fs/promises";
import test from "node:test";
import { createPDFSource } from "./pdf-source.ts";
import { normalizeMediaSource } from "./media-source.ts";
import type { MediaSourceKind } from "./media-source.ts";

void test("accepts relative, encrypted, and object media sources", () => {
for (const [source, kind] of [
["/audio/intro.mp3", "audio"],
["../video/intro.mp4", "video"],
["//media.example.test/poster.webp", "image"],
["https://media.example.test/subtitles.vtt", "track"],
["blob:session-id", "stream"],
] as const) {
assert.equal(normalizeMediaSource(` ${source} `, { kind }), source);
}
});

void test("requires an explicit opt-in for unencrypted remote sources", () => {
const source = "http://localhost:4173/video.mp4";
assert.throws(
() => normalizeMediaSource(source, { kind: "video" }),
/\[VIZE_UI_MEDIA_DISALLOWED_SOURCE\]/,
);
assert.equal(normalizeMediaSource(source, { kind: "video", allowInsecure: true }), source);
});

void test("accepts only category-matched inline data", () => {
const sources = [
["data:audio/ogg;base64,T2dnUw==", "audio"],
["data:image/png;base64,iVBORw0KGgo=", "image"],
["data:video/mp4;base64,AAAA", "video"],
["data:application/pdf;base64,JVBERi0xLjQ=", "pdf"],
["data:text/vtt;charset=utf-8,WEBVTT%0A%0A", "track"],
["data:text/vtt;base64,V0VCVlRU", "track"],
] as const;

for (const [source, kind] of sources) {
assert.equal(normalizeMediaSource(source, { kind }), source);
}

assert.throws(
() => normalizeMediaSource(sources[1][0], { kind: "video" }),
/\[VIZE_UI_MEDIA_DISALLOWED_SOURCE\]/,
);
assert.throws(
() => normalizeMediaSource("data:video/mp4;base64,AAAA", { kind: "stream" }),
/\[VIZE_UI_MEDIA_DISALLOWED_SOURCE\]/,
);
});

void test("rejects malformed and script-capable sources", () => {
for (const source of [
"",
"javascript:alert(1)",
"file:///private/report.pdf",
"https://media.example.test/video.mp4\nscript",
"data:video/mp4;base64,not-base64",
]) {
assert.throws(
() => normalizeMediaSource(source, { kind: "video" }),
/\[VIZE_UI_MEDIA_(?:INVALID|DISALLOWED)_SOURCE\]/,
);
}

assert.throws(
() => normalizeMediaSource("data:text/vtt,WEBVTT%QQ", { kind: "track" }),
/\[VIZE_UI_MEDIA_DISALLOWED_SOURCE\]/,
);
assert.throws(
() => normalizeMediaSource(42 as unknown as string, { kind: "video" }),
/\[VIZE_UI_MEDIA_INVALID_SOURCE\]/,
);
assert.throws(
() => normalizeMediaSource("/media", { kind: "unknown" as MediaSourceKind }),
/\[VIZE_UI_MEDIA_INVALID_KIND\]/,
);
});

void test("sets and replaces PDF page fragments", () => {
assert.equal(createPDFSource("/report.pdf", { page: 3 }), "/report.pdf#page=3");
assert.equal(
createPDFSource("/report.pdf#zoom=page-width&page=2&view=Fit", { page: 7 }),
"/report.pdf#zoom=page-width&view=Fit&page=7",
);
assert.equal(createPDFSource("/report.pdf#page=2"), "/report.pdf#page=2");

for (const page of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
assert.throws(
() => createPDFSource("/report.pdf", { page }),
/\[VIZE_UI_MEDIA_INVALID_PDF_PAGE\]/,
);
}
});

void test("publishes independent ESM entries and declarations", async () => {
const packageJson = JSON.parse(
await readFile(new URL("../package.json", import.meta.url), "utf8"),
) as {
readonly exports: Readonly<Record<string, { readonly import: string; readonly types: string }>>;
};

for (const exportName of [".", "./pdf", "./source"]) {
const entry = packageJson.exports[exportName];
if (entry === undefined) assert.fail(`Missing package export: ${exportName}`);
await stat(new URL(`..${entry.import.slice(1)}`, import.meta.url));
await stat(new URL(`..${entry.types.slice(1)}`, import.meta.url));
}

const distributionUrl = new URL("../dist/source.mjs", import.meta.url);
const sourceEntry = (await import(distributionUrl.href)) as {
readonly normalizeMediaSource: unknown;
};
assert.equal(typeof sourceEntry.normalizeMediaSource, "function");
});
116 changes: 116 additions & 0 deletions npm/ui/media/src/media-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/** Media resource category used to constrain inline data. */
export type MediaSourceKind = "audio" | "image" | "pdf" | "stream" | "track" | "video";

/** Options for {@link normalizeMediaSource}. */
export interface NormalizeMediaSourceOptions {
/** Expected media resource category. */
readonly kind: MediaSourceKind;

/**
* Permits an unencrypted HTTP resource for local development.
*
* @default false
*/
readonly allowInsecure?: boolean;
}

const SOURCE_KINDS = new Set<MediaSourceKind>([
"audio",
"image",
"pdf",
"stream",
"track",
"video",
]);
const SCHEME = /^([a-z][a-z\d+.-]*):/i;
const BASE64_PAYLOAD = /^(?:[a-z\d+/]{4})*(?:[a-z\d+/]{2}==|[a-z\d+/]{3}=)?$/i;
const BINARY_MEDIA_TYPE = /^(audio|image|video)\/([a-z\d][a-z\d.+-]*)$/i;
const INVALID_PERCENT_ESCAPE = /%(?![a-f\d]{2})/i;

/**
* Validates and normalizes a media resource reference.
*
* Relative references, network-relative references, encrypted remote URLs,
* object URLs, and category-matched inline data are accepted. Unencrypted
* remote URLs require an explicit opt-in. Unknown and script-capable schemes
* are rejected.
*
* @throws {TypeError} When the resource is empty, malformed, unsafe, or does
* not match the requested media category.
*/
export function normalizeMediaSource(source: string, options: NormalizeMediaSourceOptions): string {
const kind = options?.kind;
if (!SOURCE_KINDS.has(kind)) {
throw new TypeError(`[VIZE_UI_MEDIA_INVALID_KIND] Unknown media source kind: ${String(kind)}`);
}

if (typeof source !== "string") {
throw new TypeError("[VIZE_UI_MEDIA_INVALID_SOURCE] Media source must be a string");
}

const normalized = source.trim();
if (normalized.length === 0 || containsControlCharacter(normalized)) {
throw new TypeError(
"[VIZE_UI_MEDIA_INVALID_SOURCE] Media source must be non-empty and contain no control characters",
);
}

const scheme = SCHEME.exec(normalized)?.[1]?.toLowerCase();
if (scheme === undefined || scheme === "https" || scheme === "blob") return normalized;
if (scheme === "http" && options.allowInsecure === true) return normalized;
if (scheme === "data" && isAllowedDataSource(normalized, kind)) return normalized;

throw new TypeError(
`[VIZE_UI_MEDIA_DISALLOWED_SOURCE] Source is not allowed for ${kind}: ${scheme}`,
);
}

function isAllowedDataSource(source: string, kind: MediaSourceKind): boolean {
if (kind === "stream") return false;

const commaIndex = source.indexOf(",");
if (commaIndex < 6) return false;

const metadata = source.slice(5, commaIndex).toLowerCase();
const payload = source.slice(commaIndex + 1);
if (payload.length === 0) return false;

const segments = metadata.split(";");
const mediaType = segments.shift();
const isBase64 = segments.at(-1) === "base64";
if (isBase64) segments.pop();

if (kind === "pdf") {
return (
mediaType === "application/pdf" && segments.length === 0 && isBase64 && isValidBase64(payload)
);
}

if (kind === "track") {
const hasValidParameters =
segments.length === 0 || (segments.length === 1 && segments[0] === "charset=utf-8");
if (mediaType !== "text/vtt" || !hasValidParameters) return false;
return isBase64 ? isValidBase64(payload) : !INVALID_PERCENT_ESCAPE.test(payload);
}

const inlineKind = mediaType === undefined ? undefined : BINARY_MEDIA_TYPE.exec(mediaType)?.[1];
return (
inlineKind?.toLowerCase() === kind &&
segments.length === 0 &&
isBase64 &&
isValidBase64(payload)
);
}

function isValidBase64(payload: string): boolean {
return payload.length > 0 && BASE64_PAYLOAD.test(payload);
}

function containsControlCharacter(value: string): boolean {
for (const character of value) {
const code = character.charCodeAt(0);
if (code <= 0x1f || code === 0x7f) return true;
}

return false;
}
Loading
Loading