Workers that can be deployed on Workers

Full-stack blog running on Cloudflare Workers. D1 stores posts and pages, while KV stores administrator sessions and site configuration.

Deploy to Cloudflare

One-click deployment (Deploy to Cloudflare)

Click the button above to deploy a copy to your own Cloudflare account:

  1. Log in to Cloudflare. The button will guide you to select an account and set a project name.
  2. Enter the administrator account (ADMIN_USER / ADMIN_PASS) — the only required fields.
  3. Cloudflare automatically completes the following: clone the repository → create and bind the D1 database, KV namespace, and R2 bucket → run database migrations → build and deploy.
  4. After deployment, visit the assigned *.workers.dev domain and go to /admin/login to access the admin panel.

No code or configuration changes are required; you only need administrator credentials. Optional features (AI summaries and Giscus comments) are disabled by default — if you do not need them, leave the current settings unchanged; enable them after deployment when needed:

# AI article summaries (OpenAI protocol compatible, such as DeepSeek)
wrangler secret put OPENAI_API_KEY

# Giscus comments (GitHub Discussions)
wrangler secret put GISCUS_REPO_ID
wrangler secret put GISCUS_CATEGORY
wrangler secret put GISCUS_CATEGORY_ID

Note: The Deploy to Cloudflare configuration page only asks for the required fields declared in .dev.vars.example (the administrator account). Optional variables are not declared in the configuration, so you will not be asked to enter them and do not need to modify anything.

Features

  • Public frontend: post list, site search, post details, right-side section navigation, reading progress and reading time, copy post links, RSS subscriptions, and Giscus comments
  • Admin panel: create, edit, delete, and publish posts and pages
  • Custom post paths; when left blank during creation, a pinyin path is generated from the title automatically, with numbers appended to duplicate paths
  • Administrator login (single account, env secret)
  • No public user system; comment identity verification is provided by GitHub/Giscus
  • AI summaries: content wrapped in [ai-summary]...[/ai-summary] in an article is sent once to an OpenAI protocol API when the post is saved; the original content is rendered above the AI summary box
  • Year-round article activity wall: records publication and actual edits of public posts; click a date to view the specific changes
  • Public access reports: display traffic trends, popular pages, referrer domains, and device types without storing IP addresses or visitor identifiers
  • Times are displayed uniformly as UTC+8 (Asia/Shanghai), while the database continues to store UTC
  • Secure rendering: Markdown is sanitized with DOMPurify, drafts are visible only in the admin panel, and administrator login attempts are rate-limited
  • Health check: /healthz
  • Search and indexing: /search searches titles and body text; /robots.txt and /sitemap.xml are generated automatically to help search engines discover public content
  • Tag browsing: /tags displays tags and post counts for published posts; click a tag to filter posts precisely by tag. Draft tags are not made public

Security notes

  • Do not commit real secrets to the repository. Configure ADMIN_PASS, OPENAI_API_KEY, and other secrets with wrangler secret put or in the local .dev.vars file (ignored by Git). Keep only public configuration in wrangler.jsonc.
  • If a secret has ever appeared in Git history (even if the file was later deleted), revoke it and rotate it immediately on the relevant platform. Consider cleaning the history with git filter-repo and force-pushing afterward (this changes all commit hashes and requires collaborators to clone the repository again).
  • The admin panel enables __Host--prefixed cookies for administrator sessions (in production), same-origin validation, login-failure rate limiting, and salted session tokens.

Deployment

When the project receives its first request, it automatically checks for and runs the built-in database migrations. Existing sites do not need to manually run 0011_post_tags.sql when upgrading to the tag feature.

1. Install dependencies

npm install

2. Create a D1 database

wrangler d1 create blog-db

Find database_id in the output and add it to wrangler.jsonc:

[[d1_databases]]
database_id = "你的ID"

3. Create a KV namespace

wrangler kv:namespace create SESSIONS

Find id in the output and add it to wrangler.jsonc:

[[kv_namespaces]]
id = "你的ID"

4. Set the administrator account

wrangler secret put ADMIN_USER   # Enter the username
wrangler secret put ADMIN_PASS   # Enter the password

5. Migrate the database

Migrations run automatically during deployment (see step 7), but you can also run them separately first:

wrangler d1 migrations apply DB --remote

6. Configure AI summaries (optional)

Summaries use an OpenAI protocol-compatible chat completions endpoint. OPENAI_BASE_URL and OPENAI_MODEL have default values in [vars] in wrangler.toml; change them as needed for your provider (such as DeepSeek, Moonshot, or a local deployment).

