「JavaScriptを使っていません」と書いてあるWebサービスは珍しくありませんが、その多くは書いていないだけで、ブラウザ側にスクリプトを禁止させているわけではありません。textlog(stagas/textlog)は、そこを一歩踏み込んでいます。本番サイト textlog.cc のレスポンスヘッダには script-src 'none' が入っており、仮に何らかの経路でスクリプトタグが紛れ込んでも、ブラウザがその実行を拒否します。280文字の投稿・フォロー・ハッシュタグ・返信・テーマ切替まで備えた実運用のSNSが、AGPL-3.0の全ソース公開で、この制約の下に組み立てられています。Hacker Newsの Show HN で199ポイントを集めた本プロジェクトを、実測とソースコードの両面から検証しました。

textlog.ccの8ルートに対してcurlでCSPヘッダを取得し、全ルートでscript-src 'none'が返り、HTML内のscriptタグが0個であることを示したターミナル出力
本記事で実行した検証の実出力。8ルートすべてが script-src 'none' を返し、HTML中の <script 出現数は全ルート0だった(2026-08-10 実測)
この記事のポイント
結局何ができる:280文字の投稿・返信・フォロー・ハッシュタグ購読・RSS/Atom配信・読み書き両対応のAPIを持つSNSが、クライアントJS 0バイトで動く。
何を解決する:テーマ切替・CSRF対策・ページング・キャッシュ制御を「JSに逃がさずに」解く実装が、16,000行・228テストの読める規模で全部揃っている。
何を代替できる:SPAの定石をそのまま持ち込むと重くなる読み物系サービスの設計指針。TwitterやMastodonの機能面の代替ではない。
30秒でわかる textlog(2026年8月10日時点の実測)
  • 正体:stagas氏(コミット176本)中心のAGPL-3.0 OSS。TypeScript・Bun + Hono + SQLite、React 19はサーバー側レンダリング専用で使う。
  • 規模:star 58・fork 6・コミット189本・作成2026-08-06。bun test228 pass / 0 fail(37ファイル・1,026 expect・10.01秒)。
  • 依存の薄さ:直接依存6個、node_modules 実測47パッケージ・69MB、インストール1.4秒。
  • 転送量:トップページHTML 24,202B + CSS 34,770B(1年immutable)+テーマCSS 1,076B。JSは0バイト。
  • 注意:モデレーションはOpenAI APIに依存しfail-closed。キーもバイパスフラグも無いセルフホストでは投稿自体が通らない(後述の実測を参照)。

依存パッケージを47個に抑える設計は、そのまま取り込むサードパーティコードの面積を減らすことでもあります。依存経由の攻撃全般の考え方はサプライチェーンセキュリティ2026|攻撃手法・防御ツール・実践チェックリストにまとめています。

textlogとは——「JSを使わない」と「JSを実行させない」の違い

textlogは、280文字のノートを投稿し、人とハッシュタグをフォローし、返信で会話するだけのSNSです。いいねもリポストもフォロワー数の表示もありません。2026年8月6日にリポジトリが作られ、Hacker Newsの「Show HN: textlog – A quiet, text-only microblogging platform, open-source, no JS」で199ポイント・75コメントを集めました。

技術的に見るべきなのは、その「no JS」の担保の仕方です。多くのサイトは「バンドルを配信していない」という運用上の事実にとどまりますが、textlogは src/http.tssecurityHeaders() でCSPを組み立て、src/app.tsx の124行目で全レスポンスに適用しています。

default-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none';
object-src 'none'; img-src 'self' data:; style-src 'self'; script-src 'none'; connect-src 'self'

script-src 'none' は、外部スクリプトもインラインスクリプトも eval も、ブラウザに一律で拒否させる指定です。仮にXSSでスクリプトタグを注入できたとしても、ブラウザがそれを実行しません。攻撃面を「入力を全部エスケープできているか」という運用の正しさに賭けるのではなく、実行エンジン側で閉じているのが、この設計の要点です。

