@zachacious/protoc-gen-connect-vue 1.0.2
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/LICENSE +21 -0
- package/README.md +2 -0
- package/bin/protoc-gen-connect-vue +2 -0
- package/dist/generator.js +336914 -0
- package/package.json +33 -0
- package/templates/api.ts.mustache +56 -0
- package/templates/client.ts.mustache +128 -0
- package/templates/index.ts.mustache +5 -0
- package/templates/rpc.ts.mustache +50 -0
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zachacious/protoc-gen-connect-vue",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Smart TanStack Query & ConnectRPC SDK generator for Vue.js",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/generator.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"protoc-gen-connect-vue": "bin/protoc-gen-connect-vue"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"bin",
|
|
13
|
+
"templates"
|
|
14
|
+
],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "bun build ./src/generator.ts --outdir ./dist --target node --bundle",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@bufbuild/protobuf": "^2.10.2",
|
|
24
|
+
"@bufbuild/protoplugin": "^2.10.2",
|
|
25
|
+
"mustache": "^4.2.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/mustache": "^4.2.6",
|
|
29
|
+
"@types/node": "^25.0.3",
|
|
30
|
+
"ts-node": "^10.9.2",
|
|
31
|
+
"typescript": "^5.9.3"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { computed } from "vue";
|
|
2
|
+
import { useQuery, useMutation, useInfiniteQuery, useQueryClient, useIsFetching, useIsMutating } from "@tanstack/vue-query";
|
|
3
|
+
import { ConnectError } from "@connectrpc/connect";
|
|
4
|
+
import { useGrpcClient } from "./client";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
{{#rpcs}}
|
|
8
|
+
{{queryDefinitionName}} as {{queryDefinitionName}}Def,
|
|
9
|
+
{{/rpcs}}
|
|
10
|
+
} from "./gen/{{{connectQueryFile}}}";
|
|
11
|
+
|
|
12
|
+
{{#wktImports}}import type { {{.}} } from "@bufbuild/protobuf";{{/wktImports}}
|
|
13
|
+
{{#localImports}}import type { {{.}} } from "./gen/{{{protoPbFile}}}";{{/localImports}}
|
|
14
|
+
{{#externalImports}}
|
|
15
|
+
import type { {{#types}}{{.}}, {{/types}} } from "{{{path}}}";
|
|
16
|
+
{{/externalImports}}
|
|
17
|
+
|
|
18
|
+
export type APIResponse<T> = { error: string | null; data: T | null; };
|
|
19
|
+
|
|
20
|
+
async function callConnect<ReqT, ResT, DataT>(
|
|
21
|
+
connectMethod: (req: ReqT) => Promise<ResT>,
|
|
22
|
+
request: ReqT,
|
|
23
|
+
dataExtractor: (res: ResT) => DataT
|
|
24
|
+
): Promise<APIResponse<DataT>> {
|
|
25
|
+
try {
|
|
26
|
+
const res = await connectMethod(request);
|
|
27
|
+
return { error: null, data: dataExtractor(res) };
|
|
28
|
+
} catch (err) {
|
|
29
|
+
return {
|
|
30
|
+
error: err instanceof ConnectError ? err.message : "Unknown error",
|
|
31
|
+
data: null
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const useApi = () => {
|
|
37
|
+
const { client } = useGrpcClient();
|
|
38
|
+
const queryClient = useQueryClient();
|
|
39
|
+
|
|
40
|
+
// Global background activity tracking
|
|
41
|
+
const isFetching = useIsFetching();
|
|
42
|
+
const isMutating = useIsMutating();
|
|
43
|
+
const isGlobalLoading = computed(() => isFetching.value > 0 || isMutating.value > 0);
|
|
44
|
+
|
|
45
|
+
{{#rpcs}}
|
|
46
|
+
{{> rpc}}
|
|
47
|
+
{{/rpcs}}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
{{#rpcs}}
|
|
51
|
+
{{functionName}},
|
|
52
|
+
{{hookName}},
|
|
53
|
+
{{/rpcs}}
|
|
54
|
+
isGlobalLoading,
|
|
55
|
+
};
|
|
56
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { {{serviceName}} } from './gen/{{{protoPbFile}}}';
|
|
2
|
+
import { createClient, ConnectError, type Interceptor } from "@connectrpc/connect";
|
|
3
|
+
import { createConnectTransport } from "@connectrpc/connect-web";
|
|
4
|
+
|
|
5
|
+
let baseUrl = 'http://localhost:3000';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Configure the API endpoint at runtime.
|
|
9
|
+
*/
|
|
10
|
+
export const setBaseUrl = (url: string) => {
|
|
11
|
+
baseUrl = url;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// --- AUTH RESOLVER ---
|
|
15
|
+
export type AuthResolver = () => string | null | Promise<string | null>;
|
|
16
|
+
let resolveToken: AuthResolver = () => null;
|
|
17
|
+
export const setAuthResolver = (resolver: AuthResolver) => { resolveToken = resolver; };
|
|
18
|
+
|
|
19
|
+
// --- ERROR CALLBACK ---
|
|
20
|
+
export type ErrorCallback = (error: ConnectError, url: string) => void;
|
|
21
|
+
let onError: ErrorCallback = () => {};
|
|
22
|
+
export const setSDKErrorCallback = (cb: ErrorCallback) => { onError = cb; };
|
|
23
|
+
|
|
24
|
+
const transportInterceptor: Interceptor = (next) => async (req) => {
|
|
25
|
+
const token = await resolveToken();
|
|
26
|
+
if (token) req.header.set("x-api-key", token);
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
return await next(req);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (err instanceof ConnectError) {
|
|
32
|
+
onError(err, req.url);
|
|
33
|
+
}
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const transport = createConnectTransport({
|
|
39
|
+
baseUrl,
|
|
40
|
+
interceptors: [transportInterceptor],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const client = createClient({{serviceName}}, transport);
|
|
44
|
+
|
|
45
|
+
export const globalQueryConfig = {
|
|
46
|
+
defaultOptions: {
|
|
47
|
+
queries: {
|
|
48
|
+
staleTime: 1000 * 60 * 5,
|
|
49
|
+
gcTime: 1000 * 60 * 30,
|
|
50
|
+
networkMode: 'offlineFirst',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const useGrpcClient = () => ({ client });
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
|
|
59
|
+
// the following is an example of how to use the hybrid api
|
|
60
|
+
|
|
61
|
+
// this generated sdk uses both raw wrappers and tanstack query
|
|
62
|
+
|
|
63
|
+
<script setup lang="ts">
|
|
64
|
+
import { useApi } from "@/api";
|
|
65
|
+
import { ref } from "vue";
|
|
66
|
+
|
|
67
|
+
const api = useApi();
|
|
68
|
+
|
|
69
|
+
// 1. Using the TanStack Hook (Auto-refetches)
|
|
70
|
+
const { data, isLoading } = api.useGetProfileQuery({ userId: "1" });
|
|
71
|
+
|
|
72
|
+
// 2. Using the Manual Wrapper (For actions/buttons)
|
|
73
|
+
const manualResult = ref("");
|
|
74
|
+
async function handleUpdate() {
|
|
75
|
+
const { data, error } = await api.updateProfile({ name: "New User" });
|
|
76
|
+
manualResult.value = error ? error : "Success!";
|
|
77
|
+
}
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<template>
|
|
81
|
+
<div v-if="isLoading">Loading...</div>
|
|
82
|
+
<div v-else>{{ data?.name }}</div>
|
|
83
|
+
|
|
84
|
+
<button @click="handleUpdate">Update Manually</button>
|
|
85
|
+
<p>{{ manualResult }}</p>
|
|
86
|
+
</template>
|
|
87
|
+
|
|
88
|
+
// use this to set the error interceptor - for instance to log to sentry or something
|
|
89
|
+
import { setAuthResolver, setSDKErrorCallback } from '@/api';
|
|
90
|
+
import { useAuthStore } from '@/stores/auth';
|
|
91
|
+
|
|
92
|
+
const auth = useAuthStore();
|
|
93
|
+
|
|
94
|
+
// Dynamic Auth: Returns current token from Pinia
|
|
95
|
+
setAuthResolver(() => auth.token);
|
|
96
|
+
|
|
97
|
+
// Flexible Error Handling:
|
|
98
|
+
setSDKErrorCallback((err, url) => {
|
|
99
|
+
if (process.env.NODE_ENV === 'development') {
|
|
100
|
+
console.error(`SDK Error at ${url}:`, err.message);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Custom Production Logic
|
|
104
|
+
if (err.code === 16) { // Unauthenticated
|
|
105
|
+
auth.logout();
|
|
106
|
+
window.location.href = '/login';
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
// how to setup the auth interceptors
|
|
112
|
+
// to make sure the endpoints get the token
|
|
113
|
+
|
|
114
|
+
// src/stores/auth.ts
|
|
115
|
+
import { defineStore } from 'pinia';
|
|
116
|
+
import { setAuthResolver } from '@/api/client';
|
|
117
|
+
|
|
118
|
+
export const useAuthStore = defineStore('auth', () => {
|
|
119
|
+
const token = ref(null);
|
|
120
|
+
|
|
121
|
+
// Register with the SDK
|
|
122
|
+
setAuthResolver(() => token.value);
|
|
123
|
+
|
|
124
|
+
return { token };
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
*/
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard Async: {{functionName}}
|
|
3
|
+
*/
|
|
4
|
+
const {{functionName}} = async (req: {{inputType}}): Promise<APIResponse<{{outputType}}>> => {
|
|
5
|
+
const res = await callConnect(client.{{functionName}}, req, (res) => res);
|
|
6
|
+
if (!res.error) queryClient.invalidateQueries({ queryKey: ["{{resource}}"] });
|
|
7
|
+
return res;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* TanStack Hook: {{hookName}}
|
|
12
|
+
*/
|
|
13
|
+
const {{hookName}} = (
|
|
14
|
+
{{#isQuery}}
|
|
15
|
+
input: {{inputType}},
|
|
16
|
+
options: any = {}
|
|
17
|
+
{{/isQuery}}
|
|
18
|
+
{{^isQuery}}
|
|
19
|
+
options: any = {}
|
|
20
|
+
{{/isQuery}}
|
|
21
|
+
) => {
|
|
22
|
+
{{#isPaginated}}
|
|
23
|
+
return useInfiniteQuery({
|
|
24
|
+
...{{queryDefinitionName}}Def,
|
|
25
|
+
...options,
|
|
26
|
+
queryKey: ["{{resource}}", "{{functionName}}", input],
|
|
27
|
+
initialPageParam: 1,
|
|
28
|
+
getNextPageParam: options.getNextPageParam || ((lastPage: any) => lastPage.nextPage ?? undefined),
|
|
29
|
+
});
|
|
30
|
+
{{/isPaginated}}
|
|
31
|
+
{{^isPaginated}}
|
|
32
|
+
{{#isQuery}}
|
|
33
|
+
return useQuery({
|
|
34
|
+
...{{queryDefinitionName}}Def,
|
|
35
|
+
...options,
|
|
36
|
+
queryKey: ["{{resource}}", "{{functionName}}", input],
|
|
37
|
+
});
|
|
38
|
+
{{/isQuery}}
|
|
39
|
+
{{^isQuery}}
|
|
40
|
+
return useMutation({
|
|
41
|
+
...{{queryDefinitionName}}Def,
|
|
42
|
+
...options,
|
|
43
|
+
onSuccess: async (data, variables, context) => {
|
|
44
|
+
await queryClient.invalidateQueries({ queryKey: ["{{resource}}"] });
|
|
45
|
+
if (options.onSuccess) return options.onSuccess(data, variables, context);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
{{/isQuery}}
|
|
49
|
+
{{/isPaginated}}
|
|
50
|
+
};
|