serverless-ircd 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.github/workflows/ci.yml +3 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +273 -29
  4. package/README.md +155 -55
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-worker/package.json +1 -1
  10. package/apps/cf-worker/src/worker.ts +4 -4
  11. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  12. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  13. package/apps/cf-worker/wrangler.test.toml +6 -6
  14. package/apps/cf-worker/wrangler.toml +7 -5
  15. package/apps/local-cli/package.json +1 -1
  16. package/apps/local-cli/src/config-loader.ts +8 -0
  17. package/apps/local-cli/src/main.ts +16 -0
  18. package/apps/local-cli/src/server.ts +1 -0
  19. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  20. package/apps/web/landing/index.html +14 -16
  21. package/apps/web/package.json +2 -2
  22. package/apps/web/scripts/build.mjs +23 -16
  23. package/apps/web/src/build-env.ts +1 -1
  24. package/apps/web/src/config-schema.ts +6 -6
  25. package/apps/web/tests/build-smoke.test.ts +14 -14
  26. package/apps/web/tests/config-schema.test.ts +1 -1
  27. package/docs/AWS-Deployment.md +21 -8
  28. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  29. package/docs/PlanExtensions.md +113 -3
  30. package/docs/Release-Process.md +23 -13
  31. package/docs/Services.md +546 -0
  32. package/docs/WebClientGuide.md +32 -31
  33. package/package.json +2 -2
  34. package/packages/aws-adapter/package.json +1 -1
  35. package/packages/aws-adapter/src/aws-runtime.ts +5 -0
  36. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  37. package/packages/aws-adapter/src/config-loader.ts +11 -0
  38. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  39. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  40. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  41. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  42. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  43. package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
  44. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  45. package/packages/aws-adapter/src/index.ts +4 -0
  46. package/packages/aws-adapter/src/stats.ts +6 -1
  47. package/packages/aws-adapter/src/tables.ts +34 -4
  48. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  49. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  50. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  51. package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
  52. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  53. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  54. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  55. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  56. package/packages/cf-adapter/package.json +1 -1
  57. package/packages/cf-adapter/src/config-loader.ts +11 -0
  58. package/packages/cf-adapter/src/connection-do.ts +112 -2
  59. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  60. package/packages/cf-adapter/src/env.ts +8 -0
  61. package/packages/cf-adapter/src/index.ts +5 -0
  62. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  63. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  64. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  65. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  66. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  67. package/packages/in-memory-runtime/package.json +1 -1
  68. package/packages/irc-core/package.json +1 -1
  69. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  70. package/packages/irc-core/src/commands/account-auth.ts +172 -0
  71. package/packages/irc-core/src/commands/chanserv.ts +882 -0
  72. package/packages/irc-core/src/commands/hostserv.ts +487 -0
  73. package/packages/irc-core/src/commands/index.ts +12 -0
  74. package/packages/irc-core/src/commands/join.ts +164 -8
  75. package/packages/irc-core/src/commands/markread.ts +202 -0
  76. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  77. package/packages/irc-core/src/commands/mode.ts +96 -4
  78. package/packages/irc-core/src/commands/nickserv.ts +390 -0
  79. package/packages/irc-core/src/commands/oper.ts +18 -1
  80. package/packages/irc-core/src/commands/operserv.ts +346 -0
  81. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  82. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  83. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  84. package/packages/irc-core/src/commands/registration.ts +61 -6
  85. package/packages/irc-core/src/commands/sasl.ts +18 -49
  86. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  87. package/packages/irc-core/src/commands/topic.ts +37 -0
  88. package/packages/irc-core/src/config.ts +36 -5
  89. package/packages/irc-core/src/effects.ts +56 -1
  90. package/packages/irc-core/src/ports.ts +1653 -84
  91. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  92. package/packages/irc-core/src/state/channel.ts +21 -1
  93. package/packages/irc-core/src/state/connection.ts +25 -1
  94. package/packages/irc-core/src/types.ts +48 -12
  95. package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
  96. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  97. package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
  98. package/packages/irc-core/tests/commands/join.test.ts +393 -1
  99. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  100. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  101. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  102. package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
  103. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  104. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  105. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  106. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  107. package/packages/irc-core/tests/commands/registration.test.ts +788 -14
  108. package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
  109. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  111. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  112. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  113. package/packages/irc-core/tests/config.test.ts +49 -5
  114. package/packages/irc-core/tests/effects.test.ts +19 -0
  115. package/packages/irc-core/tests/message-store.test.ts +63 -0
  116. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  117. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  118. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  119. package/packages/irc-server/package.json +1 -1
  120. package/packages/irc-server/src/actor.ts +71 -16
  121. package/packages/irc-server/src/dispatch.ts +94 -7
  122. package/packages/irc-server/src/routing.ts +19 -0
  123. package/packages/irc-server/tests/actor.test.ts +623 -12
  124. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  125. package/packages/irc-server/tests/routing.test.ts +6 -0
  126. package/packages/irc-test-support/package.json +1 -1
  127. package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
  128. package/packages/irc-test-support/src/index.ts +1 -0
  129. package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
  130. package/tools/ci-hardening/package.json +1 -1
  131. package/tools/load-test/package.json +1 -1
  132. package/tools/tcp-ws-forwarder/package.json +1 -1
  133. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
  134. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  135. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -16,9 +16,18 @@
