Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
40c1de5
fix(lib): bound the memory cache, follow fetch redirect rules, lint c…
Makisuo Nov 10, 2026
0982634
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
edc519d
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
2ccde94
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
d5300ac
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
2360938
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
118b333
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
ef81259
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
420e8d5
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
35f3e05
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
4ca46a7
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
d048638
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
ecb2138
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
9c6c221
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
45552d2
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
faa86f1
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
177e341
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
b394a3d
Merge branch 'chore/audit-selfhost-ci' into fix/audit-libraries
Makisuo Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions lib/cache/src/cache-backend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { assert, describe, it } from "@effect/vitest"
import { Array as Arr, Effect } from "effect"
import { makeMemoryBackend } from "./cache-backend"

const HOUR_MS = 3_600_000

describe("makeMemoryBackend", () => {
it.effect("drops expired entries whose keys are never read again", () =>
Effect.gen(function* () {
const backend = makeMemoryBackend()
yield* backend.put("pinned", "live", "kept", 100_000 * 3600, 0)
// One key per hour with a one-hour TTL: each is expired before the next is written.
yield* Effect.forEach(Arr.range(1, 5000), (hour) =>
backend.put("baseline", `org_1:${hour}`, hour, 3600, hour * HOUR_MS * 2),
)

assert.isBelow(backend.size(), 1000)
assert.strictEqual(yield* backend.get("pinned", "live", 5000 * HOUR_MS * 2), "kept")
}),
)

it.effect("keeps every entry that has not expired", () =>
Effect.gen(function* () {
const backend = makeMemoryBackend()
const keys = Arr.range(1, 2000)
yield* Effect.forEach(keys, (n) => backend.put("bucket", `k${n}`, n, 3600, n))
const values = yield* Effect.forEach(keys, (n) => backend.get("bucket", `k${n}`, 5000))

assert.deepStrictEqual(values, keys)
}),
)

it.effect("does not store an entry whose TTL is not a finite number", () =>
Effect.gen(function* () {
const backend = makeMemoryBackend()
yield* backend.put("bucket", "key", "old", 60, 0)
yield* backend.put("bucket", "key", "nan", Number.NaN, 0)
yield* backend.put("bucket", "other", "forever", Number.POSITIVE_INFINITY, 0)

assert.isUndefined(yield* backend.get("bucket", "key", 1))
assert.isUndefined(yield* backend.get("bucket", "other", 1))
assert.strictEqual(backend.size(), 0)
}),
)
})
40 changes: 29 additions & 11 deletions lib/cache/src/cache-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,6 @@ export interface EdgeCacheBackend {
/**
* Injected edge-cache storage backend (`caches.default` in prod, in-memory in
* tests/dev).
*
* The tag string still names the old home. Tags are identity, not
* documentation — `EdgeCacheIOError` next door is a `Schema.TaggedError`
* whose tag is its serialized `_tag`, so renaming this family for tidiness
* would be a wire-contract change for no behavioural gain.
*/
export class CacheBackend extends Context.Service<CacheBackend, EdgeCacheBackend>()(
"@maple/cache/CacheBackend",
Expand All @@ -60,13 +55,32 @@ interface MemoryEntry {
readonly expiresAt: number
}

/** A pure in-process `EdgeCacheBackend` — used for tests, dev, and non-Workers hosts. */
export const makeMemoryBackend = (): EdgeCacheBackend => {
/** The in-memory backend, plus how many entries it currently holds. */
export interface MemoryCacheBackend extends EdgeCacheBackend {
readonly size: () => number
}

const MEMORY_SWEEP_MIN_ENTRIES = 256

/** A pure in-process `EdgeCacheBackend`, used for tests, dev, and non-Workers hosts. */
export const makeMemoryBackend = (): MemoryCacheBackend => {
const store = new Map<string, MemoryEntry>()
const composite = (bucket: string, hash: string) => `${bucket}:${hash}`
let sweepAt = MEMORY_SWEEP_MIN_ENTRIES

// Keys with a rotating part (`orgId:hour`) are never read again, so reads alone
// would never evict them. Sweeping only once the map has doubled keeps `put` amortized O(1).
const sweepExpired = (nowMs: number) => {
if (store.size < sweepAt) return
store.forEach((entry, key) => {
if (entry.expiresAt <= nowMs) store.delete(key)
})
sweepAt = Math.max(MEMORY_SWEEP_MIN_ENTRIES, store.size * 2)
}

return {
name: "memory",
size: () => store.size,
get: (bucket, hash, nowMs) =>
Effect.sync(() => {
const entry = store.get(composite(bucket, hash))
Expand All @@ -79,10 +93,14 @@ export const makeMemoryBackend = (): EdgeCacheBackend => {
}),
put: (bucket, hash, value, ttlSeconds, nowMs) =>
Effect.sync(() => {
store.set(composite(bucket, hash), {
value,
expiresAt: nowMs + ttlSeconds * 1000,
})
const expiresAt = nowMs + ttlSeconds * 1000
// A non-finite expiry would never compare as expired, so it is not stored.
if (!Number.isFinite(expiresAt)) {
store.delete(composite(bucket, hash))
return
}
store.set(composite(bucket, hash), { value, expiresAt })
sweepExpired(nowMs)
}),
delete: (bucket, hash) =>
Effect.sync(() => {
Expand Down
2 changes: 1 addition & 1 deletion lib/cache/src/edge-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,7 @@ export class EdgeCacheService extends Context.Service<EdgeCacheService, EdgeCach
"@maple/cache/EdgeCacheService",
) {
/**
* Backed by the injected `CacheBackend` (Workers KV in prod, in-memory in
* Backed by the injected `CacheBackend` (the Workers Cache API in prod, in-memory in
* tests/dev — supplied by the host app). The runtime binding never enters
* this package, keeping `globalThis.caches` out of the web/cli bundles.
*/
Expand Down
13 changes: 8 additions & 5 deletions lib/oxlint-config/plugins/test-slop.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,17 @@ const TEST_MODIFIERS = new Set([
"prop",
])

/** `it(...)`, `it.effect(...)`, `it.each(rows)(...)`, `test.skip(...)`. */
/** `it(...)`, `it.effect(...)`, `it.each(rows)(...)`, `it.effect.skip(...)`, `it.live.each(rows)(...)`. */
const isTestCall = (node) => {
let callee = node.callee
if (callee.type === "CallExpression") callee = callee.callee
if (callee.type === "Identifier") return TEST_CALLEES.has(callee.name)
if (callee.type !== "MemberExpression" || callee.computed) return false
if (callee.object.type !== "Identifier" || !TEST_CALLEES.has(callee.object.name)) return false
return TEST_MODIFIERS.has(callee.property.name) || callee.property.name === "each"
// Modifiers chain (`it.effect.skip`), so walk every link down to the `it` or `test` root.
while (callee.type === "MemberExpression") {
if (callee.computed) return false
if (!TEST_MODIFIERS.has(callee.property.name) && callee.property.name !== "each") return false
callee = callee.object
}
return callee.type === "Identifier" && TEST_CALLEES.has(callee.name)
}

const testCallback = (node) => {
Expand Down
9 changes: 9 additions & 0 deletions lib/oxlint-config/scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ export const View = () => { useEffect(() => {}, []); return null }
"src/math.test.ts": `import { expect, it } from "vitest"
it("adds", () => { expect(1).toBeDefined() })
it("nothing", () => { const x = 1 + 1; void x })
`,
"src/chained-skip.test.ts": `import { expect, it } from "vitest"
it.concurrent.skip("exists", () => { expect(1).toBeDefined() })
`,
"src/chained-each.test.ts": `import { expect, it } from "vitest"
it.skip.each([1, 2])("exists %i", (n) => { expect(n).toBeDefined() })
`,
}

Expand All @@ -94,6 +100,9 @@ const expected = {
"src/boundary-file.ts": [],
"src/view.tsx": ["effect-strict(no-react-use-effect)"],
"src/math.test.ts": ["test-slop(no-weak-only-assertions)", "vitest(expect-expect)"],
// Chained modifiers are still tests: `it.concurrent.skip(...)`, `it.skip.each(rows)(...)`.
"src/chained-skip.test.ts": ["test-slop(no-weak-only-assertions)"],
"src/chained-each.test.ts": ["test-slop(no-weak-only-assertions)"],
"src/clean.ts": [],
}

Expand Down
2 changes: 1 addition & 1 deletion lib/prometheus-otlp/src/otlp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* - histogram `count`/`bucketCounts` are JSON numbers, not the spec's strings
* - attribute values as `{ "stringValue": "…" }`
*
* `__fixtures__/otlp-export.json` pins that shape; consumers assert against it.
* `apps/scraper/src/__fixtures__/otlp-export.json` pins that shape; consumers assert against it.
*/
import type { PromMetricFamily, PromSample } from "./parser"

Expand Down
77 changes: 77 additions & 0 deletions lib/safe-fetch/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,83 @@ describe("guard with strictRedirects", () => {
)
})

/** What the hop after a same-origin redirect looked like on the wire. */
const hopAfterRedirect = (
status: number,
send: (
client: HttpClient.HttpClient.With<HttpClientError.HttpClientError | UrlValidationError>,
url: string,
options: { readonly headers: Record<string, string>; readonly body: HttpBody.HttpBody },
) => Effect.Effect<unknown, HttpClientError.HttpClientError | UrlValidationError>,
) =>
Effect.gen(function* () {
const seen: Array<ReadonlyArray<string | null | undefined>> = []
const fakeFetch: typeof fetch = async (_input, init) => {
const headers = new Headers(init?.headers)
seen.push([
init?.method,
bodyOf(init?.body),
headers.get("content-type"),
headers.get("content-encoding"),
headers.get("content-language"),
headers.get("x-api-key"),
])
return seen.length === 1
? new Response(null, { status, headers: { location: "/next" } })
: new Response("ok", { status: 200 })
}
yield* run(fakeFetch, (client) =>
send(client, "https://api.example.com/x", {
headers: { "Content-Encoding": "identity", "Content-Language": "en", "X-Api-Key": "k" },
body: HttpBody.text("payload", "application/json"),
}),
)
return seen[1]
})

describe("guard redirects follow the fetch standard", () => {
it.effect("a POST continues as a body-less GET after a 301, 302 or 303", () =>
Effect.gen(function* () {
const hops = yield* Effect.forEach([301, 302, 303], (status) =>
hopAfterRedirect(status, (client, url, options) => client.post(url, options)),
)
const bodylessGet = ["GET", "", null, null, null, "k"]
assert.deepStrictEqual(hops, [bodylessGet, bodylessGet, bodylessGet])
}),
)

it.effect("a PUT keeps its body on a 302 and becomes a GET only on a 303", () =>
Effect.gen(function* () {
const hops = yield* Effect.forEach([302, 303], (status) =>
hopAfterRedirect(status, (client, url, options) => client.put(url, options)),
)
assert.deepStrictEqual(hops, [
["PUT", "payload", "application/json", "identity", "en", "k"],
["GET", "", null, null, null, "k"],
])
}),
)

it.effect("a HEAD stays a HEAD after a 303", () =>
Effect.gen(function* () {
const hop = yield* hopAfterRedirect(303, (client, url, options) =>
client.head(url, { headers: options.headers }),
)
assert.strictEqual(hop?.[0], "HEAD")
}),
)

it.effect("a POST is replayed with its body after a 307 or 308", () =>
Effect.gen(function* () {
const hops = yield* Effect.forEach([307, 308], (status) =>
hopAfterRedirect(status, (client, url, options) => client.post(url, options)),
)
const replayed = ["POST", "payload", "application/json", "identity", "en", "k"]
assert.deepStrictEqual(hops, [replayed, replayed])
}),
)
})

describe("describeHttpClientError", () => {
it.effect("redacts the request URL from a transport failure's message", () =>
Effect.gen(function* () {
Expand Down
27 changes: 24 additions & 3 deletions lib/safe-fetch/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,8 @@ export interface GuardOptions {
/**
* For requests whose headers all come from user config: a cross-origin hop drops every
* header, 301/302/303 continue as a body-less GET, and 307/308 keep method and body only
* within one origin. Without it, only credential headers are dropped cross-origin.
* within one origin. Without it, only credential headers are dropped cross-origin and
* method and body follow the fetch standard (see `fetchRedirectRequest`).
*/
readonly strictRedirects?: boolean
/** With `strictRedirects`: headers the caller sets itself (not user config), kept across origins. */
Expand All @@ -247,17 +248,37 @@ export interface GuardOptions {

const SEE_OTHER_STATUSES: ReadonlySet<number> = new Set([301, 302, 303])

/** Headers describing a body. `setBody` already clears `content-type` and `content-length`. */
const BODY_HEADERS = ["content-encoding", "content-language", "content-location"] as const

const withoutBody = (request: HttpClientRequest.HttpClientRequest) =>
Arr.reduce(BODY_HEADERS, HttpClientRequest.setBody(request, HttpBody.empty), (next, name) =>
HttpClientRequest.removeHeader(next, name),
)

/** The request a strict redirect sends: HEAD stays HEAD, anything else replays as a plain GET. */
const strictRedirectRequest = (
request: HttpClientRequest.HttpClientRequest,
status: number,
crossOrigin: boolean,
) => {
if (!SEE_OTHER_STATUSES.has(status) && !crossOrigin) return request
const bodyless = HttpClientRequest.setBody(request, HttpBody.empty)
const bodyless = withoutBody(request)
return request.method === "HEAD" ? bodyless : HttpClientRequest.setMethod(bodyless, "GET")
}

/**
* The fetch standard's rewrite: a 303 turns anything but GET or HEAD into a GET, a 301 or 302
* turns a POST into a GET, both without the body. 307 and 308 replay the request as it was.
*/
const fetchRedirectRequest = (request: HttpClientRequest.HttpClientRequest, status: number) => {
const becomesGet =
status === 303
? request.method !== "GET" && request.method !== "HEAD"
: (status === 301 || status === 302) && request.method === "POST"
return becomesGet ? HttpClientRequest.setMethod(withoutBody(request), "GET") : request
}

/** The full URL a request targets, query params and hash included. */
const requestUrl = (request: HttpClientRequest.HttpClientRequest) =>
Effect.fromResult(Url.make(request.url, request.urlParams, Option.getOrUndefined(request.hash))).pipe(
Expand Down Expand Up @@ -388,7 +409,7 @@ const guardWith = <E, R, E2>(
const next = HttpClientRequest.setUrl(
strict
? strictRedirectRequest(outgoing, response.status, target.origin !== validated.origin)
: outgoing,
: fetchRedirectRequest(outgoing, response.status),
target,
)
return yield* hop(next, redirects + 1, validated.origin)
Expand Down
28 changes: 27 additions & 1 deletion lib/strings/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { describe, expect, it } from "vitest"
import { pipe, Array as Arr, String as Str } from "effect"
import { codePointAt, codeUnitAt, indexOfFrom, matchAllIndexed, replaceWith } from "./index"
import {
codePointAt,
codeUnitAt,
escapeHtml,
indexOfFrom,
matchAllIndexed,
replaceWith,
truncate,
} from "./index"

describe("replaceWith", () => {
it("passes capture groups and replaces every match of a global regex", () => {
Expand Down Expand Up @@ -59,3 +67,21 @@ describe("position reads", () => {
])
})
})

describe("escapeHtml", () => {
it("escapes markup and both quote styles, ampersands first", () => {
expect(escapeHtml(`<a href="x" title='y'>&amp;</a>`)).toBe(
"&lt;a href=&quot;x&quot; title=&#39;y&#39;&gt;&amp;amp;&lt;/a&gt;",
)
})
})

describe("truncate", () => {
it("returns text that already fits untouched", () => {
expect(truncate("hello", 5)).toBe("hello")
})

it("cuts longer text to exactly max characters, ending in an ellipsis", () => {
expect(truncate("hello world", 5)).toBe("hell…")
})
})
22 changes: 19 additions & 3 deletions lib/strings/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// String operations effect/String lacks: function replacers and position-aware reads.
// Each wraps the one native call it needs, so callers stay on pipeable, typed helpers.
import { Array as Arr } from "effect"
// String operations effect/String lacks: function replacers, position-aware reads, HTML escaping
// and truncation. The wrappers each own the one native call they need.
import { Array as Arr, String as Str } from "effect"
import { dual } from "effect/Function"

/** Receives the full match and each capture group; an unmatched optional group is `undefined`. */
Expand Down Expand Up @@ -65,3 +65,19 @@ export const matchAllIndexed: {
// @effect-diagnostics-next-line preferEffectString:off -- the native call this helper wraps
Arr.fromIterable(self.matchAll(pattern)),
)

/** Escapes text for HTML element content and quoted attributes alike. */
export const escapeHtml = (value: string): string =>
Str.replaceAll(
"'",
"&#39;",
)(
Str.replaceAll(
'"',
"&quot;",
)(Str.replaceAll(">", "&gt;")(Str.replaceAll("<", "&lt;")(Str.replaceAll("&", "&amp;")(value)))),
)

/** `text` cut to at most `max` characters, the last one an ellipsis when anything was cut. */
export const truncate = (text: string, max: number): string =>
text.length <= max ? text : `${Str.slice(0, max - 1)(text)}…`
Loading
Loading