Textpad: an example app

What it is

Textpad is a small text editor that keeps its documents on HashBin. It is a complete working app in one HTML file of plain JavaScript, with no build step, written to be read. This page walks through how it works.

  • Open: /docs/textpad/app#256t=<256t string> shows the text stored under that 256t string.
  • Edit and save: Save publishes the text to HashBin and puts its new 256t string in the address bar, so the URL is always a link to what is on screen.
  • Pay with the user's account: saving is billed to the user's own HashBin balance. They approve Textpad once on hashbin.org and stay connected on later visits.
  • Explain problems: a malformed 256t string, one with nothing stored under it, or content that isn't text each gets its own clear message.

Textpad is also available as an iOS and Android app, built with Expo or Flutter.

Open Textpad

How saving is paid for

  • Textpad is a registered HashBin app. The first time a user saves, Textpad sends them to hashbin.org to sign in and approve it, using OAuth 2.0 with PKCE. Textpad never sees their password or an API key.
  • Textpad asks for two permissions: content:write to publish, and balance:read to show the balance.
  • Each save is charged to the user's balance at the normal price, and kept for the default retention period set on their account page (one month unless they change it). Texts of 64 bytes or less are free.
  • The user stays connected: the SDK keeps a refresh token in the browser's localStorage and renews access as needed. Disconnect revokes it, and users can also revoke Textpad from their account page.
  • Content on HashBin can't be changed. Saving an edited text creates a new 256t string; the old text stays available under its old string until it expires.

Messages and what causes them

SituationTextpad shows
The string has characters outside A–Z a–z 0–9 - _, or its length doesn't match the length it encodes (for example, a link cut off when copied)"… isn't a valid 256t string. Check that the link was copied completely."
The URL ends in #256t= with nothing after it"This link has "#256t=" but no 256t string after it."
256t.us answers 404: never saved, expired, or deleted"Nothing is stored under this 256t string. It may have expired or been deleted, or it was never saved."
256t.us answers 451: the content is under dispute"This text is unavailable while a dispute about it is reviewed."
256t.us answers 429: the text has used up its download allowance"HashBin is limiting how often this text can be downloaded. Try again later."
The content isn't valid UTF-8, or contains NUL characters (binary data)"This 256t string points to content that isn't text, so Textpad can't open it."
The network request fails"Couldn't reach HashBin to load this text…"
The user declines on the HashBin approval page"You didn't approve Textpad on HashBin, so nothing was saved." Their edits are kept.
The balance is too low to saveHashBin's explanation, with an Add funds link.
The connection was revoked or has expired"Your HashBin connection has ended. Click Save to connect again."

When a link can't be opened, the editor is left empty instead of showing the previous text, so nothing on screen can be mistaken for the content of a broken link.

The code, step by step

All of Textpad's logic is one <script type="module"> in app.html, in seven numbered parts. The excerpts below are copied from it exactly; the full source is at the end of this page.

1. Configuration

Textpad uses the HashBin browser SDK for the OAuth flow. The configuration names the app (its client ID), where HashBin is, and the redirect URI that HashBin sends the user back to after they approve Textpad.

import { HashBinClient } from "/sdk/hashbin.js";
// The client ID from registering Textpad at https://hashbin.org/developers
let CLIENT_ID = "app_1a74e62b-d752-4a3c-81af-5c850af18c5b";
// The HashBin service, and the domain that serves published content.
let HASHBIN_URL = "https://hashbin.org";
let CONTENT_URL = "https://256t.us";

// Development only: on a page served by a local HashBin server
// (npm run dev:local), "?dev=<client ID>" uses that server and an app
// registered on it, so nothing touches the real service.
const DEV_CLIENT_ID = new URLSearchParams(location.search).get("dev");
const DEV = Boolean(DEV_CLIENT_ID) && ["localhost", "127.0.0.1"].includes(location.hostname);
if (DEV) {
  CLIENT_ID = DEV_CLIENT_ID;
  HASHBIN_URL = CONTENT_URL = location.origin;
}

// HashBin sends the user back here after they approve Textpad. It must
// match a redirect URI registered for the app, character for character.
const REDIRECT_URI = location.origin + location.pathname +
  (DEV ? `?dev=${encodeURIComponent(CLIENT_ID)}` : "");

