serverless-ircd 0.3.0 → 0.5.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 +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +349 -18
- package/README.md +132 -30
- package/apps/aws-stack/README.md +6 -5
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +150 -10
- package/apps/aws-stack/tests/stack.test.ts +145 -4
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.test.toml +6 -0
- package/apps/cf-worker/wrangler.toml +28 -2
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +10 -0
- package/apps/local-cli/src/server.ts +149 -24
- package/apps/local-cli/tests/e2e.test.ts +1 -1
- package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
- package/package.json +14 -10
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +1 -1
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +75 -4
- package/packages/aws-adapter/src/config-loader.ts +32 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
- package/packages/aws-adapter/src/handlers/connect.ts +96 -9
- package/packages/aws-adapter/src/handlers/default.ts +98 -7
- package/packages/aws-adapter/src/handlers/index.ts +109 -4
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +8 -0
- package/packages/aws-adapter/src/serialize.ts +11 -1
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
- package/packages/aws-adapter/tests/account-store.test.ts +19 -20
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +174 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +302 -53
- package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +5 -1
- package/packages/cf-adapter/src/cf-runtime.ts +68 -5
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +278 -85
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +54 -11
- package/packages/cf-adapter/src/index.ts +11 -8
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +12 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
- package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +15 -8
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/wrangler.test.toml +15 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +6 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +24 -3
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +17 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/isupport.ts +6 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +4 -8
- package/packages/irc-core/src/commands/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -0
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/userhost.ts +84 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +65 -0
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +33 -30
- package/packages/irc-core/src/index.ts +3 -0
- package/packages/irc-core/src/ports.ts +360 -12
- package/packages/irc-core/src/protocol/numerics.ts +48 -11
- package/packages/irc-core/src/protocol/outbound.ts +20 -3
- package/packages/irc-core/src/types.ts +46 -2
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/tests/account-store.test.ts +45 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -0
- package/packages/irc-core/tests/commands/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +57 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +151 -6
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +95 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +102 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +393 -16
- package/packages/irc-server/src/dispatch.ts +1 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +15 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +104 -0
- package/packages/irc-server/tests/actor.test.ts +1489 -4
- package/packages/irc-server/tests/dispatch.test.ts +37 -17
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +5 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +141 -3
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +10 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +9 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +107 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
- package/docs/ADR-002-location-of-authority.md +0 -82
- package/docs/ADR-003-durable-object-sharding.md +0 -93
- package/docs/ADR-004-dynamodb-schema.md +0 -96
- package/docs/ADR-005-wss-only-transport-v1.md +0 -83
- package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
- package/docs/ADR-007-deterministic-ports.md +0 -82
- package/docs/ADR-008-monorepo-tooling.md +0 -60
- package/docs/AWS-Adapter-Architecture.md +0 -496
- package/docs/AWS-Deployment.md +0 -1186
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -11
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -443
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
2
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
3
|
+
import { type Clock, createConnection } from '@serverless-ircd/irc-core';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { AwsRuntime } from '../src/aws-runtime.js';
|
|
6
|
+
import type { AwsRuntimeHandlers } from '../src/aws-runtime.js';
|
|
7
|
+
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
8
|
+
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
9
|
+
import { AwsStats } from '../src/stats.js';
|
|
10
|
+
import type { TablesConfig } from '../src/tables.js';
|
|
11
|
+
|
|
12
|
+
const available = process.env.DDB_AVAILABLE === '1';
|
|
13
|
+
const endpoint = process.env.DYNAMO_ENDPOINT;
|
|
14
|
+
|
|
15
|
+
interface Fixture {
|
|
16
|
+
tables: TablesConfig;
|
|
17
|
+
client: ReturnType<typeof createDynamoDocumentClient>;
|
|
18
|
+
cleanup: () => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let fx: Fixture;
|
|
22
|
+
|
|
23
|
+
async function makeFixture(): Promise<Fixture> {
|
|
24
|
+
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
25
|
+
const prefix = `t${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
|
|
26
|
+
const tables = Object.fromEntries(
|
|
27
|
+
Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
|
|
28
|
+
) as TablesConfig;
|
|
29
|
+
const client = createDynamoDocumentClient({ endpoint });
|
|
30
|
+
for (const [logical, props] of Object.entries(TABLE_DEFS)) {
|
|
31
|
+
const pkName = props.partitionKey?.name;
|
|
32
|
+
const skName = props.sortKey?.name;
|
|
33
|
+
if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
|
|
34
|
+
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
|
|
35
|
+
{ AttributeName: pkName, AttributeType: 'S' },
|
|
36
|
+
];
|
|
37
|
+
const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
38
|
+
{ AttributeName: pkName, KeyType: 'HASH' },
|
|
39
|
+
];
|
|
40
|
+
if (skName !== undefined) {
|
|
41
|
+
attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
|
|
42
|
+
keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
|
|
43
|
+
}
|
|
44
|
+
await client.send(
|
|
45
|
+
new CreateTableCommand({
|
|
46
|
+
TableName: `${prefix}${logical}`,
|
|
47
|
+
AttributeDefinitions: attributeDefinitions,
|
|
48
|
+
KeySchema: keySchema,
|
|
49
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
tables,
|
|
55
|
+
client,
|
|
56
|
+
cleanup: async () => {
|
|
57
|
+
for (const logical of Object.keys(TABLE_DEFS)) {
|
|
58
|
+
await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function noopHandlers(): AwsRuntimeHandlers {
|
|
65
|
+
return {
|
|
66
|
+
send: () => {},
|
|
67
|
+
disconnect: () => {},
|
|
68
|
+
snapshot: () => undefined,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function makeRuntime(connId: string, clockFn: () => number = () => 1_000): AwsRuntime {
|
|
73
|
+
const clock: Clock = { now: clockFn };
|
|
74
|
+
return new AwsRuntime({
|
|
75
|
+
dynamo: fx.client,
|
|
76
|
+
tables: fx.tables,
|
|
77
|
+
connId,
|
|
78
|
+
handlers: noopHandlers(),
|
|
79
|
+
managementApi: null,
|
|
80
|
+
clock,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe.skipIf(!available)('AwsStats', () => {
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
fx = await makeFixture();
|
|
87
|
+
});
|
|
88
|
+
afterEach(async () => {
|
|
89
|
+
if (fx) await fx.cleanup();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('returns zero counts for an empty deployment', async () => {
|
|
93
|
+
const stats = new AwsStats({
|
|
94
|
+
dynamo: fx.client,
|
|
95
|
+
tables: fx.tables,
|
|
96
|
+
uptimeStartedAt: 1_000,
|
|
97
|
+
});
|
|
98
|
+
const snap = await stats.getStats();
|
|
99
|
+
expect(snap.users).toBe(0);
|
|
100
|
+
expect(snap.channels).toBe(0);
|
|
101
|
+
expect(snap.servers).toBe(1);
|
|
102
|
+
expect(snap.localConns).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('counts registered connections (visible, invisible, opers)', async () => {
|
|
106
|
+
const rt = makeRuntime('self');
|
|
107
|
+
const a = createConnection({ id: 'c1', connectedSince: 0 });
|
|
108
|
+
a.nick = 'alice';
|
|
109
|
+
a.registration = 'registered';
|
|
110
|
+
const b = createConnection({ id: 'c2', connectedSince: 0 });
|
|
111
|
+
b.nick = 'bob';
|
|
112
|
+
b.registration = 'registered';
|
|
113
|
+
b.userModes.invisible = true;
|
|
114
|
+
const c = createConnection({ id: 'c3', connectedSince: 0 });
|
|
115
|
+
c.nick = 'carol';
|
|
116
|
+
c.registration = 'registered';
|
|
117
|
+
c.userModes.oper = true;
|
|
118
|
+
const d = createConnection({ id: 'c4', connectedSince: 0 });
|
|
119
|
+
d.registration = 'pre-registration';
|
|
120
|
+
await rt.persistConnectionState(a);
|
|
121
|
+
await rt.persistConnectionState(b);
|
|
122
|
+
await rt.persistConnectionState(c);
|
|
123
|
+
await rt.persistConnectionState(d);
|
|
124
|
+
|
|
125
|
+
const stats = new AwsStats({
|
|
126
|
+
dynamo: fx.client,
|
|
127
|
+
tables: fx.tables,
|
|
128
|
+
uptimeStartedAt: 1_000,
|
|
129
|
+
});
|
|
130
|
+
const snap = await stats.getStats();
|
|
131
|
+
// 3 registered (2 visible + 1 invisible), 1 oper, 1 unknown, 4 total.
|
|
132
|
+
expect(snap.users).toBe(2);
|
|
133
|
+
expect(snap.invisible).toBe(1);
|
|
134
|
+
expect(snap.opers).toBe(1);
|
|
135
|
+
expect(snap.unknownConnections).toBe(1);
|
|
136
|
+
expect(snap.localConns).toBe(4);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('counts channels via the ChannelMeta table', async () => {
|
|
140
|
+
const rt = makeRuntime('self');
|
|
141
|
+
await rt.applyChannelDelta('#a', {
|
|
142
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
|
|
143
|
+
});
|
|
144
|
+
await rt.applyChannelDelta('#b', {
|
|
145
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const stats = new AwsStats({
|
|
149
|
+
dynamo: fx.client,
|
|
150
|
+
tables: fx.tables,
|
|
151
|
+
uptimeStartedAt: 1_000,
|
|
152
|
+
});
|
|
153
|
+
const snap = await stats.getStats();
|
|
154
|
+
expect(snap.channels).toBe(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('echoes the supplied uptimeStartedAt', async () => {
|
|
158
|
+
const stats = new AwsStats({
|
|
159
|
+
dynamo: fx.client,
|
|
160
|
+
tables: fx.tables,
|
|
161
|
+
uptimeStartedAt: 7_777,
|
|
162
|
+
});
|
|
163
|
+
const snap = await stats.getStats();
|
|
164
|
+
expect(snap.uptimeStartedAt).toBe(7_777);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('honours supplied maxLocalConns / maxGlobalConns overrides', async () => {
|
|
168
|
+
const rt = makeRuntime('self');
|
|
169
|
+
const a = createConnection({ id: 'c1', connectedSince: 0 });
|
|
170
|
+
a.nick = 'alice';
|
|
171
|
+
a.registration = 'registered';
|
|
172
|
+
await rt.persistConnectionState(a);
|
|
173
|
+
|
|
174
|
+
const stats = new AwsStats({
|
|
175
|
+
dynamo: fx.client,
|
|
176
|
+
tables: fx.tables,
|
|
177
|
+
uptimeStartedAt: 1_000,
|
|
178
|
+
maxLocalConns: 99,
|
|
179
|
+
maxGlobalConns: 88,
|
|
180
|
+
});
|
|
181
|
+
const snap = await stats.getStats();
|
|
182
|
+
expect(snap.maxLocalConns).toBe(99);
|
|
183
|
+
expect(snap.maxGlobalConns).toBe(88);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Mock-based unit tests — exercise the defensive Scan fallbacks without
|
|
189
|
+
// requiring DynamoDB Local.
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Builds a stub DocumentClient whose `send` returns canned Scan output.
|
|
194
|
+
* Exercises the `?? []` and `?? 0` fallbacks in {@link AwsStats.getStats}
|
|
195
|
+
* that the DynamoDB-Local integration suite cannot reach (the SDK always
|
|
196
|
+
* returns defined arrays / counts there).
|
|
197
|
+
*
|
|
198
|
+
* Pass `connectionsItems: undefined` to simulate the SDK omitting the
|
|
199
|
+
* `Items` field entirely (the `?? []` fallback path).
|
|
200
|
+
*/
|
|
201
|
+
function stubDynamo(
|
|
202
|
+
connectionsItems: unknown[] | undefined,
|
|
203
|
+
channelCount: number | undefined,
|
|
204
|
+
): DynamoDBDocumentClient {
|
|
205
|
+
const calls: { TableName: string }[] = [];
|
|
206
|
+
const stub = {
|
|
207
|
+
send: async (cmd: {
|
|
208
|
+
input?: { TableName?: string; Select?: string };
|
|
209
|
+
TableName?: string;
|
|
210
|
+
Select?: string;
|
|
211
|
+
}) => {
|
|
212
|
+
// ScanCommand exposes its input via `.input`; both shapes are tolerated.
|
|
213
|
+
const input = (cmd as { input?: { TableName?: string; Select?: string } }).input ?? cmd;
|
|
214
|
+
const tableName = input.TableName ?? '';
|
|
215
|
+
const select = input.Select;
|
|
216
|
+
calls.push({ TableName: tableName });
|
|
217
|
+
void calls;
|
|
218
|
+
// Heuristic: the Connections scan omits `Select`, the ChannelMeta
|
|
219
|
+
// scan sets `Select: 'COUNT'`.
|
|
220
|
+
if (select === 'COUNT') {
|
|
221
|
+
return { Count: channelCount };
|
|
222
|
+
}
|
|
223
|
+
// Returning `{}` (no Items key) exercises the `?? []` fallback.
|
|
224
|
+
return connectionsItems === undefined ? {} : { Items: connectionsItems };
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
// Expose calls for any future assertions.
|
|
228
|
+
(stub as { _calls?: unknown })._calls = calls;
|
|
229
|
+
return stub as unknown as DynamoDBDocumentClient;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
describe('AwsStats — defensive Scan fallbacks (mocked client)', () => {
|
|
233
|
+
// These tests do NOT require DynamoDB Local — they exercise the
|
|
234
|
+
// `?? []` / `?? 0` fallbacks by feeding a stub client.
|
|
235
|
+
it('returns empty arrays when the Connections Scan yields no Items field', async () => {
|
|
236
|
+
const tables: TablesConfig = {
|
|
237
|
+
Connections: 'Connections',
|
|
238
|
+
ChannelMeta: 'ChannelMeta',
|
|
239
|
+
ChannelMembers: 'ChannelMembers',
|
|
240
|
+
Nicks: 'Nicks',
|
|
241
|
+
Accounts: 'Accounts',
|
|
242
|
+
};
|
|
243
|
+
// Pass `undefined` so the stub returns `{}` — no Items key — to
|
|
244
|
+
// exercise the `?? []` fallback on the Connections scan, and
|
|
245
|
+
// `undefined` for Count to exercise the `?? 0` fallback.
|
|
246
|
+
const dynamo = stubDynamo(undefined, undefined);
|
|
247
|
+
const stats = new AwsStats({ dynamo, tables, uptimeStartedAt: 1_000 });
|
|
248
|
+
const snap = await stats.getStats();
|
|
249
|
+
expect(snap.users).toBe(0);
|
|
250
|
+
expect(snap.unknownConnections).toBe(0);
|
|
251
|
+
expect(snap.channels).toBe(0);
|
|
252
|
+
expect(snap.localConns).toBe(0);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('honours maxLocalConns / maxGlobalConns overrides via the stub client', async () => {
|
|
256
|
+
const tables: TablesConfig = {
|
|
257
|
+
Connections: 'Connections',
|
|
258
|
+
ChannelMeta: 'ChannelMeta',
|
|
259
|
+
ChannelMembers: 'ChannelMembers',
|
|
260
|
+
Nicks: 'Nicks',
|
|
261
|
+
Accounts: 'Accounts',
|
|
262
|
+
};
|
|
263
|
+
const dynamo = stubDynamo([], 0);
|
|
264
|
+
const stats = new AwsStats({
|
|
265
|
+
dynamo,
|
|
266
|
+
tables,
|
|
267
|
+
uptimeStartedAt: 1_000,
|
|
268
|
+
maxLocalConns: 42,
|
|
269
|
+
maxGlobalConns: 43,
|
|
270
|
+
});
|
|
271
|
+
const snap = await stats.getStats();
|
|
272
|
+
expect(snap.maxLocalConns).toBe(42);
|
|
273
|
+
expect(snap.maxGlobalConns).toBe(43);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('counts connections returned by the stub client', async () => {
|
|
277
|
+
const tables: TablesConfig = {
|
|
278
|
+
Connections: 'Connections',
|
|
279
|
+
ChannelMeta: 'ChannelMeta',
|
|
280
|
+
ChannelMembers: 'ChannelMembers',
|
|
281
|
+
Nicks: 'Nicks',
|
|
282
|
+
Accounts: 'Accounts',
|
|
283
|
+
};
|
|
284
|
+
// Two registered connection rows.
|
|
285
|
+
const items = [
|
|
286
|
+
{
|
|
287
|
+
connectionId: 'c1',
|
|
288
|
+
registration: 'registered',
|
|
289
|
+
capNegotiating: false,
|
|
290
|
+
caps: [],
|
|
291
|
+
userModes: { invisible: false, oper: false, wallops: false, serverNotices: false },
|
|
292
|
+
lastSeen: 0,
|
|
293
|
+
connectedSince: 0,
|
|
294
|
+
idleSince: 0,
|
|
295
|
+
version: 1,
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
connectionId: 'c2',
|
|
299
|
+
registration: 'registered',
|
|
300
|
+
capNegotiating: false,
|
|
301
|
+
caps: [],
|
|
302
|
+
userModes: { invisible: true, oper: false, wallops: false, serverNotices: false },
|
|
303
|
+
lastSeen: 0,
|
|
304
|
+
connectedSince: 0,
|
|
305
|
+
idleSince: 0,
|
|
306
|
+
version: 1,
|
|
307
|
+
},
|
|
308
|
+
];
|
|
309
|
+
const dynamo = stubDynamo(items, 3);
|
|
310
|
+
const stats = new AwsStats({ dynamo, tables, uptimeStartedAt: 1_000 });
|
|
311
|
+
const snap = await stats.getStats();
|
|
312
|
+
expect(snap.users).toBe(1); // 2 registered − 1 invisible
|
|
313
|
+
expect(snap.invisible).toBe(1);
|
|
314
|
+
expect(snap.channels).toBe(3);
|
|
315
|
+
expect(snap.localConns).toBe(2);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -34,6 +34,11 @@ interface Fixture {
|
|
|
34
34
|
|
|
35
35
|
let fx: Fixture | null;
|
|
36
36
|
|
|
37
|
+
function requireFx(): Fixture {
|
|
38
|
+
if (!fx) throw new Error('test fixture not initialized');
|
|
39
|
+
return fx;
|
|
40
|
+
}
|
|
41
|
+
|
|
37
42
|
async function makeFixture(): Promise<Fixture> {
|
|
38
43
|
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
39
44
|
const prefix = `s${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
|
|
@@ -81,8 +86,8 @@ async function makeFixture(): Promise<Fixture> {
|
|
|
81
86
|
|
|
82
87
|
function params(overrides: Partial<SweepParams> = {}): SweepParams {
|
|
83
88
|
return {
|
|
84
|
-
dynamo:
|
|
85
|
-
tables:
|
|
89
|
+
dynamo: requireFx().client,
|
|
90
|
+
tables: requireFx().tables,
|
|
86
91
|
...overrides,
|
|
87
92
|
};
|
|
88
93
|
}
|
|
@@ -100,18 +105,18 @@ async function seedConnection(opts: {
|
|
|
100
105
|
if (opts.nick !== undefined) {
|
|
101
106
|
state.nick = opts.nick as Nick;
|
|
102
107
|
state.user = opts.nick;
|
|
103
|
-
await
|
|
108
|
+
await requireFx().client.send(
|
|
104
109
|
new PutCommand({
|
|
105
|
-
TableName:
|
|
110
|
+
TableName: requireFx().tables.Nicks,
|
|
106
111
|
Item: marshalNick(opts.nick as Nick, opts.connId, opts.idleSince),
|
|
107
112
|
}),
|
|
108
113
|
);
|
|
109
114
|
}
|
|
110
115
|
for (const chan of opts.channels ?? []) {
|
|
111
116
|
state.joinedChannels.add(chan);
|
|
112
|
-
await
|
|
117
|
+
await requireFx().client.send(
|
|
113
118
|
new PutCommand({
|
|
114
|
-
TableName:
|
|
119
|
+
TableName: requireFx().tables.ChannelMembers,
|
|
115
120
|
Item: marshalChannelMember(chan.toLowerCase(), {
|
|
116
121
|
conn: opts.connId,
|
|
117
122
|
nick: (opts.nick ?? 'x') as Nick,
|
|
@@ -121,9 +126,9 @@ async function seedConnection(opts: {
|
|
|
121
126
|
}),
|
|
122
127
|
);
|
|
123
128
|
}
|
|
124
|
-
await
|
|
129
|
+
await requireFx().client.send(
|
|
125
130
|
new PutCommand({
|
|
126
|
-
TableName:
|
|
131
|
+
TableName: requireFx().tables.Connections,
|
|
127
132
|
Item: marshalConnection(state, opts.idleSince),
|
|
128
133
|
}),
|
|
129
134
|
);
|
|
@@ -131,16 +136,16 @@ async function seedConnection(opts: {
|
|
|
131
136
|
|
|
132
137
|
async function connectionExists(connId: string): Promise<boolean> {
|
|
133
138
|
const { GetCommand } = await import('@aws-sdk/lib-dynamodb');
|
|
134
|
-
const res = await
|
|
135
|
-
new GetCommand({ TableName:
|
|
139
|
+
const res = await requireFx().client.send(
|
|
140
|
+
new GetCommand({ TableName: requireFx().tables.Connections, Key: { connectionId: connId } }),
|
|
136
141
|
);
|
|
137
142
|
return res.Item !== undefined;
|
|
138
143
|
}
|
|
139
144
|
|
|
140
145
|
async function membersOf(channelKey: string): Promise<{ connectionId: string }[]> {
|
|
141
|
-
const res = await
|
|
146
|
+
const res = await requireFx().client.send(
|
|
142
147
|
new QueryCommand({
|
|
143
|
-
TableName:
|
|
148
|
+
TableName: requireFx().tables.ChannelMembers,
|
|
144
149
|
KeyConditionExpression: 'channelName = :k',
|
|
145
150
|
ExpressionAttributeValues: { ':k': channelKey },
|
|
146
151
|
}),
|
|
@@ -150,8 +155,8 @@ async function membersOf(channelKey: string): Promise<{ connectionId: string }[]
|
|
|
150
155
|
|
|
151
156
|
async function nickExists(nick: string): Promise<boolean> {
|
|
152
157
|
const { GetCommand } = await import('@aws-sdk/lib-dynamodb');
|
|
153
|
-
const res = await
|
|
154
|
-
new GetCommand({ TableName:
|
|
158
|
+
const res = await requireFx().client.send(
|
|
159
|
+
new GetCommand({ TableName: requireFx().tables.Nicks, Key: { nickLower: nick.toLowerCase() } }),
|
|
155
160
|
);
|
|
156
161
|
return res.Item !== undefined;
|
|
157
162
|
}
|
|
@@ -281,8 +286,10 @@ describe.skipIf(!available)('gone-connection sweeper', () => {
|
|
|
281
286
|
|
|
282
287
|
it('cleanupConnection is safe to call on a row the sweeper already removed (no throw)', async () => {
|
|
283
288
|
await seedConnection({ connId: 'stale', idleSince: 0, nick: 'zed' });
|
|
284
|
-
await cleanupConnection(
|
|
285
|
-
await expect(
|
|
289
|
+
await cleanupConnection(requireFx().client, requireFx().tables, 'stale', null);
|
|
290
|
+
await expect(
|
|
291
|
+
cleanupConnection(requireFx().client, requireFx().tables, 'stale', null),
|
|
292
|
+
).resolves.toBeUndefined();
|
|
286
293
|
});
|
|
287
294
|
|
|
288
295
|
it('uses Date.now() when now is omitted (real-clock default path)', async () => {
|
|
@@ -297,9 +304,9 @@ describe.skipIf(!available)('gone-connection sweeper', () => {
|
|
|
297
304
|
|
|
298
305
|
it('skips a row whose idleSince is not a number (defensive — corrupted row)', async () => {
|
|
299
306
|
// Write a Connections row directly with a non-numeric idleSince.
|
|
300
|
-
await
|
|
307
|
+
await requireFx().client.send(
|
|
301
308
|
new PutCommand({
|
|
302
|
-
TableName:
|
|
309
|
+
TableName: requireFx().tables.Connections,
|
|
303
310
|
Item: {
|
|
304
311
|
connectionId: 'corrupt',
|
|
305
312
|
registration: 'registered',
|
|
@@ -37,6 +37,11 @@ interface Fixture {
|
|
|
37
37
|
|
|
38
38
|
let fx: Fixture | null;
|
|
39
39
|
|
|
40
|
+
function requireFx(): Fixture {
|
|
41
|
+
if (!fx) throw new Error('test fixture not initialized');
|
|
42
|
+
return fx;
|
|
43
|
+
}
|
|
44
|
+
|
|
40
45
|
async function makeFixture(): Promise<Fixture> {
|
|
41
46
|
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
42
47
|
const prefix = `x${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
|
|
@@ -89,8 +94,8 @@ function makeRuntime(connId: string): AwsRuntime {
|
|
|
89
94
|
snapshot: () => undefined,
|
|
90
95
|
};
|
|
91
96
|
return new AwsRuntime({
|
|
92
|
-
dynamo:
|
|
93
|
-
tables:
|
|
97
|
+
dynamo: requireFx().client,
|
|
98
|
+
tables: requireFx().tables,
|
|
94
99
|
connId,
|
|
95
100
|
handlers,
|
|
96
101
|
managementApi: null,
|
|
@@ -100,9 +105,9 @@ function makeRuntime(connId: string): AwsRuntime {
|
|
|
100
105
|
/** Persists a fresh Connections row with no joined channels. */
|
|
101
106
|
async function seedConnection(connId: string, idleSince = 1_000): Promise<void> {
|
|
102
107
|
const state = createConnection({ id: connId, connectedSince: idleSince });
|
|
103
|
-
await
|
|
108
|
+
await requireFx().client.send(
|
|
104
109
|
new PutCommand({
|
|
105
|
-
TableName:
|
|
110
|
+
TableName: requireFx().tables.Connections,
|
|
106
111
|
Item: marshalConnection(state, idleSince),
|
|
107
112
|
}),
|
|
108
113
|
);
|
|
@@ -110,9 +115,9 @@ async function seedConnection(connId: string, idleSince = 1_000): Promise<void>
|
|
|
110
115
|
|
|
111
116
|
/** Reads the raw Connections row (so we can inspect the stored shape). */
|
|
112
117
|
async function readConnection(connId: string): Promise<{ joinedChannels?: unknown } | undefined> {
|
|
113
|
-
const res = await
|
|
118
|
+
const res = await requireFx().client.send(
|
|
114
119
|
new GetCommand({
|
|
115
|
-
TableName:
|
|
120
|
+
TableName: requireFx().tables.Connections,
|
|
116
121
|
Key: { connectionId: connId },
|
|
117
122
|
}),
|
|
118
123
|
);
|
|
@@ -121,9 +126,9 @@ async function readConnection(connId: string): Promise<{ joinedChannels?: unknow
|
|
|
121
126
|
|
|
122
127
|
/** Reads every ChannelMembers row for a channel. */
|
|
123
128
|
async function readMembers(channelKey: string): Promise<{ connectionId: string }[]> {
|
|
124
|
-
const res = await
|
|
129
|
+
const res = await requireFx().client.send(
|
|
125
130
|
new QueryCommand({
|
|
126
|
-
TableName:
|
|
131
|
+
TableName: requireFx().tables.ChannelMembers,
|
|
127
132
|
KeyConditionExpression: 'channelName = :k',
|
|
128
133
|
ExpressionAttributeValues: { ':k': channelKey },
|
|
129
134
|
}),
|
|
@@ -137,15 +142,15 @@ async function seedMembership(connId: string, nick: string, channel: ChanName):
|
|
|
137
142
|
state.registration = 'registered';
|
|
138
143
|
state.nick = nick as Nick;
|
|
139
144
|
state.joinedChannels.add(channel);
|
|
140
|
-
await
|
|
145
|
+
await requireFx().client.send(
|
|
141
146
|
new PutCommand({
|
|
142
|
-
TableName:
|
|
147
|
+
TableName: requireFx().tables.Connections,
|
|
143
148
|
Item: marshalConnection(state, 1_000),
|
|
144
149
|
}),
|
|
145
150
|
);
|
|
146
|
-
await
|
|
151
|
+
await requireFx().client.send(
|
|
147
152
|
new PutCommand({
|
|
148
|
-
TableName:
|
|
153
|
+
TableName: requireFx().tables.ChannelMembers,
|
|
149
154
|
Item: marshalChannelMember(channel.toLowerCase(), {
|
|
150
155
|
conn: connId,
|
|
151
156
|
nick: nick as Nick,
|
|
@@ -182,8 +187,8 @@ describe.skipIf(!available)('membership transactions', () => {
|
|
|
182
187
|
|
|
183
188
|
const row = await readConnection('c1');
|
|
184
189
|
expect(row).toBeDefined();
|
|
185
|
-
expect(row
|
|
186
|
-
expect((row
|
|
190
|
+
expect(row?.joinedChannels).toBeInstanceOf(Set);
|
|
191
|
+
expect((row?.joinedChannels as Set<string>).has('#foo')).toBe(true);
|
|
187
192
|
});
|
|
188
193
|
|
|
189
194
|
it('PART removes both the ChannelMembers row and the channel from joinedChannels', async () => {
|
|
@@ -199,7 +204,7 @@ describe.skipIf(!available)('membership transactions', () => {
|
|
|
199
204
|
|
|
200
205
|
const row = await readConnection('c1');
|
|
201
206
|
expect(row).toBeDefined();
|
|
202
|
-
expect((row
|
|
207
|
+
expect((row?.joinedChannels as Set<string> | undefined)?.has('#foo') ?? false).toBe(false);
|
|
203
208
|
});
|
|
204
209
|
|
|
205
210
|
it('KICK (remove of a different connection) drops the target from joinedChannels', async () => {
|
|
@@ -211,7 +216,7 @@ describe.skipIf(!available)('membership transactions', () => {
|
|
|
211
216
|
});
|
|
212
217
|
|
|
213
218
|
const row = await readConnection('target');
|
|
214
|
-
expect((row
|
|
219
|
+
expect((row?.joinedChannels as Set<string> | undefined)?.has('#room') ?? false).toBe(false);
|
|
215
220
|
expect(await readMembers('#room')).toHaveLength(0);
|
|
216
221
|
});
|
|
217
222
|
|
|
@@ -234,7 +239,7 @@ describe.skipIf(!available)('membership transactions', () => {
|
|
|
234
239
|
]);
|
|
235
240
|
|
|
236
241
|
const row = await readConnection('c1');
|
|
237
|
-
const joined = row
|
|
242
|
+
const joined = row?.joinedChannels as Set<string>;
|
|
238
243
|
expect(joined.has('#foo')).toBe(true);
|
|
239
244
|
expect(joined.has('#bar')).toBe(true);
|
|
240
245
|
|
|
@@ -260,7 +265,7 @@ describe.skipIf(!available)('membership transactions', () => {
|
|
|
260
265
|
|
|
261
266
|
expect(await readMembers('#foo')).toHaveLength(1);
|
|
262
267
|
const row = await readConnection('c1');
|
|
263
|
-
const joined = row
|
|
268
|
+
const joined = row?.joinedChannels as Set<string>;
|
|
264
269
|
expect(joined.has('#foo')).toBe(true);
|
|
265
270
|
expect([...joined].filter((c) => c === '#foo')).toHaveLength(1);
|
|
266
271
|
});
|
|
@@ -283,7 +288,7 @@ describe.skipIf(!available)('membership transactions', () => {
|
|
|
283
288
|
|
|
284
289
|
expect(await readMembers('#foo')).toHaveLength(0);
|
|
285
290
|
const row = await readConnection('c1');
|
|
286
|
-
expect((row
|
|
291
|
+
expect((row?.joinedChannels as Set<string> | undefined)?.size ?? 0).toBe(0);
|
|
287
292
|
});
|
|
288
293
|
|
|
289
294
|
// -------------------------------------------------------------------------
|
|
@@ -303,7 +308,7 @@ describe.skipIf(!available)('membership transactions', () => {
|
|
|
303
308
|
|
|
304
309
|
expect(await readMembers('#foo')).toHaveLength(0);
|
|
305
310
|
const row = await readConnection('c1');
|
|
306
|
-
expect((row
|
|
311
|
+
expect((row?.joinedChannels as Set<string> | undefined)?.size ?? 0).toBe(0);
|
|
307
312
|
});
|
|
308
313
|
});
|
|
309
314
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/cf-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Cloudflare Workers adapter: Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and CfRuntime implementing IrcRuntime",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
".": {
|
|
12
12
|
"types": "./dist/index.d.ts",
|
|
13
13
|
"import": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./accounts": {
|
|
16
|
+
"types": "./dist/d1-account-store.d.ts",
|
|
17
|
+
"import": "./dist/d1-account-store.js"
|
|
14
18
|
}
|
|
15
19
|
},
|
|
16
20
|
"scripts": {
|