@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.
@@ -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 };
@@ -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
+ }