@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.
Files changed (94) hide show
  1. package/package.json +17 -5
  2. package/src/__tests__/publish-command.test.ts +46 -0
  3. package/src/__tests__/publish-flow.test.ts +220 -0
  4. package/src/__tests__/read-viewport.test.ts +45 -0
  5. package/src/__tests__/run-publish.test.ts +92 -0
  6. package/src/__tests__/serve-command.test.ts +44 -0
  7. package/src/__tests__/serve-request-path.test.ts +67 -0
  8. package/src/__tests__/serve-static-server.test.ts +88 -0
  9. package/src/api.d.ts +31 -0
  10. package/src/api.d.ts.map +1 -0
  11. package/src/api.js +28 -0
  12. package/src/api.js.map +1 -0
  13. package/src/api.ts +55 -0
  14. package/src/bundle/read-viewport.d.ts +23 -0
  15. package/src/bundle/read-viewport.d.ts.map +1 -0
  16. package/src/bundle/read-viewport.js +25 -0
  17. package/src/bundle/read-viewport.js.map +1 -0
  18. package/src/bundle/read-viewport.ts +36 -0
  19. package/src/commands/deploy.d.ts.map +1 -1
  20. package/src/commands/deploy.js +8 -24
  21. package/src/commands/deploy.js.map +1 -1
  22. package/src/commands/deploy.ts +10 -28
  23. package/src/commands/narrate.d.ts.map +1 -1
  24. package/src/commands/narrate.js +39 -170
  25. package/src/commands/narrate.js.map +1 -1
  26. package/src/commands/narrate.ts +48 -289
  27. package/src/commands/pack.d.ts.map +1 -1
  28. package/src/commands/pack.js +12 -112
  29. package/src/commands/pack.js.map +1 -1
  30. package/src/commands/pack.ts +12 -128
  31. package/src/commands/publish.d.ts +3 -0
  32. package/src/commands/publish.d.ts.map +1 -0
  33. package/src/commands/publish.js +64 -0
  34. package/src/commands/publish.js.map +1 -0
  35. package/src/commands/publish.ts +86 -0
  36. package/src/commands/render.d.ts.map +1 -1
  37. package/src/commands/render.js +12 -194
  38. package/src/commands/render.js.map +1 -1
  39. package/src/commands/render.ts +19 -286
  40. package/src/commands/serve.d.ts +3 -0
  41. package/src/commands/serve.d.ts.map +1 -0
  42. package/src/commands/serve.js +91 -0
  43. package/src/commands/serve.js.map +1 -0
  44. package/src/commands/serve.ts +112 -0
  45. package/src/index.d.ts.map +1 -1
  46. package/src/index.js +4 -0
  47. package/src/index.js.map +1 -1
  48. package/src/index.ts +4 -0
  49. package/src/narrate/run-narrate.d.ts +43 -0
  50. package/src/narrate/run-narrate.d.ts.map +1 -0
  51. package/src/narrate/run-narrate.js +174 -0
  52. package/src/narrate/run-narrate.js.map +1 -0
  53. package/src/narrate/run-narrate.ts +298 -0
  54. package/src/pack/run-pack.d.ts +34 -0
  55. package/src/pack/run-pack.d.ts.map +1 -0
  56. package/src/pack/run-pack.js +117 -0
  57. package/src/pack/run-pack.js.map +1 -0
  58. package/src/pack/run-pack.ts +163 -0
  59. package/src/publish/github.d.ts +11 -0
  60. package/src/publish/github.d.ts.map +1 -0
  61. package/src/publish/github.js +197 -0
  62. package/src/publish/github.js.map +1 -0
  63. package/src/publish/github.ts +216 -0
  64. package/src/publish/publish-flow.d.ts +110 -0
  65. package/src/publish/publish-flow.d.ts.map +1 -0
  66. package/src/publish/publish-flow.js +117 -0
  67. package/src/publish/publish-flow.js.map +1 -0
  68. package/src/publish/publish-flow.ts +199 -0
  69. package/src/publish/run-publish.d.ts +41 -0
  70. package/src/publish/run-publish.d.ts.map +1 -0
  71. package/src/publish/run-publish.js +77 -0
  72. package/src/publish/run-publish.js.map +1 -0
  73. package/src/publish/run-publish.ts +131 -0
  74. package/src/render/run-render.d.ts +31 -0
  75. package/src/render/run-render.d.ts.map +1 -0
  76. package/src/render/run-render.js +196 -0
  77. package/src/render/run-render.js.map +1 -0
  78. package/src/render/run-render.ts +287 -0
  79. package/src/serve/request-path.d.ts +23 -0
  80. package/src/serve/request-path.d.ts.map +1 -0
  81. package/src/serve/request-path.js +52 -0
  82. package/src/serve/request-path.js.map +1 -0
  83. package/src/serve/request-path.ts +55 -0
  84. package/src/serve/run-serve.d.ts +31 -0
  85. package/src/serve/run-serve.d.ts.map +1 -0
  86. package/src/serve/run-serve.js +35 -0
  87. package/src/serve/run-serve.js.map +1 -0
  88. package/src/serve/run-serve.ts +68 -0
  89. package/src/serve/static-server.d.ts +38 -0
  90. package/src/serve/static-server.d.ts.map +1 -0
  91. package/src/serve/static-server.js +91 -0
  92. package/src/serve/static-server.js.map +1 -0
  93. package/src/serve/static-server.ts +118 -0
  94. 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
+ }