実際に8ルートを叩いて確認しました。読者が自分で再現できるコマンドを示します。

# textlog.cc の各ルートで CSP と <script> 出現数を数える
for p in "" hot about api enter explore post/1 tag/textlog; do
  hdr=$(curl -sS -D - -o /tmp/pg.html "https://textlog.cc/$p")
  printf "%-12s %s script=%s\n" "/$p" \
    "$(echo "$hdr" | grep -io "script-src [^;]*")" \
    "$(grep -c "<script" /tmp/pg.html)"
done

結果は8ルートすべてが script-src 'none' で、<script の出現数は全ルート0でした。インラインの onclick / onsubmit などのイベントハンドラ属性も0件です。/enter(ログイン)と /explore にはHTMLの <form> が存在し、操作はすべてフォーム送信とリダイレクトで完結しています。

開発時だけは例外で、DEV_RELOAD=true のとき CSP は script-src 'self' 'unsafe-inline' に緩和されます。ホットリロードのためで、本番の bun run start 経路では適用されません。

補助的なヘッダも一通り揃っています。X-Frame-Options: DENYframe-ancestors 'none'(埋め込み専用ルートのみ frame-ancestors *)、Referrer-Policy: same-originX-Content-Type-Options: nosniffPermissions-Policy でカメラ・マイク・位置情報・決済・USBを全部空にする指定、HTTPSのときだけ付く Strict-Transport-Security: max-age=31536000; includeSubDomainsbase-uri 'none'form-action 'self' は、base要素の差し替えやフォームの外部送信によるデータ持ち出しを塞ぐ指定で、CSPを書くときに抜けがちな2つです。

