Developing Blocks

Introduction

Blocks are discrete components that allow users to view, explore, or edit data.

The Block Protocol defines a standard for communication between blocks and the applications that embed them.

The protocol is split into a core specification setting out how applications and blocks communicate, and service specifications defining what applications and blocks communicate.

This guide helps get you set up and introduces some of the key features of the graph service specification, which deals with creating, reading and updating data records (or “entities”).

In practice, most block developers will not need to know the lower-level details of the specifications, as the libraries we provide implement them.

Choosing your approach

We provide three templates which allow you to define the entry point for your block in different ways:

  • custom-element: create a block defined as a custom element (also known as Web Components).
  • html: create a block defined as an HTML file, with JavaScript added via <script> tags.
  • react: create a block defined as a React component.

To create a new block, run npx create-block-app@latest block-name --template template, replacing block-name with the name to give your block, and template with one of the template names listed above.

I want to use a different technology

If you want to write blocks using other technologies or frameworks, you have several options:

  1. use a custom-element template and use different approaches when constructing the element-i.e. use a custom element as a wrapper for anything else you like.
  2. use an html template and import and use your additional libraries inside the <script> tag.
  3. use a language which can be transpiled to JavaScript. As an example, see this blog post on writing a block using F#. This block uses React, you can use transpiled JavaScript in blocks defined as HTML files or custom elements too.

I don’t want to use TypeScript

