@7h3/protocol 0.4.0 → 0.5.1
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/README.md +6 -323
- package/agentAdapter.d.ts +26 -0
- package/auditLog.d.ts +54 -0
- package/capability.d.ts +67 -0
- package/cborCodec.d.ts +34 -0
- package/conformanceVectors.d.ts +20 -0
- package/encryption.d.ts +85 -0
- package/envelopeCbor.d.ts +33 -0
- package/frameworkAdapters.d.ts +72 -0
- package/gateway.d.ts +53 -0
- package/grpcBinding.d.ts +29 -0
- package/httpBinding.d.ts +50 -0
- package/index.d.ts +39 -0
- package/index.js +3662 -0
- package/keyInfra.d.ts +64 -0
- package/keyRegistry.d.ts +9 -0
- package/keyRotation.d.ts +20 -0
- package/mcpGateway.d.ts +37 -0
- package/mcpTransports.d.ts +62 -0
- package/mcpWrapper.d.ts +83 -0
- package/otel.d.ts +65 -0
- package/package.json +13 -79
- package/policyEnforcer.d.ts +50 -0
- package/policyTelemetryFeedback.d.ts +11 -0
- package/protocol.d.ts +66 -0
- package/protocolAgent.d.ts +58 -0
- package/protocolBinary.d.ts +8 -0
- package/protocolCapabilities.d.ts +24 -0
- package/protocolReplay.d.ts +35 -0
- package/protocolTransport.d.ts +73 -0
- package/queueBinding.d.ts +43 -0
- package/rateLimiter.d.ts +18 -0
- package/{src/redisClient.ts → redisClient.d.ts} +25 -75
- package/replayStores.d.ts +98 -0
- package/revocation.d.ts +71 -0
- package/routePolicy.d.ts +30 -0
- package/runtimePolicy.d.ts +24 -0
- package/runtimePolicyManager.d.ts +15 -0
- package/runtimePolicyPresets.d.ts +11 -0
- package/signedResponse.d.ts +21 -0
- package/stream.d.ts +59 -0
- package/telemetry.d.ts +79 -0
- package/webhookBinding.d.ts +30 -0
- package/wsBinding.d.ts +52 -0
- package/.dockerignore +0 -19
- package/.github/dependabot.yml +0 -32
- package/.github/workflows/ci.yml +0 -31
- package/.github/workflows/publish.yml +0 -59
- package/.github/workflows/scorecard.yml +0 -37
- package/7h3.example.yaml +0 -125
- package/CHANGELOG.md +0 -92
- package/CONTRIBUTING.md +0 -82
- package/Dockerfile +0 -73
- package/GOVERNANCE.md +0 -62
- package/SECURITY.md +0 -70
- package/bench-results/replay-cache-full-1777891033256.json +0 -10
- package/bench-results/replay-cache-full-1777896317488.json +0 -10
- package/bench-results/replay-cache-full-1777900993184.json +0 -10
- package/bench-results/replay-cache-full-1777901019285.json +0 -10
- package/bench-results/replay-cache-quick-1777870170126.json +0 -10
- package/bench-results/signature-profiles-quick-1775875160079.json +0 -85
- package/bench-results/signature-profiles-quick-1775983539716.json +0 -85
- package/bench-results/signature-profiles-quick-1776237913190.json +0 -85
- package/bench-results/wire-codecs-full-1777891019803.json +0 -93
- package/bench-results/wire-codecs-full-1777896260964.json +0 -93
- package/bench-results/wire-codecs-full-1777901004247.json +0 -93
- package/bench-results/wire-codecs-quick-1775972879056.json +0 -93
- package/bench-results/wire-codecs-quick-1775983541111.json +0 -93
- package/bench-results/wire-codecs-quick-1776237914299.json +0 -93
- package/bench-results/wire-codecs-quick-1777841285236.json +0 -93
- package/bench-results/wire-codecs-quick-1777841321772.json +0 -93
- package/bench-results/wire-codecs-quick-1777841330408.json +0 -93
- package/bench-results/wire-codecs-quick-1777852886082.json +0 -93
- package/bench-results/wire-codecs-quick-1777852988773.json +0 -93
- package/bench-results/wire-codecs-quick-1777870188095.json +0 -93
- package/bench-results/wire-codecs-quick-1777870263918.json +0 -93
- package/bench-results/wire-codecs-quick-1777870455034.json +0 -93
- package/bench-results/wire-codecs-quick-1778816163081.json +0 -93
- package/bench-results/wire-codecs-quick-1778843936130.json +0 -93
- package/bin/7h3.ts +0 -385
- package/conformance/7h3_v0_1.json +0 -77
- package/conformance/7h3_v0_1_binary.json +0 -20
- package/conformance/aip_v0_1_binary.json +0 -20
- package/docker-compose.yaml +0 -77
- package/docs/ADOPTION_PLAN.md +0 -120
- package/docs/AGENTS.md +0 -77
- package/docs/AIP_RFC_v0.1.md +0 -97
- package/docs/AI_DECISION_CARD.md +0 -122
- package/docs/AI_RUNTIME_POLICY.json +0 -126
- package/docs/AI_RUNTIME_POLICY.yaml +0 -110
- package/docs/BACKPRESSURE_TUNING.md +0 -65
- package/docs/BENCHMARK_CLAIM_MATRIX.md +0 -42
- package/docs/BENCHMARK_REPORT_TEMPLATE.md +0 -169
- package/docs/BINARY_CODEC_BENCH.md +0 -23
- package/docs/CLEAN_CLONE_RUNBOOK.md +0 -36
- package/docs/CLOCK_SKEW_POLICY.md +0 -30
- package/docs/DISTRIBUTED_REPLAY.md +0 -142
- package/docs/FUZZ_CAMPAIGN.md +0 -121
- package/docs/GATEWAY.md +0 -195
- package/docs/KEY_MANAGEMENT_POLICY.md +0 -53
- package/docs/KEY_REVOCATION.md +0 -69
- package/docs/MCP_WRAPPER.md +0 -159
- package/docs/MIGRATION_GUIDE.md +0 -40
- package/docs/OPERATORS.md +0 -184
- package/docs/PERF_REGRESSION_POLICY.md +0 -34
- package/docs/PROJECT_EXAMINATION_2026-05-31.md +0 -219
- package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +0 -135
- package/docs/RELEASE_GATE.md +0 -25
- package/docs/RELEASE_NOTES_v0.1.0.md +0 -54
- package/docs/SECURITY_REVIEW_2026-06-05.md +0 -165
- package/docs/TELEMETRY.md +0 -41
- package/docs/THREAT_MODEL.md +0 -89
- package/docs/VERSIONING_POLICY.md +0 -30
- package/docs/assets/banner.png +0 -0
- package/eslint.config.js +0 -15
- package/fuzz/ts/harness-decode.ts +0 -136
- package/fuzz/ts/harness-verify.ts +0 -121
- package/fuzz/ts/run.ts +0 -35
- package/mcp-server/README.md +0 -38
- package/mcp-server/package-lock.json +0 -1187
- package/mcp-server/package.json +0 -35
- package/mcp-server/src/index.ts +0 -236
- package/mcp-server/tsconfig.json +0 -14
- package/scripts/aip-framework-quickstart.ts +0 -110
- package/scripts/aip-mcp-gateway.ts +0 -38
- package/scripts/aip-mcp-wrap-demo.ts +0 -72
- package/scripts/aip-quickstart.ts +0 -60
- package/scripts/bench-diff.ts +0 -118
- package/scripts/bench-protocol-e2e.ts +0 -937
- package/scripts/bench-protocol-openloop.ts +0 -1397
- package/scripts/bench-replay-cache.ts +0 -76
- package/scripts/bench-signature-profiles.ts +0 -180
- package/scripts/bench-wire-codecs.ts +0 -161
- package/scripts/build-binary-conformance.ts +0 -36
- package/scripts/build-release-dashboard.ts +0 -175
- package/scripts/canary-rollout.ts +0 -38
- package/scripts/mcpGatewayCli.test.ts +0 -116
- package/scripts/prepare-aip-package.ts +0 -88
- package/scripts/regen-conformance-sigs.ts +0 -18
- package/scripts/release-gate.ts +0 -19
- package/scripts/validate-runtime-policy.ts +0 -18
- package/sdk/browser/index.test.ts +0 -162
- package/sdk/browser/index.ts +0 -257
- package/sdk/browser/package.json +0 -13
- package/sdk/go/go.mod +0 -3
- package/sdk/go/http.go +0 -135
- package/sdk/go/protocol.go +0 -324
- package/sdk/go/protocol_test.go +0 -334
- package/sdk/go/webhook.go +0 -136
- package/sdk/python/README.md +0 -18
- package/sdk/python/protocol_7h3/__init__.py +0 -46
- package/sdk/python/protocol_7h3/http.py +0 -212
- package/sdk/python/protocol_7h3/keys.py +0 -149
- package/sdk/python/protocol_7h3/protocol.py +0 -525
- package/sdk/python/protocol_7h3/queue.py +0 -118
- package/sdk/python/protocol_7h3/webhook.py +0 -116
- package/sdk/python/pyproject.toml +0 -40
- package/sdk/python/tests/test_conformance.py +0 -110
- package/sdk/python/tests/test_http.py +0 -305
- package/sdk/python/tests/test_keys.py +0 -417
- package/sdk/python/tests/test_queue.py +0 -120
- package/sdk/python/tests/test_webhook.py +0 -345
- package/sdk/rust/Cargo.lock +0 -371
- package/sdk/rust/Cargo.toml +0 -25
- package/sdk/rust/README.md +0 -31
- package/sdk/rust/fuzz/Cargo.toml +0 -29
- package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +0 -46
- package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +0 -11
- package/sdk/rust/src/bin/aip_mcp_gateway.rs +0 -59
- package/sdk/rust/src/http.rs +0 -145
- package/sdk/rust/src/keys.rs +0 -161
- package/sdk/rust/src/lib.rs +0 -688
- package/sdk/rust/src/queue.rs +0 -79
- package/sdk/rust/src/webhook.rs +0 -86
- package/sdk/rust/tests/conformance.rs +0 -148
- package/sdk/rust/tests/gateway.rs +0 -130
- package/sdk/rust/tests/http_webhook_queue.rs +0 -201
- package/sdk/rust/tests/keys.rs +0 -189
- package/src/agentAdapter.test.ts +0 -48
- package/src/agentAdapter.ts +0 -56
- package/src/auditLog.test.ts +0 -145
- package/src/auditLog.ts +0 -147
- package/src/conformance.test.ts +0 -136
- package/src/conformanceVectors.ts +0 -99
- package/src/frameworkAdapters.test.ts +0 -290
- package/src/frameworkAdapters.ts +0 -261
- package/src/gateway.test.ts +0 -343
- package/src/gateway.ts +0 -171
- package/src/grpcBinding.test.ts +0 -211
- package/src/grpcBinding.ts +0 -103
- package/src/httpBinding.test.ts +0 -376
- package/src/httpBinding.ts +0 -163
- package/src/index.ts +0 -32
- package/src/keyInfra.test.ts +0 -278
- package/src/keyInfra.ts +0 -228
- package/src/keyRegistry.ts +0 -59
- package/src/keyRotation.test.ts +0 -78
- package/src/keyRotation.ts +0 -72
- package/src/mcpGateway.test.ts +0 -129
- package/src/mcpGateway.ts +0 -250
- package/src/mcpTransports.test.ts +0 -92
- package/src/mcpTransports.ts +0 -169
- package/src/mcpWrapper.test.ts +0 -179
- package/src/mcpWrapper.ts +0 -206
- package/src/policyEnforcer.test.ts +0 -99
- package/src/policyEnforcer.ts +0 -169
- package/src/policyTelemetryFeedback.test.ts +0 -25
- package/src/policyTelemetryFeedback.ts +0 -38
- package/src/protocol.bench.ts +0 -37
- package/src/protocol.test.ts +0 -155
- package/src/protocol.ts +0 -413
- package/src/protocolAgent.test.ts +0 -105
- package/src/protocolAgent.ts +0 -169
- package/src/protocolBinary.test.ts +0 -165
- package/src/protocolBinary.ts +0 -312
- package/src/protocolCapabilities.ts +0 -70
- package/src/protocolFuzz.advanced.test.ts +0 -235
- package/src/protocolFuzz.test.ts +0 -111
- package/src/protocolNegative.test.ts +0 -97
- package/src/protocolReplay.test.ts +0 -71
- package/src/protocolReplay.ts +0 -194
- package/src/protocolTransport.test.ts +0 -556
- package/src/protocolTransport.ts +0 -483
- package/src/queueBinding.test.ts +0 -130
- package/src/queueBinding.ts +0 -102
- package/src/rateLimiter.test.ts +0 -96
- package/src/rateLimiter.ts +0 -46
- package/src/redisIntegration.test.ts +0 -134
- package/src/replayStores.test.ts +0 -141
- package/src/replayStores.ts +0 -82
- package/src/revocation.test.ts +0 -98
- package/src/revocation.ts +0 -0
- package/src/routePolicy.test.ts +0 -87
- package/src/routePolicy.ts +0 -72
- package/src/runtimePolicy.test.ts +0 -49
- package/src/runtimePolicy.ts +0 -81
- package/src/runtimePolicyManager.test.ts +0 -29
- package/src/runtimePolicyManager.ts +0 -50
- package/src/runtimePolicyPresets.ts +0 -43
- package/src/signedResponse.test.ts +0 -111
- package/src/signedResponse.ts +0 -83
- package/src/webhookBinding.test.ts +0 -144
- package/src/webhookBinding.ts +0 -115
- package/src/wsBinding.test.ts +0 -221
- package/src/wsBinding.ts +0 -100
- package/tsconfig.json +0 -15
- package/tsconfig.lib.json +0 -23
- package/vite.lib.config.ts +0 -16
package/src/queueBinding.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type ProtocolEnvelope,
|
|
3
|
-
createEnvelope,
|
|
4
|
-
signEnvelopeEd25519,
|
|
5
|
-
verifyEnvelopeEd25519,
|
|
6
|
-
} from './protocol'
|
|
7
|
-
|
|
8
|
-
export interface QueueSignOptions {
|
|
9
|
-
privateKey: string
|
|
10
|
-
sender: string
|
|
11
|
-
recipient?: string
|
|
12
|
-
ttlMs?: number
|
|
13
|
-
keyId?: string
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface QueueVerifyOptions {
|
|
17
|
-
publicKey: string
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export type SignedEnvelope = ProtocolEnvelope
|
|
21
|
-
|
|
22
|
-
export interface QueueMessage<T> {
|
|
23
|
-
envelope: SignedEnvelope
|
|
24
|
-
payload: T
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Signs a payload for queue transit (Kafka/SQS/Pub-Sub/RabbitMQ).
|
|
29
|
-
* Returns a JSON string: {"envelope": SignedEnvelope, "payload": T}
|
|
30
|
-
* Default TTL is 1 hour (3 600 000 ms).
|
|
31
|
-
*/
|
|
32
|
-
export async function signQueueMessage<T>(
|
|
33
|
-
payload: T,
|
|
34
|
-
opts: QueueSignOptions,
|
|
35
|
-
): Promise<string> {
|
|
36
|
-
const ttlMs = opts.ttlMs ?? 3_600_000
|
|
37
|
-
const content = typeof payload === 'string' ? payload : JSON.stringify(payload)
|
|
38
|
-
|
|
39
|
-
const envelope = createEnvelope({
|
|
40
|
-
sender: opts.sender,
|
|
41
|
-
recipient: opts.recipient,
|
|
42
|
-
intent: 'TASK',
|
|
43
|
-
content,
|
|
44
|
-
ttlMs,
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
const signed = await signEnvelopeEd25519(envelope, opts.privateKey, opts.keyId)
|
|
48
|
-
|
|
49
|
-
const message: QueueMessage<T> = { envelope: signed, payload }
|
|
50
|
-
return JSON.stringify(message)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Parses a queue message JSON string and verifies the envelope signature.
|
|
55
|
-
* Throws if the message is malformed or the signature is invalid.
|
|
56
|
-
*/
|
|
57
|
-
export async function verifyQueueMessage<T>(
|
|
58
|
-
message: string,
|
|
59
|
-
opts: QueueVerifyOptions,
|
|
60
|
-
): Promise<{ payload: T; envelope: ProtocolEnvelope }> {
|
|
61
|
-
let parsed: QueueMessage<T>
|
|
62
|
-
try {
|
|
63
|
-
parsed = JSON.parse(message) as QueueMessage<T>
|
|
64
|
-
} catch {
|
|
65
|
-
throw new Error('Queue message is not valid JSON')
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (!parsed.envelope || typeof parsed.envelope !== 'object') {
|
|
69
|
-
throw new Error('Queue message missing envelope')
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const valid = await verifyEnvelopeEd25519(parsed.envelope, opts.publicKey)
|
|
73
|
-
if (!valid) {
|
|
74
|
-
throw new Error('Queue message signature verification failed')
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return { payload: parsed.payload, envelope: parsed.envelope }
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Verifies a batch of queue message strings.
|
|
82
|
-
* Never throws — returns per-message success/failure objects.
|
|
83
|
-
*/
|
|
84
|
-
export async function verifyQueueBatch<T>(
|
|
85
|
-
messages: string[],
|
|
86
|
-
opts: QueueVerifyOptions,
|
|
87
|
-
): Promise<Array<{ ok: true; payload: T; envelope: ProtocolEnvelope } | { ok: false; raw: string; error: string }>> {
|
|
88
|
-
return Promise.all(
|
|
89
|
-
messages.map(async (raw) => {
|
|
90
|
-
try {
|
|
91
|
-
const { payload, envelope } = await verifyQueueMessage<T>(raw, opts)
|
|
92
|
-
return { ok: true as const, payload, envelope }
|
|
93
|
-
} catch (err) {
|
|
94
|
-
return {
|
|
95
|
-
ok: false as const,
|
|
96
|
-
raw,
|
|
97
|
-
error: err instanceof Error ? err.message : String(err),
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}),
|
|
101
|
-
)
|
|
102
|
-
}
|
package/src/rateLimiter.test.ts
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
-
import { SlidingWindowRateLimiter } from './rateLimiter'
|
|
3
|
-
|
|
4
|
-
describe('SlidingWindowRateLimiter', () => {
|
|
5
|
-
let limiter: SlidingWindowRateLimiter
|
|
6
|
-
|
|
7
|
-
beforeEach(() => {
|
|
8
|
-
limiter = new SlidingWindowRateLimiter()
|
|
9
|
-
})
|
|
10
|
-
|
|
11
|
-
it('check() allows when under limit', () => {
|
|
12
|
-
const result = limiter.check('alice', { requests: 5, windowMs: 1000 })
|
|
13
|
-
expect(result.allowed).toBe(true)
|
|
14
|
-
expect(result.remaining).toBe(4) // 4 left after this one would be consumed
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
it('check() does not record a timestamp', () => {
|
|
18
|
-
const policy = { requests: 1, windowMs: 1000 }
|
|
19
|
-
limiter.check('alice', policy)
|
|
20
|
-
limiter.check('alice', policy)
|
|
21
|
-
// Still allowed — check doesn't consume
|
|
22
|
-
const result = limiter.check('alice', policy)
|
|
23
|
-
expect(result.allowed).toBe(true)
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
it('consume() records and blocks at limit', () => {
|
|
27
|
-
const policy = { requests: 2, windowMs: 10_000 }
|
|
28
|
-
const r1 = limiter.consume('alice', policy)
|
|
29
|
-
expect(r1.allowed).toBe(true)
|
|
30
|
-
expect(r1.remaining).toBe(1)
|
|
31
|
-
|
|
32
|
-
const r2 = limiter.consume('alice', policy)
|
|
33
|
-
expect(r2.allowed).toBe(true)
|
|
34
|
-
expect(r2.remaining).toBe(0)
|
|
35
|
-
|
|
36
|
-
const r3 = limiter.consume('alice', policy)
|
|
37
|
-
expect(r3.allowed).toBe(false)
|
|
38
|
-
expect(r3.remaining).toBe(0)
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('consume() sliding window expires old timestamps', () => {
|
|
42
|
-
const policy = { requests: 2, windowMs: 1000 }
|
|
43
|
-
const baseMs = 10_000
|
|
44
|
-
|
|
45
|
-
// Fill the window at t=0
|
|
46
|
-
limiter.consume('alice', policy, baseMs)
|
|
47
|
-
limiter.consume('alice', policy, baseMs)
|
|
48
|
-
|
|
49
|
-
// Blocked at t=500 (still inside window)
|
|
50
|
-
const blocked = limiter.consume('alice', policy, baseMs + 500)
|
|
51
|
-
expect(blocked.allowed).toBe(false)
|
|
52
|
-
|
|
53
|
-
// Allowed at t=1001 (first entry has expired)
|
|
54
|
-
const allowed = limiter.consume('alice', policy, baseMs + 1001)
|
|
55
|
-
expect(allowed.allowed).toBe(true)
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('reset() clears a specific key', () => {
|
|
59
|
-
const policy = { requests: 1, windowMs: 10_000 }
|
|
60
|
-
limiter.consume('alice', policy)
|
|
61
|
-
const blocked = limiter.consume('alice', policy)
|
|
62
|
-
expect(blocked.allowed).toBe(false)
|
|
63
|
-
|
|
64
|
-
limiter.reset('alice')
|
|
65
|
-
const allowed = limiter.consume('alice', policy)
|
|
66
|
-
expect(allowed.allowed).toBe(true)
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it('clear() clears all keys', () => {
|
|
70
|
-
const policy = { requests: 1, windowMs: 10_000 }
|
|
71
|
-
limiter.consume('alice', policy)
|
|
72
|
-
limiter.consume('bob', policy)
|
|
73
|
-
|
|
74
|
-
limiter.clear()
|
|
75
|
-
|
|
76
|
-
expect(limiter.consume('alice', policy).allowed).toBe(true)
|
|
77
|
-
expect(limiter.consume('bob', policy).allowed).toBe(true)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('resetMs is non-zero when window has entries', () => {
|
|
81
|
-
const policy = { requests: 5, windowMs: 1000 }
|
|
82
|
-
const baseMs = 50_000
|
|
83
|
-
limiter.consume('alice', policy, baseMs)
|
|
84
|
-
const result = limiter.check('alice', policy, baseMs + 100)
|
|
85
|
-
expect(result.resetMs).toBeGreaterThan(0)
|
|
86
|
-
expect(result.resetMs).toBeLessThanOrEqual(1000)
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
it('different keys are independent', () => {
|
|
90
|
-
const policy = { requests: 1, windowMs: 10_000 }
|
|
91
|
-
limiter.consume('alice', policy)
|
|
92
|
-
// alice is blocked, but bob is not
|
|
93
|
-
expect(limiter.consume('alice', policy).allowed).toBe(false)
|
|
94
|
-
expect(limiter.consume('bob', policy).allowed).toBe(true)
|
|
95
|
-
})
|
|
96
|
-
})
|
package/src/rateLimiter.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
export type RateLimitPolicy = { requests: number; windowMs: number }
|
|
2
|
-
export type RateLimitResult = { allowed: boolean; remaining: number; resetMs: number }
|
|
3
|
-
|
|
4
|
-
class SlidingWindowRateLimiter {
|
|
5
|
-
private windows: Map<string, number[]> = new Map()
|
|
6
|
-
|
|
7
|
-
// Check without recording — pure read
|
|
8
|
-
check(key: string, policy: RateLimitPolicy, nowMs = Date.now()): RateLimitResult {
|
|
9
|
-
const cutoff = nowMs - policy.windowMs
|
|
10
|
-
const timestamps = (this.windows.get(key) ?? []).filter(t => t > cutoff)
|
|
11
|
-
const used = timestamps.length
|
|
12
|
-
const allowed = used < policy.requests
|
|
13
|
-
const remaining = Math.max(0, policy.requests - used - (allowed ? 1 : 0))
|
|
14
|
-
// resetMs: time until the oldest timestamp falls out of window (0 if window empty)
|
|
15
|
-
const resetMs = timestamps.length > 0 ? Math.max(0, timestamps[0] + policy.windowMs - nowMs) : 0
|
|
16
|
-
return { allowed, remaining, resetMs }
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// Consume — record a timestamp if allowed
|
|
20
|
-
consume(key: string, policy: RateLimitPolicy, nowMs = Date.now()): RateLimitResult {
|
|
21
|
-
const cutoff = nowMs - policy.windowMs
|
|
22
|
-
const timestamps = (this.windows.get(key) ?? []).filter(t => t > cutoff)
|
|
23
|
-
const used = timestamps.length
|
|
24
|
-
const allowed = used < policy.requests
|
|
25
|
-
|
|
26
|
-
if (allowed) {
|
|
27
|
-
timestamps.push(nowMs)
|
|
28
|
-
this.windows.set(key, timestamps)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const remaining = Math.max(0, policy.requests - timestamps.length)
|
|
32
|
-
const resetMs = timestamps.length > 0 ? Math.max(0, timestamps[0] + policy.windowMs - nowMs) : 0
|
|
33
|
-
return { allowed, remaining, resetMs }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
reset(key: string): void {
|
|
37
|
-
this.windows.delete(key)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
clear(): void {
|
|
41
|
-
this.windows.clear()
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export const globalRateLimiter = new SlidingWindowRateLimiter()
|
|
46
|
-
export { SlidingWindowRateLimiter }
|
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
import net from 'node:net'
|
|
2
|
-
import { afterAll, describe, expect, it } from 'vitest'
|
|
3
|
-
import type { RedisLikeClient, RedisSetOptions } from './redisClient'
|
|
4
|
-
import { createRedisReplayStore } from './replayStores'
|
|
5
|
-
import { createRedisRevocationStore } from './revocation'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Minimal RESP client over a raw socket — just enough of the Redis wire
|
|
9
|
-
* protocol to drive SET/GET/DEL. Dependency-free so the integration test can
|
|
10
|
-
* run against a real redis-server without adding an npm Redis client.
|
|
11
|
-
*/
|
|
12
|
-
class NetRedisClient implements RedisLikeClient {
|
|
13
|
-
private socket!: net.Socket
|
|
14
|
-
private buffer = Buffer.alloc(0)
|
|
15
|
-
private readonly waiters: Array<(reply: string | number | null | Error) => void> = []
|
|
16
|
-
|
|
17
|
-
connect(host = '127.0.0.1', port = 6379): Promise<void> {
|
|
18
|
-
return new Promise((resolve, reject) => {
|
|
19
|
-
this.socket = net.createConnection({ host, port }, resolve)
|
|
20
|
-
this.socket.on('error', reject)
|
|
21
|
-
this.socket.on('data', (chunk) => {
|
|
22
|
-
this.buffer = Buffer.concat([this.buffer, chunk])
|
|
23
|
-
this.drain()
|
|
24
|
-
})
|
|
25
|
-
})
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
close(): void {
|
|
29
|
-
this.socket?.end()
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
private drain(): void {
|
|
33
|
-
while (this.waiters.length > 0) {
|
|
34
|
-
const parsed = this.parseReply(this.buffer)
|
|
35
|
-
if (!parsed) return
|
|
36
|
-
this.buffer = this.buffer.subarray(parsed.consumed)
|
|
37
|
-
this.waiters.shift()!(parsed.value)
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
private parseReply(buf: Buffer): { value: string | number | null | Error; consumed: number } | null {
|
|
42
|
-
const end = buf.indexOf('\r\n')
|
|
43
|
-
if (end < 0) return null
|
|
44
|
-
const type = String.fromCharCode(buf[0]!)
|
|
45
|
-
const line = buf.toString('utf8', 1, end)
|
|
46
|
-
const headerConsumed = end + 2
|
|
47
|
-
if (type === '+') return { value: line, consumed: headerConsumed }
|
|
48
|
-
if (type === '-') return { value: new Error(line), consumed: headerConsumed }
|
|
49
|
-
if (type === ':') return { value: Number(line), consumed: headerConsumed }
|
|
50
|
-
if (type === '$') {
|
|
51
|
-
const len = Number(line)
|
|
52
|
-
if (len === -1) return { value: null, consumed: headerConsumed }
|
|
53
|
-
const dataEnd = headerConsumed + len
|
|
54
|
-
if (buf.length < dataEnd + 2) return null
|
|
55
|
-
return { value: buf.toString('utf8', headerConsumed, dataEnd), consumed: dataEnd + 2 }
|
|
56
|
-
}
|
|
57
|
-
return { value: new Error(`unsupported RESP type: ${type}`), consumed: headerConsumed }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
private command(args: string[]): Promise<string | number | null> {
|
|
61
|
-
const payload = `*${args.length}\r\n${args.map((a) => `$${Buffer.byteLength(a)}\r\n${a}\r\n`).join('')}`
|
|
62
|
-
return new Promise((resolve, reject) => {
|
|
63
|
-
this.waiters.push((reply) => (reply instanceof Error ? reject(reply) : resolve(reply)))
|
|
64
|
-
this.socket.write(payload)
|
|
65
|
-
})
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async set(key: string, value: string, options: RedisSetOptions = {}): Promise<'OK' | null> {
|
|
69
|
-
const args = ['SET', key, value]
|
|
70
|
-
if (options.pxMs !== undefined) args.push('PX', String(options.pxMs))
|
|
71
|
-
if (options.nx) args.push('NX')
|
|
72
|
-
const reply = await this.command(args)
|
|
73
|
-
return reply === 'OK' ? 'OK' : null
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async get(key: string): Promise<string | null> {
|
|
77
|
-
const reply = await this.command(['GET', key])
|
|
78
|
-
return reply === null ? null : String(reply)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async del(key: string): Promise<number> {
|
|
82
|
-
const reply = await this.command(['DEL', key])
|
|
83
|
-
return Number(reply)
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async function redisAvailable(): Promise<NetRedisClient | null> {
|
|
88
|
-
const client = new NetRedisClient()
|
|
89
|
-
try {
|
|
90
|
-
await client.connect()
|
|
91
|
-
return client
|
|
92
|
-
} catch {
|
|
93
|
-
return null
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const client = await redisAvailable()
|
|
98
|
-
const runOrSkip = client ? describe : describe.skip
|
|
99
|
-
if (!client) {
|
|
100
|
-
console.warn('[redisIntegration] redis-server unreachable on 127.0.0.1:6379 — skipping live integration tests')
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Unique namespace per run so concurrent/previous runs never collide.
|
|
104
|
-
const ns = `aip:test:${Date.now()}:${Math.floor(Math.random() * 1e6)}:`
|
|
105
|
-
|
|
106
|
-
runOrSkip('Redis integration (live server)', () => {
|
|
107
|
-
const touched: string[] = []
|
|
108
|
-
|
|
109
|
-
afterAll(async () => {
|
|
110
|
-
if (!client) return
|
|
111
|
-
for (const key of touched) await client.del(key)
|
|
112
|
-
client.close()
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
it('replay store reserves a key once against real Redis', async () => {
|
|
116
|
-
const prefix = `${ns}replay:`
|
|
117
|
-
const store = createRedisReplayStore(client!, { keyPrefix: prefix })
|
|
118
|
-
const now = Date.now()
|
|
119
|
-
touched.push(`${prefix}s|m1|n1`)
|
|
120
|
-
expect(await store.reserve('s|m1|n1', now + 5000, now)).toBe(true)
|
|
121
|
-
expect(await store.reserve('s|m1|n1', now + 5000, now)).toBe(false)
|
|
122
|
-
expect(await store.reserve('s|m2|n2', now + 5000, now)).toBe(true)
|
|
123
|
-
touched.push(`${prefix}s|m2|n2`)
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
it('revocation store reflects a revoke against real Redis', async () => {
|
|
127
|
-
const prefix = `${ns}revoked:`
|
|
128
|
-
const store = createRedisRevocationStore(client!, { keyPrefix: prefix, cacheTtlMs: 0 })
|
|
129
|
-
touched.push(`${prefix}agent.a agent-k1`)
|
|
130
|
-
expect(await store.isRevoked('agent.a', 'agent-k1')).toBe(false)
|
|
131
|
-
await store.revoke('agent.a', 'agent-k1')
|
|
132
|
-
expect(await store.isRevoked('agent.a', 'agent-k1')).toBe(true)
|
|
133
|
-
})
|
|
134
|
-
})
|
package/src/replayStores.test.ts
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import { InMemoryRedisLikeClient } from './redisClient'
|
|
3
|
-
import { createRedisReplayStore } from './replayStores'
|
|
4
|
-
import { DistributedReplayCache } from './protocolReplay'
|
|
5
|
-
import { createEnvelope } from './protocol'
|
|
6
|
-
|
|
7
|
-
describe('InMemoryRedisLikeClient (reference client)', () => {
|
|
8
|
-
it('SET NX returns OK on first write and null when the key already exists', async () => {
|
|
9
|
-
const client = new InMemoryRedisLikeClient()
|
|
10
|
-
const first = await client.set('k', '1', { nx: true, pxMs: 1000 })
|
|
11
|
-
const second = await client.set('k', '1', { nx: true, pxMs: 1000 })
|
|
12
|
-
expect(first).toBe('OK')
|
|
13
|
-
expect(second).toBeNull()
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
it('SET NX succeeds again after the PX TTL elapses', async () => {
|
|
17
|
-
let now = 1000
|
|
18
|
-
const client = new InMemoryRedisLikeClient({ now: () => now })
|
|
19
|
-
expect(await client.set('k', '1', { nx: true, pxMs: 500 })).toBe('OK')
|
|
20
|
-
now = 1400
|
|
21
|
-
expect(await client.set('k', '1', { nx: true, pxMs: 500 })).toBeNull()
|
|
22
|
-
now = 1600
|
|
23
|
-
expect(await client.set('k', '1', { nx: true, pxMs: 500 })).toBe('OK')
|
|
24
|
-
})
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
describe('createRedisReplayStore', () => {
|
|
28
|
-
it('reserve returns true for a new key and false for a duplicate', async () => {
|
|
29
|
-
const store = createRedisReplayStore(new InMemoryRedisLikeClient())
|
|
30
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(true)
|
|
31
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(false)
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it('reserves distinct keys independently', async () => {
|
|
35
|
-
const store = createRedisReplayStore(new InMemoryRedisLikeClient())
|
|
36
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(true)
|
|
37
|
-
expect(await store.reserve('sender|m2|n2', 5000, 1000)).toBe(true)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('sets the Redis key with the prefix and a positive PX derived from expiry minus now', async () => {
|
|
41
|
-
const client = new InMemoryRedisLikeClient()
|
|
42
|
-
const setSpy = vi.spyOn(client, 'set')
|
|
43
|
-
const store = createRedisReplayStore(client, { keyPrefix: 'aip:replay:' })
|
|
44
|
-
await store.reserve('sender|m1|n1', 5000, 1000)
|
|
45
|
-
expect(setSpy).toHaveBeenCalledWith('aip:replay:sender|m1|n1', '1', { nx: true, pxMs: 4000 })
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('clamps PX to at least 1ms when expiry is not in the future', async () => {
|
|
49
|
-
const client = new InMemoryRedisLikeClient()
|
|
50
|
-
const setSpy = vi.spyOn(client, 'set')
|
|
51
|
-
const store = createRedisReplayStore(client)
|
|
52
|
-
await store.reserve('sender|m1|n1', 1000, 1000)
|
|
53
|
-
expect(setSpy).toHaveBeenCalledWith('aip:replay:sender|m1|n1', '1', { nx: true, pxMs: 1 })
|
|
54
|
-
})
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
describe('createRedisReplayStore degraded behavior', () => {
|
|
58
|
-
function throwingClient() {
|
|
59
|
-
return {
|
|
60
|
-
set: async () => {
|
|
61
|
-
throw new Error('redis unreachable')
|
|
62
|
-
},
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
it('errorBehavior "reject" returns false and fires onDegraded when the client throws', async () => {
|
|
67
|
-
const onDegraded = vi.fn()
|
|
68
|
-
const store = createRedisReplayStore(throwingClient(), { errorBehavior: 'reject', onDegraded })
|
|
69
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(false)
|
|
70
|
-
expect(onDegraded).toHaveBeenCalledOnce()
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('errorBehavior "allow" returns true and fires onDegraded when the client throws', async () => {
|
|
74
|
-
const onDegraded = vi.fn()
|
|
75
|
-
const store = createRedisReplayStore(throwingClient(), { errorBehavior: 'allow', onDegraded })
|
|
76
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(true)
|
|
77
|
-
expect(onDegraded).toHaveBeenCalledOnce()
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('errorBehavior "fallback" (default) degrades to a local store and fires onDegraded', async () => {
|
|
81
|
-
const onDegraded = vi.fn()
|
|
82
|
-
const store = createRedisReplayStore(throwingClient(), { onDegraded })
|
|
83
|
-
// first call to the degraded local store reserves successfully
|
|
84
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(true)
|
|
85
|
-
// second identical call is caught locally as a replay
|
|
86
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(false)
|
|
87
|
-
expect(onDegraded).toHaveBeenCalledTimes(2)
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it('errorBehavior "fallback" delegates to an explicitly provided local store', async () => {
|
|
91
|
-
const onDegraded = vi.fn()
|
|
92
|
-
const fallback = createRedisReplayStore(new InMemoryRedisLikeClient())
|
|
93
|
-
const store = createRedisReplayStore(throwingClient(), { fallback, onDegraded })
|
|
94
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(true)
|
|
95
|
-
expect(await store.reserve('sender|m1|n1', 5000, 1000)).toBe(false)
|
|
96
|
-
expect(onDegraded).toHaveBeenCalledTimes(2)
|
|
97
|
-
})
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
describe('createRedisReplayStore batch (reserveMany)', () => {
|
|
101
|
-
it('reserves a batch, deduplicating within the batch and against prior reservations', async () => {
|
|
102
|
-
const store = createRedisReplayStore(new InMemoryRedisLikeClient())
|
|
103
|
-
await store.reserve('sender|m0|n0', 5000, 1000)
|
|
104
|
-
const results = await store.reserveMany!(
|
|
105
|
-
[
|
|
106
|
-
{ key: 'sender|m0|n0', expiresAtMs: 5000 }, // already reserved → false
|
|
107
|
-
{ key: 'sender|m1|n1', expiresAtMs: 5000 }, // new → true
|
|
108
|
-
{ key: 'sender|m1|n1', expiresAtMs: 5000 }, // duplicate within batch → false
|
|
109
|
-
],
|
|
110
|
-
1000,
|
|
111
|
-
)
|
|
112
|
-
expect(results).toEqual([false, true, false])
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
it('uses the client pipeline when one is available (single round-trip)', async () => {
|
|
116
|
-
const client = new InMemoryRedisLikeClient()
|
|
117
|
-
const pipelineSpy = vi.spyOn(client, 'pipeline')
|
|
118
|
-
const store = createRedisReplayStore(client)
|
|
119
|
-
await store.reserveMany!(
|
|
120
|
-
[
|
|
121
|
-
{ key: 'sender|m1|n1', expiresAtMs: 5000 },
|
|
122
|
-
{ key: 'sender|m2|n2', expiresAtMs: 5000 },
|
|
123
|
-
],
|
|
124
|
-
1000,
|
|
125
|
-
)
|
|
126
|
-
expect(pipelineSpy).toHaveBeenCalledOnce()
|
|
127
|
-
})
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
describe('DistributedReplayCache batch delegation', () => {
|
|
131
|
-
it('consumeMany routes through the store reserveMany when present', async () => {
|
|
132
|
-
const store = createRedisReplayStore(new InMemoryRedisLikeClient())
|
|
133
|
-
const reserveManySpy = vi.spyOn(store, 'reserveMany')
|
|
134
|
-
const cache = new DistributedReplayCache(store)
|
|
135
|
-
const e1 = { ...createEnvelope({ sender: 's', intent: 'PING', content: 'p', messageId: 'm1', nonce: 'n1', nowMs: 1000 }), signature: undefined } as never
|
|
136
|
-
const e2 = { ...createEnvelope({ sender: 's', intent: 'PING', content: 'p', messageId: 'm2', nonce: 'n2', nowMs: 1000 }), signature: undefined } as never
|
|
137
|
-
const results = await cache.consumeMany([e1, e2], 1000)
|
|
138
|
-
expect(reserveManySpy).toHaveBeenCalledOnce()
|
|
139
|
-
expect(results.map((r) => r.ok)).toEqual([true, true])
|
|
140
|
-
})
|
|
141
|
-
})
|
package/src/replayStores.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import type { DistributedReplayStore } from './protocolReplay'
|
|
2
|
-
import { InMemoryRedisLikeClient, type RedisLikeClient } from './redisClient'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* What to do when the Redis client throws (network blip, server down).
|
|
6
|
-
*
|
|
7
|
-
* - `fallback` (default): degrade to a local in-memory store so traffic keeps
|
|
8
|
-
* flowing with single-node replay protection still in force. Quality +
|
|
9
|
-
* reliability + scalability: Redis is never a fleet-wide kill switch.
|
|
10
|
-
* - `reject`: fail closed — deny the message because uniqueness cannot be confirmed.
|
|
11
|
-
* - `allow`: fail open — accept the message (skip the check) to maximise uptime.
|
|
12
|
-
*/
|
|
13
|
-
export type ReplayErrorBehavior = 'fallback' | 'reject' | 'allow'
|
|
14
|
-
|
|
15
|
-
export interface RedisReplayStoreOptions {
|
|
16
|
-
/** Key namespace in Redis. Default `aip:replay:`. */
|
|
17
|
-
keyPrefix?: string
|
|
18
|
-
/** Behavior when the Redis client throws. Default `fallback`. */
|
|
19
|
-
errorBehavior?: ReplayErrorBehavior
|
|
20
|
-
/** Local store used when `errorBehavior` is `fallback`. Defaults to an in-memory-backed store. */
|
|
21
|
-
fallback?: DistributedReplayStore
|
|
22
|
-
/** Observability hook fired whenever a Redis error forces degraded handling. */
|
|
23
|
-
onDegraded?: (error: unknown, context: { key: string; behavior: ReplayErrorBehavior }) => void
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Creates a {@link DistributedReplayStore} backed by Redis-style `SET NX PX`.
|
|
28
|
-
*
|
|
29
|
-
* Drop the returned store into `new DistributedReplayCache(store)` (or pass a
|
|
30
|
-
* `DistributedReplayCache` as the transport `replayCache`).
|
|
31
|
-
*/
|
|
32
|
-
export function createRedisReplayStore(
|
|
33
|
-
client: RedisLikeClient,
|
|
34
|
-
options: RedisReplayStoreOptions = {},
|
|
35
|
-
): DistributedReplayStore {
|
|
36
|
-
const keyPrefix = options.keyPrefix ?? 'aip:replay:'
|
|
37
|
-
const errorBehavior = options.errorBehavior ?? 'fallback'
|
|
38
|
-
const onDegraded = options.onDegraded
|
|
39
|
-
const fallback =
|
|
40
|
-
options.fallback ??
|
|
41
|
-
(errorBehavior === 'fallback'
|
|
42
|
-
? // The local fallback runs over a non-throwing in-memory client, so it
|
|
43
|
-
// needs no fallback of its own — `reject` terminates the chain.
|
|
44
|
-
createRedisReplayStore(new InMemoryRedisLikeClient(), { keyPrefix, errorBehavior: 'reject' })
|
|
45
|
-
: undefined)
|
|
46
|
-
|
|
47
|
-
function degrade(error: unknown, key: string, expiresAtMs: number, nowMs: number): Promise<boolean> | boolean {
|
|
48
|
-
onDegraded?.(error, { key, behavior: errorBehavior })
|
|
49
|
-
if (errorBehavior === 'allow') return true
|
|
50
|
-
if (errorBehavior === 'reject') return false
|
|
51
|
-
return fallback ? fallback.reserve(key, expiresAtMs, nowMs) : false
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return {
|
|
55
|
-
async reserve(key: string, expiresAtMs: number, nowMs: number): Promise<boolean> {
|
|
56
|
-
const pxMs = Math.max(1, expiresAtMs - nowMs)
|
|
57
|
-
try {
|
|
58
|
-
const result = await client.set(`${keyPrefix}${key}`, '1', { nx: true, pxMs })
|
|
59
|
-
return result === 'OK'
|
|
60
|
-
} catch (error) {
|
|
61
|
-
return degrade(error, key, expiresAtMs, nowMs)
|
|
62
|
-
}
|
|
63
|
-
},
|
|
64
|
-
|
|
65
|
-
async reserveMany(entries: Array<{ key: string; expiresAtMs: number }>, nowMs: number): Promise<boolean[]> {
|
|
66
|
-
if (entries.length === 0) return []
|
|
67
|
-
if (client.pipeline) {
|
|
68
|
-
try {
|
|
69
|
-
const batch = client.pipeline()
|
|
70
|
-
for (const { key, expiresAtMs } of entries) {
|
|
71
|
-
batch.set(`${keyPrefix}${key}`, '1', { nx: true, pxMs: Math.max(1, expiresAtMs - nowMs) })
|
|
72
|
-
}
|
|
73
|
-
const results = await batch.exec()
|
|
74
|
-
return results.map((result) => result === 'OK')
|
|
75
|
-
} catch (error) {
|
|
76
|
-
return Promise.all(entries.map((entry) => degrade(error, entry.key, entry.expiresAtMs, nowMs)))
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return Promise.all(entries.map((entry) => this.reserve(entry.key, entry.expiresAtMs, nowMs)))
|
|
80
|
-
},
|
|
81
|
-
}
|
|
82
|
-
}
|