@scenar/cli 0.1.20 → 0.2.1

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 (112) hide show
  1. package/example-bundle/assets/index-BLQ14SEo.js +147 -0
  2. package/example-bundle/assets/logo-f-5LjVqJ.png +0 -0
  3. package/example-bundle/assets/style-Dnzx_jUO.css +1 -0
  4. package/example-bundle/index.html +13 -0
  5. package/example-bundle/pack-manifest.json +36 -0
  6. package/example-bundle/scenario.json +9 -0
  7. package/package.json +17 -5
  8. package/src/__tests__/pack-generate-embed-entry.test.ts +59 -6
  9. package/src/__tests__/publish-command.test.ts +46 -0
  10. package/src/__tests__/publish-flow.test.ts +220 -0
  11. package/src/__tests__/read-viewport.test.ts +45 -0
  12. package/src/__tests__/run-publish.test.ts +92 -0
  13. package/src/__tests__/serve-command.test.ts +44 -0
  14. package/src/__tests__/serve-request-path.test.ts +67 -0
  15. package/src/__tests__/serve-static-server.test.ts +88 -0
  16. package/src/__tests__/try-command.test.ts +21 -0
  17. package/src/api.d.ts +31 -0
  18. package/src/api.d.ts.map +1 -0
  19. package/src/api.js +28 -0
  20. package/src/api.js.map +1 -0
  21. package/src/api.ts +55 -0
  22. package/src/bundle/read-viewport.d.ts +23 -0
  23. package/src/bundle/read-viewport.d.ts.map +1 -0
  24. package/src/bundle/read-viewport.js +25 -0
  25. package/src/bundle/read-viewport.js.map +1 -0
  26. package/src/bundle/read-viewport.ts +36 -0
  27. package/src/commands/deploy.d.ts.map +1 -1
  28. package/src/commands/deploy.js +8 -24
  29. package/src/commands/deploy.js.map +1 -1
  30. package/src/commands/deploy.ts +10 -28
  31. package/src/commands/narrate.d.ts.map +1 -1
  32. package/src/commands/narrate.js +39 -170
  33. package/src/commands/narrate.js.map +1 -1
  34. package/src/commands/narrate.ts +48 -289
  35. package/src/commands/pack.d.ts.map +1 -1
  36. package/src/commands/pack.js +12 -112
  37. package/src/commands/pack.js.map +1 -1
  38. package/src/commands/pack.ts +12 -128
  39. package/src/commands/publish.d.ts +3 -0
  40. package/src/commands/publish.d.ts.map +1 -0
  41. package/src/commands/publish.js +64 -0
  42. package/src/commands/publish.js.map +1 -0
  43. package/src/commands/publish.ts +86 -0
  44. package/src/commands/render.d.ts.map +1 -1
  45. package/src/commands/render.js +12 -194
  46. package/src/commands/render.js.map +1 -1
  47. package/src/commands/render.ts +19 -286
  48. package/src/commands/serve.d.ts +3 -0
  49. package/src/commands/serve.d.ts.map +1 -0
  50. package/src/commands/serve.js +91 -0
  51. package/src/commands/serve.js.map +1 -0
  52. package/src/commands/serve.ts +112 -0
  53. package/src/commands/try.d.ts +3 -0
  54. package/src/commands/try.d.ts.map +1 -0
  55. package/src/commands/try.js +99 -0
  56. package/src/commands/try.js.map +1 -0
  57. package/src/commands/try.ts +122 -0
  58. package/src/index.d.ts.map +1 -1
  59. package/src/index.js +6 -0
  60. package/src/index.js.map +1 -1
  61. package/src/index.ts +6 -0
  62. package/src/narrate/run-narrate.d.ts +43 -0
  63. package/src/narrate/run-narrate.d.ts.map +1 -0
  64. package/src/narrate/run-narrate.js +174 -0
  65. package/src/narrate/run-narrate.js.map +1 -0
  66. package/src/narrate/run-narrate.ts +298 -0
  67. package/src/pack/generate-embed-entry.d.ts +11 -0
  68. package/src/pack/generate-embed-entry.d.ts.map +1 -1
  69. package/src/pack/generate-embed-entry.js +81 -10
  70. package/src/pack/generate-embed-entry.js.map +1 -1
  71. package/src/pack/generate-embed-entry.ts +81 -10
  72. package/src/pack/run-pack.d.ts +34 -0
  73. package/src/pack/run-pack.d.ts.map +1 -0
  74. package/src/pack/run-pack.js +117 -0
  75. package/src/pack/run-pack.js.map +1 -0
  76. package/src/pack/run-pack.ts +163 -0
  77. package/src/publish/github.d.ts +11 -0
  78. package/src/publish/github.d.ts.map +1 -0
  79. package/src/publish/github.js +197 -0
  80. package/src/publish/github.js.map +1 -0
  81. package/src/publish/github.ts +216 -0
  82. package/src/publish/publish-flow.d.ts +110 -0
  83. package/src/publish/publish-flow.d.ts.map +1 -0
  84. package/src/publish/publish-flow.js +117 -0
  85. package/src/publish/publish-flow.js.map +1 -0
  86. package/src/publish/publish-flow.ts +199 -0
  87. package/src/publish/run-publish.d.ts +41 -0
  88. package/src/publish/run-publish.d.ts.map +1 -0
  89. package/src/publish/run-publish.js +77 -0
  90. package/src/publish/run-publish.js.map +1 -0
  91. package/src/publish/run-publish.ts +131 -0
  92. package/src/render/run-render.d.ts +31 -0
  93. package/src/render/run-render.d.ts.map +1 -0
  94. package/src/render/run-render.js +196 -0
  95. package/src/render/run-render.js.map +1 -0
  96. package/src/render/run-render.ts +287 -0
  97. package/src/serve/request-path.d.ts +23 -0
  98. package/src/serve/request-path.d.ts.map +1 -0
  99. package/src/serve/request-path.js +52 -0
  100. package/src/serve/request-path.js.map +1 -0
  101. package/src/serve/request-path.ts +55 -0
  102. package/src/serve/run-serve.d.ts +31 -0
  103. package/src/serve/run-serve.d.ts.map +1 -0
  104. package/src/serve/run-serve.js +35 -0
  105. package/src/serve/run-serve.js.map +1 -0
  106. package/src/serve/run-serve.ts +68 -0
  107. package/src/serve/static-server.d.ts +38 -0
  108. package/src/serve/static-server.d.ts.map +1 -0
  109. package/src/serve/static-server.js +91 -0
  110. package/src/serve/static-server.js.map +1 -0
  111. package/src/serve/static-server.ts +118 -0
  112. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,287 @@
