@missing-elements/h5p-embed 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 missing-elements
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,130 @@
1
- # Temporary Holding Version
1
+ # @missing-elements/h5p-embed
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Self-hosted H5P embeds. One command writes the H5P embed page as a static site; you deploy it to
4
+ a player domain of your own, and any site — a page builder, a hosted CMS, an LMS page, a portal
5
+ with signed-in users — embeds a package with an iframe and one script line. No H5P server, no
6
+ backend, and no third-party service between your learners and your content.
7
+
8
+ ```bash
9
+ npx @missing-elements/h5p-embed h5p-player
10
+ ```
11
+
12
+ ```
13
+ Wrote h5p-player/ (11.1 MB): the embed page, the player 0.5.1, the H5P runtime, the library pack.
14
+ ```
15
+
16
+ Deploy the folder to any static host, then embed:
17
+
18
+ ```html
19
+ <iframe src="https://h5p-player.example.net/?src=https://cdn.example.org/course.h5p"
20
+ allow="fullscreen" style="width: 100%; border: 0"></iframe>
21
+ <script src="https://h5p-player.example.net/resizer.js"></script>
22
+ ```
23
+
24
+ The script line sizes the iframe to the content. A page that already has h5p.org's
25
+ `h5p-resizer.js` needs no second one; it speaks the same protocol.
26
+
27
+ ## Why a domain of its own
28
+
29
+ An H5P package is JavaScript, and the player runs it on the origin that serves the page. On a
30
+ domain that holds nothing else, a package cannot reach your site's page, cookies, storage or APIs.
31
+ Use a separate registrable domain — `h5p-player.example.net`, not `h5p.example.com` — because a
32
+ subdomain is the same site and receives cookies set for `.example.com`. Put no accounts, no
33
+ cookies and nothing else on it.
34
+
35
+ ## Options
36
+
37
+ | Option | Effect |
38
+ |---|---|
39
+ | `--packages <origins>` | Play packages, and fetch library bundles, only from these origins (this domain's own is always allowed). The page refuses anything else by name, and the policy's `connect-src` blocks it in the browser. Add `https://api.h5p.org` to allow `libraries=hub` |
40
+ | `--ancestors <origins>` | Only these sites may frame the page: `frame-ancestors`, which only a header can carry |
41
+ | `--no-libraries` | Leave out the 9.5 MB library pack; `libraries=pack` then means the hub, where allowed |
42
+ | `--force` | Write into a folder that is not empty, replacing only this tool's files |
43
+
44
+ Origins are written `https://host.example`, comma or space separated, with no path or trailing
45
+ slash. `http://localhost` is accepted for trying it locally.
46
+
47
+ List every host a package URL passes through. The page checks the address it is given, but the
48
+ browser's policy also applies to each redirect, so a listed host that redirects to a CDN off the
49
+ list fails as an ordinary network error, with nothing naming the list as the cause.
50
+
51
+ A player domain for one organisation is best locked to its own hosts:
52
+
53
+ ```bash
54
+ npx @missing-elements/h5p-embed h5p-player \
55
+ --packages https://cdn.example.org \
56
+ --ancestors "https://www.example.org https://lms.example.org"
57
+ ```
58
+
59
+ ## Hosting
60
+
61
+ | Host | Policy and caching |
62
+ |---|---|
63
+ | Netlify, Cloudflare Pages | `_headers`, written beside the page |
64
+ | Vercel | `vercel.json`, written beside the page; deploy the folder as a project |
65
+ | GitHub Pages, S3, nginx, anything else | The page carries the policy in a `<meta>` tag. `--ancestors` needs a header: configure `Content-Security-Policy: frame-ancestors …` on the server, or leave it out |
66
+
67
+ The site works at the domain's root or under a path (`https://example.github.io/player/`): every
68
+ URL in it is relative, and the player's Service Worker takes the scope `<folder>/h5p/`. Serve it
69
+ over https; a frame in a page on plain http has no Service Worker.
70
+
71
+ `h5p-sw.js` should be served with `Cache-Control: no-cache`, as the header files say, so an update
72
+ reaches learners on their next visit. To update, run the command again with `--force` and
73
+ redeploy.
74
+
75
+ ## The address
76
+
77
+ | Parameter | Effect |
78
+ |---|---|
79
+ | `src=<url>` | The package, required. Encode `&`, `#`, `+`, `%` and spaces in it |
80
+ | `libraries=pack`, `hub` or `<url>` | Libraries for an export that has none (h5p.com and h5p.org exports usually do not). `pack` is the copy on this domain, with the hub behind it where allowed, or the hub alone on a site written with `--no-libraries`; several sources may be given, tried in order |
81
+ | `frame`, `copyright`, `export`, `icon`, `reporting` | H5P's action bar under the content and its buttons |
82
+ | `fullscreen=off` | No fullscreen button |
83
+ | `preload=auto` | Start fetching media at once |
84
+ | `activity-id=<IRI>` | The object id every xAPI statement names, instead of the package URL |
85
+ | `custom-css=<url>` | A stylesheet of yours, loaded into the content |
86
+ | `xapi=<origin>` | Relay statements to the embedding page, see below |
87
+
88
+ Not available on the address: a custom script, and a learner's name. A script is a capability on
89
+ the player's origin that a link should not hand out, and a name has no place in a URL.
90
+
91
+ Opened on its own (not framed), the page asks before playing a package from another origin
92
+ unless `--packages` names it: there, every package shares the domain's storage.
93
+
94
+ ## Results
95
+
96
+ Add `&xapi=<the embedding page's origin>` and the frame posts every statement to that origin
97
+ and no other:
98
+
99
+ ```js
100
+ const frame = document.querySelector('iframe')
101
+ addEventListener('message', ({ source, origin, data }) => {
102
+ if (source !== frame.contentWindow || origin !== 'https://h5p-player.example.net') return
103
+ if (data?.context !== 'h5p-offline-player') return
104
+ if (data.action === 'xapi') send(data.statement) // every statement
105
+ if (data.action === 'finished') send(data.statement) // the final one, with result.score
106
+ })
107
+ ```
108
+
109
+ The checks prove where a message came from, not what it says: a package can post any statement,
110
+ so treat relayed results as the learner's report, not as proof for a grade. Each statement
111
+ carries `context.revision`, a fingerprint of the package build.
112
+
113
+ ## As a library
114
+
115
+ ```js
116
+ import { buildSite } from '@missing-elements/h5p-embed'
117
+
118
+ await buildSite({ out: 'public/player', packages: ['https://cdn.example.org'] })
119
+ ```
120
+
121
+ `@missing-elements/h5p-embed/embed.js` exports `startEmbed({ librariesPack, packages })`, the
122
+ page's script, for a site that builds the page into its own pipeline.
123
+
124
+ ## Licences
125
+
126
+ This package is MIT. The folder it writes also carries
127
+ [`@missing-elements/h5p-runtime`](https://www.npmjs.com/package/@missing-elements/h5p-runtime), the
128
+ H5P core runtime, under the GPL-3.0, as `frame-assets/` with its `LICENSE.txt` and `NOTICE.txt`;
129
+ and, unless `--no-libraries`, the H5P hub's libraries under their own licences, listed in
130
+ `libraries.txt`. `NOTICE.txt` in the folder says which file is what.
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs'
3
+ import { relative } from 'node:path'
4
+ import { parseArgs } from 'node:util'
5
+ import { EmbedError, buildSite } from '../lib/build.mjs'
6
+
7
+ const USAGE = `Usage: h5p-embed [folder] [options]
8
+
9
+ Writes the H5P embed page as a static site, ready to deploy to a player domain of your own.
10
+ The folder defaults to ./h5p-player.
11
+
12
+ Options:
13
+ --packages <origins> play packages, and fetch library bundles, only from these origins
14
+ (comma or space separated; this site's own is always allowed). Add
15
+ https://api.h5p.org to allow libraries=hub, and any host a package
16
+ URL redirects to. Default: any https host.
17
+ --ancestors <origins> only these sites may frame the page (frame-ancestors, sent as a header).
18
+ Default: any site.
19
+ --no-libraries leave out the 9.5 MB library pack that libraries=pack names
20
+ --force write into a folder that is not empty, replacing only this tool's files
21
+ -h, --help show this
22
+ -v, --version print the version`
23
+
24
+ let args
25
+ try {
26
+ args = parseArgs({
27
+ allowPositionals: true,
28
+ options: {
29
+ packages: { type: 'string', multiple: true },
30
+ ancestors: { type: 'string', multiple: true },
31
+ 'no-libraries': { type: 'boolean' },
32
+ force: { type: 'boolean' },
33
+ help: { type: 'boolean', short: 'h' },
34
+ version: { type: 'boolean', short: 'v' }
35
+ }
36
+ })
37
+ } catch (error) {
38
+ console.error(`h5p-embed: ${error instanceof Error ? error.message : error}\n\n${USAGE}`)
39
+ process.exit(2)
40
+ }
41
+
42
+ const { values, positionals } = args
43
+ if (values.help) {
44
+ console.log(USAGE)
45
+ process.exit(0)
46
+ }
47
+ if (values.version) {
48
+ console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version)
49
+ process.exit(0)
50
+ }
51
+ if (positionals.length > 1) {
52
+ console.error(`h5p-embed: one folder at most, got ${positionals.length}\n\n${USAGE}`)
53
+ process.exit(2)
54
+ }
55
+
56
+ const megabytes = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`
57
+
58
+ try {
59
+ const site = await buildSite({
60
+ out: positionals[0] ?? 'h5p-player',
61
+ libraries: !values['no-libraries'],
62
+ packages: values.packages ?? null,
63
+ ancestors: values.ancestors ?? null,
64
+ force: values.force ?? false
65
+ })
66
+ const below = relative(process.cwd(), site.out)
67
+ const folder = below === '' ? '.' : below.startsWith('..') ? site.out : below
68
+ const parts = [`the player ${site.version}`, 'the H5P runtime', site.libraries ? 'the library pack' : null].filter(Boolean)
69
+ console.log(`Wrote ${folder}/ (${megabytes(site.size)}): the embed page, ${parts.join(', ')}.`)
70
+ console.log(`Packages from: ${site.packages ? `this site, ${site.packages.join(', ')}` : 'any https host'}.`)
71
+ console.log(`Framed by: ${site.ancestors ? site.ancestors.join(', ') : 'any site'}.`)
72
+ console.log(`
73
+ Deploy the folder to a domain that holds nothing else — a separate registrable domain, not a
74
+ subdomain of your site — then embed a package:
75
+
76
+ <iframe src="https://<player domain>/?src=<package url>" allow="fullscreen"
77
+ style="width: 100%; border: 0"></iframe>
78
+ <script src="https://<player domain>/resizer.js"></script>
79
+
80
+ _headers (Netlify, Cloudflare Pages) and vercel.json (Vercel) carry the policy and the caching.
81
+ Other hosts, GitHub Pages among them, get the policy from the page itself${site.ancestors ? ', without --ancestors,\nwhich only a header can carry' : ''}.`)
82
+ } catch (error) {
83
+ if (error instanceof EmbedError) {
84
+ console.error(`h5p-embed: ${error.message}`)
85
+ process.exit(1)
86
+ }
87
+ throw error
88
+ }
package/lib/build.mjs ADDED
@@ -0,0 +1,231 @@
1
+ import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ /**
7
+ * Writes the embed page as a static site: one folder, deployable to any static host, that a site
8
+ * frames from a player domain of its own. Everything comes from installed packages — the element
9
+ * and its workers from the player's `dist/`, the H5P runtime from its own package as
10
+ * `frame-assets/`, the library pack from `@missing-elements/h5p-libraries` — so the versions are
11
+ * the ones this package was published with, and nothing is fetched.
12
+ */
13
+
14
+ export class EmbedError extends Error {}
15
+
16
+ const require = createRequire(import.meta.url)
17
+ // `fileURLToPath` rather than `import.meta.dirname`, which Node 20 has only from 20.11.
18
+ const SITE = fileURLToPath(new URL('../site', import.meta.url))
19
+
20
+ /** The page's own files, copied as they are; `index.html` is written, not copied. */
21
+ const PAGE_FILES = ['embed.js', 'embed.css', 'main.js', 'resizer.js']
22
+ /** The player's files, by the names the element looks for beside itself. */
23
+ const PLAYER_FILES = ['h5p-player.js', 'h5p-sw.js', 'h5p-jobs.js']
24
+
25
+ /** Where an installed package's file is, or a message that says which one is missing. */
26
+ function installed(specifier, hint) {
27
+ try {
28
+ return require.resolve(specifier)
29
+ } catch {
30
+ throw new EmbedError(`${specifier} is not installed or not built. ${hint}`)
31
+ }
32
+ }
33
+
34
+ /**
35
+ * An origin as the CSP and the page compare it: `https://host[:port]`, or plain http on a loopback
36
+ * address for trying it locally. Anything with a path, a query or a trailing slash is refused
37
+ * rather than trimmed, so a typo is not silently a different policy.
38
+ */
39
+ export function parseOrigin(value) {
40
+ let url
41
+ try {
42
+ url = new URL(value)
43
+ } catch {
44
+ throw new EmbedError(`Not an origin: ${value}. Write it as https://host.example`)
45
+ }
46
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
47
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
48
+ throw new EmbedError(`Not an https origin: ${value}`)
49
+ }
50
+ if (url.origin !== value) throw new EmbedError(`Not an origin: ${value}. Write it as ${url.origin}, with no path or trailing slash`)
51
+ return url.origin
52
+ }
53
+
54
+ /** Origins given as a list, or as one string separated by commas or whitespace. */
55
+ export function parseOrigins(values) {
56
+ if (values == null) return null
57
+ const list = (Array.isArray(values) ? values : [values]).flatMap((value) => String(value).split(/[\s,]+/)).filter(Boolean)
58
+ return [...new Set(list.map(parseOrigin))]
59
+ }
60
+
61
+ /**
62
+ * The page's policy. `connect-src` is where the element and its workers may fetch packages and
63
+ * library bundles from: any https host by default, which is what a player for links needs, or
64
+ * exactly the hosts given. `frame-ancestors` goes only into a header — a `<meta>` policy ignores
65
+ * it — and names who may frame the page; without it any site may.
66
+ */
67
+ /**
68
+ * @param {object} [options]
69
+ * @param {string[] | null} [options.packages]
70
+ * @param {string[] | null} [options.ancestors]
71
+ * @param {boolean} [options.meta] for the page's `<meta>`, which cannot carry `frame-ancestors`
72
+ */
73
+ export function contentSecurityPolicy({ packages = null, ancestors = null, meta = false } = {}) {
74
+ const directives = [
75
+ "default-src 'self'",
76
+ "script-src 'self'",
77
+ "style-src 'self'",
78
+ "img-src 'self' data: blob:",
79
+ "font-src 'self'",
80
+ `connect-src ${["'self'", ...(packages ?? ['https:'])].join(' ')}`,
81
+ "worker-src 'self' blob:",
82
+ "frame-src 'self'",
83
+ "object-src 'none'",
84
+ "base-uri 'self'",
85
+ "form-action 'self'"
86
+ ]
87
+ if (ancestors && !meta) directives.push(`frame-ancestors ${ancestors.join(' ')}`)
88
+ return directives.join('; ')
89
+ }
90
+
91
+ /** Header rules, one set for every host format: the policy everywhere, and the caching that matters. */
92
+ function headerRules(csp) {
93
+ const revalidate = 'public, max-age=3600, stale-while-revalidate=86400'
94
+ return [
95
+ {
96
+ path: '/*',
97
+ headers: {
98
+ 'Content-Security-Policy': csp,
99
+ 'X-Content-Type-Options': 'nosniff',
100
+ 'Referrer-Policy': 'strict-origin-when-cross-origin'
101
+ }
102
+ },
103
+ // The worker is checked on every load, so an update reaches learners on their next visit.
104
+ { path: '/h5p-sw.js', headers: { 'Cache-Control': 'no-cache' } },
105
+ { path: '/frame-assets/*', headers: { 'Cache-Control': revalidate } },
106
+ { path: '/resizer.js', headers: { 'Cache-Control': revalidate } },
107
+ { path: '/libraries.h5p', headers: { 'Cache-Control': revalidate } }
108
+ ]
109
+ }
110
+
111
+ /** Netlify's and Cloudflare Pages' `_headers`. */
112
+ function netlifyHeaders(rules) {
113
+ return rules.map(({ path, headers }) => `${path}\n${Object.entries(headers).map(([key, value]) => ` ${key}: ${value}`).join('\n')}`).join('\n') + '\n'
114
+ }
115
+
116
+ /** Vercel's `vercel.json`, for deploying the folder as a project of its own. */
117
+ function vercelConfig(rules) {
118
+ const source = (path) => (path === '/*' ? '/(.*)' : path.replace(/\*$/, '(.*)'))
119
+ return JSON.stringify(
120
+ { headers: rules.map(({ path, headers }) => ({ source: source(path), headers: Object.entries(headers).map(([key, value]) => ({ key, value })) })) },
121
+ null,
122
+ 2
123
+ ) + '\n'
124
+ }
125
+
126
+ function notice({ version, libraries }) {
127
+ return `This folder is the H5P embed page, written by @missing-elements/h5p-embed.
128
+
129
+ index.html, main.js, embed.js, embed.css, resizer.js, config.js
130
+ the embed page and the sizing script: MIT
131
+ h5p-player.js, h5p-sw.js, h5p-jobs.js
132
+ @missing-elements/h5p-offline-player ${version}: MIT. The two workers also
133
+ carry zip.js, BSD-3-Clause, its licence in each file's header.
134
+ frame-assets/ @missing-elements/h5p-runtime, the H5P core runtime: GPL-3.0-only. Its
135
+ LICENSE.txt and NOTICE.txt say what it is and where its source is; keep them
136
+ with it.
137
+ ${libraries ? ` libraries.h5p @missing-elements/h5p-libraries, the H5P hub's libraries, each under its
138
+ own licence, listed in libraries.txt.
139
+ ` : ''}
140
+ Serve it from a domain that holds nothing else, and frame it:
141
+
142
+ <iframe src="https://<this domain>/?src=<package url>" allow="fullscreen"
143
+ style="width: 100%; border: 0"></iframe>
144
+ <script src="https://<this domain>/resizer.js"></script>
145
+
146
+ https://github.com/missing-elements/h5p-offline-player/tree/main/packages/embed
147
+ `
148
+ }
149
+
150
+ /** Whether a directory exists and has anything in it. A path that is a file is an error. */
151
+ async function occupied(dir) {
152
+ let info
153
+ try {
154
+ info = await stat(dir)
155
+ } catch {
156
+ return false
157
+ }
158
+ if (!info.isDirectory()) throw new EmbedError(`${dir} is a file, not a folder.`)
159
+ return (await readdir(dir)).length > 0
160
+ }
161
+
162
+ /**
163
+ * Writes the site into `out`. Refuses a folder that already has files in it unless `force`, and
164
+ * even then only replaces the files it writes, so pointing it at the wrong folder costs nothing
165
+ * that was not its own.
166
+ *
167
+ * @param {object} options
168
+ * @param {string} options.out
169
+ * @param {boolean} [options.libraries] include the library pack, for `libraries=pack` (default true)
170
+ * @param {string[] | string | null} [options.packages] the only origins packages may come from
171
+ * @param {string[] | string | null} [options.ancestors] the only origins that may frame the page
172
+ * @param {boolean} [options.force]
173
+ */
174
+ export async function buildSite({ out, libraries = true, packages = null, ancestors = null, force = false }) {
175
+ if (!out) throw new EmbedError('No output folder given.')
176
+ const target = resolve(out)
177
+ const allowedPackages = parseOrigins(packages)
178
+ const allowedAncestors = parseOrigins(ancestors)
179
+ if (allowedPackages?.length === 0) throw new EmbedError('--packages names no origin.')
180
+ if (allowedAncestors?.length === 0) throw new EmbedError('--ancestors names no origin.')
181
+ // Checked with --force too: a path that is a file is refused either way.
182
+ if ((await occupied(target)) && !force) {
183
+ throw new EmbedError(`${out} is not empty. Choose an empty folder, or pass --force to replace the files this writes.`)
184
+ }
185
+
186
+ const player = dirname(installed('@missing-elements/h5p-offline-player/dist/h5p-player.js', 'Run `pnpm build` in the workspace, or reinstall this package.'))
187
+ const runtime = dirname(installed('@missing-elements/h5p-runtime/dist/h5p.css', 'Run `pnpm build` in the workspace, or reinstall this package.'))
188
+ const pack = libraries ? installed('@missing-elements/h5p-libraries/libraries.h5p', 'Reinstall this package, or pass --no-libraries.') : null
189
+ const version = (await readFile(join(player, 'VERSION'), 'utf8').catch(() => 'unknown')).trim()
190
+
191
+ await mkdir(target, { recursive: true })
192
+ for (const name of PAGE_FILES) await cp(join(SITE, name), join(target, name))
193
+ for (const name of PLAYER_FILES) await cp(join(player, name), join(target, name))
194
+ await rm(join(target, 'frame-assets'), { recursive: true, force: true })
195
+ await cp(runtime, join(target, 'frame-assets'), { recursive: true })
196
+ if (pack) {
197
+ await cp(pack, join(target, 'libraries.h5p'))
198
+ await cp(join(dirname(pack), 'libraries.txt'), join(target, 'libraries.txt'))
199
+ } else {
200
+ await rm(join(target, 'libraries.h5p'), { force: true })
201
+ await rm(join(target, 'libraries.txt'), { force: true })
202
+ }
203
+
204
+ const html = await readFile(join(SITE, 'index.html'), 'utf8')
205
+ await writeFile(join(target, 'index.html'), html.replace('%CSP%', contentSecurityPolicy({ packages: allowedPackages, meta: true })))
206
+ await writeFile(
207
+ join(target, 'config.js'),
208
+ `// Written by h5p-embed: what main.js hands the page.\nexport default ${JSON.stringify({ libraries: Boolean(pack), packages: allowedPackages })}\n`
209
+ )
210
+
211
+ const csp = contentSecurityPolicy({ packages: allowedPackages, ancestors: allowedAncestors })
212
+ const rules = headerRules(csp)
213
+ await writeFile(join(target, '_headers'), netlifyHeaders(rules))
214
+ await writeFile(join(target, 'vercel.json'), vercelConfig(rules))
215
+ await writeFile(join(target, 'NOTICE.txt'), notice({ version, libraries: Boolean(pack) }))
216
+
217
+ return { out: target, version, csp, libraries: Boolean(pack), packages: allowedPackages, ancestors: allowedAncestors, size: await sizeOf(target) }
218
+ }
219
+
220
+ /**
221
+ * The folder's size in bytes, for the summary. Paths from a recursive `readdir` rather than
222
+ * `Dirent.parentPath`, which Node 20 has only from 20.12.
223
+ */
224
+ async function sizeOf(dir) {
225
+ let total = 0
226
+ for (const name of await readdir(dir, { recursive: true })) {
227
+ const info = await stat(join(dir, name))
228
+ if (info.isFile()) total += info.size
229
+ }
230
+ return total
231
+ }
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@missing-elements/h5p-embed",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Self-hosted H5P embeds: writes the H5P embed page as a static site for a player domain of your own, then any site frames it with an iframe and one script line. No H5P server, no third-party service; optional allowlists for package hosts and embedding sites.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/missing-elements/h5p-offline-player.git",
9
+ "directory": "packages/embed"
10
+ },
11
+ "homepage": "https://github.com/missing-elements/h5p-offline-player/tree/main/packages/embed#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/missing-elements/h5p-offline-player/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "bin": {
20
+ "h5p-embed": "./bin/h5p-embed.mjs"
21
+ },
22
+ "exports": {
23
+ ".": "./lib/build.mjs",
24
+ "./embed.js": "./site/embed.js",
25
+ "./embed.css": "./site/embed.css",
26
+ "./resizer.js": "./site/resizer.js",
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "bin",
31
+ "lib",
32
+ "site"
33
+ ],
34
+ "keywords": [
35
+ "h5p",
36
+ "h5p-embed",
37
+ "h5p-self-hosted",
38
+ "h5p-player",
39
+ "iframe",
40
+ "embed",
41
+ "static-site",
42
+ "elearning",
43
+ "cli"
44
+ ],
45
+ "scripts": {
46
+ "test": "pnpm --filter @missing-elements/h5p-offline-player build && vitest run",
47
+ "typecheck": "tsc -p tsconfig.json",
48
+ "prepack": "node -e \"require('node:fs').copyFileSync('../../LICENSE', 'LICENSE')\"",
49
+ "prepublishOnly": "pnpm typecheck && pnpm test"
50
+ },
51
+ "dependencies": {
52
+ "@missing-elements/h5p-libraries": "workspace:^",
53
+ "@missing-elements/h5p-offline-player": "workspace:^",
54
+ "@missing-elements/h5p-runtime": "workspace:^"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^26.6.4",
58
+ "playwright": "^1.50.0",
59
+ "typescript": "^7.0.2",
60
+ "vitest": "^5.0.3"
61
+ },
62
+ "engines": {
63
+ "node": ">=20"
64
+ }
65
+ }
package/site/embed.css ADDED
@@ -0,0 +1,84 @@
1
+ /* The embeddable page: nothing of its own on screen but the element, so the embedding site's
2
+ layout is the layout. `flow-root` keeps the body's height honest for the size it reports. */
3
+ html,
4
+ body {
5
+ margin: 0;
6
+ padding: 0;
7
+ background: transparent;
8
+ }
9
+
10
+ body {
11
+ display: flow-root;
12
+ font: 15px/1.5 system-ui, -apple-system, 'Segoe UI', sans-serif;
13
+ color: #15171c;
14
+ }
15
+
16
+ h5p-player {
17
+ display: block;
18
+ }
19
+
20
+ .notice {
21
+ margin: 0;
22
+ padding: 0.9rem 1.1rem;
23
+ border: 1px solid #e2e6ec;
24
+ border-radius: 10px;
25
+ background: #f6f8fa;
26
+ }
27
+
28
+ .notice.error {
29
+ border-color: #f0c2c5;
30
+ background: #fdf2f3;
31
+ color: #8f1b22;
32
+ }
33
+
34
+ .notice a {
35
+ color: inherit;
36
+ font-weight: 600;
37
+ }
38
+
39
+ .notice button {
40
+ margin-left: 0.5rem;
41
+ padding: 0.35rem 0.8rem;
42
+ border: 1px solid currentColor;
43
+ border-radius: 6px;
44
+ background: transparent;
45
+ color: inherit;
46
+ font: inherit;
47
+ cursor: pointer;
48
+ }
49
+
50
+ /* The element draws nothing until it is ready, and the first seconds go to registering the worker
51
+ and probing the package. The loader is in the HTML, so it shows before any script has run. */
52
+ .loader {
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: center;
56
+ gap: 0.7rem;
57
+ min-height: 160px;
58
+ color: #5b6472;
59
+ }
60
+
61
+ .loader[hidden] {
62
+ display: none;
63
+ }
64
+
65
+ .spinner {
66
+ width: 1.4rem;
67
+ height: 1.4rem;
68
+ border: 3px solid #d9dee6;
69
+ border-top-color: #4a5568;
70
+ border-radius: 50%;
71
+ animation: spin 0.8s linear infinite;
72
+ }
73
+
74
+ @keyframes spin {
75
+ to {
76
+ transform: rotate(360deg);
77
+ }
78
+ }
79
+
80
+ @media (prefers-reduced-motion: reduce) {
81
+ .spinner {
82
+ animation-duration: 2.4s;
83
+ }
84
+ }
package/site/embed.js ADDED
@@ -0,0 +1,279 @@
1
+ /*! @missing-elements/h5p-embed. MIT. */
2
+ /**
3
+ * The embed page: the element alone, driven by the query string, for a site that puts a player
4
+ * domain of its own in an iframe. `startEmbed()` runs it; the page's `main.js` calls it with what
5
+ * `h5p-embed` was told when it wrote the site, and the demo's `/embed` with the demo's copy of the
6
+ * library pack.
7
+ *
8
+ * ?src=<package url>[&libraries=pack|hub|<url> …][&preload=auto][&xapi=<parent origin>]
9
+ * [&frame][&copyright][&export][&icon][&reporting][&fullscreen=off]
10
+ * [&activity-id=<IRI>][&custom-css=<stylesheet url>]
11
+ *
12
+ * Upward it speaks H5P's own resizer protocol — the `hello` / `resize` exchange that h5p.org's
13
+ * embed code and its `h5p-resizer.js` use — so a page that already resizes h5p.org iframes
14
+ * resizes this one without a change, and any other page gets `resizer.js` from this origin. xAPI
15
+ * statements are relayed to the parent only when `xapi=` names the parent's origin, and they are
16
+ * posted to that origin only.
17
+ */
18
+
19
+ /** Where `libraries=hub` fetches from: the one hub host that sends CORS headers (see the player). */
20
+ const HUB_ORIGIN = 'https://api.h5p.org'
21
+
22
+ /**
23
+ * @param {object} [options]
24
+ * @param {string | null} [options.librariesPack] the URL of a copy of `@missing-elements/h5p-libraries`
25
+ * on this site, which `libraries=pack` names; without one, `pack` is refused
26
+ * @param {string[] | null} [options.packages] the origins packages and library bundles may come
27
+ * from, besides this page's own; `null` plays any, asking first when the storage is this origin's
28
+ */
29
+ export function startEmbed({ librariesPack = null, packages = null } = {}) {
30
+ const params = new URLSearchParams(location.search)
31
+ const player = document.querySelector('h5p-player')
32
+ const notice = document.querySelector('#notice')
33
+ const loader = document.querySelector('#loader')
34
+ const framed = window.parent !== window
35
+
36
+ /* ---------------------------------------------------------------- notices */
37
+
38
+ const say = (text, kind = '', link = null) => {
39
+ notice.replaceChildren()
40
+ if (text) {
41
+ notice.append(text)
42
+ if (link) {
43
+ const anchor = document.createElement('a')
44
+ anchor.href = link.href
45
+ anchor.target = '_top'
46
+ anchor.rel = 'noopener'
47
+ anchor.textContent = link.text
48
+ notice.append(' ', anchor, '.')
49
+ }
50
+ }
51
+ notice.className = `notice ${kind}`.trim()
52
+ notice.hidden = !text
53
+ requestAnimationFrame(announce)
54
+ }
55
+
56
+ const refuse = (text) => {
57
+ loader.hidden = true
58
+ say(text, 'error')
59
+ }
60
+
61
+ /* ---------------------------------------------------------------- sizing, upward */
62
+
63
+ /** The parent hears about the height in the shape h5p-resizer.js expects. Nothing in it is secret. */
64
+ const post = (message, target = '*') => {
65
+ if (framed) window.parent.postMessage(message, target)
66
+ }
67
+
68
+ // The body's own height rather than the document's scrollHeight: the latter can never report
69
+ // less than the frame, so a shrink would never be seen.
70
+ const contentHeight = () => Math.ceil(document.body.getBoundingClientRect().height)
71
+
72
+ const announce = () => post({ context: 'h5p', action: 'resize', scrollHeight: contentHeight() })
73
+
74
+ window.addEventListener('message', (event) => {
75
+ if (event.source !== window.parent || !event.data || event.data.context !== 'h5p') return
76
+ switch (event.data.action) {
77
+ case 'ready':
78
+ // h5p-resizer.js announces itself once it is on the page; it expects a `hello` back.
79
+ post({ context: 'h5p', action: 'hello' })
80
+ break
81
+ case 'hello':
82
+ announce()
83
+ break
84
+ case 'resizePrepared':
85
+ announce()
86
+ break
87
+ }
88
+ })
89
+
90
+ post({ context: 'h5p', action: 'hello' })
91
+
92
+ // The element dispatches `resize` before it applies the height to itself; measure after layout.
93
+ player.addEventListener('resize', () => requestAnimationFrame(announce))
94
+ player.addEventListener('ready', () => requestAnimationFrame(announce))
95
+
96
+ /* ---------------------------------------------------------------- xAPI, relayed on request */
97
+
98
+ /** An origin, or nothing: the parameter has to be exactly what `event.origin` will read. */
99
+ const originOf = (value) => {
100
+ if (!value) return null
101
+ try {
102
+ const origin = new URL(value).origin
103
+ return origin !== 'null' && origin === value.replace(/\/$/, '') ? origin : null
104
+ } catch {
105
+ return null
106
+ }
107
+ }
108
+
109
+ const relayTo = originOf(params.get('xapi'))
110
+ if (relayTo && framed) {
111
+ for (const type of ['xapi', 'finished']) {
112
+ player.addEventListener(type, (event) => {
113
+ post({ context: 'h5p-offline-player', action: type, ...event.detail }, relayTo)
114
+ })
115
+ }
116
+ }
117
+
118
+ /* ---------------------------------------------------------------- errors, and Safari */
119
+
120
+ player.addEventListener('error', (event) => {
121
+ const { code, message } = event.detail
122
+ if (code === 'no-worker' && framed) {
123
+ // Detected, not sniffed: a browser, an in-app one or a page that is not https may give a
124
+ // frame no Service Worker, and the player cannot run without one. Safari does allow it.
125
+ say("This browser does not run the player inside another site's page.", 'error', {
126
+ href: location.href,
127
+ text: 'Open it on its own'
128
+ })
129
+ return
130
+ }
131
+ // Once the content is up, a runtime error inside it is the content's business: it keeps
132
+ // running, and a red notice over a working video would say otherwise.
133
+ if (code === 'runtime' && player.state === 'ready') {
134
+ console.warn(`h5p-player: the content reported an error and kept running: ${message}`)
135
+ return
136
+ }
137
+ say(message || code, 'error')
138
+ })
139
+
140
+ player.addEventListener('statechange', (event) => {
141
+ const { state } = event.detail
142
+ if (state !== 'error') say('')
143
+ // Shown from the HTML on, until the content is up or the load has failed.
144
+ loader.hidden = state === 'ready' || state === 'error' || state === 'idle'
145
+ requestAnimationFrame(announce)
146
+ })
147
+
148
+ /* ---------------------------------------------------------------- which hosts */
149
+
150
+ /** The origin of a URL as this page resolves it, or `null` for what is not one. */
151
+ const urlOrigin = (value) => {
152
+ try {
153
+ const url = new URL(value, location.href)
154
+ // A `data:` or `blob:` URL has an opaque origin: name its scheme, so it is never "ours".
155
+ return url.origin === 'null' ? url.protocol : url.origin
156
+ } catch {
157
+ return null
158
+ }
159
+ }
160
+
161
+ const allowed = packages ? new Set(packages) : null
162
+ /** Whether this player was told it may fetch from `origin`. Always true without a list. */
163
+ const permitted = (origin) => origin === location.origin || !allowed || allowed.has(origin)
164
+
165
+ /**
166
+ * The `libraries` value with `pack` resolved to this site's copy, or an error to show. With a
167
+ * list of hosts, every bundle's origin has to be on it, the hub's included; the CSP that
168
+ * `h5p-embed` wrote says the same, this only says it in words.
169
+ */
170
+ const librarySources = (value) => {
171
+ const sources = []
172
+ for (const token of value.split(/\s+/).filter(Boolean)) {
173
+ if (token === 'pack') {
174
+ // The pack, with the hub behind it for what it lacks, where the hub may be reached: an
175
+ // export without its libraries then plays with no request to h5p.org in the common case.
176
+ // A site set up without the pack falls back to the hub alone, so a snippet written for
177
+ // `pack` keeps playing after a rebuild with --no-libraries.
178
+ if (librariesPack) sources.push(librariesPack)
179
+ if (permitted(HUB_ORIGIN)) sources.push('hub')
180
+ else if (!librariesPack) return { error: 'This player was set up without the library pack, so libraries=pack is not available here.' }
181
+ } else if (token === 'hub') {
182
+ if (!permitted(HUB_ORIGIN)) return { error: 'This player does not fetch libraries from the H5P hub.' }
183
+ sources.push(token)
184
+ } else {
185
+ const origin = urlOrigin(token)
186
+ if (!origin || !permitted(origin)) return { error: `This player does not fetch libraries from ${origin ?? token}.` }
187
+ sources.push(token)
188
+ }
189
+ }
190
+ return { value: [...new Set(sources)].join(' ') }
191
+ }
192
+
193
+ /**
194
+ * The element's display options, by their attribute names, for the embedding page to ask for:
195
+ * `&frame&copyright&export` shows H5P's action bar with those buttons, `&fullscreen=off` takes
196
+ * that one away, `&activity-id=` names the statements' object and `&custom-css=` restyles the
197
+ * content to the embedding site's taste. Not `custom-js`, `embed-code` or `user`: a script is
198
+ * a capability on this origin that a link should not hand out, the embed is the embed, and a
199
+ * learner's name has no place in a URL.
200
+ */
201
+ const applyOptions = () => {
202
+ for (const name of ['frame', 'copyright', 'export', 'icon', 'reporting']) {
203
+ if (params.has(name) && params.get(name) !== 'off') player.setAttribute(name, '')
204
+ }
205
+ if (params.get('fullscreen') === 'off') player.setAttribute('fullscreen', 'off')
206
+ for (const name of ['activity-id', 'custom-css']) {
207
+ const value = params.get(name)?.trim()
208
+ if (value) player.setAttribute(name, value)
209
+ }
210
+ }
211
+
212
+ const start = (value, libraries) => {
213
+ if (libraries) player.setAttribute('libraries', libraries)
214
+ if (params.get('preload') === 'auto') player.setAttribute('preload', 'auto')
215
+ applyOptions()
216
+ player.setAttribute('src', value)
217
+ }
218
+
219
+ /**
220
+ * Whether this document's storage is this origin's own: top level, or framed by this origin. A
221
+ * package's scripts run with the storage of the origin it plays on. In another site's frame
222
+ * that storage is partitioned by the embedding site, so a page can only ever reach what was
223
+ * played under its own embed; opened on its own, a link to a package from elsewhere waits for
224
+ * a click.
225
+ */
226
+ const sharesOriginStorage = () => {
227
+ if (!framed) return true
228
+ try {
229
+ return window.parent.location.origin === location.origin
230
+ } catch {
231
+ return false // Another origin's frame: reading its location throws, and the storage is partitioned.
232
+ }
233
+ }
234
+
235
+ /* ---------------------------------------------------------------- load */
236
+
237
+ const src = params.get('src')?.trim()
238
+ if (!src) {
239
+ refuse('No package given. Add ?src=<url of a .h5p file> to the address.')
240
+ return
241
+ }
242
+ const origin = urlOrigin(src)
243
+ if (!origin) {
244
+ refuse('The package address is not a URL.')
245
+ return
246
+ }
247
+ if (!permitted(origin)) {
248
+ refuse(`This player does not play packages from ${origin}.`)
249
+ return
250
+ }
251
+ const libraries = params.get('libraries')?.trim()
252
+ const sources = libraries ? librarySources(libraries) : { value: '' }
253
+ if (sources.error) {
254
+ refuse(sources.error)
255
+ return
256
+ }
257
+
258
+ // A host on the list was vouched for when the site was set up; anything else from another
259
+ // origin waits for a click when it would share this origin's storage.
260
+ if (origin === location.origin || allowed || !sharesOriginStorage()) {
261
+ start(src, sources.value)
262
+ return
263
+ }
264
+ loader.hidden = true
265
+ const host = new URL(src, location.href).host || origin
266
+ const button = document.createElement('button')
267
+ button.type = 'button'
268
+ button.textContent = 'Open the package'
269
+ button.addEventListener('click', () => {
270
+ say('')
271
+ loader.hidden = false
272
+ start(src, sources.value)
273
+ })
274
+ say(
275
+ `This link opens a package from ${host}. A package runs its own scripts on this site, and they ` +
276
+ 'can read what other packages saved in this browser. Open it only if you trust that site.'
277
+ )
278
+ notice.append(' ', button)
279
+ }
@@ -0,0 +1,22 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta http-equiv="Content-Security-Policy" content="%CSP%" />
7
+ <meta name="robots" content="noindex" />
8
+ <title>H5P player</title>
9
+ <link rel="stylesheet" href="./embed.css" />
10
+ <script type="module" src="./h5p-player.js"></script>
11
+ <script type="module" src="./main.js"></script>
12
+ </head>
13
+
14
+ <body>
15
+ <div class="loader" id="loader" role="status">
16
+ <span class="spinner" aria-hidden="true"></span>
17
+ <span class="loader-text">Loading…</span>
18
+ </div>
19
+ <h5p-player auto-resize></h5p-player>
20
+ <p class="notice" id="notice" hidden></p>
21
+ </body>
22
+ </html>
package/site/main.js ADDED
@@ -0,0 +1,10 @@
1
+ /*! @missing-elements/h5p-embed. MIT. */
2
+ // The page's entry: `config.js` is what `h5p-embed` was told when it wrote this site — whether
3
+ // the library pack is here, and which hosts packages may come from.
4
+ import config from './config.js'
5
+ import { startEmbed } from './embed.js'
6
+
7
+ startEmbed({
8
+ librariesPack: config.libraries ? new URL('./libraries.h5p', import.meta.url).href : null,
9
+ packages: config.packages
10
+ })
@@ -0,0 +1,61 @@
1
+ /*! h5p-offline-player resizer. MIT. Sizes an <iframe> of the H5P embed page to its content. */
2
+ /**
3
+ * The page-side half of H5P's resizer protocol, for a page that frames the embed page
4
+ * (`@missing-elements/h5p-embed`). The frame can only report its height upward, by
5
+ * `postMessage`, and something on the page has to apply it:
6
+ * this script, in one line, or h5p.org's own `h5p-resizer.js`, which speaks the same protocol.
7
+ * This one is served from the player's origin, so an embedding page sends nothing to a third
8
+ * party and does not depend on a path on h5p.org.
9
+ *
10
+ * <script src="https://<player origin>/resizer.js"></script>
11
+ *
12
+ * It answers every frame on the page that speaks the protocol, which is also what h5p.org's
13
+ * script does: a frame can only ever ask for the height of itself. Classic script, no module,
14
+ * so it runs wherever an embed block runs.
15
+ */
16
+ (function () {
17
+ if (window.__h5pResizer) return;
18
+ window.__h5pResizer = true;
19
+
20
+ var frameOf = function (source) {
21
+ var frames = document.getElementsByTagName('iframe');
22
+ for (var i = 0; i < frames.length; i++) {
23
+ if (frames[i].contentWindow === source) return frames[i];
24
+ }
25
+ return null;
26
+ };
27
+
28
+ window.addEventListener('message', function (event) {
29
+ var data = event.data;
30
+ if (!data || data.context !== 'h5p' || !event.source) return;
31
+ var frame = frameOf(event.source);
32
+ if (!frame) return;
33
+ switch (data.action) {
34
+ case 'hello':
35
+ // The frame asks whether anyone is listening; the reply makes it start reporting.
36
+ event.source.postMessage({ context: 'h5p', action: 'hello' }, event.origin);
37
+ break;
38
+ case 'prepareResize':
39
+ // The frame is about to measure itself; it wants the box no larger than its content.
40
+ if (typeof data.scrollHeight === 'number' && frame.clientHeight !== data.scrollHeight) {
41
+ frame.style.height = data.scrollHeight + 'px';
42
+ }
43
+ event.source.postMessage({ context: 'h5p', action: 'resizePrepared' }, event.origin);
44
+ break;
45
+ case 'resize':
46
+ if (typeof data.scrollHeight === 'number') frame.style.height = data.scrollHeight + 'px';
47
+ break;
48
+ }
49
+ });
50
+
51
+ // A frame that loaded before this script said its hello to nobody; tell every frame the
52
+ // listener is here, and the ones that speak the protocol say hello again.
53
+ var announce = function () {
54
+ var frames = document.getElementsByTagName('iframe');
55
+ for (var i = 0; i < frames.length; i++) {
56
+ try { frames[i].contentWindow.postMessage({ context: 'h5p', action: 'ready' }, '*'); } catch (e) { /* not ours */ }
57
+ }
58
+ };
59
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', announce);
60
+ else announce();
61
+ })();