@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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { getJson } from "./http.js";
|
|
2
|
+
import { mapHttpFailure } from "../GitHub.js";
|
|
3
|
+
import { NodeRelease } from "../NodeRelease.js";
|
|
4
|
+
import { SemVer } from "@effected/semver";
|
|
5
|
+
import { Effect, Option, Schema } from "effect";
|
|
6
|
+
|
|
7
|
+
//#region src/internal/feeds.ts
|
|
8
|
+
const NODE_DIST_URL = "https://nodejs.org/dist/index.json";
|
|
9
|
+
const NODE_SCHEDULE_URL = "https://raw.githubusercontent.com/nodejs/Release/refs/heads/main/schedule.json";
|
|
10
|
+
/** The subset of nodejs.org's dist index this package reads. */
|
|
11
|
+
const NodeDistIndex = Schema.Array(Schema.Struct({
|
|
12
|
+
version: Schema.String,
|
|
13
|
+
date: Schema.String,
|
|
14
|
+
npm: Schema.optionalKey(Schema.Union([Schema.String, Schema.Boolean]))
|
|
15
|
+
}));
|
|
16
|
+
const NodeScheduleFeed = Schema.Record(Schema.String, Schema.Struct({
|
|
17
|
+
start: Schema.String,
|
|
18
|
+
lts: Schema.optionalKey(Schema.String),
|
|
19
|
+
maintenance: Schema.optionalKey(Schema.String),
|
|
20
|
+
end: Schema.String,
|
|
21
|
+
codename: Schema.optionalKey(Schema.String)
|
|
22
|
+
}));
|
|
23
|
+
const decodeDate = Schema.decodeUnknownEffect(Schema.DateTimeUtcFromString);
|
|
24
|
+
/**
|
|
25
|
+
* Parse a version string, yielding `None` rather than failing.
|
|
26
|
+
*
|
|
27
|
+
* The feeds carry tags this package cannot and need not understand (release
|
|
28
|
+
* candidates, `bun-v1.0.0-canary`, ancient `0.0.0-N` Deno tags). One of them is
|
|
29
|
+
* not a reason to fail the whole load; it is a reason to skip that entry.
|
|
30
|
+
*/
|
|
31
|
+
const tryParseSemVer = (raw) => SemVer.parse(raw).pipe(Effect.map(Option.some), Effect.catchTag("InvalidVersionError", () => Effect.succeed(Option.none())));
|
|
32
|
+
const tryParseDate = (raw) => decodeDate(raw).pipe(Effect.map(Option.some), Effect.catch(() => Effect.succeed(Option.none())));
|
|
33
|
+
/** Strip a leading `v` or `bun-v` from a tag name. */
|
|
34
|
+
const stripTagPrefix = (tag) => {
|
|
35
|
+
const withoutBun = tag.startsWith("bun-") ? tag.slice(4) : tag;
|
|
36
|
+
return withoutBun.startsWith("v") || withoutBun.startsWith("V") ? withoutBun.slice(1) : withoutBun;
|
|
37
|
+
};
|
|
38
|
+
/** Fetch nodejs.org's dist index. Unauthenticated — this is not a GitHub API. */
|
|
39
|
+
const fetchNodeReleases = () => getJson(NODE_DIST_URL, NodeDistIndex).pipe(Effect.mapError((failure) => mapHttpFailure(failure, "anonymous")), Effect.map((entries) => entries.map((entry) => ({
|
|
40
|
+
version: entry.version.replace(/^v/, ""),
|
|
41
|
+
npm: typeof entry.npm === "string" ? entry.npm : "0.0.0",
|
|
42
|
+
date: entry.date
|
|
43
|
+
}))));
|
|
44
|
+
/** Fetch the `nodejs/Release` schedule. Also unauthenticated: raw.githubusercontent.com. */
|
|
45
|
+
const fetchNodeSchedule = () => getJson(NODE_SCHEDULE_URL, NodeScheduleFeed).pipe(Effect.mapError((failure) => mapHttpFailure(failure, "anonymous")));
|
|
46
|
+
/** Build Node releases, skipping entries this package cannot parse. */
|
|
47
|
+
const buildNodeReleases = (raw) => Effect.forEach(raw, (entry) => Effect.gen(function* () {
|
|
48
|
+
const version = yield* tryParseSemVer(entry.version);
|
|
49
|
+
const npm = yield* tryParseSemVer(entry.npm);
|
|
50
|
+
const date = yield* tryParseDate(entry.date);
|
|
51
|
+
if (Option.isNone(version) || Option.isNone(npm) || Option.isNone(date)) return Option.none();
|
|
52
|
+
return Option.some(NodeRelease.make({
|
|
53
|
+
version: version.value,
|
|
54
|
+
npm: npm.value,
|
|
55
|
+
date: date.value
|
|
56
|
+
}));
|
|
57
|
+
})).pipe(Effect.map((results) => results.flatMap(Option.toArray)));
|
|
58
|
+
/**
|
|
59
|
+
* Fetch a runtime's releases from GitHub, dropping drafts and prereleases.
|
|
60
|
+
*
|
|
61
|
+
* Bun and Deno differ by a repository name and a tag prefix. v3 had two fetcher
|
|
62
|
+
* services, two service files and two `*Live` layers for that difference.
|
|
63
|
+
*/
|
|
64
|
+
const fetchGitHubReleases = (client, owner, repo) => client.listReleases(owner, repo).pipe(Effect.map((releases) => releases.filter((release) => !release.draft && !release.prerelease).flatMap((release) => {
|
|
65
|
+
const date = release.published_at;
|
|
66
|
+
if (date === null) return [];
|
|
67
|
+
return [{
|
|
68
|
+
version: stripTagPrefix(release.tag_name),
|
|
69
|
+
date
|
|
70
|
+
}];
|
|
71
|
+
})));
|
|
72
|
+
/** Build a release list of `{ version, date }` records, skipping unparseable entries. */
|
|
73
|
+
const buildReleases = (raw, make) => Effect.forEach(raw, (entry) => Effect.gen(function* () {
|
|
74
|
+
const version = yield* tryParseSemVer(entry.version);
|
|
75
|
+
const date = yield* tryParseDate(entry.date);
|
|
76
|
+
if (Option.isNone(version) || Option.isNone(date)) return Option.none();
|
|
77
|
+
return Option.some(make({
|
|
78
|
+
version: version.value,
|
|
79
|
+
date: date.value
|
|
80
|
+
}));
|
|
81
|
+
})).pipe(Effect.map((results) => results.flatMap(Option.toArray)));
|
|
82
|
+
|
|
83
|
+
//#endregion
|
|
84
|
+
export { buildNodeReleases, buildReleases, fetchGitHubReleases, fetchNodeReleases, fetchNodeSchedule, stripTagPrefix, tryParseSemVer };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { GitHubClient } from "../GitHub.js";
|
|
2
|
+
import { buildReleases, fetchGitHubReleases } from "./feeds.js";
|
|
3
|
+
import { make } from "./releaseIndex.js";
|
|
4
|
+
import { resolveWith } from "./resolve.js";
|
|
5
|
+
import { Effect, Layer, Option } from "effect";
|
|
6
|
+
|
|
7
|
+
//#region src/internal/githubRuntime.ts
|
|
8
|
+
/**
|
|
9
|
+
* Build a layer for a GitHub-hosted runtime around a strategy.
|
|
10
|
+
*
|
|
11
|
+
* The strategy's channels flow out into the layer's, so `layerOffline` requires
|
|
12
|
+
* no `GitHubClient` and `layer` cannot fail — stated by the types, not asserted
|
|
13
|
+
* by a cast.
|
|
14
|
+
*/
|
|
15
|
+
const build = (spec, strategy) => {
|
|
16
|
+
const { tag, runtime, owner, repo, spanName, defaults, make: make$1 } = spec;
|
|
17
|
+
return Layer.effect(tag, Effect.gen(function* () {
|
|
18
|
+
const index = yield* make();
|
|
19
|
+
yield* strategy(index, Effect.gen(function* () {
|
|
20
|
+
return yield* buildReleases(yield* fetchGitHubReleases(yield* GitHubClient, owner, repo), make$1);
|
|
21
|
+
}), buildReleases(defaults, make$1));
|
|
22
|
+
return { resolve: Effect.fn(spanName)(function* (options) {
|
|
23
|
+
const range = options?.range ?? "*";
|
|
24
|
+
yield* Effect.annotateCurrentSpan({
|
|
25
|
+
runtime,
|
|
26
|
+
range
|
|
27
|
+
});
|
|
28
|
+
return yield* resolveWith({
|
|
29
|
+
index,
|
|
30
|
+
runtime,
|
|
31
|
+
constraint: range,
|
|
32
|
+
increments: options?.increments ?? "latest",
|
|
33
|
+
defaultVersion: options?.defaultVersion,
|
|
34
|
+
defaultsToLts: false,
|
|
35
|
+
refine: (releases) => releases,
|
|
36
|
+
pickLts: () => Option.none()
|
|
37
|
+
});
|
|
38
|
+
}) };
|
|
39
|
+
}));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
//#endregion
|
|
43
|
+
export { build };
|
package/internal/http.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Duration, Effect, Schedule } from "effect";
|
|
2
|
+
import { HttpClient } from "effect/unstable/http";
|
|
3
|
+
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
|
|
4
|
+
|
|
5
|
+
//#region src/internal/http.ts
|
|
6
|
+
const parseHeaderInt = (raw) => {
|
|
7
|
+
if (raw === void 0) return void 0;
|
|
8
|
+
const n = Number.parseInt(raw, 10);
|
|
9
|
+
return Number.isInteger(n) ? n : void 0;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* A `retry-after` is a number a remote server chose, so it is bounded before it
|
|
13
|
+
* becomes a sleep. Without this a misconfigured — or hostile — server parks the
|
|
14
|
+
* caller's fiber for as long as it likes by sending one large header. A negative
|
|
15
|
+
* value is discarded outright.
|
|
16
|
+
*/
|
|
17
|
+
const MAX_RETRY_AFTER_SECONDS = 60;
|
|
18
|
+
const parseRetryAfter = (raw) => {
|
|
19
|
+
const seconds = parseHeaderInt(raw);
|
|
20
|
+
return seconds === void 0 || seconds < 0 ? void 0 : seconds;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Map an HTTP status onto the failure ladder.
|
|
24
|
+
*
|
|
25
|
+
* 401 is an authentication problem and 429 is definitionally a rate limit. 403
|
|
26
|
+
* is the one that has to be classified rather than assumed: GitHub returns it
|
|
27
|
+
* for an exhausted rate limit, but *also* for permission and resource failures
|
|
28
|
+
* ("Resource not accessible by personal access token"), which no amount of
|
|
29
|
+
* backoff will fix. Treating every 403 as a rate limit retries those three times
|
|
30
|
+
* and then reports a `RateLimitError` naming a quota that was never the problem.
|
|
31
|
+
*
|
|
32
|
+
* The two signals GitHub documents for a rate limit are the primary limit's
|
|
33
|
+
* `x-ratelimit-remaining: 0` and the secondary limit's `retry-after`. A 403 with
|
|
34
|
+
* neither is a permission failure, and it leaves as a `NetworkError` carrying the
|
|
35
|
+
* status rather than being retried.
|
|
36
|
+
*/
|
|
37
|
+
const failureForStatus = (url, status, headers) => {
|
|
38
|
+
if (status === 401) return { _kind: "auth" };
|
|
39
|
+
if (status === 403 || status === 429) {
|
|
40
|
+
const remaining = parseHeaderInt(headers["x-ratelimit-remaining"]);
|
|
41
|
+
const retryAfter = parseRetryAfter(headers["retry-after"]);
|
|
42
|
+
if (status === 429 || remaining === 0 || retryAfter !== void 0) return {
|
|
43
|
+
_kind: "rateLimit",
|
|
44
|
+
...retryAfter !== void 0 ? { retryAfter } : {},
|
|
45
|
+
limit: parseHeaderInt(headers["x-ratelimit-limit"]) ?? 0,
|
|
46
|
+
remaining: remaining ?? 0
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
_kind: "network",
|
|
51
|
+
url,
|
|
52
|
+
status,
|
|
53
|
+
cause: `HTTP ${status}`
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* GET a URL and decode its JSON body with `schema`.
|
|
58
|
+
*
|
|
59
|
+
* Every failure mode — transport, status, malformed body — leaves through the
|
|
60
|
+
* typed channel as an {@link HttpFailure}. Nothing throws.
|
|
61
|
+
*/
|
|
62
|
+
const getJson = (url, schema, headers = {}) => Effect.gen(function* () {
|
|
63
|
+
const response = yield* (yield* HttpClient.HttpClient).get(url, { headers }).pipe(Effect.mapError((cause) => ({
|
|
64
|
+
_kind: "network",
|
|
65
|
+
url,
|
|
66
|
+
cause
|
|
67
|
+
})));
|
|
68
|
+
if (response.status < 200 || response.status >= 300) return yield* Effect.fail(failureForStatus(url, response.status, response.headers));
|
|
69
|
+
return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe(Effect.mapError((cause) => ({
|
|
70
|
+
_kind: "parse",
|
|
71
|
+
source: url,
|
|
72
|
+
cause
|
|
73
|
+
})));
|
|
74
|
+
});
|
|
75
|
+
/**
|
|
76
|
+
* Exponential backoff, overridden by the server's own `retry-after` when it sent
|
|
77
|
+
* one.
|
|
78
|
+
*
|
|
79
|
+
* GitHub tells you how long to wait for a secondary rate limit, and guessing
|
|
80
|
+
* `1s, 2s, 4s` against that is both ruder and less effective than doing as it
|
|
81
|
+
* asked. `Schedule.passthrough` re-types the schedule's output as its *input* —
|
|
82
|
+
* the failure — which is what lets `modifyDelay` see the `retryAfter` that the
|
|
83
|
+
* failure is carrying and replace the computed delay with it.
|
|
84
|
+
*/
|
|
85
|
+
const rateLimitBackoff = Schedule.exponential("1 second").pipe(Schedule.setInputType(), Schedule.passthrough, Schedule.modifyDelay(({ duration, output: failure }) => Effect.succeed(failure._kind === "rateLimit" && failure.retryAfter !== void 0 ? Duration.seconds(Math.min(failure.retryAfter, MAX_RETRY_AFTER_SECONDS)) : duration)));
|
|
86
|
+
/**
|
|
87
|
+
* Retry a rate-limited effect, honoring the server's backoff.
|
|
88
|
+
*
|
|
89
|
+
* Only `rateLimit` failures are retried — an auth failure, a permission failure
|
|
90
|
+
* or a malformed body will not fix itself, and retrying them just burns the
|
|
91
|
+
* caller's quota.
|
|
92
|
+
*/
|
|
93
|
+
const retryOnRateLimit = (effect) => effect.pipe(Effect.retry({
|
|
94
|
+
schedule: rateLimitBackoff,
|
|
95
|
+
times: 3,
|
|
96
|
+
while: (failure) => failure._kind === "rateLimit"
|
|
97
|
+
}));
|
|
98
|
+
/**
|
|
99
|
+
* Validate a caller-supplied numeric bound.
|
|
100
|
+
*
|
|
101
|
+
* `if (n < 1)` is the obvious spelling and it is wrong twice over: every
|
|
102
|
+
* relational comparison against `NaN` is `false`, so `NaN` sails through, and
|
|
103
|
+
* so does `2.5`. Both are developer wiring errors rather than data conditions,
|
|
104
|
+
* so they die rather than entering the typed channel.
|
|
105
|
+
*/
|
|
106
|
+
const requirePositiveInteger = (name, value) => Number.isInteger(value) && value >= 1 ? Effect.succeed(value) : Effect.die(/* @__PURE__ */ new Error(`${name} must be a positive integer, received ${value}`));
|
|
107
|
+
/**
|
|
108
|
+
* Walk a paginated listing, decoding each page and stopping at a short page.
|
|
109
|
+
*
|
|
110
|
+
* Pagination is bounded in two places: the caller's `pages` (validated) and a
|
|
111
|
+
* hard {@link PAGE_CEILING}, so a remote server cannot drive an unbounded loop.
|
|
112
|
+
*/
|
|
113
|
+
const paginate = (url, schema, headers, options = {}) => Effect.gen(function* () {
|
|
114
|
+
const perPage = yield* requirePositiveInteger("perPage", options.perPage ?? 100);
|
|
115
|
+
const requested = yield* requirePositiveInteger("pages", options.pages ?? 5);
|
|
116
|
+
const maxPages = Math.min(requested, 100);
|
|
117
|
+
const all = [];
|
|
118
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
119
|
+
const items = yield* retryOnRateLimit(getJson(url(page, perPage), schema, headers));
|
|
120
|
+
all.push(...items);
|
|
121
|
+
if (items.length < perPage) break;
|
|
122
|
+
}
|
|
123
|
+
return all;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
//#endregion
|
|
127
|
+
export { getJson, paginate, retryOnRateLimit };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Effect, Option, Ref } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/releaseIndex.ts
|
|
4
|
+
/** Newest first. */
|
|
5
|
+
const byVersionDescending = (releases) => [...releases].sort((a, b) => b.version.compare(a.version));
|
|
6
|
+
/**
|
|
7
|
+
* Build an empty index.
|
|
8
|
+
*
|
|
9
|
+
* The index starts empty and `"cache"`: an index nobody loaded has certainly
|
|
10
|
+
* not been served from an API.
|
|
11
|
+
*/
|
|
12
|
+
const make = () => Effect.gen(function* () {
|
|
13
|
+
const state = yield* Ref.make({
|
|
14
|
+
releases: [],
|
|
15
|
+
source: "cache"
|
|
16
|
+
});
|
|
17
|
+
const filter = (range) => Ref.get(state).pipe(Effect.map(({ releases }) => releases.filter((r) => range.test(r.version))));
|
|
18
|
+
return {
|
|
19
|
+
load: (releases, source) => Ref.set(state, {
|
|
20
|
+
releases: byVersionDescending(releases),
|
|
21
|
+
source
|
|
22
|
+
}),
|
|
23
|
+
releases: Ref.get(state).pipe(Effect.map(({ releases }) => releases)),
|
|
24
|
+
source: Ref.get(state).pipe(Effect.map(({ source }) => source)),
|
|
25
|
+
filter,
|
|
26
|
+
resolve: (range) => filter(range).pipe(Effect.map((matches) => Option.fromUndefinedOr(matches[0])))
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
/**
|
|
30
|
+
* Collapse releases to one per major, one per minor, or leave every patch.
|
|
31
|
+
*
|
|
32
|
+
* Input is newest-first, so the first release seen for a group key is that
|
|
33
|
+
* group's winner and later ones are older — no comparison needed.
|
|
34
|
+
*/
|
|
35
|
+
const groupByIncrements = (releases, increments) => {
|
|
36
|
+
if (increments === "patch") return releases;
|
|
37
|
+
const seen = /* @__PURE__ */ new Map();
|
|
38
|
+
for (const release of releases) {
|
|
39
|
+
const key = increments === "latest" ? String(release.version.major) : `${release.version.major}.${release.version.minor}`;
|
|
40
|
+
if (!seen.has(key)) seen.set(key, release);
|
|
41
|
+
}
|
|
42
|
+
return [...seen.values()];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
//#endregion
|
|
46
|
+
export { groupByIncrements, make };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { groupByIncrements } from "./releaseIndex.js";
|
|
2
|
+
import { NoMatchingVersionError, ResolvedVersions, UnresolvableDefaultError } from "../ResolvedVersions.js";
|
|
3
|
+
import { Range } from "@effected/semver";
|
|
4
|
+
import { Effect, Option } from "effect";
|
|
5
|
+
|
|
6
|
+
//#region src/internal/resolve.ts
|
|
7
|
+
/**
|
|
8
|
+
* Filter, group, rank and package a resolution.
|
|
9
|
+
*
|
|
10
|
+
* `refine` is where Node's phase filter plugs in; Bun and Deno pass everything
|
|
11
|
+
* through. `pickLts` likewise: only Node has an LTS notion.
|
|
12
|
+
*/
|
|
13
|
+
const resolveWith = (args) => Effect.gen(function* () {
|
|
14
|
+
const { index, runtime, constraint, increments, defaultVersion, refine, pickLts, defaultsToLts, phases } = args;
|
|
15
|
+
const range = yield* Range.parse(constraint);
|
|
16
|
+
const grouped = groupByIncrements(refine(yield* index.filter(range)), increments);
|
|
17
|
+
if (grouped.length === 0) return yield* new NoMatchingVersionError({
|
|
18
|
+
runtime,
|
|
19
|
+
constraint,
|
|
20
|
+
...phases !== void 0 ? { phases } : {}
|
|
21
|
+
});
|
|
22
|
+
const versions = grouped.map((release) => release.version.toString());
|
|
23
|
+
const lts = pickLts(grouped).pipe(Option.map((release) => release.version.toString()));
|
|
24
|
+
const chosenDefault = yield* Option.match(Option.fromUndefinedOr(defaultVersion), {
|
|
25
|
+
onNone: () => Effect.succeed(defaultsToLts ? lts : Option.none()),
|
|
26
|
+
onSome: (raw) => Range.parse(raw).pipe(Effect.flatMap((defaultRange) => index.resolve(defaultRange)), Effect.flatMap(Option.match({
|
|
27
|
+
onNone: () => Effect.fail(new UnresolvableDefaultError({
|
|
28
|
+
runtime,
|
|
29
|
+
defaultVersion: raw
|
|
30
|
+
})),
|
|
31
|
+
onSome: (release) => Effect.succeed(Option.some(release.version.toString()))
|
|
32
|
+
})))
|
|
33
|
+
});
|
|
34
|
+
return ResolvedVersions.make({
|
|
35
|
+
source: yield* index.source,
|
|
36
|
+
versions,
|
|
37
|
+
latest: versions[0],
|
|
38
|
+
...Option.isSome(lts) ? { lts: lts.value } : {},
|
|
39
|
+
...Option.isSome(chosenDefault) ? { default: chosenDefault.value } : {}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
//#endregion
|
|
44
|
+
export { resolveWith };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { FreshnessError } from "../ResolvedVersions.js";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/internal/strategy.ts
|
|
5
|
+
/**
|
|
6
|
+
* The three cache strategies, parameterized once.
|
|
7
|
+
*
|
|
8
|
+
* v3 shipped nine layer files for this: {auto, fresh, offline} × {node, bun,
|
|
9
|
+
* deno}, where the Bun and Deno "fresh" layers differed by a repository name.
|
|
10
|
+
* The strategy is a property of *how the index is populated*, not of which
|
|
11
|
+
* runtime it holds, so it is one function per strategy taking a loader.
|
|
12
|
+
*
|
|
13
|
+
* Each is typed exactly, which is the point: `offline` requires nothing and
|
|
14
|
+
* cannot fail, `auto` requires the feed but still cannot fail, and only `fresh`
|
|
15
|
+
* carries a `FreshnessError`. A single function switching on a kind would union
|
|
16
|
+
* all three channels together and force every layer to advertise failures it
|
|
17
|
+
* cannot have.
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Load the bundled snapshot. No IO, no failure, no requirements.
|
|
23
|
+
*/
|
|
24
|
+
const populateOffline = (index, offline) => offline.pipe(Effect.flatMap((releases) => index.load(releases, "cache")));
|
|
25
|
+
/**
|
|
26
|
+
* Live data or a typed failure.
|
|
27
|
+
*
|
|
28
|
+
* The caller chose this strategy to say that a snapshot is not an acceptable
|
|
29
|
+
* substitute, so the feed's failure becomes theirs.
|
|
30
|
+
*/
|
|
31
|
+
const populateFresh = (index, runtime, live) => live.pipe(Effect.mapError((cause) => new FreshnessError({
|
|
32
|
+
runtime,
|
|
33
|
+
cause
|
|
34
|
+
})), Effect.flatMap((releases) => index.load(releases, "api")));
|
|
35
|
+
/**
|
|
36
|
+
* Try the live feed; fall back to the bundled snapshot.
|
|
37
|
+
*
|
|
38
|
+
* The fallback is **visible**: provenance becomes `"cache"` and a warning is
|
|
39
|
+
* logged. v3 fell back silently and then reported the result as `source: "api"`,
|
|
40
|
+
* so a caller had no way to tell a live answer from a stale one served after a
|
|
41
|
+
* network failure.
|
|
42
|
+
*/
|
|
43
|
+
const populateAuto = (index, runtime, live, offline) => live.pipe(Effect.flatMap((releases) => index.load(releases, "api")), Effect.catch((cause) => Effect.gen(function* () {
|
|
44
|
+
yield* Effect.logWarning("Live release feed unavailable; falling back to the bundled snapshot", {
|
|
45
|
+
runtime,
|
|
46
|
+
cause
|
|
47
|
+
});
|
|
48
|
+
const releases = yield* offline;
|
|
49
|
+
yield* index.load(releases, "cache");
|
|
50
|
+
})));
|
|
51
|
+
|
|
52
|
+
//#endregion
|
|
53
|
+
export { populateAuto, populateFresh, populateOffline };
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@effected/runtimes",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Resolve semver-compatible Node.js, Bun and Deno runtime versions with offline fallback.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"node",
|
|
8
|
+
"bun",
|
|
9
|
+
"deno",
|
|
10
|
+
"runtime",
|
|
11
|
+
"version",
|
|
12
|
+
"semver",
|
|
13
|
+
"resolver",
|
|
14
|
+
"effect",
|
|
15
|
+
"effected"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/spencerbeggs/effected/tree/main/packages/runtimes#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/spencerbeggs/effected/issues"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/spencerbeggs/effected.git",
|
|
24
|
+
"directory": "packages/runtimes"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": {
|
|
28
|
+
"name": "C. Spencer Beggs",
|
|
29
|
+
"email": "spencer@beggs.codes",
|
|
30
|
+
"url": "https://spencerbeg.gs"
|
|
31
|
+
},
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"type": "module",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./index.d.ts",
|
|
37
|
+
"import": "./index.js"
|
|
38
|
+
},
|
|
39
|
+
"./package.json": "./package.json"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@effected/semver": "0.1.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"effect": "4.0.0-beta.98"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=24.11.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// This file is read by tools that parse documentation comments conforming to the TSDoc standard.
|
|
2
|
+
// It should be published with your NPM package. It should not be tracked by Git.
|
|
3
|
+
{
|
|
4
|
+
"tsdocVersion": "0.12",
|
|
5
|
+
"toolPackages": [
|
|
6
|
+
{
|
|
7
|
+
"packageName": "@microsoft/api-extractor",
|
|
8
|
+
"packageVersion": "7.58.9"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|