overlaify 0.0.0-stage → 0.1.2
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 +123 -2
- package/assets/overlay-spec.md +196 -0
- package/assets/sdk/v1/overlaify.js +437 -0
- package/assets/templates/be-right-back.html +123 -0
- package/assets/templates/catalog.json +348 -0
- package/assets/templates/chat-stack.html +68 -0
- package/assets/templates/death-counter.html +94 -0
- package/assets/templates/emote-wall.html +125 -0
- package/assets/templates/goal-bar.html +89 -0
- package/assets/templates/hug-command.html +151 -0
- package/assets/templates/neon-alert.html +84 -0
- package/assets/templates/newsroom-ticker.html +136 -0
- package/assets/templates/starting-soon.html +145 -0
- package/assets/templates/subathon-timer.html +127 -0
- package/assets/templates/webcam-frame.html +93 -0
- package/bin/overlaify.js +114 -0
- package/lib/commands.js +67 -0
- package/lib/dev.html +328 -0
- package/lib/dev.js +181 -0
- package/lib/publish.js +272 -0
- package/lib/starter.html +51 -0
- package/lib/validate.js +362 -0
- package/lib/ws.js +79 -0
- package/package.json +35 -4
- package/skill/SKILL.md +88 -0
- package/skill/examples/chat-stack.html +68 -0
- package/skill/examples/goal-bar.html +89 -0
- package/skill/examples/neon-alert.html +84 -0
- package/skill/references/overlay-spec.md +196 -0
package/lib/validate.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// Checks an overlay's HTML against the Overlaify Overlay Spec (docs/overlay-spec.md).
|
|
2
|
+
// Plain string scanning, no DOM, so it runs the same in Pages Functions, Node scripts and a future CLI.
|
|
3
|
+
|
|
4
|
+
export const SPEC_VERSION = 1;
|
|
5
|
+
export const MAX_BYTES = 80000;
|
|
6
|
+
export const SETTING_TYPES = ['text', 'number', 'toggle', 'color', 'select', 'segmented', 'range'];
|
|
7
|
+
export const ACTION_STYLES = ['primary', 'secondary', 'danger'];
|
|
8
|
+
export const INPUT_TYPES = ['number', 'text', 'select'];
|
|
9
|
+
export const RESERVED_KEYS = ['channel', 'sources', 'volume', 'mute', 'test'];
|
|
10
|
+
export const EVENTS = ['chat', 'follow', 'sub', 'gift', 'giftbomb', 'cheer', 'tip', 'raid'];
|
|
11
|
+
const LIFECYCLE = ['connected', 'disconnected', '*'];
|
|
12
|
+
const ALLOWED_HOSTS = ['fonts.googleapis.com', 'fonts.gstatic.com', 'static-cdn.jtvnw.net'];
|
|
13
|
+
const SDK_HOSTS = ['www.overlaify.com', 'overlaify.com'];
|
|
14
|
+
// Namespaces, not requests.
|
|
15
|
+
const NAMESPACE_HOSTS = ['www.w3.org'];
|
|
16
|
+
const NAME = /^[A-Za-z][A-Za-z0-9_-]{0,39}$/;
|
|
17
|
+
const HEX = /^#[0-9a-f]{6}$/i;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* validateOverlay(html) → { ok, errors, warnings, manifest }
|
|
21
|
+
* Each issue is { code, message }. `ok` means no errors; warnings don't block hosting.
|
|
22
|
+
*/
|
|
23
|
+
export function validateOverlay(html) {
|
|
24
|
+
const errors = [];
|
|
25
|
+
const warnings = [];
|
|
26
|
+
const err = (code, message) => errors.push({ code, message });
|
|
27
|
+
const warn = (code, message) => warnings.push({ code, message });
|
|
28
|
+
html = String(html || '');
|
|
29
|
+
|
|
30
|
+
checkDocument(html, err, warn);
|
|
31
|
+
|
|
32
|
+
const blocks = manifestBlocks(html);
|
|
33
|
+
let manifest = null;
|
|
34
|
+
if (blocks.length > 1) err('manifest_duplicate', `Found ${blocks.length} overlaify-manifest blocks; keep one.`);
|
|
35
|
+
if (blocks.length) {
|
|
36
|
+
const b = blocks[0];
|
|
37
|
+
if (!/\btype\s*=\s*["']?application\/json\b/i.test(b.attrs)) {
|
|
38
|
+
err('manifest_type', 'The overlaify-manifest <script> needs type="application/json".');
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
manifest = JSON.parse(b.body);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
err('manifest_json', `The manifest is not valid JSON (${e.message}). Use double quotes, no comments, no trailing commas.`);
|
|
44
|
+
}
|
|
45
|
+
if (manifest !== null && (typeof manifest !== 'object' || Array.isArray(manifest))) {
|
|
46
|
+
err('manifest_json', 'The manifest must be a JSON object: { "spec": 1, "settings": [...], "actions": [...] }.');
|
|
47
|
+
manifest = null;
|
|
48
|
+
}
|
|
49
|
+
if (manifest) checkManifest(manifest, err, warn);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
checkCode(stripManifest(html), manifest, blocks.length > 0, err, warn);
|
|
53
|
+
|
|
54
|
+
return { ok: errors.length === 0, errors, warnings, manifest };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The issues as a bulleted list, for showing a person or asking a model to fix them. */
|
|
58
|
+
export function formatIssues(issues) {
|
|
59
|
+
return issues.map((i) => `- [${i.code}] ${i.message}`).join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* ------------------------------------------------------------------ */
|
|
63
|
+
/* Document */
|
|
64
|
+
/* ------------------------------------------------------------------ */
|
|
65
|
+
|
|
66
|
+
function checkDocument(html, err, warn) {
|
|
67
|
+
const bytes = new TextEncoder().encode(html).length;
|
|
68
|
+
if (bytes > MAX_BYTES) err('too_large', `The overlay is ${Math.round(bytes / 1000)} KB; the limit is ${MAX_BYTES / 1000} KB.`);
|
|
69
|
+
if (!/<html[\s>]/i.test(html)) warn('not_html', 'Not a complete HTML document; wrap it in <!doctype html><html>…</html>.');
|
|
70
|
+
if (!/<title>\s*[^<\s][^<]*<\/title>/i.test(html)) warn('title_missing', 'Add a short overlay name in <title>.');
|
|
71
|
+
|
|
72
|
+
const tags = html.match(/<[a-z][^>]*>/gi) || [];
|
|
73
|
+
for (const tag of tags) {
|
|
74
|
+
const name = tag.match(/^<([a-z0-9-]+)/i)[1].toLowerCase();
|
|
75
|
+
if (name === 'iframe' || name === 'object' || name === 'embed') {
|
|
76
|
+
err('embedded_frame', `<${name}> is not allowed; overlays can't embed other pages.`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (name === 'script') {
|
|
80
|
+
const src = attr(tag, 'src');
|
|
81
|
+
// The SDK tag is fine (local previews need it); Overlaify swaps it for its own when hosting.
|
|
82
|
+
if (src !== null && !isSdkUrl(src)) {
|
|
83
|
+
err('external_script', `<script src="${short(src)}"> is not allowed; put all JS inline.`);
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (name === 'link') {
|
|
88
|
+
const href = attr(tag, 'href');
|
|
89
|
+
const rel = (attr(tag, 'rel') || '').toLowerCase();
|
|
90
|
+
// preconnect/dns-prefetch only warm up a connection; they load nothing.
|
|
91
|
+
if (href !== null && !isDynamic(href) && !/\b(preconnect|dns-prefetch)\b/.test(rel) && !allowedUrl(href)) {
|
|
92
|
+
err('external_resource', `<link href="${short(href)}"> loads an external file; inline it (only Google Fonts are allowed).`);
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
for (const a of ['src', 'poster', 'data']) {
|
|
97
|
+
const v = attr(tag, a);
|
|
98
|
+
if (v !== null && !isDynamic(v) && !allowedUrl(v)) {
|
|
99
|
+
err('external_resource', `<${name} ${a}="${short(v)}"> loads an external file; use a data: URL, inline SVG, canvas or emoji.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (name === 'image' || name === 'use' || name === 'feimage') {
|
|
103
|
+
const v = attr(tag, 'href') ?? attr(tag, 'xlink:href');
|
|
104
|
+
if (v !== null && !v.startsWith('#') && !isDynamic(v) && !allowedUrl(v)) {
|
|
105
|
+
err('external_resource', `<${name} href="${short(v)}"> loads an external file.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// CSS only: <style> blocks and style="" attributes (in JS, url(...) is usually a function call).
|
|
111
|
+
const css = [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi)].map((m) => m[1])
|
|
112
|
+
.concat(tags.map((t) => attr(t, 'style')).filter(Boolean)).join('\n');
|
|
113
|
+
for (const m of css.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi)) {
|
|
114
|
+
const u = m[2].trim();
|
|
115
|
+
if (u && !u.startsWith('#') && !isDynamic(u) && !allowedUrl(u)) err('external_resource', `CSS url(${short(u)}) loads an external file; use a data: URL or a gradient.`);
|
|
116
|
+
}
|
|
117
|
+
// @import url(...) was covered above.
|
|
118
|
+
for (const m of css.matchAll(/@import\s+['"]([^'"]+)/gi)) {
|
|
119
|
+
if (!allowedUrl(m[1])) err('external_resource', `@import ${short(m[1])} loads an external stylesheet; inline it (only Google Fonts are allowed).`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// data:/blob: and the spec's allowed hosts. Relative URLs have nothing to load from on Overlaify.
|
|
124
|
+
function allowedUrl(u) {
|
|
125
|
+
u = u.trim();
|
|
126
|
+
if (!u || /^(data|blob):/i.test(u)) return true;
|
|
127
|
+
const host = hostOf(u);
|
|
128
|
+
return host !== null && ALLOWED_HOSTS.includes(host);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Markup built in JS (`<img src="${url}">`, 'url(' + x + ')') is filled in at run time; the scan can't judge it.
|
|
132
|
+
function isDynamic(v) {
|
|
133
|
+
return /\$\{|['"`]|\s\+|\+\s/.test(v);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// /sdk/v1/overlaify.js, on Overlaify or relative to wherever the overlay is served (overlaify dev).
|
|
137
|
+
function isSdkUrl(u) {
|
|
138
|
+
const host = hostOf(u);
|
|
139
|
+
return (host === null || SDK_HOSTS.includes(host)) && /(^|\/)sdk\/(v\d+\/)?overlaify(\.min)?\.js(\?|$)/i.test(u);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hostOf(u) {
|
|
143
|
+
const m = u.match(/^(?:https?:)?\/\/([^/?#:\s]+)/i);
|
|
144
|
+
return m ? m[1].toLowerCase() : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function attr(tag, name) {
|
|
148
|
+
const re = new RegExp(`\\s${name.replace(':', '\\:')}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i');
|
|
149
|
+
const m = tag.match(re);
|
|
150
|
+
return m ? (m[1] ?? m[2] ?? m[3]) : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function short(s) {
|
|
154
|
+
return s.length > 60 ? s.slice(0, 57) + '...' : s;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ------------------------------------------------------------------ */
|
|
158
|
+
/* Manifest */
|
|
159
|
+
/* ------------------------------------------------------------------ */
|
|
160
|
+
|
|
161
|
+
const SCRIPT = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
|
|
162
|
+
const isManifest = (attrs) => /\bid\s*=\s*["']?overlaify-manifest\b/i.test(attrs);
|
|
163
|
+
|
|
164
|
+
function manifestBlocks(html) {
|
|
165
|
+
return [...html.matchAll(SCRIPT)].filter((m) => isManifest(m[1])).map((m) => ({ attrs: m[1], body: m[2] }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function stripManifest(html) {
|
|
169
|
+
return html.replace(SCRIPT, (all, attrs) => (isManifest(attrs) ? '' : all));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function checkManifest(m, err, warn) {
|
|
173
|
+
if (!('spec' in m)) warn('spec_missing', `Add "spec": ${SPEC_VERSION} to the manifest.`);
|
|
174
|
+
else if (m.spec !== SPEC_VERSION) err('spec_version', `Unknown spec version ${JSON.stringify(m.spec)}; this validator knows spec ${SPEC_VERSION}.`);
|
|
175
|
+
for (const k of Object.keys(m)) {
|
|
176
|
+
if (!['spec', 'settings', 'actions'].includes(k)) warn('manifest_unknown_key', `Unknown manifest key "${k}"; only spec, settings and actions are read.`);
|
|
177
|
+
}
|
|
178
|
+
if ('settings' in m && !Array.isArray(m.settings)) err('manifest_shape', '"settings" must be an array.');
|
|
179
|
+
if ('actions' in m && !Array.isArray(m.actions)) err('manifest_shape', '"actions" must be an array.');
|
|
180
|
+
|
|
181
|
+
const keys = new Set();
|
|
182
|
+
(Array.isArray(m.settings) ? m.settings : []).forEach((s, i) => checkSetting(s, i, keys, err, warn));
|
|
183
|
+
const names = new Set();
|
|
184
|
+
(Array.isArray(m.actions) ? m.actions : []).forEach((a, i) => checkAction(a, i, names, err, warn));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function checkSetting(s, i, keys, err, warn) {
|
|
188
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) return err('setting_key', `settings[${i}] must be an object.`);
|
|
189
|
+
const at = typeof s.key === 'string' ? `Setting "${s.key}"` : `settings[${i}]`;
|
|
190
|
+
if (typeof s.key !== 'string' || !NAME.test(s.key)) {
|
|
191
|
+
err('setting_key', `${at}: "key" must be letters, digits, _ or -, starting with a letter (max 40).`);
|
|
192
|
+
} else if (RESERVED_KEYS.includes(s.key)) {
|
|
193
|
+
err('setting_reserved', `${at}: "${s.key}" is reserved; the platform always provides ${RESERVED_KEYS.join(', ')}.`);
|
|
194
|
+
} else if (keys.has(s.key)) {
|
|
195
|
+
err('setting_duplicate', `${at} is declared twice.`);
|
|
196
|
+
} else {
|
|
197
|
+
keys.add(s.key);
|
|
198
|
+
}
|
|
199
|
+
if (typeof s.label !== 'string' || !s.label.trim()) warn('setting_label', `${at}: add a short "label".`);
|
|
200
|
+
for (const f of ['desc', 'group']) {
|
|
201
|
+
if (f in s && typeof s[f] !== 'string') warn('setting_field', `${at}: "${f}" should be a string.`);
|
|
202
|
+
}
|
|
203
|
+
if (!SETTING_TYPES.includes(s.type)) {
|
|
204
|
+
return err('setting_type', `${at}: "type" must be one of ${SETTING_TYPES.join(', ')}.`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const has = 'default' in s;
|
|
208
|
+
const d = s.default;
|
|
209
|
+
if (!has) warn('setting_default_missing', `${at}: add a "default" (the value the overlay starts with).`);
|
|
210
|
+
const bad = (want) => err('setting_default', `${at}: "default" must be ${want}, got ${JSON.stringify(d)}.`);
|
|
211
|
+
|
|
212
|
+
switch (s.type) {
|
|
213
|
+
case 'text':
|
|
214
|
+
if (has && typeof d !== 'string') bad('a string');
|
|
215
|
+
break;
|
|
216
|
+
case 'number':
|
|
217
|
+
if (has && !isNum(d)) bad('a number');
|
|
218
|
+
break;
|
|
219
|
+
case 'toggle':
|
|
220
|
+
if (has && typeof d !== 'boolean') bad('true or false');
|
|
221
|
+
break;
|
|
222
|
+
case 'color':
|
|
223
|
+
if (has && !(typeof d === 'string' && HEX.test(d))) bad('a #rrggbb color');
|
|
224
|
+
if ('swatches' in s && !(Array.isArray(s.swatches) && s.swatches.every((c) => typeof c === 'string' && HEX.test(c)))) {
|
|
225
|
+
err('setting_swatches', `${at}: "swatches" must be an array of #rrggbb colors.`);
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
case 'select':
|
|
229
|
+
case 'segmented': {
|
|
230
|
+
const values = optionValues(s.options);
|
|
231
|
+
if (!values) {
|
|
232
|
+
err('setting_options', `${at}: "options" must be a non-empty array of values or { "value", "label" }.`);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
if (s.type === 'segmented' && values.length > 4) warn('setting_options', `${at}: segmented fits 2-4 choices; use "select" for ${values.length}.`);
|
|
236
|
+
if (has && !values.includes(String(d))) bad(`one of the options (${values.join(', ')})`);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
case 'range':
|
|
240
|
+
if (!isNum(s.min) || !isNum(s.max) || s.min >= s.max) {
|
|
241
|
+
err('setting_range', `${at}: a range needs numeric "min" < "max".`);
|
|
242
|
+
} else if (has && (!isNum(d) || d < s.min || d > s.max)) {
|
|
243
|
+
bad(`a number from ${s.min} to ${s.max}`);
|
|
244
|
+
}
|
|
245
|
+
if ('step' in s && !(isNum(s.step) && s.step > 0)) err('setting_range', `${at}: "step" must be a number above 0.`);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function checkAction(a, i, names, err, warn) {
|
|
251
|
+
if (!a || typeof a !== 'object' || Array.isArray(a)) return err('action_name', `actions[${i}] must be an object.`);
|
|
252
|
+
const at = typeof a.name === 'string' ? `Action "${a.name}"` : `actions[${i}]`;
|
|
253
|
+
if (typeof a.name !== 'string' || !NAME.test(a.name)) {
|
|
254
|
+
err('action_name', `${at}: "name" must be letters, digits, _ or -, starting with a letter (max 40).`);
|
|
255
|
+
} else if (names.has(a.name)) {
|
|
256
|
+
err('action_duplicate', `${at} is declared twice.`);
|
|
257
|
+
} else {
|
|
258
|
+
names.add(a.name);
|
|
259
|
+
}
|
|
260
|
+
if (typeof a.label !== 'string' || !a.label.trim()) warn('action_label', `${at}: add a "label" for its key.`);
|
|
261
|
+
if ('group' in a && typeof a.group !== 'string') warn('action_field', `${at}: "group" should be a string.`);
|
|
262
|
+
if ('wide' in a && typeof a.wide !== 'boolean') warn('action_field', `${at}: "wide" should be true or false.`);
|
|
263
|
+
if ('style' in a && !ACTION_STYLES.includes(a.style)) err('action_style', `${at}: "style" must be one of ${ACTION_STYLES.join(', ')}.`);
|
|
264
|
+
if ('confirm' in a && typeof a.confirm !== 'boolean' && typeof a.confirm !== 'string') {
|
|
265
|
+
err('action_confirm', `${at}: "confirm" must be true or a short question.`);
|
|
266
|
+
}
|
|
267
|
+
if (!('input' in a)) return;
|
|
268
|
+
const p = a.input;
|
|
269
|
+
if (!p || typeof p !== 'object' || !INPUT_TYPES.includes(p.type)) {
|
|
270
|
+
return err('action_input', `${at}: "input" must be { "type": ${INPUT_TYPES.join(' | ')}, "label", "default", ... }.`);
|
|
271
|
+
}
|
|
272
|
+
if (p.type === 'number') {
|
|
273
|
+
for (const f of ['default', 'min', 'max', 'step']) {
|
|
274
|
+
if (f in p && !isNum(p[f])) err('action_input', `${at}: input "${f}" must be a number.`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (p.type === 'select') {
|
|
278
|
+
const values = optionValues(p.options);
|
|
279
|
+
if (!values) err('action_input', `${at}: a select input needs non-empty "options".`);
|
|
280
|
+
else if ('default' in p && !values.includes(String(p.default))) err('action_input', `${at}: input "default" must be one of the options.`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function optionValues(options) {
|
|
285
|
+
if (!Array.isArray(options) || !options.length) return null;
|
|
286
|
+
const out = [];
|
|
287
|
+
for (const o of options) {
|
|
288
|
+
if (typeof o === 'string' || isNum(o)) out.push(String(o));
|
|
289
|
+
else if (o && typeof o === 'object' && (typeof o.value === 'string' || isNum(o.value))) out.push(String(o.value));
|
|
290
|
+
else return null;
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const isNum = (n) => typeof n === 'number' && Number.isFinite(n);
|
|
296
|
+
|
|
297
|
+
/* ------------------------------------------------------------------ */
|
|
298
|
+
/* Code */
|
|
299
|
+
/* ------------------------------------------------------------------ */
|
|
300
|
+
|
|
301
|
+
// Literal first arguments of Overlaify.<fn>('...'), e.g. calls('param') → Set of setting keys read.
|
|
302
|
+
function calls(code, fn) {
|
|
303
|
+
const re = new RegExp(`\\bOverlaify\\s*\\.\\s*${fn}\\s*\\(\\s*(['"\`])([^'"\`]+)\\1`, 'g');
|
|
304
|
+
return new Set([...code.matchAll(re)].map((m) => m[2]));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function checkCode(code, manifest, hasManifest, err, warn) {
|
|
308
|
+
const params = calls(code, 'param');
|
|
309
|
+
const handlers = calls(code, 'action');
|
|
310
|
+
const settings = new Set((Array.isArray(manifest?.settings) ? manifest.settings : []).map((s) => s?.key)
|
|
311
|
+
.filter((k) => typeof k === 'string' && NAME.test(k) && !RESERVED_KEYS.includes(k)));
|
|
312
|
+
const actions = new Set((Array.isArray(manifest?.actions) ? manifest.actions : []).map((a) => a?.name).filter(Boolean));
|
|
313
|
+
const ownParams = [...params].filter((k) => !RESERVED_KEYS.includes(k));
|
|
314
|
+
|
|
315
|
+
if (!hasManifest && (ownParams.length || handlers.size)) {
|
|
316
|
+
warn('manifest_missing', 'The overlay reads settings or handles actions but has no overlaify-manifest, so the streamer gets no settings panel or keys.');
|
|
317
|
+
}
|
|
318
|
+
if (manifest) {
|
|
319
|
+
// Overlaify.params.get(...) or a computed key reads settings this scan can't see.
|
|
320
|
+
const dynamic = /\bOverlaify\s*\.\s*params\b/.test(code) || /\bOverlaify\s*\.\s*param\s*\(\s*[^'"`\s)]/.test(code);
|
|
321
|
+
if (!dynamic) {
|
|
322
|
+
for (const k of settings) {
|
|
323
|
+
if (!params.has(k)) warn('setting_unused', `Setting "${k}" is never read; use Overlaify.param('${k}', <default>) or remove it.`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const k of ownParams) {
|
|
327
|
+
if (!settings.has(k)) warn('param_undeclared', `Overlaify.param('${k}') is not declared in the manifest's settings, so the panel can't set it.`);
|
|
328
|
+
}
|
|
329
|
+
const dynamicActions = /\bOverlaify\s*\.\s*action\s*\(\s*[^'"`\s)]/.test(code);
|
|
330
|
+
if (!dynamicActions) {
|
|
331
|
+
for (const n of actions) {
|
|
332
|
+
if (!handlers.has(n)) err('action_unhandled', `Action "${n}" has no handler; add Overlaify.action('${n}', fn) or remove it, or its key does nothing.`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
for (const n of handlers) {
|
|
336
|
+
if (!actions.has(n)) warn('action_undeclared', `Overlaify.action('${n}') is not in the manifest, so the streamer has no key for it.`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const events = calls(code, 'on');
|
|
341
|
+
for (const t of events) {
|
|
342
|
+
if (!EVENTS.includes(t) && !LIFECYCLE.includes(t) && !t.startsWith('action:')) {
|
|
343
|
+
warn('unknown_event', `Overlaify.on('${t}') never fires; events are ${EVENTS.join(', ')} (plus connected, disconnected, '*').`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (events.has('cheer') && events.has('tip')) {
|
|
347
|
+
warn('cheer_and_tip', `Twitch bits fire both 'cheer' and 'tip', so handling both counts them twice; handle 'tip' alone.`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const scripts = [...code.matchAll(SCRIPT)].map((m) => m[2]).join('\n');
|
|
351
|
+
const net = scripts.match(/\b(fetch|XMLHttpRequest|WebSocket|EventSource)\s*\(/);
|
|
352
|
+
if (net) warn('network_access', `${net[1]}() makes network calls; overlays get events from the SDK.`);
|
|
353
|
+
const dialog = scripts.match(/(?:^|[^\w$.]|\bwindow\.)(alert|confirm|prompt)\s*\(/);
|
|
354
|
+
if (dialog) warn('blocking_dialog', `${dialog[1]}() needs a person to click; overlays must just run.`);
|
|
355
|
+
|
|
356
|
+
const urls = new Set();
|
|
357
|
+
for (const m of code.matchAll(/\bhttps?:\/\/[^\s"'`)<>]+/gi)) {
|
|
358
|
+
const host = hostOf(m[0]);
|
|
359
|
+
if (host && !ALLOWED_HOSTS.includes(host) && !NAMESPACE_HOSTS.includes(host) && !SDK_HOSTS.includes(host)) urls.add(host);
|
|
360
|
+
}
|
|
361
|
+
if (urls.size) warn('external_url', `The code mentions ${[...urls].join(', ')}; overlays can only load from Google Fonts and Twitch emotes.`);
|
|
362
|
+
}
|
package/lib/ws.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Just enough WebSocket server for `overlaify dev`: the SDK in OBS connects to /api/live/:room and receives
|
|
2
|
+
// action keys as text frames, the same protocol as the hosted LiveRoom. No dependencies, text frames only.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
6
|
+
|
|
7
|
+
/** Completes the handshake for an HTTP 'upgrade' request. Returns a socket wrapper, or null if it isn't a WebSocket. */
|
|
8
|
+
export function acceptWebSocket(req, socket, { onMessage, onClose }) {
|
|
9
|
+
const key = req.headers['sec-websocket-key'];
|
|
10
|
+
if (!key || (req.headers.upgrade || '').toLowerCase() !== 'websocket') {
|
|
11
|
+
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const accept = createHash('sha1').update(key + GUID).digest('base64');
|
|
15
|
+
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
|
16
|
+
socket.setNoDelay(true);
|
|
17
|
+
|
|
18
|
+
let buf = Buffer.alloc(0);
|
|
19
|
+
let closed = false;
|
|
20
|
+
const ws = {
|
|
21
|
+
send(text) { if (!closed) socket.write(frame(0x1, Buffer.from(text))); },
|
|
22
|
+
close() { if (!closed) { socket.write(frame(0x8, Buffer.alloc(0))); socket.end(); } }
|
|
23
|
+
};
|
|
24
|
+
const done = () => { if (!closed) { closed = true; onClose(ws); } };
|
|
25
|
+
|
|
26
|
+
socket.on('data', (chunk) => {
|
|
27
|
+
buf = Buffer.concat([buf, chunk]);
|
|
28
|
+
for (;;) {
|
|
29
|
+
const f = parse(buf);
|
|
30
|
+
if (!f) break;
|
|
31
|
+
buf = buf.subarray(f.length);
|
|
32
|
+
if (f.opcode === 0x1) onMessage(ws, f.payload.toString('utf8'));
|
|
33
|
+
else if (f.opcode === 0x9) socket.write(frame(0xA, f.payload));
|
|
34
|
+
else if (f.opcode === 0x8) { ws.close(); done(); return; }
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
socket.on('close', done);
|
|
38
|
+
socket.on('error', done);
|
|
39
|
+
return ws;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// One complete frame at the start of buf: { opcode, payload, length }, or null if more bytes are needed.
|
|
43
|
+
// Client frames are always masked. Fragmented messages aren't supported; the SDK never sends them.
|
|
44
|
+
function parse(buf) {
|
|
45
|
+
if (buf.length < 2) return null;
|
|
46
|
+
const opcode = buf[0] & 0x0f;
|
|
47
|
+
const masked = (buf[1] & 0x80) !== 0;
|
|
48
|
+
let len = buf[1] & 0x7f;
|
|
49
|
+
let at = 2;
|
|
50
|
+
if (len === 126) {
|
|
51
|
+
if (buf.length < 4) return null;
|
|
52
|
+
len = buf.readUInt16BE(2);
|
|
53
|
+
at = 4;
|
|
54
|
+
} else if (len === 127) {
|
|
55
|
+
if (buf.length < 10) return null;
|
|
56
|
+
len = Number(buf.readBigUInt64BE(2));
|
|
57
|
+
at = 10;
|
|
58
|
+
}
|
|
59
|
+
const mask = masked ? buf.subarray(at, at + 4) : null;
|
|
60
|
+
if (masked) at += 4;
|
|
61
|
+
if (buf.length < at + len) return null;
|
|
62
|
+
const payload = Buffer.from(buf.subarray(at, at + len));
|
|
63
|
+
if (mask) for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
|
|
64
|
+
return { opcode, payload, length: at + len };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function frame(opcode, payload) {
|
|
68
|
+
const len = payload.length;
|
|
69
|
+
const head = len < 126 ? Buffer.from([0x80 | opcode, len])
|
|
70
|
+
: len < 65536 ? Buffer.from([0x80 | opcode, 126, len >> 8, len & 0xff])
|
|
71
|
+
: Buffer.concat([Buffer.from([0x80 | opcode, 127]), bigLen(len)]);
|
|
72
|
+
return Buffer.concat([head, payload]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function bigLen(n) {
|
|
76
|
+
const b = Buffer.alloc(8);
|
|
77
|
+
b.writeBigUInt64BE(BigInt(n));
|
|
78
|
+
return b;
|
|
79
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "overlaify",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Build Twitch/OBS overlays that follow the Overlaify Overlay Spec: start from a template, validate, preview with simulated events and control them live.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"overlaify": "bin/overlaify.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"assets",
|
|
13
|
+
"skill"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"sync": "node scripts/sync.mjs"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"twitch",
|
|
23
|
+
"obs",
|
|
24
|
+
"overlay",
|
|
25
|
+
"stream",
|
|
26
|
+
"alerts",
|
|
27
|
+
"browser-source",
|
|
28
|
+
"claude",
|
|
29
|
+
"skill"
|
|
30
|
+
],
|
|
31
|
+
"homepage": "https://www.overlaify.com",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/overlaify/overlaify.git"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT"
|
|
37
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: overlaify
|
|
3
|
+
description: Build live-stream overlays for Twitch/OBS (alert boxes, chat boxes, goal bars, timers, counters, tickers, webcam frames, Starting soon / BRB scenes, chat-command effects) as single HTML files that follow the Overlaify Overlay Spec, then validate and preview them with the overlaify CLI. Use whenever someone wants a stream overlay, an OBS browser source, or to change one that already exists.
|
|
4
|
+
argument-hint: "[describe the overlay you want]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Overlaify overlays
|
|
8
|
+
|
|
9
|
+
An Overlaify overlay is one HTML file that runs as an OBS Browser Source (1920×1080, transparent). It gets
|
|
10
|
+
Twitch events from the Overlaify SDK and declares its settings and live control keys in a JSON manifest.
|
|
11
|
+
Following the spec is what makes it work in OBS, in `overlaify dev`, and on overlaify.com without changes.
|
|
12
|
+
|
|
13
|
+
The full rules are in `references/overlay-spec.md`. Read it before writing your first overlay in a session.
|
|
14
|
+
`examples/` has three overlays that pass the spec: an alert queue (`neon-alert.html`), a chat box
|
|
15
|
+
(`chat-stack.html`) and a goal with settings and actions (`goal-bar.html`).
|
|
16
|
+
|
|
17
|
+
## Workflow
|
|
18
|
+
|
|
19
|
+
1. **Start the file.** Use `npx overlaify init <file>.html` for a blank starter (an alert with a manifest),
|
|
20
|
+
or `npx overlaify init <file>.html --template <slug>` to start from a library template
|
|
21
|
+
(`npx overlaify init --list` shows them). Pick the template closest to the request, then edit it.
|
|
22
|
+
2. **Write the overlay** (rules below). Keep everything in the one file.
|
|
23
|
+
3. **Validate** with `npx overlaify validate <file>.html`. Fix every error; fix warnings unless there is a
|
|
24
|
+
reason not to, and say what it is. Repeat until it passes.
|
|
25
|
+
4. **Preview.** Suggest that the user run `npx overlaify dev <file>.html`. It is a long-running server, so
|
|
26
|
+
start it in the background if you run it yourself. It prints:
|
|
27
|
+
- a dev page with the preview, event buttons (follow, sub, gift, bits, raid, chat), the settings panel
|
|
28
|
+
and the action keys;
|
|
29
|
+
- an OBS URL (`http://localhost:4545/overlay`) to add as a Browser source at 1920×1080. It reloads
|
|
30
|
+
when the file is saved, and the action keys reach it live.
|
|
31
|
+
Tell the user to set their Twitch channel in the dev page's settings so real chat and events arrive.
|
|
32
|
+
5. **Publish (only when the user asks).** `npx overlaify publish <file>.html --private` or `--public` puts it
|
|
33
|
+
on overlaify.com at `/<their username>/<name>` with a hosted OBS URL (named after the file, e.g. `my-alert.html` → `my-alert`; `--name` sets another). Ask the user which one they want; never pick for
|
|
34
|
+
them. Private: only they can open it on the site, but the OBS URL still works. Public: anyone with a link
|
|
35
|
+
can open and remix it, and it can be picked for the Overlaify Library. If it says they're not logged in,
|
|
36
|
+
ask them to run `npx overlaify login` (it opens a browser to approve). Publishing the same file again
|
|
37
|
+
adds a new version.
|
|
38
|
+
6. **Start from an overlay on overlaify.com.** When the user names one (`<user>/<name>` or its page URL),
|
|
39
|
+
`npx overlaify download <user>/<name> [file]` saves it as a local file to change, then carry on from step 3.
|
|
40
|
+
A private one only downloads for its owner; if it says to log in, ask them to run `npx overlaify login`.
|
|
41
|
+
|
|
42
|
+
If `npx` can't be used, the file still works: it loads the SDK from overlaify.com, so OBS can open it as a
|
|
43
|
+
local file with `?channel=<twitch name>` added.
|
|
44
|
+
|
|
45
|
+
## Rules that matter most
|
|
46
|
+
|
|
47
|
+
- One self-contained HTML document, at most 80 KB. Inline all CSS and JS. Keep
|
|
48
|
+
`<script src="https://www.overlaify.com/sdk/v1/overlaify.js"></script>` in `<head>`; it is the only
|
|
49
|
+
external script. External files are limited to Google Fonts and Twitch emote images; draw everything else
|
|
50
|
+
with CSS, inline SVG, canvas or emoji.
|
|
51
|
+
- `html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: transparent; }`.
|
|
52
|
+
Paint a full-screen background only for full scenes (Starting soon, BRB).
|
|
53
|
+
- Events come only from the SDK: `Overlaify.on('sub', fn)` and friends. There are no network calls, no
|
|
54
|
+
clicks and no dialogs; the overlay just runs.
|
|
55
|
+
- Events look the same on every platform. For bits and other money, handle `tip` (show `e.display`, size
|
|
56
|
+
with `e.usd`), not `cheer`, which is Twitch-only; never both, or bits count twice.
|
|
57
|
+
- For alerts, use `Overlaify.queue(handler)` so alerts never overlap.
|
|
58
|
+
- Never insert viewer text as HTML. Use `textContent`, `Overlaify.escape()`, or
|
|
59
|
+
`Overlaify.renderMessage(text, emotes)` for chat with emotes.
|
|
60
|
+
- Manifest in `<head>`: `<script type="application/json" id="overlaify-manifest">{ "spec": 1, ... }</script>`.
|
|
61
|
+
- Every customizable value is a **setting**, read with `Overlaify.param('key', <same default>)`.
|
|
62
|
+
- Every **action** has a handler, `Overlaify.action('name', fn)`. Add actions only for live state
|
|
63
|
+
(timers, counters, goals, queues, scenes): pause/resume, add/remove, reset with `"style": "danger"` and
|
|
64
|
+
`"confirm": true`.
|
|
65
|
+
- Never declare `channel`, `volume`, `mute` or `test`; the platform provides them.
|
|
66
|
+
- Wrap `localStorage` in `try/catch` (the page runs sandboxed), and use it when an action's state should
|
|
67
|
+
survive an OBS refresh.
|
|
68
|
+
|
|
69
|
+
## Making it look good
|
|
70
|
+
|
|
71
|
+
Aim for a premium, hand-made overlay pack, not a generic template:
|
|
72
|
+
- one clear visual idea;
|
|
73
|
+
- a palette and type pairing that match the streamer's description;
|
|
74
|
+
- motion with personality: an entrance, a subtle idle and an exit.
|
|
75
|
+
|
|
76
|
+
Animate `transform` and `opacity`. Text must stay readable over busy gameplay and on phone screens: large
|
|
77
|
+
sizes, strong contrast, an outline or shadow. Place things where streamers expect them unless asked
|
|
78
|
+
otherwise:
|
|
79
|
+
- alerts: top-center
|
|
80
|
+
- chat: left edge
|
|
81
|
+
- goal bars: top edge
|
|
82
|
+
- tickers: bottom edge
|
|
83
|
+
- webcam frames: a corner
|
|
84
|
+
|
|
85
|
+
## Changing an existing overlay
|
|
86
|
+
|
|
87
|
+
Edit the file in place and keep everything that wasn't asked to change. When behavior changes, update the
|
|
88
|
+
manifest too (new settings, renamed actions), then validate again.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<script src="https://www.overlaify.com/sdk/v1/overlaify.js"></script>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<title>Glass Chat Stack</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;800&display=swap" rel="stylesheet">
|
|
9
|
+
<script type="application/json" id="overlaify-manifest">
|
|
10
|
+
{
|
|
11
|
+
"spec": 1,
|
|
12
|
+
"settings": [
|
|
13
|
+
{"key": "max", "label": "Messages on screen", "type": "range", "default": 7, "min": 1, "max": 20, "step": 1, "desc": "Maximum number of messages on screen."},
|
|
14
|
+
{"key": "fade", "label": "Fade after", "type": "range", "default": 30, "min": 0, "max": 300, "step": 5, "unit": "s", "desc": "Seconds before a message fades out. 0 keeps messages until they are pushed off."},
|
|
15
|
+
{"key": "hidebots", "label": "Hide users", "type": "text", "default": "nightbot,streamelements,streamlabs", "desc": "Comma-separated usernames (lowercase, no spaces) whose messages are hidden."}
|
|
16
|
+
],
|
|
17
|
+
"actions": [
|
|
18
|
+
{"name": "clear", "label": "Clear chat", "style": "danger", "wide": true}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
<style>
|
|
23
|
+
html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: transparent; }
|
|
24
|
+
#chat { position: absolute; left: 40px; bottom: 60px; width: 520px; display: flex; flex-direction: column; gap: 12px; }
|
|
25
|
+
.msg {
|
|
26
|
+
font-family: 'Outfit', system-ui, sans-serif; font-size: 26px; line-height: 1.35; color: #f4f1ff;
|
|
27
|
+
background: rgba(16, 13, 26, .72); backdrop-filter: blur(8px);
|
|
28
|
+
border-left: 5px solid var(--uc, #9b5cff); border-radius: 6px 16px 16px 6px;
|
|
29
|
+
padding: 12px 18px 14px; box-shadow: 0 8px 24px rgba(0,0,0,.35);
|
|
30
|
+
text-shadow: 0 1px 2px rgba(0,0,0,.6);
|
|
31
|
+
transform-origin: left bottom; animation: slide .35s cubic-bezier(.2,1.2,.4,1);
|
|
32
|
+
transition: opacity .6s, transform .6s;
|
|
33
|
+
}
|
|
34
|
+
.msg.gone { opacity: 0; transform: translateX(-40px); }
|
|
35
|
+
.who { font-weight: 800; color: var(--uc, #9b5cff); margin-right: 8px; }
|
|
36
|
+
.badge { display: inline-block; font-size: 15px; font-weight: 600; padding: 1px 8px; border-radius: 99px; margin-right: 6px; vertical-align: 3px; color: #0b0a10; }
|
|
37
|
+
.b-mod { background: #4dffa6; } .b-sub { background: #ffb547; } .b-vip { background: #ff4f8b; } .b-broadcaster { background: #ff6b4a; }
|
|
38
|
+
.bits { font-weight: 800; color: #c6ff3d; margin-left: 6px; }
|
|
39
|
+
@keyframes slide { from { opacity: 0; transform: translateY(20px) scale(.96); } }
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
<div id="chat"></div>
|
|
44
|
+
<script>
|
|
45
|
+
const MAX = Overlaify.param('max', 7);
|
|
46
|
+
const FADE = Overlaify.param('fade', 30); // seconds, 0 = never
|
|
47
|
+
const HIDE_BOTS = Overlaify.param('hidebots', 'nightbot,streamelements,streamlabs').split(',');
|
|
48
|
+
const box = document.getElementById('chat');
|
|
49
|
+
|
|
50
|
+
function remove(el) { el.classList.add('gone'); setTimeout(() => el.remove(), 600); }
|
|
51
|
+
|
|
52
|
+
Overlaify.on('chat', e => {
|
|
53
|
+
if (HIDE_BOTS.includes(e.user) || e.message.startsWith('!')) return;
|
|
54
|
+
const el = document.createElement('div');
|
|
55
|
+
el.className = 'msg';
|
|
56
|
+
el.style.setProperty('--uc', e.color || '#9b5cff');
|
|
57
|
+
const badges = [e.isBroadcaster && 'broadcaster', e.isMod && 'mod', e.isVip && 'vip', e.isSub && 'sub'].filter(Boolean)
|
|
58
|
+
.map(b => `<span class="badge b-${b}">${b === 'broadcaster' ? 'LIVE' : b.toUpperCase()}</span>`).join('');
|
|
59
|
+
el.innerHTML = `${badges}<span class="who">${Overlaify.escape(e.name)}</span>${Overlaify.renderMessage(e.message, e.emotes)}${e.bits ? `<span class="bits">◆ ${e.bits}</span>` : ''}`;
|
|
60
|
+
box.appendChild(el);
|
|
61
|
+
while (box.children.length > MAX) box.firstElementChild.remove();
|
|
62
|
+
if (FADE > 0) setTimeout(() => el.isConnected && remove(el), FADE * 1000);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
Overlaify.action('clear', () => { box.replaceChildren(); });
|
|
66
|
+
</script>
|
|
67
|
+
</body>
|
|
68
|
+
</html>
|