npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@missing-elements/h5p-offline-player

v0.1.5

Published

Browser-only H5P player web component. Plays arbitrary .h5p archives from a URL or a local file, with no server-side extraction.

Readme

@missing-elements/h5p-offline-player

npm licence

A browser-only H5P player, as one web component. It plays an arbitrary .h5p archive from a URL or from disk — no server-side extraction, no backend, nothing to unpack ahead of time.

<script type="module" src="h5p-player.js"></script>
<h5p-player src="https://host.example/course.h5p"></h5p-player>

Setting src loads, the way it does on <video>.

Getting started

The player is three things: the element (h5p-player.js, an ES module), a Service Worker script (h5p-sw.js) and a folder of H5P runtime files (frame-assets/). The worker is the one file that has to be served from your own origin, because browsers refuse to register a worker from anywhere else. The other two can come from your bundle or from a CDN.

A · With a bundler (Vite, webpack 5, Rollup):

npm i @missing-elements/h5p-offline-player
import '@missing-elements/h5p-offline-player'
<h5p-player src="https://host.example/course.h5p" auto-resize></h5p-player>

That is all: nothing to copy, nothing to configure. The element names every file it needs — the worker, the two runtime scripts, the stylesheet, each font — with its own new URL('./file', import.meta.url), and each file stands alone, so these bundlers emit them as hashed assets and rewrite the URLs themselves, in the dev server as in the build. A bundler that does not follow that pattern, esbuild among them, leaves the files behind: then serve the package's dist/ folder from a static path and set sw and assets-base to it.

B · No build step:

<script type="module"
  src="https://cdn.jsdelivr.net/npm/@missing-elements/h5p-offline-player/dist/h5p-player.js"></script>
<h5p-player src="https://host.example/course.h5p" sw="/h5p-sw.js" auto-resize></h5p-player>

Copy dist/h5p-sw.js from the same CDN path onto your site and point sw at it. The frame assets keep loading from the CDN. At the root its scope is /h5p/, not /, so an existing site worker is left alone.

C · An iframe, nothing on your site: frame the hosted player page, /embed?src=<package url>. It sizes itself through H5P's own resizer protocol and relays xAPI statements to your page on request. See Setup C in the setup guide.

Requirements: the page is on https:// or localhost, and the package's host sends CORS headers. Range support on the host is optional; without it the archive is downloaded once and played from the browser's cache.

const player = document.querySelector('h5p-player')
player.addEventListener('xapi', (event) => send(event.detail.statement))
player.addEventListener('error', (event) => {
  if (event.detail.code === 'no-cors') offerFilePicker()
})
input.addEventListener('change', () => { player.file = input.files[0] })

The full guide, with single-worker hosts and troubleshooting, is h5p-player-setup.md.

How it works

An .h5p file is a zip. Normally a server unpacks it and serves the files; here a Service Worker reads the archive in place and answers the H5P runtime's requests out of it:

  • the archive's central directory is read over HTTP Range requests, so a 300 MB package costs a few kilobytes before it starts;
  • large spans are pulled by several connections at once and reassembled in order, because a single connection is often capped well below the link — the first one streams straight into the inflate, and the others open once it flows, so a slow link still sees its first bytes within a second or two;
  • small entries and scripts are inflated once into a Cache API store — and on a host that honours Range, the runs of the archive that hold the libraries are pulled whole before the frame boots, a few requests instead of one or two per file. Against a host that answers a client's requests one at a time, that was the difference between a 128 s boot and a 9 s one;
  • large stored media is sliced straight out of the archive — never extracted, never stored;
  • large deflated media is inflated into 8 MB chunks by a page-side worker, and served progressively as a shorter 206 so a cold video starts before extraction finishes. One video at a time, the one the content asked for first at the head of the line, because a runtime that instantiates six videos at boot asks for all six, and sharing the link six ways only makes the one on screen slow;
  • the frame document the runtime lives in is generated by the worker, with a per-response CSP.

Nothing larger than one chunk is ever held in memory. A host that does not honour Range is handled by downloading the archive into the chunk store — indexed from its local headers as it arrives, so a package laid out libraries-first boots while its media is still coming down. A host with no CORS headers cannot be read by any browser, and the element says so with error: no-cors so the page can offer a file picker instead.

