@brandlab-digital/site-next

中文文档
4.2.2发布于 2026/11/06UNLICENSEDTypeScriptESM + CJS近 30 天下载 8094

Proprietary software © Brandlab Digital Marketing. All rights reserved. Published for Brandlab client sites.

原始使用文档
文档目录6 节
  1. Reviews: refresh on change, not every day (3.13.0)
  2. Estate segment preset ( ./estate-segment )
  3. Contact delivery: accepted is not delivered (3.14.0)
  4. Contact route: shared spam filter (4.1.0)
  5. Contact route: Preview test enquiries reach only Brandlab (…
  6. Contact route: abuse limits and the test marker (3.10.0)

@brandlab-digital/site-next

Proprietary software © Brandlab Digital Marketing. All rights reserved. Published for Brandlab client sites.

Reviews: refresh on change, not every day (3.13.0)

The shared reviews fetch (getBusinessProfileReviews, ./google-business-reviews/server) carries the tags brandlab-reviews and brandlab-reviews:<site>. By default it still keeps a successful answer for one day (BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS), so a site that installs 3.13.0 for another reason keeps daily reviews.

A site opts into refresh on change by passing refresh: 'on-change', in the same call that already names the site, once it has the route below deployed with its secret and is registered with the daily reviews checker:

// src/lib/reviews.ts
getBusinessProfileReviews({ site: 'hr-kitchens', refresh: 'on-change' })

The answer is then kept for 7 days (BUSINESS_PROFILE_REVIEWS_ON_CHANGE_REVALIDATE_SECONDS) as a safety net only. Once a day the checker hashes each site's feed and, when the reviews have really changed, calls the site's signed revalidate route, which refreshes the tag. A new review still shows within a day; pages are rewritten only after a real change. If a call fails, pages refresh themselves within the week. The choice sits on the fetch, not in brandlab.config.ts, because the fetch is where Next reads it at build time and a site's config file is not imported at runtime.

The route, at src/app/api/brandlab/reviews-revalidate/route.ts:

import { createBrandlabReviewsRevalidateRoute } from '@brandlab-digital/site-next/google-business-reviews/server'

export const { POST } = createBrandlabReviewsRevalidateRoute({ site: 'hr-kitchens' })

Set BRANDLAB_REVALIDATE_SECRET (at least 32 characters) in the site's Vercel project and on the checker; never in git. The call contract:

RequestPOST /api/brandlab/reviews-revalidate, body {"site":"<slug>","hash":"<64 hex>"} (1 KB at most)
Signaturex-brandlab-timestamp: <Unix seconds> and x-brandlab-signature: v1=<hex HMAC-SHA256 of "<timestamp>.<body>">; site-core createBusinessProfileReviewsRevalidateRequest builds both
Hashsite-core hashBusinessProfileReviews(feed): the rating, the count and each review's id, rating, text, author, times and reply, sorted by id
Who decidesThe checker keeps the last hash each site accepted with a 200 and calls only when today's differs. The route also skips a hash it already applied, but only per server instance
200{ ok: true, revalidated: true } after revalidateTag('brandlab-reviews:<site>', 'max'), or revalidated: false, reason: 'unchanged'
Errors400 malformed body or another site's slug; 401 missing or wrong signature, or a timestamp more than 5 minutes off; 413 body over 1 KB; 429 with Retry-After past 10 requests per IP in 10 minutes; 503 secret not set; 500 refresh failed. The checker records the hash only after a 200

Refused requests log one line without the secret or signature, for example {"event":"brandlab_reviews_revalidate","site":"hr-kitchens","result":"refused","reason":"signature"}.

Estate segment preset (./estate-segment)

Service-in-suburb and suburb pages export revalidate = false, written literally because Next reads segment config statically, and a site test calls assertBrandlabEstateSegment(page). Deploys and the reviews tag rewrite them. A page that shows reviews follows the reviews fetch: daily by default, weekly with refresh: 'on-change'. Ready-gate check W3 (full tier, from 3.13.0) fails an estate family that refreshes more often than every 7 days, so a full-tier estate that shows reviews adopts on-change with the route, or records the family in siteSystem.refreshExceptions in brandlab.config.ts: { family, revalidateSeconds, reason, decisionId }.

Contact delivery: accepted is not delivered (3.14.0)

When Resend accepts an enquiry it is queued, not delivered. Resend can still bounce it, drop it because the business inbox is on the account's suppression list, or have it reported as spam, while the site has already said "sent". That is how BL Roofing lost enquiries. contact_form keeps its analytics-v4 meaning (Resend accepted the enquiry) and fires exactly as before; delivery is now tracked beside it.

Before sending. createBrandlabContactRouteHandler asks Resend's suppression list about each business inbox (GET /suppressions/{email}, cached for 5 minutes per inbox, at most 2.5 seconds per lookup). The check only records and alerts; it never blocks a send. When an inbox is suppressed the route logs and alerts recipient_suppressed, and the enquiry still goes to the client's own inbox (never rerouted, never copied to Brandlab) with the normal "sent" result and contact_form as usual. Keeping the inbox and its email billing in order is the client's responsibility; the alert lets Brandlab tell them the same day. A site can opt in to refusing instead with refuseWhenAllSuppressed: true: when every inbox is suppressed the visitor then gets the form's "could not send, please call" answer and no conversion fires. A failed or refused lookup never stops an enquiry: it is sent as before and logged once per 5 minutes as brandlab_contact_suppression_check_unavailable. The lookup needs a full-access key: set RESEND_LOOKUP_API_KEY so RESEND_API_KEY can stay sending-only (without it the lookup uses RESEND_API_KEY). A sending-only key is refused with restricted_api_key, and the warning line names the missing permission ("needs":"full-access key (RESEND_LOOKUP_API_KEY)"). suppressionCheck: false turns the check off; a route that replaces the provider with createResendSend (synthetic tests) has it off unless it passes one.

After sending. A signed webhook route records what happened to the business copy. Add src/app/api/brandlab/resend-webhook/route.ts:

import { createBrandlabResendWebhookRoute } from '@brandlab-digital/site-next/contact/server'
import { contactContract } from '@/config/contact'

export const { POST } = createBrandlabResendWebhookRoute({ projectId: contactContract.projectId })

Then, in plain steps:

  1. Deploy the route.
  2. In Resend, open Webhooks and add an endpoint: https://<the site's domain>/api/brandlab/resend-webhook.
  3. Tick the email events: delivered, delivery delayed, bounced, complained, suppressed and failed.
  4. Open the new webhook and copy its signing secret (it starts with whsec_).
  5. In the site's Vercel project, add RESEND_WEBHOOK_SECRET with that value for Production (and Preview if previews send real enquiries). Never put it in git or .env.example values.
  6. Redeploy, send a test enquiry (?bl_test=1), and check the logs show a brandlab_contact_delivery_status line with "state":"delivered".

Resend webhooks cover the whole account, so each site's route records only the emails its own contact route sent (the project_id tag) and answers 200 to the rest. The signature is checked the Svix way Resend documents (HMAC-SHA256 over <svix-id>.<svix-timestamp>.<raw body>, 5 minutes' clock tolerance); a bad signature gets 401, a missing secret 503. Failed attempts are rate-limited per x-real-ip (10 in 10 minutes, then 429 with Retry-After; rateLimit adjusts it or false turns it off); a correctly signed request never counts. A retry with an svix-id the instance already recorded answers 200 duplicate: true without logging again; that list is small and per instance, so it is best effort and the monitor still de-duplicates.