16
16
  import { resolve } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { TABLE_DEFS } from '@serverless-ircd/aws-adapter/cdk-table-defs';
19
- import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
19
+ import {
20
+ CfnOutput,
21
+ CfnParameter,
22
+ Duration,
23
+ RemovalPolicy,
24
+ Stack,
25
+ type StackProps,
26
+ } from 'aws-cdk-lib';
27
+ import { CfnAccount } from 'aws-cdk-lib/aws-apigateway';
20
28
  import {
21
29
  CfnIntegrationResponse,
30
+ type CfnStage,
22
31
  WebSocketApi,
23
32
  type WebSocketIntegration,
24
33
  WebSocketStage,
@@ -37,8 +46,10 @@ import {
37
46
  import { LambdaTarget } from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets';
38
47
  import { Rule, Schedule } from 'aws-cdk-lib/aws-events';
39
48
  import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets';
49
+ import { PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
40
50
  import { Code, Function as Lambda, Runtime } from 'aws-cdk-lib/aws-lambda';
41
51
  import { NodejsFunction, type NodejsFunctionProps } from 'aws-cdk-lib/aws-lambda-nodejs';
52
+ import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
42
53
  import type { Construct } from 'constructs';
43
54
 
44
55
  /** Stage name served by the auto-deploying WebSocket stage. */
@@ -154,10 +165,79 @@ export class IrcAwsStack extends Stack {
154
165
  super(scope, id, props);
155
166
 
156
167
  const environmentName = props.environmentName ?? DEFAULT_ENVIRONMENT_NAME;
157
- const serverName = props.serverName ?? DEFAULT_SERVER_NAME;
158
- const networkName = props.networkName ?? DEFAULT_NETWORK_NAME;
168
+
169
+ // Server identity is exposed as CloudFormation Parameters so it can be
170
+ // overridden at deploy time (`cdk deploy --parameters ServerName=...`,
171
+ // `aws cloudformation update-stack --parameters`, or the CFN console)
172
+ // without re-synth. The construct prop (and the `bin/aws.ts` context
173
+ // var) flow into the parameter `Default`; a `--parameters` override at
174
+ // deploy time wins over the default. Lambdas consume the value via
175
+ // `{ Ref: ... }` so an update rolls the env vars with no code change.
176
+ const serverNameParam = new CfnParameter(this, 'ServerName', {
177
+ type: 'String',
178
+ description:
179
+ "Server name advertised to IRC clients (the `001` welcome source, ISUPPORT's SERVER_NAME). " +
180
+ 'Override at deploy time via `--parameters ServerName=...`.',
181
+ default: props.serverName ?? DEFAULT_SERVER_NAME,
182
+ });
183
+ const networkNameParam = new CfnParameter(this, 'NetworkName', {
184
+ type: 'String',
185
+ description:
186
+ 'Network name advertised to IRC clients in ISUPPORT (`NETWORK`). ' +
187
+ 'Override at deploy time via `--parameters NetworkName=...`.',
188
+ default: props.networkName ?? DEFAULT_NETWORK_NAME,
189
+ });
190
+ const serverName = serverNameParam.valueAsString;
191
+ const networkName = networkNameParam.valueAsString;
192
+
193
+ // Execution logging for the WebSocket API. INFO enables full
194
+ // request/response tracing (verbose — use for debugging connect/drop
195
+ // issues); ERROR logs only server-side failures; OFF disables. The
196
+ // level is a CFN parameter so it can be flipped at deploy time without
197
+ // a code change: `cdk deploy --parameters ApiGatewayLoggingLevel=OFF`.
198
+ // NB: execution logging also needs the account-level CloudWatch role,
199
+ // provisioned below (`ApiGatewayAccount`); that resource is GLOBAL per
200
+ // account+region, so two stacks with logging ON in the same
201
+ // account+region will conflict — deploy each env in its own account or
202
+ // set this to OFF.
203
+ const apiLoggingLevelParam = new CfnParameter(this, 'ApiGatewayLoggingLevel', {
204
+ type: 'String',
205
+ description:
206
+ 'API Gateway WebSocket execution logging level. INFO = full request/response tracing ' +
207
+ '(verbose, for debugging); ERROR = 5xx only; OFF = disabled. Requires the account ' +
208
+ 'CloudWatch role (auto-provisioned; global per account+region).',
209
+ default: 'INFO',
210
+ allowedValues: ['OFF', 'INFO', 'ERROR'],
211
+ });
159
212
  const motd = (props.motdLines ?? DEFAULT_MOTD_LINES).join('\n');
160
213
 
214
+ // EventBridge schedule expressions are surfaced as CloudFormation
215
+ // Parameters for the same reason as server identity: they can be
216
+ // overridden at deploy time (`--parameters SweeperScheduleExpression=...`)
217
+ // without a re-synth. The defaults are derived from the same
218
+ // `Schedule.rate(Duration)` calls used previously so the produced
219
+ // strings (`rate(5 minutes)`, `rate(1 minute)` — including the
220
+ // singular/plural handling) are byte-identical to the old behaviour.
221
+ // Validation moves from synth (Duration bounds) to deploy time: an
222
+ // invalid expression (`rate(0 minutes)`, a malformed `cron(...)`)
223
+ // fails the `AWS::Events::Rule` create with an EventBridge error.
224
+ const sweeperScheduleParam = new CfnParameter(this, 'SweeperScheduleExpression', {
225
+ type: 'String',
226
+ description:
227
+ 'EventBridge schedule expression for the gone-connection sweeper ' +
228
+ '(`rate(5 minutes)` or a `cron(...)` expression). Override at deploy ' +
229
+ 'time via `--parameters SweeperScheduleExpression=...`.',
230
+ default: Schedule.rate(SWEEPER_SCHEDULE_RATE).expressionString,
231
+ });
232
+ const pingCheckerScheduleParam = new CfnParameter(this, 'PingCheckerScheduleExpression', {
233
+ type: 'String',
234
+ description:
235
+ 'EventBridge schedule expression for the idle / PING checker ' +
236
+ '(`rate(1 minute)` or a `cron(...)` expression). Override at deploy ' +
237
+ 'time via `--parameters PingCheckerScheduleExpression=...`.',
238
+ default: Schedule.rate(PING_CHECKER_SCHEDULE_RATE).expressionString,
239
+ });
240
+
161
241
  // Prefix every physical table name with the environment so parallel
162
242
  // envs (staging + production) cannot collide in one account+region.
163
243
  // The first letter is capitalised to compose cleanly with the
@@ -248,7 +328,7 @@ export class IrcAwsStack extends Stack {
248
328
  integrationId: connectIntegration.integrationId,
249
329
  integrationResponseKey: '$default',
250
330
  responseParameters: {
251
- 'method.response.header.Sec-WebSocket-Protocol':
331
+ 'route.response.header.Sec-WebSocket-Protocol':
252
332
  'integration.response.header.Sec-WebSocket-Protocol',
253
333
  },
254
334
  });
@@ -258,11 +338,90 @@ export class IrcAwsStack extends Stack {
258
338
  // own WebSocket API), so `prod` in every env resolves to a distinct
259
339
  // endpoint. Keeping a fixed stage keeps client connect URLs stable
260
340
  // and identical in shape across environments.
341
+ // CloudWatch LogGroup + IAM role for API Gateway access + execution
342
+ // logging. Execution logging (full request/response at INFO) is what
343
+ // makes APIGW-side frame drops diagnosable — the Lambda logs alone
344
+ // can't show a frame that never reached the handler.
345
+ const apiLogGroup = new LogGroup(this, 'ApiGatewayAccessLogs', {
346
+ retention: RetentionDays.TWO_WEEKS,
347
+ removalPolicy: RemovalPolicy.DESTROY,
348
+ });
349
+ const apiLoggingRole = new Role(this, 'ApiGatewayLoggingRole', {
350
+ assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
351
+ });
352
+ // APIGW validates this role when it is set as the account CloudWatch
353
+ // role, and rejects one scoped to a single log-group ARN: execution
354
+ // logs land in APIGW's OWN log groups (AWS/ApiGateway...), separate
355
+ // from this stack's access-log group. Grant the logs write actions
356
+ // across all log groups. Defined inline (not via the
357
+ // AmazonAPIGatewayPushToCloudWatchLogs managed policy) because that
358
+ // managed policy is not available in every partition.
359
+ apiLoggingRole.addToPolicy(
360
+ new PolicyStatement({
361
+ actions: [
362
+ 'logs:CreateLogGroup',
363
+ 'logs:CreateLogStream',
364
+ 'logs:DescribeLogGroups',
365
+ 'logs:DescribeLogStreams',
366
+ 'logs:PutLogEvents',
367
+ ],
368
+ resources: ['*'],
369
+ }),
370
+ );
371
+ // Account-level CloudWatch role for API Gateway. GLOBAL per
372
+ // account+region (see ApiGatewayLoggingLevel caveat above): two stacks
373
+ // in the same account+region both defining this resource will conflict.
374
+ const apiAccount = new CfnAccount(this, 'ApiGatewayAccount', {
375
+ cloudWatchRoleArn: apiLoggingRole.roleArn,
376
+ });
377
+ // APIGW validates the role's CONTENT — trust AND the attached
378
+ // permissions — when it processes the AWS::ApiGateway::Account
379
+ // resource. `addToPolicy` renders as a SEPARATE AWS::IAM::Policy, and
380
+ // the Account only references the role Arn, so CloudFormation can apply
381
+ // the Account after the Role is created but BEFORE the policy attaches.
382
+ // APIGW then assumes a permission-less role and rejects the update:
383
+ // "The role ARN does not have required permissions configured."
384
+ // The explicit dependency forces the policy to be applied first.
385
+ const loggingRolePolicy = apiLoggingRole.node.findChild('DefaultPolicy');
386
+ apiAccount.node.addDependency(loggingRolePolicy);
387
+
261
388
  const stage = new WebSocketStage(this, 'Stage', {
262
389
  webSocketApi,
263
390
  stageName: STAGE_NAME,
264
391
  autoDeploy: true,
265
392
  });
393
+ const cfnStage = stage.node.defaultChild as CfnStage;
394
+ // The Stage's logging config requires the account-wide CloudWatch role
395
+ // to exist first; without this explicit dependency CloudFormation can
396
+ // apply the Stage before the Account resource and APIGW rejects the
397
+ // update: "CloudWatch Logs role ARN must be set in account settings".
398
+ cfnStage.addResourceDependency(apiAccount);
399
+ // Access + execution logging on the stage. Set at the L1 (CfnStage)
400
+ // level because the L2 `accessLogSettings.format` expects an ILogFormat
401
+ // object; the raw CFN `AccessLogSettings.Format` is a plain string.
402
+ // Access log: one line per request — enough to correlate a connect /
403
+ // frame with a connectionId without the verbosity of execution logs.
404
+ cfnStage.accessLogSettings = {
405
+ destinationArn: apiLogGroup.logGroupArn,
406
+ format: JSON.stringify({
407
+ requestId: '$context.requestId',
408
+ routeKey: '$context.routeKey',
409
+ eventType: '$context.eventType',
410
+ connectionId: '$context.connectionId',
411
+ status: '$context.status',
412
+ responseLength: '$context.responseLength',
413
+ requestTime: '$context.requestTime',
414
+ sourceIp: '$context.sourceIp',
415
+ }),
416
+ };
417
+ // Execution logging: the parameter drives LoggingLevel (OFF/INFO/ERROR);
418
+ // DataTraceEnabled logs full request/response bodies when a level is
419
+ // active. This is the layer that shows whether APIGW received and routed
420
+ // a client frame or dropped it pre-Lambda.
421
+ cfnStage.defaultRouteSettings = {
422
+ loggingLevel: apiLoggingLevelParam.valueAsString,
423
+ dataTraceEnabled: true,
424
+ };
266
425
 
267
426
  stage.grantManagementApiAccess(handler);
268
427
  stage.grantManagementApiAccess(pingChecker);
@@ -283,31 +442,40 @@ export class IrcAwsStack extends Stack {
283
442
  pingChecker.addEnvironment(envName, physicalName);
284
443
  }
285
444
 
286
- // Server identity (consumed by the handler's config loader). Sourced
287
- // from the single prop bag and injected into BOTH the handler and the
288
- // ping checker the ping checker needs SERVER_NAME / NETWORK_NAME /
289
- // MOTD to build the same server-shaped config via the shared loader.
290
- // The sweeper does not. MOTD is `\n`-joined so `buildLambdaConfigInput`
291
- // splits it back into the expected `motdLines` array.
445
+ // Server identity is injected into the handler, ping checker, AND the
446
+ // sweeper. All three share `buildDepsFromEnv` at cold start, which calls
447
+ // `loadServerConfigFromLambdaEnv`and that loader hard-requires
448
+ // SERVER_NAME / NETWORK_NAME (and reads MOTD). Omitting them on the
449
+ // sweeper made every scheduled invocation throw "serverName: Required".
450
+ // Values flow through the CloudFormation parameters ({ Ref }) so a
451
+ // `--parameters` update rolls all four Lambdas with no code change.
452
+ // MOTD is `\n`-joined so `buildLambdaConfigInput` splits it back into
453
+ // the expected `motdLines` array.
292
454
  handler.addEnvironment('SERVER_NAME', serverName);
293
455
  handler.addEnvironment('NETWORK_NAME', networkName);
294
456
  handler.addEnvironment('MOTD', motd);
295
457
  pingChecker.addEnvironment('SERVER_NAME', serverName);
296
458
  pingChecker.addEnvironment('NETWORK_NAME', networkName);
297
459
  pingChecker.addEnvironment('MOTD', motd);
298
-
299
- // EventBridge schedule → sweeper Lambda. Fixed rate per the architecture
300
- // doc; the sweeper is self-throttling (Scans + per-row cleanupConnection).
460
+ sweeper.addEnvironment('SERVER_NAME', serverName);
461
+ sweeper.addEnvironment('NETWORK_NAME', networkName);
462
+ sweeper.addEnvironment('MOTD', motd);
463
+
464
+ // EventBridge schedule → sweeper Lambda. The schedule expression comes
465
+ // from a CloudFormation Parameter (see above), so it can be overridden
466
+ // at deploy time; the sweeper is self-throttling
467
+ // (Scans + per-row cleanupConnection).
301
468
  new Rule(this, 'SweeperSchedule', {
302
- schedule: Schedule.rate(SWEEPER_SCHEDULE_RATE),
469
+ schedule: Schedule.expression(sweeperScheduleParam.valueAsString),
303
470
  targets: [new LambdaFunction(sweeper)],
304
471
  });
305
472
 
306
- // EventBridge schedule → idle / PING checker Lambda. Fixed rate(1 minute)
307
- // so an idle connection sees its first PING within one tick of the
308
- // configured `DEFAULT_PING_INTERVAL_MS` (60 s).
473
+ // EventBridge schedule → idle / PING checker Lambda. Schedule expression
474
+ // is a deploy-time Parameter; the default (`rate(1 minute)`) means an
475
+ // idle connection sees its first PING within one tick of the configured
476
+ // `DEFAULT_PING_INTERVAL_MS` (60 s).
309
477
  new Rule(this, 'PingCheckerSchedule', {
310
- schedule: Schedule.rate(PING_CHECKER_SCHEDULE_RATE),
478
+ schedule: Schedule.expression(pingCheckerScheduleParam.valueAsString),
311
479
  targets: [new LambdaFunction(pingChecker)],
312
480
  });
313
481