tachlon 0.0.1 → 0.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/README.md +154 -3
- package/dist-lib/LiberationMono-Regular.woff2 +0 -0
- package/dist-lib/LiberationSans-Bold.woff2 +0 -0
- package/dist-lib/LiberationSans-Regular.woff2 +0 -0
- package/dist-lib/adapters/validate.js +159 -0
- package/dist-lib/app_bg.wasm +0 -0
- package/dist-lib/cli/args.js +36 -0
- package/dist-lib/cli/csp.js +68 -0
- package/dist-lib/cli/detect.js +86 -0
- package/dist-lib/cli/doctor.js +205 -0
- package/dist-lib/cli/gpu.js +66 -0
- package/dist-lib/cli/init.js +103 -0
- package/dist-lib/cli/main.js +7 -0
- package/dist-lib/cli/probe.js +35 -0
- package/dist-lib/cli/run.js +92 -0
- package/dist-lib/cli/template.js +52 -0
- package/dist-lib/engine-size.json +1 -0
- package/dist-lib/errors.js +44 -0
- package/dist-lib/gantt.css +2 -0
- package/dist-lib/product.js +4 -0
- package/dist-lib/react.js +75 -0
- package/dist-lib/schema.json +38 -0
- package/dist-lib/src-CTpvK70m.js +7212 -0
- package/dist-lib/tachlon.js +1 -0
- package/dist-lib/types/adapters/bryntum.d.ts +68 -0
- package/dist-lib/types/adapters/dhtmlx.d.ts +96 -0
- package/dist-lib/types/adapters/index.d.ts +7 -0
- package/dist-lib/types/adapters/plain.d.ts +35 -0
- package/dist-lib/types/adapters/validate.d.ts +26 -0
- package/dist-lib/types/api.d.ts +207 -0
- package/dist-lib/types/commands.d.ts +76 -0
- package/dist-lib/types/document.d.ts +59 -0
- package/dist-lib/types/edit-result.d.ts +51 -0
- package/dist-lib/types/errors.d.ts +33 -0
- package/dist-lib/types/events.d.ts +80 -0
- package/dist-lib/types/filter-names.d.ts +5 -0
- package/dist-lib/types/index.d.ts +92 -0
- package/dist-lib/types/mount-load.d.ts +30 -0
- package/dist-lib/types/open-schedule.d.ts +81 -0
- package/dist-lib/types/product.d.ts +2 -0
- package/dist-lib/types/react.d.ts +32 -0
- package/dist-lib/types/set-filter.d.ts +20 -0
- package/dist-lib/types/view-state.d.ts +95 -0
- package/package.json +61 -5
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// `doctor`: reads a project that lists the package and says, check by check, whether the chart is installed right. It writes nothing.
|
|
2
|
+
// A `Finding` is a code, a status, what was seen and one sentence to fix it; `run.ts` prints them and picks the exit code (1 when one failed).
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { dirname, join, relative } from "node:path";
|
|
5
|
+
import { validate } from "../adapters/validate.js";
|
|
6
|
+
import { codeFor, fixFor } from "../errors.js";
|
|
7
|
+
import { allowsConnect, allowsWasm, configPolicies } from "./csp.js";
|
|
8
|
+
import { readJsonObject, Refusal } from "./detect.js";
|
|
9
|
+
import { BROWSER_VARIABLE } from "./gpu.js";
|
|
10
|
+
const pass = (code, what) => ({ code, status: "pass", what, fix: "" });
|
|
11
|
+
const fail = (code, what, fix) => ({ code, status: "fail", what, fix });
|
|
12
|
+
const MAGIC = [0x00, 0x61, 0x73, 0x6d];
|
|
13
|
+
const why = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ");
|
|
14
|
+
/** Every distinct copy of package `name` the project can load: the nearest `node_modules/<name>` first, then the ones nested one level down (inside another package, or in pnpm's store). */
|
|
15
|
+
export function copiesOf(dir, name) {
|
|
16
|
+
let modules;
|
|
17
|
+
for (let d = dir; modules === undefined; d = dirname(d)) {
|
|
18
|
+
if (existsSync(join(d, "node_modules", name, "package.json")))
|
|
19
|
+
modules = join(d, "node_modules");
|
|
20
|
+
else if (dirname(d) === d)
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const found = new Set([realpathSync(join(modules, name))]);
|
|
24
|
+
const nested = (holder) => {
|
|
25
|
+
const copy = join(holder, "node_modules", name);
|
|
26
|
+
if (existsSync(join(copy, "package.json")))
|
|
27
|
+
found.add(realpathSync(copy));
|
|
28
|
+
};
|
|
29
|
+
for (const entry of readdirSync(modules)) {
|
|
30
|
+
const sub = join(modules, entry);
|
|
31
|
+
if (entry === ".pnpm" || entry.startsWith("@"))
|
|
32
|
+
readdirSync(sub).forEach((s) => nested(join(sub, s)));
|
|
33
|
+
else if (!entry.startsWith("."))
|
|
34
|
+
nested(sub);
|
|
35
|
+
}
|
|
36
|
+
return [...found];
|
|
37
|
+
}
|
|
38
|
+
/** The engine size the package's build recorded in `copy` (`dist-lib/engine-size.json`, written by `scripts/emit-engine-size.ts`); undefined when the file is missing or holds no whole number. */
|
|
39
|
+
function recordedSize(copy) {
|
|
40
|
+
try {
|
|
41
|
+
const { bytes } = JSON.parse(readFileSync(join(copy, "dist-lib", "engine-size.json"), "utf8"));
|
|
42
|
+
return typeof bytes === "number" && Number.isInteger(bytes) && bytes > 0 ? bytes : undefined;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** The engine in the installed copy: there, starts `\0asm`, and the size the copy's own build recorded (never a file read from the running install, which may be the copy itself). */
|
|
49
|
+
function engineFinding(project, copy) {
|
|
50
|
+
const file = join(copy, "dist-lib", "app_bg.wasm");
|
|
51
|
+
const shown = relative(project.dir, file);
|
|
52
|
+
const again = `Delete the package from node_modules and install it again with \`${project.manager} ${project.manager === "npm" ? "install" : "add"}\``;
|
|
53
|
+
if (!existsSync(file))
|
|
54
|
+
return fail("ENGINE_MISSING", `${shown} is missing`, `${again}, and stop any cleanup step that deletes .wasm files.`);
|
|
55
|
+
const bytes = readFileSync(file);
|
|
56
|
+
if (!MAGIC.every((b, i) => bytes[i] === b))
|
|
57
|
+
return fail("ENGINE_NOT_WASM", `${shown} does not start with "\\0asm"`, `${again}: the file is not the engine, which a half-finished install or a tool that rewrites .wasm files leaves behind.`);
|
|
58
|
+
const want = recordedSize(copy);
|
|
59
|
+
if (want === undefined)
|
|
60
|
+
return fail("ENGINE_UNRECORDED", `${relative(project.dir, join(copy, "dist-lib", "engine-size.json"))} is missing or unreadable, so the engine's size cannot be checked`, `${again}: the package's build records the engine's size there, and a copy without it is not a whole package.`);
|
|
61
|
+
if (bytes.length !== want)
|
|
62
|
+
return fail("ENGINE_SIZE", `${shown} is ${bytes.length} bytes; the size recorded when the package was built is ${want} bytes`, `${again}: a file of another size is cut short or comes from another version of the package.`);
|
|
63
|
+
return pass("ENGINE_OK", `${shown} starts with "\\0asm" and is ${want} bytes, the size recorded when the package was built`);
|
|
64
|
+
}
|
|
65
|
+
/** A Next app serves the engine from its own copy, `public/app_bg.wasm` (`init` writes it and never overwrites it): there, starts `\0asm`, and the size the installed package recorded, so a copy left from an older version is named. None when the package recorded no size (`engineFinding` fails that). */
|
|
66
|
+
function engineCopyFindings(project, copy) {
|
|
67
|
+
const file = join(project.dir, "public", "app_bg.wasm");
|
|
68
|
+
const shown = relative(project.dir, file);
|
|
69
|
+
const from = relative(project.dir, join(copy, "dist-lib", "app_bg.wasm"));
|
|
70
|
+
const refresh = `Copy the installed engine over it with \`cp ${from} ${shown}\``;
|
|
71
|
+
if (!existsSync(file))
|
|
72
|
+
return [fail("ENGINE_COPY_MISSING", `${shown} is missing`, `${refresh}, or run init again to write it.`)];
|
|
73
|
+
const want = recordedSize(copy);
|
|
74
|
+
if (want === undefined)
|
|
75
|
+
return [];
|
|
76
|
+
const bytes = readFileSync(file);
|
|
77
|
+
const wasm = MAGIC.every((b, i) => bytes[i] === b);
|
|
78
|
+
const source = join(copy, "dist-lib", "app_bg.wasm");
|
|
79
|
+
// The installed engine itself when it is there (a build of the same size can hold other code); `engineFinding` fails a missing or cut-short one.
|
|
80
|
+
const other = existsSync(source) && !bytes.equals(readFileSync(source));
|
|
81
|
+
if (bytes.length !== want || !wasm || other) {
|
|
82
|
+
const how = !wasm ? `${bytes.length} bytes and does not start with "\\0asm"` : bytes.length === want ? `${want} bytes like the installed engine, but its content differs` : `${bytes.length} bytes`;
|
|
83
|
+
return [fail("ENGINE_COPY_STALE", `${shown} is ${how}; the engine in the installed package is ${want} bytes`, `${refresh}: a copy that differs comes from another version of the package, and the page would run that engine.`)];
|
|
84
|
+
}
|
|
85
|
+
return [pass("ENGINE_COPY_OK", `${shown} starts with "\\0asm" and is the same ${want} bytes as the engine in the installed package`)];
|
|
86
|
+
}
|
|
87
|
+
/** The page's Content-Security-Policy: the project's config and, with `--url`, the served page. The engine needs `'wasm-unsafe-eval'`, and `connect-src` must allow the engine, even one on the page's own origin (`'self'` matches it; checked only against a served policy and a known engine address, `engineKnown`). Every fault of a policy is reported in one run. No policy found, no finding. */
|
|
88
|
+
function cspFindings(project, probe, engineKnown) {
|
|
89
|
+
const out = [];
|
|
90
|
+
const policies = [...configPolicies(project.dir), ...(probe?.policies ?? [])];
|
|
91
|
+
const [page, engine] = probe && engineKnown ? [new URL(probe.page), new URL(probe.engine.url)] : [];
|
|
92
|
+
for (const p of policies) {
|
|
93
|
+
if (!allowsWasm(p.text))
|
|
94
|
+
out.push(fail("CSP_WASM_EVAL", `${p.source} sets a policy whose script-src (or default-src) does not allow 'wasm-unsafe-eval', so the browser refuses to compile the engine`, `Add 'wasm-unsafe-eval' to the script-src (or default-src) directive in ${p.source}.`));
|
|
95
|
+
if (page && engine && probe?.policies.includes(p) && !allowsConnect(p.text, engine, page))
|
|
96
|
+
out.push(fail("CSP_CONNECT_SRC", `${p.source} sets a policy whose connect-src (or default-src) does not allow the engine at ${engine.origin}`, engine.origin === page.origin ? `Add 'self' to the connect-src (or default-src) directive in the page's policy.` : `Add ${engine.origin} to the connect-src directive in the page's policy, or serve the engine from the page's own origin.`));
|
|
97
|
+
}
|
|
98
|
+
return policies.length > 0 && out.length === 0 ? [pass("CSP_OK", `the policy in ${[...new Set(policies.map((p) => p.source))].join(", ")} lets the engine compile${page && probe?.policies.length ? " and connect" : ""}`)] : out;
|
|
99
|
+
}
|
|
100
|
+
/** The engine at the address the page names, as served: status 200, starts `\0asm`, type `application/wasm`; in a Next app also byte-equal to the installed engine (`installed`). A page that names none has no address to check outside a Next app, whose engine `init` puts at `/app_bg.wasm`: there is nothing to fail, so the check is skipped. */
|
|
101
|
+
function servedFindings({ engine: e }, installed, next) {
|
|
102
|
+
if (!e.named && !next)
|
|
103
|
+
return [{ code: "ENGINE_URL_SKIPPED", status: "skipped", what: "not checked: the page names no .wasm address (a Vite build names its hashed engine file only in a script)", fix: "Open the page in a browser, take the address of the .wasm file from its network panel and request it: it must answer 200 as application/wasm." }];
|
|
104
|
+
const serve = `Serve the engine file at ${e.url}`;
|
|
105
|
+
if (e.status === null)
|
|
106
|
+
return [fail("ENGINE_FETCH", `${e.url} could not be requested: ${e.error}`, `${serve} and check that the address is reachable.`)];
|
|
107
|
+
if (e.status !== 200)
|
|
108
|
+
return [fail("ENGINE_FETCH", `${e.url} answered with status ${e.status}`, `${serve}; a missing file or a blocked path answers with another status.`)];
|
|
109
|
+
if (!MAGIC.every((b, i) => e.bytes[i] === b))
|
|
110
|
+
return [fail("ENGINE_NOT_WASM", `${e.url} does not start with "\\0asm": it starts with "${e.bytes.subarray(0, 8).toString("latin1").replace(/[^\x20-\x7e]/g, "?")}"`, `${serve}: a fallback that answers every unknown path with the page hides a missing file.`)];
|
|
111
|
+
if (!/^application\/wasm\s*(?:;|$)/i.test(e.type))
|
|
112
|
+
return [fail("ENGINE_CONTENT_TYPE", `${e.url} answers with the type "${e.type}", not "application/wasm"`, `Serve .wasm files as application/wasm, which the browser needs to compile the engine as it downloads.`)];
|
|
113
|
+
if (installed !== undefined && existsSync(installed) && !e.bytes.equals(readFileSync(installed)))
|
|
114
|
+
return [fail("ENGINE_URL_STALE", `${e.url} serves ${e.bytes.length} bytes that differ from the engine in the installed package`, `Copy the installed engine to public/app_bg.wasm and deploy again: the site serves an engine of another version.`)];
|
|
115
|
+
return [pass("ENGINE_URL_OK", `${e.url} answers 200 with ${e.bytes.length} bytes of WebAssembly as application/wasm`)];
|
|
116
|
+
}
|
|
117
|
+
const SOURCE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|s?css|html)$/;
|
|
118
|
+
const SKIPPED_FOLDERS = new Set(["node_modules", "dist", "build", "out", "coverage"]);
|
|
119
|
+
/** Whether some source file under `dir` holds `needle`; node_modules, build output and dot folders are not source. */
|
|
120
|
+
function mentions(dir, needle) {
|
|
121
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
122
|
+
if (e.name.startsWith("."))
|
|
123
|
+
continue;
|
|
124
|
+
const path = join(dir, e.name);
|
|
125
|
+
if (e.isDirectory()) {
|
|
126
|
+
if (!SKIPPED_FOLDERS.has(e.name) && mentions(path, needle))
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
else if (e.isFile() && SOURCE.test(e.name) && statSync(path).size < 1e6 && readFileSync(path, "utf8").includes(needle))
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
/** `validate()` on the JSON in `file`: one finding for each issue. A file that cannot be read is a refusal (exit 2); one that is not JSON is a finding. */
|
|
135
|
+
function dataFindings(file) {
|
|
136
|
+
let text;
|
|
137
|
+
try {
|
|
138
|
+
text = readFileSync(file, "utf8");
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
throw new Refusal(`${file} cannot be read: ${why(e)}`);
|
|
142
|
+
}
|
|
143
|
+
let data;
|
|
144
|
+
try {
|
|
145
|
+
data = JSON.parse(text);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
return [fail("DATA_NOT_JSON", `${file} is not valid JSON: ${why(e)}`, "Fix the JSON syntax the message names, then run doctor again.")];
|
|
149
|
+
}
|
|
150
|
+
const result = validate(data);
|
|
151
|
+
if (result.ok)
|
|
152
|
+
return [pass("DATA_OK", `${file} passes validate()`)];
|
|
153
|
+
return result.issues.map((i) => fail("DATA_INVALID", `${i.path || "data"}: expected ${i.expected}, got ${i.got}`, i.fix));
|
|
154
|
+
}
|
|
155
|
+
/** The findings for `project`: its package listed and installed once, the engine, a stylesheet import, the data file when given, and the GPU the browser reported (skipped when none answered). */
|
|
156
|
+
export function diagnose(project, own, c) {
|
|
157
|
+
const pkg = readJsonObject(join(project.dir, "package.json"));
|
|
158
|
+
if (!Object.hasOwn({ ...pkg.dependencies, ...pkg.devDependencies }, own.name)) {
|
|
159
|
+
return [fail("NOT_LISTED", `package.json lists no ${own.name}`, `Run \`${own.name} init\` here, or add ${own.name} with \`${project.manager} ${project.manager === "npm" ? "install" : "add"} ${own.name}\`.`)];
|
|
160
|
+
}
|
|
161
|
+
const copies = copiesOf(project.dir, own.name);
|
|
162
|
+
const version = (copy) => String(readJsonObject(join(copy, "package.json")).version);
|
|
163
|
+
const at = (copy) => `${version(copy)} at ${relative(project.dir, copy)}`;
|
|
164
|
+
const out = [];
|
|
165
|
+
if (copies.length === 0)
|
|
166
|
+
out.push(fail("NOT_INSTALLED", `package.json lists ${own.name} but node_modules holds no copy`, `Run \`${project.manager} install\` in ${project.dir}.`));
|
|
167
|
+
else {
|
|
168
|
+
out.push(pass("INSTALLED", `${own.name} ${at(copies[0])}`));
|
|
169
|
+
out.push(copies.length === 1 ? pass("ONE_COPY", "one copy is installed") : fail("MANY_COPIES", `${copies.length} copies are installed: ${copies.map(at).join(", ")}`, "Give every package that depends on it the same version range and install again, so the page loads one copy."));
|
|
170
|
+
out.push(engineFinding(project, copies[0]));
|
|
171
|
+
if (project.bundler === "next")
|
|
172
|
+
out.push(...engineCopyFindings(project, copies[0]));
|
|
173
|
+
}
|
|
174
|
+
const next = project.bundler === "next";
|
|
175
|
+
out.push(...cspFindings(project, c.probe, c.probe !== undefined && (c.probe.engine.named || next)));
|
|
176
|
+
if (c.probe)
|
|
177
|
+
out.push(...servedFindings(c.probe, next && copies.length > 0 ? join(copies[0], "dist-lib", "app_bg.wasm") : undefined, next));
|
|
178
|
+
const css = `${own.name}/gantt.css`;
|
|
179
|
+
out.push(mentions(project.dir, css) ? pass("STYLESHEET_OK", `a source file imports ${css}`) : fail("STYLESHEET_MISSING", `no source file imports ${css}`, `Add \`import "${css}";\` to the module that mounts the chart, or the chart draws unstyled.`));
|
|
180
|
+
if (c.data !== undefined)
|
|
181
|
+
out.push(...dataFindings(c.data));
|
|
182
|
+
out.push(gpuFinding(c.gpu));
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
/** The graphics path a browser gave the chart: WebGPU or WebGL2 with the adapter's name, or neither (the fault `mount` rejects with). With no browser answer, the check is skipped and says why: a Node process has no WebGPU or WebGL2 to ask. */
|
|
186
|
+
function gpuFinding(gpu) {
|
|
187
|
+
const none = codeFor("GPU_NONE");
|
|
188
|
+
if (gpu && "api" in gpu) {
|
|
189
|
+
if (gpu.api === "webgpu")
|
|
190
|
+
return pass("GPU_WEBGPU", `the browser has a WebGPU adapter (${gpu.adapter}), so the chart draws with WebGPU`);
|
|
191
|
+
if (gpu.api === "webgl2")
|
|
192
|
+
return pass("GPU_WEBGL2", `the browser has no WebGPU adapter but has WebGL2 (${gpu.adapter}), so the chart draws with WebGL2`);
|
|
193
|
+
return fail("GPU_NONE", `the browser has neither WebGPU nor WebGL2, so \`mount\` rejects with ${none}`, fixFor(none));
|
|
194
|
+
}
|
|
195
|
+
return { code: "GPU_SKIPPED", status: "skipped", what: `not checked: ${gpu ? gpu.unavailable : "no browser was asked"}; a Node process has no WebGPU or WebGL2 itself, and \`mount\` reports ${none} when the browser has neither`, fix: `Install Chrome, Edge or Chromium (or name one with ${BROWSER_VARIABLE}) and run doctor again, or open the page in a browser and read the error \`mount\` rejects with.` };
|
|
196
|
+
}
|
|
197
|
+
/** The findings as lines a person reads. */
|
|
198
|
+
export function describeFindings(findings, where) {
|
|
199
|
+
const mark = { pass: "ok ", fail: "FAIL", skipped: "skip" };
|
|
200
|
+
const lines = [`doctor in ${where}`];
|
|
201
|
+
for (const f of findings)
|
|
202
|
+
lines.push(` ${mark[f.status]} ${f.code}: ${f.what}`, ...(f.fix ? [` fix: ${f.fix}`] : []));
|
|
203
|
+
const n = findings.filter((f) => f.status === "fail").length;
|
|
204
|
+
return [...lines, n === 0 ? "No problems found." : `${n} problem${n === 1 ? "" : "s"} found.`, ""].join("\n");
|
|
205
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// What `doctor` asks a real browser about the GPU: it opens a page of its own, headless, in a Chrome, Edge or Chromium found on this machine, and reads the answer the page writes.
|
|
2
|
+
// Only this file starts a browser; `doctor.ts` turns what it returns into a finding.
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { accessSync, constants, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { delimiter, join } from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { PRODUCT_NAME } from "../product.js";
|
|
10
|
+
/** The environment variable that names the browser to ask, for a machine where it is not on the path. */
|
|
11
|
+
export const BROWSER_VARIABLE = `${PRODUCT_NAME.toUpperCase()}_BROWSER`;
|
|
12
|
+
const NAMES = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"];
|
|
13
|
+
const APPS = ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"];
|
|
14
|
+
const TIMEOUT_MS = 30_000;
|
|
15
|
+
/** The page: it asks as `mount` does, and writes the answer into its title, which `--dump-dom` prints. A file address is a secure context, where `navigator.gpu` exists. */
|
|
16
|
+
const PAGE = `<!doctype html><title></title><script>
|
|
17
|
+
(async () => {
|
|
18
|
+
const adapter = await navigator.gpu?.requestAdapter().catch(() => null);
|
|
19
|
+
if (adapter) return { api: "webgpu", adapter: [adapter.info.vendor, adapter.info.architecture, adapter.info.description].filter(Boolean).join(" ") || "unnamed adapter" };
|
|
20
|
+
const gl = document.createElement("canvas").getContext("webgl2");
|
|
21
|
+
if (!gl) return { api: "none", adapter: "" };
|
|
22
|
+
const info = gl.getExtension("WEBGL_debug_renderer_info");
|
|
23
|
+
return { api: "webgl2", adapter: String(gl.getParameter(info ? info.UNMASKED_RENDERER_WEBGL : gl.RENDERER)) };
|
|
24
|
+
})().then((answer) => (document.title = "gpu=" + encodeURIComponent(JSON.stringify(answer))));
|
|
25
|
+
</script>`;
|
|
26
|
+
const exec = promisify(execFile);
|
|
27
|
+
const why = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").slice(0, 200);
|
|
28
|
+
/** The browser to ask: the one the environment variable names, else the first of the usual names on the path, else the usual place of an app on a Mac. */
|
|
29
|
+
function findBrowser(env) {
|
|
30
|
+
if (env[BROWSER_VARIABLE])
|
|
31
|
+
return env[BROWSER_VARIABLE];
|
|
32
|
+
const runnable = (file) => {
|
|
33
|
+
try {
|
|
34
|
+
accessSync(file, constants.X_OK);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const onPath = (env.PATH ?? "").split(delimiter).filter(Boolean).flatMap((d) => NAMES.map((n) => join(d, n)));
|
|
42
|
+
return [...onPath, ...APPS].find(runnable);
|
|
43
|
+
}
|
|
44
|
+
/** Opens the page in the browser `env` leads to and returns what it said; no browser, one that fails, or one that prints no report is `unavailable`, with the reason. */
|
|
45
|
+
export async function askBrowser(env = process.env) {
|
|
46
|
+
const browser = findBrowser(env);
|
|
47
|
+
if (!browser)
|
|
48
|
+
return { unavailable: `no Chrome, Edge or Chromium was found on this machine (name one with ${BROWSER_VARIABLE})` };
|
|
49
|
+
const dir = mkdtempSync(join(tmpdir(), "doctor-gpu-"));
|
|
50
|
+
try {
|
|
51
|
+
const page = join(dir, "gpu.html");
|
|
52
|
+
writeFileSync(page, PAGE);
|
|
53
|
+
const { stdout } = await exec(browser, ["--headless=new", "--no-first-run", `--user-data-dir=${join(dir, "profile")}`, "--virtual-time-budget=10000", "--dump-dom", pathToFileURL(page).href], { timeout: TIMEOUT_MS, maxBuffer: 5e6 });
|
|
54
|
+
const report = /<title>gpu=([^<]*)<\/title>/.exec(stdout)?.[1];
|
|
55
|
+
const got = report === undefined ? undefined : JSON.parse(decodeURIComponent(report));
|
|
56
|
+
if (got?.api !== "webgpu" && got?.api !== "webgl2" && got?.api !== "none")
|
|
57
|
+
return { unavailable: `${browser} ran the page but sent no GPU report` };
|
|
58
|
+
return { api: got.api, adapter: String(got.adapter ?? "") };
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
return { unavailable: `${browser} did not answer: ${why(e)}` };
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
rmSync(dir, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// `init`: plans how to add the chart to a project (`planInit`, also `--dry-run`), then does it (`applyInit`): install the package, write the chart module.
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join, posix, relative } from "node:path";
|
|
5
|
+
import { Refusal } from "./detect.js";
|
|
6
|
+
import { copiesOf } from "./doctor.js";
|
|
7
|
+
import { chartModule } from "./template.js";
|
|
8
|
+
/** Why `init` cannot run in `project`, or undefined. */
|
|
9
|
+
export function refusal(project, o) {
|
|
10
|
+
if (project.workspace && !o.explicitDir)
|
|
11
|
+
return `${project.dir} is a workspace root; pass --dir <the package to add it to>`;
|
|
12
|
+
if (project.bundler === undefined && !o.plain)
|
|
13
|
+
return `init supports Vite and found ${project.otherBundler ?? "no bundler"} instead; pass --plain to write a chart module that needs no bundler`;
|
|
14
|
+
if (project.bundler === "next" && project.routerRoot === undefined)
|
|
15
|
+
return "this is a Next.js project with neither an app/ nor a pages/ folder (looked in the project folder and src/)";
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
function newFiles(project, own, plain) {
|
|
19
|
+
const kind = project.framework === "none" || plain ? "plain" : "react";
|
|
20
|
+
const ext = project.typescript ? (kind === "plain" ? "ts" : "tsx") : kind === "plain" ? "js" : "jsx";
|
|
21
|
+
const file = `${own.name}-chart.${ext}`;
|
|
22
|
+
const text = chartModule(kind, project.typescript, own.name, project.bundler === "next");
|
|
23
|
+
if (project.bundler === "next")
|
|
24
|
+
return [{ path: posix.join(project.routerRoot ?? "", "components", file), text }, { path: "public/app_bg.wasm", text: undefined }];
|
|
25
|
+
return [{ path: existsSync(join(project.dir, "src")) ? posix.join("src", file) : file, text }];
|
|
26
|
+
}
|
|
27
|
+
const installCommand = (project, own, o) => [project.manager, [project.manager === "npm" ? "install" : "add", o.spec ?? `${own.name}@${own.version}`]];
|
|
28
|
+
/** The plan for `project` and the files it writes, or the sentence that says why it cannot be made. */
|
|
29
|
+
export function planInit(project, own, o) {
|
|
30
|
+
const why = refusal(project, o);
|
|
31
|
+
if (why !== undefined)
|
|
32
|
+
return { error: why };
|
|
33
|
+
const outputs = newFiles(project, own, o.plain);
|
|
34
|
+
const taken = outputs.filter((f) => existsSync(join(project.dir, f.path)));
|
|
35
|
+
// The engine copy (no text) is left as it is whatever it holds: `doctor` names a copy that is out of date.
|
|
36
|
+
const same = (f) => f.text === undefined || readFileSync(join(project.dir, f.path), "utf8") === f.text;
|
|
37
|
+
const blocking = taken.find((f) => !same(f));
|
|
38
|
+
if (blocking !== undefined)
|
|
39
|
+
return { error: `${blocking.path} already exists, and init never overwrites a file` };
|
|
40
|
+
const [cmd, args] = installCommand(project, own, o);
|
|
41
|
+
// Done when every file is there; a file that is missing from a partly set up project is still written.
|
|
42
|
+
const done = taken.length === outputs.length;
|
|
43
|
+
const files = (done ? outputs : outputs.filter((f) => !taken.includes(f))).map((f) => f.path);
|
|
44
|
+
const plan = { dir: project.dir, manager: project.manager, framework: o.plain ? "none" : project.framework, bundler: project.bundler ?? "none", install: [cmd, ...args].join(" "), files, done, dryRun: o.dryRun };
|
|
45
|
+
return { plan, outputs };
|
|
46
|
+
}
|
|
47
|
+
/** The engine file Next serves from `public/`: the installed package's copy, or a refusal when the package is not there to copy from. */
|
|
48
|
+
function engineBytes(project, own, installed) {
|
|
49
|
+
const copy = copiesOf(project.dir, own.name)[0];
|
|
50
|
+
const file = copy === undefined ? undefined : join(copy, "dist-lib", "app_bg.wasm");
|
|
51
|
+
if (file === undefined || !existsSync(file))
|
|
52
|
+
throw new Refusal(`${own.name}/dist-lib/app_bg.wasm is not in node_modules, so there is no engine to copy to public/; ${installed ? "the package was installed and " : ""}no file was written`);
|
|
53
|
+
return readFileSync(file);
|
|
54
|
+
}
|
|
55
|
+
/** Installs the package (unless `o.install` is false), then writes each file; a failed install writes no file. Returns the lines to print. */
|
|
56
|
+
export function applyInit(project, own, o, plan, outputs) {
|
|
57
|
+
if (plan.done)
|
|
58
|
+
return [`already set up, nothing changed: ${plan.files.join(", ")} is already here`];
|
|
59
|
+
const lines = [];
|
|
60
|
+
if (o.install) {
|
|
61
|
+
const [cmd, args] = installCommand(project, own, o);
|
|
62
|
+
const ran = spawnSync(cmd, args, { cwd: project.dir, encoding: "utf8", shell: process.platform === "win32" });
|
|
63
|
+
if (ran.error || ran.status !== 0)
|
|
64
|
+
throw new Refusal(`${plan.install} failed${ran.error ? `: ${ran.error.message}` : ` (exit ${ran.status})`}; init wrote no files\n${(ran.stderr ?? "").trim()}`.trim());
|
|
65
|
+
lines.push(` ran: ${plan.install}`);
|
|
66
|
+
}
|
|
67
|
+
else
|
|
68
|
+
lines.push(" install skipped (--no-install)");
|
|
69
|
+
const todo = outputs.filter((f) => plan.files.includes(f.path));
|
|
70
|
+
const bodies = todo.map((f) => f.text ?? engineBytes(project, own, o.install)); // read first: a missing engine writes no file
|
|
71
|
+
const wrote = [];
|
|
72
|
+
for (const [i, f] of todo.entries()) {
|
|
73
|
+
try {
|
|
74
|
+
mkdirSync(dirname(join(project.dir, f.path)), { recursive: true });
|
|
75
|
+
writeFileSync(join(project.dir, f.path), bodies[i], { flag: "wx" });
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
const why = (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ");
|
|
79
|
+
throw new Refusal(`${f.path} cannot be written (${why}); ${o.install ? "the package was installed and " : ""}${wrote.length === 0 ? "no file was written" : `only ${wrote.join(", ")} was written`}`);
|
|
80
|
+
}
|
|
81
|
+
wrote.push(f.path);
|
|
82
|
+
lines.push(` wrote: ${f.path}`);
|
|
83
|
+
}
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
/** What to add to a page and run next, as lines. */
|
|
87
|
+
export function nextSteps(plan, outputs) {
|
|
88
|
+
const module = `${plan.bundler === "next" ? "../components" : "."}/${posix.basename(outputs[0].path).replace(/\.[jt]sx?$/, "")}`; // a Next page sits in app/ or pages/, one folder below components/
|
|
89
|
+
const line = /\.[jt]sx$/.test(outputs[0].path) ? `import Chart from "${module}"; // then render <Chart />` : `import { mountChart } from "${module}"; // then mountChart(<a div with the class "gantt" and a height>)`;
|
|
90
|
+
return ["Add this line to a page:", ` ${line}`, `Then run: ${plan.manager} run dev`];
|
|
91
|
+
}
|
|
92
|
+
/** The plan as lines a person reads (`--dry-run`). */
|
|
93
|
+
export function describe(plan, cwd) {
|
|
94
|
+
const where = relative(cwd, plan.dir) || ".";
|
|
95
|
+
return [
|
|
96
|
+
`init (dry run) in ${where}`,
|
|
97
|
+
` package manager: ${plan.manager}`,
|
|
98
|
+
` framework: ${plan.framework} (${plan.bundler})`,
|
|
99
|
+
...(plan.done ? [" already set up: nothing to install or write"] : [` would run: ${plan.install}`, ...plan.files.map((f) => ` would write: ${f}`)]),
|
|
100
|
+
"Nothing was written (--dry-run).",
|
|
101
|
+
"",
|
|
102
|
+
].join("\n");
|
|
103
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `bin` entry (built to dist-lib/cli/main.js by tsconfig.cli.json): runs the command line and exits with its code. See run.ts.
|
|
3
|
+
import { runAsync } from "./run.js";
|
|
4
|
+
const result = await runAsync(process.argv.slice(2), process.cwd());
|
|
5
|
+
process.stdout.write(result.out);
|
|
6
|
+
process.stderr.write(result.err);
|
|
7
|
+
process.exitCode = result.code;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// What `doctor --url <page>` reads from the network: the page's policy, and the engine at the address the page names (or `/app_bg.wasm`, where `init` puts it in a Next app).
|
|
2
|
+
// Only this file touches the network; `doctor.ts` turns what it returns into findings.
|
|
3
|
+
import { Refusal } from "./detect.js";
|
|
4
|
+
import { metaPolicies } from "./csp.js";
|
|
5
|
+
const TIMEOUT_MS = 15_000;
|
|
6
|
+
/** A `.wasm` address the page's HTML names, such as a preload link; the first one wins. */
|
|
7
|
+
const WASM_REF = /["'(=\s]([^"'()\s<>=]+\.wasm)(?:[?#][^"'()\s<>]*)?(?=["')\s>])/;
|
|
8
|
+
/** The error's message with its cause's: `fetch failed` alone does not say refused, not found or timed out. */
|
|
9
|
+
const why = (e) => (e instanceof Error ? `${e.message}${e.cause instanceof Error ? ` (${e.cause.message})` : ""}` : String(e)).replace(/\s+/g, " ");
|
|
10
|
+
/** Fetches `page` and its engine. A page that cannot be fetched is a refusal (exit 2); an engine that cannot be is an answer with no status, which `doctor` reports as a finding. */
|
|
11
|
+
export async function probePage(page) {
|
|
12
|
+
let res;
|
|
13
|
+
let html;
|
|
14
|
+
try {
|
|
15
|
+
res = await fetch(page, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
16
|
+
html = await res.text();
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
throw new Refusal(`${page} could not be fetched: ${why(e)}`);
|
|
20
|
+
}
|
|
21
|
+
if (res.status !== 200)
|
|
22
|
+
throw new Refusal(`${page} answered with status ${res.status}, not 200; pass the address of the page that mounts the chart`);
|
|
23
|
+
const source = `the response of ${page}`;
|
|
24
|
+
const header = (res.headers.get("content-security-policy") ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
25
|
+
const ref = WASM_REF.exec(html)?.[1];
|
|
26
|
+
const url = new URL(ref ?? "/app_bg.wasm", res.url).href;
|
|
27
|
+
const policies = [...header.map((text) => ({ source, text })), ...metaPolicies(html, source)];
|
|
28
|
+
try {
|
|
29
|
+
const got = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
30
|
+
return { page: res.url, policies, engine: { url, status: got.status, type: got.headers.get("content-type") ?? "", bytes: Buffer.from(await got.arrayBuffer()), error: "", named: ref !== undefined } };
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
return { page: res.url, policies, engine: { url, status: null, type: "", bytes: Buffer.alloc(0), error: why(e), named: ref !== undefined } };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The command line: arguments in, exit code and the two streams out. `main.ts` only prints them and exits, so tests call this.
|
|
2
|
+
// exit 0: done, the plan printed, or `doctor` found nothing wrong. 1: `doctor` found problems. 2: could not run (bad arguments, a refusal).
|
|
3
|
+
import { relative, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { PRODUCT_NAME } from "../product.js";
|
|
6
|
+
import { parseArgs } from "./args.js";
|
|
7
|
+
import { detect, findProjectDir, readJsonObject, Refusal } from "./detect.js";
|
|
8
|
+
import { describeFindings, diagnose } from "./doctor.js";
|
|
9
|
+
import { applyInit, describe, nextSteps, planInit } from "./init.js";
|
|
10
|
+
import { askBrowser, BROWSER_VARIABLE } from "./gpu.js";
|
|
11
|
+
import { probePage } from "./probe.js";
|
|
12
|
+
/** This package's own name and version: `../../package.json` is the package's from `src/cli/` and from `dist-lib/cli/` alike. */
|
|
13
|
+
function own() {
|
|
14
|
+
const { name, version } = readJsonObject(fileURLToPath(new URL("../../package.json", import.meta.url)));
|
|
15
|
+
return { name: String(name), version: String(version) };
|
|
16
|
+
}
|
|
17
|
+
const usage = (name) => `${PRODUCT_NAME} command line\n\nusage: ${name} init [--dry-run] [--dir <folder>] [--spec <package spec>] [--no-install] [--plain] [--json]\n ${name} doctor [--dir <folder>] [--data <file>] [--url <page>] [--json]\n\n init install the package into a Vite or Next.js project and write the chart module (src/<name>-chart; for Next, components/<name>-chart and the engine file public/app_bg.wasm);\n --dry-run only prints the plan;\n --plain writes the module that needs no framework; a second run changes nothing\n doctor check an installed chart and say how to fix each problem: the package listed and installed once, the engine file (in Next, its copy in public/), a stylesheet import, the page's Content-Security-Policy in the project's config (it must allow 'wasm-unsafe-eval');\n --data checks a task data file; --url fetches the served page and checks its policy and that its engine address (the .wasm it names; in a Next app, else /app_bg.wasm) answers with the engine;\n it writes nothing in the project, and asks a Chrome, Edge or Chromium on this machine (${BROWSER_VARIABLE} names one) whether the chart gets WebGPU or WebGL2\n\nexit codes: 0 done, 1 problems found, 2 could not run\n`;
|
|
18
|
+
const refuse = (err) => ({ code: 2, out: "", err: `${err}\n` });
|
|
19
|
+
/** Runs the command line `argv` (without `node` and the script) from the folder `cwd`; a file it cannot read is exit 2 and one sentence. `doctor --url` needs the page read first, and `doctor` the browser asked about the GPU: `runAsync` does both, and passes them as `probe` and `gpu`. */
|
|
20
|
+
export function run(argv, cwd, probe, gpu) {
|
|
21
|
+
try {
|
|
22
|
+
return runCommand(argv, cwd, probe, gpu);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
if (e instanceof Refusal)
|
|
26
|
+
return refuse(e.message);
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** `run`, reading the page first when `doctor` is given `--url` and asking a browser (`askGpu`) about the GPU; the entry `main.ts` calls. A page it cannot fetch is exit 2. */
|
|
31
|
+
export async function runAsync(argv, cwd, askGpu = askBrowser) {
|
|
32
|
+
const args = parseArgs(argv);
|
|
33
|
+
if ("error" in args || args.command !== "doctor" || args.help || args.version)
|
|
34
|
+
return run(argv, cwd);
|
|
35
|
+
try {
|
|
36
|
+
const probe = args.url === undefined ? undefined : await probePage(args.url);
|
|
37
|
+
return run(argv, cwd, probe, await askGpu());
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
if (e instanceof Refusal)
|
|
41
|
+
return refuse(e.message);
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function runCommand(argv, cwd, probe, gpu) {
|
|
46
|
+
const me = own();
|
|
47
|
+
const args = parseArgs(argv);
|
|
48
|
+
if ("error" in args)
|
|
49
|
+
return refuse(`${args.error}\n\n${usage(me.name)}`);
|
|
50
|
+
if (args.version)
|
|
51
|
+
return { code: 0, out: `${me.version}\n`, err: "" };
|
|
52
|
+
if (args.help)
|
|
53
|
+
return { code: 0, out: usage(me.name), err: "" };
|
|
54
|
+
if (args.command !== "init" && args.command !== "doctor")
|
|
55
|
+
return refuse(`${args.command === undefined ? "no command" : `unknown command ${args.command}`}\n\n${usage(me.name)}`);
|
|
56
|
+
const dir = findProjectDir(args.dir === undefined ? cwd : resolve(cwd, args.dir), args.dir !== undefined);
|
|
57
|
+
if (dir === undefined)
|
|
58
|
+
return refuse("no package.json here or in a folder above it; pass --dir <project folder>");
|
|
59
|
+
if (args.url !== undefined && args.command !== "doctor")
|
|
60
|
+
return refuse(`--url is for doctor\n\n${usage(me.name)}`);
|
|
61
|
+
if (args.url !== undefined && probe === undefined)
|
|
62
|
+
throw new Error("run cannot read a page: call runAsync for doctor --url");
|
|
63
|
+
const project = detect(dir);
|
|
64
|
+
if (args.command === "doctor") {
|
|
65
|
+
if (project.workspace && args.dir === undefined)
|
|
66
|
+
return refuse(`${dir} is a workspace root; pass --dir <the package to check>`);
|
|
67
|
+
let findings;
|
|
68
|
+
try {
|
|
69
|
+
findings = diagnose(project, me, { data: args.data === undefined ? undefined : resolve(cwd, args.data), probe, gpu });
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
// A folder or file the check must read but cannot (permission, a vanished file) means it could not run: exit 2, never the exit 1 of a finding, and never a pass.
|
|
73
|
+
const fs = e;
|
|
74
|
+
if (e instanceof Error && typeof fs.code === "string" && typeof fs.syscall === "string")
|
|
75
|
+
throw new Refusal(`doctor could not read ${fs.path ?? "a file"} (${fs.code}); fix its permissions or remove it, then run doctor again`);
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
const code = findings.some((f) => f.status === "fail") ? 1 : 0;
|
|
79
|
+
return { code, out: args.json ? `${JSON.stringify({ dir, findings }, null, 2)}\n` : describeFindings(findings, relative(cwd, dir) || "."), err: "" };
|
|
80
|
+
}
|
|
81
|
+
const options = { explicitDir: args.dir !== undefined, dryRun: args.dryRun, install: args.install, spec: args.spec, plain: args.plain };
|
|
82
|
+
const made = planInit(project, me, options);
|
|
83
|
+
if ("error" in made)
|
|
84
|
+
return refuse(made.error);
|
|
85
|
+
const { plan, outputs } = made;
|
|
86
|
+
if (args.dryRun)
|
|
87
|
+
return { code: 0, out: args.json ? `${JSON.stringify(plan, null, 2)}\n` : describe(plan, cwd), err: "" };
|
|
88
|
+
const wrote = applyInit(project, me, options, plan, outputs);
|
|
89
|
+
if (args.json)
|
|
90
|
+
return { code: 0, out: `${JSON.stringify(plan, null, 2)}\n`, err: "" };
|
|
91
|
+
return { code: 0, out: [`init in ${relative(cwd, dir) || "."}`, ...wrote, ...(plan.done ? [] : nextSteps(plan, outputs)), ""].join("\n"), err: "" };
|
|
92
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The chart module `init` writes into a project: three sample tasks, the stylesheet, and the call that removes the chart.
|
|
2
|
+
// `name` is the package's own name, read at run time (the product's name is written in one place only).
|
|
3
|
+
const SAMPLE = `const tasks = [
|
|
4
|
+
{ id: 1, name: "Design", start: "2026-11-02", duration: 3 },
|
|
5
|
+
{ id: 2, name: "Build", start: "2026-11-05", duration: 5 },
|
|
6
|
+
{ id: 3, name: "Ship", start: "2026-11-12", duration: 1 },
|
|
7
|
+
];
|
|
8
|
+
const links = [
|
|
9
|
+
{ from: 1, to: 2 },
|
|
10
|
+
{ from: 2, to: 3 },
|
|
11
|
+
];
|
|
12
|
+
`;
|
|
13
|
+
/** The module's text: `kind` is a React component or a function for any page; `ts` adds the types; `next` marks it a client component and names the engine file in public/. */
|
|
14
|
+
export function chartModule(kind, ts, name, next = false) {
|
|
15
|
+
const use = next ? `"use client";\n` : "";
|
|
16
|
+
const options = next ? `{ tasks, links, wasmUrl: "/app_bg.wasm" }` : "{ tasks, links }";
|
|
17
|
+
if (kind === "plain") {
|
|
18
|
+
return `${use}// Draws the chart in a div with the class "gantt" and a height. Call it once the page has the div; keep what it returns and call that to remove the chart.
|
|
19
|
+
import { mount } from "${name}";
|
|
20
|
+
import "${name}/gantt.css";
|
|
21
|
+
|
|
22
|
+
${SAMPLE}
|
|
23
|
+
export async function mountChart(element${ts ? ": HTMLDivElement" : ""})${ts ? ": Promise<() => void>" : ""} {
|
|
24
|
+
const chart = await mount(element, ${options});
|
|
25
|
+
return () => chart.destroy();
|
|
26
|
+
}
|
|
27
|
+
`;
|
|
28
|
+
}
|
|
29
|
+
return `${use}// The chart as a React component: <Chart /> draws it when it mounts and removes it when it unmounts.
|
|
30
|
+
import { useEffect, useRef } from "react";
|
|
31
|
+
import { mount${ts ? ", type Gantt" : ""} } from "${name}";
|
|
32
|
+
import "${name}/gantt.css";
|
|
33
|
+
|
|
34
|
+
${SAMPLE}
|
|
35
|
+
export default function Chart() {
|
|
36
|
+
const element = useRef${ts ? "<HTMLDivElement>" : ""}(null);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
let chart${ts ? ": Gantt | undefined" : ""};
|
|
39
|
+
let unmounted = false;
|
|
40
|
+
mount(element.current${ts ? " as HTMLDivElement" : ""}, ${options}).then((made) => {
|
|
41
|
+
if (unmounted) made.destroy();
|
|
42
|
+
else chart = made;
|
|
43
|
+
});
|
|
44
|
+
return () => {
|
|
45
|
+
unmounted = true;
|
|
46
|
+
chart?.destroy();
|
|
47
|
+
};
|
|
48
|
+
}, []);
|
|
49
|
+
return <div ref={element} className="gantt" style={{ width: "100%", height: 300 }} />;
|
|
50
|
+
}
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"bytes":5693596}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// What a failed start tells a person or a coding assistant (tl#1249): one stable `code` for each cause `mount` can name, and for each
|
|
2
|
+
// code one `fix`, a sentence to act on. The code is the product name in capitals and the cause (`<NAME>_ENGINE_FETCH`), read from
|
|
3
|
+
// `PRODUCT_NAME`, so a rename is that file. This file is pure: no DOM, no wasm. `mount-load.ts` probes the page and asks it what to say.
|
|
4
|
+
import { PRODUCT_NAME } from "./product.js"; // `.ts`, so the Node tier can load this file as it loads the adapters
|
|
5
|
+
/** Every cause `mount` names, with the sentence that says what to do. */
|
|
6
|
+
const FIXES = {
|
|
7
|
+
DATA_INVALID: "Correct the fields `message` lists and call `mount` again; `validate(data)` checks plain task data against the same rules without mounting.",
|
|
8
|
+
ENGINE_FETCH: "Serve the engine file (`app_bg.wasm`, the file the chart's script names with `new URL`) from the address the page asks for, with status 200; the `message` has the status.",
|
|
9
|
+
ENGINE_NOT_WASM: "The address answers with something other than the engine. Serve the file `app_bg.wasm` itself, as `application/wasm`, and make a single-page fallback route skip `.wasm` files.",
|
|
10
|
+
ENGINE_LOAD_FAILED: "The file is the engine, but it did not start. Rebuild so `app.js` and `app_bg.wasm` come from the same build, and read the browser console and the error in `cause` for the underlying error.",
|
|
11
|
+
ENGINE_CSP: "Add `'wasm-unsafe-eval'` to the page's `script-src` (or `default-src`) in its Content-Security-Policy, so the browser may compile the engine.",
|
|
12
|
+
GPU_NONE: "The browser has neither WebGPU nor WebGL2 here. Try a current Chrome, Edge, Firefox or Safari with hardware acceleration on; a headless or remote desktop session often has none.",
|
|
13
|
+
FILE_UNREADABLE: "The browser could not read the `File` given as `schedule`: it was moved, deleted or is locked. Pick the file again and call `mount` again.",
|
|
14
|
+
};
|
|
15
|
+
/** Where the codes are explained: a heading in `docs/WASM_API.md`, the page that lists every code with its `fix`. */
|
|
16
|
+
export const ERROR_DOCS = "docs/WASM_API.md#mount-error-codes";
|
|
17
|
+
/** The code for a cause. */
|
|
18
|
+
export function codeFor(cause) {
|
|
19
|
+
return `${PRODUCT_NAME.toUpperCase()}_${cause}`;
|
|
20
|
+
}
|
|
21
|
+
/** The sentence that says what to do about `code`. */
|
|
22
|
+
export function fixFor(code) {
|
|
23
|
+
return FIXES[code.slice(PRODUCT_NAME.length + 1)];
|
|
24
|
+
}
|
|
25
|
+
const MAGIC = [0x00, 0x61, 0x73, 0x6d];
|
|
26
|
+
/** The code and the one line for a failed engine load, from the probe and from what `init` threw. A pure function of both. */
|
|
27
|
+
export function engineFailure(probe, thrown) {
|
|
28
|
+
const text = thrown instanceof Error ? thrown.message : String(thrown);
|
|
29
|
+
if (probe.status === null)
|
|
30
|
+
return { code: codeFor("ENGINE_FETCH"), message: `The chart's engine file could not be requested: ${text}` };
|
|
31
|
+
if (probe.status < 200 || probe.status > 299)
|
|
32
|
+
return { code: codeFor("ENGINE_FETCH"), message: `The chart's engine file answered with status ${probe.status}.` };
|
|
33
|
+
if (!MAGIC.every((byte, i) => probe.head[i] === byte)) {
|
|
34
|
+
const shown = [...probe.head].map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : `\\x${b.toString(16).padStart(2, "0")}`)).join("");
|
|
35
|
+
return { code: codeFor("ENGINE_NOT_WASM"), message: `The chart's engine file is not WebAssembly: it starts with "${shown}", not "\\0asm".` };
|
|
36
|
+
}
|
|
37
|
+
if (/content security polic|blocked by CSP/i.test(text))
|
|
38
|
+
return { code: codeFor("ENGINE_CSP"), message: "The page's Content-Security-Policy does not let the browser compile the chart's engine." };
|
|
39
|
+
return { code: codeFor("ENGINE_LOAD_FAILED"), message: `The chart's engine file is WebAssembly, but it did not start: ${text}` };
|
|
40
|
+
}
|
|
41
|
+
/** `GPU_NONE` when the browser has neither a WebGPU adapter (`gpu`) nor WebGL2 (`webgl2`, asked only when `gpu` is false), else `undefined`. */
|
|
42
|
+
export function graphicsCode(gpu, webgl2) {
|
|
43
|
+
return gpu || webgl2() ? undefined : codeFor("GPU_NONE");
|
|
44
|
+
}
|