JSPM

houdini

0.9.10
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 4933
  • Score
    100M100P100Q115072F
  • License MIT

The disappearing graphql client for SvelteKit

Package Exports

  • houdini

This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (houdini) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.

Readme

houdini

The disappearing GraphQL client for Sapper and SvelteKit.

NOTE: Houdini is in the early phases of development. Please create an issue or start a discussion if you run into problems. For more information on what's coming for this project, you can visit the roadmap.

If you are interested in helping out, the contributing guide should provide some guidance. If you need something more specific, feel free to reach out to @AlecAivazis on the Svelte discord. There's lots to do regardless of how deep you want to dive 🙂

✨  Features

  • Composable and colocated data requirements for your components
  • Normalized cache with declarative updates
  • Generated types
  • Subscriptions
  • Support for Sapper and SvelteKit's public beta

At its core, houdini seeks to enable a high quality developer experience without compromising bundle size. Like Svelte, houdini shifts what is traditionally handled by a bloated runtime into a compile step that allows for the generation of an incredibly lean GraphQL abstraction for your application.

📚  Table of Contents

  1. Example
  2. Installation
  3. Configuring Your Application
    1. Sapper
    2. SvelteKit
    3. Svelte
  4. Config File
  5. Running the Compiler
  6. Fetching Data
    1. Query variables and page data
    2. Loading State
    3. Refetching Data
    4. What about load?
  7. Fragments
    1. Fragment Arguments
  8. Mutations
    1. Updating fields
    2. Lists
      1. Insert
      2. Remove
      3. Delete
      4. Conditionals
  9. Subscriptions
    1. Configuring the WebSocket client
    2. Using graphql-ws
    3. Using subscriptions-transport-ws
  10. Custom Scalars
  11. Authentication
  12. Notes, Constraints, and Conventions

🕹️  Example

A demo can be found in the example directory.

Please note that the examples in that directory and this readme showcase the typescript definitions generated by the compiler. While it is highly recommended, Typescript is NOT required in order to use houdini.

⚡  Installation

houdini is available on npm.

yarn add -D houdini houdini-preprocess
# or
npm install --save-dev houdini houdini-preprocess

🔧  Configuring Your Application

Adding houdini to an existing project can easily be done with the provided command-line tool. If you don't already have an existing app, visit this link for help setting one up. Once you have a project and want to add houdini, execute the following command:

npx houdini init

This will create a few necessary files, as well as pull down a json representation of your API's schema. Next, generate your runtime:

npx houdini generate

and finally, add the preprocessor to your sapper setup:

import houdini from 'houdini-preprocess'

// somewhere in your config file
{
    plugins: [
        svelte({
            preprocess: [houdini()],
        }),
    ]
}

Sapper

You'll need to add the preprocessor to both your client and your server configuration. With that in place, the only thing left to configure your Sapper application is to connect your client and server to the generate network layer:

// in both src/client.js and src/server.js

import { setEnvironment } from '$houdini'
import env from './environment'

setEnvironment(env)

SvelteKit

We need to define an alias so that your codebase can import the generated runtime. Add the following values to svelte.config.js:

{
    kit: {
        vite: {
            resolve: {
                alias: {
                    $houdini: path.resolve('.', '$houdini')
                }
            }
        }
    }
}

And finally, we need to configure our application to use the generated network layer. To do this, add the following block of code to src/routes/__layout.svelte:

<script context="module">
    import env from '../environment';
    import { setEnvironment } from '$houdini';

    setEnvironment(env);
</script>

You might need to generate your runtime in order to fix typescript errors.

  Running the Compiler

The compiler is responsible for a number of things, ranging from generating the actual runtime to creating types for your documents. Running the compiler can be done with npx or via a script in package.json and needs to be run every time a GraphQL document in your source code changes:

npx houdini generate

The generated runtime can be accessed by importing $houdini anywhere in your application.

If you have updated your schema on the server, you can pull down the most recent schema before generating your runtime by using --pull-schema or -p:

npx houdini generate --pull-schema

Note: If you are building your application with adapter-static (or any other adapter that turns your application into a static site), you will need to set the static value in your config file to true.

Svelte

If you are working on an application that isn't using SvelteKit or Sapper, you have to configure the compiler and preprocessor to generate the correct logic by setting the framework field in your config file to "svelte".

