tempo-sdk
v0.0.51
Published
Tempo SDK — Vite, Next.js, and Expo plugins for JSX annotation and shared page/storyboard types
Readme
tempo-sdk
Tempo's JSX-annotation plugins for Vite and Next.js. At build time, every JSX
element in your codebase gets data-tempo-* attributes that map the rendered
DOM back to the exact source-file location that produced it. Tempo's design
surface uses these attributes to navigate from a clicked element to its source.
This package ships two plugins from one workspace:
tempo-sdk— Vite plugin (tempoVitePlugin)tempo-sdk/nextjs— Next.js plugin (tempoNextjsPlugin)tempo-sdk/nextjs/plugin— default-exported Next.js plugin for ESM config filestempo-sdk/expo/plugin— Expo Babel plugin (tempoExpoBabelPlugin)
Both produce byte-identical data-tempo-* output, so downstream consumers
(source-editor.ts, frame-preload.ts) work the same regardless of which
framework the user's project is on.
Next.js usage
1. Install
In a workspace package:
{
"dependencies": { "tempo-sdk": "workspace:*" }
}In an external project: pnpm add tempo-sdk (or your package manager's equivalent).
2. Wrap next.config
CommonJS (next.config.js):
const tempoNextjsPlugin = require("tempo-sdk/nextjs");
module.exports = tempoNextjsPlugin()({
reactStrictMode: true,
// ...rest of your existing config
});ESM (next.config.mjs / next.config.ts):
import tempoNextjsPlugin from "tempo-sdk/nextjs/plugin";
export default tempoNextjsPlugin()({
reactStrictMode: true,
});You can also use the named export from tempo-sdk/nextjs:
import { tempoNextjsPlugin } from "tempo-sdk/nextjs";3. Set the TEMPO env var when launching dev
TEMPO=true next dev # Turbopack (Next.js 16+ default)
TEMPO=true next dev --webpack # Webpack modeWithout TEMPO set, the plugin is a complete no-op. You can leave
tempoNextjsPlugin() in next.config.js permanently — there is zero overhead
in normal dev. Tempo's start script sets TEMPO=true automatically when
launching the project under Tempo.
4. App Router — that's it
The loader auto-injects <meta name="tempo-project-root"> into the first JSX
<head> it sees (typically app/layout.tsx). Every JSX element in your project
gets data-tempo-filepath, data-tempo-position, data-tempo-modifiedts, and
data-tempo-supports-* attributes baked into the SWC-compiled output.
Example rendered HTML:
<button
data-tempo-filepath="components/Button.tsx"
data-tempo-position="142"
data-tempo-modifiedts="1708900000000"
data-tempo-supports-children="true"
>
Click me
</button>
<head>
<meta name="tempo-project-root" content="/abs/path/to/project" />
</head>5. Pages Router — one extra line in _document.tsx
<Head> from next/document is a custom React component (capital H), not
intrinsic JSX <head> (lowercase), so auto-inject doesn't fire there. Add the
project-root meta manually:
// pages/_document.tsx
import { Html, Head, Main, NextScript } from "next/document";
import { tempoProjectRoot } from "tempo-sdk/nextjs";
export default function Document() {
return (
<Html>
<Head>
<meta name="tempo-project-root" content={tempoProjectRoot()} />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}tempoProjectRoot() returns the same canonical root the loader uses for
data-tempo-filepath, so paths reconstruct correctly.
Options
All optional:
tempoNextjsPlugin({
autoInjectRootMeta: false, // disable head meta auto-inject
productionBuild: true, // run in `next build` too (rare)
computeAnnotatedPropsModule: "./tempo-strategy.js", // custom prop-computation
})(nextConfig);computeAnnotatedPropsModule is a path (not a function) because Turbopack
requires loader options to be JSON-serializable. The module's default export
must be a function (info, filepath, modifiedtsMs) => Record<string, PropValue>.
Composing with other wrappers (@next/mdx, etc.)
tempoNextjsPlugin follows the @next/mdx higher-order-config pattern, so it
composes naturally:
const tempoNextjsPlugin = require("tempo-sdk/nextjs");
const withMDX = require("@next/mdx")();
module.exports = tempoNextjsPlugin()(
withMDX({ reactStrictMode: true })
);tempoNextjsPlugin should be the outermost wrapper so its webpack/turbopack
chaining sees the inner wrappers' contributions.
Supported Next.js versions
| Next.js | Webpack | Turbopack | Notes |
|---|---|---|---|
| 16.x | --webpack flag | default | Validated end-to-end |
| 15.3 – 15.x | default | --turbo flag | Wrapper writes to turbopack.* |
| 13.0 – 15.2 | default | --turbo flag | Wrapper writes to experimental.turbo.* |
| 12.x | default | n/a | Webpack only; turbopack field omitted |
| < 12 | n/a | n/a | Out of scope |
The wrapper detects the version via next/package.json and writes to the
correct field automatically.
Vite usage
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { tempoVitePlugin } from "tempo-sdk";
export default defineConfig({
plugins: [tempoVitePlugin(), react()],
});Same TEMPO env-var gating, same data-tempo-* output as the Next.js plugin.
The Vite plugin additionally exposes an /__tempo/errors SSE endpoint for
streaming concrete compile errors to the design surface. It does not publish a
source-update stream or supervise source-to-visible HMR transactions; normal
updates belong to Vite and the official React plugin.
Canvas runtime surface
tempo-sdk/canvas exposes the bundler-agnostic runtime used by the generated
Canvas host:
CanvasHostaccepts either legacychildrenor literalcanvasModulesloaders. Passing both throwsCanvasHostContractErrorinstead of silently rendering an ambiguous host.CanvasImportBoundaryowns lazy import, Suspense, canvas-scoped error reporting, and current-owner mount success. It does not replay accepted modules or inspect Vite transactions.getCanvasHostRecoveryFeed()returns the document-localhost-recovery-feed. It carries exactly one product fact (module-load-failure: a canvas import rejection or an HMR module-evaluation failure) to the generated recovery adapter; no hot context, cycle number, owner success, or reload command crosses the SDK boundary.canvasHostCapabilitydeclares protocol version 1 withsupportsModules: true. It is defense-in-depth, not the primary compatibility gate.
Before generating .tempo/tempo-host.tsx, tempo-devserver runs the primary
installed-package check. An incompatible SDK fails before browser evaluation
with canvas_host_sdk_incompatible and an actionable install command. Tempo's
managed sidecar rollout first runs ensureTempoSdkLatestSpec; whether that
upgrade was attempted is included in the typed compatibility error. The rollout
contract is publish the compatible SDK first, verify the packed artifact, then
ship the devserver that requires it.
Expo usage
Expo support is for Tempo's web canvas runtime. It annotates Expo web output for selection and editing; it does not make native-device canvases editable.
Configure the Tempo-owned Expo sidecar app's Babel config:
// tempo/babel.config.js
const path = require("node:path");
const { tempoExpoBabelPlugin } = require("tempo-sdk/expo/plugin");
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: [
[
tempoExpoBabelPlugin,
{
root: path.resolve(__dirname, ".."),
},
],
],
};
};If the Expo app uses NativeWind-style className, pass
reactNativeClassName: true in the plugin options.
The sidecar Metro config must watch the parent project so tempo/ can import
real app components:
// tempo/metro.config.js
const path = require("node:path");
const { getDefaultConfig } = require("expo/metro-config");
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "..");
const sidecarNodeModules = path.join(projectRoot, "node_modules");
const config = getDefaultConfig(projectRoot);
const sidecarModule = (moduleName) =>
path.join(sidecarNodeModules, ...moduleName.split("/"));
const singletonModules = {
react: sidecarModule("react"),
"react-dom": sidecarModule("react-dom"),
"react-native": sidecarModule("react-native"),
"react-native-web": sidecarModule("react-native-web"),
"react/jsx-runtime": sidecarModule("react/jsx-runtime.js"),
"react/jsx-dev-runtime": sidecarModule("react/jsx-dev-runtime.js"),
};
config.watchFolders = Array.from(
new Set([...(config.watchFolders ?? []), workspaceRoot]),
);
config.resolver.nodeModulesPaths = Array.from(
new Set([
sidecarNodeModules,
path.join(workspaceRoot, "node_modules"),
...(config.resolver.nodeModulesPaths ?? []),
]),
);
config.resolver.disableHierarchicalLookup = true;
config.resolver.extraNodeModules = {
...(config.resolver.extraNodeModules ?? {}),
...singletonModules,
};
config.resolver.alias = {
...(config.resolver.alias ?? {}),
...singletonModules,
};
module.exports = config;Those singleton aliases are important for sidecar Expo projects. Without them,
Metro can resolve React Native files imported from the parent app against the
parent app's node_modules while the sidecar resolves its own runtime against
tempo/node_modules, which can produce duplicate React instances and invalid
hook-call failures.
Launch Expo web with TEMPO=true:
TEMPO=true npx expo start --webTempo discovers the printed local URL and probes /tempo-host; no fixed port is
required.
How it works
The Next.js plugin is a webpack-compatible loader (nextjs-loader.cjs) plus a
higher-order config wrapper (tempoNextjsPlugin) that registers the loader for
both Webpack and Turbopack from a single user-facing entrypoint.
The flow per source file:
- Resource gating —
.tsx/.jsxonly, excludenode_modules, requireprocess.env.TEMPO. - Read mtime via
fs.statSync. - Compute project-relative filepath via
path.relative(rootContext, …). - Call
annotateFilefrom@modules/annotationwith a callback that returns thedata-tempo-*props for each JSX element. The walker visits everyJSXElement, fragments are skipped, and props are inserted as JSX attribute strings at the end of each opening tag. - If the file contains an intrinsic JSX
<head>, splice<meta name="tempo-project-root" content="…" />into the post-annotation source. The injected meta does not itself carrydata-tempo-*attrs (it has no source location), which keeps every other element'sdata-tempo-positionpointing at the byte offset of<in the on-disk source file. - Return the annotated source. Errors at any step cause the loader to return
the original source unchanged —
correct or untouched, always.
The tempoNextjsPlugin wrapper:
- Adds an
enforce: "pre"rule underconfig.module.rulesfor Webpack (/.(tsx|jsx)$/). - Adds a
*.{tsx,jsx}rule undernextConfig.turbopack.rulesfor Turbopack withcondition: { all: [{ not: "foreign" }, "development"] }(excludes node_modules / Next internals AND production builds). - Chains an existing user-supplied
webpack(config, options)function and merges with any existingturbopack.rulesso other plugins are preserved. - No-ops entirely when
process.env.TEMPOis not set.
See SPEC_NEXTJS.md for the complete design specification.
Demo
pnpm --filter @tempo-modules/tempo-sdk-nextjs-demo dev # Turbopack on :3477
pnpm --filter @tempo-modules/tempo-sdk-nextjs-demo dev:webpack # Webpack on :3478Then:
curl -s localhost:3477/ | grep -oE 'data-tempo-filepath="[^"]+"' | sort -uShould print one line per source file rendered on the home page.
Tests
pnpm test # all 308 tests across both plugins
pnpm test:nextjs # 175 Next.js tests (~24s)
pnpm test:nextjs:unit # fast unit tests, no servers
pnpm test:nextjs:integration # live `next dev` against demo (Turbopack + Webpack)
pnpm test:nextjs:production # `next build` correctness (no annotations in prod)Test layers:
| File | Tests | Coverage |
|---|---|---|
| nextjs-loader.test.ts | 41 | Loader function — file filtering, default strategy, auto-inject, error handling |
| nextjs-wrapper.test.ts | 26 | tempoNextjsPlugin — webpack chaining, turbopack merging, version detection, options |
| nextjs-fixtures.test.ts | 40 | Project shapes — App/Pages Router, server/client components, all routing types |
| nextjs-published.test.ts | 14 | Built dist/*.cjs and dist/*.js — CJS callable, ESM imports, parity |
| nextjs-production.test.ts | 6 | next build produces zero data-tempo-* attributes |
| nextjs-e2e-correctness.test.ts | — | Live next dev against demo (both bundlers); every recorded data-tempo-position actually points at < in the on-disk source file |
Related docs
SPEC.md— Vite plugin specificationSPEC_NEXTJS.md— Next.js plugin specificationSPEC_TSC.md— TypeScript type-analyzer (used by both plugins fordata-tempo-supports-*)
