Skip to content
Open
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
1db473a
Initial plan
Copilot Apr 3, 2026
7c237fa
Pass openApiDocument to Context constructor in module-loader
Copilot Apr 3, 2026
2cfdd55
Ensure openApiDocument updates reach Context instances when spec chan…
Copilot Apr 3, 2026
4c25b46
Replace in-place key mutation with a read-only Proxy for openApiDocum…
Copilot Apr 3, 2026
a20b89c
Simplify Proxy: drop .current wrapper, default openApiDocumentRef to {}
Copilot Apr 4, 2026
939f7e1
Merge branch 'main' into copilot/pass-openapi-document-to-context
pmcelhaney Apr 4, 2026
1665e29
Merge branch 'main' into copilot/pass-openapi-document-to-context
pmcelhaney Apr 6, 2026
f9d7dc7
Resolve merge conflict markers in module-loader.ts and app.ts
Copilot Apr 6, 2026
05a3c31
Fix TS2741: make paths optional in OpenApiDocument to allow empty def…
Copilot Apr 6, 2026
d653169
Merge branch 'main' into copilot/pass-openapi-document-to-context
github-actions[bot] Apr 7, 2026
fdffde6
Merge branch 'main' into copilot/pass-openapi-document-to-context
github-actions[bot] Apr 7, 2026
6a62ed1
Merge branch 'main' into copilot/pass-openapi-document-to-context
github-actions[bot] Apr 7, 2026
814ba54
Merge branch 'main' into copilot/pass-openapi-document-to-context
github-actions[bot] Apr 7, 2026
fae0bcc
Merge branch 'main' into copilot/pass-openapi-document-to-context
github-actions[bot] Apr 7, 2026
0c8e6ff
Merge branch 'main' into copilot/pass-openapi-document-to-context
github-actions[bot] Apr 7, 2026
7545c07
Merge branch 'main' into copilot/pass-openapi-document-to-context
github-actions[bot] Apr 7, 2026
4fd18fd
Merge branch 'main' into copilot/pass-openapi-document-to-context
github-actions[bot] Apr 7, 2026
04b7b69
Merge branch 'main' into copilot/pass-openapi-document-to-context
pmcelhaney Apr 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
16 changes: 16 additions & 0 deletions .changeset/pass-openapi-document-to-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"counterfact": minor
---

Pass the OpenAPI document to the Context class constructor in `_.context.ts`.

The `Context` constructor now receives an `openApiDocument` property alongside the existing `loadContext` and `readJson` helpers:

```ts
// routes/_.context.ts
export class Context {
constructor({ openApiDocument, loadContext, readJson }) {
this.openApiDocument = openApiDocument;
}
}
```
13 changes: 13 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export async function createMswHandlers(
compiledPathsDirectory,
registry,
contextRegistry,
openApiDocument,
);
await moduleLoader.load();
const routes = registry.routes;
Expand Down Expand Up @@ -142,6 +143,7 @@ export async function counterfact(config: Config) {
compiledPathsDirectory,
registry,
contextRegistry,
openApiDocument,
);

