@wyndlass/app-sdk 0.0.0 → 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.
Files changed (75) hide show
  1. package/README.md +212 -1
  2. package/cli/app-package.mjs +111 -0
  3. package/cli/commands.mjs +133 -0
  4. package/cli/main.mjs +73 -0
  5. package/cli/package.mjs +119 -0
  6. package/cli/preview.mjs +142 -0
  7. package/cli/zip.mjs +63 -0
  8. package/dist/contract/app-contract.d.ts +221 -0
  9. package/dist/contract/app-contract.js +6 -0
  10. package/dist/contract/app-definition.d.ts +155 -0
  11. package/dist/contract/app-definition.js +3 -0
  12. package/dist/contract/data-answer.d.ts +40 -0
  13. package/dist/contract/data-answer.js +3 -0
  14. package/dist/contract/data-calendar.d.ts +57 -0
  15. package/dist/contract/data-calendar.js +3 -0
  16. package/dist/contract/data-news.d.ts +29 -0
  17. package/dist/contract/data-news.js +3 -0
  18. package/dist/contract/data-quotes.d.ts +75 -0
  19. package/dist/contract/data-quotes.js +3 -0
  20. package/dist/contract/data-series.d.ts +51 -0
  21. package/dist/contract/data-series.js +3 -0
  22. package/dist/contract/data-snapshot.d.ts +30 -0
  23. package/dist/contract/data-snapshot.js +3 -0
  24. package/dist/contract/data-weather.d.ts +421 -0
  25. package/dist/contract/data-weather.js +3 -0
  26. package/dist/contract/player-api-event.d.ts +253 -0
  27. package/dist/contract/player-api-event.js +3 -0
  28. package/dist/contract/player-api-status.d.ts +178 -0
  29. package/dist/contract/player-api-status.js +3 -0
  30. package/dist/easing.d.ts +16 -0
  31. package/dist/easing.js +71 -0
  32. package/dist/index.d.ts +94 -0
  33. package/dist/index.js +218 -0
  34. package/dist/preview/frame/frame.js +2 -0
  35. package/dist/preview/frame/index.html +12 -0
  36. package/dist/preview/index.html +40 -0
  37. package/dist/preview/page.js +7 -0
  38. package/dist/qr.d.ts +5 -0
  39. package/dist/qr.js +14 -0
  40. package/dist/react.d.ts +52 -0
  41. package/dist/react.js +149 -0
  42. package/package.json +83 -4
  43. package/protocol/app-definition.schema.json +435 -0
  44. package/protocol/app-packages.json +172 -0
  45. package/protocol/common.schema.json +197 -0
  46. package/protocol/samples/calendar.json +63 -0
  47. package/protocol/samples/events/app.command.json +14 -0
  48. package/protocol/samples/events/item.end.json +13 -0
  49. package/protocol/samples/events/item.start.json +18 -0
  50. package/protocol/samples/events/state.changed.json +11 -0
  51. package/protocol/samples/news.json +37 -0
  52. package/protocol/samples/player-status.json +58 -0
  53. package/protocol/samples/weather.json +59 -0
  54. package/skill/wyndlass-app/SKILL.md +90 -0
  55. package/skill/wyndlass-app/references/contract.md +91 -0
  56. package/skill/wyndlass-app/references/mcp.md +79 -0
  57. package/skill/wyndlass-app/references/templates.md +38 -0
  58. package/templates/react/AGENTS.md +17 -0
  59. package/templates/react/README.md +23 -0
  60. package/templates/react/app.json +14 -0
  61. package/templates/react/gitignore +2 -0
  62. package/templates/react/package.json +22 -0
  63. package/templates/react/src/app.css +7 -0
  64. package/templates/react/src/app.tsx +39 -0
  65. package/templates/react/src/env.d.ts +13 -0
  66. package/templates/react/tsconfig.json +13 -0
  67. package/templates/vanilla/AGENTS.md +17 -0
  68. package/templates/vanilla/README.md +23 -0
  69. package/templates/vanilla/app.json +14 -0
  70. package/templates/vanilla/gitignore +2 -0
  71. package/templates/vanilla/package.json +18 -0
  72. package/templates/vanilla/src/app.css +7 -0
  73. package/templates/vanilla/src/app.ts +59 -0
  74. package/templates/vanilla/src/env.d.ts +13 -0
  75. package/templates/vanilla/tsconfig.json +12 -0
