serverless-ircd 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +352 -0
- package/MOTD.txt +3 -0
- package/PLAN-FIXES.md +358 -0
- package/PLAN.md +420 -0
- package/README.md +115 -81
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.toml +25 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +56 -0
- package/apps/local-cli/src/server.ts +241 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +74 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/dashboards/cloudwatch-irc.json +139 -0
- package/docs/AWS-Adapter-Architecture.md +494 -0
- package/docs/AWS-Deployment.md +1107 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +1 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +440 -0
- package/package.json +8 -2
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/aws-runtime.ts +783 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
- package/packages/aws-adapter/src/config-loader.ts +96 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +322 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
- package/packages/aws-adapter/src/handlers/index.ts +248 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +67 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +427 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/config-loader.ts +106 -0
- package/packages/cf-adapter/src/connection-do.ts +34 -0
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
- package/packages/irc-core/package.json +12 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +7 -0
- package/packages/irc-core/src/commands/join.ts +59 -7
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +71 -14
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/config.ts +247 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +5 -0
- package/packages/irc-core/src/ports.ts +655 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +66 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +277 -9
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/config.test.ts +646 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +162 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +151 -0
- package/packages/irc-server/tests/actor.test.ts +470 -14
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +201 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/progress.md +107 -0
- package/tickets.md +2485 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/webircgateway/LICENSE +201 -0
- package/webircgateway/Makefile +44 -0
- package/webircgateway/README.md +134 -0
- package/webircgateway/config.conf.example +135 -0
- package/webircgateway/go.mod +16 -0
- package/webircgateway/go.sum +89 -0
- package/webircgateway/main.go +118 -0
- package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
- package/webircgateway/pkg/identd/identd.go +86 -0
- package/webircgateway/pkg/identd/rpcclient.go +59 -0
- package/webircgateway/pkg/irc/isupport.go +56 -0
- package/webircgateway/pkg/irc/message.go +217 -0
- package/webircgateway/pkg/irc/state.go +79 -0
- package/webircgateway/pkg/proxy/proxy.go +129 -0
- package/webircgateway/pkg/proxy/server.go +237 -0
- package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
- package/webircgateway/pkg/webircgateway/client.go +741 -0
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
- package/webircgateway/pkg/webircgateway/config.go +385 -0
- package/webircgateway/pkg/webircgateway/gateway.go +278 -0
- package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
- package/webircgateway/pkg/webircgateway/hooks.go +152 -0
- package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
- package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
- package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
- package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
- package/webircgateway/pkg/webircgateway/utils.go +147 -0
- package/webircgateway/plugins/example/plugin.go +11 -0
- package/webircgateway/plugins/stats/plugin.go +52 -0
- package/webircgateway/staticcheck.conf +1 -0
- package/webircgateway/webircgateway.svg +3 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CI smoke e2e for a deployed AWS API Gateway WebSocket stack.
|
|
4
|
+
*
|
|
5
|
+
* Replays the same CONNECT → REGISTER → JOIN → PRIVMSG → QUIT flow as the
|
|
6
|
+
* cf-worker smoke script, but against a live deployed API Gateway WebSocket
|
|
7
|
+
* endpoint. Exits non-zero on any failure so the GitHub Actions deploy job
|
|
8
|
+
* fails the build.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node scripts/smoke.mjs --url wss://abc123.execute-api.us-east-1.amazonaws.com/prod
|
|
12
|
+
*
|
|
13
|
+
* Defaults to `ws://localhost:8787` so the same script works against a local
|
|
14
|
+
* sam-local / localstack dev session.
|
|
15
|
+
*
|
|
16
|
+
* Dependencies: only the `ws` WebSocket client (a transitive dev dep). The
|
|
17
|
+
* script is plain Node ESM — no TypeScript transform needed in CI.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { WebSocket } from 'ws';
|
|
21
|
+
import { DEFAULT_SMOKE_URL, numericEquals, parseArgs, waitForPredicate } from './smoke-helpers.mjs';
|
|
22
|
+
|
|
23
|
+
const POLL_MS = 50;
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
25
|
+
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = parseArgs(process.argv.slice(2));
|
|
29
|
+
} catch (err) {
|
|
30
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (parsed.help) {
|
|
35
|
+
process.stdout.write(
|
|
36
|
+
[
|
|
37
|
+
'Usage: node scripts/smoke.mjs --url wss://<api-gateway-websocket-url>',
|
|
38
|
+
'',
|
|
39
|
+
'Options:',
|
|
40
|
+
' --url <ws-url> WebSocket URL of the deployed stack.',
|
|
41
|
+
' Defaults to ws://localhost:8787 (local dev).',
|
|
42
|
+
' -h, --help Show this help.',
|
|
43
|
+
'',
|
|
44
|
+
].join('\n'),
|
|
45
|
+
);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const url = parsed.url ?? DEFAULT_SMOKE_URL;
|
|
50
|
+
|
|
51
|
+
runSmoke().catch((err) => {
|
|
52
|
+
process.stderr.write(
|
|
53
|
+
`${JSON.stringify({
|
|
54
|
+
ts: new Date().toISOString(),
|
|
55
|
+
level: 'error',
|
|
56
|
+
msg: 'aws-stack smoke e2e failed',
|
|
57
|
+
url,
|
|
58
|
+
error: err instanceof Error ? err.message : String(err),
|
|
59
|
+
})}\n`,
|
|
60
|
+
);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
async function runSmoke() {
|
|
65
|
+
const ws = await openSocket(url);
|
|
66
|
+
const received = [];
|
|
67
|
+
|
|
68
|
+
ws.on('message', (data) => {
|
|
69
|
+
const text = data.toString();
|
|
70
|
+
for (const line of text.split('\r\n')) {
|
|
71
|
+
if (line.length > 0) received.push(line);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const waitForNumeric = (code, timeoutMs = DEFAULT_TIMEOUT_MS) =>
|
|
76
|
+
waitForPredicate(
|
|
77
|
+
(line) => numericEquals(line, code),
|
|
78
|
+
`numeric ${code}`,
|
|
79
|
+
received,
|
|
80
|
+
timeoutMs,
|
|
81
|
+
POLL_MS,
|
|
82
|
+
);
|
|
83
|
+
const waitForLine = (predicate, label, timeoutMs = DEFAULT_TIMEOUT_MS) =>
|
|
84
|
+
waitForPredicate(predicate, label, received, timeoutMs, POLL_MS);
|
|
85
|
+
|
|
86
|
+
// REGISTER
|
|
87
|
+
ws.send('NICK smoke\r\n');
|
|
88
|
+
ws.send('USER smoke 0 * :Smoke Test\r\n');
|
|
89
|
+
await waitForNumeric('001', DEFAULT_TIMEOUT_MS);
|
|
90
|
+
await waitForNumeric('376', DEFAULT_TIMEOUT_MS);
|
|
91
|
+
|
|
92
|
+
// JOIN
|
|
93
|
+
ws.send('JOIN #smoke\r\n');
|
|
94
|
+
await waitForLine((l) => l.includes('JOIN') && l.includes('#smoke'), 'JOIN echo');
|
|
95
|
+
await waitForNumeric('353', DEFAULT_TIMEOUT_MS);
|
|
96
|
+
await waitForNumeric('366', DEFAULT_TIMEOUT_MS);
|
|
97
|
+
|
|
98
|
+
// PRIVMSG (no echo expected — echo-message is opt-in via CAP)
|
|
99
|
+
ws.send('PRIVMSG #smoke :hello from ci\r\n');
|
|
100
|
+
|
|
101
|
+
// QUIT — expect the server to close the socket
|
|
102
|
+
await new Promise((resolve, reject) => {
|
|
103
|
+
const timer = setTimeout(resolve, DEFAULT_TIMEOUT_MS);
|
|
104
|
+
ws.once('close', () => {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
resolve();
|
|
107
|
+
});
|
|
108
|
+
ws.once('error', (err) => {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
reject(new Error(`socket error during QUIT: ${err.message}`));
|
|
111
|
+
});
|
|
112
|
+
ws.send('QUIT :smoke test complete\r\n');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
ws.close();
|
|
116
|
+
process.stdout.write(
|
|
117
|
+
`${JSON.stringify({
|
|
118
|
+
ts: new Date().toISOString(),
|
|
119
|
+
level: 'info',
|
|
120
|
+
msg: 'aws-stack smoke e2e passed',
|
|
121
|
+
url,
|
|
122
|
+
})}\n`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function openSocket(targetUrl) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const ws = new WebSocket(targetUrl);
|
|
129
|
+
const timer = setTimeout(() => {
|
|
130
|
+
ws.terminate();
|
|
131
|
+
reject(new Error(`connect timeout: ${targetUrl}`));
|
|
132
|
+
}, DEFAULT_TIMEOUT_MS);
|
|
133
|
+
ws.once('open', () => {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
resolve(ws);
|
|
136
|
+
});
|
|
137
|
+
ws.once('error', (err) => {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
reject(new Error(`connect failed: ${err.message}`));
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `apps/aws-stack` CDK stack — the AWS substrate for ServerlessIRCd (PLAN §6.2).
|
|
3
|
+
*
|
|
4
|
+
* • 5 DynamoDB tables sourced from `@serverless-ircd/aws-adapter/cdk-table-defs`
|
|
5
|
+
* (Connections, ChannelMeta, ChannelMembers, Nicks, Accounts).
|
|
6
|
+
* • ONE Node 20 `NodejsFunction` bundling the AWS Lambda handler
|
|
7
|
+
* (`$connect` / `$disconnect` / `$default` dispatched on
|
|
8
|
+
* `event.requestContext.routeKey`).
|
|
9
|
+
* • ONE Node 20 `NodejsFunction` for the gone-connection sweeper
|
|
10
|
+
* (`sweeperHandler`), invoked on a fixed EventBridge schedule.
|
|
11
|
+
* • API Gateway v2 WebSocket API + auto-deploying `prod` stage.
|
|
12
|
+
* • Least-privilege IAM: scoped DynamoDB data-plane grants per table +
|
|
13
|
+
* `execute-api:ManageConnections` scoped to this API's stage connections.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { TABLE_DEFS } from '@serverless-ircd/aws-adapter/cdk-table-defs';
|
|
19
|
+
import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
|
|
20
|
+
import { WebSocketApi, WebSocketStage } from 'aws-cdk-lib/aws-apigatewayv2';
|
|
21
|
+
import { WebSocketLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
|
|
22
|
+
import { Table } from 'aws-cdk-lib/aws-dynamodb';
|
|
23
|
+
import { Rule, Schedule } from 'aws-cdk-lib/aws-events';
|
|
24
|
+
import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets';
|
|
25
|
+
import { Runtime } from 'aws-cdk-lib/aws-lambda';
|
|
26
|
+
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
|
|
27
|
+
import type { Construct } from 'constructs';
|
|
28
|
+
|
|
29
|
+
/** Stage name served by the auto-deploying WebSocket stage. */
|
|
30
|
+
const STAGE_NAME = 'prod';
|
|
31
|
+
|
|
32
|
+
/** Fixed rate for the gone-connection sweeper (matches the architecture doc). */
|
|
33
|
+
const SWEEPER_SCHEDULE_RATE = Duration.minutes(5);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fixed rate for the idle / PING checker. One minute is the finest
|
|
37
|
+
* granularity EventBridge `rate(...)` expressions support, which
|
|
38
|
+
* matches the default `DEFAULT_PING_INTERVAL_MS` (60 s).
|
|
39
|
+
*/
|
|
40
|
+
const PING_CHECKER_SCHEDULE_RATE = Duration.minutes(1);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Environment assumed when {@link IrcStackProps.environmentName} is
|
|
44
|
+
* omitted. Keeps the existing deploy pipeline green without requiring
|
|
45
|
+
* callers to pass the flag.
|
|
46
|
+
*/
|
|
47
|
+
const DEFAULT_ENVIRONMENT_NAME = 'staging';
|
|
48
|
+
|
|
49
|
+
/** Default server identity, used when the corresponding prop is omitted. */
|
|
50
|
+
const DEFAULT_SERVER_NAME = 'irc.example.com';
|
|
51
|
+
const DEFAULT_NETWORK_NAME = 'ExampleNet';
|
|
52
|
+
const DEFAULT_MOTD_LINES = [
|
|
53
|
+
'Welcome to the ServerlessIRCd deployment.',
|
|
54
|
+
'This server runs on AWS Lambda.',
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Construct props for {@link IrcAwsStack}. Acts as the single
|
|
61
|
+
* configuration bag extended by deploy-time knobs (environment name,
|
|
62
|
+
* server identity, …) so the prop surface grows in one place.
|
|
63
|
+
*/
|
|
64
|
+
export interface IrcStackProps extends StackProps {
|
|
65
|
+
/**
|
|
66
|
+
* Logical deployment environment (e.g. `staging`, `production`).
|
|
67
|
+
*
|
|
68
|
+
* Prefixes every physical DynamoDB table name
|
|
69
|
+
* (`<Env><LogicalId>` — first letter capitalised, e.g.
|
|
70
|
+
* `StagingConnections`) and is intended to be folded into the
|
|
71
|
+
* CloudFormation stack id by the caller
|
|
72
|
+
* (`IrcAwsStack-<environmentName>`). Defaults to `staging`.
|
|
73
|
+
*/
|
|
74
|
+
readonly environmentName?: string;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Server name advertised to clients (`SERVER_NAME` / the `001`
|
|
78
|
+
* welcome source). Defaults to `irc.example.com`.
|
|
79
|
+
*/
|
|
80
|
+
readonly serverName?: string;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Network name advertised in `ISUPPORT` (`NETWORK`). Defaults to
|
|
84
|
+
* `ExampleNet`.
|
|
85
|
+
*/
|
|
86
|
+
readonly networkName?: string;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Lines of the Message of the Day. Joined with `\n` so the Lambda
|
|
90
|
+
* config loader (`buildLambdaConfigInput`) round-trips them back into
|
|
91
|
+
* a `string[]` correctly. Defaults to a short two-line banner.
|
|
92
|
+
*/
|
|
93
|
+
readonly motdLines?: string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class IrcAwsStack extends Stack {
|
|
97
|
+
constructor(scope: Construct, id: string, props: IrcStackProps = {}) {
|
|
98
|
+
super(scope, id, props);
|
|
99
|
+
|
|
100
|
+
const environmentName = props.environmentName ?? DEFAULT_ENVIRONMENT_NAME;
|
|
101
|
+
const serverName = props.serverName ?? DEFAULT_SERVER_NAME;
|
|
102
|
+
const networkName = props.networkName ?? DEFAULT_NETWORK_NAME;
|
|
103
|
+
const motd = (props.motdLines ?? DEFAULT_MOTD_LINES).join('\n');
|
|
104
|
+
|
|
105
|
+
// Prefix every physical table name with the environment so parallel
|
|
106
|
+
// envs (staging + production) cannot collide in one account+region.
|
|
107
|
+
// The first letter is capitalised to compose cleanly with the
|
|
108
|
+
// PascalCase logical ids (`staging` → `StagingConnections`).
|
|
109
|
+
const tables = Object.fromEntries(
|
|
110
|
+
Object.entries(TABLE_DEFS).map(([logicalId, tableProps]) => {
|
|
111
|
+
const table = new Table(this, logicalId, {
|
|
112
|
+
...tableProps,
|
|
113
|
+
tableName: prefixedTableName(environmentName, logicalId),
|
|
114
|
+
removalPolicy: RemovalPolicy.DESTROY,
|
|
115
|
+
});
|
|
116
|
+
return [logicalId, table];
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const handler = new NodejsFunction(this, 'IrcHandler', {
|
|
121
|
+
runtime: Runtime.NODEJS_20_X,
|
|
122
|
+
entry: handlerEntry(),
|
|
123
|
+
handler: 'handler',
|
|
124
|
+
bundling: {
|
|
125
|
+
// AWS SDK v3 is in the Lambda Node 20 runtime; exclude it so the
|
|
126
|
+
// bundle stays small. irc-core / irc-server are bundled because
|
|
127
|
+
// they are first-party.
|
|
128
|
+
externalModules: ['@aws-sdk/*'],
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Sweeper Lambda — same entry, different handler export. Shares the
|
|
133
|
+
// bundle (tree-shaken to the sweeper path by esbuild at synth time).
|
|
134
|
+
// Catches connections that vanished without APIGW emitting $disconnect;
|
|
135
|
+
// see packages/aws-adapter/src/handlers/sweeper.ts.
|
|
136
|
+
const sweeper = new NodejsFunction(this, 'IrcSweeper', {
|
|
137
|
+
runtime: Runtime.NODEJS_20_X,
|
|
138
|
+
entry: handlerEntry(),
|
|
139
|
+
handler: 'sweeperHandler',
|
|
140
|
+
bundling: {
|
|
141
|
+
externalModules: ['@aws-sdk/*'],
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Idle / PING checker Lambda — same entry, different handler export.
|
|
146
|
+
// Sends a server-initiated PING to idle connections and disconnects
|
|
147
|
+
// those that never answer; see
|
|
148
|
+
// packages/aws-adapter/src/handlers/ping-checker.ts. The Cloudflare
|
|
149
|
+
// adapter implements the equivalent flow inside `ConnectionDO.alarm()`.
|
|
150
|
+
const pingChecker = new NodejsFunction(this, 'IrcPingChecker', {
|
|
151
|
+
runtime: Runtime.NODEJS_20_X,
|
|
152
|
+
entry: handlerEntry(),
|
|
153
|
+
handler: 'pingCheckerHandler',
|
|
154
|
+
bundling: {
|
|
155
|
+
externalModules: ['@aws-sdk/*'],
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
for (const table of Object.values(tables)) {
|
|
160
|
+
table.grantReadWriteData(handler);
|
|
161
|
+
table.grantReadWriteData(sweeper);
|
|
162
|
+
table.grantReadWriteData(pingChecker);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const webSocketApi = new WebSocketApi(this, 'IrcWsApi', {
|
|
166
|
+
connectRouteOptions: {
|
|
167
|
+
integration: new WebSocketLambdaIntegration('ConnectIntegration', handler),
|
|
168
|
+
},
|
|
169
|
+
disconnectRouteOptions: {
|
|
170
|
+
integration: new WebSocketLambdaIntegration('DisconnectIntegration', handler),
|
|
171
|
+
},
|
|
172
|
+
defaultRouteOptions: {
|
|
173
|
+
integration: new WebSocketLambdaIntegration('DefaultIntegration', handler),
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// The stage name is intentionally NOT suffixed with the environment:
|
|
178
|
+
// each environment is its own CloudFormation stack (and therefore its
|
|
179
|
+
// own WebSocket API), so `prod` in every env resolves to a distinct
|
|
180
|
+
// endpoint. Keeping a fixed stage keeps client connect URLs stable
|
|
181
|
+
// and identical in shape across environments.
|
|
182
|
+
const stage = new WebSocketStage(this, 'Stage', {
|
|
183
|
+
webSocketApi,
|
|
184
|
+
stageName: STAGE_NAME,
|
|
185
|
+
autoDeploy: true,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
stage.grantManagementApiAccess(handler);
|
|
189
|
+
stage.grantManagementApiAccess(pingChecker);
|
|
190
|
+
handler.addEnvironment('MANAGEMENT_URL', stage.callbackUrl);
|
|
191
|
+
pingChecker.addEnvironment('MANAGEMENT_URL', stage.callbackUrl);
|
|
192
|
+
|
|
193
|
+
// Inject each table's physical name as an env var so every Lambda can
|
|
194
|
+
// construct a `TablesConfig` at cold start via `buildDepsFromEnv`. The
|
|
195
|
+
// env-var value is the environment-prefixed physical name (computed
|
|
196
|
+
// through the same helper as the Table construct so they match
|
|
197
|
+
// exactly), NOT a CloudFormation Ref — the runtime needs the literal
|
|
198
|
+
// per-env table name to target the correct table.
|
|
199
|
+
for (const logicalId of Object.keys(TABLE_DEFS)) {
|
|
200
|
+
const envName = `${logicalId.toUpperCase()}_TABLE`;
|
|
201
|
+
const physicalName = prefixedTableName(environmentName, logicalId);
|
|
202
|
+
handler.addEnvironment(envName, physicalName);
|
|
203
|
+
sweeper.addEnvironment(envName, physicalName);
|
|
204
|
+
pingChecker.addEnvironment(envName, physicalName);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Server identity (consumed by the handler's config loader). Sourced
|
|
208
|
+
// from the single prop bag and injected into BOTH the handler and the
|
|
209
|
+
// ping checker — the ping checker needs SERVER_NAME / NETWORK_NAME /
|
|
210
|
+
// MOTD to build the same server-shaped config via the shared loader.
|
|
211
|
+
// The sweeper does not. MOTD is `\n`-joined so `buildLambdaConfigInput`
|
|
212
|
+
// splits it back into the expected `motdLines` array.
|
|
213
|
+
handler.addEnvironment('SERVER_NAME', serverName);
|
|
214
|
+
handler.addEnvironment('NETWORK_NAME', networkName);
|
|
215
|
+
handler.addEnvironment('MOTD', motd);
|
|
216
|
+
pingChecker.addEnvironment('SERVER_NAME', serverName);
|
|
217
|
+
pingChecker.addEnvironment('NETWORK_NAME', networkName);
|
|
218
|
+
pingChecker.addEnvironment('MOTD', motd);
|
|
219
|
+
|
|
220
|
+
// EventBridge schedule → sweeper Lambda. Fixed rate per the architecture
|
|
221
|
+
// doc; the sweeper is self-throttling (Scans + per-row cleanupConnection).
|
|
222
|
+
new Rule(this, 'SweeperSchedule', {
|
|
223
|
+
schedule: Schedule.rate(SWEEPER_SCHEDULE_RATE),
|
|
224
|
+
targets: [new LambdaFunction(sweeper)],
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// EventBridge schedule → idle / PING checker Lambda. Fixed rate(1 minute)
|
|
228
|
+
// so an idle connection sees its first PING within one tick of the
|
|
229
|
+
// configured `DEFAULT_PING_INTERVAL_MS` (60 s).
|
|
230
|
+
new Rule(this, 'PingCheckerSchedule', {
|
|
231
|
+
schedule: Schedule.rate(PING_CHECKER_SCHEDULE_RATE),
|
|
232
|
+
targets: [new LambdaFunction(pingChecker)],
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
new CfnOutput(this, 'ConnectUrl', {
|
|
236
|
+
description: 'WebSocket connect endpoint clients dial (wss).',
|
|
237
|
+
value: stage.url,
|
|
238
|
+
});
|
|
239
|
+
new CfnOutput(this, 'ManagementUrl', {
|
|
240
|
+
description: 'HTTPS management endpoint for ApiGatewayManagementApi.postToConnection.',
|
|
241
|
+
value: stage.callbackUrl,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Absolute path to the Lambda handler entry (`packages/aws-adapter/src/handlers/index.ts`).
|
|
248
|
+
* Computed relative to this file so `cdk synth` works from any CWD.
|
|
249
|
+
*/
|
|
250
|
+
function handlerEntry(): string {
|
|
251
|
+
return resolve(HERE, '..', '..', '..', 'packages', 'aws-adapter', 'src', 'handlers', 'index.ts');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Builds the physical DynamoDB table name for a logical id under the
|
|
256
|
+
* given environment. Capitalises the environment's first letter so it
|
|
257
|
+
* composes cleanly with PascalCase logical ids
|
|
258
|
+
* (`staging` + `Connections` → `StagingConnections`).
|
|
259
|
+
*/
|
|
260
|
+
function prefixedTableName(environmentName: string, logicalId: string): string {
|
|
261
|
+
const cap = environmentName.charAt(0).toUpperCase() + environmentName.slice(1);
|
|
262
|
+
return `${cap}${logicalId}`;
|
|
263
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB table schema definitions are owned by `@serverless-ircd/aws-adapter`
|
|
3
|
+
* so the runtime and the CDK stack share a single source of truth.
|
|
4
|
+
*
|
|
5
|
+
* Re-exported here for backwards compatibility with anything importing from
|
|
6
|
+
* `apps/aws-stack/src/tables.js`. New code should import directly from
|
|
7
|
+
* `@serverless-ircd/aws-adapter/cdk-table-defs`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { TABLE_DEFS } from '@serverless-ircd/aws-adapter/cdk-table-defs';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localstack-backed deploy-time validation (gated).
|
|
3
|
+
*
|
|
4
|
+
* This is the "deploy contract" companion to `stack.test.ts`'s synth-time
|
|
5
|
+
* contract. It runs the real `cdklocal` CLI against a localstack container
|
|
6
|
+
* and asserts the synthesized template still contains all five tables —
|
|
7
|
+
* catching drift between the CDK constructs and what the localstack-aware
|
|
8
|
+
* synthesizer emits.
|
|
9
|
+
*
|
|
10
|
+
* Gated behind `LOCALSTACK=1` so it never runs in the default `pnpm test`
|
|
11
|
+
* (and therefore never blocks CI on a missing Docker daemon). See the
|
|
12
|
+
* package README for setup instructions.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execSync } from 'node:child_process';
|
|
16
|
+
import { mkdtempSync, readFileSync, readdirSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { describe, expect, it } from 'vitest';
|
|
21
|
+
|
|
22
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
|
|
24
|
+
describe.skipIf(!process.env.LOCALSTACK)('localstack', () => {
|
|
25
|
+
it('synths five DynamoDB tables when run through cdklocal', () => {
|
|
26
|
+
const outDir = mkdtempSync(join(tmpdir(), 'aws-stack-localstack-'));
|
|
27
|
+
execSync(`cdklocal synth --output "${outDir}"`, {
|
|
28
|
+
cwd: PKG_ROOT,
|
|
29
|
+
env: { ...process.env, CDK_CONTEXT_JSON: '{}' },
|
|
30
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const templateFile = readdirSync(outDir).find((f) => f.endsWith('.template.json'));
|
|
34
|
+
if (!templateFile) {
|
|
35
|
+
throw new Error('cdklocal produced no synthesized template');
|
|
36
|
+
}
|
|
37
|
+
const template = JSON.parse(readFileSync(join(outDir, templateFile), 'utf8')) as {
|
|
38
|
+
Resources?: Record<string, { Type: string }>;
|
|
39
|
+
};
|
|
40
|
+
const tables = Object.values(template.Resources ?? {}).filter(
|
|
41
|
+
(r) => r.Type === 'AWS::DynamoDB::Table',
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(tables).toHaveLength(5);
|
|
45
|
+
}, 60_000);
|
|
46
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the pure helpers backing the AWS staging smoke e2e
|
|
3
|
+
* (`scripts/smoke.mjs`). The helpers live in `scripts/smoke-helpers.mjs`
|
|
4
|
+
* (plain ESM, no TS transform) so they can run in CI without a build step;
|
|
5
|
+
* this file is the red→green driver — it imports them via a sibling
|
|
6
|
+
* declaration so the workspace typecheck still passes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import {
|
|
11
|
+
type ParseResult,
|
|
12
|
+
numericEquals,
|
|
13
|
+
parseArgs,
|
|
14
|
+
waitForPredicate,
|
|
15
|
+
} from '../scripts/smoke-helpers.mjs';
|
|
16
|
+
|
|
17
|
+
describe('numericEquals', () => {
|
|
18
|
+
it('matches a bare numeric line with no server prefix', () => {
|
|
19
|
+
expect(numericEquals('001 :Welcome to the network', '001')).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('matches a numeric line carrying a :server prefix', () => {
|
|
23
|
+
expect(numericEquals(':irc.example.com 376 smoke :End of MOTD', '376')).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns false when a different numeric occupies the command slot', () => {
|
|
27
|
+
expect(numericEquals(':irc.example.com 375 smoke :MOTD', '376')).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('does not false-positive on a numeric embedded in the trailing text', () => {
|
|
31
|
+
expect(numericEquals(':nick PRIVMSG #chan :001 is not the command', '001')).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('parseArgs', () => {
|
|
36
|
+
it('defaults the url to localhost when no --url is supplied', () => {
|
|
37
|
+
expect(parseArgs([])).toStrictEqual<ParseResult>({ url: 'ws://localhost:8787', help: false });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('reads the value following --url', () => {
|
|
41
|
+
expect(parseArgs(['--url', 'wss://irc.example.com/prod'])).toStrictEqual<ParseResult>({
|
|
42
|
+
url: 'wss://irc.example.com/prod',
|
|
43
|
+
help: false,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('signals help for -h', () => {
|
|
48
|
+
expect(parseArgs(['-h']).help).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('signals help for --help', () => {
|
|
52
|
+
expect(parseArgs(['--help']).help).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('throws on an unknown argument', () => {
|
|
56
|
+
expect(() => parseArgs(['--bogus'])).toThrow(/unknown argument/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('throws when --url is given without a value', () => {
|
|
60
|
+
expect(() => parseArgs(['--url'])).toThrow(/--url/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('tolerates a leading -- options terminator (pnpm script passthrough)', () => {
|
|
64
|
+
// `pnpm smoke:aws:staging -- --url wss://x` forwards `['--', '--url', 'wss://x']`.
|
|
65
|
+
expect(parseArgs(['--', '--url', 'wss://irc.example.com/prod'])).toStrictEqual<ParseResult>({
|
|
66
|
+
url: 'wss://irc.example.com/prod',
|
|
67
|
+
help: false,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('waitForPredicate', () => {
|
|
73
|
+
it('resolves with the first received line that satisfies the predicate', async () => {
|
|
74
|
+
const received = [':srv 001 smoke :Welcome', ':srv 376 smoke :End of MOTD'];
|
|
75
|
+
const line = await waitForPredicate(
|
|
76
|
+
(l) => numericEquals(l, '376'),
|
|
77
|
+
'numeric 376',
|
|
78
|
+
received,
|
|
79
|
+
1000,
|
|
80
|
+
5,
|
|
81
|
+
);
|
|
82
|
+
expect(line).toBe(':srv 376 smoke :End of MOTD');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects with a message naming the label and dumping the received buffer on timeout', async () => {
|
|
86
|
+
const received = [':srv 001 smoke :Welcome'];
|
|
87
|
+
await expect(
|
|
88
|
+
waitForPredicate((l) => numericEquals(l, '376'), 'numeric 376', received, 20, 5),
|
|
89
|
+
).rejects.toThrow(/timed out waiting for numeric 376/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('includes the received lines in the timeout error for diagnostics', async () => {
|
|
93
|
+
const received = [':srv 001 smoke :Welcome'];
|
|
94
|
+
await expect(
|
|
95
|
+
waitForPredicate((l) => numericEquals(l, '376'), 'numeric 376', received, 20, 5),
|
|
96
|
+
).rejects.toThrow(/:srv 001 smoke :Welcome/);
|
|
97
|
+
});
|
|
98
|
+
});
|