@effected/runtimes 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.
- package/BunResolver.js +79 -0
- package/DenoResolver.js +79 -0
- package/GitHub.js +220 -0
- package/LICENSE +21 -0
- package/NodeRelease.js +73 -0
- package/NodeResolver.js +114 -0
- package/NodeSchedule.js +217 -0
- package/README.md +155 -0
- package/ResolvedVersions.js +122 -0
- package/index.d.ts +737 -0
- package/index.js +9 -0
- package/internal/defaults/bun.js +855 -0
- package/internal/defaults/deno.js +1543 -0
- package/internal/defaults/node.js +4443 -0
- package/internal/feeds.js +84 -0
- package/internal/githubRuntime.js +43 -0
- package/internal/http.js +127 -0
- package/internal/releaseIndex.js +46 -0
- package/internal/resolve.js +44 -0
- package/internal/strategy.js +53 -0
- package/package.json +50 -0
- package/tsdoc-metadata.json +11 -0
package/NodeSchedule.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { DateTime, Effect, Option, Order, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/NodeSchedule.ts
|
|
4
|
+
/**
|
|
5
|
+
* The Node.js release schedule and the lifecycle phases derived from it.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Lifecycle phase of a Node.js major release line.
|
|
11
|
+
*
|
|
12
|
+
* - `current` — actively receiving features and bug fixes.
|
|
13
|
+
* - `active-lts` — Long-Term Support: bug and security fixes only.
|
|
14
|
+
* - `maintenance-lts` — critical security fixes only.
|
|
15
|
+
* - `end-of-life` — no longer maintained.
|
|
16
|
+
*
|
|
17
|
+
* @public
|
|
18
|
+
*/
|
|
19
|
+
const NodePhase = Schema.Literals([
|
|
20
|
+
"current",
|
|
21
|
+
"active-lts",
|
|
22
|
+
"maintenance-lts",
|
|
23
|
+
"end-of-life"
|
|
24
|
+
]);
|
|
25
|
+
/**
|
|
26
|
+
* A date in the release schedule could not be understood.
|
|
27
|
+
*
|
|
28
|
+
* Raised when `nodejs/Release` publishes a `schedule.json` whose dates this
|
|
29
|
+
* package cannot parse — an operator-facing signal that the upstream feed
|
|
30
|
+
* changed shape, not something a caller can recover from.
|
|
31
|
+
*
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
34
|
+
var InvalidScheduleDateError = class extends Schema.TaggedErrorClass()("InvalidScheduleDateError", {
|
|
35
|
+
/** The schedule key whose entry failed, e.g. `"v20"`. */
|
|
36
|
+
key: Schema.String,
|
|
37
|
+
/** The field that failed, e.g. `"start"`. */
|
|
38
|
+
field: Schema.String,
|
|
39
|
+
/** The value that could not be parsed. */
|
|
40
|
+
value: Schema.String
|
|
41
|
+
}) {};
|
|
42
|
+
/**
|
|
43
|
+
* The release-line key a version belongs to.
|
|
44
|
+
*
|
|
45
|
+
* Node's early history is the whole reason this exists. `nodejs/Release`
|
|
46
|
+
* publishes `v0.8`, `v0.10` and `v0.12` as three *separate* release lines with
|
|
47
|
+
* their own start and end dates — the major number does not identify them.
|
|
48
|
+
* Everything from `v4` on is keyed by the major alone.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* import { nodeReleaseLine } from "@effected/runtimes";
|
|
53
|
+
*
|
|
54
|
+
* nodeReleaseLine({ major: 20, minor: 11 }); // "20"
|
|
55
|
+
* nodeReleaseLine({ major: 0, minor: 12 }); // "0.12"
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* @public
|
|
59
|
+
*/
|
|
60
|
+
const nodeReleaseLine = (version) => version.major === 0 ? `0.${version.minor ?? 0}` : String(version.major);
|
|
61
|
+
/**
|
|
62
|
+
* One release line's lifecycle dates.
|
|
63
|
+
*
|
|
64
|
+
* @public
|
|
65
|
+
*/
|
|
66
|
+
var NodeScheduleEntry = class extends Schema.Class("NodeScheduleEntry")({
|
|
67
|
+
/**
|
|
68
|
+
* The release line as `nodejs/Release` keys it, without the `v`: `"20"`, or
|
|
69
|
+
* `"0.10"` for the three dotted early lines.
|
|
70
|
+
*/
|
|
71
|
+
line: Schema.String,
|
|
72
|
+
/** The Node.js major version number, e.g. `20`. `0` for every `0.x` line. */
|
|
73
|
+
major: Schema.Number,
|
|
74
|
+
/** The minor, for the `0.x` lines that are each their own release line. */
|
|
75
|
+
minor: Schema.optionalKey(Schema.Number),
|
|
76
|
+
/** When the line was first released. */
|
|
77
|
+
start: Schema.DateTimeUtc,
|
|
78
|
+
/** When the line entered Active LTS, absent if it never does (odd majors). */
|
|
79
|
+
lts: Schema.optionalKey(Schema.DateTimeUtc),
|
|
80
|
+
/** When the line entered Maintenance LTS. */
|
|
81
|
+
maintenance: Schema.optionalKey(Schema.DateTimeUtc),
|
|
82
|
+
/** When the line reaches end of life. */
|
|
83
|
+
end: Schema.DateTimeUtc,
|
|
84
|
+
/** The LTS codename, e.g. `"Iron"`. Empty for lines that never got one. */
|
|
85
|
+
codename: Schema.String
|
|
86
|
+
}) {};
|
|
87
|
+
/**
|
|
88
|
+
* The raw shape of `schedule.json` as `nodejs/Release` publishes it: a map of
|
|
89
|
+
* `"vNN"` to ISO date strings.
|
|
90
|
+
*
|
|
91
|
+
* @public
|
|
92
|
+
*/
|
|
93
|
+
const NodeScheduleData = Schema.Record(Schema.String, Schema.Struct({
|
|
94
|
+
start: Schema.String,
|
|
95
|
+
lts: Schema.optionalKey(Schema.String),
|
|
96
|
+
maintenance: Schema.optionalKey(Schema.String),
|
|
97
|
+
end: Schema.String,
|
|
98
|
+
codename: Schema.optionalKey(Schema.String)
|
|
99
|
+
}));
|
|
100
|
+
const decodeDate = Schema.decodeUnknownEffect(Schema.DateTimeUtcFromString);
|
|
101
|
+
const parseDate = (key, field, value) => decodeDate(value).pipe(Effect.mapError(() => new InvalidScheduleDateError({
|
|
102
|
+
key,
|
|
103
|
+
field,
|
|
104
|
+
value
|
|
105
|
+
})));
|
|
106
|
+
/**
|
|
107
|
+
* Ascending by major, then by minor within the `0.x` lines — so `v0.8` sorts
|
|
108
|
+
* before `v0.10`, which a plain string or major-only comparison would not do.
|
|
109
|
+
*/
|
|
110
|
+
const byLine = Order.combine(Order.mapInput(Order.Number, (entry) => entry.major), Order.mapInput(Order.Number, (entry) => entry.minor ?? 0));
|
|
111
|
+
/** `"v20"` or `"v0.10"`, and nothing else — the file has carried other keys. */
|
|
112
|
+
const SCHEDULE_KEY = /^v?(\d+)(?:\.(\d+))?$/;
|
|
113
|
+
/**
|
|
114
|
+
* An immutable snapshot of the Node.js release schedule.
|
|
115
|
+
*
|
|
116
|
+
* In v3 a `Ref<NodeSchedule>` was threaded *into every `NodeRelease`* so that
|
|
117
|
+
* `release.phase()` could reach the schedule — mutable service state inside an
|
|
118
|
+
* immutable domain value, which is why `NodeRelease` could not be a data class.
|
|
119
|
+
* Here the schedule is a value the caller passes in: phase is a function of
|
|
120
|
+
* `(release, schedule, now)`, and nothing in the model is mutable.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* import { NodeSchedule } from "@effected/runtimes";
|
|
125
|
+
* import { DateTime, Effect, Option } from "effect";
|
|
126
|
+
*
|
|
127
|
+
* const program = Effect.gen(function* () {
|
|
128
|
+
* const schedule = yield* NodeSchedule.fromData({
|
|
129
|
+
* v20: { start: "2023-04-18", lts: "2023-10-24", end: "2026-04-30" },
|
|
130
|
+
* });
|
|
131
|
+
* const phase = schedule.phaseFor({ major: 20 }, DateTime.makeUnsafe("2024-01-01"));
|
|
132
|
+
* return Option.getOrNull(phase); // "active-lts"
|
|
133
|
+
* });
|
|
134
|
+
* ```
|
|
135
|
+
*
|
|
136
|
+
* @public
|
|
137
|
+
*/
|
|
138
|
+
var NodeSchedule = class NodeSchedule extends Schema.Class("NodeSchedule")({
|
|
139
|
+
/** The known release lines, ascending by major. */
|
|
140
|
+
entries: Schema.Array(NodeScheduleEntry) }) {
|
|
141
|
+
/**
|
|
142
|
+
* A schedule that knows nothing.
|
|
143
|
+
*
|
|
144
|
+
* `phaseFor` returns `Option.none()` for every major, which is the correct
|
|
145
|
+
* answer before a schedule has been loaded.
|
|
146
|
+
*/
|
|
147
|
+
static empty = NodeSchedule.make({ entries: [] });
|
|
148
|
+
/**
|
|
149
|
+
* Parse the raw `schedule.json` shape into a schedule.
|
|
150
|
+
*
|
|
151
|
+
* Keys that are not of the form `"vNN"` are skipped — the upstream file has
|
|
152
|
+
* historically carried non-version keys, and one of them is not a reason to
|
|
153
|
+
* fail the whole schedule. A key that *is* a version but whose dates do not
|
|
154
|
+
* parse fails with {@link InvalidScheduleDateError}.
|
|
155
|
+
*/
|
|
156
|
+
static fromData = Effect.fn("NodeSchedule.fromData")(function* (data) {
|
|
157
|
+
const entries = [];
|
|
158
|
+
for (const [key, value] of Object.entries(data)) {
|
|
159
|
+
const match = SCHEDULE_KEY.exec(key);
|
|
160
|
+
if (match === null) continue;
|
|
161
|
+
const major = Number(match[1]);
|
|
162
|
+
const minor = match[2] === void 0 ? void 0 : Number(match[2]);
|
|
163
|
+
const start = yield* parseDate(key, "start", value.start);
|
|
164
|
+
const end = yield* parseDate(key, "end", value.end);
|
|
165
|
+
const lts = value.lts === void 0 ? void 0 : yield* parseDate(key, "lts", value.lts);
|
|
166
|
+
const maintenance = value.maintenance === void 0 ? void 0 : yield* parseDate(key, "maintenance", value.maintenance);
|
|
167
|
+
entries.push(NodeScheduleEntry.make({
|
|
168
|
+
line: nodeReleaseLine({
|
|
169
|
+
major,
|
|
170
|
+
minor
|
|
171
|
+
}),
|
|
172
|
+
major,
|
|
173
|
+
start,
|
|
174
|
+
end,
|
|
175
|
+
codename: value.codename ?? "",
|
|
176
|
+
...minor !== void 0 ? { minor } : {},
|
|
177
|
+
...lts !== void 0 ? { lts } : {},
|
|
178
|
+
...maintenance !== void 0 ? { maintenance } : {}
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
return NodeSchedule.make({ entries: entries.sort(byLine) });
|
|
182
|
+
});
|
|
183
|
+
/**
|
|
184
|
+
* The schedule entry for a version's release line, if the schedule knows it.
|
|
185
|
+
*/
|
|
186
|
+
entryFor(version) {
|
|
187
|
+
const line = nodeReleaseLine(version);
|
|
188
|
+
return Option.fromUndefinedOr(this.entries.find((entry) => entry.line === line));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The lifecycle phase of a version's release line at a point in time.
|
|
192
|
+
*
|
|
193
|
+
* `now` is an explicit parameter rather than a read of the wall clock, which
|
|
194
|
+
* is what makes every phase transition testable without mocking time.
|
|
195
|
+
*
|
|
196
|
+
* Returns `Option.none()` when the schedule does not know the line, or when
|
|
197
|
+
* `now` is before the line was released — an unreleased line has no phase.
|
|
198
|
+
*/
|
|
199
|
+
phaseFor(version, now) {
|
|
200
|
+
return this.entryFor(version).pipe(Option.flatMap((entry) => {
|
|
201
|
+
if (DateTime.isLessThan(now, entry.start)) return Option.none();
|
|
202
|
+
if (DateTime.isGreaterThanOrEqualTo(now, entry.end)) return Option.some("end-of-life");
|
|
203
|
+
if (entry.maintenance !== void 0 && DateTime.isGreaterThanOrEqualTo(now, entry.maintenance)) return Option.some("maintenance-lts");
|
|
204
|
+
if (entry.lts !== void 0 && DateTime.isGreaterThanOrEqualTo(now, entry.lts)) return Option.some("active-lts");
|
|
205
|
+
return Option.some("current");
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* Whether a phase counts as Long-Term Support.
|
|
211
|
+
*
|
|
212
|
+
* @public
|
|
213
|
+
*/
|
|
214
|
+
const isLtsPhase = (phase) => phase === "active-lts" || phase === "maintenance-lts";
|
|
215
|
+
|
|
216
|
+
//#endregion
|
|
217
|
+
export { InvalidScheduleDateError, NodePhase, NodeSchedule, NodeScheduleData, NodeScheduleEntry, isLtsPhase, nodeReleaseLine };
|
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# @effected/runtimes
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@effected/runtimes)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
Resolve semver-compatible Node.js, Bun and Deno versions from the live release feeds, with a bundled offline snapshot as a fallback. Ask for `>=20` in the `active-lts` phase and get back every match, newest first, plus the LTS pick and whatever you nominated as the default. Node's lifecycle phases come from the real `nodejs/Release` schedule and are evaluated against the clock, so `current`, `active-lts`, `maintenance-lts` and `end-of-life` mean what they mean today rather than on the day the package was published.
|
|
9
|
+
|
|
10
|
+
> **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
|
|
11
|
+
> development against a single pinned Effect v4 beta. Packages graduate to
|
|
12
|
+
> `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
|
|
13
|
+
> exactly the ones the kit is built and tested against, install
|
|
14
|
+
> [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
|
|
15
|
+
>
|
|
16
|
+
> **Stability: unstable.** This package's API surface is not yet considered
|
|
17
|
+
> complete and may change across `0.x` releases. Pin an exact version — even a
|
|
18
|
+
> package marked *stable* before `1.0.0` can introduce a breaking change by
|
|
19
|
+
> accident, and an exact pin turns that into a type-check error rather than a
|
|
20
|
+
> runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
|
|
21
|
+
|
|
22
|
+
## Why @effected/runtimes
|
|
23
|
+
|
|
24
|
+
Every answer carries an honest `source`. A resolver that fetches live data, silently fails, serves a snapshot and reports `source: "api"` is worse than one that never had a snapshot — you cannot tell a fresh answer from a stale one, and a CI job pinning its toolchain will happily install a version that was current last quarter. Here the field is set by whichever strategy actually populated the index: `"api"` when the feed answered, `"cache"` when the snapshot did, including when the automatic strategy fell back to it, and the fallback logs a warning on the way through.
|
|
25
|
+
|
|
26
|
+
The freshness policy is a layer, not a flag, and the types follow: `layerOffline` has no error channel and no requirements, because a snapshot read cannot fail and does no IO. `layerFresh` fails with `FreshnessError` and nothing else, because you chose it to say a snapshot is not an acceptable substitute. A single resolver switching on a strategy enum would union all three error channels together and force the offline layer to advertise a failure it cannot have.
|
|
27
|
+
|
|
28
|
+
There is no HTTP client in the dependency tree either. The v3 library carried Octokit and `@octokit/auth-app` to fund exactly two REST GETs; this one goes through `HttpClient` from `effect/unstable/http` and lets you supply the transport. `@effected/semver` is the only runtime dependency, and it is first-party.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install @effected/runtimes effect
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pnpm add @effected/runtimes effect
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Requires Node.js >=24.11.0.
|
|
41
|
+
|
|
42
|
+
`effect` v4 is the only peer dependency. `@effected/semver` is a regular dependency and comes along automatically; nothing else reaches your tree.
|
|
43
|
+
|
|
44
|
+
Live resolution needs an `HttpClient`, provided at the edge with `FetchHttpClient.layer` from `effect/unstable/http` — that layer has no requirements of its own, so it works anywhere `fetch` does. If you only ever use `layerOffline`, you need no HTTP client at all.
|
|
45
|
+
|
|
46
|
+
The command-line interface ships as a separate package, so this package's consumers never install `@effect/platform-node`.
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { NodeResolver } from "@effected/runtimes";
|
|
52
|
+
import { Effect } from "effect";
|
|
53
|
+
import { FetchHttpClient } from "effect/unstable/http";
|
|
54
|
+
|
|
55
|
+
const program = Effect.gen(function* () {
|
|
56
|
+
const node = yield* NodeResolver;
|
|
57
|
+
return yield* node.resolve({ range: ">=20", phases: ["active-lts"] });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
Effect.runPromise(program.pipe(Effect.provide(NodeResolver.layer), Effect.provide(FetchHttpClient.layer))).then(
|
|
61
|
+
console.log,
|
|
62
|
+
);
|
|
63
|
+
// ResolvedVersions {
|
|
64
|
+
// source: "api", // "cache" if the feed was unreachable and the snapshot answered
|
|
65
|
+
// versions: [...], // every active-LTS Node matching >=20, newest first
|
|
66
|
+
// latest: "...", // the newest of them
|
|
67
|
+
// lts: "..." // the newest LTS pick
|
|
68
|
+
// }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
No credentials are needed for Node: both feeds it reads — nodejs.org's release index and the `nodejs/Release` schedule — are unauthenticated.
|
|
72
|
+
|
|
73
|
+
## Cache strategy as layer
|
|
74
|
+
|
|
75
|
+
Each of the three resolvers exposes the same three layer **constants**:
|
|
76
|
+
|
|
77
|
+
| Layer | Behavior | Fails with | Requires |
|
|
78
|
+
| ----- | -------- | ---------- | -------- |
|
|
79
|
+
| `layer` | Fetch live; on failure fall back to the bundled snapshot, log a warning and report `source: "cache"` | never | the resolver's transport |
|
|
80
|
+
| `layerFresh` | Live data or nothing | `FreshnessError` | the resolver's transport |
|
|
81
|
+
| `layerOffline` | The bundled snapshot only. No IO. | never | nothing |
|
|
82
|
+
|
|
83
|
+
The transport differs by runtime, and that is not an accident:
|
|
84
|
+
|
|
85
|
+
| Resolver | Feed | Requires |
|
|
86
|
+
| -------- | ---- | -------- |
|
|
87
|
+
| `NodeResolver` | nodejs.org's release index and the `nodejs/Release` schedule — both unauthenticated | `HttpClient` |
|
|
88
|
+
| `BunResolver` | GitHub releases for `oven-sh/bun` | `GitHubClient` |
|
|
89
|
+
| `DenoResolver` | GitHub releases for `denoland/deno` | `GitHubClient` |
|
|
90
|
+
|
|
91
|
+
`GitHubClient.layerDefault` is the batteries-included wiring: environment-detected credentials over `fetch`. Credential precedence is `GITHUB_PERSONAL_ACCESS_TOKEN`, then `GITHUB_TOKEN`, then anonymous — GitHub allows anonymous requests at a much lower rate limit. Supply your own with `GitHubAuth.token(redactedToken)`, or use `GitHubClient.layer` directly to bring both a credential and a transport:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { BunResolver, DenoResolver, GitHubClient, NodeResolver } from "@effected/runtimes";
|
|
95
|
+
import { Layer } from "effect";
|
|
96
|
+
import { FetchHttpClient } from "effect/unstable/http";
|
|
97
|
+
|
|
98
|
+
export const ResolversLive = Layer.mergeAll(
|
|
99
|
+
NodeResolver.layer.pipe(Layer.provide(FetchHttpClient.layer)),
|
|
100
|
+
BunResolver.layer.pipe(Layer.provide(GitHubClient.layerDefault)),
|
|
101
|
+
DenoResolver.layer.pipe(Layer.provide(GitHubClient.layerDefault)),
|
|
102
|
+
);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`GitHubAuth.token` returns a fresh layer per call, so bind it to a const rather than calling it inline twice — layers are memoized by reference. GitHub App authentication (JWT signing plus the installation-token exchange) is deliberately not implemented, because it would mean a runtime dependency: `GitHubAuth` is a pluggable service, and an application that needs App auth supplies its own `Layer<GitHubAuth>`.
|
|
106
|
+
|
|
107
|
+
## Resolving
|
|
108
|
+
|
|
109
|
+
`resolve` takes a semver range, a grouping granularity and an optional default range. Node additionally takes the lifecycle phases to accept and the date to evaluate them at:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { NodeResolver } from "@effected/runtimes";
|
|
113
|
+
import { Effect } from "effect";
|
|
114
|
+
|
|
115
|
+
const program = Effect.gen(function* () {
|
|
116
|
+
const node = yield* NodeResolver;
|
|
117
|
+
return yield* node.resolve({
|
|
118
|
+
range: ">=18",
|
|
119
|
+
phases: ["active-lts", "maintenance-lts"],
|
|
120
|
+
increments: "minor",
|
|
121
|
+
defaultVersion: "^22",
|
|
122
|
+
});
|
|
123
|
+
}).pipe(Effect.provide(NodeResolver.layerOffline));
|
|
124
|
+
// ResolvedVersions with one entry per minor line, and `default` set to the newest match for ^22.
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`increments` groups the matches: `latest` keeps the newest version of each major line, `minor` the newest patch of each minor line, `patch` every matching release. `range` defaults to `*`, `increments` to `latest`, and Node's `phases` to `["current", "active-lts"]`. The phase evaluation date comes from `Clock` via `DateTime.now`, so `TestClock` drives it, and passing `date` pins it explicitly.
|
|
128
|
+
|
|
129
|
+
The Node schedule is keyed by release *line*, not by major — `nodejs/Release` publishes `v0.8`, `v0.10` and `v0.12` as three distinct lines, and parsing them all down to major `0` collapses them onto whichever came first.
|
|
130
|
+
|
|
131
|
+
## Errors
|
|
132
|
+
|
|
133
|
+
| Tag | Means | Recovery |
|
|
134
|
+
| --- | --- | --- |
|
|
135
|
+
| `InvalidRangeError` | The semver range is malformed. Raised by `@effected/semver` and imported from there. | A typo in a range is a typo, not a not-found. Report it as one. |
|
|
136
|
+
| `NoMatchingVersionError` | The range is fine and nothing matched it. Carries `runtime`, `constraint` and the `phases` searched. | Widen the range, or accept more phases. |
|
|
137
|
+
| `UnresolvableDefaultError` | An explicit `defaultVersion` was asked for and nothing matched it. Carries `runtime` and `defaultVersion`. | Distinct from the above, because Node's default otherwise falls back to the LTS pick — silently dropping it would hand you LTS as though you had asked for it. |
|
|
138
|
+
| `FreshnessError` | `layerFresh` could not reach the feed. Carries `runtime` and the structural `cause`. | Retry, or fall back to `layer` and accept a snapshot. |
|
|
139
|
+
| `RateLimitError` | GitHub's rate limit is exhausted. Carries `limit`, `remaining` and, when GitHub said, `retryAfter` in seconds. | Authenticate, or back off by `retryAfter`. A `403` is classified from the response headers, never guessed from the body. |
|
|
140
|
+
| `AuthenticationError` | GitHub rejected the credential. Carries `method` — `"token"` or `"anonymous"`. | Check the token, or supply one. |
|
|
141
|
+
| `NetworkError` | The request failed, or returned a status that is neither auth nor rate limit. Carries `url`, the `status` where there was one, and the structural `cause`. | A permission `403` lands here and is not retried. |
|
|
142
|
+
| `ResponseParseError` | A feed responded, but not with the shape this package expects. Carries `source` and the structural `cause`. | An operator-facing signal that an upstream feed changed. Malformed data fails typed, never as a defect. |
|
|
143
|
+
|
|
144
|
+
## Features
|
|
145
|
+
|
|
146
|
+
- `NodeResolver`, `BunResolver`, `DenoResolver` — one service per runtime, each with `layer`, `layerFresh` and `layerOffline`.
|
|
147
|
+
- `ResolvedVersions` — `source`, `versions`, `latest`, and optionally `lts` and `default`. An empty match is an error, not an empty result, so `latest` is always present.
|
|
148
|
+
- `NodeSchedule` / `NodePhase` / `NodeScheduleEntry` — the `nodejs/Release` lifecycle schedule, keyed by release line, with `isLtsPhase` and `nodeReleaseLine` helpers.
|
|
149
|
+
- `GitHubClient` / `GitHubAuth` — a minimal GitHub REST client over `HttpClient`, with anonymous, explicit-token and environment-detected auth layers. Pagination is bounded, and a server-supplied `retry-after` is capped before it becomes a sleep.
|
|
150
|
+
- `BunRelease`, `DenoRelease`, `NodeRelease` — the decoded release models, plus `GitHubTag` and `GitHubRelease` for the REST payloads they are built from.
|
|
151
|
+
- Tagged errors throughout, each carrying its cause structurally.
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { NodePhase } from "./NodeSchedule.js";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/ResolvedVersions.ts
|
|
5
|
+
/**
|
|
6
|
+
* The shared vocabulary of resolution: what a resolver returns, and how it fails.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The JavaScript runtime a resolver targets.
|
|
12
|
+
*
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
const Runtime = Schema.Literals([
|
|
16
|
+
"node",
|
|
17
|
+
"bun",
|
|
18
|
+
"deno"
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* Where a resolution's release data came from.
|
|
22
|
+
*
|
|
23
|
+
* - `api` — a live fetch of the upstream feed succeeded.
|
|
24
|
+
* - `cache` — the bundled snapshot was used, either because the offline
|
|
25
|
+
* strategy was chosen or because the auto strategy fell back to it.
|
|
26
|
+
*
|
|
27
|
+
* This is honest provenance. In v3 the field existed, was advertised as a
|
|
28
|
+
* headline feature, and was hardcoded to `"api"` by every resolver — so a
|
|
29
|
+
* caller could not tell a live answer from a snapshot served after a silent
|
|
30
|
+
* network failure.
|
|
31
|
+
*
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
34
|
+
const Source = Schema.Literals(["api", "cache"]);
|
|
35
|
+
/**
|
|
36
|
+
* The granularity at which matching versions are grouped.
|
|
37
|
+
*
|
|
38
|
+
* - `latest` — the newest version of each major line.
|
|
39
|
+
* - `minor` — the newest patch of each minor line.
|
|
40
|
+
* - `patch` — every matching release.
|
|
41
|
+
*
|
|
42
|
+
* @public
|
|
43
|
+
*/
|
|
44
|
+
const Increments = Schema.Literals([
|
|
45
|
+
"latest",
|
|
46
|
+
"minor",
|
|
47
|
+
"patch"
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* What every resolver returns.
|
|
51
|
+
*
|
|
52
|
+
* @public
|
|
53
|
+
*/
|
|
54
|
+
var ResolvedVersions = class extends Schema.Class("ResolvedVersions")({
|
|
55
|
+
/** Whether this answer came from a live feed or the bundled snapshot. */
|
|
56
|
+
source: Source,
|
|
57
|
+
/** Every matching version, newest first. */
|
|
58
|
+
versions: Schema.Array(Schema.String),
|
|
59
|
+
/** The newest matching version. Always present — an empty match is an error, not an empty result. */
|
|
60
|
+
latest: Schema.String,
|
|
61
|
+
/** The newest matching LTS version. Node only, and only when one matches. */
|
|
62
|
+
lts: Schema.optionalKey(Schema.String),
|
|
63
|
+
/** The version the caller asked to treat as the default, resolved. */
|
|
64
|
+
default: Schema.optionalKey(Schema.String)
|
|
65
|
+
}) {};
|
|
66
|
+
/**
|
|
67
|
+
* No release matched the constraint.
|
|
68
|
+
*
|
|
69
|
+
* Distinct from an invalid constraint: `@effected/semver`'s `InvalidRangeError`
|
|
70
|
+
* says the range is malformed, this says the range is fine and nothing matched.
|
|
71
|
+
* v3 collapsed the former into the latter, so a typo in a range surfaced to the
|
|
72
|
+
* user as "no versions found".
|
|
73
|
+
*
|
|
74
|
+
* @public
|
|
75
|
+
*/
|
|
76
|
+
var NoMatchingVersionError = class extends Schema.TaggedErrorClass()("NoMatchingVersionError", {
|
|
77
|
+
/** The runtime that was searched. */
|
|
78
|
+
runtime: Runtime,
|
|
79
|
+
/** The semver range that matched nothing. */
|
|
80
|
+
constraint: Schema.String,
|
|
81
|
+
/** The lifecycle phases the search was restricted to. Node only. */
|
|
82
|
+
phases: Schema.optionalKey(Schema.Array(NodePhase))
|
|
83
|
+
}) {};
|
|
84
|
+
/**
|
|
85
|
+
* An explicit `defaultVersion` was asked for and nothing matched it.
|
|
86
|
+
*
|
|
87
|
+
* Distinct from {@link NoMatchingVersionError}, which says the *main* range
|
|
88
|
+
* matched nothing. Here the main range resolved fine and the caller's separate
|
|
89
|
+
* default range did not.
|
|
90
|
+
*
|
|
91
|
+
* It has to be its own failure rather than a silently dropped field. `default`
|
|
92
|
+
* is `optionalKey`, so an unresolvable default could simply be omitted — and for
|
|
93
|
+
* Node, whose default falls back to the LTS pick, the caller would then be handed
|
|
94
|
+
* the LTS version as though they had asked for it. Naming a default that does not
|
|
95
|
+
* exist is a mistake, and it reaches the caller as one.
|
|
96
|
+
*
|
|
97
|
+
* @public
|
|
98
|
+
*/
|
|
99
|
+
var UnresolvableDefaultError = class extends Schema.TaggedErrorClass()("UnresolvableDefaultError", {
|
|
100
|
+
/** The runtime that was searched. */
|
|
101
|
+
runtime: Runtime,
|
|
102
|
+
/** The range the caller asked to treat as the default. */
|
|
103
|
+
defaultVersion: Schema.String
|
|
104
|
+
}) {};
|
|
105
|
+
/**
|
|
106
|
+
* Fresh data was required and could not be obtained.
|
|
107
|
+
*
|
|
108
|
+
* Raised only by the `layerFresh` strategy, at layer construction: the caller
|
|
109
|
+
* asked for live data and said, by choosing that strategy, that a snapshot is
|
|
110
|
+
* not an acceptable substitute.
|
|
111
|
+
*
|
|
112
|
+
* @public
|
|
113
|
+
*/
|
|
114
|
+
var FreshnessError = class extends Schema.TaggedErrorClass()("FreshnessError", {
|
|
115
|
+
/** The runtime whose feed could not be reached. */
|
|
116
|
+
runtime: Runtime,
|
|
117
|
+
/** The underlying transport or parse failure. */
|
|
118
|
+
cause: Schema.Defect()
|
|
119
|
+
}) {};
|
|
120
|
+
|
|
121
|
+
//#endregion
|
|
122
|
+
export { FreshnessError, Increments, NoMatchingVersionError, ResolvedVersions, Runtime, Source, UnresolvableDefaultError };
|