@juxtapose/server 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Magic Factory LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # @juxtapose/server
2
+
3
+ Local review API for juxtapose — the contract every review UI is a skin over.
4
+
5
+ Part of [juxtapose](https://github.com/juxtapose-dev/juxtapose), visual
6
+ regression testing for component workbenches.
7
+
8
+ **You probably don't need to install this directly.** Use
9
+ [`@juxtapose/cli`](https://www.npmjs.com/package/@juxtapose/cli) for the
10
+ command line, or
11
+ [`@juxtapose/storybook`](https://www.npmjs.com/package/@juxtapose/storybook)
12
+ to review diffs inside Storybook. This package is a building block those
13
+ depend on.
14
+
15
+ Full documentation: https://github.com/juxtapose-dev/juxtapose
16
+
17
+ MIT © Magic Factory LLC
@@ -0,0 +1,4 @@
1
+ export type { ReviewBundle, ReviewSource, RunProgress, ImageKind } from './types.js';
2
+ export { createRouter, API_PREFIX, type Handler } from './router.js';
3
+ export { RunDirSource, type SourceOptions } from './source.js';
4
+ export { serve, type ServeHandle } from './serve.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { createRouter, API_PREFIX } from './router.js';
2
+ export { RunDirSource } from './source.js';
3
+ export { serve } from './serve.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,UAAU,EAAgB,MAAM,aAAa,CAAC;AACrE,OAAO,EAAE,YAAY,EAAsB,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAE,KAAK,EAAoB,MAAM,YAAY,CAAC"}
@@ -0,0 +1,15 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { ReviewSource } from './types.js';
3
+ /** Mounted under this prefix so it cannot collide with workbench routes. */
4
+ export declare const API_PREFIX = "/jux/api";
5
+ export interface Handler {
6
+ (req: IncomingMessage, res: ServerResponse): Promise<boolean>;
7
+ }
8
+ /**
9
+ * Returns a handler that resolves true when it handled the request.
10
+ *
11
+ * The boolean matters for the middleware case: when mounted inside a dev
12
+ * server, anything not ours has to fall through to the next handler rather
13
+ * than 404.
14
+ */
15
+ export declare function createRouter(source: ReviewSource): Handler;
package/dist/router.js ADDED
@@ -0,0 +1,122 @@
1
+ // HTTP surface over a ReviewSource.
2
+ //
3
+ // Hand-rolled rather than pulling in a framework: there are ten routes, none
4
+ // of them need middleware, and the addon has to mount this inside Storybook's
5
+ // own Vite dev server where an extra HTTP stack would just be in the way.
6
+ /** Mounted under this prefix so it cannot collide with workbench routes. */
7
+ export const API_PREFIX = '/jux/api';
8
+ const IMAGE_KINDS = new Set(['current', 'baseline', 'diff']);
9
+ /**
10
+ * Returns a handler that resolves true when it handled the request.
11
+ *
12
+ * The boolean matters for the middleware case: when mounted inside a dev
13
+ * server, anything not ours has to fall through to the next handler rather
14
+ * than 404.
15
+ */
16
+ export function createRouter(source) {
17
+ return async function handle(req, res) {
18
+ const url = req.url ?? '/';
19
+ if (!url.startsWith(API_PREFIX))
20
+ return false;
21
+ const path = url.slice(API_PREFIX.length).split('?')[0];
22
+ const method = req.method ?? 'GET';
23
+ try {
24
+ await route(source, method, path, req, res);
25
+ }
26
+ catch (err) {
27
+ // A failure here is a bug in the tool, not in the user's stories, so
28
+ // surface it rather than silently returning an empty report.
29
+ json(res, 500, { error: err instanceof Error ? err.message : String(err) });
30
+ }
31
+ return true;
32
+ };
33
+ }
34
+ async function route(source, method, path, req, res) {
35
+ if (method === 'GET' && path === '/bundle') {
36
+ return json(res, 200, await source.bundle());
37
+ }
38
+ if (method === 'GET' && path === '/progress') {
39
+ return json(res, 200, await source.progress());
40
+ }
41
+ if (method === 'GET' && path === '/stories') {
42
+ return json(res, 200, { stories: await source.stories() });
43
+ }
44
+ // /image/{kind}/{storyId} — storyId may contain slashes on some platforms,
45
+ // so take everything after the kind rather than splitting into fixed parts.
46
+ const image = /^\/image\/([^/]+)\/(.+)$/.exec(path);
47
+ if (method === 'GET' && image) {
48
+ const kind = image[1];
49
+ if (!IMAGE_KINDS.has(kind))
50
+ return json(res, 400, { error: 'unknown image kind' });
51
+ const bytes = await source.image(kind, decodeURIComponent(image[2]));
52
+ if (!bytes)
53
+ return json(res, 404, { error: 'no such image' });
54
+ res.writeHead(200, {
55
+ 'content-type': 'image/webp',
56
+ 'content-length': bytes.length,
57
+ // Images are content-addressed by the ?v= the client appends, so they
58
+ // can be cached hard; without this the panel refetches every poll.
59
+ 'cache-control': 'public, max-age=31536000, immutable',
60
+ });
61
+ res.end(bytes);
62
+ return;
63
+ }
64
+ const handoff = /^\/handoff\/(.+)$/.exec(path);
65
+ if (method === 'GET' && handoff) {
66
+ const prompt = await source.handoff(decodeURIComponent(handoff[1]));
67
+ if (prompt === null)
68
+ return json(res, 404, { error: 'no such story in the last run' });
69
+ res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
70
+ res.end(prompt);
71
+ return;
72
+ }
73
+ if (method === 'POST' && path === '/approve') {
74
+ const body = await readJson(req);
75
+ if (!body?.storyId)
76
+ return json(res, 400, { error: 'storyId required' });
77
+ await source.approve(body.storyId);
78
+ return json(res, 200, { ok: true });
79
+ }
80
+ if (method === 'POST' && path === '/run') {
81
+ const body = await readJson(req);
82
+ // Deliberately not awaited: a run takes seconds and the client polls
83
+ // /progress. Holding the request open would just time out.
84
+ void source.run(body?.storyIds).catch(() => { });
85
+ return json(res, 202, { started: true });
86
+ }
87
+ if (method === 'POST' && path === '/cancel') {
88
+ await source.cancel();
89
+ return json(res, 200, { ok: true });
90
+ }
91
+ json(res, 404, { error: `no route for ${method} ${path}` });
92
+ }
93
+ function json(res, status, body) {
94
+ const payload = JSON.stringify(body);
95
+ res.writeHead(status, {
96
+ 'content-type': 'application/json; charset=utf-8',
97
+ 'content-length': Buffer.byteLength(payload),
98
+ 'cache-control': 'no-store',
99
+ });
100
+ res.end(payload);
101
+ }
102
+ async function readJson(req) {
103
+ const chunks = [];
104
+ let size = 0;
105
+ for await (const chunk of req) {
106
+ size += chunk.length;
107
+ // A review API has no business accepting large bodies; refuse rather
108
+ // than buffering whatever arrives.
109
+ if (size > 1_000_000)
110
+ return null;
111
+ chunks.push(chunk);
112
+ }
113
+ if (chunks.length === 0)
114
+ return null;
115
+ try {
116
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ //# sourceMappingURL=router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ import type { ReviewSource } from './types.js';
2
+ export interface ServeHandle {
3
+ url: string;
4
+ port: number;
5
+ close(): Promise<void>;
6
+ }
7
+ export declare function serve(source: ReviewSource, port?: number): Promise<ServeHandle>;
package/dist/serve.js ADDED
@@ -0,0 +1,27 @@
1
+ // Standalone server, for `jux serve` and for tests.
2
+ //
3
+ // The addon mounts `createRouter` directly into Storybook's dev server
4
+ // instead, which is why the router is separate from this.
5
+ import { createServer } from 'node:http';
6
+ import { createRouter } from './router.js';
7
+ export async function serve(source, port = 0) {
8
+ const handler = createRouter(source);
9
+ const server = createServer((req, res) => {
10
+ void handler(req, res).then((handled) => {
11
+ if (handled)
12
+ return;
13
+ res.writeHead(404, { 'content-type': 'text/plain' });
14
+ res.end('not found');
15
+ });
16
+ });
17
+ await new Promise((ok) => server.listen(port, '127.0.0.1', ok));
18
+ const addr = server.address();
19
+ return {
20
+ url: `http://127.0.0.1:${addr.port}`,
21
+ port: addr.port,
22
+ close: () => new Promise((ok) => {
23
+ server.close(() => ok());
24
+ }),
25
+ };
26
+ }
27
+ //# sourceMappingURL=serve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.js","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAAA,oDAAoD;AACpD,EAAE;AACF,uEAAuE;AACvE,0DAA0D;AAE1D,OAAO,EAAE,YAAY,EAAe,MAAM,WAAW,CAAC;AAEtD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAS3C,MAAM,CAAC,KAAK,UAAU,KAAK,CAAC,MAAoB,EAAE,IAAI,GAAG,CAAC;IACxD,MAAM,OAAO,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;IAErC,MAAM,MAAM,GAAW,YAAY,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;QAC/C,KAAK,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE;YACtC,IAAI,OAAO;gBAAE,OAAO;YACpB,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,YAAY,EAAE,CAAC,CAAC;YACrD,GAAG,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QACvB,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,MAAM,IAAI,OAAO,CAAO,CAAC,EAAE,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,WAAW,EAAE,EAAE,CAAC,CAAC,CAAC;IACtE,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,EAAiB,CAAC;IAE7C,OAAO;QACL,GAAG,EAAE,oBAAoB,IAAI,CAAC,IAAI,EAAE;QACpC,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,KAAK,EAAE,GAAG,EAAE,CACV,IAAI,OAAO,CAAO,CAAC,EAAE,EAAE,EAAE;YACvB,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;QAC3B,CAAC,CAAC;KACL,CAAC;AACJ,CAAC"}
@@ -0,0 +1,50 @@
1
+ import { type BaselineStore, type Platform, type ProjectConfig, type StoryManifest } from '@juxtapose/core';
2
+ import type { ImageKind, ReviewBundle, ReviewSource, RunProgress } from './types.js';
3
+ export interface SourceOptions {
4
+ projectRoot: string;
5
+ platform: Platform;
6
+ store: BaselineStore;
7
+ config: ProjectConfig;
8
+ /** Where the workbench is served. For the addon this is Storybook's own
9
+ * dev server, which is why it can change between requests. */
10
+ baseUrl: string;
11
+ runDir: string;
12
+ branch: string;
13
+ commitSha: string;
14
+ }
15
+ export declare class RunDirSource implements ReviewSource {
16
+ private readonly opts;
17
+ private report;
18
+ private storyCache;
19
+ private browser;
20
+ private running;
21
+ private done;
22
+ private total;
23
+ private currentStory;
24
+ private abort;
25
+ /**
26
+ * Approvals are serialized through this chain.
27
+ *
28
+ * "Approve all" fires N parallel requests and each one is a read-modify-write
29
+ * of the same manifest file. Without a queue the last writer wins and every
30
+ * other approval is silently lost — which is exactly the kind of bug that
31
+ * erodes trust in the tool.
32
+ */
33
+ private approvalQueue;
34
+ constructor(opts: SourceOptions);
35
+ setBaseUrl(baseUrl: string): void;
36
+ bundle(): Promise<ReviewBundle>;
37
+ progress(): Promise<RunProgress>;
38
+ stories(): Promise<StoryManifest[]>;
39
+ /** Drop the cached story index — the workbench told us it changed. */
40
+ flushStories(): void;
41
+ image(kind: ImageKind, storyId: string): Promise<Buffer | null>;
42
+ handoff(storyId: string): Promise<string | null>;
43
+ approve(storyId: string): Promise<void>;
44
+ run(storyIds?: string[]): Promise<void>;
45
+ cancel(): Promise<void>;
46
+ close(): Promise<void>;
47
+ private ensureBrowser;
48
+ private loadReport;
49
+ private readImage;
50
+ }
package/dist/source.js ADDED
@@ -0,0 +1,228 @@
1
+ // A ReviewSource backed by run artifacts on disk plus a live capture pool.
2
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { createHash } from 'node:crypto';
5
+ import path from 'node:path';
6
+ import { chromium } from 'playwright';
7
+ import { buildHandoffPrompt, createAncestryCheck, alwaysAncestor, deterministicLaunchOptions, encodeStoryId, runCapture, DEFAULT_VIEWPORT, } from '@juxtapose/core';
8
+ export class RunDirSource {
9
+ opts;
10
+ report = null;
11
+ storyCache = null;
12
+ browser = null;
13
+ running = false;
14
+ done = 0;
15
+ total = 0;
16
+ currentStory = null;
17
+ abort = null;
18
+ /**
19
+ * Approvals are serialized through this chain.
20
+ *
21
+ * "Approve all" fires N parallel requests and each one is a read-modify-write
22
+ * of the same manifest file. Without a queue the last writer wins and every
23
+ * other approval is silently lost — which is exactly the kind of bug that
24
+ * erodes trust in the tool.
25
+ */
26
+ approvalQueue = Promise.resolve();
27
+ constructor(opts) {
28
+ this.opts = opts;
29
+ }
30
+ setBaseUrl(baseUrl) {
31
+ this.opts.baseUrl = baseUrl;
32
+ }
33
+ async bundle() {
34
+ const report = await this.loadReport();
35
+ const manifest = await this.opts.store.readManifest();
36
+ // A story counts as approved if its captured bytes now match the manifest.
37
+ // Deriving this rather than storing it means an approval made through the
38
+ // CLI shows up in the panel without any shared state between them.
39
+ const approved = [];
40
+ for (const result of report?.results ?? []) {
41
+ if (result.status === 'pass')
42
+ continue;
43
+ const entry = manifest.baselines[result.storyId];
44
+ if (!entry)
45
+ continue;
46
+ const current = await this.readImage('current', result.storyId);
47
+ if (current && hash(current) === entry.hash)
48
+ approved.push(result.storyId);
49
+ }
50
+ return { report, approved, running: this.running };
51
+ }
52
+ async progress() {
53
+ return {
54
+ running: this.running,
55
+ done: this.done,
56
+ total: this.total,
57
+ storyId: this.currentStory,
58
+ };
59
+ }
60
+ async stories() {
61
+ if (this.storyCache)
62
+ return this.storyCache;
63
+ this.storyCache = await this.opts.platform.enumerate({
64
+ projectRoot: this.opts.projectRoot,
65
+ origin: this.opts.baseUrl,
66
+ });
67
+ return this.storyCache;
68
+ }
69
+ /** Drop the cached story index — the workbench told us it changed. */
70
+ flushStories() {
71
+ this.storyCache = null;
72
+ }
73
+ async image(kind, storyId) {
74
+ if (kind === 'baseline')
75
+ return this.opts.store.get(storyId);
76
+ return this.readImage(kind, storyId);
77
+ }
78
+ async handoff(storyId) {
79
+ const report = await this.loadReport();
80
+ const result = report?.results.find((r) => r.storyId === storyId);
81
+ if (!result)
82
+ return null;
83
+ const story = (await this.stories()).find((s) => s.storyId === storyId);
84
+ const images = path.join(this.opts.runDir, 'images');
85
+ const safe = encodeStoryId(storyId);
86
+ return buildHandoffPrompt({
87
+ storyId,
88
+ storyTitle: story ? `${story.title} — ${story.name}` : storyId,
89
+ status: result.status,
90
+ mismatchPercent: result.mismatchPercent,
91
+ mismatchPixels: result.mismatchPixels,
92
+ changedRegions: result.changedRegions,
93
+ viewport: story?.params.viewport ?? this.opts.config.viewport ?? DEFAULT_VIEWPORT,
94
+ componentPath: story?.componentPath ?? '',
95
+ images: {
96
+ baseline: path.join(images, `${safe}-baseline.webp`),
97
+ current: path.join(images, `${safe}-current.webp`),
98
+ diff: result.status === 'fail' ? path.join(images, `${safe}-diff.webp`) : undefined,
99
+ },
100
+ projectRoot: this.opts.projectRoot,
101
+ });
102
+ }
103
+ async approve(storyId) {
104
+ const task = this.approvalQueue.then(async () => {
105
+ const image = await this.readImage('current', storyId);
106
+ if (!image)
107
+ throw new Error(`no capture for ${storyId} in the last run`);
108
+ await this.opts.store.put(storyId, image, {
109
+ commit: this.opts.commitSha,
110
+ approvedBy: process.env.JUXTAPOSE_USER ?? process.env.USER ?? 'unknown',
111
+ approvedAt: new Date().toISOString(),
112
+ });
113
+ });
114
+ // Keep the chain alive even if this link rejects, or one failed approval
115
+ // would wedge every subsequent one.
116
+ this.approvalQueue = task.catch(() => { });
117
+ await task;
118
+ }
119
+ async run(storyIds) {
120
+ if (this.running)
121
+ return;
122
+ const all = await this.stories();
123
+ const selected = storyIds?.length
124
+ ? all.filter((s) => storyIds.includes(s.storyId))
125
+ : all;
126
+ this.running = true;
127
+ this.done = 0;
128
+ this.total = selected.length;
129
+ this.currentStory = null;
130
+ this.abort = new AbortController();
131
+ try {
132
+ const browser = await this.ensureBrowser();
133
+ const report = await runCapture({
134
+ browser,
135
+ platform: this.opts.platform,
136
+ store: this.opts.store,
137
+ baseUrl: this.opts.baseUrl,
138
+ stories: selected,
139
+ totalCount: all.length,
140
+ config: this.opts.config,
141
+ ancestry: existsSync(path.join(this.opts.projectRoot, '.git'))
142
+ ? createAncestryCheck(this.opts.projectRoot)
143
+ : alwaysAncestor,
144
+ runId: Date.now().toString(36),
145
+ branch: this.opts.branch,
146
+ commitSha: this.opts.commitSha,
147
+ signal: this.abort.signal,
148
+ onProgress: (done, total, storyId) => {
149
+ this.done = done;
150
+ this.total = total;
151
+ this.currentStory = storyId;
152
+ },
153
+ onArtifact: async (a) => {
154
+ const images = path.join(this.opts.runDir, 'images');
155
+ await mkdir(images, { recursive: true });
156
+ const safe = encodeStoryId(a.storyId);
157
+ await writeFile(path.join(images, `${safe}-current.webp`), a.current);
158
+ if (a.diff)
159
+ await writeFile(path.join(images, `${safe}-diff.webp`), a.diff);
160
+ },
161
+ });
162
+ // A partial run only re-captured some stories; merging keeps the others
163
+ // visible instead of blanking the panel down to the subset.
164
+ this.report = storyIds?.length ? mergeReports(this.report, report) : report;
165
+ await mkdir(this.opts.runDir, { recursive: true });
166
+ await writeFile(path.join(this.opts.runDir, 'report.json'), JSON.stringify(this.report, null, 2) + '\n');
167
+ }
168
+ finally {
169
+ this.running = false;
170
+ this.currentStory = null;
171
+ this.abort = null;
172
+ }
173
+ }
174
+ async cancel() {
175
+ this.abort?.abort();
176
+ }
177
+ async close() {
178
+ await this.browser?.close().catch(() => { });
179
+ this.browser = null;
180
+ }
181
+ async ensureBrowser() {
182
+ // Kept alive across runs: launching Chromium is most of the latency on a
183
+ // single-story recapture, which is the common case in the panel.
184
+ if (this.browser?.isConnected())
185
+ return this.browser;
186
+ this.browser = await chromium.launch(deterministicLaunchOptions());
187
+ return this.browser;
188
+ }
189
+ async loadReport() {
190
+ if (this.report)
191
+ return this.report;
192
+ try {
193
+ const raw = await readFile(path.join(this.opts.runDir, 'report.json'), 'utf8');
194
+ this.report = JSON.parse(raw);
195
+ }
196
+ catch {
197
+ this.report = null;
198
+ }
199
+ return this.report;
200
+ }
201
+ async readImage(kind, storyId) {
202
+ const file = path.join(this.opts.runDir, 'images', `${encodeStoryId(storyId)}-${kind}.webp`);
203
+ try {
204
+ return await readFile(file);
205
+ }
206
+ catch {
207
+ return null;
208
+ }
209
+ }
210
+ }
211
+ /** Overlay fresh results onto the previous report, keeping untouched stories. */
212
+ function mergeReports(previous, fresh) {
213
+ if (!previous)
214
+ return fresh;
215
+ const byId = new Map(previous.results.map((r) => [r.storyId, r]));
216
+ for (const result of fresh.results)
217
+ byId.set(result.storyId, result);
218
+ const results = [...byId.values()].sort((a, b) => a.storyId.localeCompare(b.storyId));
219
+ const summary = { pass: 0, fail: 0, new: 0, stale: 0, skip: 0 };
220
+ for (const r of results)
221
+ summary[r.status]++;
222
+ return { ...fresh, results, summary, totalCount: previous.totalCount };
223
+ }
224
+ /** The same hash the manifest stores, so a match means "already approved". */
225
+ function hash(bytes) {
226
+ return createHash('sha256').update(bytes).digest('hex');
227
+ }
228
+ //# sourceMappingURL=source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"source.js","sourceRoot":"","sources":["../src/source.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,35 @@
1
+ import type { RunReport, StoryManifest } from '@juxtapose/core';
2
+ /** What the panel polls for. One request, everything it needs to render. */
3
+ export interface ReviewBundle {
4
+ report: RunReport | null;
5
+ /** Story ids approved since the run, so the UI can grey them out without
6
+ * waiting for a re-run. */
7
+ approved: string[];
8
+ /** True while a capture is in flight. */
9
+ running: boolean;
10
+ }
11
+ export interface RunProgress {
12
+ running: boolean;
13
+ done: number;
14
+ total: number;
15
+ storyId: string | null;
16
+ }
17
+ export type ImageKind = 'current' | 'baseline' | 'diff';
18
+ /**
19
+ * Everything the HTTP layer needs, with no knowledge of how it is served.
20
+ *
21
+ * Splitting this out means the same implementation backs the standalone
22
+ * `jux serve` and the middleware mounted inside a workbench's dev server —
23
+ * and that a test can drive it without a socket.
24
+ */
25
+ export interface ReviewSource {
26
+ bundle(): Promise<ReviewBundle>;
27
+ progress(): Promise<RunProgress>;
28
+ image(kind: ImageKind, storyId: string): Promise<Buffer | null>;
29
+ handoff(storyId: string): Promise<string | null>;
30
+ approve(storyId: string): Promise<void>;
31
+ /** Capture a subset, or everything when `storyIds` is omitted. */
32
+ run(storyIds?: string[]): Promise<void>;
33
+ cancel(): Promise<void>;
34
+ stories(): Promise<StoryManifest[]>;
35
+ }
package/dist/types.js ADDED
@@ -0,0 +1,7 @@
1
+ // The review API's contract.
2
+ //
3
+ // This is deliberately the stable surface, not the panel. A Storybook addon,
4
+ // a Ladle UI, an MCP server and a CLI all want the same operations; making
5
+ // HTTP the contract means each is a skin rather than a reimplementation.
6
+ export {};
7
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,6BAA6B;AAC7B,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,yEAAyE"}
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@juxtapose/server",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "Local review API for juxtapose — the contract every review UI is a skin over.",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "license": "MIT",
10
+ "main": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": "./dist/index.js",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "dependencies": {
20
+ "playwright": "^1.48.0",
21
+ "@juxtapose/core": "0.2.0"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/juxtapose-dev/juxtapose.git",
26
+ "directory": "packages/server"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.json"
30
+ }
31
+ }