@signal-spec/react-client 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -2
- package/bin/signal-spec.mjs +67 -0
- package/dist/index.d.mts +38 -0
- package/dist/index.mjs +117 -0
- package/package.json +50 -3
- package/src/generation/hbs-helpers.ts +108 -0
- package/src/generation/main.ts +20 -0
- package/src/generation/spec.ts +76 -0
- package/src/generation/template.hbs +56 -0
- package/src/generation/templates/config.hbs +14 -0
- package/src/generation/templates/dtos.hbs +25 -0
- package/src/generation/templates/hubClient.hbs +110 -0
package/README.md
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
-
#
|
|
1
|
+
# SignalSpec React Client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Generate typed React hooks and DTOs from a SignalSpec JSON document:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
signal-spec generate --input=./signal-spec.json --output=./src/generated --backend-uri=https://localhost:49363
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
During development, use `vp run generate` followed by the same flags.
|
|
10
|
+
`--input` accepts a local JSON file, a tsconfig path alias such as `@/spec.json`,
|
|
11
|
+
or an HTTP(S) URL. `--output` accepts a local folder or tsconfig alias.
|
|
12
|
+
Paths resolve from the current working directory. Quote paths containing spaces.
|
|
13
|
+
All three options are required; both `--flag=value` and `--flag value` work.
|
|
14
|
+
|
|
15
|
+
Generated hooks automatically import `config.ts`, which initializes the shared
|
|
16
|
+
Zustand connection store with the supplied backend URI. No provider is required.
|
|
17
|
+
After importing generated hooks, applications can override the default:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { useSignalRConnectionsStore } from "@signal-spec/react-client";
|
|
21
|
+
|
|
22
|
+
useSignalRConnectionsStore.setState({ backendUri: "https://api.example.com" });
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Overrides affect new connections. Existing connections keep their original URL.
|
|
26
|
+
The URI is shared across generated clients in the same application.
|
|
27
|
+
Generation overwrites matching files, retains stale files, and may leave partial
|
|
28
|
+
output if TypeSynth reports an error. Generation failures return a nonzero exit code.
|
|
29
|
+
|
|
30
|
+
Development: `vp install`, `vp check`, `vp test`, and `vp run build`.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
const help = `Usage: signal-spec generate --input <json-or-url> --output <folder> --backend-uri <uri>
|
|
10
|
+
|
|
11
|
+
Input and output paths resolve from the current directory, including tsconfig aliases.
|
|
12
|
+
The backend URI becomes the generated default and can be overridden at runtime.
|
|
13
|
+
Options accept --flag=value or --flag value. Use --help to show this message.`;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
if (!args.length || args.includes("--help")) {
|
|
18
|
+
console.log(help);
|
|
19
|
+
} else {
|
|
20
|
+
if (args.shift() !== "generate") throw new Error('Expected command "generate".');
|
|
21
|
+
const flags = {};
|
|
22
|
+
while (args.length) {
|
|
23
|
+
const arg = args.shift();
|
|
24
|
+
const match = /^--(input|output|backend-uri)(?:=(.*))?$/.exec(arg);
|
|
25
|
+
if (!match) throw new Error(`Unknown argument: ${arg}`);
|
|
26
|
+
const [, key, inline] = match;
|
|
27
|
+
if (Object.hasOwn(flags, key)) throw new Error(`Duplicate option --${key}.`);
|
|
28
|
+
const value = inline ?? args.shift();
|
|
29
|
+
if (!value || value.startsWith("--")) throw new Error(`--${key} requires a value.`);
|
|
30
|
+
flags[key] = value;
|
|
31
|
+
}
|
|
32
|
+
for (const key of ["input", "output", "backend-uri"]) {
|
|
33
|
+
if (!flags[key]) throw new Error(`Missing required option --${key}.`);
|
|
34
|
+
}
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
const typesynth = resolve(dirname(require.resolve("typesynth/package.json")), "dist/bin/cli.js");
|
|
37
|
+
const sources = fileURLToPath(new URL("../src/generation/", import.meta.url));
|
|
38
|
+
let temporary;
|
|
39
|
+
try {
|
|
40
|
+
if (/^https?:\/\//i.test(flags.input)) {
|
|
41
|
+
const response = await fetch(flags.input);
|
|
42
|
+
if (!response.ok) throw new Error(`Failed to fetch spec: HTTP ${response.status}`);
|
|
43
|
+
const text = await response.text();
|
|
44
|
+
JSON.parse(text);
|
|
45
|
+
temporary = await mkdtemp(resolve(tmpdir(), "signal-spec-"));
|
|
46
|
+
flags.input = resolve(temporary, "spec.json");
|
|
47
|
+
await writeFile(flags.input, text);
|
|
48
|
+
}
|
|
49
|
+
const child = spawn(process.execPath, [typesynth, "generate", `--sources=${sources}`,
|
|
50
|
+
`--input=${flags.input}`, `--output=${flags.output}`], {
|
|
51
|
+
stdio: "inherit",
|
|
52
|
+
env: { ...process.env, SIGNAL_SPEC_BACKEND_URI: flags["backend-uri"] },
|
|
53
|
+
});
|
|
54
|
+
process.exitCode = await new Promise((done, reject) => {
|
|
55
|
+
child.on("error", reject);
|
|
56
|
+
child.on("exit", code => done(code ?? 1));
|
|
57
|
+
});
|
|
58
|
+
} finally {
|
|
59
|
+
if (temporary) await rm(temporary, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as SignalR from "@microsoft/signalr";
|
|
2
|
+
import { HubConnection } from "@microsoft/signalr";
|
|
3
|
+
//#region src/helpers/useSignalRConnections.d.ts
|
|
4
|
+
interface SignalRConnectionData {
|
|
5
|
+
connection: SignalR.HubConnection;
|
|
6
|
+
componentsSubscribed: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function isConnectionAlive(connectionState: SignalR.HubConnectionState | null | undefined): boolean | null | undefined;
|
|
9
|
+
interface SignalRConnections {
|
|
10
|
+
createConnection: (hubName: string) => Promise<SignalRConnectionData>;
|
|
11
|
+
setupEventHandlers: <T>(hubName: string, handlers: Partial<T>, onConnected?: (connection: SignalR.HubConnection) => void) => Promise<void>;
|
|
12
|
+
deleteConnection: <T>(hubName: string, handlers: Partial<T>) => void;
|
|
13
|
+
}
|
|
14
|
+
interface SignalRConfiguration {
|
|
15
|
+
backendUri: string;
|
|
16
|
+
enableDebug?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** Configure SignalR before mounting consumers. Backend switching is not supported. */
|
|
19
|
+
export declare function configureSignalR({ backendUri, enableDebug }: SignalRConfiguration): void;
|
|
20
|
+
export declare function useSignalRConnections(): SignalRConnections;
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region src/helpers/useStableHandlers.d.ts
|
|
23
|
+
/**
|
|
24
|
+
* This is basically MUI's {@link useEventCallback} hook, but designed for objects
|
|
25
|
+
* containing handlers instead of a singular handler.
|
|
26
|
+
* @param handlers
|
|
27
|
+
*/
|
|
28
|
+
export declare function useStableHandlers<T extends Record<string, (...args: any[]) => any>>(handlers: T | undefined): T;
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/helpers/safeInvokeHubMethod.d.ts
|
|
31
|
+
/**
|
|
32
|
+
* Safely invokes the SignalR hub method, i.e. it will only throw in case of an actual error,
|
|
33
|
+
* and will return `Warning` object in case the connection hasn't yet been established or
|
|
34
|
+
* has been already closed.
|
|
35
|
+
*/
|
|
36
|
+
export declare function safeInvokeHubMethod(connection: HubConnection | null, methodName: string, ...args: any[]): Promise<any>;
|
|
37
|
+
//#endregion
|
|
38
|
+
export type { SignalRConfiguration, SignalRConnectionData, SignalRConnections };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import * as SignalR from "@microsoft/signalr";
|
|
3
|
+
import { HubConnectionState } from "@microsoft/signalr";
|
|
4
|
+
import { useLayoutEffect, useMemo, useRef } from "react";
|
|
5
|
+
//#region src/helpers/useSignalRConnections.ts
|
|
6
|
+
function isConnectionAlive(connectionState) {
|
|
7
|
+
return connectionState && connectionState !== SignalR.HubConnectionState.Disconnected && connectionState !== SignalR.HubConnectionState.Disconnecting;
|
|
8
|
+
}
|
|
9
|
+
const useSignalRConnectionsStore = create((set, get) => ({
|
|
10
|
+
backendUri: "",
|
|
11
|
+
EnableSignalRDebug: false,
|
|
12
|
+
connectionsStore: /* @__PURE__ */ new Map(),
|
|
13
|
+
async createConnection(hubName) {
|
|
14
|
+
let data = get().connectionsStore.get(hubName);
|
|
15
|
+
if (!data) {
|
|
16
|
+
const { backendUri, EnableSignalRDebug } = get();
|
|
17
|
+
data = {
|
|
18
|
+
connection: new SignalR.HubConnectionBuilder().withUrl(backendUri + "/hubs/" + hubName, { withCredentials: true }).configureLogging(EnableSignalRDebug ? SignalR.LogLevel.Information : SignalR.LogLevel.None).withAutomaticReconnect().build(),
|
|
19
|
+
componentsSubscribed: 0
|
|
20
|
+
};
|
|
21
|
+
const connectionsStore = new Map(get().connectionsStore);
|
|
22
|
+
connectionsStore.set(hubName, data);
|
|
23
|
+
set({ connectionsStore });
|
|
24
|
+
}
|
|
25
|
+
if (!isConnectionAlive(data.connection.state)) await data.connection.start().catch((error) => {
|
|
26
|
+
if (get().EnableSignalRDebug) console.error(error);
|
|
27
|
+
});
|
|
28
|
+
return data;
|
|
29
|
+
},
|
|
30
|
+
async setupEventHandlers(hubName, handlers, onConnected) {
|
|
31
|
+
let data = get().connectionsStore.get(hubName);
|
|
32
|
+
if (!data) data = await get().createConnection(hubName);
|
|
33
|
+
onConnected?.(data.connection);
|
|
34
|
+
for (const key of Object.keys(handlers)) {
|
|
35
|
+
const handler = handlers[key];
|
|
36
|
+
if (handler !== void 0) data.connection.on(key, handler);
|
|
37
|
+
}
|
|
38
|
+
data.componentsSubscribed++;
|
|
39
|
+
set({ connectionsStore: new Map(get().connectionsStore) });
|
|
40
|
+
},
|
|
41
|
+
deleteConnection(hubName, handlers) {
|
|
42
|
+
const data = get().connectionsStore.get(hubName);
|
|
43
|
+
if (!data) return;
|
|
44
|
+
for (const key of Object.keys(handlers)) {
|
|
45
|
+
const handler = handlers[key];
|
|
46
|
+
if (handler !== void 0) data.connection.off(key, handler);
|
|
47
|
+
}
|
|
48
|
+
data.componentsSubscribed--;
|
|
49
|
+
const connectionsStore = new Map(get().connectionsStore);
|
|
50
|
+
if (data.componentsSubscribed === 0) {
|
|
51
|
+
data.connection.stop().catch((error) => {
|
|
52
|
+
if (get().EnableSignalRDebug) console.error(error);
|
|
53
|
+
});
|
|
54
|
+
connectionsStore.delete(hubName);
|
|
55
|
+
}
|
|
56
|
+
set({ connectionsStore });
|
|
57
|
+
}
|
|
58
|
+
}));
|
|
59
|
+
/** Configure SignalR before mounting consumers. Backend switching is not supported. */
|
|
60
|
+
function configureSignalR({ backendUri, enableDebug = false }) {
|
|
61
|
+
useSignalRConnectionsStore.setState({
|
|
62
|
+
backendUri,
|
|
63
|
+
EnableSignalRDebug: enableDebug
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function useSignalRConnections() {
|
|
67
|
+
return {
|
|
68
|
+
createConnection: useSignalRConnectionsStore((state) => state.createConnection),
|
|
69
|
+
setupEventHandlers: useSignalRConnectionsStore((state) => state.setupEventHandlers),
|
|
70
|
+
deleteConnection: useSignalRConnectionsStore((state) => state.deleteConnection)
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/helpers/useStableHandlers.ts
|
|
75
|
+
/**
|
|
76
|
+
* This is basically MUI's {@link useEventCallback} hook, but designed for objects
|
|
77
|
+
* containing handlers instead of a singular handler.
|
|
78
|
+
* @param handlers
|
|
79
|
+
*/
|
|
80
|
+
function useStableHandlers(handlers) {
|
|
81
|
+
const ref = useRef(handlers);
|
|
82
|
+
useLayoutEffect(() => {
|
|
83
|
+
ref.current = handlers;
|
|
84
|
+
});
|
|
85
|
+
return useMemo(() => {
|
|
86
|
+
const result = {};
|
|
87
|
+
if (!handlers) return result;
|
|
88
|
+
for (const key of Object.keys(handlers)) result[key] = (...args) => {
|
|
89
|
+
return ref.current[key](...args);
|
|
90
|
+
};
|
|
91
|
+
return result;
|
|
92
|
+
}, []);
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region src/helpers/safeInvokeHubMethod.ts
|
|
96
|
+
/**
|
|
97
|
+
* Safely invokes the SignalR hub method, i.e. it will only throw in case of an actual error,
|
|
98
|
+
* and will return `Warning` object in case the connection hasn't yet been established or
|
|
99
|
+
* has been already closed.
|
|
100
|
+
*/
|
|
101
|
+
async function safeInvokeHubMethod(connection, methodName, ...args) {
|
|
102
|
+
if (connection?.state !== HubConnectionState.Connected) {
|
|
103
|
+
console.warn("[SignalSpec] Skipped invocation: not connected");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return await connection.invoke(methodName, ...args);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err instanceof Error && err.message.includes("underlying connection being closed")) {
|
|
110
|
+
console.warn("[SignalSpec] Skipped invocation: connection is being closed");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
//#endregion
|
|
117
|
+
export { configureSignalR, isConnectionAlive, safeInvokeHubMethod, useSignalRConnections, useStableHandlers };
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signal-spec/react-client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A SignalR client generator for React based on the SignalSpec.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/IvanKobtsev/SignalSpec.ReactClient.git"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"bin",
|
|
12
|
+
"src/generation"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./dist/index.mjs",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"typesynth": "0.1.1",
|
|
24
|
+
"zustand": "^5.0.16"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@microsoft/signalr": "^10.0.11",
|
|
28
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^26.6.5",
|
|
32
|
+
"@types/react": "^19.3.0",
|
|
33
|
+
"typescript": "^7.0.2",
|
|
34
|
+
"vite-plus": "1.3.0"
|
|
35
|
+
},
|
|
36
|
+
"devEngines": {
|
|
37
|
+
"packageManager": {
|
|
38
|
+
"name": "pnpm",
|
|
39
|
+
"version": "12.16.1",
|
|
40
|
+
"onFail": "download"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"bin": {
|
|
44
|
+
"signal-spec": "bin/signal-spec.mjs"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "vp pack",
|
|
48
|
+
"dev": "vp pack --watch",
|
|
49
|
+
"test": "vp test",
|
|
50
|
+
"check": "vp check",
|
|
51
|
+
"generate": "node --use-system-ca ./bin/signal-spec.mjs generate --input=https://localhost:49363/api/signal-spec.json --output=./src/result --backend-uri=https://localhost:49363"
|
|
52
|
+
}
|
|
6
53
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export enum SignalRMethodType {
|
|
2
|
+
Default = "default",
|
|
3
|
+
FrontendMethod = "frontendMethod",
|
|
4
|
+
BackendMethod = "backendMethod",
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type MethodParameter = {
|
|
8
|
+
name: string;
|
|
9
|
+
type: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type SignalRClientMethod = {
|
|
13
|
+
name: string;
|
|
14
|
+
methodType: SignalRMethodType;
|
|
15
|
+
parameters: MethodParameter[];
|
|
16
|
+
returns: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
* Converts unknown types into records or void's
|
|
21
|
+
*/
|
|
22
|
+
function safeType(p: { type: string } | string | undefined) {
|
|
23
|
+
if (!p) return "void";
|
|
24
|
+
if (typeof p === "string") return p;
|
|
25
|
+
if (p.type === "object") return "Record<string, unknown>";
|
|
26
|
+
else return p.type;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/*
|
|
30
|
+
* For differentiating between interfaces and types
|
|
31
|
+
*/
|
|
32
|
+
function isInterface(name: unknown) {
|
|
33
|
+
return typeof name === "string" && name.startsWith("I");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/*
|
|
37
|
+
* For skipping frontend methods when generating invoker
|
|
38
|
+
*/
|
|
39
|
+
function isBackendMethod(method?: SignalRClientMethod) {
|
|
40
|
+
return (
|
|
41
|
+
method?.methodType === SignalRMethodType.BackendMethod ||
|
|
42
|
+
method?.methodType === SignalRMethodType.Default
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
* For skipping backend when generating handling interface
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
function isFrontendMethod(method?: SignalRClientMethod) {
|
|
51
|
+
return (
|
|
52
|
+
method?.methodType === SignalRMethodType.FrontendMethod ||
|
|
53
|
+
method?.methodType === SignalRMethodType.Default
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
* For skipping entire event block if there's no frontend methods at all
|
|
59
|
+
*/
|
|
60
|
+
function hasAnyFrontendMethods(methods?: SignalRClientMethod[]) {
|
|
61
|
+
return (
|
|
62
|
+
methods &&
|
|
63
|
+
methods.some(
|
|
64
|
+
(method) =>
|
|
65
|
+
method.methodType === SignalRMethodType.FrontendMethod ||
|
|
66
|
+
method.methodType === SignalRMethodType.Default,
|
|
67
|
+
)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/*
|
|
72
|
+
* For skipping entire invoker block if there's no backend methods at all
|
|
73
|
+
*/
|
|
74
|
+
function hasAnyBackendMethods(methods?: SignalRClientMethod[]) {
|
|
75
|
+
return (
|
|
76
|
+
methods &&
|
|
77
|
+
methods.some(
|
|
78
|
+
(method) =>
|
|
79
|
+
method.methodType === SignalRMethodType.BackendMethod ||
|
|
80
|
+
method.methodType === SignalRMethodType.Default,
|
|
81
|
+
)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/*
|
|
86
|
+
* For comparing values in templates, e.g. for checking if return type is void or not
|
|
87
|
+
*/
|
|
88
|
+
function eq(a: unknown, b: unknown) {
|
|
89
|
+
return a === b;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/*
|
|
93
|
+
* Concatenates two strings
|
|
94
|
+
*/
|
|
95
|
+
function concat(a?: string, b?: string) {
|
|
96
|
+
return (a ?? "") + (b ?? "");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default {
|
|
100
|
+
eq,
|
|
101
|
+
concat,
|
|
102
|
+
safeType,
|
|
103
|
+
isInterface,
|
|
104
|
+
isBackendMethod,
|
|
105
|
+
isFrontendMethod,
|
|
106
|
+
hasAnyFrontendMethods,
|
|
107
|
+
hasAnyBackendMethods,
|
|
108
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type GeneratorScriptProps } from "typesynth";
|
|
2
|
+
import { dtoImports, validateSpec } from "./spec.ts";
|
|
3
|
+
|
|
4
|
+
export default async function main({ data, generate }: GeneratorScriptProps) {
|
|
5
|
+
validateSpec(data);
|
|
6
|
+
await generate(
|
|
7
|
+
"config",
|
|
8
|
+
{ backendUriLiteral: JSON.stringify(process.env.SIGNAL_SPEC_BACKEND_URI ?? "") },
|
|
9
|
+
"./config.ts",
|
|
10
|
+
);
|
|
11
|
+
await generate("dtos", data, "./DTOs.ts");
|
|
12
|
+
|
|
13
|
+
for (const [name, hubData] of Object.entries(data["hubs"])) {
|
|
14
|
+
await generate(
|
|
15
|
+
"hubClient",
|
|
16
|
+
{ ...hubData, clientName: name, dtoImports: dtoImports(hubData, data.dtos) },
|
|
17
|
+
`./hooks/use${hubData.hubName}.ts`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { SignalRMethodType, type SignalRClientMethod } from "./hbs-helpers.ts";
|
|
2
|
+
|
|
3
|
+
type Hub = { hubName: string; hubContract: { methods: SignalRClientMethod[] } };
|
|
4
|
+
export type Spec = { hubs: Record<string, Hub>; dtos: Record<string, unknown> };
|
|
5
|
+
|
|
6
|
+
function invalid(path: string, expected: string): never {
|
|
7
|
+
throw new Error(
|
|
8
|
+
`Invalid SignalSpec at ${path}: expected ${expected}. The JSON may have been generated by an incompatible SignalSpec version, or may not be a SignalSpec document.`,
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function record(value: unknown, path: string): Record<string, unknown> {
|
|
13
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) invalid(path, "an object");
|
|
14
|
+
return value as Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function text(value: unknown, path: string) {
|
|
18
|
+
if (typeof value !== "string" || !value.trim()) invalid(path, "a non-empty string");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function array(value: unknown, path: string): unknown[] {
|
|
22
|
+
if (!Array.isArray(value)) invalid(path, "an array");
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function properties(value: unknown, path: string) {
|
|
27
|
+
for (const [index, entry] of array(value, path).entries()) {
|
|
28
|
+
const itemPath = `${path}[${index}]`;
|
|
29
|
+
const item = record(entry, itemPath);
|
|
30
|
+
text(item.name, `${itemPath}.name`);
|
|
31
|
+
text(item.type, `${itemPath}.type`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validateSpec(value: unknown): asserts value is Spec {
|
|
36
|
+
const spec = record(value, "$");
|
|
37
|
+
const hubs = record(spec.hubs, "$.hubs");
|
|
38
|
+
const dtos = record(spec.dtos, "$.dtos");
|
|
39
|
+
for (const [name, value] of Object.entries(dtos)) {
|
|
40
|
+
const path = `$.dtos[${JSON.stringify(name)}]`;
|
|
41
|
+
properties(record(value, path).properties, `${path}.properties`);
|
|
42
|
+
}
|
|
43
|
+
for (const [name, value] of Object.entries(hubs)) {
|
|
44
|
+
const path = `$.hubs[${JSON.stringify(name)}]`;
|
|
45
|
+
const hub = record(value, path);
|
|
46
|
+
text(hub.hubName, `${path}.hubName`);
|
|
47
|
+
const contract = record(hub.hubContract, `${path}.hubContract`);
|
|
48
|
+
for (const [index, value] of array(contract.methods, `${path}.hubContract.methods`).entries()) {
|
|
49
|
+
const methodPath = `${path}.hubContract.methods[${index}]`;
|
|
50
|
+
const method = record(value, methodPath);
|
|
51
|
+
text(method.name, `${methodPath}.name`);
|
|
52
|
+
if (!Object.values(SignalRMethodType).includes(method.methodType as SignalRMethodType))
|
|
53
|
+
invalid(`${methodPath}.methodType`, "default, frontendMethod, or backendMethod");
|
|
54
|
+
if (method.parameters !== undefined)
|
|
55
|
+
properties(method.parameters, `${methodPath}.parameters`);
|
|
56
|
+
if (method.returns !== undefined && method.returns !== null)
|
|
57
|
+
text(method.returns, `${methodPath}.returns`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function dtoImports(hub: Hub, dtos: Spec["dtos"]): string[] {
|
|
63
|
+
const names = new Set(Object.keys(dtos));
|
|
64
|
+
const imports = new Set<string>();
|
|
65
|
+
for (const method of hub.hubContract.methods) {
|
|
66
|
+
for (const type of [
|
|
67
|
+
...(method.parameters ?? []).map((parameter) => parameter.type),
|
|
68
|
+
method.returns ?? "",
|
|
69
|
+
]) {
|
|
70
|
+
for (const identifier of type.match(/[A-Za-z_$][\w$]*/g) ?? []) {
|
|
71
|
+
if (names.has(identifier)) imports.add(identifier);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return [...imports].sort();
|
|
76
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import * as SignalR from '@microsoft/signalr';
|
|
3
|
+
import { useSignalRConnections } from '@/services/signalR/SignalRConnectionsProvider.tsx';
|
|
4
|
+
import { EnableSignalRDebugInformation } from '@/application/constants/env-variables.ts';
|
|
5
|
+
import { useStableHandlers } from '@/helpers/useStableHandlers.ts';
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
//--------------------------------------------------------------------------------------------------------------
|
|
12
|
+
// Hubs
|
|
13
|
+
//--------------------------------------------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
//--------------------------------------------------------------------------------------------------------------
|
|
18
|
+
// DTOs
|
|
19
|
+
//--------------------------------------------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
{{#each x-signal-r-client.hubs}}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
{{/each}}
|
|
29
|
+
|
|
30
|
+
//--------------------------------------------------------------------------------------------------------------
|
|
31
|
+
// Change tracking
|
|
32
|
+
//--------------------------------------------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
export enum DomainType {
|
|
35
|
+
{{#each x-domain-types.types}}
|
|
36
|
+
{{this}} = '{{this}}',
|
|
37
|
+
{{/each}}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
{{#each x-domain-types.payloads}}
|
|
41
|
+
export interface {{@key}} {
|
|
42
|
+
{{#each properties}}
|
|
43
|
+
{{name}}: {{safeType this}},
|
|
44
|
+
{{/each}}
|
|
45
|
+
}
|
|
46
|
+
{{/each}}
|
|
47
|
+
|
|
48
|
+
export type DomainTypePayloadMap = {
|
|
49
|
+
{{#each x-domain-types.types}}
|
|
50
|
+
{{#if (lookup ../x-domain-types.payloads (concat this "Payload"))}}
|
|
51
|
+
[DomainType.{{this}}]: {{this}}Payload;
|
|
52
|
+
{{/if}}
|
|
53
|
+
{{/each}}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const SIGNAL_R_CLIENT_CHECKSUM = "{{magic__checksum}}";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { configureSignalR } from "@signal-spec/react-client";
|
|
2
|
+
|
|
3
|
+
//--------------------------------------------------------------------------------------------------------------
|
|
4
|
+
// <auto-generated>
|
|
5
|
+
// This file is auto-generated by "SignalSpec.ReactClient" (@signal-spec/react-client).
|
|
6
|
+
// Do not modify this file manually.
|
|
7
|
+
// If you want to adjust it, copy the template "config.hbs" and supply your edited version of it.
|
|
8
|
+
// </auto-generated>
|
|
9
|
+
//--------------------------------------------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
export const backendUri = {{{backendUriLiteral}}};
|
|
12
|
+
export const EnableSignalRDebug = false;
|
|
13
|
+
configureSignalR({ backendUri, enableDebug: EnableSignalRDebug });
|
|
14
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//--------------------------------------------------------------------------------------------------------------
|
|
2
|
+
// <auto-generated>
|
|
3
|
+
// This file is auto-generated by "SignalSpec.ReactClient" (@signal-spec/react-client).
|
|
4
|
+
// Do not modify this file manually.
|
|
5
|
+
// If you want to adjust it, copy the template "dtos.hbs" and supply your edited version of it.
|
|
6
|
+
// </auto-generated>
|
|
7
|
+
//--------------------------------------------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
{{#each dtos}}
|
|
10
|
+
{{#if (isInterface @key)}}
|
|
11
|
+
export interface {{@key}} {
|
|
12
|
+
{{#each properties}}
|
|
13
|
+
{{name}}: {{safeType this}},
|
|
14
|
+
{{/each}}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
{{else}}
|
|
18
|
+
export type {{@key}} = {
|
|
19
|
+
{{#each properties}}
|
|
20
|
+
{{name}}: {{safeType this}},
|
|
21
|
+
{{/each}}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
{{/if}}
|
|
25
|
+
{{/each}}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { backendUri } from "../config";
|
|
2
|
+
import type { HubConnection } from "@microsoft/signalr";
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
{{#if dtoImports}}
|
|
5
|
+
import type { {{#each dtoImports}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} } from "../DTOs";
|
|
6
|
+
{{/if}}
|
|
7
|
+
import { useStableHandlers, useSignalRConnections{{#if (hasAnyBackendMethods hubContract.methods)}}, safeInvokeHubMethod{{/if}} } from "@signal-spec/react-client";
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
//--------------------------------------------------------------------------------------------------------------
|
|
11
|
+
// <auto-generated>
|
|
12
|
+
// This file is auto-generated by "SignalSpec.ReactClient" (@signal-spec/react-client).
|
|
13
|
+
// Do not modify this file manually.
|
|
14
|
+
// If you want to adjust it, copy the template "hubClient.hbs" and supply your edited version of it.
|
|
15
|
+
// </auto-generated>
|
|
16
|
+
//--------------------------------------------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
{{#if (hasAnyFrontendMethods hubContract.methods)}}
|
|
19
|
+
interface {{clientName}}Frontend {
|
|
20
|
+
{{#each hubContract.methods}}
|
|
21
|
+
{{#if (isFrontendMethod this)}}
|
|
22
|
+
{{name}}: (
|
|
23
|
+
{{#if parameters}}
|
|
24
|
+
{{#each parameters}}
|
|
25
|
+
{{name}}: {{safeType this}}{{#unless @last}}, {{/unless}}
|
|
26
|
+
{{/each}}
|
|
27
|
+
{{/if}}
|
|
28
|
+
) => Promise<{{#if returns}}{{safeType returns}}{{else}}void{{/if}}>;
|
|
29
|
+
{{/if}}
|
|
30
|
+
{{/each}}
|
|
31
|
+
}
|
|
32
|
+
{{/if}}
|
|
33
|
+
|
|
34
|
+
{{#if (hasAnyBackendMethods hubContract.methods)}}
|
|
35
|
+
interface {{clientName}}Backend {
|
|
36
|
+
{{#each hubContract.methods}}
|
|
37
|
+
{{#if (isBackendMethod this)}}
|
|
38
|
+
{{name}}: (
|
|
39
|
+
{{#if parameters}}
|
|
40
|
+
{{#each parameters}}
|
|
41
|
+
{{name}}: {{safeType this}}{{#unless @last}}, {{/unless}}
|
|
42
|
+
{{/each}}
|
|
43
|
+
{{/if}}
|
|
44
|
+
) => Promise<{{#if returns}}{{safeType returns}}{{else}}void{{/if}}>;
|
|
45
|
+
{{/if}}
|
|
46
|
+
{{/each}}
|
|
47
|
+
}
|
|
48
|
+
{{/if}}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
export function use{{hubName}}(
|
|
52
|
+
{{#if (hasAnyFrontendMethods hubContract.methods)}}handlers: Partial<{{clientName}}Frontend> = {},{{/if}}
|
|
53
|
+
enabled: boolean = true,
|
|
54
|
+
): { {{#if (hasAnyBackendMethods hubContract.methods)}}invoker: {{clientName}}Methods;{{/if}} connection: HubConnection | null; } {
|
|
55
|
+
|
|
56
|
+
const { setupEventHandlers, deleteConnection } = useSignalRConnections();
|
|
57
|
+
const [connection, setConnection] = useState<HubConnection | null>(
|
|
58
|
+
null,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const stableHandlers = useStableHandlers(handlers);
|
|
62
|
+
const onConnected = (connection: HubConnection) =>
|
|
63
|
+
setConnection(connection);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!enabled) return;
|
|
67
|
+
|
|
68
|
+
setupEventHandlers('{{hubName}}', stableHandlers, onConnected);
|
|
69
|
+
|
|
70
|
+
return () => {
|
|
71
|
+
deleteConnection('{{hubName}}', stableHandlers);
|
|
72
|
+
};
|
|
73
|
+
}, [backendUri, enabled]);
|
|
74
|
+
|
|
75
|
+
{{#if (hasAnyBackendMethods hubContract.methods)}}
|
|
76
|
+
const invoker = {
|
|
77
|
+
{{#each hubContract.methods}}
|
|
78
|
+
{{#if (isBackendMethod this)}}
|
|
79
|
+
{{name}}: async (
|
|
80
|
+
{{#if parameters}}
|
|
81
|
+
{{#each parameters}}
|
|
82
|
+
{{name}}: {{safeType this}}{{#unless @last}}, {{/unless}}
|
|
83
|
+
{{/each}}
|
|
84
|
+
{{/if}}
|
|
85
|
+
) => {
|
|
86
|
+
{{#if returns}}
|
|
87
|
+
return await safeInvokeHubMethod(connection,
|
|
88
|
+
'{{name}}'
|
|
89
|
+
{{#if parameters}}, {{/if}}
|
|
90
|
+
{{#each parameters}}
|
|
91
|
+
{{name}}{{#unless @last}}, {{/unless}}
|
|
92
|
+
{{/each}}
|
|
93
|
+
) as {{safeType returns}};
|
|
94
|
+
{{else}}
|
|
95
|
+
await safeInvokeHubMethod(connection,
|
|
96
|
+
'{{name}}'
|
|
97
|
+
{{#if parameters}}, {{/if}}
|
|
98
|
+
{{#each parameters}}
|
|
99
|
+
{{name}}{{#unless @last}}, {{/unless}}
|
|
100
|
+
{{/each}}
|
|
101
|
+
);
|
|
102
|
+
{{/if}}
|
|
103
|
+
}{{#unless @last}},{{/unless}}
|
|
104
|
+
{{/if}}
|
|
105
|
+
{{/each}}
|
|
106
|
+
};
|
|
107
|
+
{{/if}}
|
|
108
|
+
|
|
109
|
+
return { {{#if (hasAnyBackendMethods hubContract.methods)}}invoker,{{/if}} connection };
|
|
110
|
+
}
|