flowchart LR A["ブラウザ"] -->|"GET /hot"| B["Caddy"] B --> C["Bun + Hono"] C --> D["React renderToString
サーバー側のみ"] C --> E["bun:sqlite"] D --> F["HTML 24,202B"] F -->|"CSP: script-src 'none'"| A A --> G["JS実行なし
操作はform + redirect"]

JavaScriptなしでテーマを切り替える——Cookieからサーバーが :root{} を出し分ける

ダークモード実装は、no-JS設計で最初に壁になる部分です。通常はlocalStorageに設定を持ち、ページ読み込み時にJSでクラスを付け替えますが、この方式は初回描画で一瞬だけ違う配色が見える、いわゆるFOUCを起こしやすいことでも知られています。

textlogは src/theme.ts で、これをサーバー側に寄せて解決しています。テーマは appearance Cookieに テーマ名.アクセント名 の形式で入っており、リクエストのたびにサーバーがCSSカスタムプロパティ群を文字列として組み立て、/theme.css として返します。

テーマは system / light / dark / sepia / dracula の5種、アクセントカラーは theme / sage / purple / cyan / pink / amber / blue / rust の8種で、組み合わせは40通りです。system を選んだときだけ、ライトの定義と @media(prefers-color-scheme:dark) で包んだダークの定義を両方吐き出し、OS設定に追従させます。

実測すると挙動がはっきりします。

curl -sS https://textlog.cc/theme.css | head -c 120
# :root{color-scheme:light;--bg:#f4f3ee;--ink:#20231f; ...  (1,076バイト)

curl -sS -H 'Cookie: appearance=dracula.cyan' https://textlog.cc/theme.css | head -c 120
# :root{color-scheme:dark;--bg:#282a36;--ink:#f8f8f2;--accent:#8be9fd; ...  (521バイト)

Cookieを付けると内容が変わり、サイズも1,076B→521Bに減ります。system は2テーマ分を出力するのに対し、dracula は1テーマ分で済むためです。CSSはHTMLの <link> で読み込まれるため、最初の描画時点で最終的な配色が確定しており、切り替えのちらつきが構造的に発生しません

配信の分け方も理にかなっています。共通スタイルの /styles.css は34,770Bで cache-control: public, max-age=31536000, immutable(1年キャッシュ・バージョン付きURL)、ユーザーごとに変わる /theme.css は1KB前後で private, no-store。キャッシュできる大きい方とできない小さい方を、別ファイルに分離しています。

appearance Cookieの値をサーバーが読み、パレット定義からCSSカスタムプロパティを組み立ててtheme.cssとして返すまでの流れを示した図
テーマ切替のパイプライン。JSではなくCookie値とサーバー側のパレット定義で :root{} を出し分ける

細かいところでは、アクセント色を選んだときのボタン背景色を mix() という自前関数で計算しています。16進数を2桁ずつ切り出して線形補間するだけの実装で、CSSの color-mix() に頼らずサーバー側で確定させる方針です。ファビコンも同じ考え方で、themeLogoSvg() がアクセント色を埋め込んだSVGをその場で生成し、/favicon-theme.svg?v=system.theme として配信します。ファビコンの色までテーマに追従するわけです。

もう1つ、SSR専用React特有の実装として AsyncLocalStorage の使い方があります。クライアントで動くReactならContext経由でテーマを配れますが、サーバー側では複数リクエストが同時に走るため、モジュールスコープの変数に入れると混線します。textlogは appearanceContext.run() でリクエストごとのスコープを作り、activeAppearance() でコンポーネントから読み出しています。React ContextのSSR版を、Node/Bunの標準APIで代替している形です。

トークンを配らないCSRF対策——Origin/Referer検証とSameSite=Laxの分担

フォーム送信だけでアプリを組み立てると、CSRF(クロスサイトリクエストフォージェリ)対策が正面から問題になります。定石は隠しフィールドにCSRFトークンを埋め込む方式ですが、textlogはトークンを一切発行していません。

代わりに使っているのが src/http.ts の2つの関数です。

export function requiresSameOrigin(method: string, path: string) {
  return method !== 'GET' && method !== 'HEAD' && !path.startsWith('/api/')
}

export function isSameOriginRequest(request: Request, appUrl = Bun.env.APP_URL) {
  const expectedOrigin = new URL(appUrl || request.url).origin
  const origin = request.headers.get('origin')
  if (origin) return new URL(origin).origin === expectedOrigin
  const referer = request.headers.get('referer')
  return Boolean(referer && new URL(referer).origin === expectedOrigin)
}

更新系のリクエストに対して Origin ヘッダを見て、無ければ Referer にフォールバックし、どちらも無ければ拒否します。これが src/app.tsx の151行目でグローバルミドルウェアとして効いています。加えてセッションCookieは HttpOnly; SameSite=Lax; Secure(HTTPS時)で、そもそもクロスサイトのPOSTにCookieが付きません。SameSite=Laxが第一の防壁、Origin/Referer検証が第二の防壁という二重構成です。

興味深いのは /api/ を意図的に除外している点で、その理由がコード中のコメントに明記されています。

ソース中のコメント(src/http.ts)の要旨
ブラウザのフォームは同一オリジン検査をかける。APIは対象外にしてある。理由は、APIがBearerトークンで認証しており、ブラウザはクロスサイトのリクエストにそのトークンを自動付与できないため。加えてネイティブクライアントは OriginReferer も送ってこない。

CSRFが成立するのは「ブラウザが認証情報を勝手に付けてしまう」からであって、Bearerトークンのように明示的に付ける認証では成立しません。逆にAPIへ同一オリジン検査をかけると、ヘッダを送らないネイティブクライアントを閉め出してしまいます。ここは脅威モデルを理解した上での意図的な非対称で、単なる例外処理ではありません。

script-src none・SameSite=Lax・Origin/Referer検証・ボディ上限とレート制限の4層でCSRFとXSSに対処する構成を示した階層図
CSRFトークンを使わずに組み立てられた多層防御。APIだけがOrigin検証の対象外になる

入力サイズの上限もヘッダの段階で切っています。全体で64KB、通常フォームは8KB、違法報告フォームだけ16KB。content-length を先に見て超過なら413を返し、実際のボディ長も再確認する二段構えです。加えてレート制限が2層あり、リクエスト全体は10秒あたり50回を超えると5分間ブロック、認証系は用途別に細かく設定されています(ログイン10回/15分、サインアップ5回/60分、パスワード再設定の要求は3回/60分など)。IPは IP_PSEUDONYM_SECRET で日次ローテーションする鍵付きハッシュに変換して保存しており、生IPを溜め込まない方針です。

いいねが無いのに「hot」がある——textlogのランキング式を読み解く

textlogは「エンゲージメントの仕掛けは無い」と説明していますが、/hot という人気順フィードは存在します。ここは矛盾に見えるところなので、src/hot.ts の実装を実際に読みました。

まず前提として、いいね・ふぁぼに相当するテーブルもエンドポイントも存在しませんsrc/routes/interactions.tsx にあるPOSTルートはフォロー・ブロック・タグフォロー・タグブロック・通報だけです。したがってスコアの材料は「返信」しかありません。

スコアは2段階の指数減衰で決まります。定数は次の通りです。

const activityHalfLifeHours = 6        // 会話の熱量の半減期
const recencyHalfLifeHours = 8         // 経過時間による半減期
const directReplyWeight = 4            // 直接返信の重み
const repliesPerHalfLifeDoubling = 5   // 返信5件ごとに半減期を2倍
const maxReplyDecayBoost = 20          // 半減期の延長は返信20件で頭打ち

第1段階は、投稿本体に重み1、他人からの返信に重み 4 × 0.5^(深さ-1) を与え、直近の活動時刻からの経過時間で半減期6時間の減衰をかけて合計します。深い階層の返信ほど軽くなる設計です。

第2段階が独特で、第1段階のスコアに対して経過時間の減衰をかける際、返信数に応じて半減期そのものを引き伸ばします。式にすると半減期は 8 × 2^(min(返信数, 20) / 5) 時間。返信5件で16時間、10件で32時間、20件で128時間まで伸び、そこで頭打ちになります。会話が続いている投稿ほど、上位に長く留まる仕組みです。

返信数0件・5件・10件・20件のときにhotスコアの半減期が8時間から128時間まで伸びることを示した減衰カーブの図
返信数によって半減期が変わる。0件なら8時間、20件で128時間(上限)。伸ばすのは「会話の量」だけで、閲覧数やいいねは式に入らない

不正対策も式の中に埋め込まれています。自分の投稿への自己返信はSQLの WHERE descendants.user_id != candidate.user_id で除外され、同一ユーザーからの複数返信は row_number() によるランク付けで最初の1件だけが採用されます(reply_rank = 1)。自分で連投してもスコアは上がらず、他人が1人で連投しても1件分にしかなりません。

このあたりの調整履歴が、マイグレーション名にそのまま残っているのが面白いところです。bun test の出力に流れる28本のマイグレーションのうち、後半はほぼ全部がランキング調整でした。

・v20 direct_reply_hot_scores — 返信ベースのスコアを導入
・v21 exclude_self_replies_from_hot_scores — 自己返信を除外
・v22 increase_direct_reply_hot_weight — 直接返信の重みを引き上げ
・v23 include_decaying_nested_replies_in_hot_scores — ネストした返信を減衰付きで算入
・v27 exponential_reply_hot_decay — 半減期の指数的な引き伸ばしへ
・v28 deduplicate_hot_replies_by_user — 同一ユーザーの返信を重複排除

「エンゲージメントの仕掛けは無い」という説明は、正確には「閲覧数・いいね・フォロワー数をランキングに使わない」という意味であり、ランキング自体は存在します。ただしその入力が会話の量に限定されていて、しかも式が全部読めるという点は、通常のSNSとは違う性質です。

ページングも、JSが無い前提で作られています。無限スクロールが使えないため、base64urlでエンコードしたキーセットカーソルをURLに埋め込む方式です。カーソルにはバージョン番号・基準時刻・スコア・最終活動時刻・作成時刻・ID・方向が入っており、デコード時に型と範囲を全項目検証して、壊れていれば null を返します。スコアが時間とともに変化するランキングでも、基準時刻をカーソルに含めることでページ間の一貫性を保つ作りです。

サーバーサイドレンダリングで最も事故が起きやすいのが、キャッシュの設定です。ログイン後の個人向けページを共有キャッシュに載せてしまい、他人に配信される事故は繰り返し起きています。

textlogは htmlCacheControl() で、この判断をホワイトリスト方式にしています。公開キャッシュを許すのは以下をすべて満たす場合だけです。

・GETまたはHEADで、ステータスが200であること
・リクエストに cookieauthorization も無く、レスポンスに set-cookie も無いこと
・パスが公開ページの明示リスト(//hot/latest/explore/about/api など)か、/u/ハンドル/post/数字/tag/タグ/embed/ のパターンに一致すること
replyreporteditwelcomeresettoken といった一時的なクエリパラメータが付いていないこと

条件を1つでも外れたら private, no-store です。そして公開キャッシュを許す場合には vary: Cookie を必ず追加します。実際、トップページのレスポンスには cache-control: public, max-age=30, stale-while-revalidate=120vary: Cookie の両方が入っていました。

重要なのは「Cookieが付いていたら公開キャッシュしない」をデフォルト拒否で書いている点です。許可リストに載っているパスだけが公開対象になるため、新しいルートを足したときに自動で公開キャッシュ対象になることがありません。

max-age=30 という短さも意図的でしょう。フィードは30秒で陳腐化する一方、stale-while-revalidate=120 により、期限切れ後も2分間は古い内容を返しつつ裏で更新できます。小さなサーバーで突発的なトラフィックを受けるときに効く設定です。実際、検証中に何度かリクエストを送った際、サイトが502を返す瞬間がありました。単一の小規模サーバーで運用されており、デプロイのたびに短い断が起きる構成と見られます。

静かなSNSが投稿のたびにOpenAIを呼ぶ——モデレーションのfail-closed設計

ここまでは「外部依存を削ぎ落とした設計」の話でしたが、1か所だけ外部APIへの依存があります。src/moderation.ts が、OpenAIの omni-moderation-latest を呼んでいます。

呼ばれる箇所を数えると、投稿の作成・編集・返信、プロフィールのbioとハンドル、サインアップ時のハンドル——つまりユーザーがテキストを書き込むほぼ全経路です。8秒のタイムアウト付きで、flagged が返れば拒否します。

問題は、失敗したときの挙動です。ソースを読むと、APIキーが無い場合は { ok: false, reason: 'unavailable' } を返します。つまりモデレーションが動かないときは投稿を通さない、fail-closedです。実際に4パターンで確かめました。

# textlogのソースツリーで moderateText を直接呼ぶ
env -u OPENAI_API_KEY -u MODERATION_DISABLED bun modtest.ts
#   -> {"ok":false,"reason":"unavailable"}      キーもフラグも無い → 拒否

env -u OPENAI_API_KEY MODERATION_DISABLED=true bun modtest.ts
#   -> {"ok":true}                              明示的に無効化 → 素通し

OPENAI_API_KEY="" MODERATION_DISABLED=false bun modtest.ts
#   -> {"ok":false,"reason":"unavailable"}      空文字キー → 拒否

OPENAI_API_KEY="sk-invalid-xxxxxxxx" bun modtest.ts
#   -> {"ok":false,"reason":"unavailable"}      不正キーで401 → 拒否

正確に言うと、MODERATION_DISABLED を明示的に有効にしない限りfail-closedです。この挙動は意図されたもので、.env.example にも「MODERATION_DISABLED=true が意図的かつ一時的な本番判断である場合を除き、OPENAI_API_KEY は必須」と書かれています。プライバシーポリシーにあたる src/components/legal.tsx でも、OpenAIがテキストを処理する旨が開示されています。

MODERATION_DISABLEDが有効なら素通し、無効かつAPIキーが無い/不正なら投稿拒否、正常時はflaggedの有無で分岐する状態遷移図
モデレーションの分岐。実測した4パターンに対応する。OpenAI側が429やタイムアウトを返した場合も投稿は通らない

設計判断として筋は通っています。個人運営のSNSで違法コンテンツが野放しになるリスクを考えれば、「検査できないなら受け付けない」は妥当な選択です。一方で運用上は、OpenAI APIの障害・レート制限・キーの失効が、そのまま全投稿の停止に直結することを意味します。セルフホストする場合は、この依存を許容するか MODERATION_DISABLED=true で外して自前の対策を用意するかを、最初に決める必要があります。JSを排して依存を削ぎ落としたサービスが、書き込み経路の可用性については外部APIに全面的に預けている——という構図は、記事にする価値のある対比だと思います。

JSを捨てて何を失うか——機能別の実装対照表と、自分で建てて確かめる手順

no-JS設計は「何もしない」ことではなく、JSで解いていた問題を別の場所で解き直すことです。textlogのソースから読み取れる対応関係を整理しました。

機能 JS前提の一般的な実装 textlogの実装 失うもの
テーマ切替 localStorage+起動時にクラス付け替え Cookie値からサーバーが :root{} を生成 即時プレビュー(1往復必要)
文字数カウンタ inputイベントで残数を表示 <textarea maxLength={280} required> のみ リアルタイムの残数表示
入力の復元 クライアント状態で保持 エラー時にサーバーが defaultValue に再描画 なし(同等)
フォロー操作 fetchして部分更新 POST → Referer を検証して元のページへリダイレクト 画面遷移なしの更新
ページング 無限スクロール base64urlキーセットカーソルをURLに埋め込み 連続スクロール
フィード切替 クライアント状態 feed Cookie(following/hot/latest) 即時切替
CSRF対策 トークンを隠しフィールドに SameSite=Lax + Origin/Referer検証 なし(同等以上)
XSS対策 エスケープの徹底 エスケープ+script-src 'none' で実行を禁止 なし(多層化)

文字数カウンタの扱いが、この設計の性格をよく表しています。src/components/compose.tsx の実体は次の1行です。

<textarea name="body" maxLength={280} required autoFocus defaultValue={body} />

maxLength はブラウザ標準の属性なので、281文字目は入力できません。残り何文字かは表示されない代わりに、「280 characters max」という固定テキストが添えられているだけです。そして defaultValue={body} により、モデレーション拒否などでエラーになったとき、サーバーが書きかけの本文をそのまま textarea に戻します。クライアント状態を持たずに入力を失わせない、no-JSの定番テクニックです。

動的な操作はページの外——OpenAPI付きのREST API

ページからJSを追い出した分、プログラムから触る口はHTTP APIとして正面に用意されています。/api/openapi.json にOpenAPI 3.1.0の仕様が置かれており、実測で21パス・26オペレーション、認証方式は bearerAuth です。

読み取り側は /api/v1/feeds/latest/api/v1/feeds/hot/api/v1/search/api/v1/posts/:id/api/v1/users/:handle/posts/api/v1/tags/:tag/posts/api/v1/firehose など。書き込み側も /api/v1/posts(投稿)、/api/v1/users/:handle/follow(フォロー)、/api/v1/posts/:id/report(通報)まで揃っており、フォームでできることはAPIでもできます。認証は /api/v1/auth/request/api/v1/auth/verify の2段階で、メール経由のマジックリンクからBearerトークンを受け取る流れです。

ここで前述のCSRF設計が効いてきます。ブラウザのフォームは同一オリジン検査で守り、APIはBearerトークンで守る。認証方式が違うので防御方式も分けるという切り分けが、requiresSameOrigin() の1行に集約されているわけです。なお robots.txt/api/ 以下をクロール対象から外しており、公開ページだけがインデックス対象になっています。RSSとAtom(/hot.rss/hot.atom)も配信されているため、購読用途ならAPIキーすら要りません。

自分で建てて確かめる

自分の環境で確かめる手順は次の通りです。BunとSQLiteだけで動くため、DBサーバーの用意は要りません。

git clone https://github.com/stagas/textlog.git && cd textlog
bun install                      # 実測: 47パッケージ / 69MB / 1.4秒
MODERATION_DISABLED=true bun test # 実測: 228 pass / 0 fail / 10.01秒
MODERATION_DISABLED=true bun run dev
# http://localhost:3000 を開く。開発時は DEV_RELOAD により CSP が緩和される点に注意

運用まわりも、個人プロジェクトとしては踏み込んだ作りです。src/backup-automation.ts は1時間ごとに日次バックアップの要否を確認し、保持期間を過ぎたものを削除します。さらに四半期ごとにリストア訓練を自動実行する仕組みが入っており、バックアップを一時ディレクトリへ実際に復元して整合性を検証し、所要時間つきのJSONレポートを drills/ に書き出します。失敗時は BACKUP_ALERT_WEBHOOK_URL へ通知する経路まで用意されています。「バックアップは取っているが戻せるか試したことがない」という定番の落とし穴を、コード側で潰しにいく構成です。

検索はSQLiteのFTS5を使っています。マイグレーションv26 post_full_text_searchcontent='posts' の外部コンテンツ方式で仮想テーブルを作り、投稿の挿入・削除・更新に連動するトリガーでインデックスを同期させる形です。全文検索のために別ミドルウェアを立てずに済ませています。データエクスポートも src/data-export.ts に実装されており、投稿・ハッシュタグ・メンション・フォロー・フォロワー・購読タグをまとめてJSONで書き出せます。

テストを走らせると28本のマイグレーションが順に流れ、そのまま設計の変遷が読めます。bun test が10秒で完走する規模のコードベースで、CSP・CSRF・キャッシュ制御・ランキングの実装が一通り揃っているのは、教材としてかなり効率が良い部類です。依存の少なさそのものを点検したい場合は、npm v12でpostinstallがデフォルト無効化へ|自システム確認コマンドと段階的な移行手順SBOM入門|ソフトウェア部品表でサプライチェーン攻撃を防ぐSPDX・CycloneDX・Syft活用法も合わせて参照してください。

なお制約もはっきりしています。280文字という上限についてはHacker Newsのスレッドで複数の指摘があり、作者は500文字への引き上げを検討していると回答しています。またAGPL-3.0のため、改変版をネットワーク越しにサービス提供する場合はソース公開義務が生じます。自社プロダクトへ実装を流用する前に、LICENSE原文の確認が必要です。

まとめ

textlogは、SNSとしての機能の豊富さで勝負するプロジェクトではありません。技術的に見る価値があるのは、「JavaScriptを実行させない」という制約を先に置いたとき、テーマ切替・CSRF対策・ページング・キャッシュ制御・ランキングがそれぞれどう解き直されるか、その全部が16,000行・228テストの読める規模で揃っている点です。

特に、CSPの script-src 'none' によるXSSの多層防御、Cookieベースのテーマ生成によるFOUCの構造的な排除、SameSite=Lax とOrigin/Referer検証によるトークンレスCSRF対策、そしてデフォルト拒否で書かれたキャッシュ制御は、no-JSのサービスを作る予定が無くても、通常のSSRアプリにそのまま持ち込める設計です。

一方で、書き込み経路の可用性がOpenAIのモデレーションAPIに全面的に依存している点は、セルフホストを検討するなら最初に判断すべき箇所です。依存を削ぎ落とした設計と、1か所に集中した外部依存。この2つが同居しているところまで含めて、読む価値のあるコードベースだと思います。

参照ソース