hazo_embed
v0.2.1
Published
App-agnostic embeddable-widget toolkit — reserved-param contract, iframe snippet generation, HMAC+DB embed keys, and a postMessage auto-resize runtime. Consumers supply the widget component + tool registry.
Readme
hazo_embed
App-agnostic embeddable-widget toolkit. It knows nothing about any
particular timer/calculator/tool — consumers supply the widget component and
a tool registry (slug ↔ config) on their own side. Generalizes the embed
pattern gotimer built bespoke (gotimer/src/lib/embed/*, embed.js route)
into a reusable package for a second app.
Installation
npm install hazo_embed hazo_secure hazo_connect hazo_apiPeer Dependencies
| Package | Required | Notes |
|---------|----------|-------|
| react / react-dom | ^18 or ^19 | Required |
| hazo_secure | ^1.4.0 | Required for ./server — HMAC sign/verify (hazo_secure/crypto) |
| hazo_connect | ^3.9.2 | Required for ./server — embed_keys store CRUD |
| hazo_api | ^2.5.1 | Required for ./api — ok()/fail() response envelopes |
| next | ^14 or ^16 | Optional — needed for ./api route factories and ./runtime's embedJsRouteBody() |
| hazo_auth | ^10.8.2 | Optional — bring your own getUserId resolver for ./api |
| hazo_seo | ^0.4.0 | Optional — not imported directly; reserved for future embed-page metadata helpers |
| hazo_ui | ^6.0.0 | Optional — required only if you use <EmbedCodeGenerator> (./client); not needed for the embed runtime/iframe/server entries |
| hazo_theme | ^1.0.0 | Optional — required alongside hazo_ui for <EmbedCodeGenerator> |
If you use <EmbedCodeGenerator>, also add the Tailwind v4 @source directive so its
utility classes compile (see hazo_ui/hazo_theme set up in your app first):
@import "tailwindcss";
@source "../node_modules/hazo_embed/dist";Subpaths
| Import path | Contents |
|-------------|----------|
| hazo_embed | Pure, server-safe: EmbedParams/EmbedMode/etc. types, parseEmbedParams/serializeEmbedParams, buildEmbedSnippet |
| hazo_embed/client | "use client" — <EmbedCodeGenerator> (inline embed-code panel), <EmbedWatermark> (backlink) |
| hazo_embed/server | Server-only — createEmbedKeyStore, mintEmbedKey, resolveEmbedRender, HMAC sign/verify wrappers |
| hazo_embed/api | Next.js Route Handler factories — createEmbedKeyRoute, createEmbedKeyListRoute, createEmbedKeyRevokeRoute |
| hazo_embed/runtime | <EmbedFrame> + useEmbedAutoResize (widget side), embedJsRouteBody() (host-page /embed.js helper) |
Quick Start
1. Run the schema setup
psql "$DATABASE_URL" -f node_modules/hazo_embed/ddl/postgres.sql
# or
sqlite3 path/to/app.sqlite < node_modules/hazo_embed/ddl/sqlite.sql2. Set HAZO_EMBED_SECRET
HAZO_EMBED_SECRET=$(openssl rand -base64 32)Without this env var, resolveEmbedRender always degrades to the
watermark-only stub (fails closed, never throws).
3. Parse embed params on your embed route
// app/e/[toolSlug]/page.tsx
import { parseEmbedParams } from "hazo_embed";
const params = parseEmbedParams(searchParams, { reserved: ["your_tool_specific_key"] });
// params.config now holds everything your existing ?restore= seed path expects.4. Resolve the render decision
import { resolveEmbedRender, createEmbedKeyStore, getEmbedSecret } from "hazo_embed/server";
import { getHazoConnectSingleton } from "hazo_connect/nextjs/setup";
const decision = await resolveEmbedRender(
{
keyPublic: searchParams.get("key_public"),
origin: headers().get("origin"),
referer: headers().get("referer"),
secFetchSite: headers().get("sec-fetch-site"),
toolSlug: "your-tool-slug",
},
{ secret: getEmbedSecret(), store: createEmbedKeyStore(await getHazoConnectSingleton()) },
);
if (decision.full) {
// render the widget in decision.mode
} else {
// render <EmbedWatermark> only — still a backlink impression
}5. Mount the widget runtime + host helper
// widget side
import { EmbedFrame } from "hazo_embed/runtime";
<EmbedFrame><YourWidget /></EmbedFrame>
// app/embed.js/route.ts (host page helper)
import { embedJsRouteBody } from "hazo_embed/runtime";
export async function GET() {
return new Response(embedJsRouteBody(), {
headers: { "Content-Type": "application/javascript; charset=utf-8" },
});
}6. Wire the key-issuance API (optional — for signed keys)
// app/api/embed/keys/route.ts
import { createEmbedKeyRoute, createEmbedKeyListRoute } from "hazo_embed/api";
import { createEmbedKeyStore, getEmbedSecret } from "hazo_embed/server";
import { getHazoConnectSingleton } from "hazo_connect/nextjs/setup";
const ctx = {
getUserId: async (req) => (await yourAuthLookup(req))?.id ?? null,
getStore: async () => createEmbedKeyStore(await getHazoConnectSingleton()),
getSecret: () => getEmbedSecret(),
};
export const POST = createEmbedKeyRoute(ctx);
export const GET = createEmbedKeyListRoute(ctx);Reserved param contract
Base presentation keys (theme, size, accent, bg, font, controls,
branding, mode) are always pulled onto typed EmbedParams fields.
Everything else lands in config. Pass opts.reserved to
parseEmbedParams to carve out your own additional typed keys (e.g. a timer
app's started/autostart/on_expire) — those land in config as raw,
uncoerced strings so your own type logic re-parses them exactly as before.
Embed code generator
<EmbedCodeGenerator> (from hazo_embed/client) is an always-inline panel
— it no longer takes open/onClose props; mount it wherever you want the
generator to render (a settings page, a dashboard tab, etc.) and control
visibility yourself if needed (e.g. with your own {visible && <...>} guard
or a hazo_ui tab/dialog). It's built on hazo_ui (Select/Input/
Checkbox/Label/Button/HazoUiDialog). Layout: a controls form on the
left (theme/size/accent/controls/mode selectors, a declared-domains input, an
extraControls slot for your own fields, and a "Show 'Powered by' branding"
checkbox) drives a live-preview iframe on the right; the styled code panel
with a copy button spans the full width below. Pass extraControls to inject
consumer-specific inputs — wire them to update the config prop so the live
preview re-renders as the user edits them.
Props
| Prop | Type | Required | Notes |
|------|------|----------|-------|
| toolSlug | string | Yes | Becomes part of the embed URL path |
| toolName | string | Yes | Shown in the preview title |
| origin | string | Yes | Host app origin, e.g. https://example.com |
| config | Record<string, unknown> | No | Tool-specific config merged into EmbedParams.config |
| keyPublic | string | No | Signed embed key to include in the generated snippet |
| watermark | EmbedWatermarkSpec | Yes | .text/.href drive the "Powered by" backlink |
| defaults | Partial<{ theme: EmbedTheme; size: EmbedSize; accent: string; controls: EmbedControls; mode: EmbedMode }> | No | Initial values for the presentation fields |
| onDeclareDomains | (domains: string[]) => void | No | Called as the user edits the "declared domains" field |
| showBrandingToggle | boolean | No | Render the branding checkbox at all. Default true |
| defaultBrandingShown | boolean | No | Initial branding-shown state. Default true |
| onRemoveBranding | () => boolean \| Promise<boolean> | No | Veto hook called when the user unchecks branding — see below |
| upgradeUrl | string | No | CTA href opened by the built-in upgrade dialog |
| upgradeDialog | { title?: string; description?: string; ctaText?: string } | No | Overrides the upgrade dialog's copy |
| extraControls | ReactNode | No | Consumer fields rendered in the left column below the built-in ones — wire them to config to drive the live preview |
| className | string | No | Applied to the panel's root element |
Branding-gate semantics
- Checking the box (restoring branding) is always free and always allowed.
- Unchecking it (removing branding) calls
onRemoveBranding():undefined(prop omitted) → allowed, branding is removed for free.- Returns or resolves truthy → allowed, branding is removed.
- Returns or resolves falsy, or throws/rejects → branding stays on
and the built-in upgrade dialog (
HazoUiDialog) opens, using yourupgradeDialogcopy plus an "Upgrade plan" CTA that opensupgradeUrlin a new tab.
- The generated snippet reflects the current state: branding shown →
watermark.show=trueandparams.branding="full"; branding removed →watermark.show=falseandparams.branding="hidden".
Free path — branding removal always allowed
import { EmbedCodeGenerator } from "hazo_embed/client";
<EmbedCodeGenerator
toolSlug="your-tool-slug"
toolName="Your Tool"
origin="https://yourapp.com"
watermark={{ show: true, text: "Powered by YourApp", href: "https://yourapp.com" }}
onRemoveBranding={() => true}
/>Paid gate — veto with upgrade CTA
import { EmbedCodeGenerator } from "hazo_embed/client";
<EmbedCodeGenerator
toolSlug="your-tool-slug"
toolName="Your Tool"
origin="https://yourapp.com"
watermark={{ show: true, text: "Powered by YourApp", href: "https://yourapp.com" }}
onRemoveBranding={() => currentUser.plan === "paid"}
upgradeUrl="https://example.com/pricing"
upgradeDialog={{
title: "Upgrade to remove branding",
description: "Removing the \"Powered by\" badge requires a paid plan.",
ctaText: "See plans",
}}
/>onRemoveBranding can also be async (e.g. a server check) — return/resolve
a boolean, or throw/reject to fall back to the upgrade dialog:
<EmbedCodeGenerator
/* ...required props... */
onRemoveBranding={async () => {
const res = await fetch("/api/billing/can-remove-branding");
if (!res.ok) throw new Error("check failed");
return (await res.json()).allowed;
}}
upgradeUrl="https://example.com/pricing"
/>Consumer requirements
hazo_ui ^6.0.0andhazo_theme ^1.0.0are optional peer deps — required only if you use<EmbedCodeGenerator>. The embed runtime/iframe (./runtime) and server entries (./server,./api) don't need them.- Tailwind v4: add
@source "../node_modules/hazo_embed/dist";alongside your existinghazo_ui/hazo_theme@sourceentries so the generator's utility classes compile.
License
MIT
