@shivaedev/effect-github 0.0.0-stage → 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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/dist/GitHub.d.ts +25 -0
  4. package/dist/GitHub.d.ts.map +1 -0
  5. package/dist/GitHub.js +63 -0
  6. package/dist/GitHub.js.map +1 -0
  7. package/dist/GitHubConfig.d.ts +5 -0
  8. package/dist/GitHubConfig.d.ts.map +1 -0
  9. package/dist/GitHubConfig.js +5 -0
  10. package/dist/GitHubConfig.js.map +1 -0
  11. package/dist/GitHubToken.d.ts +9 -0
  12. package/dist/GitHubToken.d.ts.map +1 -0
  13. package/dist/GitHubToken.js +8 -0
  14. package/dist/GitHubToken.js.map +1 -0
  15. package/dist/cache.d.ts +12 -0
  16. package/dist/cache.d.ts.map +1 -0
  17. package/dist/cache.js +17 -0
  18. package/dist/cache.js.map +1 -0
  19. package/dist/errors.d.ts +23 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +18 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/githubApp.d.ts +14 -0
  24. package/dist/githubApp.d.ts.map +1 -0
  25. package/dist/githubApp.js +51 -0
  26. package/dist/githubApp.js.map +1 -0
  27. package/dist/json.d.ts +7 -0
  28. package/dist/json.d.ts.map +1 -0
  29. package/dist/json.js +14 -0
  30. package/dist/json.js.map +1 -0
  31. package/dist/rateLimits.d.ts +20 -0
  32. package/dist/rateLimits.d.ts.map +1 -0
  33. package/dist/rateLimits.js +43 -0
  34. package/dist/rateLimits.js.map +1 -0
  35. package/dist/requests.d.ts +9 -0
  36. package/dist/requests.d.ts.map +1 -0
  37. package/dist/requests.js +57 -0
  38. package/dist/requests.js.map +1 -0
  39. package/dist/status.d.ts +6 -0
  40. package/dist/status.d.ts.map +1 -0
  41. package/dist/status.js +33 -0
  42. package/dist/status.js.map +1 -0
  43. package/dist/verifyWebhook.d.ts +4 -0
  44. package/dist/verifyWebhook.d.ts.map +1 -0
  45. package/dist/verifyWebhook.js +14 -0
  46. package/dist/verifyWebhook.js.map +1 -0
  47. package/package.json +70 -3
  48. package/src/GitHub.ts +87 -0
  49. package/src/GitHubConfig.ts +5 -0
  50. package/src/GitHubToken.ts +18 -0
  51. package/src/cache.ts +25 -0
  52. package/src/errors.ts +17 -0
  53. package/src/githubApp.ts +66 -0
  54. package/src/json.ts +16 -0
  55. package/src/rateLimits.ts +56 -0
  56. package/src/requests.ts +68 -0
  57. package/src/status.ts +37 -0
  58. package/src/verifyWebhook.ts +19 -0
