Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
### v6.0.0

- Supported Node.js versions: `^22.19.0 || ^24.0.0`.
- The `typescript` dependency is now optional and only required for making `Integration`:
- Either import and assign the `typescript` property to its constructor argument;
- Or use the new static async method `create()` to delegate the import;
- This change addresses the potential memory consumption issue.

## Version 5

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ schemas, it can be exported to frontend side, thus ensuring that the established
Install the package and its peer dependencies.

```shell
pnpm add zod-sockets zod socket.io typescript
pnpm add zod-sockets zod socket.io
```

## Set up config
Expand Down Expand Up @@ -577,8 +577,9 @@ In order to establish constraints for events on the client side you can generate

```typescript
import { Integration } from "zod-sockets";
import typescript from "typescript";

const integration = new Integration({ config, actions });
const integration = new Integration({ typescript, config, actions });
const typescriptCode = integration.print(); // write this to a file
```

Expand Down
3 changes: 2 additions & 1 deletion example/generate-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { writeFile } from "node:fs/promises";
import { Integration } from "zod-sockets";
import { actions } from "./actions";
import { config } from "./config";
import typescript from "typescript";

await writeFile(
"example-client.ts",
new Integration({ config, actions }).print(),
new Integration({ typescript, config, actions }).print(),
"utf-8",
);
5 changes: 5 additions & 0 deletions zod-sockets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@
"typescript": "catalog:peer",
"zod": "catalog:peer"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
},
"devDependencies": {
"@types/ramda": "^0.31.0",
"socket.io": "catalog:dev",
Expand Down
10 changes: 9 additions & 1 deletion zod-sockets/src/__snapshots__/zts.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = `
] | bigint)[];
};
}>;
looseRecord: Record<"one" | "two", boolean>;
looseRecord: Record<"one" | "two", boolean> & Record<PropertyKey, boolean>;
map: any;
set: any;
intersection: (string & number) | bigint;
Expand All @@ -77,6 +77,13 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = `
catch: number;
pipeline: string;
readonly: string;
extended: {
hex: string;
hash: string;
};
codec: string;
slug: string;
xor: string | number;
}"
`;

Expand Down Expand Up @@ -277,6 +284,7 @@ exports[`zod-to-ts > z.optional() > Zod 4: should add question mark only to opti
required: string;
}
] | undefined;
exact?: string;
}"
`;

Expand Down
6 changes: 4 additions & 2 deletions zod-sockets/src/integration.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ts from "typescript";
import { z } from "zod";
import { ActionsFactory } from "./actions-factory";
import { Config, createSimpleConfig } from "./config";
Expand All @@ -16,6 +17,7 @@ describe("Integration", () => {
});
const input = z.tuple([feature.array()]);
const instance = new Integration({
typescript: ts,
config: sampleConfig,
actions: [
new ActionsFactory(sampleConfig).build({
Expand All @@ -28,7 +30,7 @@ describe("Integration", () => {
expect(instance.print()).toMatchSnapshot();
});

test("should handle namespaces with emission", () => {
test("should handle namespaces with emission", async () => {
const configWithEmission = new Config().addNamespace({
path: "/test",
emission: {
Expand All @@ -38,7 +40,7 @@ describe("Integration", () => {
},
},
});
const instance = new Integration({
const instance = await Integration.create({
config: configWithEmission,
actions: [],
});
Expand Down
127 changes: 60 additions & 67 deletions zod-sockets/src/integration.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
import ts from "typescript";
import type ts from "typescript";
import { z } from "zod";
import { AbstractAction } from "./action";
import { makeCleanId } from "./common-helpers";
import { Config } from "./config";
import { makeEventFnSchema } from "./integration-helpers";
import { Namespaces, normalizeNS } from "./namespace";
import { zodToTs } from "./zts";
import {
addJsDoc,
makeType,
printNode,
f,
exportModifier,
} from "./typescript-api";
import { TypescriptAPI } from "./typescript-api";

interface IntegrationProps {
interface IntegrationParams {
typescript: typeof ts;
config: Config<Namespaces>;
actions: AbstractAction[];
/**
Expand All @@ -30,18 +25,20 @@ const fallbackNs = "root";
const registryScopes = ["emission", "actions"];

export class Integration {
/** @internal */
protected readonly api: TypescriptAPI;
#program: ts.Node[] = [];
#aliases: Record<
string, // namespace
Map<object, ts.TypeAliasDeclaration>
> = {};
#ids = {
path: f.createIdentifier("path"),
socket: f.createIdentifier("Socket"),
socketBase: f.createIdentifier("SocketBase"),
ioClient: f.createStringLiteral("socket.io-client"),
emission: f.createIdentifier(makeCleanId(registryScopes[0])),
actions: f.createIdentifier(makeCleanId(registryScopes[1])),
path: "path",
socket: "Socket",
socketBase: "SocketBase",
ioClient: "socket.io-client",
emission: makeCleanId(registryScopes[0]),
actions: makeCleanId(registryScopes[1]),
};
protected registry: Record<
string, // namespace
Expand All @@ -51,48 +48,49 @@ export class Integration {
>
> = {};

#makeAlias(
ns: string,
key: object,
produce: () => ts.TypeNode,
): ts.TypeReferenceNode {
#makeAlias(ns: string, key: object, produce: () => ts.TypeNode): ts.TypeNode {
let name = this.#aliases[ns].get(key)?.name?.text;
if (!name) {
name = `Type${this.#aliases[ns].size + 1}`;
const temp = f.createLiteralTypeNode(f.createNull());
this.#aliases[ns].set(key, makeType(name, temp));
this.#aliases[ns].set(key, makeType(name, produce()));
const temp = this.api.makeLiteralType(null);
this.#aliases[ns].set(key, this.api.makeType(name, temp));
this.#aliases[ns].set(key, this.api.makeType(name, produce()));
}
return f.createTypeReferenceNode(name);
return this.api.ensureTypeNode(name);
}