Set the API key in the deployment environment:

wrangler secret put OPENAI_API_KEY

For local development, create a .dev.vars file in the project root:

OPENAI_API_KEY=sk-...

Without an API key, posts can still be saved normally; AI summaries simply will not be generated.

7. Deploy

npm run deploy

The deploy script runs the database migrations (wrangler d1 migrations apply DB --remote) before deploying the Worker. It is idempotent and safe to run repeatedly.

Upgrading an existing deployment to this version: If you previously ran the 0001–0010 SQL files manually using wrangler d1 execute, the d1_migrations table contains no records. npm run deploy will try to rerun these migrations and fail. Before the first upgrade, manually mark the completed migrations:

wrangler d1 execute blog-db --remote --command "CREATE TABLE IF NOT EXISTS d1_migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, applied_at TEXT NOT NULL DEFAULT (datetime('now')))"
wrangler d1 execute blog-db --remote --command "INSERT OR IGNORE INTO d1_migrations (name) VALUES ('0001_init'),('0002_users'),('0003_pages'),('0004_ai_summary'),('0005_post_activities'),('0006_remove_user_system'),('0007_post_license'),('0008_custom_license'),('0009_page_views'),('0010_page_view_country')"

After marking them complete, run npm run deploy to apply subsequent migrations incrementally.

Local development

wrangler d1 migrations apply DB --local
npm run dev

Visit http://localhost:8787 locally. The admin panel is at /admin/login.

Run the complete check before committing:

npm run check

Using AI summaries

In the Markdown body of an article, wrap content to summarize with [ai-summary] and [/ai-summary]. Multiple blocks are supported:

Body paragraph…

[ai-summary]
Here is a longer passage that you want the AI to summarize…
[/ai-summary]

More body text…

When the article is saved, the API is called once to generate a summary for each block, which is then stored in the database. Rendering does not call the API again. When editing, an existing summary is reused if the block content has not changed; it is regenerated only when the content changes.

Article licenses

When creating or editing an article, you can select a license separately. If none is selected, CC BY 4.0 is used by default. The site supports versions 1.0, 2.0, 2.5, 3.0, and 4.0 of the six CC licenses, CC0 1.0, common open-source software licenses, “All rights reserved,” and custom licenses. The current license is displayed on the article details page. Changing the license or custom terms of a published article is also recorded in the article activity wall.

Custom article paths

When creating an article, “Article path” may be left blank, in which case a pinyin path is generated from the title. When entering it manually, use only lowercase English letters, numbers, and hyphens; for example, after entering my-first-post, the article URL is /post/my-first-post. When editing an article, the current path is retained by default. Clearing it before saving regenerates a pinyin path from the current title. Changing the path of a published article changes its public URL and Giscus pathname mapping.

File structure

src/
  index.ts        Routing entry point
  auth.ts         Session management
  posts.ts        Post CRUD
  pages.ts        Page CRUD
  html.ts         HTML templates
  ai-summary.ts   AI summaries: extract marked blocks and call the API
  analytics.ts    Anonymous page-visit statistics and public report aggregation
  time.ts         UTC and UTC+8 time conversion
  config.ts       Optional environment-variable checks
migrations/
  0001_init.sql         Create tables
  0004_ai_summary.sql   Add the ai_summary column to posts
  0005_post_activities.sql  Article publication and edit activity
  0006_remove_user_system.sql  Remove the legacy user and local comment tables
  0007_post_license.sql  Article-level licenses
  0008_custom_license.sql  Custom license name and text
  0009_page_views.sql  Anonymous page-visit statistics
  0010_page_view_country.sql  Visitor country or region code
  0011_post_tags.sql         Article tags
  0012_query_indexes.sql     Public query indexes
wrangler.jsonc          Cloud deployment configuration (no secrets)
wrangler.toml           Local configuration (ignored by Git)

RSS subscriptions

The RSS subscription URL is:

https://your-domain/rss.xml

https://your-domain/feed.xml is also supported. Copy the address into an RSS reader such as Feedly, Inoreader, or Follow to subscribe to updates from public posts.

Search and sitemap

  • Site search: https://your-domain/search searches the titles and body text of published posts and returns at most 50 results per request.
  • https://your-domain/robots.txt allows public content to be crawled and points to the sitemap.
  • https://your-domain/sitemap.xml lists the homepage, published posts, and published pages. The URL is generated automatically from the current domain and requires no additional configuration.

Access reports

