@kieransimkin/dancemoves 3.0.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 +338 -0
- package/README.md +893 -0
- package/RUDIMENTS-API.md +394 -0
- package/docs/README.md +73 -0
- package/docs/api/adapters.md +400 -0
- package/docs/api/downloads.md +158 -0
- package/docs/api/effects.md +463 -0
- package/docs/api/javascript.md +500 -0
- package/docs/api/paper-planes.md +167 -0
- package/docs/api/styling.md +292 -0
- package/docs/api/surface.json +446 -0
- package/docs/api/wordpress.md +365 -0
- package/docs/clay-transform-validation.md +84 -0
- package/docs/development.md +254 -0
- package/docs/guide.md +288 -0
- package/docs/javascript-api.md +292 -0
- package/docs/javascript-server.md +150 -0
- package/docs/react-next.md +185 -0
- package/docs/releasing-shared.md +162 -0
- package/docs/rudiments-source-integrity.md +80 -0
- package/docs/shared-feature-coverage.md +34 -0
- package/docs/shared-runtime-migration.md +111 -0
- package/docs/source-audit.md +120 -0
- package/docs/validation-setup.md +102 -0
- package/docs/wordpress-examples.md +206 -0
- package/docs/wordpress-shared-runtime.md +133 -0
- package/lib/admin.min.js +1 -0
- package/lib/assets/DanceRudiments-LICENSE +21 -0
- package/lib/assets/DanceRudiments-build-manifest.json +21 -0
- package/lib/assets/paper-dreams-plane-atlas.png +0 -0
- package/lib/build-manifest.json +117 -0
- package/lib/dancemoves.css +9 -0
- package/lib/dancemoves.min.js +5 -0
- package/lib/index.cjs +4802 -0
- package/lib/index.d.ts +71 -0
- package/lib/index.mjs +4780 -0
- package/lib/react.cjs +154 -0
- package/lib/react.d.ts +12 -0
- package/lib/react.mjs +135 -0
- package/lib/rudiments.d.ts +124 -0
- package/lib/server.cjs +411 -0
- package/lib/server.d.ts +11 -0
- package/lib/server.mjs +367 -0
- package/lib/styles/clay-stars-effects.css +1 -0
- package/lib/styles/clay-stars-rudiments.css +1 -0
- package/lib/styles/dance-moves-core.css +1 -0
- package/lib/styles/ks-epk-device-orientation.css +1 -0
- package/lib/styles/paper-dreams-flight.css +1 -0
- package/lib/wordpress.js +5 -0
- package/package.json +101 -0
package/lib/server.cjs
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
20
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
21
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
22
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
23
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
24
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
25
|
+
mod
|
|
26
|
+
));
|
|
27
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
28
|
+
|
|
29
|
+
// .build/modules/server.mjs
|
|
30
|
+
var server_exports = {};
|
|
31
|
+
__export(server_exports, {
|
|
32
|
+
PAGE_META_KEYS: () => PAGE_META_KEYS,
|
|
33
|
+
ValidationError: () => ValidationError,
|
|
34
|
+
createCaptureHandler: () => createCaptureHandler,
|
|
35
|
+
createDownloadService: () => createDownloadService,
|
|
36
|
+
createMemoryPageStore: () => createMemoryPageStore,
|
|
37
|
+
createMemoryRateLimiter: () => createMemoryRateLimiter,
|
|
38
|
+
fromWordPressMeta: () => fromWordPressMeta,
|
|
39
|
+
normalizeDownloadPath: () => normalizeDownloadPath,
|
|
40
|
+
validateMotionCapture: () => validateMotionCapture,
|
|
41
|
+
validatePageConfig: () => validatePageConfig,
|
|
42
|
+
validateTimingFile: () => validateTimingFile
|
|
43
|
+
});
|
|
44
|
+
module.exports = __toCommonJS(server_exports);
|
|
45
|
+
var import_node_crypto = require("node:crypto");
|
|
46
|
+
var import_promises = require("node:fs/promises");
|
|
47
|
+
var import_node_fs = require("node:fs");
|
|
48
|
+
var import_node_path = __toESM(require("node:path"), 1);
|
|
49
|
+
var import_node_stream = require("node:stream");
|
|
50
|
+
|
|
51
|
+
// .build/modules/config.mjs
|
|
52
|
+
var PAGE_META_KEYS = Object.freeze({
|
|
53
|
+
bpm: "_dance_moves_bpm",
|
|
54
|
+
lyric: "_dance_moves_lyric_timing_id",
|
|
55
|
+
cue: "_dance_moves_cue_timing_id",
|
|
56
|
+
popups: "_dance_moves_lyric_popups_enabled",
|
|
57
|
+
duration: "_dance_moves_master_duration_ms",
|
|
58
|
+
effect: "_dance_moves_effect"
|
|
59
|
+
});
|
|
60
|
+
var object = (value) => value && typeof value === "object" && !Array.isArray(value);
|
|
61
|
+
function finite(value, name, min, max) {
|
|
62
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max)
|
|
63
|
+
throw new RangeError(`${name} must be a finite number in [${min}, ${max}]`);
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
function url(value, name) {
|
|
67
|
+
if (value === void 0 || value === "") return "";
|
|
68
|
+
if (typeof value !== "string" || value.length > 4096 || /[\u0000-\u001f]/.test(value)) throw new TypeError(`Invalid ${name}`);
|
|
69
|
+
const parsed = new URL(value, "https://dancemoves.invalid/");
|
|
70
|
+
if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password)
|
|
71
|
+
throw new TypeError(`${name} must be an HTTP(S) or relative URL`);
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
function validatePageConfig(input = {}) {
|
|
75
|
+
if (!object(input)) throw new TypeError("Page configuration must be an object");
|
|
76
|
+
const explicit = input.bpm !== void 0 && input.bpm !== null && input.bpm !== "";
|
|
77
|
+
const bpm = explicit ? Math.round(finite(input.bpm, "bpm", 20, 400) * 1e3) / 1e3 : 120;
|
|
78
|
+
const effect = input.effect ?? "";
|
|
79
|
+
if (!["", "paper-planes"].includes(effect)) throw new RangeError("effect must be empty or paper-planes");
|
|
80
|
+
if (input.lyricPopupsEnabled !== void 0 && typeof input.lyricPopupsEnabled !== "boolean") throw new TypeError("lyricPopupsEnabled must be boolean");
|
|
81
|
+
const duration = finite(input.masterDurationMilliseconds ?? 0, "masterDurationMilliseconds", 0, 864e5);
|
|
82
|
+
const ticks = finite(input.sharedControlTicks ?? 0, "sharedControlTicks", 0, 65536);
|
|
83
|
+
const disclosure = finite(input.lyricDisclosureTicks ?? 6, "lyricDisclosureTicks", 1, 65536);
|
|
84
|
+
const pageId = input.pageId ?? 0;
|
|
85
|
+
if (!Number.isSafeInteger(pageId) || pageId < 0) throw new RangeError("pageId must be a non-negative safe integer");
|
|
86
|
+
return Object.freeze({
|
|
87
|
+
pageId,
|
|
88
|
+
bpm,
|
|
89
|
+
bpmSource: explicit && input.bpmSource !== "fallback" ? "explicit" : "fallback",
|
|
90
|
+
fallbackBpm: 120,
|
|
91
|
+
ticksPerBeat: 16,
|
|
92
|
+
tickDefinition: "sixteenth-of-beat",
|
|
93
|
+
longDurationQuantumTicks: 16,
|
|
94
|
+
cueTimingUrl: url(input.cueTimingUrl, "cueTimingUrl"),
|
|
95
|
+
lyricTimingUrl: url(input.lyricTimingUrl, "lyricTimingUrl"),
|
|
96
|
+
lyricPopupsEnabled: input.lyricPopupsEnabled === true,
|
|
97
|
+
masterDurationMilliseconds: Math.round(duration * 1e3) / 1e3,
|
|
98
|
+
sharedControlTicks: ticks,
|
|
99
|
+
lyricDisclosureTicks: disclosure,
|
|
100
|
+
effect,
|
|
101
|
+
diagnostics: input.diagnostics === true
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async function fromWordPressMeta(meta, resolveAttachment) {
|
|
105
|
+
if (!object(meta)) throw new TypeError("meta must be an object");
|
|
106
|
+
const numeric2 = (value) => value !== "" && value !== null && value !== void 0 && Number.isFinite(Number(value));
|
|
107
|
+
const b = meta[PAGE_META_KEYS.bpm];
|
|
108
|
+
const bpm = numeric2(b) && Number(b) >= 20 && Number(b) <= 400 ? Number(b) : void 0;
|
|
109
|
+
const resolve = async (key) => {
|
|
110
|
+
const value = Number(meta[key] || 0);
|
|
111
|
+
if (!Number.isSafeInteger(value)) throw new RangeError("Attachment ID must be a safe integer");
|
|
112
|
+
const id = Math.abs(value);
|
|
113
|
+
if (!id) return "";
|
|
114
|
+
if (typeof resolveAttachment !== "function") throw new TypeError("An attachment URL resolver is required for nonzero IDs");
|
|
115
|
+
return await resolveAttachment(id) || "";
|
|
116
|
+
};
|
|
117
|
+
const duration = Number(meta[PAGE_META_KEYS.duration]);
|
|
118
|
+
return validatePageConfig({
|
|
119
|
+
bpm,
|
|
120
|
+
lyricTimingUrl: await resolve(PAGE_META_KEYS.lyric),
|
|
121
|
+
cueTimingUrl: await resolve(PAGE_META_KEYS.cue),
|
|
122
|
+
lyricPopupsEnabled: meta[PAGE_META_KEYS.popups] === true || String(meta[PAGE_META_KEYS.popups]) === "1",
|
|
123
|
+
masterDurationMilliseconds: Number.isFinite(duration) && duration > 0 && duration <= 864e5 ? duration : 0,
|
|
124
|
+
effect: meta[PAGE_META_KEYS.effect] === "paper-planes" ? "paper-planes" : ""
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function createMemoryPageStore({ maxPages = 100, maxRevisions = 20 } = {}) {
|
|
128
|
+
for (const [name, value] of Object.entries({ maxPages, maxRevisions })) if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(name);
|
|
129
|
+
const pages = /* @__PURE__ */ new Map();
|
|
130
|
+
const key = (id) => {
|
|
131
|
+
if (typeof id !== "string" || !id.trim() || id.length > 160) throw new TypeError("Invalid page id");
|
|
132
|
+
return id;
|
|
133
|
+
};
|
|
134
|
+
const clone = (value) => value ? structuredClone(value) : null;
|
|
135
|
+
return Object.freeze({
|
|
136
|
+
read(id) {
|
|
137
|
+
return clone(pages.get(key(id))?.at(-1) || null);
|
|
138
|
+
},
|
|
139
|
+
revisions(id) {
|
|
140
|
+
return clone(pages.get(key(id)) || []);
|
|
141
|
+
},
|
|
142
|
+
save(id, input, { expectedRevision } = {}) {
|
|
143
|
+
key(id);
|
|
144
|
+
const config = validatePageConfig(input);
|
|
145
|
+
const history = pages.get(id) || [];
|
|
146
|
+
const current = history.at(-1)?.revision || 0;
|
|
147
|
+
if (expectedRevision !== void 0 && expectedRevision !== current) throw new Error("Revision conflict");
|
|
148
|
+
if (!pages.has(id) && pages.size >= maxPages) throw new Error("Page store capacity reached");
|
|
149
|
+
const record = { revision: current + 1, config, savedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
150
|
+
pages.set(id, [...history, record].slice(-maxRevisions));
|
|
151
|
+
return clone(record);
|
|
152
|
+
},
|
|
153
|
+
restore(id, revision, options) {
|
|
154
|
+
const record = (pages.get(key(id)) || []).find((item) => item.revision === revision);
|
|
155
|
+
if (!record) throw new RangeError("Revision not found");
|
|
156
|
+
return this.save(id, record.config, options);
|
|
157
|
+
},
|
|
158
|
+
delete(id) {
|
|
159
|
+
return pages.delete(key(id));
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// .build/modules/server.mjs
|
|
165
|
+
var ValidationError = class extends Error {
|
|
166
|
+
constructor(code, message, status = 400) {
|
|
167
|
+
super(message);
|
|
168
|
+
this.name = "ValidationError";
|
|
169
|
+
this.code = code;
|
|
170
|
+
this.status = status;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
var fail = (code, message, status) => {
|
|
174
|
+
throw new ValidationError(code, message, status);
|
|
175
|
+
};
|
|
176
|
+
function validateTimingFile(input, { kind = "cue", filename, maxBytes = 1048576 } = {}) {
|
|
177
|
+
if (!["cue", "lyric"].includes(kind)) throw new TypeError("kind must be cue or lyric");
|
|
178
|
+
if (filename) {
|
|
179
|
+
const ext = import_node_path.default.extname(filename).toLowerCase();
|
|
180
|
+
if (!(kind === "lyric" ? [".lrc"] : [".lrc", ".cue"]).includes(ext)) fail("timing_extension", "Incorrect timing-file extension");
|
|
181
|
+
}
|
|
182
|
+
if (typeof input !== "string" && !(input instanceof Uint8Array)) throw new TypeError("Timing input must be text or bytes");
|
|
183
|
+
const bytes = typeof input === "string" ? Buffer.from(input, "utf8") : input;
|
|
184
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || bytes.byteLength > maxBytes) fail("timing_size", "Timing file exceeds the byte limit", 413);
|
|
185
|
+
let text;
|
|
186
|
+
try {
|
|
187
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
188
|
+
} catch {
|
|
189
|
+
fail("timing_utf8", "Timing file is not valid UTF-8");
|
|
190
|
+
}
|
|
191
|
+
if (/[\u0000\uFFFD]/.test(text)) fail("timing_utf8", "Timing file contains a null or replacement character");
|
|
192
|
+
const stamps = [...text.matchAll(/\[(\d{1,3}):(\d{2})(?:[\.:](\d{1,3}))?\]/g)];
|
|
193
|
+
if (!stamps.length) fail("timing_timestamps", "Timing file must contain at least one bracketed timestamp");
|
|
194
|
+
let previous = -1;
|
|
195
|
+
for (const stamp of stamps) {
|
|
196
|
+
const time = Number(stamp[1]) * 60 + Number(stamp[2]) + (stamp[3] ? Number("0." + stamp[3]) : 0);
|
|
197
|
+
if (time < previous) fail("timing_order", "Timing timestamps must be monotonic");
|
|
198
|
+
previous = time;
|
|
199
|
+
}
|
|
200
|
+
return Object.freeze({ text, kind, bytes: bytes.byteLength, timestamps: stamps.length });
|
|
201
|
+
}
|
|
202
|
+
function normalizeDownloadPath(value) {
|
|
203
|
+
if (typeof value !== "string" || value.length > 4096) return "";
|
|
204
|
+
let result;
|
|
205
|
+
try {
|
|
206
|
+
result = decodeURIComponent(value.trim().replaceAll("\\", "/"));
|
|
207
|
+
} catch {
|
|
208
|
+
return "";
|
|
209
|
+
}
|
|
210
|
+
if (!result || result.startsWith("/") || /[:\u0000-\u001f\u007f\\]/.test(result) || !/\.mp3$/i.test(result)) return "";
|
|
211
|
+
if (result.split("/").some((part) => !part || part === "." || part === "..")) return "";
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
function asOrigin(value) {
|
|
215
|
+
const u = new URL(value);
|
|
216
|
+
if (!["https:", "http:"].includes(u.protocol) || u.username || u.password) throw new TypeError("Invalid HTTP origin");
|
|
217
|
+
return u.origin;
|
|
218
|
+
}
|
|
219
|
+
function privateHeaders() {
|
|
220
|
+
return { "Cache-Control": "private, no-store, max-age=0", "X-Content-Type-Options": "nosniff", "X-Robots-Tag": "noindex, nofollow" };
|
|
221
|
+
}
|
|
222
|
+
function json(body, status = 200, extra = {}) {
|
|
223
|
+
return new Response(JSON.stringify(body), { status, headers: { ...privateHeaders(), "Content-Type": "application/json; charset=utf-8", ...extra } });
|
|
224
|
+
}
|
|
225
|
+
function createDownloadService({ rootDirectory, secret, origin, route = "/api/download" } = {}) {
|
|
226
|
+
if (typeof rootDirectory !== "string" || !import_node_path.default.isAbsolute(rootDirectory)) throw new TypeError("rootDirectory must be an absolute local directory");
|
|
227
|
+
if (!(typeof secret === "string" || secret instanceof Uint8Array) || Buffer.byteLength(secret) < 32) throw new TypeError("Use a server-only secret of at least 32 bytes");
|
|
228
|
+
const baseOrigin = asOrigin(origin);
|
|
229
|
+
if (typeof route !== "string" || !route.startsWith("/") || route.startsWith("//")) throw new TypeError("route must be a same-origin absolute path");
|
|
230
|
+
const routeURL = new URL(route, baseOrigin);
|
|
231
|
+
if (routeURL.origin !== baseOrigin || routeURL.search || routeURL.hash) throw new TypeError("route must not include an origin, query or fragment");
|
|
232
|
+
const digest = (relative) => (0, import_node_crypto.createHmac)("sha256", secret).update(relative).digest("hex");
|
|
233
|
+
function sign(relative) {
|
|
234
|
+
const normal = normalizeDownloadPath(relative);
|
|
235
|
+
if (!normal) throw new ValidationError("download_path", "A safe relative MP3 path is required");
|
|
236
|
+
const url2 = new URL(routeURL);
|
|
237
|
+
url2.searchParams.set("path", normal);
|
|
238
|
+
url2.searchParams.set("signature", digest(normal));
|
|
239
|
+
return url2.href;
|
|
240
|
+
}
|
|
241
|
+
async function handle(request) {
|
|
242
|
+
let input;
|
|
243
|
+
try {
|
|
244
|
+
input = new URL(request.url);
|
|
245
|
+
} catch {
|
|
246
|
+
return new Response(null, { status: 404, headers: privateHeaders() });
|
|
247
|
+
}
|
|
248
|
+
if (input.pathname !== routeURL.pathname) return new Response(null, { status: 404, headers: privateHeaders() });
|
|
249
|
+
const relative = normalizeDownloadPath(input.searchParams.get("path"));
|
|
250
|
+
const signature = input.searchParams.get("signature") || "";
|
|
251
|
+
if (!relative || !/^[0-9a-f]{64}$/i.test(signature) || !(0, import_node_crypto.timingSafeEqual)(Buffer.from(signature, "hex"), Buffer.from(digest(relative), "hex")))
|
|
252
|
+
return new Response(null, { status: 404, headers: privateHeaders() });
|
|
253
|
+
if (!["GET", "HEAD"].includes(request.method)) return new Response(null, { status: 405, headers: { ...privateHeaders(), Allow: "GET, HEAD" } });
|
|
254
|
+
let file;
|
|
255
|
+
try {
|
|
256
|
+
const base = await (0, import_promises.realpath)(rootDirectory);
|
|
257
|
+
const resolved = await (0, import_promises.realpath)(import_node_path.default.join(base, ...relative.split("/")));
|
|
258
|
+
const rel = import_node_path.default.relative(base, resolved);
|
|
259
|
+
if (!rel || rel === ".." || rel.startsWith(".." + import_node_path.default.sep) || import_node_path.default.isAbsolute(rel) || import_node_path.default.extname(resolved).toLowerCase() !== ".mp3") return new Response(null, { status: 404, headers: privateHeaders() });
|
|
260
|
+
file = await (0, import_promises.open)(resolved, import_node_fs.constants.O_RDONLY | (import_node_fs.constants.O_NOFOLLOW || 0));
|
|
261
|
+
const stat = await file.stat();
|
|
262
|
+
if (!stat.isFile()) {
|
|
263
|
+
await file.close();
|
|
264
|
+
return new Response(null, { status: 404, headers: privateHeaders() });
|
|
265
|
+
}
|
|
266
|
+
const filename = import_node_path.default.basename(resolved);
|
|
267
|
+
const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_") || "audio.mp3";
|
|
268
|
+
const encoded = encodeURIComponent(filename).replace(/['()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
|
|
269
|
+
const headers = {
|
|
270
|
+
...privateHeaders(),
|
|
271
|
+
"Content-Type": "audio/mpeg",
|
|
272
|
+
"Content-Length": String(stat.size),
|
|
273
|
+
"Content-Disposition": `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`
|
|
274
|
+
};
|
|
275
|
+
if (request.method === "HEAD") {
|
|
276
|
+
await file.close();
|
|
277
|
+
return new Response(null, { status: 200, headers });
|
|
278
|
+
}
|
|
279
|
+
const stream = file.createReadStream({ autoClose: true });
|
|
280
|
+
return new Response(import_node_stream.Readable.toWeb(stream), { status: 200, headers });
|
|
281
|
+
} catch {
|
|
282
|
+
if (file) await file.close().catch(() => {
|
|
283
|
+
});
|
|
284
|
+
return new Response(null, { status: 404, headers: privateHeaders() });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return Object.freeze({ sign, handle });
|
|
288
|
+
}
|
|
289
|
+
var numeric = (v, min, max) => typeof v === "number" && Number.isFinite(v) && v >= min && v <= max;
|
|
290
|
+
function boundedText(value, max = 500) {
|
|
291
|
+
return String(value ?? "").replace(/[\u0000-\u001f\u007f]/g, "").slice(0, max);
|
|
292
|
+
}
|
|
293
|
+
function validateMotionCapture(payload) {
|
|
294
|
+
if (!payload || payload.schema !== "ks-epk-motion-recording/v1") fail("ks_motion_schema", "Unsupported capture schema");
|
|
295
|
+
if (!Array.isArray(payload.samples) || payload.samples.length < 1 || payload.samples.length > 500) fail("ks_motion_samples", "A capture must contain 1..500 samples");
|
|
296
|
+
const samples = payload.samples.map((sample) => {
|
|
297
|
+
if (!sample || !numeric(sample.milliseconds, 0, 12e4) || !numeric(sample.beta, -180, 180) || !numeric(sample.gamma, -90, 90)) fail("ks_motion_invalid_samples", "Invalid motion sample");
|
|
298
|
+
return {
|
|
299
|
+
milliseconds: sample.milliseconds,
|
|
300
|
+
beta: sample.beta,
|
|
301
|
+
gamma: sample.gamma,
|
|
302
|
+
alpha: sample.alpha === null || sample.alpha === void 0 ? null : numeric(sample.alpha, 0, 360) ? sample.alpha : null,
|
|
303
|
+
screenAngle: numeric(sample.screenAngle ?? 0, -360, 360) ? sample.screenAngle || 0 : null,
|
|
304
|
+
...Object.fromEntries(["absolute", "nativeTrusted", "simulatedTrusted", "fieldParity", "dispatchParity", "mapperParity"].map((k) => [k, !!sample[k]]))
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
const clean = {
|
|
308
|
+
schema: payload.schema,
|
|
309
|
+
capturedAt: boundedText(payload.capturedAt, 100),
|
|
310
|
+
userAgent: boundedText(payload.userAgent),
|
|
311
|
+
samples,
|
|
312
|
+
screenAngle: numeric(payload.screenAngle ?? 0, -360, 360) ? payload.screenAngle || 0 : null,
|
|
313
|
+
storedSampleCount: samples.length,
|
|
314
|
+
sampleStrategy: boundedText(payload.sampleStrategy || "legacy", 80).toLowerCase().replace(/[^a-z0-9_-]/g, ""),
|
|
315
|
+
fullWindowObserved: !!payload.fullWindowObserved,
|
|
316
|
+
expectedDifference: boundedText(payload.expectedDifference),
|
|
317
|
+
passed: !!payload.passed,
|
|
318
|
+
failures: (Array.isArray(payload.failures) ? payload.failures : []).slice(0, 100).filter((v) => v && typeof v === "object").map((v) => ({
|
|
319
|
+
type: boundedText(v.type || "unknown", 80).toLowerCase().replace(/[^a-z0-9_-]/g, ""),
|
|
320
|
+
sample: Number.isFinite(Number(v.sample)) ? Math.abs(Math.trunc(Number(v.sample))) : null,
|
|
321
|
+
validPairs: Number.isFinite(Number(v.validPairs)) ? Math.abs(Math.trunc(Number(v.validPairs))) : null
|
|
322
|
+
}))
|
|
323
|
+
};
|
|
324
|
+
for (const key of ["targetDurationMilliseconds", "captureDurationMilliseconds", "sampledDurationMilliseconds", "storageIntervalMilliseconds"]) clean[key] = numeric(payload[key] ?? 0, 0, 12e4) ? payload[key] || 0 : null;
|
|
325
|
+
for (const key of ["processedPairCount", "failureCount"]) clean[key] = Math.min(1e5, Math.abs(Math.trunc(Number(payload[key]) || (key === "processedPairCount" ? samples.length : clean.failures.length))));
|
|
326
|
+
if (Buffer.byteLength(JSON.stringify(clean)) > 262144) fail("ks_motion_size", "Capture too large", 413);
|
|
327
|
+
return clean;
|
|
328
|
+
}
|
|
329
|
+
function createMemoryRateLimiter({ limit = 8, windowMilliseconds = 6e5, maxKeys = 1e4, now = Date.now } = {}) {
|
|
330
|
+
for (const [key, value] of Object.entries({ limit, windowMilliseconds, maxKeys })) if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(key);
|
|
331
|
+
const records = /* @__PURE__ */ new Map();
|
|
332
|
+
return Object.freeze({ consume(key) {
|
|
333
|
+
if (typeof key !== "string" || !key || key.length > 512) return false;
|
|
334
|
+
const time = now();
|
|
335
|
+
for (const [id, r2] of records) if (r2.until <= time) records.delete(id);
|
|
336
|
+
if (!records.has(key) && records.size >= maxKeys) return false;
|
|
337
|
+
const r = records.get(key) || { until: time + windowMilliseconds, count: 0 };
|
|
338
|
+
records.set(key, r);
|
|
339
|
+
return ++r.count <= limit;
|
|
340
|
+
} });
|
|
341
|
+
}
|
|
342
|
+
async function boundedBody(request, maxBytes) {
|
|
343
|
+
const length = request.headers.get("content-length");
|
|
344
|
+
if (length && (!/^\d+$/.test(length) || Number(length) > maxBytes)) fail("ks_motion_size", "Capture too large", 413);
|
|
345
|
+
if (!request.body) fail("ks_motion_schema", "Missing JSON body");
|
|
346
|
+
const reader = request.body.getReader();
|
|
347
|
+
const chunks = [];
|
|
348
|
+
let size = 0;
|
|
349
|
+
try {
|
|
350
|
+
while (true) {
|
|
351
|
+
const { done, value } = await reader.read();
|
|
352
|
+
if (done) break;
|
|
353
|
+
size += value.byteLength;
|
|
354
|
+
if (size > maxBytes) {
|
|
355
|
+
await reader.cancel();
|
|
356
|
+
fail("ks_motion_size", "Capture too large", 413);
|
|
357
|
+
}
|
|
358
|
+
chunks.push(value);
|
|
359
|
+
}
|
|
360
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (error instanceof ValidationError) throw error;
|
|
363
|
+
fail("ks_motion_schema", "Invalid UTF-8 JSON");
|
|
364
|
+
} finally {
|
|
365
|
+
reader.releaseLock();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function createCaptureHandler({ authorize, store, rateLimit, allowedOrigin, maxBytes = 262144 } = {}) {
|
|
369
|
+
for (const [key, fn] of Object.entries({ authorize, store, rateLimit })) if (typeof fn !== "function") throw new TypeError(`${key} callback is required`);
|
|
370
|
+
const origin = asOrigin(allowedOrigin);
|
|
371
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 262144) throw new RangeError("maxBytes must be 1..262144");
|
|
372
|
+
return async (request) => {
|
|
373
|
+
if (request.method !== "POST") return json({ code: "method_not_allowed" }, 405, { Allow: "POST" });
|
|
374
|
+
try {
|
|
375
|
+
const source = request.headers.get("origin");
|
|
376
|
+
if (source) {
|
|
377
|
+
let sourceOrigin;
|
|
378
|
+
try {
|
|
379
|
+
sourceOrigin = asOrigin(source);
|
|
380
|
+
} catch {
|
|
381
|
+
return json({ code: "ks_motion_origin" }, 403);
|
|
382
|
+
}
|
|
383
|
+
if (sourceOrigin !== origin) return json({ code: "ks_motion_origin" }, 403);
|
|
384
|
+
}
|
|
385
|
+
if (!await authorize(request)) return json({ code: "ks_motion_forbidden" }, 403);
|
|
386
|
+
if (!await rateLimit(request)) return json({ code: "ks_motion_rate" }, 429, { "Retry-After": "600" });
|
|
387
|
+
if (!/^application\/json(?:\s*;|$)/i.test(request.headers.get("content-type") || "")) return json({ code: "ks_motion_content_type" }, 415);
|
|
388
|
+
const capture = validateMotionCapture(await boundedBody(request, maxBytes));
|
|
389
|
+
const stored = await store(capture, { visibility: "private", request });
|
|
390
|
+
if (!stored || !stored.captureId || stored.storedPrivately !== true) throw new Error("Storage did not confirm private persistence");
|
|
391
|
+
return json({ captureId: stored.captureId, sampleCount: capture.samples.length, storedPrivately: true });
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (error instanceof ValidationError) return json({ code: error.code, message: error.message }, error.status);
|
|
394
|
+
return json({ code: "ks_motion_server", message: "Capture could not be stored" }, 500);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
399
|
+
0 && (module.exports = {
|
|
400
|
+
PAGE_META_KEYS,
|
|
401
|
+
ValidationError,
|
|
402
|
+
createCaptureHandler,
|
|
403
|
+
createDownloadService,
|
|
404
|
+
createMemoryPageStore,
|
|
405
|
+
createMemoryRateLimiter,
|
|
406
|
+
fromWordPressMeta,
|
|
407
|
+
normalizeDownloadPath,
|
|
408
|
+
validateMotionCapture,
|
|
409
|
+
validatePageConfig,
|
|
410
|
+
validateTimingFile
|
|
411
|
+
});
|
package/lib/server.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export {validatePageConfig,fromWordPressMeta,PAGE_META_KEYS,createMemoryPageStore} from './index';
|
|
2
|
+
import type {MotionCapture} from './index';
|
|
3
|
+
export class ValidationError extends Error {code:string;status:number;constructor(code:string,message:string,status?:number)}
|
|
4
|
+
export function validateTimingFile(input:string|Uint8Array,options?:{kind?:'cue'|'lyric';filename?:string;maxBytes?:number}):Readonly<{text:string;kind:string;bytes:number;timestamps:number}>;
|
|
5
|
+
export function normalizeDownloadPath(value:unknown):string;
|
|
6
|
+
export function createDownloadService(options:{rootDirectory:string;secret:string|Uint8Array;origin:string;route?:string}):Readonly<{sign(relative:string):string;handle(request:Request):Promise<Response>}>;
|
|
7
|
+
export function validateMotionCapture(input:unknown):MotionCapture;
|
|
8
|
+
export function createMemoryRateLimiter(options?:{limit?:number;windowMilliseconds?:number;maxKeys?:number;now?:()=>number}):Readonly<{consume(key:string):boolean}>;
|
|
9
|
+
export function createCaptureHandler(options:{authorize:(request:Request)=>boolean|Promise<boolean>;rateLimit:(request:Request)=>boolean|Promise<boolean>;
|
|
10
|
+
store:(capture:MotionCapture,context:{visibility:'private';request:Request})=>Promise<{captureId:string|number;storedPrivately:true}>;
|
|
11
|
+
allowedOrigin:string;maxBytes?:number}):(request:Request)=>Promise<Response>;
|