feat: introduce local-first SVG workbench
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { lstat, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { format } from "prettier";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourcePath = join(root, "src", "toolbox", "manifest.source.json");
|
||||
const outputPath = join(root, "public", "toolbox-app.json");
|
||||
const packagePath = join(root, "package.json");
|
||||
const applicationVersionPath = join(root, "src", "version.ts");
|
||||
const publicPath = join(root, "public");
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
|
||||
const source = JSON.parse(await readFile(sourcePath, "utf8"));
|
||||
const packageJson = JSON.parse(await readFile(packagePath, "utf8"));
|
||||
const applicationVersionSource = await readFile(applicationVersionPath, "utf8");
|
||||
const applicationVersion =
|
||||
/^export const APPLICATION_VERSION = "([^"]+)";$/mu.exec(
|
||||
applicationVersionSource,
|
||||
)?.[1];
|
||||
|
||||
if (
|
||||
source.version !== packageJson.version ||
|
||||
applicationVersion !== packageJson.version
|
||||
) {
|
||||
throw new Error(
|
||||
`Version drift: manifest ${source.version}, application ${String(applicationVersion)}, package ${packageJson.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
source.source?.repository !== "https://git.add-ideas.de/lotobo/svg-tools" ||
|
||||
source.source?.license !== "GPL-3.0-or-later"
|
||||
) {
|
||||
throw new Error("Manifest source identity is incomplete or inconsistent");
|
||||
}
|
||||
|
||||
for (const asset of source.assets ?? []) {
|
||||
if (
|
||||
typeof asset !== "string" ||
|
||||
!asset.startsWith("./") ||
|
||||
asset.includes("\\") ||
|
||||
asset.split("/").includes("..")
|
||||
) {
|
||||
throw new Error(`Unsafe manifest asset path: ${JSON.stringify(asset)}`);
|
||||
}
|
||||
const details = await lstat(join(publicPath, asset.slice(2))).catch(
|
||||
() => null,
|
||||
);
|
||||
if (!details?.isFile() || details.isSymbolicLink()) {
|
||||
throw new Error(`Manifest asset is missing or unsafe: ${asset}`);
|
||||
}
|
||||
}
|
||||
|
||||
const serialized = await format(JSON.stringify(source), {
|
||||
filepath: outputPath,
|
||||
});
|
||||
if (checkOnly) {
|
||||
const current = await readFile(outputPath, "utf8").catch(() => "");
|
||||
if (current !== serialized) {
|
||||
throw new Error(
|
||||
`${relative(root, outputPath)} is stale; run npm run manifest:generate`,
|
||||
);
|
||||
}
|
||||
console.log("Toolbox manifest is synchronized");
|
||||
} else {
|
||||
await writeFile(outputPath, serialized);
|
||||
console.log(`Generated ${relative(root, outputPath)}`);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
access,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
const input = path.join(root, "dist");
|
||||
const argument = (name, fallback) => {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : fallback;
|
||||
};
|
||||
const output = path.resolve(
|
||||
root,
|
||||
argument("--output", `release/svg-tools-${packageJson.version}.zip`),
|
||||
);
|
||||
const checksumOutput = `${output}.sha256`;
|
||||
const force = process.argv.includes("--force");
|
||||
|
||||
if (path.extname(output).toLowerCase() !== ".zip") {
|
||||
throw new Error("Release output must use a .zip extension");
|
||||
}
|
||||
if (output === path.parse(output).root || output === root) {
|
||||
throw new Error("Release output is not a safe file target");
|
||||
}
|
||||
|
||||
const exists = (file) =>
|
||||
access(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (!force && (await exists(output))) {
|
||||
throw new Error(`Release already exists (use --force): ${output}`);
|
||||
}
|
||||
if (!force && (await exists(checksumOutput))) {
|
||||
throw new Error(`Checksum already exists (use --force): ${checksumOutput}`);
|
||||
}
|
||||
|
||||
const required = [
|
||||
"index.html",
|
||||
"toolbox-app.json",
|
||||
"favicon.svg",
|
||||
"canvas-frame-controller.js",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE",
|
||||
"SOURCE.md",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
"THIRD_PARTY_LICENSES.txt",
|
||||
"LICENSES/README.md",
|
||||
"LICENSES/npm-runtime-licenses.txt",
|
||||
];
|
||||
for (const name of required) {
|
||||
const details = await lstat(path.join(input, name)).catch(() => null);
|
||||
if (!details?.isFile() || details.isSymbolicLink()) {
|
||||
throw new Error(`Release input is missing a regular file: ${name}`);
|
||||
}
|
||||
}
|
||||
const assets = await lstat(path.join(input, "assets")).catch(() => null);
|
||||
if (!assets?.isDirectory() || assets.isSymbolicLink()) {
|
||||
throw new Error("Release input is missing its assets directory");
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(input, "toolbox-app.json"), "utf8"),
|
||||
);
|
||||
if (
|
||||
manifest.id !== "de.add-ideas.svg-tools" ||
|
||||
manifest.version !== packageJson.version ||
|
||||
manifest.entry !== "./" ||
|
||||
manifest.icon !== "./favicon.svg" ||
|
||||
!manifest.assets?.includes("./canvas-frame-controller.js")
|
||||
) {
|
||||
throw new Error("Packaged Toolbox manifest identity/assets are invalid");
|
||||
}
|
||||
|
||||
async function collect(directory, prefix = "") {
|
||||
const files = [];
|
||||
for (const entry of (await readdir(directory, { withFileTypes: true })).sort(
|
||||
(left, right) =>
|
||||
left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
|
||||
)) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(`Release input contains a symbolic link: ${relative}`);
|
||||
}
|
||||
if (entry.isDirectory()) files.push(...(await collect(absolute, relative)));
|
||||
else if (entry.isFile()) files.push({ absolute, relative });
|
||||
else throw new Error(`Unsupported release entry: ${relative}`);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const files = await collect(input);
|
||||
if (files.length > 65_535) throw new Error("Release contains too many files");
|
||||
for (const { relative } of files) {
|
||||
if (
|
||||
relative.endsWith(".map") ||
|
||||
/(?:^|\/)(?:\.env(?:\.|$)|id_rsa|id_ed25519|.*\.pem$|.*\.key$)/iu.test(
|
||||
relative,
|
||||
)
|
||||
) {
|
||||
throw new Error(`Forbidden release entry: ${relative}`);
|
||||
}
|
||||
if (relative.startsWith("/") || relative.split("/").includes("..")) {
|
||||
throw new Error(`Unsafe release entry: ${relative}`);
|
||||
}
|
||||
}
|
||||
|
||||
const indexHtml = await readFile(path.join(input, "index.html"), "utf8");
|
||||
if (/\b(?:src|href)=["']\//iu.test(indexHtml)) {
|
||||
throw new Error("index.html contains a root-absolute asset reference");
|
||||
}
|
||||
|
||||
const crcTable = new Uint32Array(256);
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||
}
|
||||
crcTable[index] = value >>> 0;
|
||||
}
|
||||
function crc32(bytes) {
|
||||
let value = 0xffffffff;
|
||||
for (const byte of bytes)
|
||||
value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
|
||||
return (value ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
function header(length) {
|
||||
return Buffer.alloc(length);
|
||||
}
|
||||
|
||||
const localParts = [];
|
||||
const centralParts = [];
|
||||
let offset = 0;
|
||||
for (const file of files) {
|
||||
const source = await readFile(file.absolute);
|
||||
const compressed = deflateRawSync(source, { level: 9 });
|
||||
const name = Buffer.from(file.relative, "utf8");
|
||||
const checksum = crc32(source);
|
||||
if (
|
||||
source.byteLength > 0xffffffff ||
|
||||
compressed.byteLength > 0xffffffff ||
|
||||
offset > 0xffffffff
|
||||
) {
|
||||
throw new Error("ZIP64 releases are not supported");
|
||||
}
|
||||
|
||||
const local = header(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(0x0800, 6);
|
||||
local.writeUInt16LE(8, 8);
|
||||
local.writeUInt16LE(0, 10);
|
||||
local.writeUInt16LE(0x0021, 12);
|
||||
local.writeUInt32LE(checksum, 14);
|
||||
local.writeUInt32LE(compressed.byteLength, 18);
|
||||
local.writeUInt32LE(source.byteLength, 22);
|
||||
local.writeUInt16LE(name.byteLength, 26);
|
||||
local.writeUInt16LE(0, 28);
|
||||
localParts.push(local, name, compressed);
|
||||
|
||||
const central = header(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(0x0314, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(0x0800, 8);
|
||||
central.writeUInt16LE(8, 10);
|
||||
central.writeUInt16LE(0, 12);
|
||||
central.writeUInt16LE(0x0021, 14);
|
||||
central.writeUInt32LE(checksum, 16);
|
||||
central.writeUInt32LE(compressed.byteLength, 20);
|
||||
central.writeUInt32LE(source.byteLength, 24);
|
||||
central.writeUInt16LE(name.byteLength, 28);
|
||||
central.writeUInt16LE(0, 30);
|
||||
central.writeUInt16LE(0, 32);
|
||||
central.writeUInt16LE(0, 34);
|
||||
central.writeUInt16LE(0, 36);
|
||||
central.writeUInt32LE((0o100644 << 16) >>> 0, 38);
|
||||
central.writeUInt32LE(offset, 42);
|
||||
centralParts.push(central, name);
|
||||
offset += local.byteLength + name.byteLength + compressed.byteLength;
|
||||
}
|
||||
const centralOffset = offset;
|
||||
const centralSize = centralParts.reduce(
|
||||
(sum, part) => sum + part.byteLength,
|
||||
0,
|
||||
);
|
||||
if (centralOffset + centralSize > 0xffffffff) {
|
||||
throw new Error("ZIP64 releases are not supported");
|
||||
}
|
||||
const end = header(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(0, 4);
|
||||
end.writeUInt16LE(0, 6);
|
||||
end.writeUInt16LE(files.length, 8);
|
||||
end.writeUInt16LE(files.length, 10);
|
||||
end.writeUInt32LE(centralSize, 12);
|
||||
end.writeUInt32LE(centralOffset, 16);
|
||||
end.writeUInt16LE(0, 20);
|
||||
const archive = Buffer.concat([...localParts, ...centralParts, end]);
|
||||
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
const staging = await mkdtemp(path.join(path.dirname(output), ".svg-release-"));
|
||||
const stagedArchive = path.join(staging, path.basename(output));
|
||||
const stagedChecksum = `${stagedArchive}.sha256`;
|
||||
try {
|
||||
await writeFile(stagedArchive, archive, { flag: "wx", mode: 0o644 });
|
||||
const digest = createHash("sha256").update(archive).digest("hex");
|
||||
await writeFile(stagedChecksum, `${digest} ${path.basename(output)}\n`, {
|
||||
flag: "wx",
|
||||
mode: 0o644,
|
||||
});
|
||||
if (force) {
|
||||
await rm(output, { force: true });
|
||||
await rm(checksumOutput, { force: true });
|
||||
}
|
||||
await rename(stagedArchive, output);
|
||||
await rename(stagedChecksum, checksumOutput);
|
||||
process.stdout.write(
|
||||
`Created ${path.relative(root, output)} (${archive.byteLength} bytes, ${files.length} files)\nSHA-256 ${digest}\n`,
|
||||
);
|
||||
} finally {
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const workspace = path.dirname(root);
|
||||
const portal = path.resolve(
|
||||
process.env.TOOLBOX_PORTAL_DIR ?? path.join(workspace, "toolbox-portal"),
|
||||
);
|
||||
const appPackage = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
const portalPackage = JSON.parse(
|
||||
await readFile(path.join(portal, "package.json"), "utf8"),
|
||||
);
|
||||
const artifact = path.join(
|
||||
root,
|
||||
"release",
|
||||
`svg-tools-${appPackage.version}.zip`,
|
||||
);
|
||||
|
||||
async function run(command, arguments_, cwd) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, arguments_, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, CI: "1" },
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else
|
||||
reject(
|
||||
new Error(
|
||||
`${command} exited with ${code ?? `signal ${signal ?? "unknown"}`}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const details = await stat(artifact).catch(() => null);
|
||||
if (!details?.isFile()) {
|
||||
throw new Error(`Build the app release first: ${artifact}`);
|
||||
}
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(artifact))
|
||||
.digest("hex");
|
||||
const sidecar = await readFile(`${artifact}.sha256`, "utf8");
|
||||
if (!sidecar.startsWith(`${digest} ${path.basename(artifact)}`)) {
|
||||
throw new Error("The app release checksum sidecar does not match the ZIP");
|
||||
}
|
||||
|
||||
await access(path.join(portal, "scripts", "assemble.mjs"));
|
||||
await run(
|
||||
process.platform === "win32" ? "npm.cmd" : "npm",
|
||||
["run", "build"],
|
||||
portal,
|
||||
);
|
||||
|
||||
const temporary = await mkdtemp(
|
||||
path.join(os.tmpdir(), "svg-tools-portal-smoke-"),
|
||||
);
|
||||
try {
|
||||
const example = JSON.parse(
|
||||
await readFile(
|
||||
path.join(portal, "release", "toolbox.lock.example.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const lock = {
|
||||
...example,
|
||||
releaseVersion: "0.10.0-smoke.0",
|
||||
portalVersion: portalPackage.version,
|
||||
apps: [
|
||||
{
|
||||
id: "de.add-ideas.svg-tools",
|
||||
version: appPackage.version,
|
||||
artifact: pathToFileURL(artifact).href,
|
||||
sha256: digest,
|
||||
target: "svg",
|
||||
},
|
||||
],
|
||||
};
|
||||
const lockFile = path.join(temporary, "toolbox.lock.json");
|
||||
const output = path.join(temporary, "assembled", "toolbox");
|
||||
const archive = path.join(temporary, "assembled", "toolbox.zip");
|
||||
await writeFile(lockFile, `${JSON.stringify(lock, null, 2)}\n`, {
|
||||
flag: "wx",
|
||||
});
|
||||
await run(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(portal, "scripts", "assemble.mjs"),
|
||||
"--lock",
|
||||
lockFile,
|
||||
"--portal-dist",
|
||||
path.join(portal, "dist"),
|
||||
"--output",
|
||||
output,
|
||||
"--archive",
|
||||
archive,
|
||||
],
|
||||
portal,
|
||||
);
|
||||
|
||||
const catalogue = JSON.parse(
|
||||
await readFile(path.join(output, "toolbox.catalog.json"), "utf8"),
|
||||
);
|
||||
const manifest = JSON.parse(
|
||||
await readFile(
|
||||
path.join(output, "apps", "svg", "toolbox-app.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const appIndex = await readFile(
|
||||
path.join(output, "apps", "svg", "index.html"),
|
||||
"utf8",
|
||||
);
|
||||
if (
|
||||
catalogue.apps.length !== 1 ||
|
||||
catalogue.apps[0]?.manifest !== "./apps/svg/toolbox-app.json" ||
|
||||
manifest.id !== "de.add-ideas.svg-tools" ||
|
||||
manifest.version !== appPackage.version ||
|
||||
!manifest.assets?.includes("./canvas-frame-controller.js") ||
|
||||
/\b(?:src|href)=["']\//iu.test(appIndex)
|
||||
) {
|
||||
throw new Error(
|
||||
"Assembled SVG Tools identity or relocatable assets changed",
|
||||
);
|
||||
}
|
||||
for (const relative of [
|
||||
"apps/svg/favicon.svg",
|
||||
"apps/svg/canvas-frame-controller.js",
|
||||
"apps/svg/LICENSE",
|
||||
"apps/svg/SOURCE.md",
|
||||
"toolbox.release.json",
|
||||
]) {
|
||||
const file = await stat(path.join(output, relative)).catch(() => null);
|
||||
if (!file?.isFile()) throw new Error(`Assembly is missing ${relative}`);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Portal assembly smoke passed for SVG Tools ${appPackage.version} (${digest})\n`,
|
||||
);
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const publicDirectory = path.join(root, "public");
|
||||
const required = [
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"SOURCE.md",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
];
|
||||
|
||||
await mkdir(publicDirectory, { recursive: true });
|
||||
for (const name of required) {
|
||||
const source = path.join(root, name);
|
||||
await readFile(source);
|
||||
await cp(source, path.join(publicDirectory, name));
|
||||
}
|
||||
|
||||
const publicLicenses = path.join(publicDirectory, "LICENSES");
|
||||
await rm(publicLicenses, { recursive: true, force: true });
|
||||
await cp(path.join(root, "LICENSES"), publicLicenses, { recursive: true });
|
||||
const publicDocs = path.join(publicDirectory, "docs");
|
||||
await rm(publicDocs, { recursive: true, force: true });
|
||||
await cp(path.join(root, "docs"), publicDocs, { recursive: true });
|
||||
|
||||
const lock = JSON.parse(
|
||||
await readFile(path.join(root, "package-lock.json"), "utf8"),
|
||||
);
|
||||
const runtimeLicenseSections = [];
|
||||
for (const [location, locked] of Object.entries(lock.packages ?? {}).sort(
|
||||
([left], [right]) => (left < right ? -1 : left > right ? 1 : 0),
|
||||
)) {
|
||||
if (!location.includes("node_modules/") || locked.dev === true) continue;
|
||||
const packageDirectory = path.join(root, location);
|
||||
const details = JSON.parse(
|
||||
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
|
||||
);
|
||||
const candidates = (await readdir(packageDirectory))
|
||||
.filter((name) => /^(?:licen[cs]e|copying|notice)(?:\.|$)/iu.test(name))
|
||||
.sort();
|
||||
const texts = [];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
texts.push(
|
||||
`--- ${candidate} ---\n${await readFile(path.join(packageDirectory, candidate), "utf8")}`,
|
||||
);
|
||||
} catch {
|
||||
// Ignore directories or non-text aliases; another matching file may exist.
|
||||
}
|
||||
}
|
||||
runtimeLicenseSections.push(
|
||||
[
|
||||
"=".repeat(78),
|
||||
`${details.name}@${details.version}`,
|
||||
`Declared licence: ${details.license ?? locked.license ?? "See upstream package"}`,
|
||||
`Installed from: ${location}`,
|
||||
"=".repeat(78),
|
||||
texts.length
|
||||
? texts.join("\n\n")
|
||||
: "No package-local licence file was present; see THIRD_PARTY_NOTICES.md and the upstream source.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
path.join(publicLicenses, "npm-runtime-licenses.txt"),
|
||||
`${runtimeLicenseSections.join("\n\n")}\n`,
|
||||
);
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
const rows = [
|
||||
"# Runtime dependency licences",
|
||||
"",
|
||||
"Generated from the exact lock used for this build.",
|
||||
"",
|
||||
"| Package | Version | Licence |",
|
||||
"| --- | --- | --- |",
|
||||
];
|
||||
for (const name of Object.keys(packageJson.dependencies).sort()) {
|
||||
const manifestPath = path.join(root, "node_modules", name, "package.json");
|
||||
const details = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
rows.push(
|
||||
`| \`${details.name}\` | ${details.version} | ${details.license ?? "See packaged notices"} |`,
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
path.join(publicDirectory, "THIRD_PARTY_LICENSES.txt"),
|
||||
`${rows.join("\n")}\n`,
|
||||
);
|
||||
|
||||
console.log("Prepared static release notices");
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createServer } from "node:http";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"dist",
|
||||
);
|
||||
const nestedPrefix = "/deep/nested/svg/";
|
||||
const mediaTypes = new Map([
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".mjs", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".wasm", "application/wasm"],
|
||||
]);
|
||||
const catalogue = {
|
||||
schemaVersion: 1,
|
||||
id: "de.add-ideas.svg-tools.browser-test",
|
||||
name: "SVG Tools browser-test Toolbox",
|
||||
home: "./",
|
||||
theme: { mode: "system", brand: "add·ideas" },
|
||||
apps: [{ manifest: "./toolbox-app.json", enabled: true }],
|
||||
};
|
||||
const headers = {
|
||||
"Content-Security-Policy":
|
||||
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; frame-src 'self' blob:; manifest-src 'self'",
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
};
|
||||
|
||||
function safeFile(requestPath) {
|
||||
const decoded = decodeURIComponent(requestPath);
|
||||
const relative = decoded.startsWith(nestedPrefix)
|
||||
? decoded.slice(nestedPrefix.length)
|
||||
: decoded.replace(/^\/+/, "");
|
||||
const normalized = path.posix.normalize(relative || "index.html");
|
||||
if (
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
path.isAbsolute(normalized)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return path.join(root, normalized);
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname === "/toolbox.catalog.json") {
|
||||
response.writeHead(200, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
...headers,
|
||||
});
|
||||
response.end(JSON.stringify(catalogue));
|
||||
return;
|
||||
}
|
||||
let file = safeFile(url.pathname);
|
||||
if (!file) {
|
||||
response.writeHead(400).end("Bad request");
|
||||
return;
|
||||
}
|
||||
if ((await stat(file).catch(() => null))?.isDirectory()) {
|
||||
file = path.join(file, "index.html");
|
||||
}
|
||||
const content = await readFile(file);
|
||||
const isCanvasController =
|
||||
path.basename(file) === "canvas-frame-controller.js";
|
||||
response.writeHead(200, {
|
||||
"Content-Type":
|
||||
mediaTypes.get(path.extname(file)) ?? "application/octet-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
...headers,
|
||||
...(isCanvasController
|
||||
? {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cross-Origin-Resource-Policy": "cross-origin",
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
response.end(content);
|
||||
} catch {
|
||||
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
response.end("Not found");
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(4173, "127.0.0.1", () => {
|
||||
console.log("SVG Tools test server listening on http://127.0.0.1:4173");
|
||||
});
|
||||
Reference in New Issue
Block a user