feat: add SchemaAdapter system for typemap integration (Zod/Valibot), fix scanner pathToFileURL

- Add from_typemap.ts with SchemaAdapter interface, defaultAdapter, zodAdapter(), valibotAdapter()
- OperationRegistry now accepts optional { schemaAdapter } config; default is TypeBox passthrough
- Adapters use @alkdev/typemap (optional peer dep) with lazy init; detect schemas via ~standard vendor protocol
- Fix scanner pathToFileURL to encode URI components and normalize Windows backslashes
- Add from-typemap sub-path export in package.json and tsup config
- Add test suite covering defaultAdapter, zodAdapter, valibotAdapter, and registry integration
This commit is contained in:
2026-05-16 09:24:34 +00:00
parent 92936f4232
commit 3e1884cd23
8 changed files with 390 additions and 19 deletions

115
src/from_typemap.ts Normal file
View File

@@ -0,0 +1,115 @@
import type { TSchema } from "@alkdev/typebox";
import { KindGuard } from "@alkdev/typebox";
import { getLogger } from "@logtape/logtape";
const logger = getLogger("operations:typemap");
export interface SchemaAdapter {
toTypeBox(schema: unknown): TSchema;
init?(): Promise<void>;
}
export const defaultAdapter: SchemaAdapter = {
toTypeBox(schema: unknown): TSchema {
if (KindGuard.IsSchema(schema)) {
return schema as TSchema;
}
throw new Error(
`SchemaAdapter: expected a TypeBox schema, but received ${typeof schema}. ` +
`Install @alkdev/typemap and use zodAdapter/valibotAdapter to convert from other schema libraries.`
);
},
};
function isStandardSchemaFrom(value: unknown, vendor: string): boolean {
if (typeof value !== "object" || value === null) return false;
const standard = (value as Record<string, unknown>)["~standard"];
if (typeof standard !== "object" || standard === null) return false;
return (standard as Record<string, unknown>)["vendor"] === vendor;
}
export function zodAdapter(): SchemaAdapter & { init(): Promise<void> } {
let TypeBoxFromZod: ((schema: unknown) => TSchema) | null = null;
let loaded = false;
return {
async init(): Promise<void> {
if (loaded) return;
try {
const typemap = await import("@alkdev/typemap");
TypeBoxFromZod = typemap.TypeBoxFromZod as ((schema: unknown) => TSchema);
loaded = true;
logger.info("zodAdapter: loaded @alkdev/typemap");
} catch {
throw new Error(
"zodAdapter requires @alkdev/typemap as a peer dependency. " +
"Install it with: npm install @alkdev/typemap"
);
}
},
toTypeBox(schema: unknown): TSchema {
if (KindGuard.IsSchema(schema)) {
return schema as TSchema;
}
if (isStandardSchemaFrom(schema, "zod") && TypeBoxFromZod) {
return TypeBoxFromZod(schema);
}
if (TypeBoxFromZod && !isStandardSchemaFrom(schema, "zod") && !isStandardSchemaFrom(schema, "valibot")) {
throw new Error(
`zodAdapter: schema is not a Zod or TypeBox schema (received ${typeof schema})`
);
}
if (TypeBoxFromZod) {
throw new Error(
`zodAdapter: schema uses a different schema library than Zod. Use the appropriate adapter.`
);
}
throw new Error(
"zodAdapter: not initialized. Call await adapter.init() before using toTypeBox()."
);
},
};
}
export function valibotAdapter(): SchemaAdapter & { init(): Promise<void> } {
let TypeBoxFromValibot: ((schema: unknown) => TSchema) | null = null;
let loaded = false;
return {
async init(): Promise<void> {
if (loaded) return;
try {
const typemap = await import("@alkdev/typemap");
TypeBoxFromValibot = typemap.TypeBoxFromValibot as ((schema: unknown) => TSchema);
loaded = true;
logger.info("valibotAdapter: loaded @alkdev/typemap");
} catch {
throw new Error(
"valibotAdapter requires @alkdev/typemap as a peer dependency. " +
"Install it with: npm install @alkdev/typemap"
);
}
},
toTypeBox(schema: unknown): TSchema {
if (KindGuard.IsSchema(schema)) {
return schema as TSchema;
}
if (isStandardSchemaFrom(schema, "valibot") && TypeBoxFromValibot) {
return TypeBoxFromValibot(schema);
}
if (TypeBoxFromValibot && !isStandardSchemaFrom(schema, "valibot") && !isStandardSchemaFrom(schema, "zod")) {
throw new Error(
`valibotAdapter: schema is not a Valibot or TypeBox schema (received ${typeof schema})`
);
}
if (TypeBoxFromValibot) {
throw new Error(
`valibotAdapter: schema uses a different schema library than Valibot. Use the appropriate adapter.`
);
}
throw new Error(
"valibotAdapter: not initialized. Call await adapter.init() before using toTypeBox()."
);
},
};
}

