hadars 0.2.2-rc.1 → 0.3.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 +130 -14
- package/cli-lib.ts +54 -3
- package/dist/cli.js +123 -18
- package/dist/cloudflare.d.cts +1 -1
- package/dist/cloudflare.d.ts +1 -1
- package/dist/{hadars-mKu5txjW.d.cts → hadars-CSWWhlQC.d.cts} +37 -3
- package/dist/{hadars-mKu5txjW.d.ts → hadars-CSWWhlQC.d.ts} +37 -3
- package/dist/index.cjs +30 -1
- package/dist/index.d.cts +29 -2
- package/dist/index.d.ts +29 -2
- package/dist/index.js +29 -1
- package/dist/lambda.d.cts +1 -1
- package/dist/lambda.d.ts +1 -1
- package/dist/utils/Head.tsx +95 -2
- package/package.json +1 -1
- package/src/build.ts +3 -0
- package/src/index.tsx +2 -1
- package/src/source/context.ts +5 -5
- package/src/source/graphiql.ts +8 -3
- package/src/source/inference.ts +92 -20
- package/src/source/runner.ts +29 -5
- package/src/source/store.ts +2 -0
- package/src/static.ts +3 -0
- package/src/types/hadars.ts +43 -4
- package/src/utils/Head.tsx +95 -2
package/dist/index.cjs
CHANGED
|
@@ -33,6 +33,7 @@ __export(index_exports, {
|
|
|
33
33
|
HadarsHead: () => Head,
|
|
34
34
|
initServerDataCache: () => initServerDataCache,
|
|
35
35
|
loadModule: () => loadModule,
|
|
36
|
+
useGraphQL: () => useGraphQL,
|
|
36
37
|
useServerData: () => useServerData
|
|
37
38
|
});
|
|
38
39
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -296,6 +297,32 @@ function useServerData(key, fn) {
|
|
|
296
297
|
if (existing.status === "rejected") throw existing.reason;
|
|
297
298
|
return existing.value;
|
|
298
299
|
}
|
|
300
|
+
function toCacheKey(doc) {
|
|
301
|
+
if (typeof doc === "string") return doc.trim();
|
|
302
|
+
for (const def of doc?.definitions ?? []) {
|
|
303
|
+
if (def.kind === "OperationDefinition" && def.name?.value) {
|
|
304
|
+
return `op:${def.name.value}`;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return JSON.stringify(doc?.definitions ?? doc);
|
|
308
|
+
}
|
|
309
|
+
function useGraphQL(query, variables) {
|
|
310
|
+
const key = ["__gql", toCacheKey(query), JSON.stringify(variables ?? {})];
|
|
311
|
+
return useServerData(key, async () => {
|
|
312
|
+
const executor = globalThis.__hadarsGraphQL;
|
|
313
|
+
if (!executor) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
"[hadars] useGraphQL: no GraphQL executor is available for this request. Make sure you have `sources` or a `graphql` executor configured in hadars.config.ts."
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
const result = await executor(query, variables);
|
|
319
|
+
if (result.errors?.length) {
|
|
320
|
+
const messages = result.errors.map((e) => e.message).join(", ");
|
|
321
|
+
throw new Error(`[hadars] GraphQL error: ${messages}`);
|
|
322
|
+
}
|
|
323
|
+
return result;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
299
326
|
var Head = import_react.default.memo(({ children, status }) => {
|
|
300
327
|
const ctx = getCtx();
|
|
301
328
|
if (!ctx) return null;
|
|
@@ -309,7 +336,8 @@ var Head = import_react.default.memo(({ children, status }) => {
|
|
|
309
336
|
const childProps = child.props;
|
|
310
337
|
switch (childType) {
|
|
311
338
|
case "title": {
|
|
312
|
-
|
|
339
|
+
const raw = childProps["children"];
|
|
340
|
+
setTitle(Array.isArray(raw) ? raw.join("") : String(raw ?? ""));
|
|
313
341
|
return;
|
|
314
342
|
}
|
|
315
343
|
case "meta": {
|
|
@@ -355,5 +383,6 @@ function loadModule(path) {
|
|
|
355
383
|
HadarsHead,
|
|
356
384
|
initServerDataCache,
|
|
357
385
|
loadModule,
|
|
386
|
+
useGraphQL,
|
|
358
387
|
useServerData
|
|
359
388
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { b as HadarsDocumentNode } from './hadars-CSWWhlQC.cjs';
|
|
2
|
+
export { G as GraphQLExecutor, c as HadarsApp, H as HadarsEntryModule, d as HadarsGetClientProps, e as HadarsGetFinalProps, f as HadarsGetInitialProps, a as HadarsOptions, g as HadarsProps, h as HadarsRequest, i as HadarsSourceEntry, j as HadarsStaticContext } from './hadars-CSWWhlQC.cjs';
|
|
2
3
|
import React from 'react';
|
|
3
4
|
|
|
4
5
|
/** Call this before hydrating to seed the client cache from the server's data.
|
|
@@ -32,6 +33,32 @@ declare function initServerDataCache(data: Record<string, unknown>): void;
|
|
|
32
33
|
* if (!user) return null; // undefined while pending on the first SSR pass
|
|
33
34
|
*/
|
|
34
35
|
declare function useServerData<T>(key: string | string[], fn: () => Promise<T> | T): T | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Execute a GraphQL query server-side and return the result.
|
|
38
|
+
*
|
|
39
|
+
* Wraps `useServerData` — on the client the pre-resolved value is read from
|
|
40
|
+
* the hydration cache. During client-side navigation hadars automatically
|
|
41
|
+
* fires a data-only request to the server so the query re-executes there.
|
|
42
|
+
*
|
|
43
|
+
* Throws if the executor returns GraphQL errors, so the page is correctly
|
|
44
|
+
* marked as failed during `hadars export static`.
|
|
45
|
+
*
|
|
46
|
+
* ```tsx
|
|
47
|
+
* // Typed via codegen document — TData and TVariables are inferred:
|
|
48
|
+
* const result = useGraphQL(GetPostDocument, { slug });
|
|
49
|
+
* const post = result?.data?.blogPost; // fully typed
|
|
50
|
+
*
|
|
51
|
+
* // Plain string query — untyped:
|
|
52
|
+
* const result = useGraphQL(`{ allBlogPost { slug title } }`);
|
|
53
|
+
* if (!result) return null; // undefined while pending on first SSR pass
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
declare function useGraphQL<TData, TVariables extends Record<string, unknown>>(query: HadarsDocumentNode<TData, TVariables>, variables?: TVariables): {
|
|
57
|
+
data?: TData;
|
|
58
|
+
} | undefined;
|
|
59
|
+
declare function useGraphQL(query: string, variables?: Record<string, unknown>): {
|
|
60
|
+
data?: Record<string, unknown>;
|
|
61
|
+
} | undefined;
|
|
35
62
|
declare const Head: React.FC<{
|
|
36
63
|
children?: React.ReactNode;
|
|
37
64
|
status?: number;
|
|
@@ -57,4 +84,4 @@ declare const Head: React.FC<{
|
|
|
57
84
|
*/
|
|
58
85
|
declare function loadModule<T = any>(path: string): Promise<T>;
|
|
59
86
|
|
|
60
|
-
export { Head as HadarsHead, initServerDataCache, loadModule, useServerData };
|
|
87
|
+
export { HadarsDocumentNode, Head as HadarsHead, initServerDataCache, loadModule, useGraphQL, useServerData };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { b as HadarsDocumentNode } from './hadars-CSWWhlQC.js';
|
|
2
|
+
export { G as GraphQLExecutor, c as HadarsApp, H as HadarsEntryModule, d as HadarsGetClientProps, e as HadarsGetFinalProps, f as HadarsGetInitialProps, a as HadarsOptions, g as HadarsProps, h as HadarsRequest, i as HadarsSourceEntry, j as HadarsStaticContext } from './hadars-CSWWhlQC.js';
|
|
2
3
|
import React from 'react';
|
|
3
4
|
|
|
4
5
|
/** Call this before hydrating to seed the client cache from the server's data.
|
|
@@ -32,6 +33,32 @@ declare function initServerDataCache(data: Record<string, unknown>): void;
|
|
|
32
33
|
* if (!user) return null; // undefined while pending on the first SSR pass
|
|
33
34
|
*/
|
|
34
35
|
declare function useServerData<T>(key: string | string[], fn: () => Promise<T> | T): T | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Execute a GraphQL query server-side and return the result.
|
|
38
|
+
*
|
|
39
|
+
* Wraps `useServerData` — on the client the pre-resolved value is read from
|
|
40
|
+
* the hydration cache. During client-side navigation hadars automatically
|
|
41
|
+
* fires a data-only request to the server so the query re-executes there.
|
|
42
|
+
*
|
|
43
|
+
* Throws if the executor returns GraphQL errors, so the page is correctly
|
|
44
|
+
* marked as failed during `hadars export static`.
|
|
45
|
+
*
|
|
46
|
+
* ```tsx
|
|
47
|
+
* // Typed via codegen document — TData and TVariables are inferred:
|
|
48
|
+
* const result = useGraphQL(GetPostDocument, { slug });
|
|
49
|
+
* const post = result?.data?.blogPost; // fully typed
|
|
50
|
+
*
|
|
51
|
+
* // Plain string query — untyped:
|
|
52
|
+
* const result = useGraphQL(`{ allBlogPost { slug title } }`);
|
|
53
|
+
* if (!result) return null; // undefined while pending on first SSR pass
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
declare function useGraphQL<TData, TVariables extends Record<string, unknown>>(query: HadarsDocumentNode<TData, TVariables>, variables?: TVariables): {
|
|
57
|
+
data?: TData;
|
|
58
|
+
} | undefined;
|
|
59
|
+
declare function useGraphQL(query: string, variables?: Record<string, unknown>): {
|
|
60
|
+
data?: Record<string, unknown>;
|
|
61
|
+
} | undefined;
|
|
35
62
|
declare const Head: React.FC<{
|
|
36
63
|
children?: React.ReactNode;
|
|
37
64
|
status?: number;
|
|
@@ -57,4 +84,4 @@ declare const Head: React.FC<{
|
|
|
57
84
|
*/
|
|
58
85
|
declare function loadModule<T = any>(path: string): Promise<T>;
|
|
59
86
|
|
|
60
|
-
export { Head as HadarsHead, initServerDataCache, loadModule, useServerData };
|
|
87
|
+
export { HadarsDocumentNode, Head as HadarsHead, initServerDataCache, loadModule, useGraphQL, useServerData };
|
package/dist/index.js
CHANGED
|
@@ -257,6 +257,32 @@ function useServerData(key, fn) {
|
|
|
257
257
|
if (existing.status === "rejected") throw existing.reason;
|
|
258
258
|
return existing.value;
|
|
259
259
|
}
|
|
260
|
+
function toCacheKey(doc) {
|
|
261
|
+
if (typeof doc === "string") return doc.trim();
|
|
262
|
+
for (const def of doc?.definitions ?? []) {
|
|
263
|
+
if (def.kind === "OperationDefinition" && def.name?.value) {
|
|
264
|
+
return `op:${def.name.value}`;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return JSON.stringify(doc?.definitions ?? doc);
|
|
268
|
+
}
|
|
269
|
+
function useGraphQL(query, variables) {
|
|
270
|
+
const key = ["__gql", toCacheKey(query), JSON.stringify(variables ?? {})];
|
|
271
|
+
return useServerData(key, async () => {
|
|
272
|
+
const executor = globalThis.__hadarsGraphQL;
|
|
273
|
+
if (!executor) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
"[hadars] useGraphQL: no GraphQL executor is available for this request. Make sure you have `sources` or a `graphql` executor configured in hadars.config.ts."
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
const result = await executor(query, variables);
|
|
279
|
+
if (result.errors?.length) {
|
|
280
|
+
const messages = result.errors.map((e) => e.message).join(", ");
|
|
281
|
+
throw new Error(`[hadars] GraphQL error: ${messages}`);
|
|
282
|
+
}
|
|
283
|
+
return result;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
260
286
|
var Head = React.memo(({ children, status }) => {
|
|
261
287
|
const ctx = getCtx();
|
|
262
288
|
if (!ctx) return null;
|
|
@@ -270,7 +296,8 @@ var Head = React.memo(({ children, status }) => {
|
|
|
270
296
|
const childProps = child.props;
|
|
271
297
|
switch (childType) {
|
|
272
298
|
case "title": {
|
|
273
|
-
|
|
299
|
+
const raw = childProps["children"];
|
|
300
|
+
setTitle(Array.isArray(raw) ? raw.join("") : String(raw ?? ""));
|
|
274
301
|
return;
|
|
275
302
|
}
|
|
276
303
|
case "meta": {
|
|
@@ -315,5 +342,6 @@ export {
|
|
|
315
342
|
Head as HadarsHead,
|
|
316
343
|
initServerDataCache,
|
|
317
344
|
loadModule,
|
|
345
|
+
useGraphQL,
|
|
318
346
|
useServerData
|
|
319
347
|
};
|
package/dist/lambda.d.cts
CHANGED
package/dist/lambda.d.ts
CHANGED
package/dist/utils/Head.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import type { AppHead, AppUnsuspend, LinkProps, MetaProps, ScriptProps, StyleProps } from '../types/hadars'
|
|
2
|
+
import type { AppHead, AppUnsuspend, HadarsDocumentNode, LinkProps, MetaProps, ScriptProps, StyleProps } from '../types/hadars'
|
|
3
3
|
|
|
4
4
|
interface InnerContext {
|
|
5
5
|
setTitle: (title: string) => void;
|
|
@@ -357,6 +357,98 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
357
357
|
}
|
|
358
358
|
|
|
359
359
|
|
|
360
|
+
// ── useGraphQL ────────────────────────────────────────────────────────────────
|
|
361
|
+
//
|
|
362
|
+
// Execute a GraphQL query during SSR via the hadars data layer. The executor
|
|
363
|
+
// is stored in globalThis.__hadarsGraphQL by the framework before each render.
|
|
364
|
+
// On the client, useServerData handles hydration + client-side navigation.
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Derive a stable cache key string from a query argument.
|
|
368
|
+
* For string queries the key is the trimmed query string.
|
|
369
|
+
* For document nodes we use the operation name when available (fast, concise)
|
|
370
|
+
* and fall back to stringifying the definitions (always stable).
|
|
371
|
+
* The key is ONLY used for cache lookup — the original document is passed to
|
|
372
|
+
* the executor so it can call print() itself.
|
|
373
|
+
*/
|
|
374
|
+
function toCacheKey(doc: any): string {
|
|
375
|
+
if (typeof doc === 'string') return doc.trim();
|
|
376
|
+
// Use the operation name for named operations — compact and stable.
|
|
377
|
+
for (const def of doc?.definitions ?? []) {
|
|
378
|
+
if (def.kind === 'OperationDefinition' && def.name?.value) {
|
|
379
|
+
return `op:${def.name.value}`;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// Anonymous operation — fall back to stringifying definitions.
|
|
383
|
+
return JSON.stringify(doc?.definitions ?? doc);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Execute a GraphQL query server-side and return the result.
|
|
388
|
+
*
|
|
389
|
+
* Wraps `useServerData` — on the client the pre-resolved value is read from
|
|
390
|
+
* the hydration cache. During client-side navigation hadars automatically
|
|
391
|
+
* fires a data-only request to the server so the query re-executes there.
|
|
392
|
+
*
|
|
393
|
+
* Throws if the executor returns GraphQL errors, so the page is correctly
|
|
394
|
+
* marked as failed during `hadars export static`.
|
|
395
|
+
*
|
|
396
|
+
* ```tsx
|
|
397
|
+
* // Typed via codegen document — TData and TVariables are inferred:
|
|
398
|
+
* const result = useGraphQL(GetPostDocument, { slug });
|
|
399
|
+
* const post = result?.data?.blogPost; // fully typed
|
|
400
|
+
*
|
|
401
|
+
* // Plain string query — untyped:
|
|
402
|
+
* const result = useGraphQL(`{ allBlogPost { slug title } }`);
|
|
403
|
+
* if (!result) return null; // undefined while pending on first SSR pass
|
|
404
|
+
* ```
|
|
405
|
+
*/
|
|
406
|
+
// Overload 1: TypedDocumentNode — TData and TVariables are inferred from the document.
|
|
407
|
+
export function useGraphQL<
|
|
408
|
+
TData,
|
|
409
|
+
TVariables extends Record<string, unknown>,
|
|
410
|
+
>(
|
|
411
|
+
query: HadarsDocumentNode<TData, TVariables>,
|
|
412
|
+
variables?: TVariables,
|
|
413
|
+
): { data?: TData } | undefined;
|
|
414
|
+
// Overload 2: plain string query — untyped result.
|
|
415
|
+
export function useGraphQL(
|
|
416
|
+
query: string,
|
|
417
|
+
variables?: Record<string, unknown>,
|
|
418
|
+
): { data?: Record<string, unknown> } | undefined;
|
|
419
|
+
// Implementation
|
|
420
|
+
export function useGraphQL(
|
|
421
|
+
query: string | HadarsDocumentNode<unknown, Record<string, unknown>>,
|
|
422
|
+
variables?: Record<string, unknown>,
|
|
423
|
+
): { data?: unknown } | undefined {
|
|
424
|
+
const key = ['__gql', toCacheKey(query), JSON.stringify(variables ?? {})];
|
|
425
|
+
|
|
426
|
+
return useServerData(key, async () => {
|
|
427
|
+
const executor: ((q: any, v?: Record<string, unknown>) => Promise<any>) | undefined =
|
|
428
|
+
(globalThis as any).__hadarsGraphQL;
|
|
429
|
+
|
|
430
|
+
if (!executor) {
|
|
431
|
+
throw new Error(
|
|
432
|
+
'[hadars] useGraphQL: no GraphQL executor is available for this request. ' +
|
|
433
|
+
'Make sure you have `sources` or a `graphql` executor configured in hadars.config.ts.',
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Pass the original query (string or document object) — the executor
|
|
438
|
+
// calls print() itself so codegen documents without loc.source.body work.
|
|
439
|
+
const result = await executor(query, variables);
|
|
440
|
+
|
|
441
|
+
if (result.errors?.length) {
|
|
442
|
+
const messages = result.errors.map((e: { message: string }) => e.message).join(', ');
|
|
443
|
+
throw new Error(`[hadars] GraphQL error: ${messages}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return result;
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── HadarsHead ────────────────────────────────────────────────────────────────
|
|
451
|
+
|
|
360
452
|
export const Head: React.FC<{
|
|
361
453
|
children?: React.ReactNode;
|
|
362
454
|
status?: number;
|
|
@@ -381,7 +473,8 @@ export const Head: React.FC<{
|
|
|
381
473
|
|
|
382
474
|
switch ( childType ) {
|
|
383
475
|
case 'title': {
|
|
384
|
-
|
|
476
|
+
const raw = childProps['children'];
|
|
477
|
+
setTitle(Array.isArray(raw) ? raw.join('') : String(raw ?? ''));
|
|
385
478
|
return;
|
|
386
479
|
}
|
|
387
480
|
case 'meta': {
|
package/package.json
CHANGED
package/src/build.ts
CHANGED
|
@@ -546,6 +546,9 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
546
546
|
getFinalProps,
|
|
547
547
|
} = (await import(importPath)) as HadarsEntryModule<any>;
|
|
548
548
|
|
|
549
|
+
// Expose the executor globally so useGraphQL() in components can reach it.
|
|
550
|
+
(globalThis as any).__hadarsGraphQL = devStaticCtx?.graphql;
|
|
551
|
+
|
|
549
552
|
const { head, status, getAppBody, finalize } = await getReactResponse(request, {
|
|
550
553
|
document: {
|
|
551
554
|
body: Component as React.FC<HadarsProps<object>>,
|
package/src/index.tsx
CHANGED
|
@@ -10,8 +10,9 @@ export type {
|
|
|
10
10
|
HadarsStaticContext,
|
|
11
11
|
GraphQLExecutor,
|
|
12
12
|
HadarsSourceEntry,
|
|
13
|
+
HadarsDocumentNode,
|
|
13
14
|
} from "./types/hadars";
|
|
14
|
-
export { Head as HadarsHead, useServerData, initServerDataCache } from './utils/Head';
|
|
15
|
+
export { Head as HadarsHead, useServerData, useGraphQL, initServerDataCache } from './utils/Head';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Dynamically loads a module with target-aware behaviour:
|
package/src/source/context.ts
CHANGED
|
@@ -65,7 +65,7 @@ export function makeGatsbyContext(
|
|
|
65
65
|
set: (key, value) => { cacheMap.set(key, value); return Promise.resolve(); },
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
-
const reporter: GatsbyReporter = {
|
|
68
|
+
const reporter: GatsbyReporter & Record<string, unknown> = {
|
|
69
69
|
info: (msg) => console.log(`[${pluginName}] ${msg}`),
|
|
70
70
|
warn: (msg) => console.warn(`[${pluginName}] WARN: ${msg}`),
|
|
71
71
|
error: (msg, err) => console.error(`[${pluginName}] ERROR: ${msg}`, err ?? ''),
|
|
@@ -79,14 +79,14 @@ export function makeGatsbyContext(
|
|
|
79
79
|
setErrorMap: () => {},
|
|
80
80
|
log: (msg: string) => console.log(`[${pluginName}] ${msg}`),
|
|
81
81
|
success: (msg: string) => console.log(`[${pluginName}] ✓ ${msg}`),
|
|
82
|
-
}
|
|
82
|
+
};
|
|
83
83
|
|
|
84
84
|
const actions: GatsbyActions = {
|
|
85
85
|
createNode: (node) => store.createNode(node),
|
|
86
86
|
deleteNode: ({ id }) => {
|
|
87
|
-
// NodeStore doesn't
|
|
88
|
-
//
|
|
89
|
-
|
|
87
|
+
// NodeStore doesn't implement delete — it's rarely needed on a first run.
|
|
88
|
+
// Warn so plugin authors know the operation was ignored.
|
|
89
|
+
console.warn(`[${pluginName}] deleteNode("${id}") called but is not supported by hadars — node will remain in the store.`);
|
|
90
90
|
},
|
|
91
91
|
touchNode: () => { /* no-op — only relevant for Gatsby's caching layer */ },
|
|
92
92
|
};
|
package/src/source/graphiql.ts
CHANGED
|
@@ -49,7 +49,12 @@ export function createGraphiqlHandler(
|
|
|
49
49
|
executor: GraphQLExecutor,
|
|
50
50
|
): (req: Request) => Promise<Response | undefined> {
|
|
51
51
|
return async (req: Request): Promise<Response | undefined> => {
|
|
52
|
-
|
|
52
|
+
let url: URL;
|
|
53
|
+
try {
|
|
54
|
+
url = new URL(req.url);
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
53
58
|
if (url.pathname !== GRAPHQL_PATH) return undefined;
|
|
54
59
|
|
|
55
60
|
// GET — serve GraphiQL IDE
|
|
@@ -71,8 +76,8 @@ export function createGraphiqlHandler(
|
|
|
71
76
|
});
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
if (!body.query) {
|
|
75
|
-
return new Response(JSON.stringify({ errors: [{ message: 'Missing "query" field' }] }), {
|
|
79
|
+
if (typeof body.query !== 'string' || !body.query.trim()) {
|
|
80
|
+
return new Response(JSON.stringify({ errors: [{ message: 'Missing or invalid "query" field — must be a non-empty string' }] }), {
|
|
76
81
|
status: 400,
|
|
77
82
|
headers: { 'Content-Type': 'application/json' },
|
|
78
83
|
});
|
package/src/source/inference.ts
CHANGED
|
@@ -43,6 +43,8 @@ function inferFieldShape(value: unknown, seenTypes: Set<string>): FieldShape {
|
|
|
43
43
|
// ── Schema string builder ──────────────────────────────────────────────────────
|
|
44
44
|
|
|
45
45
|
const INTERNAL_FIELDS = new Set(['id', 'internal', '__typename', 'parent', 'children']);
|
|
46
|
+
/** GraphQL spec reserves all names beginning with __ for introspection. */
|
|
47
|
+
const isReservedFieldName = (name: string) => name.startsWith('__');
|
|
46
48
|
|
|
47
49
|
/** Scalar GraphQL types that are safe to use as lookup filter arguments. */
|
|
48
50
|
const FILTERABLE_SCALARS = new Set(['String', 'Int', 'Float', 'Boolean', 'ID']);
|
|
@@ -59,7 +61,7 @@ function buildTypeFields(nodes: readonly Record<string, unknown>[]): InferredFie
|
|
|
59
61
|
|
|
60
62
|
for (const node of nodes) {
|
|
61
63
|
for (const [key, val] of Object.entries(node)) {
|
|
62
|
-
if (INTERNAL_FIELDS.has(key)) continue;
|
|
64
|
+
if (INTERNAL_FIELDS.has(key) || isReservedFieldName(key)) continue;
|
|
63
65
|
if (fieldMap.has(key)) continue;
|
|
64
66
|
const { type } = inferFieldShape(val, new Set());
|
|
65
67
|
fieldMap.set(key, {
|
|
@@ -106,17 +108,14 @@ function buildSingleArgs(fields: InferredField[]): string {
|
|
|
106
108
|
// ── Public API ─────────────────────────────────────────────────────────────────
|
|
107
109
|
|
|
108
110
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* Returns null if graphql-js is not installed — in that case the caller should
|
|
112
|
-
* surface a clear error message asking the user to install `graphql`.
|
|
111
|
+
* Load graphql-js from the user's project and build a schema + SDL from the
|
|
112
|
+
* node store. Returns null if graphql-js is not installed.
|
|
113
113
|
*/
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
// in the user's node_modules rather than the CLI's own node_modules.
|
|
114
|
+
async function loadAndBuildSchema(store: NodeStore): Promise<{
|
|
115
|
+
schema: any;
|
|
116
|
+
sdl: string;
|
|
117
|
+
gql: any;
|
|
118
|
+
} | null> {
|
|
120
119
|
let gql: any;
|
|
121
120
|
try {
|
|
122
121
|
const { createRequire } = await import('node:module');
|
|
@@ -127,16 +126,15 @@ export async function buildSchemaExecutor(
|
|
|
127
126
|
return null;
|
|
128
127
|
}
|
|
129
128
|
|
|
130
|
-
const { buildSchema,
|
|
131
|
-
|
|
129
|
+
const { buildSchema, printSchema, print } = gql;
|
|
132
130
|
const types = store.getTypes();
|
|
131
|
+
|
|
133
132
|
if (types.length === 0) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return (query, variables) => graphql({ schema, source: query, variableValues: variables });
|
|
133
|
+
const rawSdl = 'type Query { _empty: String }';
|
|
134
|
+
return { schema: buildSchema(rawSdl), sdl: rawSdl, gql };
|
|
137
135
|
}
|
|
138
136
|
|
|
139
|
-
// Infer field shapes once per type — reused for
|
|
137
|
+
// Infer field shapes once per type — reused for SDL, resolvers, and args.
|
|
140
138
|
const typeFields = new Map(
|
|
141
139
|
types.map(typeName => {
|
|
142
140
|
const nodes = store.getNodesByType(typeName) as Record<string, unknown>[];
|
|
@@ -157,18 +155,50 @@ export async function buildSchemaExecutor(
|
|
|
157
155
|
].join('\n');
|
|
158
156
|
});
|
|
159
157
|
|
|
160
|
-
const
|
|
158
|
+
const rawSdl = [
|
|
161
159
|
...typeSDLs,
|
|
162
160
|
`type Query {\n${queryFields.join('\n')}\n}`,
|
|
163
161
|
].join('\n\n');
|
|
164
162
|
|
|
165
163
|
let schema: any;
|
|
166
164
|
try {
|
|
167
|
-
schema = buildSchema(
|
|
165
|
+
schema = buildSchema(rawSdl);
|
|
168
166
|
} catch (err) {
|
|
169
167
|
throw new Error(`[hadars] Failed to build GraphQL schema from node store: ${(err as Error).message}`);
|
|
170
168
|
}
|
|
171
169
|
|
|
170
|
+
return { schema, sdl: printSchema(schema), gql };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Build a GraphQL executor backed by the node store.
|
|
175
|
+
*
|
|
176
|
+
* Returns null if graphql-js is not installed — in that case the caller should
|
|
177
|
+
* surface a clear error message asking the user to install `graphql`.
|
|
178
|
+
*/
|
|
179
|
+
/**
|
|
180
|
+
* Normalise a query argument to a string.
|
|
181
|
+
* Accepts either a plain query string or a TypedDocumentNode / codegen document object.
|
|
182
|
+
*/
|
|
183
|
+
function toQueryString(query: unknown, print: (doc: any) => string): string {
|
|
184
|
+
return typeof query === 'string' ? query : print(query);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function buildSchemaExecutor(
|
|
188
|
+
store: NodeStore,
|
|
189
|
+
): Promise<GraphQLExecutor | null> {
|
|
190
|
+
const built = await loadAndBuildSchema(store);
|
|
191
|
+
if (!built) return null;
|
|
192
|
+
|
|
193
|
+
const { schema, gql } = built;
|
|
194
|
+
const { graphql, print } = gql;
|
|
195
|
+
const types = store.getTypes();
|
|
196
|
+
|
|
197
|
+
if (types.length === 0) {
|
|
198
|
+
return (query, variables) =>
|
|
199
|
+
graphql({ schema, source: toQueryString(query, print), variableValues: variables });
|
|
200
|
+
}
|
|
201
|
+
|
|
172
202
|
// Build root resolver map
|
|
173
203
|
const rootValue: Record<string, unknown> = {};
|
|
174
204
|
for (const typeName of types) {
|
|
@@ -184,5 +214,47 @@ export async function buildSchemaExecutor(
|
|
|
184
214
|
}
|
|
185
215
|
|
|
186
216
|
return (query, variables) =>
|
|
187
|
-
graphql({ schema, rootValue, source: query, variableValues: variables }) as any;
|
|
217
|
+
graphql({ schema, rootValue, source: toQueryString(query, print), variableValues: variables }) as any;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Return the inferred GraphQL schema as a SDL string suitable for writing to a
|
|
222
|
+
* `schema.graphql` file and consuming with graphql-codegen or gql.tada.
|
|
223
|
+
*
|
|
224
|
+
* Returns null if graphql-js is not installed in the user's project.
|
|
225
|
+
*/
|
|
226
|
+
export async function buildSchemaSDL(store: NodeStore): Promise<string | null> {
|
|
227
|
+
const built = await loadAndBuildSchema(store);
|
|
228
|
+
return built?.sdl ?? null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Introspect a custom GraphQL executor and return its schema as SDL.
|
|
233
|
+
* Uses the standard introspection query so the executor doesn't need to know
|
|
234
|
+
* about hadars internals.
|
|
235
|
+
*
|
|
236
|
+
* Returns null if graphql-js is not installed in the user's project.
|
|
237
|
+
*/
|
|
238
|
+
export async function introspectExecutorSDL(
|
|
239
|
+
executor: GraphQLExecutor,
|
|
240
|
+
): Promise<string | null> {
|
|
241
|
+
let gql: any;
|
|
242
|
+
try {
|
|
243
|
+
const { createRequire } = await import('node:module');
|
|
244
|
+
const projectRequire = createRequire(process.cwd() + '/package.json');
|
|
245
|
+
const graphqlPath = projectRequire.resolve('graphql');
|
|
246
|
+
gql = await import(graphqlPath);
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const { getIntrospectionQuery, buildClientSchema, printSchema } = gql;
|
|
252
|
+
const result = await executor(getIntrospectionQuery());
|
|
253
|
+
if (result.errors?.length) {
|
|
254
|
+
throw new Error(`[hadars] Introspection failed: ${result.errors[0].message}`);
|
|
255
|
+
}
|
|
256
|
+
if (!result.data) {
|
|
257
|
+
throw new Error('[hadars] Introspection returned no data');
|
|
258
|
+
}
|
|
259
|
+
return printSchema(buildClientSchema(result.data));
|
|
188
260
|
}
|
package/src/source/runner.ts
CHANGED
|
@@ -19,10 +19,13 @@ import type { HadarsSourceEntry } from '../types/hadars';
|
|
|
19
19
|
const SETTLE_IDLE_MS = 300;
|
|
20
20
|
/** Hard timeout — give up waiting after this many ms. */
|
|
21
21
|
const SETTLE_TIMEOUT_MS = 10_000;
|
|
22
|
+
/** Timeout for a single sourceNodes() call before we give up waiting. */
|
|
23
|
+
const SOURCE_NODES_TIMEOUT_MS = 30_000;
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Wait until no createNode call has been received for SETTLE_IDLE_MS,
|
|
25
27
|
* or until SETTLE_TIMEOUT_MS elapses, whichever comes first.
|
|
28
|
+
* Timers are unref()ed so they don't keep the process alive artificially.
|
|
26
29
|
*/
|
|
27
30
|
function waitForSettle(getLastNodeTime: () => number): Promise<void> {
|
|
28
31
|
return new Promise(resolve => {
|
|
@@ -32,10 +35,24 @@ function waitForSettle(getLastNodeTime: () => number): Promise<void> {
|
|
|
32
35
|
if (idle >= SETTLE_IDLE_MS || Date.now() >= deadline) {
|
|
33
36
|
resolve();
|
|
34
37
|
} else {
|
|
35
|
-
setTimeout(check, 50);
|
|
38
|
+
const t = setTimeout(check, 50);
|
|
39
|
+
t.unref?.();
|
|
36
40
|
}
|
|
37
41
|
};
|
|
38
|
-
setTimeout(check, SETTLE_IDLE_MS);
|
|
42
|
+
const t = setTimeout(check, SETTLE_IDLE_MS);
|
|
43
|
+
t.unref?.();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run a promise with a hard timeout. Rejects with a clear message if the
|
|
49
|
+
* deadline is exceeded, but does not cancel the original promise.
|
|
50
|
+
*/
|
|
51
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const t = setTimeout(() => reject(new Error(`[hadars] "${label}" timed out after ${ms}ms`)), ms);
|
|
54
|
+
t.unref?.();
|
|
55
|
+
promise.then(resolve, reject).finally(() => clearTimeout(t));
|
|
39
56
|
});
|
|
40
57
|
}
|
|
41
58
|
|
|
@@ -93,11 +110,18 @@ export async function runSources(
|
|
|
93
110
|
const ctx = makeGatsbyContext(trackingStore, pluginName, options, emitter);
|
|
94
111
|
|
|
95
112
|
try {
|
|
96
|
-
await
|
|
113
|
+
await withTimeout(
|
|
114
|
+
Promise.resolve(mod.sourceNodes(ctx, options)),
|
|
115
|
+
SOURCE_NODES_TIMEOUT_MS,
|
|
116
|
+
`${pluginName} sourceNodes`,
|
|
117
|
+
);
|
|
97
118
|
} catch (err) {
|
|
98
|
-
|
|
99
|
-
|
|
119
|
+
const cause = err instanceof Error ? err : new Error(String(err));
|
|
120
|
+
const wrapped = new Error(
|
|
121
|
+
`[hadars] source plugin "${pluginName}" threw during sourceNodes: ${cause.message}`,
|
|
100
122
|
);
|
|
123
|
+
wrapped.cause = cause;
|
|
124
|
+
throw wrapped;
|
|
101
125
|
}
|
|
102
126
|
|
|
103
127
|
// Phase 1: wait for async initial-scan nodes (e.g. chokidar ready → createFileNode).
|
package/src/source/store.ts
CHANGED
|
@@ -22,6 +22,8 @@ export class NodeStore {
|
|
|
22
22
|
private byType = new Map<string, HadarsNode[]>();
|
|
23
23
|
|
|
24
24
|
createNode(node: HadarsNode): void {
|
|
25
|
+
if (!node.id) throw new Error('[hadars] createNode: node.id must be a non-empty string');
|
|
26
|
+
if (!node.internal?.type) throw new Error('[hadars] createNode: node.internal.type must be a non-empty string');
|
|
25
27
|
this.byId.set(node.id, node);
|
|
26
28
|
const list = this.byType.get(node.internal.type) ?? [];
|
|
27
29
|
// Replace existing node with same id if present
|
package/src/static.ts
CHANGED
|
@@ -51,6 +51,9 @@ export async function renderStaticSite(opts: {
|
|
|
51
51
|
try {
|
|
52
52
|
const req = parseRequest(new Request('http://localhost' + urlPath));
|
|
53
53
|
|
|
54
|
+
// Expose the executor globally so useGraphQL() in components can reach it.
|
|
55
|
+
(globalThis as any).__hadarsGraphQL = staticCtx.graphql;
|
|
56
|
+
|
|
54
57
|
const { head, getAppBody, finalize } = await getReactResponse(req, {
|
|
55
58
|
document: {
|
|
56
59
|
body: ssrModule.default as any,
|