@opencraw/studio 0.0.2

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 (32) hide show
  1. package/README.md +11 -0
  2. package/bin/opencraw-studio.mjs +4 -0
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.esm.js +886 -0
  5. package/dist/src/index.d.ts +10 -0
  6. package/dist/src/main.d.ts +11 -0
  7. package/dist/src/page-snapshot/fetch-start-page.use-case.d.ts +20 -0
  8. package/dist/src/page-snapshot/index.d.ts +2 -0
  9. package/dist/src/recipe-workspace/index.d.ts +3 -0
  10. package/dist/src/recipe-workspace/open-workspace.use-case.d.ts +15 -0
  11. package/dist/src/recipe-workspace/save-recipe.use-case.d.ts +16 -0
  12. package/dist/src/sample-run/index.d.ts +6 -0
  13. package/dist/src/sample-run/load-recipe-pair.use-case.d.ts +18 -0
  14. package/dist/src/sample-run/run-sample.use-case.d.ts +30 -0
  15. package/dist/src/sample-run/sample-run-record.contract.d.ts +19 -0
  16. package/dist/src/studio-api/command.contract.d.ts +67 -0
  17. package/dist/src/studio-api/event.contract.d.ts +70 -0
  18. package/dist/src/studio-api/index.d.ts +11 -0
  19. package/dist/src/studio-api/sample-run-view.contract.d.ts +28 -0
  20. package/dist/src/studio-api/start-page-view.contract.d.ts +7 -0
  21. package/dist/src/studio-api/workspace-view.contract.d.ts +55 -0
  22. package/dist/src/studio-server/access-token.policy.d.ts +26 -0
  23. package/dist/src/studio-server/fetch-start-page.handler.d.ts +14 -0
  24. package/dist/src/studio-server/index.d.ts +3 -0
  25. package/dist/src/studio-server/open-workspace.handler.d.ts +13 -0
  26. package/dist/src/studio-server/run-sample.handler.d.ts +28 -0
  27. package/dist/src/studio-server/save-recipe.handler.d.ts +15 -0
  28. package/dist/src/studio-server/static-file.handler.d.ts +14 -0
  29. package/dist/src/studio-server/studio-http.use-case.d.ts +32 -0
  30. package/dist/src/studio-server/websocket.client.d.ts +22 -0
  31. package/dist/src/studio-server/workspace.store.d.ts +21 -0
  32. package/package.json +80 -0