API

<h5p-player
  src="…"            package URL — setting it loads; setting it again aborts and reloads
  sw="…"             worker URL (default: h5p-sw.js next to the element, same-origin)
  assets-base="…"    directory of the frame assets (default: next to the element)
  libraries="…"      where to get libraries a package does not carry (default: nowhere)
  allow-origins="…"  extra origins the frame's CSP should permit, space separated
  auto-resize        follow the content's own height
  preload="auto"     pull large deflated media before the content asks (default: none)
></h5p-player>

Video that cannot stream

Most media starts playing while the rest arrives. Two properties of a package stop that, and some exports have both:

  • the media is deflated in the zip rather than stored, so there is no byte a Range request can reach without the whole stream up to it — and packagers routinely deflate an mp4 for a 0.9% saving;
  • the mp4 is not faststart, so its moov index is the last few kilobytes of the file, and no frame decodes until the final byte lands.

Together they mean a 220 MB video must transfer completely before it shows anything. Nothing on the player's side shortens that: the bytes are genuinely required. What it can do is start earlier.

<h5p-player src="course.h5p" preload="auto"></h5p-player>

After the content is up, the player pulls large deflated entries in archive order, one at a time, so the wait happens while the learner is still on the first slide instead of when they press play. It waits for the content to appear first — those same bytes would otherwise compete with the archive reads that boot the runtime — and it never runs two at once, which would only halve the rate of whichever video is needed first.

It is off by default: it spends a learner's bandwidth on media they may never reach, and that is the host's call. Progress arrives as progress events with phase: 'extract'.

The real fix belongs to whoever builds the package, and the repository ships it as a script:

npm run normalize -- course.h5p                    # writes course.normalized.h5p beside it
npm run normalize -- https://…/course.h5p --dry-run  # inspect only

It rewrites the container and leaves the content alone: media is stored rather than deflated, an mp4 whose index sits at the end is remuxed so the index comes first, entries are ordered so the libraries arrive before the media, and text an exporter left stored is deflated. The package grows by about 1% and any H5P host still accepts it. For the 220 MB example above, the video starts after about a megabyte instead of after the last byte, and seeking works at once.

What the frame is allowed to reach

The frame runs untrusted content under a generated CSP. Media, images and iframes are open, so embedded video works; scripts are same-origin plus a short list of what content types genuinely load at runtime — MathJax for H5P.MathDisplay, Google's WebFont loader, and the YouTube, Vimeo and Panopto player APIs that H5P.Video puts in the document before it embeds anything.

For a host the list cannot know about — a tenant's own Panopto or Echo360 server, an in-house CDN — allow-origins adds it:

<h5p-player src="…" allow-origins="https://tenant.panopto.com https://cdn.corp.example"></h5p-player>

It only ever adds hosts. Anything that is not plainly a host is dropped, so a stray value cannot append directives of its own. A blocked resource names its directive and origin in the console.

The policy allows 'unsafe-eval'. Packages that bundle EmbeddedJS — board games, older question sets — compile their templates with eval and render nothing without it. It gives an attacker no reach the archive's own scripts lack: those are served from the frame's origin and already run under 'self', so anyone who can put a file in the package can already run what they like. What the policy is for is limiting where code and data can come from, and this does not widen that.

Packages without libraries

Exports from h5p.com and h5p.org routinely contain content/ and nothing else: the site they came from already has the libraries, so bundling them would be waste. Anywhere else such a package cannot run, and the runtime's only symptom is a 404 for <MainLibrary>/library.json.

The player refuses these up front and names what is absent. libraries tells it where to look instead:

<!-- the official H5P content-type server, keyed on the package's mainLibrary -->
<h5p-player src="stripped.h5p" libraries="hub"></h5p-player>

<!-- or any .h5p that carries the library folders -->
<h5p-player src="stripped.h5p" libraries="/h5p/libraries-bundle.h5p"></h5p-player>

Entries then resolve against the package first and the bundle second, and the two h5p.json manifests are merged — a stripped export also cuts preloadedDependencies down to the main library, so without the merge an Interactive Video would load but its interactions would not. The bundle is downloaded once, cached, and shared by every package that uses it.

