@heyramzi/review 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/node/client/http.d.ts +18 -0
- package/dist/node/client/http.js +79 -0
- package/dist/node/core/draft.d.ts +36 -0
- package/dist/node/core/draft.js +75 -0
- package/dist/node/core/enums.d.ts +13 -0
- package/dist/node/core/enums.js +28 -0
- package/dist/node/core/notes.d.ts +34 -0
- package/dist/node/core/notes.js +57 -0
- package/dist/node/core/openscreen.d.ts +63 -0
- package/dist/node/core/openscreen.js +97 -0
- package/dist/node/core/render-words.d.ts +8 -0
- package/dist/node/core/render-words.js +12 -0
- package/dist/node/core/types.d.ts +434 -0
- package/dist/node/core/types.js +2 -0
- package/dist/node/diagrams/figure.d.ts +20 -0
- package/dist/node/diagrams/figure.js +27 -0
- package/dist/node/diagrams/placeholder.d.ts +21 -0
- package/dist/node/diagrams/placeholder.js +42 -0
- package/dist/node/index.d.ts +4 -0
- package/dist/node/index.js +3 -0
- package/dist/node/mount.d.ts +49 -0
- package/dist/node/mount.js +63 -0
- package/dist/node/server/brand.d.ts +29 -0
- package/dist/node/server/brand.js +144 -0
- package/dist/node/server/errors.d.ts +5 -0
- package/dist/node/server/errors.js +9 -0
- package/dist/node/server/guards.d.ts +6 -0
- package/dist/node/server/guards.js +69 -0
- package/dist/node/server/handler.d.ts +14 -0
- package/dist/node/server/handler.js +143 -0
- package/dist/node/server/http.d.ts +33 -0
- package/dist/node/server/http.js +167 -0
- package/dist/node/server/index.d.ts +7 -0
- package/dist/node/server/index.js +6 -0
- package/dist/node/server/notes.d.ts +31 -0
- package/dist/node/server/notes.js +73 -0
- package/dist/node/ui/sprite.d.ts +11 -0
- package/dist/node/ui/sprite.js +56 -0
- package/dist/review.css +2 -0
- package/dist/review.js +18369 -0
- package/package.json +67 -3
- package/src/theme/base.css +346 -0
- package/src/theme/tokens.css +198 -0
- package/README.md +0 -3
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ServerResponse } from "node:http";
|
|
2
|
+
export type FontFile = {
|
|
3
|
+
weight: number;
|
|
4
|
+
file: string;
|
|
5
|
+
};
|
|
6
|
+
/** The part of a brand the theme reads. */
|
|
7
|
+
export type BrandTheme = {
|
|
8
|
+
roles: {
|
|
9
|
+
dark: string;
|
|
10
|
+
gray: string;
|
|
11
|
+
light: string;
|
|
12
|
+
primary: string;
|
|
13
|
+
};
|
|
14
|
+
radius: number;
|
|
15
|
+
files: {
|
|
16
|
+
text: FontFile[];
|
|
17
|
+
title: FontFile[];
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export declare const contrast: (a: string, b: string) => number;
|
|
21
|
+
/**
|
|
22
|
+
* The studio's chrome in their brand: dark ground, light text, primary accent, their corner and
|
|
23
|
+
* two faces. Every value is held to a reading contrast; status colours stay fixed.
|
|
24
|
+
*/
|
|
25
|
+
export declare function brandTheme(brand: BrandTheme): string;
|
|
26
|
+
/** The theme fields of `<brandDir>/brand.json`, or null when there's no brand yet. */
|
|
27
|
+
export declare function readBrandTheme(brandDir: string): BrandTheme | null;
|
|
28
|
+
/** Answers `/brand.css` and `/brand/fonts/<file>`; false for any other path. Read per request, so a brand edit shows on reload. */
|
|
29
|
+
export declare function serveBrand(brandDir: string, pathname: string, response: ServerResponse): boolean;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A person's brand as review tokens, served as /brand.css with its fonts, so Editor OS and
|
|
3
|
+
* Shorts OS draw the same page in the same brand.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
const rgb = (hex) => [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
|
|
8
|
+
const toHex = (c) => `#${c
|
|
9
|
+
.map((v) => Math.round(Math.min(255, Math.max(0, v)))
|
|
10
|
+
.toString(16)
|
|
11
|
+
.padStart(2, "0"))
|
|
12
|
+
.join("")}`;
|
|
13
|
+
/** WCAG relative luminance. */
|
|
14
|
+
const luminance = (hex) => {
|
|
15
|
+
const [r, g, b] = rgb(hex).map((v) => {
|
|
16
|
+
const s = v / 255;
|
|
17
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
18
|
+
});
|
|
19
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
20
|
+
};
|
|
21
|
+
export const contrast = (a, b) => {
|
|
22
|
+
const [hi, lo] = [luminance(a), luminance(b)].toSorted((x, y) => y - x);
|
|
23
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
24
|
+
};
|
|
25
|
+
/** `t` is how much of `to` goes into `from`. */
|
|
26
|
+
const mix = (from, to, t) => {
|
|
27
|
+
const [r1, g1, b1] = rgb(from);
|
|
28
|
+
const [r2, g2, b2] = rgb(to);
|
|
29
|
+
return toHex([r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t]);
|
|
30
|
+
};
|
|
31
|
+
/** Walks `color` toward `to` until it reads at `min` against `against`: a brand colour is kept as long as it can be read. */
|
|
32
|
+
const reach = (color, to, against, min, from = color) => {
|
|
33
|
+
for (let t = 0; t <= 1.0001; t += 0.04) {
|
|
34
|
+
const c = mix(from, to, t);
|
|
35
|
+
if (contrast(c, against) >= min)
|
|
36
|
+
return c;
|
|
37
|
+
}
|
|
38
|
+
return to;
|
|
39
|
+
};
|
|
40
|
+
/** The text colour for a button filled with `fill`: the brand's own ink or ground when one reads at 4.5, else white or black, whichever reads better. */
|
|
41
|
+
const onFill = (fill, candidates) => {
|
|
42
|
+
const ok = candidates.find((c) => contrast(c, fill) >= 4.5);
|
|
43
|
+
if (ok)
|
|
44
|
+
return ok;
|
|
45
|
+
return contrast("#ffffff", fill) >= contrast("#000000", fill) ? "#ffffff" : "#000000";
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The studio's chrome in their brand: dark ground, light text, primary accent, their corner and
|
|
49
|
+
* two faces. Every value is held to a reading contrast; status colours stay fixed.
|
|
50
|
+
*/
|
|
51
|
+
export function brandTheme(brand) {
|
|
52
|
+
const { dark, gray, light, primary } = brand.roles;
|
|
53
|
+
const [darker, lighter] = luminance(dark) <= luminance(light) ? [dark, light] : [light, dark];
|
|
54
|
+
// One scheme per side of the theme switch: the darker role is the dark ground, the lighter one the light.
|
|
55
|
+
const scheme = (base, ink0) => {
|
|
56
|
+
const ground = luminance(base) < 0.5;
|
|
57
|
+
const ink = contrast(ink0, base) >= 7 ? ink0 : reach(ink0, ground ? "#ffffff" : "#000000", base, 7);
|
|
58
|
+
const dim = contrast(gray, base) >= 4.5 ? gray : reach(mix(base, ink, 0.7), ink, base, 4.5);
|
|
59
|
+
const faint = reach(mix(base, ink, 0.5), ink, base, 3);
|
|
60
|
+
const accent = reach(primary, ink, base, 3);
|
|
61
|
+
return [
|
|
62
|
+
`color-scheme:${ground ? "dark" : "light"}`,
|
|
63
|
+
`--ground:${base}`,
|
|
64
|
+
`--surface:${mix(base, ink, 0.05)}`,
|
|
65
|
+
`--raised:${mix(base, ink, 0.1)}`,
|
|
66
|
+
`--line-soft:${mix(base, ink, 0.12)}`,
|
|
67
|
+
`--line:${mix(base, ink, 0.18)}`,
|
|
68
|
+
`--text:${ink}`,
|
|
69
|
+
`--dim:${dim}`,
|
|
70
|
+
`--faint:${faint}`,
|
|
71
|
+
`--accent:${accent}`,
|
|
72
|
+
`--on-accent:${onFill(accent, [base, ink])}`,
|
|
73
|
+
`--accent-deep:${reach(primary, base, ink, 4.5)}`,
|
|
74
|
+
];
|
|
75
|
+
};
|
|
76
|
+
const radius = Math.min(20, Math.max(0, Math.round(brand.radius)));
|
|
77
|
+
const face = (family, files) => files
|
|
78
|
+
.map((f) => `@font-face{font-family:"${family}";font-weight:${f.weight};font-display:swap;src:url(/brand/fonts/${encodeURIComponent(f.file)})}`)
|
|
79
|
+
.join("\n");
|
|
80
|
+
const text = brand.files.text;
|
|
81
|
+
const title = brand.files.title.length ? brand.files.title : text;
|
|
82
|
+
const fonts = [face("brand-chrome-text", text), face("brand-chrome-title", title)]
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.join("\n");
|
|
85
|
+
const shared = [
|
|
86
|
+
`--radius:${radius}px`,
|
|
87
|
+
`--radius-sm:${Math.min(6, radius)}px`,
|
|
88
|
+
...(text.length ? [`--sans:"brand-chrome-text","Satoshi",system-ui,sans-serif`] : []),
|
|
89
|
+
...(title.length ? [`--display:"brand-chrome-title","Manrope",system-ui,sans-serif`] : []),
|
|
90
|
+
];
|
|
91
|
+
return `\n/* The person's brand, drawn over the defaults above. */\n${fonts}\n:root{${[...scheme(darker, lighter), ...shared].join(";")}}\n:root[data-theme="light"]{${scheme(lighter, darker).join(";")}}\n`;
|
|
92
|
+
}
|
|
93
|
+
/** The theme fields of `<brandDir>/brand.json`, or null when there's no brand yet. */
|
|
94
|
+
export function readBrandTheme(brandDir) {
|
|
95
|
+
const path = join(brandDir, "brand.json");
|
|
96
|
+
if (!existsSync(path))
|
|
97
|
+
return null;
|
|
98
|
+
try {
|
|
99
|
+
const h = JSON.parse(readFileSync(path, "utf8"));
|
|
100
|
+
if (!h.roles || !h.type)
|
|
101
|
+
return null;
|
|
102
|
+
return {
|
|
103
|
+
roles: h.roles,
|
|
104
|
+
radius: h.radius ?? 10,
|
|
105
|
+
files: { text: h.type.files ?? [], title: h.type.title?.files ?? [] },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Answers `/brand.css` and `/brand/fonts/<file>`; false for any other path. Read per request, so a brand edit shows on reload. */
|
|
113
|
+
export function serveBrand(brandDir, pathname, response) {
|
|
114
|
+
if (pathname === "/brand.css") {
|
|
115
|
+
const brand = readBrandTheme(brandDir);
|
|
116
|
+
response.writeHead(200, {
|
|
117
|
+
"content-type": "text/css; charset=utf-8",
|
|
118
|
+
"cache-control": "no-cache",
|
|
119
|
+
});
|
|
120
|
+
response.end(brand ? brandTheme(brand) : "");
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
const font = pathname.match(/^\/brand\/fonts\/([^/]+)$/);
|
|
124
|
+
if (!font)
|
|
125
|
+
return false;
|
|
126
|
+
let name;
|
|
127
|
+
try {
|
|
128
|
+
name = decodeURIComponent(font[1] ?? "");
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
name = "";
|
|
132
|
+
}
|
|
133
|
+
const file = join(brandDir, "fonts", basename(name));
|
|
134
|
+
if (!name || name !== basename(name) || !existsSync(file)) {
|
|
135
|
+
response.writeHead(404).end("no such font");
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
response.writeHead(200, {
|
|
139
|
+
"content-type": `font/${file.split(".").pop()}`,
|
|
140
|
+
"cache-control": "no-store",
|
|
141
|
+
});
|
|
142
|
+
response.end(readFileSync(file));
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Frame, NoteTarget, ReviewAction } from "../core/types.js";
|
|
2
|
+
export declare function isFrame(value: unknown): value is Frame;
|
|
3
|
+
export declare function isNoteTarget(value: unknown): value is NoteTarget;
|
|
4
|
+
export declare function isReviewAction(value: unknown): value is ReviewAction;
|
|
5
|
+
/** The action a body holds, or null when it isn't one. */
|
|
6
|
+
export declare const parseAction: (body: unknown) => ReviewAction | null;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Hand-written type guards: the body of a POST is `unknown` until it passes one of these. */
|
|
2
|
+
import { BEAT_STATES, NOTE_ACTS, isOneOf } from "../core/enums.js";
|
|
3
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
+
const isText = (value, max = 4000) => typeof value === "string" && value.trim().length > 0 && value.length <= max;
|
|
5
|
+
const isId = (value) => typeof value === "string" && value.length > 0 && value.length <= 200;
|
|
6
|
+
const isUnit = (value) => typeof value === "number" && value >= 0 && value <= 1;
|
|
7
|
+
const isSeconds = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
8
|
+
export function isFrame(value) {
|
|
9
|
+
if (!isRecord(value) || !isUnit(value.x) || !isUnit(value.y))
|
|
10
|
+
return false;
|
|
11
|
+
const hasW = value.w !== undefined;
|
|
12
|
+
const hasH = value.h !== undefined;
|
|
13
|
+
if (hasW !== hasH)
|
|
14
|
+
return false;
|
|
15
|
+
return !hasW || (isUnit(value.w) && isUnit(value.h));
|
|
16
|
+
}
|
|
17
|
+
const isAnchor = (value) => isRecord(value) && isId(value.source) && isSeconds(value.at);
|
|
18
|
+
export function isNoteTarget(value) {
|
|
19
|
+
if (!isRecord(value))
|
|
20
|
+
return false;
|
|
21
|
+
switch (value.kind) {
|
|
22
|
+
case "video":
|
|
23
|
+
return true;
|
|
24
|
+
case "beat":
|
|
25
|
+
return isId(value.beat);
|
|
26
|
+
case "text":
|
|
27
|
+
return (isId(value.from) &&
|
|
28
|
+
isId(value.to) &&
|
|
29
|
+
typeof value.quote === "string" &&
|
|
30
|
+
value.quote.length <= 2000);
|
|
31
|
+
case "time":
|
|
32
|
+
return (isSeconds(value.at) &&
|
|
33
|
+
(value.span === undefined || isSeconds(value.span)) &&
|
|
34
|
+
(value.frame === undefined || isFrame(value.frame)) &&
|
|
35
|
+
(value.anchor === undefined || isAnchor(value.anchor)));
|
|
36
|
+
default:
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function isReviewAction(value) {
|
|
41
|
+
if (!isRecord(value))
|
|
42
|
+
return false;
|
|
43
|
+
switch (value.type) {
|
|
44
|
+
case "decide":
|
|
45
|
+
return (isId(value.beat) &&
|
|
46
|
+
isOneOf(BEAT_STATES, value.state) &&
|
|
47
|
+
(value.pick === undefined || isId(value.pick)));
|
|
48
|
+
case "music":
|
|
49
|
+
return isId(value.keep) || value.skip === true || value.more === true;
|
|
50
|
+
case "note":
|
|
51
|
+
return isNoteTarget(value.target) && isText(value.text);
|
|
52
|
+
case "note.act":
|
|
53
|
+
return isId(value.note) && isOneOf(NOTE_ACTS, value.act);
|
|
54
|
+
case "note.reply":
|
|
55
|
+
return isId(value.note) && isText(value.text);
|
|
56
|
+
case "speech":
|
|
57
|
+
return (isId(value.source) &&
|
|
58
|
+
isSeconds(value.start) &&
|
|
59
|
+
isSeconds(value.end) &&
|
|
60
|
+
value.end > value.start &&
|
|
61
|
+
isText(value.text, 2000));
|
|
62
|
+
case "refresh":
|
|
63
|
+
return true;
|
|
64
|
+
default:
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** The action a body holds, or null when it isn't one. */
|
|
69
|
+
export const parseAction = (body) => isReviewAction(body) ? body : null;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { ReviewAdapter } from "../core/types.js";
|
|
3
|
+
export type ReviewHandlerOptions = {
|
|
4
|
+
/** The URL prefix, e.g. "/review". */
|
|
5
|
+
base: string;
|
|
6
|
+
heartbeatMs?: number;
|
|
7
|
+
/** Largest POST body in bytes. */
|
|
8
|
+
maxBody?: number;
|
|
9
|
+
};
|
|
10
|
+
export type ReviewHandler = (request: IncomingMessage, response: ServerResponse) => boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Serves state, act, events, media and library. Returns false for any other path.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createReviewHandler(adapter: ReviewAdapter, options: ReviewHandlerOptions): ReviewHandler;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { ReviewError } from "./errors.js";
|
|
2
|
+
import { parseAction } from "./guards.js";
|
|
3
|
+
import { broadcaster, json, readBody, serveRange } from "./http.js";
|
|
4
|
+
const ID = /^[\w.-]{1,120}$/;
|
|
5
|
+
const problem = (response, status, error) => json(response, status, { ok: false, error });
|
|
6
|
+
function fail(response, error) {
|
|
7
|
+
if (error instanceof ReviewError)
|
|
8
|
+
return problem(response, error.status, error.message);
|
|
9
|
+
console.error("[review]", error);
|
|
10
|
+
problem(response, 500, "Couldn't read this video.");
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Serves state, act, events, media and library. Returns false for any other path.
|
|
14
|
+
*/
|
|
15
|
+
export function createReviewHandler(adapter, options) {
|
|
16
|
+
const base = options.base.replace(/\/+$/, "");
|
|
17
|
+
const maxBody = options.maxBody ?? 256 * 1024;
|
|
18
|
+
const streams = new Map();
|
|
19
|
+
/** One watch and one reload per video, however many pages are open on it. */
|
|
20
|
+
function stream(id) {
|
|
21
|
+
const existing = streams.get(id);
|
|
22
|
+
if (existing)
|
|
23
|
+
return existing;
|
|
24
|
+
const cast = broadcaster(options.heartbeatMs);
|
|
25
|
+
let loading = false;
|
|
26
|
+
let dirty = false;
|
|
27
|
+
const push = async () => {
|
|
28
|
+
if (loading) {
|
|
29
|
+
dirty = true;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
loading = true;
|
|
33
|
+
try {
|
|
34
|
+
cast.send("model", await adapter.load(id));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
cast.send("problem", {
|
|
38
|
+
error: error instanceof Error ? error.message : "Couldn't read the video.",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
loading = false;
|
|
43
|
+
if (dirty) {
|
|
44
|
+
dirty = false;
|
|
45
|
+
void push();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const entry = { cast, unwatch: adapter.watch(id, () => void push()) };
|
|
50
|
+
streams.set(id, entry);
|
|
51
|
+
return entry;
|
|
52
|
+
}
|
|
53
|
+
async function events(request, response, id) {
|
|
54
|
+
let model;
|
|
55
|
+
try {
|
|
56
|
+
model = await adapter.load(id);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
return fail(response, error);
|
|
60
|
+
}
|
|
61
|
+
const entry = stream(id);
|
|
62
|
+
entry.cast.add(response, `event: model\ndata: ${JSON.stringify(model)}\n\n`);
|
|
63
|
+
request.on("close", () => {
|
|
64
|
+
if (entry.cast.count() > 0)
|
|
65
|
+
return;
|
|
66
|
+
entry.unwatch();
|
|
67
|
+
streams.delete(id);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async function act(request, response, id) {
|
|
71
|
+
if (!String(request.headers["content-type"] ?? "").includes("application/json")) {
|
|
72
|
+
return problem(response, 415, "Send JSON.");
|
|
73
|
+
}
|
|
74
|
+
const raw = await readBody(request, maxBody);
|
|
75
|
+
if (raw === null)
|
|
76
|
+
return problem(response, 413, "That note is too long.");
|
|
77
|
+
let body;
|
|
78
|
+
try {
|
|
79
|
+
body = JSON.parse(raw);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return problem(response, 400, "That isn't JSON.");
|
|
83
|
+
}
|
|
84
|
+
const action = parseAction(body);
|
|
85
|
+
if (!action)
|
|
86
|
+
return problem(response, 400, "That isn't an action this page knows.");
|
|
87
|
+
try {
|
|
88
|
+
const result = await adapter.act(id, action);
|
|
89
|
+
json(response, result.ok ? 200 : 422, result);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
fail(response, error);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return (request, response) => {
|
|
96
|
+
const url = new URL(request.url ?? "/", "http://review.local");
|
|
97
|
+
if (!url.pathname.startsWith(`${base}/`))
|
|
98
|
+
return false;
|
|
99
|
+
const [rawId = "", route = "", ...rest] = url.pathname.slice(base.length + 1).split("/");
|
|
100
|
+
let id;
|
|
101
|
+
try {
|
|
102
|
+
id = decodeURIComponent(rawId);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
problem(response, 400, "Bad video id.");
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
if (!ID.test(id) || rest.length > 0) {
|
|
109
|
+
problem(response, 404, "Not a review route.");
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
const method = request.method ?? "GET";
|
|
113
|
+
const reading = method === "GET" || method === "HEAD";
|
|
114
|
+
if (route === "state" && reading) {
|
|
115
|
+
adapter.load(id).then((model) => json(response, 200, model), (error) => fail(response, error));
|
|
116
|
+
}
|
|
117
|
+
else if (route === "events" && method === "GET") {
|
|
118
|
+
void events(request, response, id);
|
|
119
|
+
}
|
|
120
|
+
else if (route === "media" && reading) {
|
|
121
|
+
const file = url.searchParams.get("file");
|
|
122
|
+
const path = file ? adapter.media(id, file) : null;
|
|
123
|
+
if (path)
|
|
124
|
+
serveRange(request, response, path);
|
|
125
|
+
else
|
|
126
|
+
problem(response, 404, "No such file.");
|
|
127
|
+
}
|
|
128
|
+
else if (route === "library" && reading) {
|
|
129
|
+
Promise.resolve(adapter.library?.(id) ?? []).then((shelves) => json(response, 200, shelves), (error) => fail(response, error));
|
|
130
|
+
}
|
|
131
|
+
else if (route === "act" && method === "POST") {
|
|
132
|
+
void act(request, response, id);
|
|
133
|
+
}
|
|
134
|
+
else if (["state", "events", "media", "act", "library"].includes(route)) {
|
|
135
|
+
response.setHeader("allow", route === "act" ? "POST" : "GET");
|
|
136
|
+
problem(response, 405, "Wrong method.");
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
problem(response, 404, "Not a review route.");
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export type Range = {
|
|
3
|
+
status: 200 | 206;
|
|
4
|
+
start: number;
|
|
5
|
+
end: number;
|
|
6
|
+
headers: Record<string, string>;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* A video served whole can't seek. Returns null for a range the file can't satisfy (416).
|
|
10
|
+
*/
|
|
11
|
+
export declare function rangeHeaders(size: number, header: string | undefined): Range | null;
|
|
12
|
+
export declare const contentTypeOf: (path: string) => string;
|
|
13
|
+
/** Stream a file with range support. Answers 404 for a missing file and 416 for a bad range. */
|
|
14
|
+
export declare function serveRange(request: IncomingMessage, response: ServerResponse, path: string, contentType?: string): void;
|
|
15
|
+
/** `Events` names each event and the body it carries, so a host checks every send against its pages. */
|
|
16
|
+
export type Broadcaster<Events extends object = Record<string, unknown>> = {
|
|
17
|
+
add(response: ServerResponse, first?: string): void;
|
|
18
|
+
send<Name extends keyof Events & string>(event: Name, data: Events[Name]): void;
|
|
19
|
+
count(): number;
|
|
20
|
+
/** End every open stream. */
|
|
21
|
+
close(): void;
|
|
22
|
+
};
|
|
23
|
+
/** A set of SSE clients with a heartbeat, so a proxy or a sleeping laptop notices a dead one. */
|
|
24
|
+
export declare function broadcaster<Events extends object = Record<string, unknown>>(heartbeatMs?: number): Broadcaster<Events>;
|
|
25
|
+
/**
|
|
26
|
+
* The request body, or null when it ran past `max` bytes. The rest is read and dropped, not cut
|
|
27
|
+
* off, so the 413 still reaches a client that is mid-upload.
|
|
28
|
+
*/
|
|
29
|
+
export declare function readBytes(request: IncomingMessage, max: number): Promise<Buffer | null>;
|
|
30
|
+
/** The body as text, or null past `max` bytes. */
|
|
31
|
+
export declare function readBody(request: IncomingMessage, max: number): Promise<string | null>;
|
|
32
|
+
/** One JSON answer. `headers` replace the defaults of the same name, such as a route's own cache rule. */
|
|
33
|
+
export declare function json(response: ServerResponse, status: number, body: unknown, headers?: Record<string, string>): void;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plumbing every review server needs: ranged file reads, a set of server-sent-event clients,
|
|
3
|
+
* a capped body read and a JSON answer.
|
|
4
|
+
*/
|
|
5
|
+
import { createReadStream, statSync } from "node:fs";
|
|
6
|
+
import { extname } from "node:path";
|
|
7
|
+
/**
|
|
8
|
+
* A video served whole can't seek. Returns null for a range the file can't satisfy (416).
|
|
9
|
+
*/
|
|
10
|
+
export function rangeHeaders(size, header) {
|
|
11
|
+
const base = { "accept-ranges": "bytes" };
|
|
12
|
+
if (!header)
|
|
13
|
+
return {
|
|
14
|
+
status: 200,
|
|
15
|
+
start: 0,
|
|
16
|
+
end: size - 1,
|
|
17
|
+
headers: { ...base, "content-length": String(size) },
|
|
18
|
+
};
|
|
19
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
20
|
+
if (!match || size === 0)
|
|
21
|
+
return null;
|
|
22
|
+
const [, rawStart = "", rawEnd = ""] = match;
|
|
23
|
+
let start;
|
|
24
|
+
let end;
|
|
25
|
+
if (rawStart === "") {
|
|
26
|
+
// WHY: "bytes=-500" means the last 500 bytes.
|
|
27
|
+
const tail = Number(rawEnd);
|
|
28
|
+
if (!tail)
|
|
29
|
+
return null;
|
|
30
|
+
start = Math.max(size - tail, 0);
|
|
31
|
+
end = size - 1;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
start = Number(rawStart);
|
|
35
|
+
end = rawEnd === "" ? size - 1 : Math.min(Number(rawEnd), size - 1);
|
|
36
|
+
}
|
|
37
|
+
if (start > end || start >= size)
|
|
38
|
+
return null;
|
|
39
|
+
return {
|
|
40
|
+
status: 206,
|
|
41
|
+
start,
|
|
42
|
+
end,
|
|
43
|
+
headers: {
|
|
44
|
+
...base,
|
|
45
|
+
"content-range": `bytes ${start}-${end}/${size}`,
|
|
46
|
+
"content-length": String(end - start + 1),
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const TYPES = {
|
|
51
|
+
".mp4": "video/mp4",
|
|
52
|
+
".m4v": "video/mp4",
|
|
53
|
+
".mov": "video/quicktime",
|
|
54
|
+
".webm": "video/webm",
|
|
55
|
+
".mp3": "audio/mpeg",
|
|
56
|
+
".m4a": "audio/mp4",
|
|
57
|
+
".wav": "audio/wav",
|
|
58
|
+
".ogg": "audio/ogg",
|
|
59
|
+
".png": "image/png",
|
|
60
|
+
".jpg": "image/jpeg",
|
|
61
|
+
".jpeg": "image/jpeg",
|
|
62
|
+
".webp": "image/webp",
|
|
63
|
+
};
|
|
64
|
+
export const contentTypeOf = (path) => TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
65
|
+
/** Stream a file with range support. Answers 404 for a missing file and 416 for a bad range. */
|
|
66
|
+
export function serveRange(request, response, path, contentType = contentTypeOf(path)) {
|
|
67
|
+
let size;
|
|
68
|
+
try {
|
|
69
|
+
const stat = statSync(path);
|
|
70
|
+
if (!stat.isFile())
|
|
71
|
+
throw new Error("not a file");
|
|
72
|
+
size = stat.size;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
response.writeHead(404).end();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const range = rangeHeaders(size, request.headers.range);
|
|
79
|
+
if (!range) {
|
|
80
|
+
response.writeHead(416, { "content-range": `bytes */${size}` }).end();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
response.writeHead(range.status, {
|
|
84
|
+
...range.headers,
|
|
85
|
+
"content-type": contentType,
|
|
86
|
+
"cache-control": "no-cache",
|
|
87
|
+
});
|
|
88
|
+
if (request.method === "HEAD") {
|
|
89
|
+
response.end();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const stream = createReadStream(path, { start: range.start, end: range.end });
|
|
93
|
+
stream.on("error", () => response.destroy());
|
|
94
|
+
response.on("close", () => stream.destroy());
|
|
95
|
+
stream.pipe(response);
|
|
96
|
+
}
|
|
97
|
+
/** A set of SSE clients with a heartbeat, so a proxy or a sleeping laptop notices a dead one. */
|
|
98
|
+
export function broadcaster(heartbeatMs = 15_000) {
|
|
99
|
+
const clients = new Set();
|
|
100
|
+
let timer;
|
|
101
|
+
const stop = () => {
|
|
102
|
+
if (timer && clients.size === 0)
|
|
103
|
+
clearInterval(timer);
|
|
104
|
+
if (clients.size === 0)
|
|
105
|
+
timer = undefined;
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
add(response, first) {
|
|
109
|
+
response.writeHead(200, {
|
|
110
|
+
"content-type": "text/event-stream",
|
|
111
|
+
"cache-control": "no-cache, no-transform",
|
|
112
|
+
connection: "keep-alive",
|
|
113
|
+
"x-accel-buffering": "no",
|
|
114
|
+
});
|
|
115
|
+
response.write(`retry: 2000\n${first ?? ""}\n`);
|
|
116
|
+
clients.add(response);
|
|
117
|
+
response.on("close", () => {
|
|
118
|
+
clients.delete(response);
|
|
119
|
+
stop();
|
|
120
|
+
});
|
|
121
|
+
timer ??= setInterval(() => {
|
|
122
|
+
for (const client of clients)
|
|
123
|
+
client.write(": ping\n\n");
|
|
124
|
+
}, heartbeatMs).unref();
|
|
125
|
+
},
|
|
126
|
+
send(event, data) {
|
|
127
|
+
const line = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
128
|
+
for (const client of clients)
|
|
129
|
+
client.write(line);
|
|
130
|
+
},
|
|
131
|
+
count: () => clients.size,
|
|
132
|
+
close() {
|
|
133
|
+
for (const client of clients)
|
|
134
|
+
client.end();
|
|
135
|
+
clients.clear();
|
|
136
|
+
stop();
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The request body, or null when it ran past `max` bytes. The rest is read and dropped, not cut
|
|
142
|
+
* off, so the 413 still reaches a client that is mid-upload.
|
|
143
|
+
*/
|
|
144
|
+
export async function readBytes(request, max) {
|
|
145
|
+
const chunks = [];
|
|
146
|
+
let size = 0;
|
|
147
|
+
for await (const chunk of request) {
|
|
148
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
149
|
+
size += buffer.length;
|
|
150
|
+
if (size <= max)
|
|
151
|
+
chunks.push(buffer);
|
|
152
|
+
}
|
|
153
|
+
return size > max ? null : Buffer.concat(chunks);
|
|
154
|
+
}
|
|
155
|
+
/** The body as text, or null past `max` bytes. */
|
|
156
|
+
export async function readBody(request, max) {
|
|
157
|
+
return (await readBytes(request, max))?.toString("utf8") ?? null;
|
|
158
|
+
}
|
|
159
|
+
/** One JSON answer. `headers` replace the defaults of the same name, such as a route's own cache rule. */
|
|
160
|
+
export function json(response, status, body, headers = {}) {
|
|
161
|
+
response.writeHead(status, {
|
|
162
|
+
"content-type": "application/json; charset=utf-8",
|
|
163
|
+
"cache-control": "no-store",
|
|
164
|
+
...headers,
|
|
165
|
+
});
|
|
166
|
+
response.end(JSON.stringify(body));
|
|
167
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createReviewHandler, type ReviewHandler, type ReviewHandlerOptions, } from "./handler.js";
|
|
2
|
+
export { ReviewError } from "./errors.js";
|
|
3
|
+
export { isFrame, isNoteTarget, isReviewAction, parseAction } from "./guards.js";
|
|
4
|
+
export { broadcaster, contentTypeOf, json, rangeHeaders, readBody, readBytes, serveRange, type Broadcaster, type Range, } from "./http.js";
|
|
5
|
+
export { addNote, applyNoteAct, applyReply, nextId, replyTo, type NoteExtras, type NoteInput, type NotesResult, } from "./notes.js";
|
|
6
|
+
export type * from "../core/types.js";
|
|
7
|
+
export { brandTheme, contrast, readBrandTheme, serveBrand, type BrandTheme, type FontFile, } from "./brand.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createReviewHandler, } from "./handler.js";
|
|
2
|
+
export { ReviewError } from "./errors.js";
|
|
3
|
+
export { isFrame, isNoteTarget, isReviewAction, parseAction } from "./guards.js";
|
|
4
|
+
export { broadcaster, contentTypeOf, json, rangeHeaders, readBody, readBytes, serveRange, } from "./http.js";
|
|
5
|
+
export { addNote, applyNoteAct, applyReply, nextId, replyTo, } from "./notes.js";
|
|
6
|
+
export { brandTheme, contrast, readBrandTheme, serveBrand, } from "./brand.js";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Note, NoteAct, NoteTarget } from "../core/types.js";
|
|
2
|
+
export type NotesResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
notes: Note[];
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
error: string;
|
|
8
|
+
};
|
|
9
|
+
/** n1, n2, n3: short enough to say out loud. */
|
|
10
|
+
export declare function nextId(notes: readonly Pick<Note, "id">[]): string;
|
|
11
|
+
export type NoteInput = {
|
|
12
|
+
target: NoteTarget;
|
|
13
|
+
text: string;
|
|
14
|
+
};
|
|
15
|
+
export type NoteExtras = {
|
|
16
|
+
pass?: string;
|
|
17
|
+
by?: Note["by"];
|
|
18
|
+
now?: Date;
|
|
19
|
+
id?: string;
|
|
20
|
+
chat?: boolean;
|
|
21
|
+
reply?: string;
|
|
22
|
+
};
|
|
23
|
+
/** A new note at the end of the list. */
|
|
24
|
+
export declare function addNote(notes: readonly Note[], input: NoteInput, extras?: NoteExtras): NotesResult & {
|
|
25
|
+
note?: Note;
|
|
26
|
+
};
|
|
27
|
+
/** Run one verb on one note, only from a status where it's legal. */
|
|
28
|
+
export declare function applyNoteAct(notes: readonly Note[], id: string, act: NoteAct): NotesResult;
|
|
29
|
+
/** Not happy with the answer: it joins the thread with the reply, and the note goes back to open. */
|
|
30
|
+
export declare function replyTo(note: Note, text: string): Note;
|
|
31
|
+
export declare function applyReply(notes: readonly Note[], id: string, text: string): NotesResult;
|