@@ -0,0 +1,886 @@
1
+ import { normalize, sep, join, extname, dirname, resolve } from 'node:path';
2
+ import { createServer } from 'node:http';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { z } from 'zod';
5
+ import { randomBytes, createHash } from 'node:crypto';
6
+ import { BrowserClient, HttpClient, readRecipeSource, parseInputRecipe, parseOutputRecipe, bindRecipeSet, memorySink, createCrawler, traceLine, RecipeBindingError, RecipeValidationError } from '@opencraw/core';
7
+ import { writeFile } from 'node:fs/promises';
8
+ import { existsSync, createReadStream } from 'node:fs';
9
+
10
+ /** A budget for a sample run; never authored into a recipe, only asked for by the caller. */
11
+ const sampleBudgetSchema = z.object({
12
+ maxRecords: z.number().int().positive().optional(),
13
+ maxPages: z.number().int().positive().optional(),
14
+ maxMs: z.number().int().positive().optional()
15
+ });
16
+ /** Opens a recipe folder: the server reads it and answers with a `WorkspaceView`. */
17
+ const openWorkspaceCommandSchema = z.object({
18
+ type: z.literal('open-workspace'),
19
+ folder: z.string().min(1)
20
+ });
21
+ /** Runs one input recipe under a budget, streaming `trace-line` and `record` events, ending in `run-finished`. */
22
+ const runSampleCommandSchema = z.object({
23
+ type: z.literal('run-sample'),
24
+ recipeId: z.string().min(1),
25
+ budget: sampleBudgetSchema.optional()
26
+ });
27
+ /** Closes the crawler of the run in progress, if any; a no-op otherwise. */
28
+ const stopRunCommandSchema = z.object({
29
+ type: z.literal('stop-run')
30
+ });
31
+ /** Writes a recipe file: the whole object, pretty-printed by the server (see `recipe-workspace`). */
32
+ const saveRecipeCommandSchema = z.object({
33
+ type: z.literal('save-recipe'),
34
+ path: z.string().min(1),
35
+ recipe: z.record(z.string(), z.unknown())
36
+ });
37
+ /** Fetches the HTML of an input recipe's start page, as the engine would see it. */
38
+ const fetchStartPageCommandSchema = z.object({
39
+ type: z.literal('fetch-start-page'),
40
+ recipeId: z.string().min(1)
41
+ });
42
+ const studioCommandSchema = z.discriminatedUnion('type', [openWorkspaceCommandSchema, runSampleCommandSchema, stopRunCommandSchema, saveRecipeCommandSchema, fetchStartPageCommandSchema]);
43
+
44
+ /** Why a sample run stopped before finishing on its own; mirrors `@opencraw/core`'s `RecipeReport.stoppedBy`. */
45
+ const stoppedBySchema = z.enum(['sample-maxRecords', 'sample-maxPages', 'sample-maxMs']);
46
+ /** One line of the engine's own trace output (`traceLine`), streamed as a sample run proceeds. */
47
+ const traceLineEventSchema = z.object({
48
+ type: z.literal('trace-line'),
49
+ line: z.string()
50
+ });
51
+ /** One record a sample run emitted. */
52
+ const recordEventSchema = z.object({
53
+ type: z.literal('record'),
54
+ recipeId: z.string(),
55
+ key: z.string().nullable(),
56
+ data: z.record(z.string(), z.unknown())
57
+ });
58
+ /** A sample run ended: how many records, and why it stopped when it did not finish on its own. */
59
+ const runFinishedEventSchema = z.object({
60
+ type: z.literal('run-finished'),
61
+ recipeId: z.string(),
62
+ emitted: z.number(),
63
+ rejected: z.number(),
64
+ duplicates: z.number(),
65
+ durationMs: z.number(),
66
+ error: z.string().optional(),
67
+ stoppedBy: stoppedBySchema.optional()
68
+ });
69
+ /** The workspace's recipes changed on disk (a save, or a future watcher) and should be re-fetched. */
70
+ const workspaceChangedEventSchema = z.object({
71
+ type: z.literal('workspace-changed')
72
+ });
73
+ const studioEventSchema = z.discriminatedUnion('type', [traceLineEventSchema, recordEventSchema, runFinishedEventSchema, workspaceChangedEventSchema]);
74
+
75
+ /** One problem found on a recipe file, with the JSON path it lives at. */
76
+ const recipeIssueSchema = z.object({
77
+ path: z.string(),
78
+ message: z.string(),
79
+ kind: z.enum(['validation', 'binding'])
80
+ });
81
+ /** One recipe file in an opened workspace. */
82
+ const recipeListingSchema = z.object({
83
+ file: z.string(),
84
+ kind: z.enum(['input', 'output', 'unknown']),
85
+ id: z.string().optional(),
86
+ issues: z.array(recipeIssueSchema),
87
+ /** The file's JSON, pretty-printed: the JSON tab's starting text (see `save-recipe` for how an edit is written back). */
88
+ text: z.string()
89
+ });
90
+ /** What `open-workspace` answers with: every recipe file directly inside the folder. */
91
+ const workspaceViewSchema = z.object({
92
+ folder: z.string(),
93
+ recipes: z.array(recipeListingSchema)
94
+ });
95
+
96
+ /** One record of a sample run's result, kept for the Records tab. */
97
+ const sampleRunRecordSchema = z.object({
98
+ key: z.string().nullable(),
99
+ data: z.record(z.string(), z.unknown())
100
+ });
101
+ /** The last sample run's result, for a client that opens the UI after it finished. */
102
+ const sampleRunViewSchema = z.object({
103
+ recipeId: z.string(),
104
+ emitted: z.number(),
105
+ rejected: z.number(),
106
+ duplicates: z.number(),
107
+ durationMs: z.number(),
108
+ error: z.string().optional(),
109
+ stoppedBy: stoppedBySchema.optional(),
110
+ records: z.array(sampleRunRecordSchema),
111
+ trace: z.array(z.string())
112
+ });
113
+
114
+ /** What `fetch-start-page` answers with. */
115
+ const startPageViewSchema = z.object({
116
+ html: z.string()
117
+ });
118
+
119
+ /** The cookie set on the page's own document response, so its own asset and API requests carry the token too. */
120
+ const TOKEN_COOKIE = 'opencraw_token';
121
+ /** A random token, generated once at server startup and required on every request. */
122
+ function generateToken() {
123
+ return randomBytes(24).toString('hex');
124
+ }
125
+ /**
126
+ * The token a request carries, checked in this order: the `x-opencraw-token`
127
+ * header, the `token` query param, the studio's own cookie (set after the
128
+ * first authenticated document request, so a browser's own asset and API
129
+ * requests need not repeat the query param).
130
+ *
131
+ * @param request - The incoming request.
132
+ * @param query - Its URL's query string, parsed.
133
+ * @returns The token found, or `undefined`.
134
+ */
135
+ function tokenOf(request, query) {
136
+ const header = request.headers['x-opencraw-token'];
137
+ if (typeof header === 'string') return header;
138
+ const fromQuery = query.get('token');
139
+ return fromQuery ?? cookieToken(request.headers.cookie);
140
+ }
141
+ /**
142
+ * Whether a request proves it holds the server's token.
143
+ *
144
+ * @param expected - The server's token.
145
+ * @param request - The incoming request.
146
+ * @param query - Its URL's query string, parsed.
147
+ * @returns Whether the request is authorized.
148
+ */
149
+ function isAuthorized(expected, request, query) {
150
+ return tokenOf(request, query) === expected;
151
+ }
152
+ function cookieToken(header) {
153
+ if (header === undefined) return undefined;
154
+ for (const part of header.split(';')) {
155
+ const [name, ...rest] = part.trim().split('=');
156
+ if (name === TOKEN_COOKIE) return rest.join('=');
157
+ }
158
+ return undefined;
159
+ }
160
+
161
+ /**
162
+ * Fetches an input recipe's start page as the engine would see it: a fresh
163
+ * Playwright page in web mode, core's HTTP client in api mode. Returns plain
164
+ * text: the page's HTML, or a readable rendering of a non-HTML api response.
165
+ * No rewriting, no script stripping, no node ids yet: that is phase 2's
166
+ * `page-snapshot` proper (studio plan, #91). Provisional for phase 0 in two
167
+ * ways, both worth knowing before relying on it: it opens a bare page
168
+ * without running the recipe's own steps first, so a start point behind a
169
+ * `session.bootstrap` login shows what an anonymous visitor sees; and it
170
+ * launches a fresh browser or HTTP context per call, one page then closes
171
+ * it, rather than reusing whatever the last sample run had open.
172
+ *
173
+ * @param input - The input recipe; only `mode` and the first `start` point are used.
174
+ * @param browser - Browser launch settings for web mode (an executable path override, headless…).
175
+ * @returns The page's content as plain text.
176
+ * @throws Error when the recipe has no start point.
177
+ */
178
+ async function fetchStartPage(input, browser) {
179
+ const point = input.start[0];
180
+ if (point === undefined) throw new Error(`recipe "${input.id}" has no start point`);
181
+ return input.mode === 'web' ? fetchWeb(point.url, browser) : fetchApi(point.url);
182
+ }
183
+ async function fetchWeb(url, browser) {
184
+ const client = await BrowserClient.launch(browser);
185
+ try {
186
+ const session = await client.newSession();
187
+ try {
188
+ await session.page.goto(url);
189
+ return await session.page.content();
190
+ } finally {
191
+ await session.close();
192
+ }
193
+ } finally {
194
+ await client.close();
195
+ }
196
+ }
197
+ async function fetchApi(url) {
198
+ const client = await HttpClient.open({});
199
+ try {
200
+ const response = await client.send({
201
+ url
202
+ });
203
+ return textOf(response.body);
204
+ } finally {
205
+ await client.dispose();
206
+ }
207
+ }
208
+ /** Every body kind the studio can show as text today; others get a placeholder rather than a crash. */
209
+ function textOf(body) {
210
+ if (body.kind === 'html') return body.html;
211
+ if (body.kind === 'text') return body.text;
212
+ if (body.kind === 'json') return JSON.stringify(body.data, null, 2);
213
+ return `[${body.kind} document: the content pane shows HTML and JSON responses for now]`;
214
+ }
215
+
216
+ /**
217
+ * Finds and parses one input recipe of a workspace folder, and the output
218
+ * recipe it names.
219
+ *
220
+ * @param folder - The workspace folder.
221
+ * @param recipeId - The input recipe's id.
222
+ * @returns The parsed pair.
223
+ * @throws RecipeValidationError when either recipe does not parse.
224
+ * @throws Error when the input or its output is not in the folder.
225
+ */
226
+ async function loadRecipePair(folder, recipeId) {
227
+ const documents = await readRecipeSource(folder);
228
+ const inputDocument = documents.find(document => kindOf(document.content) === 'input' && idOf(document.content) === recipeId);
229
+ if (inputDocument === undefined) throw new Error(`no input recipe "${recipeId}" in this workspace`);
230
+ const input = parseInputRecipe(inputDocument.content, inputDocument.source);
231
+ const outputDocument = documents.find(document => kindOf(document.content) === 'output' && idOf(document.content) === input.output);
232
+ if (outputDocument === undefined) throw new Error(`no output recipe "${input.output}" in this workspace`);
233
+ return {
234
+ input,
235
+ output: parseOutputRecipe(outputDocument.content, outputDocument.source)
236
+ };
237
+ }
238
+ function kindOf(content) {
239
+ return content?.kind;
240
+ }
241
+ function idOf(content) {
242
+ return content?.id;
243
+ }
244
+
245
+ /**
246
+ * Runs one input recipe of a workspace folder as a sample: `debug: true` (so
247
+ * each `record:emit` carries its data), an in-memory sink, and `budget` as
248
+ * the crawler's `sample` (`@opencraw/core`'s `CrawlOptions.sample`, #89).
249
+ * Every engine event becomes a trace line with the same `traceLine`
250
+ * formatter the cli's `--trace` uses; every emitted record reaches
251
+ * `callbacks.onRecord` as it happens, not only in the final result.
252
+ *
253
+ * @param folder - The workspace folder the recipe lives in.
254
+ * @param recipeId - The input recipe to run.
255
+ * @param budget - The sample budget; unset runs the recipe to completion (still capped by its own `limits`).
256
+ * @param callbacks - Where trace lines and records go as the run proceeds.
257
+ * @returns A handle: `stop` closes the crawler cleanly, `result` resolves when the run ends.
258
+ * @throws RecipeValidationError, RecipeBindingError, Error when the recipe or its output cannot be loaded or bound.
259
+ */
260
+ async function runSample(folder, recipeId, budget, callbacks) {
261
+ const {
262
+ input,
263
+ output
264
+ } = await loadRecipePair(folder, recipeId);
265
+ const set = bindRecipeSet(output, [input]);
266
+ const sink = memorySink();
267
+ const crawler = createCrawler({
268
+ sink,
269
+ debug: true,
270
+ sample: budget,
271
+ onEvent: event => {
272
+ const line = traceLine(event);
273
+ if (line !== undefined) callbacks.onTraceLine(line);
274
+ if (event.type === 'record:emit') callbacks.onRecord({
275
+ key: event.key,
276
+ data: event.data
277
+ });
278
+ }
279
+ });
280
+ const result = runToResult(crawler, set, sink);
281
+ return {
282
+ stop: () => crawler.close(),
283
+ result
284
+ };
285
+ }
286
+ async function runToResult(crawler, set, sink) {
287
+ try {
288
+ const report = await crawler.run(set);
289
+ const recipe = report.recipes[0];
290
+ return {
291
+ recipeId: recipe.recipeId,
292
+ emitted: recipe.emitted,
293
+ rejected: recipe.rejected,
294
+ duplicates: recipe.duplicates,
295
+ durationMs: recipe.durationMs,
296
+ error: recipe.error,
297
+ stoppedBy: recipe.stoppedBy,
298
+ records: sink.records.map(record => ({
299
+ key: record.key,
300
+ data: record.data
301
+ }))
302
+ };
303
+ } finally {
304
+ await crawler.close();
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Handles `fetch-start-page`: loads the named input recipe from the current
310
+ * workspace and fetches its start point (see `page-snapshot`'s own caveats:
311
+ * no session bootstrap, a fresh browser or HTTP context per call).
312
+ *
313
+ * @param state - The server state: `folder` must already be set.
314
+ * @param command - The command.
315
+ * @returns The page's content as plain text.
316
+ * @throws Error when no workspace is open, or the recipe cannot be found or loaded.
317
+ */
318
+ async function handleFetchStartPage(state, command) {
319
+ if (state.folder === undefined) throw new Error('open a workspace first');
320
+ const {
321
+ input
322
+ } = await loadRecipePair(state.folder, command.recipeId);
323
+ const html = await fetchStartPage(input);
324
+ return {
325
+ html
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Opens a recipe folder: every `.json`/`.jsonl` recipe directly inside it
331
+ * (core's own directory reading, so relative `file:` URLs resolve the same
332
+ * way a real run would), parsed and, for each output recipe found, bound to
333
+ * the input recipes that name it. Nothing throws for a bad recipe or a
334
+ * recipe that does not bind: every problem is reported on its file with the
335
+ * JSON path core already computes, exactly as `opencraw validate` prints it.
336
+ *
337
+ * @param folder - The workspace folder.
338
+ * @returns Every recipe file found, each with its validation and binding issues.
339
+ * @throws Error when the folder does not exist or cannot be read.
340
+ */
341
+ async function openWorkspace(folder) {
342
+ const documents = await readRecipeSource(folder);
343
+ const entries = documents.map(document => parseDocument(document));
344
+ const outputs = entries.filter(isParsed('output'));
345
+ const inputs = entries.filter(isParsed('input'));
346
+ for (const output of outputs) bindOutput(output, inputs);
347
+ for (const input of inputs) {
348
+ if (outputs.every(output => output.recipe.id !== input.recipe.output)) {
349
+ input.issues.push({
350
+ path: 'output',
351
+ message: `no output recipe "${input.recipe.output}" in this workspace`,
352
+ kind: 'binding'
353
+ });
354
+ }
355
+ }
356
+ return {
357
+ folder,
358
+ recipes: entries.map(({
359
+ file,
360
+ kind,
361
+ id,
362
+ issues,
363
+ content
364
+ }) => ({
365
+ file,
366
+ kind,
367
+ id,
368
+ issues,
369
+ text: `${JSON.stringify(content, null, 2)}\n`
370
+ }))
371
+ };
372
+ }
373
+ /** Binds one output recipe to the inputs that name it, filing every binding issue back on the recipe it names. */
374
+ function bindOutput(output, inputs) {
375
+ const matching = inputs.filter(input => input.recipe.output === output.recipe.id);
376
+ try {
377
+ bindRecipeSet(output.recipe, matching.map(input => input.recipe));
378
+ } catch (error) {
379
+ if (!(error instanceof RecipeBindingError)) throw error;
380
+ for (const issue of error.issues) {
381
+ const target = issue.recipeId === output.recipe.id ? output : matching.find(input => input.recipe.id === issue.recipeId);
382
+ target?.issues.push({
383
+ path: issue.path,
384
+ message: issue.message,
385
+ kind: 'binding'
386
+ });
387
+ }
388
+ }
389
+ }
390
+ function isParsed(kind) {
391
+ return entry => entry.kind === kind && entry.recipe !== undefined;
392
+ }
393
+ function parseDocument(document) {
394
+ const kind = document.content?.kind;
395
+ if (kind === 'output') return parseOne(document.source, document.content, 'output', parseOutputRecipe);
396
+ if (kind === 'input') return parseOne(document.source, document.content, 'input', parseInputRecipe);
397
+ return {
398
+ file: document.source,
399
+ kind: 'unknown',
400
+ content: document.content,
401
+ issues: [{
402
+ path: 'kind',
403
+ message: '"kind" is missing or not "input"/"output"',
404
+ kind: 'validation'
405
+ }]
406
+ };
407
+ }
408
+ function parseOne(file, content, kind, parse) {
409
+ try {
410
+ const recipe = parse(content, file);
411
+ return {
412
+ file,
413
+ kind,
414
+ content,
415
+ id: recipe.id,
416
+ issues: [],
417
+ recipe
418
+ };
419
+ } catch (error) {
420
+ if (!(error instanceof RecipeValidationError)) throw error;
421
+ return {
422
+ file,
423
+ kind,
424
+ content,
425
+ issues: error.issues.map(issue => ({
426
+ path: issue.path === '' ? '(root)' : issue.path,
427
+ message: issue.message,
428
+ kind: 'validation'
429
+ }))
430
+ };
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Writes a recipe back to its file: pretty JSON, a 2-space indent, a
436
+ * trailing newline. Phase 0 takes the JSON tab's whole parsed object rather
437
+ * than a patch, so this is a full replacement, not a splice: loading a file
438
+ * the studio has not touched and saving it again keeps every key, value and
439
+ * key order (`JSON.parse` preserves insertion order, and `JSON.stringify`
440
+ * writes it back), but not necessarily the file's exact original formatting
441
+ * (alignment, how arrays wrap, a trailing comment). Byte-identical
442
+ * round-trip for a hand-formatted file is future work; see this file's test
443
+ * for what is guaranteed today.
444
+ *
445
+ * @param path - The recipe file to write.
446
+ * @param recipe - The whole recipe object.
447
+ */
448
+ async function saveRecipe(path, recipe) {
449
+ await writeFile(path, `${JSON.stringify(recipe, null, 2)}\n`, 'utf8');
450
+ }
451
+
452
+ /** @returns Fresh state for a new server instance: no workspace open, no run, no sockets. */
453
+ function createStudioState() {
454
+ return {
455
+ sockets: new Set()
456
+ };
457
+ }
458
+ /**
459
+ * Sends one event to every connected client. A socket that errors while
460
+ * sending (gone away without a clean close yet reaching us) is dropped
461
+ * rather than left to fail every later broadcast too.
462
+ *
463
+ * @param state - The server state holding the connected sockets.
464
+ * @param event - The event to send.
465
+ */
466
+ function broadcast(state, event) {
467
+ const text = JSON.stringify(event);
468
+ for (const socket of state.sockets) {
469
+ try {
470
+ socket.send(text);
471
+ } catch {
472
+ state.sockets.delete(socket);
473
+ }
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Handles `open-workspace`: reads the folder, remembers it as the server's
479
+ * current workspace (every later command that names a recipe resolves it
480
+ * against this folder), and tells every connected client the workspace changed.
481
+ *
482
+ * @param state - The server state to update.
483
+ * @param command - The command.
484
+ * @returns The workspace's recipes and their issues.
485
+ */
486
+ async function handleOpenWorkspace(state, command) {
487
+ const view = await openWorkspace(command.folder);
488
+ state.folder = command.folder;
489
+ broadcast(state, {
490
+ type: 'workspace-changed'
491
+ });
492
+ return view;
493
+ }
494
+
495
+ /**
496
+ * Handles `run-sample`: stops whatever sample was still running (one run at
497
+ * a time), starts the new one, and returns once it has started, not once it
498
+ * finishes: `trace-line` and `record` stream over the WebSocket as the run
499
+ * proceeds, and `run-finished` closes it out.
500
+ *
501
+ * @param state - The server state: `folder` must already be set (`open-workspace` first).
502
+ * @param command - The command.
503
+ * @returns `{ started: true }` once the run has begun.
504
+ * @throws Error when no workspace is open, or the recipe cannot be loaded or bound.
505
+ */
506
+ async function handleRunSample(state, command) {
507
+ if (state.folder === undefined) throw new Error('open a workspace first');
508
+ await state.activeRun?.stop();
509
+ const handle = await runSample(state.folder, command.recipeId, command.budget, {
510
+ onTraceLine: line => {
511
+ broadcast(state, {
512
+ type: 'trace-line',
513
+ line
514
+ });
515
+ },
516
+ onRecord: record => {
517
+ broadcast(state, {
518
+ type: 'record',
519
+ recipeId: command.recipeId,
520
+ key: record.key,
521
+ data: record.data
522
+ });
523
+ }
524
+ });
525
+ state.activeRun = handle;
526
+ void handle.result.then(result => {
527
+ if (state.activeRun === handle) state.activeRun = undefined;
528
+ broadcast(state, {
529
+ type: 'run-finished',
530
+ recipeId: result.recipeId,
531
+ emitted: result.emitted,
532
+ rejected: result.rejected,
533
+ duplicates: result.duplicates,
534
+ durationMs: result.durationMs,
535
+ error: result.error,
536
+ stoppedBy: result.stoppedBy
537
+ });
538
+ });
539
+ return {
540
+ started: true
541
+ };
542
+ }
543
+ /**
544
+ * Handles `stop-run`: closes the crawler of the run in progress, if any.
545
+ * `run-finished` still follows once the interrupted run's promise settles.
546
+ *
547
+ * @param state - The server state holding the active run, if any.
548
+ * @param _command - The command (carries nothing beyond its type).
549
+ * @returns Whether a run was actually in progress to stop.
550
+ */
551
+ async function handleStopRun(state, _command) {
552
+ const run = state.activeRun;
553
+ if (run === undefined) return {
554
+ stopped: false
555
+ };
556
+ await run.stop();
557
+ return {
558
+ stopped: true
559
+ };
560
+ }
561
+
562
+ /**
563
+ * Handles `save-recipe`: writes the file (see `recipe-workspace/save-recipe.use-case.ts`
564
+ * for the pretty-printing and its round-trip guarantee), then tells every
565
+ * connected client the workspace changed, so the UI can re-fetch it.
566
+ *
567
+ * @param state - The server state holding the connected sockets.
568
+ * @param command - The command: the file path and the whole recipe object.
569
+ * @returns `{ saved: true }` once the write has completed.
570
+ */
571
+ async function handleSaveRecipe(state, command) {
572
+ await saveRecipe(command.path, command.recipe);
573
+ broadcast(state, {
574
+ type: 'workspace-changed'
575
+ });
576
+ return {
577
+ saved: true
578
+ };
579
+ }
580
+
581
+ const MIME = {
582
+ '.html': 'text/html; charset=utf-8',
583
+ '.js': 'text/javascript; charset=utf-8',
584
+ '.mjs': 'text/javascript; charset=utf-8',
585
+ '.css': 'text/css; charset=utf-8',
586
+ '.json': 'application/json; charset=utf-8',
587
+ '.svg': 'image/svg+xml',
588
+ '.png': 'image/png',
589
+ '.ico': 'image/x-icon',
590
+ '.woff': 'font/woff',
591
+ '.woff2': 'font/woff2'
592
+ };
593
+ /**
594
+ * Serves the built UI from `root` (`packages/studio/dist/ui`, `apps/studio-ui`'s
595
+ * build output): the requested path if it exists as a file, `index.html`
596
+ * otherwise, so a client-side route (or the bare `/`) always gets the SPA's
597
+ * shell. `root` not existing at all (the UI has not been built) answers 404
598
+ * with a hint rather than a stack trace.
599
+ *
600
+ * @param root - The built UI's folder.
601
+ * @param pathname - The request's URL path.
602
+ * @param response - The response to write to.
603
+ */
604
+ function serveStatic(root, pathname, response) {
605
+ const safe = normalize(pathname).split(sep).filter(segment => segment !== '..').join(sep);
606
+ const candidate = join(root, safe);
607
+ const file = safe !== '' && safe !== sep && existsSync(candidate) ? candidate : join(root, 'index.html');
608
+ if (!existsSync(file)) {
609
+ response.writeHead(404, {
610
+ 'content-type': 'text/plain; charset=utf-8'
611
+ });
612
+ response.end('the studio UI has not been built yet: run `nx build studio-ui`');
613
+ return;
614
+ }
615
+ response.writeHead(200, {
616
+ 'content-type': MIME[extname(file)] ?? 'application/octet-stream'
617
+ });
618
+ createReadStream(file).pipe(response);
619
+ }
620
+
621
+ /** RFC 6455's fixed handshake suffix. */
622
+ const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
623
+ /**
624
+ * Accepts one WebSocket upgrade on the raw socket `node:http` hands an
625
+ * `upgrade` listener, implementing just enough of RFC 6455 for the studio's
626
+ * own one-directional event stream: the server sends small JSON text
627
+ * frames, the client sends none (commands go over `POST /api/command`). No
628
+ * `ws` dependency: this is deliberately small rather than general-purpose.
629
+ *
630
+ * @param request - The upgrade request (its `Sec-WebSocket-Key` header).
631
+ * @param socket - The raw, already-connected socket.
632
+ * @returns The connection: `send`, `close`, and where messages and the close go.
633
+ */
634
+ function acceptWebSocket(request, socket) {
635
+ const key = request.headers['sec-websocket-key'] ?? '';
636
+ const accept = createHash('sha1').update(key + WS_GUID).digest('base64');
637
+ socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
638
+ const messageHandlers = [];
639
+ const closeHandlers = [];
640
+ let buffer = Buffer.alloc(0);
641
+ socket.on('data', chunk => {
642
+ buffer = consumeFrames(Buffer.concat([buffer, chunk]), messageHandlers, closeHandlers, socket);
643
+ });
644
+ socket.on('close', () => {
645
+ for (const handler of closeHandlers) handler();
646
+ });
647
+ socket.on('error', () => {
648
+ for (const handler of closeHandlers) handler();
649
+ });
650
+ return {
651
+ send: text => {
652
+ if (!socket.destroyed) socket.write(frame(text));
653
+ },
654
+ close: () => {
655
+ socket.end();
656
+ },
657
+ onMessage: handler => {
658
+ messageHandlers.push(handler);
659
+ },
660
+ onClose: handler => {
661
+ closeHandlers.push(handler);
662
+ }
663
+ };
664
+ }
665
+ /** One unmasked text frame (server-to-client frames are never masked). */
666
+ function frame(text) {
667
+ const payload = Buffer.from(text, 'utf8');
668
+ const {
669
+ length
670
+ } = payload;
671
+ const header = length < 126 ? Buffer.from([0x81, length]) : length < 65536 ? Buffer.concat([Buffer.from([0x81, 126]), lengthBytes(length, 2)]) : Buffer.concat([Buffer.from([0x81, 127]), lengthBytes(length, 8)]);
672
+ return Buffer.concat([header, payload]);
673
+ }
674
+ function lengthBytes(value, size) {
675
+ const buffer = Buffer.alloc(size);
676
+ if (size === 2) buffer.writeUInt16BE(value);else buffer.writeBigUInt64BE(BigInt(value));
677
+ return buffer;
678
+ }
679
+ /**
680
+ * Reads every complete frame at the front of `buffer`, dispatching text and
681
+ * close frames, and returns what is left (a partial frame still arriving).
682
+ */
683
+ function consumeFrames(buffer, messageHandlers, closeHandlers, socket) {
684
+ let rest = buffer;
685
+ for (;;) {
686
+ if (rest.length < 2) return rest;
687
+ const opcode = rest[0] & 0x0F;
688
+ const masked = (rest[1] & 0x80) !== 0;
689
+ let length = rest[1] & 0x7F;
690
+ let offset = 2;
691
+ if (length === 126) {
692
+ if (rest.length < offset + 2) return rest;
693
+ length = rest.readUInt16BE(offset);
694
+ offset += 2;
695
+ } else if (length === 127) {
696
+ if (rest.length < offset + 8) return rest;
697
+ length = Number(rest.readBigUInt64BE(offset));
698
+ offset += 8;
699
+ }
700
+ const maskLength = masked ? 4 : 0;
701
+ if (rest.length < offset + maskLength + length) return rest;
702
+ const mask = masked ? rest.subarray(offset, offset + 4) : undefined;
703
+ offset += maskLength;
704
+ const payload = Buffer.from(rest.subarray(offset, offset + length));
705
+ if (mask !== undefined) for (let index = 0; index < payload.length; index += 1) payload[index] ^= mask[index % 4];
706
+ offset += length;
707
+ if (opcode === 0x8) {
708
+ for (const handler of closeHandlers) handler();
709
+ socket.end();
710
+ return Buffer.alloc(0);
711
+ }
712
+ // 0x1 text; ping/pong and continuation frames are ignored: every message here fits in one text frame.
713
+ if (opcode === 0x1) for (const handler of messageHandlers) handler(payload.toString('utf8'));
714
+ rest = rest.subarray(offset);
715
+ }
716
+ }
717
+
718
+ /** Where the UI Vite builds into, relative to this package: `dist/ui` next to the bundled server. */
719
+ function defaultUiRoot() {
720
+ return join(dirname(fileURLToPath(import.meta.url)), 'ui');
721
+ }
722
+ /**
723
+ * Starts the studio's HTTP server: one `POST /api/command` for every
724
+ * command `studio-api` defines, `GET /ws` for the event stream, and the
725
+ * built UI for everything else. Binds `127.0.0.1` only; every request needs
726
+ * the token (`access-token.policy.ts`) — as a header, a query param, or the
727
+ * cookie the server sets once a document request proves the query param.
728
+ *
729
+ * @param options - The UI root and the port; both default sensibly.
730
+ * @returns The running server.
731
+ */
732
+ async function startStudioServer(options = {}) {
733
+ const token = generateToken();
734
+ const uiRoot = options.uiRoot ?? defaultUiRoot();
735
+ const state = createStudioState();
736
+ if (options.initialFolder !== undefined) state.folder = options.initialFolder;
737
+ const server = createServer((request, response) => {
738
+ void handleRequest(request, response, token, state, uiRoot);
739
+ });
740
+ server.on('upgrade', (request, socket) => {
741
+ handleUpgrade(request, socket, token, state);
742
+ });
743
+ await new Promise((resolve, reject) => {
744
+ server.once('error', reject);
745
+ server.listen(options.port ?? 0, '127.0.0.1', resolve);
746
+ });
747
+ const address = server.address();
748
+ const port = typeof address === 'object' && address !== null ? address.port : 0;
749
+ const folderParam = options.initialFolder === undefined ? '' : `&folder=${encodeURIComponent(options.initialFolder)}`;
750
+ return {
751
+ url: `http://127.0.0.1:${port}/?token=${token}${folderParam}`,
752
+ token,
753
+ close: () => stop(server, state)
754
+ };
755
+ }
756
+ async function stop(server, state) {
757
+ await state.activeRun?.stop();
758
+ server.closeAllConnections();
759
+ await new Promise((resolve, reject) => {
760
+ server.close(error => error === undefined ? resolve() : reject(error));
761
+ });
762
+ }
763
+ async function handleRequest(request, response, token, state, uiRoot) {
764
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
765
+ if (!isAuthorized(token, request, url.searchParams)) {
766
+ response.writeHead(401, {
767
+ 'content-type': 'application/json'
768
+ });
769
+ response.end(JSON.stringify({
770
+ error: 'missing or invalid token'
771
+ }));
772
+ return;
773
+ }
774
+ if (url.pathname === '/api/command' && request.method === 'POST') {
775
+ await handleCommand(request, response, state);
776
+ return;
777
+ }
778
+ if (url.pathname === '/' && url.searchParams.get('token') !== null) response.setHeader('set-cookie', `${TOKEN_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`);
779
+ serveStatic(uiRoot, url.pathname, response);
780
+ }
781
+ async function handleCommand(request, response, state) {
782
+ let command;
783
+ try {
784
+ const body = JSON.parse(await bodyOf(request));
785
+ const parsed = studioCommandSchema.safeParse(body);
786
+ if (!parsed.success) {
787
+ respondJson(response, 400, {
788
+ error: parsed.error.message
789
+ });
790
+ return;
791
+ }
792
+ command = parsed.data;
793
+ } catch (error) {
794
+ respondJson(response, 400, {
795
+ error: error instanceof Error ? error.message : String(error)
796
+ });
797
+ return;
798
+ }
799
+ try {
800
+ respondJson(response, 200, await dispatch(command, state));
801
+ } catch (error) {
802
+ respondJson(response, 500, {
803
+ error: error instanceof Error ? error.message : String(error)
804
+ });
805
+ }
806
+ }
807
+ function dispatch(command, state) {
808
+ switch (command.type) {
809
+ case 'open-workspace':
810
+ {
811
+ return handleOpenWorkspace(state, command);
812
+ }
813
+ case 'run-sample':
814
+ {
815
+ return handleRunSample(state, command);
816
+ }
817
+ case 'stop-run':
818
+ {
819
+ return handleStopRun(state);
820
+ }
821
+ case 'save-recipe':
822
+ {
823
+ return handleSaveRecipe(state, command);
824
+ }
825
+ case 'fetch-start-page':
826
+ {
827
+ return handleFetchStartPage(state, command);
828
+ }
829
+ }
830
+ }
831
+ function respondJson(response, status, body) {
832
+ response.writeHead(status, {
833
+ 'content-type': 'application/json'
834
+ });
835
+ response.end(JSON.stringify(body));
836
+ }
837
+ function bodyOf(request) {
838
+ return new Promise((resolve, reject) => {
839
+ let body = '';
840
+ request.on('data', chunk => {
841
+ body += chunk.toString('utf8');
842
+ });
843
+ request.on('end', () => {
844
+ resolve(body);
845
+ });
846
+ request.on('error', reject);
847
+ });
848
+ }
849
+ function handleUpgrade(request, socket, token, state) {
850
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
851
+ if (url.pathname !== '/ws' || !isAuthorized(token, request, url.searchParams)) {
852
+ socket.destroy();
853
+ return;
854
+ }
855
+ const connection = acceptWebSocket(request, socket);
856
+ state.sockets.add(connection);
857
+ connection.onClose(() => {
858
+ state.sockets.delete(connection);
859
+ });
860
+ }
861
+
862
+ /**
863
+ * Runs `opencraw-studio`: starts the server, opens `folder` (if given) as
864
+ * the workspace right away, and prints the URL with the token, the way
865
+ * `docs/research/recipe-studio.md` §2 shows it: `http://127.0.0.1:<port>/?token=…`.
866
+ * The process stays up (the server keeps the event loop alive) until it is
867
+ * interrupted (Ctrl+C).
868
+ *
869
+ * @param argv - Arguments after the program name: an optional recipe folder, `.` when omitted.
870
+ */
871
+ async function main(argv) {
872
+ const folder = resolve(argv[0] ?? '.');
873
+ const server = await startStudioServer({
874
+ initialFolder: folder
875
+ });
876
+ process.stdout.write(`OpenCraw Studio: ${server.url}\n`);
877
+ process.stdout.write(`Workspace: ${folder}\n`);
878
+ const shutdown = () => {
879
+ void server.close();
880
+ };
881
+ process.on('SIGINT', shutdown);
882
+ process.on('SIGTERM', shutdown);
883
+ }
884
+
885
+ export { fetchStartPage, fetchStartPageCommandSchema, loadRecipePair, main, openWorkspace, openWorkspaceCommandSchema, recipeIssueSchema, recipeListingSchema, recordEventSchema, runFinishedEventSchema, runSample, runSampleCommandSchema, sampleBudgetSchema, sampleRunRecordSchema, sampleRunViewSchema, saveRecipe, saveRecipeCommandSchema, startPageViewSchema, startStudioServer, stopRunCommandSchema, stoppedBySchema, studioCommandSchema, studioEventSchema, traceLineEventSchema, workspaceChangedEventSchema, workspaceViewSchema };
886
+ //# sourceMappingURL=index.esm.js.map