Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.

feat(core/graphcache): Add support for client-side-only directive processing (#3317)

+5
.changeset/brave-lamps-punch.md
···
+
---
+
'@urql/exchange-graphcache': patch
+
---
+
+
Use new `FormattedNode` / `formatDocument` functionality added to `@urql/core` to slightly speed up directive processing by using the client-side `_directives` dictionary that `formatDocument` adds.
+5
.changeset/rude-waves-check.md
···
+
---
+
'@urql/core': minor
+
---
+
+
Update `formatDocument` to output `FormattedNode` type mapping. The formatter will now annotate added `__typename` fields with `_generated: true`, place selection nodes' directives onto a `_directives` dictionary, and will filter directives to not include `"_"` underscore prefixed directives in the final query. This prepares us for a feature that allows enhanced client-side directives in Graphcache.
+16 -13
exchanges/graphcache/src/ast/node.ts
···
import {
NamedTypeNode,
NameNode,
+
DirectiveNode,
SelectionNode,
SelectionSetNode,
-
InlineFragmentNode,
FieldNode,
FragmentDefinitionNode,
-
Kind,
} from '@0no-co/graphql.web';
-
export type SelectionSet = ReadonlyArray<SelectionNode>;
+
import { FormattedNode } from '@urql/core';
+
+
export type SelectionSet = readonly FormattedNode<SelectionNode>[];
+
+
const EMPTY_DIRECTIVES: Record<string, DirectiveNode | undefined> = {};
+
+
/** Returns the directives dictionary of a given node */
+
export const getDirectives = (node: {
+
_directives?: Record<string, DirectiveNode | undefined>;
+
}) => node._directives || EMPTY_DIRECTIVES;
/** Returns the name of a given node */
export const getName = (node: { name: NameNode }): string => node.name.value;
···
/** Returns the SelectionSet for a given inline or defined fragment node */
export const getSelectionSet = (node: {
-
selectionSet?: SelectionSetNode;
-
}): SelectionSet =>
-
node.selectionSet ? node.selectionSet.selections : emptySelectionSet;
+
selectionSet?: FormattedNode<SelectionSetNode>;
+
}): FormattedNode<SelectionSet> =>
+
(node.selectionSet
+
? node.selectionSet.selections
+
: emptySelectionSet) as FormattedNode<SelectionSet>;
export const getTypeCondition = (node: {
typeCondition?: NamedTypeNode;
}): string | null =>
node.typeCondition ? node.typeCondition.name.value : null;
-
-
export const isFieldNode = (node: SelectionNode): node is FieldNode =>
-
node.kind === Kind.FIELD;
-
-
export const isInlineFragment = (
-
node: SelectionNode
-
): node is InlineFragmentNode => node.kind === Kind.INLINE_FRAGMENT;
+22 -15
exchanges/graphcache/src/ast/traversal.test.ts
···
-
import { gql } from '@urql/core';
+
import { formatDocument, gql } from '@urql/core';
import { describe, it, expect } from 'vitest';
import { getSelectionSet } from './node';
···
describe('getMainOperation', () => {
it('retrieves the first operation', () => {
-
const doc = gql`
+
const doc = formatDocument(gql`
query Query {
field
}
-
`;
+
`);
+
const operation = getMainOperation(doc);
expect(operation).toBe(doc.definitions[0]);
});
it('throws when no operation is found', () => {
-
const doc = gql`
+
const doc = formatDocument(gql`
fragment _ on Query {
field
}
-
`;
+
`);
+
expect(() => getMainOperation(doc)).toThrow();
});
});
describe('shouldInclude', () => {
it('should include fields with truthy @include or falsy @skip directives', () => {
-
const doc = gql`
+
const doc = formatDocument(gql`
{
fieldA @include(if: true)
fieldB @skip(if: false)
}
-
`;
+
`);
+
const fieldA = getSelectionSet(getMainOperation(doc))[0];
const fieldB = getSelectionSet(getMainOperation(doc))[1];
expect(shouldInclude(fieldA, {})).toBe(true);
···
});
it('should exclude fields with falsy @include or truthy @skip directives', () => {
-
const doc = gql`
+
const doc = formatDocument(gql`
{
fieldA @include(if: false)
fieldB @skip(if: true)
}
-
`;
+
`);
+
const fieldA = getSelectionSet(getMainOperation(doc))[0];
const fieldB = getSelectionSet(getMainOperation(doc))[1];
expect(shouldInclude(fieldA, {})).toBe(false);
···
});
it('ignore other directives', () => {
-
const doc = gql`
+
const doc = formatDocument(gql`
{
field @test(if: false)
}
-
`;
+
`);
+
const field = getSelectionSet(getMainOperation(doc))[0];
expect(shouldInclude(field, {})).toBe(true);
});
it('ignore unknown arguments on directives', () => {
-
const doc = gql`
+
const doc = formatDocument(gql`
{
field @skip(if: true, other: false)
}
-
`;
+
`);
+
const field = getSelectionSet(getMainOperation(doc))[0];
expect(shouldInclude(field, {})).toBe(false);
});
it('ignore directives with invalid first arguments', () => {
-
const doc = gql`
+
const doc = formatDocument(gql`
{
field @skip(other: true)
}
-
`;
+
`);
+
const field = getSelectionSet(getMainOperation(doc))[0];
expect(shouldInclude(field, {})).toBe(true);
});
+39 -41
exchanges/graphcache/src/ast/traversal.ts
···
Kind,
} from '@0no-co/graphql.web';
-
import { getName } from './node';
-
+
import { FormattedNode } from '@urql/core';
+
import { getName, getDirectives } from './node';
import { invariant } from '../helpers/help';
import { Fragments, Variables } from '../types';
+
function getMainOperation(
+
doc: FormattedNode<DocumentNode>
+
): FormattedNode<OperationDefinitionNode>;
+
function getMainOperation(doc: DocumentNode): OperationDefinitionNode;
+
/** Returns the main operation's definition */
-
export const getMainOperation = (
-
doc: DocumentNode
-
): OperationDefinitionNode => {
+
function getMainOperation(doc: DocumentNode): OperationDefinitionNode {
for (let i = 0; i < doc.definitions.length; i++) {
if (doc.definitions[i].kind === Kind.OPERATION_DEFINITION) {
-
return doc.definitions[i] as OperationDefinitionNode;
+
return doc.definitions[i] as FormattedNode<OperationDefinitionNode>;
}
}
···
'node for a query, subscription, or mutation.',
1
);
-
};
+
}
+
+
export { getMainOperation };
/** Returns a mapping from fragment names to their selections */
-
export const getFragments = (doc: DocumentNode): Fragments => {
+
export const getFragments = (doc: FormattedNode<DocumentNode>): Fragments => {
const fragments: Fragments = {};
for (let i = 0; i < doc.definitions.length; i++) {
const node = doc.definitions[i];
···
/** Resolves @include and @skip directives to determine whether field is included. */
export const shouldInclude = (
-
node: SelectionNode,
+
node: FormattedNode<SelectionNode>,
vars: Variables
): boolean => {
-
// Finds any @include or @skip directive that forces the node to be skipped
-
for (let i = 0; node.directives && i < node.directives.length; i++) {
-
const directive = node.directives[i];
-
const name = getName(directive);
-
if (
-
(name === 'include' || name === 'skip') &&
-
directive.arguments &&
-
directive.arguments[0] &&
-
getName(directive.arguments[0]) === 'if'
-
) {
-
// Return whether this directive forces us to skip
-
// `@include(if: false)` or `@skip(if: true)`
-
const value = valueFromASTUntyped(directive.arguments[0].value, vars);
-
return name === 'include' ? !!value : !value;
+
const directives = getDirectives(node);
+
if (directives.include || directives.skip) {
+
// Finds any @include or @skip directive that forces the node to be skipped
+
for (const name in directives) {
+
const directive = directives[name];
+
if (
+
directive &&
+
(name === 'include' || name === 'skip') &&
+
directive.arguments &&
+
directive.arguments[0] &&
+
getName(directive.arguments[0]) === 'if'
+
) {
+
// Return whether this directive forces us to skip
+
// `@include(if: false)` or `@skip(if: true)`
+
const value = valueFromASTUntyped(directive.arguments[0].value, vars);
+
return name === 'include' ? !!value : !value;
+
}
}
}
-
return true;
};
/** Resolves @defer directive to determine whether a fragment is potentially skipped. */
export const isDeferred = (
-
node: FragmentSpreadNode | InlineFragmentNode,
+
node: FormattedNode<FragmentSpreadNode | InlineFragmentNode>,
vars: Variables
): boolean => {
-
for (let i = 0; node.directives && i < node.directives.length; i++) {
-
const directive = node.directives[i];
-
const name = getName(directive);
-
if (name === 'defer') {
-
for (
-
let j = 0;
-
directive.arguments && j < directive.arguments.length;
-
j++
-
) {
-
const argument = directive.arguments[i];
-
if (getName(argument) === 'if') {
-
// Return whether `@defer(if: )` is enabled
-
return !!valueFromASTUntyped(argument.value, vars);
-
}
+
const { defer } = getDirectives(node);
+
if (defer) {
+
for (const argument of defer.arguments || []) {
+
if (getName(argument) === 'if') {
+
// Return whether `@defer(if: )` is enabled
+
return !!valueFromASTUntyped(argument.value, vars);
}
-
-
return true;
}
+
return true;
}
return false;
+57 -41
exchanges/graphcache/src/ast/variables.test.ts
···
-
import { gql } from '@urql/core';
+
import { formatDocument, gql } from '@urql/core';
import { describe, it, expect } from 'vitest';
import { getMainOperation } from './traversal';
import { normalizeVariables, filterVariables } from './variables';
···
it('normalizes variables', () => {
const input = { x: 42 };
const operation = getMainOperation(
-
gql`
-
query ($x: Int!) {
-
field
-
}
-
`
+
formatDocument(
+
gql`
+
query ($x: Int!) {
+
field
+
}
+
`
+
)
);
const normalized = normalizeVariables(operation, input);
expect(normalized).toEqual({ x: 42 });
···
it('normalizes variables with defaults', () => {
const input = { x: undefined };
const operation = getMainOperation(
-
gql`
-
query ($x: Int! = 42) {
-
field
-
}
-
`
+
formatDocument(
+
gql`
+
query ($x: Int! = 42) {
+
field
+
}
+
`
+
)
);
const normalized = normalizeVariables(operation, input);
expect(normalized).toEqual({ x: 42 });
···
it('normalizes variables even with missing fields', () => {
const input = { x: undefined };
const operation = getMainOperation(
-
gql`
-
query ($x: Int!) {
-
field
-
}
-
`
+
formatDocument(
+
gql`
+
query ($x: Int!) {
+
field
+
}
+
`
+
)
);
const normalized = normalizeVariables(operation, input);
expect(normalized).toEqual({});
···
it('skips normalizing for queries without variables', () => {
const operation = getMainOperation(
-
gql`
-
query {
-
field
-
}
-
`
+
formatDocument(
+
gql`
+
query {
+
field
+
}
+
`
+
)
);
(operation as any).variableDefinitions = undefined;
const normalized = normalizeVariables(operation, {});
···
it('preserves missing variables', () => {
const operation = getMainOperation(
-
gql`
-
query {
-
field
-
}
-
`
+
formatDocument(
+
gql`
+
query {
+
field
+
}
+
`
+
)
);
(operation as any).variableDefinitions = undefined;
const normalized = normalizeVariables(operation, { test: true });
···
describe('filterVariables', () => {
it('returns undefined when no variables are defined', () => {
const operation = getMainOperation(
-
gql`
-
query {
-
field
-
}
-
`
+
formatDocument(
+
gql`
+
query {
+
field
+
}
+
`
+
)
);
const vars = filterVariables(operation, { test: true });
expect(vars).toBe(undefined);
···
it('filters out missing vars', () => {
const input = { x: true, y: false };
const operation = getMainOperation(
-
gql`
-
query ($x: Int!) {
-
field
-
}
-
`
+
formatDocument(
+
gql`
+
query ($x: Int!) {
+
field
+
}
+
`
+
)
);
const vars = filterVariables(operation, input);
expect(vars).toEqual({ x: true });
···
it('ignores defaults', () => {
const input = { x: undefined };
const operation = getMainOperation(
-
gql`
-
query ($x: Int! = 42) {
-
field
-
}
-
`
+
formatDocument(
+
gql`
+
query ($x: Int! = 42) {
+
field
+
}
+
`
+
)
);
const vars = filterVariables(operation, input);
expect(vars).toEqual({ x: undefined });
+25 -22
exchanges/graphcache/src/cacheExchange.ts
···
makeOperation,
Operation,
OperationResult,
-
OperationContext,
RequestPolicy,
CacheOutcome,
} from '@urql/core';
···
// This registers queries with the data layer to ensure commutativity
const prepareForwardedOperation = (operation: Operation) => {
-
let context: Partial<OperationContext> | undefined;
if (operation.kind === 'query') {
// Pre-reserve the position of the result layer
reserveLayer(store.data, operation.key);
···
// Mark operation layer as done
noopDataState(store.data, operation.key);
return operation;
-
} else if (
+
}
+
+
const query = formatDocument(operation.query);
+
operation = makeOperation(
+
operation.kind,
+
{
+
key: operation.key,
+
query,
+
variables: operation.variables
+
? filterVariables(getMainOperation(query), operation.variables)
+
: operation.variables,
+
},
+
{ ...operation.context }
+
);
+
+
if (
operation.kind === 'mutation' &&
operation.context.requestPolicy !== 'network-only'
) {
operations.set(operation.key, operation);
// This executes an optimistic update for mutations and registers it if necessary
initDataState('write', store.data, operation.key, true, false);
-
const { dependencies } = _write(store, operation, undefined, undefined);
+
const { dependencies } = _write(
+
store,
+
operation as any,
+
undefined,
+
undefined
+
);
clearDataState();
if (dependencies.size) {
// Update blocked optimistic dependencies
···
executePendingOperations(operation, pendingOperations, true);
// Mark operation as optimistic
-
context = { optimistic: true };
+
operation.context.optimistic = true;
}
}
-
return makeOperation(
-
operation.kind,
-
{
-
key: operation.key,
-
query: formatDocument(operation.query),
-
variables: operation.variables
-
? filterVariables(
-
getMainOperation(operation.query),
-
operation.variables
-
)
-
: operation.variables,
-
},
-
{ ...operation.context, ...context }
-
);
+
return operation;
};
// This updates the known dependencies for the passed operation
···
result: OperationResult,
pendingOperations: Operations
): OperationResult => {
-
// Retrieve the original operation to remove changes made by formatDocument
-
const operation =
-
operations.get(result.operation.key) || result.operation;
+
const { operation } = result;
if (operation.kind === 'mutation') {
// Collect previous dependencies that have been written for optimistic updates
const dependencies = optimisticKeysToDependencies.get(operation.key);
-1
exchanges/graphcache/src/offlineExchange.ts
···
} from '@urql/core';
import { SerializedRequest, CacheExchangeOpts, StorageAdapter } from './types';
-
import { cacheExchange } from './cacheExchange';
import { toRequestPolicy } from './helpers/operation';
+15 -14
exchanges/graphcache/src/operations/query.ts
···
-
import { CombinedError } from '@urql/core';
+
import { formatDocument, FormattedNode, CombinedError } from '@urql/core';
import {
FieldNode,
···
input?: Data | null | undefined,
error?: CombinedError | undefined
): QueryResult => {
-
const operation = getMainOperation(request.query);
+
const query = formatDocument(request.query);
+
const operation = getMainOperation(query);
const rootKey = store.rootFields[operation.operation];
const rootSelect = getSelectionSet(operation);
const ctx = makeContext(
store,
normalizeVariables(operation, request.variables),
-
getFragments(request.query),
+
getFragments(query),
rootKey,
rootKey,
error
···
const readRoot = (
ctx: Context,
entityKey: string,
-
select: SelectionSet,
+
select: FormattedNode<SelectionSet>,
input: Data
): Data => {
const typename = ctx.store.rootNames[entityKey]
···
ctx
);
-
let node: FieldNode | void;
+
let node: FormattedNode<FieldNode> | void;
let hasChanged = InMemoryData.currentForeignData;
const output = InMemoryData.makeData(input);
while ((node = iterate())) {
···
const readRootField = (
ctx: Context,
-
select: SelectionSet,
+
select: FormattedNode<SelectionSet>,
originalData: Link<Data>
): Link<Data> => {
if (Array.isArray(originalData)) {
···
export const _queryFragment = (
store: Store,
-
query: DocumentNode,
+
query: FormattedNode<DocumentNode>,
entity: Partial<Data> | string,
variables?: Variables,
fragmentName?: string
): Data | null => {
const fragments = getFragments(query);
-
let fragment: FragmentDefinitionNode;
+
let fragment: FormattedNode<FragmentDefinitionNode>;
if (fragmentName) {
-
fragment = fragments[fragmentName] as FragmentDefinitionNode;
+
fragment = fragments[fragmentName]!;
if (!fragment) {
warn(
'readFragment(...) was called with a fragment name that does not exist.\n' +
···
}
} else {
const names = Object.keys(fragments);
-
fragment = fragments[names[0]] as FragmentDefinitionNode;
+
fragment = fragments[names[0]]!;
if (!fragment) {
warn(
'readFragment(...) was called with an empty fragment.\n' +
···
const readSelection = (
ctx: Context,
key: string,
-
select: SelectionSet,
+
select: FormattedNode<SelectionSet>,
input: Data,
result?: Data
): Data | undefined => {
···
let hasPartials = false;
let hasNext = false;
let hasChanged = InMemoryData.currentForeignData;
-
let node: FieldNode | void;
+
let node: FormattedNode<FieldNode> | void;
const output = InMemoryData.makeData(input);
while ((node = iterate()) !== undefined) {
// Derive the needed data from our node.
···
typename: string,
fieldName: string,
key: string,
-
select: SelectionSet,
+
select: FormattedNode<SelectionSet>,
prevData: void | null | Data | Data[],
result: void | DataField,
isOwnedData: boolean
···
link: Link | Link[],
typename: string,
fieldName: string,
-
select: SelectionSet,
+
select: FormattedNode<SelectionSet>,
prevData: void | null | Data | Data[],
isOwnedData: boolean
): DataField | undefined => {
+11 -4
exchanges/graphcache/src/operations/shared.test.ts
···
import { describe, it, expect } from 'vitest';
-
import { TypedDocumentNode, gql } from '@urql/core';
+
import {
+
TypedDocumentNode,
+
FormattedNode,
+
formatDocument,
+
gql,
+
} from '@urql/core';
import { FieldNode } from '@0no-co/graphql.web';
import { makeSelectionIterator, deferRef } from './shared';
import { SelectionSet } from '../ast';
-
const selectionOfDocument = (doc: TypedDocumentNode): SelectionSet => {
-
for (const definition of doc.definitions)
+
const selectionOfDocument = (
+
doc: TypedDocumentNode
+
): FormattedNode<SelectionSet> => {
+
for (const definition of formatDocument(doc).definitions)
if (definition.kind === 'OperationDefinition')
-
return definition.selectionSet.selections;
+
return definition.selectionSet.selections as FormattedNode<SelectionSet>;
return [];
};
+13 -13
exchanges/graphcache/src/operations/shared.ts
···
-
import { CombinedError, ErrorLike } from '@urql/core';
+
import { CombinedError, ErrorLike, FormattedNode } from '@urql/core';
import {
+
Kind,
FieldNode,
InlineFragmentNode,
FragmentDefinitionNode,
···
import {
isDeferred,
-
isInlineFragment,
getTypeCondition,
getSelectionSet,
getName,
SelectionSet,
-
isFieldNode,
} from '../ast';
import { warn, pushDebugNode, popDebugNode } from '../helpers/help';
···
};
const isFragmentHeuristicallyMatching = (
-
node: InlineFragmentNode | FragmentDefinitionNode,
+
node: FormattedNode<InlineFragmentNode | FragmentDefinitionNode>,
typename: void | string,
entityKey: string,
vars: Variables
···
return (
currentOperation === 'write' ||
!getSelectionSet(node).some(node => {
-
if (!isFieldNode(node)) return false;
+
if (node.kind !== Kind.FIELD) return false;
const fieldKey = keyOfField(getName(node), getFieldArguments(node, vars));
return !hasField(entityKey, fieldKey);
})
···
};
interface SelectionIterator {
-
(): FieldNode | undefined;
+
(): FormattedNode<FieldNode> | undefined;
}
export const makeSelectionIterator = (
typename: void | string,
entityKey: string,
defer: boolean,
-
selectionSet: SelectionSet,
+
selectionSet: FormattedNode<SelectionSet>,
ctx: Context
): SelectionIterator => {
let child: SelectionIterator | void;
let index = 0;
return function next() {
-
let node: FieldNode | undefined;
+
let node: FormattedNode<FieldNode> | undefined;
while (child || index < selectionSet.length) {
node = undefined;
deferRef = defer;
···
const select = selectionSet[index++];
if (!shouldInclude(select, ctx.variables)) {
/*noop*/
-
} else if (!isFieldNode(select)) {
+
} else if (select.kind !== Kind.FIELD) {
// A fragment is either referred to by FragmentSpread or inline
-
const fragment = !isInlineFragment(select)
-
? ctx.fragments[getName(select)]
-
: select;
+
const fragment =
+
select.kind !== Kind.INLINE_FRAGMENT
+
? ctx.fragments[getName(select)]
+
: select;
if (fragment) {
const isMatching =
!fragment.typeCondition ||
···
);
}
}
-
} else {
+
} else if (currentOperation === 'write' || !select._generated) {
return select;
}
}
+11 -10
exchanges/graphcache/src/operations/write.ts
···
-
import { CombinedError } from '@urql/core';
+
import { formatDocument, FormattedNode, CombinedError } from '@urql/core';
import {
FieldNode,
···
InMemoryData.getCurrentDependencies();
}
-
const operation = getMainOperation(request.query);
+
const query = formatDocument(request.query);
+
const operation = getMainOperation(query);
const result: WriteResult = {
data: data || InMemoryData.makeData(),
dependencies: InMemoryData.currentDependencies!,
···
const ctx = makeContext(
store,
normalizeVariables(operation, request.variables),
-
getFragments(request.query),
+
getFragments(query),
kind,
kind,
error
···
export const _writeFragment = (
store: Store,
-
query: DocumentNode,
+
query: FormattedNode<DocumentNode>,
data: Partial<Data>,
variables?: Variables,
fragmentName?: string
) => {
const fragments = getFragments(query);
-
let fragment: FragmentDefinitionNode;
+
let fragment: FormattedNode<FragmentDefinitionNode>;
if (fragmentName) {
-
fragment = fragments[fragmentName] as FragmentDefinitionNode;
+
fragment = fragments[fragmentName]!;
if (!fragment) {
warn(
'writeFragment(...) was called with a fragment name that does not exist.\n' +
···
}
} else {
const names = Object.keys(fragments);
-
fragment = fragments[names[0]] as FragmentDefinitionNode;
+
fragment = fragments[names[0]]!;
if (!fragment) {
warn(
'writeFragment(...) was called with an empty fragment.\n' +
···
const writeSelection = (
ctx: Context,
entityKey: undefined | string,
-
select: SelectionSet,
+
select: FormattedNode<SelectionSet>,
data: Data
) => {
// These fields determine how we write. The `Query` root type is written
···
ctx
);
-
let node: FieldNode | void;
+
let node: FormattedNode<FieldNode> | void;
while ((node = iterate())) {
const fieldName = getName(node);
const fieldArgs = getFieldArguments(node, ctx.variables);
···
const writeField = (
ctx: Context,
-
select: SelectionSet,
+
select: FormattedNode<SelectionSet>,
data: null | Data | NullArray<Data>,
parentFieldKey?: string,
prevLink?: Link
-2
exchanges/graphcache/src/store/store.ts
···
updater: (data: T | null) => T | null
): void {
const request = createRequest(input.query, input.variables!);
-
request.query = formatDocument(request.query);
const output = updater(this.readQuery(request));
if (output !== null) {
_write(this, request, output as any, undefined);
···
readQuery<T = Data, V = Variables>(input: QueryInput<T, V>): T | null {
const request = createRequest(input.query, input.variables!);
-
request.query = formatDocument(request.query);
return _query(this, request, undefined, undefined).data as T | null;
}
+4 -3
exchanges/graphcache/src/types.ts
···
DocumentInput,
RequestExtensions,
TypedDocumentNode,
+
FormattedNode,
ErrorLike,
} from '@urql/core';
-
import { FragmentDefinitionNode } from '@0no-co/graphql.web';
+
import { DocumentNode, FragmentDefinitionNode } from '@0no-co/graphql.web';
import { IntrospectionData } from './ast';
/** Nullable GraphQL list types of `T`.
···
* executing.
*/
export interface Fragments {
-
[fragmentName: string]: void | FragmentDefinitionNode;
+
[fragmentName: string]: void | FormattedNode<FragmentDefinitionNode>;
}
/** Non-object JSON values as serialized by a GraphQL API
···
* GraphQL operation: its query document and variables.
*/
export interface OperationRequest {
-
query: Exclude<DocumentInput<any, any>, string>;
+
query: FormattedNode<DocumentNode> | DocumentNode;
variables?: any;
}
+2 -2
packages/core/src/exchanges/cache.ts
···
import {
makeOperation,
addMetadata,
-
collectTypesFromResponse,
+
collectTypenames,
formatDocument,
} from '../utils';
···
// than using subscriptions as “signals” to reexecute queries. However, if they’re
// just used as signals, it’s intuitive to hook them up using `additionalTypenames`
if (response.operation.kind !== 'subscription') {
-
typenames = collectTypesFromResponse(response.data).concat(typenames);
+
typenames = collectTypenames(response.data).concat(typenames);
}
// Invalidates the cache given a mutation's response
+36
packages/core/src/types.ts
···
import type { GraphQLError, DocumentNode } from './utils/graphql';
+
import type {
+
Kind,
+
DirectiveNode,
+
ValueNode,
+
TypeNode,
+
} from '@0no-co/graphql.web';
import { Subscription, Source } from 'wonka';
import { Client } from './client';
import { CombinedError } from './utils/error';
···
*/
__ensureTypesOfVariablesAndResultMatching?: (variables: Variables) => Result;
};
+
+
/** GraphQL nodes with added `_directives` dictionary on nodes with directives.
+
*
+
* @remarks
+
* The {@link formatDocument} utility processes documents to add `__typename`
+
* fields to them. It additionally provides additional directives processing
+
* and outputs this type.
+
*
+
* When applied, every node with non-const directives, will have an additional
+
* `_directives` dictionary added to it, and filter directives starting with
+
* a leading `_` underscore from the directives array.
+
*/
+
export type FormattedNode<Node> = Node extends readonly (infer Child)[]
+
? readonly FormattedNode<Child>[]
+
: Node extends ValueNode | TypeNode
+
? Node
+
: Node extends { kind: Kind }
+
? {
+
[K in Exclude<keyof Node, 'directives' | 'loc'>]: FormattedNode<Node[K]>;
+
} extends infer Node
+
? Node extends {
+
kind: Kind.FIELD | Kind.INLINE_FRAGMENT | Kind.FRAGMENT_SPREAD;
+
}
+
? Node & {
+
_generated?: boolean;
+
_directives?: Record<string, DirectiveNode> | undefined;
+
}
+
: Node
+
: Node
+
: Node;
/** Any GraphQL `DocumentNode` or query string input.
*
+88
packages/core/src/utils/collectTypenames.test.ts
···
+
import { describe, it, expect } from 'vitest';
+
import { collectTypenames } from './collectTypenames';
+
+
describe('collectTypenames', () => {
+
it('returns all typenames included in a response as an array', () => {
+
const typeNames = collectTypenames({
+
todos: [
+
{
+
id: 1,
+
__typename: 'Todo',
+
},
+
],
+
});
+
expect(typeNames).toEqual(['Todo']);
+
});
+
+
it('does not duplicate typenames', () => {
+
const typeNames = collectTypenames({
+
todos: [
+
{
+
id: 1,
+
__typename: 'Todo',
+
},
+
{
+
id: 3,
+
__typename: 'Todo',
+
},
+
],
+
});
+
expect(typeNames).toEqual(['Todo']);
+
});
+
+
it('returns multiple different typenames', () => {
+
const typeNames = collectTypenames({
+
todos: [
+
{
+
id: 1,
+
__typename: 'Todo',
+
},
+
{
+
id: 3,
+
__typename: 'Avocado',
+
},
+
],
+
});
+
expect(typeNames).toEqual(['Todo', 'Avocado']);
+
});
+
+
it('works on nested objects', () => {
+
const typeNames = collectTypenames({
+
todos: [
+
{
+
id: 1,
+
__typename: 'Todo',
+
},
+
{
+
id: 2,
+
subTask: {
+
id: 3,
+
__typename: 'SubTask',
+
},
+
},
+
],
+
});
+
expect(typeNames).toEqual(['Todo', 'SubTask']);
+
});
+
+
it('traverses nested arrays of objects', () => {
+
const typenames = collectTypenames({
+
todos: [
+
{
+
id: 1,
+
authors: [
+
[
+
{
+
name: 'Phil',
+
__typename: 'Author',
+
},
+
],
+
],
+
__typename: 'Todo',
+
},
+
],
+
});
+
+
expect(typenames).toEqual(['Author', 'Todo']);
+
});
+
});
+30
packages/core/src/utils/collectTypenames.ts
···
+
interface EntityLike {
+
[key: string]: EntityLike | EntityLike[] | any;
+
__typename: string | null | void;
+
}
+
+
const collectTypes = (obj: EntityLike | EntityLike[], types: Set<string>) => {
+
if (Array.isArray(obj)) {
+
for (const item of obj) collectTypes(item, types);
+
} else if (typeof obj === 'object' && obj !== null) {
+
for (const key in obj) {
+
if (key === '__typename' && typeof obj[key] === 'string') {
+
types.add(obj[key] as string);
+
} else {
+
collectTypes(obj[key], types);
+
}
+
}
+
}
+
+
return types;
+
};
+
+
/** Finds and returns a list of `__typename` fields found in response data.
+
*
+
* @privateRemarks
+
* This is used by `@urql/core`’s document `cacheExchange` to find typenames
+
* in a given GraphQL response’s data.
+
*/
+
export const collectTypenames = (response: object): string[] => [
+
...collectTypes(response as EntityLike, new Set()),
+
];
+150
packages/core/src/utils/formatDocument.test.ts
···
+
import { Kind, parse, print } from '@0no-co/graphql.web';
+
import { describe, it, expect } from 'vitest';
+
import { createRequest } from './request';
+
import { formatDocument } from './formatDocument';
+
+
const formatTypeNames = (query: string) => {
+
const typedNode = formatDocument(parse(query));
+
return print(typedNode);
+
};
+
+
describe('formatDocument', () => {
+
it('creates a new instance when adding typenames', () => {
+
const doc = parse(`{ id todos { id } }`) as any;
+
const newDoc = formatDocument(doc) as any;
+
expect(doc).not.toBe(newDoc);
+
expect(doc.definitions).not.toBe(newDoc.definitions);
+
expect(doc.definitions[0]).not.toBe(newDoc.definitions[0]);
+
expect(doc.definitions[0].selectionSet).not.toBe(
+
newDoc.definitions[0].selectionSet
+
);
+
expect(doc.definitions[0].selectionSet.selections).not.toBe(
+
newDoc.definitions[0].selectionSet.selections
+
);
+
// Here we're equal again:
+
expect(doc.definitions[0].selectionSet.selections[0]).toBe(
+
newDoc.definitions[0].selectionSet.selections[0]
+
);
+
// Not equal again:
+
expect(doc.definitions[0].selectionSet.selections[1]).not.toBe(
+
newDoc.definitions[0].selectionSet.selections[1]
+
);
+
expect(doc.definitions[0].selectionSet.selections[1].selectionSet).not.toBe(
+
newDoc.definitions[0].selectionSet.selections[1].selectionSet
+
);
+
// Equal again:
+
expect(
+
doc.definitions[0].selectionSet.selections[1].selectionSet.selections[0]
+
).toBe(
+
newDoc.definitions[0].selectionSet.selections[1].selectionSet
+
.selections[0]
+
);
+
});
+
+
it('preserves the hashed key of the resulting query', () => {
+
const doc = parse(`{ id todos { id } }`) as any;
+
const expectedKey = createRequest(doc, undefined).key;
+
const formattedDoc = formatDocument(doc);
+
expect(formattedDoc).not.toBe(doc);
+
const actualKey = createRequest(formattedDoc, undefined).key;
+
expect(expectedKey).toBe(actualKey);
+
});
+
+
it('does not preserve the referential integrity with a cloned object', () => {
+
const doc = parse(`{ id todos { id } }`);
+
const formattedDoc = formatDocument(doc);
+
expect(formattedDoc).not.toBe(doc);
+
const query = { ...formattedDoc };
+
const reformattedDoc = formatDocument(query);
+
expect(reformattedDoc).not.toBe(doc);
+
});
+
+
it('preserves custom properties', () => {
+
const doc = parse(`{ todos { id } }`) as any;
+
doc.documentId = '123';
+
expect((formatDocument(doc) as any).documentId).toBe(doc.documentId);
+
});
+
+
it('adds typenames to a query string', () => {
+
expect(formatTypeNames(`{ todos { id } }`)).toMatchInlineSnapshot(`
+
"{
+
todos {
+
id
+
__typename
+
}
+
}"
+
`);
+
});
+
+
it('does not duplicate typenames', () => {
+
expect(
+
formatTypeNames(`{
+
todos {
+
id
+
__typename
+
}
+
}`)
+
).toMatchInlineSnapshot(`
+
"{
+
todos {
+
id
+
__typename
+
}
+
}"
+
`);
+
});
+
+
it('does add typenames when it is aliased', () => {
+
expect(
+
formatTypeNames(`{
+
todos {
+
id
+
typename: __typename
+
}
+
}`)
+
).toMatchInlineSnapshot(`
+
"{
+
todos {
+
id
+
typename: __typename
+
__typename
+
}
+
}"
+
`);
+
});
+
+
it('processes directives', () => {
+
const document = `
+
{
+
todos @skip {
+
id @_test
+
}
+
}
+
`;
+
+
const node = formatDocument(parse(document));
+
+
expect(node).toHaveProperty(
+
'definitions.0.selectionSet.selections.0.selectionSet.selections.0._directives',
+
{
+
test: {
+
kind: Kind.DIRECTIVE,
+
arguments: [],
+
name: {
+
kind: Kind.NAME,
+
value: '_test',
+
},
+
},
+
}
+
);
+
+
expect(formatTypeNames(document)).toMatchInlineSnapshot(`
+
"{
+
todos @skip {
+
id
+
__typename
+
}
+
}"
+
`);
+
});
+
});
+122
packages/core/src/utils/formatDocument.ts
···
+
import {
+
Kind,
+
FieldNode,
+
SelectionNode,
+
DefinitionNode,
+
DirectiveNode,
+
} from '@0no-co/graphql.web';
+
import { KeyedDocumentNode, keyDocument } from './request';
+
import { FormattedNode, TypedDocumentNode } from '../types';
+
+
const formatNode = <
+
T extends SelectionNode | DefinitionNode | TypedDocumentNode<any, any>
+
>(
+
node: T
+
): FormattedNode<T> => {
+
if ('definitions' in node) {
+
const definitions: FormattedNode<DefinitionNode>[] = [];
+
for (const definition of node.definitions) {
+
const newDefinition = formatNode(definition);
+
definitions.push(newDefinition);
+
}
+
+
return { ...node, definitions } as FormattedNode<T>;
+
}
+
+
if ('directives' in node && node.directives && node.directives.length) {
+
const directives: DirectiveNode[] = [];
+
const _directives = {};
+
for (const directive of node.directives) {
+
let name = directive.name.value;
+
if (name[0] !== '_') {
+
directives.push(directive);
+
} else {
+
name = name.slice(1);
+
}
+
_directives[name] = directive;
+
}
+
node = { ...node, directives, _directives };
+
}
+
+
if ('selectionSet' in node) {
+
const selections: FormattedNode<SelectionNode>[] = [];
+
let hasTypename = node.kind === Kind.OPERATION_DEFINITION;
+
if (node.selectionSet) {
+
for (const selection of node.selectionSet.selections || []) {
+
hasTypename =
+
hasTypename ||
+
(selection.kind === Kind.FIELD &&
+
selection.name.value === '__typename' &&
+
!selection.alias);
+
const newSelection = formatNode(selection);
+
selections.push(newSelection);
+
}
+
+
if (!hasTypename) {
+
selections.push({
+
kind: Kind.FIELD,
+
name: {
+
kind: Kind.NAME,
+
value: '__typename',
+
},
+
_generated: true,
+
} as FormattedNode<FieldNode>);
+
}
+
+
return {
+
...node,
+
selectionSet: { ...node.selectionSet, selections },
+
} as FormattedNode<T>;
+
}
+
}
+
+
return node as FormattedNode<T>;
+
};
+
+
const formattedDocs = new Map<number, KeyedDocumentNode>();
+
+
/** Formats a GraphQL document to add `__typename` fields and process client-side directives.
+
*
+
* @param node - a {@link DocumentNode}.
+
* @returns a {@link FormattedDocument}
+
*
+
* @remarks
+
* Cache {@link Exchange | Exchanges} will require typename introspection to
+
* recognize types in a GraphQL response. To retrieve these typenames,
+
* this function is used to add the `__typename` fields to non-root
+
* selection sets of a GraphQL document.
+
*
+
* Additionally, this utility will process directives, filter out client-side
+
* directives starting with an `_` underscore, and place a `_directives` dictionary
+
* on selection nodes.
+
*
+
* This utility also preserves the internally computed key of the
+
* document as created by {@link createRequest} to avoid any
+
* formatting from being duplicated.
+
*
+
* @see {@link https://spec.graphql.org/October2021/#sec-Type-Name-Introspection} for more information
+
* on typename introspection via the `__typename` field.
+
*/
+
export const formatDocument = <T extends TypedDocumentNode<any, any>>(
+
node: T
+
): FormattedNode<T> => {
+
const query = keyDocument(node);
+
+
let result = formattedDocs.get(query.__key);
+
if (!result) {
+
formattedDocs.set(
+
query.__key,
+
(result = formatNode(query) as KeyedDocumentNode)
+
);
+
// Ensure that the hash of the resulting document won't suddenly change
+
// we are marking __key as non-enumerable so when external exchanges use visit
+
// to manipulate a document we won't restore the previous query due to the __key
+
// property.
+
Object.defineProperty(result, '__key', {
+
value: query.__key,
+
enumerable: false,
+
});
+
}
+
+
return result as FormattedNode<T>;
+
};
+2 -1
packages/core/src/utils/index.ts
···
export * from './error';
export * from './request';
export * from './result';
-
export * from './typenames';
export * from './variables';
+
export * from './collectTypenames';
+
export * from './formatDocument';
export * from './maskTypename';
export * from './streamUtils';
export * from './operation';
+1 -1
packages/core/src/utils/request.test.ts
···
import { parse, print } from '@0no-co/graphql.web';
import { gql } from '../gql';
import { createRequest, stringifyDocument } from './request';
-
import { formatDocument } from './typenames';
+
import { formatDocument } from './formatDocument';
describe('createRequest', () => {
it('should hash identical queries identically', () => {
-201
packages/core/src/utils/typenames.test.ts
···
-
import { parse, print } from '@0no-co/graphql.web';
-
import { describe, it, expect } from 'vitest';
-
import { collectTypesFromResponse, formatDocument } from './typenames';
-
import { createRequest } from './request';
-
-
const formatTypeNames = (query: string) => {
-
const typedNode = formatDocument(parse(query));
-
return print(typedNode);
-
};
-
-
describe('formatTypeNames', () => {
-
it('creates a new instance when adding typenames', () => {
-
const doc = parse(`{ id todos { id } }`) as any;
-
const newDoc = formatDocument(doc) as any;
-
expect(doc).not.toBe(newDoc);
-
expect(doc.definitions).not.toBe(newDoc.definitions);
-
expect(doc.definitions[0]).not.toBe(newDoc.definitions[0]);
-
expect(doc.definitions[0].selectionSet).not.toBe(
-
newDoc.definitions[0].selectionSet
-
);
-
expect(doc.definitions[0].selectionSet.selections).not.toBe(
-
newDoc.definitions[0].selectionSet.selections
-
);
-
// Here we're equal again:
-
expect(doc.definitions[0].selectionSet.selections[0]).toBe(
-
newDoc.definitions[0].selectionSet.selections[0]
-
);
-
// Not equal again:
-
expect(doc.definitions[0].selectionSet.selections[1]).not.toBe(
-
newDoc.definitions[0].selectionSet.selections[1]
-
);
-
expect(doc.definitions[0].selectionSet.selections[1].selectionSet).not.toBe(
-
newDoc.definitions[0].selectionSet.selections[1].selectionSet
-
);
-
// Equal again:
-
expect(
-
doc.definitions[0].selectionSet.selections[1].selectionSet.selections[0]
-
).toBe(
-
newDoc.definitions[0].selectionSet.selections[1].selectionSet
-
.selections[0]
-
);
-
});
-
-
it('preserves the hashed key of the resulting query', () => {
-
const doc = parse(`{ id todos { id } }`) as any;
-
const expectedKey = createRequest(doc, undefined).key;
-
const formattedDoc = formatDocument(doc);
-
expect(formattedDoc).not.toBe(doc);
-
const actualKey = createRequest(formattedDoc, undefined).key;
-
expect(expectedKey).toBe(actualKey);
-
});
-
-
it('does not preserve the referential integrity with a cloned object', () => {
-
const doc = parse(`{ id todos { id } }`);
-
const formattedDoc = formatDocument(doc);
-
expect(formattedDoc).not.toBe(doc);
-
const query = { ...formattedDoc };
-
const reformattedDoc = formatDocument(query);
-
expect(reformattedDoc).not.toBe(doc);
-
});
-
-
it('preserves custom properties', () => {
-
const doc = parse(`{ todos { id } }`) as any;
-
doc.documentId = '123';
-
expect((formatDocument(doc) as any).documentId).toBe(doc.documentId);
-
});
-
-
it('adds typenames to a query string', () => {
-
expect(formatTypeNames(`{ todos { id } }`)).toMatchInlineSnapshot(`
-
"{
-
todos {
-
id
-
__typename
-
}
-
}"
-
`);
-
});
-
-
it('does not duplicate typenames', () => {
-
expect(
-
formatTypeNames(`{
-
todos {
-
id
-
__typename
-
}
-
}`)
-
).toMatchInlineSnapshot(`
-
"{
-
todos {
-
id
-
__typename
-
}
-
}"
-
`);
-
});
-
-
it('does add typenames when it is aliased', () => {
-
expect(
-
formatTypeNames(`{
-
todos {
-
id
-
typename: __typename
-
}
-
}`)
-
).toMatchInlineSnapshot(`
-
"{
-
todos {
-
id
-
typename: __typename
-
__typename
-
}
-
}"
-
`);
-
});
-
});
-
-
describe('collectTypesFromResponse', () => {
-
it('returns all typenames included in a response as an array', () => {
-
const typeNames = collectTypesFromResponse({
-
todos: [
-
{
-
id: 1,
-
__typename: 'Todo',
-
},
-
],
-
});
-
expect(typeNames).toEqual(['Todo']);
-
});
-
-
it('does not duplicate typenames', () => {
-
const typeNames = collectTypesFromResponse({
-
todos: [
-
{
-
id: 1,
-
__typename: 'Todo',
-
},
-
{
-
id: 3,
-
__typename: 'Todo',
-
},
-
],
-
});
-
expect(typeNames).toEqual(['Todo']);
-
});
-
-
it('returns multiple different typenames', () => {
-
const typeNames = collectTypesFromResponse({
-
todos: [
-
{
-
id: 1,
-
__typename: 'Todo',
-
},
-
{
-
id: 3,
-
__typename: 'Avocado',
-
},
-
],
-
});
-
expect(typeNames).toEqual(['Todo', 'Avocado']);
-
});
-
-
it('works on nested objects', () => {
-
const typeNames = collectTypesFromResponse({
-
todos: [
-
{
-
id: 1,
-
__typename: 'Todo',
-
},
-
{
-
id: 2,
-
subTask: {
-
id: 3,
-
__typename: 'SubTask',
-
},
-
},
-
],
-
});
-
expect(typeNames).toEqual(['Todo', 'SubTask']);
-
});
-
-
it('traverses nested arrays of objects', () => {
-
const typenames = collectTypesFromResponse({
-
todos: [
-
{
-
id: 1,
-
authors: [
-
[
-
{
-
name: 'Phil',
-
__typename: 'Author',
-
},
-
],
-
],
-
__typename: 'Todo',
-
},
-
],
-
});
-
-
expect(typenames).toEqual(['Author', 'Todo']);
-
});
-
});
-125
packages/core/src/utils/typenames.ts
···
-
import { Kind, SelectionNode, DefinitionNode } from '@0no-co/graphql.web';
-
import { KeyedDocumentNode, keyDocument } from './request';
-
import { TypedDocumentNode } from '../types';
-
-
interface EntityLike {
-
[key: string]: EntityLike | EntityLike[] | any;
-
__typename: string | null | void;
-
}
-
-
const collectTypes = (obj: EntityLike | EntityLike[], types: Set<string>) => {
-
if (Array.isArray(obj)) {
-
for (const item of obj) collectTypes(item, types);
-
} else if (typeof obj === 'object' && obj !== null) {
-
for (const key in obj) {
-
if (key === '__typename' && typeof obj[key] === 'string') {
-
types.add(obj[key] as string);
-
} else {
-
collectTypes(obj[key], types);
-
}
-
}
-
}
-
-
return types;
-
};
-
-
/** Finds and returns a list of `__typename` fields found in response data.
-
*
-
* @privateRemarks
-
* This is used by `@urql/core`’s document `cacheExchange` to find typenames
-
* in a given GraphQL response’s data.
-
*/
-
export const collectTypesFromResponse = (response: object): string[] => [
-
...collectTypes(response as EntityLike, new Set()),
-
];
-
-
const formatNode = <
-
T extends SelectionNode | DefinitionNode | TypedDocumentNode<any, any>
-
>(
-
node: T
-
): T => {
-
let hasChanged = false;
-
-
if ('definitions' in node) {
-
const definitions: DefinitionNode[] = [];
-
for (const definition of node.definitions) {
-
const newDefinition = formatNode(definition);
-
hasChanged = hasChanged || newDefinition !== definition;
-
definitions.push(newDefinition);
-
}
-
if (hasChanged) return { ...node, definitions };
-
} else if ('selectionSet' in node) {
-
const selections: SelectionNode[] = [];
-
let hasTypename = node.kind === Kind.OPERATION_DEFINITION;
-
if (node.selectionSet) {
-
for (const selection of node.selectionSet.selections || []) {
-
hasTypename =
-
hasTypename ||
-
(selection.kind === Kind.FIELD &&
-
selection.name.value === '__typename' &&
-
!selection.alias);
-
const newSelection = formatNode(selection);
-
hasChanged = hasChanged || newSelection !== selection;
-
selections.push(newSelection);
-
}
-
if (!hasTypename) {
-
hasChanged = true;
-
selections.push({
-
kind: Kind.FIELD,
-
name: {
-
kind: Kind.NAME,
-
value: '__typename',
-
},
-
});
-
}
-
if (hasChanged)
-
return { ...node, selectionSet: { ...node.selectionSet, selections } };
-
}
-
}
-
-
return node;
-
};
-
-
const formattedDocs = new Map<number, KeyedDocumentNode>();
-
-
/** Adds `__typename` fields to a GraphQL `DocumentNode`.
-
*
-
* @param node - a {@link DocumentNode}.
-
* @returns a copy of the passed {@link DocumentNode} with added `__typename` introspection fields.
-
*
-
* @remarks
-
* Cache {@link Exchange | Exchanges} will require typename introspection to
-
* recognize types in a GraphQL response. To retrieve these typenames,
-
* this function is used to add the `__typename` fields to non-root
-
* selection sets of a GraphQL document.
-
*
-
* This utility also preserves the internally computed key of the
-
* document as created by {@link createRequest} to avoid any
-
* formatting from being duplicated.
-
*
-
* @see {@link https://spec.graphql.org/October2021/#sec-Type-Name-Introspection} for more information
-
* on typename introspection via the `__typename` field.
-
*/
-
export const formatDocument = <T extends TypedDocumentNode<any, any>>(
-
node: T
-
): T => {
-
const query = keyDocument(node);
-
-
let result = formattedDocs.get(query.__key);
-
if (!result) {
-
formattedDocs.set(
-
query.__key,
-
(result = formatNode(query) as KeyedDocumentNode)
-
);
-
// Ensure that the hash of the resulting document won't suddenly change
-
// we are marking __key as non-enumerable so when external exchanges use visit
-
// to manipulate a document we won't restore the previous query due to the __key
-
// property.
-
Object.defineProperty(result, '__key', {
-
value: query.__key,
-
enumerable: false,
-
});
-
}
-
-
return result as unknown as T;
-
};
+6 -4
scripts/rollup/config.mjs
···
const rel = relative(source.dir, process.cwd());
plugins.push({
async writeBundle() {
-
await fs.mkdir(source.dir, { recursive: true });
-
await fs.writeFile(join(source.dir, 'package.json'), JSON.stringify({
+
const packageJson = JSON.stringify({
name: source.name,
private: true,
version: '0.0.0',
···
source: join(rel, source.source),
exports: {
'.': {
+
types: join(rel, source.types),
import: join(rel, source.module),
require: join(rel, source.main),
-
types: join(rel, source.types),
source: join(rel, source.source),
},
'./package.json': './package.json'
},
-
}, null, 2));
+
}, null, 2).trim();
+
+
await fs.mkdir(source.dir, { recursive: true });
+
await fs.writeFile(join(source.dir, 'package.json'), packageJson + '\n');
},
});
}