The public report is available at /stats, or through the “Access reports” button at the top of the homepage. The page shows the last 24 hours by default and can be switched to the last 7, 30, or 90 days. The 24-hour trend is aggregated by hour in UTC+8; the other ranges are aggregated by calendar day in UTC+8. The page requests /stats.json every 5 seconds and refreshes the current range in place, including traffic trends, popular pages, external referrer domains, visitor countries or regions, and device types.

Statistics store only the two-letter country or region code provided by Cloudflare based on the visitor’s IP address. IP addresses, complete User-Agent strings, and visitor identifiers are not stored. Analytics cookies are not used. Common crawlers, browser prefetch requests, and requests for the /stats and /stats.json reports are filtered out. Visits with DNT: 1 or Sec-GPC: 1 are not recorded. The report therefore shows page-view counts, not unique visitor counts.

Collapsible content

Use the following syntax in Markdown to create content that is collapsed by default:

[details="Title"]
This is collapsible Markdown content.
[/details]

The page displays a small arrow and the title in quotation marks. Click it to expand the body.

Giscus comment configuration

Comments on this site use Giscus, and comment content is stored in GitHub Discussions. Before configuring it, prepare a public GitHub repository and enable Discussions under Settings -> Features in the repository.

1. Install the Giscus App

Open github.com/apps/giscus and install Giscus on the repository that will store comments. It is recommended to authorize only this blog repository to minimize unnecessary permissions.

2. Obtain the Giscus configuration values

Visit giscus.app and fill in the repository and Discussion category. Enter the repository as username/repository-name, for example:

hekuo5310/blog

After generating the configuration at the bottom of the page, record these three values:

  • data-repo-id corresponds to GISCUS_REPO_ID
  • data-category corresponds to GISCUS_CATEGORY
  • data-category-id corresponds to GISCUS_CATEGORY_ID

This project uses pathname by default to map article URLs to Discussions, meaning each article has its own discussion. To use another mapping method, set GISCUS_MAPPING.

3. Configure Cloudflare Workers

Add the following variables to the [vars] section of wrangler.toml. The IDs must be the real values generated by the Giscus page; do not leave the example values in place:

[vars]
GISCUS_REPO = "用户名/仓库名"
GISCUS_REPO_ID = "R_kgDOxxxxxxxx"
GISCUS_CATEGORY = "Announcements"
GISCUS_CATEGORY_ID = "DIC_kwDOxxxxxxxx"
GISCUS_MAPPING = "pathname"
GISCUS_LANG = "zh-CN"

You can also set the variables from the command line during deployment:

wrangler secret put GISCUS_REPO_ID
wrangler secret put GISCUS_CATEGORY
wrangler secret put GISCUS_CATEGORY_ID

These three values are not passwords, so configuring them with [vars] is more intuitive. If you do not want them to appear in the configuration file, you can use the secret commands above. GISCUS_REPO, GISCUS_MAPPING, and GISCUS_LANG are optional; their defaults are hekuo5310/blog, pathname, and zh-CN, respectively.

4. Local development configuration

Add the values required for local testing to .dev.vars in the project root:

GISCUS_REPO=用户名/仓库名
GISCUS_REPO_ID=R_kgDOxxxxxxxx
GISCUS_CATEGORY=Announcements
GISCUS_CATEGORY_ID=DIC_kwDOxxxxxxxx
GISCUS_MAPPING=pathname
GISCUS_LANG=zh-CN

Then start the development server:

npm run dev

Open any public article. If the Giscus comment box appears at the bottom, the configuration succeeded. If GISCUS_REPO_ID, GISCUS_CATEGORY, or GISCUS_CATEGORY_ID is not configured, the page displays a configuration notice and does not load the comment box.

Frequently asked questions

  • Comment box shows Discussion not found: check that the repository is public, Discussions is enabled, and the Giscus App is installed; then copy the three IDs again.
  • Unable to comment after signing in: Giscus uses GitHub login. Confirm that the current account has permission to post comments in the repository.
  • Articles do not have separate comments: confirm that GISCUS_MAPPING is pathname and that article URLs remain stable.

Offline reading

“Enable offline mode” in the public page navigation downloads the gzip-compressed JSON site archive provided by /offline/archive and extracts it into the browser’s Cache Storage. Offline mode is enabled only after downloading, extraction, and version verification all succeed; on failure, the existing mode is retained. The archive includes published posts, public custom pages, the homepage and tag pagination, the archive, the search index, licenses, RSS, the Markdown rendering script, and site images referenced by public article content. Offline search matches titles and body text and returns at most 50 posts.