// What Textpad asks the user to allow: publishing (billed to their
// balance) and reading their balance so it can be shown.
const SCOPES = ["content:write", "balance:read"];

const hb = new HashBinClient({
  clientId: CLIENT_ID,
  redirectUri: REDIRECT_URI,
  baseUrl: HASHBIN_URL,
  contentBaseUrl: CONTENT_URL
});

The redirect URI must match one registered for the app exactly. hashbin.org serves pages without the .html extension, so Textpad's registered redirect URI is https://hashbin.org/docs/textpad/app; location.origin + location.pathname produces exactly that. The ?dev= switch only takes effect on localhost or 127.0.0.1, so it can't redirect a deployed copy anywhere unexpected.

2. Page state

Textpad remembers the 256t string of the text it shows (currentCid) and that text as it was loaded or last saved (savedText). Comparing savedText with the editor tells it whether there are unsaved changes, which decides whether Save is enabled and whether to warn before leaving.

// Shows text in the editor as the saved version of currentCid.
function setDocument(cid, text) {
  currentCid = cid;
  editor.value = text;
  // Browsers turn "\r\n" line endings into "\n" inside a textarea, so take
  // the saved text back from the editor; otherwise a text with Windows
  // line endings would look edited before the user has typed anything.
  savedText = editor.value;
  updateControls();
}

3. The 256t string in the URL

Textpad keeps the 256t string in the URL fragment: app#256t=<string>. The fragment is never sent to a server, and changing it doesn't reload the page.

