Skip to content

Commit 868f0cd

Browse files
feat(#50): state — session-scoped mutable shared state
ofetch #435 (proposed by pi0, never built). New `state` option holds a mutable object shared by every call on a Misina instance. Hooks read AND write `ctx.options.state`: declare module 'misina' { interface MisinaState { token?: string requestCount?: number } } const session = createMisina({ state: { token: 'v1', requestCount: 0 }, hooks: { beforeRequest: (ctx) => { ctx.options.state.requestCount! += 1 const headers = new Headers(ctx.request.headers) if (ctx.options.state.token) headers.set('authorization', `Bearer ${ctx.options.state.token}`) return new Request(ctx.request, { headers }) }, }, }) // Mutate state at runtime — next call uses the new token: // hook can do `ctx.options.state.token = newToken` Semantics: - Same reference returned to every call on the same instance (closure-captured at createMisina time) - .extend() does NOT inherit — child gets its own state object (mutations don't leak across boundaries) - Default: empty {} when no state supplied - Type-safe via module augmentation of MisinaState Audit pass 57 (test/state.test.ts, 5 tests): Reference identity across calls, token rotation, default empty, .extend() isolation, multiple-call same-reference. Closes #50. Suite: 469 / 469 (was 464). Lint+typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent aa307f4 commit 868f0cd

5 files changed

Lines changed: 194 additions & 1 deletion

File tree

src/_merge.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export function mergeOptions(a: MisinaOptions, b: MisinaOptions): MisinaOptions
4444
} else if (key === "meta") {
4545
// Shallow merge — child keys win, parent keys preserved.
4646
out.meta = { ...(a.meta as object), ...(value as object) }
47+
} else if (key === "state") {
48+
// State is per-instance — never inherit parent's state on .extend().
49+
// Replace cleanly so child mutations don't leak across boundaries.
50+
out.state = value as MisinaOptions["state"]
4751
} else {
4852
;(out as Record<string, unknown>)[key] = value
4953
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export type {
5050
MisinaHooks,
5151
MisinaMeta,
5252
MisinaOptions,
53+
MisinaState,
5354
MisinaRequestInit,
5455
MisinaResolvedOptions,
5556
MisinaResponse,

src/misina.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type {
2626
MisinaResponse,
2727
MisinaResponsePromise,
2828
MisinaResult,
29+
MisinaState,
2930
SafeMisina,
3031
} from "./types.ts"
3132

@@ -39,12 +40,16 @@ const DEFAULT_TIMEOUT = 10_000
3940
export function createMisina(defaults: MisinaOptions = {}): Misina {
4041
const driver: MisinaDriver =
4142
defaults.driver ?? fetchDriverFactory({ fetch: defaults.fetch } as never)
43+
// Per-instance shared state. Same reference returned to every call so
44+
// hooks can read AND mutate. Initialize from defaults.state if provided
45+
// (NOT cloned — caller owns the object lifecycle). Otherwise empty.
46+
const sharedState: MisinaState = (defaults.state ?? {}) as MisinaState
4247

4348
async function request<T = unknown>(
4449
input: string,
4550
init: MisinaRequestInit = {},
4651
): Promise<MisinaResponse<T>> {
47-
const options = resolveOptions(input, init, defaults)
52+
const options = resolveOptions(input, init, defaults, sharedState)
4853
const startedAt = performance.now()
4954

5055
for (const initHook of options.hooks.init) initHook(options)
@@ -392,6 +397,7 @@ function resolveOptions(
392397
input: string,
393398
init: MisinaRequestInit,
394399
defaults: MisinaOptions,
400+
sharedState: MisinaState,
395401
): MisinaResolvedOptions {
396402
const method = (init.method ?? "GET") as HttpMethod
397403
const baseURL = init.baseURL ?? defaults.baseURL
@@ -416,6 +422,7 @@ function resolveOptions(
416422
allowedProtocols,
417423
trailingSlash,
418424
meta: { ...defaults.meta, ...init.meta },
425+
state: sharedState,
419426
method,
420427
headers,
421428
body,

src/types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ export type MaybeArray<T> = T | T[]
1414
// biome-ignore lint/complexity/noBannedTypes: empty interface is the augmentation surface
1515
export interface MisinaMeta {}
1616

17+
/**
18+
* Session-scoped mutable state shared by every call on a Misina instance.
19+
* Hooks read and write `ctx.options.state` directly. Empty by default —
20+
* augment in your project to add typed keys (matches MisinaMeta pattern).
21+
*/
22+
// biome-ignore lint/complexity/noBannedTypes: empty interface is the augmentation surface
23+
export interface MisinaState {}
24+
1725
/**
1826
* Per-phase mutable context shared across hooks for a single request lifecycle.
1927
* `request` and `response` are populated as the lifecycle progresses.
@@ -175,6 +183,13 @@ export interface MisinaOptions {
175183
* Useful as a guardrail when a backend 404s on the wrong canonical form.
176184
*/
177185
trailingSlash?: "preserve" | "strip" | "forbid"
186+
/**
187+
* Session-scoped mutable state — shared by every call on this instance.
188+
* Hooks can read AND write `ctx.options.state` to coordinate auth tokens,
189+
* counters, etc. NOT merged on `.extend()` — child instances get their
190+
* own state object so a child mutation doesn't leak to the parent.
191+
*/
192+
state?: MisinaState
178193
/**
179194
* Per-request user data — flows through every hook on `ctx.options.meta`.
180195
*
@@ -329,6 +344,12 @@ export interface MisinaResolvedOptions {
329344
allowedProtocols: readonly string[]
330345
trailingSlash: "preserve" | "strip" | "forbid"
331346
meta: MisinaMeta
347+
/**
348+
* Session state — same reference shared across every call on this
349+
* instance. Hooks can mutate it freely. Type via module augmentation
350+
* of `MisinaState` to add fields.
351+
*/
352+
state: MisinaState
332353
method: HttpMethod
333354
headers: Record<string, string>
334355
body?: unknown

test/state.test.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { describe, expect, it } from "vitest"
2+
import { createMisina } from "../src/index.ts"
3+
4+
declare module "../src/types.ts" {
5+
interface MisinaState {
6+
counter?: number
7+
token?: string
8+
}
9+
}
10+
11+
describe("state — session-scoped mutable shared object", () => {
12+
it("hooks see the same state reference across calls", async () => {
13+
const driver = {
14+
name: "p",
15+
request: async () => new Response("{}", { headers: { "content-type": "application/json" } }),
16+
}
17+
const m = createMisina({
18+
driver,
19+
retry: 0,
20+
state: { counter: 0 },
21+
hooks: {
22+
beforeRequest: (ctx) => {
23+
if (ctx.options.state.counter != null) {
24+
ctx.options.state.counter++
25+
}
26+
},
27+
},
28+
})
29+
30+
await m.get("https://api.test/")
31+
await m.get("https://api.test/")
32+
await m.get("https://api.test/")
33+
34+
// The state object is mutated by every call — read back via a hook
35+
// by triggering a final call and inspecting.
36+
let observed: number | undefined
37+
await m.get("https://api.test/", {
38+
hooks: {
39+
afterResponse: (ctx) => {
40+
observed = ctx.options.state.counter
41+
},
42+
},
43+
})
44+
expect(observed).toBe(4)
45+
})
46+
47+
it("token rotation in hooks affects subsequent calls", async () => {
48+
let captured: (string | null)[] = []
49+
const driver = {
50+
name: "p",
51+
request: async (req: Request) => {
52+
captured.push(req.headers.get("authorization"))
53+
return new Response("{}", { headers: { "content-type": "application/json" } })
54+
},
55+
}
56+
const m = createMisina({
57+
driver,
58+
retry: 0,
59+
state: { token: "v1" },
60+
hooks: {
61+
beforeRequest: (ctx) => {
62+
const headers = new Headers(ctx.request.headers)
63+
if (ctx.options.state.token)
64+
headers.set("authorization", `Bearer ${ctx.options.state.token}`)
65+
return new Request(ctx.request, { headers })
66+
},
67+
},
68+
})
69+
70+
await m.get("https://api.test/")
71+
expect(captured[0]).toBe("Bearer v1")
72+
73+
// Mutate state externally — next call uses the new token.
74+
await m.get("https://api.test/", {
75+
hooks: {
76+
init: (opts) => {
77+
opts.state.token = "v2"
78+
},
79+
},
80+
})
81+
expect(captured[1]).toBe("Bearer v2")
82+
})
83+
84+
it("default empty {} when no state supplied", async () => {
85+
let captured: object | undefined
86+
const driver = {
87+
name: "p",
88+
request: async () => new Response("{}", { headers: { "content-type": "application/json" } }),
89+
}
90+
const m = createMisina({
91+
driver,
92+
retry: 0,
93+
hooks: {
94+
beforeRequest: (ctx) => {
95+
captured = ctx.options.state
96+
},
97+
},
98+
})
99+
100+
await m.get("https://api.test/")
101+
expect(captured).toEqual({})
102+
})
103+
104+
it(".extend() does NOT inherit parent state — child gets its own", async () => {
105+
const driver = {
106+
name: "p",
107+
request: async () => new Response("{}", { headers: { "content-type": "application/json" } }),
108+
}
109+
const parent = createMisina({
110+
driver,
111+
retry: 0,
112+
state: { counter: 100 },
113+
})
114+
const child = parent.extend({ state: { counter: 0 } })
115+
116+
let parentObserved: number | undefined
117+
let childObserved: number | undefined
118+
await parent.get("https://api.test/", {
119+
hooks: {
120+
beforeRequest: (ctx) => {
121+
ctx.options.state.counter = (ctx.options.state.counter ?? 0) + 1
122+
parentObserved = ctx.options.state.counter
123+
},
124+
},
125+
})
126+
await child.get("https://api.test/", {
127+
hooks: {
128+
beforeRequest: (ctx) => {
129+
ctx.options.state.counter = (ctx.options.state.counter ?? 0) + 1
130+
childObserved = ctx.options.state.counter
131+
},
132+
},
133+
})
134+
135+
expect(parentObserved).toBe(101)
136+
expect(childObserved).toBe(1) // child's state is fresh
137+
})
138+
139+
it("state is the same reference across multiple calls on one instance", async () => {
140+
const driver = {
141+
name: "p",
142+
request: async () => new Response("{}", { headers: { "content-type": "application/json" } }),
143+
}
144+
const captured: object[] = []
145+
const m = createMisina({
146+
driver,
147+
retry: 0,
148+
state: { counter: 0 },
149+
hooks: {
150+
beforeRequest: (ctx) => {
151+
captured.push(ctx.options.state)
152+
},
153+
},
154+
})
155+
156+
await m.get("https://api.test/")
157+
await m.get("https://api.test/")
158+
expect(captured[0]).toBe(captured[1]) // same reference
159+
})
160+
})

0 commit comments

Comments
 (0)