mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-17 05:49:35 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
386d37194b | ||
|
|
2b8be50321 | ||
|
|
7313d6b024 |
@@ -0,0 +1,10 @@
|
||||
---
|
||||
'@fission-ai/openspec': patch
|
||||
---
|
||||
|
||||
Preview the built docs site with a first-party static server instead of `serve`.
|
||||
`pnpm start` in `website/` now runs `scripts/preview.mjs`, a dependency-free
|
||||
server that mirrors Cloudflare Pages routing for a Next static export. This
|
||||
drops the `serve` dev dependency whose transitive tree kept publishing
|
||||
advisories (brace-expansion x3, fast-uri, compression) that had to be pinned by
|
||||
hand even though none of that code ever reached the published site.
|
||||
@@ -54,6 +54,24 @@ describe('pnpm workspace configuration', () => {
|
||||
expect(workspace.overrides).toEqual(lockfile.overrides);
|
||||
});
|
||||
|
||||
it('previews the built site without a server dependency', () => {
|
||||
const packageJson = readJson('website/package.json');
|
||||
const lockfile = readYaml('website/pnpm-lock.yaml');
|
||||
|
||||
// `pnpm start` ran `serve`, whose dev-only tree kept publishing advisories
|
||||
// into the site's graph — four of them (brace-expansion x3, fast-uri) turned
|
||||
// the Security workflow red on `main` and had to be pinned by hand, for code
|
||||
// that never reached the published site. A first-party preview script has no
|
||||
// tree to audit. Re-adding a server dependency means taking that back on
|
||||
// purpose, so it should update this test too.
|
||||
expect(packageJson.scripts.start).toBe('node scripts/preview.mjs');
|
||||
expect(fs.existsSync(path.join(projectRoot, 'website/scripts/preview.mjs'))).toBe(true);
|
||||
const serverPackages = Object.keys(lockfile.packages).filter((key) =>
|
||||
/^(serve|serve-handler)@/.test(key)
|
||||
);
|
||||
expect(serverPackages).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes install policy changes in Nix and security validation', () => {
|
||||
const flake = fs.readFileSync(path.join(projectRoot, 'flake.nix'), 'utf8');
|
||||
const ci = fs.readFileSync(path.join(projectRoot, '.github/workflows/ci.yml'), 'utf8');
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import type { AddressInfo } from 'net';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
type PreviewModule = {
|
||||
resolveStaticFile(root: string, requestPath: string): string | null;
|
||||
contentTypeFor(filePath: string): string;
|
||||
createPreviewServer(root: string): import('http').Server;
|
||||
};
|
||||
|
||||
// A literal relative specifier, so the runner resolves it the same way it
|
||||
// resolves every other import in this suite. Building the path at runtime
|
||||
// instead handed it a Windows absolute path, which is not a module specifier:
|
||||
// the suite failed to load on windows-pwsh with `SyntaxError: Invalid or
|
||||
// unexpected token`, and a `file://` URL did not fix it either.
|
||||
// `test/pnpm-workspace-config.test.ts` pins the script's location.
|
||||
const preview = (await import(
|
||||
'../website/scripts/preview.mjs'
|
||||
)) as unknown as PreviewModule;
|
||||
|
||||
/**
|
||||
* Mirrors a Next static export: a route is written as `docs/cli.html`, and a
|
||||
* directory of the same name holds its data files but no index.html.
|
||||
*/
|
||||
function buildFixture(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-preview-'));
|
||||
fs.writeFileSync(path.join(root, 'index.html'), '<html>home</html>');
|
||||
fs.writeFileSync(path.join(root, '404.html'), '<html>missing</html>');
|
||||
fs.writeFileSync(path.join(root, 'robots.txt'), 'User-agent: *\n');
|
||||
fs.mkdirSync(path.join(root, 'docs', 'cli'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'docs', 'cli.html'), '<html>cli</html>');
|
||||
fs.writeFileSync(path.join(root, 'docs', 'cli', 'data.txt'), 'data');
|
||||
fs.mkdirSync(path.join(root, 'guide'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'guide', 'index.html'), '<html>guide</html>');
|
||||
return root;
|
||||
}
|
||||
|
||||
describe('website preview server', () => {
|
||||
let root: string;
|
||||
|
||||
beforeAll(() => {
|
||||
root = buildFixture();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('resolveStaticFile', () => {
|
||||
it('serves the root index', () => {
|
||||
expect(preview.resolveStaticFile(root, '/')).toBe(path.join(root, 'index.html'));
|
||||
});
|
||||
|
||||
it('falls back to the exported .html file for a route', () => {
|
||||
expect(preview.resolveStaticFile(root, '/docs/cli')).toBe(
|
||||
path.join(root, 'docs', 'cli.html')
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves a route the same way with a trailing slash', () => {
|
||||
expect(preview.resolveStaticFile(root, '/docs/cli/')).toBe(
|
||||
path.join(root, 'docs', 'cli.html')
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers a directory index when one exists', () => {
|
||||
expect(preview.resolveStaticFile(root, '/guide')).toBe(
|
||||
path.join(root, 'guide', 'index.html')
|
||||
);
|
||||
});
|
||||
|
||||
it('serves a file by its exact path', () => {
|
||||
expect(preview.resolveStaticFile(root, '/robots.txt')).toBe(path.join(root, 'robots.txt'));
|
||||
});
|
||||
|
||||
it('returns null for a path with no match', () => {
|
||||
expect(preview.resolveStaticFile(root, '/nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to escape the export directory', () => {
|
||||
const outside = path.join(root, '..', 'escaped.txt');
|
||||
fs.writeFileSync(outside, 'secret');
|
||||
try {
|
||||
expect(preview.resolveStaticFile(root, '/../escaped.txt')).toBeNull();
|
||||
expect(preview.resolveStaticFile(root, '/%2e%2e/escaped.txt')).toBeNull();
|
||||
expect(preview.resolveStaticFile(root, '/docs/../../escaped.txt')).toBeNull();
|
||||
} finally {
|
||||
fs.rmSync(outside, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects malformed encoding and null bytes', () => {
|
||||
expect(preview.resolveStaticFile(root, '/%E0%A4%A')).toBeNull();
|
||||
expect(preview.resolveStaticFile(root, '/index.html%00.txt')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('contentTypeFor', () => {
|
||||
it('names the common static types', () => {
|
||||
expect(preview.contentTypeFor('a/b.html')).toBe('text/html; charset=utf-8');
|
||||
expect(preview.contentTypeFor('a/b.css')).toBe('text/css; charset=utf-8');
|
||||
expect(preview.contentTypeFor('a/b.svg')).toBe('image/svg+xml');
|
||||
expect(preview.contentTypeFor('a/b.woff2')).toBe('font/woff2');
|
||||
});
|
||||
|
||||
it('falls back to a byte stream for anything else', () => {
|
||||
expect(preview.contentTypeFor('a/b.unknown')).toBe('application/octet-stream');
|
||||
});
|
||||
});
|
||||
|
||||
describe('over HTTP', () => {
|
||||
it('serves pages and answers a miss with the 404 page', async () => {
|
||||
const server = preview.createPreviewServer(root);
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
try {
|
||||
const page = await fetch(`http://127.0.0.1:${port}/docs/cli`);
|
||||
expect(page.status).toBe(200);
|
||||
expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
|
||||
expect(await page.text()).toBe('<html>cli</html>');
|
||||
|
||||
const missing = await fetch(`http://127.0.0.1:${port}/nope`);
|
||||
expect(missing.status).toBe(404);
|
||||
expect(await missing.text()).toBe('<html>missing</html>');
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@
|
||||
"sync:docs:watch": "node --watch-path=../docs --watch-path=../docs-lab --watch-path=docs.sync.config.mjs --watch-preserve-output scripts/sync-docs.mjs",
|
||||
"build": "pnpm run sync:docs && fumadocs-mdx && next build",
|
||||
"dev": "pnpm run sync:docs && (pnpm run sync:docs:watch & next dev)",
|
||||
"start": "serve out",
|
||||
"start": "node scripts/preview.mjs",
|
||||
"types:check": "pnpm run sync:docs && fumadocs-mdx && next typegen && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -29,7 +29,6 @@
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"postcss": "^8.5.29",
|
||||
"serve": "^14.2.6",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
|
||||
Generated
-627
File diff suppressed because it is too large
Load Diff
@@ -12,17 +12,7 @@ allowBuilds:
|
||||
overrides:
|
||||
postcss: ^8.5.28
|
||||
sharp: ^0.35.3
|
||||
# GHSA-6j4f-fj2g-mc7p, GHSA-qhr7-859c-m2p7, GHSA-q2hr-2g5m-vwhr — brace-expansion
|
||||
# DoS via nested or comma-heavy brace groups. Dev-only (serve > serve-handler >
|
||||
# minimatch); never in the static site.
|
||||
brace-expansion@<5.0.12: '>=5.0.12 <6'
|
||||
# GHSA-hrr3-gc8f-f4qj — fast-uri. Dev-only (serve > ajv).
|
||||
fast-uri@<3.1.8: ^3.1.8
|
||||
# GHSA-2v37-7h3g-55p8 / CVE-2026-67213 — nanoid infinite loop on size=0. Build-time
|
||||
# only (transitive via postcss); this is a statically exported site with no server
|
||||
# runtime. Remove once transitive nanoid is >=3.3.17 (check: pnpm why nanoid).
|
||||
nanoid@<3.3.17: '>=3.3.17 <4'
|
||||
# GHSA-vc2v-76pw-4v95 — compression memory-leak DoS on premature response close.
|
||||
# Dev-only (serve > compression); never in the static site. Remove once
|
||||
# transitive compression is >=1.8.2 (check: pnpm why compression).
|
||||
compression@<1.8.2: '>=1.8.2 <2'
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Serve the built static site in `out/` for a local look at what Cloudflare
|
||||
// Pages will publish. Backs `pnpm start`.
|
||||
//
|
||||
// Deliberately dependency-free. This replaced `serve`, whose transitive tree
|
||||
// kept publishing advisories into a dev-only tool: four of them (brace-expansion
|
||||
// x3, fast-uri) turned the Security workflow red on `main` and had to be pinned
|
||||
// by hand, and none of that code ever reached the published site. A preview
|
||||
// server for a static export is small enough to own outright.
|
||||
//
|
||||
// Routing approximates Cloudflare Pages for a Next static export:
|
||||
// / -> out/index.html
|
||||
// /docs/cli -> out/docs/cli.html (or out/docs/cli/index.html)
|
||||
// /docs/cli/ -> the same, so links work with or without a trailing slash
|
||||
// anything else -> out/404.html with status 404
|
||||
//
|
||||
// `out/_redirects` is a Cloudflare feature and is not applied here, same as
|
||||
// before: the preview serves the exported files as they are.
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { dirname, extname, join, normalize, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const websiteRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const outDir = join(websiteRoot, 'out');
|
||||
|
||||
const CONTENT_TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.otf': 'font/otf',
|
||||
};
|
||||
|
||||
export function contentTypeFor(filePath) {
|
||||
return CONTENT_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a request path to a file inside `root`, or null when nothing matches.
|
||||
*
|
||||
* Escaping `root` is never a match: the decoded path is normalized and the
|
||||
* result is checked to still sit under `root`, so `..` segments and an
|
||||
* encoded `%2e%2e` both fall through to the 404 page.
|
||||
*/
|
||||
export function resolveStaticFile(root, requestPath) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = decodeURIComponent(requestPath);
|
||||
} catch {
|
||||
return null; // Malformed percent-encoding.
|
||||
}
|
||||
if (decoded.includes('\0')) return null;
|
||||
|
||||
// Normalize in POSIX terms first: a request path is URL-shaped, so its
|
||||
// separator is `/` on every platform.
|
||||
// Trailing separators are dropped so a directory request and the `.html`
|
||||
// fallback for it build the same base path.
|
||||
const relativePath = normalize(decoded.replace(/^\/+/, '').split('/').join(sep)).replace(
|
||||
new RegExp(`\\${sep}+$`),
|
||||
''
|
||||
);
|
||||
const base = relativePath === '' || relativePath === '.' ? root : join(root, relativePath);
|
||||
if (base !== root && !base.startsWith(root + sep)) return null;
|
||||
|
||||
// Next's export writes a route as `docs/cli.html`, not `docs/cli/index.html`,
|
||||
// so both spellings have to resolve either way the link was written.
|
||||
const candidates = decoded.endsWith('/')
|
||||
? [join(base, 'index.html'), `${base}.html`]
|
||||
: [base, join(base, 'index.html'), `${base}.html`];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate !== root && !candidate.startsWith(root + sep)) continue;
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The preview server for a built export directory. Exported for tests. */
|
||||
export function createPreviewServer(root) {
|
||||
return createServer((request, response) => {
|
||||
const requestPath = new URL(request.url ?? '/', 'http://localhost').pathname;
|
||||
const filePath = resolveStaticFile(root, requestPath);
|
||||
|
||||
if (!filePath) {
|
||||
const notFound = join(root, '404.html');
|
||||
if (existsSync(notFound)) {
|
||||
response.writeHead(404, { 'content-type': 'text/html; charset=utf-8' });
|
||||
createReadStream(notFound).pipe(response);
|
||||
return;
|
||||
}
|
||||
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
response.end('Not found\n');
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, { 'content-type': contentTypeFor(filePath) });
|
||||
createReadStream(filePath).pipe(response);
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!existsSync(outDir)) {
|
||||
console.error(`No build found at ${outDir}. Run "pnpm build" first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
const host = process.env.HOST ?? 'localhost';
|
||||
|
||||
createPreviewServer(outDir).listen(port, host, () => {
|
||||
console.log(`Serving ${outDir} at http://${host}:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Importing this file for its helpers must not start a server.
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main();
|
||||
}
|
||||
Reference in New Issue
Block a user