Please keep in mind that returning the response from a query, you should not rely on this.redirect to handle the redirect as it will update your browsers location attribute, causing a hard transition to that url. Instead, you should use this.error to return an error and handle the redirect in a way that's appropriate for your application.

📄 Config File

All configuration for your houdini application is defined in a single file that is imported by both the runtime and the command-line tool. Because of this, you must make sure that any imports and logic are resolvable in both environments. This means that if you rely on process.env or other node-specifics you will have to use a plugin to replace the expression with something that can run in the browser.

🚀  Fetching Data

Grabbing data from your API is done with the query function:

<script lang="ts">
    import { query, graphql, AllItems } from '$houdini'

    // load the items
    const { data } = query<AllItems>(graphql`
        query AllItems {
            items {
                id
                text
            }
        }
    `)
</script>

{#each $data.items as item}
    <div>{item.text}</div>
{/each}

Query variables and page data

At the moment, query variables are declared as a function in the module context of your component. This function must be named after your query and in a sapper application, it takes the same arguments that are passed to the preload function described in the Sapper documentation. In a SvelteKit project, this function takes the same arguments passed to the load function described in the SvelteKit docs. Regardless of the framework, you can return the value from this.error and this.redirect in order to change the behavior of the response. Here is a modified example from the demo:

// src/routes/[filter].svelte

<script lang="ts">
    import { query, graphql, AllItems } from '$houdini'

    // load the items
    const { data } = query<AllItems>(graphql`
        query AllItems($completed: Boolean) {
            items(completed: $completed) {
                id
                text
            }
        }
    `)
</script>

<script context="module" lang="ts">
    // This is the function for the AllItems query.
    // Query variable functions must be named <QueryName>Variables.
    export function AllItemsVariables(page): AllItems$input {
        // make sure we recognize the value
        if (!['active', 'completed'].includes(page.params.filter)) {
            return this.error(400, "filter must be one of 'active' or 'completed'")
        }

        return {
            completed: page.params.filter === 'completed',
        }
    }
</script>

{#each $data.items as item}
    <div>{item.text}</div>
{/each}

Loading State

The methods used for tracking the loading state of your queries changes depending on the context of your component. For queries that live in routes (ie, in /src/routes/...), the actual query happens in a load function as described in What about load?. Because of this, the best way to track if your query is loading is to use the navigating store exported from $app/stores:

// src/routes/index.svelte

<script>
    import { query } from '$houdini'
    import { navigating } from '$app/stores'

    const { data } = query(...)
</script>

{#if $navigating}
    loading...
{:else}
    data is loaded!
{/if}

However, since queries inside of non-route components (ie, ones that are not defined in /src/routes/...) do not get hoisted to a load function, the recommended practice to is use the store returned from the result of query:

// src/components/MyComponent.svelte

<script>
    import { query } from '$houdini'

    const { data, loading } = query(...)
</script>

{#if $loading}
    loading...
{:else}
    data is loaded!
{/if}

Refetching Data

Refetching data is done with the refetch function provided from the result of a query:


<script lang="ts">
    import { query, graphql, AllItems } from '$houdini'

    // load the items
    const { refetch } = query<AllItems>(graphql`
        query AllItems($completed: Boolean) {
            items(completed: $completed) {
                id
                text
            }
        }
    `)

    let completed = true

    $: refetch({ completed })
</script>

<input type=checkbox bind:checked={completed}>

What about load?

Don't worry - that's where the preprocessor comes in. One of its responsibilities is moving the actual fetch into a load. You can think of the block at the top of this section as equivalent to:

<script context="module">
    export async function load() {
            return {
                _data: await this.fetch({
                    text: `
                        query AllItems {
                            items {
                                id
                                text
                            }
                        }
                    `
                }),
            }
    }
</script>

<script>
    export let _data

    const data = readable(_data, ...)
</script>

{#each $data.items as item}
    <div>{item.text}</div>
{/each}

🧩  Fragments

Your components will want to make assumptions about which attributes are available in your queries. To support this, Houdini uses GraphQL fragments embedded within your component. Take, for example, a UserAvatar component that requires the profilePicture field of a User:

// components/UserAvatar.svelte

<script lang="ts">
    import { fragment, graphql, UserAvatar } from '$houdini'

    // the reference will get passed as a prop
    export let user: UserAvatar

    const data = fragment(graphql`
        fragment UserAvatar on User {
            profilePicture
        }
    `, user)
</script>

<img src={$data.profilePicture} />

This component can be rendered anywhere we want to query for a user, with a guarantee that all necessary data has been asked for:

// src/routes/users.svelte

<script>
    import { query, graphql, AllUsers } from '$houdini'
    import { UserAvatar } from 'components'

    const { data } = query<AllUsers>(graphql`
        query AllUsers {
            users {
                id
                ...UserAvatar
            }
        }
    `)
</script>

{#each $data.users as user}
    <UserAvatar user={user} />
{/each}

It's worth mentioning explicitly that a component can rely on multiple fragments at the same time so long as the fragment names are unique and prop names are different.

Fragment Arguments

In some situations it's necessary to configure the documents inside of a fragment. For example, you might want to extend the UserAvatar component to allow for different sized profile pictures. To support this, houdini provides two directives @arguments and @with which declare arguments for a fragment and provide values, respectively.

Default values can be provided to fragment arguments with the default key:

fragment UserAvatar on User @arguments(width: {type:"Int", default: 50}) {
    profilePicture(width: $width)
}

An argument with no default value is considered required. If no value is provided, an error will be thrown when generating your runtime. Providing values for fragments is done with the @with decorator:

query AllUsers {
    users {
        ...UserAvatar @with(width: 100)
    }
}

📝  Mutations

Mutations are defined in your component like the rest of the documents but instead of triggering a network request when called, you get a function which can be invoked to execute the mutation. Here's another modified example from the demo:

<script lang="ts">
    import { mutation, graphql, UncheckItem } from '$houdini'

    let itemID: string

    const uncheckItem = mutation<UncheckItem>(graphql`
        mutation UncheckItem($id: ID!) {
            uncheckItem(item: $id) {
                item {
                    id
                    completed
                }
            }
        }
    `)
</script>

<button on:click={() => uncheckItem({ id: itemID })}>
    Uncheck Item
</button>

Note: mutations usually do best when combined with at least one fragment grabbing the information needed for the mutation (for an example of this pattern, see below.)

Updating fields

When a mutation is responsible for updating fields of entities, houdini should take care of the details for you as long as you request the updated data alongside the record's id. Take for example, an TodoItemRow component:

<script lang="ts">
    import { fragment, mutation, graphql, TodoItemRow } from '$houdini'

    export let item: TodoItemRow

    // the resulting store will stay up to date whenever `checkItem`
    // is triggered
    const data = fragment(
        graphql`
            fragment TodoItemRow on TodoItem {
                id
                text
                completed
            }
        `,
        item
    )

    const checkItem = mutation<CompleteItem>(graphql`
        mutation CompleteItem($id: ID!) {
            checkItem(item: $id) {
                item {
                    id
                    completed
                }
            }
        }
    `)
</script>

<li class:completed={$data.completed}>
    <input
        name={$data.text}
        class="toggle"
        type="checkbox"
        checked={$data.completed}
        on:click={handleClick}
    />
    <label for={$data.text}>{$data.text}</label>
    <button class="destroy" on:click={() => deleteItem({ id: $data.id })} />
</li>

Lists

Adding and removing records from a list is done by mixing together a few different generated fragments and directives. In order to tell the compiler which lists are targets for these operations, you have to mark them with the @list directive and provide a unique name:

query AllItems {
    items @list(name: "All_Items") {
        id
    }
}

It's recommended to name these lists with a different casing convention than the rest of your application to distinguish the generated fragments from those in your codebase.

Inserting a record

With this field tagged, any mutation that returns an Item can be used to insert items in this list:

mutation NewItem($input: AddItemInput!) {
    addItem(input: $input) {
        ...All_Items_insert
    }
}

Removing a record

Any mutation that returns an Item can also be used to remove an item from the list:

mutation RemoveItem($input: RemoveItemInput!) {
    removeItem(input: $input) {
        ...All_Items_remove
    }
}

Deleting a record

Sometimes it can be tedious to remove a record from every single list that mentions it. For these situations, Houdini provides a directive that can be used to mark a field in the mutation response holding the ID of a record to delete from all lists.

mutation DeleteItem($id: ID!) {
    deleteItem(id: $id) {
        itemID @Item_delete
    }
}

Conditionals

Sometimes you only want to add or remove a record from a list when an argument has a particular value. For example, in a todo list you might only want to add the result to the list if there is no filter being applied. To support this, houdini provides the @when and @when_not directives:

mutation NewItem($input: AddItemInput!) {
    addItem(input: $input) {
        ...All_Items_insert @when_not(argument: "completed", value: "true")
    }
}

🧾  Subscriptions

Subscriptions in houdini are handled with the subscription function exported by your runtime. This function takes a tagged document, and returns a store with the most recent value returned by the server. Keep in mind that houdini will keep the cache (and any subscribing components) up to date as new data is encountered.

It's worth mentioning that you can use the same fragments described in the mutation section in order to update houdini's cache with the response from a subscription.

Here is an example of a simple subscription from the example application included in this repo:

<script lang="ts">
    import {
        fragment,
        mutation,
        graphql,
        subscription,
        ItemEntry_item,
    } from '$houdini'

    // the reference we're passed from our parents
    export let item: ItemEntry_item

    // get the information we need about the item
    const data = fragment(/* ... */)

    // since we're just using subscriptions to stay up to date, we don't care about the return value
    subscription(
        graphql`
            subscription ItemUpdate($id: ID!) {
                itemUpdate(id: $id) {
                    item {
                        id
                        completed
                        text
                    }
                }
            }
        `,
        {
            id: $data.id,
        }
    )
</script>

<li class:completed={$data.completed}>
    <div class="view">
        <input
            name={$data.text}
            class="toggle"
            type="checkbox"
            checked={$data.completed}
            on:click={handleClick}
        />
        <label for={$data.text}>{$data.text}</label>
        <button class="destroy" on:click={() => deleteItem({ id: $data.id })} />
    </div>
</li>

Configuring the WebSocket client

Houdini can work with any websocket client as long as you can provide an object that satisfies the SubscriptionHandler interface as the second argument to the Environment's constructor. Keep in mind that WebSocket connections only exist between the browser and your API, therefor you must remember to pass null when configuring your environment on the rendering server.

Using graphql-ws

If your API supports the graphql-ws protocol, you can create a client and pass it directly:

// environment.ts

import { createClient } from 'graphql-ws'
import { browser } from '$app/env'

// in sapper, this would be something like `(process as any).browser`
let socketClient = browser
    ? new createClient({
            url: 'ws://api.url',
      })
    : null

export default new Environment(fetchQuery, socketClient)

Using subscriptions-transport-ws

If you are using the deprecated subscriptions-transport-ws library and associated protocol, you will have to slightly modify the above block:

// environment.ts

import { SubscriptionClient } from 'subscriptions-transport-ws'
import { browser } from '$app/env'

let socketClient: SubscriptionHandler | null = null
if (browser) {
    // instantiate the transport client
    const client = new SubscriptionClient('ws://api.url', {
        reconnect: true,
    })

    // wrap the client in something houdini can use
    socketClient = {
        subscribe(payload, handlers) {
            // send the request
            const { unsubscribe } = client.request(payload).subscribe(handlers)

            // return the function to unsubscribe
            return unsubscribe
        },
    }
}

export default new Environment(fetchQuery, socketClient)

⚖️ Custom Scalars

Configuring your runtime to handle custom scalars is done under the scalars key in your config:

// houdini.config.js

export default {
    // ...

    scalars: {
        // the name of the scalar we are configuring
        DateTime: {
            // the corresponding typescript type
            type: 'Date',
            // turn the api's response into that type
            unmarshal(val) {
                return new Date(val)
            },
            // turn the value into something the API can use
            marshal(date) {
                return date.getTime()
            },
        },
    },
}

🔐  Authentication

houdini defers to SvelteKit's sessions for authentication. Assuming that the session has been populated somehow, you can access it through the second argument in the environment definition:

//src/environment.ts

import { Environment } from '$houdini'

// this function can take a second argument that will contain the session
// data during a request or mutation
export default new Environment(async function ({ text, variables = {} }, session) {
    const result = await this.fetch('http://localhost:4000', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Authorization': session.token ? `Bearer ${session.token}` : null,
        },
        body: JSON.stringify({
            query: text,
            variables,
        }),
    })

    // parse the result as json
    return await result.json()
})

⚠️  Notes, Constraints, and Conventions

  • The compiler must be run every time the contents of a graphql tagged string changes
  • Every GraphQL Document must have a name that is unique
  • Variable functions must be named after their query
  • Documents with a query must have only one operation in them
  • Documents without an operation must have only one fragment in them