@shivaedev/discord 0.0.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +58 -1
  4. package/dist/DiscordConfig.d.ts +7 -0
  5. package/dist/DiscordConfig.d.ts.map +1 -0
  6. package/dist/DiscordConfig.js +5 -0
  7. package/dist/DiscordConfig.js.map +1 -0
  8. package/dist/DiscordGateway.d.ts +9 -0
  9. package/dist/DiscordGateway.d.ts.map +1 -0
  10. package/dist/DiscordGateway.js +13 -0
  11. package/dist/DiscordGateway.js.map +1 -0
  12. package/dist/DiscordRest.d.ts +8 -0
  13. package/dist/DiscordRest.d.ts.map +1 -0
  14. package/dist/DiscordRest.js +12 -0
  15. package/dist/DiscordRest.js.map +1 -0
  16. package/dist/DiscordToken.d.ts +6 -0
  17. package/dist/DiscordToken.d.ts.map +1 -0
  18. package/dist/DiscordToken.js +4 -0
  19. package/dist/DiscordToken.js.map +1 -0
  20. package/dist/GatewayTransport.d.ts +9 -0
  21. package/dist/GatewayTransport.d.ts.map +1 -0
  22. package/dist/GatewayTransport.js +4 -0
  23. package/dist/GatewayTransport.js.map +1 -0
  24. package/dist/adapters/restStatus.d.ts +3 -0
  25. package/dist/adapters/restStatus.d.ts.map +1 -0
  26. package/dist/adapters/restStatus.js +8 -0
  27. package/dist/adapters/restStatus.js.map +1 -0
  28. package/dist/adapters/routeKey.d.ts +5 -0
  29. package/dist/adapters/routeKey.d.ts.map +1 -0
  30. package/dist/adapters/routeKey.js +16 -0
  31. package/dist/adapters/routeKey.js.map +1 -0
  32. package/dist/adapters/webSocketLayer.d.ts +4 -0
  33. package/dist/adapters/webSocketLayer.d.ts.map +1 -0
  34. package/dist/adapters/webSocketLayer.js +7 -0
  35. package/dist/adapters/webSocketLayer.js.map +1 -0
  36. package/dist/errors.d.ts +19 -0
  37. package/dist/errors.d.ts.map +1 -0
  38. package/dist/errors.js +14 -0
  39. package/dist/errors.js.map +1 -0
  40. package/dist/gatewayConnection.d.ts +6 -0
  41. package/dist/gatewayConnection.d.ts.map +1 -0
  42. package/dist/gatewayConnection.js +85 -0
  43. package/dist/gatewayConnection.js.map +1 -0
  44. package/dist/gatewayProtocol.d.ts +41 -0
  45. package/dist/gatewayProtocol.d.ts.map +1 -0
  46. package/dist/gatewayProtocol.js +21 -0
  47. package/dist/gatewayProtocol.js.map +1 -0
  48. package/dist/rateLimits.d.ts +8 -0
  49. package/dist/rateLimits.d.ts.map +1 -0
  50. package/dist/rateLimits.js +36 -0
  51. package/dist/rateLimits.js.map +1 -0
  52. package/dist/request.d.ts +4 -0
  53. package/dist/request.d.ts.map +1 -0
  54. package/dist/request.js +6 -0
  55. package/dist/request.js.map +1 -0
  56. package/dist/restClient.d.ts +11 -0
  57. package/dist/restClient.d.ts.map +1 -0
  58. package/dist/restClient.js +51 -0
  59. package/dist/restClient.js.map +1 -0
  60. package/dist/runGateway.d.ts +6 -0
  61. package/dist/runGateway.d.ts.map +1 -0
  62. package/dist/runGateway.js +29 -0
  63. package/dist/runGateway.js.map +1 -0
  64. package/package.json +63 -4
  65. package/src/DiscordConfig.ts +9 -0
  66. package/src/DiscordGateway.ts +13 -0
  67. package/src/DiscordRest.ts +12 -0
  68. package/src/DiscordToken.ts +3 -0
  69. package/src/GatewayTransport.ts +9 -0
  70. package/src/adapters/restStatus.ts +9 -0
  71. package/src/adapters/routeKey.ts +15 -0
  72. package/src/adapters/webSocketLayer.ts +7 -0
  73. package/src/errors.ts +13 -0
  74. package/src/gatewayConnection.ts +89 -0
  75. package/src/gatewayProtocol.ts +42 -0
  76. package/src/rateLimits.ts +39 -0
  77. package/src/request.ts +7 -0
  78. package/src/restClient.ts +64 -0
  79. package/src/runGateway.ts +32 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request.d.ts","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,OAAO,CAAC,OAAO,SAAS,MAAM,CAAC,iBAAiB,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,OAAO,6nBAE3I"}
