@shimabell06/cloud-arch-icon-browser 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/THIRD_PARTY_NOTICES.md +59 -0
- package/cli/cli.js +130 -0
- package/cli/index.js +6 -0
- package/cli/server.js +254 -0
- package/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/assets/index-DFX5Ype9.js +9 -0
- package/dist/assets/index-xx5O9pha.css +2 -0
- package/dist/index.html +14 -0
- package/dist/licenses/Fuse-Apache-2.0.txt +201 -0
- package/dist/licenses/Geist-OFL-1.1.txt +93 -0
- package/dist/licenses/shadcn-MIT.txt +21 -0
- package/dist/licenses/zip.js-BSD-3-Clause.txt +28 -0
- package/package.json +82 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ShimaBell0619
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Cloud Arch Icon Browser
|
|
2
|
+
|
|
3
|
+
> Pre-release: `v0.1.0` is in development.
|
|
4
|
+
|
|
5
|
+
A local, browser-based viewer for Microsoft Azure Architecture Icons.
|
|
6
|
+
|
|
7
|
+
Cloud Arch Icon Browser is an independent open-source project. It is not affiliated with, endorsed by, or sponsored by Microsoft.
|
|
8
|
+
|
|
9
|
+
Microsoft Azure Architecture Icons are **not included** in this repository or npm package. Users download the latest official icon package directly from Microsoft and use those icons in accordance with Microsoft's terms.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @shimabell06/cloud-arch-icon-browser
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The command starts a temporary HTTP server bound to `127.0.0.1` on an available port and opens the local web UI in the default browser. The ZIP selected by the user is processed locally and is not uploaded or persisted by the application.
|
|
18
|
+
|
|
19
|
+
## MVP goals
|
|
20
|
+
|
|
21
|
+
- Load a user-selected official Azure Architecture Icons ZIP.
|
|
22
|
+
- Preserve and browse the ZIP folder hierarchy as categories.
|
|
23
|
+
- Search icons with exact, prefix, substring, normalized, and fuzzy matching.
|
|
24
|
+
- Preview SVG icons without modifying the original SVG.
|
|
25
|
+
- Download individual original SVG files with their official filenames unchanged.
|
|
26
|
+
- Run locally through `npx` with no automatic runtime network access.
|
|
27
|
+
|
|
28
|
+
## Official icon package
|
|
29
|
+
|
|
30
|
+
Download the latest package from Microsoft Learn:
|
|
31
|
+
|
|
32
|
+
https://learn.microsoft.com/en-us/azure/architecture/icons/
|
|
33
|
+
|
|
34
|
+
The app may provide manual links to Microsoft documentation, but it does not automatically download the icon package or contact Microsoft at runtime.
|
|
35
|
+
|
|
36
|
+
## Project status
|
|
37
|
+
|
|
38
|
+
The project includes a local browser UI and a React-independent core for ZIP validation, metadata/categories, search, lazy SVG previews, and original-file downloads. The UI supports local package selection/replacement, recursive category browsing, debounced search, responsive navigation, details dialogs, and keyboard interaction. The production localhost CLI/static server is implemented with loopback-only binding, Host/path validation, restrictive browser security headers, and clean shutdown behavior.
|
|
39
|
+
|
|
40
|
+
Release-readiness automation covers Linux browser E2E/accessibility/visual regression, Linux build/package checks, Windows/macOS packaged CLI smoke tests, `npm pack` leak validation, Changesets release PRs, an OIDC npm release workflow, and a weekly Microsoft source-change watcher. The npm package name is `@shimabell06/cloud-arch-icon-browser`; publication is activated with `private: false`. The current official `Azure_Public_Service_Icons_V24.zip` has passed the production-parser compatibility verification recorded in [`COMPATIBILITY.md`](./COMPATIBILITY.md). The remaining first-publication work is the one-time npm registry bootstrap and Trusted Publisher configuration described in [`docs/RELEASE.md`](./docs/RELEASE.md).
|
|
41
|
+
|
|
42
|
+
See the [core integration notes](./docs/icon-package-core.md) for its API and validation boundaries and [`docs/RELEASE.md`](./docs/RELEASE.md) for the release/bootstrap runbook.
|
|
43
|
+
|
|
44
|
+
## Development
|
|
45
|
+
|
|
46
|
+
Supported Node.js versions are defined by `package.json`:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
^22.22.2 || ^24.15.0 || >=26.0.0
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Node 25.x is intentionally unsupported by the current dependency set. For reproducible local development and CI, use the exact version in [`.node-version`](./.node-version), currently Node 24.20.0.
|
|
53
|
+
|
|
54
|
+
With nvm:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
nvm install 24.20.0
|
|
58
|
+
nvm use 24.20.0
|
|
59
|
+
npm ci
|
|
60
|
+
npm run dev
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Open the URL printed by Vite. Development and preview servers bind to `127.0.0.1`. Geist Sans Variable and all UI dependencies are bundled locally; the only external link in the shell opens Microsoft Learn when clicked.
|
|
64
|
+
|
|
65
|
+
Core validation commands:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm run check
|
|
69
|
+
npm run typecheck
|
|
70
|
+
npm test
|
|
71
|
+
npm run build
|
|
72
|
+
npm run test:cli-smoke
|
|
73
|
+
npm run test:cli-package-smoke
|
|
74
|
+
npm run verify:package
|
|
75
|
+
npm run verify:release-ready
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Browser E2E uses only the project-owned dummy ZIP fixture generated by `scripts/ui-review/create_fixture.py`:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
python3 scripts/ui-review/create_fixture.py /tmp/cloud-arch-icon-browser-e2e-fixture.zip
|
|
82
|
+
npx playwright install chromium
|
|
83
|
+
E2E_FIXTURE=/tmp/cloud-arch-icon-browser-e2e-fixture.zip npm run test:e2e
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`npm run test:e2e:update` updates the deliberately small Linux visual baseline set and should only be used when an intentional visual change has been reviewed.
|
|
87
|
+
|
|
88
|
+
To verify a maintainer-downloaded current official ZIP without committing it:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm run verify:official -- /path/to/latest-official.zip
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`check:fix` applies Biome fixes; `test:watch` runs Vitest interactively. `build` typechecks the project, writes the Web UI to `dist/`, and compiles the production CLI to `cli/`. After a build, `node cli/index.js` exercises the same localhost server path that the package binary uses. `preview` is a Vite development tool for inspecting the Web build and is not the production CLI/server.
|
|
95
|
+
|
|
96
|
+
## Project documents
|
|
97
|
+
|
|
98
|
+
See:
|
|
99
|
+
|
|
100
|
+
- [`DESIGN.md`](./DESIGN.md) — product and architecture source of truth.
|
|
101
|
+
- [`AGENTS.md`](./AGENTS.md) — repository instructions for coding agents.
|
|
102
|
+
- [`COMPATIBILITY.md`](./COMPATIBILITY.md) — official package compatibility and verification status.
|
|
103
|
+
- [`CONTRIBUTING.md`](./CONTRIBUTING.md) — contribution workflow.
|
|
104
|
+
- [`docs/RELEASE.md`](./docs/RELEASE.md) — release, npm bootstrap, OIDC, and watcher runbook.
|
|
105
|
+
- [`SECURITY.md`](./SECURITY.md) — vulnerability reporting policy.
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
The source code for this project is licensed under the MIT License. See [`LICENSE`](./LICENSE).
|
|
110
|
+
|
|
111
|
+
Third-party software and assets retain their own licenses. See [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md).
|
|
112
|
+
|
|
113
|
+
Microsoft Azure Architecture Icons are Microsoft assets and are not covered by this project's MIT License.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Third-Party Notices
|
|
2
|
+
|
|
3
|
+
This project depends on third-party open-source software. Dependency licenses remain the property of their respective copyright holders and are not replaced by this project's MIT License.
|
|
4
|
+
|
|
5
|
+
## Bundled notices and assets
|
|
6
|
+
|
|
7
|
+
### Geist
|
|
8
|
+
|
|
9
|
+
The application bundles Geist Sans Variable through the exact-pinned `@fontsource-variable/geist` 5.3.0 dependency. Vite emits the font files into the local build; no font CDN is used at runtime.
|
|
10
|
+
|
|
11
|
+
Geist is distributed under the SIL Open Font License 1.1 (OFL-1.1). The font files are third-party assets and are not licensed under this project's MIT License. The upstream copyright notice and full OFL text are retained verbatim in [`public/licenses/Geist-OFL-1.1.txt`](./public/licenses/Geist-OFL-1.1.txt) and copied by Vite to `dist/licenses/Geist-OFL-1.1.txt`, which is included in the npm package.
|
|
12
|
+
|
|
13
|
+
### shadcn/ui-derived component
|
|
14
|
+
|
|
15
|
+
`src/components/ui/button.tsx` is derived from the official shadcn/ui Base UI Rhea registry. Copyright (c) 2023 shadcn. The upstream MIT license is retained in [`public/licenses/shadcn-MIT.txt`](./public/licenses/shadcn-MIT.txt) and copied into `dist/licenses/` by Vite.
|
|
16
|
+
|
|
17
|
+
### ZIP processing and search
|
|
18
|
+
|
|
19
|
+
`@zip.js/zip.js` 2.11.1 is distributed under BSD-3-Clause. Its unmodified license is retained in [`public/licenses/zip.js-BSD-3-Clause.txt`](./public/licenses/zip.js-BSD-3-Clause.txt) and copied into `dist/licenses/`.
|
|
20
|
+
|
|
21
|
+
Fuse.js 7.5.0 is distributed under Apache-2.0. Its unmodified license is retained in [`public/licenses/Fuse-Apache-2.0.txt`](./public/licenses/Fuse-Apache-2.0.txt) and copied into `dist/licenses/`.
|
|
22
|
+
|
|
23
|
+
The core uses zip.js with its local JavaScript compression fallback. No remote worker, WASM resource, or search service is fetched at runtime.
|
|
24
|
+
|
|
25
|
+
## Direct runtime dependency inventory
|
|
26
|
+
|
|
27
|
+
The `v0.1.0` release line uses exact-pinned direct runtime dependencies:
|
|
28
|
+
|
|
29
|
+
- `@base-ui/react` 1.8.0
|
|
30
|
+
- `@fontsource-variable/geist` 5.3.0
|
|
31
|
+
- `@zip.js/zip.js` 2.11.1
|
|
32
|
+
- `class-variance-authority` 0.7.1
|
|
33
|
+
- `clsx` 2.1.1
|
|
34
|
+
- `fuse.js` 7.5.0
|
|
35
|
+
- `lucide-react` 1.41.0
|
|
36
|
+
- `open` 11.0.2
|
|
37
|
+
- `react` 19.2.8
|
|
38
|
+
- `react-dom` 19.2.8
|
|
39
|
+
- `tailwind-merge` 3.6.0
|
|
40
|
+
|
|
41
|
+
`open` is used only by the local CLI to ask the operating system to open the generated `127.0.0.1` URL in the user's default browser. It does not add an automatic external runtime network request.
|
|
42
|
+
|
|
43
|
+
## Development and release tooling
|
|
44
|
+
|
|
45
|
+
Development-only tooling is not intentionally shipped in the npm tarball. Direct tooling includes Biome, TypeScript, Vite/Tailwind, Vitest and Testing Library, Playwright 1.62.1, `@axe-core/playwright` 4.13.0, and `@changesets/cli` 2.29.7.
|
|
46
|
+
|
|
47
|
+
`npm run verify:package` inspects `npm pack --dry-run --json` and fails if development/test directories, ZIP files, SVG assets, or non-allowlisted top-level paths would be published. Release CI also runs `npm audit --audit-level=high` before publication.
|
|
48
|
+
|
|
49
|
+
When a bundled dependency is changed, refresh any copied upstream license text required by that dependency and update this notice when the distribution obligations change.
|
|
50
|
+
|
|
51
|
+
## Microsoft Azure Architecture Icons
|
|
52
|
+
|
|
53
|
+
Microsoft Azure Architecture Icons are **not third-party assets distributed by this project**. They are intentionally excluded from the repository, test fixtures, screenshots, and npm package.
|
|
54
|
+
|
|
55
|
+
Users obtain the icon package directly from Microsoft:
|
|
56
|
+
|
|
57
|
+
https://learn.microsoft.com/en-us/azure/architecture/icons/
|
|
58
|
+
|
|
59
|
+
Those icons are Microsoft assets governed by Microsoft's published terms. This project's MIT License does not grant rights to Microsoft icons. The weekly maintenance watcher fetches only the Microsoft Learn HTML page and never downloads or republishes the icon ZIP/SVG assets.
|
package/cli/cli.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import open from "open";
|
|
4
|
+
import { startStaticServer, } from "./server.js";
|
|
5
|
+
const HELP_TEXT = `Cloud Arch Icon Browser
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
cloud-arch-icon-browser
|
|
9
|
+
cloud-arch-icon-browser --help
|
|
10
|
+
cloud-arch-icon-browser --version
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
-h, --help Show this help message
|
|
14
|
+
-v, --version Show the package version
|
|
15
|
+
`;
|
|
16
|
+
export function parseCliArguments(args) {
|
|
17
|
+
if (args.length === 0) {
|
|
18
|
+
return { kind: "start" };
|
|
19
|
+
}
|
|
20
|
+
if (args.length === 1) {
|
|
21
|
+
const argument = args[0];
|
|
22
|
+
if (argument === "-h" || argument === "--help") {
|
|
23
|
+
return { kind: "help" };
|
|
24
|
+
}
|
|
25
|
+
if (argument === "-v" || argument === "--version") {
|
|
26
|
+
return { kind: "version" };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { kind: "invalid", arguments: [...args] };
|
|
30
|
+
}
|
|
31
|
+
export async function readPackageVersion() {
|
|
32
|
+
const candidates = [
|
|
33
|
+
new URL("../package.json", import.meta.url),
|
|
34
|
+
new URL("../../package.json", import.meta.url),
|
|
35
|
+
];
|
|
36
|
+
for (const packageJsonUrl of candidates) {
|
|
37
|
+
let packageJsonText;
|
|
38
|
+
try {
|
|
39
|
+
packageJsonText = await readFile(packageJsonUrl, "utf8");
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (isMissingFileError(error))
|
|
43
|
+
continue;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
const packageJson = JSON.parse(packageJsonText);
|
|
47
|
+
if (!isRecord(packageJson) || typeof packageJson.version !== "string") {
|
|
48
|
+
throw new Error("package.json does not contain a valid version string.");
|
|
49
|
+
}
|
|
50
|
+
return packageJson.version;
|
|
51
|
+
}
|
|
52
|
+
throw new Error("Unable to locate package.json.");
|
|
53
|
+
}
|
|
54
|
+
export async function attemptBrowserOpen(url, opener = open, warn = (message) => process.stderr.write(message)) {
|
|
55
|
+
try {
|
|
56
|
+
await opener(url);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
warn(`Unable to open the default browser automatically: ${formatError(error)}\n` +
|
|
60
|
+
`Open ${url} manually to continue.\n`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function runCli(args) {
|
|
64
|
+
const command = parseCliArguments(args);
|
|
65
|
+
if (command.kind === "help") {
|
|
66
|
+
process.stdout.write(HELP_TEXT);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
if (command.kind === "version") {
|
|
70
|
+
try {
|
|
71
|
+
process.stdout.write(`${await readPackageVersion()}\n`);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
process.stderr.write(`Unable to read package version: ${formatError(error)}\n`);
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (command.kind === "invalid") {
|
|
80
|
+
process.stderr.write(`Unsupported arguments: ${command.arguments.join(" ")}\n` +
|
|
81
|
+
"Run cloud-arch-icon-browser --help for supported options.\n");
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
let server;
|
|
85
|
+
try {
|
|
86
|
+
const staticRoot = fileURLToPath(new URL("../dist/", import.meta.url));
|
|
87
|
+
server = await startStaticServer({ rootDirectory: staticRoot });
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
process.stderr.write(`Unable to start local server: ${formatError(error)}\n`);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
process.stdout.write(`Cloud Arch Icon Browser: ${server.url}\n`);
|
|
94
|
+
installShutdownHandlers(server);
|
|
95
|
+
void attemptBrowserOpen(server.url);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
function installShutdownHandlers(server) {
|
|
99
|
+
let shuttingDown = false;
|
|
100
|
+
const shutdown = async () => {
|
|
101
|
+
if (shuttingDown)
|
|
102
|
+
return;
|
|
103
|
+
shuttingDown = true;
|
|
104
|
+
process.off("SIGINT", handleSignal);
|
|
105
|
+
process.off("SIGTERM", handleSignal);
|
|
106
|
+
try {
|
|
107
|
+
await server.close();
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
process.stderr.write(`Unable to stop local server cleanly: ${formatError(error)}\n`);
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const handleSignal = () => {
|
|
115
|
+
void shutdown();
|
|
116
|
+
};
|
|
117
|
+
process.once("SIGINT", handleSignal);
|
|
118
|
+
process.once("SIGTERM", handleSignal);
|
|
119
|
+
}
|
|
120
|
+
function isMissingFileError(error) {
|
|
121
|
+
if (!(error instanceof Error) || !("code" in error))
|
|
122
|
+
return false;
|
|
123
|
+
return error.code === "ENOENT" || error.code === "ENOTDIR";
|
|
124
|
+
}
|
|
125
|
+
function isRecord(value) {
|
|
126
|
+
return typeof value === "object" && value !== null;
|
|
127
|
+
}
|
|
128
|
+
function formatError(error) {
|
|
129
|
+
return error instanceof Error ? error.message : "Unknown error";
|
|
130
|
+
}
|
package/cli/index.js
ADDED
package/cli/server.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { readFile, readdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { createServer, } from "node:http";
|
|
3
|
+
import { extname, resolve, sep } from "node:path";
|
|
4
|
+
const LOOPBACK_HOST = "127.0.0.1";
|
|
5
|
+
const CONTENT_SECURITY_POLICY = [
|
|
6
|
+
"default-src 'self'",
|
|
7
|
+
"script-src 'self'",
|
|
8
|
+
"style-src 'self'",
|
|
9
|
+
"img-src 'self' blob: data:",
|
|
10
|
+
"font-src 'self'",
|
|
11
|
+
"connect-src 'self'",
|
|
12
|
+
"object-src 'none'",
|
|
13
|
+
"base-uri 'none'",
|
|
14
|
+
"frame-ancestors 'none'",
|
|
15
|
+
"form-action 'none'",
|
|
16
|
+
].join("; ");
|
|
17
|
+
const MIME_TYPES = new Map([
|
|
18
|
+
[".css", "text/css; charset=utf-8"],
|
|
19
|
+
[".gif", "image/gif"],
|
|
20
|
+
[".html", "text/html; charset=utf-8"],
|
|
21
|
+
[".ico", "image/x-icon"],
|
|
22
|
+
[".jpeg", "image/jpeg"],
|
|
23
|
+
[".jpg", "image/jpeg"],
|
|
24
|
+
[".js", "text/javascript; charset=utf-8"],
|
|
25
|
+
[".json", "application/json; charset=utf-8"],
|
|
26
|
+
[".map", "application/json; charset=utf-8"],
|
|
27
|
+
[".mjs", "text/javascript; charset=utf-8"],
|
|
28
|
+
[".png", "image/png"],
|
|
29
|
+
[".svg", "image/svg+xml"],
|
|
30
|
+
[".ttf", "font/ttf"],
|
|
31
|
+
[".txt", "text/plain; charset=utf-8"],
|
|
32
|
+
[".wasm", "application/wasm"],
|
|
33
|
+
[".webp", "image/webp"],
|
|
34
|
+
[".woff", "font/woff"],
|
|
35
|
+
[".woff2", "font/woff2"],
|
|
36
|
+
]);
|
|
37
|
+
export async function startStaticServer(options) {
|
|
38
|
+
const rootDirectory = await realpath(options.rootDirectory);
|
|
39
|
+
const staticAssets = await loadStaticAssets(rootDirectory);
|
|
40
|
+
let selectedPort = 0;
|
|
41
|
+
const server = createServer((request, response) => {
|
|
42
|
+
try {
|
|
43
|
+
handleRequest(request, response, staticAssets, selectedPort);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
if (!response.headersSent) {
|
|
47
|
+
sendText(request, response, 500, "Internal Server Error\n");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
response.destroy();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
server.on("clientError", (_error, socket) => {
|
|
54
|
+
if (socket.writable) {
|
|
55
|
+
socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
await listenOnAvailablePort(server);
|
|
59
|
+
const address = server.address();
|
|
60
|
+
if (address === null || typeof address === "string") {
|
|
61
|
+
await closeServer(server);
|
|
62
|
+
throw new Error("Unable to determine the localhost server port.");
|
|
63
|
+
}
|
|
64
|
+
selectedPort = address.port;
|
|
65
|
+
const url = `http://${LOOPBACK_HOST}:${selectedPort}/`;
|
|
66
|
+
let closePromise = null;
|
|
67
|
+
return {
|
|
68
|
+
port: selectedPort,
|
|
69
|
+
url,
|
|
70
|
+
close: () => {
|
|
71
|
+
closePromise ??= closeServer(server);
|
|
72
|
+
return closePromise;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function handleRequest(request, response, staticAssets, port) {
|
|
77
|
+
applySecurityHeaders(response);
|
|
78
|
+
if (!isExpectedHost(request.headers.host, port)) {
|
|
79
|
+
sendText(request, response, 403, "Forbidden\n");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
83
|
+
response.setHeader("Allow", "GET, HEAD");
|
|
84
|
+
sendText(request, response, 405, "Method Not Allowed\n");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const relativePath = parseStaticPath(request.url);
|
|
88
|
+
if (relativePath === null) {
|
|
89
|
+
sendText(request, response, 400, "Bad Request\n");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const asset = staticAssets.get(relativePath);
|
|
93
|
+
if (asset === undefined) {
|
|
94
|
+
sendText(request, response, 404, "Not Found\n");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
response.statusCode = 200;
|
|
98
|
+
response.setHeader("Content-Type", asset.contentType);
|
|
99
|
+
response.setHeader("Content-Length", String(asset.body.byteLength));
|
|
100
|
+
response.setHeader("Cache-Control", asset.cacheControl);
|
|
101
|
+
if (request.method === "HEAD") {
|
|
102
|
+
response.end();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
response.end(asset.body);
|
|
106
|
+
}
|
|
107
|
+
async function loadStaticAssets(rootDirectory) {
|
|
108
|
+
const assets = new Map();
|
|
109
|
+
await collectStaticAssets(rootDirectory, rootDirectory, [], assets);
|
|
110
|
+
if (!assets.has("index.html")) {
|
|
111
|
+
throw new Error("Static build output does not contain index.html.");
|
|
112
|
+
}
|
|
113
|
+
return assets;
|
|
114
|
+
}
|
|
115
|
+
async function collectStaticAssets(rootDirectory, currentDirectory, pathSegments, assets) {
|
|
116
|
+
const entries = await readdir(currentDirectory, { withFileTypes: true });
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
const absolutePath = resolve(currentDirectory, entry.name);
|
|
119
|
+
if (!isWithinRoot(rootDirectory, absolutePath)) {
|
|
120
|
+
throw new Error("Static build output escaped its configured root.");
|
|
121
|
+
}
|
|
122
|
+
const nextSegments = [...pathSegments, entry.name];
|
|
123
|
+
if (entry.isSymbolicLink()) {
|
|
124
|
+
throw new Error("Static build output must not contain symbolic links.");
|
|
125
|
+
}
|
|
126
|
+
if (entry.isDirectory()) {
|
|
127
|
+
await collectStaticAssets(rootDirectory, absolutePath, nextSegments, assets);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (!entry.isFile()) {
|
|
131
|
+
throw new Error("Static build output contains an unsupported file type.");
|
|
132
|
+
}
|
|
133
|
+
const canonicalPath = await realpath(absolutePath);
|
|
134
|
+
if (!isWithinRoot(rootDirectory, canonicalPath)) {
|
|
135
|
+
throw new Error("Static build output escaped its configured root.");
|
|
136
|
+
}
|
|
137
|
+
const relativePath = nextSegments.join("/");
|
|
138
|
+
const body = await readFile(canonicalPath);
|
|
139
|
+
assets.set(relativePath, {
|
|
140
|
+
body,
|
|
141
|
+
contentType: MIME_TYPES.get(extname(canonicalPath).toLowerCase()) ??
|
|
142
|
+
"application/octet-stream",
|
|
143
|
+
cacheControl: cacheControlFor(relativePath),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function parseStaticPath(rawUrl) {
|
|
148
|
+
if (rawUrl === undefined ||
|
|
149
|
+
!rawUrl.startsWith("/") ||
|
|
150
|
+
rawUrl.startsWith("//")) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const queryIndex = rawUrl.indexOf("?");
|
|
154
|
+
const rawPath = queryIndex === -1 ? rawUrl : rawUrl.slice(0, queryIndex);
|
|
155
|
+
let decodedPath;
|
|
156
|
+
try {
|
|
157
|
+
decodedPath = decodeURIComponent(rawPath);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
if (decodedPath.includes("\0") || decodedPath.includes("\\")) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
if (decodedPath.split("/").some((segment) => segment === "..")) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (decodedPath === "/") {
|
|
169
|
+
return "index.html";
|
|
170
|
+
}
|
|
171
|
+
const relativePath = decodedPath.slice(1);
|
|
172
|
+
return relativePath.length > 0 ? relativePath : null;
|
|
173
|
+
}
|
|
174
|
+
function isExpectedHost(hostHeader, port) {
|
|
175
|
+
if (hostHeader === undefined)
|
|
176
|
+
return false;
|
|
177
|
+
const normalizedHost = hostHeader.trim().toLowerCase();
|
|
178
|
+
return (normalizedHost === `${LOOPBACK_HOST}:${port}` ||
|
|
179
|
+
normalizedHost === `localhost:${port}`);
|
|
180
|
+
}
|
|
181
|
+
function isWithinRoot(rootDirectory, candidatePath) {
|
|
182
|
+
return (candidatePath === rootDirectory ||
|
|
183
|
+
candidatePath.startsWith(`${rootDirectory}${sep}`));
|
|
184
|
+
}
|
|
185
|
+
function cacheControlFor(relativePath) {
|
|
186
|
+
if (relativePath === "index.html") {
|
|
187
|
+
return "no-store";
|
|
188
|
+
}
|
|
189
|
+
if (/^assets\/.+-[A-Za-z0-9_-]{8,}\.[^/]+$/.test(relativePath)) {
|
|
190
|
+
return "public, max-age=31536000, immutable";
|
|
191
|
+
}
|
|
192
|
+
return "no-cache";
|
|
193
|
+
}
|
|
194
|
+
function applySecurityHeaders(response) {
|
|
195
|
+
response.setHeader("Content-Security-Policy", CONTENT_SECURITY_POLICY);
|
|
196
|
+
response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
197
|
+
response.setHeader("Permissions-Policy", permissionsPolicy());
|
|
198
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
199
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
200
|
+
response.setHeader("X-Frame-Options", "DENY");
|
|
201
|
+
}
|
|
202
|
+
function permissionsPolicy() {
|
|
203
|
+
return [
|
|
204
|
+
"accelerometer=()",
|
|
205
|
+
"camera=()",
|
|
206
|
+
"geolocation=()",
|
|
207
|
+
"gyroscope=()",
|
|
208
|
+
"magnetometer=()",
|
|
209
|
+
"microphone=()",
|
|
210
|
+
"payment=()",
|
|
211
|
+
"serial=()",
|
|
212
|
+
"usb=()",
|
|
213
|
+
].join(", ");
|
|
214
|
+
}
|
|
215
|
+
function sendText(request, response, statusCode, body) {
|
|
216
|
+
applySecurityHeaders(response);
|
|
217
|
+
response.statusCode = statusCode;
|
|
218
|
+
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
219
|
+
response.setHeader("Content-Length", String(Buffer.byteLength(body)));
|
|
220
|
+
response.setHeader("Cache-Control", "no-store");
|
|
221
|
+
if (request.method === "HEAD") {
|
|
222
|
+
response.end();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
response.end(body);
|
|
226
|
+
}
|
|
227
|
+
function listenOnAvailablePort(server) {
|
|
228
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
229
|
+
const handleError = (error) => {
|
|
230
|
+
server.off("listening", handleListening);
|
|
231
|
+
rejectPromise(error);
|
|
232
|
+
};
|
|
233
|
+
const handleListening = () => {
|
|
234
|
+
server.off("error", handleError);
|
|
235
|
+
resolvePromise();
|
|
236
|
+
};
|
|
237
|
+
server.once("error", handleError);
|
|
238
|
+
server.once("listening", handleListening);
|
|
239
|
+
server.listen({ host: LOOPBACK_HOST, port: 0 });
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
function closeServer(server) {
|
|
243
|
+
if (!server.listening)
|
|
244
|
+
return Promise.resolve();
|
|
245
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
246
|
+
server.close((error) => {
|
|
247
|
+
if (error) {
|
|
248
|
+
rejectPromise(error);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
resolvePromise();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|