Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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;
}
}
```
17 changes: 16 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
) {
// TODO: For some reason the Vitest Custom Commands needed by Vitest Browser mode fail on fs.readFile when they are called from the nested loadOpenApiDocument function.
// If we "pre-read" the file here it works. This is a workaround to avoid the issue.
await fs.readFile(config.openApiPath);

Check warning on line 64 in src/app.ts

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-non-literal-fs-filename] Found readFile from package "node:fs/promises" with non literal argument at index 0
const openApiDocument = await loadOpenApiDocument(config.openApiPath);
if (openApiDocument === undefined) {
throw new Error(
Expand All @@ -85,6 +85,7 @@
compiledPathsDirectory,
registry,
contextRegistry,
openApiDocument,
);
await moduleLoader.load();
const routes = registry.routes;
Expand Down Expand Up @@ -129,10 +130,12 @@
config.generate,
);

const openApiDocument = await loadOpenApiDocument(config.openApiPath);

const dispatcher = new Dispatcher(
registry,
contextRegistry,
await loadOpenApiDocument(config.openApiPath),
openApiDocument,
config,
);

Expand All @@ -146,6 +149,7 @@
compiledPathsDirectory,
registry,
contextRegistry,
openApiDocument,
);

const middleware = koaMiddleware(dispatcher, config);
Expand All @@ -161,6 +165,17 @@

if (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
41 changes: 41 additions & 0 deletions src/server/module-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { CHOKIDAR_OPTIONS } from "./constants.js";
import { type Context, ContextRegistry } from "./context-registry.js";
import { determineModuleKind } from "./determine-module-kind.js";
import type { OpenApiDocument } from "./dispatcher.js";
import { ModuleDependencyGraph } from "./module-dependency-graph.js";
import type { MiddlewareFunction, Module, Registry } from "./registry.js";
import { uncachedImport } from "./uncached-import.js";
Expand Down Expand Up @@ -51,6 +52,10 @@

private readonly contextRegistry: ContextRegistry;

private openApiDocumentRef: OpenApiDocument;

private readonly openApiDocumentProxy: OpenApiDocument;

private readonly dependencyGraph = new ModuleDependencyGraph();

private readonly uncachedImport: (moduleName: string) => Promise<unknown> =
Expand All @@ -62,11 +67,46 @@
basePath: string,
registry: Registry,
contextRegistry = new ContextRegistry(),
openApiDocument: OpenApiDocument = {},

Check failure on line 70 in src/server/module-loader.ts

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

Property 'paths' is missing in type '{}' but required in type '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;
},
});
}

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

public async watch(): Promise<void> {
Expand Down Expand Up @@ -268,6 +308,7 @@

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