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/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
- setTitle(childProps["children"]);
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
- export { G as GraphQLExecutor, b as HadarsApp, H as HadarsEntryModule, c as HadarsGetClientProps, d as HadarsGetFinalProps, e as HadarsGetInitialProps, a as HadarsOptions, f as HadarsProps, g as HadarsRequest, h as HadarsSourceEntry, i as HadarsStaticContext } from './hadars-mKu5txjW.cjs';
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
- export { G as GraphQLExecutor, b as HadarsApp, H as HadarsEntryModule, c as HadarsGetClientProps, d as HadarsGetFinalProps, e as HadarsGetInitialProps, a as HadarsOptions, f as HadarsProps, g as HadarsRequest, h as HadarsSourceEntry, i as HadarsStaticContext } from './hadars-mKu5txjW.js';
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
- setTitle(childProps["children"]);
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
@@ -1,4 +1,4 @@
1
- import { H as HadarsEntryModule, a as HadarsOptions } from './hadars-mKu5txjW.cjs';
1
+ import { H as HadarsEntryModule, a as HadarsOptions } from './hadars-CSWWhlQC.cjs';
2
2
 
3
3
  /**
4
4
  * AWS Lambda adapter for hadars.
package/dist/lambda.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { H as HadarsEntryModule, a as HadarsOptions } from './hadars-mKu5txjW.js';
1
+ import { H as HadarsEntryModule, a as HadarsOptions } from './hadars-CSWWhlQC.js';
2
2
 
3
3
  /**
4
4
  * AWS Lambda adapter for hadars.
@@ -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
- setTitle(childProps['children'] as string);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.2.2-rc.1",
3
+ "version": "0.3.0",
4
4
  "description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
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:
@@ -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
- } as any;
82
+ };
83
83
 
84
84
  const actions: GatsbyActions = {
85
85
  createNode: (node) => store.createNode(node),
86
86
  deleteNode: ({ id }) => {
87
- // NodeStore doesn't expose delete — it's rarely needed by source plugins
88
- // on first run, so we silently no-op. A full implementation would add
89
- // store.deleteNode().
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
  };
@@ -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
- const url = new URL(req.url);
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
  });
@@ -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
- * Build a GraphQL executor backed by the node store.
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
- export async function buildSchemaExecutor(
115
- store: NodeStore,
116
- ): Promise<GraphQLExecutor | null> {
117
- // graphql is an optional peer dependency installed in the user's project,
118
- // not in hadars itself. Resolve it from process.cwd() so Node.js finds it
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, graphql } = gql;
131
-
129
+ const { buildSchema, printSchema, print } = gql;
132
130
  const types = store.getTypes();
131
+
133
132
  if (types.length === 0) {
134
- // Empty store return a no-op executor with a dummy schema
135
- const schema = buildSchema('type Query { _empty: String }');
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 both the SDL and resolvers.
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 sdl = [
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(sdl);
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
  }
@@ -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 mod.sourceNodes(ctx, options);
113
+ await withTimeout(
114
+ Promise.resolve(mod.sourceNodes(ctx, options)),
115
+ SOURCE_NODES_TIMEOUT_MS,
116
+ `${pluginName} sourceNodes`,
117
+ );
97
118
  } catch (err) {
98
- throw new Error(
99
- `[hadars] source plugin "${pluginName}" threw during sourceNodes: ${(err as Error).message}`,
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).
@@ -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,