hadars 0.2.0 → 0.2.2-rc.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 +247 -15
- package/cli-lib.ts +184 -15
- package/dist/chunk-H72BZXOA.js +332 -0
- package/dist/cli.js +596 -23
- package/dist/cloudflare.cjs +1394 -0
- package/dist/cloudflare.d.cts +64 -0
- package/dist/cloudflare.d.ts +64 -0
- package/dist/cloudflare.js +68 -0
- package/dist/{hadars-Bh-V5YXg.d.cts → hadars-mKu5txjW.d.cts} +93 -37
- package/dist/{hadars-Bh-V5YXg.d.ts → hadars-mKu5txjW.d.ts} +93 -37
- package/dist/index.cjs +140 -164
- package/dist/index.d.cts +5 -11
- package/dist/index.d.ts +5 -11
- package/dist/index.js +140 -163
- package/dist/lambda.cjs +6 -2
- package/dist/lambda.d.cts +1 -2
- package/dist/lambda.d.ts +1 -2
- package/dist/lambda.js +10 -317
- package/dist/ssr-render-worker.js +3 -2
- package/dist/utils/Head.tsx +149 -195
- package/dist/utils/clientScript.tsx +9 -0
- package/index.ts +3 -0
- package/package.json +7 -2
- package/src/build.ts +29 -0
- package/src/cloudflare.ts +139 -0
- package/src/index.tsx +3 -3
- package/src/source/context.ts +113 -0
- package/src/source/graphiql.ts +96 -0
- package/src/source/inference.ts +188 -0
- package/src/source/runner.ts +114 -0
- package/src/source/store.ts +48 -0
- package/src/ssr-render-worker.ts +4 -1
- package/src/static.ts +106 -0
- package/src/types/hadars.ts +91 -2
- package/src/utils/Head.tsx +149 -195
- package/src/utils/clientScript.tsx +9 -0
- package/src/utils/response.tsx +10 -3
package/src/index.tsx
CHANGED
|
@@ -7,11 +7,11 @@ export type {
|
|
|
7
7
|
HadarsGetClientProps,
|
|
8
8
|
HadarsEntryModule,
|
|
9
9
|
HadarsApp,
|
|
10
|
+
HadarsStaticContext,
|
|
11
|
+
GraphQLExecutor,
|
|
12
|
+
HadarsSourceEntry,
|
|
10
13
|
} from "./types/hadars";
|
|
11
14
|
export { Head as HadarsHead, useServerData, initServerDataCache } from './utils/Head';
|
|
12
|
-
import { AppProviderSSR, AppProviderCSR } from "./utils/Head";
|
|
13
|
-
|
|
14
|
-
export const HadarsContext = typeof window === 'undefined' ? AppProviderSSR : AppProviderCSR;
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Dynamically loads a module with target-aware behaviour:
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gatsby sourceNodes context shim.
|
|
3
|
+
*
|
|
4
|
+
* Gatsby passes a rich object to each source plugin's `sourceNodes` function.
|
|
5
|
+
* We implement the subset that the vast majority of CMS source plugins actually
|
|
6
|
+
* use so that existing plugins work without modification.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { EventEmitter } from 'node:events';
|
|
11
|
+
import type { NodeStore, HadarsNode } from './store';
|
|
12
|
+
|
|
13
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface GatsbyCache {
|
|
16
|
+
get(key: string): Promise<unknown>;
|
|
17
|
+
set(key: string, value: unknown): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface GatsbyReporter {
|
|
21
|
+
info(message: string): void;
|
|
22
|
+
warn(message: string): void;
|
|
23
|
+
error(message: string, err?: Error): void;
|
|
24
|
+
panic(message: string, err?: Error): never;
|
|
25
|
+
activityTimer(name: string): { start(): void; end(): void };
|
|
26
|
+
verbose(message: string): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GatsbyActions {
|
|
30
|
+
createNode(node: HadarsNode): void;
|
|
31
|
+
deleteNode(node: { id: string }): void;
|
|
32
|
+
touchNode(node: { id: string }): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GatsbyNodeHelpers {
|
|
36
|
+
actions: GatsbyActions;
|
|
37
|
+
createNodeId(input: string): string;
|
|
38
|
+
createContentDigest(content: unknown): string;
|
|
39
|
+
getNode(id: string): HadarsNode | undefined;
|
|
40
|
+
getNodes(): HadarsNode[];
|
|
41
|
+
getNodesByType(type: string): HadarsNode[];
|
|
42
|
+
cache: GatsbyCache;
|
|
43
|
+
reporter: GatsbyReporter;
|
|
44
|
+
// Gatsby passes these; many plugins destructure but never call them
|
|
45
|
+
store: unknown;
|
|
46
|
+
emitter: unknown;
|
|
47
|
+
tracing: unknown;
|
|
48
|
+
schema: unknown;
|
|
49
|
+
/** Available in Gatsby 4+ — mostly unused by source plugins. */
|
|
50
|
+
parentSpan: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Factory ───────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
export function makeGatsbyContext(
|
|
56
|
+
store: NodeStore,
|
|
57
|
+
pluginName: string,
|
|
58
|
+
pluginOptions: Record<string, unknown> = {},
|
|
59
|
+
emitter: EventEmitter = new EventEmitter(),
|
|
60
|
+
): GatsbyNodeHelpers {
|
|
61
|
+
// A simple in-memory cache per plugin instance
|
|
62
|
+
const cacheMap = new Map<string, unknown>();
|
|
63
|
+
const cache: GatsbyCache = {
|
|
64
|
+
get: (key) => Promise.resolve(cacheMap.get(key)),
|
|
65
|
+
set: (key, value) => { cacheMap.set(key, value); return Promise.resolve(); },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const reporter: GatsbyReporter = {
|
|
69
|
+
info: (msg) => console.log(`[${pluginName}] ${msg}`),
|
|
70
|
+
warn: (msg) => console.warn(`[${pluginName}] WARN: ${msg}`),
|
|
71
|
+
error: (msg, err) => console.error(`[${pluginName}] ERROR: ${msg}`, err ?? ''),
|
|
72
|
+
panic: (msg, err) => { console.error(`[${pluginName}] PANIC: ${msg}`, err ?? ''); process.exit(1); },
|
|
73
|
+
verbose: (msg) => { if (process.env.HADARS_VERBOSE) console.log(`[${pluginName}] ${msg}`); },
|
|
74
|
+
activityTimer: (name) => ({
|
|
75
|
+
start: () => { if (process.env.HADARS_VERBOSE) console.log(`[${pluginName}] ▶ ${name}`); },
|
|
76
|
+
end: () => { if (process.env.HADARS_VERBOSE) console.log(`[${pluginName}] ■ ${name}`); },
|
|
77
|
+
}),
|
|
78
|
+
// Gatsby 4+ reporter extras — used by some plugins but safe to no-op
|
|
79
|
+
setErrorMap: () => {},
|
|
80
|
+
log: (msg: string) => console.log(`[${pluginName}] ${msg}`),
|
|
81
|
+
success: (msg: string) => console.log(`[${pluginName}] ✓ ${msg}`),
|
|
82
|
+
} as any;
|
|
83
|
+
|
|
84
|
+
const actions: GatsbyActions = {
|
|
85
|
+
createNode: (node) => store.createNode(node),
|
|
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().
|
|
90
|
+
},
|
|
91
|
+
touchNode: () => { /* no-op — only relevant for Gatsby's caching layer */ },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
actions,
|
|
96
|
+
createNodeId: (input) => createHash('sha256').update(pluginName + input).digest('hex'),
|
|
97
|
+
createContentDigest: (content) => {
|
|
98
|
+
const str = typeof content === 'string' ? content : JSON.stringify(content);
|
|
99
|
+
return createHash('md5').update(str).digest('hex');
|
|
100
|
+
},
|
|
101
|
+
getNode: (id) => store.getNode(id),
|
|
102
|
+
getNodes: () => store.getNodes(),
|
|
103
|
+
getNodesByType: (t) => store.getNodesByType(t),
|
|
104
|
+
cache,
|
|
105
|
+
reporter,
|
|
106
|
+
// Stubs for rarely-used Gatsby internals that plugins may destructure
|
|
107
|
+
store: {},
|
|
108
|
+
emitter,
|
|
109
|
+
tracing: { startSpan: () => ({ finish: () => {} }) },
|
|
110
|
+
schema: {},
|
|
111
|
+
parentSpan: null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphiQL dev endpoint — serves the GraphiQL IDE at GET /__hadars/graphql
|
|
3
|
+
* and a JSON GraphQL API at POST /__hadars/graphql.
|
|
4
|
+
*
|
|
5
|
+
* Only mounted in dev mode when config.sources is present.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { GraphQLExecutor } from '../types/hadars';
|
|
9
|
+
|
|
10
|
+
export const GRAPHQL_PATH = '/__hadars/graphql';
|
|
11
|
+
|
|
12
|
+
// GraphiQL HTML shell — UMD bundles from unpkg (dev-only).
|
|
13
|
+
// Using UMD avoids ES module peer-dependency conflicts (CodeMirror, etc.)
|
|
14
|
+
// and matches the official GraphiQL standalone embedding guide.
|
|
15
|
+
const GRAPHIQL_HTML = `<!doctype html>
|
|
16
|
+
<html lang="en">
|
|
17
|
+
<head>
|
|
18
|
+
<meta charset="utf-8">
|
|
19
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
20
|
+
<title>GraphiQL — hadars</title>
|
|
21
|
+
<style>
|
|
22
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
23
|
+
body { height: 100vh; overflow: hidden; }
|
|
24
|
+
#graphiql { height: 100vh; }
|
|
25
|
+
</style>
|
|
26
|
+
<link rel="stylesheet" href="https://unpkg.com/graphiql@3/graphiql.min.css" />
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
<div id="graphiql"></div>
|
|
30
|
+
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
|
31
|
+
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
|
32
|
+
<script src="https://unpkg.com/graphiql@3/graphiql.min.js" crossorigin></script>
|
|
33
|
+
<script>
|
|
34
|
+
const root = ReactDOM.createRoot(document.getElementById('graphiql'));
|
|
35
|
+
root.render(
|
|
36
|
+
React.createElement(GraphiQL, {
|
|
37
|
+
fetcher: GraphiQL.createFetcher({ url: '${GRAPHQL_PATH}' }),
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
</script>
|
|
41
|
+
</body>
|
|
42
|
+
</html>`;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns a fetch handler that covers GET and POST for `/__hadars/graphql`.
|
|
46
|
+
* Returns `undefined` for any other path so callers can chain normally.
|
|
47
|
+
*/
|
|
48
|
+
export function createGraphiqlHandler(
|
|
49
|
+
executor: GraphQLExecutor,
|
|
50
|
+
): (req: Request) => Promise<Response | undefined> {
|
|
51
|
+
return async (req: Request): Promise<Response | undefined> => {
|
|
52
|
+
const url = new URL(req.url);
|
|
53
|
+
if (url.pathname !== GRAPHQL_PATH) return undefined;
|
|
54
|
+
|
|
55
|
+
// GET — serve GraphiQL IDE
|
|
56
|
+
if (req.method === 'GET') {
|
|
57
|
+
return new Response(GRAPHIQL_HTML, {
|
|
58
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// POST — execute GraphQL query
|
|
63
|
+
if (req.method === 'POST') {
|
|
64
|
+
let body: { query?: string; variables?: Record<string, unknown>; operationName?: string };
|
|
65
|
+
try {
|
|
66
|
+
body = await req.json();
|
|
67
|
+
} catch {
|
|
68
|
+
return new Response(JSON.stringify({ errors: [{ message: 'Invalid JSON body' }] }), {
|
|
69
|
+
status: 400,
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!body.query) {
|
|
75
|
+
return new Response(JSON.stringify({ errors: [{ message: 'Missing "query" field' }] }), {
|
|
76
|
+
status: 400,
|
|
77
|
+
headers: { 'Content-Type': 'application/json' },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const result = await executor(body.query, body.variables);
|
|
83
|
+
return new Response(JSON.stringify(result), {
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
});
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return new Response(JSON.stringify({ errors: [{ message: (err as Error).message }] }), {
|
|
88
|
+
status: 500,
|
|
89
|
+
headers: { 'Content-Type': 'application/json' },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return new Response('Method Not Allowed', { status: 405 });
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema inference — builds a GraphQL schema from the node store automatically,
|
|
3
|
+
* just like Gatsby does. Uses graphql-js (must be installed in the user's project).
|
|
4
|
+
*
|
|
5
|
+
* Dynamically imports graphql so hadars itself does not depend on it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { NodeStore } from './store';
|
|
9
|
+
import type { GraphQLExecutor } from '../types/hadars';
|
|
10
|
+
|
|
11
|
+
// ── Primitive inference ────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
type ScalarName = 'String' | 'Int' | 'Float' | 'Boolean' | 'ID';
|
|
14
|
+
|
|
15
|
+
function inferScalar(value: unknown): ScalarName {
|
|
16
|
+
if (typeof value === 'boolean') return 'Boolean';
|
|
17
|
+
if (typeof value === 'number') return Number.isInteger(value) ? 'Int' : 'Float';
|
|
18
|
+
return 'String';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface FieldShape {
|
|
22
|
+
type: string; // e.g. "String", "Int", "Boolean", "[String]", "InternalType"
|
|
23
|
+
nullable: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function inferFieldShape(value: unknown, seenTypes: Set<string>): FieldShape {
|
|
27
|
+
if (value === null || value === undefined) {
|
|
28
|
+
return { type: 'String', nullable: true };
|
|
29
|
+
}
|
|
30
|
+
if (Array.isArray(value)) {
|
|
31
|
+
const inner = value.length > 0
|
|
32
|
+
? inferFieldShape(value[0], seenTypes)
|
|
33
|
+
: { type: 'String', nullable: true };
|
|
34
|
+
return { type: `[${inner.type}]`, nullable: true };
|
|
35
|
+
}
|
|
36
|
+
if (typeof value === 'object') {
|
|
37
|
+
// nested object — we don't recurse deeply for now; use JSON string
|
|
38
|
+
return { type: 'String', nullable: true };
|
|
39
|
+
}
|
|
40
|
+
return { type: inferScalar(value), nullable: true };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Schema string builder ──────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const INTERNAL_FIELDS = new Set(['id', 'internal', '__typename', 'parent', 'children']);
|
|
46
|
+
|
|
47
|
+
/** Scalar GraphQL types that are safe to use as lookup filter arguments. */
|
|
48
|
+
const FILTERABLE_SCALARS = new Set(['String', 'Int', 'Float', 'Boolean', 'ID']);
|
|
49
|
+
|
|
50
|
+
interface InferredField {
|
|
51
|
+
name: string;
|
|
52
|
+
type: string;
|
|
53
|
+
/** True when the base type is a plain scalar (not a list/object). */
|
|
54
|
+
filterable: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildTypeFields(nodes: readonly Record<string, unknown>[]): InferredField[] {
|
|
58
|
+
const fieldMap = new Map<string, InferredField>();
|
|
59
|
+
|
|
60
|
+
for (const node of nodes) {
|
|
61
|
+
for (const [key, val] of Object.entries(node)) {
|
|
62
|
+
if (INTERNAL_FIELDS.has(key)) continue;
|
|
63
|
+
if (fieldMap.has(key)) continue;
|
|
64
|
+
const { type } = inferFieldShape(val, new Set());
|
|
65
|
+
fieldMap.set(key, {
|
|
66
|
+
name: key,
|
|
67
|
+
type,
|
|
68
|
+
filterable: FILTERABLE_SCALARS.has(type),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return Array.from(fieldMap.values());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildTypeSDL(typeName: string, fields: InferredField[]): string {
|
|
77
|
+
const lines = [
|
|
78
|
+
' id: ID!',
|
|
79
|
+
...fields.map(f => ` ${f.name}: ${f.type}`),
|
|
80
|
+
];
|
|
81
|
+
return `type ${typeName} {\n${lines.join('\n')}\n}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Query builder ──────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/** Build allXxx / xxx query names from a type name, matching Gatsby's convention. */
|
|
87
|
+
function queryNames(typeName: string) {
|
|
88
|
+
const lower = typeName.charAt(0).toLowerCase() + typeName.slice(1);
|
|
89
|
+
return { single: lower, all: `all${typeName}` };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build the SDL argument list for the single-item query.
|
|
94
|
+
* Includes `id` plus every filterable scalar field so callers can look up
|
|
95
|
+
* nodes by any natural key (e.g. slug, email) without knowing the hashed id.
|
|
96
|
+
* The resolver returns the first node where ALL supplied arguments match.
|
|
97
|
+
*/
|
|
98
|
+
function buildSingleArgs(fields: InferredField[]): string {
|
|
99
|
+
const args = [
|
|
100
|
+
'id: ID',
|
|
101
|
+
...fields.filter(f => f.filterable && f.name !== 'id').map(f => `${f.name}: ${f.type}`),
|
|
102
|
+
];
|
|
103
|
+
return args.join(', ');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Public API ─────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
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`.
|
|
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.
|
|
120
|
+
let gql: any;
|
|
121
|
+
try {
|
|
122
|
+
const { createRequire } = await import('node:module');
|
|
123
|
+
const projectRequire = createRequire(process.cwd() + '/package.json');
|
|
124
|
+
const graphqlPath = projectRequire.resolve('graphql');
|
|
125
|
+
gql = await import(graphqlPath);
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const { buildSchema, graphql } = gql;
|
|
131
|
+
|
|
132
|
+
const types = store.getTypes();
|
|
133
|
+
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 });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Infer field shapes once per type — reused for both the SDL and resolvers.
|
|
140
|
+
const typeFields = new Map(
|
|
141
|
+
types.map(typeName => {
|
|
142
|
+
const nodes = store.getNodesByType(typeName) as Record<string, unknown>[];
|
|
143
|
+
return [typeName, buildTypeFields(nodes)] as const;
|
|
144
|
+
})
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const typeSDLs = types.map(typeName =>
|
|
148
|
+
buildTypeSDL(typeName, typeFields.get(typeName)!)
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const queryFields = types.map(typeName => {
|
|
152
|
+
const { single, all } = queryNames(typeName);
|
|
153
|
+
const args = buildSingleArgs(typeFields.get(typeName)!);
|
|
154
|
+
return [
|
|
155
|
+
` ${single}(${args}): ${typeName}`,
|
|
156
|
+
` ${all}: [${typeName}!]!`,
|
|
157
|
+
].join('\n');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const sdl = [
|
|
161
|
+
...typeSDLs,
|
|
162
|
+
`type Query {\n${queryFields.join('\n')}\n}`,
|
|
163
|
+
].join('\n\n');
|
|
164
|
+
|
|
165
|
+
let schema: any;
|
|
166
|
+
try {
|
|
167
|
+
schema = buildSchema(sdl);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
throw new Error(`[hadars] Failed to build GraphQL schema from node store: ${(err as Error).message}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Build root resolver map
|
|
173
|
+
const rootValue: Record<string, unknown> = {};
|
|
174
|
+
for (const typeName of types) {
|
|
175
|
+
const { single, all } = queryNames(typeName);
|
|
176
|
+
rootValue[all] = () => store.getNodesByType(typeName);
|
|
177
|
+
// Single-item resolver: return the first node matching ALL supplied args.
|
|
178
|
+
rootValue[single] = (args: Record<string, unknown>) => {
|
|
179
|
+
const nodes = store.getNodesByType(typeName) as Record<string, unknown>[];
|
|
180
|
+
return nodes.find(node =>
|
|
181
|
+
Object.entries(args).every(([k, v]) => v === undefined || node[k] === v)
|
|
182
|
+
) ?? null;
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return (query, variables) =>
|
|
187
|
+
graphql({ schema, rootValue, source: query, variableValues: variables }) as any;
|
|
188
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source plugin runner — calls each plugin's `sourceNodes` with a Gatsby-compatible
|
|
3
|
+
* context object, then returns a populated node store ready for schema inference.
|
|
4
|
+
*
|
|
5
|
+
* Handles async plugins (e.g. gatsby-source-filesystem) that return immediately
|
|
6
|
+
* and create nodes later via file-system events:
|
|
7
|
+
* 1. Wait for nodes to stop arriving ("settle").
|
|
8
|
+
* 2. Emit BOOTSTRAP_FINISHED on the plugin's emitter.
|
|
9
|
+
* 3. Wait for any post-bootstrap nodes to settle.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { EventEmitter } from 'node:events';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { NodeStore } from './store';
|
|
15
|
+
import { makeGatsbyContext } from './context';
|
|
16
|
+
import type { HadarsSourceEntry } from '../types/hadars';
|
|
17
|
+
|
|
18
|
+
/** ms without a new createNode call before we consider the plugin settled. */
|
|
19
|
+
const SETTLE_IDLE_MS = 300;
|
|
20
|
+
/** Hard timeout — give up waiting after this many ms. */
|
|
21
|
+
const SETTLE_TIMEOUT_MS = 10_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wait until no createNode call has been received for SETTLE_IDLE_MS,
|
|
25
|
+
* or until SETTLE_TIMEOUT_MS elapses, whichever comes first.
|
|
26
|
+
*/
|
|
27
|
+
function waitForSettle(getLastNodeTime: () => number): Promise<void> {
|
|
28
|
+
return new Promise(resolve => {
|
|
29
|
+
const deadline = Date.now() + SETTLE_TIMEOUT_MS;
|
|
30
|
+
const check = () => {
|
|
31
|
+
const idle = Date.now() - getLastNodeTime();
|
|
32
|
+
if (idle >= SETTLE_IDLE_MS || Date.now() >= deadline) {
|
|
33
|
+
resolve();
|
|
34
|
+
} else {
|
|
35
|
+
setTimeout(check, 50);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
setTimeout(check, SETTLE_IDLE_MS);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runSources(
|
|
43
|
+
sources: HadarsSourceEntry[],
|
|
44
|
+
): Promise<NodeStore> {
|
|
45
|
+
const store = new NodeStore();
|
|
46
|
+
|
|
47
|
+
for (const entry of sources) {
|
|
48
|
+
const { resolve, options = {} } = entry;
|
|
49
|
+
|
|
50
|
+
let mod: { sourceNodes?: (ctx: any, opts?: any) => Promise<void> | void };
|
|
51
|
+
if (typeof resolve === 'string') {
|
|
52
|
+
// Resolve from the user's project directory so the package is found
|
|
53
|
+
// in their node_modules, not in the CLI's own node_modules.
|
|
54
|
+
const projectRequire = createRequire(process.cwd() + '/package.json');
|
|
55
|
+
|
|
56
|
+
// Gatsby plugins export sourceNodes from gatsby-node.js, not their
|
|
57
|
+
// package main. Try gatsby-node.js first, fall back to main entry.
|
|
58
|
+
let pkgPath: string;
|
|
59
|
+
try {
|
|
60
|
+
pkgPath = projectRequire.resolve(`${resolve}/gatsby-node`);
|
|
61
|
+
} catch {
|
|
62
|
+
pkgPath = projectRequire.resolve(resolve);
|
|
63
|
+
}
|
|
64
|
+
mod = await import(pkgPath);
|
|
65
|
+
} else {
|
|
66
|
+
mod = resolve as any;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (typeof mod.sourceNodes !== 'function') {
|
|
70
|
+
const name = typeof resolve === 'string' ? resolve : '(module)';
|
|
71
|
+
console.warn(`[hadars] source plugin ${name} does not export sourceNodes — skipping`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const pluginName = typeof resolve === 'string' ? resolve : 'hadars-source';
|
|
76
|
+
|
|
77
|
+
// Track the last time createNode was called so we can detect settling.
|
|
78
|
+
let lastNodeTime = Date.now();
|
|
79
|
+
const trackingStore = new Proxy(store, {
|
|
80
|
+
get(target, prop) {
|
|
81
|
+
if (prop === 'createNode') {
|
|
82
|
+
return (node: any) => {
|
|
83
|
+
lastNodeTime = Date.now();
|
|
84
|
+
return target.createNode(node);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return (target as any)[prop];
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Real EventEmitter — plugins subscribe to BOOTSTRAP_FINISHED on this.
|
|
92
|
+
const emitter = new EventEmitter();
|
|
93
|
+
const ctx = makeGatsbyContext(trackingStore, pluginName, options, emitter);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await mod.sourceNodes(ctx, options);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`[hadars] source plugin "${pluginName}" threw during sourceNodes: ${(err as Error).message}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Phase 1: wait for async initial-scan nodes (e.g. chokidar ready → createFileNode).
|
|
104
|
+
await waitForSettle(() => lastNodeTime);
|
|
105
|
+
|
|
106
|
+
// Signal Gatsby lifecycle end — some plugins create additional nodes here.
|
|
107
|
+
emitter.emit('BOOTSTRAP_FINISHED');
|
|
108
|
+
|
|
109
|
+
// Phase 2: wait for any post-bootstrap nodes to settle.
|
|
110
|
+
await waitForSettle(() => lastNodeTime);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return store;
|
|
114
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory node store — the equivalent of Gatsby's internal node database.
|
|
3
|
+
* Source plugins write nodes here via the context shim; the schema inferencer
|
|
4
|
+
* reads them to build the GraphQL schema.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface HadarsNode {
|
|
8
|
+
id: string;
|
|
9
|
+
/** Gatsby convention: the node type string, e.g. "MarkdownRemark", "ContentfulBlogPost". */
|
|
10
|
+
internal: {
|
|
11
|
+
type: string;
|
|
12
|
+
contentDigest: string;
|
|
13
|
+
content?: string;
|
|
14
|
+
mediaType?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
};
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class NodeStore {
|
|
21
|
+
private byId = new Map<string, HadarsNode>();
|
|
22
|
+
private byType = new Map<string, HadarsNode[]>();
|
|
23
|
+
|
|
24
|
+
createNode(node: HadarsNode): void {
|
|
25
|
+
this.byId.set(node.id, node);
|
|
26
|
+
const list = this.byType.get(node.internal.type) ?? [];
|
|
27
|
+
// Replace existing node with same id if present
|
|
28
|
+
const idx = list.findIndex(n => n.id === node.id);
|
|
29
|
+
if (idx >= 0) list[idx] = node; else list.push(node);
|
|
30
|
+
this.byType.set(node.internal.type, list);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getNode(id: string): HadarsNode | undefined {
|
|
34
|
+
return this.byId.get(id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getNodes(): HadarsNode[] {
|
|
38
|
+
return Array.from(this.byId.values());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getNodesByType(type: string): HadarsNode[] {
|
|
42
|
+
return this.byType.get(type) ?? [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getTypes(): string[] {
|
|
46
|
+
return Array.from(this.byType.keys());
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/ssr-render-worker.ts
CHANGED
|
@@ -58,12 +58,14 @@ async function runFullLifecycle(serialReq: SerializableRequest) {
|
|
|
58
58
|
let props: any = {
|
|
59
59
|
...(getInitProps ? await getInitProps(parsedReq) : {}),
|
|
60
60
|
location: serialReq.location,
|
|
61
|
-
context,
|
|
62
61
|
};
|
|
63
62
|
|
|
64
63
|
// Create per-request cache for useServerData, active for all renders.
|
|
65
64
|
const unsuspend = { cache: new Map<string, any>() };
|
|
66
65
|
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
66
|
+
// Expose the head context so HadarsHead can write into it without needing
|
|
67
|
+
// the user to manually wrap their App with HadarsContext.
|
|
68
|
+
(globalThis as any).__hadarsContext = context;
|
|
67
69
|
|
|
68
70
|
// Single pass — component-level self-retry resolves all useServerData inline.
|
|
69
71
|
// context.head is fully populated by the time renderToString returns.
|
|
@@ -72,6 +74,7 @@ async function runFullLifecycle(serialReq: SerializableRequest) {
|
|
|
72
74
|
appHtml = await renderToString(createElement(Component, props));
|
|
73
75
|
} finally {
|
|
74
76
|
(globalThis as any).__hadarsUnsuspend = null;
|
|
77
|
+
(globalThis as any).__hadarsContext = null;
|
|
75
78
|
}
|
|
76
79
|
// Head is captured after the render — all components have run.
|
|
77
80
|
const headHtml = buildHeadHtml(context.head);
|