@moldesk/visualizer 0.0.0-stage → 0.0.5
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/dist/assets/MOLSTAR_LICENSE.txt +21 -0
- package/dist/assets/THIRD_PARTY_NOTICES.txt +5 -0
- package/dist/assets/bundle.json +18 -0
- package/dist/assets/viewer.css +114 -0
- package/dist/assets/viewer.js +7724 -0
- package/dist/bundle.d.ts +26 -0
- package/dist/bundle.js +48 -0
- package/dist/contract.d.ts +146 -0
- package/dist/contract.js +433 -0
- package/dist/contract.test.d.ts +1 -0
- package/dist/contract.test.js +78 -0
- package/dist/csv.d.ts +16 -0
- package/dist/csv.js +93 -0
- package/dist/csv.test.d.ts +1 -0
- package/dist/csv.test.js +30 -0
- package/dist/export.d.ts +23 -0
- package/dist/export.js +61 -0
- package/dist/fixtures.test.d.ts +1 -0
- package/dist/fixtures.test.js +61 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/limits.d.ts +24 -0
- package/dist/limits.js +28 -0
- package/dist/report.d.ts +53 -0
- package/dist/report.js +195 -0
- package/dist/update.d.ts +41 -0
- package/dist/update.js +147 -0
- package/dist/update.test.d.ts +1 -0
- package/dist/update.test.js +167 -0
- package/package.json +51 -3
- package/README.md +0 -3
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ContractError, validateDescription, validateReport } from "./contract.js";
|
|
3
|
+
const sha = "a".repeat(64);
|
|
4
|
+
function report(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
schemaVersion: 1,
|
|
7
|
+
id: "r1",
|
|
8
|
+
title: "T",
|
|
9
|
+
resources: [
|
|
10
|
+
{ id: "p", label: "p.pdb", format: "pdb", sha256: sha, sizeBytes: 3, encoding: "base64", data: "YWJj" },
|
|
11
|
+
{ id: "t", label: "t.csv", format: "csv", sha256: sha, sizeBytes: 3, encoding: "base64", data: "YWJj" },
|
|
12
|
+
],
|
|
13
|
+
scenes: [{ id: "s1", title: "S", structures: [{ id: "a", resourceId: "p" }] }],
|
|
14
|
+
panels: [],
|
|
15
|
+
provenance: { createdAt: "2026-10-10T00:00:00.000Z" },
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
describe("validateReport", () => {
|
|
20
|
+
it("accepts a minimal report", () => {
|
|
21
|
+
expect(validateReport(report()).scenes).toHaveLength(1);
|
|
22
|
+
});
|
|
23
|
+
it("allows chart-only reports", () => {
|
|
24
|
+
expect(validateReport(report({ scenes: [] })).scenes).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
it.each([
|
|
27
|
+
["unknown schema", { schemaVersion: 2 }],
|
|
28
|
+
["unknown top-level key", { extra: 1 }],
|
|
29
|
+
["scene referencing a data resource", { scenes: [{ id: "s", title: "S", structures: [{ id: "a", resourceId: "t" }] }] }],
|
|
30
|
+
["scene referencing a missing resource", { scenes: [{ id: "s", title: "S", structures: [{ id: "a", resourceId: "zz" }] }] }],
|
|
31
|
+
["duplicate scene ids", { scenes: [
|
|
32
|
+
{ id: "s", title: "S", structures: [{ id: "a", resourceId: "p" }] },
|
|
33
|
+
{ id: "s", title: "S", structures: [{ id: "a", resourceId: "p" }] },
|
|
34
|
+
] }],
|
|
35
|
+
["bad transform length", { scenes: [{ id: "s", title: "S", structures: [{ id: "a", resourceId: "p", transform: [1, 2] }] }] }],
|
|
36
|
+
["non-finite transform", { scenes: [{ id: "s", title: "S", structures: [{ id: "a", resourceId: "p", transform: Array(16).fill(Infinity) }] }] }],
|
|
37
|
+
["css injection color", { scenes: [{ id: "s", title: "S", structures: [{ id: "a", resourceId: "p" }],
|
|
38
|
+
components: [{ id: "c", structureId: "a", selection: { kind: "all" }, color: "red;background:url(x)" }] }] }],
|
|
39
|
+
["table on a molecular resource", { panels: [{ id: "x", kind: "table", dataset: { resourceId: "p" }, columns: [] }] }],
|
|
40
|
+
["panel referencing unknown scene", { panels: [{ id: "x", kind: "structure", sceneIds: ["nope"] }] }],
|
|
41
|
+
["hash mismatch in size", { resources: [{ id: "p", label: "p", format: "pdb", sha256: sha, sizeBytes: 99, encoding: "base64", data: "YWJj" }] }],
|
|
42
|
+
["bad sha", { resources: [{ id: "p", label: "p", format: "pdb", sha256: "xyz", sizeBytes: 3, encoding: "base64", data: "YWJj" }] }],
|
|
43
|
+
])("rejects %s", (_name, overrides) => {
|
|
44
|
+
expect(() => validateReport(report(overrides))).toThrow(ContractError);
|
|
45
|
+
});
|
|
46
|
+
it("rejects __proto__ keys", () => {
|
|
47
|
+
const evil = JSON.parse('{"schemaVersion":1,"__proto__":{"x":1}}');
|
|
48
|
+
expect(() => validateReport(evil)).toThrow(ContractError);
|
|
49
|
+
});
|
|
50
|
+
it("rejects a __proto__ rowsKey", () => {
|
|
51
|
+
expect(() => validateReport(report({
|
|
52
|
+
resources: [
|
|
53
|
+
{ id: "p", label: "p", format: "pdb", sha256: sha, sizeBytes: 3, encoding: "base64", data: "YWJj" },
|
|
54
|
+
{ id: "j", label: "j", format: "json", sha256: sha, sizeBytes: 3, encoding: "base64", data: "YWJj" },
|
|
55
|
+
],
|
|
56
|
+
panels: [{ id: "x", kind: "table", dataset: { resourceId: "j", rowsKey: "__proto__" }, columns: [] }],
|
|
57
|
+
}))).toThrow(ContractError);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe("validateDescription", () => {
|
|
61
|
+
const base = {
|
|
62
|
+
title: "T",
|
|
63
|
+
resources: [{ id: "p", source: { kind: "output", path: "/run/output/a.cif" }, format: "mmcif", label: "a" }],
|
|
64
|
+
scenes: [{ id: "s", title: "S", structures: [{ id: "a", resourceId: "p" }] }],
|
|
65
|
+
panels: [],
|
|
66
|
+
};
|
|
67
|
+
it("accepts a description", () => {
|
|
68
|
+
expect(validateDescription(base).resources).toHaveLength(1);
|
|
69
|
+
});
|
|
70
|
+
it("rejects URL sources", () => {
|
|
71
|
+
const bad = { ...base, resources: [{ ...base.resources[0], source: { kind: "output", path: "https://x/a.cif" } }] };
|
|
72
|
+
expect(() => validateDescription(bad)).toThrow(/URLs are not allowed/);
|
|
73
|
+
});
|
|
74
|
+
it("rejects duplicate resource ids", () => {
|
|
75
|
+
const bad = { ...base, resources: [base.resources[0], base.resources[0]] };
|
|
76
|
+
expect(() => validateDescription(bad)).toThrow(/duplicate id/);
|
|
77
|
+
});
|
|
78
|
+
});
|
package/dist/csv.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface CsvTable {
|
|
2
|
+
header: string[];
|
|
3
|
+
rows: string[][];
|
|
4
|
+
}
|
|
5
|
+
export interface CsvOptions {
|
|
6
|
+
maxRows?: number;
|
|
7
|
+
maxCells?: number;
|
|
8
|
+
maxCellLength?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare class CsvError extends Error {
|
|
11
|
+
readonly code = "INVALID_CSV";
|
|
12
|
+
constructor(message: string);
|
|
13
|
+
}
|
|
14
|
+
export declare function parseCsv(text: string, options?: CsvOptions): CsvTable;
|
|
15
|
+
/** Rows as objects keyed by header; null-prototype so header names cannot hit Object.prototype. */
|
|
16
|
+
export declare function csvRecords(table: CsvTable): Array<Record<string, string>>;
|
package/dist/csv.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Bounded RFC 4180-style CSV parser. Browser-neutral: no Node or DOM imports,
|
|
2
|
+
// so adapters and the viewer can share it. Cells are always returned as strings.
|
|
3
|
+
import { REPORT_LIMITS } from "./limits.js";
|
|
4
|
+
export class CsvError extends Error {
|
|
5
|
+
code = "INVALID_CSV";
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "CsvError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function parseCsv(text, options = {}) {
|
|
12
|
+
const maxRows = options.maxRows ?? REPORT_LIMITS.maxTableRows;
|
|
13
|
+
const maxCells = options.maxCells ?? REPORT_LIMITS.maxTableCells;
|
|
14
|
+
const maxCellLength = options.maxCellLength ?? REPORT_LIMITS.maxCellLength;
|
|
15
|
+
const records = [];
|
|
16
|
+
let row = [];
|
|
17
|
+
let cell = "";
|
|
18
|
+
let inQuotes = false;
|
|
19
|
+
let cells = 0;
|
|
20
|
+
let i = text.charCodeAt(0) === 0xfeff ? 1 : 0; // strip BOM
|
|
21
|
+
const endCell = () => {
|
|
22
|
+
row.push(cell);
|
|
23
|
+
cell = "";
|
|
24
|
+
if (++cells > maxCells)
|
|
25
|
+
throw new CsvError(`more than ${maxCells} cells`);
|
|
26
|
+
};
|
|
27
|
+
const endRow = () => {
|
|
28
|
+
endCell();
|
|
29
|
+
// Skip fully blank lines (a single empty cell).
|
|
30
|
+
if (!(row.length === 1 && row[0] === "")) {
|
|
31
|
+
records.push(row);
|
|
32
|
+
if (records.length > maxRows + 1)
|
|
33
|
+
throw new CsvError(`more than ${maxRows} rows`);
|
|
34
|
+
}
|
|
35
|
+
row = [];
|
|
36
|
+
};
|
|
37
|
+
for (; i < text.length; i++) {
|
|
38
|
+
const ch = text[i];
|
|
39
|
+
if (inQuotes) {
|
|
40
|
+
if (ch === '"') {
|
|
41
|
+
if (text[i + 1] === '"') {
|
|
42
|
+
cell += '"';
|
|
43
|
+
i++;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
inQuotes = false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
cell += ch;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else if (ch === '"' && cell === "") {
|
|
54
|
+
inQuotes = true;
|
|
55
|
+
}
|
|
56
|
+
else if (ch === ",") {
|
|
57
|
+
endCell();
|
|
58
|
+
}
|
|
59
|
+
else if (ch === "\n" || ch === "\r") {
|
|
60
|
+
if (ch === "\r" && text[i + 1] === "\n")
|
|
61
|
+
i++;
|
|
62
|
+
endRow();
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
cell += ch;
|
|
66
|
+
}
|
|
67
|
+
if (cell.length > maxCellLength)
|
|
68
|
+
throw new CsvError(`cell longer than ${maxCellLength} characters`);
|
|
69
|
+
}
|
|
70
|
+
if (inQuotes)
|
|
71
|
+
throw new CsvError("unterminated quoted cell");
|
|
72
|
+
if (cell !== "" || row.length > 0)
|
|
73
|
+
endRow();
|
|
74
|
+
const header = records.shift();
|
|
75
|
+
if (!header)
|
|
76
|
+
throw new CsvError("empty CSV");
|
|
77
|
+
for (const [n, r] of records.entries()) {
|
|
78
|
+
if (r.length !== header.length) {
|
|
79
|
+
throw new CsvError(`row ${n + 2} has ${r.length} cells, expected ${header.length}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { header, rows: records };
|
|
83
|
+
}
|
|
84
|
+
/** Rows as objects keyed by header; null-prototype so header names cannot hit Object.prototype. */
|
|
85
|
+
export function csvRecords(table) {
|
|
86
|
+
return table.rows.map((r) => {
|
|
87
|
+
const rec = Object.create(null);
|
|
88
|
+
table.header.forEach((h, k) => {
|
|
89
|
+
rec[h] = r[k];
|
|
90
|
+
});
|
|
91
|
+
return rec;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/csv.test.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { CsvError, csvRecords, parseCsv } from "./csv.js";
|
|
3
|
+
describe("parseCsv", () => {
|
|
4
|
+
it("parses quoted cells with commas, quotes and newlines", () => {
|
|
5
|
+
const t = parseCsv('design,note\r\nd1,"a, ""b""\nc"\r\nd2,plain\r\n');
|
|
6
|
+
expect(t.header).toEqual(["design", "note"]);
|
|
7
|
+
expect(t.rows).toEqual([
|
|
8
|
+
["d1", 'a, "b"\nc'],
|
|
9
|
+
["d2", "plain"],
|
|
10
|
+
]);
|
|
11
|
+
});
|
|
12
|
+
it("strips BOM and skips blank lines", () => {
|
|
13
|
+
expect(parseCsv("a,b\n\n1,2\n").rows).toEqual([["1", "2"]]);
|
|
14
|
+
});
|
|
15
|
+
it("rejects ragged rows, unterminated quotes and empty input", () => {
|
|
16
|
+
expect(() => parseCsv("a,b\n1\n")).toThrow(CsvError);
|
|
17
|
+
expect(() => parseCsv('a\n"x')).toThrow(/unterminated/);
|
|
18
|
+
expect(() => parseCsv("")).toThrow(/empty/);
|
|
19
|
+
});
|
|
20
|
+
it("enforces bounds", () => {
|
|
21
|
+
expect(() => parseCsv("a\n1\n2\n3\n", { maxRows: 2 })).toThrow(/rows/);
|
|
22
|
+
expect(() => parseCsv("a,b\n1,2\n", { maxCells: 3 })).toThrow(/cells/);
|
|
23
|
+
expect(() => parseCsv("a\nxxxxxx\n", { maxCellLength: 3 })).toThrow(/longer/);
|
|
24
|
+
});
|
|
25
|
+
it("returns null-prototype records", () => {
|
|
26
|
+
const [rec] = csvRecords(parseCsv("__proto__,b\n1,2\n"));
|
|
27
|
+
expect(Object.getPrototypeOf(rec)).toBeNull();
|
|
28
|
+
expect(rec["__proto__"]).toBe("1");
|
|
29
|
+
});
|
|
30
|
+
});
|
package/dist/export.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ViewerBundle } from "./bundle.js";
|
|
2
|
+
import { type ReportInput } from "./report.js";
|
|
3
|
+
import type { MolecularReportV1 } from "./contract.js";
|
|
4
|
+
export interface ExportedReport {
|
|
5
|
+
html: string;
|
|
6
|
+
report: MolecularReportV1;
|
|
7
|
+
sha256: string;
|
|
8
|
+
sizeBytes: number;
|
|
9
|
+
sourceSha256: string[];
|
|
10
|
+
viewerVersion: string;
|
|
11
|
+
bundleSha256: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class PublishError extends Error {
|
|
14
|
+
readonly code: "OUTPUT_EXISTS" | "REPORT_CONFLICT" | "UNSUPPORTED_REPORT" | "INVALID_REPORT";
|
|
15
|
+
constructor(code: "OUTPUT_EXISTS" | "REPORT_CONFLICT" | "UNSUPPORTED_REPORT" | "INVALID_REPORT", message: string);
|
|
16
|
+
}
|
|
17
|
+
export declare function exportReport(input: ReportInput, bundle?: ViewerBundle): ExportedReport;
|
|
18
|
+
/**
|
|
19
|
+
* Writes `content` to a new file that must not already exist. Staged in the destination
|
|
20
|
+
* directory, then published with an exclusive link (copy fallback) so a concurrent writer
|
|
21
|
+
* is never overwritten.
|
|
22
|
+
*/
|
|
23
|
+
export declare function writeFileExclusive(dest: string, content: string | Uint8Array, mode?: number): Promise<void>;
|
package/dist/export.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Trusted exporter: assembles a report HTML from verified bytes and publishes it safely.
|
|
2
|
+
import { constants, promises as fsp } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { loadBundle, sha256Hex } from "./bundle.js";
|
|
6
|
+
import { buildEnvelope, createReport, renderHtml } from "./report.js";
|
|
7
|
+
export class PublishError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = "PublishError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function exportReport(input, bundle = loadBundle()) {
|
|
16
|
+
const report = createReport(input);
|
|
17
|
+
const envelope = buildEnvelope(report, bundle);
|
|
18
|
+
const html = renderHtml(envelope, bundle, report.title);
|
|
19
|
+
return {
|
|
20
|
+
html,
|
|
21
|
+
report,
|
|
22
|
+
sha256: sha256Hex(html),
|
|
23
|
+
sizeBytes: Buffer.byteLength(html),
|
|
24
|
+
sourceSha256: report.resources.map((r) => r.sha256),
|
|
25
|
+
viewerVersion: bundle.metadata.viewerVersion,
|
|
26
|
+
bundleSha256: bundle.metadata.bundleSha256,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Writes `content` to a new file that must not already exist. Staged in the destination
|
|
31
|
+
* directory, then published with an exclusive link (copy fallback) so a concurrent writer
|
|
32
|
+
* is never overwritten.
|
|
33
|
+
*/
|
|
34
|
+
export async function writeFileExclusive(dest, content, mode = 0o644) {
|
|
35
|
+
const tmp = path.join(path.dirname(dest), `.${path.basename(dest)}.tmp-${process.pid}-${randomUUID()}`);
|
|
36
|
+
try {
|
|
37
|
+
await fsp.writeFile(tmp, content, { flag: "wx", mode });
|
|
38
|
+
try {
|
|
39
|
+
await fsp.link(tmp, dest);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const code = error.code;
|
|
43
|
+
if (code === "EEXIST")
|
|
44
|
+
throw new PublishError("OUTPUT_EXISTS", `Refusing to overwrite existing file: ${dest}`);
|
|
45
|
+
if (code !== "EPERM" && code !== "ENOTSUP" && code !== "EXDEV")
|
|
46
|
+
throw error;
|
|
47
|
+
try {
|
|
48
|
+
await fsp.copyFile(tmp, dest, constants.COPYFILE_EXCL);
|
|
49
|
+
}
|
|
50
|
+
catch (copyError) {
|
|
51
|
+
if (copyError.code === "EEXIST") {
|
|
52
|
+
throw new PublishError("OUTPUT_EXISTS", `Refusing to overwrite existing file: ${dest}`);
|
|
53
|
+
}
|
|
54
|
+
throw copyError;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await fsp.rm(tmp, { force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { computeBundleSha256, sha256Hex, TEMPLATE_VERSION } from "./bundle.js";
|
|
7
|
+
import { exportReport } from "./export.js";
|
|
8
|
+
import { decodeEnvelope, extractEnvelope } from "./report.js";
|
|
9
|
+
import { applyUpdate, planUpdate } from "./update.js";
|
|
10
|
+
const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), "../test/fixtures");
|
|
11
|
+
function bundle(tag) {
|
|
12
|
+
const js = `/* ${tag} */`;
|
|
13
|
+
const css = `/* css ${tag} */`;
|
|
14
|
+
const assets = { "viewer.js": sha256Hex(js), "viewer.css": sha256Hex(css) };
|
|
15
|
+
return {
|
|
16
|
+
js,
|
|
17
|
+
css,
|
|
18
|
+
metadata: {
|
|
19
|
+
viewerVersion: "1.0.0",
|
|
20
|
+
molstarVersion: "5.12.0",
|
|
21
|
+
templateVersion: TEMPLATE_VERSION,
|
|
22
|
+
compatible: { envelopeVersions: [1], schemaVersions: [1] },
|
|
23
|
+
assets,
|
|
24
|
+
bundleSha256: computeBundleSha256(assets),
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const FORMATS = { ".pdb": "pdb", ".cif": "mmcif", ".sdf": "sdf", ".json": "json" };
|
|
29
|
+
const files = readdirSync(fixtures).filter((f) => f.slice(f.lastIndexOf(".")) in FORMATS);
|
|
30
|
+
let dir;
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
dir = mkdtempSync(path.join(os.tmpdir(), "moldesk-fixtures-"));
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
35
|
+
describe("real structure fixtures", () => {
|
|
36
|
+
it("covers the scientific edge cases the browser smoke relies on", () => {
|
|
37
|
+
for (const f of ["1AAY.pdb", "1D3Z-3models.pdb", "1IGT-VH.pdb", "3HZ7.pdb", "HEM-only.pdb", "1CRN.cif", "1BNA.cif"]) {
|
|
38
|
+
expect(files).toContain(f);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
it.each(files)("%s: original bytes survive export, decode, and a viewer update unchanged", async (name) => {
|
|
42
|
+
const original = new Uint8Array(readFileSync(path.join(fixtures, name)));
|
|
43
|
+
const format = FORMATS[name.slice(name.lastIndexOf("."))];
|
|
44
|
+
const exported = exportReport({
|
|
45
|
+
title: name,
|
|
46
|
+
resources: [{ id: "r", label: name, format, bytes: original }],
|
|
47
|
+
scenes: format === "json" ? [] : [{ id: "s", title: name, structures: [{ id: "a", resourceId: "r" }] }],
|
|
48
|
+
panels: [],
|
|
49
|
+
provenance: {},
|
|
50
|
+
}, bundle("a"));
|
|
51
|
+
const before = decodeEnvelope(extractEnvelope(exported.html));
|
|
52
|
+
expect(before.resources[0].sha256).toBe(sha256Hex(original));
|
|
53
|
+
expect(Buffer.from(before.resources[0].data, "base64").equals(Buffer.from(original))).toBe(true);
|
|
54
|
+
const file = path.join(dir, "r.html");
|
|
55
|
+
writeFileSync(file, exported.html);
|
|
56
|
+
await applyUpdate(await planUpdate(file, { bundle: bundle("b") }));
|
|
57
|
+
const after = decodeEnvelope(extractEnvelope(readFileSync(file, "utf8")));
|
|
58
|
+
expect(after.resources).toEqual(before.resources);
|
|
59
|
+
expect(after.scenes).toEqual(before.scenes);
|
|
60
|
+
});
|
|
61
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export * from "./contract.js";
|
|
2
|
+
export { BundleError, loadBundle, sha256Hex, type BundleMetadata, type ViewerBundle } from "./bundle.js";
|
|
3
|
+
export { buildEnvelope, createReport, decodeEnvelope, extractEnvelope, renderHtml, ReportFormatError, type ReportEnvelopeV1, type ReportInput, } from "./report.js";
|
|
4
|
+
export { exportReport, PublishError, writeFileExclusive, type ExportedReport } from "./export.js";
|
|
5
|
+
export { applyUpdate, planUpdate, type UpdatePlan, type UpdateResult, type UpdateStatus } from "./update.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Node APIs. Never loads the browser bundle as code; it is only read as text.
|
|
2
|
+
export * from "./contract.js";
|
|
3
|
+
export { BundleError, loadBundle, sha256Hex } from "./bundle.js";
|
|
4
|
+
export { buildEnvelope, createReport, decodeEnvelope, extractEnvelope, renderHtml, ReportFormatError, } from "./report.js";
|
|
5
|
+
export { exportReport, PublishError, writeFileExclusive } from "./export.js";
|
|
6
|
+
export { applyUpdate, planUpdate } from "./update.js";
|
package/dist/limits.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const REPORT_LIMITS: {
|
|
2
|
+
/** Largest single source file (original bytes) embedded in a report. */
|
|
3
|
+
readonly maxResourceBytes: number;
|
|
4
|
+
/** Sum of all embedded original bytes in one report. */
|
|
5
|
+
readonly maxTotalResourceBytes: number;
|
|
6
|
+
/** Largest report HTML file the updater will read. Covers base64 overhead + viewer. */
|
|
7
|
+
readonly maxReportFileBytes: number;
|
|
8
|
+
readonly maxResources: 512;
|
|
9
|
+
readonly maxScenes: 256;
|
|
10
|
+
readonly maxStructuresPerScene: 64;
|
|
11
|
+
readonly maxComponentsPerScene: 128;
|
|
12
|
+
readonly maxPanels: 64;
|
|
13
|
+
readonly maxColumns: 64;
|
|
14
|
+
readonly maxSelectionIds: 1024;
|
|
15
|
+
readonly maxResidueRefs: 10000;
|
|
16
|
+
readonly maxIdLength: 128;
|
|
17
|
+
readonly maxLabelLength: 256;
|
|
18
|
+
readonly maxTitleLength: 512;
|
|
19
|
+
readonly maxFieldLength: 128;
|
|
20
|
+
/** Bounds for parsed tabular data (CSV rows or JSON arrays). */
|
|
21
|
+
readonly maxTableRows: 100000;
|
|
22
|
+
readonly maxTableCells: 2000000;
|
|
23
|
+
readonly maxCellLength: 4096;
|
|
24
|
+
};
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Named bounds for report contents. Shared by the Node exporter/updater and the
|
|
2
|
+
// browser viewer, so it must stay free of Node and DOM imports. Values are
|
|
3
|
+
// practical starting points; see docs/visualizer.md for measurements.
|
|
4
|
+
const MiB = 1024 * 1024;
|
|
5
|
+
export const REPORT_LIMITS = {
|
|
6
|
+
/** Largest single source file (original bytes) embedded in a report. */
|
|
7
|
+
maxResourceBytes: 64 * MiB,
|
|
8
|
+
/** Sum of all embedded original bytes in one report. */
|
|
9
|
+
maxTotalResourceBytes: 256 * MiB,
|
|
10
|
+
/** Largest report HTML file the updater will read. Covers base64 overhead + viewer. */
|
|
11
|
+
maxReportFileBytes: 400 * MiB,
|
|
12
|
+
maxResources: 512,
|
|
13
|
+
maxScenes: 256,
|
|
14
|
+
maxStructuresPerScene: 64,
|
|
15
|
+
maxComponentsPerScene: 128,
|
|
16
|
+
maxPanels: 64,
|
|
17
|
+
maxColumns: 64,
|
|
18
|
+
maxSelectionIds: 1024,
|
|
19
|
+
maxResidueRefs: 10_000,
|
|
20
|
+
maxIdLength: 128,
|
|
21
|
+
maxLabelLength: 256,
|
|
22
|
+
maxTitleLength: 512,
|
|
23
|
+
maxFieldLength: 128,
|
|
24
|
+
/** Bounds for parsed tabular data (CSV rows or JSON arrays). */
|
|
25
|
+
maxTableRows: 100_000,
|
|
26
|
+
maxTableCells: 2_000_000,
|
|
27
|
+
maxCellLength: 4096,
|
|
28
|
+
};
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ViewerBundle } from "./bundle.js";
|
|
2
|
+
import { type MolecularReportV1, type Panel, type ResourceFormat, type Scene } from "./contract.js";
|
|
3
|
+
export declare const ENVELOPE_MAGIC = "moldesk-molecular-report";
|
|
4
|
+
export declare const ENVELOPE_ELEMENT_ID = "moldesk-report";
|
|
5
|
+
export interface ReportEnvelopeV1 {
|
|
6
|
+
magic: typeof ENVELOPE_MAGIC;
|
|
7
|
+
envelopeVersion: 1;
|
|
8
|
+
payloadEncoding: "base64-json";
|
|
9
|
+
/** UTF-8 MolecularReportV1 JSON, base64. */
|
|
10
|
+
payload: string;
|
|
11
|
+
payloadSha256: string;
|
|
12
|
+
viewer: {
|
|
13
|
+
version: string;
|
|
14
|
+
molstarVersion: string;
|
|
15
|
+
bundleSha256: string;
|
|
16
|
+
templateVersion: number;
|
|
17
|
+
};
|
|
18
|
+
lastUpdatedAt?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare class ReportFormatError extends Error {
|
|
21
|
+
readonly code = "INVALID_REPORT";
|
|
22
|
+
constructor(message: string);
|
|
23
|
+
}
|
|
24
|
+
export interface ReportInput {
|
|
25
|
+
title: string;
|
|
26
|
+
resources: Array<{
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
format: ResourceFormat;
|
|
30
|
+
bytes: Uint8Array;
|
|
31
|
+
}>;
|
|
32
|
+
scenes: Scene[];
|
|
33
|
+
panels: Panel[];
|
|
34
|
+
provenance: {
|
|
35
|
+
runId?: string;
|
|
36
|
+
model?: string;
|
|
37
|
+
modelVersion?: string;
|
|
38
|
+
};
|
|
39
|
+
id?: string;
|
|
40
|
+
createdAt?: string;
|
|
41
|
+
}
|
|
42
|
+
/** Builds a validated report. Resource hashes are over the original bytes. */
|
|
43
|
+
export declare function createReport(input: ReportInput): MolecularReportV1;
|
|
44
|
+
export declare function buildEnvelope(report: MolecularReportV1, bundle: ViewerBundle, lastUpdatedAt?: string): ReportEnvelopeV1;
|
|
45
|
+
export declare function escapeHtml(text: string): string;
|
|
46
|
+
/** Restrictive policy: no network of any kind; only the two hashed inline blocks may run/style. */
|
|
47
|
+
export declare function contentSecurityPolicy(js: string, css: string): string;
|
|
48
|
+
/** Deterministic: same envelope + bundle always yields identical bytes. */
|
|
49
|
+
export declare function renderHtml(envelope: ReportEnvelopeV1, bundle: ViewerBundle, title: string): string;
|
|
50
|
+
/** Narrow exact extractor: exactly one envelope element, no HTML parsing, no script execution. */
|
|
51
|
+
export declare function extractEnvelope(html: string): ReportEnvelopeV1;
|
|
52
|
+
/** Verifies payload and every resource against its recorded digest, then validates the schema. */
|
|
53
|
+
export declare function decodeEnvelope(envelope: ReportEnvelopeV1): MolecularReportV1;
|