@carvajalconsultants/headstart 1.0.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 ADDED
@@ -0,0 +1,210 @@
1
+ # Headstart
2
+
3
+ Library to assist in integrating PostGraphile with Tanstack Start and URQL.
4
+
5
+ This library is made up of:
6
+
7
+ - An URQL Exchange that queries grafast for SSR pages (so we don't make an unecessary HTTP request)
8
+ - A Grafserv adapter to work with Tanstack Start, including Web Socket support.
9
+ - SSR Exchange to be used on the server and client for hydration
10
+
11
+ ## Installation
12
+
13
+ 1. Initialize Tanstack Start as per: https://tanstack.com/router/latest/docs/framework/react/start/getting-started
14
+
15
+ 2. Install Postgraphile as a library as per: https://tanstack.com/router/latest/docs/framework/react/start/getting-started
16
+ Specifically, the `graphile.config.ts` and `pgl.ts`. If you need WebSocket support, make sure to add a plugin in `graphile.config.ts` as per: https://postgraphile.org/postgraphile/next/subscriptions
17
+
18
+ 3. Enable WebSockets in `app.config.ts` if you need it:
19
+
20
+ ```
21
+ // app.config.ts
22
+ import { defineConfig } from "@tanstack/start/config";
23
+
24
+ export default defineConfig({
25
+ server: {
26
+ experimental: {
27
+ websocket: true,
28
+ },
29
+ },
30
+ });
31
+ ```
32
+
33
+ 4. Make sure to add the `/api` endpoint to the grafserv configuration so that Ruru GraphQL client works correctly:
34
+
35
+ ```
36
+ // graphile.config.ts
37
+ const preset: GraphileConfig.Preset = {
38
+ ...
39
+ grafserv: {
40
+ ...
41
+ graphqlPath: "/api",
42
+ eventStreamPath: "/api",
43
+ },
44
+ ...
45
+ };
46
+ ```
47
+
48
+ 5. Add the api.ts file so that it calls our GraphQL handler. This will receive all GraphQL requests at the /api endpoint.
49
+
50
+ ```
51
+ // app/api.ts
52
+ import { createGraphQLRouteHandler } from "@carvajalconsultants/headstart";
53
+ import { pgl } from "../pgl";
54
+
55
+ export default createGraphQLRouteHandler(pgl);
56
+ ```
57
+
58
+ 6. Now we need to configure URQL for client and server rendering, first we start with the server. Create this provider:
59
+
60
+ ```
61
+ // app/graphql/serverProvider.tsx
62
+ import { Client, Provider } from "urql";
63
+ import { grafastExchange, ssr } from "@carvajalconsultants/headstart";
64
+ import { pgl } from "../../pgl";
65
+
66
+ /**
67
+ * Configure URQL for server side querying with Grafast.
68
+ *
69
+ * This removes the need to make an HTTP request to ourselves and simply executes the GraphQL query.
70
+ */
71
+ export const client = new Client({
72
+ url: ".",
73
+ exchanges: [ssr, grafastExchange(pgl)],
74
+ });
75
+ ```
76
+
77
+ 7. Create the server side router which uses Grafast to execute queries:
78
+
79
+ ```
80
+ // app/serverRouter.tsx
81
+ import { ssr } from "@carvajalconsultants/headstart";
82
+ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
83
+ import { Provider } from "urql";
84
+ import { client } from "./graphql/serverProvider";
85
+ import { routeTree } from "./routeTree.gen";
86
+
87
+ export function createRouter() {
88
+ const router = createTanStackRouter({
89
+ routeTree,
90
+ context: {
91
+ client,
92
+ },
93
+
94
+ // Send data to client so URQL can be hydrated.
95
+ dehydrate: () => ({ initialData: ssr.extractData() }),
96
+
97
+ // Wrap our entire route with the URQL provider so we can execute queries and mutations.
98
+ Wrap: ({ children }) => <Provider value={client}>{children}</Provider>,
99
+ });
100
+
101
+ return router;
102
+ }
103
+ ```
104
+
105
+ 8. Modify the TSR server-side rendering function to use this new router:
106
+
107
+ ```
108
+ // app/ssr.tsx
109
+ /// <reference types="vinxi/types/server" />
110
+ import { getRouterManifest } from "@tanstack/start/router-manifest";
111
+ import {
112
+ createStartHandler,
113
+ defaultStreamHandler,
114
+ } from "@tanstack/start/server";
115
+
116
+ import { createRouter } from "./serverRouter";
117
+
118
+ export default createStartHandler({
119
+ createRouter,
120
+ getRouterManifest,
121
+ })(defaultStreamHandler);
122
+ ```
123
+
124
+ 9. Add the client side router which uses the fetch exchange to execute queries, mutations, etc.:
125
+
126
+ ```
127
+ // app/clientRouter.tsx
128
+ import { ssr } from "@carvajalconsultants/headstart";
129
+ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
130
+ import { Provider } from "urql";
131
+ import { client } from "./graphql/clientProvider";
132
+ import { routeTree } from "./routeTree.gen";
133
+
134
+ export function createRouter() {
135
+ const router = createTanStackRouter({
136
+ routeTree,
137
+ context: {
138
+ client,
139
+ },
140
+ hydrate: (dehydrated) => {
141
+ // Hydrate URQL with data passed by TSR, this is generated by dehydrate function in server router.
142
+ ssr.restoreData(dehydrated.initialData);
143
+ },
144
+
145
+ // Wrap our entire route with the URQL provider so we can execute queries and mutations.
146
+ Wrap: ({ children }) => <Provider value={client}>{children}</Provider>,
147
+ });
148
+
149
+ return router;
150
+ }
151
+ ```
152
+
153
+ 10. Tell TSR to use our client side router:
154
+
155
+ ```
156
+ // app/client.tsx
157
+ /// <reference types="vinxi/types/client" />
158
+ import { StartClient } from "@tanstack/start";
159
+ import { hydrateRoot } from "react-dom/client";
160
+ import { createRouter } from "./clientRouter";
161
+
162
+ const router = createRouter();
163
+
164
+ // biome-ignore lint: Safe enough to assume root element will be there
165
+ hydrateRoot(document.getElementById("root")!, <StartClient router={router} />);
166
+ ```
167
+
168
+ 11. Last but not least, you're ready to start using URQL on your components and pages. First we create the route using the loader option so we can pre-load data:
169
+
170
+ ```
171
+ export const Route = createFileRoute("/")({
172
+ ...
173
+ validateSearch: zodSearchValidator(paramSchema),
174
+
175
+ loaderDeps: ({ search: { page } }) => ({ page }),
176
+ loader: ({ context, deps: { page } }) =>
177
+ context.client.query(
178
+ gql`...`
179
+ { first: CHARITIES_PER_PAGE, offset: (page - 1) * CHARITIES_PER_PAGE },
180
+ ),
181
+ ...
182
+ });
183
+ ```
184
+
185
+ 12. Now in your component, you can query with URQL as you normally would:
186
+
187
+ ```
188
+ const Home = () => {
189
+ const { page } = Route.useSearch();
190
+
191
+ const [{ data, error }] = useQuery({
192
+ query: gql`...`,
193
+ variables: {
194
+ first: CHARITIES_PER_PAGE,
195
+ offset: (page - 1) * CHARITIES_PER_PAGE,
196
+ },
197
+ });
198
+
199
+ // Subscribe to any data changes on the server
200
+ useSubscription({ query: allCharitiesSubscription });
201
+ }
202
+ ```
203
+
204
+ ## Deployment
205
+
206
+ 1. Run `bun run build`
207
+ 2. `cd .output/server`
208
+ 3. `rm -rf node_modules`
209
+ 4. `bun install`
210
+ 5. `bun run index.mjs`
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@carvajalconsultants/headstart",
3
+ "version": "1.0.0",
4
+ "description": "Library to assist in integrating PostGraphile with Tanstack Start and URQL.",
5
+ "license": "MIT",
6
+ "author": "Miguel Carvajal <omar@carvajalonline.com>",
7
+ "repository": "github:carvajalconsultants/headstart",
8
+ "type": "module",
9
+ "main": "src/index.ts",
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "scripts": {
14
+ "lint": "biome check --write"
15
+ },
16
+ "dependencies": {
17
+ "@biomejs/biome": "^1.9.4",
18
+ "@tanstack/start": "^1.100.0",
19
+ "postgraphile": "^5.0.0-beta.37",
20
+ "urql": "^4.2.1",
21
+ "vinxi": "^0.5.2"
22
+ },
23
+ "devDependencies": {
24
+ "@types/ws": "^8.5.14",
25
+ "typescript": "^5.7.3"
26
+ }
27
+ }
@@ -0,0 +1,93 @@
1
+ import { execute, hookArgs } from "grafast";
2
+ import {
3
+ CombinedError,
4
+ type Exchange,
5
+ type Operation,
6
+ type OperationResult,
7
+ } from "urql";
8
+ import { filter, fromPromise, mergeMap, pipe } from "wonka";
9
+
10
+ import type { Maybe } from "grafast";
11
+ import type { PostGraphileInstance } from "postgraphile";
12
+
13
+ export const grafastExchange = (pgl: PostGraphileInstance): Exchange => {
14
+ return () => (ops$) => {
15
+ return pipe(
16
+ ops$,
17
+
18
+ // Skip anything that's not a query since that's all we can run
19
+ filter((operation) => operation.kind === "query"),
20
+
21
+ mergeMap((operation) => fromPromise(runGrafastQuery(pgl, operation))),
22
+ );
23
+ };
24
+ };
25
+
26
+ /**
27
+ * Run the URQL query with Grafast, without making an HTTP request to ourselves (which is unnecessary overhead).
28
+ *
29
+ * @param pgl Postgraphile instance that we will run the URQL query with.
30
+ * @param operation URQL operation, typically a query that will be run with Grafast.
31
+ * @returns Query data, which is used to pass along in the Exchange chain.
32
+ */
33
+ const runGrafastQuery = async (
34
+ pgl: PostGraphileInstance,
35
+ operation: Operation,
36
+ ): Promise<OperationResult> => {
37
+ try {
38
+ const { variables: variableValues, query: document } = operation;
39
+
40
+ const args = {
41
+ resolvedPreset: pgl.getResolvedPreset(),
42
+ schema: await pgl.getSchema(),
43
+ document,
44
+ variableValues: variableValues as Maybe<{
45
+ readonly [variable: string]: unknown;
46
+ }>,
47
+ };
48
+ // Add the Graphile context to our args so Grafast can run
49
+ await hookArgs(args);
50
+
51
+ // Run the query with Grafast, to get the result from PostgreSQL
52
+ const result = await execute(args);
53
+
54
+ if (!result || typeof result !== "object" || !("data" in result)) {
55
+ throw new Error("Unexpected result format from execute");
56
+ }
57
+
58
+ const { data, errors, extensions } = result;
59
+
60
+ if (errors && errors.length > 0) {
61
+ return {
62
+ operation,
63
+ data,
64
+ error: new CombinedError({ graphQLErrors: [...errors] }),
65
+ extensions,
66
+ stale: false,
67
+ hasNext: false,
68
+ };
69
+ }
70
+
71
+ return {
72
+ operation,
73
+ data,
74
+ error: undefined,
75
+ extensions,
76
+ stale: false,
77
+ hasNext: false,
78
+ };
79
+ } catch (error) {
80
+ console.error("Error in runGrafastQuery:", error);
81
+
82
+ return {
83
+ operation,
84
+ data: undefined,
85
+ error: new CombinedError({
86
+ networkError: error instanceof Error ? error : new Error(String(error)),
87
+ }),
88
+ extensions: undefined,
89
+ stale: false,
90
+ hasNext: false,
91
+ };
92
+ }
93
+ };
@@ -0,0 +1,112 @@
1
+ import { CloseCode, makeServer } from "graphql-ws";
2
+ import { makeGraphQLWSConfig } from "postgraphile/grafserv";
3
+ import { grafserv } from "postgraphile/grafserv/h3/v1";
4
+ import { defineEventHandler, getHeader, toWebRequest } from "vinxi/http";
5
+
6
+ import type { IncomingMessage } from "node:http";
7
+ import type { StartAPIHandlerCallback } from "@tanstack/start/api";
8
+ import { type Hooks, type Peer, defineHooks } from "crossws";
9
+ import type { GrafservBase } from "grafserv";
10
+ import type { H3Grafserv } from "grafserv/h3/v1";
11
+ import type { PostGraphileInstance } from "postgraphile";
12
+ import type { WebSocket } from "ws";
13
+
14
+ /**
15
+ * This is an H3 handler that does all of the GraphQL request processing in TSR (including Subscriptions).
16
+ *
17
+ * Code is basically from: https://discord.com/channels/489127045289476126/498852330754801666/1260251871877271704
18
+ */
19
+
20
+ /**
21
+ * TODO: make it generic when crossws implements the WS API (Peer.close, Peer.protocol)
22
+ * instead of accessing the socket directly through context (server agnostic)
23
+ * https://github.com/unjs/crossws/issues/23
24
+ * https://github.com/unjs/crossws/issues/16
25
+ */
26
+ function makeWsHandler(instance: H3Grafserv): Partial<Hooks> {
27
+ const graphqlWsServer = makeServer(makeGraphQLWSConfig(instance));
28
+
29
+ return {
30
+ open(peer) {
31
+ // TODO Getting the socket like this causes problems deploying to bun. We really need a better websocket implementation with crossws
32
+ // Websocket from the H3 instance, so this is the browser client
33
+ const socket = peer.websocket as Required<WebSocket>;
34
+
35
+ // a new socket opened, let graphql-ws take over
36
+ const closed = graphqlWsServer.opened(
37
+ {
38
+ protocol: socket.protocol, // will be validated
39
+ send: (data) =>
40
+ new Promise((resolve, reject) => {
41
+ socket.send(data, (err: Error) =>
42
+ err ? reject(err) : resolve(),
43
+ );
44
+ }),
45
+ close: (code, reason) => {
46
+ socket.close(code, reason);
47
+ },
48
+ onMessage: (cb) => {
49
+ socket.on("message", async (event) => {
50
+ try {
51
+ await cb(event.toString());
52
+ } catch (err) {
53
+ try {
54
+ socket.close(CloseCode.InternalServerError, err.message);
55
+ } catch {
56
+ // noop
57
+ }
58
+ }
59
+ });
60
+ },
61
+ },
62
+ // pass values to the `extra` field in the context
63
+ //{ peer, socket, request },
64
+ {},
65
+ );
66
+ // socket.once("close", (_socket, code: number, reason: Buffer) => closed(code, reason.toString()));
67
+ },
68
+ };
69
+ }
70
+
71
+ // Make sure this is not instantiated more than once
72
+ let serv: GrafservBase;
73
+
74
+ /**
75
+ * Actual H3 endpoint handler that intercepts requests to /api/graphql and processes with Postgraphile.
76
+ *
77
+ * @param pgl Postgraphile instance that has the connection to the database.
78
+ * @param cb Start API handler callback, usually defaultAPIFileRouteHandler from Start.
79
+ */
80
+ export const createStartAPIHandler = (
81
+ pgl: PostGraphileInstance,
82
+ cb: StartAPIHandlerCallback,
83
+ ) => {
84
+ if (!serv) {
85
+ // Initialize Grafserv which is the one that actually processes the GraphQL requests.
86
+ serv = pgl.createServ(grafserv);
87
+ }
88
+
89
+ return defineEventHandler({
90
+ handler: async (event) => {
91
+ const request = toWebRequest(event);
92
+
93
+ // Sad interception until we can get the socket instance in the Tanstack Start API
94
+ if (request.url.indexOf("/api/graphql") > -1) {
95
+ const acceptHeader = getHeader(event, "accept");
96
+
97
+ if (acceptHeader === "text/event-stream") {
98
+ // SSE events are handled here
99
+ return serv.handleEventStreamEvent(event);
100
+ }
101
+
102
+ // Process query and mutation GraphQL requests here
103
+ return serv.handleGraphQLEvent(event);
104
+ }
105
+
106
+ return await cb({ request });
107
+ },
108
+
109
+ // Initialize the handler that manages WebSocket subscriptions
110
+ websocket: makeWsHandler(serv),
111
+ });
112
+ };
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./graphQLRouteHandler";
2
+ export * from "./grafastExchange";
3
+ export * from "./ssrExchange";
@@ -0,0 +1,16 @@
1
+ import { ssrExchange } from "urql";
2
+
3
+ const isServerSide = typeof window === "undefined";
4
+
5
+ /**
6
+ * URQL Exchange for server-side rendering.
7
+ *
8
+ * It is used on the client and server.
9
+ *
10
+ * On the server, it rounds up the data so that it can be sent to the client to hydrate.
11
+ *
12
+ * On the client side, it assists in hydrating the data so we don't hit the server with data we already have.
13
+ */
14
+ export const ssr = ssrExchange({
15
+ isClient: !isServerSide,
16
+ });