@@ -0,0 +1,6 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { DiscordRest } from "#DiscordRest.ts";
3
+ export function request(method, path, schema, body) {
4
+ return Effect.flatMap(DiscordRest, (rest) => rest.request(method, path, body)).pipe(Effect.flatMap(Schema.decodeUnknownEffect(schema)));
5
+ }
6
+ //# sourceMappingURL=request.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request.js","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAExC,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAE9C,MAAM,UAAU,OAAO,CAAoD,MAAkB,EAAE,IAAY,EAAE,MAAe,EAAE,IAAc;IAC3I,OAAO,MAAM,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AACzI,CAAC"}
@@ -0,0 +1,11 @@
1
+ import { Effect, Schema, Semaphore } from "effect";
2
+ import { HttpClient } from "effect/http";
3
+ import type { HttpMethod } from "effect/http/HttpMethod";
4
+ import { DiscordToken } from "#DiscordToken.ts";
5
+ import { DiscordRateLimited, DiscordTransportError } from "#errors.ts";
6
+ export declare const initializeRest: Effect.Effect<{
7
+ lock: Semaphore.Semaphore;
8
+ request: (method: HttpMethod, path: string, body?: unknown) => Effect.Effect<unknown, DiscordRateLimited | import("effect/http/HttpBody").HttpBodyError | import("effect/http/HttpClientError").HttpClientError | import("@shivaedev/http/errors.ts").InvalidBodyLimit | import("@shivaedev/http/errors.ts").RemoteGone | import("@shivaedev/http/errors.ts").RemoteHttpError | import("@shivaedev/http/errors.ts").RemoteNotFound | import("@shivaedev/http/errors.ts").RemoteRateLimited | import("@shivaedev/http/errors.ts").RemoteUnauthorized | import("@shivaedev/http/errors.ts").ResponseBodyTooLarge | Schema.SchemaError, never>;
9
+ }, never, DiscordToken | HttpClient.HttpClient>;
10
+ export declare function restRequest(state: Effect.Success<typeof initializeRest>): (method: HttpMethod, path: string, body?: unknown) => Effect.Effect<unknown, DiscordRateLimited | DiscordTransportError | import("effect/http/HttpBody").HttpBodyError | import("@shivaedev/http/errors.ts").InvalidBodyLimit | import("@shivaedev/http/errors.ts").RemoteGone | import("@shivaedev/http/errors.ts").RemoteHttpError | import("@shivaedev/http/errors.ts").RemoteNotFound | import("@shivaedev/http/errors.ts").RemoteRateLimited | import("@shivaedev/http/errors.ts").RemoteUnauthorized | import("@shivaedev/http/errors.ts").ResponseBodyTooLarge | Schema.SchemaError, never>;
11
+ //# sourceMappingURL=restClient.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"restClient.d.ts","sourceRoot":"","sources":["../src/restClient.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAY,MAAM,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAqB,MAAM,aAAa,CAAC;AAC5D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAMzD,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAOvE,eAAO,MAAM,cAAc;;;+CAuCzB,CAAC;AAEH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,CAAC,OAAO,CAAC,OAAO,cAAc,CAAC,YACvD,UAAU,QAAQ,MAAM,SAAS,OAAO,khBAKxD"}
@@ -0,0 +1,51 @@
1
+ import { Effect, Redacted, Schema, Semaphore } from "effect";
2
+ import { HttpClient, HttpClientRequest } from "effect/http";
3
+ import { readBoundedJson } from "@shivaedev/http/body.ts";
4
+ import { checkStatus } from "@shivaedev/http/checkStatus.ts";
5
+ import { isEmpty, isRateLimited } from "#adapters/restStatus.ts";
6
+ import { routeKey } from "#adapters/routeKey.ts";
7
+ import { DiscordConfig } from "#DiscordConfig.ts";
8
+ import { DiscordToken } from "#DiscordToken.ts";
9
+ import { DiscordRateLimited, DiscordTransportError } from "#errors.ts";
10
+ import { rateLimits } from "#rateLimits.ts";
11
+ const Limited = Schema.Struct({ global: Schema.optional(Schema.Boolean), retryAfter: Schema.Number }).pipe(Schema.encodeKeys({ retryAfter: "retry_after" }));
12
+ export const initializeRest = Effect.gen(function* () {
13
+ const client = HttpClient.withScope(yield* HttpClient.HttpClient);
14
+ const token = yield* DiscordToken;
15
+ const config = yield* DiscordConfig;
16
+ const rates = rateLimits();
17
+ const lock = yield* Semaphore.make(1);
18
+ const request = Effect.fnUntraced(function* (method, path, body) {
19
+ const { major, route } = routeKey(method, path);
20
+ let input = HttpClientRequest.make(method)(`${config.apiUrl}${path}`).pipe(HttpClientRequest.setHeader("Authorization", `Bot ${Redacted.value(token)}`));
21
+ if (body !== undefined) {
22
+ input = yield* HttpClientRequest.bodyJson(input, body);
23
+ }
24
+ for (let attempt = 0;; attempt += 1) {
25
+ yield* rates.wait(route, major);
26
+ const answer = yield* Effect.scoped(Effect.gen(function* () {
27
+ const response = yield* client.execute(input);
28
+ yield* rates.observe(route, major, response.headers);
29
+ if (isRateLimited(response.status)) {
30
+ const limit = yield* readBoundedJson(response, 64 * 1024, Limited);
31
+ yield* rates.refuse(route, major, limit.retryAfter, limit.global === true);
32
+ if (attempt === 3) {
33
+ return yield* new DiscordRateLimited({ retryAfter: limit.retryAfter });
34
+ }
35
+ return { retry: true, value: undefined };
36
+ }
37
+ yield* checkStatus(response);
38
+ const value = isEmpty(response.status) ? undefined : yield* readBoundedJson(response, 1024 * 1024, Schema.Unknown);
39
+ return { retry: false, value };
40
+ }));
41
+ if (!answer.retry) {
42
+ return answer.value;
43
+ }
44
+ }
45
+ });
46
+ return { lock, request };
47
+ });
48
+ export function restRequest(state) {
49
+ return (method, path, body) => state.request(method, path, body).pipe(state.lock.withPermit, Effect.catchTag("HttpClientError", () => Effect.fail(new DiscordTransportError({}))));
50
+ }
51
+ //# sourceMappingURL=restClient.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"restClient.js","sourceRoot":"","sources":["../src/restClient.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ import { Effect } from "effect";
2
+ import { DiscordGatewayClosed } from "#errors.ts";
3
+ import { GatewayTransport } from "#GatewayTransport.ts";
4
+ import type { GatewayDispatch } from "#gatewayProtocol.ts";
5
+ export declare const runGateway: (dispatch: (event: GatewayDispatch) => Effect.Effect<void>) => Effect.Effect<never, DiscordGatewayClosed | import("#errors.ts").DiscordGatewayProtocolError, import("./DiscordToken.ts").DiscordToken | GatewayTransport>;
6
+ //# sourceMappingURL=runGateway.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runGateway.d.ts","sourceRoot":"","sources":["../src/runGateway.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAGhC,OAAO,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAClD,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAExD,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,qBAAqB,CAAC;AAE3E,eAAO,MAAM,UAAU,qBAAkD,eAAe,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,+JAuB9G,CAAC"}
@@ -0,0 +1,29 @@
1
+ import { Effect } from "effect";
2
+ import { Socket } from "effect/socket";
3
+ import { DiscordConfig } from "#DiscordConfig.ts";
4
+ import { DiscordGatewayClosed } from "#errors.ts";
5
+ import { GatewayTransport } from "#GatewayTransport.ts";
6
+ import { gatewayConnection } from "#gatewayConnection.ts";
7
+ export const runGateway = Effect.fnUntraced(function* (dispatch) {
8
+ const config = yield* DiscordConfig;
9
+ const transport = yield* GatewayTransport;
10
+ const session = { resumeUrl: undefined, sequence: null, sessionId: undefined };
11
+ while (true) {
12
+ const socket = yield* transport.open(`${session.resumeUrl ?? config.gatewayUrl}/?v=10&encoding=json`);
13
+ yield* gatewayConnection(session, dispatch)
14
+ .pipe(Effect.provideService(Socket.Socket, socket))
15
+ .pipe(Effect.catchTag("SocketError", (error) => {
16
+ const code = error.reason._tag === "SocketCloseError" ? error.reason.code : undefined;
17
+ if (code === 4004 || (code !== undefined && code >= 4010 && code <= 4014)) {
18
+ return Effect.fail(new DiscordGatewayClosed({ code }));
19
+ }
20
+ if (code === 4007 || code === 4009) {
21
+ session.sessionId = undefined;
22
+ session.resumeUrl = undefined;
23
+ session.sequence = null;
24
+ }
25
+ return Effect.sleep("1 second");
26
+ }));
27
+ }
28
+ });
29
+ //# sourceMappingURL=runGateway.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runGateway.js","sourceRoot":"","sources":["../src/runGateway.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClD,OAAO,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAClD,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAG1D,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,QAAyD;IAC/G,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,aAAa,CAAC;IACpC,MAAM,SAAS,GAAG,KAAK,CAAC,CAAC,gBAAgB,CAAC;IAC1C,MAAM,OAAO,GAAmB,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,SAAS,EAAE,CAAC;IAC/F,OAAO,IAAI,EAAE,CAAC;QACb,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,SAAS,IAAI,MAAM,CAAC,UAAU,sBAAsB,CAAC,CAAC;QACtG,KAAK,CAAC,CAAC,iBAAiB,CAAC,OAAO,EAAE,QAAQ,CAAC;aACzC,IAAI,CAAC,MAAM,CAAC,cAAc,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;aAClD,IAAI,CACJ,MAAM,CAAC,QAAQ,CAAC,aAAa,EAAE,CAAC,KAAK,EAAE,EAAE;YACxC,MAAM,IAAI,GAAG,KAAK,CAAC,MAAM,CAAC,IAAI,KAAK,kBAAkB,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;YACtF,IAAI,IAAI,KAAK,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,CAAC,EAAE,CAAC;gBAC3E,OAAO,MAAM,CAAC,IAAI,CAAC,IAAI,oBAAoB,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;YACxD,CAAC;YACD,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;gBACpC,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;gBAC9B,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;gBAC9B,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;YACzB,CAAC;YACD,OAAO,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;QACjC,CAAC,CAAC,CACF,CAAC;IACJ,CAAC;AACF,CAAC,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,12 +1,71 @@
1
1
  {
2
2
  "name": "@shivaedev/discord",
3
- "version": "0.0.0",
4
- "description": "Name reserved for an upcoming ShivaeDev Platform package.",
5
- "license": "MIT",
3
+ "version": "0.2.0",
4
+ "description": "Discord integrations for Effect applications",
5
+ "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/discord#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/ShivaeDev/platform/issues"
8
+ },
6
9
  "repository": {
7
10
  "type": "git",
8
11
  "url": "git+https://github.com/ShivaeDev/platform.git",
9
12
  "directory": "packages/discord"
10
13
  },
