From 52c87f98de32289c47768002ef5ddfac1d7dc498 Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Mon, 9 Nov 2026 10:27:17 +0200 Subject: [PATCH 1/2] feat: play packages from the library pack's parts, not the whole bundle /h5p handed startEmbed() the whole libraries.h5p, so the first package that lacked a library downloaded all of it: 15.5 MB since pack 1.1.0, 1.8 MB of it libraries an H5P site's upload check wants and the player never reads. It now passes the pack export, one part per library, as sites written by h5p-embed have done since 0.8: - A package downloads only the parts it needs, each checked against the pack's SHA-256 and kept for every package that uses it. The build page reads the same parts, and dist/ drops libraries.h5p: 23 MB in all. - A package that carries older libraries plays the pack's newer versions (player 0.10), as the website says it does. The report names them in libraryBundle.refreshed; &refresh-libraries=off plays them as exported. The local declaration of startEmbed() goes: h5p-embed ships its own since 1.1.0, and only that one knows the pack option. Co-Authored-By: Claude Opus 5.5 --- README.md | 19 +++++++++++------- e2e/player.spec.ts | 49 +++++++++++++++++++++++++++++++--------------- src/h5p-embed.d.ts | 10 ---------- src/h5p-page.ts | 19 ++++++++++++------ tsconfig.json | 1 - 5 files changed, 58 insertions(+), 40 deletions(-) delete mode 100644 src/h5p-embed.d.ts diff --git a/README.md b/README.md index 7de4c3b..dd3d4fa 100644 --- a/README.md +++ b/README.md @@ -46,19 +46,24 @@ is shown, as the H5P CMS plugins check an upload: text keeps only the tags its f semantics do not name is dropped. Only a package that cannot play without its own copy of a library waits for a click that names its host. -A package exported without its libraries, as H5P.com and h5p.org export them, gets them from -`@missing-elements/h5p-libraries`: one `.h5p` with the H5P hub's libraries for every content type it serves, about -14 MB, which Vite emits as a hashed asset and the page uses by default (`defaultLibraries: 'pack'`). Nothing is -behind it: since player 0.6 there is no H5P hub fallback, so a content type the pack lacks is reported as missing -libraries, and the page makes no request to a third party by itself. Since player 0.6.1 the frame upgrades content -written for an older minor version of a library to the one the pack serves, with that library's `upgrades.js`. +A package exported without its libraries, as H5P.com and h5p.org export them, gets them from the pack of +`@missing-elements/h5p-libraries`: the H5P hub's libraries for every content type it serves, one part per library, +which Vite emits as hashed assets and the page uses by default (`pack`, `defaultLibraries: 'pack'`). A package +downloads only the parts it needs, each checked against the pack's SHA-256 and kept in the browser for every package +that uses it; the build page reads the same parts. Nothing is behind the pack: since player 0.6 there is no H5P hub +fallback, so a content type the pack lacks is reported as missing libraries, and the page makes no request to a third +party by itself. The frame upgrades content written for an older minor version of a library to the one the pack +serves, with that library's `upgrades.js`, so a package that carries older libraries plays the pack's newer ones +(player 0.10; `&refresh-libraries=off` plays them as exported), and the `report` names them in +`libraryBundle.refreshed`. Content made for a newer minor version than the pack has plays at the pack's newest older +one, listed in the report's `downgradedLibraries` (player 1.2; `&downgrade-libraries=off` refuses it instead). H5P's own buttons and dialogs, such as Fullscreen and Rights of use, are in the package's own language, `defaultLanguage` (or `language`) in its `h5p.json`, so they match the content's text (player 1.1.0). The frame document takes the same language, for screen readers. `&lang=` on the address, `de` or `pt-BR`, is the language for a package that names none. The translations come from the runtime (runtime 0.1.2): more than fifty languages, from Moodle's language packs, in a script Vite emits as `assets/l10n-*.js` that the frame fetches only for a language other -than English. The bundle's licence list is emitted as +than English. The pack's licence list is emitted as `assets/libraries-LICENSES.txt`. `&libraries=` in the query string overrides the sources; `&libraries=none` turns them off. diff --git a/e2e/player.spec.ts b/e2e/player.spec.ts index a052728..1a290f4 100644 --- a/e2e/player.spec.ts +++ b/e2e/player.spec.ts @@ -20,11 +20,14 @@ function watchConsole(page: Page): string[] { return problems } +/** Library sources a page of this origin should never ask: the pack whole, the H5P hub, jsDelivr's copy of the pack. */ +const ELSEWHERE = /\/libraries-[^/]*\.h5p|api\.h5p\.org|cdn\.jsdelivr\.net/ + test('the player page plays a package', async ({ page, baseURL }) => { const problems = watchConsole(page) - const bundle: string[] = [] - page.on('request', (request) => { - if (/\/assets\/libraries-.*\.h5p|api\.h5p\.org/.test(request.url())) bundle.push(request.url()) + const elsewhere: string[] = [] + page.context().on('request', (request) => { + if (ELSEWHERE.test(request.url())) elsewhere.push(request.url()) }) await page.goto(`/h5p?src=${baseURL}${SAMPLE}`) @@ -38,20 +41,23 @@ test('the player page plays a package', async ({ page, baseURL }) => { const frame = page.frameLocator('h5p-player iframe').first() await expect(frame.locator('.h5p-content, .h5p-container').first()).toBeVisible() - // A complete package never costs the library bundle, or a request anywhere else for libraries. - expect(bundle, 'no library source was asked for').toEqual([]) + // A complete package costs at most the parts of the libraries it carries at older versions than + // the pack's, from this origin: never the pack whole, and nothing from anywhere else. + expect(elsewhere, 'no library source off this origin, and not the whole pack, was asked for').toEqual([]) expect(problems, problems.join('\n')).toEqual([]) }) -test('a package exported without its libraries plays from the bundle', async ({ page, baseURL }) => { +test('a package exported without its libraries plays from the pack served here, part by part', async ({ page, baseURL }) => { const problems = watchConsole(page) - const bundle: number[] = [] + const parts: number[] = [] const elsewhere: string[] = [] - page.on('response', (response) => { - if (/\/assets\/libraries-.*\.h5p/.test(response.url())) bundle.push(response.status()) + // The context's events, not the page's: the player's workers fetch the parts. + page.context().on('response', (response) => { + const url = response.url() + if (url.startsWith(`${baseURL}/assets/`) && url.endsWith('.h5p')) parts.push(response.status()) }) - page.on('request', (request) => { - if (/api\.h5p\.org|cdn\.jsdelivr\.net/.test(request.url())) elsewhere.push(request.url()) + page.context().on('request', (request) => { + if (ELSEWHERE.test(request.url())) elsewhere.push(request.url()) }) // The quiz as H5P.com would export it: `content/` and a manifest that names only the main library. // It was written for Question Set 1.20 and the pack serves 1.21, so the frame runs the library's @@ -59,11 +65,14 @@ test('a package exported without its libraries plays from the bundle', async ({ await page.goto(`/h5p?src=${baseURL}/samples/quiz-without-libraries.h5p`) const player = page.locator('h5p-player') // The pack served here, and nothing behind it: the player has no hub to fall back to. - await expect(player).toHaveAttribute('libraries', /^\/assets\/libraries-[^ ]*\.h5p$/) + await expect(player).toHaveAttribute('libraries', 'pack') await expect.poll(async () => player.evaluate((element: HTMLElement & { state?: string }) => element.state), { timeout: 40_000 }).toBe('ready') await expect(page.frameLocator('h5p-player iframe').first().locator('.h5p-content, .h5p-container').first()).toBeVisible() - expect(bundle.length, 'the bundle was fetched from this origin').toBeGreaterThan(0) - expect(elsewhere, 'no library source off this origin was asked for').toEqual([]) + // The parts the quiz needs, a handful of the pack's hundred and more, each from this origin. + expect(parts.length, 'parts were fetched from this origin').toBeGreaterThan(0) + expect(parts.length, 'only the parts the quiz needs').toBeLessThan(40) + expect(parts.filter((status) => status !== 200), 'every part answered').toEqual([]) + expect(elsewhere, 'no library source off this origin, and not the whole pack, was asked for').toEqual([]) expect(problems, problems.join('\n')).toEqual([]) }) @@ -96,7 +105,7 @@ test('opened on its own, a package from another site plays untrusted, without a // Played as data: none of the package's own code, its libraries from the pack served here, and // its content shown only after the runtime's boot script has checked it against their semantics. expect(await player.evaluate((element: HTMLElement & { untrusted?: boolean }) => element.untrusted)).toBe(true) - await expect(player).toHaveAttribute('libraries', /^\/assets\/libraries-[^ ]*\.h5p$/) + await expect(player).toHaveAttribute('libraries', 'pack') await expect(page.getByRole('button', { name: 'Open with its own scripts' })).toHaveCount(0) await expect(page.frameLocator('h5p-player iframe').first().locator('.h5p-content, .h5p-container').first()).toBeVisible() expect(problems, problems.join('\n')).toEqual([]) @@ -138,7 +147,15 @@ test('a page that frames it gets a hello, a height and a report', async ({ page, action: 'report', source: { type: 'range-http', size: expect.any(Number) }, metadata: { title: 'Do you know what just happened?', license: 'CC0 1.0', authors: ['missing-elements'], mainLibrary: 'H5P.QuestionSet' }, - libraryBundle: null, + // The quiz carries its libraries, older than the pack's, so it plays the pack's newer versions + // from this origin (player 0.10) and the report names them; the website says so under its preview. + libraryBundle: { + url: `${baseURL}/assets/`, + origin: baseURL, + fromCache: expect.any(Boolean), + refreshed: expect.arrayContaining([{ library: 'H5P.QuestionSet', from: '1.20.31', to: expect.stringMatching(/^1\.\d+\.\d+$/) }]) + }, + downgradedLibraries: [], elapsedMs: expect.any(Number) }) }) diff --git a/src/h5p-embed.d.ts b/src/h5p-embed.d.ts deleted file mode 100644 index ff1253b..0000000 --- a/src/h5p-embed.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** `@missing-elements/h5p-embed` ships JavaScript with JSDoc and no declarations; this is the part the page uses. */ -declare module '@missing-elements/h5p-embed/embed.js' { - export function startEmbed(options?: { - librariesPack?: string | null - packages?: string[] | null - defaultLibraries?: string | null - runtime?: object | null - askInOwnFrame?: boolean - }): void -} diff --git a/src/h5p-page.ts b/src/h5p-page.ts index beebf81..fbf661f 100644 --- a/src/h5p-page.ts +++ b/src/h5p-page.ts @@ -1,7 +1,7 @@ import '@missing-elements/h5p-offline-player' import '@missing-elements/h5p-embed/embed.css' import { startEmbed } from '@missing-elements/h5p-embed/embed.js' -import libraries from '@missing-elements/h5p-libraries/libraries.h5p?url' +import { pack } from '@missing-elements/h5p-libraries' import { runtime } from '@missing-elements/h5p-runtime' /** @@ -12,13 +12,20 @@ import { runtime } from '@missing-elements/h5p-runtime' * * /h5p?src=[&frame][©right][&export][&icon][&reporting][&fullscreen=off] * [&xapi=][&activity-id=][&custom-css=][&lang=] - * [&libraries=pack| …|none][&preload=auto] + * [&libraries=pack| …|none][&refresh-libraries=off][&downgrade-libraries=off][&preload=auto] * * Options, as this origin needs them: + * - `pack`: the library pack's `pack` export, one part per library, which Vite emits here. A + * package downloads only the parts it needs, each checked against the pack's SHA-256 and kept in + * the browser for every package that uses it, where the whole `libraries.h5p` (15.5 MB since + * pack 1.1.0, 1.8 MB of it libraries the player never reads) was downloaded at the first package + * that lacked anything. A package that carries an older version of a library plays the pack's + * newer one (player 0.10), unless the address says `&refresh-libraries=off`. The build page reads + * the same parts. * - `defaultLibraries: 'pack'`: an export without its libraries (H5P.com, h5p.org) takes them from - * the pack served here, and from nowhere else: the player has no hub fallback since 0.6, so the - * page asks no third party for anything. Snippets have never carried `&libraries=`, so this keeps - * them playing such exports. + * that pack, and from nowhere else: the player has no hub fallback since 0.6, so the page asks no + * third party for anything. Snippets have never carried `&libraries=`, so this keeps them playing + * such exports. * - `packages` left out: a package from any host plays, which a public tool needs. * - `askInOwnFrame: false`: a page of this origin that frames it gets the package as it is, not * `untrusted`; nothing on this origin frames it except the browser tests. Framed by another site, @@ -26,4 +33,4 @@ import { runtime } from '@missing-elements/h5p-runtime' * package from another site plays `untrusted` (h5p-embed 0.4): none of its own code runs, and * since the runtime's 0.2 its content is checked against its libraries' semantics first. */ -startEmbed({ librariesPack: libraries, defaultLibraries: 'pack', runtime, askInOwnFrame: false }) +startEmbed({ pack, defaultLibraries: 'pack', runtime, askInOwnFrame: false }) diff --git a/tsconfig.json b/tsconfig.json index c25df3a..ba29d2a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,6 @@ "src/h5p-page.ts", "src/build-page.ts", "src/build-source.ts", - "src/h5p-embed.d.ts", "e2e", "playwright.config.ts", "vite.config.ts" From c680ca9062738772e5432d723c2fd0871c02f92b Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Mon, 9 Nov 2026 10:39:36 +0200 Subject: [PATCH 2/2] feat: enhance request tracking for library pack parts in player tests --- e2e/player.spec.ts | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/e2e/player.spec.ts b/e2e/player.spec.ts index 1a290f4..b056998 100644 --- a/e2e/player.spec.ts +++ b/e2e/player.spec.ts @@ -20,15 +20,28 @@ function watchConsole(page: Page): string[] { return problems } -/** Library sources a page of this origin should never ask: the pack whole, the H5P hub, jsDelivr's copy of the pack. */ -const ELSEWHERE = /\/libraries-[^/]*\.h5p|api\.h5p\.org|cdn\.jsdelivr\.net/ +/** + * Collects every request that leaves the origin under test, and any for the whole library pack, + * which this origin no longer serves. The pack's parts come from here and from nowhere else, so a + * library source of whatever name, jsDelivr's copy of the pack, another CDN or the H5P hub, shows + * up as a request to another origin: no list of hosts to keep. The listener is on the context, not + * the page, so that it also hears the Service Worker's own requests, which a page does not. + */ +function watchLeaving(page: Page, baseURL: string | undefined): string[] { + if (!baseURL) throw new Error('playwright.config.ts sets no baseURL') + const own = new URL(baseURL).origin + const leaving: string[] = [] + page.context().on('request', (request) => { + const { protocol, origin, pathname } = new URL(request.url()) + if (protocol !== 'http:' && protocol !== 'https:') return // data: and blob: + if (origin !== own || /\/libraries-[^/]*\.h5p$/.test(pathname)) leaving.push(request.url()) + }) + return leaving +} test('the player page plays a package', async ({ page, baseURL }) => { const problems = watchConsole(page) - const elsewhere: string[] = [] - page.context().on('request', (request) => { - if (ELSEWHERE.test(request.url())) elsewhere.push(request.url()) - }) + const leaving = watchLeaving(page, baseURL) await page.goto(`/h5p?src=${baseURL}${SAMPLE}`) const player = page.locator('h5p-player') @@ -43,22 +56,19 @@ test('the player page plays a package', async ({ page, baseURL }) => { // A complete package costs at most the parts of the libraries it carries at older versions than // the pack's, from this origin: never the pack whole, and nothing from anywhere else. - expect(elsewhere, 'no library source off this origin, and not the whole pack, was asked for').toEqual([]) + expect(leaving, 'nothing left this origin, and the whole pack was not asked for').toEqual([]) expect(problems, problems.join('\n')).toEqual([]) }) test('a package exported without its libraries plays from the pack served here, part by part', async ({ page, baseURL }) => { const problems = watchConsole(page) const parts: number[] = [] - const elsewhere: string[] = [] - // The context's events, not the page's: the player's workers fetch the parts. + const leaving = watchLeaving(page, baseURL) + // On the context, as in `watchLeaving`, so that a part the Service Worker fetches is counted too. page.context().on('response', (response) => { const url = response.url() if (url.startsWith(`${baseURL}/assets/`) && url.endsWith('.h5p')) parts.push(response.status()) }) - page.context().on('request', (request) => { - if (ELSEWHERE.test(request.url())) elsewhere.push(request.url()) - }) // The quiz as H5P.com would export it: `content/` and a manifest that names only the main library. // It was written for Question Set 1.20 and the pack serves 1.21, so the frame runs the library's // upgrade steps on it first; a failed upgrade logs a console error, which fails this test. @@ -72,7 +82,7 @@ test('a package exported without its libraries plays from the pack served here, expect(parts.length, 'parts were fetched from this origin').toBeGreaterThan(0) expect(parts.length, 'only the parts the quiz needs').toBeLessThan(40) expect(parts.filter((status) => status !== 200), 'every part answered').toEqual([]) - expect(elsewhere, 'no library source off this origin, and not the whole pack, was asked for').toEqual([]) + expect(leaving, 'nothing left this origin, and the whole pack was not asked for').toEqual([]) expect(problems, problems.join('\n')).toEqual([]) })