You can write your block in regular JavaScript using the methods described above - just rename your files from *.tsx/*.ts to *.jsx/*.js, remove the types, and get coding.

Bear in mind that a block schema will not be automatically generated as part of the build process – you can write your own block-schema.json file at the root of your block’s folder, giving the type of props your App component accepts in JSON Schema format.

Creating a block

  1. Move to a folder where you want to create your block.
  2. Run npx create-block-app@latest your-block-name --template template.
  3. Switch to your new folder: cd [your-block-name].
  4. Run yarn install && yarn dev or npm install && npm run dev.
  5. Open http://localhost:63212 in your browser to see your block.

The development environment

The create-block-app package provides everything you need to develop a block.

  • src/app.tsx or src/app.ts contains your block’s code.
    • You can include dependencies in your block but bear in mind that the more dependencies you add, the bigger your block’s download size will be. Common dependencies which you can reasonably expect an embedding application to provide (e.g. React) can be defined as peerDependencies in package.json.
  • yarn dev or npm run dev will run your block in development mode, serving it locally with hot reloading at http://localhost:63212.
    • This uses the file at src/dev.tsx to render your block within a mock embedding application called MockBlockDock.
    • By default, dev mode will also show you the properties that are being passed to your block and the contents of the mock datastore. Remove debug from MockBlockDock to turn this off.
  • yarn build or npm run build will:
    • Bundle the component into a single source file (without any dependencies listed as peerDependencies).
    • Generate a JSON schema from the BlockEntityProperties type representing the data interface with the block. If your block folder contains block-schema.json, this custom schema will be used instead.
    • Generate a block-metadata.json file which:
      • points to the schema and source files.
      • brings in metadata from package.json, such as the block name and description.
      • brings in anything in the blockprotocol object in package.json, e.g.
        • blockType: the type of block this is.
        • displayName: a friendly display name.
        • examples: an array of example data structures your block would accept and use.
        • image: a preview image showing your block in action.
        • icon: an icon to be associated with your block.
      • list the externals, which are generated from peerDependencies in package.json.

Lifecycle of a block

When a block is loaded into an embedding application:

  1. the embedding application parses its block-metadata.json file and:
  2. provides any external dependencies which the block needs.
  3. sets up message handling as described in the core specification.
  4. loads the block with the appropriate strategy for its blockType.
  5. the block then receives any data which the embedder can provide straight away:
  6. custom-element and react-type blocks will be sent this initial data as properties.
  7. html-type blocks will be sent messages containing the initial data.
  8. the block can then do whatever it chooses to do with those properties.
  9. at any time after this initialization, the block may send further messages via a Service for specific purposes, such as reading and writing data within the embedding application.

The starter blocks created by create-block-app implement a simple example of this lifecycle:

  1. the BlockEntityProperties type in src/app.tsx defines the properties expected for the blockEntity (part of the Graph Service).
  2. mock values for the blockEntity are passed to MockBlockDock in dev.tsx, including the properties it expects (in this case, a name of type string).
  3. the block receives the data for blockEntity:
  4. the react and custom-element blocks receive the blockEntity as part of the graph object in their properties.
  5. the html block registers a callback for the blockEntity message.
  6. the block accesses the properties in blockEntity and uses the name property to render its Hello, World! message.

Using the Graph Service

The Graph Service describes how entities can be created, queried, updated, and linked together, including the block entity. It enables your block to create, read, update, and delete data in the embedding application.

The Graph Service is available via the graphService property in each starter template. It has a number of methods corresponding to the messages defined in the specification.

Using these methods in combination, you can create complex graphs from within a block without having to know anything about the implementation details of the application embedding it.

Each message payload is the same: an object containing data and errors keys.

Depending on the template, you may need to check that the graphService is available before using it (we will be removing the need for this shortly). These checks are omitted below.

Updating the blockEntity

A common use for the Graph Service is to update the blockEntity to update the properties that are sent to the block (including when it is loaded again in the future).

To do this, you need to call updateEntity using the entityId of the blockEntity:

// Update the block entity, and receive the updated entity in return
const { data, errors } = await graphService.updateEntity({
  data: {
    entityId: blockEntity.entityId,
    properties: { name: "Bob" },
  },
});

You can get the blockEntity from properties for custom-element and react-type blocks, and from a message callback for html-type blocks. There are examples in the templates.

As soon as the updateEntity call is processed, your block will be re-rendered with the updated properties, or in the case of html blocks, the blockEntity message re-sent with the updated data. You could therefore omit the { data, errors } from the above snippet and rely on the updated properties or message.

If you’re using the custom-element template, you have a helper method to achieve the above:

this.updateSelf({ name: "Bob" });

Exploring the data store

There are messages for exploring the data available in the embedding application:

  • aggregateEntities allows you to request a list of available entities.
  • aggregateEntityTypes allows you to request a list of available entity *types-*entities typically belong to a type, which describes their expected structure.

You can browse the available entities and types in order to display them, or create links between them.

Linking entities to the blockEntity

To link other entities to the block, call createLink with the relevant source and destination:

graphService?.createLink({
  data: {
    sourceEntityId: blockEntity.entityId,
    destinationEntityId: "person-1",
    path: "friend",
  },
});

Any entities linked to the block will appear in the linkedEntities property, with the links themselves appearing in linkGroups. You can also link other arbitrary entities together.

Going further

If you are using TypeScript, the types for methods available on graphService (as defined in the @blockprotocol/graph package) should help you understand what methods are available and how they operate.

You can also review the messages defined in the graph specification here. If you do so, bear in mind the following:

  • only the ones marked as source: "block" are available as methods to your block.
  • any message marked as sentOnInitialization with source: "embeder" will be passed to your block as soon as it’s loaded. Remember that custom-element and react-type blocks receive these messages as properties under a graph object, whereas html blocks can register callbacks for each message.
  • if a message has respondedToBy, it expects a response and the graphService method will return a Promise which will resolve with the { data, errors } object. If a message does not expect a response, the method will return void.

Testing your block in HASH

HASH is an open-source, data-centric, all-in-one workspace built atop the Block Protocol. While it is not yet ready for production use, you can use it to test your block in a real embedding application with example data.

To load your block into HASH:

  1. Visit the HASH repo and follow the instructions to run HASH locally.
  2. In your block’s directory, run yarn dev or npm run dev. This will serve your block locally at a localhost URL in development mode and will reload if you make changes to your source code. Alternatively, you can run yarn build && yarn serve to test a production build of your block which will not automatically rebuild if you make changes to the source code.
  3. Copy that localhost URL to your clipboard.
  4. In HASH, create or navigate to a Page.
  5. Find the […] menu button on the left of a block in the Page (you should see this button under the title on a new page).
  6. Open that menu and paste the block’s localhost URL into the “Load block from URL…” input field and click "Load block".

If you’re running your block in dev mode (yarn dev or npm run dev) and you make changes to the source code, your block will rebuild and HASH should automatically refresh the page. If you’re running your block in production mode, you will need to run yarn build from your block folder and reload your block manually in HASH by repeating steps 5-6 again.

Build

Once you’ve finished writing your block, run yarn build or npm run build.

This will produce a compiled version of your code in the dist folder, along with metadata files describing your block (block-metadata.json), and the data it accepts (block-schema.json).

It is worth updating the blockprotocol object in package.json to include your own icon, image, and examples for your block. These will automatically be included in the block-metadata.json produced after running yarn build or npm run build.

You now have a block package that you can provide to apps to use, by publishing it on the Hub.

Publish

Once you've built a block, you can add it to the Hub, so that:

  • your block will have an instant online demo playground
  • your block will be searchable via our block API
  • you can claim your namespace in blockprotocol.org

To publish a block on the Hub, the initial process is as follows:

  1. Have your block source code available in a GitHub repository
  2. Claim your username by signing up at https://blockprotocol.org
  3. Add a JSON file to the hub folder, in a subfolder matching the username you registered in step one, preceded by the @ symbol (e.g. @myusername)
  4. Create a pull request from your fork

The JSON metadata file should look like this:

{
  // REQUIRED - path to the repository with your block's source code
  repository: "https://github.com/hashintel/hash.git",
  // REQUIRED - commit hash (pins block to a specific version)
  commit: "adb915bf8fc4f84e33a2cd21f217225e50c3d7fa",

  // IF NEEDED - see below
  distDir: "dist",
  folder: "packages/blocks/block-embed",
  workspace: "@hashintel/block-embed",
}

My block source is in a subfolder

  • If it’s in a yarn workspace, please provide workspace
  • If it’s not in a yarn workspace, please provide its folder path

My block requires building from its source

  • please provide the distDir where the build artifacts will appear

Note

We assume that running yarn install && yarn build will build your block (after switching to the relevant folder/workspace, if provided). The specified distDir will be relative to the workspace directory or folder, if provided.

We're hiring full-stack TypeScript/React developers to build blocks full-time, and grow theBlock Protocol ecosystem.Learn more