notes.dt.in.th

Now that the core is usable, I want to tackle a first use case — opening a file in an external viewer.

To simplify this task, for now I will use a hardcoded list of external integrations. Making this list customizable will come later.

postMessage protocol: The messaging protocol I decided to use is based on JSON-RPC specification, following how Microsoft also uses it in its Language Server Protocol Specification.

Results

I implemented integrations for these:

External viewer

How it works: When opening a file with an external viewer, tmp.spacet.me opens up a new window, passing along a session ID. Once the viewer loads and detects the session ID, it makes a JSON-RPC call to the opener to obtain the file contents.

Communication

Implementation and refactoring

I started by doing the simplest thing that could possibly work and hardcoded the postMessage handling code. Here the code is infested with type assertions just to make TypeScript compiler happy. When experimenting, I think this is a good thing to do, just don't forget to clean it up later, which is coming up right next.

You don't need to understand the whole code here, just look at the code around the comments:

window.addEventListener('message', async (e) => {
  const fromWindow = (e.source as unknown) as Window

  // Check for a method call.
  if (e.data.method === 'tmp/getOpenedFile') {
    // In this block, `e.data` is `any`, so no IntelliSense.

    // (hovertip) const sessionId: any
    const sessionId = e.data.params.sessionId
    const session = sessionStorage[`session:${sessionId}`]
    if (!session) return
    const sessionState = JSON.parse(session)
    const db = getFilesDatabase()
    const doc = await db.get(sessionState.openedFile, {
      binary: true,
      attachments: true,
    })
    fromWindow.postMessage(
      // Send a reply.
      {
        // Right now this object can be arbitrary payload;
        // there's currently no type-checking here.
        // So I might introduce a bug at some point...
        jsonrpc: '2.0',
        id: e.data.id,
        result: {
          blob: (doc._attachments.blob as any).data,
          file: {
            _rev: doc._rev,
            _id: doc._id,
            name: doc.name,
            type: doc.type,
          },
        },
      },
      e.origin
    )
  }
})

Now while this works, I can see that this way of writing code can cause trouble soon. Right now to answer these questions you would need to read how the code is implemented, because it has never been explicitly stated, nor documented, in the code base:

  • Which methods are available?
  • What kind of parameters do these methods take?
  • What do the results look like for each method?

This makes the interface prone to break, and I don't want that. I think in this case static typing could help here. So I went ahead and created a TypeScript interface as a way to document the RPC interface. I wrote it in a way that I want to read it in the future.

interface RpcInterface extends JsonRpcDefinition {
  'tmp/getOpenedFile': {
    params: {
      sessionId: string
    }
    result: {
      blob: Blob
      file: {
        _rev: string
        _id: string
        name: string
        type: string
      }
    }
  }
}

Now, the message handler looks like this:

const rpc = new JsonRpcPayloadChecker<RpcInterface>()

window.addEventListener('message', async (e) => {
  const fromWindow = (e.source as unknown) as Window
  // Changed
  if (rpc.isMethodCall(e.data, 'tmp/getOpenedFile')) {
    // In this block, `e.data.params` shall have type
    // `RpcInterface['tmp/getOpenedFile']` which is `{ sessionId: string }`

    // (hovertip) const sessionId: string
    const sessionId = e.data.params.sessionId
    const session = sessionStorage[`session:${sessionId}`]
    if (!session) return
    const sessionState = JSON.parse(session)
    const db = getFilesDatabase()
    const doc = await db.get(sessionState.openedFile, {
      binary: true,
      attachments: true,
    })
    fromWindow.postMessage(
      // Changed
      rpc.replyResult(e.data, {
        // In here, the type should flow to this object.
        // So any deviations from the declared interface
        // would cause a type-checking error.
        blob: (doc._attachments.blob as any).data,
        file: {
          _rev: doc._rev,
          _id: doc._id,
          name: doc.name,
          type: doc.type,
        },
      }),
      e.origin
    )
  }
})

To make the type flow, here is the rest of that code. This is, IMO, one of the very few places where I would write advanced types. IMO advanced types hurt the readability of code, so it is best used in an isolated place, where it will make types flow better to the code that uses it. TypeScript tax is a thing, but let's manage it instead of avoiding it.

A litmus test I use for when to use advanced types is this: Does it reduce the amount of type annotations in other files? Don't let advanced types infect the rest of your codebase!

export class JsonRpcPayloadChecker<T extends JsonRpcDefinition> {
  isMethodCall<K extends keyof T>(
    message: unknown,
    method: K
  ): message is JsonRpcMethodCall<K, T[K]['params']> {
    return isJsonRpcMethodCall(message) && message.method === method
  }

  replyResult<K extends keyof T>(
    message: JsonRpcMethodCall<K, any>,
    result: T[K]['result']
  ) {
    return {
      jsonrpc: '2.0',
      id: message.id,
      result: result,
    }
  }
}

export interface JsonRpcDefinition {
  [methodName: string]: {
    params: any
    result?: any
  }
}

export interface JsonRpcMethodCall<MethodName, MethodParams> {
  id: string
  method: MethodName
  params: MethodParams
}

function isJsonRpcMethodCall(
  message: any
): message is { method: string; params: any } {
  return message && message.method
}