Both halves accept onDeliveryFailure(failure), a same-day alert hook (email, Slack or a durable store). It gets the code, source, enquiry reference and Resend email id, never an address or form value. If it throws on a webhook event the route answers 500 so Resend retries the event.

Environment variableWhereWhat
RESEND_API_KEYVercel, Production and PreviewSends enquiries; can stay a sending-only key
RESEND_LOOKUP_API_KEYVercel, Production and Preview (optional)A full-access key used only for the suppression lookup; without it the lookup uses RESEND_API_KEY
RESEND_WEBHOOK_SECRETVercel, ProductionThe webhook's whsec_ signing secret
the contract's delivery.recipientEnvVercel, Production and PreviewThe business inbox

For the daily delivery monitor (P26). Read these JSON lines from the site's logs:

LineMeaningAction
{"event":"brandlab_contact_delivery_failed",…,"code":"recipient_suppressed","source":"pre-send","recipients":"all"}The inbox is suppressed; the enquiry was still sent (Resend will drop it) and the visitor saw "sent" (unless the site opted in to refuseWhenAllSuppressed: true, when it was not sent and the visitor was asked to call)Same day: tell the client to fix the inbox, remove it from Resend → Suppressions, tell Romie
same, "recipients":"some"One of several inboxes is suppressed; the others got itSame day: fix or remove that inbox
…"code":"recipient_bounced"/"recipient_complained"/"recipient_suppressed"/"provider_failed","source":"webhook"Resend accepted it but the business copy did not arrive (detail gives Resend's bounce type, suppression type or failure reason)Same day: the enquiry counted as a contact_form but the business never saw it; find it by submission (the first 16 characters of the confirmation id) or emailId in Resend and forward it
{"event":"brandlab_contact_delivery_status","state":"delivered"} / "state":"delayed"Delivered, or Resend is retrying a soft bounceNone; a delayed with no later delivered or failure within a day needs a look
brandlab_contact_suppression_check_unavailableThe pre-send check could not ask Resend (code says why; needs names a missing permission)Set a full-access RESEND_LOOKUP_API_KEY; enquiries still send
brandlab_contact_delivery_alert_failedThe alert hook failedCheck the hook

De-duplicate webhook lines on webhookId (Resend retries an event with the same id). Compare each day's contact_form count in GA4 with delivered lines: every accepted enquiry should end in exactly one delivered or one failure. Ready-gate check H13 (enquiry tier, from 3.14.0) checks the same inbox before launch: a real sender, MX records, the sender domain verified in Resend and the inbox not suppressed. Run brandlab-ready --launch with the inbox variable and a full-access key (RESEND_LOOKUP_API_KEY) set in the shell for that run, never written to a file. Without them H13 reports "not run" and names what to set: in an ordinary run that does not block the tier, and under --launch it fails.

Contact route: shared spam filter (4.1.0)

Every site on the shared contact handler screens enquiries for sales pitches after validation and before delivery. A blocked enquiry gets the normal silent success: the visitor sees the usual thank-you, nothing is sent to Resend, contact_form does not fire, and the server logs one redacted line with the site and signals only: {"event":"brandlab_contact_spam_blocked","site":"abloom","signals":["pitch-marketing","foreign-suburb"]}.

  • Strong signals: pitch wording (SEO and rankings, Google review selling or removal, take-off and estimating, "reach me via email" lures, website audits and redesigns), wa.me / WhatsApp / Telegram links, look-alike Unicode letters, and a known spam sender (SHA-256 of the trimmed, lower-cased address; raw addresses are never stored).
  • Weak signals: any link in the message, an overseas phone number in the text, and a suburb that is clearly foreign or random characters ("IN", "Dhaka", "cvsfewf"). Typos, abbreviations ("Nth Ryde", "St Ives"), postcodes and interstate places are fine. Only the field whose key is suburb is read; street-address fields are never scored.
  • Rule: block only with at least two signals, at least one of them strong. A second hit in the same pitch category counts as another signal. Weak signals never block on their own or together, and a filter fault always delivers.
  • Fill time: the shared form stamps formStartedAt when it renders. A submission under 3 seconds later is dropped silently (signals:["fill-time"]). A form without the stamp is never dropped on time. Scripted live or preview test enquiries must wait at least 3 seconds after the page loads before sending.

Site overrides go through spamFilter on createBrandlabContactRouteHandler: extraPhrases, knownSenderHashes, ignoreSignals, minFillMs, or false to turn the filter off. There is no bypass word; a genuine test enquiry passes on its own wording.

Contact route: Preview test enquiries reach only Brandlab (4.2.0)

On a Preview deployment (VERCEL_ENV other than production) a marked test enquiry (bl_test=1) is sent only to BRANDLAB_PREVIEW_TEST_RECIPIENT ([email protected]), whatever the deployment's CONTACT_EMAIL says, with "[Test]" in the subject and a silent success. It works on a Preview with no recipient set; real enquiries there still fail until one is. The marker is not a spam-filter bypass: the enquiry must pass the shared filter like any other. Every answer of a Preview's contact route carries x-brandlab-contact-mode: preview; test-inbox=brandlab (BRANDLAB_PREVIEW_CONTACT_HEADER); production never sends it. Production keeps the 3.10.0 behaviour: a marked enquiry goes to the configured recipients with "[Test]". Send the release test with conformance's brandlab-preview-enquiry, which checks that header before it sends anything.

Contact route: abuse limits and the test marker (3.10.0)

createBrandlabContactRouteHandler limits distinct enquiries per client IP and per email address where enquiries are delivered (production and Preview): 10 per IP in 10 minutes and 5 per address in an hour by default (BRANDLAB_CONTACT_RATE_LIMITS), held in memory per server instance. A resend of the same enquiry never counts, an enquiry that fails validation never counts, and a refused one gets a retryable 429 with Retry-After and is never sent. Adjust or turn them off with rateLimit: { perIp, perAddress } | false.

The in-memory limiter is the first layer. The second is a Vercel Firewall rate-limit rule on the route, which holds across every instance. Template (Vercel CLI, run in the linked project; check the project's Firewall tab for the plan's rate-limit support first, and keep the limit above the route's own):

vercel firewall rules add "Contact form rate limit" \
  --condition '{"type":"path","op":"eq","value":"/api/contact"}' \
  --condition '{"type":"method","op":"eq","value":"POST"}' \
  --action rate_limit \
  --rate-limit-window 600 \
  --rate-limit-requests 20 \
  --rate-limit-keys ip \
  --rate-limit-action rate_limit --yes

A Brandlab test enquiry (a launch proof or canary) opens the page with ?bl_test=1 (remembered for the tab; ?bl_test=0 ends it). Each enquiry then carries bl_test=1: the server delivers it to the configured recipient with [Test] in the subject, never writes the marker into the email and answers without a confirmation, so the browser never sends contact_form or an Ads conversion for it, and a test visit sends no lead_form_start or lead_form_error. Canary previews deliver only to the Brandlab test inbox.

Canary and launch-proof runs count against the per-address limit (5 per address per hour), so use plus-addressed test inboxes, one per run or per site (test+abloom-1@…, test+abloom-2@…), and spread repeated runs across them. A refused enquiry logs one line: {"event":"brandlab_contact_rate_limited","rule":"address","retryAfterSeconds":1800}.

版本与发布记录

@brandlab-digital/site-next 共发布 118 个版本,首次发布于 2026/05/26;过去 12 个月发布了 118 个版本。

版本发布日期距今
4.2.2latest2026/11/062 天前
4.2.12026/11/062 天前
4.2.02026/11/062 天前
4.1.02026/11/053 天前
4.0.02026/11/053 天前
3.20.02026/11/044 天前

其他发布通道:next → 1.0.0,maintenance-1-0 → 1.0.15,maintenance-3-4 → 3.4.1,maintenance-3-5 → 3.5.2,maintenance-3-3 → 3.3.6。

关于 @brandlab-digital/site-next 的常见问题

@brandlab-digital/site-next 是什么?

Proprietary software © Brandlab Digital Marketing. All rights reserved. Published for Brandlab client sites.

如何安装 @brandlab-digital/site-next?

在项目目录运行 npm install @brandlab-digital/site-next,或使用 pnpm add / yarn add / bun add。

@brandlab-digital/site-next 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@brandlab-digital/site-next 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@brandlab-digital/site-next 可以商用吗?

@brandlab-digital/site-next 使用 UNLICENSED 许可。作者声明为 UNLICENSED,即不授予公开使用许可(与公共领域的 Unlicense 不同)。使用前需获得作者授权。以上为概括,不构成法律意见,请以许可原文为准。

@brandlab-digital/site-next 的下载量有多少?

根据 npm 官方下载统计,@brandlab-digital/site-next 在 2026/10/09 – 2026/11/07 这 30 天内被下载 8,094 次,日均约 270 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@brandlab-digital/site-next 还在维护吗?

最新版本 4.2.2 发布于 2026/11/06(2 天前);至今共发布 118 个版本,其中过去 12 个月发布了 118 个;首次发布于 2026/05/26。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。