@@ -0,0 +1,57 @@
1
+ import { DateTime, Effect, Option, Redacted } from "effect";
2
+ import { HttpClientRequest } from "effect/http";
3
+ import { readBoundedBody } from "@shivaedev/platform/http-client/body.ts";
4
+ import { ResponseBodyTooLarge } from "@shivaedev/platform/http-client/errors.ts";
5
+ import { responseCache } from "./cache.js";
6
+ import { GitHubTransportError } from "./errors.js";
7
+ import { failRateLimited } from "./rateLimits.js";
8
+ import { checkStatus, isUnchanged } from "./status.js";
9
+ export function requests(client, token, rates) {
10
+ const cache = responseCache();
11
+ const accessToken = Effect.isEffect(token) ? token : Effect.succeed(token);
12
+ return Effect.fn("GitHub.request")(function* (input, limit) {
13
+ const path = new URL(input.url).pathname;
14
+ const resource = rates.resource(path);
15
+ const blocked = rates.blocked.get(resource);
16
+ if (blocked !== undefined && DateTime.isGreaterThan(blocked.retryAt, yield* DateTime.now)) {
17
+ return yield* failRateLimited(path, blocked, rates.observations.get(resource)?.remaining);
18
+ }
19
+ const key = `${input.url} ${input.headers.accept}`;
20
+ const cached = input.method === "GET" ? cache.get(key) : undefined;
21
+ const request = requestHeaders(input, cached, yield* accessToken);
22
+ const response = yield* client.execute(request).pipe(Effect.mapError(() => new GitHubTransportError({ path })));
23
+ const observedResource = rates.observe(response);
24
+ if (isUnchanged(response) && cached !== undefined) {
25
+ rates.failures.delete(observedResource);
26
+ rates.blocked.delete(observedResource);
27
+ if (cached.bytes.byteLength > limit) {
28
+ return yield* new ResponseBodyTooLarge({ limit });
29
+ }
30
+ return cached;
31
+ }
32
+ yield* checkStatus(response, observedResource, rates.failures.get(observedResource) ?? 0).pipe(Effect.catchTag("HttpClientError", () => Effect.fail(new GitHubTransportError({ path }))), Effect.catchTag("GitHubRateLimited", (error) => {
33
+ rates.blocked.set(error.resource, error);
34
+ rates.failures.set(error.resource, (rates.failures.get(error.resource) ?? 0) + 1);
35
+ return failRateLimited(path, error, rates.observations.get(error.resource)?.remaining);
36
+ }));
37
+ rates.failures.delete(observedResource);
38
+ rates.blocked.delete(observedResource);
39
+ const bytes = yield* readBoundedBody(response, limit).pipe(Effect.catchTag("HttpClientError", () => Effect.fail(new GitHubTransportError({ path }))));
40
+ const entry = { bytes, decoded: new Map(), response };
41
+ if (input.method === "GET" && response.headers.etag !== undefined) {
42
+ cache.set(key, entry);
43
+ }
44
+ return entry;
45
+ });
46
+ }
47
+ function requestHeaders(input, cached, token) {
48
+ let request = HttpClientRequest.removeHeader(input, "if-none-match").pipe(HttpClientRequest.setHeader("X-GitHub-Api-Version", "2022-11-28"));
49
+ if (Option.isSome(token)) {
50
+ request = HttpClientRequest.setHeader(request, "Authorization", `Bearer ${Redacted.value(token.value)}`);
51
+ }
52
+ if (cached?.response.headers.etag !== undefined) {
53
+ request = HttpClientRequest.setHeader(request, "If-None-Match", cached.response.headers.etag);
54
+ }
55
+ return request;
56
+ }
57
+ //# sourceMappingURL=requests.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"requests.js","sourceRoot":"","sources":["../src/requests.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAC5D,OAAO,EAAmB,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACjE,OAAO,EAAE,eAAe,EAAE,MAAM,yCAAyC,CAAC;AAC1E,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAuB,aAAa,EAAE,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAEnD,OAAO,EAAE,eAAe,EAAmB,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAEvD,MAAM,UAAU,QAAQ,CAAC,MAA6B,EAAE,KAA6B,EAAE,KAAiB;IACvG,MAAM,KAAK,GAAG,aAAa,EAAE,CAAC;IAC9B,MAAM,WAAW,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAC3E,OAAO,MAAM,CAAC,EAAE,CAAC,gBAAgB,CAAC,CAAC,QAAQ,CAAC,EAAE,KAA0C,EAAE,KAAa;QACtG,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC;QACzC,MAAM,QAAQ,GAAG,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QACtC,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC5C,IAAI,OAAO,KAAK,SAAS,IAAI,QAAQ,CAAC,aAAa,CAAC,OAAO,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;YAC3F,OAAO,KAAK,CAAC,CAAC,eAAe,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC,CAAC;QAC3F,CAAC;QACD,MAAM,GAAG,GAAG,GAAG,KAAK,CAAC,GAAG,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;QACnD,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;QACnE,MAAM,OAAO,GAAG,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC,WAAW,CAAC,CAAC;QAClE,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,oBAAoB,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;QAChH,MAAM,gBAAgB,GAAG,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;QACjD,IAAI,WAAW,CAAC,QAAQ,CAAC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;YACnD,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC;YACxC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC;YACvC,IAAI,MAAM,CAAC,KAAK,CAAC,UAAU,GAAG,KAAK,EAAE,CAAC;gBACrC,OAAO,KAAK,CAAC,CAAC,IAAI,oBAAoB,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;YACnD,CAAC;YACD,OAAO,MAAM,CAAC;QACf,CAAC;QACD,KAAK,CAAC,CAAC,WAAW,CAAC,QAAQ,EAAE,gBAAgB,EAAE,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAC7F,MAAM,CAAC,QAAQ,CAAC,iBAAiB,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,oBAAoB,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EACzF,MAAM,CAAC,QAAQ,CAAC,mBAAmB,EAAE,CAAC,KAAK,EAAE,EAAE;YAC9C,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;YACzC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAClF,OAAO,eAAe,CAAC,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC,CAAC;QACxF,CAAC,CAAC,CACF,CAAC;QACF,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC;QACxC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC;QACvC,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,eAAe,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC,IAAI,CACzD,MAAM,CAAC,QAAQ,CAAC,iBAAiB,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,oBAAoB,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CACzF,CAAC;QACF,MAAM,KAAK,GAAmB,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,GAAG,EAAE,EAAE,QAAQ,EAAE,CAAC;QACtE,IAAI,KAAK,CAAC,MAAM,KAAK,KAAK,IAAI,QAAQ,CAAC,OAAO,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YACnE,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QACvB,CAAC;QACD,OAAO,KAAK,CAAC;IACd,CAAC,CAAC,CAAC;AACJ,CAAC;AAED,SAAS,cAAc,CACtB,KAA0C,EAC1C,MAAkC,EAClC,KAA+C;IAE/C,IAAI,OAAO,GAAG,iBAAiB,CAAC,YAAY,CAAC,KAAK,EAAE,eAAe,CAAC,CAAC,IAAI,CAAC,iBAAiB,CAAC,SAAS,CAAC,sBAAsB,EAAE,YAAY,CAAC,CAAC,CAAC;IAC7I,IAAI,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;QAC1B,OAAO,GAAG,iBAAiB,CAAC,SAAS,CAAC,OAAO,EAAE,eAAe,EAAE,UAAU,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAC1G,CAAC;IACD,IAAI,MAAM,EAAE,QAAQ,CAAC,OAAO,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QACjD,OAAO,GAAG,iBAAiB,CAAC,SAAS,CAAC,OAAO,EAAE,eAAe,EAAE,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/F,CAAC;IACD,OAAO,OAAO,CAAC;AAChB,CAAC"}
@@ -0,0 +1,6 @@
1
+ import { Effect } from "effect";
2
+ import { type HttpClientResponse } from "effect/http";
3
+ import { GitHubRateLimited } from "./errors.ts";
4
+ export declare function isUnchanged(response: HttpClientResponse.HttpClientResponse): boolean;
5
+ export declare const checkStatus: (response: HttpClientResponse.HttpClientResponse, resource: string, failures?: any) => Effect.Effect<HttpClientResponse.HttpClientResponse, GitHubRateLimited | import("effect/http/HttpClientError").HttpClientError | import("@shivaedev/platform/http-client/errors.ts").ResponseBodyTooLarge | import("@shivaedev/platform/http-client/errors.ts").RemoteStatusError, never>;
6
+ //# sourceMappingURL=status.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,MAAM,EAAkB,MAAM,QAAQ,CAAC;AAC1D,OAAO,EAAc,KAAK,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAIlE,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAEhD,wBAAgB,WAAW,CAAC,QAAQ,EAAE,kBAAkB,CAAC,kBAAkB,WAE1E;AAED,eAAO,MAAM,WAAW,kXAiBtB,CAAC"}
package/dist/status.js ADDED
@@ -0,0 +1,33 @@
1
+ import { DateTime, Effect, Option, Schema } from "effect";
2
+ import { HttpClient } from "effect/http";
3
+ import { readBoundedText } from "@shivaedev/platform/http-client/body.ts";
4
+ import { filterStatus } from "@shivaedev/platform/http-client/filterStatus.ts";
5
+ import { parseRetryAfter } from "@shivaedev/platform/http-client/parseRetryAfter.ts";
6
+ import { GitHubRateLimited } from "./errors.js";
7
+ export function isUnchanged(response) {
8
+ return response.status === 304;
9
+ }
10
+ export const checkStatus = Effect.fnUntraced(function* (response, resource, failures = 0) {
11
+ const { status, headers } = response;
12
+ if (status === 403 || status === 429) {
13
+ const limitedByHeaders = status === 429 || headers["x-ratelimit-remaining"] === "0" || headers["retry-after"] !== undefined;
14
+ const limitedByMessage = !limitedByHeaders && status === 403 && (yield* messageNamesRateLimit(response));
15
+ if (limitedByHeaders || limitedByMessage) {
16
+ const now = yield* DateTime.now;
17
+ const reset = Number(headers["x-ratelimit-reset"] ?? 0) * 1000;
18
+ const fallback = headers["x-ratelimit-remaining"] === "0" && reset > DateTime.toEpochMillis(now)
19
+ ? reset
20
+ : DateTime.toEpochMillis(now) + Math.min(15 * 60_000, 60_000 * 2 ** failures);
21
+ const retryAt = parseRetryAfter(headers["retry-after"], now) ?? DateTime.makeUnsafe(fallback);
22
+ return yield* new GitHubRateLimited({ resource, retryAt });
23
+ }
24
+ }
25
+ return yield* filterStatus(HttpClient.makeWith(() => Effect.succeed(response), Effect.succeed)).execute(response.request);
26
+ });
27
+ const Message = Schema.fromJsonString(Schema.Struct({ message: Schema.String }));
28
+ const messageNamesRateLimit = Effect.fnUntraced(function* (response) {
29
+ const text = yield* readBoundedText(response, 64 * 1024);
30
+ const body = Schema.decodeUnknownOption(Message)(text);
31
+ return Option.isSome(body) && /rate limit/iu.test(body.value.message);
32
+ });
33
+ //# sourceMappingURL=status.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status.js","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ import { Effect, Redacted, Schema } from "effect";
2
+ import { GitHubWebhookSignatureError } from "./errors.ts";
3
+ export declare const verifyWebhook: <TSchema extends Schema.Constraint>(body: Uint8Array<ArrayBufferLike>, signature: string | undefined, secret: Redacted.Redacted<string>, schema: TSchema) => Effect.Effect<TSchema["Type"], GitHubWebhookSignatureError | Schema.SchemaError, TSchema["DecodingServices"]>;
4
+ //# sourceMappingURL=verifyWebhook.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verifyWebhook.d.ts","sourceRoot":"","sources":["../src/verifyWebhook.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAClD,OAAO,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAE1D,eAAO,MAAM,aAAa,GAAgC,OAAO,SAAS,MAAM,CAAC,UAAU,wOAczF,CAAC"}
@@ -0,0 +1,14 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { Effect, Redacted, Schema } from "effect";
3
+ import { GitHubWebhookSignatureError } from "./errors.js";
4
+ export const verifyWebhook = Effect.fnUntraced(function* (body, signature, secret, schema) {
5
+ if (signature === undefined || !/^sha256=[0-9a-f]{64}$/u.test(signature)) {
6
+ return yield* new GitHubWebhookSignatureError();
7
+ }
8
+ const expected = createHmac("sha256", Redacted.value(secret)).update(body).digest();
9
+ if (!timingSafeEqual(expected, Buffer.from(signature.slice(7), "hex"))) {
10
+ return yield* new GitHubWebhookSignatureError();
11
+ }
12
+ return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(new TextDecoder().decode(body));
13
+ });
14
+ //# sourceMappingURL=verifyWebhook.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verifyWebhook.js","sourceRoot":"","sources":["../src/verifyWebhook.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAClD,OAAO,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAE1D,MAAM,CAAC,MAAM,aAAa,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EACvD,IAAgB,EAChB,SAA6B,EAC7B,MAAiC,EACjC,MAAe;IAEf,IAAI,SAAS,KAAK,SAAS,IAAI,CAAC,wBAAwB,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;QAC1E,OAAO,KAAK,CAAC,CAAC,IAAI,2BAA2B,EAAE,CAAC;IACjD,CAAC;IACD,MAAM,QAAQ,GAAG,UAAU,CAAC,QAAQ,EAAE,QAAQ,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,CAAC;IACpF,IAAI,CAAC,eAAe,CAAC,QAAQ,EAAE,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,EAAE,CAAC;QACxE,OAAO,KAAK,CAAC,CAAC,IAAI,2BAA2B,EAAE,CAAC;IACjD,CAAC;IACD,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,mBAAmB,CAAC,MAAM,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;AACzG,CAAC,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,73 @@
1
1
  {
2
2
  "name": "@shivaedev/effect-github",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Typed GitHub REST requests, conditional reads and resource budgets for Effect applications",
5
+ "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/effect-github#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/ShivaeDev/platform/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/ShivaeDev/platform.git",
12
+ "directory": "packages/effect-github"
13
+ },
14
+ "license": "MIT",
15
+ "sideEffects": false,
16
+ "type": "module",
17
+ "imports": {
18
+ "#test/*": "./src/test-support/*",
19
+ "#*.ts": {
20
+ "source": "./src/*.ts",
21
+ "types": "./dist/*.d.ts",
22
+ "default": "./dist/*.js"
23
+ }
24
+ },
25
+ "exports": {
26
+ "./*.ts": {
27
+ "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
+ "README.md"
40
+ ],
41
+ "dependencies": {
42
+ "@shivaedev/effect-service": "0.4.1",
43
+ "@shivaedev/platform": "0.28.0"
44
+ },
45
+ "devDependencies": {
46
+ "@effect/platform-node": "4.0.5",
47
+ "@effect/platform-node-shared": "4.0.5",
48
+ "@effect/vitest": "4.0.5",
49
+ "@shivaedev/effect-test": "0.8.1",
50
+ "@shivaedev/quality": "0.38.0",
51
+ "@shivaedev/test-story": "0.19.0",
52
+ "@types/node": "24.19.2",
53
+ "effect": "4.0.5",
54
+ "typescript": "7.0.2",
55
+ "vitest": "5.0.5"
56
+ },
57
+ "peerDependencies": {
58
+ "effect": "4.0.5"
59
+ },
60
+ "engines": {
61
+ "node": ">=24"
62
+ },
63
+ "publishConfig": {
64
+ "access": "public",
65
+ "provenance": true
66
+ },
67
+ "scripts": {
68
+ "build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc --project tsconfig.build.json",
69
+ "ready": "pnpm typecheck && pnpm test && pnpm build",
70
+ "test": "vitest run",
71
+ "typecheck": "tsc --noEmit"
72
+ }
6
73
  }