11
- "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/discord#readme"
14
+ "license": "MIT",
15
+ "sideEffects": false,
16
+ "type": "module",
17
+ "imports": {
18
+ "#test/*": "./src/test-support/*",
19
+ "#*.ts": {
20
+ "@shivaedev/source": "./src/*.ts",
21
+ "types": "./dist/*.d.ts",
22
+ "default": "./dist/*.js"
23
+ }
24
+ },
25
+ "exports": {
26
+ "./*.ts": {
27
+ "@shivaedev/source": "./src/*.ts",
28
+ "types": "./dist/*.d.ts",
29
+ "default": "./dist/*.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "src",
36
+ "!src/**/*.test.ts",
37
+ "!src/**/*.spec.ts",
38
+ "!src/**/test-support",
39
+ "CHANGELOG.md",
40
+ "README.md"
41
+ ],
42
+ "dependencies": {
43
+ "@shivaedev/http": "0.6.0",
44
+ "@shivaedev/service": "0.7.0"
45
+ },
46
+ "devDependencies": {
47
+ "@shivaedev/quality": "0.60.0",
48
+ "@shivaedev/test-kits": "0.3.0",
49
+ "@shivaedev/test-story": "0.38.0",
50
+ "@types/node": "24.19.2",
51
+ "effect": "4.0.6",
52
+ "typescript": "7.0.2",
53
+ "vitest": "5.0.5"
54
+ },
55
+ "peerDependencies": {
56
+ "effect": "4.0.6"
57
+ },
58
+ "engines": {
59
+ "node": ">=24"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public",
63
+ "provenance": true
64
+ },
65
+ "scripts": {
66
+ "build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc --project tsconfig.build.json",
67
+ "ready": "pnpm typecheck && pnpm test && pnpm build",
68
+ "test": "vitest run",
69
+ "typecheck": "tsc --noEmit"
70
+ }
12
71
  }