constructor({
typescript,
config: { namespaces },
actions,
maxOverloads = 3,
}: IntegrationProps) {
}: IntegrationParams) {
this.api = new TypescriptAPI(typescript);
this.#program.push(
f.createImportDeclaration(
this.api.f.createImportDeclaration(
undefined,
f.createImportClause(
true,
this.api.f.createImportClause(
this.api.ts.SyntaxKind.TypeKeyword,
undefined,
f.createNamedImports([
f.createImportSpecifier(
this.api.f.createNamedImports([
this.api.f.createImportSpecifier(
false,
this.#ids.socket,
this.#ids.socketBase,
this.api.f.createIdentifier(this.#ids.socket),
this.api.f.createIdentifier(this.#ids.socketBase),
),
]),
),
this.#ids.ioClient,
this.api.f.createStringLiteral(this.#ids.ioClient),
),
);

for (const [ns, { emission }] of Object.entries(namespaces)) {
this.#aliases[ns] = new Map<z.ZodTypeAny, ts.TypeAliasDeclaration>();
this.registry[ns] = { emission: [], actions: [] };
const commons = { makeAlias: this.#makeAlias.bind(this, ns) };
const commons = {
makeAlias: this.#makeAlias.bind(this, ns),
api: this.api,
};
for (const [event, { schema, ack }] of Object.entries(emission)) {
const node = zodToTs(makeEventFnSchema(schema, ack, maxOverloads), {
isResponse: true,
Expand All @@ -115,69 +113,64 @@ export class Integration {
for (const ns in this.registry) {
const publicName = makeCleanId(ns) || makeCleanId(fallbackNs);

const nsNameNode = f.createVariableStatement(
exportModifier,
f.createVariableDeclarationList(
[
f.createVariableDeclaration(
this.#ids.path,
undefined,
undefined,
f.createStringLiteral(normalizeNS(ns)),
),
],
ts.NodeFlags.Const,
),
const nsNameNode = this.api.makeConst(
this.#ids.path,
this.api.f.createStringLiteral(normalizeNS(ns)),
{ expose: true },
);
addJsDoc(
this.api.addJsDoc(
nsNameNode,
`@desc The actual path of the ${publicName} namespace`,
);

const interfaces = Object.entries(this.registry[ns]).map(
([scope, events]) =>
f.createInterfaceDeclaration(
exportModifier,
this.api.makeInterface(
makeCleanId(scope),
undefined,
undefined,
events.map(({ event, node }) =>
f.createPropertySignature(undefined, event, undefined, node),
this.api.makeInterfaceProp(event, node),
),
{ expose: true },
),
);
const socketNode = f.createTypeAliasDeclaration(
exportModifier,
const socketNode = this.api.makeType(
this.#ids.socket,
undefined,
f.createTypeReferenceNode(this.#ids.socketBase, [
f.createTypeReferenceNode(this.#ids.emission),
f.createTypeReferenceNode(this.#ids.actions),
this.api.ensureTypeNode(this.#ids.socketBase, [
this.#ids.emission,
this.#ids.actions,
]),
{ expose: true },
);
addJsDoc(
this.api.addJsDoc(
socketNode,
`@example const socket: ${publicName}.${this.#ids.socket.text} = io(${publicName}.${this.#ids.path.text})`,
`@example const socket: ${publicName}.${this.#ids.socket} = io(${publicName}.${this.#ids.path})`,
);
this.#program.push(
f.createModuleDeclaration(
exportModifier,
f.createIdentifier(publicName),
f.createModuleBlock([
this.api.f.createModuleDeclaration(
this.api.exportModifier,
this.api.f.createIdentifier(publicName),
this.api.f.createModuleBlock([
nsNameNode,
...this.#aliases[ns].values(),
...interfaces,
socketNode,
]),
ts.NodeFlags.Namespace,
this.api.ts.NodeFlags.Namespace,
),
);
}
}

public static async create(params: Omit<IntegrationParams, "typescript">) {
return new Integration({
...params,
typescript: (await import("typescript"))["default"],
});
}

public print(printerOptions?: ts.PrinterOptions) {
return this.#program
.map((node) => printNode(node, printerOptions))
.map((node) => this.api.printNode(node, printerOptions))
.join("\n\n");
}
}
Loading