@smthrs/artifacts 0.0.0-stage → 1.0.0-rc.3

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 (171) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/LICENSE +21 -0
  3. package/README.md +173 -2
  4. package/dist/cjs/ArtifactBackupLease.d.ts +34 -0
  5. package/dist/cjs/ArtifactBackupLease.d.ts.map +1 -0
  6. package/dist/cjs/ArtifactBackupLease.js +175 -0
  7. package/dist/cjs/ArtifactBackupLease.js.map +7 -0
  8. package/dist/cjs/ArtifactStore.d.ts +129 -0
  9. package/dist/cjs/ArtifactStore.d.ts.map +1 -0
  10. package/dist/cjs/ArtifactStore.js +73 -0
  11. package/dist/cjs/ArtifactStore.js.map +7 -0
  12. package/dist/cjs/ArtifactStoreError.d.ts +82 -0
  13. package/dist/cjs/ArtifactStoreError.d.ts.map +1 -0
  14. package/dist/cjs/ArtifactStoreError.js +72 -0
  15. package/dist/cjs/ArtifactStoreError.js.map +7 -0
  16. package/dist/cjs/ArtifactStoreMetrics.d.ts +70 -0
  17. package/dist/cjs/ArtifactStoreMetrics.d.ts.map +1 -0
  18. package/dist/cjs/ArtifactStoreMetrics.js +51 -0
  19. package/dist/cjs/ArtifactStoreMetrics.js.map +7 -0
  20. package/dist/cjs/ArtifactSweep.d.ts +185 -0
  21. package/dist/cjs/ArtifactSweep.d.ts.map +1 -0
  22. package/dist/cjs/ArtifactSweep.js +163 -0
  23. package/dist/cjs/ArtifactSweep.js.map +7 -0
  24. package/dist/cjs/CombinedArtifacts.d.ts +103 -0
  25. package/dist/cjs/CombinedArtifacts.d.ts.map +1 -0
  26. package/dist/cjs/CombinedArtifacts.js +189 -0
  27. package/dist/cjs/CombinedArtifacts.js.map +7 -0
  28. package/dist/cjs/FileLease.d.ts +113 -0
  29. package/dist/cjs/FileLease.d.ts.map +1 -0
  30. package/dist/cjs/FileLease.js +201 -0
  31. package/dist/cjs/FileLease.js.map +7 -0
  32. package/dist/cjs/FileSystemArtifactStore.d.ts +93 -0
  33. package/dist/cjs/FileSystemArtifactStore.d.ts.map +1 -0
  34. package/dist/cjs/FileSystemArtifactStore.js +276 -0
  35. package/dist/cjs/FileSystemArtifactStore.js.map +7 -0
  36. package/dist/cjs/MemoryArtifactStore.d.ts +25 -0
  37. package/dist/cjs/MemoryArtifactStore.d.ts.map +1 -0
  38. package/dist/cjs/MemoryArtifactStore.js +84 -0
  39. package/dist/cjs/MemoryArtifactStore.js.map +7 -0
  40. package/dist/cjs/NoopArtifactStore.d.ts +17 -0
  41. package/dist/cjs/NoopArtifactStore.d.ts.map +1 -0
  42. package/dist/cjs/NoopArtifactStore.js +46 -0
  43. package/dist/cjs/NoopArtifactStore.js.map +7 -0
  44. package/dist/cjs/RemoteArtifacts.d.ts +237 -0
  45. package/dist/cjs/RemoteArtifacts.d.ts.map +1 -0
  46. package/dist/cjs/RemoteArtifacts.js +405 -0
  47. package/dist/cjs/RemoteArtifacts.js.map +7 -0
  48. package/dist/cjs/index.d.ts +65 -0
  49. package/dist/cjs/index.d.ts.map +1 -0
  50. package/dist/cjs/index.js +47 -0
  51. package/dist/cjs/index.js.map +7 -0
  52. package/dist/cjs/internal/ArtifactLocks.d.ts +30 -0
  53. package/dist/cjs/internal/ArtifactLocks.d.ts.map +1 -0
  54. package/dist/cjs/internal/ArtifactLocks.js +93 -0
  55. package/dist/cjs/internal/ArtifactLocks.js.map +7 -0
  56. package/dist/cjs/internal/ArtifactPath.d.ts +19 -0
  57. package/dist/cjs/internal/ArtifactPath.d.ts.map +1 -0
  58. package/dist/cjs/internal/ArtifactPath.js +74 -0
  59. package/dist/cjs/internal/ArtifactPath.js.map +7 -0
  60. package/dist/cjs/measureBytes.d.ts +18 -0
  61. package/dist/cjs/measureBytes.d.ts.map +1 -0
  62. package/dist/cjs/measureBytes.js +43 -0
  63. package/dist/cjs/measureBytes.js.map +7 -0
  64. package/dist/cjs/package.json +1 -0
  65. package/dist/cjs/snapshotBytes.d.ts +19 -0
  66. package/dist/cjs/snapshotBytes.d.ts.map +1 -0
  67. package/dist/cjs/snapshotBytes.js +41 -0
  68. package/dist/cjs/snapshotBytes.js.map +7 -0
  69. package/dist/cjs/validateDigest.d.ts +22 -0
  70. package/dist/cjs/validateDigest.d.ts.map +1 -0
  71. package/dist/cjs/validateDigest.js +42 -0
  72. package/dist/cjs/validateDigest.js.map +7 -0
  73. package/dist/esm/ArtifactBackupLease.d.ts +34 -0
  74. package/dist/esm/ArtifactBackupLease.d.ts.map +1 -0
  75. package/dist/esm/ArtifactBackupLease.js +117 -0
  76. package/dist/esm/ArtifactBackupLease.js.map +1 -0
  77. package/dist/esm/ArtifactStore.d.ts +129 -0
  78. package/dist/esm/ArtifactStore.d.ts.map +1 -0
  79. package/dist/esm/ArtifactStore.js +83 -0
  80. package/dist/esm/ArtifactStore.js.map +1 -0
  81. package/dist/esm/ArtifactStoreError.d.ts +82 -0
  82. package/dist/esm/ArtifactStoreError.d.ts.map +1 -0
  83. package/dist/esm/ArtifactStoreError.js +77 -0
  84. package/dist/esm/ArtifactStoreError.js.map +1 -0
  85. package/dist/esm/ArtifactStoreMetrics.d.ts +70 -0
  86. package/dist/esm/ArtifactStoreMetrics.d.ts.map +1 -0
  87. package/dist/esm/ArtifactStoreMetrics.js +77 -0
  88. package/dist/esm/ArtifactStoreMetrics.js.map +1 -0
  89. package/dist/esm/ArtifactSweep.d.ts +185 -0
  90. package/dist/esm/ArtifactSweep.d.ts.map +1 -0
  91. package/dist/esm/ArtifactSweep.js +202 -0
  92. package/dist/esm/ArtifactSweep.js.map +1 -0
  93. package/dist/esm/CombinedArtifacts.d.ts +103 -0
  94. package/dist/esm/CombinedArtifacts.d.ts.map +1 -0
  95. package/dist/esm/CombinedArtifacts.js +214 -0
  96. package/dist/esm/CombinedArtifacts.js.map +1 -0
  97. package/dist/esm/FileLease.d.ts +113 -0
  98. package/dist/esm/FileLease.d.ts.map +1 -0
  99. package/dist/esm/FileLease.js +239 -0
  100. package/dist/esm/FileLease.js.map +1 -0
  101. package/dist/esm/FileSystemArtifactStore.d.ts +93 -0
  102. package/dist/esm/FileSystemArtifactStore.d.ts.map +1 -0
  103. package/dist/esm/FileSystemArtifactStore.js +336 -0
  104. package/dist/esm/FileSystemArtifactStore.js.map +1 -0
  105. package/dist/esm/MemoryArtifactStore.d.ts +25 -0
  106. package/dist/esm/MemoryArtifactStore.d.ts.map +1 -0
  107. package/dist/esm/MemoryArtifactStore.js +67 -0
  108. package/dist/esm/MemoryArtifactStore.js.map +1 -0
  109. package/dist/esm/NoopArtifactStore.d.ts +17 -0
  110. package/dist/esm/NoopArtifactStore.d.ts.map +1 -0
  111. package/dist/esm/NoopArtifactStore.js +27 -0
  112. package/dist/esm/NoopArtifactStore.js.map +1 -0
  113. package/dist/esm/RemoteArtifacts.d.ts +237 -0
  114. package/dist/esm/RemoteArtifacts.d.ts.map +1 -0
  115. package/dist/esm/RemoteArtifacts.js +554 -0
  116. package/dist/esm/RemoteArtifacts.js.map +1 -0
  117. package/dist/esm/index.d.ts +65 -0
  118. package/dist/esm/index.d.ts.map +1 -0
  119. package/dist/esm/index.js +65 -0
  120. package/dist/esm/index.js.map +1 -0
  121. package/dist/esm/internal/ArtifactLocks.d.ts +30 -0
  122. package/dist/esm/internal/ArtifactLocks.d.ts.map +1 -0
  123. package/dist/esm/internal/ArtifactLocks.js +86 -0
  124. package/dist/esm/internal/ArtifactLocks.js.map +1 -0
  125. package/dist/esm/internal/ArtifactPath.d.ts +19 -0
  126. package/dist/esm/internal/ArtifactPath.d.ts.map +1 -0
  127. package/dist/esm/internal/ArtifactPath.js +59 -0
  128. package/dist/esm/internal/ArtifactPath.js.map +1 -0
  129. package/dist/esm/measureBytes.d.ts +18 -0
  130. package/dist/esm/measureBytes.d.ts.map +1 -0
  131. package/dist/esm/measureBytes.js +22 -0
  132. package/dist/esm/measureBytes.js.map +1 -0
  133. package/dist/esm/snapshotBytes.d.ts +19 -0
  134. package/dist/esm/snapshotBytes.d.ts.map +1 -0
  135. package/dist/esm/snapshotBytes.js +30 -0
  136. package/dist/esm/snapshotBytes.js.map +1 -0
  137. package/dist/esm/validateDigest.d.ts +22 -0
  138. package/dist/esm/validateDigest.d.ts.map +1 -0
  139. package/dist/esm/validateDigest.js +26 -0
  140. package/dist/esm/validateDigest.js.map +1 -0
  141. package/docs/README.md +200 -0
  142. package/docs/api.md +474 -0
  143. package/docs/concepts/content-addressing.md +174 -0
  144. package/docs/concepts/coordination.md +141 -0
  145. package/docs/concepts/tiers.md +147 -0
  146. package/docs/guides/fence-a-backup.md +123 -0
  147. package/docs/guides/reclaim-disk-space.md +143 -0
  148. package/docs/guides/serve-the-artifact-protocol.md +140 -0
  149. package/docs/guides/share-artifacts-across-machines.md +180 -0
  150. package/docs/guides/test-against-an-artifact-store.md +158 -0
  151. package/docs/installation.md +98 -0
  152. package/docs/quickstart.md +182 -0
  153. package/docs/troubleshooting.md +232 -0
  154. package/package.json +179 -3
  155. package/src/ArtifactBackupLease.ts +213 -0
  156. package/src/ArtifactStore.ts +152 -0
  157. package/src/ArtifactStoreError.ts +96 -0
  158. package/src/ArtifactStoreMetrics.ts +83 -0
  159. package/src/ArtifactSweep.ts +336 -0
  160. package/src/CombinedArtifacts.ts +369 -0
  161. package/src/FileLease.ts +380 -0
  162. package/src/FileSystemArtifactStore.ts +459 -0
  163. package/src/MemoryArtifactStore.ts +83 -0
  164. package/src/NoopArtifactStore.ts +30 -0
  165. package/src/RemoteArtifacts.ts +809 -0
  166. package/src/index.ts +71 -0
  167. package/src/internal/ArtifactLocks.ts +110 -0
  168. package/src/internal/ArtifactPath.ts +72 -0
  169. package/src/measureBytes.ts +28 -0
  170. package/src/snapshotBytes.ts +34 -0
  171. package/src/validateDigest.ts +31 -0
