libaribhtml5 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 +21 -0
- package/README.md +124 -0
- package/THIRD_PARTY_NOTICES.md +24 -0
- package/dist/sdk/arib-vfs-sw.js +336 -0
- package/dist/sdk/libaribhtml5.js +11 -0
- package/dist/sdk/libaribhtml5.mjs +1353 -0
- package/dist/types/broadcast-document.d.ts +12 -0
- package/dist/types/broadcast-url.d.ts +9 -0
- package/dist/types/device-identifier.d.ts +4 -0
- package/dist/types/index.d.ts +23 -0
- package/dist/types/media-plane.d.ts +79 -0
- package/dist/types/program-guide.d.ts +25 -0
- package/dist/types/program-info.d.ts +29 -0
- package/dist/types/receiver-host.d.ts +144 -0
- package/dist/types/runtime/clock.d.ts +5 -0
- package/dist/types/runtime/fonts.d.ts +4 -0
- package/dist/types/runtime/install.d.ts +31 -0
- package/dist/types/runtime/resources.d.ts +22 -0
- package/dist/types/runtime/romsound-markup.d.ts +5 -0
- package/dist/types/runtime/romsound.d.ts +4 -0
- package/dist/types/runtime/system-information.d.ts +14 -0
- package/dist/types/service-worker-vfs.d.ts +66 -0
- package/docs/android-program-guide.md +127 -0
- package/docs/broadcast-clock.md +57 -0
- package/docs/broadcast-resources-and-fonts.md +143 -0
- package/docs/media-plane-integration.md +227 -0
- package/docs/receiver-host-contracts.md +92 -0
- package/package.json +69 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 huggy
|
|
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
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# libaribhtml5
|
|
2
|
+
|
|
3
|
+
[日本語](#日本語) | [English](#english)
|
|
4
|
+
|
|
5
|
+
## 日本語
|
|
6
|
+
|
|
7
|
+
ブラウザ上で動作する ARIB HTML5 受信機ランタイムのプロトタイプです。初期デモでは、
|
|
8
|
+
展開済みの BS4K アプリケーションを放送時のパスで配信し、アプリケーションの
|
|
9
|
+
スクリプトが実行される前に受信機 API のポリフィルを注入します。
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pnpm install
|
|
13
|
+
pnpm dev
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Vite が出力した URL を開いてください。放送による自動起動アプリケーションには
|
|
17
|
+
**透明起動ページ**を、表示ページを直接確認するには**表示ページ**を使用します。
|
|
18
|
+
|
|
19
|
+
### 注意事項
|
|
20
|
+
|
|
21
|
+
- 放送アプリケーションが利用する一部のオンラインデータは、ブラウザから放送局の
|
|
22
|
+
サーバーへ直接リクエストされます。現在のデモでは、安全のため、このような外部への
|
|
23
|
+
ネットワークリクエストをすべてブロックしています。
|
|
24
|
+
- ACAS のシリアル番号は `0721 0721 0721 0724 9674` に固定してエミュレートしています。
|
|
25
|
+
|
|
26
|
+
### SDK バンドル
|
|
27
|
+
|
|
28
|
+
npm から利用する場合は、パッケージをインストールして ESM エントリを import します。
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npm install libaribhtml5
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { AribReceiverHost, installRuntime } from 'libaribhtml5'
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
従来の `<script>` 向け IIFE は `libaribhtml5/iife`、Service Worker ファイルは
|
|
39
|
+
`libaribhtml5/arib-vfs-sw.js` として公開されます。
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
pnpm build:sdk
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
このコマンドは `dist/sdk/libaribhtml5.js` と、ブラウザ VFS 用の
|
|
46
|
+
`dist/sdk/arib-vfs-sw.js` を生成します。SDK は `AribReceiverHost`、
|
|
47
|
+
`ServiceWorkerBroadcastVfs`、`installRuntime` を持つ `window.ARIBHTML5` を公開します。受信機内蔵音は
|
|
48
|
+
`src/runtime/romsound/` 以下で個別の MP3 ファイルとして管理され、SDK のビルド時に
|
|
49
|
+
すべてデータ URL として単一の JavaScript バンドルへ埋め込まれます。
|
|
50
|
+
|
|
51
|
+
プレーヤーへ統合する際のメディアスロット、adapter、レイヤー、ライフサイクル、字幕の契約は
|
|
52
|
+
[メディアプレーン共通統合ガイド](docs/media-plane-integration.md)を参照してください。
|
|
53
|
+
データ放送へ入る時は `host.loadApplication()`、通常プレーヤーへ戻す時は
|
|
54
|
+
`host.exitApplication()` を使用します。
|
|
55
|
+
|
|
56
|
+
録画再生ではシステム時計ではなく、ストリームの NTP と再生位置を
|
|
57
|
+
`host.setBroadcastClock()` へ渡します。変換例は
|
|
58
|
+
[録画再生時の放送時計](docs/broadcast-clock.md)を参照してください。
|
|
59
|
+
|
|
60
|
+
EIT 由来の番組情報と受信機固有情報を host から渡す契約は
|
|
61
|
+
[receiver host contracts](docs/receiver-host-contracts.md)を参照してください。
|
|
62
|
+
|
|
63
|
+
Service Worker の `/data-broadcast/` ルーティング、データ放送リソースの
|
|
64
|
+
store/update/release ライフサイクル、および ARIB 追加記号フォントは
|
|
65
|
+
[放送リソースとフォント](docs/broadcast-resources-and-fonts.md)を参照してください。
|
|
66
|
+
|
|
67
|
+
Android/WebView の「放送予定」検索結果から受信機所有の番組表・番組詳細を開く場合は、
|
|
68
|
+
[`onOpenProgramGuide` とブラウザ fallback](docs/android-program-guide.md)を参照してください。
|
|
69
|
+
|
|
70
|
+
## English
|
|
71
|
+
|
|
72
|
+
Browser-hosted ARIB HTML5 receiver-runtime prototype. The initial demo serves the
|
|
73
|
+
extracted BS4K application under its broadcast path and injects receiver API
|
|
74
|
+
polyfills before the application scripts execute.
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
pnpm install
|
|
78
|
+
pnpm dev
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Open the URL printed by Vite. Use **透明起動ページ** for the broadcast autostart
|
|
82
|
+
application and **表示ページ** to inspect the visible page directly.
|
|
83
|
+
|
|
84
|
+
### Notes
|
|
85
|
+
|
|
86
|
+
- Some online data used by broadcast applications is requested directly from the
|
|
87
|
+
browser to the broadcaster's servers. For safety, the current demo blocks all
|
|
88
|
+
such external network requests.
|
|
89
|
+
- The ACAS serial number is emulated with the fixed value
|
|
90
|
+
`0721 0721 0721 0724 9674`.
|
|
91
|
+
|
|
92
|
+
### SDK bundle
|
|
93
|
+
|
|
94
|
+
Install the package to use its typed ESM entry point:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
npm install libaribhtml5
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { AribReceiverHost, installRuntime } from 'libaribhtml5'
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The browser-global IIFE remains available as `libaribhtml5/iife`, and the
|
|
105
|
+
Service Worker is exported as `libaribhtml5/arib-vfs-sw.js`.
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
pnpm build:sdk
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
This produces `dist/sdk/libaribhtml5.js` plus `dist/sdk/arib-vfs-sw.js` for the
|
|
112
|
+
optional browser VFS, and exposes `window.ARIBHTML5` with `AribReceiverHost`,
|
|
113
|
+
`ServiceWorkerBroadcastVfs`, and `installRuntime`. Receiver built-in sounds are maintained
|
|
114
|
+
as individual MP3 files under `src/runtime/romsound/`; the SDK build inlines all
|
|
115
|
+
of them as data URLs in the single JavaScript bundle.
|
|
116
|
+
|
|
117
|
+
See the [common media-plane integration guide](docs/media-plane-integration.md)
|
|
118
|
+
for the shared video layering, geometry, lifecycle, and caption contract.
|
|
119
|
+
See [broadcast resources, Worker routing, and fonts](docs/broadcast-resources-and-fonts.md)
|
|
120
|
+
for the `/data-broadcast/` namespace and cache/font lifecycle.
|
|
121
|
+
See [Android program-guide integration](docs/android-program-guide.md) for handing a
|
|
122
|
+
future-program result to a receiver-owned WebView/native EPG with a browser fallback.
|
|
123
|
+
See [receiver host contracts](docs/receiver-host-contracts.md) for program metadata
|
|
124
|
+
and receiver-owned system information.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
`src/runtime/romsound/*.mp3` contains receiver built-in sound data restored from
|
|
4
|
+
[web-bml](https://github.com/otya128/web-bml), used under the MIT License.
|
|
5
|
+
|
|
6
|
+
Copyright (c) 2022 otya
|
|
7
|
+
|
|
8
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
9
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
10
|
+
in the Software without restriction, including without limitation the rights
|
|
11
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
12
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
13
|
+
furnished to do so, subject to the following conditions:
|
|
14
|
+
|
|
15
|
+
The above copyright notice and this permission notice shall be included in all
|
|
16
|
+
copies or substantial portions of the Software.
|
|
17
|
+
|
|
18
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
19
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
20
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
21
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
22
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
23
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
24
|
+
SOFTWARE.
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
"use strict";
|
|
3
|
+
function deferRomSoundMarkup(source) {
|
|
4
|
+
return source.replace(
|
|
5
|
+
/\s+src\s*=\s*(?:(["'])(romsound:\/\/\d+)\1|(romsound:\/\/\d+)(?=[\s/>]))/gi,
|
|
6
|
+
(_match, _quote, quoted, bare) => ` data-arib-romsound="${quoted ?? bare}"`
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
function normalizeBasePath(value) {
|
|
10
|
+
const base = value || "/data-broadcast/";
|
|
11
|
+
return `/${base.replace(/^\/+|\/+$/g, "")}/`;
|
|
12
|
+
}
|
|
13
|
+
function rewriteBroadcastObjectMarkup(source) {
|
|
14
|
+
return source.replace(/<object\b[^>]*>/gi, (tag) => {
|
|
15
|
+
const broadcast = /\btype\s*=\s*(?:["']video\/x-arib2-broadcast["']|video\/x-arib2-broadcast)(?:\s|\/?>)/i.test(tag);
|
|
16
|
+
if (!broadcast) return tag;
|
|
17
|
+
return tag.replace(
|
|
18
|
+
/\s+type\s*=\s*(?:["']video\/x-arib2-broadcast["']|video\/x-arib2-broadcast)/i,
|
|
19
|
+
' data-arib-type="video/x-arib2-broadcast"'
|
|
20
|
+
).replace(/\s+data\s*=\s*(["'])(.*?)\1/i, " data-arib-data=$1$2$1");
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function receiverPath(path, base, scope) {
|
|
24
|
+
if (path.startsWith(scope)) return path;
|
|
25
|
+
const mount = base.startsWith(scope) ? base.slice(scope.length).replace(/^\/+|\/+$/g, "") : "";
|
|
26
|
+
const requested = path.replace(/^\/+/, "");
|
|
27
|
+
if (mount && (requested === mount || requested.startsWith(`${mount}/`))) {
|
|
28
|
+
return `${scope}${requested}`;
|
|
29
|
+
}
|
|
30
|
+
return `${base}${requested}`;
|
|
31
|
+
}
|
|
32
|
+
function prefixBroadcastRootAttributes(source, basePath, scopePath) {
|
|
33
|
+
const base = normalizeBasePath(basePath);
|
|
34
|
+
const scope = normalizeBasePath(scopePath ?? basePath);
|
|
35
|
+
return source.replace(
|
|
36
|
+
/(\b(?:href|src|action|poster)\s*=\s*)(?:(['"])(\/[^/][^"']*)\2|(\/[^\s>]*))/gi,
|
|
37
|
+
(match, name, quote, quotedPath, barePath) => {
|
|
38
|
+
const path = quotedPath ?? barePath;
|
|
39
|
+
if (!path || path.startsWith(base)) return match;
|
|
40
|
+
const value = receiverPath(path, base, scope);
|
|
41
|
+
return quote ? `${name}${quote}${value}${quote}` : `${name}${value}`;
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
function prepareBroadcastStylesheet(source, options = {}) {
|
|
46
|
+
const base = normalizeBasePath(options.basePath);
|
|
47
|
+
const scope = normalizeBasePath(options.scopePath ?? options.basePath);
|
|
48
|
+
return source.replace(
|
|
49
|
+
/url\(\s*(["']?)(\/[^/)][^)]*)\1\s*\)/gi,
|
|
50
|
+
(match, quote, path) => path.startsWith(base) ? match : `url(${quote}${receiverPath(path, base, scope)}${quote})`
|
|
51
|
+
).replace(
|
|
52
|
+
/(@import\s+)(["'])(\/[^/][^"']*)\2/gi,
|
|
53
|
+
(match, keyword, quote, path) => path.startsWith(base) ? match : `${keyword}${quote}${receiverPath(path, base, scope)}${quote}`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
function prepareBroadcastHtml(source, options = {}) {
|
|
57
|
+
const prepared = prefixBroadcastRootAttributes(
|
|
58
|
+
deferRomSoundMarkup(rewriteBroadcastObjectMarkup(source)),
|
|
59
|
+
options.basePath,
|
|
60
|
+
options.scopePath
|
|
61
|
+
);
|
|
62
|
+
if (!options.bootstrap) return prepared;
|
|
63
|
+
const head = /<head(?:\s[^>]*)?>/i.exec(prepared);
|
|
64
|
+
if (!head) return `${options.bootstrap}${prepared}`;
|
|
65
|
+
const offset = head.index + head[0].length;
|
|
66
|
+
return `${prepared.slice(0, offset)}${options.bootstrap}${prepared.slice(offset)}`;
|
|
67
|
+
}
|
|
68
|
+
const scopeUrl = new URL(self.registration.scope);
|
|
69
|
+
const VFS_PREFIX = scopeUrl.pathname.endsWith("/") ? scopeUrl.pathname : `${scopeUrl.pathname}/`;
|
|
70
|
+
const CACHE_NAME = `libaribhtml5-arib-vfs-v1:${VFS_PREFIX}`;
|
|
71
|
+
const CACHE_RESOURCE_PATH = `${VFS_PREFIX}.libaribhtml5-arib-vfs-resource`;
|
|
72
|
+
const CACHE_SESSION_PATH = `${VFS_PREFIX}.libaribhtml5-arib-vfs-session`;
|
|
73
|
+
const RUNTIME_BOOTSTRAP = "<script>parent.__ARIB_HTML5_INSTALL__?.(window)<\/script>";
|
|
74
|
+
const resources = /* @__PURE__ */ new Map();
|
|
75
|
+
const waiters = /* @__PURE__ */ new Map();
|
|
76
|
+
let enabled = false;
|
|
77
|
+
let uniqueBasenameFallback = false;
|
|
78
|
+
let restorePromise = null;
|
|
79
|
+
function cacheRequest(path) {
|
|
80
|
+
const url = new URL(CACHE_RESOURCE_PATH, self.location.origin);
|
|
81
|
+
url.searchParams.set("path", path);
|
|
82
|
+
return new Request(url.href);
|
|
83
|
+
}
|
|
84
|
+
function sessionRequest() {
|
|
85
|
+
return new Request(new URL(CACHE_SESSION_PATH, self.location.origin).href);
|
|
86
|
+
}
|
|
87
|
+
async function beginPersistentSession() {
|
|
88
|
+
await caches.delete(CACHE_NAME);
|
|
89
|
+
const cache = await caches.open(CACHE_NAME);
|
|
90
|
+
await cache.put(sessionRequest(), new Response("", {
|
|
91
|
+
headers: {
|
|
92
|
+
"X-Arib-VFS-Session": "active",
|
|
93
|
+
"X-Arib-VFS-Unique-Basename-Fallback": uniqueBasenameFallback ? "1" : "0"
|
|
94
|
+
}
|
|
95
|
+
}));
|
|
96
|
+
restorePromise = null;
|
|
97
|
+
}
|
|
98
|
+
async function persistResource(path, resource) {
|
|
99
|
+
const cache = await caches.open(CACHE_NAME);
|
|
100
|
+
await cache.put(cacheRequest(path), new Response(resource.data.slice(), {
|
|
101
|
+
headers: {
|
|
102
|
+
"Content-Type": resource.contentType || "application/octet-stream",
|
|
103
|
+
"X-Arib-VFS-Path": path
|
|
104
|
+
}
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
async function restorePersistentResources() {
|
|
108
|
+
if (enabled) return true;
|
|
109
|
+
if (restorePromise) return restorePromise;
|
|
110
|
+
restorePromise = (async () => {
|
|
111
|
+
const cache = await caches.open(CACHE_NAME);
|
|
112
|
+
const session = await cache.match(sessionRequest());
|
|
113
|
+
if (!session) return false;
|
|
114
|
+
uniqueBasenameFallback = session.headers.get("X-Arib-VFS-Unique-Basename-Fallback") === "1";
|
|
115
|
+
for (const request of await cache.keys()) {
|
|
116
|
+
const response = await cache.match(request);
|
|
117
|
+
const path = response?.headers.get("X-Arib-VFS-Path");
|
|
118
|
+
if (!path) continue;
|
|
119
|
+
resources.set(path, {
|
|
120
|
+
data: new Uint8Array(await response.arrayBuffer()),
|
|
121
|
+
contentType: response.headers.get("Content-Type") || ""
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
enabled = true;
|
|
125
|
+
return true;
|
|
126
|
+
})();
|
|
127
|
+
try {
|
|
128
|
+
return await restorePromise;
|
|
129
|
+
} finally {
|
|
130
|
+
restorePromise = null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function normalizePath(value) {
|
|
134
|
+
let pathname;
|
|
135
|
+
try {
|
|
136
|
+
pathname = decodeURIComponent(new URL(String(value), self.location.origin).pathname);
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (pathname.startsWith(VFS_PREFIX)) pathname = pathname.slice(VFS_PREFIX.length);
|
|
141
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
142
|
+
if (!parts.length || parts.some((part) => part === "." || part === "..")) return null;
|
|
143
|
+
return parts.join("/");
|
|
144
|
+
}
|
|
145
|
+
function uniqueBasenameMatch(path) {
|
|
146
|
+
if (!uniqueBasenameFallback) return null;
|
|
147
|
+
const slash = path.lastIndexOf("/");
|
|
148
|
+
const basename = slash >= 0 ? path.slice(slash + 1) : path;
|
|
149
|
+
if (!basename) return null;
|
|
150
|
+
let match = null;
|
|
151
|
+
for (const candidate of resources.keys()) {
|
|
152
|
+
if (candidate !== basename && !candidate.endsWith(`/${basename}`)) continue;
|
|
153
|
+
if (match !== null) return null;
|
|
154
|
+
match = candidate;
|
|
155
|
+
}
|
|
156
|
+
return match;
|
|
157
|
+
}
|
|
158
|
+
function hasBroadcastRoot(path) {
|
|
159
|
+
const slash = path.indexOf("/");
|
|
160
|
+
const root = slash < 0 ? path : path.slice(0, slash);
|
|
161
|
+
for (const candidate of resources.keys()) {
|
|
162
|
+
if (candidate === root || candidate.startsWith(`${root}/`)) return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
function hasResourceCandidate(value) {
|
|
167
|
+
const path = normalizePath(value);
|
|
168
|
+
return Boolean(path && (resources.has(path) || hasBroadcastRoot(path) || uniqueBasenameMatch(path) !== null));
|
|
169
|
+
}
|
|
170
|
+
function contentType(path, supplied) {
|
|
171
|
+
if (supplied) return supplied;
|
|
172
|
+
const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
|
|
173
|
+
return (/* @__PURE__ */ new Map([
|
|
174
|
+
[".html", "text/html; charset=utf-8"],
|
|
175
|
+
[".htm", "text/html; charset=utf-8"],
|
|
176
|
+
[".css", "text/css; charset=utf-8"],
|
|
177
|
+
[".js", "text/javascript; charset=utf-8"],
|
|
178
|
+
[".json", "application/json; charset=utf-8"],
|
|
179
|
+
[".svg", "image/svg+xml"],
|
|
180
|
+
[".png", "image/png"],
|
|
181
|
+
[".jpg", "image/jpeg"],
|
|
182
|
+
[".jpeg", "image/jpeg"],
|
|
183
|
+
[".gif", "image/gif"],
|
|
184
|
+
[".webp", "image/webp"],
|
|
185
|
+
[".woff", "font/woff"],
|
|
186
|
+
[".woff2", "font/woff2"]
|
|
187
|
+
])).get(extension) || "application/octet-stream";
|
|
188
|
+
}
|
|
189
|
+
function broadcastRootPath(path) {
|
|
190
|
+
const separator = path.indexOf("/");
|
|
191
|
+
return separator <= 0 ? VFS_PREFIX : `${VFS_PREFIX}${path.slice(0, separator)}/`;
|
|
192
|
+
}
|
|
193
|
+
function wake(path, resource) {
|
|
194
|
+
const exact = waiters.get(path);
|
|
195
|
+
if (exact) {
|
|
196
|
+
waiters.delete(path);
|
|
197
|
+
for (const resolve of exact) resolve({ path, resource });
|
|
198
|
+
}
|
|
199
|
+
if (!uniqueBasenameFallback) return;
|
|
200
|
+
for (const [requestedPath, pending] of [...waiters]) {
|
|
201
|
+
const fallback = uniqueBasenameMatch(requestedPath);
|
|
202
|
+
if (!fallback) continue;
|
|
203
|
+
const fallbackResource = resources.get(fallback);
|
|
204
|
+
if (!fallbackResource) continue;
|
|
205
|
+
waiters.delete(requestedPath);
|
|
206
|
+
for (const resolve of pending) resolve({ path: fallback, resource: fallbackResource });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function waitFor(path, timeout = 3e4) {
|
|
210
|
+
const current = resources.get(path);
|
|
211
|
+
if (current) return Promise.resolve({ path, resource: current });
|
|
212
|
+
const fallback = uniqueBasenameMatch(path);
|
|
213
|
+
if (fallback) return Promise.resolve({ path: fallback, resource: resources.get(fallback) });
|
|
214
|
+
return new Promise((resolve) => {
|
|
215
|
+
const pending = waiters.get(path) || /* @__PURE__ */ new Set();
|
|
216
|
+
pending.add(resolve);
|
|
217
|
+
waiters.set(path, pending);
|
|
218
|
+
setTimeout(() => {
|
|
219
|
+
pending.delete(resolve);
|
|
220
|
+
if (!pending.size) waiters.delete(path);
|
|
221
|
+
resolve(null);
|
|
222
|
+
}, timeout);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
async function hasAvailableResource(value) {
|
|
226
|
+
await restorePersistentResources();
|
|
227
|
+
const path = normalizePath(`/${value || ""}`);
|
|
228
|
+
return Boolean(enabled && path && resources.has(path));
|
|
229
|
+
}
|
|
230
|
+
async function serve(request) {
|
|
231
|
+
const path = normalizePath(request.url);
|
|
232
|
+
if (!path) return new Response("Bad broadcast path", { status: 400 });
|
|
233
|
+
const resolved = await waitFor(path);
|
|
234
|
+
if (!resolved) return new Response("Broadcast resource is not available", { status: 404 });
|
|
235
|
+
if (resolved.path !== path) {
|
|
236
|
+
return Response.redirect(new URL(`${VFS_PREFIX}${resolved.path}`, self.location.origin), 302);
|
|
237
|
+
}
|
|
238
|
+
const resource = resolved.resource;
|
|
239
|
+
const type = contentType(path, resource.contentType);
|
|
240
|
+
let body = resource.data.slice(0);
|
|
241
|
+
if (/^text\/html(?:;|$)/i.test(type)) {
|
|
242
|
+
const source = prepareBroadcastHtml(new TextDecoder().decode(resource.data), {
|
|
243
|
+
basePath: broadcastRootPath(resolved.path),
|
|
244
|
+
scopePath: VFS_PREFIX,
|
|
245
|
+
bootstrap: RUNTIME_BOOTSTRAP
|
|
246
|
+
});
|
|
247
|
+
body = new TextEncoder().encode(source);
|
|
248
|
+
} else if (/^text\/css(?:;|$)/i.test(type)) {
|
|
249
|
+
const source = prepareBroadcastStylesheet(new TextDecoder().decode(resource.data), {
|
|
250
|
+
basePath: broadcastRootPath(resolved.path),
|
|
251
|
+
scopePath: VFS_PREFIX
|
|
252
|
+
});
|
|
253
|
+
body = new TextEncoder().encode(source);
|
|
254
|
+
}
|
|
255
|
+
return new Response(request.method === "HEAD" ? null : body, {
|
|
256
|
+
status: 200,
|
|
257
|
+
headers: {
|
|
258
|
+
"Cache-Control": "no-store",
|
|
259
|
+
"Content-Type": type,
|
|
260
|
+
"Content-Security-Policy": "default-src 'self' data: blob:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; object-src 'none'; frame-src 'none'",
|
|
261
|
+
"X-Content-Type-Options": "nosniff"
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
self.addEventListener("install", (event) => {
|
|
266
|
+
event.waitUntil(self.skipWaiting());
|
|
267
|
+
});
|
|
268
|
+
self.addEventListener("activate", (event) => {
|
|
269
|
+
event.waitUntil(self.clients.claim());
|
|
270
|
+
});
|
|
271
|
+
self.addEventListener("message", (event) => {
|
|
272
|
+
const message = event.data || {};
|
|
273
|
+
const reply = (value) => event.ports[0]?.postMessage(value);
|
|
274
|
+
const handle = async () => {
|
|
275
|
+
if (message.type === "arib-vfs-probe") {
|
|
276
|
+
reply({ ok: true, available: await hasAvailableResource(message.path) });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (message.type === "arib-vfs-begin") {
|
|
280
|
+
enabled = true;
|
|
281
|
+
uniqueBasenameFallback = Boolean(message.uniqueBasenameFallback);
|
|
282
|
+
resources.clear();
|
|
283
|
+
for (const pending of waiters.values()) {
|
|
284
|
+
for (const resolve of pending) resolve(null);
|
|
285
|
+
}
|
|
286
|
+
waiters.clear();
|
|
287
|
+
await beginPersistentSession();
|
|
288
|
+
reply({ ok: true });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (message.type === "arib-vfs-put") {
|
|
292
|
+
const path = normalizePath(`/${message.path || ""}`);
|
|
293
|
+
if (!path || !(message.data instanceof ArrayBuffer)) {
|
|
294
|
+
reply({ ok: false, error: "invalid resource" });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const resource = {
|
|
298
|
+
data: new Uint8Array(message.data),
|
|
299
|
+
contentType: String(message.contentType || "")
|
|
300
|
+
};
|
|
301
|
+
enabled = true;
|
|
302
|
+
resources.set(path, resource);
|
|
303
|
+
await persistResource(path, resource);
|
|
304
|
+
wake(path, resource);
|
|
305
|
+
reply({ ok: true });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (message.type === "arib-vfs-reset") {
|
|
309
|
+
enabled = false;
|
|
310
|
+
uniqueBasenameFallback = false;
|
|
311
|
+
resources.clear();
|
|
312
|
+
restorePromise = null;
|
|
313
|
+
for (const pending of waiters.values()) {
|
|
314
|
+
for (const resolve of pending) resolve(null);
|
|
315
|
+
}
|
|
316
|
+
waiters.clear();
|
|
317
|
+
await caches.delete(CACHE_NAME);
|
|
318
|
+
reply({ ok: true });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
reply({ ok: false, error: "unsupported VFS message" });
|
|
322
|
+
};
|
|
323
|
+
event.waitUntil(handle().catch((error) => {
|
|
324
|
+
reply({ ok: false, error: error?.message || String(error) });
|
|
325
|
+
}));
|
|
326
|
+
});
|
|
327
|
+
self.addEventListener("fetch", (event) => {
|
|
328
|
+
const url = new URL(event.request.url);
|
|
329
|
+
if (url.origin !== self.location.origin || !url.pathname.startsWith(VFS_PREFIX) || event.request.method !== "GET" && event.request.method !== "HEAD") return;
|
|
330
|
+
event.respondWith((async () => {
|
|
331
|
+
await restorePersistentResources();
|
|
332
|
+
if (!enabled || !hasResourceCandidate(url.pathname)) return fetch(event.request);
|
|
333
|
+
return serve(event.request);
|
|
334
|
+
})());
|
|
335
|
+
});
|
|
336
|
+
})();
|