decoupled-client
v0.2.0
Published
Type-safe TypeScript client for Decoupled Drupal. Full autocomplete, zero GraphQL required.
Downloads
274
Maintainers
Readme
decoupled-client
Type-safe TypeScript client for Decoupled Drupal. Full autocomplete, zero GraphQL boilerplate.
Install
npm install decoupled-clientQuick Start
import { createClient } from 'decoupled-client'
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_DRUPAL_BASE_URL!,
clientId: process.env.DRUPAL_CLIENT_ID!,
clientSecret: process.env.DRUPAL_CLIENT_SECRET!,
})
// Raw GraphQL query
const data = await client.query(`
query {
nodeArticles(first: 10) {
nodes { id title path }
}
}
`)
// Route-based query (resolve a Drupal path)
const page = await client.queryByPath('/about', routeQuery)Typed Client (with codegen)
Generate a fully typed client from your Drupal GraphQL schema:
# 1. Sync the schema (generates schema/introspection.json)
npx decoupled-cli@latest schema sync
# 2. Generate the typed client
node -e "
const { generateClientCode } = require('decoupled-client/codegen')
const fs = require('fs')
const schema = JSON.parse(fs.readFileSync('schema/introspection.json', 'utf8'))
fs.writeFileSync('schema/client.ts', generateClientCode(schema.__schema))
"Then use it:
import { createClient } from 'decoupled-client'
import { createTypedClient } from './schema/client'
const base = createClient({
baseUrl: process.env.NEXT_PUBLIC_DRUPAL_BASE_URL!,
clientId: process.env.DRUPAL_CLIENT_ID!,
clientSecret: process.env.DRUPAL_CLIENT_SECRET!,
})
const client = createTypedClient(base)
// Full autocomplete on content types and fields
const articles = await client.getEntries('NodeArticle', { first: 10 })
const article = await client.getEntry('NodeArticle', articleId)
const page = await client.getEntryByPath('/about')API
createClient(config)
Create a base client with OAuth authentication.
interface ClientConfig {
baseUrl: string // Drupal site URL
clientId: string // OAuth client ID
clientSecret: string // OAuth client secret
fetch?: typeof globalThis.fetch // Custom fetch (for Next.js ISR tags)
proxyUrl?: string // Proxy URL (skips OAuth, for frontend proxies)
graphqlPath?: string // GraphQL endpoint path (default: /graphql)
}client.query<T>(query, variables?)
Execute a raw GraphQL query. Returns the data field from the response.
const data = await client.query<{ nodeArticles: { nodes: Article[] } }>(`
query ($first: Int) {
nodeArticles(first: $first) {
nodes { id title }
}
}
`, { first: 5 })client.queryByPath<T>(path, routeQuery)
Resolve a Drupal path to a content node using a route() query.
const page = await client.queryByPath('/about', `
query ($path: String!) {
route(path: $path) {
... on RouteInternal {
entity {
... on NodePage { id title body { value } }
}
}
}
}
`)Returns null if no content found at the path.
Typed Client (generated)
The createTypedClient(baseClient) function wraps the base client with type-safe methods:
interface TypedClient {
getEntries<K extends ContentTypeName>(
type: K,
options?: QueryOptions,
): Promise<ContentTypeMap[K][]>
getEntry<K extends ContentTypeName>(
type: K,
id: string,
): Promise<ContentTypeMap[K] | null>
getEntryByPath(path: string): Promise<ContentNode | null>
raw<T>(query: string, variables?: Record<string, any>): Promise<T>
}
interface QueryOptions {
first?: number
after?: string
sortKey?: string
reverse?: boolean
}Types
The client exports base types for Drupal entities:
import type {
DrupalNode, // { __typename, id, title, path, created, changed }
DrupalParagraph, // { __typename, id }
DrupalTerm, // { __typename, id, name, path?, description? }
Text, // { value: string }
TextSummary, // { value: string; summary?: string }
Image, // { url, alt, width, height, variations? }
Link, // { uri, title }
DateTime, // { time: string }
} from 'decoupled-client'Authentication
The client uses OAuth 2.0 client_credentials grant:
- Tokens are cached in memory and refreshed automatically (60s before expiry)
- Set
proxyUrlinstead ofclientId/clientSecretto skip OAuth and use a frontend proxy
Error Handling
import { DecoupledError, AuthError, NotFoundError } from 'decoupled-client'
try {
const data = await client.query(myQuery)
} catch (error) {
if (error instanceof AuthError) {
// OAuth failed — check credentials
console.error('Auth failed:', error.statusCode)
} else if (error instanceof DecoupledError) {
// GraphQL errors
console.error('Query errors:', error.graphqlErrors)
}
}Next.js Integration
Pass a custom fetch to enable ISR revalidation tags:
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_DRUPAL_BASE_URL!,
clientId: process.env.DRUPAL_CLIENT_ID!,
clientSecret: process.env.DRUPAL_CLIENT_SECRET!,
fetch: (url, options) =>
globalThis.fetch(url, {
...options,
next: { tags: ['drupal'] },
}),
})Then call revalidateTag('drupal') in your revalidation webhook to bust the cache.
License
MIT