@@ -0,0 +1,9 @@
1
+ import { Context } from "effect";
2
+
3
+ export const DiscordConfig = Context.Reference<{
4
+ readonly apiUrl: string;
5
+ readonly gatewayUrl: string;
6
+ readonly intents: number;
7
+ }>("@shivaedev/discord/DiscordConfig", {
8
+ defaultValue: () => ({ apiUrl: "https://discord.com/api/v10", gatewayUrl: "wss://gateway.discord.gg", intents: 0 }),
9
+ });
@@ -0,0 +1,13 @@
1
+ import { Effect } from "effect";
2
+ import { defineService } from "@shivaedev/service/defineService.ts";
3
+ import { DiscordConfig } from "#DiscordConfig.ts";
4
+ import { DiscordToken } from "#DiscordToken.ts";
5
+ import { GatewayTransport } from "#GatewayTransport.ts";
6
+ import { runGateway } from "#runGateway.ts";
7
+
8
+ export const DiscordGateway = defineService({
9
+ id: "@shivaedev/discord/DiscordGateway",
10
+ initialize: Effect.void,
11
+ methods: () => ({ run: runGateway }),
12
+ requires: [DiscordConfig, DiscordToken, GatewayTransport],
13
+ });
@@ -0,0 +1,12 @@
1
+ import { HttpClient } from "effect/http";
2
+ import { defineService } from "@shivaedev/service/defineService.ts";
3
+ import { DiscordConfig } from "#DiscordConfig.ts";
4
+ import { DiscordToken } from "#DiscordToken.ts";
5
+ import { initializeRest, restRequest } from "#restClient.ts";
6
+
7
+ export const DiscordRest = defineService({
8
+ id: "@shivaedev/discord/DiscordRest",
9
+ initialize: initializeRest,
10
+ methods: (state) => ({ request: restRequest(state) }),
11
+ requires: [HttpClient.HttpClient, DiscordToken, DiscordConfig],
12
+ });
@@ -0,0 +1,3 @@
1
+ import { Context, type Redacted } from "effect";
2
+
3
+ export class DiscordToken extends Context.Service<DiscordToken, Redacted.Redacted<string>>()("@shivaedev/discord/DiscordToken") {}
@@ -0,0 +1,9 @@
1
+ import { Context, type Effect } from "effect";
2
+ import type { Socket } from "effect/socket";
3
+
4
+ export class GatewayTransport extends Context.Service<
5
+ GatewayTransport,
6
+ {
7
+ readonly open: (url: string) => Effect.Effect<Socket.Socket>;
8
+ }
9
+ >()("@shivaedev/discord/GatewayTransport") {}
@@ -0,0 +1,9 @@
1
+ import * as HttpStatus from "effect/http/HttpStatus";
2
+
3
+ export function isRateLimited(status: number) {
4
+ return status === HttpStatus.fromLiteral("TooManyRequests");
5
+ }
6
+
7
+ export function isEmpty(status: number) {
8
+ return status === HttpStatus.fromLiteral("NoContent");
9
+ }
@@ -0,0 +1,15 @@
1
+ export function routeKey(method: string, path: string) {
2
+ const parts = path.split("?")[0]?.split("/") ?? [];
3
+ let major = "";
4
+ if (parts[1] === "channels" || parts[1] === "guilds") {
5
+ major = parts[2] ?? "";
6
+ }
7
+ if (parts[1] === "webhooks") {
8
+ major = `${parts[2]}/${parts[3]}`;
9
+ }
10
+ const normalized = parts.map((part, index) => (/^\d+$/u.test(part) && index !== 2 ? ":id" : part));
11
+ if (parts[1] === "channels" || parts[1] === "guilds" || parts[1] === "webhooks") {
12
+ normalized[2] = ":major";
13
+ }
14
+ return { major, route: `${method} ${normalized.join("/")}` };
15
+ }
@@ -0,0 +1,7 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { Socket } from "effect/socket";
3
+ import { GatewayTransport } from "#GatewayTransport.ts";
4
+
5
+ export const webSocketLayer = Layer.succeed(GatewayTransport)({
6
+ open: (url) => Socket.makeWebSocket(url).pipe(Effect.provide(Socket.layerWebSocketConstructorGlobal)),
7
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { Schema } from "effect";
2
+
3
+ export class DiscordTransportError extends Schema.TaggedError<DiscordTransportError>()("DiscordTransportError", {}) {}
4
+
5
+ export class DiscordRateLimited extends Schema.TaggedError<DiscordRateLimited>()("DiscordRateLimited", {
6
+ retryAfter: Schema.Number,
7
+ }) {}
8
+
9
+ export class DiscordGatewayClosed extends Schema.TaggedError<DiscordGatewayClosed>()("DiscordGatewayClosed", {
10
+ code: Schema.Number,
11
+ }) {}
12
+
13
+ export class DiscordGatewayProtocolError extends Schema.TaggedError<DiscordGatewayProtocolError>()("DiscordGatewayProtocolError", {}) {}
@@ -0,0 +1,89 @@
1
+ import { Deferred, Effect, Redacted, Schema } from "effect";
2
+ import { Socket } from "effect/socket";
3
+ import { DiscordConfig } from "#DiscordConfig.ts";
4
+ import { DiscordToken } from "#DiscordToken.ts";
5
+ import { decodeData, decodeFrame, type GatewayDispatch, type GatewayFrame, type GatewaySession, Hello, Ready, reconnect } from "#gatewayProtocol.ts";
6
+
7
+ export function gatewayConnection(session: GatewaySession, dispatch: (event: GatewayDispatch) => Effect.Effect<void>) {
8
+ return Effect.gen(function* () {
9
+ const socket = yield* Socket.Socket;
10
+ const token = yield* DiscordToken;
11
+ const { intents } = yield* DiscordConfig;
12
+ const reader = yield* socket.reader;
13
+ const writer = yield* socket.writer;
14
+ const failed = yield* Deferred.make<never, Socket.SocketError>();
15
+ let acknowledged = true;
16
+ const send = Effect.fnUntraced(function* (op: number, d: unknown) {
17
+ const frame = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ d, op }).pipe(Effect.orDie);
18
+ yield* writer.write(frame);
19
+ });
20
+ const startHeartbeat = Effect.fnUntraced(function* (interval: number) {
21
+ const pulse = Effect.gen(function* () {
22
+ while (true) {
23
+ yield* Effect.sleep(interval);
24
+ if (!acknowledged) {
25
+ return yield* reconnect();
26
+ }
27
+ acknowledged = false;
28
+ yield* send(1, session.sequence);
29
+ }
30
+ });
31
+ yield* pulse.pipe(
32
+ Effect.catch((error) => Deferred.fail(failed, error)),
33
+ Effect.forkScoped,
34
+ );
35
+ });
36
+ const hello = Effect.fnUntraced(function* (data: unknown) {
37
+ const { heartbeatInterval } = yield* decodeData(Hello, data);
38
+ const payload =
39
+ session.sessionId === undefined
40
+ ? { intents, properties: { browser: "platform", device: "platform", os: "linux" }, token: Redacted.value(token) }
41
+ : { seq: session.sequence, "session_id": session.sessionId, token: Redacted.value(token) };
42
+ yield* send(session.sessionId === undefined ? 2 : 6, payload);
43
+ yield* startHeartbeat(heartbeatInterval);
44
+ });
45
+ const deliver = Effect.fnUntraced(function* (frame: GatewayFrame) {
46
+ if (frame.t === "READY") {
47
+ const ready = yield* decodeData(Ready, frame.d);
48
+ session.sessionId = ready.sessionId;
49
+ session.resumeUrl = ready.resumeUrl;
50
+ }
51
+ if (frame.t !== undefined && frame.t !== null && frame.s !== undefined && frame.s !== null) {
52
+ yield* dispatch({ data: frame.d, sequence: frame.s, type: frame.t });
53
+ }
54
+ });
55
+ const handle = Effect.fnUntraced(function* (frame: GatewayFrame) {
56
+ if (frame.s !== undefined && frame.s !== null) {
57
+ session.sequence = frame.s;
58
+ }
59
+ switch (frame.op) {
60
+ case 10:
61
+ return yield* hello(frame.d);
62
+ case 11:
63
+ acknowledged = true;
64
+ return;
65
+ case 1:
66
+ return yield* send(1, session.sequence);
67
+ case 7:
68
+ return yield* reconnect();
69
+ case 9:
70
+ if (frame.d !== true) {
71
+ session.sessionId = undefined;
72
+ session.resumeUrl = undefined;
73
+ session.sequence = null;
74
+ }
75
+ return yield* reconnect();
76
+ case 0:
77
+ return yield* deliver(frame);
78
+ }
79
+ });
80
+ const consume = Effect.gen(function* () {
81
+ while (true) {
82
+ for (const raw of yield* reader.pull) {
83
+ yield* handle(yield* decodeFrame(raw));
84
+ }
85
+ }
86
+ });
87
+ return yield* Effect.raceFirst(consume, Deferred.await(failed));
88
+ }).pipe(Effect.scoped);
89
+ }
@@ -0,0 +1,42 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { Socket } from "effect/socket";
3
+ import { DiscordGatewayProtocolError } from "#errors.ts";
4
+
5
+ export const GatewayFrame = Schema.Struct({
6
+ d: Schema.Unknown,
7
+ op: Schema.Number,
8
+ s: Schema.optional(Schema.NullOr(Schema.Number)),
9
+ t: Schema.optional(Schema.NullOr(Schema.String)),
10
+ });
11
+ export type GatewayFrame = typeof GatewayFrame.Type;
12
+
13
+ export interface GatewayDispatch {
14
+ readonly data: unknown;
15
+ readonly sequence: number;
16
+ readonly type: string;
17
+ }
18
+
19
+ export interface GatewaySession {
20
+ resumeUrl: string | undefined;
21
+ sequence: number | null;
22
+ sessionId: string | undefined;
23
+ }
24
+
25
+ export function decodeFrame(frame: string | Uint8Array) {
26
+ return Schema.decodeUnknownEffect(Schema.fromJsonString(GatewayFrame))(typeof frame === "string" ? frame : new TextDecoder().decode(frame)).pipe(
27
+ Effect.mapError(() => new DiscordGatewayProtocolError({})),
28
+ );
29
+ }
30
+
31
+ export const Hello = Schema.Struct({ heartbeatInterval: Schema.Number }).pipe(Schema.encodeKeys({ heartbeatInterval: "heartbeat_interval" }));
32
+ export const Ready = Schema.Struct({ resumeUrl: Schema.String, sessionId: Schema.String }).pipe(
33
+ Schema.encodeKeys({ resumeUrl: "resume_gateway_url", sessionId: "session_id" }),
34
+ );
35
+
36
+ export function decodeData<TSchema extends Schema.ConstraintDecoder<unknown>>(schema: TSchema, data: unknown) {
37
+ return Schema.decodeUnknownEffect(schema)(data).pipe(Effect.mapError(() => new DiscordGatewayProtocolError({})));
38
+ }
39
+
40
+ export function reconnect() {
41
+ return Effect.fail(new Socket.SocketError({ reason: new Socket.SocketCloseError({ code: 4000 }) }));
42
+ }
@@ -0,0 +1,39 @@
1
+ import { Clock, Effect } from "effect";
2
+ import type { Headers } from "effect/http";
3
+
4
+ export function rateLimits() {
5
+ const routes = new Map<string, string>();
6
+ const blocked = new Map<string, number>();
7
+ let globalReset = 0;
8
+ function key(route: string, major: string) {
9
+ return `${routes.get(route) ?? route}:${major}`;
10
+ }
11
+ return {
12
+ observe: (route: string, major: string, headers: Headers.Headers) =>
13
+ Effect.gen(function* () {
14
+ const bucket = headers["x-ratelimit-bucket"];
15
+ if (bucket !== undefined) {
16
+ routes.set(route, bucket);
17
+ }
18
+ if (headers["x-ratelimit-remaining"] === "0") {
19
+ blocked.set(key(route, major), (yield* Clock.currentTimeMillis) + Number(headers["x-ratelimit-reset-after"] ?? 0) * 1000);
20
+ }
21
+ }),
22
+ refuse: (route: string, major: string, seconds: number, global: boolean) =>
23
+ Effect.gen(function* () {
24
+ const reset = (yield* Clock.currentTimeMillis) + seconds * 1000;
25
+ if (global) {
26
+ globalReset = Math.max(globalReset, reset);
27
+ } else {
28
+ blocked.set(key(route, major), reset);
29
+ }
30
+ }),
31
+ wait: (route: string, major: string) =>
32
+ Effect.gen(function* () {
33
+ const delay = Math.max(globalReset, blocked.get(key(route, major)) ?? 0) - (yield* Clock.currentTimeMillis);
34
+ if (delay > 0) {
35
+ yield* Effect.sleep(delay);
36
+ }
37
+ }),
38
+ };
39
+ }
package/src/request.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { Effect, Schema } from "effect";
2
+ import type { HttpMethod } from "effect/http/HttpMethod";
3
+ import { DiscordRest } from "#DiscordRest.ts";
4
+
5
+ export function request<TSchema extends Schema.ConstraintDecoder<unknown>>(method: HttpMethod, path: string, schema: TSchema, body?: unknown) {
6
+ return Effect.flatMap(DiscordRest, (rest) => rest.request(method, path, body)).pipe(Effect.flatMap(Schema.decodeUnknownEffect(schema)));
7
+ }
@@ -0,0 +1,64 @@
1
+ import { Effect, Redacted, Schema, Semaphore } from "effect";
2
+ import { HttpClient, HttpClientRequest } from "effect/http";
3
+ import type { HttpMethod } from "effect/http/HttpMethod";
4
+ import { readBoundedJson } from "@shivaedev/http/body.ts";
5
+ import { checkStatus } from "@shivaedev/http/checkStatus.ts";
6
+ import { isEmpty, isRateLimited } from "#adapters/restStatus.ts";
7
+ import { routeKey } from "#adapters/routeKey.ts";
8
+ import { DiscordConfig } from "#DiscordConfig.ts";
9
+ import { DiscordToken } from "#DiscordToken.ts";
10
+ import { DiscordRateLimited, DiscordTransportError } from "#errors.ts";
11
+ import { rateLimits } from "#rateLimits.ts";
12
+
13
+ const Limited = Schema.Struct({ global: Schema.optional(Schema.Boolean), retryAfter: Schema.Number }).pipe(
14
+ Schema.encodeKeys({ retryAfter: "retry_after" }),
15
+ );
16
+
17
+ export const initializeRest = Effect.gen(function* () {
18
+ const client = HttpClient.withScope(yield* HttpClient.HttpClient);
19
+ const token = yield* DiscordToken;
20
+ const config = yield* DiscordConfig;
21
+ const rates = rateLimits();
22
+ const lock = yield* Semaphore.make(1);
23
+ const request = Effect.fnUntraced(function* (method: HttpMethod, path: string, body?: unknown) {
24
+ const { major, route } = routeKey(method, path);
25
+ let input = HttpClientRequest.make(method)(`${config.apiUrl}${path}`).pipe(
26
+ HttpClientRequest.setHeader("Authorization", `Bot ${Redacted.value(token)}`),
27
+ );
28
+ if (body !== undefined) {
29
+ input = yield* HttpClientRequest.bodyJson(input, body);
30
+ }
31
+ for (let attempt = 0; ; attempt += 1) {
32
+ yield* rates.wait(route, major);
33
+ const answer = yield* Effect.scoped(
34
+ Effect.gen(function* () {
35
+ const response = yield* client.execute(input);
36
+ yield* rates.observe(route, major, response.headers);
37
+ if (isRateLimited(response.status)) {
38
+ const limit = yield* readBoundedJson(response, 64 * 1024, Limited);
39
+ yield* rates.refuse(route, major, limit.retryAfter, limit.global === true);
40
+ if (attempt === 3) {
41
+ return yield* new DiscordRateLimited({ retryAfter: limit.retryAfter });
42
+ }
43
+ return { retry: true, value: undefined };
44
+ }
45
+ yield* checkStatus(response);
46
+ const value = isEmpty(response.status) ? undefined : yield* readBoundedJson(response, 1024 * 1024, Schema.Unknown);
47
+ return { retry: false, value };
48
+ }),
49
+ );
50
+ if (!answer.retry) {
51
+ return answer.value;
52
+ }
53
+ }
54
+ });
55
+ return { lock, request };
56
+ });
57
+
58
+ export function restRequest(state: Effect.Success<typeof initializeRest>) {
59
+ return (method: HttpMethod, path: string, body?: unknown) =>
60
+ state.request(method, path, body).pipe(
61
+ state.lock.withPermit,
62
+ Effect.catchTag("HttpClientError", () => Effect.fail(new DiscordTransportError({}))),
63
+ );
64
+ }
@@ -0,0 +1,32 @@
1
+ import { Effect } from "effect";
2
+ import { Socket } from "effect/socket";
3
+ import { DiscordConfig } from "#DiscordConfig.ts";
4
+ import { DiscordGatewayClosed } from "#errors.ts";
5
+ import { GatewayTransport } from "#GatewayTransport.ts";
6
+ import { gatewayConnection } from "#gatewayConnection.ts";
7
+ import type { GatewayDispatch, GatewaySession } from "#gatewayProtocol.ts";
8
+
9
+ export const runGateway = Effect.fnUntraced(function* (dispatch: (event: GatewayDispatch) => Effect.Effect<void>) {
10
+ const config = yield* DiscordConfig;
11
+ const transport = yield* GatewayTransport;
12
+ const session: GatewaySession = { resumeUrl: undefined, sequence: null, sessionId: undefined };
13
+ while (true) {
14
+ const socket = yield* transport.open(`${session.resumeUrl ?? config.gatewayUrl}/?v=10&encoding=json`);
15
+ yield* gatewayConnection(session, dispatch)
16
+ .pipe(Effect.provideService(Socket.Socket, socket))
17
+ .pipe(
18
+ Effect.catchTag("SocketError", (error) => {
19
+ const code = error.reason._tag === "SocketCloseError" ? error.reason.code : undefined;
20
+ if (code === 4004 || (code !== undefined && code >= 4010 && code <= 4014)) {
21
+ return Effect.fail(new DiscordGatewayClosed({ code }));
22
+ }
23
+ if (code === 4007 || code === 4009) {
24
+ session.sessionId = undefined;
25
+ session.resumeUrl = undefined;
26
+ session.sequence = null;
27
+ }
28
+ return Effect.sleep("1 second");
29
+ }),
30
+ );
31
+ }
32
+ });