function cidFromUrl() {
  const match = location.hash.match(/^#256t=(.*)$/);
  if (!match) {
    return null;
  }
  try {
    return decodeURIComponent(match[1]);
  } catch {
    return match[1]; // A stray "%" can't be decoded; isValid256t rejects it.
  }
}

A 256t string has two parts, both in base64url. The first 8 characters hold the content's length in bytes. The rest is the content itself when it is 64 bytes or less, or else its SHA-512 hash, which is always 86 characters. Checking that the two parts agree catches typos and truncated links before any network request is made:

function base64UrlToBytes(text) {
  const base64 = text.replace(/-/g, "+").replace(/_/g, "/");
  return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
}

// A 256t string is base64url text in two parts:
//   - 8 characters holding the content's length in bytes
//     (a 6-byte big-endian number);
//   - then the content itself if it is 64 bytes or less, or else the
//     content's SHA-512 hash (always 86 characters).
// Checking this up front gives a clear message for a mistyped or cut-off
// link instead of a confusing "not found".
function contentLength(cid) {
  return base64UrlToBytes(cid.slice(0, 8)).reduce((total, byte) => total * 256 + byte, 0);
}

function isValid256t(cid) {
  if (!/^[A-Za-z0-9_-]{8,94}$/.test(cid)) {
    return false;
  }
  const length = contentLength(cid);
  const expectedRest = length <= 64 ? Math.ceil((length * 4) / 3) : 86;
  return cid.length - 8 === expectedRest;
}

4. Loading text

HashBin stores bytes, not text. Textpad decodes them as UTF-8 in strict mode, so anything that isn't text is refused instead of being shown as garbled characters:

// Content on HashBin is just bytes. Textpad only opens bytes that are
// valid UTF-8 text. "fatal: true" makes the decoder throw on anything
// else instead of quietly inserting replacement characters, and
// "ignoreBOM: true" keeps a leading byte order mark so that saving
// unchanged text gives back exactly the same bytes and 256t string.
function decodeText(bytes) {
  const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
  // Valid UTF-8 can still be binary data; a NUL character is a reliable
  // sign of that, and real text practically never contains one.
  if (text.includes("\0")) {
    throw new TypeError("Content contains NUL characters");
  }
  return text;
}

Loading checks the string, then either decodes a short text straight from the string or downloads a longer one from https://256t.us/<string>. Downloading needs no account and no token. Each failure gets its own message:

async function loadFromUrl() {
  const cid = cidFromUrl();
  clearMessage();

  if (cid === null) {
    setDocument(null, "");
    return;
  }

  // Whatever happens below, don't leave the previous text on screen as if
  // it belonged to this link.
  setDocument(null, "");

  if (cid === "") {
    showMessage("This link has \"#256t=\" but no 256t string after it.", { error: true });
    return;
  }
  if (!isValid256t(cid)) {
    const shown = cid.length > 100 ? `${cid.slice(0, 100)}…` : cid;
    showMessage(`"${shown}" isn't a valid 256t string. Check that the link was copied completely.`, { error: true });
    return;
  }

  // Short texts are inside the 256t string itself, so there is nothing
  // to download.
  if (contentLength(cid) <= 64) {
    showText(cid, base64UrlToBytes(cid.slice(8)));
    return;
  }

  busy = true;
  updateControls();
  showMessage("Loading…");
  try {
    let response;
    try {
      response = await fetch(hb.getContentUrl(cid));
    } catch {
      showMessage("Couldn't reach HashBin to load this text. Check your connection and reload the page.", { error: true });
      return;
    }

    if (response.status === 404) {
      showMessage("Nothing is stored under this 256t string. It may have expired or been deleted, or it was never saved.", { error: true });
      return;
    }
    if (response.status === 451) {
      showMessage("This text is unavailable while a dispute about it is reviewed.", { error: true });
      return;
    }
    // Each stored text has a download allowance; when it runs out, 256t.us answers 429.
    if (response.status === 429) {
      showMessage("HashBin is limiting how often this text can be downloaded. Try again later.", { error: true });
      return;
    }
    if (!response.ok) {
      showMessage(`HashBin couldn't return this text (HTTP ${response.status}). Try again later.`, { error: true });
      return;
    }

    showText(cid, await response.arrayBuffer());
  } finally {
    busy = false;
    updateControls();
  }
}

5. Connecting to the user's HashBin account

The first save sends the user to hashbin.org to approve Textpad. That leaves the page, so Textpad first stores the user's text in sessionStorage, keyed by the OAuth state value. When HashBin sends the user back, finishSignIn trades the one-time code for tokens through the SDK, restores the text, and returns it so the save can carry on.

const DRAFT_KEY = "textpad:draft:";

function isConnected() {
  return Boolean(hb.getTokens()?.refreshToken);
}

async function connectAndSave() {
  const { url, state } = await hb.authorize({ scopes: SCOPES, mode: "manual" });
  sessionStorage.setItem(DRAFT_KEY + state, JSON.stringify({
    cid: currentCid,
    savedText,
    text: editor.value
  }));
  busy = true; // Also stops the "unsaved changes" warning while leaving.
  updateControls();
  location.assign(url);
}

// When HashBin sends the user back, the URL carries ?code=…&state=… (or
// ?error=access_denied if they declined). This exchanges the code for
// tokens, cleans the URL and returns the draft saved before leaving.
async function finishSignIn() {
  const params = new URLSearchParams(location.search);
  if (!params.has("code") && !params.has("error")) {
    return null;
  }

  const draftKey = DRAFT_KEY + params.get("state");
  const draft = JSON.parse(sessionStorage.getItem(draftKey) || "null");
  sessionStorage.removeItem(draftKey);

  // Put the URL back the way it was before signing in.
  history.replaceState(null, "", REDIRECT_URI + (draft?.cid ? `#256t=${draft.cid}` : ""));

  if (draft) {
    currentCid = draft.cid;
    savedText = draft.savedText;
    editor.value = draft.text;
  }

  if (params.get("error")) {
    showMessage("You didn't approve Textpad on HashBin, so nothing was saved.", { error: true });
    return null;
  }
  try {
    await hb.handleCallback({ search: params.toString() });
  } catch (error) {
    showMessage(`Connecting to HashBin failed: ${error.message}`, { error: true });
    return null;
  }
  return draft;
}

Every request made as the user goes through api(). The SDK's authorizedFetch adds the access token and renews it when it has expired. A 401 response means the user revoked Textpad, so Textpad forgets the tokens and asks them to connect again.

// Raised when the stored sign-in no longer works (it expired, or the user
// revoked Textpad on their HashBin account page).
class NotConnectedError extends Error {}

// Calls the HashBin API as the user. The SDK adds the access token and
// renews it when it has expired; when renewal fails it forgets the tokens.
async function api(path, options) {
  let response;
  try {
    response = await hb.authorizedFetch(path, options);
  } catch (error) {
    if (!isConnected()) {
      throw new NotConnectedError();
    }
    throw error;
  }
  if (response.status === 401) {
    hb.clearTokens();
    throw new NotConnectedError();
  }
  return response;
}

6. Saving text

Saving is a single POST /api/content whose body is the text. The response holds the new 256t string (cid), which Textpad writes into the URL with history.pushState, so the browser's Back button returns to the previous version.

async function save() {
  if (busy || !hasUnsavedChanges() || editor.value === "") {
    return;
  }
  if (!isConnected()) {
    showMessage("Taking you to HashBin to approve Textpad…");
    await connectAndSave();
    return;
  }

  const text = editor.value;
  busy = true;
  updateControls();
  showMessage("Saving…");
  try {
    // The request body is the text itself. Uploads made with an OAuth
    // app are kept for the user's default retention period (set on their
    // HashBin account page) and charged to their balance. Texts of 64
    // bytes or less are stored inside the 256t string and are free.
    const response = await api("/api/content", {
      method: "POST",
      headers: { "content-type": "text/plain; charset=utf-8" },
      body: text
    });
    const result = await response.json().catch(() => ({}));

    if (!response.ok) {
      if (result.error === "insufficient_balance") {
        showMessage(result.message || "Your HashBin balance is too low to save this.", {
          error: true,
          link: { href: `${HASHBIN_URL}/deposit.html`, text: "Add funds" }
        });
      } else {
        showMessage(`Saving failed: ${result.message || result.error || `HTTP ${response.status}`}`, { error: true });
      }
      return;
    }

    // Record the new 256t string in the URL. pushState doesn't fire
    // "hashchange", so the text isn't reloaded, and Back returns to the
    // previous version.
    setUrlHash(`#256t=${result.cid}`);
    currentCid = result.cid;
    savedText = text;
    const expires = result.expires_at
      ? ` Kept until ${new Date(result.expires_at).toLocaleDateString()}.`
      : "";
    showMessage(`Saved. The address bar now links to this text.${expires}`, {
      link: { href: hb.getContentUrl(result.cid), text: "View raw text" }
    });
    showAccount();
  } catch (error) {
    if (error instanceof NotConnectedError) {
      showAccount();
      showMessage("Your HashBin connection has ended. Click Save to connect again.", { error: true });
    } else {
      showMessage(`Saving failed: ${error.message}`, { error: true });
    }
  } finally {
    busy = false;
    updateControls();
  }
}

7. Starting up

On load, Textpad first checks whether HashBin has just sent the user back from the approval page. If it has, Textpad finishes the save they asked for; otherwise it opens whatever the URL points to. Listeners, not shown here, handle Back and Forward, pasted links, the New button, Ctrl/⌘+S, and the unsaved-changes warning.

// Start up: finish a sign-in if HashBin just sent the user back, then
// either carry on with the save they asked for or load the URL's text.
const draft = await finishSignIn();
shownHash = location.hash;
if (draft) {
  updateControls();
  showAccount();
  save();
} else {
  if (messageBox.hidden) {
    await loadFromUrl();
  } else {
    updateControls();
  }
  showAccount();
}

Run your own copy

  1. Register an app on the Developers page. Use the address your copy will have as its redirect URI, for example https://your-name.github.io/textpad/app.html. Use the address exactly as it appears in the browser; some hosts drop the .html.
  2. Save app.html to your site.
  3. Change the SDK import to the hosted copy: import { HashBinClient } from "https://hashbin.org/sdk/hashbin.js";
  4. Replace CLIENT_ID with your app's client ID.
  5. Change the links to /deposit.html and the docs if you like. Everything else, including HASHBIN_URL and CONTENT_URL, stays as it is.

To work on Textpad against a local HashBin server instead (npm run dev:local), register an app on that server with the redirect URI http://127.0.0.1:8787/docs/textpad/app?dev=<client ID> and open that same address.

Things to know

  • Tokens live in localStorage. Any script on the same origin can read them, so don't load scripts you don't trust on a page that uses the SDK. Textpad inserts every message with textContent, never innerHTML, so text from a 256t string can't inject markup.
  • Line endings. Browsers turn Windows \r\n line endings into \n inside a text box, so saving such a file after editing it stores \n endings.
  • Saving the same text again. Save stays disabled until the text changes. If edited text happens to match something already on HashBin (for example after undoing back to an earlier version), HashBin keeps the existing copy and extends how long it is kept, which is charged like a new save.
  • Saves use the account's default retention. Apps cannot choose how long content is kept; users set their default on the account page.

Full source

The complete app.html, loaded from the live file:

Loading…