It is off by default. libraries="hub" is a request to a third party on every cold load, which is the host's decision to make, not the element's.

Properties: src, file (a File from a picker — setting it loads), pkgId, state (idle | probing | downloading | indexing | ready | error), scope (read-only).

| Event | When | |---|---| | ready | The runtime is up and the content is visible | | xapi | Any xAPI statement from the content — the only channel for results; nothing is stored | | finished | The content reported completion, with its score | | progress | Download, warm-up or extraction progress, fraction 0–1 (or null when the total is unknown); phase is download, libraries, warm or extract | | resize | The content's height changed | | statechange | state changed | | error | code: no-cors, no-worker, network, quota, bad-archive, runtime. A runtime error after ready leaves state at ready: the content threw but is still running |

const player = document.querySelector('h5p-player')
player.addEventListener('xapi', (e) => console.log(e.detail.statement))
player.addEventListener('error', (e) => {
  if (e.detail.code === 'no-cors') showDownloadHint()
})
input.onchange = () => (player.file = input.files[0])

The element renders the content and nothing else — no URL field, no file picker, no progress bar, no "open in another browser" banner. Those belong to the host page, built out of these events. index.html in this repo is a working example of one.

Requirements

  • The page is served over https:// or localhost. Service Workers do not run on http:// or file://.
  • h5p-sw.js is served from the site's own origin — browsers reject cross-origin Service Worker registration. With a bundler this is automatic. Frame assets may come from a CDN.
  • Package URLs send CORS headers. Range support is optional but avoids a full download first.
  • Storage is optional on a host that honours Range, and for a file picked from disk: the player caches what fits and serves the rest straight from the archive. A host without Range makes it a requirement — the whole archive has to be stored — and a package larger than the room the browser gives the site is refused with error: quota, naming the size it needed.

Integration details, including bundler-specific setup and single-worker hosts, are in h5p-player-setup.md. The design is in h5p-offline-player-architecture.md.

Development

npm install
npx playwright install chromium   # once, for the browser tests

npm run dev     # the demo player page on http://localhost:5173
npm test        # unit tests plus the browser suite
npm run build
npm run normalize -- course.h5p   # rewrite a package so it streams (see above)
npm run build:demo                # the hosted demo, as Vercel builds it, into dist-demo/
npm run preview:demo              # serve it locally with the production headers
npm run demo:content              # rebuild the demo's content packages from their sources

npm run dev serves the player page with real content — a quiz, an interactive video, an accordion and dialog cards, built from H5P hub libraries around text written for this player — plus, in dev only, generated test archives under /fixtures/ covering each path the player takes: a host that honours Range and one that does not, 20 MB of deflated and of stored media, hostile entry names, a file that is not an H5P package. /demo/ is the two-line integration, with the other ways to embed it — xAPI, a file from disk, an iframe, two players on one page — linked from there.

The demo site deploys to Vercel from vercel.json: the pages, the element, its worker and the frame assets as static files, plus one function that plays a host without Range support.

Working on the code? Start with AGENTS.md.

Licence

The player's own code — the element, the two workers, the scripts — is MIT, see LICENSE. The package as published is not MIT alone, and its license field says so: (MIT AND GPL-3.0-only).

  • dist/frame-assets/ is the H5P core runtime from h5p-standalone: the scripts unmodified, the stylesheet rebuilt so that it stands alone. h5p-standalone's own code is MIT, but its frame.bundle.js, stylesheet and icon fonts come from h5p-php-library, which is GPL-3.0; upstream confirms it in issue #188 while its npm metadata still says MIT. The directory carries its own LICENSE.txt and NOTICE.txt. Keep them with it when you copy or serve it: a site serving these files is distributing GPL code. A bundler emits the runtime files without the text files beside them, so h5p.css carries a notice comment of its own that names the licence. Whether the copyleft reaches the page around the player is a legal question, not one this README answers.
  • The two Service Worker scripts in dist/, h5p-sw.js and h5p-sw-mount.js, bundle zip.js (BSD-3-Clause). Minification strips its notice from the files, so NOTICE.md reproduces it.