View File

@@ -1,6 +1,7 @@
export { OperationType, OperationContextSchema, OperationSpecSchema, AccessControlSchema, ErrorDefinitionSchema } from "./types.js";
export type { IOperationDefinition, OperationHandler, SubscriptionHandler, Identity, OperationEnv, OperationContext, OperationSpec, AccessControl, ErrorDefinition } from "./types.js";
export { OperationRegistry } from "./registry.js";
export type { RegistryOptions } from "./registry.js";
export { formatValueErrors, assertIsSchema, validateOrThrow, collectErrors } from "./validation.js";
export { buildEnv } from "./env.js";
export type { EnvOptions } from "./env.js";
@@ -15,6 +16,8 @@ export { PendingRequestMap, buildCallHandler } from "./call.js";
export type { CallEventMap, CallEventMapValue, CallRequestedEvent, CallRespondedEvent, CallAbortedEvent, CallErrorEvent, CallHandler, CallHandlerConfig } from "./call.js";
export { checkAccess } from "./access.js";
export { subscribe } from "./subscribe.js";
export { defaultAdapter, zodAdapter, valibotAdapter } from "./from_typemap.js";
export type { SchemaAdapter } from "./from_typemap.js";
export { createMCPClient, closeMCPClient, MCPClientLoader } from "./from_mcp.js";
export type { MCPClientConfig, MCPClientWrapper } from "./from_mcp.js";
export { ResponseEnvelopeSchema, ResponseMetaSchema, RESPONSE_SOURCES, isResponseEnvelope, localEnvelope, httpEnvelope, mcpEnvelope, unwrap } from "./response-envelope.js";

View File

@@ -1,28 +1,44 @@
import type { OperationContext, OperationSpec, OperationHandler, SubscriptionHandler, Identity, AccessControl } from "./types.js";
import { getLogger } from "@logtape/logtape";
import { Value } from "@alkdev/typebox/value";
import { KindGuard } from "@alkdev/typebox";
import { KindGuard, type TSchema } from "@alkdev/typebox";
import { assertIsSchema, validateOrThrow, collectErrors, formatValueErrors } from "./validation.js";
import { isResponseEnvelope, localEnvelope, type ResponseEnvelope } from "./response-envelope.js";
import { CallError, InfrastructureErrorCode } from "./error.js";
import { checkAccess } from "./access.js";
import type { SchemaAdapter } from "./from_typemap.js";
import { defaultAdapter } from "./from_typemap.js";
const logger = getLogger("operations:registry");
export interface RegistryOptions {
schemaAdapter?: SchemaAdapter;
}
export class OperationRegistry {
private specs = new Map<string, OperationSpec>();
private handlers = new Map<string, OperationHandler | SubscriptionHandler>();
private adapter: SchemaAdapter;
constructor(options?: RegistryOptions) {
this.adapter = options?.schemaAdapter ?? defaultAdapter;
}
private opId(namespace: string, name: string): string {
return `${namespace}.${name}`;
}
private toTypeBox(schema: unknown): TSchema {
return this.adapter.toTypeBox(schema);
}
register(operation: OperationSpec & { handler?: OperationHandler | SubscriptionHandler }): void {
const id = this.opId(operation.namespace, operation.name);
assertIsSchema(operation.inputSchema, `${id} inputSchema`);
assertIsSchema(operation.outputSchema, `${id} outputSchema`);
const inputSchema = this.toTypeBox(operation.inputSchema);
const outputSchema = this.toTypeBox(operation.outputSchema);
const { handler, ...spec } = operation;
this.specs.set(id, spec);
const resolvedSpec: OperationSpec = { ...spec, inputSchema, outputSchema };
this.specs.set(id, resolvedSpec);
if (handler) {
this.handlers.set(id, handler);
}
@@ -37,9 +53,10 @@ export class OperationRegistry {
registerSpec(spec: OperationSpec): void {
const id = this.opId(spec.namespace, spec.name);
assertIsSchema(spec.inputSchema, `${id} inputSchema`);
assertIsSchema(spec.outputSchema, `${id} outputSchema`);
this.specs.set(id, spec);
const inputSchema = this.toTypeBox(spec.inputSchema);
const outputSchema = this.toTypeBox(spec.outputSchema);
const resolvedSpec: OperationSpec = { ...spec, inputSchema, outputSchema };
this.specs.set(id, resolvedSpec);
logger.info(`Registered spec: ${id}`);
}

View File

@@ -93,5 +93,9 @@ async function processDirectory(
}
function pathToFileURL(absolutePath: string): string {
return `file://${absolutePath}`;
let normalized = absolutePath.replace(/\\/g, "/");
if (!normalized.startsWith("/")) {
normalized = `/${normalized}`;
}
return `file://${encodeURI(normalized).replace(/#/g, "%23")}`;
}