The admin panel, drafts, login sessions, configuration secrets, and access reports are not cached. Third-party images, comments, external links, and access reports require a network connection. The offline archive does not enumerate the R2 bucket; it only reads images referenced by public content. The comment component is not loaded when public pages are packaged. The total UTF-8 size of the generated archive after Base64 and JSON escaping but before gzip compression is limited to 32 MiB; generation is refused if this limit is exceeded to avoid exceeding Worker memory limits.

Offline mode is provided through a Service Worker. It requires HTTPS (local localhost is also supported) and a browser that supports DecompressionStream('gzip'). The cache is valid only in the current browser and on the current site; clearing site data or reclaiming storage invalidates it.

The version consists of the Workers VERSION deployment ID and a digest of public content. On every offline page navigation, when connectivity is restored, when returning to the page, and once per minute, /offline/version is checked. If a redeployment or change to public content is detected, the offline cache is deleted, online mode is restored, and the user is prompted to enable offline mode again. During a complete network outage, server updates cannot be detected; the existing archive continues to be used and is checked again once connectivity returns. “Switch to online mode” clears the offline archive and reloads the current page over the network. Online mode does not cache pages automatically.

Run npm run check to perform offline regression tests, TypeScript checks, and Wrangler deployment bundle checks.

The offline browser script is stored as .js.txt. Wrangler’s default Text module rule imports it as a string, which is then served through a .js HTTP route, preventing browser code from running when the Worker starts. The browser distribution scripts for Marked 18.0.6 and DOMPurify 3.4.12 are stored in the same directory with their original copyright notices preserved. Update both text resources when upgrading dependencies. npm run check also checks the actual Wrangler output and verifies server startup and the four script-resource routes.

ChatGPT token statistics

See the ChatGPT Usage Statistics Configuration Guide for complete setup instructions, including KV, the sync token, existing UserScript configuration, initial synchronization, API testing, and frequently asked questions.

The standalone /chatgpt-stats page displays a “ChatGPT Usage Statistics” card with total tokens, the daily peak, the longest task, the longest and current streaks, the latest synchronization time, a year-long daily heatmap, and a lightweight SVG cumulative graph. It reuses the blog’s cards, CSS variables, and dark mode. On mobile, only the heatmap scrolls horizontally. Skeleton loading, empty data, retry after failure, and the “Statistics may not have synced yet” notice after more than 24 hours are supported. Relative times update every minute, and public data refreshes every 5 minutes.

Fixed API and synchronization protocol

  • POST /api/chatgpt-stats/update: Authorization: Bearer <SYNC_TOKEN>, Content-Type: application/json.
  • GET /api/chatgpt-stats: public read access, Cache-Control: public, max-age=300; returns JSON null when no record exists yet.
  • Routes are registered in src/index.ts; validation and storage are in src/chatgpt-stats.ts; the component is in src/chatgpt-stats-view.ts; page interactions are in src/chatgpt-stats-client.js.txt; src/html.ts integrates chatgptStatsPage into the standalone page. The “Token statistics” navigation item opens this page, while the homepage does not display the statistics card.

POST is fully compatible with the existing UserScript’s eight fields, so the synchronization script does not need to be rewritten:

{
  "totalTokens": 1415463545,
  "peakDailyTokens": 125716880,
  "longestTaskSeconds": 80080,
  "longestStreakDays": 14,
  "currentStreakDays": 0,
  "daily": [{"start_date":"2026-09-30","tokens":53256440,"chat_turns":0}],
  "weekly": [{"start_date":"2026-09-28","tokens":97597082,"chat_turns":0}],
  "cumulative": [{"start_date":"2026-09-30","tokens":1415463545,"chat_turns":0}]
}

Arrays are parsed according to the actual Profile activity record structure: start_date must be a valid YYYY-MM-DD date, tokens must be a non-negative finite number, and chat_turns is an optional non-negative finite number. The tokens values in the cumulative array are already cumulative and are not summed again. Arrays may be empty, with a maximum of 5,000 entries each. Duplicate dates and unknown record fields are rejected. The five numeric fields must be non-negative finite numbers. Unknown top-level fields (including the client-provided updatedAt) are rejected. Invalid input returns 400; a missing or invalid Bearer token returns 401; request bodies over 256 KiB return 413; missing configuration or storage failures return 503. The size limit checks both the declared length and the actual streamed byte count.

KV stores only the eight reconstructed values above and a server-generated ISO 8601 updatedAt. The blog backend does not access ChatGPT and does not store the original Profile, cookies, ChatGPT Authorization, account or device IDs, or chat content. The API does not log the Authorization header or POST body. A wildcard CORS configuration is unnecessary; the existing GM_xmlhttpRequest can send requests directly.

