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..b056998 100644 --- a/e2e/player.spec.ts +++ b/e2e/player.spec.ts @@ -20,12 +20,28 @@ function watchConsole(page: Page): string[] { return problems } +/** + * 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 bundle: string[] = [] - page.on('request', (request) => { - if (/\/assets\/libraries-.*\.h5p|api\.h5p\.org/.test(request.url())) bundle.push(request.url()) - }) + const leaving = watchLeaving(page, baseURL) await page.goto(`/h5p?src=${baseURL}${SAMPLE}`) const player = page.locator('h5p-player') @@ -38,20 +54,20 @@ 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(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 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 elsewhere: string[] = [] - page.on('response', (response) => { - if (/\/assets\/libraries-.*\.h5p/.test(response.url())) bundle.push(response.status()) - }) - page.on('request', (request) => { - if (/api\.h5p\.org|cdn\.jsdelivr\.net/.test(request.url())) elsewhere.push(request.url()) + const parts: number[] = [] + 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()) }) // 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 +75,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(leaving, 'nothing left this origin, and the whole pack was not asked for').toEqual([]) expect(problems, problems.join('\n')).toEqual([]) }) @@ -96,7 +115,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 +157,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"