@@ -0,0 +1,809 @@
1
+ /**
2
+ * The shared artifact tier: an {@link ArtifactStore.Service} spoken over HTTP.
3
+ *
4
+ * The wire protocol mirrors Bazel's dumb-HTTP remote cache, class
5
+ * `HttpCacheClient` in `com.google.devtools.build.lib.remote.http`, which
6
+ * documents it as: "CAS (Content Addressable Storage) blobs are stored under
7
+ * the path `/cas/base16-key`", uploaded with `PUT`, downloaded with `GET`. We add
8
+ * `HEAD /cas/{digest}` for a single existence probe and
9
+ * `POST /cas/findMissing` for the batched one, because Bazel's HTTP client has
10
+ * no `findMissingDigests` at all — it answers "everything is missing" and
11
+ * re-uploads — and a batched probe is the whole reason
12
+ * `MissingDigestsFinder` exists in the gRPC client.
13
+ *
14
+ * Transport is Effect's own `HttpClient` tag. There is no lower transport in
15
+ * this repository, and there does not need to be one: the kernel already
16
+ * decorates that tag with `net:get`/`net:post` capability checks, so a remote
17
+ * artifact fetch is permission-checked like every other egress.
18
+ *
19
+ * ## Chunked uploads
20
+ *
21
+ * With `chunkBytes` set, a blob past that size travels as a sequence of
22
+ * `PUT /cas/{digest}` requests carrying `Content-Range: bytes {a}-{b}/{total}`,
23
+ * preceded by a `HEAD /cas/{digest}` existence probe and a
24
+ * `Content-Range: bytes *\/{total}` probe with an empty body. This is the
25
+ * resumable-upload shape HTTP already has: the range probe asks what prefix
26
+ * the tier holds and `308` means "keep going", with a `Range: bytes=0-{last}`
27
+ * header on the probe or on a chunk answer moving the offset.
28
+ *
29
+ * Only a `308` continues the sequence, and the completing chunk's `2xx` is
30
+ * confirmed with `HEAD` before `put` reports the digest as published. A `2xx`
31
+ * anywhere else, to the empty probe or to a chunk the tier is still owed bytes
32
+ * after, reads as a tier that ignored `Content-Range` and stored the body it
33
+ * was handed, which is what plain WebDAV `PUT` does. That tier, the one that
34
+ * answers `400`, `411`, or `416`, and the one whose `HEAD` will not confirm
35
+ * the stored length all get the same treatment: one whole-blob `PUT`, which
36
+ * overwrites the partial body the sequence left behind. So the blob always
37
+ * lands whole, and the cost of turning the dial on against a server that
38
+ * never learned about it is round trips, never a digest published over bytes
39
+ * the server does not hold.
40
+ *
41
+ * This repository's own hosted and self-hosted cache services are such
42
+ * servers: neither implements the resumable sequence, and both refuse a
43
+ * ranged `PUT` with `400`, RFC 9110 section 14.5's answer from a resource
44
+ * that does not support partial `PUT`. Against them `chunkBytes` costs the
45
+ * probe round trip and the blob travels whole, and both cap one request body
46
+ * at 16 MiB, so a blob past that cap is refused with `413` in either mode.
47
+ *
48
+ * The endpoint and its credentials arrive as **layer construction options**.
49
+ * They are capabilities, never step inputs: they are not hashed into a step
50
+ * key, journaled, or returned in a recorded result. Invalid endpoint errors are
51
+ * sanitized before they cross the boundary.
52
+ *
53
+ * @since 1.0.0-rc.0
54
+ */
55
+
56
+ import * as Duration from "effect/Duration"
57
+ import * as Effect from "effect/Effect"
58
+ import * as Layer from "effect/Layer"
59
+ import * as Option from "effect/Option"
60
+ import * as Schema from "effect/Schema"
61
+ import * as Stream from "effect/Stream"
62
+ import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"
63
+ import * as Headers from "effect/unstable/http/Headers"
64
+ import * as HttpClient from "effect/unstable/http/HttpClient"
65
+ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
66
+ import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
67
+ import * as ArtifactStore from "./ArtifactStore.ts"
68
+
69
+ /**
70
+ * How eagerly a composition materializes shared blobs into its local store.
71
+ *
72
+ * @category models
73
+ * @since 1.0.0-rc.0
74
+ */
75
+ export const DownloadPolicy = Schema.Literals(["all", "toplevel", "minimal"] as const)
76
+
77
+ /**
78
+ * How eagerly a composition materializes remote blobs.
79
+ *
80
+ * @category models
81
+ * @since 1.0.0-rc.0
82
+ */
83
+ export type DownloadPolicy = typeof DownloadPolicy.Type
84
+
85
+ /**
86
+ * Every download policy, in materialization order.
87
+ *
88
+ * @category models
89
+ * @since 1.0.0-rc.0
90
+ */
91
+ export const downloadPolicies: ReadonlyArray<DownloadPolicy> = Object.freeze(["all", "toplevel", "minimal"])
92
+
93
+ /**
94
+ * A remote artifact tier, which is an ordinary store that also states how
95
+ * eagerly a composition reading through it should materialize blobs locally.
96
+ *
97
+ * @category models
98
+ * @since 1.0.0-rc.0
99
+ */
100
+ export interface Service extends ArtifactStore.Service {
101
+ readonly downloadPolicy: DownloadPolicy
102
+ }
103
+
104
+ /**
105
+ * Reads the download policy a store declares, or `undefined` for a store that
106
+ * declares none — every local store, and any foreign implementation.
107
+ *
108
+ * @category getters
109
+ * @since 1.0.0-rc.0
110
+ */
111
+ export const downloadPolicyOf = (store: ArtifactStore.Service): DownloadPolicy | undefined => {
112
+ const declared = (store as { readonly downloadPolicy?: unknown }).downloadPolicy
113
+ return Schema.is(DownloadPolicy)(declared) ? declared : undefined
114
+ }
115
+
116
+ /**
117
+ * How to reach the shared artifact tier.
118
+ *
119
+ * @category models
120
+ * @since 1.0.0-rc.0
121
+ * @slop
122
+ */
123
+ export interface Options {
124
+ /**
125
+ * The cache root, e.g. `https://cache.example.com`. `/cas/{digest}` and
126
+ * `/cas/findMissing` are resolved beneath it. A trailing slash is ignored.
127
+ *
128
+ * Four shapes are refused at construction as `invalid_configuration`, before
129
+ * any request leaves and therefore before `headers` can reach a wire: a value
130
+ * that is not a string, one no `URL` parser accepts, a scheme other than
131
+ * `https:` (plain `http:` is accepted only for the literal loopback hosts
132
+ * `localhost`, `127.0.0.1`, and `[::1]`), and an endpoint carrying
133
+ * userinfo, a query, or a fragment. The
134
+ * last rule keeps a credential out of a place nothing here would redact: an
135
+ * endpoint is interpolated into every request path and into span attributes.
136
+ *
137
+ * The refusal message names only the violated rule and never echoes the
138
+ * endpoint, so a rejected `https://user:secret@host` cannot leak its
139
+ * credential into a log line or a durable error.
140
+ */
141
+ readonly endpoint: string
142
+ /**
143
+ * Headers sent with every request — an `Authorization` bearer token, a
144
+ * tenant header, whatever the deployment's gateway wants. This is the
145
+ * credential seam, and it is deliberately construction-time: a credential
146
+ * that arrived as a step input would be hashed into a step key and persisted
147
+ * everywhere the journal goes.
148
+ *
149
+ * The protocol wins every collision. These headers are applied to a request
150
+ * first. Configured `content-type`, `content-length`, and `content-range`
151
+ * are removed, then computed only where the protocol needs them. Whole-blob
152
+ * uploads, including fallbacks, never carry `content-range`.
153
+ * All configured header names are redacted in HTTP spans, in addition to
154
+ * the host's existing redaction rules.
155
+ *
156
+ * A name that is not an HTTP token, or a value longer than 16 KiB or
157
+ * carrying a control character (CR and LF included), is refused at
158
+ * construction as `invalid_configuration`.
159
+ */
160
+ readonly headers?: Record<string, string> | undefined
161
+ /**
162
+ * The deadline on a single download, from the request leaving to the last
163
+ * body byte arriving. A shared tier that stops answering must fail the read
164
+ * rather than park it forever. `CombinedArtifacts.get` propagates a remote
165
+ * `ArtifactStoreError`, including a download timeout. Only opportunistic
166
+ * uploads from `CombinedArtifacts.put` ignore remote failures;
167
+ * `ArtifactSync.hydrate` separately turns a failure into replay rejection.
168
+ * Defaults to 60 seconds, Bazel's `--remote_timeout`
169
+ * default for the same REST protocol (its `RemoteOptions`: "For the REST
170
+ * cache, this is both the connect and the read timeout").
171
+ */
172
+ readonly downloadTimeout?: Duration.Input | undefined
173
+ /**
174
+ * Deadline for an upload, including every resume probe and chunk. Defaults
175
+ * to 60 seconds.
176
+ */
177
+ readonly uploadTimeout?: Duration.Input | undefined
178
+ /**
179
+ * Deadline for an existence probe or one `findMissing` batch, including its
180
+ * response body. Defaults to 60 seconds.
181
+ */
182
+ readonly requestTimeout?: Duration.Input | undefined
183
+ /**
184
+ * The largest download this client will buffer, in bytes. The body is read
185
+ * incrementally and abandoned the moment it exceeds the bound — and refused
186
+ * outright when `Content-Length` already declares the excess — so a
187
+ * mis-serving or hostile cache cannot make this process buffer and hash an
188
+ * arbitrarily large body before the digest check refuses it. Defaults to
189
+ * 256 MiB.
190
+ */
191
+ readonly maxDownloadBytes?: number | undefined
192
+ /**
193
+ * Largest `findMissing` response body accepted. Defaults to the protocol's
194
+ * 256 KiB request/response bound and may only lower that bound.
195
+ */
196
+ readonly maxFindMissingResponseBytes?: number | undefined
197
+ /**
198
+ * Above this many bytes an upload travels as a sequence of `Content-Range`
199
+ * `PUT`s instead of one whole-blob body, and a transfer that died partway
200
+ * resumes from the prefix the tier kept rather than starting over. See
201
+ * {@link module:RemoteArtifacts | the chunked upload protocol} below.
202
+ *
203
+ * Absent by default: every upload is one whole-blob `PUT`, which is what
204
+ * Bazel's dumb-HTTP client does and what every deployment already serving
205
+ * this protocol expects. Set it when a proxy caps request bodies, or when
206
+ * artifacts are large enough that losing a transfer is expensive.
207
+ *
208
+ * The repository's own hosted and self-hosted cache services answer the
209
+ * ranged probe with `400`, so against them this dial degrades to one
210
+ * whole-blob `PUT`, and their 16 MiB body cap refuses a larger blob with
211
+ * `413` in either mode.
212
+ */
213
+ readonly chunkBytes?: number | undefined
214
+ /**
215
+ * How eagerly a composition reading through this tier materializes blobs
216
+ * into its local store. This is Bazel's `--remote_download_{all,toplevel,
217
+ * minimal}` dial (`RemoteOutputChecker`), and it lives here because the
218
+ * shared tier is the thing being conserved.
219
+ *
220
+ * - `all` (the default) prefetches every referenced blob when a replay is
221
+ * admitted, so the replay reads local bytes.
222
+ * - `toplevel` prefetches nothing and materializes a blob into the local
223
+ * store on the first read that actually needs it.
224
+ * - `minimal` prefetches nothing and materializes nothing: a read is served
225
+ * straight from the shared tier and the local store never grows.
226
+ *
227
+ * `CombinedArtifacts.get` honors the last two; `@smthrs/engine-store`'s
228
+ * `ArtifactSync.hydrate` honors the first.
229
+ */
230
+ readonly downloadPolicy?: DownloadPolicy | undefined
231
+ }
232
+
233
+ /**
234
+ * The default download deadline. 60 seconds is Bazel's `--remote_timeout`
235
+ * default, governing the same dumb-HTTP cache protocol.
236
+ */
237
+ const defaultDownloadTimeout = Duration.seconds(60)
238
+ const defaultUploadTimeout = Duration.seconds(60)
239
+ const defaultRequestTimeout = Duration.seconds(60)
240
+
241
+ /**
242
+ * The default bound on a downloaded blob: 256 MiB. Artifacts are spilled step
243
+ * values, not media libraries; a body past this size is far more likely a
244
+ * mis-serving cache than a legitimate blob, and the dial is per-store for a
245
+ * deployment that knows better.
246
+ */
247
+ const defaultMaxDownloadBytes = 256 * 1024 * 1024
248
+ const maxFindMissingBatchDigests = 1_000
249
+ const maxFindMissingBodyBytes = 256 * 1024
250
+ const encoder = new TextEncoder()
251
+ const decoder = new TextDecoder("utf-8", { fatal: true })
252
+
253
+ /**
254
+ * The credential-free part of a transport failure: the HTTP client's reason
255
+ * tag and the first operating-system error code (`ECONNREFUSED`, `ENOTFOUND`,
256
+ * `CERT_HAS_EXPIRED`) in its cause chain. Never the request, the URL, the
257
+ * headers, or free-form text, any of which may carry a credential.
258
+ */
259
+ const transportReason = (cause: unknown): string | undefined => {
260
+ const reason = (cause as { readonly reason?: { readonly _tag?: unknown } } | null)?.reason
261
+ const tag = typeof reason?._tag === "string" ? reason._tag : undefined
262
+ let code: string | undefined
263
+ let current: unknown = reason ?? cause
264
+ for (let depth = 0; depth < 8 && typeof current === "object" && current !== null; depth++) {
265
+ const candidate = (current as { readonly code?: unknown }).code
266
+ if (typeof candidate === "string" && /^[A-Z][A-Z0-9_]{1,63}$/.test(candidate)) {
267
+ code = candidate
268
+ break
269
+ }
270
+ current = (current as { readonly cause?: unknown }).cause
271
+ }
272
+ const parts = [tag, code].filter((part): part is string => part !== undefined)
273
+ return parts.length === 0 ? undefined : parts.join(" ")
274
+ }
275
+
276
+ const transportFailure = (operation: string, cause?: unknown): ArtifactStore.ArtifactStoreError => {
277
+ const reason = transportReason(cause)
278
+ return new ArtifactStore.ArtifactStoreError({
279
+ code: "transport_failed",
280
+ message: `the remote artifact tier refused ${operation}${reason === undefined ? "" : ` (${reason})`}`
281
+ })
282
+ }
283
+
284
+ const unexpectedStatus = (operation: string, status: number): ArtifactStore.ArtifactStoreError =>
285
+ new ArtifactStore.ArtifactStoreError({
286
+ code: "transport_failed",
287
+ message: `the remote artifact tier answered ${operation} with HTTP ${status}`
288
+ })
289
+
290
+ const invalidOption = (name: string): ArtifactStore.ArtifactStoreError =>
291
+ new ArtifactStore.ArtifactStoreError({
292
+ code: "invalid_configuration",
293
+ message: `invalid remote artifact option: ${name}`
294
+ })
295
+
296
+ /** The batched-probe response body. */
297
+ const FindMissingResponse = Schema.Struct({ missing: Schema.Array(Schema.String) })
298
+
299
+ /** An RFC 9110 field-name token. */
300
+ const headerName = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/
301
+
302
+ /** Whether a header value carries a C0 control character or DEL, CR and LF included. */
303
+ const hasControlText = (value: string): boolean => {
304
+ for (let index = 0; index < value.length; index++) {
305
+ const unit = value.charCodeAt(index)
306
+ if (unit <= 0x1f || unit === 0x7f) return true
307
+ }
308
+ return false
309
+ }
310
+
311
+ const configurationFailure = (message: string): ArtifactStore.ArtifactStoreError =>
312
+ new ArtifactStore.ArtifactStoreError({ code: "invalid_configuration", message })
313
+
314
+ /**
315
+ * A 2xx is success, a 404 is a miss, and everything else is a transport
316
+ * failure. Bazel's HTTP client takes the same three-way split; it is the only
317
+ * classification a dumb-HTTP cache can support, because there is no richer
318
+ * error envelope on the wire.
319
+ */
320
+ const isOk = (response: HttpClientResponse.HttpClientResponse): boolean =>
321
+ response.status >= 200 && response.status < 300
322
+
323
+ /**
324
+ * Builds a remote artifact store over Effect's `HttpClient`.
325
+ *
326
+ * @category constructors
327
+ * @since 1.0.0-rc.0
328
+ * @slop
329
+ */
330
+ export const make = (
331
+ options: Options
332
+ ): Effect.Effect<Service, ArtifactStore.ArtifactStoreError, HttpClient.HttpClient> =>
333
+ Effect.gen(function*() {
334
+ const configured = yield* Effect.try({
335
+ try: () => {
336
+ const headers = options.headers === undefined
337
+ ? undefined
338
+ : Object.freeze(Object.fromEntries(
339
+ Object.entries(options.headers).map(([name, value]) => {
340
+ // Refused here, once, rather than on every request as an opaque
341
+ // transport failure: a CRLF in a value or a space in a name can
342
+ // never become a valid header.
343
+ if (typeof value !== "string" || value.length > 16 * 1024 || hasControlText(value)) {
344
+ throw new TypeError("header value")
345
+ }
346
+ if (!headerName.test(name)) throw new TypeError("header name")
347
+ return [name, value]
348
+ })
349
+ ))
350
+ return {
351
+ endpoint: options.endpoint,
352
+ headers,
353
+ downloadTimeout: options.downloadTimeout,
354
+ uploadTimeout: options.uploadTimeout,
355
+ requestTimeout: options.requestTimeout,
356
+ maxDownloadBytes: options.maxDownloadBytes,
357
+ maxFindMissingResponseBytes: options.maxFindMissingResponseBytes,
358
+ chunkBytes: options.chunkBytes,
359
+ downloadPolicy: options.downloadPolicy
360
+ }
361
+ },
362
+ catch: () => invalidOption("options")
363
+ })
364
+ if (typeof configured.endpoint !== "string") return yield* Effect.fail(invalidOption("endpoint"))
365
+ const endpoint = yield* Effect.try({
366
+ try: () => new URL(configured.endpoint),
367
+ catch: () => configurationFailure("invalid remote artifact endpoint")
368
+ })
369
+ // A self-hosted cache on this machine is reachable over plain HTTP, and
370
+ // loopback traffic never leaves the host. Only literal loopback names are
371
+ // exempt: a `*.localhost` name reaches loopback only on a resolver that
372
+ // follows RFC 6761, and one that forwards it to DNS would carry the
373
+ // credential headers over plaintext to whatever host answers.
374
+ const loopback = endpoint.hostname === "localhost" || endpoint.hostname === "127.0.0.1" ||
375
+ endpoint.hostname === "[::1]"
376
+ if (endpoint.protocol !== "https:" && !(endpoint.protocol === "http:" && loopback)) {
377
+ return yield* Effect.fail(configurationFailure("remote artifact endpoint must use HTTPS"))
378
+ }
379
+ if (endpoint.username !== "" || endpoint.password !== "" || endpoint.search !== "" || endpoint.hash !== "") {
380
+ return yield* Effect.fail(
381
+ configurationFailure("remote artifact endpoint must not contain credentials, a query, or a fragment")
382
+ )
383
+ }
384
+ endpoint.pathname = endpoint.pathname.replace(/\/+$/, "")
385
+ const base = endpoint.toString().replace(/\/+$/, "")
386
+ const headers = configured.headers
387
+ const client = yield* HttpClient.HttpClient
388
+ const credentialNames = Object.keys(headers ?? {}).map((name) => name.toLowerCase())
389
+ /**
390
+ * Protocol-owned headers are computed from each request's body and range,
391
+ * never inherited from credentials, including on a whole-blob fallback.
392
+ */
393
+ const authorized = (request: HttpClientRequest.HttpClientRequest): HttpClientRequest.HttpClientRequest =>
394
+ (headers === undefined ? request : HttpClientRequest.setHeaders(request, headers)).pipe(
395
+ HttpClientRequest.removeHeader("content-type"),
396
+ HttpClientRequest.removeHeader("content-length"),
397
+ HttpClientRequest.removeHeader("content-range")
398
+ )
399
+ // The address is a URL path segment, so it is refused before it is
400
+ // interpolated and percent-encoded when it is: an address carrying a
401
+ // separator or a `..` would otherwise point this client at a different
402
+ // resource on the configured endpoint. `ArtifactStore.validateDigest` is
403
+ // the same guard the filesystem tier applies to the same untrusted input —
404
+ // a digest read back out of a durable row.
405
+ const casUrl = (digest: ArtifactStore.Digest) => `${base}/cas/${digest}`
406
+ const scopedClient = HttpClient.withScope(client)
407
+ /**
408
+ * Keep body processing inside the exchange; abort unused responses on every exit.
409
+ *
410
+ * Redirects are never followed. A fetch-based client follows them by
411
+ * default and strips only `Authorization` on a cross-origin hop, so a
412
+ * tier answering `3xx` would otherwise receive the credential headers at
413
+ * another origin or over plain HTTP. Manual mode hands the `3xx` back,
414
+ * where it is an unexpected status (or, for `308`, the resumable-upload
415
+ * answer this protocol already speaks).
416
+ */
417
+ const send = <A, E, R>(
418
+ operation: string,
419
+ request: HttpClientRequest.HttpClientRequest,
420
+ use: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<A, E, R>
421
+ ): Effect.Effect<A, E | ArtifactStore.ArtifactStoreError, R> =>
422
+ Effect.gen(function*() {
423
+ const redactedNames = yield* Headers.CurrentRedactedNames
424
+ const requestInit = yield* Effect.serviceOption(FetchHttpClient.RequestInit)
425
+ return yield* scopedClient.execute(request).pipe(
426
+ Effect.provideService(FetchHttpClient.RequestInit, {
427
+ ...Option.getOrUndefined(requestInit),
428
+ redirect: "manual"
429
+ }),
430
+ Effect.mapError((cause) => transportFailure(operation, cause)),
431
+ Effect.flatMap(use),
432
+ Effect.provideService(Headers.CurrentRedactedNames, [...redactedNames, ...credentialNames]),
433
+ Effect.scoped
434
+ )
435
+ })
436
+ const parseDeadline = (name: string, input: Duration.Input | undefined, fallback: Duration.Duration) =>
437
+ Effect.gen(function*() {
438
+ const parsed = Duration.fromInput(input ?? fallback)
439
+ if (
440
+ Option.isNone(parsed) ||
441
+ !Number.isFinite(Duration.toMillis(parsed.value)) ||
442
+ Duration.toMillis(parsed.value) <= 0
443
+ ) {
444
+ return yield* Effect.fail(invalidOption(name))
445
+ }
446
+ return parsed.value
447
+ })
448
+ const downloadDeadline = yield* parseDeadline(
449
+ "downloadTimeout",
450
+ configured.downloadTimeout,
451
+ defaultDownloadTimeout
452
+ )
453
+ const uploadDeadline = yield* parseDeadline("uploadTimeout", configured.uploadTimeout, defaultUploadTimeout)
454
+ const requestDeadline = yield* parseDeadline("requestTimeout", configured.requestTimeout, defaultRequestTimeout)
455
+ const maxDownloadBytes = configured.maxDownloadBytes ?? defaultMaxDownloadBytes
456
+ if (!Number.isSafeInteger(maxDownloadBytes) || maxDownloadBytes <= 0) {
457
+ return yield* Effect.fail(invalidOption("maxDownloadBytes"))
458
+ }
459
+ const maxFindMissingResponseBytes = configured.maxFindMissingResponseBytes ?? maxFindMissingBodyBytes
460
+ if (
461
+ !Number.isSafeInteger(maxFindMissingResponseBytes) ||
462
+ maxFindMissingResponseBytes <= 0 ||
463
+ maxFindMissingResponseBytes > maxFindMissingBodyBytes
464
+ ) {
465
+ return yield* Effect.fail(invalidOption("maxFindMissingResponseBytes"))
466
+ }
467
+ const chunkBytes = configured.chunkBytes
468
+ if (chunkBytes !== undefined && (!Number.isSafeInteger(chunkBytes) || chunkBytes <= 0)) {
469
+ return yield* Effect.fail(invalidOption("chunkBytes"))
470
+ }
471
+ const downloadPolicy = configured.downloadPolicy ?? "all"
472
+ if (!Schema.is(DownloadPolicy)(downloadPolicy)) {
473
+ return yield* Effect.fail(invalidOption("downloadPolicy"))
474
+ }
475
+ const bodyTooLarge = (operation: string, received: number, bound: number): ArtifactStore.ArtifactStoreError =>
476
+ new ArtifactStore.ArtifactStoreError({
477
+ code: "transport_failed",
478
+ message: `the remote artifact tier answered ${operation} with ${received} bytes, past the ${bound}-byte bound`
479
+ })
480
+ const timedOut = (operation: string): ArtifactStore.ArtifactStoreError =>
481
+ new ArtifactStore.ArtifactStoreError({
482
+ code: "transport_failed",
483
+ message: `the remote artifact tier did not finish ${operation} within its configured deadline`
484
+ })
485
+ const within = <A, E, R>(
486
+ operation: string,
487
+ deadline: Duration.Duration,
488
+ effect: Effect.Effect<A, E, R>
489
+ ): Effect.Effect<A, E | ArtifactStore.ArtifactStoreError, R> =>
490
+ effect.pipe(
491
+ Effect.timeout(deadline),
492
+ Effect.catchTag("TimeoutError", () => Effect.fail(timedOut(operation)))
493
+ )
494
+ /**
495
+ * Reads a download body incrementally, refusing it the moment it exceeds
496
+ * the size bound; a `Content-Length` that already declares the excess is
497
+ * refused before a single body byte is read. Streaming instead of
498
+ * buffering the whole body means the guard fires at most one chunk past
499
+ * the bound, never after an arbitrarily large response is in memory.
500
+ */
501
+ const readBounded = (
502
+ response: HttpClientResponse.HttpClientResponse,
503
+ operation: string,
504
+ bound: number
505
+ ) =>
506
+ Effect.gen(function*() {
507
+ const declared = response.headers["content-length"]
508
+ if (declared !== undefined) {
509
+ const declaredBytes = Number(declared)
510
+ if (!/^\d+$/.test(declared) || !Number.isSafeInteger(declaredBytes)) {
511
+ return yield* Effect.fail(transportFailure(`${operation} body`))
512
+ }
513
+ if (declaredBytes > bound) return yield* Effect.fail(bodyTooLarge(operation, declaredBytes, bound))
514
+ }
515
+ const chunks: Array<Uint8Array> = []
516
+ let received = 0
517
+ yield* Stream.runForEach(response.stream, (chunk: Uint8Array) =>
518
+ Effect.suspend(() => {
519
+ received += chunk.byteLength
520
+ if (received > bound) return Effect.fail(bodyTooLarge(operation, received, bound))
521
+ chunks.push(new Uint8Array(chunk))
522
+ return Effect.void
523
+ })).pipe(
524
+ Effect.catchTag("HttpClientError", (cause) =>
525
+ // An absent body is zero bytes, not a transport refusal: the
526
+ // digest check in `get` is the arbiter of whether empty content
527
+ // is the requested artifact.
528
+ cause.reason._tag === "EmptyBodyError"
529
+ ? Effect.void
530
+ : Effect.fail(transportFailure(`${operation} body`)))
531
+ )
532
+ const bytes = new Uint8Array(received)
533
+ let offset = 0
534
+ for (const chunk of chunks) {
535
+ bytes.set(chunk, offset)
536
+ offset += chunk.byteLength
537
+ }
538
+ return bytes
539
+ })
540
+ /** The network half of `get`: request, status split, bounded body read. */
541
+ const download = (digest: ArtifactStore.Digest) =>
542
+ send("a download", authorized(HttpClientRequest.get(casUrl(digest))), (response) =>
543
+ Effect.gen(function*() {
544
+ if (response.status === 404) {
545
+ return yield* Effect.fail(new ArtifactStore.ArtifactMissing({ code: "artifact_missing", digest }))
546
+ }
547
+ if (!isOk(response)) return yield* Effect.fail(unexpectedStatus("a download", response.status))
548
+ return yield* readBounded(response, "a download", maxDownloadBytes)
549
+ }))
550
+
551
+ /** One whole-blob `PUT`: the protocol every dumb-HTTP CAS already speaks. */
552
+ const uploadWhole = (digest: ArtifactStore.Digest, bytes: Uint8Array) =>
553
+ send(
554
+ "an upload",
555
+ authorized(HttpClientRequest.put(casUrl(digest))).pipe(
556
+ HttpClientRequest.bodyUint8Array(bytes, "application/octet-stream")
557
+ ),
558
+ (response) => isOk(response) ? Effect.void : Effect.fail(unexpectedStatus("an upload", response.status))
559
+ )
560
+
561
+ /**
562
+ * The byte after the prefix a `Range: bytes=0-{last}` header reports, or
563
+ * `undefined` when the answer names no prefix. Only a prefix starting at
564
+ * zero is usable: a tier holding a middle span has nothing this client can
565
+ * continue from, so it is treated as holding nothing.
566
+ */
567
+ const resumeOffset = (response: HttpClientResponse.HttpClientResponse): number | undefined => {
568
+ const header = response.headers["range"]
569
+ if (header === undefined) return undefined
570
+ const match = /^bytes=0-(\d+)$/.exec(header.trim())
571
+ if (match === null) return undefined
572
+ const last = Number(match[1])
573
+ // A prefix past the safe integer range does not survive `Number`: the
574
+ // decimal digits are rounded, so the offset it names is a number this
575
+ // client made up. It proves nothing about what the tier holds, which is
576
+ // the same as naming no prefix at all.
577
+ if (!Number.isSafeInteger(last)) return undefined
578
+ return last + 1
579
+ }
580
+
581
+ /**
582
+ * A tier that refuses ranged bodies, in the three ways HTTP has to say
583
+ * so. `411` and `416` refuse the body's framing; `400` is RFC 9110
584
+ * section 14.5's answer from a resource that "does not support partial
585
+ * PUT requests", and it is what both of this repository's cache services
586
+ * send. Falling back on it can mask no genuine `400`: the whole-blob
587
+ * `PUT` that follows presents the same URL, digest, and credential, so a
588
+ * real refusal comes straight back on that request.
589
+ */
590
+ const rejectsRanges = (status: number): boolean => status === 400 || status === 411 || status === 416
591
+
592
+ /**
593
+ * Whether the tier holds exactly `total` bytes under `digest`, read from
594
+ * the `Content-Length` of a `HEAD` answer.
595
+ *
596
+ * This is the only claim about a chunked transfer this client will make on
597
+ * its own behalf. A tier that answers `HEAD` without a length is not
598
+ * refused — it simply proves nothing, so the caller sends the blob whole.
599
+ */
600
+ const holdsWhole = (digest: ArtifactStore.Digest, total: number) =>
601
+ send("an existence probe", authorized(HttpClientRequest.head(casUrl(digest))), (response) =>
602
+ Effect.sync(() => {
603
+ if (!isOk(response)) return false
604
+ const declared = response.headers["content-length"]
605
+ return declared !== undefined && Number(declared) === total
606
+ }))
607
+
608
+ /**
609
+ * Sends `bytes` as `Content-Range` chunks, resuming from whatever prefix
610
+ * the tier reports. Answers `false` when the tier has not proved it stored
611
+ * the whole blob, which is the caller's signal to send it whole.
612
+ *
613
+ * A `2xx` answer is never by itself proof of that. A tier that ignores
614
+ * `Content-Range`, such as plain WebDAV `PUT`, which Bazel documents as a
615
+ * supported dumb-HTTP cache, answers every request in this sequence `201`
616
+ * while storing only the last body it was handed, which for the probe is
617
+ * zero bytes. So only a `308` continues the sequence, and the completing
618
+ * chunk's `2xx` is confirmed with `HEAD` before the caller is told the
619
+ * digest is published. Everything else falls back, and the whole-blob
620
+ * `PUT` overwrites whatever partial body the sequence left behind.
621
+ */
622
+ const uploadChunked = (digest: ArtifactStore.Digest, bytes: Uint8Array, chunk: number) =>
623
+ Effect.gen(function*() {
624
+ const total = bytes.byteLength
625
+ const ranged = (range: string, body: Uint8Array) =>
626
+ send(
627
+ "an upload",
628
+ authorized(HttpClientRequest.put(casUrl(digest))).pipe(
629
+ HttpClientRequest.bodyUint8Array(body, "application/octet-stream"),
630
+ HttpClientRequest.setHeader("content-range", range)
631
+ ),
632
+ // Only status and headers are used after the response is released.
633
+ Effect.succeed
634
+ )
635
+ // A blob the tier already holds whole costs one `HEAD` and no body.
636
+ // Asking first is also what makes the probe's answer readable: a `2xx`
637
+ // to it can no longer mean "already there", so the one reading left is
638
+ // a tier that ignored the header and stored the empty body.
639
+ if (yield* holdsWhole(digest, total)) return true
640
+ // The probe carries no bytes: it exists to learn where to start, so
641
+ // that an interrupted transfer costs one round trip rather than the
642
+ // whole blob again.
643
+ const probe = yield* ranged(`bytes */${total}`, new Uint8Array(0))
644
+ if (isOk(probe) || rejectsRanges(probe.status)) return false
645
+ if (probe.status !== 308 && probe.status !== 404) {
646
+ return yield* Effect.fail(unexpectedStatus("an upload probe", probe.status))
647
+ }
648
+ let offset = probe.status === 308 ? resumeOffset(probe) ?? 0 : 0
649
+ while (offset < total) {
650
+ const end = Math.min(offset + chunk, total)
651
+ const answer = yield* ranged(`bytes ${offset}-${end - 1}/${total}`, bytes.subarray(offset, end))
652
+ if (answer.status !== 308) {
653
+ // The completing chunk may answer `2xx`; anything before it may
654
+ // not, because a tier that is still owed bytes and reports success
655
+ // is a tier that is not reading the header. Both are checked
656
+ // against what the tier actually holds below.
657
+ if (end === total && isOk(answer)) break
658
+ if (isOk(answer) || rejectsRanges(answer.status)) return false
659
+ return yield* Effect.fail(unexpectedStatus("an upload", answer.status))
660
+ }
661
+ // A tier that reports a longer prefix than this chunk delivered has
662
+ // more than we just sent, so the next chunk starts there. Never
663
+ // backwards: an answer naming a shorter prefix would loop forever.
664
+ offset = Math.max(end, resumeOffset(answer) ?? 0)
665
+ }
666
+ return yield* holdsWhole(digest, total)
667
+ })
668
+
669
+ const put: ArtifactStore.Service["put"] = Effect.fn("RemoteArtifacts.put")((bytes: Uint8Array) =>
670
+ Effect.flatMap(ArtifactStore.snapshotBytes(bytes), (snapshot) =>
671
+ Effect.gen(function*() {
672
+ const digest = yield* ArtifactStore.measureBytes(snapshot)
673
+ yield* Effect.annotateCurrentSpan({ digest })
674
+ yield* within(
675
+ "an upload",
676
+ uploadDeadline,
677
+ Effect.gen(function*() {
678
+ if (chunkBytes === undefined || snapshot.byteLength <= chunkBytes) {
679
+ yield* uploadWhole(digest, snapshot)
680
+ return
681
+ }
682
+ const transferred = yield* uploadChunked(digest, snapshot, chunkBytes)
683
+ if (!transferred) yield* uploadWhole(digest, snapshot)
684
+ })
685
+ )
686
+ return digest
687
+ }))
688
+ )
689
+
690
+ const get: ArtifactStore.Service["get"] = Effect.fn("RemoteArtifacts.get")((digest: string) =>
691
+ Effect.gen(function*() {
692
+ const validated = yield* ArtifactStore.validateDigest(digest)
693
+ yield* Effect.annotateCurrentSpan({ digest: validated })
694
+ // The deadline covers the whole exchange — request, headers, body —
695
+ // because a tier that stalls mid-body is exactly as unanswering as
696
+ // one that never accepts the connection.
697
+ const bytes = yield* within("a download", downloadDeadline, download(validated))
698
+ // The shared tier is the least trusted store there is: it is written
699
+ // by machines this one has never met. Verifying the address here means
700
+ // a mis-serving or compromised cache can waste a round trip but can
701
+ // never substitute content.
702
+ const measured = yield* ArtifactStore.measureBytes(bytes)
703
+ if (measured !== validated) {
704
+ return yield* Effect.fail(
705
+ new ArtifactStore.ArtifactCorruption({
706
+ code: "artifact_corruption",
707
+ recordedDigest: validated,
708
+ measuredDigest: measured
709
+ })
710
+ )
711
+ }
712
+ return bytes
713
+ })
714
+ )
715
+
716
+ const has: ArtifactStore.Service["has"] = Effect.fn("RemoteArtifacts.has")((digest: string) =>
717
+ Effect.gen(function*() {
718
+ const validated = yield* ArtifactStore.validateDigest(digest)
719
+ yield* Effect.annotateCurrentSpan({ digest: validated })
720
+ const response = yield* within(
721
+ "an existence probe",
722
+ requestDeadline,
723
+ send("an existence probe", authorized(HttpClientRequest.head(casUrl(validated))), Effect.succeed)
724
+ )
725
+ if (response.status === 404) return false
726
+ if (!isOk(response)) return yield* Effect.fail(unexpectedStatus("an existence probe", response.status))
727
+ return true
728
+ })
729
+ )
730
+
731
+ const findMissing: ArtifactStore.Service["findMissing"] = Effect.fn("RemoteArtifacts.findMissing")(
732
+ (digests: Iterable<string>) =>
733
+ Effect.gen(function*() {
734
+ const requested = [...new Set(digests)]
735
+ if (requested.length === 0) return []
736
+ // The batch travels in a JSON body rather than a path, but an
737
+ // unusable address is still an unusable address, and the local tier
738
+ // refuses the same batch wholesale rather than probing part of it.
739
+ const validated: Array<ArtifactStore.Digest> = []
740
+ for (const digest of requested) validated.push(yield* ArtifactStore.validateDigest(digest))
741
+ yield* Effect.annotateCurrentSpan({ count: validated.length })
742
+ const missing = new Set<string>()
743
+ for (let offset = 0; offset < validated.length; offset += maxFindMissingBatchDigests) {
744
+ const batch = validated.slice(offset, offset + maxFindMissingBatchDigests)
745
+ const requestBody = encoder.encode(JSON.stringify({ digests: batch }))
746
+ /* v8 ignore next 3 -- strict 64-byte digests plus the 1,000-entry batch cap make this
747
+ * at most 67,013 bytes; keep the protocol assertion beside serialization in case either invariant changes. */
748
+ if (requestBody.byteLength > maxFindMissingBodyBytes) {
749
+ return yield* Effect.fail(configurationFailure("remote artifact findMissing request exceeds 256 KiB"))
750
+ }
751
+ const decoded = yield* within(
752
+ "a batched existence probe",
753
+ requestDeadline,
754
+ send(
755
+ "a batched existence probe",
756
+ authorized(HttpClientRequest.post(`${base}/cas/findMissing`)).pipe(
757
+ HttpClientRequest.bodyUint8Array(requestBody, "application/json")
758
+ ),
759
+ (response) =>
760
+ Effect.gen(function*() {
761
+ if (!isOk(response)) {
762
+ return yield* Effect.fail(unexpectedStatus("a batched existence probe", response.status))
763
+ }
764
+ const bodyBytes = yield* readBounded(
765
+ response,
766
+ "a batched existence probe",
767
+ maxFindMissingResponseBytes
768
+ )
769
+ const body = yield* Effect.try({
770
+ try: () => JSON.parse(decoder.decode(bodyBytes)) as unknown,
771
+ catch: () => transportFailure("a batched existence probe body")
772
+ })
773
+ return yield* Schema.decodeUnknownEffect(FindMissingResponse)(body).pipe(
774
+ Effect.mapError(() => transportFailure("a batched existence probe body"))
775
+ )
776
+ })
777
+ )
778
+ )
779
+ const asked = new Set<string>(batch)
780
+ for (const digest of decoded.missing) if (asked.has(digest)) missing.add(digest)
781
+ }
782
+ // "The returned set is guaranteed to be a subset of `digests`"
783
+ // (`MissingDigestsFinder`). Bazel gets that from the server; we
784
+ // enforce it on this side too, because a server that answered with
785
+ // an unrequested digest would otherwise make the caller upload bytes
786
+ // it never asked about.
787
+ return validated.filter((digest) => missing.has(digest))
788
+ })
789
+ )
790
+
791
+ return { put, get, has, findMissing, downloadPolicy }
792
+ })
793
+
794
+ /**
795
+ * Provides a remote artifact store as the `ArtifactStore` tag.
796
+ *
797
+ * Composing this *alone* makes every artifact read and write a network round
798
+ * trip. The intended production shape is
799
+ * {@link CombinedArtifacts.layer | CombinedArtifacts}, with this as its remote
800
+ * tier.
801
+ *
802
+ * @category layers
803
+ * @since 1.0.0-rc.0
804
+ * @slop
805
+ */
806
+ export const layer = (
807
+ options: Options
808
+ ): Layer.Layer<ArtifactStore.ArtifactStore, ArtifactStore.ArtifactStoreError, HttpClient.HttpClient> =>
809
+ Layer.effect(ArtifactStore.ArtifactStore)(make(options))