@scenar/cli 0.1.20-rc.1 → 0.2.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/package.json +17 -5
- package/src/__tests__/publish-command.test.ts +46 -0
- package/src/__tests__/publish-flow.test.ts +220 -0
- package/src/__tests__/read-viewport.test.ts +45 -0
- package/src/__tests__/run-publish.test.ts +92 -0
- package/src/__tests__/serve-command.test.ts +44 -0
- package/src/__tests__/serve-request-path.test.ts +67 -0
- package/src/__tests__/serve-static-server.test.ts +88 -0
- package/src/api.d.ts +31 -0
- package/src/api.d.ts.map +1 -0
- package/src/api.js +28 -0
- package/src/api.js.map +1 -0
- package/src/api.ts +55 -0
- package/src/bundle/read-viewport.d.ts +23 -0
- package/src/bundle/read-viewport.d.ts.map +1 -0
- package/src/bundle/read-viewport.js +25 -0
- package/src/bundle/read-viewport.js.map +1 -0
- package/src/bundle/read-viewport.ts +36 -0
- package/src/commands/deploy.d.ts.map +1 -1
- package/src/commands/deploy.js +8 -24
- package/src/commands/deploy.js.map +1 -1
- package/src/commands/deploy.ts +10 -28
- package/src/commands/narrate.d.ts.map +1 -1
- package/src/commands/narrate.js +39 -170
- package/src/commands/narrate.js.map +1 -1
- package/src/commands/narrate.ts +48 -289
- package/src/commands/pack.d.ts.map +1 -1
- package/src/commands/pack.js +12 -112
- package/src/commands/pack.js.map +1 -1
- package/src/commands/pack.ts +12 -128
- package/src/commands/publish.d.ts +3 -0
- package/src/commands/publish.d.ts.map +1 -0
- package/src/commands/publish.js +64 -0
- package/src/commands/publish.js.map +1 -0
- package/src/commands/publish.ts +86 -0
- package/src/commands/render.d.ts.map +1 -1
- package/src/commands/render.js +12 -194
- package/src/commands/render.js.map +1 -1
- package/src/commands/render.ts +19 -286
- package/src/commands/serve.d.ts +3 -0
- package/src/commands/serve.d.ts.map +1 -0
- package/src/commands/serve.js +91 -0
- package/src/commands/serve.js.map +1 -0
- package/src/commands/serve.ts +112 -0
- package/src/index.d.ts.map +1 -1
- package/src/index.js +4 -0
- package/src/index.js.map +1 -1
- package/src/index.ts +4 -0
- package/src/narrate/run-narrate.d.ts +43 -0
- package/src/narrate/run-narrate.d.ts.map +1 -0
- package/src/narrate/run-narrate.js +174 -0
- package/src/narrate/run-narrate.js.map +1 -0
- package/src/narrate/run-narrate.ts +298 -0
- package/src/pack/run-pack.d.ts +34 -0
- package/src/pack/run-pack.d.ts.map +1 -0
- package/src/pack/run-pack.js +117 -0
- package/src/pack/run-pack.js.map +1 -0
- package/src/pack/run-pack.ts +163 -0
- package/src/publish/github.d.ts +11 -0
- package/src/publish/github.d.ts.map +1 -0
- package/src/publish/github.js +197 -0
- package/src/publish/github.js.map +1 -0
- package/src/publish/github.ts +216 -0
- package/src/publish/publish-flow.d.ts +110 -0
- package/src/publish/publish-flow.d.ts.map +1 -0
- package/src/publish/publish-flow.js +117 -0
- package/src/publish/publish-flow.js.map +1 -0
- package/src/publish/publish-flow.ts +199 -0
- package/src/publish/run-publish.d.ts +41 -0
- package/src/publish/run-publish.d.ts.map +1 -0
- package/src/publish/run-publish.js +77 -0
- package/src/publish/run-publish.js.map +1 -0
- package/src/publish/run-publish.ts +131 -0
- package/src/render/run-render.d.ts +31 -0
- package/src/render/run-render.d.ts.map +1 -0
- package/src/render/run-render.js +196 -0
- package/src/render/run-render.js.map +1 -0
- package/src/render/run-render.ts +287 -0
- package/src/serve/request-path.d.ts +23 -0
- package/src/serve/request-path.d.ts.map +1 -0
- package/src/serve/request-path.js +52 -0
- package/src/serve/request-path.js.map +1 -0
- package/src/serve/request-path.ts +55 -0
- package/src/serve/run-serve.d.ts +31 -0
- package/src/serve/run-serve.d.ts.map +1 -0
- package/src/serve/run-serve.js +35 -0
- package/src/serve/run-serve.js.map +1 -0
- package/src/serve/run-serve.ts +68 -0
- package/src/serve/static-server.d.ts +38 -0
- package/src/serve/static-server.d.ts.map +1 -0
- package/src/serve/static-server.js +91 -0
- package/src/serve/static-server.js.map +1 -0
- package/src/serve/static-server.ts +118 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"request-path.d.ts","sourceRoot":"","sources":["../../../src/serve/request-path.ts"],"names":[],"mappings":"AAEA,2EAA2E;AAC3E,eAAO,MAAM,UAAU,eAAe,CAAC;AAEvC,4EAA4E;AAC5E,eAAO,MAAM,oBAAoB,6BAA6B,CAAC;AAE/D;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyBhE;AAED,mFAAmF;AACnF,wBAAgB,cAAc,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAG3D"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { CONTENT_TYPE_BY_EXTENSION, finalExtension } from "../pack/bundle-contract.js";
|
|
2
|
+
/** File served for a directory request ("/" or any path ending in "/"). */
|
|
3
|
+
export const INDEX_FILE = "index.html";
|
|
4
|
+
/** Content type for an extension the bundle contract does not recognise. */
|
|
5
|
+
export const DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
|
6
|
+
/**
|
|
7
|
+
* Resolve an incoming request URL path to a clean, bundle-relative file path —
|
|
8
|
+
* or null if the request is malformed or attempts to escape the bundle root.
|
|
9
|
+
*
|
|
10
|
+
* Pure: no filesystem access. The caller maps the returned relative path onto
|
|
11
|
+
* disk (and 404s if the file is absent). This is the security-critical core of
|
|
12
|
+
* the local server, so it is isolated and exhaustively unit-tested:
|
|
13
|
+
* - percent-decoding (rejecting malformed encodings),
|
|
14
|
+
* - directory requests mapped to {@link INDEX_FILE},
|
|
15
|
+
* - rejection of "", ".", ".." segments and backslash/NUL injection.
|
|
16
|
+
*
|
|
17
|
+
* A defence-in-depth absolute-path containment check still runs in the server
|
|
18
|
+
* after this resolves, but this function alone must never yield a traversing
|
|
19
|
+
* path.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveStaticPath(urlPath) {
|
|
22
|
+
// Keep only the path; drop any query string or fragment defensively.
|
|
23
|
+
const pathOnly = urlPath.split(/[?#]/, 1)[0] ?? "";
|
|
24
|
+
let decoded;
|
|
25
|
+
try {
|
|
26
|
+
decoded = decodeURIComponent(pathOnly);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null; // malformed percent-encoding
|
|
30
|
+
}
|
|
31
|
+
// Requests are absolute-path form; reject separator/NUL injection outright.
|
|
32
|
+
if (!decoded.startsWith("/"))
|
|
33
|
+
return null;
|
|
34
|
+
if (decoded.includes("\0") || decoded.includes("\\"))
|
|
35
|
+
return null;
|
|
36
|
+
// A directory request ("/" or trailing slash) serves the index document.
|
|
37
|
+
const withIndex = decoded === "/" || decoded.endsWith("/") ? decoded + INDEX_FILE : decoded;
|
|
38
|
+
const segments = withIndex.replace(/^\/+/, "").split("/");
|
|
39
|
+
for (const segment of segments) {
|
|
40
|
+
// Empty (double-slash), current-dir, and parent-dir segments are all
|
|
41
|
+
// rejected: the charset is otherwise permissive and "." is a valid char.
|
|
42
|
+
if (segment === "" || segment === "." || segment === "..")
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return segments.join("/");
|
|
46
|
+
}
|
|
47
|
+
/** Canonical content type for a bundle file path, keyed on its final extension. */
|
|
48
|
+
export function contentTypeFor(relativePath) {
|
|
49
|
+
const ext = finalExtension(relativePath);
|
|
50
|
+
return CONTENT_TYPE_BY_EXTENSION[ext] ?? DEFAULT_CONTENT_TYPE;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=request-path.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"request-path.js","sourceRoot":"","sources":["../../../src/serve/request-path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAEvF,2EAA2E;AAC3E,MAAM,CAAC,MAAM,UAAU,GAAG,YAAY,CAAC;AAEvC,4EAA4E;AAC5E,MAAM,CAAC,MAAM,oBAAoB,GAAG,0BAA0B,CAAC;AAE/D;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,iBAAiB,CAAC,OAAe;IAC/C,qEAAqE;IACrE,MAAM,QAAQ,GAAG,OAAO,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAEnD,IAAI,OAAe,CAAC;IACpB,IAAI,CAAC;QACH,OAAO,GAAG,kBAAkB,CAAC,QAAQ,CAAC,CAAC;IACzC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC,CAAC,6BAA6B;IAC5C,CAAC;IAED,4EAA4E;IAC5E,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1C,IAAI,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAElE,yEAAyE;IACzE,MAAM,SAAS,GAAG,OAAO,KAAK,GAAG,IAAI,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,GAAG,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC;IAE5F,MAAM,QAAQ,GAAG,SAAS,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC1D,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,qEAAqE;QACrE,yEAAyE;QACzE,IAAI,OAAO,KAAK,EAAE,IAAI,OAAO,KAAK,GAAG,IAAI,OAAO,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;IACzE,CAAC;IACD,OAAO,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC5B,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,cAAc,CAAC,YAAoB;IACjD,MAAM,GAAG,GAAG,cAAc,CAAC,YAAY,CAA2C,CAAC;IACnF,OAAO,yBAAyB,CAAC,GAAG,CAAC,IAAI,oBAAoB,CAAC;AAChE,CAAC"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { CONTENT_TYPE_BY_EXTENSION, finalExtension } from "../pack/bundle-contract.js";
|
|
2
|
+
|
|
3
|
+
/** File served for a directory request ("/" or any path ending in "/"). */
|
|
4
|
+
export const INDEX_FILE = "index.html";
|
|
5
|
+
|
|
6
|
+
/** Content type for an extension the bundle contract does not recognise. */
|
|
7
|
+
export const DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve an incoming request URL path to a clean, bundle-relative file path —
|
|
11
|
+
* or null if the request is malformed or attempts to escape the bundle root.
|
|
12
|
+
*
|
|
13
|
+
* Pure: no filesystem access. The caller maps the returned relative path onto
|
|
14
|
+
* disk (and 404s if the file is absent). This is the security-critical core of
|
|
15
|
+
* the local server, so it is isolated and exhaustively unit-tested:
|
|
16
|
+
* - percent-decoding (rejecting malformed encodings),
|
|
17
|
+
* - directory requests mapped to {@link INDEX_FILE},
|
|
18
|
+
* - rejection of "", ".", ".." segments and backslash/NUL injection.
|
|
19
|
+
*
|
|
20
|
+
* A defence-in-depth absolute-path containment check still runs in the server
|
|
21
|
+
* after this resolves, but this function alone must never yield a traversing
|
|
22
|
+
* path.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveStaticPath(urlPath: string): string | null {
|
|
25
|
+
// Keep only the path; drop any query string or fragment defensively.
|
|
26
|
+
const pathOnly = urlPath.split(/[?#]/, 1)[0] ?? "";
|
|
27
|
+
|
|
28
|
+
let decoded: string;
|
|
29
|
+
try {
|
|
30
|
+
decoded = decodeURIComponent(pathOnly);
|
|
31
|
+
} catch {
|
|
32
|
+
return null; // malformed percent-encoding
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Requests are absolute-path form; reject separator/NUL injection outright.
|
|
36
|
+
if (!decoded.startsWith("/")) return null;
|
|
37
|
+
if (decoded.includes("\0") || decoded.includes("\\")) return null;
|
|
38
|
+
|
|
39
|
+
// A directory request ("/" or trailing slash) serves the index document.
|
|
40
|
+
const withIndex = decoded === "/" || decoded.endsWith("/") ? decoded + INDEX_FILE : decoded;
|
|
41
|
+
|
|
42
|
+
const segments = withIndex.replace(/^\/+/, "").split("/");
|
|
43
|
+
for (const segment of segments) {
|
|
44
|
+
// Empty (double-slash), current-dir, and parent-dir segments are all
|
|
45
|
+
// rejected: the charset is otherwise permissive and "." is a valid char.
|
|
46
|
+
if (segment === "" || segment === "." || segment === "..") return null;
|
|
47
|
+
}
|
|
48
|
+
return segments.join("/");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Canonical content type for a bundle file path, keyed on its final extension. */
|
|
52
|
+
export function contentTypeFor(relativePath: string): string {
|
|
53
|
+
const ext = finalExtension(relativePath) as keyof typeof CONTENT_TYPE_BY_EXTENSION;
|
|
54
|
+
return CONTENT_TYPE_BY_EXTENSION[ext] ?? DEFAULT_CONTENT_TYPE;
|
|
55
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Viewport } from "../pack/viewport.js";
|
|
2
|
+
import { type BundleServerHandle } from "./static-server.js";
|
|
3
|
+
/** Options for {@link runServe}. */
|
|
4
|
+
export interface RunServeOptions {
|
|
5
|
+
readonly bundleDir: string;
|
|
6
|
+
/** Port to bind; 0 lets the OS choose a free one. */
|
|
7
|
+
readonly port: number;
|
|
8
|
+
/** Host/interface to bind (default: localhost). */
|
|
9
|
+
readonly host?: string;
|
|
10
|
+
}
|
|
11
|
+
/** A running preview server plus the derived presentation details. */
|
|
12
|
+
export interface ServeResult {
|
|
13
|
+
/** The running server (call .close() to stop). */
|
|
14
|
+
readonly handle: BundleServerHandle;
|
|
15
|
+
/** The browser-clickable URL (https→http downgraded for *.localhost). */
|
|
16
|
+
readonly url: string;
|
|
17
|
+
/** A ready-to-paste responsive <iframe> snippet for this URL. */
|
|
18
|
+
readonly snippet: string;
|
|
19
|
+
/** The viewport used for the snippet (recorded, or the default). */
|
|
20
|
+
readonly viewport: Viewport;
|
|
21
|
+
/** Whether the snippet's viewport came from the bundle (vs. the default). */
|
|
22
|
+
readonly recordedViewport: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Validate a packed bundle directory and start serving it — the orchestration
|
|
26
|
+
* behind `scenar serve`, with no process/exit coupling so both the CLI command
|
|
27
|
+
* and the MCP server can call it. The returned handle keeps running until the
|
|
28
|
+
* caller closes it. Throws on an invalid bundle or an unavailable port.
|
|
29
|
+
*/
|
|
30
|
+
export declare function runServe(options: RunServeOptions): Promise<ServeResult>;
|
|
31
|
+
//# sourceMappingURL=run-serve.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run-serve.d.ts","sourceRoot":"","sources":["../../../src/serve/run-serve.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAqB,KAAK,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAKhF,oCAAoC;AACpC,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,qDAAqD;IACrD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,mDAAmD;IACnD,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,sEAAsE;AACtE,MAAM,WAAW,WAAW;IAC1B,kDAAkD;IAClD,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAC;IACpC,yEAAyE;IACzE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,iEAAiE;IACjE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC5B,6EAA6E;IAC7E,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;CACpC;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,WAAW,CAAC,CA2B7E"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { resolve, join } from "node:path";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { buildEmbedSnippet } from "../deploy/embed-snippet.js";
|
|
4
|
+
import { localViewUrl } from "../deploy/deploy-flow.js";
|
|
5
|
+
import { readBundleViewport } from "../bundle/read-viewport.js";
|
|
6
|
+
import { startBundleServer } from "./static-server.js";
|
|
7
|
+
/** The servable entry every packed bundle contains at its root. */
|
|
8
|
+
const INDEX_FILE = "index.html";
|
|
9
|
+
/**
|
|
10
|
+
* Validate a packed bundle directory and start serving it — the orchestration
|
|
11
|
+
* behind `scenar serve`, with no process/exit coupling so both the CLI command
|
|
12
|
+
* and the MCP server can call it. The returned handle keeps running until the
|
|
13
|
+
* caller closes it. Throws on an invalid bundle or an unavailable port.
|
|
14
|
+
*/
|
|
15
|
+
export async function runServe(options) {
|
|
16
|
+
const resolvedDir = resolve(options.bundleDir);
|
|
17
|
+
const info = await stat(resolvedDir).catch(() => null);
|
|
18
|
+
if (!info || !info.isDirectory()) {
|
|
19
|
+
throw new Error(`${options.bundleDir} is not a directory. Pass a bundle produced by \`scenar pack\`.`);
|
|
20
|
+
}
|
|
21
|
+
const indexInfo = await stat(join(resolvedDir, INDEX_FILE)).catch(() => null);
|
|
22
|
+
if (!indexInfo || !indexInfo.isFile()) {
|
|
23
|
+
throw new Error(`no ${INDEX_FILE} in ${options.bundleDir}. Run \`scenar pack\` to produce a servable bundle first.`);
|
|
24
|
+
}
|
|
25
|
+
const handle = await startBundleServer({
|
|
26
|
+
rootDir: resolvedDir,
|
|
27
|
+
port: options.port,
|
|
28
|
+
host: options.host,
|
|
29
|
+
});
|
|
30
|
+
const url = localViewUrl(handle.url);
|
|
31
|
+
const { viewport, recorded } = await readBundleViewport(resolvedDir);
|
|
32
|
+
const snippet = buildEmbedSnippet({ embedUrl: url, viewport });
|
|
33
|
+
return { handle, url, snippet, viewport, recordedViewport: recorded };
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=run-serve.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run-serve.js","sourceRoot":"","sources":["../../../src/serve/run-serve.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACxC,OAAO,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAC/D,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAEhE,OAAO,EAAE,iBAAiB,EAA2B,MAAM,oBAAoB,CAAC;AAEhF,mEAAmE;AACnE,MAAM,UAAU,GAAG,YAAY,CAAC;AAyBhC;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,OAAwB;IACrD,MAAM,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IAE/C,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;IACvD,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,CAAC;QACjC,MAAM,IAAI,KAAK,CACb,GAAG,OAAO,CAAC,SAAS,iEAAiE,CACtF,CAAC;IACJ,CAAC;IACD,MAAM,SAAS,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;IAC9E,IAAI,CAAC,SAAS,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,CAAC;QACtC,MAAM,IAAI,KAAK,CACb,MAAM,UAAU,OAAO,OAAO,CAAC,SAAS,2DAA2D,CACpG,CAAC;IACJ,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,iBAAiB,CAAC;QACrC,OAAO,EAAE,WAAW;QACpB,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,IAAI,EAAE,OAAO,CAAC,IAAI;KACnB,CAAC,CAAC;IAEH,MAAM,GAAG,GAAG,YAAY,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IACrC,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,MAAM,kBAAkB,CAAC,WAAW,CAAC,CAAC;IACrE,MAAM,OAAO,GAAG,iBAAiB,CAAC,EAAE,QAAQ,EAAE,GAAG,EAAE,QAAQ,EAAE,CAAC,CAAC;IAE/D,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,gBAAgB,EAAE,QAAQ,EAAE,CAAC;AACxE,CAAC"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { resolve, join } from "node:path";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { buildEmbedSnippet } from "../deploy/embed-snippet.js";
|
|
4
|
+
import { localViewUrl } from "../deploy/deploy-flow.js";
|
|
5
|
+
import { readBundleViewport } from "../bundle/read-viewport.js";
|
|
6
|
+
import type { Viewport } from "../pack/viewport.js";
|
|
7
|
+
import { startBundleServer, type BundleServerHandle } from "./static-server.js";
|
|
8
|
+
|
|
9
|
+
/** The servable entry every packed bundle contains at its root. */
|
|
10
|
+
const INDEX_FILE = "index.html";
|
|
11
|
+
|
|
12
|
+
/** Options for {@link runServe}. */
|
|
13
|
+
export interface RunServeOptions {
|
|
14
|
+
readonly bundleDir: string;
|
|
15
|
+
/** Port to bind; 0 lets the OS choose a free one. */
|
|
16
|
+
readonly port: number;
|
|
17
|
+
/** Host/interface to bind (default: localhost). */
|
|
18
|
+
readonly host?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A running preview server plus the derived presentation details. */
|
|
22
|
+
export interface ServeResult {
|
|
23
|
+
/** The running server (call .close() to stop). */
|
|
24
|
+
readonly handle: BundleServerHandle;
|
|
25
|
+
/** The browser-clickable URL (https→http downgraded for *.localhost). */
|
|
26
|
+
readonly url: string;
|
|
27
|
+
/** A ready-to-paste responsive <iframe> snippet for this URL. */
|
|
28
|
+
readonly snippet: string;
|
|
29
|
+
/** The viewport used for the snippet (recorded, or the default). */
|
|
30
|
+
readonly viewport: Viewport;
|
|
31
|
+
/** Whether the snippet's viewport came from the bundle (vs. the default). */
|
|
32
|
+
readonly recordedViewport: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate a packed bundle directory and start serving it — the orchestration
|
|
37
|
+
* behind `scenar serve`, with no process/exit coupling so both the CLI command
|
|
38
|
+
* and the MCP server can call it. The returned handle keeps running until the
|
|
39
|
+
* caller closes it. Throws on an invalid bundle or an unavailable port.
|
|
40
|
+
*/
|
|
41
|
+
export async function runServe(options: RunServeOptions): Promise<ServeResult> {
|
|
42
|
+
const resolvedDir = resolve(options.bundleDir);
|
|
43
|
+
|
|
44
|
+
const info = await stat(resolvedDir).catch(() => null);
|
|
45
|
+
if (!info || !info.isDirectory()) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`${options.bundleDir} is not a directory. Pass a bundle produced by \`scenar pack\`.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const indexInfo = await stat(join(resolvedDir, INDEX_FILE)).catch(() => null);
|
|
51
|
+
if (!indexInfo || !indexInfo.isFile()) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`no ${INDEX_FILE} in ${options.bundleDir}. Run \`scenar pack\` to produce a servable bundle first.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const handle = await startBundleServer({
|
|
58
|
+
rootDir: resolvedDir,
|
|
59
|
+
port: options.port,
|
|
60
|
+
host: options.host,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const url = localViewUrl(handle.url);
|
|
64
|
+
const { viewport, recorded } = await readBundleViewport(resolvedDir);
|
|
65
|
+
const snippet = buildEmbedSnippet({ embedUrl: url, viewport });
|
|
66
|
+
|
|
67
|
+
return { handle, url, snippet, viewport, recordedViewport: recorded };
|
|
68
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Server } from "node:http";
|
|
2
|
+
/** A running bundle server: where it is reachable and how to stop it. */
|
|
3
|
+
export interface BundleServerHandle {
|
|
4
|
+
/** The base URL the bundle is served at (e.g. http://localhost:4173/). */
|
|
5
|
+
readonly url: string;
|
|
6
|
+
/** The port the server actually bound (useful when port 0 was requested). */
|
|
7
|
+
readonly port: number;
|
|
8
|
+
/** Stop accepting connections and release the port. */
|
|
9
|
+
close(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
/** Options for {@link startBundleServer}. */
|
|
12
|
+
export interface StartBundleServerOptions {
|
|
13
|
+
/** Bundle directory to serve (a `scenar pack` output). */
|
|
14
|
+
readonly rootDir: string;
|
|
15
|
+
/** Port to bind; 0 lets the OS pick a free one (handy in tests). */
|
|
16
|
+
readonly port: number;
|
|
17
|
+
/** Host/interface to bind. Defaults to "localhost". */
|
|
18
|
+
readonly host?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Create (but do not start) an HTTP server that serves a packed bundle as a
|
|
22
|
+
* static site. Only GET/HEAD are allowed; every response carries the bundle
|
|
23
|
+
* contract's canonical content type and `Cache-Control: no-cache` (a local
|
|
24
|
+
* preview should always reflect the latest pack, never a stale asset).
|
|
25
|
+
*
|
|
26
|
+
* No CSP or other security headers are emitted: that envelope is the serving
|
|
27
|
+
* edge's responsibility (and is enforced + tested in scenar-cloud). A local
|
|
28
|
+
* preview deliberately matches the headerless behaviour of a plain static host
|
|
29
|
+
* such as GitHub Pages — the other Phase 1 target — so what you see locally is
|
|
30
|
+
* what a `scenar publish` visitor sees.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createBundleServer(rootDir: string): Server;
|
|
33
|
+
/**
|
|
34
|
+
* Start an HTTP server for a packed bundle and resolve once it is listening.
|
|
35
|
+
* Rejects if the port is already in use (the caller surfaces a clear message).
|
|
36
|
+
*/
|
|
37
|
+
export declare function startBundleServer(options: StartBundleServerOptions): Promise<BundleServerHandle>;
|
|
38
|
+
//# sourceMappingURL=static-server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"static-server.d.ts","sourceRoot":"","sources":["../../../src/serve/static-server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsC,KAAK,MAAM,EAAuB,MAAM,WAAW,CAAC;AAMjG,yEAAyE;AACzE,MAAM,WAAW,kBAAkB;IACjC,0EAA0E;IAC1E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,uDAAuD;IACvD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,6CAA6C;AAC7C,MAAM,WAAW,wBAAwB;IACvC,0DAA0D;IAC1D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,uDAAuD;IACvD,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAQ1D;AA4CD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAuBhG"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
|
+
import { resolve, sep } from "node:path";
|
|
5
|
+
import { contentTypeFor, resolveStaticPath } from "./request-path.js";
|
|
6
|
+
/**
|
|
7
|
+
* Create (but do not start) an HTTP server that serves a packed bundle as a
|
|
8
|
+
* static site. Only GET/HEAD are allowed; every response carries the bundle
|
|
9
|
+
* contract's canonical content type and `Cache-Control: no-cache` (a local
|
|
10
|
+
* preview should always reflect the latest pack, never a stale asset).
|
|
11
|
+
*
|
|
12
|
+
* No CSP or other security headers are emitted: that envelope is the serving
|
|
13
|
+
* edge's responsibility (and is enforced + tested in scenar-cloud). A local
|
|
14
|
+
* preview deliberately matches the headerless behaviour of a plain static host
|
|
15
|
+
* such as GitHub Pages — the other Phase 1 target — so what you see locally is
|
|
16
|
+
* what a `scenar publish` visitor sees.
|
|
17
|
+
*/
|
|
18
|
+
export function createBundleServer(rootDir) {
|
|
19
|
+
const root = resolve(rootDir);
|
|
20
|
+
return createServer((req, res) => {
|
|
21
|
+
void serveRequest(root, req, res).catch(() => {
|
|
22
|
+
if (!res.headersSent)
|
|
23
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
24
|
+
res.end("Internal Server Error");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async function serveRequest(root, req, res) {
|
|
29
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
30
|
+
res.writeHead(405, { allow: "GET, HEAD", "content-type": "text/plain" });
|
|
31
|
+
res.end("Method Not Allowed");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const relativePath = resolveStaticPath(req.url ?? "/");
|
|
35
|
+
if (relativePath === null) {
|
|
36
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
37
|
+
res.end("Bad Request");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const absolutePath = resolve(root, relativePath);
|
|
41
|
+
// Defence in depth: the resolved path must stay within the bundle root even
|
|
42
|
+
// if resolveStaticPath ever let something through.
|
|
43
|
+
if (absolutePath !== root && !absolutePath.startsWith(root + sep)) {
|
|
44
|
+
res.writeHead(403, { "content-type": "text/plain" });
|
|
45
|
+
res.end("Forbidden");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const info = await stat(absolutePath).catch(() => null);
|
|
49
|
+
if (!info || !info.isFile()) {
|
|
50
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
51
|
+
res.end("Not Found");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
res.writeHead(200, {
|
|
55
|
+
"content-type": contentTypeFor(relativePath),
|
|
56
|
+
"content-length": info.size,
|
|
57
|
+
"cache-control": "no-cache",
|
|
58
|
+
});
|
|
59
|
+
if (req.method === "HEAD") {
|
|
60
|
+
res.end();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
createReadStream(absolutePath).pipe(res);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Start an HTTP server for a packed bundle and resolve once it is listening.
|
|
67
|
+
* Rejects if the port is already in use (the caller surfaces a clear message).
|
|
68
|
+
*/
|
|
69
|
+
export function startBundleServer(options) {
|
|
70
|
+
const host = options.host ?? "localhost";
|
|
71
|
+
const server = createBundleServer(options.rootDir);
|
|
72
|
+
return new Promise((resolvePromise, reject) => {
|
|
73
|
+
const onError = (error) => reject(error);
|
|
74
|
+
server.once("error", onError);
|
|
75
|
+
server.listen(options.port, host, () => {
|
|
76
|
+
server.removeListener("error", onError);
|
|
77
|
+
const address = server.address();
|
|
78
|
+
const port = typeof address === "object" && address ? address.port : options.port;
|
|
79
|
+
// Wildcard binds are not dialable hostnames; advertise localhost instead.
|
|
80
|
+
const displayHost = host === "0.0.0.0" || host === "::" ? "localhost" : host;
|
|
81
|
+
resolvePromise({
|
|
82
|
+
url: `http://${displayHost}:${port}/`,
|
|
83
|
+
port,
|
|
84
|
+
close: () => new Promise((done, fail) => {
|
|
85
|
+
server.close((err) => (err ? fail(err) : done()));
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=static-server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"static-server.js","sourceRoot":"","sources":["../../../src/serve/static-server.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
|
+
import { resolve, sep } from "node:path";
|
|
5
|
+
import { contentTypeFor, resolveStaticPath } from "./request-path.js";
|
|
6
|
+
|
|
7
|
+
/** A running bundle server: where it is reachable and how to stop it. */
|
|
8
|
+
export interface BundleServerHandle {
|
|
9
|
+
/** The base URL the bundle is served at (e.g. http://localhost:4173/). */
|
|
10
|
+
readonly url: string;
|
|
11
|
+
/** The port the server actually bound (useful when port 0 was requested). */
|
|
12
|
+
readonly port: number;
|
|
13
|
+
/** Stop accepting connections and release the port. */
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Options for {@link startBundleServer}. */
|
|
18
|
+
export interface StartBundleServerOptions {
|
|
19
|
+
/** Bundle directory to serve (a `scenar pack` output). */
|
|
20
|
+
readonly rootDir: string;
|
|
21
|
+
/** Port to bind; 0 lets the OS pick a free one (handy in tests). */
|
|
22
|
+
readonly port: number;
|
|
23
|
+
/** Host/interface to bind. Defaults to "localhost". */
|
|
24
|
+
readonly host?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create (but do not start) an HTTP server that serves a packed bundle as a
|
|
29
|
+
* static site. Only GET/HEAD are allowed; every response carries the bundle
|
|
30
|
+
* contract's canonical content type and `Cache-Control: no-cache` (a local
|
|
31
|
+
* preview should always reflect the latest pack, never a stale asset).
|
|
32
|
+
*
|
|
33
|
+
* No CSP or other security headers are emitted: that envelope is the serving
|
|
34
|
+
* edge's responsibility (and is enforced + tested in scenar-cloud). A local
|
|
35
|
+
* preview deliberately matches the headerless behaviour of a plain static host
|
|
36
|
+
* such as GitHub Pages — the other Phase 1 target — so what you see locally is
|
|
37
|
+
* what a `scenar publish` visitor sees.
|
|
38
|
+
*/
|
|
39
|
+
export function createBundleServer(rootDir: string): Server {
|
|
40
|
+
const root = resolve(rootDir);
|
|
41
|
+
return createServer((req, res) => {
|
|
42
|
+
void serveRequest(root, req, res).catch(() => {
|
|
43
|
+
if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain" });
|
|
44
|
+
res.end("Internal Server Error");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function serveRequest(root: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
50
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
51
|
+
res.writeHead(405, { allow: "GET, HEAD", "content-type": "text/plain" });
|
|
52
|
+
res.end("Method Not Allowed");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const relativePath = resolveStaticPath(req.url ?? "/");
|
|
57
|
+
if (relativePath === null) {
|
|
58
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
59
|
+
res.end("Bad Request");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const absolutePath = resolve(root, relativePath);
|
|
64
|
+
// Defence in depth: the resolved path must stay within the bundle root even
|
|
65
|
+
// if resolveStaticPath ever let something through.
|
|
66
|
+
if (absolutePath !== root && !absolutePath.startsWith(root + sep)) {
|
|
67
|
+
res.writeHead(403, { "content-type": "text/plain" });
|
|
68
|
+
res.end("Forbidden");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const info = await stat(absolutePath).catch(() => null);
|
|
73
|
+
if (!info || !info.isFile()) {
|
|
74
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
75
|
+
res.end("Not Found");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
res.writeHead(200, {
|
|
80
|
+
"content-type": contentTypeFor(relativePath),
|
|
81
|
+
"content-length": info.size,
|
|
82
|
+
"cache-control": "no-cache",
|
|
83
|
+
});
|
|
84
|
+
if (req.method === "HEAD") {
|
|
85
|
+
res.end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
createReadStream(absolutePath).pipe(res);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Start an HTTP server for a packed bundle and resolve once it is listening.
|
|
93
|
+
* Rejects if the port is already in use (the caller surfaces a clear message).
|
|
94
|
+
*/
|
|
95
|
+
export function startBundleServer(options: StartBundleServerOptions): Promise<BundleServerHandle> {
|
|
96
|
+
const host = options.host ?? "localhost";
|
|
97
|
+
const server = createBundleServer(options.rootDir);
|
|
98
|
+
|
|
99
|
+
return new Promise<BundleServerHandle>((resolvePromise, reject) => {
|
|
100
|
+
const onError = (error: Error) => reject(error);
|
|
101
|
+
server.once("error", onError);
|
|
102
|
+
server.listen(options.port, host, () => {
|
|
103
|
+
server.removeListener("error", onError);
|
|
104
|
+
const address = server.address();
|
|
105
|
+
const port = typeof address === "object" && address ? address.port : options.port;
|
|
106
|
+
// Wildcard binds are not dialable hostnames; advertise localhost instead.
|
|
107
|
+
const displayHost = host === "0.0.0.0" || host === "::" ? "localhost" : host;
|
|
108
|
+
resolvePromise({
|
|
109
|
+
url: `http://${displayHost}:${port}/`,
|
|
110
|
+
port,
|
|
111
|
+
close: () =>
|
|
112
|
+
new Promise<void>((done, fail) => {
|
|
113
|
+
server.close((err) => (err ? fail(err) : done()));
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|