@hoardodile/sdk-types 0.0.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 +18 -0
- package/README.md +59 -0
- package/dist/image-variant.d.ts +90 -0
- package/dist/image-variant.js +115 -0
- package/dist/image-variant.js.map +1 -0
- package/dist/index.d.ts +772 -0
- package/dist/index.js +353 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest-Dk6_xyNy.d.ts +204 -0
- package/dist/media-exts.d.ts +92 -0
- package/dist/media-exts.js +160 -0
- package/dist/media-exts.js.map +1 -0
- package/dist/plugin-asset-limits.d.ts +16 -0
- package/dist/plugin-asset-limits.js +13 -0
- package/dist/plugin-asset-limits.js.map +1 -0
- package/dist/plugin-capabilities.d.ts +64 -0
- package/dist/plugin-capabilities.js +37 -0
- package/dist/plugin-capabilities.js.map +1 -0
- package/dist/plugin.d.ts +49 -0
- package/dist/plugin.js +12 -0
- package/dist/plugin.js.map +1 -0
- package/dist/resource.d.ts +26 -0
- package/dist/resource.js +9 -0
- package/dist/resource.js.map +1 -0
- package/dist/result.d.ts +47 -0
- package/dist/result.js +20 -0
- package/dist/result.js.map +1 -0
- package/dist/schema.d.ts +29 -0
- package/dist/schema.js +124 -0
- package/dist/schema.js.map +1 -0
- package/dist/template.d.ts +67 -0
- package/dist/template.js +137 -0
- package/dist/template.js.map +1 -0
- package/dist/text-limits.d.ts +11 -0
- package/dist/text-limits.js +7 -0
- package/dist/text-limits.js.map +1 -0
- package/package.json +102 -0
- package/src/file-list.ts +14 -0
- package/src/image-variant.test.ts +140 -0
- package/src/image-variant.ts +234 -0
- package/src/index.ts +115 -0
- package/src/manifest.ts +186 -0
- package/src/media-exts.ts +245 -0
- package/src/plugin-asset-limits.ts +23 -0
- package/src/plugin-asset.ts +127 -0
- package/src/plugin-capabilities.ts +91 -0
- package/src/plugin-definition.test.ts +117 -0
- package/src/plugin-definition.ts +902 -0
- package/src/plugin.ts +54 -0
- package/src/read-range.ts +12 -0
- package/src/resource.ts +28 -0
- package/src/result.test.ts +64 -0
- package/src/result.ts +73 -0
- package/src/schema.ts +29 -0
- package/src/template.test.ts +116 -0
- package/src/template.ts +199 -0
- package/src/text-limits.ts +11 -0
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plugin manifest UUID (v4). Generated once when scaffolding a plugin
|
|
5
|
+
* (e.g. `crypto.randomUUID()`) and never reused across plugins — the
|
|
6
|
+
* server keys installed plugins by this id.
|
|
7
|
+
*/
|
|
8
|
+
export const pluginManifestId = z.string().uuid()
|
|
9
|
+
export type PluginManifestId = z.infer<typeof pluginManifestId>
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Declared plugin capabilities. Each flag gates the corresponding API
|
|
13
|
+
* surface: a plugin without `danmaku` gets no danmaku methods and the
|
|
14
|
+
* host enforces the permission at the capability guard, so a manifest
|
|
15
|
+
* that does not declare a capability cannot call it.
|
|
16
|
+
*/
|
|
17
|
+
export const pluginPermissions = z.object({
|
|
18
|
+
/** Read/write the resource's source metadata. */
|
|
19
|
+
sourceMeta: z.boolean().default(false),
|
|
20
|
+
/** Produce and store search metadata facets. */
|
|
21
|
+
searchMeta: z.boolean().default(false),
|
|
22
|
+
/** Create/list danmaku for resources this plugin renders. */
|
|
23
|
+
danmaku: z.boolean().default(false),
|
|
24
|
+
/** Create/list messages for resources this plugin renders. */
|
|
25
|
+
message: z.boolean().default(false),
|
|
26
|
+
/** Produce content hashes for duplicate detection / image similarity. */
|
|
27
|
+
imageHashes: z.boolean().default(false),
|
|
28
|
+
/**
|
|
29
|
+
* List and extract archive (zip/tar/7z/…) entries. The only API
|
|
30
|
+
* surface with a write side effect (the host's extraction cache), so
|
|
31
|
+
* it is denied by default.
|
|
32
|
+
*/
|
|
33
|
+
container: z.boolean().default(false),
|
|
34
|
+
/**
|
|
35
|
+
* The plugin asset vault: user-consented downloads into the plugin's
|
|
36
|
+
* own `vault/` directory plus the vault read/delete methods. Denied
|
|
37
|
+
* by default — every download needs this capability AND the user's
|
|
38
|
+
* per-request approval.
|
|
39
|
+
*/
|
|
40
|
+
download: z.boolean().default(false),
|
|
41
|
+
})
|
|
42
|
+
export type PluginPermissions = z.infer<typeof pluginPermissions>
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Label key → locale table: `{ "cover.open": { "en": "Open", "zh-CN": "打开" } }`.
|
|
46
|
+
* The host's template engine resolves `t('cover.open')` against the
|
|
47
|
+
* resource's locale from this map.
|
|
48
|
+
*/
|
|
49
|
+
export const localeString = z.record(z.string(), z.string())
|
|
50
|
+
|
|
51
|
+
/** Icon reference: an asset path inside the plugin zip (`assets/icon.svg`). */
|
|
52
|
+
export const iconRef = z.string().min(1)
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Corner template slot: a string rendered by the host's template engine
|
|
56
|
+
* over the resource scope. The engine supports `{{data.field}}` paths,
|
|
57
|
+
* pipes (`bytes`, `duration`, `number`, `inc`), comparisons
|
|
58
|
+
* (`eq`/`ne`/`gt`/`lt`/`gte`/`lte`), `if(cond, a, b)`, `join`,
|
|
59
|
+
* `t('key')` for i18n, `icon('Icon')`, `asset('path')`,
|
|
60
|
+
* `kind(...)`, and `searchKindIcons()` (the plugin's search kinds).
|
|
61
|
+
* Unknown expressions render as the empty string.
|
|
62
|
+
*/
|
|
63
|
+
const templateValue = z.string()
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Corner template slots for one content kind. Templates are rendered by
|
|
67
|
+
* the host's template engine over the resource's file list; supported
|
|
68
|
+
* directives include `{{data.field}}`, `{{duration(ms)}}`, `{{inc(n)}}`
|
|
69
|
+
* and `{{t('key')}}`.
|
|
70
|
+
*/
|
|
71
|
+
const coverKindUi = z.object({
|
|
72
|
+
tl: z.array(templateValue).optional(),
|
|
73
|
+
tr: z.array(templateValue).optional(),
|
|
74
|
+
bl: z.array(templateValue).optional(),
|
|
75
|
+
br: z.array(templateValue).optional(),
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Cover templates per content kind. A plugin declares the kinds it can
|
|
80
|
+
* produce; the host picks the block matching the resource's cover type
|
|
81
|
+
* (`image`/`video`/`audio`/`default`) and renders each corner as
|
|
82
|
+
* specified, or falls back to the default cover when no block matches.
|
|
83
|
+
*/
|
|
84
|
+
const coverKindUiMap = z.object({
|
|
85
|
+
image: coverKindUi.optional(),
|
|
86
|
+
video: coverKindUi.optional(),
|
|
87
|
+
audio: coverKindUi.optional(),
|
|
88
|
+
default: coverKindUi.optional(),
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A search facet kind: a named dimension with an icon, rendered as a
|
|
93
|
+
* facet group in the host's search UI. `key` becomes the facet key in
|
|
94
|
+
* the search metadata the plugin produces.
|
|
95
|
+
*/
|
|
96
|
+
export const searchKind = z.object({
|
|
97
|
+
key: z.string().min(1),
|
|
98
|
+
/** i18n label key shown as the facet group's title. */
|
|
99
|
+
label: z.string().min(1),
|
|
100
|
+
/** Optional icon asset path in the plugin zip. */
|
|
101
|
+
icon: templateValue.optional(),
|
|
102
|
+
})
|
|
103
|
+
export type SearchKind = z.infer<typeof searchKind>
|
|
104
|
+
|
|
105
|
+
const searchUi = z.object({
|
|
106
|
+
kinds: z.array(searchKind),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const messageUi = z.object({
|
|
110
|
+
/**
|
|
111
|
+
* Template string for message anchor chip labels. Rendered by the
|
|
112
|
+
* host's template engine. Supports `{{data.field}}`, `{{duration(ms)}}`,
|
|
113
|
+
* `{{inc(n)}}`, `{{t('key')}}`, etc.
|
|
114
|
+
*/
|
|
115
|
+
anchor: z.string().min(1).optional(),
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Manifest-declared UI preferences. These shape how the host app
|
|
120
|
+
* presents the plugin's iframe without the plugin shipping any host
|
|
121
|
+
* integration code.
|
|
122
|
+
*/
|
|
123
|
+
export const pluginManifestUi = z.object({
|
|
124
|
+
/**
|
|
125
|
+
* Preferred preview surface height (any CSS length, e.g. "85vh").
|
|
126
|
+
* Applied by both the resource detail page and the preview dialog.
|
|
127
|
+
*/
|
|
128
|
+
height: z.string().min(1).optional(),
|
|
129
|
+
/**
|
|
130
|
+
* Preferred preview surface aspect ratio (e.g. "16/9"), capped by the
|
|
131
|
+
* host at 70vh. Intended for video-centric plugins; takes precedence
|
|
132
|
+
* over `height`. When neither is set the host falls back to 60vh.
|
|
133
|
+
*/
|
|
134
|
+
aspect: z.string().min(1).optional(),
|
|
135
|
+
/**
|
|
136
|
+
* Cover template blocks per content kind. When present, the host
|
|
137
|
+
* renders the resource cover from the plugin's file templates
|
|
138
|
+
* instead of the built-in thumbnail pipeline.
|
|
139
|
+
*/
|
|
140
|
+
card: coverKindUiMap.optional(),
|
|
141
|
+
/** Search facet kinds; enables the plugin's search integration. */
|
|
142
|
+
search: searchUi.optional(),
|
|
143
|
+
/**
|
|
144
|
+
* Anchor chip label template for messages; declares message-anchor
|
|
145
|
+
* support in the host UI.
|
|
146
|
+
*/
|
|
147
|
+
message: messageUi.optional(),
|
|
148
|
+
/**
|
|
149
|
+
* Whether the plugin iframe inherits the host's app font (default true).
|
|
150
|
+
* Set to false for plugins that must render with their own fonts.
|
|
151
|
+
*/
|
|
152
|
+
inheritFont: z.boolean().optional(),
|
|
153
|
+
})
|
|
154
|
+
export type PluginManifestUi = z.infer<typeof pluginManifestUi>
|
|
155
|
+
export type CoverKindUi = z.infer<typeof coverKindUi>
|
|
156
|
+
export type CoverKindUiMap = z.infer<typeof coverKindUiMap>
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The plugin manifest contract — the single schema validated everywhere
|
|
160
|
+
* via its parse: the server at install time, the build CLI, and the
|
|
161
|
+
* workbench. A manifest lives at the zip root of a built plugin next to
|
|
162
|
+
* `main.js` and `index.html`.
|
|
163
|
+
*/
|
|
164
|
+
export const pluginManifest = z.object({
|
|
165
|
+
id: pluginManifestId,
|
|
166
|
+
/** Display name shown in the plugins list and resource badges. */
|
|
167
|
+
name: z.string().min(1),
|
|
168
|
+
/** One-line description shown in the plugins list. */
|
|
169
|
+
description: z.string().min(1),
|
|
170
|
+
/** Icon asset path inside the plugin zip. */
|
|
171
|
+
icon: iconRef.optional(),
|
|
172
|
+
/** Semantic plugin version; shown to users on the plugin card. */
|
|
173
|
+
version: z.string().min(1),
|
|
174
|
+
/** Declared capabilities (see {@link pluginPermissions}). */
|
|
175
|
+
permissions: pluginPermissions,
|
|
176
|
+
/**
|
|
177
|
+
* Localized label tables: `{ labelKey: { locale: label } }`, e.g.
|
|
178
|
+
* `{ "cover.open": { "en": "Open", "zh-CN": "打开" } }` — labels are
|
|
179
|
+
* referenced from templates with `{{t('labelKey')}}` (see
|
|
180
|
+
* {@link localeString}).
|
|
181
|
+
*/
|
|
182
|
+
i18n: z.record(z.string(), localeString).optional(),
|
|
183
|
+
/** UI preferences (see {@link pluginManifestUi}). */
|
|
184
|
+
ui: pluginManifestUi.optional(),
|
|
185
|
+
})
|
|
186
|
+
export type PluginManifest = z.infer<typeof pluginManifest>
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical media-type knowledge: the extension sets, the extension →
|
|
3
|
+
* MIME table and the MIME → media-kind mapping shared by content
|
|
4
|
+
* plugins, the runtime host's sniffer and the server's classification
|
|
5
|
+
* pipeline.
|
|
6
|
+
*
|
|
7
|
+
* Extensions are a **hint**, never the verdict: `ResourceAPI.sniff`
|
|
8
|
+
* reads the file's magic bytes and only falls back to the tables here
|
|
9
|
+
* when the content carries no recognizable signature (text formats).
|
|
10
|
+
* The sets below therefore answer "which extensions do we expect to
|
|
11
|
+
* decode", not "what is this file".
|
|
12
|
+
*
|
|
13
|
+
* Lower-case, with leading dot — match the output of
|
|
14
|
+
* `path.extname(name).toLowerCase()`.
|
|
15
|
+
*
|
|
16
|
+
* Adding a new extension here widens classification everywhere at
|
|
17
|
+
* once. Before adding, verify:
|
|
18
|
+
* - sharp can extract width/height (image)
|
|
19
|
+
* - ffprobe can read width/height/duration (video)
|
|
20
|
+
* - ffprobe can read the stream/format metadata (audio), and the
|
|
21
|
+
* extension has an entry in `AUDIO_FFMPEG_INPUT_FORMAT`
|
|
22
|
+
* - the extension has an entry in {@link EXT_MIME}
|
|
23
|
+
* - the gallery plugin's transcode-required set reflects the format
|
|
24
|
+
* (browser-renderable originals stay native; HEIC/TIFF need the
|
|
25
|
+
* sharp preview pipeline)
|
|
26
|
+
*/
|
|
27
|
+
export const IMAGE_EXTS: ReadonlySet<string> = new Set([
|
|
28
|
+
".jpg",
|
|
29
|
+
".jpeg",
|
|
30
|
+
".png",
|
|
31
|
+
".webp",
|
|
32
|
+
".gif",
|
|
33
|
+
".bmp",
|
|
34
|
+
".avif",
|
|
35
|
+
".heic",
|
|
36
|
+
".heif",
|
|
37
|
+
".tif",
|
|
38
|
+
".tiff",
|
|
39
|
+
".svg",
|
|
40
|
+
".jp2",
|
|
41
|
+
".j2k",
|
|
42
|
+
".jpx",
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
export const VIDEO_EXTS: ReadonlySet<string> = new Set([
|
|
46
|
+
".mp4",
|
|
47
|
+
".webm",
|
|
48
|
+
".mov",
|
|
49
|
+
".mkv",
|
|
50
|
+
".m4v",
|
|
51
|
+
".avi",
|
|
52
|
+
".3gp",
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
export const AUDIO_EXTS: ReadonlySet<string> = new Set([
|
|
56
|
+
".mp3",
|
|
57
|
+
".flac",
|
|
58
|
+
".ogg",
|
|
59
|
+
".m4a",
|
|
60
|
+
".wav",
|
|
61
|
+
".opus",
|
|
62
|
+
".aac",
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Video containers ffmpeg can demux from a forward-only pipe (matroska,
|
|
67
|
+
* avi). ISO-BMFF files (.mp4/.mov/.m4v) keep their moov index at the end
|
|
68
|
+
* of the file, so a stream attempt on a zip-entry source is guaranteed to
|
|
69
|
+
* fail after burning a full probesize read — consumers (thumb pipeline,
|
|
70
|
+
* cover probing) send those straight to the materialized entry instead.
|
|
71
|
+
*/
|
|
72
|
+
export const STREAMABLE_VIDEO_EXTS: ReadonlySet<string> = new Set([
|
|
73
|
+
".webm",
|
|
74
|
+
".mkv",
|
|
75
|
+
".avi",
|
|
76
|
+
])
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* ffmpeg `-f` container name for piped audio bytes (no filename hint).
|
|
80
|
+
* `.opus` files are Ogg containers; `.m4a` is ISO-BMFF, demuxed by the
|
|
81
|
+
* mp4 demuxer.
|
|
82
|
+
*/
|
|
83
|
+
export const AUDIO_FFMPEG_INPUT_FORMAT: Readonly<Record<string, string>> = {
|
|
84
|
+
".mp3": "mp3",
|
|
85
|
+
".flac": "flac",
|
|
86
|
+
".ogg": "ogg",
|
|
87
|
+
".opus": "ogg",
|
|
88
|
+
".wav": "wav",
|
|
89
|
+
".m4a": "mp4",
|
|
90
|
+
".aac": "aac",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* ffmpeg `-f` container name keyed by **sniffed MIME type**, for piped
|
|
95
|
+
* sources that have no filename ffprobe could key off. Content-derived
|
|
96
|
+
* routing is what lets a mislabelled file still demux correctly, so
|
|
97
|
+
* this table — not the extension one — drives `ResourceAPI.probe`.
|
|
98
|
+
*
|
|
99
|
+
* Several spellings map to the same container because magic-byte
|
|
100
|
+
* matchers and IANA disagree on the canonical name (`audio/wav` vs
|
|
101
|
+
* `audio/x-wav`, `video/vnd.avi` vs `video/x-msvideo`).
|
|
102
|
+
*/
|
|
103
|
+
export const MIME_FFMPEG_INPUT_FORMAT: Readonly<Record<string, string>> = {
|
|
104
|
+
"video/mp4": "mp4",
|
|
105
|
+
"video/quicktime": "mov",
|
|
106
|
+
"video/webm": "webm",
|
|
107
|
+
"video/x-matroska": "matroska",
|
|
108
|
+
"video/matroska": "matroska",
|
|
109
|
+
"video/vnd.avi": "avi",
|
|
110
|
+
"video/x-msvideo": "avi",
|
|
111
|
+
"video/avi": "avi",
|
|
112
|
+
"video/ogg": "ogg",
|
|
113
|
+
"audio/mpeg": "mp3",
|
|
114
|
+
"audio/mp3": "mp3",
|
|
115
|
+
"audio/flac": "flac",
|
|
116
|
+
"audio/x-flac": "flac",
|
|
117
|
+
"audio/ogg": "ogg",
|
|
118
|
+
"audio/opus": "ogg",
|
|
119
|
+
"audio/vorbis": "ogg",
|
|
120
|
+
"audio/wav": "wav",
|
|
121
|
+
"audio/x-wav": "wav",
|
|
122
|
+
"audio/vnd.wave": "wav",
|
|
123
|
+
"audio/wave": "wav",
|
|
124
|
+
"audio/mp4": "mp4",
|
|
125
|
+
"audio/x-m4a": "mp4",
|
|
126
|
+
"audio/aac": "aac",
|
|
127
|
+
"video/3gpp": "mp4",
|
|
128
|
+
"application/ogg": "ogg",
|
|
129
|
+
"application/x-matroska": "matroska",
|
|
130
|
+
"application/mp4": "mp4",
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The audio mirror of {@link STREAMABLE_VIDEO_EXTS}: containers whose
|
|
135
|
+
* headers lead the file, so ffmpeg/ffprobe can demux them from a
|
|
136
|
+
* forward-only pipe. `.m4a` is ISO-BMFF with a trailing moov index, so
|
|
137
|
+
* it must be probed from a materialized (seekable) entry.
|
|
138
|
+
*/
|
|
139
|
+
export const STREAMABLE_AUDIO_EXTS: ReadonlySet<string> = new Set([
|
|
140
|
+
".mp3",
|
|
141
|
+
".flac",
|
|
142
|
+
".ogg",
|
|
143
|
+
".opus",
|
|
144
|
+
".wav",
|
|
145
|
+
".aac",
|
|
146
|
+
])
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Media families a file can belong to. `other` covers everything the
|
|
150
|
+
* media pipeline does not decode (text, documents, archives, ...) — it
|
|
151
|
+
* is a real answer, not a failure.
|
|
152
|
+
*/
|
|
153
|
+
export const MEDIA_KINDS = ["image", "video", "audio", "other"] as const
|
|
154
|
+
|
|
155
|
+
export type MediaKind = (typeof MEDIA_KINDS)[number]
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Extension → canonical MIME type. Used as the *fallback* branch of
|
|
159
|
+
* content sniffing: magic-byte detection covers binary media, while
|
|
160
|
+
* text-based formats (`.txt`, `.md`, `.csv`, subtitles, ...) carry no
|
|
161
|
+
* signature and can only be named by their extension.
|
|
162
|
+
*/
|
|
163
|
+
export const EXT_MIME: Readonly<Record<string, string>> = {
|
|
164
|
+
".jpg": "image/jpeg",
|
|
165
|
+
".jpeg": "image/jpeg",
|
|
166
|
+
".png": "image/png",
|
|
167
|
+
".webp": "image/webp",
|
|
168
|
+
".gif": "image/gif",
|
|
169
|
+
".bmp": "image/bmp",
|
|
170
|
+
".avif": "image/avif",
|
|
171
|
+
".heic": "image/heic",
|
|
172
|
+
".heif": "image/heif",
|
|
173
|
+
".tif": "image/tiff",
|
|
174
|
+
".tiff": "image/tiff",
|
|
175
|
+
".jp2": "image/jp2",
|
|
176
|
+
".j2k": "image/jp2",
|
|
177
|
+
".jpx": "image/jp2",
|
|
178
|
+
".mp4": "video/mp4",
|
|
179
|
+
".m4v": "video/mp4",
|
|
180
|
+
".webm": "video/webm",
|
|
181
|
+
".mov": "video/quicktime",
|
|
182
|
+
".mkv": "video/x-matroska",
|
|
183
|
+
".avi": "video/vnd.avi",
|
|
184
|
+
".3gp": "video/3gpp",
|
|
185
|
+
".mp3": "audio/mpeg",
|
|
186
|
+
".flac": "audio/flac",
|
|
187
|
+
".ogg": "audio/ogg",
|
|
188
|
+
".opus": "audio/opus",
|
|
189
|
+
".m4a": "audio/mp4",
|
|
190
|
+
".wav": "audio/wav",
|
|
191
|
+
".aac": "audio/aac",
|
|
192
|
+
".txt": "text/plain",
|
|
193
|
+
".md": "text/markdown",
|
|
194
|
+
".csv": "text/csv",
|
|
195
|
+
".json": "application/json",
|
|
196
|
+
".xml": "text/xml",
|
|
197
|
+
".html": "text/html",
|
|
198
|
+
".htm": "text/html",
|
|
199
|
+
".svg": "image/svg+xml",
|
|
200
|
+
".srt": "application/x-subrip",
|
|
201
|
+
".vtt": "text/vtt",
|
|
202
|
+
".ass": "text/x-ssa",
|
|
203
|
+
".epub": "application/epub+zip",
|
|
204
|
+
".pdf": "application/pdf",
|
|
205
|
+
".zip": "application/zip",
|
|
206
|
+
".cbz": "application/vnd.comicbook+zip",
|
|
207
|
+
".cbr": "application/vnd.comicbook-rar",
|
|
208
|
+
".rar": "application/vnd.rar",
|
|
209
|
+
".7z": "application/x-7z-compressed",
|
|
210
|
+
".cb7": "application/x-7z-compressed",
|
|
211
|
+
".tar": "application/x-tar",
|
|
212
|
+
".cbt": "application/x-tar",
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Container MIME types whose top-level type does not describe the
|
|
217
|
+
* payload. Ogg and Matroska carry audio *or* video, and `application/*`
|
|
218
|
+
* says nothing either way — the values here are the common case, and
|
|
219
|
+
* `ResourceAPI.probe` overrides them with the stream layout ffprobe
|
|
220
|
+
* actually reports.
|
|
221
|
+
*/
|
|
222
|
+
export const MIME_KIND_OVERRIDES: Readonly<Record<string, MediaKind>> = {
|
|
223
|
+
"application/ogg": "audio",
|
|
224
|
+
"application/x-matroska": "video",
|
|
225
|
+
"application/mp4": "video",
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Media family of a MIME type: the override table first, then the
|
|
230
|
+
* top-level type. Never throws — unknown types are `other`.
|
|
231
|
+
*/
|
|
232
|
+
export function mimeToKind(mime: string): MediaKind {
|
|
233
|
+
const normalized = mime.toLowerCase()
|
|
234
|
+
const override = MIME_KIND_OVERRIDES[normalized]
|
|
235
|
+
if (override !== undefined) return override
|
|
236
|
+
if (normalized.startsWith("image/")) return "image"
|
|
237
|
+
if (normalized.startsWith("video/")) return "video"
|
|
238
|
+
if (normalized.startsWith("audio/")) return "audio"
|
|
239
|
+
return "other"
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Canonical MIME type for an extension (leading dot), or `undefined`. */
|
|
243
|
+
export function extToMime(ext: string): string | undefined {
|
|
244
|
+
return EXT_MIME[ext.toLowerCase()]
|
|
245
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin asset runtime limits — the constants shared by the host, the
|
|
3
|
+
* SDK validators and the tooling. Backed by the `./plugin-asset-limits`
|
|
4
|
+
* subpath (plugin-facing constants never export from the root entry).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Max length of a vault-relative `dest` (a plugin path is bounded, not arbitrary). */
|
|
8
|
+
export const PLUGIN_ASSET_DEST_MAX_LENGTH = 256
|
|
9
|
+
|
|
10
|
+
/** Max length of the optional human `reason` shown in the consent dialog. */
|
|
11
|
+
export const PLUGIN_ASSET_REASON_MAX_LENGTH = 200
|
|
12
|
+
|
|
13
|
+
/** Expected shape of an SRI-style sha256 pin: 64 lowercase hex characters. */
|
|
14
|
+
export const PLUGIN_ASSET_SHA256_PATTERN = /^[0-9a-f]{64}$/
|
|
15
|
+
|
|
16
|
+
/** The machine-readable asset error names, in contract order. */
|
|
17
|
+
export const PLUGIN_ASSET_ERROR_NAMES = [
|
|
18
|
+
"DENIED",
|
|
19
|
+
"UNAVAILABLE",
|
|
20
|
+
"POLICY",
|
|
21
|
+
] as const
|
|
22
|
+
|
|
23
|
+
export type PluginAssetErrorName = (typeof PLUGIN_ASSET_ERROR_NAMES)[number]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin asset contract — the download / read / delete surface of a
|
|
3
|
+
* plugin's own "vault". The vault is a host-reserved namespace inside the
|
|
4
|
+
* plugin's installed directory (`<plugin-dir>/vault/`) that the host
|
|
5
|
+
* manages on the plugin's behalf: data lands there only through the
|
|
6
|
+
* user-consented download API, and nothing a plugin ships in its zip can
|
|
7
|
+
* ever be overwritten by downloading (see the vault-confined `dest`
|
|
8
|
+
* rules).
|
|
9
|
+
*
|
|
10
|
+
* Both sides of the plugin speak the same shapes: the server-side
|
|
11
|
+
* `ResourceAPI` (main.js hooks) and the iframe `WebPluginAPI` (render)
|
|
12
|
+
* call the same four methods with the same request/result vocabulary.
|
|
13
|
+
* All methods are gated by the manifest `download` permission and
|
|
14
|
+
* denied inside the sandbox when the manifest does not declare it.
|
|
15
|
+
*
|
|
16
|
+
* Error convention (fixed rule): **classification uses `Result`,
|
|
17
|
+
* API calls throw.** `detect` (and other classifiers) return a
|
|
18
|
+
* {@link Result}; every other API method rejects with an `Error` whose
|
|
19
|
+
* `name` carries the machine-readable code. Plugins branch on
|
|
20
|
+
* {@link isPluginAssetError} — never parse messages.
|
|
21
|
+
*
|
|
22
|
+
* Runtime limits live behind `@hoardodile/sdk-types/plugin-asset-limits`;
|
|
23
|
+
* this module exports the types and the error helpers only.
|
|
24
|
+
*/
|
|
25
|
+
import type { PluginAssetErrorName } from "./plugin-asset-limits.ts"
|
|
26
|
+
|
|
27
|
+
export type { PluginAssetErrorName }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A download request: the plugin declares the plaintext URL, the vault
|
|
31
|
+
* destination, and (optionally) an integrity pin plus a reason for the
|
|
32
|
+
* consent dialog. `dest` is vault-relative only — it must resolve under
|
|
33
|
+
* `<plugin-dir>/vault/` and can never reach the plugin's own bundled
|
|
34
|
+
* files (`main.js`, `index.html`, `assets/`, ...).
|
|
35
|
+
*/
|
|
36
|
+
export type PluginDownloadRequest = {
|
|
37
|
+
/** Absolute `http(s)` URL to fetch. Shown verbatim in the consent dialog. */
|
|
38
|
+
readonly url: string
|
|
39
|
+
/**
|
|
40
|
+
* Vault-relative destination path (`"runtime/live2d.min.js"`). The host
|
|
41
|
+
* resolves it inside the plugin vault and rejects absolute paths,
|
|
42
|
+
* `..` traversal, path separators crossing segments, and reserved
|
|
43
|
+
* names — before any network request is made.
|
|
44
|
+
*/
|
|
45
|
+
readonly dest: string
|
|
46
|
+
/**
|
|
47
|
+
* Optional SRI-style integrity pin (64 lowercase hex chars). When
|
|
48
|
+
* present the host verifies the downloaded bytes against it and
|
|
49
|
+
* discards a mismatch, so a tampered or corrupted response can
|
|
50
|
+
* never be stored.
|
|
51
|
+
*/
|
|
52
|
+
readonly sha256?: string
|
|
53
|
+
/** Optional short rationale shown in the consent dialog (plugin-authored copy). */
|
|
54
|
+
readonly reason?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Result of {@link PluginDownloadRequest}: the stored file's identity.
|
|
59
|
+
* `cached` is true when the destination already existed — the host
|
|
60
|
+
* answered from the vault without any dialog and without touching the
|
|
61
|
+
* network (downloads are "ensure present", never unconditional).
|
|
62
|
+
*/
|
|
63
|
+
export type PluginDownloadResult = {
|
|
64
|
+
/** The vault-relative destination that was resolved. */
|
|
65
|
+
readonly path: string
|
|
66
|
+
readonly sizeBytes: number
|
|
67
|
+
/** sha256 of the stored bytes (host-computed, always present). */
|
|
68
|
+
readonly sha256: string
|
|
69
|
+
/** True when the file already existed and no consent/network was needed. */
|
|
70
|
+
readonly cached: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Result of {@link ResourceAPI.deleteAsset} / `WebPluginAPI.deleteAsset`.
|
|
75
|
+
* Deletion is idempotent: removing nothing is not an error.
|
|
76
|
+
*/
|
|
77
|
+
export type PluginAssetDeleteResult = {
|
|
78
|
+
/** True when a file was actually removed. */
|
|
79
|
+
readonly existed: boolean
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Error thrown by the asset methods. The name survives both wire
|
|
84
|
+
* boundaries (worker IPC and the iframe postMessage bridge), so plugin
|
|
85
|
+
* code can branch on `err.name` without parsing messages:
|
|
86
|
+
*
|
|
87
|
+
* - `DENIED` — the user declined the consent dialog, or consent timed out.
|
|
88
|
+
* - `UNAVAILABLE` — this runtime has no consent channel (CLI, workbench,
|
|
89
|
+
* offline mock) or the server is in read-only archive mode.
|
|
90
|
+
* - `POLICY` — the host rejected the request before downloading:
|
|
91
|
+
* manifest lacks the `download` permission, the URL or `dest` is not
|
|
92
|
+
* allowed, the destination is a directory, or a quota would be
|
|
93
|
+
* exceeded. Also used for reserved-name conflicts.
|
|
94
|
+
*
|
|
95
|
+
* Transport/network failures keep their own error names (e.g. socket
|
|
96
|
+
* errors) and are not part of this vocabulary.
|
|
97
|
+
*/
|
|
98
|
+
export class PluginAssetError extends Error {
|
|
99
|
+
constructor(
|
|
100
|
+
readonly code: PluginAssetErrorName,
|
|
101
|
+
message: string,
|
|
102
|
+
) {
|
|
103
|
+
super(message)
|
|
104
|
+
this.name = code
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Narrow an asset error to a machine-readable name. Works across the
|
|
110
|
+
* RPC boundaries because both preserve `Error.name` (the worker IPC and
|
|
111
|
+
* the iframe bridge carry the name explicitly) — the instance is often
|
|
112
|
+
* lost in transit, so the check keys on the name alone.
|
|
113
|
+
*/
|
|
114
|
+
export function isPluginAssetError(
|
|
115
|
+
err: unknown,
|
|
116
|
+
name: PluginAssetErrorName,
|
|
117
|
+
): err is PluginAssetError {
|
|
118
|
+
return err instanceof Error && err.name === name
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Build a `PluginAssetError` carrying the given machine-readable name. */
|
|
122
|
+
export function pluginAssetError(
|
|
123
|
+
name: PluginAssetErrorName,
|
|
124
|
+
message: string,
|
|
125
|
+
): PluginAssetError {
|
|
126
|
+
return new PluginAssetError(name, message)
|
|
127
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single permission→capability declaration: every manifest
|
|
3
|
+
* permission key, what it gates, and which runtime layers enforce it.
|
|
4
|
+
* Consumers read this table instead of hand-mirrored sets — a new
|
|
5
|
+
* permission is declared once here, and the compile-time coverage
|
|
6
|
+
* checks below make a missing declaration impossible.
|
|
7
|
+
*
|
|
8
|
+
* This module is pure TypeScript (no zod): it is type-driven off
|
|
9
|
+
* {@link PluginPermissions} and consumed by the host sandbox, the
|
|
10
|
+
* server domain and the tooling. Plugin bundles never import it.
|
|
11
|
+
*/
|
|
12
|
+
import type { PluginPermissions } from "./manifest.ts"
|
|
13
|
+
|
|
14
|
+
export type PluginCapabilityGate = {
|
|
15
|
+
/** One-line contract description (mirrors the manifest schema docs). */
|
|
16
|
+
readonly description: string
|
|
17
|
+
/**
|
|
18
|
+
* ResourceAPI method names gated at the sandbox RPC boundary. Absent
|
|
19
|
+
* means the permission is enforced at the host/service layer only
|
|
20
|
+
* (meta hooks, web routes).
|
|
21
|
+
*/
|
|
22
|
+
readonly sandboxMethods?: readonly string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The capability gates, keyed by the manifest permission key. The
|
|
27
|
+
* `satisfies` below fails to compile when a permission is declared on
|
|
28
|
+
* the manifest but missing here; the AssertTrue checks at the bottom
|
|
29
|
+
* cover the reverse direction.
|
|
30
|
+
*/
|
|
31
|
+
export const PLUGIN_CAPABILITY_GATES = {
|
|
32
|
+
sourceMeta: {
|
|
33
|
+
description: "Read/write the resource's source metadata (sourceMeta hook).",
|
|
34
|
+
},
|
|
35
|
+
searchMeta: {
|
|
36
|
+
description: "Produce and store search metadata facets (searchMeta hook).",
|
|
37
|
+
},
|
|
38
|
+
danmaku: {
|
|
39
|
+
description: "Create/list danmaku for resources this plugin renders.",
|
|
40
|
+
},
|
|
41
|
+
message: {
|
|
42
|
+
description: "Create/list messages for resources this plugin renders.",
|
|
43
|
+
},
|
|
44
|
+
imageHashes: {
|
|
45
|
+
description:
|
|
46
|
+
"Produce content hashes for duplicate detection / image similarity.",
|
|
47
|
+
},
|
|
48
|
+
container: {
|
|
49
|
+
description:
|
|
50
|
+
"List and extract archive (zip/tar/7z/…) entries; the only API surface with a write side effect.",
|
|
51
|
+
sandboxMethods: ["listContainer", "extractArchive"],
|
|
52
|
+
},
|
|
53
|
+
download: {
|
|
54
|
+
description:
|
|
55
|
+
"The plugin asset vault: user-consented downloads into the plugin's own vault/ plus the vault read/delete methods; denied by default and per-download by the user.",
|
|
56
|
+
sandboxMethods: ["download", "statAsset", "readAsset", "deleteAsset"],
|
|
57
|
+
},
|
|
58
|
+
} as const satisfies Record<keyof PluginPermissions, PluginCapabilityGate>
|
|
59
|
+
|
|
60
|
+
export type PluginCapabilityKey = keyof typeof PLUGIN_CAPABILITY_GATES
|
|
61
|
+
|
|
62
|
+
// -- compile-time coverage --------------------------------------------------
|
|
63
|
+
// Every manifest permission key is declared here and nothing more — the
|
|
64
|
+
// two assertions fail the build on either drift. Exported only so
|
|
65
|
+
// noUnusedLocals keeps them alive — never import.
|
|
66
|
+
type AssertTrue<T extends true> = T
|
|
67
|
+
export type _ManifestKeysCovered = AssertTrue<
|
|
68
|
+
keyof PluginPermissions extends keyof typeof PLUGIN_CAPABILITY_GATES
|
|
69
|
+
? true
|
|
70
|
+
: false
|
|
71
|
+
>
|
|
72
|
+
export type _TableKeysCovered = AssertTrue<
|
|
73
|
+
keyof typeof PLUGIN_CAPABILITY_GATES extends keyof PluginPermissions
|
|
74
|
+
? true
|
|
75
|
+
: false
|
|
76
|
+
>
|
|
77
|
+
|
|
78
|
+
/** Sandbox API method → capability key, derived once from the table. */
|
|
79
|
+
export const CAPABILITY_BY_METHOD: ReadonlyMap<string, PluginCapabilityKey> =
|
|
80
|
+
new Map(
|
|
81
|
+
(
|
|
82
|
+
Object.entries(PLUGIN_CAPABILITY_GATES) as [
|
|
83
|
+
PluginCapabilityKey,
|
|
84
|
+
PluginCapabilityGate,
|
|
85
|
+
][]
|
|
86
|
+
).flatMap(([capability, gate]) =>
|
|
87
|
+
(gate.sandboxMethods ?? []).map(
|
|
88
|
+
(method) => [method, capability] as [string, PluginCapabilityKey],
|
|
89
|
+
),
|
|
90
|
+
),
|
|
91
|
+
)
|