package/src/GitHub.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { Effect, Schema, Stream } from "effect";
2
+ import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http";
3
+ import { defineService } from "@shivaedev/effect-service/defineService.ts";
4
+ import type { ServiceUnavailable } from "@shivaedev/platform/errors/taxonomy.ts";
5
+ import { paginate } from "@shivaedev/platform/http-client/paginate.ts";
6
+ import type { CachedResponse } from "./cache.ts";
7
+ import { GitHubConfig } from "./GitHubConfig.ts";
8
+ import { GitHubToken } from "./GitHubToken.ts";
9
+ import { jsonResponse } from "./json.ts";
10
+ import { rateLimits } from "./rateLimits.ts";
11
+ import { requests } from "./requests.ts";
12
+
13
+ export const GitHub = defineService({
14
+ id: "@shivaedev/effect-github/GitHub",
15
+ initialize: Effect.gen(function* () {
16
+ const config = yield* GitHubConfig;
17
+ const rates = rateLimits();
18
+ return {
19
+ apiUrl: config.apiUrl,
20
+ rates,
21
+ request: requests(yield* HttpClient.HttpClient, yield* GitHubToken, rates),
22
+ };
23
+ }),
24
+ methods: ({ apiUrl, rates, request }) => {
25
+ function get(path: string, accept = "application/vnd.github+json") {
26
+ return HttpClientRequest.get(new URL(path, apiUrl).href, { headers: { Accept: accept } });
27
+ }
28
+ function readRepresentation(path: string) {
29
+ return Effect.map(request(get(path), 1024 * 1024), jsonResponse);
30
+ }
31
+ function listRepresentations(path: string, beforeRequest?: (resource: string) => Effect.Effect<void, ServiceUnavailable>) {
32
+ const entries = new WeakMap<HttpClientResponse.HttpClientResponse, CachedResponse>();
33
+ function fetchPage(input: HttpClientRequest.HttpClientRequest) {
34
+ return (beforeRequest?.(rates.resource(new URL(input.url).pathname)) ?? Effect.void).pipe(
35
+ Effect.andThen(request(input, 1024 * 1024)),
36
+ Effect.map((entry) => {
37
+ entries.set(entry.response, entry);
38
+ return entry.response;
39
+ }),
40
+ );
41
+ }
42
+ const client = HttpClient.makeWith(
43
+ (effect: Effect.Effect<HttpClientRequest.HttpClientRequest>) => Effect.flatMap(effect, fetchPage),
44
+ Effect.succeed,
45
+ );
46
+ return Effect.succeed(
47
+ paginate(client, get(path)).pipe(
48
+ Stream.mapEffect((response) => Effect.fromNullishOr(entries.get(response))),
49
+ Stream.map(jsonResponse),
50
+ ),
51
+ );
52
+ }
53
+ function writeRepresentation(method: "POST" | "PATCH", path: string, body: unknown) {
54
+ return HttpClientRequest.make(method)(new URL(path, apiUrl).href, { headers: { Accept: "application/vnd.github+json" } }).pipe(
55
+ HttpClientRequest.bodyJson(body),
56
+ Effect.flatMap((input) => request(input, 1024 * 1024)),
57
+ Effect.map(jsonResponse),
58
+ );
59
+ }
60
+ return {
61
+ bytes: (path: string, accept: string, limit: number) => Effect.map(request(get(path, accept), limit), (entry) => entry.bytes),
62
+ list: listRepresentations,
63
+ observations: () => Effect.sync(() => [...rates.observations.values()]),
64
+ read: readRepresentation,
65
+ resource: (path: string) => Effect.sync(() => rates.resource(new URL(path, apiUrl).pathname)),
66
+ text: (path: string, accept: string, limit: number) =>
67
+ Effect.map(request(get(path, accept), limit), (entry) => new TextDecoder().decode(entry.bytes)),
68
+ write: writeRepresentation,
69
+ };
70
+ },
71
+ requires: [HttpClient.HttpClient, GitHubToken],
72
+ });
73
+
74
+ export function read<TSchema extends Schema.Constraint>(path: string, schema: TSchema) {
75
+ return Effect.flatMap(GitHub, (github) => Effect.flatMap(github.read(path), (response) => response.decode(schema)));
76
+ }
77
+
78
+ export function list<TSchema extends Schema.Constraint>(path: string, schema: TSchema) {
79
+ return Stream.unwrap(Effect.flatMap(GitHub, (github) => github.list(path))).pipe(
80
+ Stream.mapEffect((response) => response.decode(Schema.Array(schema))),
81
+ Stream.flatMap(Stream.fromIterable),
82
+ );
83
+ }
84
+
85
+ export function write<TSchema extends Schema.Constraint>(method: "POST" | "PATCH", path: string, body: unknown, schema: TSchema) {
86
+ return Effect.flatMap(GitHub, (github) => Effect.flatMap(github.write(method, path, body), (response) => response.decode(schema)));
87
+ }
@@ -0,0 +1,5 @@
1
+ import { Context } from "effect";
2
+
3
+ export const GitHubConfig = Context.Reference<{ readonly apiUrl: string }>("@shivaedev/effect-github/GitHubConfig", {
4
+ defaultValue: () => ({ apiUrl: "https://api.github.com" }),
5
+ });
@@ -0,0 +1,18 @@
1
+ import { Config, Context, Effect, Layer, type Option, type Redacted } from "effect";
2
+ import type { GitHubAppError } from "./errors.ts";
3
+
4
+ type Token = Option.Option<Redacted.Redacted<string>>;
5
+
6
+ export class GitHubToken extends Context.Service<GitHubToken, Token | Effect.Effect<Token, GitHubAppError>>()(
7
+ "@shivaedev/effect-github/GitHubToken",
8
+ ) {
9
+ static readonly fromEnv = Layer.effect(GitHubToken)(
10
+ Effect.gen(function* () {
11
+ const token = yield* Config.Redacted("GH_TOKEN").pipe(
12
+ Config.orElse(() => Config.Redacted("GITHUB_TOKEN")),
13
+ Config.option,
14
+ );
15
+ return token;
16
+ }),
17
+ );
18
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { Schema } from "effect";
2
+ import type { HttpClientResponse } from "effect/http";
3
+
4
+ export interface CachedResponse {
5
+ readonly bytes: Uint8Array;
6
+ readonly decoded: Map<Schema.Constraint, unknown>;
7
+ readonly response: HttpClientResponse.HttpClientResponse;
8
+ }
9
+
10
+ export function responseCache() {
11
+ const entries = new Map<string, CachedResponse>();
12
+ return {
13
+ get: (key: string) => entries.get(key),
14
+ set(key: string, entry: CachedResponse) {
15
+ entries.delete(key);
16
+ entries.set(key, entry);
17
+ if (entries.size > 1000) {
18
+ const first = entries.keys().next();
19
+ if (!first.done) {
20
+ entries.delete(first.value);
21
+ }
22
+ }
23
+ },
24
+ };
25
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { Schema } from "effect";
2
+
3
+ export class GitHubRateLimited extends Schema.TaggedError<GitHubRateLimited>()("GitHubRateLimited", {
4
+ resource: Schema.String,
5
+ retryAt: Schema.DateTimeUtc,
6
+ }) {}
7
+
8
+ export class GitHubTransportError extends Schema.TaggedError<GitHubTransportError>()("GitHubTransportError", {
9
+ path: Schema.String,
10
+ }) {}
11
+
12
+ export class GitHubAppError extends Schema.TaggedError<GitHubAppError>()("GitHubAppError", {
13
+ stage: Schema.Literals(["sign", "exchange"]),
14
+ status: Schema.optional(Schema.Number),
15
+ }) {}
16
+
17
+ export class GitHubWebhookSignatureError extends Schema.TaggedError<GitHubWebhookSignatureError>()("GitHubWebhookSignatureError", {}) {}
@@ -0,0 +1,66 @@
1
+ import { sign } from "node:crypto";
2
+ import { Clock, Effect, Exit, Layer, Option, Redacted, Schema } from "effect";
3
+ import { HttpClient, HttpClientRequest } from "effect/http";
4
+ import { readBoundedJson } from "@shivaedev/platform/http-client/body.ts";
5
+ import { GitHubAppError } from "./errors.ts";
6
+ import { GitHubConfig } from "./GitHubConfig.ts";
7
+ import { GitHubToken } from "./GitHubToken.ts";
8
+
9
+ export type GitHubAppOptions = {
10
+ readonly appId: string;
11
+ readonly privateKey: Redacted.Redacted<string>;
12
+ } & ({ readonly installationId: number } | { readonly owner: string; readonly repository: string });
13
+
14
+ const Installation = Schema.Struct({ id: Schema.Number });
15
+ const AccessToken = Schema.Struct({ expiresAt: Schema.DateFromString, token: Schema.RedactedFromValue(Schema.String) }).pipe(
16
+ Schema.encodeKeys({ expiresAt: "expires_at" }),
17
+ );
18
+
19
+ export function githubApp(options: GitHubAppOptions) {
20
+ return Layer.effect(GitHubToken)(
21
+ Effect.gen(function* () {
22
+ const client = yield* HttpClient.HttpClient;
23
+ const { apiUrl } = yield* GitHubConfig;
24
+ const clock = yield* Clock.Clock;
25
+ let installationId = "installationId" in options ? options.installationId : undefined;
26
+ const request = appRequests(client, apiUrl);
27
+ const exchange = Effect.gen(function* () {
28
+ const now = Math.floor((yield* Clock.currentTimeMillis) / 1000);
29
+ const jwt = yield* Effect.try({
30
+ catch: () => new GitHubAppError({ stage: "sign" }),
31
+ try: () => {
32
+ const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
33
+ const payload = Buffer.from(JSON.stringify({ exp: now + 600, iat: now - 60, iss: options.appId })).toString("base64url");
34
+ const input = `${header}.${payload}`;
35
+ return `${input}.${sign("RSA-SHA256", Buffer.from(input), Redacted.value(options.privateKey)).toString("base64url")}`;
36
+ },
37
+ });
38
+ if (installationId === undefined && "owner" in options) {
39
+ const path = `/repos/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repository)}/installation`;
40
+ installationId = (yield* request("GET", path, jwt, Installation)).id;
41
+ }
42
+ return yield* request("POST", `/app/installations/${installationId}/access_tokens`, jwt, AccessToken);
43
+ });
44
+ const cached = yield* Effect.cachedWithTTL(exchange, (exit) =>
45
+ Exit.isSuccess(exit) ? Math.max(0, exit.value.expiresAt.getTime() - clock.currentTimeMillisUnsafe() - 300_000) : 0,
46
+ );
47
+ return Effect.map(cached, ({ token }) => Option.some(token));
48
+ }),
49
+ );
50
+ }
51
+
52
+ function appRequests(client: HttpClient.HttpClient, apiUrl: string) {
53
+ return Effect.fnUntraced(function* <TSchema extends Schema.Constraint>(method: "GET" | "POST", path: string, jwt: string, schema: TSchema) {
54
+ const response = yield* client
55
+ .execute(
56
+ HttpClientRequest.make(method)(new URL(path, apiUrl).href, {
57
+ headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${jwt}`, "X-GitHub-Api-Version": "2022-11-28" },
58
+ }),
59
+ )
60
+ .pipe(Effect.mapError(() => new GitHubAppError({ stage: "exchange" })));
61
+ if (response.status < 200 || response.status >= 300) {
62
+ return yield* new GitHubAppError({ stage: "exchange", status: response.status });
63
+ }
64
+ return yield* readBoundedJson(response, 64 * 1024, schema).pipe(Effect.mapError(() => new GitHubAppError({ stage: "exchange" })));
65
+ });
66
+ }
package/src/json.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { Effect, Schema } from "effect";
2
+ import type { CachedResponse } from "./cache.ts";
3
+
4
+ export const decodeJson = Effect.fnUntraced(function* <TSchema extends Schema.Constraint>(entry: CachedResponse, schema: TSchema) {
5
+ const cached = entry.decoded.get(schema);
6
+ if (entry.decoded.has(schema) && Schema.is(schema)(cached)) {
7
+ return cached;
8
+ }
9
+ const value = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(new TextDecoder().decode(entry.bytes));
10
+ entry.decoded.set(schema, value);
11
+ return value;
12
+ });
13
+
14
+ export function jsonResponse(entry: CachedResponse) {
15
+ return { decode: <TSchema extends Schema.Constraint>(schema: TSchema) => decodeJson(entry, schema) };
16
+ }
@@ -0,0 +1,56 @@
1
+ import { Clock, DateTime, Effect } from "effect";
2
+ import type { HttpClientResponse } from "effect/http";
3
+ import type { GitHubRateLimited } from "./errors.ts";
4
+
5
+ export interface RateLimitObservation {
6
+ readonly limit: number;
7
+ readonly remaining: number;
8
+ readonly reset: DateTime.Utc;
9
+ readonly resource: string;
10
+ readonly used: number;
11
+ }
12
+
13
+ export function rateLimits() {
14
+ const observations = new Map<string, RateLimitObservation>();
15
+ const blocked = new Map<string, GitHubRateLimited>();
16
+ const failures = new Map<string, number>();
17
+ const resources = new Map<string, string>();
18
+ function resource(path: string) {
19
+ return resources.get(path) ?? (path.startsWith("/search/") ? "search" : "core");
20
+ }
21
+ return {
22
+ blocked,
23
+ failures,
24
+ observations,
25
+ observe(response: HttpClientResponse.HttpClientResponse) {
26
+ const headers = response.headers;
27
+ const path = new URL(response.request.url).pathname;
28
+ const name = headers["x-ratelimit-resource"] ?? resource(path);
29
+ resources.set(path, name);
30
+ const values = ["limit", "remaining", "used", "reset"].map((key) => headers[`x-ratelimit-${key}`]);
31
+ if (values.every((value) => value !== undefined && Number.isFinite(Number(value)))) {
32
+ observations.set(name, {
33
+ limit: Number(headers["x-ratelimit-limit"]),
34
+ remaining: Number(headers["x-ratelimit-remaining"]),
35
+ reset: DateTime.makeUnsafe(Number(headers["x-ratelimit-reset"]) * 1000),
36
+ resource: name,
37
+ used: Number(headers["x-ratelimit-used"]),
38
+ });
39
+ }
40
+ return name;
41
+ },
42
+ resource,
43
+ };
44
+ }
45
+
46
+ export type RateLimits = ReturnType<typeof rateLimits>;
47
+
48
+ export const failRateLimited = Effect.fnUntraced(function* (path: string, error: GitHubRateLimited, remaining: number | undefined) {
49
+ const fields = { path, remaining, resource: error.resource, retryAt: DateTime.formatIso(error.retryAt) };
50
+ yield* Effect.logWarning("GitHub rate limited", fields);
51
+ const span = yield* Effect.currentSpan.pipe(Effect.option);
52
+ if (span._tag === "Some") {
53
+ span.value.event("GitHub.rateLimited", yield* Clock.currentTimeNanos, fields);
54
+ }
55
+ return yield* error;
56
+ });
@@ -0,0 +1,68 @@
1
+ import { DateTime, Effect, Option, Redacted } from "effect";
2
+ import { type HttpClient, HttpClientRequest } from "effect/http";
3
+ import { readBoundedBody } from "@shivaedev/platform/http-client/body.ts";
4
+ import { ResponseBodyTooLarge } from "@shivaedev/platform/http-client/errors.ts";
5
+ import { type CachedResponse, responseCache } from "./cache.ts";
6
+ import { GitHubTransportError } from "./errors.ts";
7
+ import type { GitHubToken } from "./GitHubToken.ts";
8
+ import { failRateLimited, type RateLimits } from "./rateLimits.ts";
9
+ import { checkStatus, isUnchanged } from "./status.ts";
10
+
11
+ export function requests(client: HttpClient.HttpClient, token: GitHubToken["Service"], rates: RateLimits) {
12
+ const cache = responseCache();
13
+ const accessToken = Effect.isEffect(token) ? token : Effect.succeed(token);
14
+ return Effect.fn("GitHub.request")(function* (input: HttpClientRequest.HttpClientRequest, limit: number) {
15
+ const path = new URL(input.url).pathname;
16
+ const resource = rates.resource(path);
17
+ const blocked = rates.blocked.get(resource);
18
+ if (blocked !== undefined && DateTime.isGreaterThan(blocked.retryAt, yield* DateTime.now)) {
19
+ return yield* failRateLimited(path, blocked, rates.observations.get(resource)?.remaining);
20
+ }
21
+ const key = `${input.url} ${input.headers.accept}`;
22
+ const cached = input.method === "GET" ? cache.get(key) : undefined;
23
+ const request = requestHeaders(input, cached, yield* accessToken);
24
+ const response = yield* client.execute(request).pipe(Effect.mapError(() => new GitHubTransportError({ path })));
25
+ const observedResource = rates.observe(response);
26
+ if (isUnchanged(response) && cached !== undefined) {
27
+ rates.failures.delete(observedResource);
28
+ rates.blocked.delete(observedResource);
29
+ if (cached.bytes.byteLength > limit) {
30
+ return yield* new ResponseBodyTooLarge({ limit });
31
+ }
32
+ return cached;
33
+ }
34
+ yield* checkStatus(response, observedResource, rates.failures.get(observedResource) ?? 0).pipe(
35
+ Effect.catchTag("HttpClientError", () => Effect.fail(new GitHubTransportError({ path }))),
36
+ Effect.catchTag("GitHubRateLimited", (error) => {
37
+ rates.blocked.set(error.resource, error);
38
+ rates.failures.set(error.resource, (rates.failures.get(error.resource) ?? 0) + 1);
39
+ return failRateLimited(path, error, rates.observations.get(error.resource)?.remaining);
40
+ }),
41
+ );
42
+ rates.failures.delete(observedResource);
43
+ rates.blocked.delete(observedResource);
44
+ const bytes = yield* readBoundedBody(response, limit).pipe(
45
+ Effect.catchTag("HttpClientError", () => Effect.fail(new GitHubTransportError({ path }))),
46
+ );
47
+ const entry: CachedResponse = { bytes, decoded: new Map(), response };
48
+ if (input.method === "GET" && response.headers.etag !== undefined) {
49
+ cache.set(key, entry);
50
+ }
51
+ return entry;
52
+ });
53
+ }
54
+
55
+ function requestHeaders(
56
+ input: HttpClientRequest.HttpClientRequest,
57
+ cached: CachedResponse | undefined,
58
+ token: Option.Option<Redacted.Redacted<string>>,
59
+ ) {
60
+ let request = HttpClientRequest.removeHeader(input, "if-none-match").pipe(HttpClientRequest.setHeader("X-GitHub-Api-Version", "2022-11-28"));
61
+ if (Option.isSome(token)) {
62
+ request = HttpClientRequest.setHeader(request, "Authorization", `Bearer ${Redacted.value(token.value)}`);
63
+ }
64
+ if (cached?.response.headers.etag !== undefined) {
65
+ request = HttpClientRequest.setHeader(request, "If-None-Match", cached.response.headers.etag);
66
+ }
67
+ return request;
68
+ }
package/src/status.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { DateTime, Effect, Option, Schema } from "effect";
2
+ import { HttpClient, type HttpClientResponse } from "effect/http";
3
+ import { readBoundedText } from "@shivaedev/platform/http-client/body.ts";
4
+ import { filterStatus } from "@shivaedev/platform/http-client/filterStatus.ts";
5
+ import { parseRetryAfter } from "@shivaedev/platform/http-client/parseRetryAfter.ts";
6
+ import { GitHubRateLimited } from "./errors.ts";
7
+
8
+ export function isUnchanged(response: HttpClientResponse.HttpClientResponse) {
9
+ return response.status === 304;
10
+ }
11
+
12
+ export const checkStatus = Effect.fnUntraced(function* (response: HttpClientResponse.HttpClientResponse, resource: string, failures = 0) {
13
+ const { status, headers } = response;
14
+ if (status === 403 || status === 429) {
15
+ const limitedByHeaders = status === 429 || headers["x-ratelimit-remaining"] === "0" || headers["retry-after"] !== undefined;
16
+ const limitedByMessage = !limitedByHeaders && status === 403 && (yield* messageNamesRateLimit(response));
17
+ if (limitedByHeaders || limitedByMessage) {
18
+ const now = yield* DateTime.now;
19
+ const reset = Number(headers["x-ratelimit-reset"] ?? 0) * 1000;
20
+ const fallback =
21
+ headers["x-ratelimit-remaining"] === "0" && reset > DateTime.toEpochMillis(now)
22
+ ? reset
23
+ : DateTime.toEpochMillis(now) + Math.min(15 * 60_000, 60_000 * 2 ** failures);
24
+ const retryAt = parseRetryAfter(headers["retry-after"], now) ?? DateTime.makeUnsafe(fallback);
25
+ return yield* new GitHubRateLimited({ resource, retryAt });
26
+ }
27
+ }
28
+ return yield* filterStatus(HttpClient.makeWith(() => Effect.succeed(response), Effect.succeed)).execute(response.request);
29
+ });
30
+
31
+ const Message = Schema.fromJsonString(Schema.Struct({ message: Schema.String }));
32
+
33
+ const messageNamesRateLimit = Effect.fnUntraced(function* (response: HttpClientResponse.HttpClientResponse) {
34
+ const text = yield* readBoundedText(response, 64 * 1024);
35
+ const body = Schema.decodeUnknownOption(Message)(text);
36
+ return Option.isSome(body) && /rate limit/iu.test(body.value.message);
37
+ });