@rc312/effect-aws-imds 0.0.0-stage → 0.1.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 +170 -2
  3. package/dist/aws-imds.d.ts +58 -0
  4. package/dist/aws-imds.d.ts.map +1 -0
  5. package/dist/aws-imds.js +65 -0
  6. package/dist/aws-imds.js.map +1 -0
  7. package/dist/config.d.ts +64 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +37 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/identity-document.d.ts +33 -0
  12. package/dist/identity-document.d.ts.map +1 -0
  13. package/dist/identity-document.js +58 -0
  14. package/dist/identity-document.js.map +1 -0
  15. package/dist/index.d.ts +45 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +45 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/instance-identity.d.ts +43 -0
  20. package/dist/instance-identity.d.ts.map +1 -0
  21. package/dist/instance-identity.js +38 -0
  22. package/dist/instance-identity.js.map +1 -0
  23. package/dist/internal.d.ts +11 -0
  24. package/dist/internal.d.ts.map +1 -0
  25. package/dist/internal.js +15 -0
  26. package/dist/internal.js.map +1 -0
  27. package/dist/layer.d.ts +25 -0
  28. package/dist/layer.d.ts.map +1 -0
  29. package/dist/layer.js +26 -0
  30. package/dist/layer.js.map +1 -0
  31. package/dist/metadata-error.d.ts +22 -0
  32. package/dist/metadata-error.d.ts.map +1 -0
  33. package/dist/metadata-error.js +19 -0
  34. package/dist/metadata-error.js.map +1 -0
  35. package/dist/token-error.d.ts +19 -0
  36. package/dist/token-error.d.ts.map +1 -0
  37. package/dist/token-error.js +16 -0
  38. package/dist/token-error.js.map +1 -0
  39. package/dist/token.d.ts +27 -0
  40. package/dist/token.d.ts.map +1 -0
  41. package/dist/token.js +53 -0
  42. package/dist/token.js.map +1 -0
  43. package/dist/transport.d.ts +26 -0
  44. package/dist/transport.d.ts.map +1 -0
  45. package/dist/transport.js +46 -0
  46. package/dist/transport.js.map +1 -0
  47. package/package.json +42 -4
  48. package/src/aws-imds.ts +118 -0
  49. package/src/config.ts +96 -0
  50. package/src/identity-document.ts +86 -0
  51. package/src/index.ts +63 -0
  52. package/src/instance-identity.ts +63 -0
  53. package/src/internal.ts +21 -0
  54. package/src/layer.ts +36 -0
  55. package/src/metadata-error.ts +21 -0
  56. package/src/token-error.ts +18 -0
  57. package/src/token.ts +114 -0
  58. package/src/transport.ts +101 -0
