@bivy/bivy 0.20.7-staging.2 → 0.20.7-staging.4
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/bin/bivy.mjs +100 -5
- package/bin/codex-app-server-shim.mjs +78 -27
- package/dist/agent-instructions.js +2 -1
- package/dist/agents/claude-code/runtime.js +2 -1
- package/dist/agents/profiles.js +9 -6
- package/dist/apps/backend/compare.js +62 -0
- package/dist/apps/backend/http-file.js +66 -0
- package/dist/apps/backend/index.js +431 -0
- package/dist/apps/backend/logs.js +60 -0
- package/dist/apps/backend/rows.js +79 -0
- package/dist/apps/gateway.js +121 -3
- package/dist/apps/inspector.js +56 -1
- package/dist/apps/preview-shell.js +221 -9
- package/dist/apps/registry.js +60 -2
- package/dist/apps/scenario-proxy.js +65 -0
- package/dist/apps/scenarios.js +307 -0
- package/dist/apps/service.js +147 -8
- package/dist/apps/styles.css +70 -0
- package/dist/apps/types.js +9 -1
- package/dist/controllers/app-commands.js +13 -2
- package/dist/exec.js +4 -1
- package/dist/node-env.js +6 -0
- package/dist/protocol/client-command-routes.js +1 -1
- package/dist/protocol/client-command-schemas.js +10 -2
- package/dist/runtime/codex-sessions.js +158 -6
- package/dist/runtime/credential-provisioning.js +5 -0
- package/dist/runtime/grok-sessions.js +4 -2
- package/dist/runtime/index.js +26 -19
- package/dist/runtime/inherited-session-env.js +54 -0
- package/dist/runtime/model-ref.js +14 -1
- package/dist/runtime/opencode-auth.js +67 -0
- package/dist/runtime/opencode-preflight.js +10 -1
- package/dist/runtime/protocol.js +35 -1
- package/dist/runtime/tool-call-map.js +67 -3
- package/dist/server.js +51 -16
- package/dist/session/event-log.js +76 -5
- package/dist/session/fork-standup.js +1 -1
- package/dist/session/fork.js +6 -4
- package/dist/session/transcript-normal.js +52 -0
- package/dist/session/transcript-persistence.js +20 -3
- package/dist/session-ref.js +5 -2
- package/dist/terminal.js +17 -0
- package/dist/worktree.js +25 -3
- package/package.json +1 -1
- package/web/assets/{AutomationsView-CJSYk0ja.js → AutomationsView-DJibSXQw.js} +1 -1
- package/web/assets/{ChangesCard-Bp88fY6W.js → ChangesCard-s8Qtm7eu.js} +1 -1
- package/web/assets/{LibraryView-of9wmuzN.js → LibraryView-Bz8x-H8N.js} +2 -2
- package/web/assets/{ProviderConnect-DG7SP4l2.js → ProviderConnect-DZ-Rv8Aj.js} +1 -1
- package/web/assets/{Settings-DGH1U7wj.js → Settings-DMUdD3O5.js} +3 -3
- package/web/assets/{Sheet-D-EHCMlj.js → Sheet-2wuqvpxm.js} +1 -1
- package/web/assets/{Terminal-qqysrwl_.js → Terminal-DQeYK2KK.js} +1 -1
- package/web/assets/{VoiceSettings-aeqQkwcZ.js → VoiceSettings-BbqFD1Ee.js} +1 -1
- package/web/assets/{abnfDiagram-VCTEODGH-Ct-lmBn5.js → abnfDiagram-VCTEODGH-DLSpnM9y.js} +1 -1
- package/web/assets/architecture-7GRP2DOG-CefLtGFG.js +1 -0
- package/web/assets/{architectureDiagram-5GKGNRK7-CCq716Da.js → architectureDiagram-5GKGNRK7-DOQxmFrp.js} +1 -1
- package/web/assets/{blockDiagram-I7D4REHJ-Lka9F6ZB.js → blockDiagram-I7D4REHJ-BiCBLtEP.js} +1 -1
- package/web/assets/{c4Diagram-7LVT6UL2-nvhhyzri.js → c4Diagram-7LVT6UL2-yzmqXo9k.js} +1 -1
- package/web/assets/channel-kKuds40w.js +1 -0
- package/web/assets/{chunk-4HAMMTFA-TZ5hhHBz.js → chunk-4HAMMTFA-i-QksGpD.js} +1 -1
- package/web/assets/{chunk-75Z2AOVW-IahKw8kM.js → chunk-75Z2AOVW-DWjggoX1.js} +1 -1
- package/web/assets/{chunk-DU6HZSFF-BlcZUVOy.js → chunk-DU6HZSFF-jFwsC9ox.js} +1 -1
- package/web/assets/{chunk-F27PBJKO-3MGfEgKu.js → chunk-F27PBJKO-3ej2B9gg.js} +1 -1
- package/web/assets/{chunk-GMAD6QVW-Be72n678.js → chunk-GMAD6QVW-CzfhoJ04.js} +1 -1
- package/web/assets/{chunk-GVQU2GXP-GOVLT6xm.js → chunk-GVQU2GXP-DLhbmKuJ.js} +1 -1
- package/web/assets/{chunk-IMKFNOWR-ByOzewrT.js → chunk-IMKFNOWR-dR96ErOJ.js} +1 -1
- package/web/assets/{chunk-L3NEJ4N5-BCGhHmnW.js → chunk-L3NEJ4N5-1YX2kWHP.js} +1 -1
- package/web/assets/{chunk-OSK3NFVY-CAdliJE_.js → chunk-OSK3NFVY-kDveSID9.js} +1 -1
- package/web/assets/{chunk-P2QGCYS3-CM8cpgHa.js → chunk-P2QGCYS3-Cc3Uo4HQ.js} +1 -1
- package/web/assets/{chunk-POPQ4Y6H-CXLPhaI1.js → chunk-POPQ4Y6H-CxDXuH1x.js} +1 -1
- package/web/assets/{chunk-PWAF6VOD-BegFrwDe.js → chunk-PWAF6VOD-DZBN6HSl.js} +1 -1
- package/web/assets/{chunk-SHT3W25Y-iXj7Y5RI.js → chunk-SHT3W25Y-0W01Q-w7.js} +1 -1
- package/web/assets/{chunk-SVP7TREG-73pyTBZg.js → chunk-SVP7TREG-gqFFZT4I.js} +1 -1
- package/web/assets/{chunk-TICWLB2K-D5zwuuGI.js → chunk-TICWLB2K-BGdM7dkO.js} +1 -1
- package/web/assets/classDiagram-ZZMXUADV-Dg-2WnlQ.js +1 -0
- package/web/assets/classDiagram-v2-VYDZK3BY-Dg-2WnlQ.js +1 -0
- package/web/assets/{cynefin-OW5HDTMX-D3nRr9S_.js → cynefin-OW5HDTMX-D3KvEku4.js} +1 -1
- package/web/assets/{cynefinDiagram-5FMLGOSQ-C4CKMYMZ.js → cynefinDiagram-5FMLGOSQ-CLbDBHX9.js} +1 -1
- package/web/assets/{dagre-GXQ25YYZ-D-grkZ8X.js → dagre-GXQ25YYZ-D2kdt2lo.js} +1 -1
- package/web/assets/{diagram-S7CK7UJ4-BNPS3YQX.js → diagram-S7CK7UJ4-a-ev_USx.js} +1 -1
- package/web/assets/{diagram-UQ7AKVKN-Bkp_TyRg.js → diagram-UQ7AKVKN-BwYRbF-y.js} +1 -1
- package/web/assets/{diagram-VSXAHHWV-Dpxe_6MM.js → diagram-VSXAHHWV-Da7O55Aq.js} +1 -1
- package/web/assets/{diagram-VX7I27RA-DwGBvGn-.js → diagram-VX7I27RA-Bcq-P_8M.js} +1 -1
- package/web/assets/{diagram-Z3DM3KII-D-KvONMO.js → diagram-Z3DM3KII-DSXXMWvA.js} +1 -1
- package/web/assets/{ebnfDiagram-PWID7BFC-Di_D-a-0.js → ebnfDiagram-PWID7BFC-DxFerrqi.js} +1 -1
- package/web/assets/{erDiagram-RLTQ6QDP-2pnBfTU9.js → erDiagram-RLTQ6QDP-DfyXAe2v.js} +1 -1
- package/web/assets/eventmodeling-NTZA5JFV-tdyiDQoh.js +1 -0
- package/web/assets/flowDiagram-HODETNUW-BO23Cpex.js +1 -0
- package/web/assets/{ganttDiagram-EL5Y4UJY-DAkhMTlk.js → ganttDiagram-EL5Y4UJY-QkgrgMh9.js} +1 -1
- package/web/assets/{gitGraph-4MIJSDKK-BQvemX2W.js → gitGraph-4MIJSDKK-M1n5msce.js} +1 -1
- package/web/assets/{gitGraphDiagram-WWUBYQGX-Dx7ya_2q.js → gitGraphDiagram-WWUBYQGX-DC_8Cxet.js} +1 -1
- package/web/assets/{index-CFX8FhqJ.js → index-B0Bto4kI.js} +2 -2
- package/web/assets/{index-BXPRG7Pj.css → index-Da2-4YHh.css} +1 -1
- package/web/assets/{info-A6RAGUB7-DaDps2sq.js → info-A6RAGUB7-DmyYG6Ip.js} +1 -1
- package/web/assets/{infoDiagram-27XIBGKW-DBqMvPr2.js → infoDiagram-27XIBGKW-CmrMjsqT.js} +1 -1
- package/web/assets/{ishikawaDiagram-5VMMS53U-DzciF3O_.js → ishikawaDiagram-5VMMS53U-x_6jWkNq.js} +1 -1
- package/web/assets/{journeyDiagram-3NMN7TZE-D9WHgGe3.js → journeyDiagram-3NMN7TZE-COVPGUIl.js} +1 -1
- package/web/assets/{kanban-definition-UXKFOSKX-Diokj8By.js → kanban-definition-UXKFOSKX-BDZHzeOz.js} +1 -1
- package/web/assets/{line-DmBeTXFn.js → line-KdqoiWjk.js} +1 -1
- package/web/assets/{mermaid-parser.core-H_O1CweI.js → mermaid-parser.core-C5RHAle0.js} +3 -3
- package/web/assets/{mermaid.core-BAni15Ed.js → mermaid.core-2ujtWQt3.js} +4 -4
- package/web/assets/{mindmap-definition-YA3MSWOX-B_tXdzEa.js → mindmap-definition-YA3MSWOX-CCPoAcT0.js} +1 -1
- package/web/assets/mount-CaFlQTsV.js +30 -0
- package/web/assets/{packet-AYTQ26CC-Dt4bPH7I.js → packet-AYTQ26CC-BjPan2xl.js} +1 -1
- package/web/assets/{pegDiagram-XKGWAZYB-loAbaKUZ.js → pegDiagram-XKGWAZYB-CrARxusS.js} +1 -1
- package/web/assets/{pie-WAS4IAKB-DEmhJN7M.js → pie-WAS4IAKB-BdVo8hLO.js} +1 -1
- package/web/assets/{pieDiagram-E7YTZNPT-DfQvD2QY.js → pieDiagram-E7YTZNPT-DwdhmBCX.js} +1 -1
- package/web/assets/{quadrantDiagram-AXDQQJYC-Bhqo2rT-.js → quadrantDiagram-AXDQQJYC-DbG3R6xw.js} +1 -1
- package/web/assets/{radar-RG4KPBEZ-BmV5tXie.js → radar-RG4KPBEZ-pp-coSgM.js} +1 -1
- package/web/assets/{railroad-74A4TZTK-DoebyExe.js → railroad-74A4TZTK-CxNBhSbO.js} +1 -1
- package/web/assets/railroad-abnf-HS5TGJTU-g5eNqQ81.js +1 -0
- package/web/assets/railroad-ebnf-LZEXJU2U-BI5_cD5r.js +1 -0
- package/web/assets/railroad-peg-WCYAUIDC-CLl7RlXh.js +1 -0
- package/web/assets/{railroadDiagram-O6MQD6OU-BhlfIpCW.js → railroadDiagram-O6MQD6OU-CKbKBwCj.js} +1 -1
- package/web/assets/{requirementDiagram-BXWQKSXE-vkxH8U-2.js → requirementDiagram-BXWQKSXE-DRc316xB.js} +1 -1
- package/web/assets/{sankeyDiagram-P5KCCOFB-D2iFl0bc.js → sankeyDiagram-P5KCCOFB-C6O9zcIS.js} +1 -1
- package/web/assets/{sequenceDiagram-WJ2MYXX4-Ll_m9w0-.js → sequenceDiagram-WJ2MYXX4-D3Vz1DcK.js} +1 -1
- package/web/assets/{settingsRoute-C8p10bxH.js → settingsRoute-DxPtfqmn.js} +1 -1
- package/web/assets/{stateDiagram-D77RDMKH-B4KWuRz2.js → stateDiagram-D77RDMKH-DXcUXP-l.js} +1 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-CQER-whG.js +1 -0
- package/web/assets/{swimlanes-42K2YHIH-CVXwQL4b.js → swimlanes-42K2YHIH-tfw_N04-.js} +1 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-Cf__qhQx.js +8 -0
- package/web/assets/{timeline-definition-24CTP7MA-C0XuGVkJ.js → timeline-definition-24CTP7MA-BcAeG9F_.js} +1 -1
- package/web/assets/{treeView-Q6P3EWNA-BS4edB5m.js → treeView-Q6P3EWNA-DaZCFJDf.js} +1 -1
- package/web/assets/{treemap-WGGIJYW6-BZ54DPA4.js → treemap-WGGIJYW6-C3PybXBI.js} +1 -1
- package/web/assets/{useStore-DEfLrrMh.js → useStore-Ce5mzYro.js} +9 -7
- package/web/assets/{vennDiagram-4TSXK5OY-CAKXCk3I.js → vennDiagram-4TSXK5OY-BT7w_l5a.js} +1 -1
- package/web/assets/{wardley-WFR3VGLG-DM5IZXxI.js → wardley-WFR3VGLG-CXZlxUiB.js} +1 -1
- package/web/assets/{wardleyDiagram-VM6X3IG4--WWmycX2.js → wardleyDiagram-VM6X3IG4-B5muPnst.js} +1 -1
- package/web/assets/{xychartDiagram-S5SC5T6Z-D-_SgAQy.js → xychartDiagram-S5SC5T6Z-DSaMhJlg.js} +1 -1
- package/web/index.html +2 -2
- package/web/sw.js +1 -1
- package/web/assets/architecture-7GRP2DOG-CD4t1j2T.js +0 -1
- package/web/assets/channel-Dl0Aa963.js +0 -1
- package/web/assets/classDiagram-ZZMXUADV-BvMwIvP0.js +0 -1
- package/web/assets/classDiagram-v2-VYDZK3BY-BvMwIvP0.js +0 -1
- package/web/assets/eventmodeling-NTZA5JFV-CF677pnK.js +0 -1
- package/web/assets/flowDiagram-HODETNUW-8SiMcM5n.js +0 -1
- package/web/assets/mount-Fdy9Xoxs.js +0 -27
- package/web/assets/railroad-abnf-HS5TGJTU-C_F3QWV5.js +0 -1
- package/web/assets/railroad-ebnf-LZEXJU2U-BaNfyzXp.js +0 -1
- package/web/assets/railroad-peg-WCYAUIDC-BWyMWwxG.js +0 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-B_Xx-QG6.js +0 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-XYt-UA-z.js +0 -8
package/dist/apps/registry.js
CHANGED
|
@@ -98,12 +98,66 @@ function command(spec, workspace, label) {
|
|
|
98
98
|
* preview addresses survive restarts. Views whose server Bivy doesn't own —
|
|
99
99
|
* a service without `start` — are not restored: a reused local port could
|
|
100
100
|
* belong to anything by then. Detection offers them again. */
|
|
101
|
+
/** A folder for a backend view's files: inside the workspace, never above it. */
|
|
102
|
+
function inside(workspace, dir, fallback) {
|
|
103
|
+
const relative = dir === undefined ? fallback : dir;
|
|
104
|
+
if (typeof relative !== "string" || !relative || relative.length > 300 || path.isAbsolute(relative))
|
|
105
|
+
throw new Error("A backend view's dir is a folder inside the workspace, like .bivy/requests.");
|
|
106
|
+
const resolved = path.resolve(workspace, relative);
|
|
107
|
+
const from = path.relative(path.resolve(workspace), resolved);
|
|
108
|
+
if (from === ".." || from.startsWith(`..${path.sep}`))
|
|
109
|
+
throw new Error("A backend view's dir must be inside the workspace.");
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
112
|
+
function backendTarget(spec, workspace) {
|
|
113
|
+
if (spec.kind === "requests") {
|
|
114
|
+
const base = spec.base;
|
|
115
|
+
if (base !== undefined) {
|
|
116
|
+
if (typeof base.url === "string") {
|
|
117
|
+
const url = new URL(base.url);
|
|
118
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
|
|
119
|
+
throw new Error("A requests view's base.url is an http(s) address without credentials.");
|
|
120
|
+
return { kind: "requests", dir: inside(workspace, spec.dir, ".bivy/requests"), base: { url: url.href.replace(/\/$/, "") } };
|
|
121
|
+
}
|
|
122
|
+
if (typeof base.view !== "string" || !base.view)
|
|
123
|
+
throw new Error("A requests view's base is {\"url\": …} or {\"view\": \"<web view name>\"}.");
|
|
124
|
+
return { kind: "requests", dir: inside(workspace, spec.dir, ".bivy/requests"), base: { view: base.view } };
|
|
125
|
+
}
|
|
126
|
+
return { kind: "requests", dir: inside(workspace, spec.dir, ".bivy/requests") };
|
|
127
|
+
}
|
|
128
|
+
if (spec.kind === "data")
|
|
129
|
+
return { kind: "data", dir: inside(workspace, spec.dir, ".bivy/queries"), ...command(spec, workspace, "Data view") };
|
|
130
|
+
const source = spec.source;
|
|
131
|
+
if (!source)
|
|
132
|
+
return { kind: "logs", source: {} };
|
|
133
|
+
if (typeof source.view === "string")
|
|
134
|
+
return { kind: "logs", source: { view: source.view } };
|
|
135
|
+
// A file is followed the way a person would: tail -F, from the last lines.
|
|
136
|
+
if (typeof source.file === "string")
|
|
137
|
+
return { kind: "logs", source: { command: "tail", args: ["-n", "200", "-F", path.relative(workspace, inside(workspace, source.file, ""))], workspace: fs.realpathSync(workspace) } };
|
|
138
|
+
return { kind: "logs", source: command(source, workspace, "Logs view") };
|
|
139
|
+
}
|
|
140
|
+
/** Where a backend view reads from, in words, for its row in the Apps sheet. */
|
|
141
|
+
function backendDetail(target) {
|
|
142
|
+
const line = (spec) => [spec.command, ...spec.args].join(" ").slice(0, 120);
|
|
143
|
+
if (target.kind === "requests")
|
|
144
|
+
return `${path.basename(target.dir)} · ${target.base && "url" in target.base ? target.base.url : target.base ? target.base.view : "the app's server"}`;
|
|
145
|
+
if (target.kind === "data")
|
|
146
|
+
return line(target);
|
|
147
|
+
return "command" in target.source ? line(target.source) : target.source.view ? `${target.source.view} output` : "the app's server output";
|
|
148
|
+
}
|
|
101
149
|
export class AppRegistry extends EventEmitter {
|
|
102
150
|
reservedPorts;
|
|
103
151
|
file;
|
|
152
|
+
/** Errors a web view's server logged since `since` (set by the backend
|
|
153
|
+
* views, which follow server output; asked by the gateway for the Console). */
|
|
154
|
+
serverErrors;
|
|
104
155
|
apps = new Map();
|
|
105
156
|
views = new Map();
|
|
106
157
|
persisted = new Map();
|
|
158
|
+
/** Puts a desktop app in a scenario (or none, for ""): set by the service,
|
|
159
|
+
* which runs programs; asked by the gateway, which hears the shell. */
|
|
160
|
+
desktopScenario;
|
|
107
161
|
constructor(reservedPorts = [], file) {
|
|
108
162
|
super();
|
|
109
163
|
this.reservedPorts = reservedPorts;
|
|
@@ -195,12 +249,16 @@ export class AppRegistry extends EventEmitter {
|
|
|
195
249
|
target = { kind: "display", ...command(spec, workspace, "Display view"), restartOnChange: spec.restartOnChange === true };
|
|
196
250
|
view = { ...base, kind: "web", source: "display", managed: true };
|
|
197
251
|
}
|
|
252
|
+
else if (spec.kind === "requests" || spec.kind === "data" || spec.kind === "logs") {
|
|
253
|
+
target = backendTarget(spec, workspace);
|
|
254
|
+
view = { ...base, kind: "backend", backend: spec.kind, detail: backendDetail(target) };
|
|
255
|
+
}
|
|
198
256
|
else
|
|
199
|
-
throw new Error("Unsupported app view. Supported views: web, terminal and
|
|
257
|
+
throw new Error("Unsupported app view. Supported views: web, terminal, display, requests, data and logs.");
|
|
200
258
|
app.views.push(view);
|
|
201
259
|
const stored = restore?.notes?.[view.id];
|
|
202
260
|
const notes = view.kind === "web" && Array.isArray(stored) ? stored.filter(isNote).slice(-MAX_NOTES) : [];
|
|
203
|
-
entries.push({ app, view, target, revision: 0, source, ...(notes.length ? { notes } : {}) });
|
|
261
|
+
entries.push({ app, view, target, workspace, revision: 0, source, ...(notes.length ? { notes } : {}) });
|
|
204
262
|
}
|
|
205
263
|
// Commit all views together: a bad later view cannot leave a partial app.
|
|
206
264
|
this.apps.set(app.id, app);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import https from "node:https";
|
|
5
|
+
import { once } from "node:events";
|
|
6
|
+
import { matchRule } from "./scenarios.js";
|
|
7
|
+
/** Marks a response Bivy made up for a scenario, not the app's API. */
|
|
8
|
+
export const SIMULATED = "x-bivy-simulated";
|
|
9
|
+
/** A scenario's network rule met a request: wait if it says so, then answer
|
|
10
|
+
* for the API, drop the connection ("offline"), or let the request through
|
|
11
|
+
* late (a delay alone). Returns whether the request was answered here. The
|
|
12
|
+
* preview gateway (web pages) and the API proxy (desktop apps) share it. */
|
|
13
|
+
export async function simulate(req, res, rule, headers = {}) {
|
|
14
|
+
if (rule.delayMs) {
|
|
15
|
+
const gone = await new Promise((resolve) => {
|
|
16
|
+
const timer = setTimeout(() => { res.off("close", closed); resolve(false); }, rule.delayMs);
|
|
17
|
+
const closed = () => { clearTimeout(timer); resolve(true); };
|
|
18
|
+
res.once("close", closed);
|
|
19
|
+
});
|
|
20
|
+
if (gone)
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
if (rule.offline) {
|
|
24
|
+
req.socket.destroy();
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
if (rule.status === undefined && rule.json === undefined && rule.body === undefined)
|
|
28
|
+
return false;
|
|
29
|
+
const json = rule.json !== undefined;
|
|
30
|
+
const body = Buffer.from(json ? JSON.stringify(rule.json) : rule.body ?? "");
|
|
31
|
+
res.writeHead(rule.status ?? 200, { ...headers, "content-type": json ? "application/json" : "text/plain; charset=utf-8", "content-length": body.length, [SIMULATED]: "1" });
|
|
32
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
/** A desktop app calls its API directly, so a scenario's network rules can't
|
|
36
|
+
* be applied in the preview. This proxy sits between them instead: the app is
|
|
37
|
+
* started with its API address pointed here (loopback only), matching
|
|
38
|
+
* requests are answered by the rules, and the rest go on to `target`. */
|
|
39
|
+
export async function startScenarioProxy(target, rules) {
|
|
40
|
+
const base = new URL(target);
|
|
41
|
+
const client = base.protocol === "https:" ? https : http;
|
|
42
|
+
// The app is given the target's path too, so its requests already carry it.
|
|
43
|
+
const prefix = base.pathname.replace(/\/$/, "");
|
|
44
|
+
const server = http.createServer((req, res) => {
|
|
45
|
+
void (async () => {
|
|
46
|
+
const url = req.url?.startsWith("/") ? req.url : "/";
|
|
47
|
+
const rule = matchRule(rules, req.method, url);
|
|
48
|
+
if (rule && await simulate(req, res, rule))
|
|
49
|
+
return;
|
|
50
|
+
const headers = { ...req.headers, host: base.host };
|
|
51
|
+
const upstream = client.request({ protocol: base.protocol, hostname: base.hostname, port: base.port, path: url, method: req.method, headers }, (response) => {
|
|
52
|
+
res.writeHead(response.statusCode ?? 502, response.headers);
|
|
53
|
+
response.pipe(res);
|
|
54
|
+
});
|
|
55
|
+
upstream.on("error", () => { if (!res.headersSent)
|
|
56
|
+
res.writeHead(502, { "content-type": "text/plain" }); res.end("The API isn't answering."); });
|
|
57
|
+
res.on("close", () => upstream.destroy());
|
|
58
|
+
req.pipe(upstream);
|
|
59
|
+
})().catch(() => res.destroy());
|
|
60
|
+
});
|
|
61
|
+
server.listen(0, "127.0.0.1");
|
|
62
|
+
await once(server, "listening");
|
|
63
|
+
const { port } = server.address();
|
|
64
|
+
return { url: `http://127.0.0.1:${port}${prefix}`, close: () => { server.close(); server.closeAllConnections(); } };
|
|
65
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
/** Scenarios: named starting points for a live app, one JSON file each in the
|
|
6
|
+
* project. Opening one puts the preview in that state and Reset puts it back.
|
|
7
|
+
* A web page's scenario is per viewer: a page, steps taken in it, responses
|
|
8
|
+
* the gateway simulates. A desktop app's is the app's: it restarts with the
|
|
9
|
+
* scenario's arguments and environment (its API pointed at a simulating proxy)
|
|
10
|
+
* and the steps are clicks, keys and menu items in its window. Neither needs
|
|
11
|
+
* anything from the app's framework. See docs/apps.md#scenarios. */
|
|
12
|
+
export const SCENARIO_DIR = path.join(".bivy", "scenarios");
|
|
13
|
+
const MAX_FILES = 100;
|
|
14
|
+
const MAX_BYTES = 64 * 1024;
|
|
15
|
+
const MAX_STEPS = 50;
|
|
16
|
+
const MAX_RULES = 20;
|
|
17
|
+
const MAX_CHAIN = 5;
|
|
18
|
+
const MAX_DELAY_MS = 30_000;
|
|
19
|
+
const MAX_WAIT_MS = 10_000;
|
|
20
|
+
const ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
21
|
+
const METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
|
22
|
+
const text = (value, label, max) => {
|
|
23
|
+
if (typeof value !== "string" || !value.trim() || value.length > max)
|
|
24
|
+
throw new Error(`${label} must be text of 1–${max} characters.`);
|
|
25
|
+
return value.trim();
|
|
26
|
+
};
|
|
27
|
+
const page = (value, label) => {
|
|
28
|
+
const at = text(value, label, 2048);
|
|
29
|
+
if (!at.startsWith("/") || at.startsWith("//"))
|
|
30
|
+
throw new Error(`${label} must be a path that starts with /.`);
|
|
31
|
+
return at;
|
|
32
|
+
};
|
|
33
|
+
/** Each step kind, as a row: the key that names it, how its value is read,
|
|
34
|
+
* and which preview it belongs to when only one can take it (a CSS selector
|
|
35
|
+
* means a page; pixels or a menu path mean a desktop window). Adding a kind of
|
|
36
|
+
* step means adding a row here and a case in the runner that takes it. */
|
|
37
|
+
const STEPS = [
|
|
38
|
+
{ key: "click", read: (raw) => {
|
|
39
|
+
if (Array.isArray(raw.click)) {
|
|
40
|
+
if (raw.click.length !== 2 || !raw.click.every((n) => Number.isInteger(n) && n >= 0 && n < 65536))
|
|
41
|
+
throw new Error("click in a desktop app takes [x, y]: whole pixels in its screenshot.");
|
|
42
|
+
return { step: { click: [raw.click[0], raw.click[1]] }, kind: "desktop" };
|
|
43
|
+
}
|
|
44
|
+
return { step: { click: text(raw.click, "click", 300) }, kind: "web" };
|
|
45
|
+
} },
|
|
46
|
+
{ key: "fill", read: (raw) => {
|
|
47
|
+
if (typeof raw.with !== "string" || raw.with.length > 2000)
|
|
48
|
+
throw new Error("fill needs \"with\": the text to put in the field (up to 2,000 characters).");
|
|
49
|
+
return { step: { fill: text(raw.fill, "fill", 300), with: raw.with }, kind: "web" };
|
|
50
|
+
} },
|
|
51
|
+
{ key: "type", read: (raw) => {
|
|
52
|
+
if (typeof raw.type !== "string" || !raw.type || raw.type.length > 2000)
|
|
53
|
+
throw new Error("type takes the text to type, up to 2,000 characters.");
|
|
54
|
+
return { step: { type: raw.type }, kind: "desktop" };
|
|
55
|
+
} },
|
|
56
|
+
{ key: "press", read: (raw) => ({ step: { press: text(raw.press, "press", 40) } }) },
|
|
57
|
+
{ key: "menu", read: (raw) => ({ step: { menu: text(raw.menu, "menu", 300) }, kind: "desktop" }) },
|
|
58
|
+
{ key: "wait", read: (raw) => {
|
|
59
|
+
if (typeof raw.wait === "number") {
|
|
60
|
+
if (!Number.isFinite(raw.wait) || raw.wait < 0 || raw.wait > MAX_WAIT_MS)
|
|
61
|
+
throw new Error(`wait takes a selector, or milliseconds up to ${MAX_WAIT_MS}.`);
|
|
62
|
+
return { step: { wait: Math.round(raw.wait) } };
|
|
63
|
+
}
|
|
64
|
+
return { step: { wait: text(raw.wait, "wait", 300) }, kind: "web" };
|
|
65
|
+
} },
|
|
66
|
+
];
|
|
67
|
+
/** Fields that only one kind of preview has. */
|
|
68
|
+
const FIELD_KINDS = { open: "web", fresh: "web", args: "desktop", env: "desktop", api: "desktop" };
|
|
69
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
70
|
+
function readStep(raw, index) {
|
|
71
|
+
const step = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
72
|
+
const kind = STEPS.find((row) => row.key in step);
|
|
73
|
+
if (!kind)
|
|
74
|
+
throw new Error(`Step ${index + 1} must be one of: ${STEPS.map((row) => row.key).join(", ")}.`);
|
|
75
|
+
try {
|
|
76
|
+
return kind.read(step);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
throw new Error(`Step ${index + 1}: ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function readArgs(raw) {
|
|
83
|
+
if (!Array.isArray(raw) || raw.length > 30 || !raw.every((arg) => typeof arg === "string" && arg.length <= 1000))
|
|
84
|
+
throw new Error("args must be a list of up to 30 arguments.");
|
|
85
|
+
return [...raw];
|
|
86
|
+
}
|
|
87
|
+
function readEnv(raw) {
|
|
88
|
+
const entries = raw && typeof raw === "object" && !Array.isArray(raw) ? Object.entries(raw) : undefined;
|
|
89
|
+
if (!entries || entries.length > 30 || !entries.every(([name, value]) => ENV_NAME.test(name) && typeof value === "string" && value.length <= 4000))
|
|
90
|
+
throw new Error("env maps up to 30 variable names to text values.");
|
|
91
|
+
return Object.fromEntries(entries);
|
|
92
|
+
}
|
|
93
|
+
function readApi(raw) {
|
|
94
|
+
const api = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
95
|
+
if (typeof api.env !== "string" || !ENV_NAME.test(api.env))
|
|
96
|
+
throw new Error("api.env is the environment variable the app reads its API address from, like API_URL.");
|
|
97
|
+
let target;
|
|
98
|
+
try {
|
|
99
|
+
target = new URL(String(api.target));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
throw new Error("api.target is the API's real address, like http://127.0.0.1:4000.");
|
|
103
|
+
}
|
|
104
|
+
if (!["http:", "https:"].includes(target.protocol) || target.username || target.password || target.search || target.hash)
|
|
105
|
+
throw new Error("api.target must be an http(s) address without credentials, query or fragment.");
|
|
106
|
+
return { env: api.env, target: target.href.replace(/\/$/, "") };
|
|
107
|
+
}
|
|
108
|
+
/** "POST /api/payments*" or "/api/orders": an optional method, then a path where * matches anything. */
|
|
109
|
+
function readRule(raw, index) {
|
|
110
|
+
const label = `Network rule ${index + 1}`;
|
|
111
|
+
const rule = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : undefined;
|
|
112
|
+
if (!rule)
|
|
113
|
+
throw new Error(`${label} must be an object.`);
|
|
114
|
+
const [first, second, ...extra] = text(rule.match, `${label} "match"`, 300).split(/\s+/);
|
|
115
|
+
const method = second ? first.toUpperCase() : undefined;
|
|
116
|
+
if (extra.length || (method && !METHODS.includes(method)))
|
|
117
|
+
throw new Error(`${label}: "match" is an optional method and a path, like "POST /api/payments*".`);
|
|
118
|
+
const result = { ...(method ? { method } : {}), path: page(second ?? first, `${label} path`) };
|
|
119
|
+
if (rule.status !== undefined) {
|
|
120
|
+
if (!Number.isInteger(rule.status) || rule.status < 200 || rule.status > 599)
|
|
121
|
+
throw new Error(`${label}: status must be an HTTP status from 200 to 599.`);
|
|
122
|
+
result.status = rule.status;
|
|
123
|
+
}
|
|
124
|
+
if (rule.json !== undefined)
|
|
125
|
+
result.json = rule.json;
|
|
126
|
+
if (rule.body !== undefined) {
|
|
127
|
+
if (typeof rule.body !== "string" || rule.body.length > 32_000)
|
|
128
|
+
throw new Error(`${label}: body must be text up to 32,000 characters.`);
|
|
129
|
+
result.body = rule.body;
|
|
130
|
+
}
|
|
131
|
+
if (result.json !== undefined && result.body !== undefined)
|
|
132
|
+
throw new Error(`${label}: use json or body, not both.`);
|
|
133
|
+
if (rule.delayMs !== undefined) {
|
|
134
|
+
if (typeof rule.delayMs !== "number" || !Number.isFinite(rule.delayMs) || rule.delayMs < 0 || rule.delayMs > MAX_DELAY_MS)
|
|
135
|
+
throw new Error(`${label}: delayMs must be 0–${MAX_DELAY_MS}.`);
|
|
136
|
+
result.delayMs = Math.round(rule.delayMs);
|
|
137
|
+
}
|
|
138
|
+
if (rule.offline !== undefined && rule.offline !== true)
|
|
139
|
+
throw new Error(`${label}: offline can only be true.`);
|
|
140
|
+
if (rule.offline) {
|
|
141
|
+
if (result.status !== undefined || result.json !== undefined || result.body !== undefined)
|
|
142
|
+
throw new Error(`${label}: an offline rule sends no response, so it has no status, json or body.`);
|
|
143
|
+
result.offline = true;
|
|
144
|
+
}
|
|
145
|
+
if (!result.offline && result.status === undefined && result.json === undefined && result.body === undefined && !result.delayMs)
|
|
146
|
+
throw new Error(`${label} does nothing: give it a status, json, body, delayMs or offline.`);
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
/** One scenario file's contents. Throws a message meant for whoever wrote it. */
|
|
150
|
+
export function readScenario(id, raw, changedAt = 0) {
|
|
151
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
152
|
+
throw new Error("A scenario is a JSON object with at least a \"name\".");
|
|
153
|
+
const input = raw;
|
|
154
|
+
const known = new Set(["$schema", "name", "description", "view", "from", "steps", "network", ...Object.keys(FIELD_KINDS)]);
|
|
155
|
+
const unknown = Object.keys(input).filter((key) => !known.has(key));
|
|
156
|
+
if (unknown.length)
|
|
157
|
+
throw new Error(`Unknown field${unknown.length === 1 ? "" : "s"} ${unknown.map((key) => `"${key}"`).join(", ")}. A scenario has: ${[...known].filter((key) => key !== "$schema").join(", ")}.`);
|
|
158
|
+
if (input.steps !== undefined && (!Array.isArray(input.steps) || input.steps.length > MAX_STEPS))
|
|
159
|
+
throw new Error(`steps must be a list of up to ${MAX_STEPS} steps.`);
|
|
160
|
+
if (input.network !== undefined && (!Array.isArray(input.network) || input.network.length > MAX_RULES))
|
|
161
|
+
throw new Error(`network must be a list of up to ${MAX_RULES} rules.`);
|
|
162
|
+
if (input.fresh !== undefined && typeof input.fresh !== "boolean")
|
|
163
|
+
throw new Error("fresh must be true or false.");
|
|
164
|
+
const from = input.from === undefined ? undefined : text(input.from, "from", 64);
|
|
165
|
+
if (from !== undefined && !ID.test(from))
|
|
166
|
+
throw new Error("from names another scenario by its file name, without .json.");
|
|
167
|
+
if (from === id)
|
|
168
|
+
throw new Error("A scenario can't start from itself.");
|
|
169
|
+
const steps = (input.steps ?? []).map(readStep);
|
|
170
|
+
// What the fields and steps say it's for: a page, a desktop window, or either.
|
|
171
|
+
const says = [
|
|
172
|
+
...Object.entries(FIELD_KINDS).filter(([key]) => input[key] !== undefined).map(([key, kind]) => ({ kind, what: `"${key}"` })),
|
|
173
|
+
...steps.flatMap((step, index) => step.kind ? [{ kind: step.kind, what: `step ${index + 1}` }] : []),
|
|
174
|
+
];
|
|
175
|
+
const kind = says[0]?.kind;
|
|
176
|
+
const other = says.find((item) => item.kind !== kind);
|
|
177
|
+
if (other)
|
|
178
|
+
throw new Error(`${says.find((item) => item.kind === "web").what} is for web pages and ${says.find((item) => item.kind === "desktop").what} for desktop apps: a scenario is for one or the other.`);
|
|
179
|
+
return {
|
|
180
|
+
id, name: text(input.name, "name", 80), changedAt, ...(kind ? { kind } : {}),
|
|
181
|
+
...(input.description === undefined ? {} : { description: text(input.description, "description", 200) }),
|
|
182
|
+
...(input.view === undefined ? {} : { view: text(input.view, "view", 100) }),
|
|
183
|
+
...(from ? { from } : {}),
|
|
184
|
+
...(input.open === undefined ? {} : { open: page(input.open, "open") }),
|
|
185
|
+
...(input.fresh ? { fresh: true } : {}),
|
|
186
|
+
steps: steps.map((item) => item.step),
|
|
187
|
+
network: (input.network ?? []).map(readRule),
|
|
188
|
+
...(input.args === undefined ? {} : { args: readArgs(input.args) }),
|
|
189
|
+
...(input.env === undefined ? {} : { env: readEnv(input.env) }),
|
|
190
|
+
...(input.api === undefined ? {} : { api: readApi(input.api) }),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** Every scenario file in a workspace. Files are read fresh each time (there
|
|
194
|
+
* are few, and an agent may have just written one). A file that can't be read
|
|
195
|
+
* is a problem to report, never a reason to hide the others. */
|
|
196
|
+
export function loadScenarios(workspace) {
|
|
197
|
+
const scenarios = [];
|
|
198
|
+
const problems = [];
|
|
199
|
+
const dir = path.join(workspace, SCENARIO_DIR);
|
|
200
|
+
let names;
|
|
201
|
+
try {
|
|
202
|
+
if (!fs.lstatSync(dir).isDirectory())
|
|
203
|
+
return { scenarios, problems };
|
|
204
|
+
names = fs.readdirSync(dir).filter((name) => name.endsWith(".json")).sort().slice(0, MAX_FILES);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return { scenarios, problems };
|
|
208
|
+
}
|
|
209
|
+
for (const name of names) {
|
|
210
|
+
const id = name.slice(0, -".json".length);
|
|
211
|
+
const file = path.join(SCENARIO_DIR, name);
|
|
212
|
+
try {
|
|
213
|
+
if (!ID.test(id))
|
|
214
|
+
throw new Error("Name the file with lowercase letters, digits and dashes, like checkout-api-down.json.");
|
|
215
|
+
const stat = fs.lstatSync(path.join(dir, name));
|
|
216
|
+
if (!stat.isFile())
|
|
217
|
+
throw new Error("Not a regular file.");
|
|
218
|
+
if (stat.size > MAX_BYTES)
|
|
219
|
+
throw new Error("Larger than 64 KiB.");
|
|
220
|
+
let raw;
|
|
221
|
+
try {
|
|
222
|
+
raw = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
throw new Error(`Not valid JSON: ${error.message}`);
|
|
226
|
+
}
|
|
227
|
+
scenarios.push(readScenario(id, raw, stat.mtimeMs));
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
problems.push({ id, file, error: error.message });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { scenarios, problems };
|
|
234
|
+
}
|
|
235
|
+
/** The scenarios that belong to one view: those naming it (or its app), or
|
|
236
|
+
* naming none, that fit its kind of preview. */
|
|
237
|
+
export function scenariosFor(list, app, view, kind) {
|
|
238
|
+
const names = [app.toLowerCase(), view.toLowerCase()];
|
|
239
|
+
return list.filter((scenario) => (!scenario.view || names.includes(scenario.view.toLowerCase())) && (!scenario.kind || scenario.kind === kind));
|
|
240
|
+
}
|
|
241
|
+
/** In words, for the pill: "POST /api/payments* → 503". */
|
|
242
|
+
export function describeRule(rule) {
|
|
243
|
+
const outcome = rule.offline ? "offline" : rule.status ?? (rule.json !== undefined || rule.body !== undefined ? 200 : `+${rule.delayMs}ms`);
|
|
244
|
+
return `${rule.method ? `${rule.method} ` : ""}${rule.path} → ${outcome}`;
|
|
245
|
+
}
|
|
246
|
+
function simulated(rules) {
|
|
247
|
+
if (!rules.length)
|
|
248
|
+
return undefined;
|
|
249
|
+
return describeRule(rules[0]) + (rules.length > 1 ? ` (+${rules.length - 1} more)` : "");
|
|
250
|
+
}
|
|
251
|
+
/** Resolves a scenario through its `from` chain: the base's page and steps
|
|
252
|
+
* first, then each one built on it; network rules nearest first, so a scenario
|
|
253
|
+
* overrides what it builds on. */
|
|
254
|
+
export function planScenario(list, id, kind = "web") {
|
|
255
|
+
const chain = [];
|
|
256
|
+
for (let at = id; at;) {
|
|
257
|
+
const scenario = list.find((item) => item.id === at);
|
|
258
|
+
if (!scenario)
|
|
259
|
+
throw new Error(at === id ? `No scenario called "${id}".` : `"${chain.at(-1).id}" starts from "${at}", which doesn't exist or can't be read.`);
|
|
260
|
+
if (chain.some((item) => item.id === at))
|
|
261
|
+
throw new Error(`"${id}" starts from itself through ${chain.map((item) => item.id).join(" → ")}.`);
|
|
262
|
+
chain.push(scenario);
|
|
263
|
+
if (chain.length > MAX_CHAIN)
|
|
264
|
+
throw new Error(`"${id}" builds on more than ${MAX_CHAIN} scenarios.`);
|
|
265
|
+
at = scenario.from;
|
|
266
|
+
}
|
|
267
|
+
const mismatch = chain.find((scenario) => scenario.kind && scenario.kind !== kind);
|
|
268
|
+
if (mismatch)
|
|
269
|
+
throw new Error(`"${mismatch.id}" is for ${mismatch.kind === "web" ? "web pages" : "desktop apps"}, and this is ${kind === "web" ? "a web page" : "a desktop app"}.`);
|
|
270
|
+
const order = [...chain].reverse();
|
|
271
|
+
const network = chain.flatMap((scenario) => scenario.network);
|
|
272
|
+
const api = chain.find((scenario) => scenario.api)?.api;
|
|
273
|
+
if (kind === "desktop" && network.length && !api)
|
|
274
|
+
throw new Error(`"${id}" simulates responses, but a desktop app calls its API directly: add "api": {"env": "API_URL", "target": "http://127.0.0.1:4000"} so Bivy can put a proxy in between.`);
|
|
275
|
+
return {
|
|
276
|
+
id, name: chain[0].name, kind, fresh: order.some((scenario) => scenario.fresh === true),
|
|
277
|
+
stages: order.map((scenario, index) => ({ ...(scenario.open ? { open: scenario.open } : index === 0 && kind === "web" ? { open: "/" } : {}), steps: scenario.steps })),
|
|
278
|
+
network, ...(simulated(network) ? { simulated: simulated(network) } : {}),
|
|
279
|
+
args: order.flatMap((scenario) => scenario.args ?? []), env: Object.assign({}, ...order.map((scenario) => scenario.env ?? {})), ...(api ? { api } : {}),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/** What a list or tile shows. `since`: the app's creation; files changed after it are new. */
|
|
283
|
+
export function summarize(list, problems, since, kind = "web") {
|
|
284
|
+
const rows = list.map((scenario) => {
|
|
285
|
+
try {
|
|
286
|
+
const plan = planScenario(list, scenario.id, kind);
|
|
287
|
+
const open = [...plan.stages].reverse().find((stage) => stage.open)?.open;
|
|
288
|
+
return {
|
|
289
|
+
id: scenario.id, name: scenario.name, ...(scenario.description ? { description: scenario.description } : {}),
|
|
290
|
+
...(open ? { open } : {}),
|
|
291
|
+
steps: plan.stages.reduce((sum, stage) => sum + stage.steps.length, 0),
|
|
292
|
+
...(plan.simulated ? { simulated: plan.simulated } : {}), ...(plan.fresh ? { fresh: true } : {}),
|
|
293
|
+
...(scenario.changedAt > since ? { isNew: true } : {}),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
return { id: scenario.id, name: scenario.name, steps: 0, error: error.message };
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
return [...rows, ...problems.map((problem) => ({ id: problem.id, name: problem.id, steps: 0, error: problem.error }))];
|
|
301
|
+
}
|
|
302
|
+
/** The rule a request meets, if any: the first whose method and path match. */
|
|
303
|
+
export function matchRule(rules, method, url) {
|
|
304
|
+
const [pathname] = url.split("?");
|
|
305
|
+
return rules.find((rule) => (!rule.method || rule.method === (method ?? "GET").toUpperCase())
|
|
306
|
+
&& new RegExp(`^${rule.path.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`).test(rule.path.includes("?") ? url : pathname));
|
|
307
|
+
}
|