1
+ import { resolve, join } from "node:path";
2
+ import { stat, mkdir, access, writeFile, rm } from "node:fs/promises";
3
+ import { pathToFileURL } from "node:url";
4
+ import { loadBundle, type CliBundle } from "../util/load-bundle.js";
5
+ import { generateRemotionEntry } from "./generate-entry.js";
6
+ import { resolveProvidersPath } from "./resolve-providers.js";
7
+ import { detectRenderExport } from "./detect-render-export.js";
8
+
9
+ /** Options for {@link runRender}. Paths may be relative; resolved here. */
10
+ export interface RunRenderOptions {
11
+ readonly scenarioDir: string;
12
+ readonly out?: string;
13
+ readonly fps?: number;
14
+ readonly width?: number;
15
+ readonly height?: number;
16
+ readonly compositionId?: string;
17
+ readonly entry?: string;
18
+ readonly webpackOverride?: string;
19
+ readonly onLog?: (message: string) => void;
20
+ }
21
+
22
+ /** The outcome of a successful render. */
23
+ export interface RenderResult {
24
+ readonly scenarioId: string;
25
+ readonly outputPath: string;
26
+ readonly steps: number;
27
+ readonly hasNarration: boolean;
28
+ readonly entryAutoGenerated: boolean;
29
+ readonly fps: number;
30
+ readonly width: number;
31
+ readonly height: number;
32
+ }
33
+
34
+ /**
35
+ * Render a scenario directory to an MP4 via Remotion — the pure orchestration
36
+ * behind `scenar render`, with no process/exit coupling. Throws on any failure
37
+ * (missing directory, missing Remotion peer deps, render error). The caller
38
+ * formats the result/error.
39
+ */
40
+ export async function runRender(options: RunRenderOptions): Promise<RenderResult> {
41
+ const onLog = options.onLog ?? (() => {});
42
+ const scenarioDir = resolve(options.scenarioDir);
43
+
44
+ const info = await stat(scenarioDir).catch(() => null);
45
+ if (!info) {
46
+ throw new Error(`${options.scenarioDir} does not exist.`);
47
+ }
48
+ if (!info.isDirectory()) {
49
+ throw new Error(
50
+ `${options.scenarioDir} is not a directory. ` +
51
+ "The render command requires a scenario directory (with steps.ts).",
52
+ );
53
+ }
54
+
55
+ const fps = options.fps ?? 30;
56
+ const width = options.width ?? 1920;
57
+ const height = options.height ?? 1080;
58
+
59
+ let tempDir: string | undefined;
60
+ try {
61
+ const bundle = await loadBundle(scenarioDir);
62
+ const scenarioId = bundle.id;
63
+ const compositionId = options.compositionId ?? scenarioId;
64
+ const outputPath = resolveOutputPath(options.out, scenarioId);
65
+
66
+ const { entryPoint, generated } = await resolveEntryPoint({
67
+ entryOption: options.entry,
68
+ scenarioDir,
69
+ scenarioId,
70
+ compositionId,
71
+ hasNarration: !!bundle.narrationManifest,
72
+ fps,
73
+ width,
74
+ height,
75
+ });
76
+ tempDir = generated?.tempDir;
77
+
78
+ const webpackOverride = options.webpackOverride
79
+ ? await loadWebpackOverride(options.webpackOverride)
80
+ : undefined;
81
+
82
+ onLog(`Scenario: ${scenarioId}`);
83
+ onLog(`Steps: ${bundle.steps.length}`);
84
+ onLog(`Audio: ${bundle.narrationManifest ? "yes (manifest found)" : "none"}`);
85
+ onLog(`Entry: ${generated ? "(auto-generated)" : entryPoint}`);
86
+ if (generated?.providersPath) {
87
+ onLog(`Providers: ${generated.providersPath}`);
88
+ }
89
+ if (webpackOverride) {
90
+ onLog("Webpack: custom override loaded");
91
+ }
92
+ onLog(`Output: ${outputPath}`);
93
+ onLog(`Config: ${width}x${height} @ ${fps}fps`);
94
+
95
+ await renderScenario({
96
+ bundle,
97
+ entryPoint,
98
+ compositionId,
99
+ outputPath,
100
+ fps,
101
+ width,
102
+ height,
103
+ webpackOverride,
104
+ onLog,
105
+ });
106
+
107
+ return {
108
+ scenarioId,
109
+ outputPath,
110
+ steps: bundle.steps.length,
111
+ hasNarration: !!bundle.narrationManifest,
112
+ entryAutoGenerated: !!generated,
113
+ fps,
114
+ width,
115
+ height,
116
+ };
117
+ } finally {
118
+ if (tempDir) {
119
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
120
+ }
121
+ }
122
+ }
123
+
124
+ function resolveOutputPath(outOption: string | undefined, scenarioId: string): string {
125
+ if (!outOption) {
126
+ return resolve(`./${scenarioId}.mp4`);
127
+ }
128
+ if (outOption.endsWith(".mp4")) {
129
+ return resolve(outOption);
130
+ }
131
+ return resolve(join(outOption, `${scenarioId}.mp4`));
132
+ }
133
+
134
+ interface EntryResolutionInput {
135
+ entryOption: string | undefined;
136
+ scenarioDir: string;
137
+ scenarioId: string;
138
+ compositionId: string;
139
+ hasNarration: boolean;
140
+ fps: number;
141
+ width: number;
142
+ height: number;
143
+ }
144
+
145
+ interface EntryResolutionResult {
146
+ entryPoint: string;
147
+ generated?: {
148
+ tempDir: string;
149
+ providersPath: string | null;
150
+ };
151
+ }
152
+
153
+ async function resolveEntryPoint(input: EntryResolutionInput): Promise<EntryResolutionResult> {
154
+ // Explicit --entry: use as-is, no generation.
155
+ if (input.entryOption) {
156
+ const resolved = resolve(input.entryOption);
157
+ try {
158
+ await access(resolved);
159
+ } catch {
160
+ throw new Error(`Remotion entry point not found: ${input.entryOption}`);
161
+ }
162
+ return { entryPoint: resolved };
163
+ }
164
+
165
+ // Auto-generate: detect renderStep export, find providers, write temp entry.
166
+ const renderFilePath = await detectRenderExport(input.scenarioDir);
167
+ const providersPath = await resolveProvidersPath(input.scenarioDir);
168
+
169
+ const entrySource = generateRemotionEntry({
170
+ scenarioDir: input.scenarioDir,
171
+ renderFilePath,
172
+ scenarioId: input.scenarioId,
173
+ hasNarration: input.hasNarration,
174
+ providersPath,
175
+ fps: input.fps,
176
+ width: input.width,
177
+ height: input.height,
178
+ compositionId: input.compositionId,
179
+ });
180
+
181
+ // Write the entry inside the scenario directory so webpack's standard
182
+ // node_modules resolution walks up from here into the consumer project.
183
+ // A /tmp/ location would fail because no node_modules exist there.
184
+ const tempDir = join(input.scenarioDir, ".scenar-render");
185
+ await mkdir(tempDir, { recursive: true });
186
+
187
+ const entryPath = join(tempDir, "index.tsx");
188
+ await writeFile(entryPath, entrySource, "utf-8");
189
+
190
+ return { entryPoint: entryPath, generated: { tempDir, providersPath } };
191
+ }
192
+
193
+ async function loadWebpackOverride(overridePath: string): Promise<(config: unknown) => unknown> {
194
+ const resolved = resolve(overridePath);
195
+ try {
196
+ await access(resolved);
197
+ } catch {
198
+ throw new Error(`Webpack override file not found: ${overridePath}`);
199
+ }
200
+
201
+ let mod: Record<string, unknown>;
202
+ try {
203
+ mod = (await import(pathToFileURL(resolved).href)) as Record<string, unknown>;
204
+ } catch (err) {
205
+ const detail = err instanceof Error ? err.message : String(err);
206
+ throw new Error(`Failed to import webpack override from ${overridePath}:\n${detail}`);
207
+ }
208
+
209
+ const override = (mod.default ?? mod.webpackOverride) as unknown;
210
+ if (typeof override !== "function") {
211
+ throw new Error(
212
+ `${overridePath} does not export a webpack override function.\n\n` +
213
+ "Expected a default export or named 'webpackOverride' export:\n\n" +
214
+ ' import type { WebpackOverrideFn } from "@remotion/bundler";\n\n' +
215
+ " const webpackOverride: WebpackOverrideFn = (config) => ({\n" +
216
+ " ...config,\n" +
217
+ " // your overrides\n" +
218
+ " });\n\n" +
219
+ " export default webpackOverride;\n",
220
+ );
221
+ }
222
+
223
+ return override as (config: unknown) => unknown;
224
+ }
225
+
226
+ interface RenderConfig {
227
+ bundle: CliBundle;
228
+ entryPoint: string;
229
+ compositionId: string;
230
+ outputPath: string;
231
+ fps: number;
232
+ width: number;
233
+ height: number;
234
+ webpackOverride?: (config: unknown) => unknown;
235
+ onLog: (message: string) => void;
236
+ }
237
+
238
+ async function renderScenario(config: RenderConfig): Promise<void> {
239
+ const bundler = await import("@remotion/bundler").catch(() => {
240
+ throw new Error(
241
+ "Could not load @remotion/bundler.\nInstall it: pnpm add @remotion/bundler@4.0.448",
242
+ );
243
+ });
244
+
245
+ const renderer = await import("@remotion/renderer").catch(() => {
246
+ throw new Error(
247
+ "Could not load @remotion/renderer.\nInstall it: pnpm add @remotion/renderer@4.0.448",
248
+ );
249
+ });
250
+
251
+ config.onLog("Bundling Remotion project...");
252
+
253
+ const bundleOptions: Record<string, unknown> = { entryPoint: config.entryPoint };
254
+ if (config.webpackOverride) {
255
+ bundleOptions.webpackOverride = config.webpackOverride;
256
+ }
257
+ const serveUrl = await (bundler.bundle as (opts: Record<string, unknown>) => Promise<string>)(
258
+ bundleOptions,
259
+ );
260
+
261
+ config.onLog(`Selecting composition: ${config.compositionId}`);
262
+ const composition = await renderer.selectComposition({
263
+ serveUrl,
264
+ id: config.compositionId,
265
+ });
266
+
267
+ config.onLog(
268
+ `Composition: ${composition.width}x${composition.height} @ ${composition.fps}fps, ` +
269
+ `${composition.durationInFrames} frames ` +
270
+ `(${(composition.durationInFrames / composition.fps).toFixed(1)}s)`,
271
+ );
272
+
273
+ const outDir = resolve(config.outputPath, "..");
274
+ await mkdir(outDir, { recursive: true });
275
+
276
+ config.onLog("Rendering...");
277
+ await renderer.renderMedia({
278
+ composition,
279
+ serveUrl,
280
+ codec: "h264" as const,
281
+ outputLocation: config.outputPath,
282
+ onProgress: ({ progress }: { progress: number }) => {
283
+ const pct = Math.round(progress * 100);
284
+ config.onLog(` Progress: ${pct}%`);
285
+ },
286
+ });
287
+ }
@@ -0,0 +1,23 @@
1
+ /** File served for a directory request ("/" or any path ending in "/"). */
2
+ export declare const INDEX_FILE = "index.html";
3
+ /** Content type for an extension the bundle contract does not recognise. */
4
+ export declare const DEFAULT_CONTENT_TYPE = "application/octet-stream";
5
+ /**
6
+ * Resolve an incoming request URL path to a clean, bundle-relative file path —
7
+ * or null if the request is malformed or attempts to escape the bundle root.
8
+ *
9
+ * Pure: no filesystem access. The caller maps the returned relative path onto
10
+ * disk (and 404s if the file is absent). This is the security-critical core of
11
+ * the local server, so it is isolated and exhaustively unit-tested:
12
+ * - percent-decoding (rejecting malformed encodings),
13
+ * - directory requests mapped to {@link INDEX_FILE},
14
+ * - rejection of "", ".", ".." segments and backslash/NUL injection.
15
+ *
16
+ * A defence-in-depth absolute-path containment check still runs in the server
17
+ * after this resolves, but this function alone must never yield a traversing
18
+ * path.
19
+ */
20
+ export declare function resolveStaticPath(urlPath: string): string | null;
21
+ /** Canonical content type for a bundle file path, keyed on its final extension. */
22
+ export declare function contentTypeFor(relativePath: string): string;
23
+ //# sourceMappingURL=request-path.d.ts.map
@@ -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"}