@wenathlan/devthink 2.0.13 → 2.0.15
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/CHANGELOG.md +27 -0
- package/accounts.ts +2087 -0
- package/agent.ts +2331 -0
- package/antigravity.ts +2139 -0
- package/apifreeze.ts +4146 -0
- package/auth.ts +642 -0
- package/background.ts +16837 -0
- package/bridge.ts +2080 -0
- package/build.ts +54 -0
- package/bun.ts +26 -0
- package/capture.ts +1614 -0
- package/checksecrets.ts +72 -0
- package/cjs.ts +9 -0
- package/cli.ts +3500 -0
- package/commands.ts +1101 -0
- package/companion.ts +450 -0
- package/compatibility.ts +37 -0
- package/config.ts +3137 -0
- package/constants.ts +943 -0
- package/core.ts +1473 -0
- package/crossbrowser.ts +1359 -0
- package/dashdone.ts +605 -0
- package/data.ts +943 -0
- package/database.ts +133 -0
- package/debug.ts +850 -0
- package/deno.ts +27 -0
- package/devthink.ts +707 -0
- package/dist/accounts.d.ts +409 -0
- package/dist/agent.d.ts +837 -0
- package/dist/antigravity.d.ts +380 -0
- package/dist/apifreeze.d.ts +113 -0
- package/dist/auth.d.ts +224 -0
- package/{bridge.d.ts → dist/bridge.d.ts} +1 -1
- package/dist/capture.d.ts +595 -0
- package/dist/cjs.d.ts +6 -0
- package/{cli.js → dist/cli.js} +2 -2
- package/dist/commands.d.ts +310 -0
- package/dist/compatibility.d.ts +17 -0
- package/dist/config.d.ts +1853 -0
- package/dist/constants.d.ts +637 -0
- package/dist/core.d.ts +361 -0
- package/dist/data.d.ts +330 -0
- package/dist/database.d.ts +24 -0
- package/dist/debug.d.ts +270 -0
- package/dist/engine.d.ts +74 -0
- package/dist/environments.d.ts +244 -0
- package/dist/evidence.d.ts +277 -0
- package/dist/export.d.ts +311 -0
- package/dist/fingerprint.d.ts +594 -0
- package/dist/flow.d.ts +300 -0
- package/dist/gates.d.ts +103 -0
- package/dist/identity.d.ts +51 -0
- package/dist/ids.d.ts +7 -0
- package/dist/llm.d.ts +652 -0
- package/dist/models.d.ts +1807 -0
- package/dist/net.d.ts +325 -0
- package/dist/neutral.d.ts +41 -0
- package/dist/oauth.d.ts +703 -0
- package/dist/page.d.ts +1166 -0
- package/dist/perf.d.ts +481 -0
- package/dist/plan.d.ts +134 -0
- package/dist/project.d.ts +395 -0
- package/dist/providers.d.ts +37 -0
- package/dist/quota.d.ts +453 -0
- package/dist/recovery.d.ts +621 -0
- package/dist/request.d.ts +1492 -0
- package/dist/run.d.ts +779 -0
- package/dist/runtime.d.ts +212 -0
- package/dist/search.d.ts +375 -0
- package/dist/security.d.ts +534 -0
- package/dist/serve.d.ts +136 -0
- package/dist/session.d.ts +296 -0
- package/dist/storage.d.ts +53 -0
- package/dist/streaming.d.ts +1119 -0
- package/dist/swarm.d.ts +328 -0
- package/dist/tools.d.ts +170 -0
- package/dist/types.d.ts +8357 -0
- package/dist/utils.d.ts +82 -0
- package/dist/version.d.ts +7 -0
- package/dist/versionregistry.d.ts +406 -0
- package/dist/views.d.ts +579 -0
- package/dist/workbenchsession.d.ts +69 -0
- package/dist/workflow.d.ts +649 -0
- package/docs/readme.md +92 -0
- package/docs/schemas/antigravity.json +192 -0
- package/engine.ts +1524 -0
- package/environments.ts +1005 -0
- package/evidence.ts +643 -0
- package/export.ts +889 -0
- package/fingerprint.ts +1458 -0
- package/flow.ts +1190 -0
- package/gates.ts +307 -0
- package/gateway.ts +34 -0
- package/hardening.ts +191 -0
- package/headless.ts +369 -0
- package/http.ts +2110 -0
- package/identity.ts +246 -0
- package/ids.ts +26 -0
- package/index.ts +369 -0
- package/llm.ts +2682 -0
- package/mcp.ts +1835 -0
- package/{memory.js → memory.ts} +3834 -2310
- package/models.ts +3805 -0
- package/modes.ts +43 -0
- package/net.ts +1214 -0
- package/neutral.ts +362 -0
- package/node.ts +26 -0
- package/oauth.ts +2216 -0
- package/offscreen.ts +170 -0
- package/pack.ts +956 -0
- package/package.json +175 -82
- package/page.ts +7824 -0
- package/pagebridge.ts +1116 -0
- package/perf.ts +957 -0
- package/plan.ts +672 -0
- package/plugin.ts +76 -0
- package/{policy.js → policy.ts} +5073 -4679
- package/prisma.config.ts +27 -0
- package/progress.ts +1308 -0
- package/project.ts +1291 -0
- package/protocol.ts +4187 -0
- package/providers.ts +246 -0
- package/quota.ts +1887 -0
- package/recovery.ts +2433 -0
- package/request.ts +4482 -0
- package/run.ts +2135 -0
- package/runtime.ts +526 -0
- package/search.ts +1760 -0
- package/security.ts +1673 -0
- package/serve.ts +419 -0
- package/server.ts +5538 -0
- package/session.ts +1162 -0
- package/storage.ts +182 -0
- package/streaming.ts +4101 -0
- package/swarm.ts +884 -0
- package/sync.ts +56 -0
- package/tests/agentcert.test.ts +128 -0
- package/tests/agentfleet.test.ts +214 -0
- package/tests/agentreview.test.ts +241 -0
- package/tests/agentstream.test.ts +335 -0
- package/tests/agentwork.test.ts +356 -0
- package/tests/apifreeze.test.ts +290 -0
- package/tests/apimap.test.ts +112 -0
- package/tests/approvalgate.test.ts +175 -0
- package/tests/artifactmanifest.test.ts +204 -0
- package/tests/attentionfeed.test.ts +163 -0
- package/tests/backgroundruns.test.ts +100 -0
- package/tests/batchscheduling.test.ts +131 -0
- package/tests/blackboard.test.ts +238 -0
- package/tests/bridge.test.ts +1088 -0
- package/tests/browsercoverage.test.ts +134 -0
- package/tests/browserpolyfills.test.ts +124 -0
- package/tests/build.mjs +1260 -0
- package/tests/clientauth.test.ts +223 -0
- package/tests/clitools.test.ts +578 -0
- package/tests/confirmgates.test.ts +190 -0
- package/tests/containerpack.test.ts +143 -0
- package/tests/controlflow.test.ts +2120 -0
- package/tests/coordination.test.ts +345 -0
- package/tests/copyscreen.test.ts +139 -0
- package/tests/core.test.ts +417 -0
- package/tests/costcert.test.ts +95 -0
- package/tests/crossbrowser.test.ts +115 -0
- package/tests/cspaudit.test.ts +79 -0
- package/tests/dashdone.test.ts +615 -0
- package/tests/datagrid.test.ts +137 -0
- package/tests/doccheck.test.ts +107 -0
- package/tests/dombatch.test.ts +109 -0
- package/tests/emulation.test.ts +282 -0
- package/tests/environments.test.ts +259 -0
- package/tests/evidenceviews.test.ts +88 -0
- package/tests/exporttools.test.ts +185 -0
- package/tests/firefoxprep.test.ts +134 -0
- package/tests/flowlibrary.test.ts +242 -0
- package/tests/flowrun.test.ts +303 -0
- package/tests/forensics.test.ts +439 -0
- package/tests/gateway.test.ts +939 -0
- package/tests/headlesslib.test.ts +171 -0
- package/tests/httpstream.test.ts +277 -0
- package/tests/immutablelog.test.ts +157 -0
- package/tests/inboundguard.test.ts +177 -0
- package/tests/lazyload.test.ts +72 -0
- package/tests/librarymodes.test.ts +623 -0
- package/tests/llm.test.ts +696 -0
- package/tests/manifest.test.ts +226 -0
- package/tests/maskinputs.test.ts +150 -0
- package/tests/matrixverify.test.ts +111 -0
- package/tests/mavenpack.test.ts +75 -0
- package/tests/mcpserver.test.ts +704 -0
- package/tests/memory.test.ts +4155 -0
- package/tests/memorycare.test.ts +230 -0
- package/tests/migrateplan.test.ts +349 -0
- package/tests/minimization.test.ts +414 -0
- package/tests/modelroute.test.ts +112 -0
- package/tests/multiagent.test.ts +440 -0
- package/tests/nativebridge.test.ts +807 -0
- package/tests/navigation.test.ts +284 -0
- package/tests/nugetpack.test.ts +83 -0
- package/tests/orchestration.test.ts +449 -0
- package/tests/originpolicy.test.ts +405 -0
- package/tests/outputcompare.test.ts +97 -0
- package/tests/package.test.ts +91 -0
- package/tests/pagebridge.test.ts +5909 -0
- package/tests/pentest.test.ts +75 -0
- package/tests/perfrecords.test.ts +257 -0
- package/tests/permdiff.test.ts +85 -0
- package/tests/phishguard.test.ts +128 -0
- package/tests/pickerviews.test.ts +155 -0
- package/tests/pipelines.test.ts +381 -0
- package/tests/planlint.test.ts +240 -0
- package/tests/planreview.test.ts +383 -0
- package/tests/platformtargets.test.ts +56 -0
- package/tests/policy.test.ts +8284 -0
- package/tests/portability.test.ts +96 -0
- package/tests/progress.test.ts +1036 -0
- package/tests/promptlibrary.test.ts +116 -0
- package/tests/protocol.test.ts +4793 -0
- package/tests/publishingpipeline.test.ts +314 -0
- package/tests/quickactions.test.ts +164 -0
- package/tests/readiness.test.ts +78 -0
- package/tests/recipes.test.ts +163 -0
- package/tests/redactshots.test.ts +216 -0
- package/tests/registernvidiakeys.ts +51 -0
- package/tests/relayserve.test.ts +304 -0
- package/tests/releasenotes.test.ts +276 -0
- package/tests/resilience.test.ts +467 -0
- package/tests/resourceaware.test.ts +187 -0
- package/tests/runbudget.test.ts +125 -0
- package/tests/runhistory.test.ts +299 -0
- package/tests/runreplay.test.ts +113 -0
- package/tests/runresume.test.ts +184 -0
- package/tests/runstate.test.ts +288 -0
- package/tests/runtimeadapters.test.ts +251 -0
- package/tests/safariskeleton.test.ts +115 -0
- package/tests/sandboxframe.test.ts +196 -0
- package/tests/sbom.test.ts +105 -0
- package/tests/secretvault.test.ts +188 -0
- package/tests/selcache.test.ts +111 -0
- package/tests/sessioninterface.test.ts +791 -0
- package/tests/sessions.test.ts +591 -0
- package/tests/siteprefs.test.ts +228 -0
- package/tests/snapshotdelta.test.ts +165 -0
- package/tests/statusviews.test.ts +237 -0
- package/tests/stepmeter.test.ts +200 -0
- package/tests/streamparse.test.ts +163 -0
- package/tests/stylepruning.test.ts +90 -0
- package/tests/surfaces.test.ts +282 -0
- package/tests/sweep.test.ts +78 -0
- package/tests/syncbridge.test.ts +164 -0
- package/tests/taskqueue.test.ts +206 -0
- package/tests/toolcalls.test.ts +336 -0
- package/tests/toolcatalog.test.ts +201 -0
- package/tests/torture-agent.test.ts +706 -0
- package/tests/torture-auth.test.ts +461 -0
- package/tests/torture-commands.test.ts +895 -0
- package/tests/torture-data.test.ts +923 -0
- package/tests/torture-environments.test.ts +285 -0
- package/tests/torture-export.test.ts +263 -0
- package/tests/torture-http.test.ts +1379 -0
- package/tests/torture-llm.test.ts +808 -0
- package/tests/torture-memory.test.ts +1084 -0
- package/tests/torture-plan.test.ts +829 -0
- package/tests/torture-run.test.ts +626 -0
- package/tests/torture-session.test.ts +928 -0
- package/tests/torture-swarm.test.ts +1245 -0
- package/tests/torture-tools.test.ts +964 -0
- package/tests/torture-workflow.test.ts +2354 -0
- package/tests/tourviews.test.ts +52 -0
- package/tests/transparency.test.ts +147 -0
- package/tests/trigger.test.ts +941 -0
- package/tests/v1sunset.test.ts +428 -0
- package/tests/virtlist.test.ts +94 -0
- package/tests/vision.test.ts +540 -0
- package/tests/vsixpack.test.ts +145 -0
- package/tests/webapi.test.ts +523 -0
- package/tests/workflow.test.ts +1785 -0
- package/tests/workfloweditor.test.ts +759 -0
- package/tests/xpipack.test.ts +128 -0
- package/tools.ts +734 -0
- package/types.ts +9908 -0
- package/umd.ts +9 -0
- package/utils.ts +249 -0
- package/version.ts +8 -0
- package/versionregistry.ts +1310 -0
- package/views.ts +2153 -0
- package/vitest.config.ts +12 -0
- package/web/App.tsx +78 -0
- package/web/README.md +47 -0
- package/web/capacitor.config.ts +46 -0
- package/web/const.ts +19 -0
- package/web/control.shell.tsx +66 -0
- package/web/dashboardpage.ts +554 -0
- package/web/db.ts +493 -0
- package/web/design.html +541 -0
- package/web/gatewayview/Gateway.tsx +345 -0
- package/web/gatewayview/config.ts +1042 -0
- package/web/gatewayview/definition.ts +174 -0
- package/web/icons.ts +15 -0
- package/{manifest.json → web/manifest.json} +1 -1
- package/web/optionspage.ts +691 -0
- package/web/popup.ts +1651 -0
- package/web/schema.prisma +176 -0
- package/web/sidepanel.ts +7941 -0
- package/web/sitemanifest.json +13 -0
- package/web/terminal-ui.tsx +622 -0
- package/web/transparencypage.ts +541 -0
- package/web/vite.config.ts +39 -0
- package/web/workspace.ts +23 -0
- package/workbenchmemory.ts +104 -0
- package/workbenchsession.ts +304 -0
- package/workflow.ts +4495 -0
- package/bridge.d.ts.map +0 -1
- package/bridge.js +0 -1877
- package/bun.d.ts.map +0 -1
- package/bun.js +0 -52329
- package/caps/background.json +0 -712
- package/caps/cli.json +0 -360
- package/caps/library.json +0 -3611
- package/caps/mcp.json +0 -126
- package/caps/pagebridge.json +0 -377
- package/caps/popup.json +0 -51
- package/caps/sidepanel.json +0 -522
- package/checksums.txt +0 -142
- package/cli.d.ts.map +0 -1
- package/companion.d.ts.map +0 -1
- package/companion.js +0 -389
- package/crossbrowser.d.ts.map +0 -1
- package/crossbrowser.js +0 -1191
- package/dashdone.d.ts.map +0 -1
- package/dashdone.js +0 -368
- package/deno.d.ts.map +0 -1
- package/deno.js +0 -52325
- package/devthink.umd.js +0 -52346
- package/fixtures/bun.ts +0 -7
- package/fixtures/cjs.js +0 -6
- package/fixtures/deno.ts +0 -6
- package/fixtures/example-org-pagestate.json +0 -22
- package/fixtures/importers/automa-workflow.json +0 -18
- package/fixtures/importers/selenium-side.json +0 -20
- package/fixtures/importers/tabular-plan.csv +0 -7
- package/fixtures/importers/uivision-macro.json +0 -10
- package/fixtures/importers/v1-plan.json +0 -13
- package/fixtures/mcp-client.mjs +0 -14
- package/fixtures/pages/checkout.html +0 -192
- package/fixtures/pages/dashboard.html +0 -110
- package/fixtures/pages/feed.html +0 -113
- package/fixtures/pages/product-grid.html +0 -150
- package/fixtures/plans/form-inventory-plan.json +0 -11
- package/fixtures/plans/release-notes-plan.json +0 -11
- package/fixtures/recipes/agentscompete.json +0 -11
- package/fixtures/recipes/agentsforms.json +0 -11
- package/fixtures/recipes/agentsmonitor.json +0 -11
- package/fixtures/recipes/agentsreview.json +0 -29
- package/fixtures/recipes/agentswarm.json +0 -11
- package/fixtures/recipes/fillcalendar.json +0 -12
- package/fixtures/recipes/fillcheckout.json +0 -13
- package/fixtures/recipes/filldependentdropdowns.json +0 -12
- package/fixtures/recipes/fillfromcsv.json +0 -13
- package/fixtures/recipes/fillloginform.json +0 -12
- package/fixtures/recipes/fillmultistepwizard.json +0 -13
- package/fixtures/recipes/fillregistration.json +0 -12
- package/fixtures/recipes/fillsearchfilters.json +0 -12
- package/fixtures/recipes/filltypeahead.json +0 -11
- package/fixtures/recipes/monitoralerts.json +0 -12
- package/fixtures/recipes/monitoravailability.json +0 -11
- package/fixtures/recipes/monitornetworkquiet.json +0 -12
- package/fixtures/recipes/monitorpagechanges.json +0 -11
- package/fixtures/recipes/monitorpricechange.json +0 -11
- package/fixtures/recipes/monitorschedule.json +0 -12
- package/fixtures/recipes/scrapedynamicprices.json +0 -13
- package/fixtures/recipes/scrapeimagegallery.json +0 -12
- package/fixtures/recipes/scrapeinfinitefeed.json +0 -13
- package/fixtures/recipes/scrapenestedlists.json +0 -12
- package/fixtures/recipes/scrapeproductgrid.json +0 -12
- package/fixtures/recipes/scrapereviews.json +0 -12
- package/fixtures/recipes/scrapesearchresults.json +0 -12
- package/fixtures/recipes/scrapetablepagination.json +0 -12
- package/fixtures/recipes/testaccessibility.json +0 -34
- package/fixtures/recipes/testconsoleerrors.json +0 -12
- package/fixtures/recipes/testdeeplinks.json +0 -12
- package/fixtures/recipes/testformvalidation.json +0 -13
- package/fixtures/recipes/testlayoutshift.json +0 -12
- package/fixtures/recipes/testlinkcheck.json +0 -12
- package/fixtures/recipes/testloadtiming.json +0 -12
- package/fixtures/recipes/testvisualdiff.json +0 -12
- package/fixtures/workflows/changelog-digest-workflow.json +0 -13
- package/fixtures/workflows/form-survey-workflow.json +0 -13
- package/gallery.json +0 -706
- package/hardening.d.ts.map +0 -1
- package/hardening.js +0 -188
- package/headless.d.ts.map +0 -1
- package/headless.js +0 -11522
- package/http.d.ts.map +0 -1
- package/http.js +0 -2165
- package/index.cjs +0 -54682
- package/index.d.ts.map +0 -1
- package/index.js +0 -52310
- package/index.neutral.js +0 -46343
- package/mcp.d.ts.map +0 -1
- package/mcp.js +0 -20515
- package/memory.d.ts.map +0 -1
- package/nativehost.template.json +0 -10
- package/node.cjs +0 -54686
- package/node.d.ts.map +0 -1
- package/pack.d.ts.map +0 -1
- package/pack.js +0 -805
- package/policy.d.ts.map +0 -1
- package/progress.d.ts.map +0 -1
- package/progress.js +0 -645
- package/protocol.d.ts.map +0 -1
- package/protocol.js +0 -8334
- package/schemas/audit.schema.json +0 -226
- package/schemas/capability.schema.json +0 -113
- package/schemas/envelope.schema.json +0 -162
- package/schemas/memory.schema.json +0 -51
- package/schemas/observation.schema.json +0 -113
- package/schemas/plan.schema.json +0 -208
- package/schemas/progress.schema.json +0 -126
- package/schemas/proposal.schema.json +0 -68
- package/schemas/session.schema.json +0 -82
- package/schemas/tool.schema.json +0 -94
- package/server.d.ts.map +0 -1
- package/server.js +0 -28877
- package/umd-example.html +0 -18
- package/umd.d.ts.map +0 -1
- /package/{bun.d.ts → dist/bun.d.ts} +0 -0
- /package/{cli.d.ts → dist/cli.d.ts} +0 -0
- /package/{companion.d.ts → dist/companion.d.ts} +0 -0
- /package/{crossbrowser.d.ts → dist/crossbrowser.d.ts} +0 -0
- /package/{dashdone.d.ts → dist/dashdone.d.ts} +0 -0
- /package/{deno.d.ts → dist/deno.d.ts} +0 -0
- /package/{hardening.d.ts → dist/hardening.d.ts} +0 -0
- /package/{headless.d.ts → dist/headless.d.ts} +0 -0
- /package/{http.d.ts → dist/http.d.ts} +0 -0
- /package/{index.d.ts → dist/index.d.ts} +0 -0
- /package/{mcp.d.ts → dist/mcp.d.ts} +0 -0
- /package/{memory.d.ts → dist/memory.d.ts} +0 -0
- /package/{node.d.ts → dist/node.d.ts} +0 -0
- /package/{pack.d.ts → dist/pack.d.ts} +0 -0
- /package/{policy.d.ts → dist/policy.d.ts} +0 -0
- /package/{progress.d.ts → dist/progress.d.ts} +0 -0
- /package/{protocol.d.ts → dist/protocol.d.ts} +0 -0
- /package/{server.d.ts → dist/server.d.ts} +0 -0
- /package/{umd.d.ts → dist/umd.d.ts} +0 -0
package/net.ts
ADDED
|
@@ -0,0 +1,1214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The net module of the 1.1.90 consolidation: every correlated variation of the network logic interned in this one file, so the module family carries one surface without duplicate variations.
|
|
3
|
+
* The correlation is the network plane of a run, ordered from observation through transport to control: netwatch holds the derived request observation of the run tab (the timing buffer facts, the correlation ids that join request and response pairs, the header allowlists with redaction, the body capture inside reviewed byte ceilings and the page api map with its ranking and replay extraction); socketbus holds the transport plane (the websocket channel lifecycle with reconnection backoff, the multiplexed named message streams with their channel scoped sequences, the message filter matching of waitmessage steps, the server sent events parsing with last event id resume and the long polling cursor loop with its stop conditions); and netcontrol holds the control plane (request blocking, response mocking, header rewriting, cookie scoping, proxy routing, the rate limit reads and the retry after waits, every rule run scoped and reverted the moment a run ends, fails or is cancelled).
|
|
4
|
+
* No byte ceiling, reconnect budget, backoff base, rate window or poll bound is ever hardcoded anywhere in the family: every bound stays the user's choice, header values redact before storage, body bytes never enter any audit summary, and no network control rule ever bypasses the human review.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* ── Merged from netwatch.ts ── */
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
apimapentry,
|
|
11
|
+
apireplayspec,
|
|
12
|
+
bodyfilter,
|
|
13
|
+
bodyrecord,
|
|
14
|
+
exchangerecord,
|
|
15
|
+
headerfilter,
|
|
16
|
+
responseentry,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
import { readpath } from "./http.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Network observation part two watch logics for the 1.1.43 family.
|
|
22
|
+
* Every correlated rule for the derived request observation of the run tab, the correlation ids that join request and response pairs, the header reading with allowlists and redaction, the response body capture inside reviewed byte ceilings, the page api mapping with frequency, json share and payload stability ranking and the api replay extraction paths lives in this file.
|
|
23
|
+
* The page observation derives from the timing and navigation buffers the page itself exposes, so request and response headers and bodies exist only for exchanges captured through the extension context; header values are redacted before storage and body bytes never enter any audit summary.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The netwatch kinds, listed among the available capabilities of every proposal request. */
|
|
27
|
+
export const netwatchkinds: string[] = ["watchrequests", "readheaders", "capturebodies", "mapapi", "extractapi"];
|
|
28
|
+
|
|
29
|
+
/** One derived request fact of the page timing buffers: url, initiator, timing, transfer size, protocol and the status a navigation entry exposes. */
|
|
30
|
+
export interface resourcefact {
|
|
31
|
+
url: string;
|
|
32
|
+
initiator: string;
|
|
33
|
+
entrytype: string;
|
|
34
|
+
start: number;
|
|
35
|
+
duration: number;
|
|
36
|
+
transfer: number;
|
|
37
|
+
protocol: string;
|
|
38
|
+
status?: number;
|
|
39
|
+
/** Explicit failure flag a navigation buffer reports, for example a net error. */
|
|
40
|
+
failed?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Normalizes raw timing buffer entries into derived request facts; navigation entries contribute the response status they expose. */
|
|
44
|
+
export function resourcefacts(entries: Array<Record<string, unknown>>): resourcefact[] {
|
|
45
|
+
const facts: resourcefact[] = [];
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
const url = typeof entry.name === "string" ? entry.name : "";
|
|
48
|
+
if (!url) continue;
|
|
49
|
+
const entrytype = typeof entry.entryType === "string" ? entry.entryType : "resource";
|
|
50
|
+
if (entrytype !== "resource" && entrytype !== "navigation") continue;
|
|
51
|
+
facts.push({
|
|
52
|
+
url,
|
|
53
|
+
initiator: typeof entry.initiatorType === "string" ? entry.initiatorType : "",
|
|
54
|
+
entrytype,
|
|
55
|
+
start: typeof entry.startTime === "number" && Number.isFinite(entry.startTime) ? entry.startTime : 0,
|
|
56
|
+
duration: typeof entry.duration === "number" && Number.isFinite(entry.duration) ? entry.duration : 0,
|
|
57
|
+
transfer: typeof entry.transferSize === "number" && Number.isFinite(entry.transferSize) ? entry.transferSize : 0,
|
|
58
|
+
protocol: typeof entry.nextHopProtocol === "string" ? entry.nextHopProtocol : "",
|
|
59
|
+
...(typeof entry.responseStatus === "number" && Number.isInteger(entry.responseStatus)
|
|
60
|
+
? { status: entry.responseStatus }
|
|
61
|
+
: {}),
|
|
62
|
+
...(entry.failed === true ? { failed: true } : {}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return facts;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Classifies one derived request failure: an exposed status of four hundred or more is an http error and an explicit failure flag or a zero transfer fetch without a protocol is a network error; everything else reports no error class. */
|
|
69
|
+
export function failureclass(fact: resourcefact): { errorclass?: "httperror" | "networkerror"; status: number } {
|
|
70
|
+
if (fact.status !== undefined && fact.status >= 400) return { errorclass: "httperror", status: fact.status };
|
|
71
|
+
if (fact.failed === true) return { errorclass: "networkerror", status: 0 };
|
|
72
|
+
if (
|
|
73
|
+
(fact.initiator === "fetch" || fact.initiator === "xmlhttprequest") &&
|
|
74
|
+
fact.duration > 0 &&
|
|
75
|
+
fact.transfer === 0 &&
|
|
76
|
+
fact.protocol === ""
|
|
77
|
+
)
|
|
78
|
+
return { errorclass: "networkerror", status: 0 };
|
|
79
|
+
return { status: fact.status ?? 0 };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Assigns one correlation id per run request, ordered by first observation. */
|
|
83
|
+
export function correlationid(runid: string, index: number): string {
|
|
84
|
+
return `${runid}-${index + 1}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Creates one observed exchange record from a derived request fact: the method stays honest with a question mark for fetch initiators the timing buffers cannot name, and the status and error class come from what the buffers expose. */
|
|
88
|
+
export function newexchange(input: {
|
|
89
|
+
id: string;
|
|
90
|
+
runid: string;
|
|
91
|
+
stepid: string;
|
|
92
|
+
correlationid: string;
|
|
93
|
+
fact: resourcefact;
|
|
94
|
+
at: number;
|
|
95
|
+
}): exchangerecord {
|
|
96
|
+
const verdict = failureclass(input.fact);
|
|
97
|
+
let origin = "";
|
|
98
|
+
try {
|
|
99
|
+
origin = new URL(input.fact.url).origin;
|
|
100
|
+
} catch {
|
|
101
|
+
origin = "";
|
|
102
|
+
}
|
|
103
|
+
const method = input.fact.initiator === "fetch" || input.fact.initiator === "xmlhttprequest" ? "?" : "GET";
|
|
104
|
+
const statusclass =
|
|
105
|
+
verdict.status >= 100 && verdict.status < 600
|
|
106
|
+
? verdict.status >= 200 && verdict.status < 300
|
|
107
|
+
? "success"
|
|
108
|
+
: verdict.status >= 300 && verdict.status < 400
|
|
109
|
+
? "redirect"
|
|
110
|
+
: verdict.status >= 400 && verdict.status < 500
|
|
111
|
+
? "clienterror"
|
|
112
|
+
: verdict.status >= 500
|
|
113
|
+
? "servererror"
|
|
114
|
+
: "informational"
|
|
115
|
+
: "unknown";
|
|
116
|
+
return {
|
|
117
|
+
id: input.id,
|
|
118
|
+
runid: input.runid,
|
|
119
|
+
stepid: input.stepid,
|
|
120
|
+
correlationid: input.correlationid,
|
|
121
|
+
url: input.fact.url,
|
|
122
|
+
origin,
|
|
123
|
+
method,
|
|
124
|
+
status: verdict.status,
|
|
125
|
+
statusclass,
|
|
126
|
+
...(verdict.errorclass !== undefined ? { errorclass: verdict.errorclass } : {}),
|
|
127
|
+
source: "page",
|
|
128
|
+
...(input.fact.initiator ? { initiator: input.fact.initiator } : {}),
|
|
129
|
+
timing: Math.round(input.fact.duration),
|
|
130
|
+
bytes: input.fact.transfer,
|
|
131
|
+
at: input.at,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Joins one captured response entry onto its exchange through the shared correlation id; a mismatching correlation id is refused instead of paired. */
|
|
136
|
+
export function pairexchange(exchange: exchangerecord, response: responseentry): exchangerecord {
|
|
137
|
+
if (exchange.correlationid !== response.correlationid)
|
|
138
|
+
throw new Error(
|
|
139
|
+
`The response ${response.correlationid} does not pair with the exchange ${exchange.correlationid}.`,
|
|
140
|
+
);
|
|
141
|
+
return {
|
|
142
|
+
...exchange,
|
|
143
|
+
status: response.status,
|
|
144
|
+
statusclass:
|
|
145
|
+
response.status >= 200 && response.status < 300
|
|
146
|
+
? "success"
|
|
147
|
+
: response.status >= 400 && response.status < 500
|
|
148
|
+
? "clienterror"
|
|
149
|
+
: response.status >= 500
|
|
150
|
+
? "servererror"
|
|
151
|
+
: response.status >= 300 && response.status < 400
|
|
152
|
+
? "redirect"
|
|
153
|
+
: "unknown",
|
|
154
|
+
bytes: response.bytes,
|
|
155
|
+
...(response.mime !== undefined ? { mime: response.mime } : {}),
|
|
156
|
+
...(response.bodyref !== undefined ? { bodyref: response.bodyref } : {}),
|
|
157
|
+
...(Object.keys(response.headers).length > 0 ? { responseheaders: response.headers } : {}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Filters observed exchanges by run, origin and status; the status filter accepts one status code or the failed class of every exchange with an error class. */
|
|
162
|
+
export function filterexchanges(
|
|
163
|
+
exchanges: exchangerecord[],
|
|
164
|
+
filter: { runid?: string; origin?: string; status?: number | "failed" },
|
|
165
|
+
): exchangerecord[] {
|
|
166
|
+
return exchanges.filter(
|
|
167
|
+
(exchange) =>
|
|
168
|
+
(filter.runid === undefined || exchange.runid === filter.runid) &&
|
|
169
|
+
(filter.origin === undefined || exchange.origin === filter.origin) &&
|
|
170
|
+
(filter.status === undefined ||
|
|
171
|
+
(filter.status === "failed" ? exchange.errorclass !== undefined : exchange.status === filter.status)),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Normalizes a reviewed header filter: the name allowlist and the redaction list, both lowercased; the redaction list is required before any header value is stored. */
|
|
176
|
+
export function headerfilterof(value: unknown): headerfilter {
|
|
177
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return { allow: [], redact: [] };
|
|
178
|
+
const entry = value as Record<string, unknown>;
|
|
179
|
+
const names = (source: unknown): string[] =>
|
|
180
|
+
Array.isArray(source)
|
|
181
|
+
? source
|
|
182
|
+
.filter((name): name is string => typeof name === "string" && name.trim().length > 0)
|
|
183
|
+
.map((name) => name.trim().toLowerCase())
|
|
184
|
+
: [];
|
|
185
|
+
return { allow: names(entry.allow), redact: names(entry.redact) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Applies one header filter to captured headers: only allowlisted names survive and redaction listed names keep their name with the value replaced by the redaction marker before storage. */
|
|
189
|
+
export function capturedheaders(headers: Record<string, string>, filter: headerfilter): Record<string, string> {
|
|
190
|
+
const result: Record<string, string> = {};
|
|
191
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
192
|
+
const key = name.trim().toLowerCase();
|
|
193
|
+
if (filter.allow.length > 0 && !filter.allow.includes(key)) continue;
|
|
194
|
+
result[key] = filter.redact.includes(key) ? "[redacted]" : value;
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Normalizes a reviewed body filter: url pattern, mime list and the byte ceiling of one captured body; the ceiling stays a user configured value with no code ceiling. */
|
|
200
|
+
export function bodyfilterof(value: unknown): bodyfilter {
|
|
201
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
202
|
+
const entry = value as Record<string, unknown>;
|
|
203
|
+
const filter: bodyfilter = {};
|
|
204
|
+
if (typeof entry.urlpattern === "string" && entry.urlpattern.trim()) filter.urlpattern = entry.urlpattern.trim();
|
|
205
|
+
if (Array.isArray(entry.mimes))
|
|
206
|
+
filter.mimes = entry.mimes
|
|
207
|
+
.filter((mime): mime is string => typeof mime === "string" && mime.trim().length > 0)
|
|
208
|
+
.map((mime) => mime.trim().toLowerCase());
|
|
209
|
+
if (typeof entry.ceiling === "number" && Number.isFinite(entry.ceiling) && entry.ceiling >= 0)
|
|
210
|
+
filter.ceiling = entry.ceiling;
|
|
211
|
+
return filter;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** True when one exchange matches the reviewed body filter: the url pattern appears inside the exchange url and the mime list, when reviewed, carries the exchange mime. */
|
|
215
|
+
export function bodymatches(filter: bodyfilter, exchange: { url: string; mime?: string }): boolean {
|
|
216
|
+
if (filter.urlpattern !== undefined && !exchange.url.includes(filter.urlpattern)) return false;
|
|
217
|
+
if (filter.mimes !== undefined && filter.mimes.length > 0) {
|
|
218
|
+
const mime = ((exchange.mime ?? "").split(";")[0] ?? "").trim().toLowerCase();
|
|
219
|
+
if (!filter.mimes.includes(mime)) return false;
|
|
220
|
+
}
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Mime types whose bodies carry private document or data payloads; a capturebodies step that lists one of them grades sensitive. */
|
|
225
|
+
const privatemimes = new Set([
|
|
226
|
+
"text/html",
|
|
227
|
+
"text/plain",
|
|
228
|
+
"text/xml",
|
|
229
|
+
"application/xml",
|
|
230
|
+
"application/json",
|
|
231
|
+
"text/json",
|
|
232
|
+
"application/x-www-form-urlencoded",
|
|
233
|
+
"application/graphql",
|
|
234
|
+
"multipart/form-data",
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
/** True when a mime type carries private document or data payloads instead of public static assets. */
|
|
238
|
+
export function privatemime(mime: string): boolean {
|
|
239
|
+
return privatemimes.has((mime.split(";")[0] ?? "").trim().toLowerCase());
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Captures one response body inside the reviewed byte ceiling: the stored body truncates at the ceiling and the record keeps the stored byte size with its correlation linkage. */
|
|
243
|
+
export function capturebody(input: {
|
|
244
|
+
ref: string;
|
|
245
|
+
runid: string;
|
|
246
|
+
exchange: exchangerecord;
|
|
247
|
+
body: string;
|
|
248
|
+
mime: string;
|
|
249
|
+
filter: bodyfilter;
|
|
250
|
+
at: number;
|
|
251
|
+
}): { record: bodyrecord; truncated: boolean } | { refused: string } {
|
|
252
|
+
if (!bodymatches(input.filter, input.exchange))
|
|
253
|
+
return { refused: `The exchange ${input.exchange.correlationid} does not match the reviewed body filter.` };
|
|
254
|
+
const ceiling = input.filter.ceiling;
|
|
255
|
+
const stored = ceiling !== undefined && input.body.length > ceiling ? input.body.slice(0, ceiling) : input.body;
|
|
256
|
+
return {
|
|
257
|
+
record: {
|
|
258
|
+
ref: input.ref,
|
|
259
|
+
runid: input.runid,
|
|
260
|
+
correlationid: input.exchange.correlationid,
|
|
261
|
+
url: input.exchange.url,
|
|
262
|
+
mime: input.mime,
|
|
263
|
+
bytes: stored.length,
|
|
264
|
+
body: stored,
|
|
265
|
+
at: input.at,
|
|
266
|
+
},
|
|
267
|
+
truncated: stored.length < input.body.length,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Reads the payload shape of one captured body: the field names of a json object in first seen order, with array bodies reporting the shape of their first element. */
|
|
272
|
+
export function payloadshapeof(body: string | undefined): string[] {
|
|
273
|
+
if (body === undefined) return [];
|
|
274
|
+
try {
|
|
275
|
+
const parsed = JSON.parse(body) as unknown;
|
|
276
|
+
const shape = (value: unknown): string[] =>
|
|
277
|
+
value && typeof value === "object" && !Array.isArray(value) ? Object.keys(value as Record<string, unknown>) : [];
|
|
278
|
+
if (Array.isArray(parsed)) return parsed.length > 0 ? shape(parsed[0]) : [];
|
|
279
|
+
return shape(parsed);
|
|
280
|
+
} catch {
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** True when one exchange looks like a page api call: a fetch or xmlhttprequest initiator, a captured body or an api shaped url path. */
|
|
286
|
+
function isapicandidate(exchange: exchangerecord): boolean {
|
|
287
|
+
if (exchange.initiator === "fetch" || exchange.initiator === "xmlhttprequest") return true;
|
|
288
|
+
if (exchange.bodyref !== undefined) return true;
|
|
289
|
+
try {
|
|
290
|
+
return /\/api\/|\/graphql|\.json($|\?)|\/v\d+\//i.test(new URL(exchange.url).pathname);
|
|
291
|
+
} catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Builds the page api map of the run from observed exchanges and captured bodies: endpoint, method, mime, frequency, json share and payload stability with their correlation ids. */
|
|
297
|
+
export function apientries(exchanges: exchangerecord[], bodies: bodyrecord[]): apimapentry[] {
|
|
298
|
+
const bodybyref = new Map(bodies.map((body) => [body.correlationid, body]));
|
|
299
|
+
const groups = new Map<
|
|
300
|
+
string,
|
|
301
|
+
{
|
|
302
|
+
endpoint: string;
|
|
303
|
+
method: string;
|
|
304
|
+
origin: string;
|
|
305
|
+
mimes: Map<string, number>;
|
|
306
|
+
frequency: number;
|
|
307
|
+
json: number;
|
|
308
|
+
captured: number;
|
|
309
|
+
shapes: Map<string, number>;
|
|
310
|
+
correlationids: string[];
|
|
311
|
+
}
|
|
312
|
+
>();
|
|
313
|
+
for (const exchange of exchanges) {
|
|
314
|
+
if (!isapicandidate(exchange)) continue;
|
|
315
|
+
let endpoint = exchange.url;
|
|
316
|
+
let origin = exchange.origin;
|
|
317
|
+
try {
|
|
318
|
+
const parsed = new URL(exchange.url);
|
|
319
|
+
endpoint = `${parsed.origin}${parsed.pathname}`;
|
|
320
|
+
origin = parsed.origin;
|
|
321
|
+
} catch {
|
|
322
|
+
/* an unparsable url keeps the raw endpoint */
|
|
323
|
+
}
|
|
324
|
+
const key = `${exchange.method} ${endpoint}`;
|
|
325
|
+
const group = groups.get(key) ?? {
|
|
326
|
+
endpoint,
|
|
327
|
+
method: exchange.method,
|
|
328
|
+
origin,
|
|
329
|
+
mimes: new Map<string, number>(),
|
|
330
|
+
frequency: 0,
|
|
331
|
+
json: 0,
|
|
332
|
+
captured: 0,
|
|
333
|
+
shapes: new Map<string, number>(),
|
|
334
|
+
correlationids: [],
|
|
335
|
+
};
|
|
336
|
+
group.frequency += 1;
|
|
337
|
+
group.correlationids.push(exchange.correlationid);
|
|
338
|
+
const body = exchange.bodyref !== undefined ? bodybyref.get(exchange.correlationid) : undefined;
|
|
339
|
+
const mime = body?.mime ?? exchange.mime ?? "";
|
|
340
|
+
group.mimes.set(mime, (group.mimes.get(mime) ?? 0) + 1);
|
|
341
|
+
if (body !== undefined) {
|
|
342
|
+
group.captured += 1;
|
|
343
|
+
const shape = payloadshapeof(body.body);
|
|
344
|
+
if (shape.length > 0) group.json += 1;
|
|
345
|
+
const shapekey = shape.join(",");
|
|
346
|
+
group.shapes.set(shapekey, (group.shapes.get(shapekey) ?? 0) + 1);
|
|
347
|
+
}
|
|
348
|
+
groups.set(key, group);
|
|
349
|
+
}
|
|
350
|
+
return [...groups.values()].map((group) => {
|
|
351
|
+
const mime = [...group.mimes.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? "";
|
|
352
|
+
const modalshape = [...group.shapes.entries()].sort((left, right) => right[1] - left[1])[0];
|
|
353
|
+
return {
|
|
354
|
+
endpoint: group.endpoint,
|
|
355
|
+
method: group.method,
|
|
356
|
+
mime,
|
|
357
|
+
frequency: group.frequency,
|
|
358
|
+
payloadshape: (modalshape?.[0] ?? "").split(",").filter(Boolean),
|
|
359
|
+
jsonshare: group.captured > 0 ? group.json / group.captured : 0,
|
|
360
|
+
stability: group.captured > 0 ? (modalshape?.[1] ?? 0) / group.captured : 0,
|
|
361
|
+
origin: group.origin,
|
|
362
|
+
correlationids: group.correlationids,
|
|
363
|
+
};
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Ranks page api entries by frequency, json share and payload stability so the most used stable json endpoints surface first. */
|
|
368
|
+
export function rankapis(entries: apimapentry[]): apimapentry[] {
|
|
369
|
+
const score = (entry: apimapentry): number => entry.frequency * (1 + entry.jsonshare + entry.stability);
|
|
370
|
+
return [...entries].sort((left, right) => score(right) - score(left) || right.frequency - left.frequency);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Normalizes a reviewed api replay spec: the captured endpoint, the replay verb, the parameter overrides and the dotted extraction paths. */
|
|
374
|
+
export function apireplayspecof(value: unknown): apireplayspec | undefined {
|
|
375
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
376
|
+
const entry = value as Record<string, unknown>;
|
|
377
|
+
if (typeof entry.endpoint !== "string" || !entry.endpoint.trim()) return undefined;
|
|
378
|
+
const spec: apireplayspec = { endpoint: entry.endpoint.trim() };
|
|
379
|
+
if (typeof entry.verb === "string" && entry.verb.trim()) spec.verb = entry.verb.trim().toUpperCase();
|
|
380
|
+
if (
|
|
381
|
+
entry.overrides !== undefined &&
|
|
382
|
+
entry.overrides !== null &&
|
|
383
|
+
typeof entry.overrides === "object" &&
|
|
384
|
+
!Array.isArray(entry.overrides)
|
|
385
|
+
) {
|
|
386
|
+
const overrides: Record<string, string> = {};
|
|
387
|
+
for (const [name, override] of Object.entries(entry.overrides as Record<string, unknown>)) {
|
|
388
|
+
if (typeof override === "string") overrides[name] = override;
|
|
389
|
+
}
|
|
390
|
+
spec.overrides = overrides;
|
|
391
|
+
}
|
|
392
|
+
if (Array.isArray(entry.paths))
|
|
393
|
+
spec.paths = entry.paths.filter((path): path is string => typeof path === "string" && path.trim().length > 0);
|
|
394
|
+
return spec;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Builds the replay url of one captured endpoint with the reviewed parameter overrides applied as query parameters. */
|
|
398
|
+
export function replayurl(spec: apireplayspec): string {
|
|
399
|
+
const url = new URL(spec.endpoint);
|
|
400
|
+
for (const [name, value] of Object.entries(spec.overrides ?? {})) url.searchParams.set(name, value);
|
|
401
|
+
return url.toString();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Maps one replayed response body onto the reviewed dotted extraction paths, reporting a miss instead of crashing when a path finds nothing. */
|
|
405
|
+
export function extractvalues(
|
|
406
|
+
body: string,
|
|
407
|
+
paths: string[],
|
|
408
|
+
): Array<{ path: string; value?: unknown; missing?: boolean }> {
|
|
409
|
+
let parsed: unknown;
|
|
410
|
+
try {
|
|
411
|
+
parsed = JSON.parse(body);
|
|
412
|
+
} catch {
|
|
413
|
+
return paths.map((path) => ({ path, missing: true }));
|
|
414
|
+
}
|
|
415
|
+
const fields = readpath(
|
|
416
|
+
parsed,
|
|
417
|
+
paths.map((path) => ({ name: path, path, kind: "json" as const })),
|
|
418
|
+
);
|
|
419
|
+
return fields.map((field) => ({
|
|
420
|
+
path: field.path,
|
|
421
|
+
...(field.value !== undefined ? { value: field.value } : {}),
|
|
422
|
+
...(field.missing ? { missing: true } : {}),
|
|
423
|
+
}));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/* ── Merged from socketbus.ts ── */
|
|
427
|
+
|
|
428
|
+
import type {
|
|
429
|
+
channelrecord,
|
|
430
|
+
channelstate,
|
|
431
|
+
messageenvelope,
|
|
432
|
+
messagefilter,
|
|
433
|
+
pollcursor,
|
|
434
|
+
socketoptions,
|
|
435
|
+
} from "./types.js";
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Network observation part two socket logics for the 1.1.43 family.
|
|
439
|
+
* Every correlated rule for the websocket channel lifecycle with reconnection backoff, the multiplexing of named message streams over one channel with channel scoped sequence numbers, the message filter matching of waitmessage steps, the server sent events parsing with last event id resume and the long polling cursor loop with its stop conditions lives in this file.
|
|
440
|
+
* Message payloads are opaque reviewed text; no payload value enters any audit summary built from these results.
|
|
441
|
+
*/
|
|
442
|
+
|
|
443
|
+
/** The socket and stream kinds, listed among the available capabilities of every proposal request. */
|
|
444
|
+
export const socketkinds: string[] = ["opensocket", "sendmessage", "waitmessage", "subscribesse", "longpoll"];
|
|
445
|
+
|
|
446
|
+
/** Connect seam signature: one reviewed channel url with its protocols; the extension executor wires the browser websocket, tests wire plain fixtures. */
|
|
447
|
+
export type socketconnect = (
|
|
448
|
+
url: string,
|
|
449
|
+
protocols: string[],
|
|
450
|
+
) => Promise<{ open: boolean; code?: number; error?: string }>;
|
|
451
|
+
|
|
452
|
+
/** Resolves the reviewed https origin behind a channel url: wss channels map onto their https origin so the origin grants cover both. */
|
|
453
|
+
export function channelorigin(url: string): string {
|
|
454
|
+
try {
|
|
455
|
+
const parsed = new URL(url);
|
|
456
|
+
const protocol = parsed.protocol === "wss:" ? "https:" : parsed.protocol === "ws:" ? "http:" : parsed.protocol;
|
|
457
|
+
return `${protocol}//${parsed.host}`;
|
|
458
|
+
} catch {
|
|
459
|
+
return "";
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Normalizes reviewed channel open options: url plus the socket options of protocols, reconnect budget, backoff base and backoff ceiling; every bound stays a user choice with no code ceiling. */
|
|
464
|
+
export function channeloptionsof(value: unknown): { url: string; options: socketoptions } | undefined {
|
|
465
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
466
|
+
const entry = value as Record<string, unknown>;
|
|
467
|
+
if (typeof entry.url !== "string" || !entry.url.trim()) return undefined;
|
|
468
|
+
const options: socketoptions = {};
|
|
469
|
+
if (Array.isArray(entry.protocols))
|
|
470
|
+
options.protocols = entry.protocols.filter(
|
|
471
|
+
(item): item is string => typeof item === "string" && item.trim().length > 0,
|
|
472
|
+
);
|
|
473
|
+
if (typeof entry.reconnect === "number" && Number.isFinite(entry.reconnect)) options.reconnect = entry.reconnect;
|
|
474
|
+
if (typeof entry.backoff === "number" && Number.isFinite(entry.backoff)) options.backoff = entry.backoff;
|
|
475
|
+
if (typeof entry.backoffceiling === "number" && Number.isFinite(entry.backoffceiling))
|
|
476
|
+
options.backoffceiling = entry.backoffceiling;
|
|
477
|
+
if (typeof entry.lifetime === "number" && Number.isFinite(entry.lifetime)) options.lifetime = entry.lifetime;
|
|
478
|
+
return { url: entry.url.trim(), options };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Creates one channel record in the connecting state with its origin, protocols and zeroed message counters. */
|
|
482
|
+
export function newchannel(input: {
|
|
483
|
+
id: string;
|
|
484
|
+
runid: string;
|
|
485
|
+
stepid: string;
|
|
486
|
+
kind: "websocket" | "sse";
|
|
487
|
+
url: string;
|
|
488
|
+
protocols?: string[];
|
|
489
|
+
at: number;
|
|
490
|
+
}): channelrecord {
|
|
491
|
+
return {
|
|
492
|
+
id: input.id,
|
|
493
|
+
runid: input.runid,
|
|
494
|
+
stepid: input.stepid,
|
|
495
|
+
kind: input.kind,
|
|
496
|
+
url: input.url,
|
|
497
|
+
origin: channelorigin(input.url),
|
|
498
|
+
state: "connecting",
|
|
499
|
+
openedat: input.at,
|
|
500
|
+
sent: 0,
|
|
501
|
+
received: 0,
|
|
502
|
+
reconnects: 0,
|
|
503
|
+
...(input.protocols !== undefined && input.protocols.length > 0 ? { protocols: [...input.protocols] } : {}),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Builds the exponential backoff waits of the reviewed reconnect budget: each wait doubles the base and growth stops at the user configured ceiling, so the budget bounds the growth and no code ceiling exists. */
|
|
508
|
+
export function reconnectwaits(attempts: number, base: number, ceiling: number | undefined): number[] {
|
|
509
|
+
const count = Math.max(0, Math.floor(attempts));
|
|
510
|
+
const waits: number[] = [];
|
|
511
|
+
let wait = Math.max(0, base);
|
|
512
|
+
for (let index = 0; index < count; index += 1) {
|
|
513
|
+
waits.push(wait);
|
|
514
|
+
const next = wait * 2;
|
|
515
|
+
wait = ceiling !== undefined && Number.isFinite(ceiling) && ceiling >= 0 ? Math.min(next, ceiling) : next;
|
|
516
|
+
}
|
|
517
|
+
return waits;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Opens one reviewed channel through the connect seam with the reviewed reconnect budget and backoff: failed attempts wait the exponential backoff before the retry, the channel opens on the first success and a budget exhausted without a connection fails the channel with the last error class.
|
|
522
|
+
*/
|
|
523
|
+
export async function openchannel(input: {
|
|
524
|
+
record: channelrecord;
|
|
525
|
+
options: socketoptions;
|
|
526
|
+
connect: socketconnect;
|
|
527
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
528
|
+
now?: () => number;
|
|
529
|
+
}): Promise<channelrecord> {
|
|
530
|
+
const sleep =
|
|
531
|
+
input.sleep ??
|
|
532
|
+
((milliseconds: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, Math.max(0, milliseconds))));
|
|
533
|
+
const now = input.now ?? Date.now;
|
|
534
|
+
const attempts = Math.max(1, Math.floor(input.options.reconnect ?? 0) + 1);
|
|
535
|
+
const waits = reconnectwaits(attempts - 1, input.options.backoff ?? 0, input.options.backoffceiling);
|
|
536
|
+
let record: channelrecord = { ...input.record, state: "connecting" };
|
|
537
|
+
let lasterror = "";
|
|
538
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
539
|
+
try {
|
|
540
|
+
const result = await input.connect(record.url, record.protocols ?? []);
|
|
541
|
+
if (result.open) return { ...record, state: "open", openedat: now() };
|
|
542
|
+
lasterror = result.error ?? `closed with code ${result.code ?? 0}`;
|
|
543
|
+
} catch (error) {
|
|
544
|
+
lasterror = error instanceof Error ? error.message : String(error);
|
|
545
|
+
}
|
|
546
|
+
if (attempt < attempts - 1) {
|
|
547
|
+
const wait = waits[attempt] ?? 0;
|
|
548
|
+
if (wait > 0) await sleep(wait);
|
|
549
|
+
record = { ...record, reconnects: record.reconnects + 1 };
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return { ...record, state: "failed", error: lasterror };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Closes one channel record cleanly with its close time and an optional error class, keeping every counter for the audit trail. */
|
|
556
|
+
export function closechannel(record: channelrecord, at: number, error?: string): channelrecord {
|
|
557
|
+
const state: channelstate = error !== undefined ? "failed" : "closed";
|
|
558
|
+
return { ...record, state, closedat: at, ...(error !== undefined ? { error } : {}) };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Live multiplexing state of one run: the per channel sequence counters and the inbound message queue. */
|
|
562
|
+
export interface busstate {
|
|
563
|
+
sequences: Record<string, number>;
|
|
564
|
+
queue: messageenvelope[];
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Tags one message with the channel scoped sequence number: every stream multiplexed over the channel shares one strictly increasing sequence. */
|
|
568
|
+
function tagmessage(
|
|
569
|
+
state: busstate,
|
|
570
|
+
channelid: string,
|
|
571
|
+
stream: string,
|
|
572
|
+
payload: string,
|
|
573
|
+
at: number,
|
|
574
|
+
): { state: busstate; envelope: messageenvelope } {
|
|
575
|
+
const sequence = (state.sequences[channelid] ?? 0) + 1;
|
|
576
|
+
const envelope: messageenvelope = { channelid, stream, payload, sequence, at };
|
|
577
|
+
return { state: { sequences: { ...state.sequences, [channelid]: sequence }, queue: state.queue }, envelope };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Publishes one reviewed payload on a named stream of an open channel and returns the tagged envelope. */
|
|
581
|
+
export function publishmessage(
|
|
582
|
+
state: busstate,
|
|
583
|
+
channelid: string,
|
|
584
|
+
stream: string,
|
|
585
|
+
payload: string,
|
|
586
|
+
at: number,
|
|
587
|
+
): { state: busstate; envelope: messageenvelope } {
|
|
588
|
+
return tagmessage(state, channelid, stream, payload, at);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Receives one inbound channel message on a named stream, tags it with the next channel sequence and queues it for the waitmessage matchers. */
|
|
592
|
+
export function receivemessage(
|
|
593
|
+
state: busstate,
|
|
594
|
+
channelid: string,
|
|
595
|
+
stream: string,
|
|
596
|
+
payload: string,
|
|
597
|
+
at: number,
|
|
598
|
+
): { state: busstate; envelope: messageenvelope } {
|
|
599
|
+
const tagged = tagmessage(state, channelid, stream, payload, at);
|
|
600
|
+
return { state: { ...tagged.state, queue: [...state.queue, tagged.envelope] }, envelope: tagged.envelope };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Resolves one dotted path segment against an object or an array index. */
|
|
604
|
+
function pathstep(current: unknown, segment: string): unknown {
|
|
605
|
+
if (Array.isArray(current) && /^\d+$/.test(segment)) return current[Number.parseInt(segment, 10)];
|
|
606
|
+
if (current && typeof current === "object" && !Array.isArray(current))
|
|
607
|
+
return (current as Record<string, unknown>)[segment];
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** True when one message envelope matches the reviewed filter: the stream name when reviewed, the dotted json path when reviewed and always the sequence integrity of the channel. */
|
|
612
|
+
export function matchmessage(filter: messagefilter | undefined, envelope: messageenvelope): boolean {
|
|
613
|
+
if (!filter) return true;
|
|
614
|
+
if (filter.stream !== undefined && filter.stream !== envelope.stream) return false;
|
|
615
|
+
if (filter.path !== undefined) {
|
|
616
|
+
try {
|
|
617
|
+
const parsed = JSON.parse(envelope.payload) as unknown;
|
|
618
|
+
let current: unknown = parsed;
|
|
619
|
+
let missing = false;
|
|
620
|
+
for (const segment of filter.path.split(".")) {
|
|
621
|
+
const next = pathstep(current, segment);
|
|
622
|
+
if (next === undefined) {
|
|
623
|
+
missing = true;
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
current = next;
|
|
627
|
+
}
|
|
628
|
+
if (missing) return false;
|
|
629
|
+
} catch {
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Collects the queued messages of one channel matching the reviewed filter up to the reviewed match limit, removing the matched envelopes from the queue. */
|
|
637
|
+
export function collectmessages(
|
|
638
|
+
state: busstate,
|
|
639
|
+
channelid: string,
|
|
640
|
+
filter: messagefilter | undefined,
|
|
641
|
+
): { state: busstate; matched: messageenvelope[] } {
|
|
642
|
+
const limit =
|
|
643
|
+
filter?.limit !== undefined && Number.isFinite(filter.limit) && filter.limit >= 1
|
|
644
|
+
? Math.floor(filter.limit)
|
|
645
|
+
: Number.POSITIVE_INFINITY;
|
|
646
|
+
const matched: messageenvelope[] = [];
|
|
647
|
+
const queue: messageenvelope[] = [];
|
|
648
|
+
for (const envelope of state.queue) {
|
|
649
|
+
if (envelope.channelid === channelid && matched.length < limit && matchmessage(filter, envelope))
|
|
650
|
+
matched.push(envelope);
|
|
651
|
+
else queue.push(envelope);
|
|
652
|
+
}
|
|
653
|
+
return { state: { sequences: state.sequences, queue }, matched };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Verifies the sequence integrity of a message stream: per channel the sequences must form one strictly increasing run from one with no gaps. */
|
|
657
|
+
export function sequenceintegrity(envelopes: messageenvelope[]): {
|
|
658
|
+
ok: boolean;
|
|
659
|
+
gaps: Array<{ channelid: string; expected: number; found: number }>;
|
|
660
|
+
} {
|
|
661
|
+
const last = new Map<string, number>();
|
|
662
|
+
const gaps: Array<{ channelid: string; expected: number; found: number }> = [];
|
|
663
|
+
for (const envelope of envelopes) {
|
|
664
|
+
const expected = (last.get(envelope.channelid) ?? 0) + 1;
|
|
665
|
+
if (envelope.sequence !== expected)
|
|
666
|
+
gaps.push({ channelid: envelope.channelid, expected, found: envelope.sequence });
|
|
667
|
+
last.set(envelope.channelid, Math.max(envelope.sequence, expected));
|
|
668
|
+
}
|
|
669
|
+
return { ok: gaps.length === 0, gaps };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Normalizes a reviewed message filter: stream name, dotted json path and the reviewed match limit with no code ceiling. */
|
|
673
|
+
export function messagefilterof(value: unknown): messagefilter {
|
|
674
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
675
|
+
const entry = value as Record<string, unknown>;
|
|
676
|
+
const filter: messagefilter = {};
|
|
677
|
+
if (typeof entry.stream === "string" && entry.stream.trim()) filter.stream = entry.stream.trim();
|
|
678
|
+
if (typeof entry.path === "string" && entry.path.trim()) filter.path = entry.path.trim();
|
|
679
|
+
if (typeof entry.limit === "number" && Number.isFinite(entry.limit) && entry.limit >= 1)
|
|
680
|
+
filter.limit = Math.floor(entry.limit);
|
|
681
|
+
return filter;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** One parsed server sent event: id, event name, joined data lines and the reviewed retry hint. */
|
|
685
|
+
export interface sseevent {
|
|
686
|
+
id?: string;
|
|
687
|
+
event?: string;
|
|
688
|
+
data: string;
|
|
689
|
+
retry?: number;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Parses one server sent events text chunk into complete events with id, event name, data and retry fields, returning the incomplete trailing block as the rest buffer; comment lines stay ignored. */
|
|
693
|
+
export function parsessetext(text: string): { events: sseevent[]; rest: string } {
|
|
694
|
+
const separator = text.lastIndexOf("\n\n");
|
|
695
|
+
const complete = separator === -1 ? "" : text.slice(0, separator + 2);
|
|
696
|
+
const rest = separator === -1 ? text : text.slice(separator + 2);
|
|
697
|
+
const events: sseevent[] = [];
|
|
698
|
+
for (const block of complete.split(/\n\n/)) {
|
|
699
|
+
const id: string[] = [];
|
|
700
|
+
const names: string[] = [];
|
|
701
|
+
const data: string[] = [];
|
|
702
|
+
let retry: number | undefined;
|
|
703
|
+
for (const line of block.split("\n")) {
|
|
704
|
+
if (line === "" || line.startsWith(":")) continue;
|
|
705
|
+
const colon = line.indexOf(":");
|
|
706
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
707
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
708
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
709
|
+
if (field === "id" && value !== "") id.push(value);
|
|
710
|
+
if (field === "event" && value !== "") names.push(value);
|
|
711
|
+
if (field === "data") data.push(value);
|
|
712
|
+
if (field === "retry" && /^\d+$/.test(value)) retry = Number.parseInt(value, 10);
|
|
713
|
+
}
|
|
714
|
+
if (id.length === 0 && names.length === 0 && data.length === 0) continue;
|
|
715
|
+
events.push({
|
|
716
|
+
...(id.length > 0 ? { id: id[id.length - 1] } : {}),
|
|
717
|
+
...(names.length > 0 ? { event: names[names.length - 1] } : {}),
|
|
718
|
+
data: data.join("\n"),
|
|
719
|
+
...(retry !== undefined ? { retry } : {}),
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
return { events, rest };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Builds the request headers of one server sent events subscription, resuming from the last event id after a reconnect. */
|
|
726
|
+
export function sserequestheaders(record: { lasteventid?: string }): Record<string, string> {
|
|
727
|
+
return {
|
|
728
|
+
accept: "text/event-stream",
|
|
729
|
+
...(record.lasteventid !== undefined && record.lasteventid !== "" ? { "last-event-id": record.lasteventid } : {}),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Normalizes reviewed subscription options: the event stream url, the lifetime window, the resume point and the reviewed cancellation path. */
|
|
734
|
+
export function subscriptionoptionsof(
|
|
735
|
+
value: unknown,
|
|
736
|
+
):
|
|
737
|
+
| {
|
|
738
|
+
url: string;
|
|
739
|
+
lifetime?: number;
|
|
740
|
+
lasteventid?: string;
|
|
741
|
+
cancel: { kind: "stop" | "lifetime"; value: string | number };
|
|
742
|
+
}
|
|
743
|
+
| undefined {
|
|
744
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
745
|
+
const entry = value as Record<string, unknown>;
|
|
746
|
+
if (typeof entry.url !== "string" || !entry.url.trim()) return undefined;
|
|
747
|
+
const cancel = entry.cancel;
|
|
748
|
+
if (!cancel || typeof cancel !== "object" || Array.isArray(cancel)) return undefined;
|
|
749
|
+
const cancelrecord = cancel as Record<string, unknown>;
|
|
750
|
+
if (cancelrecord.kind !== "stop" && cancelrecord.kind !== "lifetime") return undefined;
|
|
751
|
+
if (typeof cancelrecord.value !== "string" && typeof cancelrecord.value !== "number") return undefined;
|
|
752
|
+
const result: {
|
|
753
|
+
url: string;
|
|
754
|
+
lifetime?: number;
|
|
755
|
+
lasteventid?: string;
|
|
756
|
+
cancel: { kind: "stop" | "lifetime"; value: string | number };
|
|
757
|
+
} = { url: entry.url.trim(), cancel: { kind: cancelrecord.kind, value: cancelrecord.value } };
|
|
758
|
+
if (typeof entry.lifetime === "number" && Number.isFinite(entry.lifetime) && entry.lifetime > 0)
|
|
759
|
+
result.lifetime = entry.lifetime;
|
|
760
|
+
if (typeof entry.lasteventid === "string" && entry.lasteventid.trim()) result.lasteventid = entry.lasteventid.trim();
|
|
761
|
+
return result;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Normalizes a reviewed long poll cursor: url, cursor field, interval, stop condition, optional poll ceiling and the cursor query parameter; every bound stays a user choice with no code ceiling. */
|
|
765
|
+
export function pollcursorof(value: unknown): pollcursor | undefined {
|
|
766
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
767
|
+
const entry = value as Record<string, unknown>;
|
|
768
|
+
if (typeof entry.url !== "string" || !entry.url.trim()) return undefined;
|
|
769
|
+
if (typeof entry.cursorfield !== "string" || !entry.cursorfield.trim()) return undefined;
|
|
770
|
+
if (typeof entry.interval !== "number" || !Number.isFinite(entry.interval) || entry.interval <= 0) return undefined;
|
|
771
|
+
const stop = entry.stop;
|
|
772
|
+
if (!stop || typeof stop !== "object" || Array.isArray(stop)) return undefined;
|
|
773
|
+
const stoprecord = stop as Record<string, unknown>;
|
|
774
|
+
if (typeof stoprecord.field !== "string" || !stoprecord.field.trim()) return undefined;
|
|
775
|
+
if (typeof stoprecord.equals !== "string") return undefined;
|
|
776
|
+
const cursor: pollcursor = {
|
|
777
|
+
url: entry.url.trim(),
|
|
778
|
+
cursorfield: entry.cursorfield.trim(),
|
|
779
|
+
interval: entry.interval,
|
|
780
|
+
stop: { field: stoprecord.field.trim(), equals: stoprecord.equals },
|
|
781
|
+
};
|
|
782
|
+
if (typeof entry.maxpolls === "number" && Number.isFinite(entry.maxpolls) && entry.maxpolls >= 1)
|
|
783
|
+
cursor.maxpolls = Math.floor(entry.maxpolls);
|
|
784
|
+
if (typeof entry.param === "string" && entry.param.trim()) cursor.param = entry.param.trim();
|
|
785
|
+
return cursor;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Reads one cursor value from a polled response body by its dotted field path. */
|
|
789
|
+
export function cursorfrom(response: unknown, field: string): string | undefined {
|
|
790
|
+
let current: unknown = response;
|
|
791
|
+
for (const segment of field.split(".")) {
|
|
792
|
+
const next = pathstep(current, segment);
|
|
793
|
+
if (next === undefined) return undefined;
|
|
794
|
+
current = next;
|
|
795
|
+
}
|
|
796
|
+
return current === undefined || current === null ? undefined : String(current);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** Builds the next poll url of a cursor loop: the cursor rides the reviewed query parameter when one is set and posts inside the request body otherwise. */
|
|
800
|
+
export function pollurl(cursor: pollcursor, value: string | undefined): { url: string; body?: string } {
|
|
801
|
+
if (cursor.param === undefined || value === undefined) {
|
|
802
|
+
return {
|
|
803
|
+
url: cursor.url,
|
|
804
|
+
...(value !== undefined ? { body: JSON.stringify({ [cursor.cursorfield]: value }) } : {}),
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
const url = new URL(cursor.url);
|
|
808
|
+
url.searchParams.set(cursor.param, value);
|
|
809
|
+
return { url: url.toString() };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Decides one long poll iteration: the loop stops on the reviewed stop condition, the cancellation flag, the plan expiry or the reviewed poll ceiling, and otherwise returns the next poll url with the reviewed interval wait.
|
|
814
|
+
*/
|
|
815
|
+
export function polldecision(input: {
|
|
816
|
+
cursor: pollcursor;
|
|
817
|
+
polls: number;
|
|
818
|
+
response: unknown;
|
|
819
|
+
cancelled?: () => boolean;
|
|
820
|
+
expiresat?: number;
|
|
821
|
+
now: number;
|
|
822
|
+
}): { continue: boolean; reason: string; cursor?: string; next?: { url: string; body?: string; wait: number } } {
|
|
823
|
+
if (input.cancelled?.() === true) return { continue: false, reason: "The long poll loop was cancelled." };
|
|
824
|
+
if (input.expiresat !== undefined && input.now >= input.expiresat)
|
|
825
|
+
return { continue: false, reason: "The long poll loop stopped at the reviewed plan expiry." };
|
|
826
|
+
const stopvalue = cursorfrom(input.response, input.cursor.stop.field);
|
|
827
|
+
if (stopvalue !== undefined && stopvalue === input.cursor.stop.equals)
|
|
828
|
+
return { continue: false, reason: `The stop condition matched ${input.cursor.stop.field} ${stopvalue}.` };
|
|
829
|
+
if (input.cursor.maxpolls !== undefined && input.polls + 1 >= input.cursor.maxpolls)
|
|
830
|
+
return {
|
|
831
|
+
continue: false,
|
|
832
|
+
reason: `The long poll loop reached the reviewed poll ceiling of ${input.cursor.maxpolls}.`,
|
|
833
|
+
};
|
|
834
|
+
const value = cursorfrom(input.response, input.cursor.cursorfield);
|
|
835
|
+
const next = pollurl(input.cursor, value);
|
|
836
|
+
return {
|
|
837
|
+
continue: true,
|
|
838
|
+
reason: "The long poll loop continues.",
|
|
839
|
+
...(value !== undefined ? { cursor: value } : {}),
|
|
840
|
+
next: { ...next, wait: input.cursor.interval },
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/* ── Merged from netcontrol.ts ── */
|
|
845
|
+
|
|
846
|
+
/** Correlated network control logics of the 1.1.44 family: request blocking, response mocking, header rewriting, cookie scoping, proxy routing and the rate limiter, all run scoped and reverted the moment a run ends, fails or is cancelled. */
|
|
847
|
+
import type { blockrule, cookierecord, headerule, mockspec, proxyroute, ratelimitread } from "./types.js";
|
|
848
|
+
|
|
849
|
+
/** Every reviewed network control kind of the 1.1.44 family. */
|
|
850
|
+
export const controlkinds: string[] = [
|
|
851
|
+
"blockrequest",
|
|
852
|
+
"mockresponse",
|
|
853
|
+
"rewriteheaders",
|
|
854
|
+
"setcookies",
|
|
855
|
+
"readcookies",
|
|
856
|
+
"clearcookies",
|
|
857
|
+
"authflow",
|
|
858
|
+
"saveapikey",
|
|
859
|
+
"routeproxy",
|
|
860
|
+
"postform",
|
|
861
|
+
"postfiles",
|
|
862
|
+
];
|
|
863
|
+
|
|
864
|
+
/** Resolves the origin a reviewed url pattern names; a pattern without an https origin prefix names no origin and is refused. */
|
|
865
|
+
export function patternorigin(pattern: string): string | undefined {
|
|
866
|
+
const trimmed = pattern.trim();
|
|
867
|
+
if (!trimmed.startsWith("https://")) return undefined;
|
|
868
|
+
const rest = trimmed.slice("https://".length);
|
|
869
|
+
const host = rest.split("/")[0] ?? "";
|
|
870
|
+
if (!host.trim()) return undefined;
|
|
871
|
+
return `https://${host.toLowerCase()}`;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** Matches a reviewed url pattern against one request url: the pattern must name its https origin, then an optional path pattern where a single star matches within one segment and a double star matches across segments. */
|
|
875
|
+
export function matchurlpattern(pattern: string, url: string): boolean {
|
|
876
|
+
const origin = patternorigin(pattern);
|
|
877
|
+
if (!origin) return false;
|
|
878
|
+
let parsed: URL;
|
|
879
|
+
try {
|
|
880
|
+
parsed = new URL(url);
|
|
881
|
+
} catch {
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
if (parsed.origin !== origin) return false;
|
|
885
|
+
const patternpath = pattern.trim().slice(origin.length);
|
|
886
|
+
if (patternpath === "" || patternpath === "/") return true;
|
|
887
|
+
const segments = patternpath.split("/").filter((segment) => segment !== "");
|
|
888
|
+
if (segments.includes("**")) return true;
|
|
889
|
+
const pathsegments = parsed.pathname.split("/").filter((segment) => segment !== "");
|
|
890
|
+
if (segments.length !== pathsegments.length) return false;
|
|
891
|
+
return segments.every(
|
|
892
|
+
(segment, index) =>
|
|
893
|
+
segment === pathsegments[index] ||
|
|
894
|
+
(segment.includes("*") &&
|
|
895
|
+
new RegExp(
|
|
896
|
+
`^${segment
|
|
897
|
+
.split("*")
|
|
898
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
899
|
+
.join(".*")}$`,
|
|
900
|
+
).test(pathsegments[index] ?? "")),
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** Parses one reviewed block rule from step options: the url pattern and an optional resource type list. */
|
|
905
|
+
export function blockruleof(value: unknown): { urlpattern: string; resourcetypes?: string[] } | undefined {
|
|
906
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
907
|
+
const options = value as Record<string, unknown>;
|
|
908
|
+
if (typeof options.urlpattern !== "string" || !options.urlpattern.trim()) return undefined;
|
|
909
|
+
const rule: { urlpattern: string; resourcetypes?: string[] } = { urlpattern: options.urlpattern.trim() };
|
|
910
|
+
if (Array.isArray(options.resourcetypes)) {
|
|
911
|
+
const types = options.resourcetypes.filter(
|
|
912
|
+
(item): item is string => typeof item === "string" && item.trim().length > 0,
|
|
913
|
+
);
|
|
914
|
+
if (types.length === 0) return undefined;
|
|
915
|
+
rule.resourcetypes = types;
|
|
916
|
+
}
|
|
917
|
+
return rule;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** Creates one registered block rule of a run with its hit counter at zero; every rule removes itself at run end. */
|
|
921
|
+
export function newblockrule(input: {
|
|
922
|
+
id: string;
|
|
923
|
+
runid: string;
|
|
924
|
+
stepid: string;
|
|
925
|
+
urlpattern: string;
|
|
926
|
+
resourcetypes?: string[];
|
|
927
|
+
at: number;
|
|
928
|
+
}): blockrule {
|
|
929
|
+
return {
|
|
930
|
+
id: input.id,
|
|
931
|
+
runid: input.runid,
|
|
932
|
+
stepid: input.stepid,
|
|
933
|
+
urlpattern: input.urlpattern,
|
|
934
|
+
...(input.resourcetypes !== undefined ? { resourcetypes: input.resourcetypes } : {}),
|
|
935
|
+
hits: 0,
|
|
936
|
+
registeredat: input.at,
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** Parses one reviewed mock fixture from step options: url pattern, status, headers and either the reviewed body or the reference of a captured body of the 1.1.43 body store the fixture replays. */
|
|
941
|
+
export function mockspecof(
|
|
942
|
+
value: unknown,
|
|
943
|
+
):
|
|
944
|
+
| {
|
|
945
|
+
urlpattern: string;
|
|
946
|
+
status: number;
|
|
947
|
+
headers?: Record<string, string>;
|
|
948
|
+
body?: string;
|
|
949
|
+
bodyref?: string;
|
|
950
|
+
reviewed?: boolean;
|
|
951
|
+
}
|
|
952
|
+
| undefined {
|
|
953
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
954
|
+
const options = value as Record<string, unknown>;
|
|
955
|
+
if (typeof options.urlpattern !== "string" || !options.urlpattern.trim()) return undefined;
|
|
956
|
+
if (
|
|
957
|
+
typeof options.status !== "number" ||
|
|
958
|
+
!Number.isInteger(options.status) ||
|
|
959
|
+
options.status < 100 ||
|
|
960
|
+
options.status > 599
|
|
961
|
+
)
|
|
962
|
+
return undefined;
|
|
963
|
+
const hasbody = typeof options.body === "string";
|
|
964
|
+
const bodyref = typeof options.bodyref === "string" ? options.bodyref.trim() : "";
|
|
965
|
+
if (!hasbody && bodyref === "") return undefined;
|
|
966
|
+
const spec: {
|
|
967
|
+
urlpattern: string;
|
|
968
|
+
status: number;
|
|
969
|
+
headers?: Record<string, string>;
|
|
970
|
+
body?: string;
|
|
971
|
+
bodyref?: string;
|
|
972
|
+
reviewed?: boolean;
|
|
973
|
+
} = { urlpattern: options.urlpattern.trim(), status: options.status };
|
|
974
|
+
if (hasbody) spec.body = options.body as string;
|
|
975
|
+
if (bodyref !== "") spec.bodyref = bodyref;
|
|
976
|
+
if (options.headers && typeof options.headers === "object" && !Array.isArray(options.headers))
|
|
977
|
+
spec.headers = options.headers as Record<string, string>;
|
|
978
|
+
if (options.reviewed === true) spec.reviewed = true;
|
|
979
|
+
return spec;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** Creates one registered mock fixture of a run; the fixture serves matched requests and clears at run end. */
|
|
983
|
+
export function newmockspec(input: {
|
|
984
|
+
id: string;
|
|
985
|
+
runid: string;
|
|
986
|
+
stepid: string;
|
|
987
|
+
urlpattern: string;
|
|
988
|
+
status: number;
|
|
989
|
+
headers?: Record<string, string>;
|
|
990
|
+
body?: string;
|
|
991
|
+
bodyref?: string;
|
|
992
|
+
reviewed: boolean;
|
|
993
|
+
at: number;
|
|
994
|
+
}): mockspec {
|
|
995
|
+
return {
|
|
996
|
+
id: input.id,
|
|
997
|
+
runid: input.runid,
|
|
998
|
+
stepid: input.stepid,
|
|
999
|
+
urlpattern: input.urlpattern,
|
|
1000
|
+
status: input.status,
|
|
1001
|
+
...(input.headers !== undefined ? { headers: input.headers } : {}),
|
|
1002
|
+
...(input.body !== undefined ? { body: input.body } : {}),
|
|
1003
|
+
...(input.bodyref !== undefined ? { bodyref: input.bodyref } : {}),
|
|
1004
|
+
reviewed: input.reviewed,
|
|
1005
|
+
hits: 0,
|
|
1006
|
+
registeredat: input.at,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** Returns the first active mock fixture that matches the url; matched fixtures count their hits for the step result. */
|
|
1011
|
+
export function mockfor(url: string, specs: mockspec[]): mockspec | undefined {
|
|
1012
|
+
return specs.find((spec) => spec.revertedat === undefined && matchurlpattern(spec.urlpattern, url));
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** Parses one reviewed header rewrite rule from step options: url pattern, header name, operation and value. */
|
|
1016
|
+
export function headeruleof(
|
|
1017
|
+
value: unknown,
|
|
1018
|
+
): { urlpattern: string; name: string; operation: "set" | "append" | "remove"; value?: string } | undefined {
|
|
1019
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
1020
|
+
const options = value as Record<string, unknown>;
|
|
1021
|
+
if (typeof options.urlpattern !== "string" || !options.urlpattern.trim()) return undefined;
|
|
1022
|
+
if (typeof options.name !== "string" || !options.name.trim()) return undefined;
|
|
1023
|
+
if (options.operation !== "set" && options.operation !== "append" && options.operation !== "remove") return undefined;
|
|
1024
|
+
if (options.operation === "remove" && options.value !== undefined) return undefined;
|
|
1025
|
+
if (options.operation !== "remove" && typeof options.value !== "string") return undefined;
|
|
1026
|
+
const rule: { urlpattern: string; name: string; operation: "set" | "append" | "remove"; value?: string } = {
|
|
1027
|
+
urlpattern: options.urlpattern.trim(),
|
|
1028
|
+
name: options.name.trim(),
|
|
1029
|
+
operation: options.operation,
|
|
1030
|
+
};
|
|
1031
|
+
if (options.operation !== "remove") rule.value = typeof options.value === "string" ? options.value : "";
|
|
1032
|
+
return rule;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** Creates one registered header rewrite rule of a run; the rule reverts at run end. */
|
|
1036
|
+
export function newheaderule(input: {
|
|
1037
|
+
id: string;
|
|
1038
|
+
runid: string;
|
|
1039
|
+
stepid: string;
|
|
1040
|
+
urlpattern: string;
|
|
1041
|
+
name: string;
|
|
1042
|
+
operation: "set" | "append" | "remove";
|
|
1043
|
+
value?: string;
|
|
1044
|
+
at: number;
|
|
1045
|
+
}): headerule {
|
|
1046
|
+
return {
|
|
1047
|
+
id: input.id,
|
|
1048
|
+
runid: input.runid,
|
|
1049
|
+
stepid: input.stepid,
|
|
1050
|
+
urlpattern: input.urlpattern,
|
|
1051
|
+
name: input.name,
|
|
1052
|
+
operation: input.operation,
|
|
1053
|
+
...(input.value !== undefined ? { value: input.value } : {}),
|
|
1054
|
+
hits: 0,
|
|
1055
|
+
registeredat: input.at,
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** Applies the active header rewrite rules of one outgoing url: set overwrites, append joins with a comma and remove drops the header, returning the rewritten headers with the provenance of every applied rule for the audit trail. */
|
|
1060
|
+
export function applyheaderules(
|
|
1061
|
+
url: string,
|
|
1062
|
+
headers: Record<string, string>,
|
|
1063
|
+
rules: headerule[],
|
|
1064
|
+
): { headers: Record<string, string>; applied: headerule[] } {
|
|
1065
|
+
const rewritten: Record<string, string> = { ...headers };
|
|
1066
|
+
const applied: headerule[] = [];
|
|
1067
|
+
for (const rule of rules) {
|
|
1068
|
+
if (rule.revertedat !== undefined) continue;
|
|
1069
|
+
if (!matchurlpattern(rule.urlpattern, url)) continue;
|
|
1070
|
+
const name = rule.name;
|
|
1071
|
+
if (rule.operation === "remove") {
|
|
1072
|
+
delete rewritten[name];
|
|
1073
|
+
applied.push(rule);
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
const value = rule.value ?? "";
|
|
1077
|
+
if (rule.operation === "set") rewritten[name] = value;
|
|
1078
|
+
else rewritten[name] = rewritten[name] !== undefined ? `${rewritten[name]}, ${value}` : value;
|
|
1079
|
+
applied.push(rule);
|
|
1080
|
+
}
|
|
1081
|
+
return { headers: rewritten, applied };
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/** Marks one registered rule as reverted at the given time; reversion is idempotent and never touches the hit counter. */
|
|
1085
|
+
export function revertrule<T extends blockrule | mockspec | headerule | proxyroute>(rule: T, at: number): T {
|
|
1086
|
+
if (rule.revertedat !== undefined) return rule;
|
|
1087
|
+
return { ...rule, revertedat: at };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** Parses one reviewed cookie record from step options: name, domain, path, value and optional expiry. */
|
|
1091
|
+
export function cookierecordof(value: unknown): cookierecord | undefined {
|
|
1092
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
1093
|
+
const options = value as Record<string, unknown>;
|
|
1094
|
+
if (typeof options.name !== "string" || !options.name.trim()) return undefined;
|
|
1095
|
+
if (typeof options.domain !== "string" || !options.domain.trim()) return undefined;
|
|
1096
|
+
if (typeof options.path !== "string" || !options.path.trim()) return undefined;
|
|
1097
|
+
if (typeof options.value !== "string") return undefined;
|
|
1098
|
+
const record: cookierecord = {
|
|
1099
|
+
name: options.name.trim(),
|
|
1100
|
+
domain: options.domain.trim().toLowerCase(),
|
|
1101
|
+
path: options.path.trim(),
|
|
1102
|
+
value: options.value,
|
|
1103
|
+
};
|
|
1104
|
+
if (typeof options.expiresat === "number" && Number.isFinite(options.expiresat)) record.expiresat = options.expiresat;
|
|
1105
|
+
return record;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** True when the reviewed cookie domain stays inside the granted hosts: the domain equals a granted origin host or sits beneath it as a subdomain; every other domain is refused. */
|
|
1109
|
+
export function cookiedomaingranted(domain: string, grants: string[]): boolean {
|
|
1110
|
+
const host = domain.trim().toLowerCase().replace(/^\./, "");
|
|
1111
|
+
return grants.some((grant) => {
|
|
1112
|
+
let granthost = "";
|
|
1113
|
+
try {
|
|
1114
|
+
granthost = new URL(grant).hostname.toLowerCase();
|
|
1115
|
+
} catch {
|
|
1116
|
+
return false;
|
|
1117
|
+
}
|
|
1118
|
+
return host === granthost || host.endsWith(`.${granthost}`);
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/** Redacts the values of reviewed cookies for the audit trail: only names, domains, paths and expiry windows survive. */
|
|
1123
|
+
export function redactedcookies(
|
|
1124
|
+
records: cookierecord[],
|
|
1125
|
+
): Array<{ name: string; domain: string; path: string; expiresat?: number }> {
|
|
1126
|
+
return records.map((record) => ({
|
|
1127
|
+
name: record.name,
|
|
1128
|
+
domain: record.domain,
|
|
1129
|
+
path: record.path,
|
|
1130
|
+
...(record.expiresat !== undefined ? { expiresat: record.expiresat } : {}),
|
|
1131
|
+
}));
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/** Parses one reviewed proxy route from step options: scheme, host, port and the required bypass list of origins that stay direct. */
|
|
1135
|
+
export function proxyrouteof(
|
|
1136
|
+
value: unknown,
|
|
1137
|
+
): { scheme: "http" | "https" | "socks4" | "socks5"; host: string; port: number; bypass: string[] } | undefined {
|
|
1138
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
1139
|
+
const options = value as Record<string, unknown>;
|
|
1140
|
+
if (
|
|
1141
|
+
options.scheme !== "http" &&
|
|
1142
|
+
options.scheme !== "https" &&
|
|
1143
|
+
options.scheme !== "socks4" &&
|
|
1144
|
+
options.scheme !== "socks5"
|
|
1145
|
+
)
|
|
1146
|
+
return undefined;
|
|
1147
|
+
if (typeof options.host !== "string" || !options.host.trim()) return undefined;
|
|
1148
|
+
if (typeof options.port !== "number" || !Number.isInteger(options.port) || options.port < 1 || options.port > 65535)
|
|
1149
|
+
return undefined;
|
|
1150
|
+
if (
|
|
1151
|
+
!Array.isArray(options.bypass) ||
|
|
1152
|
+
options.bypass.length === 0 ||
|
|
1153
|
+
!options.bypass.every((item): item is string => typeof item === "string" && item.trim().length > 0)
|
|
1154
|
+
)
|
|
1155
|
+
return undefined;
|
|
1156
|
+
return {
|
|
1157
|
+
scheme: options.scheme,
|
|
1158
|
+
host: options.host.trim(),
|
|
1159
|
+
port: options.port,
|
|
1160
|
+
bypass: options.bypass.map((item) => item.trim()),
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** Parses one rate limit read from response headers: the remaining and limit counts plus the reset time; a reset value beyond the read time counts as epoch seconds and a smaller value as seconds remaining. */
|
|
1165
|
+
export function ratelimitreadof(
|
|
1166
|
+
headers: Record<string, string>,
|
|
1167
|
+
origin: string,
|
|
1168
|
+
now: number,
|
|
1169
|
+
): ratelimitread | undefined {
|
|
1170
|
+
const pick = (name: string): number | undefined => {
|
|
1171
|
+
for (const key of Object.keys(headers)) {
|
|
1172
|
+
if (key.toLowerCase() !== name) continue;
|
|
1173
|
+
const value = Number(headers[key]);
|
|
1174
|
+
return Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
1175
|
+
}
|
|
1176
|
+
return undefined;
|
|
1177
|
+
};
|
|
1178
|
+
const remaining = pick("x-ratelimit-remaining");
|
|
1179
|
+
const limit = pick("x-ratelimit-limit");
|
|
1180
|
+
const reset = pick("x-ratelimit-reset");
|
|
1181
|
+
if (remaining === undefined && limit === undefined && reset === undefined) return undefined;
|
|
1182
|
+
const read: ratelimitread = {
|
|
1183
|
+
origin,
|
|
1184
|
+
...(remaining !== undefined ? { remaining } : {}),
|
|
1185
|
+
...(limit !== undefined ? { limit } : {}),
|
|
1186
|
+
resetat: now,
|
|
1187
|
+
at: now,
|
|
1188
|
+
};
|
|
1189
|
+
if (reset !== undefined) read.resetat = reset > Math.floor(now / 1000) ? reset * 1000 : now + reset * 1000;
|
|
1190
|
+
return read;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/** Resolves the retry after wait of one 429 or 503 response in milliseconds from the reviewed Retry-After header; a seconds value counts from now and an HTTP date resolves to its absolute time. */
|
|
1194
|
+
export function retryafterof(status: number, headers: Record<string, string>): number | undefined {
|
|
1195
|
+
if (status !== 429 && status !== 503) return undefined;
|
|
1196
|
+
for (const key of Object.keys(headers)) {
|
|
1197
|
+
if (key.toLowerCase() !== "retry-after") continue;
|
|
1198
|
+
const raw = headers[key];
|
|
1199
|
+
if (raw === undefined) continue;
|
|
1200
|
+
const value = raw.trim();
|
|
1201
|
+
const seconds = Number(value);
|
|
1202
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
1203
|
+
const date = Date.parse(value);
|
|
1204
|
+
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
|
|
1205
|
+
return undefined;
|
|
1206
|
+
}
|
|
1207
|
+
return undefined;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/** Resolves the rate limit wait in milliseconds until the reset window passes; an absent or passed state waits nothing and the wait itself stays a user configured behavior with no code ceiling. */
|
|
1211
|
+
export function ratelimitwait(state: ratelimitread | undefined, now: number): number {
|
|
1212
|
+
if (!state) return 0;
|
|
1213
|
+
return Math.max(0, state.resetat - now);
|
|
1214
|
+
}
|