executable-stories-showcase 0.0.0-stage → 0.1.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 +202 -0
- package/README.md +17 -2
- package/dist/adapters/cookies.d.ts +11 -0
- package/dist/adapters/cookies.js +27 -0
- package/dist/adapters/cookies.js.map +1 -0
- package/dist/adapters/redux.d.ts +23 -0
- package/dist/adapters/redux.js +28 -0
- package/dist/adapters/redux.js.map +1 -0
- package/dist/adapters/tanstack-query.d.ts +17 -0
- package/dist/adapters/tanstack-query.js +43 -0
- package/dist/adapters/tanstack-query.js.map +1 -0
- package/dist/adapters/xstate.d.ts +33 -0
- package/dist/adapters/xstate.js +36 -0
- package/dist/adapters/xstate.js.map +1 -0
- package/dist/adapters/zustand.d.ts +17 -0
- package/dist/adapters/zustand.js +16 -0
- package/dist/adapters/zustand.js.map +1 -0
- package/dist/capture-CIKR46QE.js +10 -0
- package/dist/capture-CIKR46QE.js.map +1 -0
- package/dist/capture.d.ts +26 -0
- package/dist/catalog.d.ts +27 -0
- package/dist/chunk-BK4EEQXV.js +98 -0
- package/dist/chunk-BK4EEQXV.js.map +1 -0
- package/dist/chunk-C755RCRL.js +125 -0
- package/dist/chunk-C755RCRL.js.map +1 -0
- package/dist/chunk-COLHEOSE.js +148 -0
- package/dist/chunk-COLHEOSE.js.map +1 -0
- package/dist/chunk-DETES767.js +43 -0
- package/dist/chunk-DETES767.js.map +1 -0
- package/dist/chunk-ESIS3D6R.js +47 -0
- package/dist/chunk-ESIS3D6R.js.map +1 -0
- package/dist/chunk-MCC3CAWQ.js +208 -0
- package/dist/chunk-MCC3CAWQ.js.map +1 -0
- package/dist/chunk-XLNYICOI.js +234 -0
- package/dist/chunk-XLNYICOI.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +531 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt.d.ts +7 -0
- package/dist/link.d.ts +33 -0
- package/dist/network-I5SB4F6T.js +7 -0
- package/dist/network-I5SB4F6T.js.map +1 -0
- package/dist/network.d.ts +3 -0
- package/dist/next.d.ts +21 -0
- package/dist/next.js +30 -0
- package/dist/next.js.map +1 -0
- package/dist/no-msw-browser.d.ts +2 -0
- package/dist/no-msw-browser.js +8 -0
- package/dist/no-msw-browser.js.map +1 -0
- package/dist/panel.d.ts +30 -0
- package/dist/panel.js +607 -0
- package/dist/panel.js.map +1 -0
- package/dist/playwright.d.ts +31 -0
- package/dist/playwright.js +92 -0
- package/dist/playwright.js.map +1 -0
- package/dist/ready-4MENAMNI.js +13 -0
- package/dist/ready-4MENAMNI.js.map +1 -0
- package/dist/ready.d.ts +39 -0
- package/dist/record.d.ts +49 -0
- package/dist/redact.d.ts +18 -0
- package/dist/runtime.d.ts +191 -0
- package/dist/save.d.ts +27 -0
- package/dist/schema.d.ts +161 -0
- package/dist/vite.d.ts +11 -0
- package/dist/vite.js +26 -0
- package/dist/vite.js.map +1 -0
- package/package.json +118 -3
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// src/schema.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
var JsonSchema = z.json();
|
|
4
|
+
var parseJson = (text) => JsonSchema.parse(JSON.parse(text));
|
|
5
|
+
var MethodSchema = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
|
|
6
|
+
var ResponseSchema = z.object({
|
|
7
|
+
status: z.number().int().default(200),
|
|
8
|
+
/** Sent as JSON. */
|
|
9
|
+
body: JsonSchema.optional(),
|
|
10
|
+
/** Sent as text, e.g. an HTML error page. */
|
|
11
|
+
text: z.string().optional(),
|
|
12
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
13
|
+
/** Milliseconds, or "infinite" to hold the request open (loading states). */
|
|
14
|
+
delay: z.union([z.number().nonnegative(), z.literal("infinite")]).optional()
|
|
15
|
+
}).refine((r) => r.body === void 0 || r.text === void 0, {
|
|
16
|
+
message: 'Provide "body" (JSON) or "text", not both'
|
|
17
|
+
});
|
|
18
|
+
var NetworkEntrySchema = z.object({
|
|
19
|
+
method: MethodSchema.default("GET"),
|
|
20
|
+
/** MSW path syntax: "/api/users/:id", absolute URLs and wildcards work. */
|
|
21
|
+
path: z.string().min(1),
|
|
22
|
+
/** Only match requests whose query string contains these values. */
|
|
23
|
+
query: z.record(z.string(), z.string()).optional(),
|
|
24
|
+
response: ResponseSchema.optional(),
|
|
25
|
+
/** Successive responses for successive calls; the last one repeats. */
|
|
26
|
+
sequence: z.array(ResponseSchema).min(1).optional(),
|
|
27
|
+
/** Drop the inherited entry with the same method, path and query. */
|
|
28
|
+
remove: z.literal(true).optional()
|
|
29
|
+
}).refine((e) => [e.response, e.sequence, e.remove].filter(Boolean).length === 1, {
|
|
30
|
+
message: 'Provide exactly one of "response", "sequence" or "remove"'
|
|
31
|
+
});
|
|
32
|
+
var ScenarioSchema = z.object({
|
|
33
|
+
$schema: z.string().optional(),
|
|
34
|
+
// Kebab-case; a combined scenario is named by its parts, e.g. "signed-in,server-error".
|
|
35
|
+
name: z.string().regex(/^[a-z0-9][a-z0-9-]*(,[a-z0-9][a-z0-9-]*)*$/, 'Use kebab-case, e.g. "empty-search"'),
|
|
36
|
+
/** Not inherited. */
|
|
37
|
+
description: z.string().optional(),
|
|
38
|
+
/** Not inherited. */
|
|
39
|
+
tags: z.array(z.string()).optional(),
|
|
40
|
+
/** Scenario name(s) to build on. Later parents override earlier ones. */
|
|
41
|
+
extends: z.union([z.string(), z.array(z.string())]).optional(),
|
|
42
|
+
/** App route the scenario opens at, e.g. "/orders/42". Used by links, the panel, `shoot` and `openInlineScenario`. */
|
|
43
|
+
path: z.string().startsWith("/").optional(),
|
|
44
|
+
/** Query params the app should start with. null removes an inherited param. */
|
|
45
|
+
url: z.record(z.string(), z.string().nullable()).optional(),
|
|
46
|
+
/**
|
|
47
|
+
* A child entry replaces the inherited entry with the same method, path and query.
|
|
48
|
+
* Otherwise earlier entries win, and a scenario's own entries come before its parents'.
|
|
49
|
+
*/
|
|
50
|
+
network: z.array(NetworkEntrySchema).optional(),
|
|
51
|
+
/**
|
|
52
|
+
* Names of MSW handlers passed to `startScenarios({ handlers })`, e.g. ones your unit tests
|
|
53
|
+
* already use. They answer after this scenario's `network` entries. Inherited and combined.
|
|
54
|
+
*/
|
|
55
|
+
handlers: z.array(z.string().min(1)).optional(),
|
|
56
|
+
/** Per-adapter state, keyed by adapter key. Objects deep-merge; null removes a key. */
|
|
57
|
+
state: z.record(z.string(), JsonSchema).optional(),
|
|
58
|
+
/**
|
|
59
|
+
* Playwright selector that is visible once the UI shows this state,
|
|
60
|
+
* e.g. "text=Loading countries…". Not inherited: each state looks different.
|
|
61
|
+
*/
|
|
62
|
+
ready: z.string().optional(),
|
|
63
|
+
/** Browser size `shoot` and `record` use for this scenario, e.g. a phone layout. Inherited. */
|
|
64
|
+
viewport: z.object({ width: z.number().int().positive(), height: z.number().int().positive() }).optional()
|
|
65
|
+
});
|
|
66
|
+
function scenarioJsonSchema({ state = {} } = {}) {
|
|
67
|
+
return z.toJSONSchema(
|
|
68
|
+
ScenarioSchema.extend({ state: z.object(state).partial().catchall(JsonSchema).optional() }),
|
|
69
|
+
{
|
|
70
|
+
io: "input",
|
|
71
|
+
// Refinements aren't representable in JSON Schema; spell the exclusivity rules out.
|
|
72
|
+
override: ({ zodSchema, jsonSchema }) => {
|
|
73
|
+
if (zodSchema === NetworkEntrySchema) {
|
|
74
|
+
jsonSchema.oneOf = ["response", "sequence", "remove"].map((key) => ({ required: [key] }));
|
|
75
|
+
}
|
|
76
|
+
if (zodSchema === ResponseSchema) {
|
|
77
|
+
jsonSchema.not = { required: ["body", "text"] };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
function parseScenario(data, source = "scenario") {
|
|
84
|
+
const result = ScenarioSchema.safeParse(data);
|
|
85
|
+
if (result.success) return result.data;
|
|
86
|
+
throw new Error(`Invalid scenario "${data.name || source}":
|
|
87
|
+
${z.prettifyError(result.error)}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export {
|
|
91
|
+
JsonSchema,
|
|
92
|
+
parseJson,
|
|
93
|
+
MethodSchema,
|
|
94
|
+
ScenarioSchema,
|
|
95
|
+
scenarioJsonSchema,
|
|
96
|
+
parseScenario
|
|
97
|
+
};
|
|
98
|
+
//# sourceMappingURL=chunk-BK4EEQXV.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/schema.ts"],"sourcesContent":["import { z } from 'zod';\n\n/** Scenarios live in JSON files and URLs, so everything they carry is JSON. */\nexport const JsonSchema = z.json();\n\nexport type Json = z.output<typeof JsonSchema>;\n\nexport type JsonObject = { [key: string]: Json; };\n\n/** Anything an app can hand over to be stored as JSON. */\nexport type Serializable = object | string | number | boolean | null;\n\n/**\n * Decode JSON text. Pair with `JSON.stringify` to copy app state into the format scenarios\n * are stored in: functions and undefined drop out, and the result is checked to be JSON.\n */\nexport const parseJson = (text: string): Json => JsonSchema.parse(JSON.parse(text));\n\nexport const MethodSchema = z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);\n\nconst ResponseSchema = z\n .object({\n status: z.number().int().default(200),\n /** Sent as JSON. */\n body: JsonSchema.optional(),\n /** Sent as text, e.g. an HTML error page. */\n text: z.string().optional(),\n headers: z.record(z.string(), z.string()).optional(),\n /** Milliseconds, or \"infinite\" to hold the request open (loading states). */\n delay: z.union([z.number().nonnegative(), z.literal('infinite')]).optional(),\n })\n .refine((r) => r.body === undefined || r.text === undefined, {\n message: 'Provide \"body\" (JSON) or \"text\", not both',\n });\n\nconst NetworkEntrySchema = z\n .object({\n method: MethodSchema.default('GET'),\n /** MSW path syntax: \"/api/users/:id\", absolute URLs and wildcards work. */\n path: z.string().min(1),\n /** Only match requests whose query string contains these values. */\n query: z.record(z.string(), z.string()).optional(),\n response: ResponseSchema.optional(),\n /** Successive responses for successive calls; the last one repeats. */\n sequence: z.array(ResponseSchema).min(1).optional(),\n /** Drop the inherited entry with the same method, path and query. */\n remove: z.literal(true).optional(),\n })\n .refine((e) => [e.response, e.sequence, e.remove].filter(Boolean).length === 1, {\n message: 'Provide exactly one of \"response\", \"sequence\" or \"remove\"',\n });\n\nexport const ScenarioSchema = z.object({\n $schema: z.string().optional(),\n // Kebab-case; a combined scenario is named by its parts, e.g. \"signed-in,server-error\".\n name: z.string().regex(/^[a-z0-9][a-z0-9-]*(,[a-z0-9][a-z0-9-]*)*$/, 'Use kebab-case, e.g. \"empty-search\"'),\n /** Not inherited. */\n description: z.string().optional(),\n /** Not inherited. */\n tags: z.array(z.string()).optional(),\n /** Scenario name(s) to build on. Later parents override earlier ones. */\n extends: z.union([z.string(), z.array(z.string())]).optional(),\n /** App route the scenario opens at, e.g. \"/orders/42\". Used by links, the panel, `shoot` and `openInlineScenario`. */\n path: z.string().startsWith('/').optional(),\n /** Query params the app should start with. null removes an inherited param. */\n url: z.record(z.string(), z.string().nullable()).optional(),\n /**\n * A child entry replaces the inherited entry with the same method, path and query.\n * Otherwise earlier entries win, and a scenario's own entries come before its parents'.\n */\n network: z.array(NetworkEntrySchema).optional(),\n /**\n * Names of MSW handlers passed to `startScenarios({ handlers })`, e.g. ones your unit tests\n * already use. They answer after this scenario's `network` entries. Inherited and combined.\n */\n handlers: z.array(z.string().min(1)).optional(),\n /** Per-adapter state, keyed by adapter key. Objects deep-merge; null removes a key. */\n state: z.record(z.string(), JsonSchema).optional(),\n /**\n * Playwright selector that is visible once the UI shows this state,\n * e.g. \"text=Loading countries…\". Not inherited: each state looks different.\n */\n ready: z.string().optional(),\n /** Browser size `shoot` and `record` use for this scenario, e.g. a phone layout. Inherited. */\n viewport: z.object({ width: z.number().int().positive(), height: z.number().int().positive() }).optional(),\n});\n\nexport type Scenario = z.output<typeof ScenarioSchema>;\n\nexport type ScenarioInput = z.input<typeof ScenarioSchema>;\n\nexport type NetworkEntry = z.output<typeof NetworkEntrySchema>;\n\nexport type ScenarioResponse = z.output<typeof ResponseSchema>;\n\n/**\n * JSON Schema for editors, validators and AI generators.\n * Pass adapter state schemas so `state` is described too.\n */\nexport function scenarioJsonSchema({ state = {} }: { state?: Record<string, z.ZodType>; } = {}) {\n return z.toJSONSchema(\n ScenarioSchema.extend({ state: z.object(state).partial().catchall(JsonSchema).optional() }),\n {\n io: 'input',\n // Refinements aren't representable in JSON Schema; spell the exclusivity rules out.\n override: ({ zodSchema, jsonSchema }) => {\n if (zodSchema === NetworkEntrySchema) {\n jsonSchema.oneOf = ['response', 'sequence', 'remove'].map((key) => ({ required: [key] }));\n }\n\n if (zodSchema === ResponseSchema) {\n jsonSchema.not = { required: ['body', 'text'] };\n }\n },\n },\n );\n}\n\n/** Validate scenario input (from a file, URL or editor) into a Scenario, with readable errors. */\nexport function parseScenario(data: ScenarioInput, source = 'scenario'): Scenario {\n const result = ScenarioSchema.safeParse(data);\n\n if (result.success) return result.data;\n throw new Error(`Invalid scenario \"${data.name || source}\":\\n${z.prettifyError(result.error)}`);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PARAM,
|
|
3
|
+
RECORD_PARAM,
|
|
4
|
+
STRICT_PARAM
|
|
5
|
+
} from "./chunk-ESIS3D6R.js";
|
|
6
|
+
import {
|
|
7
|
+
diffScenario
|
|
8
|
+
} from "./chunk-MCC3CAWQ.js";
|
|
9
|
+
import {
|
|
10
|
+
parseScenario
|
|
11
|
+
} from "./chunk-BK4EEQXV.js";
|
|
12
|
+
|
|
13
|
+
// src/redact.ts
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
|
|
16
|
+
// src/jwt.ts
|
|
17
|
+
var encode = (value) => btoa(Array.from(new TextEncoder().encode(JSON.stringify(value)), (b) => String.fromCharCode(b)).join("")).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
18
|
+
var unsignedJwt = (claims) => `${encode({ alg: "none", typ: "JWT" })}.${encode(claims)}.`;
|
|
19
|
+
|
|
20
|
+
// src/redact.ts
|
|
21
|
+
var SECRET_KEY = /pass(word|phrase)?$|secret|api[-_]?key|private[-_]?key|access[-_]?key|credential|^otp$|card[-_]?number|^cvc$|^cvv$/i;
|
|
22
|
+
var TOKEN_KEY = /token|session[-_]?id|^sid$|authorization|cookie/i;
|
|
23
|
+
var NUMERIC_ID_KEY = /session[-_]?id$|^sid$/i;
|
|
24
|
+
var JWT = /^[\w-]+\.[\w-]+\.[\w-]*$/;
|
|
25
|
+
var SECRET_VALUE = /^(Bearer|Basic) \S|^[sr]k_(live|test)_\w|^gh[pousr]_\w{20}|^github_pat_\w|^AKIA[0-9A-Z]{16}$|^xox[abprs]-/;
|
|
26
|
+
var SECRET_PARAM = /^(code|state|nonce)$/i;
|
|
27
|
+
var PLACEHOLDER = "[redacted]";
|
|
28
|
+
var decode = (part) => {
|
|
29
|
+
try {
|
|
30
|
+
const bytes = Uint8Array.from(atob(part.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
|
|
31
|
+
const value = JSON.parse(new TextDecoder().decode(bytes));
|
|
32
|
+
return value instanceof Object && !Array.isArray(value) ? value : void 0;
|
|
33
|
+
} catch {
|
|
34
|
+
return void 0;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
var unsign = (token) => {
|
|
38
|
+
if (!JWT.test(token)) return void 0;
|
|
39
|
+
const [header = "", payload = ""] = token.split(".");
|
|
40
|
+
if (!decode(header)) return void 0;
|
|
41
|
+
const claims = decode(payload);
|
|
42
|
+
return claims && unsignedJwt(claims);
|
|
43
|
+
};
|
|
44
|
+
function redactSecrets(value) {
|
|
45
|
+
const redacted = [];
|
|
46
|
+
const walk = (node, path, key) => {
|
|
47
|
+
const text = z.string().safeParse(node);
|
|
48
|
+
const jwt = text.success ? unsign(text.data) : void 0;
|
|
49
|
+
if (jwt !== void 0) {
|
|
50
|
+
if (jwt !== text.data) redacted.push(path);
|
|
51
|
+
return jwt;
|
|
52
|
+
}
|
|
53
|
+
const secret = SECRET_KEY.test(key) || TOKEN_KEY.test(key);
|
|
54
|
+
const number = z.number().safeParse(node);
|
|
55
|
+
if ((SECRET_KEY.test(key) || NUMERIC_ID_KEY.test(key)) && number.success) {
|
|
56
|
+
redacted.push(path);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
if (text.success && (secret && text.data || SECRET_VALUE.test(text.data))) {
|
|
60
|
+
redacted.push(path);
|
|
61
|
+
return PLACEHOLDER;
|
|
62
|
+
}
|
|
63
|
+
if (text.success || number.success) return node;
|
|
64
|
+
if (Array.isArray(node)) return node.map((item, i) => walk(item, `${path}[${i}]`, key));
|
|
65
|
+
if (node instanceof Object) {
|
|
66
|
+
return Object.fromEntries(
|
|
67
|
+
Object.entries(node).map(([k, v]) => [k, walk(v, path ? `${path}.${k}` : k, secret ? key : k)])
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return node;
|
|
71
|
+
};
|
|
72
|
+
return { value: walk(value, "", ""), redacted };
|
|
73
|
+
}
|
|
74
|
+
function withoutSecretParams(params, where) {
|
|
75
|
+
const redacted = z.record(z.string(), z.unknown()).parse(redactSecrets(params).value);
|
|
76
|
+
const dropped = Object.keys(params).filter((key) => SECRET_PARAM.test(key) || redacted[key] !== params[key]);
|
|
77
|
+
if (dropped.length) console.info(`[executable-stories-showcase] left ${dropped.join(", ")} out of ${where}`);
|
|
78
|
+
return Object.fromEntries(Object.entries(params).filter(([key]) => !dropped.includes(key)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/capture.ts
|
|
82
|
+
var SCENARIO_PARAMS = /* @__PURE__ */ new Set([PARAM, STRICT_PARAM, RECORD_PARAM]);
|
|
83
|
+
function captureScenario(session, options) {
|
|
84
|
+
const params = {};
|
|
85
|
+
new URLSearchParams(location.search).forEach((v, k) => {
|
|
86
|
+
if (!SCENARIO_PARAMS.has(k)) params[k] = v;
|
|
87
|
+
});
|
|
88
|
+
const url = withoutSecretParams(params, "the captured URL");
|
|
89
|
+
const state = Object.fromEntries(
|
|
90
|
+
session.keys().flatMap((key) => {
|
|
91
|
+
const live = session.read(key);
|
|
92
|
+
if (live === void 0) return [];
|
|
93
|
+
const { value, redacted } = redactSecrets(live);
|
|
94
|
+
if (redacted.length) {
|
|
95
|
+
console.info(`[executable-stories-showcase] redacted ${redacted.join(", ")} in state["${key}"]`);
|
|
96
|
+
}
|
|
97
|
+
return [[key, value]];
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
const network = [...session.resolved?.network ?? [], ...session.recorded()];
|
|
101
|
+
const draft = parseScenario({
|
|
102
|
+
name: options.name,
|
|
103
|
+
description: options.description || void 0,
|
|
104
|
+
path: location.pathname,
|
|
105
|
+
url,
|
|
106
|
+
network: network.length ? network : void 0,
|
|
107
|
+
handlers: session.resolved?.handlers,
|
|
108
|
+
viewport: session.resolved?.viewport,
|
|
109
|
+
state,
|
|
110
|
+
ready: options.ready || void 0
|
|
111
|
+
});
|
|
112
|
+
const flat = session.catalog.resolve(draft);
|
|
113
|
+
const flatBase = options.base ? session.catalog.resolve(options.base) : void 0;
|
|
114
|
+
const forCatalog = diffScenario(flat, flatBase);
|
|
115
|
+
if (options.schemaPath) forCatalog.$schema = options.schemaPath;
|
|
116
|
+
return { flat, forCatalog };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export {
|
|
120
|
+
unsignedJwt,
|
|
121
|
+
redactSecrets,
|
|
122
|
+
withoutSecretParams,
|
|
123
|
+
captureScenario
|
|
124
|
+
};
|
|
125
|
+
//# sourceMappingURL=chunk-C755RCRL.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/redact.ts","../src/jwt.ts","../src/capture.ts"],"sourcesContent":["import { z } from 'zod';\nimport { unsignedJwt } from './jwt.js';\nimport type { Json, JsonObject } from './schema.js';\n\n/** Keys whose values are secrets whatever they hold. */\nconst SECRET_KEY =\n /pass(word|phrase)?$|secret|api[-_]?key|private[-_]?key|access[-_]?key|credential|^otp$|card[-_]?number|^cvc$|^cvv$/i;\n\n/** Keys holding bearer values: a JWT keeps its claims, anything else is replaced. */\nconst TOKEN_KEY = /token|session[-_]?id|^sid$|authorization|cookie/i;\n\n/** Numeric bearer values: session ids. */\nconst NUMERIC_ID_KEY = /session[-_]?id$|^sid$/i;\n\nconst JWT = /^[\\w-]+\\.[\\w-]+\\.[\\w-]*$/;\n\n/**\n * Values that are credentials whatever key holds them: an auth header, and the key formats of\n * Stripe, GitHub, AWS and Slack.\n */\nconst SECRET_VALUE = /^(Bearer|Basic) \\S|^[sr]k_(live|test)_\\w|^gh[pousr]_\\w{20}|^github_pat_\\w|^AKIA[0-9A-Z]{16}$|^xox[abprs]-/;\n\n/** URL params that carry a one-time credential, such as an OAuth authorization `code`. */\nconst SECRET_PARAM = /^(code|state|nonce)$/i;\n\nconst PLACEHOLDER = '[redacted]';\n\nconst decode = (part: string): JsonObject | undefined => {\n try {\n const bytes = Uint8Array.from(atob(part.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));\n const value: Json = JSON.parse(new TextDecoder().decode(bytes));\n\n return value instanceof Object && !Array.isArray(value) ? value : undefined;\n } catch {\n return undefined;\n }\n};\n\n/** A JWT re-issued unsigned with the same claims, so the app still decodes it but no server accepts it. */\nconst unsign = (token: string) => {\n if (!JWT.test(token)) return undefined;\n const [header = '', payload = ''] = token.split('.');\n\n if (!decode(header)) return undefined;\n const claims = decode(payload);\n\n return claims && unsignedJwt(claims);\n};\n\nexport interface Redacted {\n value: Json;\n /** Paths that changed, e.g. `user.accessToken`. */\n redacted: string[];\n}\n\n/**\n * Remove secrets from recorded responses and captured state: strings under secret-like keys\n * become \"[redacted]\" and numbers become 0, and signed JWTs anywhere lose their signature but\n * keep their claims.\n */\nexport function redactSecrets(value: Json): Redacted {\n const redacted: string[] = [];\n\n const walk = (node: Json, path: string, key: string): Json => {\n const text = z.string().safeParse(node);\n // A JWT keeps its claims, whatever key holds it: the app may decode it.\n const jwt = text.success ? unsign(text.data) : undefined;\n\n if (jwt !== undefined) {\n if (jwt !== text.data) redacted.push(path);\n\n return jwt;\n }\n\n // Checked before the value's type: a card number or one-time code is often a number.\n // Token-like keys only for strings: `tokens: 3` is usually a count, not a credential.\n const secret = SECRET_KEY.test(key) || TOKEN_KEY.test(key);\n const number = z.number().safeParse(node);\n\n if ((SECRET_KEY.test(key) || NUMERIC_ID_KEY.test(key)) && number.success) {\n redacted.push(path);\n\n return 0; // still a number, so the app's parsing and formatting keep working\n }\n\n if (text.success && ((secret && text.data) || SECRET_VALUE.test(text.data))) {\n redacted.push(path);\n\n return PLACEHOLDER;\n }\n\n if (text.success || number.success) return node;\n\n if (Array.isArray(node)) return node.map((item, i) => walk(item, `${path}[${i}]`, key));\n\n if (node instanceof Object) {\n // Everything under a secret key is secret too, e.g. a private key's JWK fields.\n return Object.fromEntries(\n Object.entries(node).map(([k, v]) => [k, walk(v, path ? `${path}.${k}` : k, secret ? key : k)]),\n );\n }\n\n return node;\n };\n\n return { value: walk(value, '', ''), redacted };\n}\n\n/**\n * Drops URL params whose value redaction would change (`?api_key=…`, `?token=…`), and OAuth's\n * one-time `code`, `state` and `nonce`. Saved params are matched or replayed as written, so `\"[redacted]\"`\n * would be wrong; without the param, a recorded request matches whatever value the app sends.\n */\nexport function withoutSecretParams(params: Record<string, string>, where: string): Record<string, string> {\n const redacted = z.record(z.string(), z.unknown()).parse(redactSecrets(params).value);\n const dropped = Object.keys(params).filter((key) => SECRET_PARAM.test(key) || redacted[key] !== params[key]);\n\n if (dropped.length) console.info(`[executable-stories-showcase] left ${dropped.join(', ')} out of ${where}`);\n\n return Object.fromEntries(Object.entries(params).filter(([key]) => !dropped.includes(key)));\n}\n","import type { JsonObject } from './schema.js';\n\n/** Base64url of UTF-8, so claims in any script (\"José\", \"李雷\", \"Zoë\") survive decoding. */\nconst encode = (value: JsonObject) =>\n btoa(Array.from(new TextEncoder().encode(JSON.stringify(value)), (b) => String.fromCharCode(b)).join(''))\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '');\n\n/**\n * An unsigned JWT (`alg: \"none\"`) carrying `claims`, for mocked responses in \"signed-in\" scenarios\n * when the app only decodes the token it gets back. Anything that verifies signatures rejects it,\n * which keeps it out of real backends. Set `exp` in the future if the app checks expiry.\n */\nexport const unsignedJwt = (claims: JsonObject): string =>\n `${encode({ alg: 'none', typ: 'JWT' })}.${encode(claims)}.`;\n","import { diffScenario } from './catalog.js';\nimport { PARAM, RECORD_PARAM, STRICT_PARAM } from './link.js';\nimport { redactSecrets, withoutSecretParams } from './redact.js';\nimport type { ScenarioSession } from './runtime.js';\nimport { parseScenario, type Scenario, type ScenarioInput } from './schema.js';\n\nconst SCENARIO_PARAMS = new Set([PARAM, STRICT_PARAM, RECORD_PARAM]);\n\nexport interface CaptureOptions {\n name: string;\n description?: string;\n /** Playwright selector that marks this UI state ready. */\n ready?: string;\n /**\n * Catalog scenario to `extends`. Diff uses its resolved (flattened) form.\n * Omit for a full self-contained snapshot.\n */\n base?: Scenario;\n /** Relative `$schema` path stamped on the catalog file only, e.g. `../scenario.schema.json`. */\n schemaPath?: string;\n}\n\nexport interface CapturedScenario {\n /** Flattened snapshot for inline share links. */\n flat: Scenario;\n /** File to commit: diffs against `base` when set, otherwise the flat snapshot as input. */\n forCatalog: ScenarioInput;\n}\n\n/**\n * Snapshot what the app shows now: current route and URL params, the active network\n * fixtures (plus recorded responses), and every connected adapter's live state.\n */\nexport function captureScenario(session: ScenarioSession, options: CaptureOptions): CapturedScenario {\n const params: Record<string, string> = {};\n new URLSearchParams(location.search).forEach((v, k) => {\n if (!SCENARIO_PARAMS.has(k)) params[k] = v;\n });\n // A page URL can carry a token or a magic-link code; it is saved like recorded traffic.\n const url = withoutSecretParams(params, 'the captured URL');\n\n const state = Object.fromEntries(\n session.keys().flatMap((key) => {\n const live = session.read(key);\n if (live === undefined) return [];\n\n // A store or cache can hold a session token; captured scenarios get committed and shared,\n // so they get the same redaction as recorded responses.\n const { value, redacted } = redactSecrets(live);\n\n if (redacted.length) {\n console.info(`[executable-stories-showcase] redacted ${redacted.join(', ')} in state[\"${key}\"]`);\n }\n\n return [[key, value] as const];\n }),\n );\n\n // Recorded responses go after the scenario's own: they answered requests it didn't mock.\n const network = [...(session.resolved?.network ?? []), ...session.recorded()];\n\n const draft = parseScenario({\n name: options.name,\n description: options.description || undefined,\n path: location.pathname,\n url,\n network: network.length ? network : undefined,\n handlers: session.resolved?.handlers,\n viewport: session.resolved?.viewport,\n state,\n ready: options.ready || undefined,\n });\n\n const flat = session.catalog.resolve(draft);\n const flatBase = options.base ? session.catalog.resolve(options.base) : undefined;\n const forCatalog = diffScenario(flat, flatBase);\n\n if (options.schemaPath) forCatalog.$schema = options.schemaPath;\n\n return { flat, forCatalog };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SAVE_ENDPOINT
|
|
3
|
+
} from "./chunk-ESIS3D6R.js";
|
|
4
|
+
import {
|
|
5
|
+
JsonSchema,
|
|
6
|
+
ScenarioSchema
|
|
7
|
+
} from "./chunk-BK4EEQXV.js";
|
|
8
|
+
|
|
9
|
+
// src/save.ts
|
|
10
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
11
|
+
import { createServer } from "http";
|
|
12
|
+
import { join, relative } from "path";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
var MAX_BODY = 20 * 1024 * 1024;
|
|
15
|
+
var SaveRequestSchema = z.object({ scenario: JsonSchema, overwrite: z.boolean().optional() });
|
|
16
|
+
async function handleSaveRequest(request, { dir, root, origins }) {
|
|
17
|
+
const display = (file2) => relative(root, file2).replaceAll("\\", "/");
|
|
18
|
+
const reply = (status, body2) => Response.json(body2, { status });
|
|
19
|
+
if (!allowedOrigin(request.headers, origins)) {
|
|
20
|
+
return reply(403, { error: "Save refused: only the app on localhost may write files" });
|
|
21
|
+
}
|
|
22
|
+
if (request.method === "GET") return reply(200, { dir: display(dir) });
|
|
23
|
+
if (request.method !== "POST") return reply(405, { error: "Use GET or POST" });
|
|
24
|
+
if (!request.headers.get("content-type")?.startsWith("application/json")) {
|
|
25
|
+
return reply(415, { error: "Send application/json" });
|
|
26
|
+
}
|
|
27
|
+
let body;
|
|
28
|
+
try {
|
|
29
|
+
body = SaveRequestSchema.parse(JSON.parse(await readCapped(request)));
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return reply(400, { error: err instanceof z.ZodError ? z.prettifyError(err) : String(err) });
|
|
32
|
+
}
|
|
33
|
+
const parsed = ScenarioSchema.safeParse(body.scenario);
|
|
34
|
+
if (!parsed.success) return reply(400, { error: z.prettifyError(parsed.error) });
|
|
35
|
+
const { name } = parsed.data;
|
|
36
|
+
if (name.includes(",")) return reply(400, { error: "A saved scenario name can't contain a comma" });
|
|
37
|
+
const file = join(dir, `${name}.json`);
|
|
38
|
+
if (existsSync(file) && !body.overwrite) {
|
|
39
|
+
return reply(409, { error: `${display(file)} already exists`, path: display(file) });
|
|
40
|
+
}
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
writeFileSync(file, `${JSON.stringify(body.scenario, null, 2)}
|
|
43
|
+
`);
|
|
44
|
+
return reply(200, { path: display(file), file });
|
|
45
|
+
}
|
|
46
|
+
function createSaveHandler(target) {
|
|
47
|
+
return async (req, res) => {
|
|
48
|
+
const origin = req.headers.origin;
|
|
49
|
+
if (origin && target.origins?.includes(origin)) {
|
|
50
|
+
res.setHeader("access-control-allow-origin", origin);
|
|
51
|
+
res.setHeader("vary", "origin");
|
|
52
|
+
}
|
|
53
|
+
if (req.method === "OPTIONS") {
|
|
54
|
+
res.setHeader("access-control-allow-methods", "GET, POST");
|
|
55
|
+
res.setHeader("access-control-allow-headers", "content-type");
|
|
56
|
+
res.statusCode = 204;
|
|
57
|
+
res.end();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
let response;
|
|
61
|
+
try {
|
|
62
|
+
response = isLoopbackAddress(req.socket.remoteAddress) ? await handleSaveRequest(toRequest(req), target) : Response.json({ error: "Save refused: only this machine may write files" }, { status: 403 });
|
|
63
|
+
} catch (err) {
|
|
64
|
+
response = Response.json({ error: String(err) }, { status: 500 });
|
|
65
|
+
}
|
|
66
|
+
res.statusCode = response.status;
|
|
67
|
+
response.headers.forEach((value, key) => res.setHeader(key, value));
|
|
68
|
+
res.end(await response.text());
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function startSaveServer(target) {
|
|
72
|
+
const handle = createSaveHandler(target);
|
|
73
|
+
const server = createServer((req, res) => void handle(req, res));
|
|
74
|
+
return new Promise((done, fail) => {
|
|
75
|
+
server.once("error", fail);
|
|
76
|
+
server.listen(0, "127.0.0.1", () => {
|
|
77
|
+
const { port } = z.object({ port: z.number() }).parse(server.address());
|
|
78
|
+
server.unref();
|
|
79
|
+
done(`http://127.0.0.1:${port}${SAVE_ENDPOINT}`);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function isLoopbackAddress(address) {
|
|
84
|
+
if (!address) return false;
|
|
85
|
+
const ip = address.replace(/^::ffff:/, "");
|
|
86
|
+
return ip === "::1" || /^127(\.\d{1,3}){3}$/.test(ip);
|
|
87
|
+
}
|
|
88
|
+
function allowedOrigin(headers, origins) {
|
|
89
|
+
const host = headers.get("host") ?? "";
|
|
90
|
+
if (!isLoopbackHost(host)) return false;
|
|
91
|
+
const origin = headers.get("origin");
|
|
92
|
+
if (!origin) return true;
|
|
93
|
+
if (origins) return origins.includes(origin);
|
|
94
|
+
try {
|
|
95
|
+
return new URL(origin).host === host;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function isLoopbackHost(host) {
|
|
101
|
+
let hostname;
|
|
102
|
+
try {
|
|
103
|
+
hostname = new URL(`http://${host}`).hostname;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "[::1]" || /^127(\.\d{1,3}){3}$/.test(hostname);
|
|
108
|
+
}
|
|
109
|
+
function toRequest(req) {
|
|
110
|
+
const headers = new Headers();
|
|
111
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) headers.append(req.rawHeaders[i], req.rawHeaders[i + 1]);
|
|
112
|
+
const method = req.method ?? "GET";
|
|
113
|
+
const body = method === "GET" || method === "HEAD" ? void 0 : new ReadableStream({
|
|
114
|
+
start(controller) {
|
|
115
|
+
req.on("data", (chunk) => controller.enqueue(new Uint8Array(chunk)));
|
|
116
|
+
req.on("end", () => controller.close());
|
|
117
|
+
req.on("error", (err) => controller.error(err));
|
|
118
|
+
},
|
|
119
|
+
cancel() {
|
|
120
|
+
req.destroy();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
const init = { method, headers, body, duplex: "half" };
|
|
124
|
+
return new Request(new URL(req.url ?? "/", `http://${headers.get("host") ?? "localhost"}`), init);
|
|
125
|
+
}
|
|
126
|
+
async function readCapped(request) {
|
|
127
|
+
const reader = request.body?.getReader();
|
|
128
|
+
if (!reader) return "";
|
|
129
|
+
const chunks = [];
|
|
130
|
+
let size = 0;
|
|
131
|
+
for (; ; ) {
|
|
132
|
+
const { done, value } = await reader.read();
|
|
133
|
+
if (done) break;
|
|
134
|
+
size += value.length;
|
|
135
|
+
if (size > MAX_BODY) {
|
|
136
|
+
await reader.cancel();
|
|
137
|
+
throw new Error("Scenario too large");
|
|
138
|
+
}
|
|
139
|
+
chunks.push(value);
|
|
140
|
+
}
|
|
141
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export {
|
|
145
|
+
createSaveHandler,
|
|
146
|
+
startSaveServer
|
|
147
|
+
};
|
|
148
|
+
//# sourceMappingURL=chunk-COLHEOSE.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/save.ts"],"sourcesContent":["import { existsSync, mkdirSync, writeFileSync } from 'node:fs';\nimport { createServer, type IncomingMessage, type ServerResponse } from 'node:http';\nimport { join, relative } from 'node:path';\nimport { z } from 'zod';\nimport { SAVE_ENDPOINT } from './link.js';\nimport { JsonSchema, ScenarioSchema } from './schema.js';\n\n// The panel's \"Save to project\" endpoint, shared by the Vite plugin and the Next.js route.\n\nconst MAX_BODY = 20 * 1024 * 1024;\n\nconst SaveRequestSchema = z.object({ scenario: JsonSchema, overwrite: z.boolean().optional() });\n\ninterface SaveReply {\n error?: string;\n /** Saved file, relative to the project root. */\n path?: string;\n /** Saved file, absolute. */\n file?: string;\n /** Scenarios folder, relative to the project root. */\n dir?: string;\n}\n\nexport interface SaveTarget {\n /** Absolute folder scenarios are written into. */\n dir: string;\n /** Absolute project root; replies show paths relative to it. */\n root: string;\n /** Page origins allowed to save cross-origin. Without it, only same-origin requests may. */\n origins?: string[];\n}\n\n/**\n * GET reports the folder; POST `{ scenario, overwrite? }` writes `<dir>/<name>.json`.\n * JSON from an allowed origin on a loopback host only, schema-checked, and never replaces a\n * file unless `overwrite` is set.\n */\nexport async function handleSaveRequest(\n request: Request,\n { dir, root, origins }: SaveTarget,\n): Promise<Response> {\n const display = (file: string) => relative(root, file).replaceAll('\\\\', '/');\n const reply = (status: number, body: SaveReply) => Response.json(body, { status });\n\n if (!allowedOrigin(request.headers, origins)) {\n return reply(403, { error: 'Save refused: only the app on localhost may write files' });\n }\n\n if (request.method === 'GET') return reply(200, { dir: display(dir) });\n\n if (request.method !== 'POST') return reply(405, { error: 'Use GET or POST' });\n\n // A JSON content type also forces a CORS preflight, which only allowed origins pass.\n if (!request.headers.get('content-type')?.startsWith('application/json')) {\n return reply(415, { error: 'Send application/json' });\n }\n\n let body: z.output<typeof SaveRequestSchema>;\n\n try {\n body = SaveRequestSchema.parse(JSON.parse(await readCapped(request)));\n } catch (err) {\n return reply(400, { error: err instanceof z.ZodError ? z.prettifyError(err) : String(err) });\n }\n\n // The schema's kebab-case name rule also keeps the file inside `dir`.\n const parsed = ScenarioSchema.safeParse(body.scenario);\n\n if (!parsed.success) return reply(400, { error: z.prettifyError(parsed.error) });\n\n const { name } = parsed.data;\n\n if (name.includes(',')) return reply(400, { error: \"A saved scenario name can't contain a comma\" });\n\n const file = join(dir, `${name}.json`);\n\n if (existsSync(file) && !body.overwrite) {\n return reply(409, { error: `${display(file)} already exists`, path: display(file) });\n }\n\n mkdirSync(dir, { recursive: true });\n // Write what the panel sent, not the parsed form, so defaults don't bloat the file.\n writeFileSync(file, `${JSON.stringify(body.scenario, null, 2)}\\n`);\n\n return reply(200, { path: display(file), file });\n}\n\n/**\n * The save endpoint as Node middleware. Headers can be forged by any client and the socket\n * can't, so only connections from this machine reach the handler.\n */\nexport function createSaveHandler(target: SaveTarget) {\n return async (req: IncomingMessage, res: ServerResponse) => {\n const origin = req.headers.origin;\n\n // Cross-origin saves (the Next.js save server) need CORS for the origins the target allows.\n if (origin && target.origins?.includes(origin)) {\n res.setHeader('access-control-allow-origin', origin);\n res.setHeader('vary', 'origin');\n }\n\n if (req.method === 'OPTIONS') {\n res.setHeader('access-control-allow-methods', 'GET, POST');\n res.setHeader('access-control-allow-headers', 'content-type');\n res.statusCode = 204;\n res.end();\n\n return;\n }\n\n let response: Response;\n\n try {\n response = isLoopbackAddress(req.socket.remoteAddress)\n ? await handleSaveRequest(toRequest(req), target)\n : Response.json({ error: 'Save refused: only this machine may write files' }, { status: 403 });\n } catch (err) {\n // A bad Host header or a dropped connection: answer rather than leave the request hanging.\n response = Response.json({ error: String(err) }, { status: 500 });\n }\n\n res.statusCode = response.status;\n response.headers.forEach((value, key) => res.setHeader(key, value));\n res.end(await response.text());\n };\n}\n\n/**\n * Start a save server on 127.0.0.1, unreachable from other machines whatever the app server\n * listens on. Resolves to its save URL.\n */\nexport function startSaveServer(target: SaveTarget): Promise<string> {\n const handle = createSaveHandler(target);\n const server = createServer((req, res) => void handle(req, res));\n\n return new Promise((done, fail) => {\n server.once('error', fail);\n server.listen(0, '127.0.0.1', () => {\n const { port } = z.object({ port: z.number() }).parse(server.address());\n server.unref(); // never keeps the dev process alive on its own\n done(`http://127.0.0.1:${port}${SAVE_ENDPOINT}`);\n });\n });\n}\n\n/** True for a loopback peer address, as Node reports it in `socket.remoteAddress`. */\nexport function isLoopbackAddress(address: string | undefined): boolean {\n if (!address) return false;\n const ip = address.replace(/^::ffff:/, '');\n\n return ip === '::1' || /^127(\\.\\d{1,3}){3}$/.test(ip);\n}\n\nfunction allowedOrigin(headers: Headers, origins: string[] | undefined): boolean {\n const host = headers.get('host') ?? '';\n\n // DNS rebinding points an attacker's domain at 127.0.0.1, making its requests \"same-origin\";\n // the Host header still names that domain, so only loopback hosts may write.\n if (!isLoopbackHost(host)) return false;\n\n const origin = headers.get('origin');\n\n // No Origin: same-origin GET, curl or a test. Browsers always send it on cross-origin requests.\n if (!origin) return true;\n\n if (origins) return origins.includes(origin);\n\n try {\n return new URL(origin).host === host;\n } catch {\n return false; // \"null\" from sandboxed frames and file:// pages\n }\n}\n\nfunction isLoopbackHost(host: string): boolean {\n let hostname: string;\n\n try {\n hostname = new URL(`http://${host}`).hostname;\n } catch {\n return false;\n }\n\n return hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '[::1]'\n || /^127(\\.\\d{1,3}){3}$/.test(hostname);\n}\n\nfunction toRequest(req: IncomingMessage): Request {\n const headers = new Headers();\n\n for (let i = 0; i < req.rawHeaders.length; i += 2) headers.append(req.rawHeaders[i], req.rawHeaders[i + 1]);\n\n const method = req.method ?? 'GET';\n\n // Streamed, so the handler can refuse a request before reading (or capping) its body.\n const body = method === 'GET' || method === 'HEAD' ? undefined : new ReadableStream<Uint8Array>({\n start(controller) {\n req.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));\n req.on('end', () => controller.close());\n req.on('error', (err) => controller.error(err));\n },\n cancel() {\n req.destroy();\n },\n });\n\n const init: RequestInit & { duplex: 'half'; } = { method, headers, body, duplex: 'half' };\n\n return new Request(new URL(req.url ?? '/', `http://${headers.get('host') ?? 'localhost'}`), init);\n}\n\nasync function readCapped(request: Request): Promise<string> {\n const reader = request.body?.getReader();\n\n if (!reader) return '';\n\n const chunks: Uint8Array[] = [];\n let size = 0;\n\n for (;;) {\n const { done, value } = await reader.read();\n\n if (done) break;\n size += value.length;\n\n if (size > MAX_BODY) {\n await reader.cancel();\n throw new Error('Scenario too large');\n }\n\n chunks.push(value);\n }\n\n return Buffer.concat(chunks).toString('utf8');\n}\n"],"mappings":"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","names":["file","body"]}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// src/network.ts
|
|
2
|
+
import { delay, http, HttpResponse } from "msw";
|
|
3
|
+
var verbs = {
|
|
4
|
+
GET: http.get,
|
|
5
|
+
POST: http.post,
|
|
6
|
+
PUT: http.put,
|
|
7
|
+
PATCH: http.patch,
|
|
8
|
+
DELETE: http.delete,
|
|
9
|
+
HEAD: http.head,
|
|
10
|
+
OPTIONS: http.options
|
|
11
|
+
};
|
|
12
|
+
var isCode = (href) => {
|
|
13
|
+
const { pathname } = new URL(href);
|
|
14
|
+
return /\.(m?[jt]sx?|css|s[ac]ss|less|html?|wasm)$/i.test(pathname) || pathname.startsWith("/@") || pathname.includes("/node_modules/");
|
|
15
|
+
};
|
|
16
|
+
function toHandlers(entries = []) {
|
|
17
|
+
return entries.map((entry) => {
|
|
18
|
+
const responses = entry.sequence ?? [entry.response];
|
|
19
|
+
let calls = 0;
|
|
20
|
+
return verbs[entry.method](entry.path, async ({ request }) => {
|
|
21
|
+
if (request.mode === "navigate" || isCode(request.url)) return void 0;
|
|
22
|
+
if (entry.query) {
|
|
23
|
+
const params = new URL(request.url).searchParams;
|
|
24
|
+
const matches = Object.entries(entry.query).every(([k, v]) => params.get(k) === v);
|
|
25
|
+
if (!matches) return void 0;
|
|
26
|
+
}
|
|
27
|
+
const r = responses[Math.min(calls++, responses.length - 1)];
|
|
28
|
+
if (r.delay !== void 0) await delay(r.delay);
|
|
29
|
+
const headers = new Headers(r.headers);
|
|
30
|
+
headers.set("x-content-type-options", "nosniff");
|
|
31
|
+
if (/script|wasm/i.test(headers.get("content-type") ?? "")) return void 0;
|
|
32
|
+
const init = { status: r.status, headers };
|
|
33
|
+
if (r.text !== void 0) return HttpResponse.text(r.text, init);
|
|
34
|
+
if (r.body === void 0) return new HttpResponse(null, init);
|
|
35
|
+
return HttpResponse.json(r.body, init);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export {
|
|
41
|
+
toHandlers
|
|
42
|
+
};
|
|
43
|
+
//# sourceMappingURL=chunk-DETES767.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/network.ts"],"sourcesContent":["import { delay, http, HttpResponse } from 'msw';\nimport type { NetworkEntry } from './schema.js';\n\nconst verbs = {\n GET: http.get,\n POST: http.post,\n PUT: http.put,\n PATCH: http.patch,\n DELETE: http.delete,\n HEAD: http.head,\n OPTIONS: http.options,\n} satisfies Record<NetworkEntry['method'], typeof http.get>;\n\n// The worker can't see a request's destination, so code is recognised by its path.\nconst isCode = (href: string) => {\n const { pathname } = new URL(href);\n\n return /\\.(m?[jt]sx?|css|s[ac]ss|less|html?|wasm)$/i.test(pathname)\n || pathname.startsWith('/@') || pathname.includes('/node_modules/');\n};\n\n/** Turn scenario network entries into MSW handlers. First match wins. */\nexport function toHandlers(entries: NetworkEntry[] = []) {\n return entries.map((entry) => {\n const responses = entry.sequence ?? [entry.response!];\n let calls = 0;\n\n return verbs[entry.method](entry.path, async ({ request }): Promise<Response | undefined> => {\n // Fixtures answer the app's requests, never a page load or code: a scenario from a shared\n // link could otherwise serve its own HTML or scripts (a lazy chunk) on the app's origin.\n if (request.mode === 'navigate' || isCode(request.url)) return undefined;\n\n if (entry.query) {\n const params = new URL(request.url).searchParams;\n const matches = Object.entries(entry.query).every(([k, v]) => params.get(k) === v);\n\n if (!matches) return undefined; // fall through to the next handler\n }\n\n const r = responses[Math.min(calls++, responses.length - 1)];\n\n if (r.delay !== undefined) await delay(r.delay);\n // nosniff stops a browser running a fixture as a script, whatever URL loaded it.\n const headers = new Headers(r.headers);\n headers.set('x-content-type-options', 'nosniff');\n\n if (/script|wasm/i.test(headers.get('content-type') ?? '')) return undefined;\n const init = { status: r.status, headers };\n\n if (r.text !== undefined) return HttpResponse.text(r.text, init);\n\n if (r.body === undefined) return new HttpResponse(null, init);\n\n return HttpResponse.json(r.body, init);\n });\n });\n}\n"],"mappings":";AAAA,SAAS,OAAO,MAAM,oBAAoB;AAG1C,IAAM,QAAQ;AAAA,EACZ,KAAK,KAAK;AAAA,EACV,MAAM,KAAK;AAAA,EACX,KAAK,KAAK;AAAA,EACV,OAAO,KAAK;AAAA,EACZ,QAAQ,KAAK;AAAA,EACb,MAAM,KAAK;AAAA,EACX,SAAS,KAAK;AAChB;AAGA,IAAM,SAAS,CAAC,SAAiB;AAC/B,QAAM,EAAE,SAAS,IAAI,IAAI,IAAI,IAAI;AAEjC,SAAO,8CAA8C,KAAK,QAAQ,KAC7D,SAAS,WAAW,IAAI,KAAK,SAAS,SAAS,gBAAgB;AACtE;AAGO,SAAS,WAAW,UAA0B,CAAC,GAAG;AACvD,SAAO,QAAQ,IAAI,CAAC,UAAU;AAC5B,UAAM,YAAY,MAAM,YAAY,CAAC,MAAM,QAAS;AACpD,QAAI,QAAQ;AAEZ,WAAO,MAAM,MAAM,MAAM,EAAE,MAAM,MAAM,OAAO,EAAE,QAAQ,MAAqC;AAG3F,UAAI,QAAQ,SAAS,cAAc,OAAO,QAAQ,GAAG,EAAG,QAAO;AAE/D,UAAI,MAAM,OAAO;AACf,cAAM,SAAS,IAAI,IAAI,QAAQ,GAAG,EAAE;AACpC,cAAM,UAAU,OAAO,QAAQ,MAAM,KAAK,EAAE,MAAM,CAAC,CAAC,GAAG,CAAC,MAAM,OAAO,IAAI,CAAC,MAAM,CAAC;AAEjF,YAAI,CAAC,QAAS,QAAO;AAAA,MACvB;AAEA,YAAM,IAAI,UAAU,KAAK,IAAI,SAAS,UAAU,SAAS,CAAC,CAAC;AAE3D,UAAI,EAAE,UAAU,OAAW,OAAM,MAAM,EAAE,KAAK;AAE9C,YAAM,UAAU,IAAI,QAAQ,EAAE,OAAO;AACrC,cAAQ,IAAI,0BAA0B,SAAS;AAE/C,UAAI,eAAe,KAAK,QAAQ,IAAI,cAAc,KAAK,EAAE,EAAG,QAAO;AACnE,YAAM,OAAO,EAAE,QAAQ,EAAE,QAAQ,QAAQ;AAEzC,UAAI,EAAE,SAAS,OAAW,QAAO,aAAa,KAAK,EAAE,MAAM,IAAI;AAE/D,UAAI,EAAE,SAAS,OAAW,QAAO,IAAI,aAAa,MAAM,IAAI;AAE5D,aAAO,aAAa,KAAK,EAAE,MAAM,IAAI;AAAA,IACvC,CAAC;AAAA,EACH,CAAC;AACH;","names":[]}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/link.ts
|
|
2
|
+
var PARAM = "scenario";
|
|
3
|
+
var STRICT_PARAM = "scenario-strict";
|
|
4
|
+
var RECORD_PARAM = "scenario-record";
|
|
5
|
+
var SAVE_ENDPOINT = "/__executable-stories-showcase/save";
|
|
6
|
+
var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
7
|
+
var isLoopbackSaveUrl = (value) => {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(value);
|
|
10
|
+
return url.protocol === "http:" && LOOPBACK.has(url.hostname) && url.pathname === SAVE_ENDPOINT;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
var DATA_KEY = "scenario-data";
|
|
16
|
+
function scenarioHref(resolved, { base = location.href, inline = false, strict = false, record = false } = {}) {
|
|
17
|
+
const url = new URL(base);
|
|
18
|
+
url.search = "";
|
|
19
|
+
url.hash = "";
|
|
20
|
+
if (resolved.path) url.pathname = resolved.path;
|
|
21
|
+
for (const [k, v] of Object.entries(resolved.url ?? {})) {
|
|
22
|
+
if (v !== null) url.searchParams.set(k, v);
|
|
23
|
+
}
|
|
24
|
+
if (inline) {
|
|
25
|
+
const { $schema: _schema, extends: _extends, ...snapshot } = resolved;
|
|
26
|
+
url.hash = new URLSearchParams({ [DATA_KEY]: JSON.stringify(snapshot) }).toString();
|
|
27
|
+
} else {
|
|
28
|
+
url.searchParams.set(PARAM, resolved.name);
|
|
29
|
+
}
|
|
30
|
+
if (strict) url.searchParams.set(STRICT_PARAM, "");
|
|
31
|
+
if (record) url.searchParams.set(RECORD_PARAM, "");
|
|
32
|
+
return url.toString();
|
|
33
|
+
}
|
|
34
|
+
var readInline = (hash) => new URLSearchParams(hash.slice(1)).get(DATA_KEY);
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
PARAM,
|
|
38
|
+
STRICT_PARAM,
|
|
39
|
+
RECORD_PARAM,
|
|
40
|
+
SAVE_ENDPOINT,
|
|
41
|
+
LOOPBACK,
|
|
42
|
+
isLoopbackSaveUrl,
|
|
43
|
+
DATA_KEY,
|
|
44
|
+
scenarioHref,
|
|
45
|
+
readInline
|
|
46
|
+
};
|
|
47
|
+
//# sourceMappingURL=chunk-ESIS3D6R.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/link.ts"],"sourcesContent":["import type { Scenario } from './schema.js';\n\ndeclare global {\n /** Requests the active scenario didn't cover, for tests and agents (see `unhandledRequests`). */\n var executableStoriesShowcaseUnhandled: string[] | undefined;\n}\n\nexport const PARAM = 'scenario';\n\n/** Fail unmatched requests instead of letting them reach the network. */\nexport const STRICT_PARAM = 'scenario-strict';\n\n/** Record real responses so they can be saved as a scenario. */\nexport const RECORD_PARAM = 'scenario-record';\n\n/** Dev-server route the `executable-stories-showcase/vite` plugin serves for the panel's \"Save to project\". */\nexport const SAVE_ENDPOINT = '/__executable-stories-showcase/save';\n\nexport const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]']);\n\n/**\n * Whether a save URL the app's save route hands the panel is a loopback save server, such as\n * the one the Next.js route starts on 127.0.0.1. The panel posts captured state there, so a\n * URL on any other host is refused.\n */\nexport const isLoopbackSaveUrl = (value: string): boolean => {\n try {\n const url = new URL(value);\n\n return url.protocol === 'http:' && LOOPBACK.has(url.hostname) && url.pathname === SAVE_ENDPOINT;\n } catch {\n return false;\n }\n};\n\n/** Inline scenario JSON, kept in the URL hash so it never reaches a server. */\nexport const DATA_KEY = 'scenario-data';\n\n/**\n * A shareable URL that opens the app in a resolved scenario.\n * `inline` embeds the flattened scenario, so the link works without the catalog.\n */\nexport function scenarioHref(\n resolved: Scenario,\n { base = location.href, inline = false, strict = false, record = false } = {},\n): string {\n const url = new URL(base);\n url.search = '';\n url.hash = '';\n\n if (resolved.path) url.pathname = resolved.path;\n\n for (const [k, v] of Object.entries(resolved.url ?? {})) {\n if (v !== null) url.searchParams.set(k, v);\n }\n\n if (inline) {\n const { $schema: _schema, extends: _extends, ...snapshot } = resolved;\n url.hash = new URLSearchParams({ [DATA_KEY]: JSON.stringify(snapshot) }).toString();\n } else {\n url.searchParams.set(PARAM, resolved.name);\n }\n\n if (strict) url.searchParams.set(STRICT_PARAM, '');\n\n if (record) url.searchParams.set(RECORD_PARAM, '');\n\n return url.toString();\n}\n\n/** Inline scenario JSON from a URL hash, if present. */\nexport const readInline = (hash: string) => new URLSearchParams(hash.slice(1)).get(DATA_KEY);\n"],"mappings":";AAOO,IAAM,QAAQ;AAGd,IAAM,eAAe;AAGrB,IAAM,eAAe;AAGrB,IAAM,gBAAgB;AAEtB,IAAM,WAAW,oBAAI,IAAI,CAAC,aAAa,aAAa,OAAO,CAAC;AAO5D,IAAM,oBAAoB,CAAC,UAA2B;AAC3D,MAAI;AACF,UAAM,MAAM,IAAI,IAAI,KAAK;AAEzB,WAAO,IAAI,aAAa,WAAW,SAAS,IAAI,IAAI,QAAQ,KAAK,IAAI,aAAa;AAAA,EACpF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGO,IAAM,WAAW;AAMjB,SAAS,aACd,UACA,EAAE,OAAO,SAAS,MAAM,SAAS,OAAO,SAAS,OAAO,SAAS,MAAM,IAAI,CAAC,GACpE;AACR,QAAM,MAAM,IAAI,IAAI,IAAI;AACxB,MAAI,SAAS;AACb,MAAI,OAAO;AAEX,MAAI,SAAS,KAAM,KAAI,WAAW,SAAS;AAE3C,aAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,SAAS,OAAO,CAAC,CAAC,GAAG;AACvD,QAAI,MAAM,KAAM,KAAI,aAAa,IAAI,GAAG,CAAC;AAAA,EAC3C;AAEA,MAAI,QAAQ;AACV,UAAM,EAAE,SAAS,SAAS,SAAS,UAAU,GAAG,SAAS,IAAI;AAC7D,QAAI,OAAO,IAAI,gBAAgB,EAAE,CAAC,QAAQ,GAAG,KAAK,UAAU,QAAQ,EAAE,CAAC,EAAE,SAAS;AAAA,EACpF,OAAO;AACL,QAAI,aAAa,IAAI,OAAO,SAAS,IAAI;AAAA,EAC3C;AAEA,MAAI,OAAQ,KAAI,aAAa,IAAI,cAAc,EAAE;AAEjD,MAAI,OAAQ,KAAI,aAAa,IAAI,cAAc,EAAE;AAEjD,SAAO,IAAI,SAAS;AACtB;AAGO,IAAM,aAAa,CAAC,SAAiB,IAAI,gBAAgB,KAAK,MAAM,CAAC,CAAC,EAAE,IAAI,QAAQ;","names":[]}
|