@laguzjs/server 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maxence Leguery
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,31 @@
1
- # Temporary Holding Version
1
+ # @laguzjs/server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The server side of [laguz](https://github.com/maxenceleguery/laguz). Executes
4
+ each root field of a batched GraphQL query on its own, streams the results as
5
+ they complete, and resolves queries that depend on each other in the same
6
+ request.
7
+
8
+ ```sh
9
+ npm install @laguzjs/server graphql
10
+ ```
11
+
12
+ ```ts
13
+ import { batchLoader, createHandler } from "@laguzjs/server";
14
+
15
+ export const POST = createHandler({
16
+ schema,
17
+ rootValue,
18
+ context: () => ({ users: batchLoader(loadUsersById) }),
19
+ });
20
+ ```
21
+
22
+ `createHandler` takes a `Request` and returns a `Response`, so it runs on Node,
23
+ Bun, Deno, Cloudflare Workers and Next.js route handlers. Pass your server's
24
+ `execute` and `validate` to keep envelop or Yoga plugins.
25
+
26
+ Documentation: [guide](https://github.com/maxenceleguery/laguz/blob/main/docs/guide.md#server)
27
+ and [protocol](https://github.com/maxenceleguery/laguz/blob/main/docs/protocol.md).
28
+
29
+ ## License
30
+
31
+ MIT
@@ -0,0 +1,47 @@
1
+ import { execute, validate, type ExecutionResult, type GraphQLSchema } from "graphql";
2
+ /** Protocol version announced in `extensions.laguz`. See PROTOCOL.md. */
3
+ export declare const PROTOCOL = 1;
4
+ export type ExecuteOptions = {
5
+ schema: GraphQLSchema;
6
+ source: string;
7
+ rootValue?: unknown;
8
+ contextValue?: unknown;
9
+ /** Your server's `execute`, e.g. from envelop or Yoga, to keep its plugins. */
10
+ execute?: typeof execute;
11
+ /** Your server's `validate`. */
12
+ validate?: typeof validate;
13
+ };
14
+ export type HandlerOptions = Omit<ExecuteOptions, "source" | "contextValue"> & {
15
+ /** Called once per request. Every field of the request shares the returned context. */
16
+ context?: (req: Request) => unknown;
17
+ };
18
+ /**
19
+ * Execute each root field of `source` on its own and yield the results in the
20
+ * order they complete. A slow field doesn't delay the others, and an invalid
21
+ * field fails without taking the rest of the query down.
22
+ *
23
+ * A field may take an argument from an earlier field's result
24
+ * (`team(id: $_ref__q0__teamId)`). It runs once that field resolves, with the
25
+ * value substituted, so a chain of dependent queries costs one round trip.
26
+ */
27
+ export declare function splitExecute(options: ExecuteOptions): AsyncGenerator<ExecutionResult>;
28
+ /** Fold split results back into one standard GraphQL response. */
29
+ export declare function merged(results: AsyncIterable<ExecutionResult>): Promise<ExecutionResult>;
30
+ /**
31
+ * A fetch-style handler (Bun, Deno, Cloudflare Workers, Next.js route handlers)
32
+ * for POST `{ query }`. Streams NDJSON when the client accepts it and answers
33
+ * with one merged JSON response otherwise.
34
+ */
35
+ export declare function createHandler(options: HandlerOptions): (req: Request) => Promise<Response>;
36
+ /**
37
+ * Stream results as newline-delimited JSON. If you compress the response,
38
+ * flush the compressor after each line, or the stream arrives all at once.
39
+ */
40
+ export declare function ndjson(results: AsyncIterable<unknown>): Response;
41
+ /**
42
+ * A minimal DataLoader: keys requested in the same tick are passed to `batch`
43
+ * together, and each key is loaded once. Create one per request (in `context`)
44
+ * so all the fields of a request share it.
45
+ */
46
+ export declare function batchLoader<K, V>(batch: (keys: K[]) => Promise<V[]>): (key: K) => Promise<V>;
47
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,OAAO,EAIP,QAAQ,EAIR,KAAK,eAAe,EACpB,KAAK,aAAa,EAInB,MAAM,SAAS,CAAC;AAEjB,yEAAyE;AACzE,eAAO,MAAM,QAAQ,IAAI,CAAC;AAE1B,MAAM,MAAM,cAAc,GAAG;IAC3B,MAAM,EAAE,aAAa,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,+EAA+E;IAC/E,OAAO,CAAC,EAAE,OAAO,OAAO,CAAC;IACzB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,OAAO,QAAQ,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG,IAAI,CAAC,cAAc,EAAE,QAAQ,GAAG,cAAc,CAAC,GAAG;IAC7E,uFAAuF;IACvF,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC;CACrC,CAAC;AA+CF;;;;;;;;GAQG;AACH,wBAAuB,YAAY,CAAC,OAAO,EAAE,cAAc,GAAG,cAAc,CAAC,eAAe,CAAC,CAO5F;AAwED,kEAAkE;AAClE,wBAAsB,MAAM,CAAC,OAAO,EAAE,aAAa,CAAC,eAAe,CAAC,GAAG,OAAO,CAAC,eAAe,CAAC,CAU9F;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,cAAc,SAChC,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAU/C;AAED;;;GAGG;AACH,wBAAgB,MAAM,CAAC,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,GAAG,QAAQ,CAWhE;AAMD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,OAAO,CAAC,CAAC,EAAE,CAAC,SAarD,CAAC,KAAG,OAAO,CAAC,CAAC,CAAC,CAQ5B"}
package/dist/server.js ADDED
@@ -0,0 +1,199 @@
1
+ import { execute, GraphQLError, Kind, parse, validate, visit, } from "graphql";
2
+ /** Protocol version announced in `extensions.laguz`. See PROTOCOL.md. */
3
+ export const PROTOCOL = 1;
4
+ function toLiteral(v) {
5
+ if (v === null || v === undefined)
6
+ return { kind: Kind.NULL };
7
+ if (Array.isArray(v))
8
+ return { kind: Kind.LIST, values: v.map(toLiteral) };
9
+ if (typeof v === "object") {
10
+ return {
11
+ kind: Kind.OBJECT,
12
+ fields: Object.entries(v).map(([key, value]) => ({
13
+ kind: Kind.OBJECT_FIELD,
14
+ name: { kind: Kind.NAME, value: key },
15
+ value: toLiteral(value),
16
+ })),
17
+ };
18
+ }
19
+ if (typeof v === "number") {
20
+ return { kind: Number.isInteger(v) ? Kind.INT : Kind.FLOAT, value: String(v) };
21
+ }
22
+ if (typeof v === "boolean")
23
+ return { kind: Kind.BOOLEAN, value: v };
24
+ return { kind: Kind.STRING, value: String(v) };
25
+ }
26
+ function failed(alias, errors) {
27
+ const list = typeof errors === "string" ? [new GraphQLError(errors)] : errors;
28
+ if (!alias)
29
+ return { errors: list };
30
+ return {
31
+ data: { [alias]: null },
32
+ errors: list.map((err) => new GraphQLError(err.message, { path: [alias] })),
33
+ };
34
+ }
35
+ // `$_ref__q0__team__id` reads the value at `q0.team.id`.
36
+ function refsIn(node) {
37
+ const refs = [];
38
+ visit(node, {
39
+ Variable(v) {
40
+ const [tag, alias, ...path] = v.name.value.split("__");
41
+ if (tag === "_ref" && alias && path.length)
42
+ refs.push({ variable: v.name.value, alias, path });
43
+ },
44
+ });
45
+ return refs;
46
+ }
47
+ /**
48
+ * Execute each root field of `source` on its own and yield the results in the
49
+ * order they complete. A slow field doesn't delay the others, and an invalid
50
+ * field fails without taking the rest of the query down.
51
+ *
52
+ * A field may take an argument from an earlier field's result
53
+ * (`team(id: $_ref__q0__teamId)`). It runs once that field resolves, with the
54
+ * value substituted, so a chain of dependent queries costs one round trip.
55
+ */
56
+ export async function* splitExecute(options) {
57
+ let first = true;
58
+ for await (const result of executeFields(options)) {
59
+ // Announces protocol support so clients can start forwarding refs.
60
+ yield first ? { ...result, extensions: { ...result.extensions, laguz: PROTOCOL } } : result;
61
+ first = false;
62
+ }
63
+ }
64
+ async function* executeFields({ schema, source, rootValue, contextValue, execute: run = execute, validate: check = validate, }) {
65
+ let doc;
66
+ try {
67
+ doc = parse(source);
68
+ }
69
+ catch (err) {
70
+ yield { errors: [err] };
71
+ return;
72
+ }
73
+ const op = doc.definitions[0];
74
+ // Only plain queries are split. Mutations, variables and fragments run as one document.
75
+ if (doc.definitions.length !== 1 || op.operation !== "query" || op.variableDefinitions?.length) {
76
+ const errors = check(schema, doc);
77
+ yield errors.length
78
+ ? { errors }
79
+ : await run({ schema, document: doc, rootValue, contextValue });
80
+ return;
81
+ }
82
+ const single = (sel) => ({
83
+ ...doc,
84
+ definitions: [{ ...op, selectionSet: { ...op.selectionSet, selections: [sel] } }],
85
+ });
86
+ const byAlias = new Map();
87
+ const fields = op.selectionSet.selections.map((sel) => {
88
+ const alias = sel.kind === Kind.FIELD ? (sel.alias ?? sel.name).value : undefined;
89
+ const refs = refsIn(sel);
90
+ // Looked up before anything is awaited, when only earlier fields are
91
+ // registered: a reference can't point forward, so there are no cycles.
92
+ const deps = refs.map((r) => byAlias.get(r.alias));
93
+ const result = (async () => {
94
+ let document = single(sel);
95
+ if (refs.length) {
96
+ const values = new Map();
97
+ for (const [i, r] of refs.entries()) {
98
+ const dep = await deps[i];
99
+ const value = r.path.reduce((data, key) => data?.[key], dep?.data?.[r.alias]);
100
+ if (value === undefined) {
101
+ return failed(alias, `unresolved reference to ${r.alias}.${r.path.join(".")}`);
102
+ }
103
+ values.set(r.variable, toLiteral(value));
104
+ }
105
+ document = visit(document, { Variable: (v) => values.get(v.name.value) });
106
+ }
107
+ const errors = check(schema, document);
108
+ if (errors.length)
109
+ return failed(alias, errors);
110
+ return run({ schema, document, rootValue, contextValue });
111
+ })();
112
+ if (alias)
113
+ byAlias.set(alias, result);
114
+ return result;
115
+ });
116
+ const pending = new Map(fields.map((p, i) => [i, p.then((r) => [i, r])]));
117
+ while (pending.size) {
118
+ const [i, result] = await Promise.race(pending.values());
119
+ pending.delete(i);
120
+ yield result;
121
+ }
122
+ }
123
+ /** Fold split results back into one standard GraphQL response. */
124
+ export async function merged(results) {
125
+ const data = {};
126
+ const errors = [];
127
+ let extensions;
128
+ for await (const r of results) {
129
+ Object.assign(data, r.data);
130
+ if (r.errors)
131
+ errors.push(...r.errors);
132
+ if (r.extensions)
133
+ extensions = { ...extensions, ...r.extensions };
134
+ }
135
+ return { data, ...(errors.length && { errors }), ...(extensions && { extensions }) };
136
+ }
137
+ /**
138
+ * A fetch-style handler (Bun, Deno, Cloudflare Workers, Next.js route handlers)
139
+ * for POST `{ query }`. Streams NDJSON when the client accepts it and answers
140
+ * with one merged JSON response otherwise.
141
+ */
142
+ export function createHandler(options) {
143
+ return async (req) => {
144
+ const { query } = (await req.json());
145
+ const results = splitExecute({
146
+ ...options,
147
+ source: query,
148
+ contextValue: options.context?.(req),
149
+ });
150
+ if (req.headers.get("accept")?.includes("application/x-ndjson"))
151
+ return ndjson(results);
152
+ return Response.json(await merged(results));
153
+ };
154
+ }
155
+ /**
156
+ * Stream results as newline-delimited JSON. If you compress the response,
157
+ * flush the compressor after each line, or the stream arrives all at once.
158
+ */
159
+ export function ndjson(results) {
160
+ const it = results[Symbol.asyncIterator]();
161
+ const encoder = new TextEncoder();
162
+ const body = new ReadableStream({
163
+ async pull(controller) {
164
+ const { value, done } = await it.next();
165
+ if (done)
166
+ controller.close();
167
+ else
168
+ controller.enqueue(encoder.encode(JSON.stringify(value) + "\n"));
169
+ },
170
+ });
171
+ return new Response(body, { headers: { "content-type": "application/x-ndjson" } });
172
+ }
173
+ const nextTask = globalThis.setImmediate ??
174
+ ((fn) => setTimeout(fn, 0));
175
+ /**
176
+ * A minimal DataLoader: keys requested in the same tick are passed to `batch`
177
+ * together, and each key is loaded once. Create one per request (in `context`)
178
+ * so all the fields of a request share it.
179
+ */
180
+ export function batchLoader(batch) {
181
+ let queue = [];
182
+ const cache = new Map();
183
+ function dispatch() {
184
+ const jobs = queue;
185
+ queue = [];
186
+ batch(jobs.map((job) => job.key)).then((values) => jobs.forEach((job, i) => job.resolve(values[i])), (err) => jobs.forEach((job) => job.reject(err)));
187
+ }
188
+ return (key) => {
189
+ const cached = cache.get(key);
190
+ if (cached)
191
+ return cached;
192
+ const promise = new Promise((resolve, reject) => queue.push({ key, resolve, reject }));
193
+ cache.set(key, promise);
194
+ if (queue.length === 1)
195
+ nextTask(dispatch);
196
+ return promise;
197
+ };
198
+ }
199
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@laguzjs/server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Server side of laguz: run each root field on its own, stream results, resolve dependent queries in one round trip.",
5
+ "keywords": [
6
+ "batching",
7
+ "dataloader",
8
+ "graphql",
9
+ "ndjson",
10
+ "server",
11
+ "streaming"
12
+ ],
13
+ "homepage": "https://laguz.maxenceleguery.net",
14
+ "bugs": {
15
+ "url": "https://github.com/maxenceleguery/laguz/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Maxence Leguery",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/maxenceleguery/laguz.git",
22
+ "directory": "packages/server"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src",
27
+ "!src/**/*.test.ts"
28
+ ],
29
+ "type": "module",
30
+ "sideEffects": false,
31
+ "exports": {
32
+ ".": {
33
+ "source": "./src/server.ts",
34
+ "types": "./dist/server.d.ts",
35
+ "bun": "./src/server.ts",
36
+ "default": "./dist/server.js"
37
+ },
38
+ "./package.json": "./package.json"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "provenance": true
43
+ },
44
+ "scripts": {
45
+ "build": "rm -rf dist && tsc -p tsconfig.build.json"
46
+ },
47
+ "peerDependencies": {
48
+ "graphql": ">=16"
49
+ }
50
+ }
package/src/server.ts ADDED
@@ -0,0 +1,247 @@
1
+ import {
2
+ execute,
3
+ GraphQLError,
4
+ Kind,
5
+ parse,
6
+ validate,
7
+ visit,
8
+ type ASTNode,
9
+ type DocumentNode,
10
+ type ExecutionResult,
11
+ type GraphQLSchema,
12
+ type OperationDefinitionNode,
13
+ type SelectionNode,
14
+ type ValueNode,
15
+ } from "graphql";
16
+
17
+ /** Protocol version announced in `extensions.laguz`. See PROTOCOL.md. */
18
+ export const PROTOCOL = 1;
19
+
20
+ export type ExecuteOptions = {
21
+ schema: GraphQLSchema;
22
+ source: string;
23
+ rootValue?: unknown;
24
+ contextValue?: unknown;
25
+ /** Your server's `execute`, e.g. from envelop or Yoga, to keep its plugins. */
26
+ execute?: typeof execute;
27
+ /** Your server's `validate`. */
28
+ validate?: typeof validate;
29
+ };
30
+
31
+ export type HandlerOptions = Omit<ExecuteOptions, "source" | "contextValue"> & {
32
+ /** Called once per request. Every field of the request shares the returned context. */
33
+ context?: (req: Request) => unknown;
34
+ };
35
+
36
+ function toLiteral(v: unknown): ValueNode {
37
+ if (v === null || v === undefined) return { kind: Kind.NULL };
38
+ if (Array.isArray(v)) return { kind: Kind.LIST, values: v.map(toLiteral) };
39
+ if (typeof v === "object") {
40
+ return {
41
+ kind: Kind.OBJECT,
42
+ fields: Object.entries(v).map(([key, value]) => ({
43
+ kind: Kind.OBJECT_FIELD,
44
+ name: { kind: Kind.NAME, value: key },
45
+ value: toLiteral(value),
46
+ })),
47
+ };
48
+ }
49
+ if (typeof v === "number") {
50
+ return { kind: Number.isInteger(v) ? Kind.INT : Kind.FLOAT, value: String(v) };
51
+ }
52
+ if (typeof v === "boolean") return { kind: Kind.BOOLEAN, value: v };
53
+ return { kind: Kind.STRING, value: String(v) };
54
+ }
55
+
56
+ function failed(
57
+ alias: string | undefined,
58
+ errors: string | readonly GraphQLError[],
59
+ ): ExecutionResult {
60
+ const list = typeof errors === "string" ? [new GraphQLError(errors)] : errors;
61
+ if (!alias) return { errors: list };
62
+ return {
63
+ data: { [alias]: null },
64
+ errors: list.map((err) => new GraphQLError(err.message, { path: [alias] })),
65
+ };
66
+ }
67
+
68
+ // `$_ref__q0__team__id` reads the value at `q0.team.id`.
69
+ function refsIn(node: ASTNode) {
70
+ const refs: { variable: string; alias: string; path: string[] }[] = [];
71
+ visit(node, {
72
+ Variable(v) {
73
+ const [tag, alias, ...path] = v.name.value.split("__");
74
+ if (tag === "_ref" && alias && path.length)
75
+ refs.push({ variable: v.name.value, alias, path });
76
+ },
77
+ });
78
+ return refs;
79
+ }
80
+
81
+ /**
82
+ * Execute each root field of `source` on its own and yield the results in the
83
+ * order they complete. A slow field doesn't delay the others, and an invalid
84
+ * field fails without taking the rest of the query down.
85
+ *
86
+ * A field may take an argument from an earlier field's result
87
+ * (`team(id: $_ref__q0__teamId)`). It runs once that field resolves, with the
88
+ * value substituted, so a chain of dependent queries costs one round trip.
89
+ */
90
+ export async function* splitExecute(options: ExecuteOptions): AsyncGenerator<ExecutionResult> {
91
+ let first = true;
92
+ for await (const result of executeFields(options)) {
93
+ // Announces protocol support so clients can start forwarding refs.
94
+ yield first ? { ...result, extensions: { ...result.extensions, laguz: PROTOCOL } } : result;
95
+ first = false;
96
+ }
97
+ }
98
+
99
+ async function* executeFields({
100
+ schema,
101
+ source,
102
+ rootValue,
103
+ contextValue,
104
+ execute: run = execute,
105
+ validate: check = validate,
106
+ }: ExecuteOptions): AsyncGenerator<ExecutionResult> {
107
+ let doc: DocumentNode;
108
+ try {
109
+ doc = parse(source);
110
+ } catch (err) {
111
+ yield { errors: [err as GraphQLError] };
112
+ return;
113
+ }
114
+
115
+ const op = doc.definitions[0] as OperationDefinitionNode;
116
+ // Only plain queries are split. Mutations, variables and fragments run as one document.
117
+ if (doc.definitions.length !== 1 || op.operation !== "query" || op.variableDefinitions?.length) {
118
+ const errors = check(schema, doc);
119
+ yield errors.length
120
+ ? { errors }
121
+ : await run({ schema, document: doc, rootValue, contextValue });
122
+ return;
123
+ }
124
+
125
+ const single = (sel: SelectionNode): DocumentNode => ({
126
+ ...doc,
127
+ definitions: [{ ...op, selectionSet: { ...op.selectionSet, selections: [sel] } }],
128
+ });
129
+
130
+ const byAlias = new Map<string, Promise<ExecutionResult>>();
131
+ const fields = op.selectionSet.selections.map((sel) => {
132
+ const alias = sel.kind === Kind.FIELD ? (sel.alias ?? sel.name).value : undefined;
133
+ const refs = refsIn(sel);
134
+ // Looked up before anything is awaited, when only earlier fields are
135
+ // registered: a reference can't point forward, so there are no cycles.
136
+ const deps = refs.map((r) => byAlias.get(r.alias));
137
+
138
+ const result = (async (): Promise<ExecutionResult> => {
139
+ let document = single(sel);
140
+ if (refs.length) {
141
+ const values = new Map<string, ValueNode>();
142
+ for (const [i, r] of refs.entries()) {
143
+ const dep = await deps[i];
144
+ const value = r.path.reduce((data: any, key) => data?.[key], dep?.data?.[r.alias]);
145
+ if (value === undefined) {
146
+ return failed(alias, `unresolved reference to ${r.alias}.${r.path.join(".")}`);
147
+ }
148
+ values.set(r.variable, toLiteral(value));
149
+ }
150
+ document = visit(document, { Variable: (v) => values.get(v.name.value) });
151
+ }
152
+ const errors = check(schema, document);
153
+ if (errors.length) return failed(alias, errors);
154
+ return run({ schema, document, rootValue, contextValue });
155
+ })();
156
+
157
+ if (alias) byAlias.set(alias, result);
158
+ return result;
159
+ });
160
+
161
+ const pending = new Map(fields.map((p, i) => [i, p.then((r) => [i, r] as const)]));
162
+ while (pending.size) {
163
+ const [i, result] = await Promise.race(pending.values());
164
+ pending.delete(i);
165
+ yield result;
166
+ }
167
+ }
168
+
169
+ /** Fold split results back into one standard GraphQL response. */
170
+ export async function merged(results: AsyncIterable<ExecutionResult>): Promise<ExecutionResult> {
171
+ const data: Record<string, unknown> = {};
172
+ const errors: GraphQLError[] = [];
173
+ let extensions: Record<string, unknown> | undefined;
174
+ for await (const r of results) {
175
+ Object.assign(data, r.data);
176
+ if (r.errors) errors.push(...r.errors);
177
+ if (r.extensions) extensions = { ...extensions, ...r.extensions };
178
+ }
179
+ return { data, ...(errors.length && { errors }), ...(extensions && { extensions }) };
180
+ }
181
+
182
+ /**
183
+ * A fetch-style handler (Bun, Deno, Cloudflare Workers, Next.js route handlers)
184
+ * for POST `{ query }`. Streams NDJSON when the client accepts it and answers
185
+ * with one merged JSON response otherwise.
186
+ */
187
+ export function createHandler(options: HandlerOptions) {
188
+ return async (req: Request): Promise<Response> => {
189
+ const { query } = (await req.json()) as { query: string };
190
+ const results = splitExecute({
191
+ ...options,
192
+ source: query,
193
+ contextValue: options.context?.(req),
194
+ });
195
+ if (req.headers.get("accept")?.includes("application/x-ndjson")) return ndjson(results);
196
+ return Response.json(await merged(results));
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Stream results as newline-delimited JSON. If you compress the response,
202
+ * flush the compressor after each line, or the stream arrives all at once.
203
+ */
204
+ export function ndjson(results: AsyncIterable<unknown>): Response {
205
+ const it = results[Symbol.asyncIterator]();
206
+ const encoder = new TextEncoder();
207
+ const body = new ReadableStream({
208
+ async pull(controller) {
209
+ const { value, done } = await it.next();
210
+ if (done) controller.close();
211
+ else controller.enqueue(encoder.encode(JSON.stringify(value) + "\n"));
212
+ },
213
+ });
214
+ return new Response(body, { headers: { "content-type": "application/x-ndjson" } });
215
+ }
216
+
217
+ const nextTask: (fn: () => void) => void =
218
+ (globalThis as { setImmediate?: (fn: () => void) => void }).setImmediate ??
219
+ ((fn) => setTimeout(fn, 0));
220
+
221
+ /**
222
+ * A minimal DataLoader: keys requested in the same tick are passed to `batch`
223
+ * together, and each key is loaded once. Create one per request (in `context`)
224
+ * so all the fields of a request share it.
225
+ */
226
+ export function batchLoader<K, V>(batch: (keys: K[]) => Promise<V[]>) {
227
+ let queue: { key: K; resolve: (v: V) => void; reject: (err: unknown) => void }[] = [];
228
+ const cache = new Map<K, Promise<V>>();
229
+
230
+ function dispatch() {
231
+ const jobs = queue;
232
+ queue = [];
233
+ batch(jobs.map((job) => job.key)).then(
234
+ (values) => jobs.forEach((job, i) => job.resolve(values[i]!)),
235
+ (err) => jobs.forEach((job) => job.reject(err)),
236
+ );
237
+ }
238
+
239
+ return (key: K): Promise<V> => {
240
+ const cached = cache.get(key);
241
+ if (cached) return cached;
242
+ const promise = new Promise<V>((resolve, reject) => queue.push({ key, resolve, reject }));
243
+ cache.set(key, promise);
244
+ if (queue.length === 1) nextTask(dispatch);
245
+ return promise;
246
+ };
247
+ }