本記事は生成 AI と共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

本記事ではアカウント名や ID をプレースホルダで表記しています。例:

  • Cloudflare アカウント / Workers サブドメイン: <cf-subdomain> (Workers の URL は https://jsonkeeper.<cf-subdomain>.workers.dev)
  • Firebase プロジェクト ID: <firebase-pid>
  • D1 データベース ID: <d1-database-id> (wrangler d1 create で発行)

自身の環境では、すべて自分の値に置き換えて読んでください。

別記事で JSONkeeper を PythonAnywhere 無料プランに HTTP API だけでデプロイした記録 を書きました。アップストリームの IllDepence/JSONkeeper をそのまま現代 Python で動かす方法です。今回は同じ用途 — IIIF Curation Viewer のキュレーション保存バックエンド — を Cloudflare Workers + D1 で書き直した ときの設計判断と実装、デプロイ手順を残します。

リポジトリは nakamura196/jsonkeeper-workers に置いてあります (MIT)。本記事と合わせて参照してください。

PA 版を「塩漬けの上流互換参照実装」、Workers + D1 版を「本流」とする二系統運用にしているので、それぞれを別記事として独立させています。両方を読む前提では書いていませんが、横断的な比較表は本記事の終盤に置いています。

TL;DR

  • TypeScript で 360 行src/ 配下に index.ts (191) + auth.ts (86) + curation.ts (57) + activitystreams.ts (26)。Hono をルーター、jose を JWT 検証に使う (本記事は v5.9 系を前提、v6.x (現行) でも API 互換)。
  • Firebase Admin SDK は 使わない。Firebase ID トークン (RS256) を securetoken.googleapis.com から取得した x509 公開鍵で josejwtVerify に渡すだけで、securetoken.google.com/<pid> / aud = <pid> を確認する。サーバ秘匿のサービスアカウント鍵が要らないのが大きい。ただし Admin SDK の verifyIdToken(idToken, true) で行われる 失効検知 (tokensValidAfterTime 突合)無効化ユーザ判定 は Auth REST API への問い合わせが必要なので、これらが要件なら別途実装が必要。
  • ストレージは D1 (SQLite-on-edge)。テーブルは documents 1 つ、5 つのインデックスを張る (owner_uid, jsonld_type, created_at, unlisted)。
  • JSON-LD @id 書き換えは トップレベル + ネスト両方。トップレベルが cr:Curation で、内部の Range ノードにも fragment-id (<docUrl>#frag-N) を再帰的に振る。アップストリームと同等の振る舞いを保つ部分。
  • 無料枠で 100k requests/日 + D1 5GB / 5M reads / 100k writes/日。Viewer 用途では枯渇まで気の遠くなる余裕
  • CORS は Hono の cors() middleware に X-Firebase-ID-Token / X-Access-Token / X-Unlisted を明示。Location ヘッダは exposeHeaders に入れて Viewer 側から読める状態にする。
  • デプロイは wrangler loginwrangler d1 create jsonkeeperdatabase_idwrangler.toml に記入 → wrangler d1 migrations apply jsonkeeper --remotewrangler deploy の 4 ステップ。

出発点

項目
上流の実装IllDepence/JSONkeeper (Python 3 + Flask + SQLAlchemy + firebase_admin)。mp.ex.nii.ac.jp/api/curation/json で運用されていた本体と同じ
別系統で立てた選択肢PythonAnywhere 無料プランで上記の Flask をそのまま動かす版 (別記事参照)
問題(1) PA 無料プランは CPU 100 秒/日、依存更新時の pip install で度々ぶつかる、(2) 日本からのレイテンシが 200ms 前後 (PA の web ワーカーは欧米リージョン)、(3) 上流の依存スタック (Flask 1.0 → 2.0 ピン、firebase_admin、google-cloud-*) は重く、長期メンテ負債が積まれていく
Viewer が実際に使う機能POST/GET/PUT/DELETE と CORS、Location ヘッダ返却、JSON-LD の @id 自動書き換え、X-Firebase-ID-Token の検証 だけ

最後の行が重要で、Viewer のエクスポートワークフローは JSONkeeper の機能のごく一部しか使っていない。具体的には X-Unlisted / /<id>/status PATCH / Activity Stream の change discovery (ページネーション) / garbage collection / Range サブ URL は触らない。であれば、上流をそのまま動かすコストを払うより、Viewer が触る I/F だけを実装する ほうが長期的に楽だ、というのが書き直しに踏み切った最大の理由です。

着地点

項目
URLhttps://jsonkeeper.<cf-subdomain>.workers.dev/ (本記事執筆時点でデプロイ済み)
言語 / フレームワークTypeScript + Hono (Cloudflare Workers ランタイム)
認可Firebase ID トークン → jose で RS256 検証 (Google x509 公開鍵を fetch + キャッシュ)
ストレージD1 (SQLite-on-edge)、テーブル documents
エンドポイントGET /, POST /api, GET /api/userdocs, GET/PUT/DELETE /api/:id, GET /as/collection.json
上流互換性△ Viewer が触る範囲はフル。上流の X-Unlisted / /<id>/status / Activity Stream ページネーション / GC / Range サブ URL は未実装
コード規模360 行 (TypeScript)、依存は honojose のみ

1. なぜ Cloudflare Workers + D1 を選んだか

「Viewer の保存先」という用途に対しては、選択肢として PythonAnywhere (上流をそのまま) と Cloudflare Workers + D1 (自作再実装) を並走で検討しました。比較表 (本記事末尾の詳細版より):

PA + Flask (上流)Workers + D1 (本記事)
無料枠の実用性CPU 100 秒/日 (リクエスト処理は対象外だが、依存更新時に詰まる)100k requests/日 + D1 5GB / 5M reads / 100k writes/日
日本からのレイテンシ約 200ms (PA は欧米リージョン)10〜30ms (Tokyo/Osaka エッジ)
コールドスタート長時間アイドルで遅延ありエッジで常時 hot
外向き通信ホワイトリスト経由 (securetoken.googleapis.com 等の到達確認が必要)制限なし
永続性PA の SQLite ファイル (アカウント生存中)D1 (自動レプリケーション)
上流互換性◎ 完全△ Viewer が使う範囲のみ
メンテナンス負債Flask 1.0 → 2.0 ピン、firebase_admin、google-cloud-*hono + jose のみ

「Viewer が使う範囲のみ」と割り切れることが分かったので、運用負荷の低い側 (Workers + D1) に倒しました。長期的に「触りたくないが死なせたくない」という運用要件を満たすのが選定基準です。

2. スコープの決定 — どの機能を実装するか

上流 JSONkeeper の機能を全部実装しようとすると、Viewer から見ると過剰です。Viewer のエクスポートプラグイン (icv.exportJsonKeeper.js) が実際に叩く I/F だけを残す方針にしました。

機能上流Workers 版採用判断
POST /api (新規保存)必須
GET /api/:id (取得)必須
PUT /api/:id (上書き)必須 (編集フロー)
DELETE /api/:id必須
GET /api/userdocs (自分の文書一覧)採用 (Viewer の「自分の Curation」一覧で使う可能性)
Location ヘッダ返却必須 (Viewer はこれを ?curation= で読みに行く)
X-Firebase-ID-Token 認可必須
匿名 POST 許可採用 (Viewer の allowAnonymousPost: true 構成)
JSON-LD @id 書き換え (top + nested)必須 (cr:Curation の中の Range ノードまで再帰的に処理)
X-Unlisted: true (POST のみ、PUT 時は既存値引き継ぎ)△ DB カラムだけ用意、ロジック未実装後回し
/<id>/status PATCH不要 (Viewer は触らない)
Activity Stream Collection✓ (ページング)△ 単一ページのみスコープ縮小 (簡易実装)
Garbage collection不要 (Workers + D1 では一括 cron 動かす別系統が必要、後で必要なら追加)
Range サブ URL (/<id>/range<n>)不要 (Viewer は使わない)

「全部入りクローン」を作るのではなく、「Viewer が使う必要十分」を切り出すことで 360 行に収めています。

3. プロジェクト構成

jsonkeeper-workers/
├── package.json              ; hono, jose の 2 依存
├── tsconfig.json             ; strict, ES2022, @cloudflare/workers-types
├── wrangler.toml             ; D1 バインディング、env vars
├── migrations/
│   ├── 0001_init.sql         ; documents テーブル + インデックス
│   └── 0002_unlisted.sql     ; unlisted カラム追加
├── src/
│   ├── index.ts              ; Hono ルーター (191 行)
│   ├── auth.ts               ; Firebase JWT 検証 (86 行)
│   ├── curation.ts           ; JSON-LD @id 書き換え (57 行)
│   └── activitystreams.ts    ; AS Collection 構築 (26 行)
└── test/
    └── smoke.sh              ; 4 ケースのスモークテスト

package.json (抜粋):

{
  "name": "jsonkeeper-workers",
  "type": "module",
  "scripts": {
    "dev": "wrangler dev",
    "deploy": "wrangler deploy",
    "typecheck": "tsc --noEmit",
    "db:create": "wrangler d1 create jsonkeeper",
    "db:migrate:local": "wrangler d1 migrations apply jsonkeeper --local",
    "db:migrate:remote": "wrangler d1 migrations apply jsonkeeper --remote",
    "tail": "wrangler tail"
  },
  "dependencies": {
    "hono": "^4.6.14",
    "jose": "^5.9.6"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20241218.0",
    "typescript": "^5.7.2",
    "wrangler": "^3.99.0"
  }
}

wrangler.toml:

name = "jsonkeeper"
main = "src/index.ts"
compatibility_date = "2024-12-01"

[vars]
FIREBASE_PROJECT_ID = "<firebase-pid>"
REWRITE_TYPES = "http://codh.rois.ac.jp/iiif/curation/1#Curation,http://iiif.io/api/presentation/2#Range"

[[d1_databases]]
binding = "DB"
database_name = "jsonkeeper"
database_id = "<d1-database-id>"
migrations_dir = "migrations"

[observability]
enabled = true

[vars]FIREBASE_PROJECT_IDREWRITE_TYPESシークレットではない ので wrangler.toml に直書きしています。FIREBASE_PROJECT_ID は JWT の iss / aud 検証のためだけに使うので、漏れても認証ゲートが下がるわけではない。アップストリームの config.ini 相当を vars に 2 つ置く、というのが基本的な分割。

tsconfig.jsonstrict フル、noUnusedLocalsnoImplicitAny あたりを有効化。@cloudflare/workers-types だけを types に入れて、Node 標準型は明示的に外しています。

4. 設計判断 1: 認可 — Firebase Admin SDK は使わない

これが Workers 版の最大のデザインポイントです。アップストリームは firebase_admin.auth.verify_id_token(idtoken) を呼ぶだけですが、Workers ランタイムでは Python が動かないので Firebase Admin SDK の Python 実装は当然使えません。代替として:

  • jose で RS256 を検証する
  • 公開鍵 (x509 形式の証明書) を https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com から fetch する
  • iss (https://securetoken.google.com/<firebase-pid>) と aud (<firebase-pid>) を jwtVerify の引数で指定する

これで Firebase Admin SDK の verifyIdToken(idToken) 引数なし版 (デフォルトの checkRevoked=false) と機能的に等価な検証ができます。サービスアカウント鍵が要らないのが副次的に嬉しい — PA 版では firebase-adminsdk.json をサーバに置く必要があり、漏洩時の再発行運用も用意する必要がありますが、Workers 版ではそれを丸ごと回避できる。

ただし Admin SDK の verifyIdToken(idToken, true) (checkRevoked=true) で追加される 失効検知 (tokensValidAfterTime 突合) と無効化ユーザ判定 (user.disabled) は、Identity Toolkit API への service-account 認証付きリクエストが必要なので、jose 単独では再現できません。Viewer のキュレーション保存というユースケースでは「ログアウト直後のセッションでも 1 時間以内の token 有効期限まで保存できてしまう」程度のゆるさに留まりますが、それが許容できないユースケース (重要文書の削除など) では別途 Firebase REST API か Cloud Run 経由で失効検知を加える必要があります。

実装は src/auth.ts。中核部分を抜粋:

const GOOGLE_X509_URL =
  'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com';

type JwkCache = { keys: Record<string, CryptoKey>; expiresAt: number };
let jwkCache: JwkCache | null = null;

async function getSigningKey(kid: string): Promise<CryptoKey> {
  if (!jwkCache || Date.now() >= jwkCache.expiresAt) {
    const res = await fetch(GOOGLE_X509_URL);
    if (!res.ok) throw new Error(`JWK fetch failed: ${res.status}`);
    const certs = (await res.json()) as Record<string, string>;
    const maxAge = parseMaxAge(res.headers.get('cache-control')) ?? 3600;
    const keys: Record<string, CryptoKey> = {};
    for (const [k, pem] of Object.entries(certs)) {
      keys[k] = (await importX509(pem, 'RS256')) as CryptoKey;
    }
    jwkCache = { keys, expiresAt: Date.now() + maxAge * 1000 };
  }
  const key = jwkCache.keys[kid];
  if (!key) { jwkCache = null; throw new Error(`Unknown signing kid: ${kid}`); }
  return key;
}

export async function verifyFirebaseIdToken(
  token: string,
  projectId: string,
): Promise<{ uid: string; email?: string }> {
  const header = decodeProtectedHeader(token);
  if (header.alg !== 'RS256') throw new Error(`Unexpected alg: ${header.alg}`);
  if (!header.kid) throw new Error('Missing kid');

  const key = await getSigningKey(header.kid);
  const { payload } = await jwtVerify(token, key, {
    issuer: `https://securetoken.google.com/${projectId}`,
    audience: projectId,
    algorithms: ['RS256'],
  });
  if (!payload.sub) throw new Error('Missing sub');
  return { uid: payload.sub, email: payload.email as string | undefined };
}

公開鍵キャッシュ周りの判断:

  • モジュールスコープの単一 jwkCache に置く。Workers のインスタンスはエッジで「同じ箇所からの連続したリクエスト」をある程度同じ isolate で処理するので、isolate ローカルなインメモリキャッシュとして機能する。完璧ではないが「リクエストごとに毎回 fetch」よりは桁違いに軽い。
  • Cache-Control: max-age を尊重する。Google の x509 エンドポイントは適切な max-age を返すので、それをそのまま expire 時刻に使う。最低 1 時間 (fallback) で、ローテーション時の追従もできる。
  • kid が見つからない場合は cache を破棄して次回 fetch する。新しい鍵がローテートされたタイミングでも自動的に追従できる。

middleware 部分はシンプルに:

export function authMiddleware(opts: { required: boolean }): MiddlewareHandler<AuthEnv> {
  return async (c, next) => {
    const token = extractToken(c);
    if (!token) {
      if (opts.required) return c.json({ error: 'Authentication required' }, 401);
      return next();
    }
    try {
      const { uid, email } = await verifyFirebaseIdToken(token, c.env.FIREBASE_PROJECT_ID);
      c.set('uid', uid);
      if (email) c.set('email', email);
    } catch (e) {
      if (opts.required) return c.json({ error: 'Invalid token', detail: String(e) }, 401);
    }
    return next();
  };
}

function extractToken(c: Context): string | undefined {
  const direct = c.req.header('X-Firebase-ID-Token') ?? c.req.header('x-firebase-id-token');
  if (direct) return direct;
  const auth = c.req.header('Authorization') ?? c.req.header('authorization');
  if (!auth) return undefined;
  const [scheme, value] = auth.split(/\s+/, 2);
  return scheme?.toLowerCase() === 'bearer' ? value : undefined;
}

X-Firebase-ID-Token (Viewer プラグインが使うヘッダ名) を一次選択、Authorization: Bearer ... (標準的なヘッダ) も二次選択でサポート。ルートごとに { required: true } (PUT/DELETE) と { required: false } (POST) を分ける。

5. 設計判断 2: ストレージ — D1 を素直に使う

D1 は Cloudflare の SQLite-as-a-Service。マイグレーションのファイル形式と wrangler d1 migrations apply の運用は普通の Rails / Django 風に動きます。

migrations/0001_init.sql:

CREATE TABLE IF NOT EXISTS documents (
  id            TEXT PRIMARY KEY,
  json          TEXT NOT NULL,
  owner_uid     TEXT,
  content_type  TEXT NOT NULL DEFAULT 'application/json',
  jsonld_type   TEXT,
  created_at    INTEGER NOT NULL,
  updated_at    INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_documents_owner   ON documents(owner_uid);
CREATE INDEX IF NOT EXISTS idx_documents_type    ON documents(jsonld_type);
CREATE INDEX IF NOT EXISTS idx_documents_created ON documents(created_at DESC);

migrations/0002_unlisted.sql:

ALTER TABLE documents ADD COLUMN unlisted INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_documents_unlisted ON documents(unlisted);

設計上のポイント:

  • json カラムには JSON.stringify したテキストを丸ごと入れる。D1 (SQLite) には JSON 関数もあるが、Viewer が触る単位は「ドキュメントまるごと取得 / 上書き」なので、パースは Workers 側 (TypeScript) でやればよい。
  • jsonld_type を別カラムで持つことで、Activity Stream のフィルタが INDEX で動く。WHERE jsonld_type IN (...) をフルテーブルスキャンせずに済む。
  • owner_uid は nullable。匿名 POST (allowAnonymousPost: true) を許す設計に対応するため。null のときは PUT/DELETE できない、というルールを Workers 側で実装する。
  • created_at / updated_at は UNIX 秒の INTEGER。文字列より省サイズ、ソートもラクで、JS の new Date(n * 1000) で ISO 8601 化できる。

POST /api の処理本体 (src/index.ts:50-85):

app.post('/api', authMiddleware({ required: false }), async (c) => {
  const raw = await c.req.text();
  let parsed: unknown;
  try { parsed = JSON.parse(raw); }
  catch { return c.json({ error: 'Invalid JSON' }, 400); }

  const id = crypto.randomUUID();
  const origin = serverOrigin(c.req.url);
  const docUrl = `${origin}/api/${id}`;
  const types = rewriteTypes(c.env);
  const matched = detectTopLevelType(parsed as never, types);
  const stored = matched ? rewriteIds(parsed as never, types, docUrl) : parsed;

  const now = Math.floor(Date.now() / 1000);
  const uid = c.get('uid') ?? null;
  await c.env.DB.prepare(
    `INSERT INTO documents (id, json, owner_uid, content_type, jsonld_type, created_at, updated_at)
     VALUES (?, ?, ?, ?, ?, ?, ?)`,
  ).bind(id, JSON.stringify(stored), uid, c.req.header('Content-Type') ?? 'application/json',
         matched, now, now).run();

  c.header('Location', docUrl);
  return c.json(stored as Record<string, unknown>, 201);
});

PUT / DELETE 側は authMiddleware({ required: true }) + owner_uid === uid チェックで所有者制御。匿名 POST されたドキュメントは owner_uid IS NULL になり、誰も PUT/DELETE できない (= 編集不能) 状態になります。これも上流互換の挙動。

6. 設計判断 3: JSON-LD @id 書き換え

Viewer がエクスポート時に POST してくる Curation JSON-LD は、@typecr:Curation で、内部に複数の Range ノードを持つ構造です。アップストリーム JSONkeeper は、保存先 URL に対して トップレベルの @id だけでなく、内部の Range ノードにも fragment-id 形式の @id を振ります。これがないと Range が dereferencable な URL を持たず、Viewer 側の領域強調機能が壊れる。

src/curation.ts の実装:

export function rewriteIds(
  doc: JsonValue, rewriteTypes: string[], docUrl: string,
): JsonValue {
  if (!isObject(doc)) return doc;
  const cloned = deepClone(doc);
  let counter = 0;
  const walk = (node: JsonValue, isTop: boolean) => {
    if (Array.isArray(node)) { for (const item of node) walk(item, false); return; }
    if (!isObject(node)) return;
    const types = toArray(node['@type']).filter((t): t is string => typeof t === 'string');
    const matches = types.some((t) => rewriteTypes.includes(t));
    if (matches) {
      node['@id'] = isTop ? docUrl : `${docUrl}#frag-${counter++}`;
    }
    for (const key of Object.keys(node)) {
      if (key === '@type' || key === '@id') continue;
      walk(node[key], false);
    }
  };
  walk(cloned, true);
  return cloned;
}
  • deepClone で原本を破壊しないPOST のレスポンス本文には書き換え後の JSON を返すので、レスポンスを組み立てる際に困らないようコピーしてから操作する。
  • isTop フラグでトップレベルとネストを区別。トップレベルだけ docUrl 直値、ネストは <docUrl>#frag-0, <docUrl>#frag-1, ... と連番。fragment 化することで「同じドキュメント内の異なるノードを別 URL で参照できる」状態を作る。
  • @type が配列のケースも考慮。JSON-LD では @type が単一値・配列両方をとりうる仕様なので toArray で正規化。
  • @type@id 自体は再帰しない。理屈上 @id は文字列なので歩く必要がないが、念のため明示。

上流の Python 版 (jsonkeeper/subroutines.pyhandle_incoming_json_ld) は、ルート要素にのみ pyld.jsonld.expand を適用して @type を判定し、cr:Curation だった場合はハードコードで selections をループして各 Range の @id を path-based の <docUrl>/range<n> (n は 1 始まり) に書き換える、というやや専用化された構造です (subroutines.py:L226-L233)。Workers 版では pyld 相当を持ち込まずに、全ノードを @type 文字列で再帰的に判定 して rewriteTypes リストに該当するものを書き換える、というジェネリックな書き方にしました。

ネスト要素の @id フォーマットも、上流の path-based <docUrl>/range1 に対して Workers 版は fragment-based <docUrl>#frag-0 (0 始まり) に変えています。dereferencable 性 (ブラウザでフラグメント部分が無視される) を考えれば path-based のほうが上流互換性が高いのですが、Workers 側で /api/:id/range1 のような派生エンドポイントを別途実装する手間と、Viewer 側がこの派生 URL を実際には使わないことから、fragment 化に倒しています。

pyld を使わない trade-off:

  • ✓ 依存削減 (jose 以外の npm パッケージなし)
  • ✓ コードサイズ削減 (Workers の gzip 圧縮後 3MB (Free プラン) / 10MB (Paid プラン) のバンドル制限に対する余裕)
  • @context の解決失敗 (外部 @context URL が dereferencable でない問題) に左右されない
  • ✗ JSON-LD として厳密に正しい expand を行わない (compact 形式の埋め込み @type を文字列マッチで判定するだけ)

最後の trade-off は、Viewer が POST してくる JSON が常に compact 形式 + 既知の @type URI を使うため、実用上は問題なし。

7. 設計判断 4: Activity Stream は最小実装

アップストリーム JSONkeeper の Activity Stream は IIIF Change Discovery API 0.1 (conformance level 2) に準拠していて、ページネーション + Create / Update / Reference / Offer の Activity 区別など、結構な作り込みがあります。

Workers 版は 「単一の OrderedCollection ページに、Create/Update の Activity を flat に並べる」だけ に縮小しました。src/activitystreams.ts 全体:

export function buildCollection(serverUrl: string, rows: ActivityRow[]) {
  const collectionId = `${serverUrl}/as/collection.json`;
  const items = rows.map((r) => ({
    id: `${collectionId}#activity-${r.id}`,
    type: r.created_at === r.updated_at ? 'Create' : 'Update',
    endTime: new Date(r.updated_at * 1000).toISOString(),
    object: {
      id: `${serverUrl}/api/${r.id}`,
      type: r.jsonld_type ?? undefined,
    },
  }));
  return {
    '@context': 'https://www.w3.org/ns/activitystreams',
    id: collectionId,
    type: 'OrderedCollection',
    totalItems: items.length,
    orderedItems: items,
  };
}

そして src/index.ts の handler:

app.get('/as/collection.json', async (c) => {
  const types = rewriteTypes(c.env);
  const placeholders = types.map(() => '?').join(',') || "''";
  const { results } = await c.env.DB.prepare(
    `SELECT id, jsonld_type, created_at, updated_at
     FROM documents WHERE jsonld_type IN (${placeholders}) ORDER BY created_at DESC LIMIT 5000`,
  ).bind(...types).all<ActivityRow>();
  const collection = buildCollection(serverOrigin(c.req.url), results);
  return new Response(JSON.stringify(collection), {
    status: 200,
    headers: {
      'Content-Type': 'application/activity+json',
      'Access-Control-Allow-Origin': '*',
    },
  });
});

LIMIT 5000 は単純な上限で、ページネーションは未実装。Viewer はこの Collection を読みに来ないので Viewer 用途では問題なし。外部のクローラが change discovery する用途のためには別途ページネーション実装が要りますが、必要になったタイミングで追加するスタンスです (Postponed-feature)。

8. CORS と Viewer 互換ヘッダ

Hono の cors() middleware にカスタムヘッダを明示しておきます。

app.use(
  '*',
  cors({
    origin: '*',
    allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    allowHeaders: [
      'Content-Type',
      'Accept',
      'Authorization',
      'X-Firebase-ID-Token',
      'X-Access-Token',
      'X-Unlisted',
    ],
    exposeHeaders: ['Location'],
    maxAge: 86400,
  }),
);

ポイント:

  • exposeHeaders: ['Location']: Viewer がレスポンスから読みたい唯一のカスタムヘッダ。これを忘れると CORS 上は Location が見えず、Viewer の保存後リダイレクトが壊れる。
  • allowHeaders に Viewer プラグインが送る全カスタムヘッダ: X-Firebase-ID-Token (認可)、X-Access-Token (匿名トークン)、X-Unlisted (公開設定)。アップストリームと揃えて配置。
  • maxAge: 86400: プリフライト結果のブラウザキャッシュ 24h。エッジ越しでも preflight が爆発しないようにする。

9. デプロイ runbook

ローカル環境とブラウザでの作業を分けて書きます。

9-1. 一度だけブラウザ:

  1. Cloudflare アカウント作成 (まだなら)
  2. ローカルで npx wrangler login → ブラウザが立ち上がる → 認可 → 完了

9-2. ローカル端末から:

# 1. clone & 依存インストール
git clone https://github.com/nakamura196/jsonkeeper-workers.git
cd jsonkeeper-workers
npm install

# 2. D1 データベース作成 → 返ってきた database_id を控える
npx wrangler d1 create jsonkeeper
# Created database 'jsonkeeper' with id 9270a2b6-8420-...

返ってきた database_idwrangler.tomldatabase_id = "..." に書き込みます (ここはエディタで手作業)。

# 3. マイグレーション適用 (リモート D1 へ)
npx wrangler d1 migrations apply jsonkeeper --remote

# 4. Firebase project ID を vars に書く
#    wrangler.toml の [vars] FIREBASE_PROJECT_ID = "<firebase-pid>" が正しいか確認

# 5. (任意) ローカル開発
npx wrangler dev
# → http://127.0.0.1:8787 で起動

# 6. デプロイ
npx wrangler deploy
# → Uploaded jsonkeeper (X.YZ sec)
# → Published jsonkeeper
# →   https://jsonkeeper.<cf-subdomain>.workers.dev

これで https://jsonkeeper.<cf-subdomain>.workers.dev/ でアクセス可能になります。

Firebase 側では Authentication → Settings → Authorized domains に、Viewer をホストする GitHub Pages のドメイン を追加する必要があります (これは Workers 側ではなく Viewer 側のログインを成立させるための設定なので、本記事のスコープではないが忘れずに)。

10. スモークテスト

test/smoke.sh を BASE 環境変数付きで叩きます:

$ BASE=https://jsonkeeper.<cf-subdomain>.workers.dev ./test/smoke.sh
1/4 root
{"name":"jsonkeeper-workers","endpoints":["POST /api","GET /api/:id","PUT /api/:id","DELETE /api/:id","GET /api/userdocs","GET /as/collection.json"]}

2/4 POST anonymous
  Location: https://jsonkeeper.<cf-subdomain>.workers.dev/api/c3881e3f-...

3/4 GET back
  Body: {"hello":"workers"}

4/4 POST Curation (JSON-LD @id rewrite)
  Body: {"@type":"http://codh.rois.ac.jp/iiif/curation/1#Curation","@id":"https://jsonkeeper.<cf-subdomain>.workers.dev/api/...","label":"x"}

OK

最後のケース 4/4 がポイントで、入力に "@id":"about:blank" を送ったのに対し、レスポンスでは "@id":"<Location ヘッダの URL>" に書き換わっていることを確認しています。Viewer がエクスポートで送ってくる Curation JSON は典型的にこの形 (まだ保存先 URL が決まっていないので @id が暫定値) なので、ここが通れば Viewer の保存フロー全体が動きます。

11. アップストリーム JSONkeeper との差分一覧

実装した範囲・していない範囲を一気に整理:

機能アップストリームWorkers 版差分メモ
POST /api 新規保存機能等価
GET /api/:id機能等価
PUT /api/:id (上書き)機能等価。owner_uid 一致のみ許可
DELETE /api/:id機能等価
GET /api/userdocs✓ (userdocs_added_properties で属性追加可能)✓ (追加属性は固定: jsonld_type/created_at/updated_at)簡略化
X-Firebase-ID-Token 認可✓ (firebase_admin SDK)✓ (jose + Google x509)サーバ秘匿の service account 鍵不要
X-Access-Token (自前トークン)Viewer は使わないので未実装。将来必要なら 30 行で追加可能
匿名 POST機能等価
JSON-LD @id 書き換え (top)機能等価
JSON-LD @id 書き換え (nested)✓ (Curation 限定のハードコード selections ループで /range<n> を生成)✓ (汎用の @type 再帰判定で #frag-<n> を生成)URL 形式が異なる (path vs fragment)
X-Unlisted: true POST のみ (PUT 時は既存値引き継ぎ、変更は /<id>/status PATCH 経由)△ (DB カラムだけ)ロジック未実装
/<id>/status PATCHViewer 不使用
Activity Stream✓ (ページネーション)△ (単一ページ)簡略化
Garbage collection✓ (apscheduler 経由)Workers + D1 では cron trigger で別途実装可能、未実装
Range サブ URL (/<id>/range<n>、n は 1 始まり)✗ (Workers は fragment-based #frag-<n> で代替)Viewer 不使用
CORS✓ (* + 反射)✓ (* + 明示 allowHeaders)機能等価
Location ヘッダ返却機能等価
永続性SQLite ファイルD1 (replicated)Workers 版のほうが運用ロバスト

12. ローカル開発と本番の切り替え

.dev.vars.example.dev.vars にコピーして wrangler dev を起動すると、[vars] の代わりに .dev.vars の値が使われます。D1 はローカル SQLite ファイル (.wrangler/state/v3/d1/...) に保存されるので、本番の D1 に影響を与えません。

cp .dev.vars.example .dev.vars
npx wrangler d1 migrations apply jsonkeeper --local
npx wrangler dev
# 別ターミナル
BASE=http://127.0.0.1:8787 ./test/smoke.sh

.gitignore には .dev.vars.wrangler/node_modules/ を含めてあります。

13. 撤収手順 (オリジナル URL への切り戻し)

# Worker 削除
npx wrangler delete

# D1 データベース削除 (データを残す必要があれば、先に export)
npx wrangler d1 export jsonkeeper --output backup.sql
npx wrangler d1 delete jsonkeeper

Viewer 側の curationJsonExportUrlmp.ex.nii.ac.jp/api/curation/json に戻す部分は、PA 版の撤収手順と同じ sed 一発です (別記事参照)。

14. なぜ PA 版と Workers 版の二系統にしているか

  • Workers + D1 を本流にしているのは、無料枠の余裕とレイテンシ、運用負荷の低さ。Viewer から見える挙動は機能十分。
  • PA 版を塩漬けで残しているのは、上流互換 (X-Unlisted / Activity Stream のページネーション / GC / Range サブ URL) を必要とする第三者ツールが将来登場する可能性、および「アップストリームの挙動を再現したリファレンス実装」としての参考用途。

「アーカイブ用途は PA 側、運用用途は Workers 側」と分けることで、オリジナル切り戻し時にもどちらか一方を撤収すれば自然に整理できる。

おわりに

JSONkeeper を Cloudflare Workers + D1 で書き直す、というのは結果として 「機能のうち Viewer が使う部分だけを切り出して 360 行で再実装する」 作業でした。書き直しを通じて見えた点:

  • Firebase Admin SDK は本質的には JWT 検証ルーチンjose + Google x509 公開鍵を fetch するだけで等価な検証ができる。サービスアカウント鍵運用を回避できるのは副次的に大きい。
  • アップストリームの JSON-LD @id 書き換えは pyld を呼んでいるが、Viewer が使う範囲は @type 文字列マッチで十分。compact 形式の埋め込み型しか飛んでこないなら expand は要らない。
  • D1 は SQLAlchemy + SQLite の置換として素直。マイグレーションファイル形式と wrangler d1 migrations apply の運用はそのまま使える。
  • Workers の無料枠 (100k requests/日 + D1 5GB) は IIIF Curation 用途では「事実上無制限」。codh-mirror の暫定運用のスケール感を完全にカバーする。

オリジナル URL が利用可能になった段階での予定は、PA 版と Workers 版どちらも撤収し、Viewer の curationJsonExportUrlmp.ex.nii.ac.jp に戻すこと。それまでは本記事の Workers 版を本流として動かしておきます。

同じく「IIIF Curation Viewer の保存先を自前で持ちたい」という方の参考になれば幸いです。