const middleware = koaMiddleware(dispatcher, config);
Expand All @@ -159,6 +161,17 @@ export async function counterfact(config: Config) {

if (config.openApiPath !== "_" && (watch.routes || watch.types)) {
await codeGenerator.watch();

codeGenerator.addEventListener("generate", () => {
void (async () => {
const newDoc = await loadOpenApiDocument(config.openApiPath);

if (newDoc !== undefined) {
moduleLoader.setOpenApiDocument(newDoc);
dispatcher.openApiDocument = newDoc;
}
})();
});
}

let httpTerminator: HttpTerminator | undefined;
Expand Down
2 changes: 1 addition & 1 deletion src/server/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ interface ParameterTypes {

export interface OpenApiDocument {
basePath?: string;
paths: {
paths?: {
[key: string]: {
[key in Lowercase<HttpMethods>]?: OpenApiOperation;
};
Expand Down
41 changes: 41 additions & 0 deletions src/server/module-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import createDebug from "debug";
import { CHOKIDAR_OPTIONS } from "./constants.js";
import { ContextRegistry } from "./context-registry.js";
import { determineModuleKind } from "./determine-module-kind.js";
import type { OpenApiDocument } from "./dispatcher.js";
import { FileDiscovery } from "./file-discovery.js";
import {
type ContextModule,
Expand All @@ -32,6 +33,10 @@ export class ModuleLoader extends EventTarget {

private readonly contextRegistry: ContextRegistry;

private openApiDocumentRef: OpenApiDocument;

private readonly openApiDocumentProxy: OpenApiDocument;

private readonly dependencyGraph = new ModuleDependencyGraph();

private readonly fileDiscovery: FileDiscovery;
Expand All @@ -45,14 +50,49 @@ export class ModuleLoader extends EventTarget {
basePath: string,
registry: Registry,
contextRegistry = new ContextRegistry(),
openApiDocument: OpenApiDocument = {},
) {
super();
this.basePath = basePath.replaceAll("\\", "/");
this.registry = registry;
this.contextRegistry = contextRegistry;
this.openApiDocumentRef = openApiDocument;

// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;

this.openApiDocumentProxy = new Proxy({} as OpenApiDocument, {
deleteProperty() {
return false;
},

get(_target, prop) {
return Reflect.get(self.openApiDocumentRef as object, prop);
},

getOwnPropertyDescriptor(_target, prop) {
return Reflect.getOwnPropertyDescriptor(self.openApiDocumentRef, prop);
},

has(_target, prop) {
return Reflect.has(self.openApiDocumentRef, prop);
},

ownKeys() {
return Reflect.ownKeys(self.openApiDocumentRef);
},

set() {
return false;
},
});
this.fileDiscovery = new FileDiscovery(this.basePath);
}

public setOpenApiDocument(newDoc: OpenApiDocument): void {
this.openApiDocumentRef = newDoc;
}

public async watch(): Promise<void> {
this.watcher = watch(this.basePath, CHOKIDAR_OPTIONS).on(
"all",
Expand Down Expand Up @@ -218,6 +258,7 @@ export class ModuleLoader extends EventTarget {

new endpoint.Context({
loadContext,
openApiDocument: this.openApiDocumentProxy,
readJson,
}),
);
Expand Down
119 changes: 119 additions & 0 deletions test/server/module-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,125 @@ describe("a module loader", () => {
});
});

it("passes openApiDocument to the Context constructor", async () => {
await usingTemporaryFiles(async ($) => {
await $.add(
"_.context.js",
"export class Context { constructor({ openApiDocument }) { this.openApiDocument = openApiDocument; } }",
);
await $.add("package.json", '{ "type": "module" }');

const registry: Registry = new Registry();
const contextRegistry: ContextRegistry = new ContextRegistry();
const openApiDocument = { paths: { "/hello": {} } };

const loader: ModuleLoader = new ModuleLoader(
$.path("."),
registry,
contextRegistry,
openApiDocument,
);

await loader.load();

const rootContext = contextRegistry.find("/") as any;

expect(rootContext?.openApiDocument.paths).toEqual({ "/hello": {} });
});
});

it("defaults openApiDocument to an empty object when none is provided", async () => {
await usingTemporaryFiles(async ($) => {
await $.add(
"_.context.js",
"export class Context { constructor({ openApiDocument }) { this.openApiDocument = openApiDocument; } }",
);
await $.add("package.json", '{ "type": "module" }');

const registry: Registry = new Registry();
const contextRegistry: ContextRegistry = new ContextRegistry();

const loader: ModuleLoader = new ModuleLoader(
$.path("."),
registry,
contextRegistry,
);

await loader.load();

const rootContext = contextRegistry.find("/") as any;

expect(rootContext?.openApiDocument).toBeDefined();
expect(typeof rootContext?.openApiDocument).toBe("object");
});
});

it("setOpenApiDocument works even when no initial document was provided", async () => {
await usingTemporaryFiles(async ($) => {
await $.add(
"_.context.js",
"export class Context { constructor({ openApiDocument }) { this.openApiDocument = openApiDocument; } }",
);
await $.add("package.json", '{ "type": "module" }');

const registry: Registry = new Registry();
const contextRegistry: ContextRegistry = new ContextRegistry();

const loader: ModuleLoader = new ModuleLoader(
$.path("."),
registry,
contextRegistry,
);

await loader.load();

const rootContext = contextRegistry.find("/") as any;
const capturedReference = rootContext?.openApiDocument;

loader.setOpenApiDocument({ paths: { "/added": {} } });

expect(rootContext?.openApiDocument).toBe(capturedReference);
expect(rootContext?.openApiDocument.paths).toEqual({ "/added": {} });
});
});

it("updates the openApiDocument reference in-place when setOpenApiDocument is called", async () => {
await usingTemporaryFiles(async ($) => {
await $.add(
"_.context.js",
"export class Context { constructor({ openApiDocument }) { this.openApiDocument = openApiDocument; } }",
);
await $.add("package.json", '{ "type": "module" }');

const registry: Registry = new Registry();
const contextRegistry: ContextRegistry = new ContextRegistry();
const openApiDocument = { paths: { "/hello": {} } };

const loader: ModuleLoader = new ModuleLoader(
$.path("."),
registry,
contextRegistry,
openApiDocument,
);

await loader.load();

const rootContext = contextRegistry.find("/") as any;

// Capture the proxy reference — it should remain stable
const capturedReference = rootContext?.openApiDocument;

const updatedDocument = { paths: { "/goodbye": {} } };
loader.setOpenApiDocument(updatedDocument);

// The proxy reference is stable
expect(rootContext?.openApiDocument).toBe(capturedReference);
// But the data it reads reflects the new document
expect(rootContext?.openApiDocument.paths).toEqual({ "/goodbye": {} });
expect(rootContext?.openApiDocument.paths["/hello"]).toBeUndefined();
});
});

// can't test because I can't get Jest to refresh modules
it.skip("updates the registry when a dependency is updated", async () => {
await usingTemporaryFiles(async ($) => {
Expand Down
Loading