diff --git a/notes.md b/notes.md index cdd6f495..5ba6f007 100644 --- a/notes.md +++ b/notes.md @@ -1,3 +1,5 @@ +Run redis with `redis-stack-server`, `@redis/json` is required. + ```json { "inputs": [ diff --git a/packages/api/package.json b/packages/api/package.json index e08bee12..202164e3 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -28,7 +28,6 @@ "@fastify/swagger": "^8.15.0", "@fastify/swagger-ui": "^4.0.1", "@mixwave/artisan": "workspace:*", - "@mixwave/stitcher": "workspace:*", "@ts-rest/core": "^3.49.3", "@ts-rest/fastify": "^3.49.3", "@ts-rest/open-api": "^3.49.3", diff --git a/packages/api/src/client.ts b/packages/api/src/client.ts index 0083418d..d449a17c 100644 --- a/packages/api/src/client.ts +++ b/packages/api/src/client.ts @@ -1,7 +1,5 @@ export { streamSchema, inputSchema } from "@mixwave/artisan/schemas"; -export { playlistParamsSchema } from "@mixwave/stitcher/schemas"; - export * from "./contract"; export type { JobDto, JobNodeDto } from "./types"; diff --git a/packages/api/src/contract.ts b/packages/api/src/contract.ts index 8409cbef..1d7c5128 100644 --- a/packages/api/src/contract.ts +++ b/packages/api/src/contract.ts @@ -1,6 +1,5 @@ import { initContract } from "@ts-rest/core"; import { streamSchema, inputSchema } from "@mixwave/artisan/schemas"; -import { playlistParamsSchema } from "@mixwave/stitcher/schemas"; import * as z from "zod"; import type { JobDto, JobNodeDto } from "./types.js"; @@ -11,6 +10,7 @@ export const postTranscodeBodySchema = z.object({ streams: z.array(streamSchema), segmentSize: z.number(), assetId: z.string().optional(), + package: z.boolean().optional(), }); export const postPackageBodySchema = z.object({ @@ -56,16 +56,6 @@ export const contract = c.router({ 200: c.type(), }, }, - postPlaylist: { - method: "POST", - path: "/playlist/:assetId", - body: playlistParamsSchema, - responses: { - 200: c.type<{ - url: string; - }>(), - }, - }, getSpec: { method: "GET", path: "/spec.json", diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index 04613cec..cd6011c1 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -6,6 +6,5 @@ export const env = parse( PORT: z.coerce.number(), REDIS_HOST: z.string(), REDIS_PORT: z.coerce.number(), - STITCHER_URL: z.string(), - }) + }), ); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 492c4a49..a8e7f2bd 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -6,8 +6,8 @@ import { bullBoardPlugin } from "./plugins/bull-board.js"; import { initServer } from "@ts-rest/fastify"; import { addTranscodeJob, addPackageJob } from "@mixwave/artisan/producer"; import { getJobs, getJob, getRootTreeForJobById, getJobLogs } from "./jobs.js"; -import { getPlaylistUrl } from "./playlist.js"; import { generateOpenApi } from "@ts-rest/open-api"; +import { randomUUID } from "crypto"; async function buildServer() { const app = Fastify(); @@ -18,7 +18,11 @@ async function buildServer() { const router = s.router(contract, { postTranscode: async ({ body }) => { - const { job } = await addTranscodeJob(body); + const job = await addTranscodeJob({ + assetId: randomUUID(), + package: false, + ...body, + }); return { status: 201, body: { jobId: job.id }, @@ -52,14 +56,6 @@ async function buildServer() { body: await getJobLogs(params.id), }; }, - postPlaylist: async ({ params, body }) => { - return { - status: 200, - body: { - url: await getPlaylistUrl(params.assetId, body), - }, - }; - }, getSpec: async () => { return { status: 200, diff --git a/packages/api/src/jobs.ts b/packages/api/src/jobs.ts index dd31143c..da91db1f 100644 --- a/packages/api/src/jobs.ts +++ b/packages/api/src/jobs.ts @@ -27,13 +27,17 @@ export async function getJobs(): Promise { } async function formatJobDto(job: Job): Promise { + if (!job.id) { + throw new Error("Missing jobId"); + } + let progress = 0; if (typeof job.progress === "number") { progress = job.progress; } return { - id: `${job.queueName}_${job.id}`, + id: job.id, name: job.name, state: await job.getState(), progress, @@ -46,8 +50,8 @@ async function formatJobDto(job: Job): Promise { }; } -export async function getJobLogs(prefixedId: string) { - const [queueName, id] = prefixedId.split("_"); +export async function getJobLogs(id: string) { + const queueName = id.split("_", 1)[0]; const queue = findQueueByName(queueName); const { logs } = await queue.getJobLogs(id); @@ -55,8 +59,8 @@ export async function getJobLogs(prefixedId: string) { return logs; } -export async function getJob(prefixedId: string) { - const [queueName, id] = prefixedId.split("_"); +export async function getJob(id: string) { + const queueName = id.split("_", 1)[0]; const queue = findQueueByName(queueName); const job = await Job.fromId(queue, id); @@ -104,8 +108,8 @@ export async function getRootTreeForJob(job: Job) { return await formatJobNodeDto(node); } -export async function getRootTreeForJobById(prefixedId: string) { - const [queueName, id] = prefixedId.split("_"); +export async function getRootTreeForJobById(id: string) { + const queueName = id.split("_", 1)[0]; const queue = findQueueByName(queueName); const job = await Job.fromId(queue, id); diff --git a/packages/api/src/playlist.ts b/packages/api/src/playlist.ts deleted file mode 100644 index 76f00d35..00000000 --- a/packages/api/src/playlist.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createPlaylistParamsPayload } from "@mixwave/stitcher/schemas"; -import type { PlaylistParams } from "@mixwave/stitcher/schemas"; -import { env } from "./env.js"; - -export function getPlaylistUrl(id: string, params: PlaylistParams) { - const payload = createPlaylistParamsPayload(params); - return `${env.STITCHER_URL}/out/${id}/hls/master.m3u8?p=${payload}`; -} diff --git a/packages/artisan/src/consumer/s3.ts b/packages/artisan/src/consumer/s3.ts index e74e7445..0436f7cb 100644 --- a/packages/artisan/src/consumer/s3.ts +++ b/packages/artisan/src/consumer/s3.ts @@ -1,4 +1,10 @@ -import { GetObjectCommand, PutObjectCommand, S3 } from "@aws-sdk/client-s3"; +import { + GetObjectCommand, + PutObjectCommand, + S3, + CopyObjectCommand, + DeleteObjectCommand, +} from "@aws-sdk/client-s3"; import { S3SyncClient } from "s3-sync-client"; import { env } from "./env.js"; import { basename } from "path"; @@ -7,6 +13,7 @@ import { existsSync } from "fs"; import { createReadStream } from "fs"; import type { Readable } from "stream"; import type { SyncOptions } from "s3-sync-client/dist/commands/SyncCommand"; +import type { ObjectCannedACL } from "@aws-sdk/client-s3"; const client = new S3({ endpoint: env.S3_ENDPOINT, @@ -72,3 +79,25 @@ export async function uploadJsonFile(key: string, content: string) { }), ); } + +export async function copyFile( + name: string, + key: string, + acl?: ObjectCannedACL, +) { + await client.send( + new CopyObjectCommand({ + Bucket: env.S3_BUCKET, + Key: key, + CopySource: `/${env.S3_BUCKET}/${name}`, + ACL: acl, + }), + ); + + await client.send( + new DeleteObjectCommand({ + Bucket: env.S3_BUCKET, + Key: name, + }), + ); +} diff --git a/packages/artisan/src/consumer/workers/package.ts b/packages/artisan/src/consumer/workers/package.ts index ee3672e4..8b8ab7d8 100644 --- a/packages/artisan/src/consumer/workers/package.ts +++ b/packages/artisan/src/consumer/workers/package.ts @@ -5,7 +5,7 @@ import { lookup } from "mime-types"; import { fork } from "child_process"; import { createRequire } from "node:module"; import { by639_2T } from "iso-language-codes"; -import { downloadFolder, uploadFolder } from "../s3.js"; +import { copyFile, downloadFolder, uploadFolder } from "../s3.js"; import parseFilePath from "parse-filepath"; import * as z from "zod"; import { streamSchema } from "../../schemas.js"; @@ -85,7 +85,7 @@ export default async function (job: Job) { "--fragment_duration", "4", "--hls_master_playlist_output", - "master.m3u8", + "master_tmp.m3u8", ); const fakeRequire = createRequire(import.meta.url); @@ -109,6 +109,15 @@ export default async function (job: Job) { }), }); + // When we uploaded all files, including the "master_tmp" file, let's rename it so it + // becomes available on CDN. + // This way we ensure we have all the segments on S3 before we make the manifest available. + await copyFile( + `package/${job.data.assetId}/hls/master_tmp.m3u8`, + `package/${job.data.assetId}/hls/master.m3u8`, + "public-read", + ); + job.updateProgress(100); return { diff --git a/packages/artisan/src/consumer/workers/transcode.ts b/packages/artisan/src/consumer/workers/transcode.ts index 83dbdca8..8190d54b 100644 --- a/packages/artisan/src/consumer/workers/transcode.ts +++ b/packages/artisan/src/consumer/workers/transcode.ts @@ -1,14 +1,13 @@ import { getFakeJob } from "../../lib/job-helpers.js"; import { uploadJsonFile } from "../s3.js"; -import type { Input, Stream } from "../../schemas.js"; +import { addPackageJob } from "../../producer.js"; +import type { Stream } from "../../schemas.js"; import type { FfmpegResult } from "./ffmpeg.js"; import type { Job } from "bullmq"; export type TranscodeData = { assetId: string; - inputs: Input[]; - streams: Stream[]; - segmentSize: number; + package: boolean; }; export type TranscodeResult = { @@ -28,16 +27,23 @@ export default async function (job: Job) { } return acc; }, - {} + {}, ); await job.log(`Writing meta.json (${JSON.stringify(meta)})`); await uploadJsonFile( `transcode/${job.data.assetId}/meta.json`, - JSON.stringify(meta, null, 2) + JSON.stringify(meta, null, 2), ); + if (job.data.package) { + await job.log("Will queue package job"); + await addPackageJob({ + assetId: job.data.assetId, + }); + } + job.updateProgress(100); return { diff --git a/packages/artisan/src/producer.ts b/packages/artisan/src/producer.ts index 9902bd3b..6c0f9eb3 100644 --- a/packages/artisan/src/producer.ts +++ b/packages/artisan/src/producer.ts @@ -1,7 +1,7 @@ -import { Queue, FlowProducer } from "bullmq"; -import { randomUUID } from "node:crypto"; +import { Queue, FlowProducer, Job } from "bullmq"; import { connection } from "./connection.js"; -import type { Input } from "./schemas.js"; +import { randomUUID } from "crypto"; +import type { Input, Stream } from "./schemas.js"; import type { TranscodeData } from "./consumer/workers/transcode.js"; import type { PackageData } from "./consumer/workers/package.js"; import type { FfmpegData } from "./consumer/workers/ffmpeg.js"; @@ -29,36 +29,41 @@ const ffmpegQueue = new Queue("ffmpeg", { */ export const allQueus = [transcodeQueue, packageQueue, ffmpegQueue]; -export async function addTranscodeJob( - data: Omit & { assetId?: string } -) { - const assetId = data.assetId ?? randomUUID(); +type AddTranscodeJobData = { + assetId: string; + inputs: Input[]; + streams: Stream[]; + segmentSize: number; + package: boolean; +}; - const { inputs, streams, ...restParams } = data; +export async function addTranscodeJob(data: AddTranscodeJobData) { + const jobId = `transcode_${data.assetId}`; - const genericOptions = Object.assign(restParams, { - assetId, - }); + const pendingJob = await Job.fromId(transcodeQueue, jobId); + if (pendingJob) { + return pendingJob; + } let childJobIndex = 0; const childJobs: FlowChildJob[] = []; - for (const stream of streams) { + for (const stream of data.streams) { let input: Input | undefined; if (stream.type === "video") { - input = inputs.find((input) => input.type === "video"); + input = data.inputs.find((input) => input.type === "video"); } if (stream.type === "audio") { - input = inputs.find( - (input) => input.type === "audio" && input.language === stream.language + input = data.inputs.find( + (input) => input.type === "audio" && input.language === stream.language, ); } if (stream.type === "text") { - input = inputs.find( - (input) => input.type === "text" && input.language === stream.language + input = data.inputs.find( + (input) => input.type === "text" && input.language === stream.language, ); } @@ -70,32 +75,46 @@ export async function addTranscodeJob( if (stream.type === "audio" || stream.type === "text") { params.push(stream.language); } + childJobs.push({ name: `ffmpeg(${params.join(",")})`, data: { parentSortKey: ++childJobIndex, input, stream, - ...genericOptions, + segmentSize: data.segmentSize, + assetId: data.assetId, } satisfies FfmpegData, - queueName: "ffmpeg", + opts: { + jobId: `ffmpeg_${randomUUID()}`, + }, }); } } - return await flowProducer.add({ + const { job } = await flowProducer.add({ name: "transcode", queueName: "transcode", data: { - assetId, - }, + assetId: data.assetId, + package: data.package, + } satisfies TranscodeData, children: childJobs, + opts: { + jobId, + }, }); + + return job; } -export async function addPackageJob(data: PackageData) { +type AddPackageJobData = { + assetId: string; +}; + +export async function addPackageJob(data: AddPackageJobData) { return await packageQueue.add("package", data, { - jobId: randomUUID(), + jobId: `package_${data.assetId}`, }); } diff --git a/packages/dashboard/src/components/JobActions.tsx b/packages/dashboard/src/components/JobActions.tsx deleted file mode 100644 index bcd0d6cc..00000000 --- a/packages/dashboard/src/components/JobActions.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Button } from "@/components/ui/button"; -import hlsjsLogo from "@/assets/hlsjs.svg"; -import { usePlaylist } from "@/hooks/usePlaylist"; -import type { JobDto } from "@mixwave/api/client"; - -type JobActionsProps = { - job: JobDto; -}; - -export function JobActions({ job }: JobActionsProps) { - if (!job.outputData) { - return; - } - - if (job.name.startsWith("package")) { - return ; - } - - return null; -} - -function PackageJobActions({ job }: { job: JobDto }) { - const outputData = JSON.parse(job.outputData ?? ""); - - const { data } = usePlaylist(outputData.assetId); - - return ( - - ); -} diff --git a/packages/dashboard/src/components/JobView.tsx b/packages/dashboard/src/components/JobView.tsx index 901f71a9..af76ef4f 100644 --- a/packages/dashboard/src/components/JobView.tsx +++ b/packages/dashboard/src/components/JobView.tsx @@ -1,5 +1,4 @@ import { getTimeAgo } from "@/lib/helpers"; -import { JobActions } from "./JobActions"; import { JobLogs } from "./JobLogs"; import { AlertCircle } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -13,7 +12,6 @@ export function JobView({ job }: JobViewProps) { return ( <> {job.failedReason ? : null} -
Created
@@ -57,7 +55,7 @@ function Format({ data }: { data: string | null }) { } catch {} return parsedData ? ( -
+    
       {JSON.stringify(parsedData, null, 2)}
     
) : null; diff --git a/packages/dashboard/src/hooks/usePlaylist.ts b/packages/dashboard/src/hooks/usePlaylist.ts deleted file mode 100644 index a28ef72b..00000000 --- a/packages/dashboard/src/hooks/usePlaylist.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; -import { apiClient } from "@/lib/api"; - -export function usePlaylist(assetId: string) { - return useSuspenseQuery({ - queryKey: ["playlist", assetId], - queryFn: async () => { - const response = await apiClient.postPlaylist({ - params: { assetId }, - body: { - interstitials: [], - }, - }); - if (response.status !== 200) { - throw new Error("error"); - } - return response.body; - }, - }); -} diff --git a/packages/dashboard/src/lib/helpers.ts b/packages/dashboard/src/lib/helpers.ts index eb16d411..4a8731d4 100644 --- a/packages/dashboard/src/lib/helpers.ts +++ b/packages/dashboard/src/lib/helpers.ts @@ -10,9 +10,9 @@ export function getDurationStr(job: JobDto) { return prettyMs(duration); } -export function getShortId(prefixedId: string) { - const [_, id] = prefixedId.split("_"); - return id.substring(0, 7); +export function getShortId(id: string) { + const chunks = id.split("_", 3); + return chunks[chunks.length - 1].substring(0, 7); } export function getTimeAgo(value: number | null) { diff --git a/packages/stitcher/.env.example b/packages/stitcher/.env.example index 24889a7e..6ef6fca4 100644 --- a/packages/stitcher/.env.example +++ b/packages/stitcher/.env.example @@ -1 +1,4 @@ +PORT= +REDIS_HOST= +REDIS_PORT= S3_PUBLIC_URL= \ No newline at end of file diff --git a/packages/stitcher/extern/hls-parser/stringify.ts b/packages/stitcher/extern/hls-parser/stringify.ts index 90c04667..b78b19ee 100644 --- a/packages/stitcher/extern/hls-parser/stringify.ts +++ b/packages/stitcher/extern/hls-parser/stringify.ts @@ -352,13 +352,24 @@ function buildMediaPlaylist( for (const interstitial of playlist.interstitials) { const params: string[] = ['CLASS="com.apple.hls.interstitial"']; params.unshift(`ID="${interstitial.id}"`); + + params.push(`START-DATE="${interstitial.startDate.toISOString()}"`); + + if (interstitial.duration) { + params.push(`DURATION=${interstitial.duration}`); + } + if (interstitial.uri) { + params.push(`X-ASSET-URI="${interstitial.uri}"`); + } + if (interstitial.list) { + params.push(`X-ASSET-LIST="${interstitial.list}"`); + } + params.push( - `START-DATE="${interstitial.startDate.toISOString()}"`, - `DURATION=${interstitial.duration}`, - `X-ASSET-URI="${interstitial.uri}"`, `X-RESUME-OFFSET=${interstitial.resumeOffset}`, `X-RESTRICT="${interstitial.restrict}"`, ); + lines.push(`#EXT-X-DATERANGE:${params.join(",")}`); } } diff --git a/packages/stitcher/extern/hls-parser/types.ts b/packages/stitcher/extern/hls-parser/types.ts index 21f934cd..ed11d892 100644 --- a/packages/stitcher/extern/hls-parser/types.ts +++ b/packages/stitcher/extern/hls-parser/types.ts @@ -54,8 +54,9 @@ class Rendition { class Interstitial { id: string; - uri: string; - duration: string; + uri?: string; + list?: string; + duration?: string; startDate: Date; resumeOffset?: number; restrict?: string; @@ -63,6 +64,7 @@ class Interstitial { constructor({ id, uri, + list, duration, startDate, resumeOffset = 0, @@ -70,6 +72,7 @@ class Interstitial { }: any) { this.id = id; this.uri = uri; + this.list = list; this.duration = duration; this.startDate = startDate; this.resumeOffset = resumeOffset; diff --git a/packages/stitcher/extern/vast-client/LICENSE b/packages/stitcher/extern/vast-client/LICENSE new file mode 100644 index 00000000..7499148d --- /dev/null +++ b/packages/stitcher/extern/vast-client/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2013 Olivier Poitrey + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/packages/stitcher/extern/vast-client/README.md b/packages/stitcher/extern/vast-client/README.md new file mode 100644 index 00000000..10580710 --- /dev/null +++ b/packages/stitcher/extern/vast-client/README.md @@ -0,0 +1,167 @@ +[![npm version](https://badgen.net/npm/v/vast-client)](https://badgen.net/npm/v/vast-client) +[![downloads per week](https://badgen.net/npm/dw/vast-client)](https://badgen.net/npm/dw/vast-client) +[![license](https://badgen.net/npm/license/vast-client)](https://badgen.net/npm/license/vast-client) + +# VAST Client JS + +Vast Client JS is a JavaScript library to fetch and parse Digital Video Ad Serving Template (VAST) documents. + +This library provides three components: + +* A **VAST Client** to fetch and parse VAST XML resources into JavaScript Objects. +* A **VAST Parser** to directly parse a VAST XML. +* A **VAST Tracker** to batch and call tracking URLs. + +For the full API documentation go [here](#api). +For the full Class reference go [here](https://github.com/dailymotion/vast-client-js/blob/master/docs/api/class-reference.md) + +To explore a practical example of how the vast client can be implemented, please visit this [link](https://githubbox.com/dailymotion/vast-client-js/tree/master/examples). + +Complies with the [VAST 4.3 specification](https://iabtechlab.com/wp-content/uploads/2022/09/VAST_4.3.pdf) provided by the [Interactive Advertising Bureau (IAB)](https://www.iab.com/). + +## Get Started + +VAST Client JS is available as an NPM package and can be easily installed with: + +```Bash +npm i @dailymotion/vast-client +``` + +Then import the components you need. + +### VASTClient + +If you need to fetch and parse VAST documents, you can use the `get` method from the **VASTClient**: + +```javascript +import { VASTClient } from '@dailymotion/vast-client' + +const vastClient = new VASTClient(); + +vastClient.get('https://www.examplevast.com/vast.xml') + .then(parsedVAST => { + // Do something with the parsed VAST response + }) + .catch(err => { + // Deal with the error + }); +``` + +In addition to fetching and parsing a VAST resource, **VASTClient** provides options to filter a sequence of calls based on count and time of execution, together with the possibility to track URLs using **VASTTracker**. + +If you need to directly parse a VAST XML and also follow any wrappers chain, you can use the `parseVAST` method from the **VASTClient** : + +```javascript +import { VASTClient } from '@dailymotion/vast-client' + +const vastClient = new VASTClient(); + +vastClient.parseVAST(vastXml) + .then(parsedVAST => { + // Do something with the parsed VAST response + }) + .catch(err => { + // Deal with the error + }); +``` +### VASTParser + +To directly parse a VAST XML you can use the **VASTParser**: +The **VASTParser** will make no fetching, the final response will only contain the first VAST encountered. + +```Javascript +import { VASTParser } from '@dailymotion/vast-client' + +const vastParser = new VASTParser(); + +vastParser.parseVAST(vastXml) + .then(parsedVAST => { + // Do something with the parsed VAST response + }) + .catch(err => { + // Deal with the error + }); +``` + +### VASTTracker + +To track the execution of an ad, create a **VASTTracker** instance and use the dedicated [methods](docs/api/vast-tracker.md) to calls [VAST tracking elements](https://iabtechlab.com/wp-content/uploads/2019/06/VAST_4.2_final_june26.pdf#page=28). + +```Javascript +import { VASTTracker } from '@dailymotion/vast-client' + +const vastTracker = new VASTTracker(vastClient, ad, creative); + +// Track an impression for the given ad. Will call any URI from the and tracking elements. +vastTracker.trackImpression(); +``` + +## API Documentation + +The API documentation is organized by components: + +* [VASTClient](docs/api/vast-client.md) +* [VASTParser](docs/api/vast-parser.md) +* [VASTTracker](docs/api/vast-tracker.md) + +Changelog and migration guides can be found in the [release notes](https://github.com/dailymotion/vast-client-js/releases). + +### Pre-bundled versions + +We provide several pre-bundled versions of the client (see [`dist` directory](dist/)) + +#### Bundlers + +A version for js bundlers (like webpack or rollup) is available by default when adding the lib using a package manager (like npm or yarn): [`vast-client.js`](dist/vast-client.js) or [`vast-client.min.js`](dist/vast-client.min.js) [minified]. + +```javascript +const import { + VASTClient, + VASTParser, + VASTTracker +} from '@dailymotion/vast-client' + +const vastClient = new VASTClient(); +const vastParser = new VASTParser(); +const vastTracker = new VASTTracker(); +``` + +#### Browser script + +A pre-bundled version of VAST Client JS is available: [`vast-client.min.js`](dist/vast-client.min.js) [minified]. + +To use it, either host it on your CDN or locally in your project. If you're using a script tag make sure to set the type property to module like below. + +_your index.html_ +```html + +``` +_main.js_ +```javascript +import {VASTClient, VASTParser, VASTTracker} from "vast-client.min.js" + +const vastClient = new VASTClient(); +const vastParser = new VASTParser(); +const vastTracker = new VASTTracker(); +``` + +#### Node + +A pre-bundled version for node is available too: [`vast-client-node.js`](dist/vast-client-node.js) or [`vast-client-node.min.js`](dist/vast-client-node.min.js) [minified]. + +```javascript +// Method 1: From npm +const VAST = require('@dailymotion/vast-client') + +// Method 2: For pre-bundled you must copy first the file inside your project +// then you will be able to require it without the need of npm +const VAST = require('your/path/vast-client-node.min.js') + +const vastClient = new VAST.VASTClient(); +const vastParser = new VAST.VASTParser(); +const vastTracker = new VAST.VASTTracker(); +``` + +## Build / Contribute + +See [CONTRIBUTING](docs/CONTRIBUTING.md) diff --git a/packages/stitcher/extern/vast-client/index.d.ts b/packages/stitcher/extern/vast-client/index.d.ts new file mode 100644 index 00000000..d0a8d9e2 --- /dev/null +++ b/packages/stitcher/extern/vast-client/index.d.ts @@ -0,0 +1,513 @@ +/// + +import { EventEmitter } from "events"; + +export class VASTTracker extends EventEmitter { + /** + * The VAST tracker constructor will process the tracking URLs of the selected ad/creative and returns an instance of VASTTracker. + */ + constructor( + /** + * An optional instance of VASTClient that can be updated by the tracker. + */ + client: VASTClient | null, + /** + * The ad of the selected mediaFile to track + */ + ad: VastAd, + /** + * The creative of the selected mediaFile to track + */ + creative: VastCreativeLinear, + /** + * An optional variation of the creative, for Companion and NonLinear Ads + */ + variation?: VastCreativeCompanion | VastCreativeNonLinear, + ); + /** + * Sets the duration of the ad and updates the quartiles based on that. + */ + setDuration(duration: number): void; + /** + * Update the current time value. + * This is required for tracking time related events such as start, firstQuartile, midpoint, thirdQuartile or rewind. + */ + setProgress( + /** + * Current playback time in seconds. + */ + progress: number, + ): void; + /** + * Update the mute state and call the mute/unmute tracking URLs. Emit a mute or unmute event. + */ + setMuted( + /** + * Indicate if the video is muted or not. + */ + muted: boolean, + ): void; + /** + * Update the pause state and call the resume/pause tracking URLs. Emit a resume or pause event. + */ + setPaused( + /** + * Indicate if the video is paused or not. + */ + paused: boolean, + ): void; + /** + * Update the fullscreen state and call the fullscreen tracking URLs. Emit a fullscreen or exitFullscreen event. + */ + setFullscreen( + /** + * Indicate the fullscreen mode. + */ + fullscreen: boolean, + ): void; + /** + * Update the expand state and call the expand/collapse tracking URLs. Emit a expand or collapse event + */ + setExpand( + /** + * Indicate if the video is expanded or no + */ + expanded: boolean, + ): void; + /** + * Must be called if you want to overwrite the Skipoffset value. This will init the skip countdown duration. + * Then, every time you call setProgress(), it will decrease the countdown and emit a skip-countdown event with the remaining time. + * Do not call this method if you want to keep the original Skipoffset value. + */ + setSkipDelay( + /** + * The time in seconds until the skip button is displayed. + */ + duration: number, + ): void; + /** + * Report the impression URI. Can only be called once. Will report the following URI: + * + * - All URI from the and tracking elements. + * - The creativeView URI from the events + * + * Once done, a creativeView event is emitted. + */ + trackImpression(): void; + /** + * Send a request to the URI provided by the VAST element. If an [ERRORCODE] macro is included, it will be substitute with code. + */ + errorWithCode( + /** + * Replaces [ERRORCODE] macro. [ERRORCODE] values are liste in the VAST specification. + */ + errorCode: string, + ): void; + /** + * Must be called when the user watched the linear creative until its end. Call the complete tracking URLs. + * Emit a complete events when done. + */ + complete(): void; + /** + * Must be called when the player or the window is closed during the ad. Call the closeLinear (in VAST 3.0) and close tracking URLs. + * Emit a closeLinear or a close event when done. + */ + close(): void; + /** + * Must be called when the skip button is clicked. Call the skip tracking URLs. Emit a skip event when done. + */ + skip(): void; + /** + * Must be called when the user clicks on the creative. Call the tracking URLs. + * Emit a clickthrough event with the resolved clickThrough URL when done. + */ + click(): void; + /** + * Calls the tracking URLs for the given eventName and emits the event. + */ + track( + /** + * The name of the event. Call the specified event tracking URLs. Emit the specified event when done. + */ + eventName: string, + trackOptions?: TrackOptions, + ): void; +} + +export class VASTClient { + constructor( + /** + * Used for ignoring the first n calls. Automatically reset 1 hour after the 1st ignored call. Free Lunch capping is disable if sets to 0. + * Default: 0 + */ + cappingFreeLunch?: number, + /** + * Used for ignoring calls that happen n ms after the previous call. Minimum time interval is disabled if sets to 0. + * Default: 0 + */ + cappingMinimumTimeInterval?: number, + /** + * Optional custom storage to be used instead of the default one + */ + customStorage?: VASTClientCustomStorage, + ); + cappingFreeLunch: number; + cappingMinimumTimeInterval: number; + storage: VASTClientCustomStorage | Storage; + /** + * Fetch a URL and parse the response into a valid VAST object. + * + * @param url Contains the URL for fetching the VAST XML document. + * @param options An optional set of key/value to configure the Ajax request + */ + get(url: string, options?: VastRequestOptions): Promise; + /** + * Returns a boolean indicating if there are more ads to resolve for the current parsing. + */ + hasRemainingAds(): boolean; + /** + * Resolves the next group of ads. If all is true resolves all the remaining ads. + */ + getNextAds(all?: boolean): Promise; + /** + * Returns the instance of VASTParser used by the client to parse the VAST. + * Use it to directly call a method provided by the VASTParser class. + */ + getParser(): VASTParser; + + parseVAST(doc: Document): Promise; +} + +export class VASTParser extends EventEmitter { + rootURL?: string | undefined; + + /** + * util method for handling urls, it is used to make the requests. + */ + urlHandler: VASTClientUrlHandler; + /** + * Add the replace function at the end of the URLTemplateFilters array. + * All functions in URLTemplateFilters will be called with the VAST URL as parameter before fetching the VAST URL document. + */ + addURLTemplateFilter(cb: (vastUrl: string) => string): void; + /** + * Removes the last element of the url templates filters array. + */ + removeURLTemplateFilter(): void; + /** + * Reset URLTemplateFilters to empty, previous replace function set with addURLTemplateFilter() are no longer called. + */ + clearUrlTemplateFilters(): void; + /** + * Returns how many replace function are set (ie: URLTemplateFilters length) + */ + countURLTemplateFilters(): number; + /** + * Tracks the error provided in the errorCode parameter and emits a VAST-error event for the given error. + */ + trackVastError( + /** + * An Array of url templates to use to make the tracking call + */ + urlTemplates: string[], + errorCode: Pick, + ...data: Array>> + ): void; + /** + * Fetches a VAST document for the given url. + * Returns a Promise which resolves with the fetched xml or rejects with an error, according to the result of the request. + */ + fetchVAST( + /** + * The url to request the VAST document. + */ + url: string, + /** + * how many times the current url has been wrapped + */ + wrapperDepth?: number, + /** + * url of original wrapper + */ + previousUrl?: string, + ): Promise; + /** + * Fetches and parses a VAST for the given url. + * Returns a Promise which resolves with a fully parsed VASTResponse or rejects with an Error. + */ + getAndParseVAST( + /** + * The url to request the VAST document. + */ + url: string, + /** + * An optional Object of parameters to be used in the parsing process. + */ + options?: VastRequestOptions, + ): Promise; + /** + * Parses the given xml Object into a VASTResponse. + * Returns a Promise which either resolves with the fully parsed VASTResponse or rejects with an Error. + */ + parseVAST( + /** + * A VAST XML document + */ + vastXml: Document, + /** + * An optional Object of parameters to be used in the parsing process. + */ + options?: VastRequestOptions, + ): Promise; + + /** + * Parses the given xml Object into an array of ads + * Returns the array or throws an `Error` if an invalid VAST XML is provided + */ + parseVastXml(vastXml: Document, options: ParseVastXmlOptions): VastAd[]; +} + +export interface VASTClientCustomStorage { + getItem(key: string): string | null; + setItem(key: string, val: string): void; + [key: string]: any | (() => any); +} + +export function UrlHandlerCbType(err: null, xml: XMLDocument): void; +export function UrlHandlerCbType(err: Error): void; + +export interface VASTClientUrlHandler { + get( + url: string, + options: { timeout: number; withCredentials: boolean }, + cb: typeof UrlHandlerCbType, + ): void; +} + +export interface VastRequestOptions { + /** + * A custom timeout for the requests (default 0) + */ + timeout?: number | undefined; + /** + * A boolean to enable the withCredentials options for the XHR and FLASH URLHandlers (default false) + */ + withCredentials?: boolean | undefined; + /** + * A number of Wrapper responses that can be received with no InLine response (default 0) + */ + wrapperLimit?: number | undefined; + /** + * Custom urlhandler to be used instead of the default ones urlhandlers + */ + urlHandler?: VASTClientUrlHandler | undefined; + /** + * Allows you to parse all the ads contained in the VAST or to parse them ad by ad or adPod by adPod (default true) + */ + resolveAll?: boolean | undefined; +} + +export interface VastResponse { + ads: VastAd[]; + errorURLTemplates: string[]; +} + +export interface VastError { + /** + * Whenever an error occurs during the VAST parsing, the parser will call on its own all related tracking error URLs. Reported errors are: + * no_ad: The VAST document is empty + * VAST error 101: VAST schema validation error. + * VAST error 301: Timeout of VAST URI provided in Wrapper element. + * VAST error 302: Wrapper limit reached. + * VAST error 303: No VAST response after one or more Wrappers. + */ + ERRORCODE: string | number; + ERRORMESSAGE?: string | undefined; + extensions?: VastAdExtension[] | undefined; + system?: VastSystem | string | null | undefined; +} + +export interface VastCreative { + id: string | null; + adId: string | null; + trackingEvents: VastTrackingEvents; + apiFramework: string | null; + sequence: string | number | null; + type: string; +} + +export interface VastCreativeLinear extends VastCreative { + adParameters: string | null; + duration: number; + icons: VastIcon[]; + mediaFiles: VastMediaFile[]; + skipDelay: number | null; + videoClickThroughURLTemplate: VastUrlValue | null; + videoClickTrackingURLTemplates: VastUrlValue[]; + videoCustomClickURLTemplates: VastUrlValue[]; +} + +export interface VastCreativeNonLinear extends VastCreative { + variations: VastNonLinearAd[]; +} + +export interface VastCreativeCompanion extends VastCreative { + variations: VastCompanionAd[]; +} + +export interface VastAd { + advertiser: VastAdvertiser[]; + creatives: VastCreative[]; + description: string | null; + errorURLTemplates: string[]; + extensions: VastAdExtension[]; + id: string | null; + impressionURLTemplates: VastUrlValue[]; + pricing: string | null; + sequence: string | null; + survey: string | null; + system: VastSystem | string | null; + title: string | null; +} + +export interface VastAdExtension { + name: string | null; + value: any; + attributes: VastAdAttributes; + children: VastAdExtensionChild[]; +} + +export interface VastAdAttributes { + type: string; + fallback_index: string | null; +} + +export interface VastAdExtensionChild { + attributes: VastAdChildAttributes; + name: string | undefined; + value: string | number; +} + +export interface VastAdChildAttributes { + [key: string]: any; +} + +export interface VastNonLinearAd { + nonLinearClickTrackingURLTemplates: VastUrlValue[]; + nonLinearClickThroughURLTemplate: string | null; + adParameters: string | null; + type: string | null; + iframeResource: string | null; + htmlResource: string | null; + id: string | null; + width: string; + height: string; + expandedWidth: string; + expandedHeight: string; + scalable: boolean; + maintainAspectRatio: boolean; + minSuggestedDuration: number; + apiFramework: string; + staticResource: string | null; +} + +export interface VastCompanionAd { + companionClickThroughURLTemplate: string | null; + companionClickTrackingURLTemplate: string | null | undefined; + companionClickTrackingURLTemplates: VastUrlValue[]; + height: string; + htmlResources: string[]; + id: string | null; + iframeResources: string[]; + staticResources: StaticResource[]; + trackingEvents: VastCompanionTrackingEvents; + width: string; + altText: string | null; +} + +export interface VastCompanionTrackingEvents { + creativeView: string[]; + [key: string]: string[]; +} + +export interface VastMediaFile { + apiFramework: string | null; + bitrate: number; + codec: string | null; + deliveryType: string; + fileURL: string | null; + height: number; + id: string | null; + maintainAspectRatio: boolean | null; + maxBitrate: number; + mimeType: string | null; + minBitrate: number; + scalable: boolean | null; + width: number; +} + +export interface VastTrackingEvents { + complete: string[]; + firstQuartile: string[]; + midpoint: string[]; + thirdQuartile: string[]; + [key: string]: string[]; +} + +export interface VastSystem { + value: string; + version: string | null; +} + +export interface VastIcon { + program: string | null; + height: number; + width: number; + xPosition: number; + yPosition: number; + apiFramework: string | null; + offset: string | null; + duration: number; + type: string | null; + staticResource: string | null; + htmlResource: string | null; + iframeResource: string | null; + iconClickThroughURLTemplate: string | null; + iconClickTrackingURLTemplates: VastUrlValue[]; + iconViewTrackingURLTemplate: string | null; +} + +export interface VastAdvertiser { + id: string | null; + value: string; +} + +export interface VastUrlValue { + id: string | null; + url: string; +} + +export interface StaticResource { + url: string; + creativeType: string | null; +} + +export interface TrackOptions { + /** + * An optional Object of parameters(vast macros) to be used in the tracking calls. + */ + macros?: Record | undefined; + /** + * Indicate if the event has to be tracked only once. + * Default: false + */ + once?: boolean | undefined; +} + +export interface ParseVastXmlOptions { + isRootVAST?: boolean | undefined; + url?: string | null | undefined; + wrapperDepth?: number | undefined; + allowMultipleAds?: boolean | undefined; + followAdditionalWrappers?: boolean | undefined; +} diff --git a/packages/stitcher/extern/vast-client/index.js b/packages/stitcher/extern/vast-client/index.js new file mode 100644 index 00000000..e258df15 --- /dev/null +++ b/packages/stitcher/extern/vast-client/index.js @@ -0,0 +1 @@ +export * from "./src/index.js"; diff --git a/packages/stitcher/extern/vast-client/src/ad.js b/packages/stitcher/extern/vast-client/src/ad.js new file mode 100644 index 00000000..4060b6a7 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/ad.js @@ -0,0 +1,26 @@ +export function createAd(adAttributes = {}) { + return { + id: adAttributes.id || null, + sequence: adAttributes.sequence || null, + adType: adAttributes.adType || null, + adServingId: null, + categories: [], + expires: null, + viewableImpression: [], + system: null, + title: null, + description: null, + advertiser: null, + pricing: null, + survey: null, // @deprecated in VAST 4.1 + errorURLTemplates: [], + impressionURLTemplates: [], + creatives: [], + extensions: [], + adVerifications: [], + blockedAdCategories: [], + followAdditionalWrappers: true, + allowMultipleAds: false, + fallbackOnNoAd: null, + }; +} diff --git a/packages/stitcher/extern/vast-client/src/ad_verification.js b/packages/stitcher/extern/vast-client/src/ad_verification.js new file mode 100644 index 00000000..ea184718 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/ad_verification.js @@ -0,0 +1,11 @@ +export function createAdVerification() { + return { + resource: null, + vendor: null, + browserOptional: false, + apiFramework: null, + type: null, + parameters: null, + trackingEvents: {}, + }; +} diff --git a/packages/stitcher/extern/vast-client/src/closed_caption_file.js b/packages/stitcher/extern/vast-client/src/closed_caption_file.js new file mode 100644 index 00000000..46a95997 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/closed_caption_file.js @@ -0,0 +1,7 @@ +export function createClosedCaptionFile(closedCaptionAttributes = {}) { + return { + type: closedCaptionAttributes.type || null, + language: closedCaptionAttributes.language || null, + fileURL: null, + }; +} diff --git a/packages/stitcher/extern/vast-client/src/companion_ad.js b/packages/stitcher/extern/vast-client/src/companion_ad.js new file mode 100644 index 00000000..21e0b02c --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/companion_ad.js @@ -0,0 +1,28 @@ +export function createCompanionAd(creativeAttributes = {}) { + return { + id: creativeAttributes.id || null, + adType: 'companionAd', + width: creativeAttributes.width || 0, + height: creativeAttributes.height || 0, + assetWidth: creativeAttributes.assetWidth || null, + assetHeight: creativeAttributes.assetHeight || null, + expandedWidth: creativeAttributes.expandedWidth || null, + expandedHeight: creativeAttributes.expandedHeight || null, + apiFramework: creativeAttributes.apiFramework || null, + adSlotId: creativeAttributes.adSlotId || null, + pxratio: creativeAttributes.pxratio || '1', + renderingMode: creativeAttributes.renderingMode || 'default', + staticResources: [], + htmlResources: [], + iframeResources: [], + adParameters: null, + altText: null, + companionClickThroughURLTemplate: null, + companionClickTrackingURLTemplates: [], + trackingEvents: {}, + }; +} + +export function isCompanionAd(ad) { + return ad.adType === 'companionAd'; +} diff --git a/packages/stitcher/extern/vast-client/src/creative/creative.js b/packages/stitcher/extern/vast-client/src/creative/creative.js new file mode 100644 index 00000000..38d62f6e --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/creative/creative.js @@ -0,0 +1,10 @@ +export function createCreative(creativeAttributes = {}) { + return { + id: creativeAttributes.id || null, + adId: creativeAttributes.adId || null, + sequence: creativeAttributes.sequence || null, + apiFramework: creativeAttributes.apiFramework || null, + universalAdIds: [], + creativeExtensions: [], + }; +} diff --git a/packages/stitcher/extern/vast-client/src/creative/creative_companion.js b/packages/stitcher/extern/vast-client/src/creative/creative_companion.js new file mode 100644 index 00000000..a4f5f95d --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/creative/creative_companion.js @@ -0,0 +1,15 @@ +import { createCreative } from "./creative.js"; + +export function createCreativeCompanion(creativeAttributes = {}) { + const { id, adId, sequence, apiFramework } = + createCreative(creativeAttributes); + return { + id, + adId, + sequence, + apiFramework, + type: "companion", + required: null, + variations: [], + }; +} diff --git a/packages/stitcher/extern/vast-client/src/creative/creative_linear.js b/packages/stitcher/extern/vast-client/src/creative/creative_linear.js new file mode 100644 index 00000000..93b939bb --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/creative/creative_linear.js @@ -0,0 +1,29 @@ +import { createCreative } from "./creative.js"; + +export function createCreativeLinear(creativeAttributes = {}) { + const { id, adId, sequence, apiFramework } = + createCreative(creativeAttributes); + return { + id, + adId, + sequence, + apiFramework, + type: "linear", + duration: 0, + skipDelay: null, + mediaFiles: [], + mezzanine: null, + interactiveCreativeFile: null, + closedCaptionFiles: [], + videoClickThroughURLTemplate: null, + videoClickTrackingURLTemplates: [], + videoCustomClickURLTemplates: [], + adParameters: null, + icons: [], + trackingEvents: {}, + }; +} + +export function isCreativeLinear(ad) { + return ad.type === "linear"; +} diff --git a/packages/stitcher/extern/vast-client/src/creative/creative_non_linear.js b/packages/stitcher/extern/vast-client/src/creative/creative_non_linear.js new file mode 100644 index 00000000..5b741593 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/creative/creative_non_linear.js @@ -0,0 +1,15 @@ +import { createCreative } from "./creative.js"; + +export function createCreativeNonLinear(creativeAttributes = {}) { + const { id, adId, sequence, apiFramework } = + createCreative(creativeAttributes); + return { + id, + adId, + sequence, + apiFramework, + type: "nonlinear", + variations: [], + trackingEvents: {}, + }; +} diff --git a/packages/stitcher/extern/vast-client/src/extension.js b/packages/stitcher/extern/vast-client/src/extension.js new file mode 100644 index 00000000..d4d08e4d --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/extension.js @@ -0,0 +1,16 @@ +export function createExtension() { + return { + name: null, + value: null, + attributes: {}, + children: [], + }; +} + +export function isEmptyExtension(extension) { + return ( + extension.value === null && + Object.keys(extension.attributes).length === 0 && + extension.children.length === 0 + ); +} diff --git a/packages/stitcher/extern/vast-client/src/fetcher/consts.js b/packages/stitcher/extern/vast-client/src/fetcher/consts.js new file mode 100644 index 00000000..415089eb --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/fetcher/consts.js @@ -0,0 +1 @@ +export const DEFAULT_TIMEOUT = 120000; diff --git a/packages/stitcher/extern/vast-client/src/fetcher/fetcher.js b/packages/stitcher/extern/vast-client/src/fetcher/fetcher.js new file mode 100644 index 00000000..0f06e4ab --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/fetcher/fetcher.js @@ -0,0 +1,117 @@ +import { updateEstimatedBitrate } from "../parser/bitrate.js"; +import { urlHandler } from "./url_handler.js"; +import { DEFAULT_TIMEOUT } from "./consts.js"; + +/** + * This class provides a method to fetch a VAST document + * @exports + * @class Fetcher + */ + +export class Fetcher { + constructor() { + this.URLTemplateFilters = []; + } + + /** + * Inits the fetching properties of the class with the custom values provided as options + * @param {Object} options - The options to initialize before fetching + */ + setOptions(options = {}) { + this.urlHandler = options.urlHandler || options.urlhandler || urlHandler; + this.fetchingOptions = { + timeout: options.timeout || DEFAULT_TIMEOUT, + withCredentials: Boolean(options.withCredentials), + }; + } + + /** + * Adds a filter function to the array of filters which are called before fetching a VAST document. + * @param {function} filter - The filter function to be added at the end of the array. + */ + addURLTemplateFilter(filter) { + if (typeof filter === "function") { + this.URLTemplateFilters.push(filter); + } + } + + /** + * Removes the latest URL template filter added. + */ + removeLastURLTemplateFilter() { + this.URLTemplateFilters.pop(); + } + + /** + * Returns the number of URL template filters added. + * @return {Number} + */ + countURLTemplateFilters() { + return this.URLTemplateFilters.length; + } + + /** + * Removes all the URL template filters added. + */ + clearURLTemplateFilters() { + this.URLTemplateFilters = []; + } + + /** + * Fetches a VAST document for the given url. + * @param {Object} params + * @param {String} params.url - The url to request the VAST document. + * @param {Number} params.wrapperDepth - How many times the current url has been wrapped. + * @param {(String | null)} params.previousUrl - Url of the previous VAST. + * @param {Object} params.wrapperAd - Previously parsed ad node (Wrapper) related to this fetching. + * @param {Number} params.maxWrapperDepth - The maximum number of Wrapper that can be fetch + * @param {Function} params.emitter - The function used to Emit event + * @emits VASTParser#VAST-resolving + * @emits VASTParser#VAST-resolved + * @return {Promise} + */ + async fetchVAST({ + url, + maxWrapperDepth, + emitter, + wrapperDepth = 0, + previousUrl = null, + wrapperAd = null, + }) { + const timeBeforeGet = Date.now(); + + // Process url with defined filter + this.URLTemplateFilters.forEach((filter) => { + url = filter(url); + }); + + emitter("VAST-resolving", { + url, + previousUrl, + wrapperDepth, + maxWrapperDepth, + timeout: this.fetchingOptions.timeout, + wrapperAd, + }); + + const data = await this.urlHandler.get(url, this.fetchingOptions); + const requestDuration = Math.round(Date.now() - timeBeforeGet); + + emitter("VAST-resolved", { + url, + previousUrl, + wrapperDepth, + error: data?.error || null, + duration: requestDuration, + statusCode: data?.statusCode || null, + ...data?.details, + }); + updateEstimatedBitrate(data?.details?.byteLength, requestDuration); + + if (data.error) { + throw new Error(data.error); + } else { + return data.xml; + } + } +} diff --git a/packages/stitcher/extern/vast-client/src/fetcher/url_handler.js b/packages/stitcher/extern/vast-client/src/fetcher/url_handler.js new file mode 100644 index 00000000..571d612c --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/fetcher/url_handler.js @@ -0,0 +1,88 @@ +import { DEFAULT_TIMEOUT } from "./consts.js"; +import { util } from "../util/util.js"; + +/** + * Return an object containing an XML document. + * in addition to the byteLength and the statusCode of the response. + * @param {Object} response the response of the fetch request. + * @returns {Object} + */ +async function handleResponse(response) { + const textXml = await response.text(); + let parser; + + if (!util.isBrowserEnvironment()) { + const xmlDom = await import("@xmldom/xmldom"); + parser = new xmlDom.DOMParser(); + } else { + parser = new DOMParser(); + } + + const xml = parser.parseFromString(textXml, "text/xml"); + return { + xml, + details: { + byteLength: textXml.length, + statusCode: response.status, + rawXml: textXml, + }, + }; +} + +/** + * Return a custom message if an error occured + * @param {Object} response The response of fetch request + * @returns {String | null} + */ +function handleError(response) { + if ( + typeof window !== "undefined" && + window.location.protocol === "https:" && + response.url.includes("http://") + ) { + return "URLHandler: Cannot go from HTTPS to HTTP."; + } + + if (response.status !== 200 || !response.ok) { + return `URLHandler: ${response.statusText} (${response.status})`; + } + return null; +} + +async function get(url, options) { + try { + // fetch does not have "timeout" option, we are using AbortController + // to abort the request if it reach the timeout. + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + throw new Error( + `URLHandler: Request timed out after ${ + options.timeout || DEFAULT_TIMEOUT + } ms (408)`, + ); + }, options.timeout || DEFAULT_TIMEOUT); + + const response = await fetch(url, { + ...options, + signal: controller.signal, + credentials: options.withCredentials ? "include" : "omit", + }); + clearTimeout(timer); + + const error = handleError(response); + if (error) { + return { error: new Error(error), statusCode: response.status }; + } + return handleResponse(response); + } catch (error) { + return { + error, + statusCode: error.name === "AbortError" ? 408 : null, + }; + } +} + +export const urlHandler = { + get, +}; diff --git a/packages/stitcher/extern/vast-client/src/icon.js b/packages/stitcher/extern/vast-client/src/icon.js new file mode 100644 index 00000000..f8accf9d --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/icon.js @@ -0,0 +1,21 @@ +export function createIcon() { + return { + program: null, + height: 0, + width: 0, + xPosition: 0, + yPosition: 0, + apiFramework: null, + offset: null, + duration: 0, + type: null, + staticResource: null, + htmlResource: null, + iframeResource: null, + pxratio: '1', + iconClickThroughURLTemplate: null, + iconClickTrackingURLTemplates: [], + iconViewTrackingURLTemplate: null, + iconClickFallbackImages: [], + }; +} diff --git a/packages/stitcher/extern/vast-client/src/index.js b/packages/stitcher/extern/vast-client/src/index.js new file mode 100644 index 00000000..148510f7 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/index.js @@ -0,0 +1,6 @@ +import { VASTParser } from './parser/vast_parser.js'; +import { VASTClient } from './vast_client.js'; +import { VASTTracker } from './vast_tracker.js'; +import { parseDuration } from './parser/parser_utils.js'; + +export { VASTClient, VASTParser, VASTTracker, parseDuration }; diff --git a/packages/stitcher/extern/vast-client/src/interactive_creative_file.js b/packages/stitcher/extern/vast-client/src/interactive_creative_file.js new file mode 100644 index 00000000..336e30cb --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/interactive_creative_file.js @@ -0,0 +1,14 @@ +import { parserUtils } from "./parser/parser_utils.js"; + +export function createInteractiveCreativeFile( + interactiveCreativeAttributes = {}, +) { + return { + type: interactiveCreativeAttributes.type || null, + apiFramework: interactiveCreativeAttributes.apiFramework || null, + variableDuration: parserUtils.parseBoolean( + interactiveCreativeAttributes.variableDuration, + ), + fileURL: null, + }; +} diff --git a/packages/stitcher/extern/vast-client/src/media_file.js b/packages/stitcher/extern/vast-client/src/media_file.js new file mode 100644 index 00000000..48e3b006 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/media_file.js @@ -0,0 +1,19 @@ +export function createMediaFile() { + return { + id: null, + fileURL: null, + fileSize: 0, + deliveryType: 'progressive', + mimeType: null, + mediaType: null, + codec: null, + bitrate: 0, + minBitrate: 0, + maxBitrate: 0, + width: 0, + height: 0, + apiFramework: null, // @deprecated in VAST 4.1. should be used instead. + scalable: null, + maintainAspectRatio: null, + }; +} diff --git a/packages/stitcher/extern/vast-client/src/mezzanine.js b/packages/stitcher/extern/vast-client/src/mezzanine.js new file mode 100644 index 00000000..6b66bdf8 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/mezzanine.js @@ -0,0 +1,13 @@ +export function createMezzanine() { + return { + id: null, + fileURL: null, + delivery: null, + codec: null, + type: null, + width: 0, + height: 0, + fileSize: 0, + mediaType: '2D', + }; +} diff --git a/packages/stitcher/extern/vast-client/src/non_linear_ad.js b/packages/stitcher/extern/vast-client/src/non_linear_ad.js new file mode 100644 index 00000000..016bd7f5 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/non_linear_ad.js @@ -0,0 +1,25 @@ +export function createNonLinearAd() { + return { + id: null, + width: 0, + height: 0, + expandedWidth: 0, + expandedHeight: 0, + scalable: true, + maintainAspectRatio: true, + minSuggestedDuration: 0, + apiFramework: 'static', + adType: 'nonLinearAd', + type: null, + staticResource: null, + htmlResource: null, + iframeResource: null, + nonlinearClickThroughURLTemplate: null, + nonlinearClickTrackingURLTemplates: [], + adParameters: null, + }; +} + +export function isNonLinearAd(ad) { + return ad.adType === 'nonLinearAd'; +} diff --git a/packages/stitcher/extern/vast-client/src/parser/ad_parser.js b/packages/stitcher/extern/vast-client/src/parser/ad_parser.js new file mode 100644 index 00000000..f6904ef6 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/parser/ad_parser.js @@ -0,0 +1,413 @@ +import { createAd } from "../ad.js"; +import { createAdVerification } from "../ad_verification.js"; +import { parseCreatives } from "./creatives_parser.js"; +import { parseExtensions } from "./extensions_parser.js"; +import { parserUtils } from "./parser_utils.js"; +import { parserVerification } from "./parser_verification.js"; + +/** + * This module provides methods to parse a VAST Ad Element. + */ + +/** + * Parses an Ad element (can either be a Wrapper or an InLine). + * @param {Object} adElement - The VAST Ad element to parse. + * @param {Function} emit - Emit function used to trigger Warning event + * @param {Object} options - An optional Object of parameters to be used in the parsing process. + * @emits VASTParser#VAST-warning + * @return {Object|undefined} - Object containing the ad and if it is wrapper/inline + */ +export function parseAd( + adElement, + emit, + { allowMultipleAds, followAdditionalWrappers } = {}, +) { + const childNodes = Array.from(adElement.childNodes); + + const filteredChildNodes = childNodes.filter((childNode) => { + const childNodeToLowerCase = childNode.nodeName.toLowerCase(); + return ( + childNodeToLowerCase === "inline" || + (followAdditionalWrappers !== false && childNodeToLowerCase === "wrapper") + ); + }); + + for (const node of filteredChildNodes) { + parserUtils.copyNodeAttribute("id", adElement, node); + parserUtils.copyNodeAttribute("sequence", adElement, node); + parserUtils.copyNodeAttribute("adType", adElement, node); + + if (node.nodeName === "Wrapper") { + return { ad: parseWrapper(node, emit), type: "WRAPPER" }; + } else if (node.nodeName === "InLine") { + return { + ad: parseInLine(node, emit, { allowMultipleAds }), + type: "INLINE", + }; + } + + const wrongNode = node.nodeName.toLowerCase(); + const message = + wrongNode === "inline" + ? `<${node.nodeName}> must be written ` + : `<${node.nodeName}> must be written `; + emit("VAST-warning", { + message, + wrongNode: node, + }); + } +} + +/** + * Parses an Inline + * @param {Object} adElement Element - The VAST Inline element to parse. + * @param {Function} emit - Emit function used to trigger Warning event. + * @param {Object} options - An optional Object of parameters to be used in the parsing process. + * @emits VASTParser#VAST-warning + * @return {Object} ad - The ad object. + */ +function parseInLine(adElement, emit, { allowMultipleAds } = {}) { + // if allowMultipleAds is set to false by wrapper attribute + // only the first stand-alone Ad (with no sequence values) in the + // requested VAST response is allowed so we won't parse ads with sequence + if (allowMultipleAds === false && adElement.getAttribute("sequence")) { + return null; + } + + return parseAdElement(adElement, emit); +} + +/** + * Parses an ad type (Inline or Wrapper) + * @param {Object} adTypeElement - The VAST Inline or Wrapper element to parse. + * @param {Function} emit - Emit function used to trigger Warning event. + * @emits VASTParser#VAST-warning + * @return {Object} ad - The ad object. + */ +function parseAdElement(adTypeElement, emit) { + let adVerificationsFromExtensions = []; + if (emit) { + parserVerification.verifyRequiredValues(adTypeElement, emit); + } + + const childNodes = Array.from(adTypeElement.childNodes); + const ad = createAd(parserUtils.parseAttributes(adTypeElement)); + + childNodes.forEach((node) => { + switch (node.nodeName) { + case "Error": + ad.errorURLTemplates.push(parserUtils.parseNodeText(node)); + break; + + case "Impression": + ad.impressionURLTemplates.push({ + id: node.getAttribute("id") || null, + url: parserUtils.parseNodeText(node), + }); + break; + + case "Creatives": + ad.creatives = parseCreatives( + parserUtils.childrenByName(node, "Creative"), + ); + break; + + case "Extensions": { + const extNodes = parserUtils.childrenByName(node, "Extension"); + ad.extensions = parseExtensions(extNodes); + + /* + OMID specify adVerifications should be in extensions for VAST < 4.0 + To avoid to put them on two different places in two different format we reparse it + from extensions the same way than for an AdVerifications node. + */ + if (!ad.adVerifications.length) { + adVerificationsFromExtensions = + _parseAdVerificationsFromExtensions(extNodes); + } + break; + } + case "AdVerifications": + ad.adVerifications = _parseAdVerifications( + parserUtils.childrenByName(node, "Verification"), + ); + break; + + case "AdSystem": + ad.system = { + value: parserUtils.parseNodeText(node), + version: node.getAttribute("version") || null, + }; + break; + + case "AdTitle": + ad.title = parserUtils.parseNodeText(node); + break; + + case "AdServingId": + ad.adServingId = parserUtils.parseNodeText(node); + break; + + case "Category": + ad.categories.push({ + authority: node.getAttribute("authority") || null, + value: parserUtils.parseNodeText(node), + }); + break; + + case "Expires": + ad.expires = parseInt(parserUtils.parseNodeText(node), 10); + break; + + case "ViewableImpression": + ad.viewableImpression.push(_parseViewableImpression(node)); + break; + + case "Description": + ad.description = parserUtils.parseNodeText(node); + break; + + case "Advertiser": + ad.advertiser = { + id: node.getAttribute("id") || null, + value: parserUtils.parseNodeText(node), + }; + break; + + case "Pricing": + ad.pricing = { + value: parserUtils.parseNodeText(node), + model: node.getAttribute("model") || null, + currency: node.getAttribute("currency") || null, + }; + break; + + case "Survey": + ad.survey = { + value: parserUtils.parseNodeText(node), + type: node.getAttribute("type") || null, + }; + break; + + case "BlockedAdCategories": + ad.blockedAdCategories.push({ + authority: node.getAttribute("authority") || null, + value: parserUtils.parseNodeText(node), + }); + break; + } + }); + + if (adVerificationsFromExtensions.length) { + ad.adVerifications = ad.adVerifications.concat( + adVerificationsFromExtensions, + ); + } + return ad; +} + +/** + * Parses a Wrapper element without resolving the wrapped urls. + * @param {Object} wrapperElement - The VAST Wrapper element to be parsed. + * @param {Function} emit - Emit function used to trigger Warning event. + * @emits VASTParser#VAST-warning + * @return {Ad} + */ +function parseWrapper(wrapperElement, emit) { + const ad = parseAdElement(wrapperElement, emit); + + const followAdditionalWrappersValue = wrapperElement.getAttribute( + "followAdditionalWrappers", + ); + const allowMultipleAdsValue = wrapperElement.getAttribute("allowMultipleAds"); + const fallbackOnNoAdValue = wrapperElement.getAttribute("fallbackOnNoAd"); + ad.followAdditionalWrappers = followAdditionalWrappersValue + ? parserUtils.parseBoolean(followAdditionalWrappersValue) + : true; + + ad.allowMultipleAds = allowMultipleAdsValue + ? parserUtils.parseBoolean(allowMultipleAdsValue) + : false; + + ad.fallbackOnNoAd = fallbackOnNoAdValue + ? parserUtils.parseBoolean(fallbackOnNoAdValue) + : null; + + let wrapperURLElement = parserUtils.childByName( + wrapperElement, + "VASTAdTagURI", + ); + + if (wrapperURLElement) { + ad.nextWrapperURL = parserUtils.parseNodeText(wrapperURLElement); + } else { + wrapperURLElement = parserUtils.childByName(wrapperElement, "VASTAdTagURL"); + + if (wrapperURLElement) { + ad.nextWrapperURL = parserUtils.parseNodeText( + parserUtils.childByName(wrapperURLElement, "URL"), + ); + } + } + + ad.creatives.forEach((wrapperCreativeElement) => { + if (["linear", "nonlinear"].includes(wrapperCreativeElement.type)) { + // TrackingEvents Linear / NonLinear + if (wrapperCreativeElement.trackingEvents) { + if (!ad.trackingEvents) { + ad.trackingEvents = {}; + } + if (!ad.trackingEvents[wrapperCreativeElement.type]) { + ad.trackingEvents[wrapperCreativeElement.type] = {}; + } + + for (const eventName in wrapperCreativeElement.trackingEvents) { + const urls = wrapperCreativeElement.trackingEvents[eventName]; + if ( + !Array.isArray( + ad.trackingEvents[wrapperCreativeElement.type][eventName], + ) + ) { + ad.trackingEvents[wrapperCreativeElement.type][eventName] = []; + } + urls.forEach((url) => { + ad.trackingEvents[wrapperCreativeElement.type][eventName].push(url); + }); + } + } + // ClickTracking + if (wrapperCreativeElement.videoClickTrackingURLTemplates) { + if (!Array.isArray(ad.videoClickTrackingURLTemplates)) { + ad.videoClickTrackingURLTemplates = []; + } // tmp property to save wrapper tracking URLs until they are merged + wrapperCreativeElement.videoClickTrackingURLTemplates.forEach( + (item) => { + ad.videoClickTrackingURLTemplates.push(item); + }, + ); + } + // ClickThrough + if (wrapperCreativeElement.videoClickThroughURLTemplate) { + ad.videoClickThroughURLTemplate = + wrapperCreativeElement.videoClickThroughURLTemplate; + } + // CustomClick + if (wrapperCreativeElement.videoCustomClickURLTemplates) { + if (!Array.isArray(ad.videoCustomClickURLTemplates)) { + ad.videoCustomClickURLTemplates = []; + } // tmp property to save wrapper tracking URLs until they are merged + wrapperCreativeElement.videoCustomClickURLTemplates.forEach((item) => { + ad.videoCustomClickURLTemplates.push(item); + }); + } + } + }); + + if (ad.nextWrapperURL) { + return ad; + } +} + +/** + * Parses the AdVerifications Element. + * @param {Array} verifications - The array of verifications to parse. + * @return {Array} + */ +export function _parseAdVerifications(verifications) { + const ver = []; + + verifications.forEach((verificationNode) => { + const verification = createAdVerification(); + const childNodes = Array.from(verificationNode.childNodes); + + parserUtils.assignAttributes(verificationNode.attributes, verification); + + childNodes.forEach(({ nodeName, textContent, attributes }) => { + switch (nodeName) { + case "JavaScriptResource": + case "ExecutableResource": + verification.resource = textContent.trim(); + parserUtils.assignAttributes(attributes, verification); + break; + case "VerificationParameters": + verification.parameters = textContent.trim(); + break; + } + }); + + const trackingEventsElement = parserUtils.childByName( + verificationNode, + "TrackingEvents", + ); + if (trackingEventsElement) { + parserUtils + .childrenByName(trackingEventsElement, "Tracking") + .forEach((trackingElement) => { + const eventName = trackingElement.getAttribute("event"); + const trackingURLTemplate = + parserUtils.parseNodeText(trackingElement); + if (eventName && trackingURLTemplate) { + if (!Array.isArray(verification.trackingEvents[eventName])) { + verification.trackingEvents[eventName] = []; + } + verification.trackingEvents[eventName].push(trackingURLTemplate); + } + }); + } + + ver.push(verification); + }); + + return ver; +} + +/** + * Parses the AdVerifications Element from extension for versions < 4.0 + * @param {Array} extensions - The array of extensions to parse. + * @return {Array} + */ +export function _parseAdVerificationsFromExtensions(extensions) { + let adVerificationsNode = null, + adVerifications = []; + + // Find the first (and only) AdVerifications node from extensions + extensions.some((extension) => { + return (adVerificationsNode = parserUtils.childByName( + extension, + "AdVerifications", + )); + }); + + // Parse it if we get it + if (adVerificationsNode) { + adVerifications = _parseAdVerifications( + parserUtils.childrenByName(adVerificationsNode, "Verification"), + ); + } + return adVerifications; +} + +/** + * Parses the ViewableImpression Element. + * @param {Object} viewableImpressionNode - The ViewableImpression node element. + * @return {Object} viewableImpression - The viewableImpression object + */ +export function _parseViewableImpression(viewableImpressionNode) { + const regroupNodesUrl = (urls, node) => { + const url = parserUtils.parseNodeText(node); + url && urls.push(url); + return urls; + }; + return { + id: viewableImpressionNode.getAttribute("id") || null, + viewable: parserUtils + .childrenByName(viewableImpressionNode, "Viewable") + .reduce(regroupNodesUrl, []), + notViewable: parserUtils + .childrenByName(viewableImpressionNode, "NotViewable") + .reduce(regroupNodesUrl, []), + viewUndetermined: parserUtils + .childrenByName(viewableImpressionNode, "ViewUndetermined") + .reduce(regroupNodesUrl, []), + }; +} diff --git a/packages/stitcher/extern/vast-client/src/parser/bitrate.js b/packages/stitcher/extern/vast-client/src/parser/bitrate.js new file mode 100644 index 00000000..0e7d8433 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/parser/bitrate.js @@ -0,0 +1,23 @@ +/* + We decided to put the estimated bitrate separated from classes to persist it between different instances of vast client/parser +*/ + +let estimatedBitrateCount = 0; +export let estimatedBitrate = 0; + +/** + * Calculate average estimated bitrate from the previous values and new entries + * @param {Number} byteLength - The length of the response in bytes. + * @param {Number} duration - The duration of the request in ms. + */ +export const updateEstimatedBitrate = (byteLength, duration) => { + if (!byteLength || !duration || byteLength <= 0 || duration <= 0) { + return; + } + + // We want the bitrate in kb/s, byteLength are in bytes and duration in ms, just need to convert the byteLength because kb/s = b/ms + const bitrate = (byteLength * 8) / duration; + estimatedBitrate = + (estimatedBitrate * estimatedBitrateCount + bitrate) / + ++estimatedBitrateCount; +}; diff --git a/packages/stitcher/extern/vast-client/src/parser/creative_companion_parser.js b/packages/stitcher/extern/vast-client/src/parser/creative_companion_parser.js new file mode 100644 index 00000000..429f3eec --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/parser/creative_companion_parser.js @@ -0,0 +1,106 @@ +import { createCompanionAd } from "../companion_ad.js"; +import { createCreativeCompanion } from "../creative/creative_companion.js"; +import { parserUtils } from "./parser_utils.js"; + +/** + * This module provides methods to parse a VAST CompanionAd Element. + */ + +/** + * Parses a CompanionAd. + * @param {Object} creativeElement - The VAST CompanionAd element to parse. + * @param {Object} creativeAttributes - The attributes of the CompanionAd (optional). + * @return {Object} creative - The creative object. + */ +export function parseCreativeCompanion(creativeElement, creativeAttributes) { + const creative = createCreativeCompanion(creativeAttributes); + creative.required = creativeElement.getAttribute("required") || null; + + creative.variations = parserUtils + .childrenByName(creativeElement, "Companion") + .map((companionResource) => { + const companionAd = createCompanionAd( + parserUtils.parseAttributes(companionResource), + ); + + companionAd.htmlResources = parserUtils + .childrenByName(companionResource, "HTMLResource") + .reduce((urls, resource) => { + const url = parserUtils.parseNodeText(resource); + return url ? urls.concat(url) : urls; + }, []); + + companionAd.iframeResources = parserUtils + .childrenByName(companionResource, "IFrameResource") + .reduce((urls, resource) => { + const url = parserUtils.parseNodeText(resource); + return url ? urls.concat(url) : urls; + }, []); + + companionAd.staticResources = parserUtils + .childrenByName(companionResource, "StaticResource") + .reduce((urls, resource) => { + const url = parserUtils.parseNodeText(resource); + return url + ? urls.concat({ + url, + creativeType: resource.getAttribute("creativeType") || null, + }) + : urls; + }, []); + + companionAd.altText = + parserUtils.parseNodeText( + parserUtils.childByName(companionResource, "AltText"), + ) || null; + + const trackingEventsElement = parserUtils.childByName( + companionResource, + "TrackingEvents", + ); + if (trackingEventsElement) { + parserUtils + .childrenByName(trackingEventsElement, "Tracking") + .forEach((trackingElement) => { + const eventName = trackingElement.getAttribute("event"); + const trackingURLTemplate = + parserUtils.parseNodeText(trackingElement); + if (eventName && trackingURLTemplate) { + if (!Array.isArray(companionAd.trackingEvents[eventName])) { + companionAd.trackingEvents[eventName] = []; + } + companionAd.trackingEvents[eventName].push(trackingURLTemplate); + } + }); + } + + companionAd.companionClickTrackingURLTemplates = parserUtils + .childrenByName(companionResource, "CompanionClickTracking") + .map((clickTrackingElement) => { + return { + id: clickTrackingElement.getAttribute("id") || null, + url: parserUtils.parseNodeText(clickTrackingElement), + }; + }); + + companionAd.companionClickThroughURLTemplate = + parserUtils.parseNodeText( + parserUtils.childByName(companionResource, "CompanionClickThrough"), + ) || null; + + const adParametersElement = parserUtils.childByName( + companionResource, + "AdParameters", + ); + if (adParametersElement) { + companionAd.adParameters = { + value: parserUtils.parseNodeText(adParametersElement), + xmlEncoded: adParametersElement.getAttribute("xmlEncoded") || null, + }; + } + + return companionAd; + }); + + return creative; +} diff --git a/packages/stitcher/extern/vast-client/src/parser/creative_linear_parser.js b/packages/stitcher/extern/vast-client/src/parser/creative_linear_parser.js new file mode 100644 index 00000000..51383656 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/parser/creative_linear_parser.js @@ -0,0 +1,384 @@ +import { createCreativeLinear } from "../creative/creative_linear.js"; +import { createClosedCaptionFile } from "../closed_caption_file.js"; +import { createIcon } from "../icon.js"; +import { createInteractiveCreativeFile } from "../interactive_creative_file.js"; +import { createMediaFile } from "../media_file.js"; +import { createMezzanine } from "../mezzanine.js"; +import { parserUtils } from "./parser_utils.js"; + +/** + * This module provides methods to parse a VAST Linear Element. + */ + +/** + * Parses a Linear element. + * @param {Object} creativeElement - The VAST Linear element to parse. + * @param {any} creativeAttributes - The attributes of the Linear (optional). + * @return {Object} creative - The creativeLinear object. + */ +export function parseCreativeLinear(creativeElement, creativeAttributes) { + let offset; + const creative = createCreativeLinear(creativeAttributes); + + creative.duration = parserUtils.parseDuration( + parserUtils.parseNodeText( + parserUtils.childByName(creativeElement, "Duration"), + ), + ); + const skipOffset = creativeElement.getAttribute("skipoffset"); + + if (typeof skipOffset === "undefined" || skipOffset === null) { + creative.skipDelay = null; + } else if ( + skipOffset.charAt(skipOffset.length - 1) === "%" && + creative.duration !== -1 + ) { + const percent = parseInt(skipOffset, 10); + creative.skipDelay = creative.duration * (percent / 100); + } else { + creative.skipDelay = parserUtils.parseDuration(skipOffset); + } + + const videoClicksElement = parserUtils.childByName( + creativeElement, + "VideoClicks", + ); + if (videoClicksElement) { + const videoClickThroughElement = parserUtils.childByName( + videoClicksElement, + "ClickThrough", + ); + if (videoClickThroughElement) { + creative.videoClickThroughURLTemplate = { + id: videoClickThroughElement.getAttribute("id") || null, + url: parserUtils.parseNodeText(videoClickThroughElement), + }; + } else { + creative.videoClickThroughURLTemplate = null; + } + + parserUtils + .childrenByName(videoClicksElement, "ClickTracking") + .forEach((clickTrackingElement) => { + creative.videoClickTrackingURLTemplates.push({ + id: clickTrackingElement.getAttribute("id") || null, + url: parserUtils.parseNodeText(clickTrackingElement), + }); + }); + + parserUtils + .childrenByName(videoClicksElement, "CustomClick") + .forEach((customClickElement) => { + creative.videoCustomClickURLTemplates.push({ + id: customClickElement.getAttribute("id") || null, + url: parserUtils.parseNodeText(customClickElement), + }); + }); + } + + const adParamsElement = parserUtils.childByName( + creativeElement, + "AdParameters", + ); + if (adParamsElement) { + creative.adParameters = { + value: parserUtils.parseNodeText(adParamsElement), + xmlEncoded: adParamsElement.getAttribute("xmlEncoded") || null, + }; + } + + parserUtils + .childrenByName(creativeElement, "TrackingEvents") + .forEach((trackingEventsElement) => { + parserUtils + .childrenByName(trackingEventsElement, "Tracking") + .forEach((trackingElement) => { + let eventName = trackingElement.getAttribute("event"); + const trackingURLTemplate = + parserUtils.parseNodeText(trackingElement); + if (eventName && trackingURLTemplate) { + if (eventName === "progress") { + offset = trackingElement.getAttribute("offset"); + if (!offset) { + return; + } + if (offset.charAt(offset.length - 1) === "%") { + eventName = `progress-${offset}`; + } else { + eventName = `progress-${Math.round( + parserUtils.parseDuration(offset), + )}`; + } + } + + if (!Array.isArray(creative.trackingEvents[eventName])) { + creative.trackingEvents[eventName] = []; + } + creative.trackingEvents[eventName].push(trackingURLTemplate); + } + }); + }); + + parserUtils + .childrenByName(creativeElement, "MediaFiles") + .forEach((mediaFilesElement) => { + parserUtils + .childrenByName(mediaFilesElement, "MediaFile") + .forEach((mediaFileElement) => { + creative.mediaFiles.push(parseMediaFile(mediaFileElement)); + }); + + const interactiveCreativeElement = parserUtils.childByName( + mediaFilesElement, + "InteractiveCreativeFile", + ); + if (interactiveCreativeElement) { + creative.interactiveCreativeFile = parseInteractiveCreativeFile( + interactiveCreativeElement, + ); + } + + const closedCaptionElements = parserUtils.childByName( + mediaFilesElement, + "ClosedCaptionFiles", + ); + if (closedCaptionElements) { + parserUtils + .childrenByName(closedCaptionElements, "ClosedCaptionFile") + .forEach((closedCaptionElement) => { + const closedCaptionFile = createClosedCaptionFile( + parserUtils.parseAttributes(closedCaptionElement), + ); + closedCaptionFile.fileURL = + parserUtils.parseNodeText(closedCaptionElement); + + creative.closedCaptionFiles.push(closedCaptionFile); + }); + } + + const mezzanineElement = parserUtils.childByName( + mediaFilesElement, + "Mezzanine", + ); + const requiredAttributes = getRequiredAttributes(mezzanineElement, [ + "delivery", + "type", + "width", + "height", + ]); + + if (requiredAttributes) { + const mezzanine = createMezzanine(); + + mezzanine.id = mezzanineElement.getAttribute("id"); + mezzanine.fileURL = parserUtils.parseNodeText(mezzanineElement); + mezzanine.delivery = requiredAttributes.delivery; + mezzanine.codec = mezzanineElement.getAttribute("codec"); + mezzanine.type = requiredAttributes.type; + mezzanine.width = parseInt(requiredAttributes.width, 10); + mezzanine.height = parseInt(requiredAttributes.height, 10); + mezzanine.fileSize = parseInt( + mezzanineElement.getAttribute("fileSize"), + 10, + ); + mezzanine.mediaType = + mezzanineElement.getAttribute("mediaType") || "2D"; + + creative.mezzanine = mezzanine; + } + }); + + const iconsElement = parserUtils.childByName(creativeElement, "Icons"); + if (iconsElement) { + parserUtils.childrenByName(iconsElement, "Icon").forEach((iconElement) => { + creative.icons.push(parseIcon(iconElement)); + }); + } + + return creative; +} + +/** + * Parses the MediaFile element from VAST. + * @param {Object} mediaFileElement - The VAST MediaFile element. + * @return {Object} - Parsed mediaFile object. + */ +function parseMediaFile(mediaFileElement) { + const mediaFile = createMediaFile(); + mediaFile.id = mediaFileElement.getAttribute("id"); + mediaFile.fileURL = parserUtils.parseNodeText(mediaFileElement); + mediaFile.deliveryType = mediaFileElement.getAttribute("delivery"); + mediaFile.codec = mediaFileElement.getAttribute("codec"); + mediaFile.mimeType = mediaFileElement.getAttribute("type"); + mediaFile.mediaType = mediaFileElement.getAttribute("mediaType") || "2D"; + mediaFile.apiFramework = mediaFileElement.getAttribute("apiFramework"); + mediaFile.fileSize = parseInt(mediaFileElement.getAttribute("fileSize") || 0); + mediaFile.bitrate = parseInt(mediaFileElement.getAttribute("bitrate") || 0); + mediaFile.minBitrate = parseInt( + mediaFileElement.getAttribute("minBitrate") || 0, + ); + mediaFile.maxBitrate = parseInt( + mediaFileElement.getAttribute("maxBitrate") || 0, + ); + mediaFile.width = parseInt(mediaFileElement.getAttribute("width") || 0); + mediaFile.height = parseInt(mediaFileElement.getAttribute("height") || 0); + + const scalable = mediaFileElement.getAttribute("scalable"); + if (scalable && typeof scalable === "string") { + mediaFile.scalable = parserUtils.parseBoolean(scalable); + } + + const maintainAspectRatio = mediaFileElement.getAttribute( + "maintainAspectRatio", + ); + if (maintainAspectRatio && typeof maintainAspectRatio === "string") { + mediaFile.maintainAspectRatio = + parserUtils.parseBoolean(maintainAspectRatio); + } + return mediaFile; +} + +/** + * Parses the InteractiveCreativeFile element from VAST MediaFiles node. + * @param {Object} interactiveCreativeElement - The VAST InteractiveCreativeFile element. + * @return {Object} - Parsed interactiveCreativeFile object. + */ +function parseInteractiveCreativeFile(interactiveCreativeElement) { + const interactiveCreativeFile = createInteractiveCreativeFile( + parserUtils.parseAttributes(interactiveCreativeElement), + ); + interactiveCreativeFile.fileURL = parserUtils.parseNodeText( + interactiveCreativeElement, + ); + return interactiveCreativeFile; +} + +/** + * Parses the Icon element from VAST. + * @param {Object} iconElement - The VAST Icon element. + * @return {Object} - Parsed icon object. + */ +function parseIcon(iconElement) { + const icon = createIcon(iconElement); + icon.program = iconElement.getAttribute("program"); + icon.height = parseInt(iconElement.getAttribute("height") || 0); + icon.width = parseInt(iconElement.getAttribute("width") || 0); + icon.xPosition = parseXPosition(iconElement.getAttribute("xPosition")); + icon.yPosition = parseYPosition(iconElement.getAttribute("yPosition")); + icon.apiFramework = iconElement.getAttribute("apiFramework"); + icon.pxratio = iconElement.getAttribute("pxratio") || "1"; + icon.offset = parserUtils.parseDuration(iconElement.getAttribute("offset")); + icon.duration = parserUtils.parseDuration( + iconElement.getAttribute("duration"), + ); + + parserUtils + .childrenByName(iconElement, "HTMLResource") + .forEach((htmlElement) => { + icon.type = htmlElement.getAttribute("creativeType") || "text/html"; + icon.htmlResource = parserUtils.parseNodeText(htmlElement); + }); + + parserUtils + .childrenByName(iconElement, "IFrameResource") + .forEach((iframeElement) => { + icon.type = iframeElement.getAttribute("creativeType") || 0; + icon.iframeResource = parserUtils.parseNodeText(iframeElement); + }); + + parserUtils + .childrenByName(iconElement, "StaticResource") + .forEach((staticElement) => { + icon.type = staticElement.getAttribute("creativeType") || 0; + icon.staticResource = parserUtils.parseNodeText(staticElement); + }); + + const iconClicksElement = parserUtils.childByName(iconElement, "IconClicks"); + if (iconClicksElement) { + icon.iconClickThroughURLTemplate = parserUtils.parseNodeText( + parserUtils.childByName(iconClicksElement, "IconClickThrough"), + ); + parserUtils + .childrenByName(iconClicksElement, "IconClickTracking") + .forEach((iconClickTrackingElement) => { + icon.iconClickTrackingURLTemplates.push({ + id: iconClickTrackingElement.getAttribute("id") || null, + url: parserUtils.parseNodeText(iconClickTrackingElement), + }); + }); + + const iconClickFallbackImagesElement = parserUtils.childByName( + iconClicksElement, + "IconClickFallbackImages", + ); + + if (iconClickFallbackImagesElement) { + parserUtils + .childrenByName( + iconClickFallbackImagesElement, + "IconClickFallbackImage", + ) + .forEach((iconClickFallbackImageElement) => { + icon.iconClickFallbackImages.push({ + url: + parserUtils.parseNodeText(iconClickFallbackImageElement) || null, + width: iconClickFallbackImageElement.getAttribute("width") || null, + height: + iconClickFallbackImageElement.getAttribute("height") || null, + }); + }); + } + } + + icon.iconViewTrackingURLTemplate = parserUtils.parseNodeText( + parserUtils.childByName(iconElement, "IconViewTracking"), + ); + return icon; +} + +/** + * Parses an horizontal position into a String ('left' or 'right') or into a Number. + * @param {String} xPosition - The x position to parse. + * @return {String|Number} + */ +function parseXPosition(xPosition) { + if (["left", "right"].indexOf(xPosition) !== -1) { + return xPosition; + } + + return parseInt(xPosition || 0); +} + +/** + * Parses an vertical position into a String ('top' or 'bottom') or into a Number. + * @param {String} yPosition - The x position to parse. + * @return {String|Number} + */ +function parseYPosition(yPosition) { + if (["top", "bottom"].indexOf(yPosition) !== -1) { + return yPosition; + } + + return parseInt(yPosition || 0); +} + +/** + * Getting required attributes from element + * @param {Object} element - DOM element + * @param {Array} attributes - list of attributes + * @return {Object|null} null if a least one element not present + */ +function getRequiredAttributes(element, attributes) { + const values = {}; + let error = false; + + attributes.forEach((name) => { + if (!element || !element.getAttribute(name)) { + error = true; + } else { + values[name] = element.getAttribute(name); + } + }); + + return error ? null : values; +} diff --git a/packages/stitcher/extern/vast-client/src/parser/creative_non_linear_parser.js b/packages/stitcher/extern/vast-client/src/parser/creative_non_linear_parser.js new file mode 100644 index 00000000..98d2193c --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/parser/creative_non_linear_parser.js @@ -0,0 +1,107 @@ +import { createCreativeNonLinear } from "../creative/creative_non_linear.js"; +import { createNonLinearAd } from "../non_linear_ad.js"; +import { parserUtils } from "./parser_utils.js"; + +/** + * This module provides methods to parse a VAST NonLinear Element. + */ + +/** + * Parses a NonLinear element. + * @param {any} creativeElement - The VAST NonLinear element to parse. + * @param {any} creativeAttributes - The attributes of the NonLinear (optional). + * @return {Object} creative - The CreativeNonLinear object. + */ +export function parseCreativeNonLinear(creativeElement, creativeAttributes) { + const creative = createCreativeNonLinear(creativeAttributes); + parserUtils + .childrenByName(creativeElement, "TrackingEvents") + .forEach((trackingEventsElement) => { + let eventName, trackingURLTemplate; + parserUtils + .childrenByName(trackingEventsElement, "Tracking") + .forEach((trackingElement) => { + eventName = trackingElement.getAttribute("event"); + trackingURLTemplate = parserUtils.parseNodeText(trackingElement); + + if (eventName && trackingURLTemplate) { + if (!Array.isArray(creative.trackingEvents[eventName])) { + creative.trackingEvents[eventName] = []; + } + creative.trackingEvents[eventName].push(trackingURLTemplate); + } + }); + }); + + parserUtils + .childrenByName(creativeElement, "NonLinear") + .forEach((nonlinearResource) => { + const nonlinearAd = createNonLinearAd(); + nonlinearAd.id = nonlinearResource.getAttribute("id") || null; + nonlinearAd.width = nonlinearResource.getAttribute("width"); + nonlinearAd.height = nonlinearResource.getAttribute("height"); + nonlinearAd.expandedWidth = + nonlinearResource.getAttribute("expandedWidth"); + nonlinearAd.expandedHeight = + nonlinearResource.getAttribute("expandedHeight"); + nonlinearAd.scalable = parserUtils.parseBoolean( + nonlinearResource.getAttribute("scalable"), + ); + nonlinearAd.maintainAspectRatio = parserUtils.parseBoolean( + nonlinearResource.getAttribute("maintainAspectRatio"), + ); + nonlinearAd.minSuggestedDuration = parserUtils.parseDuration( + nonlinearResource.getAttribute("minSuggestedDuration"), + ); + nonlinearAd.apiFramework = nonlinearResource.getAttribute("apiFramework"); + + parserUtils + .childrenByName(nonlinearResource, "HTMLResource") + .forEach((htmlElement) => { + nonlinearAd.type = + htmlElement.getAttribute("creativeType") || "text/html"; + nonlinearAd.htmlResource = parserUtils.parseNodeText(htmlElement); + }); + + parserUtils + .childrenByName(nonlinearResource, "IFrameResource") + .forEach((iframeElement) => { + nonlinearAd.type = iframeElement.getAttribute("creativeType") || 0; + nonlinearAd.iframeResource = parserUtils.parseNodeText(iframeElement); + }); + + parserUtils + .childrenByName(nonlinearResource, "StaticResource") + .forEach((staticElement) => { + nonlinearAd.type = staticElement.getAttribute("creativeType") || 0; + nonlinearAd.staticResource = parserUtils.parseNodeText(staticElement); + }); + + const adParamsElement = parserUtils.childByName( + nonlinearResource, + "AdParameters", + ); + if (adParamsElement) { + nonlinearAd.adParameters = { + value: parserUtils.parseNodeText(adParamsElement), + xmlEncoded: adParamsElement.getAttribute("xmlEncoded") || null, + }; + } + + nonlinearAd.nonlinearClickThroughURLTemplate = parserUtils.parseNodeText( + parserUtils.childByName(nonlinearResource, "NonLinearClickThrough"), + ); + parserUtils + .childrenByName(nonlinearResource, "NonLinearClickTracking") + .forEach((clickTrackingElement) => { + nonlinearAd.nonlinearClickTrackingURLTemplates.push({ + id: clickTrackingElement.getAttribute("id") || null, + url: parserUtils.parseNodeText(clickTrackingElement), + }); + }); + + creative.variations.push(nonlinearAd); + }); + + return creative; +} diff --git a/packages/stitcher/extern/vast-client/src/parser/creatives_parser.js b/packages/stitcher/extern/vast-client/src/parser/creatives_parser.js new file mode 100644 index 00000000..aef8c1b3 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/parser/creatives_parser.js @@ -0,0 +1,104 @@ +import { parseCreativeCompanion } from "./creative_companion_parser.js"; +import { parseCreativeLinear } from "./creative_linear_parser.js"; +import { parseCreativeNonLinear } from "./creative_non_linear_parser.js"; +import { parseExtensions } from "./extensions_parser.js"; +import { parserUtils } from "./parser_utils.js"; + +/** + * Parses the creatives from the Creatives Node. + * @param {any} creativeNodes - The creative nodes to parse. + * @return {Array} - An array of Creative objects. + */ +export function parseCreatives(creativeNodes) { + const creatives = []; + + creativeNodes.forEach((creativeElement) => { + const creativeAttributes = { + id: creativeElement.getAttribute("id") || null, + adId: parseCreativeAdIdAttribute(creativeElement), + sequence: creativeElement.getAttribute("sequence") || null, + apiFramework: creativeElement.getAttribute("apiFramework") || null, + }; + + const universalAdIds = []; + const universalAdIdElements = parserUtils.childrenByName( + creativeElement, + "UniversalAdId", + ); + universalAdIdElements.forEach((universalAdIdElement) => { + const universalAdId = { + idRegistry: + universalAdIdElement.getAttribute("idRegistry") || "unknown", + value: parserUtils.parseNodeText(universalAdIdElement), + }; + universalAdIds.push(universalAdId); + }); + + let creativeExtensions; + const creativeExtensionsElement = parserUtils.childByName( + creativeElement, + "CreativeExtensions", + ); + if (creativeExtensionsElement) { + creativeExtensions = parseExtensions( + parserUtils.childrenByName( + creativeExtensionsElement, + "CreativeExtension", + ), + ); + } + + for (const creativeTypeElementKey in creativeElement.childNodes) { + const creativeTypeElement = + creativeElement.childNodes[creativeTypeElementKey]; + let parsedCreative; + + switch (creativeTypeElement.nodeName) { + case "Linear": + parsedCreative = parseCreativeLinear( + creativeTypeElement, + creativeAttributes, + ); + break; + case "NonLinearAds": + parsedCreative = parseCreativeNonLinear( + creativeTypeElement, + creativeAttributes, + ); + break; + case "CompanionAds": + parsedCreative = parseCreativeCompanion( + creativeTypeElement, + creativeAttributes, + ); + break; + } + + if (parsedCreative) { + if (universalAdIds) { + parsedCreative.universalAdIds = universalAdIds; + } + + if (creativeExtensions) { + parsedCreative.creativeExtensions = creativeExtensions; + } + creatives.push(parsedCreative); + } + } + }); + return creatives; +} + +/** + * Parses the creative adId Attribute. + * @param {any} creativeElement - The creative element to retrieve the adId from. + * @return {String|null} + */ +function parseCreativeAdIdAttribute(creativeElement) { + return ( + creativeElement.getAttribute("AdID") || // VAST 2 spec + creativeElement.getAttribute("adID") || // VAST 3 spec + creativeElement.getAttribute("adId") || // VAST 4 spec + null + ); +} diff --git a/packages/stitcher/extern/vast-client/src/parser/extensions_parser.js b/packages/stitcher/extern/vast-client/src/parser/extensions_parser.js new file mode 100644 index 00000000..69b47973 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/parser/extensions_parser.js @@ -0,0 +1,82 @@ +import { createExtension, isEmptyExtension } from "../extension.js"; +import { parserUtils } from "./parser_utils.js"; + +/** + * Parses an array of Extension elements. + * @param {Node[]} extensions - The array of extensions to parse. + * @param {String} type - The type of extensions to parse.(Ad|Creative) + * @return {AdExtension[]|CreativeExtension[]} - The nodes parsed to extensions + */ +export function parseExtensions(extensions) { + const exts = []; + extensions.forEach((extNode) => { + const ext = _parseExtension(extNode); + + if (ext) { + exts.push(ext); + } + }); + return exts; +} + +/** + * Parses an extension child node + * @param {Node} extNode - The extension node to parse + * @return {AdExtension|CreativeExtension|null} - The node parsed to extension + */ +function _parseExtension(extNode) { + // Ignore comments + if (extNode.nodeName === "#comment") return null; + + const ext = createExtension(); + + const extNodeAttrs = extNode.attributes; + const childNodes = extNode.childNodes; + + ext.name = extNode.nodeName; + + // Parse attributes + if (extNode.attributes) { + for (const extNodeAttrKey in extNodeAttrs) { + if (extNodeAttrs.hasOwnProperty(extNodeAttrKey)) { + const extNodeAttr = extNodeAttrs[extNodeAttrKey]; + + if (extNodeAttr.nodeName && extNodeAttr.nodeValue) { + ext.attributes[extNodeAttr.nodeName] = extNodeAttr.nodeValue; + } + } + } + } + + // Parse all children + for (const childNodeKey in childNodes) { + if (childNodes.hasOwnProperty(childNodeKey)) { + const parsedChild = _parseExtension(childNodes[childNodeKey]); + if (parsedChild) { + ext.children.push(parsedChild); + } + } + } + + /* + Only parse value of Nodes with only eather no children or only a cdata or text + to avoid useless parsing that would result to a concatenation of all children + */ + if ( + ext.children.length === 0 || + (ext.children.length === 1 && + ["#cdata-section", "#text"].indexOf(ext.children[0].name) >= 0) + ) { + const txt = parserUtils.parseNodeText(extNode); + + if (txt !== "") { + ext.value = txt; + } + + // Remove the children if it's a cdata or simply text to avoid useless children + ext.children = []; + } + + // Only return not empty objects to not pollute extentions + return isEmptyExtension(ext) ? null : ext; +} diff --git a/packages/stitcher/extern/vast-client/src/parser/parser_utils.js b/packages/stitcher/extern/vast-client/src/parser/parser_utils.js new file mode 100644 index 00000000..ab678678 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/parser/parser_utils.js @@ -0,0 +1,349 @@ +import { util } from "../util/util.js"; + +/** + * This module provides support methods to the parsing classes. + */ + +/** + * Returns the first element of the given node which nodeName matches the given name. + * @param {Node} node - The node to use to find a match. + * @param {String} name - The name to look for. + * @return {Object|undefined} + */ +function childByName(node, name) { + const childNodes = Array.from(node.childNodes); + return childNodes.find((childNode) => childNode.nodeName === name); +} + +/** + * Returns all the elements of the given node which nodeName match the given name. + * @param {Node} node - The node to use to find the matches. + * @param {String} name - The name to look for. + * @return {Array} + */ +function childrenByName(node, name) { + const childNodes = Array.from(node.childNodes); + return childNodes.filter((childNode) => childNode.nodeName === name); +} + +/** + * Converts relative vastAdTagUri. + * @param {String} vastAdTagUrl - The url to resolve. + * @param {String} originalUrl - The original url. + * @return {String} + */ +function resolveVastAdTagURI(vastAdTagUrl, originalUrl) { + if (!originalUrl) { + return vastAdTagUrl; + } + + if (vastAdTagUrl.startsWith("//")) { + const { protocol } = location; + return `${protocol}${vastAdTagUrl}`; + } + + if (!vastAdTagUrl.includes("://")) { + // Resolve relative URLs (mainly for unit testing) + const baseURL = originalUrl.slice(0, originalUrl.lastIndexOf("/")); + return `${baseURL}/${vastAdTagUrl}`; + } + + return vastAdTagUrl; +} + +/** + * Converts a boolean string into a Boolean. + * @param {String} booleanString - The boolean string to convert. + * @return {Boolean} + */ +function parseBoolean(booleanString) { + return ["true", "TRUE", "True", "1"].includes(booleanString); +} + +/** + * Parses a node text (for legacy support). + * @param {Object} node - The node to parse the text from. + * @return {String} + */ +function parseNodeText(node) { + return node && (node.textContent || node.text || "").trim(); +} + +/** + * Copies an attribute from a node to another. + * @param {String} attributeName - The name of the attribute to clone. + * @param {Object} nodeSource - The source node to copy the attribute from. + * @param {Object} nodeDestination - The destination node to copy the attribute at. + */ +function copyNodeAttribute(attributeName, nodeSource, nodeDestination) { + const attributeValue = nodeSource.getAttribute(attributeName); + if (attributeValue) { + nodeDestination.setAttribute(attributeName, attributeValue); + } +} + +/** + * Converts element attributes into an object, where object key is attribute name + * and object value is attribute value + * @param {Element} element + * @returns {Object} + */ +function parseAttributes(element) { + const nodeAttributes = Array.from(element.attributes); + + return nodeAttributes.reduce((acc, nodeAttribute) => { + acc[nodeAttribute.nodeName] = nodeAttribute.nodeValue; + return acc; + }, {}); +} + +/** + * Parses a String duration into a Number. + * @param {String} durationString - The dureation represented as a string. + * @return {Number} + */ +export function parseDuration(durationString) { + if (durationString === null || typeof durationString === "undefined") { + return -1; + } + // Some VAST doesn't have an HH:MM:SS duration format but instead jus the number of seconds + if (util.isNumeric(durationString)) { + return parseInt(durationString); + } + + const durationComponents = durationString.split(":"); + if (durationComponents.length !== 3) { + return -1; + } + + const secondsAndMS = durationComponents[2].split("."); + let seconds = parseInt(secondsAndMS[0]); + if (secondsAndMS.length === 2) { + seconds += parseFloat(`0.${secondsAndMS[1]}`); + } + + const minutes = parseInt(durationComponents[1] * 60); + const hours = parseInt(durationComponents[0] * 60 * 60); + + if ( + isNaN(hours) || + isNaN(minutes) || + isNaN(seconds) || + minutes > 60 * 60 || + seconds > 60 + ) { + return -1; + } + return hours + minutes + seconds; +} + +/** + * Splits an Array of ads into an Array of Arrays of ads. + * Each subarray contains either one ad or multiple ads (an AdPod) + * @param {Array} ads - An Array of ads to split + * @return {Array} + */ +function splitVAST(ads) { + const splittedVAST = []; + let lastAdPod = null; + + ads.forEach((ad, i) => { + if (ad.sequence) { + ad.sequence = parseInt(ad.sequence, 10); + } + // The current Ad may be the next Ad of an AdPod + if (ad.sequence > 1) { + const lastAd = ads[i - 1]; + // check if the current Ad is exactly the next one in the AdPod + if (lastAd && lastAd.sequence === ad.sequence - 1) { + lastAdPod && lastAdPod.push(ad); + return; + } + // If the ad had a sequence attribute but it was not part of a correctly formed + // AdPod, let's remove the sequence attribute + delete ad.sequence; + } + + lastAdPod = [ad]; + splittedVAST.push(lastAdPod); + }); + + return splittedVAST; +} + +/** + * Parses the attributes and assign them to object + * @param {Object} attributes attribute + * @param {Object} verificationObject with properties which can be assigned + */ +function assignAttributes(attributes, verificationObject) { + if (attributes) { + Array.from(attributes).forEach(({ nodeName, nodeValue }) => { + if ( + nodeName && + nodeValue && + verificationObject.hasOwnProperty(nodeName) + ) { + let value = nodeValue; + if (typeof verificationObject[nodeName] === "boolean") { + value = parseBoolean(value); + } + verificationObject[nodeName] = value; + } + }); + } +} + +/** + * Merges the data between an unwrapped ad and his wrapper. + * @param {Ad} unwrappedAd - The 'unwrapped' Ad. + * @param {Ad} wrapper - The wrapper Ad. + * @return {void} + */ +function mergeWrapperAdData(unwrappedAd, wrapper) { + unwrappedAd.errorURLTemplates = wrapper.errorURLTemplates.concat( + unwrappedAd.errorURLTemplates, + ); + unwrappedAd.impressionURLTemplates = wrapper.impressionURLTemplates.concat( + unwrappedAd.impressionURLTemplates, + ); + unwrappedAd.extensions = wrapper.extensions.concat(unwrappedAd.extensions); + + if (wrapper.viewableImpression.length > 0) { + unwrappedAd.viewableImpression = [ + ...unwrappedAd.viewableImpression, + ...wrapper.viewableImpression, + ]; + } + + // values from the child wrapper will be overridden + unwrappedAd.followAdditionalWrappers = wrapper.followAdditionalWrappers; + unwrappedAd.allowMultipleAds = wrapper.allowMultipleAds; + unwrappedAd.fallbackOnNoAd = wrapper.fallbackOnNoAd; + + const wrapperCompanions = (wrapper.creatives || []).filter( + (creative) => creative && creative.type === "companion", + ); + const wrapperCompanionClickTracking = wrapperCompanions.reduce( + (result, creative) => { + (creative.variations || []).forEach((variation) => { + (variation.companionClickTrackingURLTemplates || []).forEach( + (companionClickTrackingURLTemplate) => { + if ( + !util.containsTemplateObject( + companionClickTrackingURLTemplate, + result, + ) + ) { + result.push(companionClickTrackingURLTemplate); + } + }, + ); + }); + return result; + }, + [], + ); + unwrappedAd.creatives = wrapperCompanions.concat(unwrappedAd.creatives); + + const wrapperHasVideoClickTracking = + wrapper.videoClickTrackingURLTemplates && + wrapper.videoClickTrackingURLTemplates.length; + + const wrapperHasVideoCustomClick = + wrapper.videoCustomClickURLTemplates && + wrapper.videoCustomClickURLTemplates.length; + + unwrappedAd.creatives.forEach((creative) => { + // merge tracking events + if (wrapper.trackingEvents && wrapper.trackingEvents[creative.type]) { + for (const eventName in wrapper.trackingEvents[creative.type]) { + const urls = wrapper.trackingEvents[creative.type][eventName]; + if (!Array.isArray(creative.trackingEvents[eventName])) { + creative.trackingEvents[eventName] = []; + } + creative.trackingEvents[eventName] = + creative.trackingEvents[eventName].concat(urls); + } + } + + if (creative.type === "linear") { + // merge video click tracking url + if (wrapperHasVideoClickTracking) { + creative.videoClickTrackingURLTemplates = + creative.videoClickTrackingURLTemplates.concat( + wrapper.videoClickTrackingURLTemplates, + ); + } + + // merge video custom click url + if (wrapperHasVideoCustomClick) { + creative.videoCustomClickURLTemplates = + creative.videoCustomClickURLTemplates.concat( + wrapper.videoCustomClickURLTemplates, + ); + } + + // VAST 2.0 support - Use Wrapper/linear/clickThrough when Inline/Linear/clickThrough is null + if ( + wrapper.videoClickThroughURLTemplate && + (creative.videoClickThroughURLTemplate === null || + typeof creative.videoClickThroughURLTemplate === "undefined") + ) { + creative.videoClickThroughURLTemplate = + wrapper.videoClickThroughURLTemplate; + } + } + + // pass wrapper companion trackers to all companions + if (creative.type === "companion" && wrapperCompanionClickTracking.length) { + (creative.variations || []).forEach((variation) => { + variation.companionClickTrackingURLTemplates = + util.joinArrayOfUniqueTemplateObjs( + variation.companionClickTrackingURLTemplates, + wrapperCompanionClickTracking, + ); + }); + } + }); + + if (wrapper.adVerifications) { + // As specified by VAST specs unwrapped ads should contains wrapper adVerification script + unwrappedAd.adVerifications = unwrappedAd.adVerifications.concat( + wrapper.adVerifications, + ); + } + + if (wrapper.blockedAdCategories) { + unwrappedAd.blockedAdCategories = unwrappedAd.blockedAdCategories.concat( + wrapper.blockedAdCategories, + ); + } + + // Merge Wrapper's creatives containing icon elements + if (wrapper.creatives?.length) { + // As specified by VAST specs, wrapper should not contain any mediafiles + const wrapperCreativesWithIconsNode = wrapper.creatives.filter( + (creative) => creative.icons?.length && !creative.mediaFiles.length, + ); + if (wrapperCreativesWithIconsNode.length) { + unwrappedAd.creatives = unwrappedAd.creatives.concat( + wrapperCreativesWithIconsNode, + ); + } + } +} + +export const parserUtils = { + childByName, + childrenByName, + resolveVastAdTagURI, + parseBoolean, + parseNodeText, + copyNodeAttribute, + parseAttributes, + parseDuration, + splitVAST, + assignAttributes, + mergeWrapperAdData, +}; diff --git a/packages/stitcher/extern/vast-client/src/parser/parser_verification.js b/packages/stitcher/extern/vast-client/src/parser/parser_verification.js new file mode 100644 index 00000000..8b221ac9 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/parser/parser_verification.js @@ -0,0 +1,169 @@ +import { requiredValues } from "../util/requiredValues.js"; +import { parserUtils } from "./parser_utils.js"; + +/** + * Verify node required values and also verify recursively all his child nodes. + * Trigger warnings if a node required value is missing. + * @param {Node} node - The node element. + * @param {Function} emit - Emit function used to trigger Warning event. + * @emits VASTParser#VAST-warning + * @param {undefined|Boolean} [isAdInline] - Passed recursively to itself. True if the node is contained inside a inLine tag. + */ +function verifyRequiredValues(node, emit, isAdInline) { + if (!node || !node.nodeName) { + return; + } + if (node.nodeName === "InLine") { + isAdInline = true; + } + verifyRequiredAttributes(node, emit); + + if (hasSubElements(node)) { + verifyRequiredSubElements(node, emit, isAdInline); + for (let i = 0; i < node.children.length; i++) { + verifyRequiredValues(node.children[i], emit, isAdInline); + } + } else if (parserUtils.parseNodeText(node).length === 0) { + emitMissingValueWarning( + { name: node.nodeName, parentName: node.parentNode.nodeName }, + emit, + ); + } +} + +/** + * Verify and trigger warnings if node required attributes are not set. + * @param {Node} node - The node element. + * @param {Function} emit - Emit function used to trigger Warning event. + * @emits VASTParser#VAST-warning + */ +function verifyRequiredAttributes(node, emit) { + if ( + !requiredValues[node.nodeName] || + !requiredValues[node.nodeName].attributes + ) { + return; + } + const requiredAttributes = requiredValues[node.nodeName].attributes; + const missingAttributes = requiredAttributes.filter( + (attributeName) => !node.getAttribute(attributeName), + ); + if (missingAttributes.length > 0) { + emitMissingValueWarning( + { + name: node.nodeName, + parentName: node.parentNode.nodeName, + attributes: missingAttributes, + }, + emit, + ); + } +} + +/** + * Verify and trigger warnings if node required sub element are not set. + * @param {Node} node - The node element + * @param {Boolean} isAdInline - True if node is contained in a inline + * @param {Function} emit - Emit function used to trigger Warning event. + * @emits VASTParser#VAST-warning + */ +function verifyRequiredSubElements(node, emit, isAdInline) { + const required = requiredValues[node.nodeName]; + // Do not verify subelement if node is a child of wrapper, but verify it if node is the Wrapper itself + // Wrapper child have no required subElement. (Only InLine does) + const isInWrapperButNotWrapperItself = + !isAdInline && node.nodeName !== "Wrapper"; + if (!required || isInWrapperButNotWrapperItself) { + return; + } + + if (required.subElements) { + const requiredSubElements = required.subElements; + const missingSubElements = requiredSubElements.filter( + (subElementName) => !parserUtils.childByName(node, subElementName), + ); + + if (missingSubElements.length > 0) { + emitMissingValueWarning( + { + name: node.nodeName, + parentName: node.parentNode.nodeName, + subElements: missingSubElements, + }, + emit, + ); + } + } + + // When InLine format is used some nodes (i.e , , or ) + // require at least one of the following resources: StaticResource, IFrameResource, HTMLResource + if (!isAdInline || !required.oneOfinLineResources) { + return; + } + + const resourceFound = required.oneOfinLineResources.some((resource) => { + return parserUtils.childByName(node, resource); + }); + if (!resourceFound) { + emitMissingValueWarning( + { + name: node.nodeName, + parentName: node.parentNode.nodeName, + oneOfResources: required.oneOfinLineResources, + }, + emit, + ); + } +} + +/** + * Check if a node has sub elements. + * @param {Node} node - The node element. + * @returns {Boolean} + */ +function hasSubElements(node) { + return node.children && node.children.length !== 0; +} + +/** + * Trigger Warning if a element is empty or has missing attributes/subelements/resources + * @param {Object} missingElement - Object containing missing elements and values + * @param {String} missingElement.name - The name of element containing missing values + * @param {String} missingElement.parentName - The parent name of element containing missing values + * @param {Array} missingElement.attributes - The array of missing attributes + * @param {Array} missingElement.subElements - The array of missing sub elements + * @param {Array} missingElement.oneOfResources - The array of resources in which at least one must be provided by the element + * @param {Function} emit - Emit function used to trigger Warning event. + * @emits VastParser#VAST-warning + */ +function emitMissingValueWarning( + { name, parentName, attributes, subElements, oneOfResources }, + emit, +) { + let message = `Element '${name}'`; + if (attributes) { + message += ` missing required attribute(s) '${attributes.join(", ")}' `; + } else if (subElements) { + message += ` missing required sub element(s) '${subElements.join(", ")}' `; + } else if (oneOfResources) { + message += ` must provide one of the following '${oneOfResources.join( + ", ", + )}' `; + } else { + message += ` is empty`; + } + + emit("VAST-warning", { + message, + parentElement: parentName, + specVersion: 4.1, + }); +} + +export const parserVerification = { + verifyRequiredValues, + hasSubElements, + emitMissingValueWarning, + verifyRequiredAttributes, + verifyRequiredSubElements, +}; diff --git a/packages/stitcher/extern/vast-client/src/parser/vast_parser.js b/packages/stitcher/extern/vast-client/src/parser/vast_parser.js new file mode 100644 index 00000000..1c285ccc --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/parser/vast_parser.js @@ -0,0 +1,469 @@ +import { parseAd } from "./ad_parser.js"; +import { EventEmitter } from "../util/event_emitter.js"; +import { parserUtils } from "./parser_utils.js"; +import { util } from "../util/util.js"; +import { createVASTResponse } from "../vast_response.js"; +import { updateEstimatedBitrate, estimatedBitrate } from "./bitrate.js"; + +const DEFAULT_MAX_WRAPPER_DEPTH = 10; +const DEFAULT_EVENT_DATA = { + ERRORCODE: 900, + extensions: [], +}; + +/** + * This class provides methods to fetch and parse a VAST document. + * @export + * @class VASTParser + * @extends EventEmitter + */ +export class VASTParser extends EventEmitter { + /** + * Creates an instance of VASTParser. + * @constructor + */ + constructor({ fetcher } = {}) { + super(); + this.maxWrapperDepth = null; + this.remainingAds = []; + this.fetcher = fetcher || null; + } + + /** + * Tracks the error provided in the errorCode parameter and emits a VAST-error event for the given error. + * @param {Array} urlTemplates - An Array of url templates to use to make the tracking call. + * @param {Object} errorCode - An Object containing the error data. + * @param {Object} data - One (or more) Object containing additional data. + * @emits VASTParser#VAST-error + * @return {void} + */ + trackVastError(urlTemplates, errorCode, ...data) { + this.emit( + "VAST-error", + Object.assign({}, DEFAULT_EVENT_DATA, errorCode, ...data), + ); + util.track(urlTemplates, errorCode); + } + + /** + * Returns an array of errorURLTemplates for the VAST being parsed. + * @return {Array} + */ + getErrorURLTemplates() { + return this.rootErrorURLTemplates.concat(this.errorURLTemplates); + } + + /** + * Returns the estimated bitrate calculated from all previous requests + * @returns The average of all estimated bitrates in kb/s. + */ + getEstimatedBitrate() { + return estimatedBitrate; + } + + /** + * Inits the parsing properties of the class with the custom values provided as options. + * @param {Object} options - The options to initialize a parsing sequence + */ + initParsingStatus(options = {}) { + this.maxWrapperDepth = options.wrapperLimit || DEFAULT_MAX_WRAPPER_DEPTH; + this.parsingOptions = { allowMultipleAds: options.allowMultipleAds }; + this.rootURL = ""; + this.resetParsingStatus(); + updateEstimatedBitrate(options.byteLength, options.requestDuration); + } + + /** + * Reset the parsing property of the class everytime a VAST is parsed + */ + resetParsingStatus() { + this.errorURLTemplates = []; + this.rootErrorURLTemplates = []; + this.vastVersion = null; + } + /** + * Resolves the next group of ads. If all is true resolves all the remaining ads. + * @param {Boolean} all - If true all the remaining ads are resolved + * @return {Promise} + */ + getRemainingAds(all) { + if (this.remainingAds.length === 0) { + return Promise.reject( + new Error("No more ads are available for the given VAST"), + ); + } + + const ads = all + ? util.flatten(this.remainingAds) + : this.remainingAds.shift(); + this.errorURLTemplates = []; + + return this.resolveAds(ads, { + wrapperDepth: 0, + url: this.rootURL, + }).then((resolvedAds) => { + return this.buildVASTResponse(resolvedAds); + }); + } + + /** + * Parses the given xml Object into a VASTResponse. + * Returns a Promise which resolves with a fully parsed VASTResponse or rejects with an Error. + * @param {Object} vastXml - An object representing a vast xml document. + * @param {Object} options - An optional Object of parameters to be used in the parsing process. + * @emits VASTParser#VAST-resolving + * @emits VASTParser#VAST-resolved + * @emits VASTParser#VAST-warning + * @return {Promise} + */ + parseVAST(vastXml, options = {}) { + this.initParsingStatus(options); + + options.isRootVAST = true; + + return this.parse(vastXml, options).then((ads) => { + return this.buildVASTResponse(ads); + }); + } + + /** + * Builds a VASTResponse which can be returned. + * @param {Array} ads - An Array of unwrapped ads + * @return {Object} + */ + buildVASTResponse(ads) { + const response = createVASTResponse({ + ads, + errorURLTemplates: this.getErrorURLTemplates(), + version: this.vastVersion, + }); + this.completeWrapperResolving(response); + + return response; + } + + /** + * Parses the given xml Object into an array of ads + * Returns the array or throws an `Error` if an invalid VAST XML is provided + * @param {Object} vastXml - An object representing an xml document. + * @param {Object} options - An optional Object of parameters to be used in the parsing process. + * @emits VASTParser#VAST-warning + * @emits VASTParser#VAST-ad-parsed + * @return {Array} + * @throws {Error} `vastXml` must be a valid VAST XMLDocument + */ + parseVastXml( + vastXml, + { + isRootVAST = false, + url = null, + wrapperDepth = 0, + allowMultipleAds, + followAdditionalWrappers, + }, + ) { + // check if is a valid VAST document + if ( + !vastXml || + !vastXml.documentElement || + vastXml.documentElement.nodeName !== "VAST" + ) { + this.emit("VAST-ad-parsed", { + type: "ERROR", + url, + wrapperDepth, + }); + throw new Error("Invalid VAST XMLDocument"); + } + + const ads = []; + const childNodes = vastXml.documentElement.childNodes; + + /* Only parse the version of the Root VAST for now because we don't know yet how to + * handle some cases like multiple wrappers in the same vast + */ + const vastVersion = vastXml.documentElement.getAttribute("version"); + if (isRootVAST) { + if (vastVersion) this.vastVersion = vastVersion; + } + // Fill the VASTResponse object with ads and errorURLTemplates + for (const nodeKey in childNodes) { + const node = childNodes[nodeKey]; + + if (node.nodeName === "Error") { + const errorURLTemplate = parserUtils.parseNodeText(node); + + // Distinguish root VAST url templates from ad specific ones + isRootVAST + ? this.rootErrorURLTemplates.push(errorURLTemplate) + : this.errorURLTemplates.push(errorURLTemplate); + } else if (node.nodeName === "Ad") { + // allowMultipleAds was introduced in VAST 3 + // for retrocompatibility set it to true + if (this.vastVersion && parseFloat(this.vastVersion) < 3) { + allowMultipleAds = true; + } else if (allowMultipleAds === false && ads.length > 1) { + // if wrapper allowMultipleAds is set to false only the first stand-alone Ad + // (with no sequence values) in the requested VAST response is allowed + break; + } + + const result = parseAd(node, this.emit.bind(this), { + allowMultipleAds, + followAdditionalWrappers, + }); + + if (result.ad) { + ads.push(result.ad); + + this.emit("VAST-ad-parsed", { + type: result.type, + url, + wrapperDepth, + adIndex: ads.length - 1, + vastVersion, + }); + } else { + // VAST version of response not supported. + this.trackVastError(this.getErrorURLTemplates(), { + ERRORCODE: 101, + }); + } + } + } + + return ads; + } + + /** + * Parses the given xml Object into an array of unwrapped ads. + * Returns a Promise which resolves with the array or rejects with an error according to the result of the parsing. + * @param {Object} vastXml - An object representing an xml document. + * @param {Object} options - An optional Object of parameters to be used in the parsing process. + * @emits VASTParser#VAST-resolving + * @emits VASTParser#VAST-resolved + * @emits VASTParser#VAST-warning + * @return {Promise} + */ + parse( + vastXml, + { + url = null, + resolveAll = true, + wrapperSequence = null, + previousUrl = null, + wrapperDepth = 0, + isRootVAST = false, + followAdditionalWrappers, + allowMultipleAds, + } = {}, + ) { + let ads = []; + // allowMultipleAds was introduced in VAST 3 as wrapper attribute + // for retrocompatibility set it to true for vast pre-version 3 + if (this.vastVersion && parseFloat(this.vastVersion) < 3 && isRootVAST) { + allowMultipleAds = true; + } + try { + ads = this.parseVastXml(vastXml, { + isRootVAST, + url, + wrapperDepth, + allowMultipleAds, + followAdditionalWrappers, + }); + } catch (e) { + return Promise.reject(e); + } + + /* Keep wrapper sequence value to not break AdPod when wrapper contain only one Ad. + e.g,for a AdPod containing : + - Inline with sequence=1 + - Inline with sequence=2 + - Wrapper with sequence=3 wrapping a Inline with sequence=1 + once parsed we will obtain : + - Inline sequence 1, + - Inline sequence 2, + - Inline sequence 3 + */ + if ( + ads.length === 1 && + wrapperSequence !== undefined && + wrapperSequence !== null + ) { + ads[0].sequence = wrapperSequence; + } + + // Split the VAST in case we don't want to resolve everything at the first time + if (resolveAll === false) { + this.remainingAds = parserUtils.splitVAST(ads); + // Remove the first element from the remaining ads array, since we're going to resolve that element + ads = this.remainingAds.shift(); + } + + return this.resolveAds(ads, { + wrapperDepth, + previousUrl, + url, + }); + } + + /** + * Resolves an Array of ads, recursively calling itself with the remaining ads if a no ad + * response is returned for the given array. + * @param {Array} ads - An array of ads to resolve + * @param {Object} options - An options Object containing resolving parameters + * @return {Promise} + */ + resolveAds(ads = [], { wrapperDepth, previousUrl, url }) { + const resolveWrappersPromises = []; + previousUrl = url; + ads.forEach((ad) => { + const resolveWrappersPromise = this.resolveWrappers( + ad, + wrapperDepth, + previousUrl, + ); + + resolveWrappersPromises.push(resolveWrappersPromise); + }); + + return Promise.all(resolveWrappersPromises).then((unwrappedAds) => { + const resolvedAds = util.flatten(unwrappedAds); + + if (!resolvedAds.length && this.remainingAds.length > 0) { + const remainingAdsToResolve = this.remainingAds.shift(); + + return this.resolveAds(remainingAdsToResolve, { + wrapperDepth, + previousUrl, + url, + }); + } + + return resolvedAds; + }); + } + + /** + * Resolves the wrappers for the given ad in a recursive way. + * Returns a Promise which resolves with the unwrapped ad or rejects with an error. + * @param {Object} adToUnWrap - An ad object to be unwrapped. + * @param {Number} wrapperDepth - The reached depth in the wrapper resolving chain. + * @param {String} previousUrl - The previous vast url. + * @return {Promise} + */ + resolveWrappers(adToUnWrap, wrapperDepth, previousUrl) { + // Copy ad from parameters to prevent altering given object outside of function scope + const ad = { ...adToUnWrap }; + return new Promise((resolve) => { + // Going one level deeper in the wrapper chain + wrapperDepth++; + // We already have a resolved VAST ad, no need to resolve wrapper + if (!ad.nextWrapperURL) { + delete ad.nextWrapperURL; + return resolve(ad); + } + + if (!this.fetcher) { + ad.VASTAdTagURI = ad.nextWrapperURL; + delete ad.nextWrapperURL; + return resolve(ad); + } + + if (wrapperDepth >= this.maxWrapperDepth) { + // Wrapper limit reached, as defined by the video player. + // Too many Wrapper responses have been received with no InLine response. + ad.errorCode = 302; + delete ad.nextWrapperURL; + return resolve(ad); + } + + // Get full URL + ad.nextWrapperURL = parserUtils.resolveVastAdTagURI( + ad.nextWrapperURL, + previousUrl, + ); + + // If allowMultipleAds is set inside the parameter 'option' of public method + // override the vast value by the one provided + const allowMultipleAds = + this.parsingOptions.allowMultipleAds ?? ad.allowMultipleAds; + // sequence doesn't carry over in wrapper element + const wrapperSequence = ad.sequence; + + this.fetcher + .fetchVAST({ + url: ad.nextWrapperURL, + emitter: this.emit.bind(this), + maxWrapperDepth: this.maxWrapperDepth, + }) + .then((xml) => { + return this.parse(xml, { + url: ad.nextWrapperURL, + previousUrl, + wrapperSequence, + wrapperDepth, + followAdditionalWrappers: ad.followAdditionalWrappers, + allowMultipleAds, + }).then((unwrappedAds) => { + delete ad.nextWrapperURL; + if (unwrappedAds.length === 0) { + // No ads returned by the wrappedResponse, discard current creatives + ad.creatives = []; + return resolve(ad); + } + + unwrappedAds.forEach((unwrappedAd) => { + if (unwrappedAd) { + parserUtils.mergeWrapperAdData(unwrappedAd, ad); + } + }); + + resolve(unwrappedAds); + }); + }) + .catch((err) => { + // Timeout of VAST URI provided in Wrapper element, or of VAST URI provided in a subsequent Wrapper element. + // (URI was either unavailable or reached a timeout as defined by the video player.) + ad.errorCode = 301; + ad.errorMessage = err.message; + resolve(ad); + }); + }); + } + + /** + * Takes care of handling errors when the wrappers are resolved. + * @param {Object} vastResponse - A resolved VASTResponse. + */ + completeWrapperResolving(vastResponse) { + // We've to wait for all elements to be parsed before handling error so we can: + // - Send computed extensions data + // - Ping all URIs defined across VAST files + + // No Ad case - The parser never bump into an element + if (vastResponse.ads.length === 0) { + this.trackVastError(vastResponse.errorURLTemplates, { ERRORCODE: 303 }); + } else { + for (let index = vastResponse.ads.length - 1; index >= 0; index--) { + // - Error encountered while parsing + // - No Creative case - The parser has dealt with soma or/and an elements + // but no creative was found + const ad = vastResponse.ads[index]; + if ((ad.errorCode || ad.creatives.length === 0) && !ad.VASTAdTagURI) { + // If VASTAdTagURI is in the vastResponse, it means we are dealing with a Wrapper when using parseVAST from the VASTParser. + // In that case, we dont want to modify the vastResponse since the creatives node is not required in a wrapper. + this.trackVastError( + ad.errorURLTemplates.concat(vastResponse.errorURLTemplates), + { ERRORCODE: ad.errorCode || 303 }, + { ERRORMESSAGE: ad.errorMessage || "" }, + { extensions: ad.extensions }, + { system: ad.system }, + ); + vastResponse.ads.splice(index, 1); + } + } + } + } +} diff --git a/packages/stitcher/extern/vast-client/src/util/event_emitter.js b/packages/stitcher/extern/vast-client/src/util/event_emitter.js new file mode 100644 index 00000000..0a7f0935 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/util/event_emitter.js @@ -0,0 +1,143 @@ +export class EventEmitter { + constructor() { + this._handlers = []; + } + + /** + * Adds the event name and handler function to the end of the handlers array. + * No checks are made to see if the handler has already been added. + * Multiple calls passing the same combination of event name and handler will result in the handler being added, + * and called, multiple times. + * @param {String} event + * @param {Function} handler + * @returns {EventEmitter} + */ + on(event, handler) { + if (typeof handler !== 'function') { + throw new TypeError( + `The handler argument must be of type Function. Received type ${typeof handler}` + ); + } + if (!event) { + throw new TypeError( + `The event argument must be of type String. Received type ${typeof event}` + ); + } + this._handlers.push({ + event, + handler, + }); + + return this; + } + + /** + * Adds a one-time handler function for the named event. + * The next time event is triggered, this handler is removed and then invoked. + * @param {String} event + * @param {Function} handler + * @returns {EventEmitter} + */ + once(event, handler) { + return this.on(event, onceWrap(this, event, handler)); + } + + /** + * Removes all instances for the specified handler from the handler array for the named event. + * @param {String} event + * @param {Function} handler + * @returns {EventEmitter} + */ + off(event, handler) { + this._handlers = this._handlers.filter((item) => { + return item.event !== event || item.handler !== handler; + }); + + return this; + } + + /** + * Synchronously calls each of the handlers registered for the named event, + * in the order they were registered, passing the supplied arguments to each. + * @param {String} event + * @param {...any} args list of arguments that will be used by the event handler + * @returns {Boolean} true if the event had handlers, false otherwise. + */ + emit(event, ...args) { + let called = false; + this._handlers.forEach((item) => { + if (item.event === '*') { + called = true; + item.handler(event, ...args); + } + if (item.event === event) { + called = true; + item.handler(...args); + } + }); + return called; + } + + /** + * Removes all listeners, or those of the specified named event. + * @param {String} event + * @returns {EventEmitter} + */ + removeAllListeners(event) { + if (!event) { + this._handlers = []; + return this; + } + + this._handlers = this._handlers.filter((item) => item.event !== event); + return this; + } + + /** + * Returns the number of listeners listening to the named event. + * @param {String} event + * @returns {Number} + */ + listenerCount(event) { + return this._handlers.filter((item) => item.event === event).length; + } + + /** + * Returns a copy of the array of listeners for the named event including those created by .once(). + * @param {String} event + * @returns {Function[]} + */ + listeners(event) { + return this._handlers.reduce((listeners, item) => { + if (item.event === event) { + listeners.push(item.handler); + } + return listeners; + }, []); + } + + /** + * Returns an array listing the events for which the emitter has registered handlers. + * @returns {String[]} + */ + eventNames() { + return this._handlers.map((item) => item.event); + } +} + +function onceWrap(target, event, handler) { + const state = { + fired: false, + wrapFn: undefined, + }; + + function onceWrapper(...args) { + if (!state.fired) { + target.off(event, state.wrapFn); + state.fired = true; + handler.bind(target)(...args); + } + } + state.wrapFn = onceWrapper; + return onceWrapper; +} diff --git a/packages/stitcher/extern/vast-client/src/util/macros.js b/packages/stitcher/extern/vast-client/src/util/macros.js new file mode 100644 index 00000000..7b2036c2 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/util/macros.js @@ -0,0 +1,49 @@ +export const supportedMacros = [ + 'ADCATEGORIES', + 'ADCOUNT', + 'ADPLAYHEAD', + 'ADSERVINGID', + 'ADTYPE', + 'APIFRAMEWORKS', + 'APPBUNDLE', + 'ASSETURI', + 'BLOCKEDADCATEGORIES', + 'BREAKMAXADLENGTH', + 'BREAKMAXADS', + 'BREAKMAXDURATION', + 'BREAKMINADLENGTH', + 'BREAKMINDURATION', + 'BREAKPOSITION', + 'CLICKPOS', + 'CLICKTYPE', + 'CLIENTUA', + 'CONTENTID', + 'CONTENTPLAYHEAD', // @deprecated VAST 4.1 + 'CONTENTURI', + 'DEVICEIP', + 'DEVICEUA', + 'DOMAIN', + 'EXTENSIONS', + 'GDPRCONSENT', + 'IFA', + 'IFATYPE', + 'INVENTORYSTATE', + 'LATLONG', + 'LIMITADTRACKING', + 'MEDIAMIME', + 'MEDIAPLAYHEAD', + 'OMIDPARTNER', + 'PAGEURL', + 'PLACEMENTTYPE', + 'PLAYERCAPABILITIES', + 'PLAYERSIZE', + 'PLAYERSTATE', + 'PODSEQUENCE', + 'REGULATIONS', + 'SERVERSIDE', + 'SERVERUA', + 'TRANSACTIONID', + 'UNIVERSALADID', + 'VASTVERSIONS', + 'VERIFICATIONVENDORS', +]; diff --git a/packages/stitcher/extern/vast-client/src/util/requiredValues.js b/packages/stitcher/extern/vast-client/src/util/requiredValues.js new file mode 100644 index 00000000..bd517144 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/util/requiredValues.js @@ -0,0 +1,74 @@ +export const requiredValues = { + Wrapper: { + subElements: ['VASTAdTagURI', 'Impression'], + }, + BlockedAdCategories: { + attributes: ['authority'], + }, + InLine: { + subElements: [ + 'AdSystem', + 'AdTitle', + 'Impression', + 'AdServingId', + 'Creatives', + ], + }, + Category: { + attributes: ['authority'], + }, + Pricing: { + attributes: ['model', 'currency'], + }, + Verification: { + oneOfinLineResources: ['JavaScriptResource', 'ExecutableResource'], + attributes: ['vendor'], + }, + UniversalAdId: { + attributes: ['idRegistry'], + }, + JavaScriptResource: { + attributes: ['apiFramework', 'browserOptional'], + }, + ExecutableResource: { + attributes: ['apiFramework', 'type'], + }, + Tracking: { + attributes: ['event'], + }, + Creatives: { + subElements: ['Creative'], + }, + Creative: { + subElements: ['UniversalAdId'], + }, + Linear: { + subElements: ['MediaFiles', 'Duration'], + }, + MediaFiles: { + subElements: ['MediaFile'], + }, + MediaFile: { + attributes: ['delivery', 'type', 'width', 'height'], + }, + Mezzanine: { + attributes: ['delivery', 'type', 'width', 'height'], + }, + NonLinear: { + oneOfinLineResources: ['StaticResource', 'IFrameResource', 'HTMLResource'], + attributes: ['width', 'height'], + }, + Companion: { + oneOfinLineResources: ['StaticResource', 'IFrameResource', 'HTMLResource'], + attributes: ['width', 'height'], + }, + StaticResource: { + attributes: ['creativeType'], + }, + Icons: { + subElements: ['Icon'], + }, + Icon: { + oneOfinLineResources: ['StaticResource', 'IFrameResource', 'HTMLResource'], + }, +}; diff --git a/packages/stitcher/extern/vast-client/src/util/storage.js b/packages/stitcher/extern/vast-client/src/util/storage.js new file mode 100644 index 00000000..ddc926bc --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/util/storage.js @@ -0,0 +1,127 @@ +let storage = null; + +/** + * This Object represents a default storage to be used in case no other storage is available. + * @constant + * @type {Object} + */ +const DEFAULT_STORAGE = { + data: {}, + length: 0, + getItem(key) { + return this.data[key]; + }, + setItem(key, value) { + this.data[key] = value; + this.length = Object.keys(this.data).length; + }, + removeItem(key) { + delete this.data[key]; + this.length = Object.keys(this.data).length; + }, + clear() { + this.data = {}; + this.length = 0; + }, +}; + +/** + * This class provides an wrapper interface to the a key-value storage. + * It uses localStorage, sessionStorage or a custom storage if none of the two is available. + * @export + * @class Storage + */ +export class Storage { + /** + * Creates an instance of Storage. + * @constructor + */ + constructor() { + this.storage = this.initStorage(); + } + + /** + * Provides a singleton instance of the wrapped storage. + * @return {Object} + */ + initStorage() { + if (storage) { + return storage; + } + + try { + storage = + typeof window !== 'undefined' && window !== null + ? window.localStorage || window.sessionStorage + : null; + } catch (storageError) { + storage = null; + } + + if (!storage || this.isStorageDisabled(storage)) { + storage = DEFAULT_STORAGE; + storage.clear(); + } + + return storage; + } + + /** + * Check if storage is disabled (like in certain cases with private browsing). + * In Safari (Mac + iOS) when private browsing is ON, localStorage is read only + * http://spin.atomicobject.com/2013/01/23/ios-private-browsing-localstorage/ + * @param {Object} testStorage - The storage to check. + * @return {Boolean} + */ + isStorageDisabled(testStorage) { + const testValue = '__VASTStorage__'; + + try { + testStorage.setItem(testValue, testValue); + if (testStorage.getItem(testValue) !== testValue) { + testStorage.removeItem(testValue); + return true; + } + } catch (e) { + return true; + } + + testStorage.removeItem(testValue); + return false; + } + + /** + * Returns the value for the given key. If the key does not exist, null is returned. + * @param {String} key - The key to retrieve the value. + * @return {any} + */ + getItem(key) { + return this.storage.getItem(key); + } + + /** + * Adds or updates the value for the given key. + * @param {String} key - The key to modify the value. + * @param {any} value - The value to be associated with the key. + * @return {any} + */ + setItem(key, value) { + return this.storage.setItem(key, value); + } + + /** + * Removes an item for the given key. + * @param {String} key - The key to remove the value. + * @return {any} + */ + removeItem(key) { + return this.storage.removeItem(key); + } + + /** + * Removes all the items from the storage. + */ + clear() { + return this.storage.clear(); + } +} diff --git a/packages/stitcher/extern/vast-client/src/util/util.js b/packages/stitcher/extern/vast-client/src/util/util.js new file mode 100644 index 00000000..46cd5de3 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/util/util.js @@ -0,0 +1,294 @@ +import { supportedMacros } from "./macros.js"; + +function track(URLTemplates, macros, options) { + const URLs = resolveURLTemplates(URLTemplates, macros, options); + + URLs.forEach((URL) => { + if (typeof window !== "undefined" && window !== null) { + const i = new Image(); + i.src = URL; + } + }); +} + +/** + * Replace the provided URLTemplates with the given values + * + * @param {Array} URLTemplates - An array of tracking url templates. + * @param {Object} [macros={}] - An optional Object of parameters to be used in the tracking calls. + * @param {Object} [options={}] - An optional Object of options to be used in the tracking calls. + */ +function resolveURLTemplates(URLTemplates, macros = {}, options = {}) { + const resolvedURLs = []; + const URLArray = extractURLsFromTemplates(URLTemplates); + + // Set default value for invalid ERRORCODE + if ( + macros["ERRORCODE"] && + !options.isCustomCode && + !/^[0-9]{3}$/.test(macros["ERRORCODE"]) + ) { + macros["ERRORCODE"] = 900; + } + + // Calc random/time based macros + macros["CACHEBUSTING"] = addLeadingZeros(Math.round(Math.random() * 1.0e8)); + macros["TIMESTAMP"] = new Date().toISOString(); + + // RANDOM/random is not defined in VAST 3/4 as a valid macro tho it's used by some adServer (Auditude) + macros["RANDOM"] = macros["random"] = macros["CACHEBUSTING"]; + + for (const macro in macros) { + macros[macro] = encodeURIComponentRFC3986(macros[macro]); + } + + for (const URLTemplateKey in URLArray) { + const resolveURL = URLArray[URLTemplateKey]; + + if (typeof resolveURL !== "string") { + continue; + } + resolvedURLs.push(replaceUrlMacros(resolveURL, macros)); + } + return resolvedURLs; +} + +/** + * Replace the macros tracking url with their value. + * If no value is provided for a supported macro and it exists in the url, + * it will be replaced by -1 as described by the VAST 4.1 iab specifications + * + * @param {String} url - Tracking url. + * @param {Object} macros - Object of macros to be replaced in the tracking calls + */ +function replaceUrlMacros(url, macros) { + url = replaceMacrosValues(url, macros); + // match any macros from the url that was not replaced + const remainingMacros = url.match(/[^[\]]+(?=])/g); + if (!remainingMacros) { + return url; + } + + let supportedRemainingMacros = remainingMacros.filter( + (macro) => supportedMacros.indexOf(macro) > -1, + ); + if (supportedRemainingMacros.length === 0) { + return url; + } + + supportedRemainingMacros = supportedRemainingMacros.reduce( + (accumulator, macro) => { + accumulator[macro] = -1; + return accumulator; + }, + {}, + ); + return replaceMacrosValues(url, supportedRemainingMacros); +} + +/** + * Replace the macros tracking url with their value. + * + * @param {String} url - Tracking url. + * @param {Object} macros - Object of macros to be replaced in the tracking calls + */ +function replaceMacrosValues(url, macros) { + let replacedMacrosUrl = url; + for (const key in macros) { + const value = macros[key]; + // this will match [${key}] and %%${key}%% and replace it + replacedMacrosUrl = replacedMacrosUrl.replace( + new RegExp(`(?:\\[|%%)(${key})(?:\\]|%%)`, "g"), + value, + ); + } + return replacedMacrosUrl; +} + +/** + * Extract the url/s from the URLTemplates. + * If the URLTemplates is an array of urls + * If the URLTemplates object has a url property + * If the URLTemplates is a single string + * + * @param {Array|String} URLTemplates - An array|string of url templates. + */ +function extractURLsFromTemplates(URLTemplates) { + if (Array.isArray(URLTemplates)) { + return URLTemplates.map((URLTemplate) => { + return URLTemplate && URLTemplate.hasOwnProperty("url") + ? URLTemplate.url + : URLTemplate; + }); + } + return URLTemplates; +} + +/** + * Filter URLTemplates elements . + * To be valid, urls should: + * - have the same protocol as the client + * or + * - be protocol-relative urls + * + * Returns an object with two arrays + * - validUrls : An array of valid URLs + * - invalidUrls: An array of invalid URLs + * + * @param {Array} URLTemplates - A Array of string/object containing urls templates. + * @returns {Object} + * + */ +function filterUrlTemplates(URLTemplates) { + return URLTemplates.reduce( + (acc, urlTemplate) => { + const url = urlTemplate.url || urlTemplate; + + isValidUrl(url) ? acc.validUrls.push(url) : acc.invalidUrls.push(url); + return acc; + }, + { validUrls: [], invalidUrls: [] }, + ); +} + +function isValidUrl(url) { + const regex = /^(https?:\/\/|\/\/)/; + return regex.test(url); +} + +/** + * Returns a boolean after checking if the object exists in the array. + * true - if the object exists, false otherwise + * + * @param {Object} obj - The object who existence is to be checked. + * @param {Array} list - List of objects. + */ +function containsTemplateObject(obj, list) { + for (let i = 0; i < list.length; i++) { + if (isTemplateObjectEqual(list[i], obj)) { + return true; + } + } + return false; +} + +/** + * Returns a boolean after comparing two Template objects. + * true - if the objects are equivalent, false otherwise + * + * @param {Object} obj1 + * @param {Object} obj2 + */ +function isTemplateObjectEqual(obj1, obj2) { + if (obj1 && obj2) { + const obj1Properties = Object.getOwnPropertyNames(obj1); + const obj2Properties = Object.getOwnPropertyNames(obj2); + + // If number of properties is different, objects are not equivalent + if (obj1Properties.length !== obj2Properties.length) { + return false; + } + + if (obj1.id !== obj2.id || obj1.url !== obj2.url) { + return false; + } + return true; + } + return false; +} + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent +function encodeURIComponentRFC3986(str) { + return encodeURIComponent(str).replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16)}`, + ); +} + +/** + * Return a string of the input number with leading zeros defined by the length param + * + * @param {Number} input - number to convert + * @param {Number} length - length of the desired string + * + * @return {String} + */ +function addLeadingZeros(input, length = 8) { + return input.toString().padStart(length, "0"); +} + +function isNumeric(n) { + return !isNaN(parseFloat(n)) && isFinite(n); +} + +function flatten(arr) { + return arr.reduce((flat, toFlatten) => { + return flat.concat( + Array.isArray(toFlatten) ? flatten(toFlatten) : toFlatten, + ); + }, []); +} + +/** + * Joins two arrays of objects without duplicates + * + * @param {Array} arr1 + * @param {Array} arr2 + * + * @return {Array} + */ +function joinArrayOfUniqueTemplateObjs(arr1 = [], arr2 = []) { + const firstArr = Array.isArray(arr1) ? arr1 : []; + const secondArr = Array.isArray(arr2) ? arr2 : []; + const arr = firstArr.concat(secondArr); + + return arr.reduce((res, val) => { + if (!containsTemplateObject(val, res)) { + res.push(val); + } + return res; + }, []); +} + +/** + * Check if a provided value is a valid time value according to the IAB definition + * Check if a provided value is a valid time value according to the IAB definition: Must be a positive number or -1. + * if not implemented by ad unit or -2 if value is unknown. + * @param {Number} time + * + * @return {Boolean} + */ +function isValidTimeValue(time) { + return Number.isFinite(time) && time >= -2; +} + +/** + * Check if we are in a browser environment + * @returns {Boolean} + */ +function isBrowserEnvironment() { + return typeof window !== "undefined"; +} + +function formatMacrosValues(macros) { + return typeof macros !== "object" ? macros : JSON.stringify(macros); +} + +export const util = { + track, + resolveURLTemplates, + extractURLsFromTemplates, + filterUrlTemplates, + containsTemplateObject, + isTemplateObjectEqual, + encodeURIComponentRFC3986, + replaceUrlMacros, + isNumeric, + flatten, + joinArrayOfUniqueTemplateObjs, + isValidTimeValue, + addLeadingZeros, + isValidUrl, + isBrowserEnvironment, + formatMacrosValues, +}; diff --git a/packages/stitcher/extern/vast-client/src/vast_client.js b/packages/stitcher/extern/vast-client/src/vast_client.js new file mode 100644 index 00000000..efdefa86 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/vast_client.js @@ -0,0 +1,201 @@ +import { Storage } from "./util/storage.js"; +import { VASTParser } from "./parser/vast_parser.js"; +import { Fetcher } from "./fetcher/fetcher.js"; + +/** + * This class provides methods to fetch and parse a VAST document using VASTParser. + * In addition it provides options to skip consecutive calls based on constraints. + * @export + * @class VASTClient + */ +export class VASTClient { + /** + * Creates an instance of VASTClient. + * @param {Number} cappingFreeLunch - The number of first calls to skip. + * @param {Number} cappingMinimumTimeInterval - The minimum time interval between two consecutive calls. + * @param {Storage} customStorage - A custom storage to use instead of the default one. + * @constructor + */ + constructor( + cappingFreeLunch = 0, + cappingMinimumTimeInterval = 0, + customStorage = new Storage(), + ) { + this.cappingFreeLunch = cappingFreeLunch; + this.cappingMinimumTimeInterval = cappingMinimumTimeInterval; + this.fetcher = new Fetcher(); + this.vastParser = new VASTParser({ fetcher: this.fetcher }); + this.storage = customStorage; + // Init values if not already set + if (this.lastSuccessfulAd === undefined) { + this.lastSuccessfulAd = 0; + } + + if (this.totalCalls === undefined) { + this.totalCalls = 0; + } + if (this.totalCallsTimeout === undefined) { + this.totalCallsTimeout = 0; + } + } + + /** + * Adds a filter function to the array of filters which are called before fetching a VAST document. + * @param {function} filter - The filter function to be added at the end of the array. + * @return {void} + */ + addURLTemplateFilter(filter) { + this.fetcher.addURLTemplateFilter(filter); + } + + /** + * Removes the last element of the url templates filters array. + * @return {void} + */ + removeLastURLTemplateFilter() { + this.fetcher.removeLastURLTemplateFilter(); + } + + /** + * Returns the number of filters of the url templates filters array. + * @return {Number} + */ + countURLTemplateFilters() { + return this.fetcher.countURLTemplateFilters(); + } + + /** + * Removes all the filter functions from the url templates filters array. + * @return {void} + */ + clearURLTemplateFilters() { + this.fetcher.clearURLTemplateFilters(); + } + + getParser() { + return this.vastParser; + } + + get lastSuccessfulAd() { + return this.storage.getItem("vast-client-last-successful-ad"); + } + + set lastSuccessfulAd(value) { + this.storage.setItem("vast-client-last-successful-ad", value); + } + + get totalCalls() { + return this.storage.getItem("vast-client-total-calls"); + } + + set totalCalls(value) { + this.storage.setItem("vast-client-total-calls", value); + } + + get totalCallsTimeout() { + return this.storage.getItem("vast-client-total-calls-timeout"); + } + + set totalCallsTimeout(value) { + this.storage.setItem("vast-client-total-calls-timeout", value); + } + + /** + * Returns a boolean indicating if there are more ads to resolve for the current parsing. + * @return {Boolean} + */ + hasRemainingAds() { + return this.vastParser.remainingAds.length > 0; + } + + /** + * Resolves the next group of ads. If all is true resolves all the remaining ads. + * @param {Boolean} all - If true all the remaining ads are resolved + * @return {Promise} + */ + getNextAds(all) { + return this.vastParser.getRemainingAds(all); + } + + /** + * Parses the given xml Object into a VASTResponse. + * Returns a Promise which resolves with a fully parsed VASTResponse or rejects with an Error. + * @param {Object} xml - An object representing a vast xml document. + * @param {Object} options - An optional Object of parameters to be used in the parsing and fetching process. + * @returns {Promise} + */ + parseVAST(xml, options = {}) { + this.fetcher.setOptions(options); + return this.vastParser.parseVAST(xml, options); + } + /** + * Gets a parsed VAST document for the given url, applying the skipping rules defined. + * Returns a Promise which resolves with a fully parsed VASTResponse or rejects with an Error. + * @param {String} url - The url to use to fecth the VAST document. + * @param {Object} options - An optional Object of parameters to be applied in the process. + * @return {Promise} + */ + get(url, options = {}) { + const now = Date.now(); + + // By default the client resolves only the first Ad or AdPod + if (!options.hasOwnProperty("resolveAll")) { + options.resolveAll = false; + } + + // Check totalCallsTimeout (first call + 1 hour), if older than now, + // reset totalCalls number, by this way the client will be eligible again + // for freelunch capping + if (this.totalCallsTimeout < now) { + this.totalCalls = 1; + this.totalCallsTimeout = now + 60 * 60 * 1000; + } else { + this.totalCalls++; + } + + return new Promise((resolve, reject) => { + if (this.cappingFreeLunch >= this.totalCalls) { + return reject( + new Error( + `VAST call canceled – FreeLunch capping not reached yet ${this.totalCalls}/${this.cappingFreeLunch}`, + ), + ); + } + + const timeSinceLastCall = now - this.lastSuccessfulAd; + + // Check timeSinceLastCall to be a positive number. If not, this mean the + // previous was made in the future. We reset lastSuccessfulAd value + if (timeSinceLastCall < 0) { + this.lastSuccessfulAd = 0; + } else if (timeSinceLastCall < this.cappingMinimumTimeInterval) { + return reject( + new Error( + `VAST call canceled – (${this.cappingMinimumTimeInterval})ms minimum interval reached`, + ), + ); + } + + this.vastParser.initParsingStatus(options); + this.fetcher.setOptions(options); + this.vastParser.rootURL = url; + + this.fetcher + .fetchVAST({ + url, + emitter: this.vastParser.emit.bind(this.vastParser), + maxWrapperDepth: this.vastParser.maxWrapperDepth, + }) + .then((xml) => { + options.previousUrl = url; + options.isRootVAST = true; + options.url = url; + return this.vastParser.parse(xml, options).then((resolvedAd) => { + const vastResponse = this.vastParser.buildVASTResponse(resolvedAd); + resolve(vastResponse); + }); + }) + .catch((err) => reject(err)); + }); + } +} diff --git a/packages/stitcher/extern/vast-client/src/vast_response.js b/packages/stitcher/extern/vast-client/src/vast_response.js new file mode 100644 index 00000000..99da86d6 --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/vast_response.js @@ -0,0 +1,7 @@ +export function createVASTResponse({ ads, errorURLTemplates, version }) { + return { + ads: ads || [], + errorURLTemplates: errorURLTemplates || [], + version: version || null, + }; +} diff --git a/packages/stitcher/extern/vast-client/src/vast_tracker.js b/packages/stitcher/extern/vast-client/src/vast_tracker.js new file mode 100644 index 00000000..1801722c --- /dev/null +++ b/packages/stitcher/extern/vast-client/src/vast_tracker.js @@ -0,0 +1,925 @@ +import { isCompanionAd } from "./companion_ad.js"; +import { isCreativeLinear } from "./creative/creative_linear.js"; +import { EventEmitter } from "./util/event_emitter.js"; +import { isNonLinearAd } from "./non_linear_ad.js"; +import { util } from "./util/util.js"; + +/** + * The default skip delay used in case a custom one is not provided + * @constant + * @type {Number} + */ +const DEFAULT_SKIP_DELAY = -1; + +/** + * This class provides methods to track an ad execution. + * + * @export + * @class VASTTracker + * @extends EventEmitter + */ +export class VASTTracker extends EventEmitter { + /** + * Creates an instance of VASTTracker. + * + * @param {VASTClient} client - An instance of VASTClient that can be updated by the tracker. [optional] + * @param {Ad} ad - The ad to track. + * @param {Creative} creative - The creative to track. + * @param {Object} [variation=null] - An optional variation of the creative. + * @param {Boolean} [muted=false] - The initial muted state of the video. + * @constructor + */ + constructor(client, ad, creative, variation = null, muted = false) { + super(); + this.ad = ad; + this.creative = creative; + this.variation = variation; + this.muted = muted; + this.impressed = false; + this.skippable = false; + this.trackingEvents = {}; + // We need to keep the last percentage of the tracker in order to + // calculate to trigger the events when the VAST duration is short + this.lastPercentage = 0; + this._alreadyTriggeredQuartiles = {}; + // Tracker listeners should be notified with some events + // no matter if there is a tracking URL or not + this.emitAlwaysEvents = [ + "creativeView", + "start", + "firstQuartile", + "midpoint", + "thirdQuartile", + "complete", + "resume", + "pause", + "rewind", + "skip", + "closeLinear", + "close", + ]; + + // Duplicate the creative's trackingEvents property so we can alter it + for (const eventName in this.creative.trackingEvents) { + const events = this.creative.trackingEvents[eventName]; + this.trackingEvents[eventName] = events.slice(0); + } + + // Nonlinear and companion creatives provide some tracking information at a variation level + // While linear creatives provided that at a creative level. That's why we need to + // differentiate how we retrieve some tracking information. + if (isCreativeLinear(this.creative)) { + this._initLinearTracking(); + } else { + this._initVariationTracking(); + } + + // If the tracker is associated with a client we add a listener to the start event + // to update the lastSuccessfulAd property. + if (client) { + this.on("start", () => { + client.lastSuccessfulAd = Date.now(); + }); + } + } + + /** + * Init the custom tracking options for linear creatives. + * + * @return {void} + */ + _initLinearTracking() { + this.linear = true; + this.skipDelay = this.creative.skipDelay; + + this.setDuration(this.creative.duration); + + this.clickThroughURLTemplate = this.creative.videoClickThroughURLTemplate; + this.clickTrackingURLTemplates = + this.creative.videoClickTrackingURLTemplates; + } + + /** + * Init the custom tracking options for nonlinear and companion creatives. + * These options are provided in the variation Object. + * + * @return {void} + */ + _initVariationTracking() { + this.linear = false; + this.skipDelay = DEFAULT_SKIP_DELAY; + + // If no variation has been provided there's nothing else to set + if (!this.variation) { + return; + } + + // Duplicate the variation's trackingEvents property so we can alter it + for (const eventName in this.variation.trackingEvents) { + const events = this.variation.trackingEvents[eventName]; + + // If for the given eventName we already had some trackingEvents provided by the creative + // we want to keep both the creative trackingEvents and the variation ones + if (this.trackingEvents[eventName]) { + this.trackingEvents[eventName] = this.trackingEvents[eventName].concat( + events.slice(0), + ); + } else { + this.trackingEvents[eventName] = events.slice(0); + } + } + + if (isNonLinearAd(this.variation)) { + this.clickThroughURLTemplate = + this.variation.nonlinearClickThroughURLTemplate; + this.clickTrackingURLTemplates = + this.variation.nonlinearClickTrackingURLTemplates; + this.setDuration(this.variation.minSuggestedDuration); + } else if (isCompanionAd(this.variation)) { + this.clickThroughURLTemplate = + this.variation.companionClickThroughURLTemplate; + this.clickTrackingURLTemplates = + this.variation.companionClickTrackingURLTemplates; + } + } + + /** + * Sets the duration of the ad and updates the quartiles based on that. + * + * @param {Number} duration - The duration of the ad. + */ + setDuration(duration) { + // check if duration is a valid time input + if (!util.isValidTimeValue(duration)) { + this.emit("TRACKER-error", { + message: `the duration provided is not valid. duration: ${duration}`, + }); + return; + } + this.assetDuration = duration; + // beware of key names, theses are also used as event names + this.quartiles = { + firstQuartile: Math.round(25 * this.assetDuration) / 100, + midpoint: Math.round(50 * this.assetDuration) / 100, + thirdQuartile: Math.round(75 * this.assetDuration) / 100, + }; + } + + /** + * Sets the duration of the ad and updates the quartiles based on that. + * This is required for tracking time related events. + * + * @param {Number} progress - Current playback time in seconds. + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @emits VASTTracker#start + * @emits VASTTracker#skip-countdown + * @emits VASTTracker#progress-[0-100]% + * @emits VASTTracker#progress-[currentTime] + * @emits VASTTracker#rewind + * @emits VASTTracker#firstQuartile + * @emits VASTTracker#midpoint + * @emits VASTTracker#thirdQuartile + */ + setProgress(progress, macros = {}, trackOnce = true) { + // check if progress is a valid time input + if (!util.isValidTimeValue(progress) || typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `One given setProgress parameter has the wrong type. progress: ${progress}, macros: ${util.formatMacrosValues( + macros, + )}`, + }); + + return; + } + + const skipDelay = this.skipDelay || DEFAULT_SKIP_DELAY; + + if (skipDelay !== -1 && !this.skippable) { + if (skipDelay > progress) { + this.emit("skip-countdown", skipDelay - progress); + } else { + this.skippable = true; + this.emit("skip-countdown", 0); + } + } + + if (this.assetDuration > 0) { + const percent = Math.round((progress / this.assetDuration) * 100); + const events = []; + if (progress > 0) { + events.push("start"); + for (let i = this.lastPercentage; i < percent; i++) { + events.push(`progress-${i + 1}%`); + } + events.push(`progress-${Math.round(progress)}`); + for (const quartile in this.quartiles) { + if ( + this.isQuartileReached(quartile, this.quartiles[quartile], progress) + ) { + events.push(quartile); + this._alreadyTriggeredQuartiles[quartile] = true; + } + } + this.lastPercentage = percent; + } + events.forEach((eventName) => { + this.track(eventName, { macros, once: trackOnce }); + }); + + if (progress < this.progress) { + this.track("rewind", { macros }); + } + } + + this.progress = progress; + } + + /** + * Checks if a quartile has been reached without have being triggered already. + * + * @param {String} quartile - Quartile name + * @param {Number} time - Time offset of the quartile, when this quartile is reached in seconds. + * @param {Number} progress - Current progress of the ads in seconds. + * + * @return {Boolean} + */ + isQuartileReached(quartile, time, progress) { + let quartileReached = false; + // if quartile time already reached and never triggered + if (time <= progress && !this._alreadyTriggeredQuartiles[quartile]) { + quartileReached = true; + } + return quartileReached; + } + + /** + * Updates the mute state and calls the mute/unmute tracking URLs. + * + * @param {Boolean} muted - Indicates if the video is muted or not. + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @emits VASTTracker#mute + * @emits VASTTracker#unmute + */ + setMuted(muted, macros = {}) { + if (typeof muted !== "boolean" || typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `One given setMuted parameter has the wrong type. muted: ${muted}, macros: ${util.formatMacrosValues( + macros, + )}`, + }); + return; + } + + if (this.muted !== muted) { + this.track(muted ? "mute" : "unmute", { macros }); + } + this.muted = muted; + } + + /** + * Update the pause state and call the resume/pause tracking URLs. + * + * @param {Boolean} paused - Indicates if the video is paused or not. + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @emits VASTTracker#pause + * @emits VASTTracker#resume + */ + setPaused(paused, macros = {}) { + if (typeof paused !== "boolean" || typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `One given setPaused parameter has the wrong type. paused: ${paused}, macros: ${util.formatMacrosValues( + macros, + )}`, + }); + return; + } + if (this.paused !== paused) { + this.track(paused ? "pause" : "resume", { macros }); + } + this.paused = paused; + } + + /** + * Updates the fullscreen state and calls the fullscreen tracking URLs. + * + * @param {Boolean} fullscreen - Indicates if the video is in fulscreen mode or not. + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @emits VASTTracker#fullscreen + * @emits VASTTracker#exitFullscreen + */ + setFullscreen(fullscreen, macros = {}) { + if (typeof fullscreen !== "boolean" || typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `One given setFullScreen parameter has the wrong type. fullscreen: ${fullscreen}, macros: ${util.formatMacrosValues( + macros, + )}`, + }); + return; + } + if (this.fullscreen !== fullscreen) { + this.track(fullscreen ? "fullscreen" : "exitFullscreen", { macros }); + } + this.fullscreen = fullscreen; + } + + /** + * Updates the expand state and calls the expand/collapse tracking URLs. + * + * @param {Boolean} expanded - Indicates if the video is expanded or not. + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @emits VASTTracker#expand + * @emits VASTTracker#playerExpand + * @emits VASTTracker#collapse + * @emits VASTTracker#playerCollapse + */ + setExpand(expanded, macros = {}) { + if (typeof expanded !== "boolean" || typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `One given setExpand parameter has the wrong type. expanded: ${expanded}, macros: ${util.formatMacrosValues( + macros, + )}`, + }); + return; + } + + if (this.expanded !== expanded) { + this.track(expanded ? "expand" : "collapse", { macros }); + this.track(expanded ? "playerExpand" : "playerCollapse", { macros }); + } + this.expanded = expanded; + } + + /** + * Must be called if you want to overwrite the Skipoffset value. + * This will init the skip countdown duration. Then, every time setProgress() is called, + * it will decrease the countdown and emit a skip-countdown event with the remaining time. + * Do not call this method if you want to keep the original Skipoffset value. + * + * @param {Number} duration - The time in seconds until the skip button is displayed. + */ + setSkipDelay(duration) { + if (!util.isValidTimeValue(duration)) { + this.emit("TRACKER-error", { + message: `setSkipDelay parameter does not have a valid value. duration: ${duration}`, + }); + return; + } + this.skipDelay = duration; + } + + /** + * Tracks an impression (can be called only once). + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @emits VASTTracker#creativeView + */ + trackImpression(macros = {}) { + if (typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `trackImpression parameter has the wrong type. macros: ${macros}`, + }); + return; + } + if (!this.impressed) { + this.impressed = true; + this.trackURLs(this.ad.impressionURLTemplates, macros); + this.track("creativeView", { macros }); + } + } + + /** + * Tracks Viewable impression + * @param {Object} [macros = {}] An optional Object containing macros and their values to be used and replaced in the tracking calls. + */ + trackViewableImpression(macros = {}) { + if (typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `trackViewableImpression given macros has the wrong type. macros: ${macros}`, + }); + return; + } + this.ad.viewableImpression.forEach((impression) => { + this.trackURLs(impression.viewable, macros); + }); + } + + /** + * Tracks NotViewable impression + * @param {Object} [macros = {}] An optional Object containing macros and their values to be used and replaced in the tracking calls. + */ + + trackNotViewableImpression(macros = {}) { + if (typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `trackNotViewableImpression given macros has the wrong type. macros: ${macros}`, + }); + return; + } + + this.ad.viewableImpression.forEach((impression) => { + this.trackURLs(impression.notViewable, macros); + }); + } + + /** + * Tracks ViewUndetermined impression + * @param {Object} [macros = {}] An optional Object containing macros and their values to be used and replaced in the tracking calls. + */ + trackUndeterminedImpression(macros = {}) { + if (typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `trackUndeterminedImpression given macros has the wrong type. macros: ${macros}`, + }); + return; + } + + this.ad.viewableImpression.forEach((impression) => { + this.trackURLs(impression.viewUndetermined, macros); + }); + } + + /** + * Send a request to the URI provided by the VAST element. + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @param {Boolean} [isCustomCode=false] - Flag to allow custom values on error code. + */ + error(macros = {}, isCustomCode = false) { + if (typeof macros !== "object" || typeof isCustomCode !== "boolean") { + this.emit("TRACKER-error", { + message: `One given error parameter has the wrong type. macros: ${util.formatMacrosValues( + macros, + )}, isCustomCode: ${isCustomCode}`, + }); + return; + } + this.trackURLs(this.ad.errorURLTemplates, macros, { isCustomCode }); + } + + /** + * Send a request to the URI provided by the VAST element. + * If an [ERRORCODE] macro is included, it will be substitute with errorCode. + * @deprecated + * @param {String} errorCode - Replaces [ERRORCODE] macro. [ERRORCODE] values are listed in the VAST specification. + * @param {Boolean} [isCustomCode=false] - Flag to allow custom values on error code. + */ + errorWithCode(errorCode, isCustomCode = false) { + if (typeof errorCode !== "string" || typeof isCustomCode !== "boolean") { + this.emit("TRACKER-error", { + message: `One given errorWithCode parameter has the wrong type. errorCode: ${errorCode}, isCustomCode: ${isCustomCode}`, + }); + return; + } + this.error({ ERRORCODE: errorCode }, isCustomCode); + //eslint-disable-next-line + console.log( + "The method errorWithCode is deprecated, please use vast tracker error method instead", + ); + } + + /** + * Must be called when the user watched the linear creative until its end. + * Calls the complete tracking URLs. + * + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @emits VASTTracker#complete + */ + complete(macros = {}) { + if (typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `complete given macros has the wrong type. macros: ${macros}`, + }); + return; + } + this.track("complete", { macros }); + } + + /** + * Must be called if the ad was not and will not be played + * This is a terminal event; no other tracking events should be sent when this is used. + * Calls the notUsed tracking URLs. + * + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @emits VASTTracker#notUsed + */ + notUsed(macros = {}) { + if (typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `notUsed given macros has the wrong type. macros: ${macros}`, + }); + return; + } + this.track("notUsed", { macros }); + this.trackingEvents = []; + } + + /** + * An optional metric that can capture all other user interactions + * under one metric such as hover-overs, or custom clicks. It should NOT replace + * clickthrough events or other existing events like mute, unmute, pause, etc. + * Calls the otherAdInteraction tracking URLs. + * + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @emits VASTTracker#otherAdInteraction + */ + otherAdInteraction(macros = {}) { + if (typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `otherAdInteraction given macros has the wrong type. macros: ${macros}`, + }); + return; + } + this.track("otherAdInteraction", { macros }); + } + + /** + * Must be called if the user clicked or otherwise activated a control used to + * pause streaming content,* which either expands the ad within the player’s + * viewable area or “takes-over” the streaming content area by launching + * additional portion of the ad. + * Calls the acceptInvitation tracking URLs. + * + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @emits VASTTracker#acceptInvitation + */ + acceptInvitation(macros = {}) { + if (typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `acceptInvitation given macros has the wrong type. macros: ${macros}`, + }); + return; + } + this.track("acceptInvitation", { macros }); + } + + /** + * Must be called if user activated a control to expand the creative. + * Calls the adExpand tracking URLs. + * + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @emits VASTTracker#adExpand + */ + adExpand(macros = {}) { + if (typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `adExpand given macros has the wrong type. macros: ${macros}`, + }); + return; + } + this.track("adExpand", { macros }); + } + + /** + * Must be called when the user activated a control to reduce the creative to its original dimensions. + * Calls the adCollapse tracking URLs. + * + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @emits VASTTracker#adCollapse + */ + adCollapse(macros = {}) { + if (typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `adCollapse given macros has the wrong type. macros: ${macros}`, + }); + return; + } + this.track("adCollapse", { macros }); + } + + /** + * Must be called if the user clicked or otherwise activated a control used to minimize the ad. + * Calls the minimize tracking URLs. + * + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @emits VASTTracker#minimize + */ + minimize(macros = {}) { + if (typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `minimize given macros has the wrong type. macros: ${macros}`, + }); + return; + } + this.track("minimize", { macros }); + } + + /** + * Must be called if the player did not or was not able to execute the provided + * verification code.The [REASON] macro must be filled with reason code + * Calls the verificationNotExecuted tracking URL of associated verification vendor. + * + * @param {String} vendor - An identifier for the verification vendor. The recommended format is [domain]-[useCase], to avoid name collisions. For example, "company.com-omid". + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @emits VASTTracker#verificationNotExecuted + */ + verificationNotExecuted(vendor, macros = {}) { + if (typeof vendor !== "string" || typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `One given verificationNotExecuted parameter has to wrong type. vendor: ${vendor}, macros: ${util.formatMacrosValues( + macros, + )}`, + }); + return; + } + if ( + !this.ad || + !this.ad.adVerifications || + !this.ad.adVerifications.length + ) { + throw new Error("No adVerifications provided"); + } + + if (!vendor) { + throw new Error( + "No vendor provided, unable to find associated verificationNotExecuted", + ); + } + + const vendorVerification = this.ad.adVerifications.find( + (verifications) => verifications.vendor === vendor, + ); + + if (!vendorVerification) { + throw new Error( + `No associated verification element found for vendor: ${vendor}`, + ); + } + const vendorTracking = vendorVerification.trackingEvents; + + if (vendorTracking && vendorTracking.verificationNotExecuted) { + const verifsNotExecuted = vendorTracking.verificationNotExecuted; + this.trackURLs(verifsNotExecuted, macros); + this.emit("verificationNotExecuted", { + trackingURLTemplates: verifsNotExecuted, + }); + } + } + + /** + * The time that the initial ad is displayed. This time is based on + * the time between the impression and either the completed length of display based + * on the agreement between transactional parties or a close, minimize, or accept + * invitation event. + * The time will be passed using [ADPLAYHEAD] macros for VAST 4.1 + * Calls the overlayViewDuration tracking URLs. + * + * @param {String} formattedDuration - The time that the initial ad is displayed. + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @emits VASTTracker#overlayViewDuration + */ + overlayViewDuration(formattedDuration, macros = {}) { + if (typeof formattedDuration !== "string" || typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `One given overlayViewDuration parameters has the wrong type. formattedDuration: ${formattedDuration}, macros: ${util.formatMacrosValues( + macros, + )}`, + }); + return; + } + macros["ADPLAYHEAD"] = formattedDuration; + this.track("overlayViewDuration", { macros }); + } + + /** + * Must be called when the player or the window is closed during the ad. + * Calls the `closeLinear` (in VAST 3.0 and 4.1) and `close` tracking URLs. + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * + * @emits VASTTracker#closeLinear + * @emits VASTTracker#close + */ + close(macros = {}) { + if (typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `close given macros has the wrong type. macros: ${macros}`, + }); + return; + } + this.track(this.linear ? "closeLinear" : "close", { macros }); + } + + /** + * Must be called when the skip button is clicked. Calls the skip tracking URLs. + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * + * @emits VASTTracker#skip + */ + skip(macros = {}) { + if (typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `skip given macros has the wrong type. macros: ${macros}`, + }); + return; + } + this.track("skip", { macros }); + } + + /** + * Must be called then loaded and buffered the creative’s media and assets either fully + * or to the extent that it is ready to play the media + * Calls the loaded tracking URLs. + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * + * @emits VASTTracker#loaded + */ + load(macros = {}) { + if (typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `load given macros has the wrong type. macros: ${macros}`, + }); + return; + } + this.track("loaded", { macros }); + } + + /** + * Must be called when the user clicks on the creative. + * It calls the tracking URLs and emits a 'clickthrough' event with the resolved + * clickthrough URL when done. + * + * @param {?String} [fallbackClickThroughURL=null] - an optional clickThroughURL template that could be used as a fallback + * @param {Object} [macros={}] - An optional Object containing macros and their values to be used and replaced in the tracking calls. + * @emits VASTTracker#clickthrough + */ + click(fallbackClickThroughURL = null, macros = {}) { + if ( + (fallbackClickThroughURL !== null && + typeof fallbackClickThroughURL !== "string") || + typeof macros !== "object" + ) { + this.emit("TRACKER-error", { + message: `One given click parameter has the wrong type. fallbackClickThroughURL: ${fallbackClickThroughURL}, macros: ${util.formatMacrosValues( + macros, + )}`, + }); + return; + } + if ( + this.clickTrackingURLTemplates && + this.clickTrackingURLTemplates.length + ) { + this.trackURLs(this.clickTrackingURLTemplates, macros); + } + + // Use the provided fallbackClickThroughURL as a fallback + const clickThroughURLTemplate = + this.clickThroughURLTemplate || fallbackClickThroughURL; + // clone second usage of macros, which get mutated inside resolveURLTemplates + const clonedMacros = { ...macros }; + + if (clickThroughURLTemplate) { + if (this.progress) { + clonedMacros["ADPLAYHEAD"] = this.progressFormatted(); + } + const clickThroughURL = util.resolveURLTemplates( + [clickThroughURLTemplate], + clonedMacros, + )[0]; + + this.emit("clickthrough", clickThroughURL); + } + } + + /** + * Calls the tracking URLs for the given eventName and emits the event. + * + * @param {String} eventName - The name of the event. + * @param {Object} options + * @param {Object} [options.macros={}] - An optional Object of parameters(vast macros) to be used in the tracking calls. + * @param {Boolean} [options.once=false] - Boolean to define if the event has to be tracked only once. + * + */ + track(eventName, { macros = {}, once = false } = {}) { + if (typeof macros !== "object") { + this.emit("TRACKER-error", { + message: `track given macros has the wrong type. macros: ${macros}`, + }); + return; + } + // closeLinear event was introduced in VAST 3.0 + // Fallback to vast 2.0 close event if necessary + if ( + eventName === "closeLinear" && + !this.trackingEvents[eventName] && + this.trackingEvents["close"] + ) { + eventName = "close"; + } + + const trackingURLTemplates = this.trackingEvents[eventName]; + const isAlwaysEmitEvent = this.emitAlwaysEvents.indexOf(eventName) > -1; + + if (trackingURLTemplates) { + this.emit(eventName, { trackingURLTemplates }); + this.trackURLs(trackingURLTemplates, macros); + } else if (isAlwaysEmitEvent) { + this.emit(eventName, null); + } + + if (once) { + delete this.trackingEvents[eventName]; + if (isAlwaysEmitEvent) { + this.emitAlwaysEvents.splice( + this.emitAlwaysEvents.indexOf(eventName), + 1, + ); + } + } + } + + /** + * Calls the tracking urls templates with the given macros . + * + * @param {Array} URLTemplates - An array of tracking url templates. + * @param {Object} [macros ={}] - An optional Object of parameters to be used in the tracking calls. + * @param {Object} [options={}] - An optional Object of options to be used in the tracking calls. + */ + trackURLs(URLTemplates, macros = {}, options = {}) { + const { validUrls, invalidUrls } = util.filterUrlTemplates(URLTemplates); + + if (invalidUrls.length) { + this.emit("TRACKER-error", { + message: `Provided urls are malformed. url: ${invalidUrls}`, + }); + } + + //Avoid mutating the object received in parameters. + const givenMacros = { ...macros }; + if (this.linear) { + if ( + this.creative && + this.creative.mediaFiles && + this.creative.mediaFiles[0] && + this.creative.mediaFiles[0].fileURL + ) { + givenMacros["ASSETURI"] = this.creative.mediaFiles[0].fileURL; + } + if (this.progress) { + givenMacros["ADPLAYHEAD"] = this.progressFormatted(); + } + } + + if (this.creative?.universalAdIds?.length) { + givenMacros["UNIVERSALADID"] = this.creative.universalAdIds + .map((universalAdId) => + universalAdId.idRegistry.concat(" ", universalAdId.value), + ) + .join(","); + } + + if (this.ad) { + if (this.ad.sequence) { + givenMacros["PODSEQUENCE"] = this.ad.sequence; + } + if (this.ad.adType) { + givenMacros["ADTYPE"] = this.ad.adType; + } + if (this.ad.adServingId) { + givenMacros["ADSERVINGID"] = this.ad.adServingId; + } + if (this.ad.categories && this.ad.categories.length) { + givenMacros["ADCATEGORIES"] = this.ad.categories + .map((category) => category.value) + .join(","); + } + if (this.ad.blockedAdCategories && this.ad.blockedAdCategories.length) { + givenMacros["BLOCKEDADCATEGORIES"] = this.ad.blockedAdCategories + .map((blockedCategorie) => blockedCategorie.value) + .join(","); + } + } + + util.track(validUrls, givenMacros, options); + } + + /** + * Formats time in seconds to VAST timecode (e.g. 00:00:10.000) + * + * @param {Number} timeInSeconds - Number in seconds + * @return {String} + */ + convertToTimecode(timeInSeconds) { + if (!util.isValidTimeValue(timeInSeconds)) { + return ""; + } + const progress = timeInSeconds * 1000; + const hours = Math.floor(progress / (60 * 60 * 1000)); + const minutes = Math.floor((progress / (60 * 1000)) % 60); + const seconds = Math.floor((progress / 1000) % 60); + const milliseconds = Math.floor(progress % 1000); + return `${util.addLeadingZeros(hours, 2)}:${util.addLeadingZeros( + minutes, + 2, + )}:${util.addLeadingZeros(seconds, 2)}.${util.addLeadingZeros( + milliseconds, + 3, + )}`; + } + + /** + * Formats time progress in a readable string. + * + * @return {String} + */ + progressFormatted() { + return this.convertToTimecode(this.progress); + } +} diff --git a/packages/stitcher/package.json b/packages/stitcher/package.json index d4f21e0a..d49e705b 100644 --- a/packages/stitcher/package.json +++ b/packages/stitcher/package.json @@ -1,36 +1,35 @@ { "name": "@mixwave/stitcher", "type": "module", - "exports": { - "./schemas": "./dist/src/schemas.js" - }, - "typesVersions": { - "*": { - "schemas": [ - "src/schemas.ts" - ] - } - }, "scripts": { "build": "tsc", "start": "node './dist/src/index.js'", "dev": "tsc-watch --onSuccess \"node ./dist/src/index.js\"" }, "devDependencies": { + "@types/hh-mm-ss": "^1.2.3", "@types/node": "^22.1.0", "@types/parse-filepath": "^1.0.2", - "@types/qs": "^6.9.15", + "@types/uuid": "^10.0.0", "tsc-watch": "^6.2.0", "typescript": "^5.5.4" }, "dependencies": { + "@dailymotion/vast-client": "^6.0.0", + "@dailymotion/vmap": "^3.3.1", "@fastify/cors": "^9.0.1", - "@fastify/http-proxy": "^9.5.0", + "@mixwave/artisan": "workspace:*", + "@ts-rest/core": "^3.49.3", + "@ts-rest/fastify": "^3.49.3", + "@ts-rest/open-api": "^3.49.3", + "@xmldom/xmldom": "^0.8.10", "@zodyac/env": "^1.1.8", + "dom-parser": "^1.1.5", "fastify": "^4.28.1", - "js-base64": "^3.7.7", + "hh-mm-ss": "^1.2.0", "parse-filepath": "^1.0.2", - "qs": "^6.13.0", + "redis": "^4.7.0", + "uuid": "^10.0.0", "zod": "^3.23.8" } } diff --git a/packages/stitcher/src/const.ts b/packages/stitcher/src/const.ts index 6ac0b2c7..0680eeb9 100644 --- a/packages/stitcher/src/const.ts +++ b/packages/stitcher/src/const.ts @@ -1,3 +1 @@ -import { env } from "./env.js"; - -export const packageBaseUrl = `${env.S3_PUBLIC_URL}/package`; +export const NAMESPACE_UUID_AD = "5b212a7e-d6a2-43bf-bd30-13b1ca1f9b13"; diff --git a/packages/stitcher/src/contract.ts b/packages/stitcher/src/contract.ts new file mode 100644 index 00000000..3720c23f --- /dev/null +++ b/packages/stitcher/src/contract.ts @@ -0,0 +1,49 @@ +import { initContract } from "@ts-rest/core"; +import * as z from "zod"; + +const c = initContract(); + +export const postSessionBodySchema = z.object({ + url: z.string(), + vmapUrl: z.string().optional(), + ads: z + .array( + z.object({ + timeOffset: z.number(), + assetId: z.string(), + }), + ) + .optional(), +}); + +export const contract = c.router({ + postSession: { + method: "POST", + path: "/session", + body: postSessionBodySchema, + responses: {}, + }, + getMasterPlaylist: { + method: "GET", + path: "/session/:sessionId/master.m3u8", + responses: {}, + }, + getMediaPlaylist: { + method: "GET", + path: "/session/:sessionId/:path/playlist.m3u8", + responses: {}, + }, + getInterstitialsList: { + method: "GET", + path: "/interstitials/:sessionId/list.json", + query: z.object({ + offset: z.coerce.number(), + }), + responses: {}, + }, + getSpec: { + method: "GET", + path: "/spec.json", + responses: {}, + }, +}); diff --git a/packages/stitcher/src/env.ts b/packages/stitcher/src/env.ts index 56b770c2..a7c4b6eb 100644 --- a/packages/stitcher/src/env.ts +++ b/packages/stitcher/src/env.ts @@ -4,6 +4,8 @@ import { z } from "zod"; export const env = parse( z.object({ PORT: z.coerce.number(), + REDIS_HOST: z.string(), + REDIS_PORT: z.coerce.number(), S3_PUBLIC_URL: z.string(), }), ); diff --git a/packages/stitcher/src/index.ts b/packages/stitcher/src/index.ts index e323f197..1c72ab2b 100644 --- a/packages/stitcher/src/index.ts +++ b/packages/stitcher/src/index.ts @@ -1,54 +1,85 @@ import Fastify from "fastify"; -import fastifyProxy from "@fastify/http-proxy"; import cors from "@fastify/cors"; import { env } from "./env.js"; -import { getPlaylist } from "./playlist.js"; -import { packageBaseUrl } from "./const.js"; -import type { - FastifyReply, - FastifyRequest, - preValidationAsyncHookHandler, -} from "fastify"; - -function parsePath(path: string) { - // Remove querystring - const [pathNoQuery] = path.split(/[?#]/); - // Remove /out/ prefix - return pathNoQuery.replace("/out/", ""); -} - -const preValidation = async ( - request: FastifyRequest<{ - Querystring: { - p?: string; - }; - }>, - reply: FastifyReply, -) => { - const path = parsePath(request.url); - - if (path.endsWith("master.m3u8")) { - const playlistText = await getPlaylist("master", path, request.query); - reply.type("application/x-mpegURL").send(playlistText); - } - - if (path.endsWith("playlist.m3u8")) { - const playlistText = await getPlaylist("media", path, request.query); - reply.type("application/x-mpegURL").send(playlistText); - } -}; +import { contract } from "./contract.js"; +import { initServer } from "@ts-rest/fastify"; +import { generateOpenApi } from "@ts-rest/open-api"; +import { createSession, getSession } from "./session.js"; +import { + formatMasterPlaylist, + formatMediaPlaylist, + formatInterstitialsJson, +} from "./playlist.js"; +import parseFilepath from "parse-filepath"; async function buildServer() { const app = Fastify(); app.register(cors); - app.register(fastifyProxy, { - upstream: packageBaseUrl, - prefix: "/out", - preValidation: preValidation as preValidationAsyncHookHandler, + const s = initServer(); + + const router = s.router(contract, { + postSession: async ({ request, body }) => { + const session = await createSession(body); + + return { + status: 200, + body: { + url: `${request.protocol}://${request.hostname}/session/${session.id}/master.m3u8`, + session, + }, + }; + }, + getMasterPlaylist: async ({ params, reply }) => { + const session = await getSession(params.sessionId); + const response = await formatMasterPlaylist(session.url); + + reply.type("application/x-mpegURL"); + + return { + status: 200, + body: response, + }; + }, + getMediaPlaylist: async ({ params, reply }) => { + const session = await getSession(params.sessionId); + const filePath = parseFilepath(session.url); + + const response = await formatMediaPlaylist( + `${filePath.dir}/${params.path}/playlist.m3u8`, + session, + ); + + reply.type("application/x-mpegURL"); + + return { + status: 200, + body: response, + }; + }, + getInterstitialsList: async ({ query, params }) => { + const session = await getSession(params.sessionId); + return { + status: 200, + body: await formatInterstitialsJson(session, query.offset), + }; + }, + getSpec: async () => { + return { + status: 200, + body: generateOpenApi(contract, { + info: { + title: "Stitcher", + version: "1.0.0", + }, + }), + }; + }, }); + app.register(s.plugin(router)); + return app; } diff --git a/packages/stitcher/src/playlist.ts b/packages/stitcher/src/playlist.ts index 86902ebe..6757396f 100644 --- a/packages/stitcher/src/playlist.ts +++ b/packages/stitcher/src/playlist.ts @@ -1,88 +1,91 @@ -import parseFilePath from "parse-filepath"; -import { parsePlaylistParamsPayload } from "./schemas.js"; import { parse, stringify } from "../extern/hls-parser/index.js"; -import { Define, Interstitial } from "../extern/hls-parser/types.js"; -import { packageBaseUrl } from "./const.js"; -import type { - MasterPlaylist, - MediaPlaylist, -} from "../extern/hls-parser/types.js"; -import type { ParsedPath } from "parse-filepath"; -import type { PlaylistParams } from "./schemas.js"; - -type PlaylistType = "master" | "media"; - -type QueryParams = { - p?: string; +import parseFilepath from "parse-filepath"; +import { Interstitial } from "../extern/hls-parser/types.js"; +import { env } from "./env.js"; +import { MasterPlaylist, MediaPlaylist } from "../extern/hls-parser/types.js"; +import { Session } from "./types.js"; + +type InterstitialAsset = { + URI: string; + DURATION: number; }; -async function fetchPlaylist(url: string) { +async function fetchPlaylist(url: string) { const response = await fetch(url); const text = await response.text(); - return parse(text); + return parse(text) as T; } -export async function getPlaylist( - type: PlaylistType, - filePath: string, - query: QueryParams, -) { - const file = parseFilePath(filePath); +export async function formatMasterPlaylist(url: string) { + const master = await fetchPlaylist(url); - const playlist = await fetchPlaylist(`${packageBaseUrl}/${file.path}`); + return stringify(master); +} - const playlistParams = parsePlaylistParamsPayload(query.p); +export async function formatMediaPlaylist(url: string, session: Session) { + const media = await fetchPlaylist(url); - if (type === "master") { - mutateMasterPlaylist(file, playlist as MasterPlaylist, playlistParams); - } - if (type === "media") { - mutateMediaPlaylist(file, playlist as MediaPlaylist, playlistParams); - } + const filePath = parseFilepath(url); - return stringify(playlist); -} + for (const segment of media.segments) { + if (segment.map?.uri === "init.mp4") { + segment.map.uri = `${filePath.dir}/init.mp4`; + } -export async function mutateMasterPlaylist( - file: ParsedPath, - playlist: MasterPlaylist, - playlistParams: PlaylistParams, -) { - playlist.defines.push( - new Define({ - value: "p", - type: "QUERYPARAM", - }), - ); + segment.uri = `${filePath.dir}/${segment.uri}`; + } - playlist.variants.forEach((variant) => { - variant.uri = `${variant.uri}?p={$p}`; + const now = Date.now(); + + media.segments[0].programDateTime = new Date(now); - variant.audio.forEach((audioTrack) => { - audioTrack.uri = `${audioTrack.uri}?p={$p}`; + session.ads + .reduce((acc, ad) => { + if (!acc.includes(ad.timeOffset)) { + acc.push(ad.timeOffset); + } + return acc; + }, []) + .forEach((timeOffset) => { + media.interstitials.push( + new Interstitial({ + id: `${timeOffset}`, + startDate: new Date(now + timeOffset * 1000), + list: `/interstitials/${session.id}/list.json?offset=${timeOffset}`, + }), + ); }); - }); + + return stringify(media); } -export async function mutateMediaPlaylist( - file: ParsedPath, - playlist: MediaPlaylist, - playlistParams: PlaylistParams, +export async function formatInterstitialsJson( + session: Session, + timeOffset: number, ) { - const now = Date.now(); + const assets: InterstitialAsset[] = []; - if (playlistParams.interstitials) { - playlist.segments[0].programDateTime = new Date(now); + const ads = session.ads.filter((ad) => ad.timeOffset === timeOffset); - playlistParams.interstitials.forEach((interstitial, index) => { - playlist.interstitials.push( - new Interstitial({ - startDate: new Date(now), - id: `i${index + 1}`, - uri: `${packageBaseUrl}/${interstitial.assetId}/hls/master.m3u8`, - duration: 15, - }), - ); + for (const ad of ads) { + const uri = `${env.S3_PUBLIC_URL}/package/${ad.assetId}/hls/master.m3u8`; + assets.push({ + URI: uri, + DURATION: await getDuration(uri), }); } + + return { ASSETS: assets }; +} + +async function getDuration(url: string) { + const master = await fetchPlaylist(url); + const filePath = parseFilepath(url); + const media = await fetchPlaylist( + `${filePath.dir}/${master.variants[0].uri}`, + ); + return media.segments.reduce((acc, segment) => { + acc += segment.duration; + return acc; + }, 0); } diff --git a/packages/stitcher/src/redis.ts b/packages/stitcher/src/redis.ts new file mode 100644 index 00000000..58005b8b --- /dev/null +++ b/packages/stitcher/src/redis.ts @@ -0,0 +1,11 @@ +import { createClient } from "redis"; +import { env } from "./env.js"; + +export const client = createClient({ + socket: { + host: env.REDIS_HOST, + port: env.REDIS_PORT, + }, +}); + +await client.connect(); diff --git a/packages/stitcher/src/schemas.ts b/packages/stitcher/src/schemas.ts deleted file mode 100644 index ed237f66..00000000 --- a/packages/stitcher/src/schemas.ts +++ /dev/null @@ -1,29 +0,0 @@ -import * as z from "zod"; -import { Base64 } from "js-base64"; - -export const playlistParamsSchema = z.object({ - interstitials: z - .array( - z.object({ - offset: z.coerce.number(), - assetId: z.string(), - }), - ) - .optional(), -}); - -export type PlaylistParams = z.infer; - -export function createPlaylistParamsPayload(params: PlaylistParams) { - return Base64.encode(JSON.stringify(params)); -} - -export function parsePlaylistParamsPayload(value?: string): PlaylistParams { - if (!value) { - return {}; - } - const result = playlistParamsSchema.safeParse( - JSON.parse(Base64.decode(value)), - ); - return result.success ? result.data : {}; -} diff --git a/packages/stitcher/src/session.ts b/packages/stitcher/src/session.ts new file mode 100644 index 00000000..c0402aa1 --- /dev/null +++ b/packages/stitcher/src/session.ts @@ -0,0 +1,44 @@ +import { client } from "./redis.js"; +import { randomUUID } from "crypto"; +import { getAdsFromVmap } from "./vmap.js"; +import type { Session, Ad } from "./types.js"; + +const REDIS_PREFIX = `stitcher:session`; + +const key = (sessionId: string) => `${REDIS_PREFIX}:${sessionId}`; + +export async function createSession(data: { + url: string; + vmapUrl?: string; + ads?: Ad[]; +}) { + const sessionId = randomUUID(); + + let ads: Ad[] = []; + + if (data.ads) { + ads = data.ads; + } else if (data.vmapUrl) { + ads = await getAdsFromVmap(data.vmapUrl); + } + + const session = { + id: sessionId, + url: data.url, + ads, + } satisfies Session; + + await client.json.set(key(sessionId), `$`, session); + + await client.expire(key(sessionId), 60 * 60 * 6); + + return session; +} + +export async function getSession(sessionId: string) { + const data = await client.json.get(key(sessionId)); + if (!data) { + throw new Error("No session found for id"); + } + return data as Session; +} diff --git a/packages/stitcher/src/types.ts b/packages/stitcher/src/types.ts new file mode 100644 index 00000000..4bd264ce --- /dev/null +++ b/packages/stitcher/src/types.ts @@ -0,0 +1,10 @@ +export type Session = { + id: string; + url: string; + ads: Ad[]; +}; + +export type Ad = { + timeOffset: number; + assetId: string; +}; diff --git a/packages/stitcher/src/vast.ts b/packages/stitcher/src/vast.ts new file mode 100644 index 00000000..ab5dea91 --- /dev/null +++ b/packages/stitcher/src/vast.ts @@ -0,0 +1,149 @@ +import { env } from "./env.js"; +import { addTranscodeJob } from "@mixwave/artisan/producer"; +import { VASTClient } from "../extern/vast-client/index.js"; +import { DOMParser } from "@xmldom/xmldom"; +import * as uuid from "uuid"; +import { NAMESPACE_UUID_AD } from "./const.js"; +import type { VmapAdBreak, VmapResponse } from "./vmap.js"; +import type { Ad } from "./types.js"; +import type { + VastResponse, + VastCreativeLinear, + VastAd, +} from "../extern/vast-client/index.js"; + +export async function extractAdsFromVmap(vmapResponse: VmapResponse) { + const ads: Ad[] = []; + + for (const adBreak of vmapResponse.adBreaks) { + const adMedias = await getAdMedias(adBreak); + + for (const adMedia of adMedias) { + if (await isPackaged(adMedia.assetId)) { + ads.push({ + timeOffset: adBreak.timeOffset, + assetId: adMedia.assetId, + }); + } else { + scheduleForPackage(adMedia); + } + } + } + + return ads; +} + +async function getAdMedias(adBreak: VmapAdBreak): Promise { + const vastClient = new VASTClient(); + + if (adBreak.vastUrl) { + const response = await vastClient.get(adBreak.vastUrl); + return await formatVastResponse(response); + } + + if (adBreak.vastData) { + const parser = new DOMParser(); + const doc = parser.parseFromString(adBreak.vastData, "text/xml"); + + const response = await vastClient.parseVAST(doc); + + return await formatVastResponse(response); + } + + return []; +} + +async function isPackaged(assetId: string) { + const response = await fetch( + `${env.S3_PUBLIC_URL}/package/${assetId}/hls/master.m3u8`, + { + method: "HEAD", + }, + ); + return response.ok; +} + +function scheduleForPackage(adMedia: AdMedia) { + addTranscodeJob({ + assetId: adMedia.assetId, + package: true, + segmentSize: 4, + inputs: [ + { + path: adMedia.url, + type: "video", + }, + { + path: adMedia.url, + type: "audio", + language: "eng", + }, + ], + streams: [ + { + type: "video", + codec: "h264", + height: 480, + bitrate: 1500000, + framerate: 24, + }, + { + type: "audio", + codec: "aac", + bitrate: 128000, + language: "eng", + }, + ], + }); +} + +async function formatVastResponse(response: VastResponse) { + return response.ads.reduce((acc, ad) => { + const creative = getCreative(ad); + if (!creative) { + return acc; + } + + const mediaFile = getMediaFile(creative); + if (!mediaFile?.fileURL) { + return acc; + } + + const adId = getAdId(creative); + + acc.push({ + assetId: adId, + url: mediaFile.fileURL, + }); + + return acc; + }, []); +} + +function getMediaFile(creative: VastCreativeLinear) { + const mediaFiles = creative.mediaFiles + .filter((mediaFile) => mediaFile.mimeType === "video/mp4") + .sort((a, b) => b.height - a.height); + return mediaFiles[0] ?? null; +} + +function getCreative(ad: VastAd) { + for (const creative of ad.creatives) { + if (creative.type === "linear") { + return creative as VastCreativeLinear; + } + } + return null; +} + +function getAdId(creative: VastCreativeLinear) { + // Do not change this, or we'll have a mismatch between the already encoded ad's and the other. + // See https://iabtechlab.com/guidance-for-uniquely-identifying-creative-asset-ids-in-vast-2/ + const adId = [creative.adId, creative.id].join("."); + return uuid.v5(adId, NAMESPACE_UUID_AD); +} + +type AdMedia = { + assetId: string; + url: string; +}; diff --git a/packages/stitcher/src/vmap.ts b/packages/stitcher/src/vmap.ts new file mode 100644 index 00000000..97776286 --- /dev/null +++ b/packages/stitcher/src/vmap.ts @@ -0,0 +1,116 @@ +import { DOMParser, XMLSerializer } from "@xmldom/xmldom"; +import { extractAdsFromVmap } from "./vast.js"; +import timeFormat from "hh-mm-ss"; + +const USER_AGENT = + "Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/38.0.2125.111 Safari/537.36"; + +export async function getAdsFromVmap(url: string) { + const vmap = await getVmap(url); + return await extractAdsFromVmap(vmap); +} + +async function getVmap(url: string): Promise { + const doc = await getXml(url); + const rootElement = doc.documentElement; + + if (rootElement.localName !== "VMAP") { + throw new Error("Url did not resolve in a vmap"); + } + + const adBreaks: VmapAdBreak[] = []; + + childList(rootElement).forEach((element) => { + if (element.localName === "AdBreak") { + const timeOffset = toTimeOffset(element.getAttribute("timeOffset")); + if (timeOffset === null) { + return; + } + + adBreaks.push({ + timeOffset, + vastUrl: getVastUrl(element), + vastData: getVastData(element), + }); + } + }); + + return { adBreaks }; +} + +function getAdSource(element: Element) { + return childList(element).find((child) => child.localName === "AdSource"); +} + +function getVastUrl(element: Element) { + const adSource = getAdSource(element); + if (!adSource) { + return; + } + + const adTagUri = childList(adSource).find( + (child) => child.localName === "AdTagURI", + ); + + return adTagUri?.textContent?.trim(); +} + +function getVastData(element: Element) { + const adSource = getAdSource(element); + if (!adSource) { + return; + } + + const vastAdData = childList(adSource).find( + (child) => child.localName === "VASTAdData", + ); + + if (!vastAdData?.firstChild) { + return; + } + + const xmlSerializer = new XMLSerializer(); + + return xmlSerializer.serializeToString(vastAdData.firstChild); +} + +async function getXml(url: string) { + const response = await fetch(url, { + headers: { + "User-Agent": USER_AGENT, + }, + }); + + const text = await response.text(); + + const parser = new DOMParser(); + + return parser.parseFromString(text, "text/xml"); +} + +function childList(node: Element) { + return Array.from(node.childNodes) as Element[]; +} + +function toTimeOffset(value: string | null) { + if (value === null) { + return null; + } + if (value === "start") { + return 0; + } + if (value === "end") { + return null; + } + return timeFormat.toS(value); +} + +export type VmapAdBreak = { + timeOffset: number; + vastUrl?: string; + vastData?: string; +}; + +export type VmapResponse = { + adBreaks: VmapAdBreak[]; +}; diff --git a/packages/stitcher/tsconfig.json b/packages/stitcher/tsconfig.json index 3e521ee2..f0be0b33 100644 --- a/packages/stitcher/tsconfig.json +++ b/packages/stitcher/tsconfig.json @@ -7,6 +7,7 @@ "skipLibCheck": true, "outDir": "./dist", "noImplicitAny": false, + "allowJs": true, "allowSyntheticDefaultImports": true } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d229e0d..5d6c6770 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,9 +32,6 @@ importers: '@mixwave/artisan': specifier: workspace:* version: link:../artisan - '@mixwave/stitcher': - specifier: workspace:* - version: link:../stitcher '@ts-rest/core': specifier: ^3.49.3 version: 3.49.3(zod@3.23.8) @@ -266,40 +263,70 @@ importers: packages/stitcher: dependencies: + '@dailymotion/vast-client': + specifier: ^6.0.0 + version: 6.0.0 + '@dailymotion/vmap': + specifier: ^3.3.1 + version: 3.3.1 '@fastify/cors': specifier: ^9.0.1 version: 9.0.1 - '@fastify/http-proxy': - specifier: ^9.5.0 - version: 9.5.0 + '@mixwave/artisan': + specifier: workspace:* + version: link:../artisan + '@ts-rest/core': + specifier: ^3.49.3 + version: 3.49.3(zod@3.23.8) + '@ts-rest/fastify': + specifier: ^3.49.3 + version: 3.49.3(@ts-rest/core@3.49.3)(fastify@4.28.1)(zod@3.23.8) + '@ts-rest/open-api': + specifier: ^3.49.3 + version: 3.49.3(@ts-rest/core@3.49.3)(zod@3.23.8) + '@xmldom/xmldom': + specifier: ^0.8.10 + version: 0.8.10 '@zodyac/env': specifier: ^1.1.8 version: 1.1.8 + dom-parser: + specifier: ^1.1.5 + version: 1.1.5 fastify: specifier: ^4.28.1 version: 4.28.1 - js-base64: - specifier: ^3.7.7 - version: 3.7.7 + hh-mm-ss: + specifier: ^1.2.0 + version: 1.2.0 parse-filepath: specifier: ^1.0.2 version: 1.0.2 - qs: - specifier: ^6.13.0 - version: 6.13.0 + redis: + specifier: ^4.7.0 + version: 4.7.0 + uuid: + specifier: ^10.0.0 + version: 10.0.0 + uuid-by-string: + specifier: ^4.0.0 + version: 4.0.0 zod: specifier: ^3.23.8 version: 3.23.8 devDependencies: + '@types/hh-mm-ss': + specifier: ^1.2.3 + version: 1.2.3 '@types/node': specifier: ^22.1.0 version: 22.1.0 '@types/parse-filepath': specifier: ^1.0.2 version: 1.0.2 - '@types/qs': - specifier: ^6.9.15 - version: 6.9.15 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 tsc-watch: specifier: ^6.2.0 version: 6.2.0(typescript@5.5.4) @@ -2603,6 +2630,18 @@ packages: w3c-keyname: 2.2.8 dev: false + /@dailymotion/vast-client@6.0.0: + resolution: {integrity: sha512-5P31hlClpxCWhr997NfowKQZ7WkH5zvgbNwDqpCARU1kdnP6UaNQKajA9Cu060br0Y3X88CyTOKY9gTd6WzuNA==} + engines: {node: '>=12.22.1'} + dependencies: + '@xmldom/xmldom': 0.8.10 + dev: false + + /@dailymotion/vmap@3.3.1: + resolution: {integrity: sha512-0+JbHbK/ePKtQYAyrZWr7q/ec2hA355hOm5qwlNUt175ml6IBqKsSSjpz9cLe+0sYCKZGega7ED6gc+uoR5pbQ==} + engines: {node: '>=12.22.1'} + dev: false + /@docsearch/css@3.6.1: resolution: {integrity: sha512-VtVb5DS+0hRIprU2CO6ZQjK2Zg4QU5HrDM1+ix6rT0umsYvFvatMAnf97NHZlVWDaaLlx7GRfR/7FikANiM2Fg==} dev: true @@ -2843,11 +2882,6 @@ packages: fast-uri: 2.4.0 dev: false - /@fastify/busboy@2.1.1: - resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} - engines: {node: '>=14'} - dev: false - /@fastify/cors@9.0.1: resolution: {integrity: sha512-YY9Ho3ovI+QHIL2hW+9X4XqQjXLjJqsU+sMV/xFsxZkE8p3GNnYVFpoOxF7SsP5ZL76gwvbo3V9L+FIekBGU4Q==} dependencies: @@ -2865,36 +2899,12 @@ packages: fast-json-stringify: 5.16.1 dev: false - /@fastify/http-proxy@9.5.0: - resolution: {integrity: sha512-1iqIdV10d5k9YtfHq9ylX5zt1NiM50fG+rIX40qt00R694sqWso3ukyTFZVk33SDoSiBW8roB7n11RUVUoN+Ag==} - dependencies: - '@fastify/reply-from': 9.8.0 - fast-querystring: 1.1.2 - fastify-plugin: 4.5.1 - ws: 8.18.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - dev: false - /@fastify/merge-json-schemas@0.1.1: resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==} dependencies: fast-deep-equal: 3.1.3 dev: false - /@fastify/reply-from@9.8.0: - resolution: {integrity: sha512-bPNVaFhEeNI0Lyl6404YZaPFokudCplidE3QoOcr78yOy6H9sYw97p5KPYvY/NJNUHfFtvxOaSAHnK+YSiv/Mg==} - dependencies: - '@fastify/error': 3.4.1 - end-of-stream: 1.4.4 - fast-content-type-parse: 1.1.0 - fast-querystring: 1.1.2 - fastify-plugin: 4.5.1 - toad-cache: 3.7.0 - undici: 5.28.4 - dev: false - /@fastify/send@2.1.0: resolution: {integrity: sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==} dependencies: @@ -3945,6 +3955,55 @@ packages: resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} dev: false + /@redis/bloom@1.2.0(@redis/client@1.6.0): + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + dependencies: + '@redis/client': 1.6.0 + dev: false + + /@redis/client@1.6.0: + resolution: {integrity: sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==} + engines: {node: '>=14'} + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + dev: false + + /@redis/graph@1.1.1(@redis/client@1.6.0): + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + dependencies: + '@redis/client': 1.6.0 + dev: false + + /@redis/json@1.0.7(@redis/client@1.6.0): + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + dependencies: + '@redis/client': 1.6.0 + dev: false + + /@redis/search@1.2.0(@redis/client@1.6.0): + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + dependencies: + '@redis/client': 1.6.0 + dev: false + + /@redis/time-series@1.1.0(@redis/client@1.6.0): + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + dependencies: + '@redis/client': 1.6.0 + dev: false + /@remix-run/router@1.19.0: resolution: {integrity: sha512-zDICCLKEwbVYTS6TjYaWtHXxkdoUvD/QXvyVZjGCsWz5vyH7aFeONlPffPdW+Y/t6KT0MgXb2Mfjun9YpWN1dA==} engines: {node: '>=14.0.0'} @@ -5227,6 +5286,10 @@ packages: dependencies: '@types/unist': 3.0.2 + /@types/hh-mm-ss@1.2.3: + resolution: {integrity: sha512-pq7ntXovS/jF6ayHs/CH8b27Fd52skZPf/TE+gl/oWEP6884xSmnAAySUrzaox5ebigqhzPdtcvUAu0AzG54ZA==} + dev: true + /@types/http-errors@2.0.4: resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} dev: false @@ -5284,6 +5347,7 @@ packages: /@types/qs@6.9.15: resolution: {integrity: sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==} + dev: false /@types/range-parser@1.2.7: resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} @@ -5330,6 +5394,10 @@ packages: /@types/unist@3.0.2: resolution: {integrity: sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==} + /@types/uuid@10.0.0: + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + dev: true + /@types/web-bluetooth@0.0.20: resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} @@ -5596,6 +5664,11 @@ packages: - '@vue/composition-api' - vue + /@xmldom/xmldom@0.8.10: + resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==} + engines: {node: '>=10.0.0'} + dev: false + /@yarnpkg/fslib@2.10.3: resolution: {integrity: sha512-41H+Ga78xT9sHvWLlFOZLIhtU6mTGZ20pZ29EiZa97vnxdohJD2AF42rCoAoWfqUz486xY6fhjMH+DYEM9r14A==} engines: {node: '>=12 <14 || 14.2 - 14.9 || >14.10.0'} @@ -6427,6 +6500,10 @@ packages: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} dev: false + /dom-parser@1.1.5: + resolution: {integrity: sha512-lCiFG48ZUzGXjKN0qhSkxD/i3ndyV6I37zQ3W2VFYLjF1ob8A+QgSsM7Ps2UT0d3LpJxLMmMHiJJ34z5hkKLiA==} + dev: false + /dotenv@16.4.5: resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} engines: {node: '>=12'} @@ -6465,12 +6542,6 @@ packages: engines: {node: '>= 0.8'} dev: false - /end-of-stream@1.4.4: - resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} - dependencies: - once: 1.4.0 - dev: false - /entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -6948,6 +7019,11 @@ packages: engines: {node: '>=10'} dev: false + /generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + dev: false + /gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -7277,6 +7353,12 @@ packages: space-separated-tokens: 2.0.2 dev: false + /hh-mm-ss@1.2.0: + resolution: {integrity: sha512-f4I9Hz1dLpX/3mrEs7yq30+FiuO3tt5NWAqAGeBTaoeoBfB8vhcQ3BphuDc5DjZb/K809agqrAaFlP0jhEU/8w==} + dependencies: + zero-fill: 2.2.4 + dev: false + /highlight.js@11.10.0: resolution: {integrity: sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==} engines: {node: '>=12.0.0'} @@ -7587,15 +7669,19 @@ packages: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true - /js-base64@3.7.7: - resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} - dev: false - /js-cookie@3.0.5: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} dev: false + /js-md5@0.7.3: + resolution: {integrity: sha512-ZC41vPSTLKGwIRjqDh8DfXoCrdQIyBgspJVPXHBGu4nZlAEvG3nf+jO9avM9RmLiGakg7vz974ms99nEV0tmTQ==} + dev: false + + /js-sha1@0.6.0: + resolution: {integrity: sha512-01gwBFreYydzmU9BmZxpVk6svJJHrVxEN3IOiGl6VO93bVKYETJ0sIth6DASI6mIFdt7NmfX9UiByRzsYHGU9w==} + dev: false + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -8987,13 +9073,6 @@ packages: side-channel: 1.0.6 dev: false - /qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} - engines: {node: '>=0.6'} - dependencies: - side-channel: 1.0.6 - dev: false - /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -9235,6 +9314,17 @@ packages: redis-errors: 1.2.0 dev: false + /redis@4.7.0: + resolution: {integrity: sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==} + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.6.0) + '@redis/client': 1.6.0 + '@redis/graph': 1.1.1(@redis/client@1.6.0) + '@redis/json': 1.0.7(@redis/client@1.6.0) + '@redis/search': 1.2.0(@redis/client@1.6.0) + '@redis/time-series': 1.1.0(@redis/client@1.6.0) + dev: false + /regenerate-unicode-properties@10.1.1: resolution: {integrity: sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==} engines: {node: '>=4'} @@ -10136,13 +10226,6 @@ packages: /undici-types@6.13.0: resolution: {integrity: sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==} - /undici@5.28.4: - resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} - engines: {node: '>=14.0'} - dependencies: - '@fastify/busboy': 2.1.1 - dev: false - /unhead@1.9.16: resolution: {integrity: sha512-FOoXkuRNDwt7PUaNE0LXNCb6RCz4vTpkGymz4tJ8rcaG5uUJ0lxGK536hzCFwFw3Xkp3n+tkt2yCcbAZE/FOvA==} dependencies: @@ -10314,6 +10397,18 @@ packages: engines: {node: '>= 0.4.0'} dev: false + /uuid-by-string@4.0.0: + resolution: {integrity: sha512-88ZSfcSkN04juiLqSsuyteqlSrXNFdsEPzSv3urnElDXNsZUXQN0smeTnh99x2DE15SCUQNgqKBfro54CuzHNQ==} + dependencies: + js-md5: 0.7.3 + js-sha1: 0.6.0 + dev: false + + /uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + dev: false + /uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -10620,6 +10715,10 @@ packages: engines: {node: '>=10'} dev: false + /zero-fill@2.2.4: + resolution: {integrity: sha512-/N5GEDauLHz2uGnuJXWO1Wfib4EC+q4yp9C1jojM7RubwEKADqIqMcYpETMm1lRop403fi3v1qTOdgDE8DIOdw==} + dev: false + /zhead@2.2.4: resolution: {integrity: sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag==} dev: false