package/src/config.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { Config, Context, Duration, Effect, Layer, Schedule } from "effect";
2
+
3
+ const RETRY_BASE_DELAY = Duration.millis(50);
4
+
5
+ /**
6
+ * The default retry policy: jittered exponential backoff from 50 ms, bounded
7
+ * to `times` retries after the first attempt.
8
+ *
9
+ * @since 0.1.0
10
+ */
11
+ const retryPolicy = (times: number) =>
12
+ Schedule.exponential(RETRY_BASE_DELAY).pipe(
13
+ Schedule.jittered,
14
+ Schedule.upTo({ times })
15
+ );
16
+
17
+ /**
18
+ * Resolved settings read by every IMDS layer.
19
+ *
20
+ * @since 0.1.0
21
+ */
22
+ interface AwsImdsSettings {
23
+ /** Base URL of the metadata service. */
24
+ readonly endpoint: string;
25
+ /**
26
+ * Requested session token lifetime. The value sent to IMDS is rounded down
27
+ * to whole seconds and clamped to 1 second through 6 hours.
28
+ */
29
+ readonly tokenTtl: Duration.Duration;
30
+ /** Timeout for each individual HTTP attempt. */
31
+ readonly attemptTimeout: Duration.Duration;
32
+ /**
33
+ * Policy for retrying timeouts, transport errors, and transient statuses.
34
+ * It is the only bound on retries, so it must stop on its own, for example
35
+ * with `Schedule.upTo`.
36
+ */
37
+ readonly retrySchedule: Schedule.Schedule<unknown>;
38
+ }
39
+
40
+ /**
41
+ * Partial settings for `AwsImdsConfig.layer` and `AwsImdsConfig.layerConfig`.
42
+ * Omitted fields keep their defaults.
43
+ *
44
+ * @since 0.1.0
45
+ */
46
+ interface AwsImdsOptions {
47
+ /** Defaults to `http://169.254.169.254`; use `http://[fd00:ec2::254]` on IPv6-only instances. */
48
+ readonly endpoint?: string | undefined;
49
+ /** Defaults to 6 hours. */
50
+ readonly tokenTtl?: Duration.Input | undefined;
51
+ /** Defaults to 1 second. */
52
+ readonly attemptTimeout?: Duration.Input | undefined;
53
+ /** Defaults to `retryPolicy(2)`. Must be bounded, for example with `Schedule.upTo`. */
54
+ readonly retrySchedule?: Schedule.Schedule<unknown> | undefined;
55
+ }
56
+
57
+ const defaults: AwsImdsSettings = {
58
+ attemptTimeout: Duration.seconds(1),
59
+ endpoint: "http://169.254.169.254",
60
+ retrySchedule: retryPolicy(2),
61
+ tokenTtl: Duration.hours(6),
62
+ };
63
+
64
+ const resolve = (options: AwsImdsOptions): AwsImdsSettings => ({
65
+ attemptTimeout: Duration.fromInputUnsafe(
66
+ options.attemptTimeout ?? defaults.attemptTimeout
67
+ ),
68
+ endpoint: options.endpoint ?? defaults.endpoint,
69
+ retrySchedule: options.retrySchedule ?? defaults.retrySchedule,
70
+ tokenTtl: Duration.fromInputUnsafe(options.tokenTtl ?? defaults.tokenTtl),
71
+ });
72
+
73
+ /**
74
+ * Settings for the IMDS layers, with defaults when nothing provides them.
75
+ *
76
+ * Provide an override to the layers with `Layer.provide`; merging it beside
77
+ * them has no effect on how they are built.
78
+ *
79
+ * @since 0.1.0
80
+ */
81
+ class AwsImdsConfig extends Context.Reference<AwsImdsSettings>(
82
+ "@rc312/effect-aws-imds/AwsImdsConfig",
83
+ { defaultValue: () => defaults }
84
+ ) {
85
+ /** Overrides the given settings and keeps defaults for the rest. */
86
+ static readonly layer = (options: AwsImdsOptions) =>
87
+ Layer.sync(AwsImdsConfig, () => resolve(options));
88
+
89
+ /** Reads settings from `Config`, field by field or as one `Config`. */
90
+ static readonly layerConfig = (config: Config.Wrap<AwsImdsOptions>) =>
91
+ Layer.effect(AwsImdsConfig, Effect.map(Config.unwrap(config), resolve));
92
+ }
93
+
94
+ export { AwsImdsConfig, retryPolicy };
95
+
96
+ export type { AwsImdsOptions, AwsImdsSettings };
@@ -0,0 +1,86 @@
1
+ import { Effect, Schema } from "effect";
2
+
3
+ const optionalNullable = <S extends Schema.Top>(schema: S) =>
4
+ Schema.NullOr(schema).pipe(
5
+ Schema.optional,
6
+ Schema.withDecodingDefault(Effect.succeed(null))
7
+ );
8
+
9
+ /**
10
+ * Schema for the EC2 instance identity document served at
11
+ * `/latest/dynamic/instance-identity/document`.
12
+ *
13
+ * Unknown keys are ignored so that fields AWS adds later do not break decoding.
14
+ *
15
+ * @since 0.1.0
16
+ */
17
+ // oxlint-disable-next-line no-redeclare -- The decoded type below intentionally shares the schema's name.
18
+ export const IdentityDocument = Schema.Struct({
19
+ accountId: Schema.String.check(Schema.isPattern(/^\d{12}$/u)).annotateKey({
20
+ description: "The ID of the AWS account that launched the instance.",
21
+ }),
22
+ architecture: Schema.Literals(["i386", "x86_64", "arm64"]).annotateKey({
23
+ description: "The architecture of the AMI used to launch the instance.",
24
+ }),
25
+ availabilityZone: Schema.NonEmptyString.annotateKey({
26
+ description: "The Availability Zone in which the instance is running.",
27
+ }),
28
+ billingProducts: optionalNullable(Schema.Array(Schema.String)).annotateKey({
29
+ description: "The billing products of the instance.",
30
+ }),
31
+ devpayProductCodes: optionalNullable(Schema.Array(Schema.String)).annotateKey(
32
+ { description: "Deprecated AWS DevPay product codes of the AMI." }
33
+ ),
34
+ imageId: Schema.String.check(
35
+ Schema.isPattern(/^ami-[0-9a-f]+$/iu)
36
+ ).annotateKey({
37
+ description: "The ID of the AMI used to launch the instance.",
38
+ }),
39
+ instanceId: Schema.String.check(
40
+ Schema.isPattern(/^i-[0-9a-f]+$/iu)
41
+ ).annotateKey({ description: "The ID of the instance." }),
42
+ instanceType: Schema.NonEmptyString.annotateKey({
43
+ description: "The instance type of the instance.",
44
+ }),
45
+ kernelId: optionalNullable(Schema.String).annotateKey({
46
+ description: "The ID of the kernel associated with the instance.",
47
+ }),
48
+ marketplaceProductCodes: optionalNullable(
49
+ Schema.Array(Schema.String)
50
+ ).annotateKey({
51
+ description: "The AWS Marketplace product codes of the AMI.",
52
+ }),
53
+ pendingTime: Schema.DateFromString.annotateKey({
54
+ description: "The date and time that the instance was launched.",
55
+ }),
56
+ privateIp: Schema.String.check(
57
+ Schema.isNonEmpty(),
58
+ Schema.isTrimmed()
59
+ ).annotateKey({
60
+ description:
61
+ "The private IPv4 address, or IPv6 address on IPv6-only instances.",
62
+ }),
63
+ ramdiskId: optionalNullable(Schema.String).annotateKey({
64
+ description: "The ID of the RAM disk associated with the instance.",
65
+ }),
66
+ region: Schema.String.check(
67
+ Schema.isPattern(/^[a-z]{2,}(?:-[a-z]+)+-\d+$/u)
68
+ ).annotateKey({
69
+ description: "The Region in which the instance is running.",
70
+ }),
71
+ version: Schema.String.check(
72
+ Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/u)
73
+ ).annotateKey({
74
+ description: "The version of the identity document format.",
75
+ }),
76
+ }).annotate({
77
+ identifier: "IdentityDocument",
78
+ title: "EC2 Instance Identity Document",
79
+ });
80
+
81
+ /**
82
+ * A decoded EC2 instance identity document.
83
+ *
84
+ * @since 0.1.0
85
+ */
86
+ export type IdentityDocument = typeof IdentityDocument.Type;
package/src/index.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Effect-native client for the AWS EC2 Instance Metadata Service (IMDS).
3
+ *
4
+ * The client speaks IMDSv2 only. Session tokens are fetched with `PUT
5
+ * /latest/api/token`, cached for slightly less than their TTL, and refreshed
6
+ * once when IMDS answers `401`. Every attempt has its own timeout, and transient
7
+ * failures are retried with jittered exponential backoff.
8
+ *
9
+ * The instance identity document is decoded against `IdentityDocument` and
10
+ * memoized after the first success. Failures are never cached, so a caller can
11
+ * try again later.
12
+ *
13
+ * Outside EC2 (local development, or a container beyond the IMDS hop limit)
14
+ * operations fail with a typed `AwsImdsError` rather than returning `undefined`.
15
+ * Apply `Effect.option` at the call site when the value is genuinely optional.
16
+ * With the defaults, an endpoint that never answers fails after about 3.2
17
+ * seconds (three 1 second attempts plus backoff); a refused connection fails
18
+ * within a fraction of a second.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * import { Effect } from "effect"
23
+ * import * as AwsImds from "@rc312/effect-aws-imds"
24
+ *
25
+ * const program = Effect.gen(function* () {
26
+ * const document = yield* AwsImds.identityDocument
27
+ * const instanceType = yield* AwsImds.get("/latest/meta-data/instance-type")
28
+ * return { region: document.region, instanceType }
29
+ * })
30
+ *
31
+ * const runnable = program.pipe(Effect.provide(AwsImds.layerFetch))
32
+ * ```
33
+ *
34
+ * @since 0.1.0
35
+ */
36
+ export {
37
+ AwsImds,
38
+ type AwsImdsError,
39
+ get,
40
+ getSchema,
41
+ optional,
42
+ } from "./aws-imds.ts";
43
+
44
+ export {
45
+ AwsImdsConfig,
46
+ type AwsImdsOptions,
47
+ type AwsImdsSettings,
48
+ retryPolicy,
49
+ } from "./config.ts";
50
+
51
+ export { IdentityDocument } from "./identity-document.ts";
52
+
53
+ export { identityDocument, InstanceIdentity } from "./instance-identity.ts";
54
+
55
+ export { clearCache, layer, layerFetch } from "./layer.ts";
56
+
57
+ export { AwsImdsToken } from "./token.ts";
58
+
59
+ export { imdsTransport, withSessionToken } from "./transport.ts";
60
+
61
+ export { AwsImdsMetadataError } from "./metadata-error.ts";
62
+
63
+ export { AwsImdsTokenError } from "./token-error.ts";
@@ -0,0 +1,63 @@
1
+ import { Cache, Context, Duration, Effect, Layer, Schema } from "effect";
2
+
3
+ import { AwsImds, decodeAt } from "./aws-imds.ts";
4
+ import type { AwsImdsError } from "./aws-imds.ts";
5
+ import { IdentityDocument } from "./identity-document.ts";
6
+ import { cacheOnSuccess } from "./internal.ts";
7
+
8
+ const IDENTITY_DOCUMENT_PATH = "/latest/dynamic/instance-identity/document";
9
+
10
+ const decodeDocument = decodeAt(
11
+ IDENTITY_DOCUMENT_PATH,
12
+ Schema.fromJsonString(IdentityDocument)
13
+ );
14
+
15
+ /**
16
+ * The decoded instance identity document, memoized after the first success.
17
+ * Failures are not cached. Provide a fixed document with `Layer.succeed` to
18
+ * test code that depends on it without any HTTP.
19
+ *
20
+ * @since 0.1.0
21
+ */
22
+ class InstanceIdentity extends Context.Service<
23
+ InstanceIdentity,
24
+ {
25
+ readonly document: Effect.Effect<IdentityDocument, AwsImdsError>;
26
+ readonly invalidate: Effect.Effect<void>;
27
+ }
28
+ >()("@rc312/effect-aws-imds/InstanceIdentity") {
29
+ /** Reads the document through `AwsImds`. */
30
+ static readonly layer: Layer.Layer<InstanceIdentity, never, AwsImds> =
31
+ Layer.effect(
32
+ InstanceIdentity,
33
+ Effect.gen(function* make() {
34
+ const imds = yield* AwsImds;
35
+
36
+ const resolve = imds
37
+ .get(IDENTITY_DOCUMENT_PATH)
38
+ .pipe(
39
+ Effect.flatMap(decodeDocument),
40
+ Effect.withSpan("InstanceIdentity.resolve")
41
+ );
42
+
43
+ const documents = yield* Cache.makeWith(() => resolve, {
44
+ capacity: 1,
45
+ timeToLive: cacheOnSuccess(Duration.infinity),
46
+ });
47
+
48
+ return InstanceIdentity.of({
49
+ document: Cache.get(documents, IDENTITY_DOCUMENT_PATH),
50
+ invalidate: Cache.invalidate(documents, IDENTITY_DOCUMENT_PATH),
51
+ });
52
+ })
53
+ );
54
+ }
55
+
56
+ /**
57
+ * The memoized, decoded instance identity document.
58
+ *
59
+ * @since 0.1.0
60
+ */
61
+ const identityDocument = InstanceIdentity.use((identity) => identity.document);
62
+
63
+ export { identityDocument, InstanceIdentity };
@@ -0,0 +1,21 @@
1
+ import { Duration, Exit } from "effect";
2
+ import { HttpClientError } from "effect/http";
3
+
4
+ // Keep only the message and status: client errors retain the request headers.
5
+ const failureDetails = (cause: Error) => {
6
+ const status = HttpClientError.isHttpClientError(cause)
7
+ ? cause.response?.status
8
+ : undefined;
9
+
10
+ return status === undefined
11
+ ? { message: cause.message }
12
+ : { message: cause.message, status };
13
+ };
14
+
15
+ // `Effect.cachedWithTTL` would also cache failures; IMDS failures are transient.
16
+ const cacheOnSuccess =
17
+ (ttl: Duration.Duration) =>
18
+ <A, E>(exit: Exit.Exit<A, E>) =>
19
+ Exit.isSuccess(exit) ? ttl : Duration.zero;
20
+
21
+ export { cacheOnSuccess, failureDetails };
package/src/layer.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { FetchHttpClient } from "effect/http";
3
+
4
+ import { AwsImds } from "./aws-imds.ts";
5
+ import { InstanceIdentity } from "./instance-identity.ts";
6
+ import { AwsImdsToken } from "./token.ts";
7
+
8
+ /**
9
+ * Provides `InstanceIdentity`, `AwsImds`, and `AwsImdsToken` from the
10
+ * `HttpClient` in the environment. Settings come from `AwsImdsConfig`.
11
+ *
12
+ * @since 0.1.0
13
+ */
14
+ const layer = InstanceIdentity.layer.pipe(
15
+ Layer.provideMerge(AwsImds.layer),
16
+ Layer.provideMerge(AwsImdsToken.layer)
17
+ );
18
+
19
+ /**
20
+ * `layer` backed by the platform `fetch` through `FetchHttpClient`.
21
+ *
22
+ * @since 0.1.0
23
+ */
24
+ const layerFetch = layer.pipe(Layer.provide(FetchHttpClient.layer));
25
+
26
+ /**
27
+ * Discards the cached session token and identity document.
28
+ *
29
+ * @since 0.1.0
30
+ */
31
+ const clearCache = Effect.andThen(
32
+ AwsImdsToken.use((token) => token.clear),
33
+ InstanceIdentity.use((identity) => identity.invalidate)
34
+ );
35
+
36
+ export { clearCache, layer, layerFetch };
@@ -0,0 +1,21 @@
1
+ import { Schema } from "effect";
2
+
3
+ /**
4
+ * Failure to read or decode the metadata at `path`.
5
+ *
6
+ * `status` is the HTTP status when IMDS answered with a non-2xx response, for
7
+ * example `404` for a path that does not exist on this instance. Transport,
8
+ * timeout, and decoding failures have no `status`. The underlying request is
9
+ * not retained because its headers carry the session token.
10
+ *
11
+ * @since 0.1.0
12
+ */
13
+ // oxlint-disable-next-line unicorn/throw-new-error -- TaggedError is a class factory.
14
+ export class AwsImdsMetadataError extends Schema.TaggedError<
15
+ AwsImdsMetadataError,
16
+ { readonly brand: unique symbol }
17
+ >()("AwsImdsMetadataError", {
18
+ message: Schema.String,
19
+ path: Schema.String,
20
+ status: Schema.optional(Schema.Int),
21
+ }) {}
@@ -0,0 +1,18 @@
1
+ import { Schema } from "effect";
2
+
3
+ /**
4
+ * Failure to obtain an IMDSv2 session token.
5
+ *
6
+ * `status` is the HTTP status when IMDS answered the token request with a
7
+ * non-2xx response. Transport, timeout, and decoding failures have no `status`.
8
+ *
9
+ * @since 0.1.0
10
+ */
11
+ // oxlint-disable-next-line unicorn/throw-new-error -- TaggedError is a class factory.
12
+ export class AwsImdsTokenError extends Schema.TaggedError<
13
+ AwsImdsTokenError,
14
+ { readonly brand: unique symbol }
15
+ >()("AwsImdsTokenError", {
16
+ message: Schema.String,
17
+ status: Schema.optional(Schema.Int),
18
+ }) {}
package/src/token.ts ADDED
@@ -0,0 +1,114 @@
1
+ import {
2
+ Cache,
3
+ Context,
4
+ Duration,
5
+ Effect,
6
+ Layer,
7
+ Option,
8
+ Redacted,
9
+ Schema,
10
+ } from "effect";
11
+ import { HttpClient } from "effect/http";
12
+
13
+ import { AwsImdsConfig } from "./config.ts";
14
+ import { cacheOnSuccess, failureDetails } from "./internal.ts";
15
+ import { AwsImdsTokenError } from "./token-error.ts";
16
+ import { imdsTransport, TOKEN_HEADER } from "./transport.ts";
17
+
18
+ const TOKEN_PATH = "/latest/api/token";
19
+ const TOKEN_TTL_HEADER = "x-aws-ec2-metadata-token-ttl-seconds";
20
+ const MIN_TTL_SECONDS = 1;
21
+ const MAX_TTL_SECONDS = 21_600;
22
+ const REFRESH_MARGIN = Duration.seconds(30);
23
+
24
+ const decodeToken = Schema.decodeEffect(
25
+ Schema.NonEmptyString.annotate({ description: "IMDSv2 session token" })
26
+ );
27
+
28
+ const ttlSeconds = (ttl: Duration.Duration) =>
29
+ Math.min(
30
+ MAX_TTL_SECONDS,
31
+ Math.max(MIN_TTL_SECONDS, Math.floor(Duration.toSeconds(ttl)))
32
+ );
33
+
34
+ // Refresh 30 seconds early, or halfway through a short-lived token.
35
+ const refreshAfter = (ttl: Duration.Duration) =>
36
+ Duration.max(
37
+ Duration.subtract(ttl, REFRESH_MARGIN),
38
+ Duration.divideUnsafe(ttl, 2)
39
+ );
40
+
41
+ const toTokenError = (cause: Error) =>
42
+ new AwsImdsTokenError(failureDetails(cause));
43
+
44
+ /**
45
+ * IMDSv2 session tokens.
46
+ *
47
+ * - `get` returns the cached token, requesting one with `PUT /latest/api/token`
48
+ * when none is cached or the cached one is close to expiry.
49
+ * - `invalidate(token)` discards `token` if it is still the cached one, so
50
+ * concurrent reads that were all rejected trigger a single refresh.
51
+ * - `clear` discards the cached token unconditionally.
52
+ *
53
+ * Provide your own implementation with `Layer.succeed` to use a fixed token.
54
+ *
55
+ * @since 0.1.0
56
+ */
57
+ class AwsImdsToken extends Context.Service<
58
+ AwsImdsToken,
59
+ {
60
+ readonly get: Effect.Effect<Redacted.Redacted<string>, AwsImdsTokenError>;
61
+ readonly invalidate: (
62
+ rejected: Redacted.Redacted<string>
63
+ ) => Effect.Effect<void>;
64
+ readonly clear: Effect.Effect<void>;
65
+ }
66
+ >()("@rc312/effect-aws-imds/AwsImdsToken") {
67
+ /** Requests tokens through the `HttpClient`, configured by `AwsImdsConfig`. */
68
+ static readonly layer: Layer.Layer<
69
+ AwsImdsToken,
70
+ never,
71
+ HttpClient.HttpClient
72
+ > = Layer.effect(
73
+ AwsImdsToken,
74
+ Effect.gen(function* make() {
75
+ const settings = yield* AwsImdsConfig;
76
+ const seconds = ttlSeconds(settings.tokenTtl);
77
+
78
+ const client = (yield* HttpClient.HttpClient).pipe(
79
+ imdsTransport(settings)
80
+ );
81
+
82
+ const fetchToken = client
83
+ .put(TOKEN_PATH, { headers: { [TOKEN_TTL_HEADER]: String(seconds) } })
84
+ .pipe(
85
+ Effect.flatMap((response) => response.text),
86
+ Effect.flatMap((text) => decodeToken(text)),
87
+ Effect.map((token) => Redacted.make(token, { label: TOKEN_HEADER })),
88
+ Effect.mapError(toTokenError),
89
+ Effect.withSpan("AwsImdsToken.fetch")
90
+ );
91
+
92
+ const tokens = yield* Cache.makeWith(() => fetchToken, {
93
+ capacity: 1,
94
+ timeToLive: cacheOnSuccess(refreshAfter(Duration.seconds(seconds))),
95
+ });
96
+
97
+ const current = Cache.get(tokens, TOKEN_PATH);
98
+ const clear = Cache.invalidate(tokens, TOKEN_PATH);
99
+
100
+ return AwsImdsToken.of({
101
+ clear,
102
+ get: current,
103
+ invalidate: (rejected) =>
104
+ Cache.getSuccess(tokens, TOKEN_PATH).pipe(
105
+ Effect.flatMap((cached) =>
106
+ Option.contains(cached, rejected) ? clear : Effect.void
107
+ )
108
+ ),
109
+ });
110
+ })
111
+ );
112
+ }
113
+
114
+ export { AwsImdsToken };
@@ -0,0 +1,101 @@
1
+ import { Effect, Redacted } from "effect";
2
+ import {
3
+ Headers,
4
+ HttpClient,
5
+ HttpClientError,
6
+ HttpClientRequest,
7
+ } from "effect/http";
8
+
9
+ import type { AwsImdsSettings } from "./config.ts";
10
+ import type { AwsImdsToken } from "./token.ts";
11
+
12
+ const TOKEN_HEADER = "x-aws-ec2-metadata-token";
13
+ const UNAUTHORIZED = 401;
14
+
15
+ // Redacts the session token from client spans that capture request headers.
16
+ const redactTokenHeader = Effect.updateService(
17
+ Headers.CurrentRedactedNames,
18
+ (names) => [...names, TOKEN_HEADER]
19
+ );
20
+
21
+ /**
22
+ * IMDS transport policy for any `HttpClient`: the endpoint as base URL, a
23
+ * timeout on each attempt including its text body, non-2xx statuses as errors,
24
+ * and transient retries on `settings.retrySchedule`.
25
+ *
26
+ * @since 0.1.0
27
+ */
28
+ const imdsTransport =
29
+ (settings: AwsImdsSettings) =>
30
+ <E, R>(client: HttpClient.HttpClient.With<E, R>) =>
31
+ client.pipe(
32
+ HttpClient.mapRequest(HttpClientRequest.prependUrl(settings.endpoint)),
33
+ HttpClient.filterStatusOk,
34
+ // HTTP responses memoize their text, so callers can read it again after
35
+ // the complete download has succeeded within the attempt's deadline.
36
+ HttpClient.tap((response) => response.text),
37
+ HttpClient.transformResponse(Effect.timeout(settings.attemptTimeout)),
38
+ HttpClient.retryTransient({
39
+ schedule: settings.retrySchedule,
40
+ // Reading the text body can fail after headers have already arrived.
41
+ // Schema/JSON decoding happens outside this transport policy.
42
+ while: (error) =>
43
+ HttpClientError.isHttpClientError(error) &&
44
+ error.reason instanceof HttpClientError.DecodeError,
45
+ })
46
+ );
47
+
48
+ const isUnauthorized = <E>(error: E) =>
49
+ HttpClientError.isHttpClientError(error) &&
50
+ error.response?.status === UNAUTHORIZED;
51
+
52
+ /**
53
+ * Wraps a client so every request carries a session token. The token is
54
+ * resolved before the request, outside its timeout and transient retries; a
55
+ * `401` invalidates that token and the request runs once more with a new one.
56
+ *
57
+ * Apply it after `imdsTransport`: the transport's timeout and retries then
58
+ * cover each metadata request, while time spent obtaining the token does not
59
+ * count against them.
60
+ *
61
+ * @since 0.1.0
62
+ */
63
+ const withSessionToken =
64
+ (token: AwsImdsToken["Service"]) =>
65
+ <E, R>(client: HttpClient.HttpClient.With<E, R>) => {
66
+ const attempt = (request: HttpClientRequest.HttpClientRequest) =>
67
+ token.get.pipe(
68
+ Effect.flatMap((sessionToken) =>
69
+ client
70
+ .execute(
71
+ HttpClientRequest.setHeader(
72
+ request,
73
+ TOKEN_HEADER,
74
+ Redacted.value(sessionToken)
75
+ )
76
+ )
77
+ .pipe(
78
+ redactTokenHeader,
79
+ Effect.tapError((failure) =>
80
+ isUnauthorized(failure)
81
+ ? token.invalidate(sessionToken)
82
+ : Effect.void
83
+ )
84
+ )
85
+ )
86
+ );
87
+
88
+ return HttpClient.makeWith(
89
+ (request: Effect.Effect<HttpClientRequest.HttpClientRequest>) =>
90
+ request.pipe(
91
+ Effect.flatMap((prepared) =>
92
+ attempt(prepared).pipe(
93
+ Effect.retry({ times: 1, while: isUnauthorized })
94
+ )
95
+ )
96
+ ),
97
+ Effect.succeed
98
+ );
99
+ };
100
+
101
+ export { imdsTransport, TOKEN_HEADER, withSessionToken };