@@ -0,0 +1,119 @@
1
+ // What `check` and `pack` share (WP-0101 decisions 5 and 8): a project's definition and module, built as core takes them,
2
+ // and the verdict core's checks give, from the reference reader the protocol holds core to (app-package.mjs, a copy).
3
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import Ajv2020 from "ajv/dist/2020.js";
7
+ import addFormats from "ajv-formats";
8
+ import { build } from "esbuild";
9
+ import { checkAppPackage, DATA_SOURCES, LIMITS } from "./app-package.mjs";
10
+ import { zip } from "./zip.mjs";
11
+
12
+ const sdk = join(dirname(fileURLToPath(import.meta.url)), "..");
13
+ /** The SDK's own version, which a module's first line names. */
14
+ export const SDK_VERSION = JSON.parse(readFileSync(join(sdk, "package.json"), "utf8")).version;
15
+
16
+ /** A refusal the CLI says in words and exits 1 on. */
17
+ export class CliError extends Error {}
18
+
19
+ let validator;
20
+ /** app-definition.schema.json, compiled as the protocol's own check compiles it. */
21
+ export function definitionValidator() {
22
+ if (validator) return validator;
23
+ const ajv = new Ajv2020({ strict: true, allErrors: true, strictRequired: false });
24
+ addFormats(ajv);
25
+ ajv.addSchema(JSON.parse(readFileSync(join(sdk, "protocol", "common.schema.json"), "utf8")));
26
+ validator = ajv.compile(JSON.parse(readFileSync(join(sdk, "protocol", "app-definition.schema.json"), "utf8")));
27
+ return validator;
28
+ }
29
+
30
+ /** Core's verdict on a package's bytes: "valid", or the code of the first check that fails. */
31
+ export function verdict(bytes) { return checkAppPackage(bytes, definitionValidator()); }
32
+
33
+ /** Each code in words, as `check` and `pack` say them. */
34
+ export const MEANING = {
35
+ "too-large": `it is over a limit: the zip at most ${LIMITS.zip / 1048576} MiB, app.json ${LIMITS.definition / 1024} KiB, app.js ${LIMITS.module / 1048576} MiB`,
36
+ "not-a-zip": "it is not a zip Wyndlass can read",
37
+ encrypted: "an entry is encrypted",
38
+ folder: "it holds a folder",
39
+ path: "an entry's name has a / or \\ in it",
40
+ entries: "a package holds exactly app.json and app.js",
41
+ "size-mismatch": "an entry unpacks to another size than it says",
42
+ "definition-json": "app.json is not a JSON object in UTF-8",
43
+ "definition-schema": "app.json does not match app-definition.schema.json",
44
+ "definition-id": "its id is under com.wyndlass., which is Wyndlass's own: use your organization's own domain",
45
+ "definition-when": "a param's when names itself, or a param there is not",
46
+ "definition-url": "a url param's values and default must be https addresses",
47
+ "definition-peers": "capabilities.peers is not open to apps yet",
48
+ "definition-data": `data names a source apps may not read: only ${DATA_SOURCES.join(", ")}`,
49
+ "module-syntax": "the module is not an ES2020 module",
50
+ "module-import": "the module imports something: an app's module brings everything it needs",
51
+ "module-default": "the module has no default export: export the element's class as the default",
52
+ };
53
+
54
+ /** The project's source of the module: src/app.tsx, .ts, .jsx or .js. */
55
+ export function entryOf(dir) {
56
+ const entry = ["app.tsx", "app.ts", "app.jsx", "app.js"].map(n => join(dir, "src", n)).find(p => existsSync(p));
57
+ if (!entry) throw new CliError(`${dir} has no src/app.ts (or .tsx, .js, .jsx): the module's source`);
58
+ return entry;
59
+ }
60
+
61
+ /** The project's definition, as text and as read (null when it is not JSON). */
62
+ export function definitionOf(dir) {
63
+ const path = join(dir, "app.json");
64
+ if (!existsSync(path)) throw new CliError(`${dir} has no app.json: the app's definition`);
65
+ const text = readFileSync(path, "utf8");
66
+ let read = null;
67
+ try { read = JSON.parse(text); } catch { /* the check says so */ }
68
+ return { text, read };
69
+ }
70
+
71
+ /**
72
+ * The module as core takes it: one ES2020 file, minified, its first line naming the app and the SDK. CSS a source
73
+ * imports becomes text, for css(); images and fonts are inlined. The source map goes beside it, never into it.
74
+ */
75
+ export async function bundle(dir, id, version) {
76
+ const outfile = join(dir, "dist", `${id}-${version}.js`);
77
+ const result = await build({
78
+ entryPoints: [entryOf(dir)], absWorkingDir: dir, outfile, bundle: true, format: "esm", target: "es2020", platform: "browser", minify: true,
79
+ write: false, legalComments: "none", charset: "utf8", sourcemap: "external", jsx: "automatic", logLevel: "silent",
80
+ define: { "process.env.NODE_ENV": "\"production\"" },
81
+ banner: { js: `/* ${id} ${version}, @wyndlass/app-sdk ${SDK_VERSION} */` },
82
+ loader: { ".css": "text", ".png": "dataurl", ".jpg": "dataurl", ".jpeg": "dataurl", ".gif": "dataurl", ".webp": "dataurl", ".svg": "dataurl", ".woff2": "dataurl", ".woff": "dataurl" },
83
+ }).catch(e => { throw new CliError(`the module does not build: ${e.errors?.[0]?.text ?? e.message}`); });
84
+ const code = result.outputFiles.find(f => f.path.endsWith(".js")).contents;
85
+ const map = result.outputFiles.find(f => f.path.endsWith(".js.map")).contents;
86
+ return { code, map };
87
+ }
88
+
89
+ /** Every source file of the project, for the warnings. */
90
+ function sources(dir) {
91
+ return readdirSync(dir).flatMap(n => {
92
+ const p = join(dir, n);
93
+ return statSync(p).isDirectory() ? sources(p) : /\.[jt]sx?$/.test(n) ? [p] : [];
94
+ });
95
+ }
96
+
97
+ /**
98
+ * A project's package, built and checked: its id and version, the zip and the map, core's verdict on the zip, and
99
+ * warnings, which refuse nothing: a module over 1 MiB, which a screen loads at every start; no ready() in the sources.
100
+ */
101
+ export async function packageOf(dir) {
102
+ const definition = definitionOf(dir);
103
+ const id = typeof definition.read?.id === "string" ? definition.read.id : "app";
104
+ const version = typeof definition.read?.version === "string" ? definition.read.version : "0.0.0";
105
+ const { code, map } = await bundle(dir, id, version);
106
+ const bytes = zip([["app.json", Buffer.from(definition.text, "utf8")], ["app.js", code]]);
107
+ const warnings = [];
108
+ if (code.length > 1024 * 1024) warnings.push(`the module is ${(code.length / 1048576).toFixed(1)} MiB: a screen loads it at every start`);
109
+ if (!sources(join(dir, "src")).some(f => /\bready\s*\(/.test(readFileSync(f, "utf8"))))
110
+ warnings.push("no call to ready() found: a screen reports an app late that has not said it is ready within 5 s");
111
+ return { id, version, bytes, map, code: verdict(bytes), warnings, definition: definition.read };
112
+ }
113
+
114
+ /** Why a definition fails its schema, in Ajv's words, for definition-schema. */
115
+ export function schemaErrors(definition) {
116
+ const validate = definitionValidator();
117
+ validate(definition);
118
+ return (validate.errors ?? []).slice(0, 5).map(e => `${e.instancePath || "app.json"} ${e.message}`);
119
+ }
@@ -0,0 +1,142 @@
1
+ // preview (WP-0101 decision 7): the app run as screens run it, on 127.0.0.1 only. The page is player-core's host, compiled
2
+ // into the package when the SDK is built (dist/preview/); this server gives it the project's module, rebuilt with the
3
+ // SDK's own esbuild whenever a source or app.json changes, the definition, and sample answers for the data the app
4
+ // declares: the protocol's fixtures, or a folder of the developer's own. The page reloads the app on each rebuild.
5
+ // With --frame (part c2), the app runs in WP-0100's frame on an origin of its own, as in the CMS: a second server, on
6
+ // 127.0.0.1 too, serves the frame's page and runtime under the frames' own policy.
7
+ import { existsSync, readdirSync, readFileSync, watch } from "node:fs";
8
+ import { createServer } from "node:http";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { context } from "esbuild";
12
+ import { CliError, definitionOf, entryOf } from "./package.mjs";
13
+
14
+ const sdk = join(dirname(fileURLToPath(import.meta.url)), "..");
15
+ const page = join(sdk, "dist", "preview");
16
+ const SOURCES = ["weather", "news", "calendar"];
17
+ const TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8" };
18
+
19
+ /** A sample answer's data for a source: the folder's <source>.json (its data, or a whole answer), else the protocol's. */
20
+ function sample(dataDir, source) {
21
+ const own = dataDir && join(dataDir, `${source}.json`);
22
+ const file = own && existsSync(own) ? own : join(sdk, "protocol", "samples", `${source}.json`);
23
+ if (!existsSync(file)) return null;
24
+ const read = JSON.parse(readFileSync(file, "utf8"));
25
+ return read && typeof read === "object" && "source" in read && "data" in read ? read.data : read;
26
+ }
27
+
28
+ /** The tables in the data folder's tables/: each .json or .csv file's text, by its name; the page reads them. */
29
+ function tables(dataDir) {
30
+ const dir = dataDir && join(dataDir, "tables");
31
+ if (!dir || !existsSync(dir)) return {};
32
+ return Object.fromEntries(readdirSync(dir).filter(n => /.(json|csv)$/i.test(n)).map(n => [n, readFileSync(join(dir, n), "utf8")]));
33
+ }
34
+
35
+ /** The player's status and the events the controller can be sent, as the Player API's fixtures give them. */
36
+ function controllerSamples() {
37
+ const samples = join(sdk, "protocol", "samples");
38
+ const events = join(samples, "events");
39
+ return {
40
+ status: JSON.parse(readFileSync(join(samples, "player-status.json"), "utf8")),
41
+ events: existsSync(events) ? Object.fromEntries(readdirSync(events).map(n => [n.replace(/.json$/, ""), JSON.parse(readFileSync(join(events, n), "utf8"))])) : {},
42
+ };
43
+ }
44
+
45
+ /** The frames' own policy (WP-0124's frames row), with the preview's page the one that may frame them. */
46
+ const framePolicy = (frames, page) => [
47
+ "default-src 'none'", `script-src ${frames} blob:`, "style-src 'unsafe-inline'", "img-src blob: data:", "font-src blob: data:", "media-src blob:",
48
+ "connect-src https:", `frame-ancestors ${page}`, "base-uri 'none'", "form-action 'none'",
49
+ ].join("; ");
50
+
51
+ /** A small picture for any image a data answer names: the preview fetches nothing from the network. */
52
+ const PICTURE = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180"><rect width="320" height="180" fill="#5a6b7d"/><path d="M0 180 L110 80 L180 140 L230 100 L320 180Z" fill="#8fa3b8"/><circle cx="250" cy="50" r="22" fill="#d9e2ec"/></svg>`);
53
+
54
+ /**
55
+ * Serves the preview of the project at `dir` on 127.0.0.1:`port` (0: any free port), and with `frame`, the frames on
56
+ * the next port (any free one where `port` is 0). Resolves once it listens, with its address and a way to stop it.
57
+ */
58
+ export async function preview(dir = ".", { port = 5199, data: dataDir, frame = false } = {}) {
59
+ const root = resolve(dir);
60
+ definitionOf(root);
61
+ const entry = entryOf(root);
62
+ if (!existsSync(join(page, "index.html"))) throw new CliError("this copy of @wyndlass/app-sdk has no preview page (dist/preview): build the SDK first");
63
+ let frames = null, framesOrigin = "", pageOrigin = "";
64
+ const clients = new Set();
65
+ const tell = () => { for (const c of clients) c.write("event: reload\ndata: {}\n\n"); };
66
+ let built = { code: "", error: "the module has not been built yet" }, version = 0, first;
67
+ const firstBuilt = new Promise(r => { first = r; });
68
+ const builder = await context({
69
+ entryPoints: [entry], absWorkingDir: root, bundle: true, format: "esm", target: "es2020", platform: "browser", write: false, sourcemap: "inline",
70
+ jsx: "automatic", logLevel: "silent", define: { "process.env.NODE_ENV": "\"development\"" }, outfile: join(root, "dist", "preview.js"),
71
+ loader: { ".css": "text", ".png": "dataurl", ".jpg": "dataurl", ".jpeg": "dataurl", ".gif": "dataurl", ".webp": "dataurl", ".svg": "dataurl", ".woff2": "dataurl", ".woff": "dataurl" },
72
+ plugins: [{ name: "wyndlass-preview", setup(b) {
73
+ b.onEnd(r => {
74
+ built = r.errors.length ? { code: "", error: r.errors.map(e => `${e.location ? `${e.location.file}:${e.location.line}: ` : ""}${e.text}`).join("\n") }
75
+ : { code: r.outputFiles.find(f => f.path.endsWith(".js")).text, error: null };
76
+ version++;
77
+ tell();
78
+ first();
79
+ });
80
+ } }],
81
+ });
82
+ await builder.watch();
83
+ // The first build done before anyone is told where the preview is: a page loaded sooner would find no module.
84
+ await firstBuilt;
85
+ // The definition is not a source esbuild follows.
86
+ const definitionWatch = watch(join(root, "app.json"), () => { version++; tell(); });
87
+
88
+ const server = createServer((req, res) => {
89
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
90
+ const send = (status, type, body, more = {}) => res.writeHead(status, { "content-type": type, "cache-control": "no-store", ...more }).end(body);
91
+ switch (url.pathname) {
92
+ case "/": return send(200, TYPES[".html"], readFileSync(join(page, "index.html")));
93
+ case "/page.js": return send(200, TYPES[".js"], readFileSync(join(page, "page.js")));
94
+ // Its version with it, so a page that connects to /events after a change knows it loaded an older one.
95
+ case "/app.js": return built.error ? send(500, "text/plain; charset=utf-8", built.error, { "x-preview-version": String(version) })
96
+ : send(200, TYPES[".js"], built.code, { "x-preview-version": String(version) });
97
+ case "/app.json": return send(200, TYPES[".json"], readFileSync(join(root, "app.json")));
98
+ case "/samples.json": {
99
+ const declared = (definitionOf(root).read?.data ?? []).filter(s => SOURCES.includes(s));
100
+ return send(200, TYPES[".json"], JSON.stringify(Object.fromEntries(declared.map(s => [s, sample(dataDir && resolve(dataDir), s)]))));
101
+ }
102
+ case "/config.json": return send(200, TYPES[".json"], JSON.stringify({ frames: framesOrigin, tables: tables(dataDir && resolve(dataDir)), controller: controllerSamples() }));
103
+ case "/picture.svg": return send(200, "image/svg+xml", PICTURE);
104
+ case "/favicon.ico": return res.writeHead(204).end();
105
+ case "/events": {
106
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
107
+ res.write(`event: hello\ndata: ${JSON.stringify({ version })}\n\n`);
108
+ clients.add(res);
109
+ req.on("close", () => clients.delete(res));
110
+ return;
111
+ }
112
+ default: return send(404, "text/plain; charset=utf-8", "not found");
113
+ }
114
+ });
115
+ await new Promise((ok, fail) => { server.once("error", fail); server.listen(port, "127.0.0.1", ok); })
116
+ .catch(e => { void builder.dispose(); definitionWatch.close(); throw new CliError(e.code === "EADDRINUSE" ? `port ${port} is taken: give another with --port` : e.message); });
117
+ const { address, port: bound } = server.address();
118
+ pageOrigin = `http://${address}:${bound}`;
119
+ if (frame) {
120
+ // The frame's page and runtime only, nothing else, under the frames' policy: the app's document there is opaque.
121
+ frames = createServer((req, res) => {
122
+ const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
123
+ const file = path === "/" ? "index.html" : path === "/frame.js" ? "frame.js" : null;
124
+ if (!file) return res.writeHead(404).end();
125
+ res.writeHead(200, { "content-type": TYPES[file.endsWith(".js") ? ".js" : ".html"], "cache-control": "no-store", "content-security-policy": framePolicy(framesOrigin, pageOrigin) })
126
+ .end(readFileSync(join(page, "frame", file)));
127
+ });
128
+ await new Promise((ok, fail) => { frames.once("error", fail); frames.listen(port === 0 ? 0 : port + 1, "127.0.0.1", ok); })
129
+ .catch(async e => { await builder.dispose(); definitionWatch.close(); server.close(); throw new CliError(e.code === "EADDRINUSE" ? `port ${port + 1}, for the frames, is taken: give another with --port` : e.message); });
130
+ framesOrigin = `http://127.0.0.1:${frames.address().port}`;
131
+ }
132
+ return {
133
+ url: `http://${address}:${bound}/`, address, port: bound, frames: framesOrigin || null,
134
+ async close() {
135
+ definitionWatch.close();
136
+ if (frames) await new Promise(r => frames.close(r));
137
+ for (const c of clients) c.end();
138
+ await builder.dispose();
139
+ await new Promise(r => server.close(r));
140
+ },
141
+ };
142
+ }
package/cli/zip.mjs ADDED
@@ -0,0 +1,63 @@
1
+ // The package's zip (WP-0101 decision 8): the same entries give the same bytes, so importing a version again is a no-op,
2
+ // not a refusal (WP-0100 decision 4). The entries in the order given, each deflated at level 9, dated 1 January 1980
3
+ // (the DOS epoch), named in UTF-8, with no extra fields or comments.
4
+ import { deflateRawSync } from "node:zlib";
5
+
6
+ const TABLE = Array.from({ length: 256 }, (_, n) => {
7
+ let c = n;
8
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
9
+ return c >>> 0;
10
+ });
11
+
12
+ /** CRC-32 (ISO 3309), as zip records it; node:zlib's own is not in every Node 20. */
13
+ export function crc32(bytes) {
14
+ let c = 0xffffffff;
15
+ for (const b of bytes) c = TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
16
+ return (c ^ 0xffffffff) >>> 0;
17
+ }
18
+
19
+ const DOS_TIME = 0, DOS_DATE = (0 << 9) | (1 << 5) | 1;
20
+
21
+ /** A zip of `entries`, each [name, bytes], in that order. */
22
+ export function zip(entries) {
23
+ const locals = [], central = [];
24
+ let offset = 0;
25
+ for (const [entryName, data] of entries) {
26
+ const raw = Buffer.from(data), body = deflateRawSync(raw, { level: 9 }), name = Buffer.from(entryName, "utf8"), crc = crc32(raw);
27
+ const local = Buffer.alloc(30);
28
+ local.writeUInt32LE(0x04034b50, 0);
29
+ local.writeUInt16LE(20, 4);
30
+ local.writeUInt16LE(0x0800, 6);
31
+ local.writeUInt16LE(8, 8);
32
+ local.writeUInt16LE(DOS_TIME, 10);
33
+ local.writeUInt16LE(DOS_DATE, 12);
34
+ local.writeUInt32LE(crc, 14);
35
+ local.writeUInt32LE(body.length, 18);
36
+ local.writeUInt32LE(raw.length, 22);
37
+ local.writeUInt16LE(name.length, 26);
38
+ const head = Buffer.alloc(46);
39
+ head.writeUInt32LE(0x02014b50, 0);
40
+ head.writeUInt16LE(20, 4);
41
+ head.writeUInt16LE(20, 6);
42
+ head.writeUInt16LE(0x0800, 8);
43
+ head.writeUInt16LE(8, 10);
44
+ head.writeUInt16LE(DOS_TIME, 12);
45
+ head.writeUInt16LE(DOS_DATE, 14);
46
+ head.writeUInt32LE(crc, 16);
47
+ head.writeUInt32LE(body.length, 20);
48
+ head.writeUInt32LE(raw.length, 24);
49
+ head.writeUInt16LE(name.length, 28);
50
+ head.writeUInt32LE(offset, 42);
51
+ locals.push(local, name, body);
52
+ central.push(head, name);
53
+ offset += local.length + name.length + body.length;
54
+ }
55
+ const directory = Buffer.concat(central);
56
+ const end = Buffer.alloc(22);
57
+ end.writeUInt32LE(0x06054b50, 0);
58
+ end.writeUInt16LE(entries.length, 8);
59
+ end.writeUInt16LE(entries.length, 10);
60
+ end.writeUInt32LE(directory.length, 12);
61
+ end.writeUInt32LE(offset, 16);
62
+ return Buffer.concat([...locals, directory, end]);
63
+ }
@@ -0,0 +1,221 @@
1
+ import type { PlayerAPIEvent } from "./player-api-event.js";
2
+ import type { PlayerAPIStatus } from "./player-api-status.js";
3
+ /**
4
+ * How the host hands an element its context (Player API 2): `element[ATTACH](context)`, once, after it makes the element
5
+ * and before the element joins the document. The element keeps the context where nothing else can reach it, and ignores a
6
+ * second call.
7
+ */
8
+ export declare const ATTACH: unique symbol;
9
+ /**
10
+ * What an app's enter and exit hooks are given (ADR 0053, decision 8; ADR 0071): when on the player's clock (the clip's
11
+ * delay is in startAt, so delayMs is 0), how, and how long; the stagger between its items, and for a paged app the page it
12
+ * is for.
13
+ */
14
+ export interface HookOptions {
15
+ startAt: number;
16
+ preset: string;
17
+ durationMs: number;
18
+ easing?: string;
19
+ delayMs?: number;
20
+ staggerMs?: number;
21
+ page?: number;
22
+ reducedMotion: boolean;
23
+ }
24
+ /** The data service's answer (data-answer.schema.json), with whether it came from this screen's own store, and its age. */
25
+ export interface DataAnswer<T = unknown> {
26
+ source: string;
27
+ params: Record<string, unknown>;
28
+ fetchedAt: string;
29
+ expiresAt: string;
30
+ stale: boolean;
31
+ attribution?: {
32
+ text: string;
33
+ url?: string;
34
+ };
35
+ data: T;
36
+ /** True when the service could not be reached and this is the last answer the screen kept. */
37
+ offline: boolean;
38
+ /** How old the data is, from fetchedAt to now. */
39
+ ageMs: number;
40
+ }
41
+ /** The screen's facts (WP-0068 part g): its name, type, tags and node path, for what an app draws itself. */
42
+ export interface ScreenFacts {
43
+ name: string;
44
+ type?: string;
45
+ tags?: string[];
46
+ /** Its node and the nodes above it, from the organization down. */
47
+ path: {
48
+ name: string;
49
+ kind: string;
50
+ }[];
51
+ /** The workspace's own fields (WP-0068 part h): its value, else the nearest node's. */
52
+ fields?: Record<string, string>;
53
+ }
54
+ /** A rule a table's row must meet to be shown (WP-0137, ADR 0125): against a value, or the screen's own. */
55
+ export interface TableRule {
56
+ column: string;
57
+ op: "eq" | "ne" | "lt" | "lte" | "gt" | "gte" | "contains";
58
+ value: string | number | boolean | null | {
59
+ screen: "name" | "location" | "site" | "fields";
60
+ key?: string;
61
+ };
62
+ }
63
+ /** An app's table binding (DES-DB-12): its table, each slot's column, and which rows it shows, in what order, how many. */
64
+ export interface TableBinding {
65
+ table: string;
66
+ fields?: Record<string, string>;
67
+ filter?: TableRule[];
68
+ sort?: {
69
+ column: string;
70
+ order: "asc" | "desc";
71
+ };
72
+ limit?: number;
73
+ }
74
+ /** An anonymous event at the screen (WP-0105): someone came into view, looked, or left after a while. */
75
+ export type ImpressionEvent = {
76
+ type: "enter";
77
+ source: string;
78
+ } | {
79
+ type: "look";
80
+ source: string;
81
+ } | {
82
+ type: "leave";
83
+ source: string;
84
+ dwellS: number;
85
+ attentionS?: number;
86
+ };
87
+ /** Each kind of sensor's people in view now, the longest any has stayed and, where faces are seen, looked. */
88
+ export interface ImpressionsNow {
89
+ source: string;
90
+ present: number;
91
+ longestDwellS: number;
92
+ longestAttentionS?: number;
93
+ }
94
+ /** What an app that declares the `impressions` sensor gets as context.impressions. */
95
+ export interface ImpressionsFeed {
96
+ now(): ImpressionsNow[];
97
+ /** Hears batches of events, at most four times a second; returns how to stop. */
98
+ on(listener: (events: ImpressionEvent[]) => void): () => void;
99
+ }
100
+ /** What an app may do with the player's controller: its definition's capabilities.player (ADR 0050, decision 3). */
101
+ export type AppCapability = "read" | "control";
102
+ /** In the designer or a preview, commands change nothing. */
103
+ export interface NotApplied {
104
+ applied: false;
105
+ reason: "preview";
106
+ }
107
+ /** The player's time (time.now). */
108
+ export interface TimeNow {
109
+ iso: string;
110
+ epochMs: number;
111
+ timeZone: string;
112
+ utcOffsetMinutes: number;
113
+ synced: boolean;
114
+ }
115
+ /**
116
+ * An app's own view of the player's controller (ADR 0050, decision 3): properties and events with capabilities.player
117
+ * "read" (the default), commands as well with "control". Its listeners go when the app does.
118
+ */
119
+ export interface AppController {
120
+ readonly app: string;
121
+ readonly instance: string;
122
+ readonly capability: AppCapability;
123
+ /** Everything the controller knows now (the same as player.status). */
124
+ status(): PlayerAPIStatus;
125
+ get<K extends keyof PlayerAPIStatus>(property: K): PlayerAPIStatus[K];
126
+ /** Listens for an event, or every event with "*"; returns how to stop. */
127
+ on(event: PlayerAPIEvent["type"] | "*", listener: (event: PlayerAPIEvent) => void): () => void;
128
+ time(): Promise<TimeNow>;
129
+ /** A command by its Player API name, e.g. call("player.pause"); throws when refused, with the Player API's code. */
130
+ call(command: string, args?: Record<string, unknown>): Promise<unknown | NotApplied>;
131
+ play(): Promise<unknown | NotApplied>;
132
+ pause(): Promise<unknown | NotApplied>;
133
+ stop(): Promise<unknown | NotApplied>;
134
+ next(): Promise<unknown | NotApplied>;
135
+ previous(): Promise<unknown | NotApplied>;
136
+ /** Ends this app's slot now: its item ends as finished and the loop moves on. */
137
+ finish(): Promise<unknown | NotApplied>;
138
+ /** Removes every listener; the host calls it when the app goes. */
139
+ dispose(): void;
140
+ }
141
+ /** What an app is given: through ATTACH (Player API 2), or as `element.wyndlass` by a player older than that. */
142
+ export interface AppContext {
143
+ /** The app's id (app-definition), or its item's id where the layout does not say. */
144
+ app: string;
145
+ /** The tag the host defined its element under. */
146
+ element: string;
147
+ props: Record<string, unknown>;
148
+ /** The screen's place (the manifest's), for "the screen's place" defaults. */
149
+ place?: {
150
+ lat: number;
151
+ lon: number;
152
+ };
153
+ /** The screen's facts (WP-0068 part g), for what an app draws itself. */
154
+ screen?: ScreenFacts | null;
155
+ timeZone: string;
156
+ locale: string;
157
+ /** True in the CMS's previews. */
158
+ preview: boolean;
159
+ /** The layout's clock: now, or the moment it is held at when seeked or paused. */
160
+ now(): number;
161
+ /** When the layout started, on the same clock. */
162
+ startedAt(): number;
163
+ /** Says the app is drawn and may be shown. One that has not by the deadline is reported late. */
164
+ ready(): void;
165
+ data<T = unknown>(source: string, params: Record<string, string | number | boolean>): Promise<DataAnswer<T>>;
166
+ /**
167
+ * The answer this screen last kept for a request, however old, at once; nothing before the first (WP-0111). An app draws
168
+ * it while `data` asks again, so it is ready without waiting for the network. Absent on players older than WP-0111 part
169
+ * b, so apps check for it.
170
+ */
171
+ kept?<T = unknown>(source: string, params: Record<string, string | number | boolean>): DataAnswer<T> | undefined;
172
+ /** An image a data answer names (a data service path), as a blob URL the app can draw. */
173
+ image(path: string): Promise<string>;
174
+ /**
175
+ * The layout's table binding for this app (DES-DB-12): its table, each slot's column, and which of its rows it shows,
176
+ * in what order and how many (WP-0137, ADR 0125).
177
+ */
178
+ binding?: TableBinding;
179
+ /**
180
+ * A data table's snapshot, as the manifest carries it; null without one. The bound table's comes with only the rows the
181
+ * binding chooses, chosen at each read on the layout's clock with the screen's facts (WP-0137).
182
+ */
183
+ snapshot(table: string): unknown | null;
184
+ /** The app's view of the player's controller, on screens (WP-0100); absent in the designer and previews. */
185
+ controller?: AppController;
186
+ /**
187
+ * The people at the screen (WP-0105), for an app whose definition declares `capabilities.sensors: ["impressions"]`,
188
+ * where the site measures them. Absent otherwise.
189
+ */
190
+ impressions?: ImpressionsFeed;
191
+ }
192
+ /**
193
+ * An app's element: an HTMLElement (a host types it as both), with the hooks the host calls, each optional, and how it
194
+ * takes its context. Written without the DOM's types, so the contract checks wherever it is read, a Worker included.
195
+ */
196
+ export interface AppElement {
197
+ /** Player API 2: takes the context, once. */
198
+ [ATTACH]?(context: AppContext): void;
199
+ /** The context as players older than Player API 2 give it, and Wyndlass's apps of the old shape read it. */
200
+ wyndlass?: AppContext;
201
+ /** Draws the app at a moment on the layout's clock; called each frame while it plays, and once when held. */
202
+ update?(epochMs: number): void;
203
+ enter?(o: HookOptions): Promise<void> | void;
204
+ exit?(o: HookOptions): Promise<void> | void;
205
+ /** A paged app's pages (ADR 0071, decision 5): how many, and how long each shows, from the data it started with. */
206
+ paging?(): {
207
+ count: number;
208
+ ms: number;
209
+ } | null;
210
+ /** Forgets every enter and exit given so far: the designer's canvas gives them again after the clips change (WP-0069). */
211
+ reset?(): void;
212
+ }
213
+ /**
214
+ * An app's module (ADR 0099 decision 2; Player API 2): its default export is its element's class, which the host defines
215
+ * under a tag of its choosing; its class extends HTMLElement. The old shape, for Wyndlass's own apps on older players
216
+ * only, defines its element itself and exports its tag as `element`; a module of Wyndlass's may have both.
217
+ */
218
+ export interface AppModule {
219
+ default?: new () => AppElement;
220
+ element?: string;
221
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * How the host hands an element its context (Player API 2): `element[ATTACH](context)`, once, after it makes the element
3
+ * and before the element joins the document. The element keeps the context where nothing else can reach it, and ignores a
4
+ * second call.
5
+ */
6
+ export const ATTACH = Symbol.for("wyndlass.app.attach");