KV and Secret

wrangler.jsonc now includes a CHATGPT_STATS binding using the project’s existing real KV namespace ID 1abb51160d5e47dbb773d8dea1b7313f, with profile as the storage key. The existing namespace is reused, so no new storage abstraction is added. Administrator session validation is restricted to the correct token format and the exact stored value 1, ensuring that statistics records are not mistaken for sessions. No new KV namespace is required for deployment. If you want an independent namespace, create one with the following command and replace the CHATGPT_STATS id with the ID returned by the command; do not change SESSIONS:

npx wrangler kv namespace create CHATGPT_STATS

The following Secret command has been verified with the project’s installed Wrangler 4.111.0:

npx wrangler secret put SYNC_TOKEN --config wrangler.jsonc

Paste a randomly generated, high-entropy token, and enter exactly the same value in the existing UserScript’s SYNC_TOKEN constant. Do not put the real token in vars, the README, or Git. The current development environment is not logged in to Cloudflare, so the Secret must be set by a user with an authenticated account session. The code and KV binding configuration are already complete.

The existing Worker domain blog.hekuo.workers.dev has been confirmed accessible. In the existing script, set YOUR_BLOG_DOMAIN (including @connect) to this domain, and set WORKER_URL to:

https://blog.hekuo.workers.dev/api/chatgpt-stats/update

If you use a custom domain, you can change both locations to the custom domain bound to the Worker. The synchronization interval, ChatGPT reading API, and eight JSON fields remain unchanged. The public GET URL is https://blog.hekuo.workers.dev/api/chatgpt-stats. KV eventual consistency and the GET endpoint’s 5-minute browser cache may cause newly synchronized data to appear after a delay.

Test synchronization (PowerShell)

Merge and deploy first, then set the Secret. The following writes only synthetic test data; the existing UserScript will overwrite it later:

$syncToken = Read-Host 'Enter the SYNC_TOKEN matching the Worker Secret' -MaskInput
$payload = @{
  totalTokens = 1000000
  peakDailyTokens = 250000
  longestTaskSeconds = 80080
  longestStreakDays = 14
  currentStreakDays = 0
  daily = @(@{ start_date = '2026-10-03'; tokens = 250000; chat_turns = 0 })
  weekly = @()
  cumulative = @(@{ start_date = '2026-10-03'; tokens = 1000000; chat_turns = 0 })
} | ConvertTo-Json -Depth 5
Invoke-RestMethod -Method Post -Uri 'https://blog.hekuo.workers.dev/api/chatgpt-stats/update' `
  -Headers @{ Authorization = "Bearer $syncToken" } -ContentType 'application/json' -Body $payload
Remove-Variable syncToken
Invoke-RestMethod -Uri 'https://blog.hekuo.workers.dev/api/chatgpt-stats'

-MaskInput requires PowerShell 7. Windows PowerShell 5.1 can use Read-Host -AsSecureString, then obtain the string locally with [System.Net.NetworkCredential]::new('', $value).Password. GET requires no login or token. A successful POST returns { "ok": true, "updatedAt": "..." }; an invalid token returns 401; negative numbers, non-arrays, and extra fields return 400. Open /chatgpt-stats to verify the card, heatmap hover behavior, year switching, and graph.

The verification command is the existing npm run check (tests, TypeScript, the actual Wrangler bundle and output startup checks); there are no separate lint or build scripts. jsdom is used only for development tests and is not included in the blog browser bundle. New regression tests cover the fixed client protocol, authentication, byte limits, rejection of sensitive fields, KV failures, session isolation, formatting, 365/366-day heatmaps, SVG, empty data, expiration, and error-retry states.

The offline full-site archive includes the page shell for /chatgpt-stats; the statistics API is always read over the network, and the page displays an offline notice when disconnected.

20 Likes

Very detailed—thumbs up!

1 Like

Sharing is support

1 Like

This isn’t testing AI summarization, is it…? At least include a picture—I don’t even know what it looks like :ac04:

Copied README. You can check it out at www.io.hk.cn; the second-to-last post has an AI summary.

Thanks, index~ I’ll learn a bit…

1 Like

That’s a lot of text. I’m worried I might not do it right—is there a video tutorial?

You only need to follow the deployment tutorials for 1–7; the sections below are optional.

Nice, thanks for sharing.

2 Likes

Take a look

Is this speed fast?

That depends on cf.

That’s still okay.

Thanks for sharing

Take a look…