From caca1f44123c9f1b7d18b09149b3d40ad08f5a23 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 21 Jul 2026 12:59:27 -0400 Subject: [PATCH 01/15] feat(templates): add Next.js templates (JavaScript + TypeScript) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `nextjs` and `nextjs-ts` templates that run a Next.js App Router app on Harper via the @harperfast/nextjs plugin. Because the app runs inside Harper, server actions and server components read and write tables directly through the `tables` global — no separate API server and no network round-trip. Ports the "Doggy Management System" demo (schema, server actions, list/detail pages, client delete button) from the nextjs-example prior art, modernized to the current harper CLI, @harperfast/nextjs 2.x, Next.js 16, and React 19. Wires both templates into the catalog: templates.js, templates.d.ts, frameworks.js (new "Next.js" family), templates.test.js, package.json files[], and the root README. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 + lib/constants/frameworks.js | 2 + lib/constants/templates.d.ts | 8 +- lib/constants/templates.js | 20 ++- lib/constants/templates.test.js | 15 +- package.json | 4 +- template-nextjs-ts/README.md | 84 ++++++++++ template-nextjs-ts/_aiignore | 1 + template-nextjs-ts/_claude/launch.json | 11 ++ template-nextjs-ts/_env | 1 + template-nextjs-ts/_env.example | 1 + .../_github/workflow/deploy.yaml | 31 ++++ template-nextjs-ts/_gitignore | 34 ++++ template-nextjs-ts/_nvmrc | 1 + template-nextjs-ts/app/actions.ts | 45 ++++++ template-nextjs-ts/app/dogs/[id]/page.tsx | 44 +++++ template-nextjs-ts/app/dogs/page.tsx | 151 ++++++++++++++++++ template-nextjs-ts/app/layout.tsx | 85 ++++++++++ template-nextjs-ts/app/not-found.tsx | 10 ++ template-nextjs-ts/app/page.tsx | 55 +++++++ template-nextjs-ts/app/ui/DeleteButton.tsx | 22 +++ template-nextjs-ts/config.yaml | 21 +++ template-nextjs-ts/eslint.config.mjs | 18 +++ template-nextjs-ts/graphql.config.yml | 3 + template-nextjs-ts/harper.d.ts | 30 ++++ template-nextjs-ts/next.config.mjs | 6 + template-nextjs-ts/package.json | 31 ++++ template-nextjs-ts/schema.graphql | 7 + template-nextjs-ts/tsconfig.json | 23 +++ template-nextjs/README.md | 83 ++++++++++ template-nextjs/_aiignore | 1 + template-nextjs/_claude/launch.json | 11 ++ template-nextjs/_env | 1 + template-nextjs/_env.example | 1 + template-nextjs/_github/workflow/deploy.yaml | 31 ++++ template-nextjs/_gitignore | 34 ++++ template-nextjs/_nvmrc | 1 + template-nextjs/app/actions.js | 40 +++++ template-nextjs/app/dogs/[id]/page.js | 44 +++++ template-nextjs/app/dogs/page.js | 150 +++++++++++++++++ template-nextjs/app/layout.js | 84 ++++++++++ template-nextjs/app/not-found.js | 10 ++ template-nextjs/app/page.js | 55 +++++++ template-nextjs/app/ui/DeleteButton.js | 22 +++ template-nextjs/config.yaml | 21 +++ template-nextjs/eslint.config.mjs | 18 +++ template-nextjs/graphql.config.yml | 3 + template-nextjs/jsconfig.json | 7 + template-nextjs/next.config.mjs | 6 + template-nextjs/package.json | 27 ++++ template-nextjs/schema.graphql | 7 + 51 files changed, 1417 insertions(+), 6 deletions(-) create mode 100644 template-nextjs-ts/README.md create mode 100644 template-nextjs-ts/_aiignore create mode 100644 template-nextjs-ts/_claude/launch.json create mode 100644 template-nextjs-ts/_env create mode 100644 template-nextjs-ts/_env.example create mode 100644 template-nextjs-ts/_github/workflow/deploy.yaml create mode 100644 template-nextjs-ts/_gitignore create mode 100644 template-nextjs-ts/_nvmrc create mode 100644 template-nextjs-ts/app/actions.ts create mode 100644 template-nextjs-ts/app/dogs/[id]/page.tsx create mode 100644 template-nextjs-ts/app/dogs/page.tsx create mode 100644 template-nextjs-ts/app/layout.tsx create mode 100644 template-nextjs-ts/app/not-found.tsx create mode 100644 template-nextjs-ts/app/page.tsx create mode 100644 template-nextjs-ts/app/ui/DeleteButton.tsx create mode 100644 template-nextjs-ts/config.yaml create mode 100644 template-nextjs-ts/eslint.config.mjs create mode 100644 template-nextjs-ts/graphql.config.yml create mode 100644 template-nextjs-ts/harper.d.ts create mode 100644 template-nextjs-ts/next.config.mjs create mode 100644 template-nextjs-ts/package.json create mode 100644 template-nextjs-ts/schema.graphql create mode 100644 template-nextjs-ts/tsconfig.json create mode 100644 template-nextjs/README.md create mode 100644 template-nextjs/_aiignore create mode 100644 template-nextjs/_claude/launch.json create mode 100644 template-nextjs/_env create mode 100644 template-nextjs/_env.example create mode 100644 template-nextjs/_github/workflow/deploy.yaml create mode 100644 template-nextjs/_gitignore create mode 100644 template-nextjs/_nvmrc create mode 100644 template-nextjs/app/actions.js create mode 100644 template-nextjs/app/dogs/[id]/page.js create mode 100644 template-nextjs/app/dogs/page.js create mode 100644 template-nextjs/app/layout.js create mode 100644 template-nextjs/app/not-found.js create mode 100644 template-nextjs/app/page.js create mode 100644 template-nextjs/app/ui/DeleteButton.js create mode 100644 template-nextjs/config.yaml create mode 100644 template-nextjs/eslint.config.mjs create mode 100644 template-nextjs/graphql.config.yml create mode 100644 template-nextjs/jsconfig.json create mode 100644 template-nextjs/next.config.mjs create mode 100644 template-nextjs/package.json create mode 100644 template-nextjs/schema.graphql diff --git a/README.md b/README.md index eaea8c8..2e703e3 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ Currently supported template presets include: - `vanilla-ts` - `react` - `react-ts` +- `nextjs` +- `nextjs-ts` You can use `.` for the project name to scaffold in the current directory. diff --git a/lib/constants/frameworks.js b/lib/constants/frameworks.js index 8429087..2108594 100644 --- a/lib/constants/frameworks.js +++ b/lib/constants/frameworks.js @@ -5,6 +5,7 @@ const { blue, cyan, green, + magenta, yellow, } = colors; @@ -28,6 +29,7 @@ const frameworkMeta = { vanilla: { display: 'Vanilla', color: yellow }, react: { display: 'React', color: cyan }, vue: { display: 'Vue', color: green }, + nextjs: { display: 'Next.js', color: magenta }, }; /** diff --git a/lib/constants/templates.d.ts b/lib/constants/templates.d.ts index 0090253..8cd565b 100644 --- a/lib/constants/templates.d.ts +++ b/lib/constants/templates.d.ts @@ -1,4 +1,4 @@ -export type Framework = 'vanilla' | 'react' | 'vue'; +export type Framework = 'vanilla' | 'react' | 'vue' | 'nextjs'; export type TemplateName = | 'vanilla-ts' @@ -10,7 +10,9 @@ export type TemplateName = | 'vue-ts' | 'vue' | 'vue-ts-ssr' - | 'vue-ssr'; + | 'vue-ssr' + | 'nextjs-ts' + | 'nextjs'; export interface TemplateInfo { /** The canonical template name (e.g. 'vanilla', 'react-ts'). Used to scaffold. */ @@ -51,4 +53,6 @@ export declare const templateNames: readonly [ 'vue', 'vue-ts-ssr', 'vue-ssr', + 'nextjs-ts', + 'nextjs', ]; diff --git a/lib/constants/templates.js b/lib/constants/templates.js index 1a4ffa2..af6be1d 100644 --- a/lib/constants/templates.js +++ b/lib/constants/templates.js @@ -1,7 +1,7 @@ const CREATE_HARPER_TREE = 'https://github.com/HarperFast/create-harper/tree/main'; /** - * @typedef {'vanilla' | 'react' | 'vue'} Framework + * @typedef {'vanilla' | 'react' | 'vue' | 'nextjs'} Framework */ /** @@ -131,6 +131,24 @@ export const templates = [ typescript: false, ssr: true, }), + template({ + name: 'nextjs-ts', + framework: 'nextjs', + title: 'Next.js + TypeScript', + description: 'A type-safe Next.js app that reads and writes Harper tables directly from server actions.', + tags: ['Next.js', 'TypeScript', 'React', 'App Router'], + typescript: true, + ssr: false, + }), + template({ + name: 'nextjs', + framework: 'nextjs', + title: 'Next.js', + description: "A Next.js app wired to Harper's Resource API, reading and writing tables from server actions.", + tags: ['Next.js', 'React', 'App Router'], + typescript: false, + ssr: false, + }), ]; /** diff --git a/lib/constants/templates.test.js b/lib/constants/templates.test.js index d78a826..0cadc8b 100644 --- a/lib/constants/templates.test.js +++ b/lib/constants/templates.test.js @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest'; import { frameworks } from './frameworks.js'; import { templateNames, templates as catalog } from './templates.js'; -const { blue, cyan, green, yellow } = colors; +const { blue, cyan, green, magenta, yellow } = colors; describe('templates catalog', () => { test('templateNames is the exact, ordered list of names (keeps templates.d.ts honest)', () => { @@ -18,6 +18,8 @@ describe('templates catalog', () => { 'vue', 'vue-ts-ssr', 'vue-ssr', + 'nextjs-ts', + 'nextjs', ]); }); @@ -42,7 +44,7 @@ describe('templates catalog', () => { describe('frameworks (derived from the catalog)', () => { test('groups templates by framework in catalog order', () => { - expect(frameworks.map((f) => f.name)).toEqual(['vanilla', 'react', 'vue']); + expect(frameworks.map((f) => f.name)).toEqual(['vanilla', 'react', 'vue', 'nextjs']); }); test('matches the expected display names and colors', () => { @@ -78,6 +80,15 @@ describe('frameworks (derived from the catalog)', () => { { name: 'vue-ssr', display: 'JavaScript + SSR', color: yellow }, ], }, + { + name: 'nextjs', + display: 'Next.js', + color: magenta, + variants: [ + { name: 'nextjs-ts', display: 'TypeScript', color: blue }, + { name: 'nextjs', display: 'JavaScript', color: yellow }, + ], + }, ]); }); }); diff --git a/package.json b/package.json index e5acb61..b4750ec 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,9 @@ "template-vue/", "template-vue-ts/", "template-vue-ssr/", - "template-vue-ts-ssr/" + "template-vue-ts-ssr/", + "template-nextjs/", + "template-nextjs-ts/" ], "scripts": { "commitlint": "commitlint --edit", diff --git a/template-nextjs-ts/README.md b/template-nextjs-ts/README.md new file mode 100644 index 0000000..023b2e6 --- /dev/null +++ b/template-nextjs-ts/README.md @@ -0,0 +1,84 @@ +# your-project-name-here + +A type-safe [Next.js](https://nextjs.org) app running on Harper via [`@harperfast/nextjs`](https://github.com/HarperFast/nextjs). Your new app is now ready for development! + +Because the app runs _inside_ Harper, server-side code (server actions and server components) can read and write your database directly through the [Resource API](https://docs.harperdb.io/docs/technical-details/reference/resources) — no separate API server and no network round-trip. + +Here's what you should do next: + +## Installation + +Make sure you have [installed Harper](https://docs.harperdb.io/docs/deployments/install-harper): + +```sh +npm install -g harper +``` + +## Development + +Start the app: + +```sh +npm run dev +``` + +Then open [http://localhost:9926](http://localhost:9926) 🎉 + +The included **Doggy Management System** demo shows the whole pattern end to end: + +- [`schema.graphql`](./schema.graphql) defines a `Dog` table. +- [`harper.d.ts`](./harper.d.ts) declares the matching types for the `tables` global. +- [`app/actions.ts`](./app/actions.ts) defines server actions (`listDogs`, `getDog`, `createDog`, `deleteDog`) that talk to `tables` with full type safety. +- [`app/dogs/page.tsx`](./app/dogs/page.tsx) (a server component) and [`app/ui/DeleteButton.tsx`](./app/ui/DeleteButton.tsx) (a client component) both call those same actions. + +### Define Your Schema + +1. Edit [`schema.graphql`](./schema.graphql) (or add more `.graphql` files). +2. Craft your schema by hand — every `@table` becomes available on the `tables` global. +3. Mirror the shape in [`harper.d.ts`](./harper.d.ts) so your server code stays type-safe. (The `@harperfast/schema-codegen` component can also generate these types for you.) +4. Save your changes. + +### Access Harper From Server Code + +Any server-side module that uses the Harper globals must first import the `harper` package: + +```ts +'use server'; + +import 'harper'; +import type { DogRecord } from '@/harper'; + +export async function listDogs(): Promise { + const dogs: DogRecord[] = []; + for await (const dog of tables.Dog.search()) { + dogs.push(dog); + } + return dogs; +} +``` + +Following Next.js best practices, put this data access in **server actions** so that both server _and_ client components can share the same functions. + +## Deployment + +When you are ready, head to [https://fabric.harper.fast/](https://fabric.harper.fast/), log in to your account, and create a cluster. + +Come back and log your local CLI into your cluster: + +```sh +harper login +``` + +Then deploy your app: + +```sh +npm run deploy +``` + +`npm run deploy` uploads the component and Harper builds the Next.js app on the server when it starts — no local build required. For faster startups you can instead deploy a prebuilt app: run it once with `npm run dev` (so Harper links its runtime into `node_modules`), then `npm run build`, add `prebuilt: true` under `@harperfast/nextjs` in [`config.yaml`](./config.yaml), and deploy. + +## Keep Going! + +For more on building Harper applications, see the [getting started guide](https://docs.harperdb.io/docs). + +For more on Harper Components, see the [Components documentation](https://docs.harperdb.io/docs/reference/components). diff --git a/template-nextjs-ts/_aiignore b/template-nextjs-ts/_aiignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/template-nextjs-ts/_aiignore @@ -0,0 +1 @@ +.env diff --git a/template-nextjs-ts/_claude/launch.json b/template-nextjs-ts/_claude/launch.json new file mode 100644 index 0000000..a2d9719 --- /dev/null +++ b/template-nextjs-ts/_claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "harper", + "runtimeExecutable": "your-package-manager-here", + "runtimeArgs": ["run", "dev"], + "port": 9926 + } + ] +} diff --git a/template-nextjs-ts/_env b/template-nextjs-ts/_env new file mode 100644 index 0000000..9f42293 --- /dev/null +++ b/template-nextjs-ts/_env @@ -0,0 +1 @@ +CLI_TARGET='your-fabric.harper.fast-cluster-url-here' diff --git a/template-nextjs-ts/_env.example b/template-nextjs-ts/_env.example new file mode 100644 index 0000000..d071546 --- /dev/null +++ b/template-nextjs-ts/_env.example @@ -0,0 +1 @@ +CLI_TARGET='YOUR_FABRIC.HARPER.FAST_CLUSTER_URL_HERE' diff --git a/template-nextjs-ts/_github/workflow/deploy.yaml b/template-nextjs-ts/_github/workflow/deploy.yaml new file mode 100644 index 0000000..2dc4dc5 --- /dev/null +++ b/template-nextjs-ts/_github/workflow/deploy.yaml @@ -0,0 +1,31 @@ +name: Deploy to Harper Fabric +on: + workflow_dispatch: +# push: +# branches: +# - main + +concurrency: + group: main + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + fetch-tags: true + - name: Set up Node.js + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + with: + cache: 'npm' + node-version-file: '.nvmrc' + - name: Install dependencies + run: npm ci + - name: Run lint + run: npm run lint + - name: Build & deploy + run: npm run deploy diff --git a/template-nextjs-ts/_gitignore b/template-nextjs-ts/_gitignore new file mode 100644 index 0000000..2b71457 --- /dev/null +++ b/template-nextjs-ts/_gitignore @@ -0,0 +1,34 @@ +.DS_Store + +# dependencies +node_modules/ +.pnp +.pnp.* + +# next.js +/.next/ +/out/ +/build + +# debug logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# testing +/coverage + +# typescript +*.tsbuildinfo +next-env.d.ts + +# dotenv environment variable files +.env +.env.* +!.env.example + +# misc +*.pem +.vercel diff --git a/template-nextjs-ts/_nvmrc b/template-nextjs-ts/_nvmrc new file mode 100644 index 0000000..32f8c50 --- /dev/null +++ b/template-nextjs-ts/_nvmrc @@ -0,0 +1 @@ +24.13.1 diff --git a/template-nextjs-ts/app/actions.ts b/template-nextjs-ts/app/actions.ts new file mode 100644 index 0000000..d0e61e9 --- /dev/null +++ b/template-nextjs-ts/app/actions.ts @@ -0,0 +1,45 @@ +'use server'; + +import 'harper'; +import type { DogRecord } from '@/harper'; +import { revalidatePath } from 'next/cache'; + +// Server actions run *inside* Harper, so they read and write tables directly through the `tables` +// global — no network round-trip to a separate API. Importing 'harper' makes the runtime globals +// available. Because these are server actions, both server and client components can call them. + +export async function listDogs(): Promise { + const dogs: DogRecord[] = []; + for await (const dog of tables.Dog.search()) { + dogs.push({ id: dog.id, name: dog.name, breed: dog.breed, age: dog.age, color: dog.color }); + } + return dogs; +} + +export async function getDog(id: string): Promise { + return tables.Dog.get(id); +} + +export async function createDog(formData: FormData): Promise { + const name = formData.get('name'); + const breed = formData.get('breed'); + const age = Number(formData.get('age')); + const color = formData.get('color'); + + if (!name || !breed || !age || !color) { + throw new Error('All fields are required'); + } + + await tables.Dog.create({ + name: String(name), + breed: String(breed), + age, + color: String(color), + }); + revalidatePath('/dogs'); +} + +export async function deleteDog(id: string): Promise { + await tables.Dog.delete(id); + revalidatePath('/dogs'); +} diff --git a/template-nextjs-ts/app/dogs/[id]/page.tsx b/template-nextjs-ts/app/dogs/[id]/page.tsx new file mode 100644 index 0000000..ee707d7 --- /dev/null +++ b/template-nextjs-ts/app/dogs/[id]/page.tsx @@ -0,0 +1,44 @@ +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { getDog } from '../../actions'; + +export const dynamic = 'force-dynamic'; + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + // In the App Router, `params` is a promise — await it before reading route values. + const { id } = await params; + const dog = await getDog(id); + + if (!dog) { + notFound(); + } + + return ( +
+ + ← Back to all dogs + +
+

+ {dog.name} +

+
+
Breed
+
{dog.breed}
+
Age
+
{dog.age} years
+
Color
+
{dog.color}
+
+
+
+ ); +} diff --git a/template-nextjs-ts/app/dogs/page.tsx b/template-nextjs-ts/app/dogs/page.tsx new file mode 100644 index 0000000..39e94ad --- /dev/null +++ b/template-nextjs-ts/app/dogs/page.tsx @@ -0,0 +1,151 @@ +import Link from 'next/link'; +import type { CSSProperties } from 'react'; +import { createDog, listDogs } from '../actions'; +import DeleteButton from '../ui/DeleteButton'; + +// Read fresh data on every request instead of caching at build time. +export const dynamic = 'force-dynamic'; + +const inputStyle: CSSProperties = { + width: '100%', + padding: '0.75rem', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '1rem', + boxSizing: 'border-box', +}; + +const labelStyle: CSSProperties = { + display: 'block', + marginBottom: '0.5rem', + fontSize: '0.875rem', + fontWeight: '500', + color: '#374151', +}; + +const cellHeader: CSSProperties = { + display: 'grid', + gridTemplateColumns: '2fr 2fr 1fr 1fr 1fr', + gap: '1rem', + padding: '1rem 0', + alignItems: 'center', +}; + +export default async function Page() { + const dogs = await listDogs(); + + return ( +
+ {/* Add-a-dog form. Submitting calls the `createDog` server action directly. */} +
+

+ Add New Dog +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + {/* Dogs list */} +
+
+

+ Dogs ({dogs.length}) +

+
+
+
+
Name
+
Breed
+
Age
+
Color
+
Actions
+
+ {dogs.map((dog) => ( +
+
+ + {dog.name} + +
+
{dog.breed}
+
{dog.age} years
+
{dog.color}
+
+ +
+
+ ))} + {dogs.length === 0 && ( +
+ No dogs found. Add one above! +
+ )} +
+
+
+ ); +} diff --git a/template-nextjs-ts/app/layout.tsx b/template-nextjs-ts/app/layout.tsx new file mode 100644 index 0000000..2a7263f --- /dev/null +++ b/template-nextjs-ts/app/layout.tsx @@ -0,0 +1,85 @@ +import Link from 'next/link'; +import type { ReactNode } from 'react'; + +export const metadata = { + title: 'Doggy Management System', + description: 'A Next.js app powered by Harper', +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + +
+ +
+
+ {children} +
+
+
+

+ Built with{' '} + + Harper + {' '} + &{' '} + + Next.js + +

+
+
+ + + ); +} diff --git a/template-nextjs-ts/app/not-found.tsx b/template-nextjs-ts/app/not-found.tsx new file mode 100644 index 0000000..bcb6761 --- /dev/null +++ b/template-nextjs-ts/app/not-found.tsx @@ -0,0 +1,10 @@ +export default function NotFound() { + return ( +
+

🐶 404 - Page Not Found

+

+ Sorry, the page you are looking for does not exist. +

+
+ ); +} diff --git a/template-nextjs-ts/app/page.tsx b/template-nextjs-ts/app/page.tsx new file mode 100644 index 0000000..9730ebd --- /dev/null +++ b/template-nextjs-ts/app/page.tsx @@ -0,0 +1,55 @@ +import Link from 'next/link'; + +export default function Page() { + return ( +
+

+ Doggy Management System +

+

+ An application for managing dog records. View, add, and organize your canine database with ease. +

+ +
+ + 🐕 Manage Dogs → + +

+ Add, view, and delete dog records in real time +

+
+
+ ); +} diff --git a/template-nextjs-ts/app/ui/DeleteButton.tsx b/template-nextjs-ts/app/ui/DeleteButton.tsx new file mode 100644 index 0000000..2b3a10e --- /dev/null +++ b/template-nextjs-ts/app/ui/DeleteButton.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { deleteDog } from '../actions'; + +export default function DeleteButton({ dogId }: { dogId: string }) { + return ( + + ); +} diff --git a/template-nextjs-ts/config.yaml b/template-nextjs-ts/config.yaml new file mode 100644 index 0000000..a458c97 --- /dev/null +++ b/template-nextjs-ts/config.yaml @@ -0,0 +1,21 @@ +# yaml-language-server: $schema=./node_modules/harper/config-app.schema.json + +# This is the configuration file for the application. +# It specifies built-in Harper components that will load the specified feature and files. +# For more information, see https://docs.harperdb.io/docs/reference/components/built-in-extensions + +# Load Environment Variables from the specified file +# loadEnv: +# files: '.env' + +# Reads GraphQL schemas to define the schema of database/tables/attributes. +graphqlSchema: + files: 'schema.graphql' + +# Runs the Next.js app as a Harper component. Under `harper dev` it serves the Next.js dev server +# with hot-module reloading; under `harper run` it builds and serves the app. Server-side code +# (server actions and server components) can read and write Harper tables directly through the +# `tables` global after importing the `harper` package — see app/actions.js. Because this plugin +# owns HTTP routing for the app, there is no `static` or `rest` handler here. +'@harperfast/nextjs': + package: '@harperfast/nextjs' diff --git a/template-nextjs-ts/eslint.config.mjs b/template-nextjs-ts/eslint.config.mjs new file mode 100644 index 0000000..78d429c --- /dev/null +++ b/template-nextjs-ts/eslint.config.mjs @@ -0,0 +1,18 @@ +import next from 'eslint-config-next'; + +const eslintConfig = [ + ...next, + { + // `tables` is a global injected by the Harper runtime for server-side code. + languageOptions: { + globals: { + tables: 'readonly', + }, + }, + }, + { + ignores: ['.next/', 'node_modules/'], + }, +]; + +export default eslintConfig; diff --git a/template-nextjs-ts/graphql.config.yml b/template-nextjs-ts/graphql.config.yml new file mode 100644 index 0000000..68d14fb --- /dev/null +++ b/template-nextjs-ts/graphql.config.yml @@ -0,0 +1,3 @@ +schema: schema.graphql +include: node_modules/harper/schema.graphql +documents: '**/*.graphql' diff --git a/template-nextjs-ts/harper.d.ts b/template-nextjs-ts/harper.d.ts new file mode 100644 index 0000000..630157e --- /dev/null +++ b/template-nextjs-ts/harper.d.ts @@ -0,0 +1,30 @@ +// Ambient type declarations for the Harper runtime. +// +// Harper injects a `tables` global for server-side code, with one entry per `@table` defined in +// schema.graphql. These hand-written declarations give you type-safe access to your data. As you +// add tables and columns to the schema, mirror them here. (The `@harperfast/schema-codegen` +// component can also generate these for you — see the other create-harper templates.) + +export interface DogRecord { + id: string; + name: string; + breed: string; + age: number; + color: string; +} + +interface HarperTable { + get(id: string): Promise; + search(query?: unknown): AsyncIterable; + create(record: Omit & { id?: string }): Promise; + delete(id: string): Promise; +} + +declare global { + const tables: { + Dog: HarperTable; + }; +} + +// `import 'harper'` loads the runtime globals as a side effect; it exposes no typed exports here. +declare module 'harper' {} diff --git a/template-nextjs-ts/next.config.mjs b/template-nextjs-ts/next.config.mjs new file mode 100644 index 0000000..68f2254 --- /dev/null +++ b/template-nextjs-ts/next.config.mjs @@ -0,0 +1,6 @@ +import { withHarper } from '@harperfast/nextjs'; + +// `withHarper` wires this Next.js app into Harper: it marks the `harper` package as a server +// external so `import 'harper'` resolves to the running Harper runtime (rather than being bundled), +// giving server-side code access to the `tables` global. Add your own Next.js config inside. +export default withHarper({}); diff --git a/template-nextjs-ts/package.json b/template-nextjs-ts/package.json new file mode 100644 index 0000000..5444855 --- /dev/null +++ b/template-nextjs-ts/package.json @@ -0,0 +1,31 @@ +{ + "name": "your-package-name-here", + "version": "0.0.0", + "private": true, + "repository": "github:HarperFast/create-harper", + "scripts": { + "agent:run": "npx -y @harperfast/agent@latest", + "agent:skills:update": "npx -y skills@latest add harperfast/skills --all --yes", + "start": "harper run .", + "dev": "harper dev .", + "build": "next build", + "lint": "eslint .", + "format": "prettier --write .", + "deploy": "harper deploy_component . restart=true replicated=true" + }, + "dependencies": { + "@harperfast/nextjs": "^2.2.1", + "next": "^16.2.11", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", + "eslint": "^9.27.0", + "eslint-config-next": "^16.2.11", + "prettier": "^3.8.1", + "typescript": "^5.9.3" + } +} diff --git a/template-nextjs-ts/schema.graphql b/template-nextjs-ts/schema.graphql new file mode 100644 index 0000000..0253ba0 --- /dev/null +++ b/template-nextjs-ts/schema.graphql @@ -0,0 +1,7 @@ +type Dog @table @export { + id: ID @primaryKey + name: String + breed: String + age: Int + color: String +} diff --git a/template-nextjs-ts/tsconfig.json b/template-nextjs-ts/tsconfig.json new file mode 100644 index 0000000..37f533a --- /dev/null +++ b/template-nextjs-ts/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/template-nextjs/README.md b/template-nextjs/README.md new file mode 100644 index 0000000..3910835 --- /dev/null +++ b/template-nextjs/README.md @@ -0,0 +1,83 @@ +# your-project-name-here + +A [Next.js](https://nextjs.org) app running on Harper via [`@harperfast/nextjs`](https://github.com/HarperFast/nextjs). Your new app is now ready for development! + +Because the app runs _inside_ Harper, server-side code (server actions and server components) can read and write your database directly through the [Resource API](https://docs.harperdb.io/docs/technical-details/reference/resources) — no separate API server and no network round-trip. + +Here's what you should do next: + +## Installation + +Make sure you have [installed Harper](https://docs.harperdb.io/docs/deployments/install-harper): + +```sh +npm install -g harper +``` + +## Development + +Start the app: + +```sh +npm run dev +``` + +Then open [http://localhost:9926](http://localhost:9926) 🎉 + +The included **Doggy Management System** demo shows the whole pattern end to end: + +- [`schema.graphql`](./schema.graphql) defines a `Dog` table. +- [`app/actions.js`](./app/actions.js) defines server actions (`listDogs`, `getDog`, `createDog`, `deleteDog`) that talk to the `tables` global. +- [`app/dogs/page.js`](./app/dogs/page.js) (a server component) and [`app/ui/DeleteButton.js`](./app/ui/DeleteButton.js) (a client component) both call those same actions. + +### Define Your Schema + +1. Edit [`schema.graphql`](./schema.graphql) (or add more `.graphql` files). +2. Craft your schema by hand — every `@table` becomes available on the `tables` global. +3. Save your changes. + +These schemas are the heart of a Harper app, specifying which tables you want and what attributes they should have. + +### Access Harper From Server Code + +Any server-side module that uses the Harper globals must first import the `harper` package: + +```js +'use server'; + +import 'harper'; + +export async function listDogs() { + const dogs = []; + for await (const dog of tables.Dog.search()) { + dogs.push(dog); + } + return dogs; +} +``` + +Following Next.js best practices, put this data access in **server actions** so that both server _and_ client components can share the same functions. + +## Deployment + +When you are ready, head to [https://fabric.harper.fast/](https://fabric.harper.fast/), log in to your account, and create a cluster. + +Come back and log your local CLI into your cluster: + +```sh +harper login +``` + +Then deploy your app: + +```sh +npm run deploy +``` + +`npm run deploy` uploads the component and Harper builds the Next.js app on the server when it starts — no local build required. For faster startups you can instead deploy a prebuilt app: run it once with `npm run dev` (so Harper links its runtime into `node_modules`), then `npm run build`, add `prebuilt: true` under `@harperfast/nextjs` in [`config.yaml`](./config.yaml), and deploy. + +## Keep Going! + +For more on building Harper applications, see the [getting started guide](https://docs.harperdb.io/docs). + +For more on Harper Components, see the [Components documentation](https://docs.harperdb.io/docs/reference/components). diff --git a/template-nextjs/_aiignore b/template-nextjs/_aiignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/template-nextjs/_aiignore @@ -0,0 +1 @@ +.env diff --git a/template-nextjs/_claude/launch.json b/template-nextjs/_claude/launch.json new file mode 100644 index 0000000..a2d9719 --- /dev/null +++ b/template-nextjs/_claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "harper", + "runtimeExecutable": "your-package-manager-here", + "runtimeArgs": ["run", "dev"], + "port": 9926 + } + ] +} diff --git a/template-nextjs/_env b/template-nextjs/_env new file mode 100644 index 0000000..9f42293 --- /dev/null +++ b/template-nextjs/_env @@ -0,0 +1 @@ +CLI_TARGET='your-fabric.harper.fast-cluster-url-here' diff --git a/template-nextjs/_env.example b/template-nextjs/_env.example new file mode 100644 index 0000000..d071546 --- /dev/null +++ b/template-nextjs/_env.example @@ -0,0 +1 @@ +CLI_TARGET='YOUR_FABRIC.HARPER.FAST_CLUSTER_URL_HERE' diff --git a/template-nextjs/_github/workflow/deploy.yaml b/template-nextjs/_github/workflow/deploy.yaml new file mode 100644 index 0000000..2dc4dc5 --- /dev/null +++ b/template-nextjs/_github/workflow/deploy.yaml @@ -0,0 +1,31 @@ +name: Deploy to Harper Fabric +on: + workflow_dispatch: +# push: +# branches: +# - main + +concurrency: + group: main + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + fetch-tags: true + - name: Set up Node.js + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + with: + cache: 'npm' + node-version-file: '.nvmrc' + - name: Install dependencies + run: npm ci + - name: Run lint + run: npm run lint + - name: Build & deploy + run: npm run deploy diff --git a/template-nextjs/_gitignore b/template-nextjs/_gitignore new file mode 100644 index 0000000..2b71457 --- /dev/null +++ b/template-nextjs/_gitignore @@ -0,0 +1,34 @@ +.DS_Store + +# dependencies +node_modules/ +.pnp +.pnp.* + +# next.js +/.next/ +/out/ +/build + +# debug logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# testing +/coverage + +# typescript +*.tsbuildinfo +next-env.d.ts + +# dotenv environment variable files +.env +.env.* +!.env.example + +# misc +*.pem +.vercel diff --git a/template-nextjs/_nvmrc b/template-nextjs/_nvmrc new file mode 100644 index 0000000..32f8c50 --- /dev/null +++ b/template-nextjs/_nvmrc @@ -0,0 +1 @@ +24.13.1 diff --git a/template-nextjs/app/actions.js b/template-nextjs/app/actions.js new file mode 100644 index 0000000..049c12c --- /dev/null +++ b/template-nextjs/app/actions.js @@ -0,0 +1,40 @@ +'use server'; + +/* global tables */ +import 'harper'; +import { revalidatePath } from 'next/cache'; + +// Server actions run *inside* Harper, so they read and write tables directly through the `tables` +// global — no network round-trip to a separate API. Importing 'harper' makes the runtime globals +// available. Because these are server actions, both server and client components can call them. + +export async function listDogs() { + const dogs = []; + for await (const dog of tables.Dog.search()) { + dogs.push({ id: dog.id, name: dog.name, breed: dog.breed, age: dog.age, color: dog.color }); + } + return dogs; +} + +export async function getDog(id) { + return tables.Dog.get(id); +} + +export async function createDog(formData) { + const name = formData.get('name'); + const breed = formData.get('breed'); + const age = Number(formData.get('age')); + const color = formData.get('color'); + + if (!name || !breed || !age || !color) { + throw new Error('All fields are required'); + } + + await tables.Dog.create({ name, breed, age, color }); + revalidatePath('/dogs'); +} + +export async function deleteDog(id) { + await tables.Dog.delete(id); + revalidatePath('/dogs'); +} diff --git a/template-nextjs/app/dogs/[id]/page.js b/template-nextjs/app/dogs/[id]/page.js new file mode 100644 index 0000000..450209a --- /dev/null +++ b/template-nextjs/app/dogs/[id]/page.js @@ -0,0 +1,44 @@ +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { getDog } from '../../actions'; + +export const dynamic = 'force-dynamic'; + +export default async function Page({ params }) { + // In the App Router, `params` is a promise — await it before reading route values. + const { id } = await params; + const dog = await getDog(id); + + if (!dog) { + notFound(); + } + + return ( +
+ + ← Back to all dogs + +
+

+ {dog.name} +

+
+
Breed
+
{dog.breed}
+
Age
+
{dog.age} years
+
Color
+
{dog.color}
+
+
+
+ ); +} diff --git a/template-nextjs/app/dogs/page.js b/template-nextjs/app/dogs/page.js new file mode 100644 index 0000000..9f4a5a4 --- /dev/null +++ b/template-nextjs/app/dogs/page.js @@ -0,0 +1,150 @@ +import Link from 'next/link'; +import { createDog, listDogs } from '../actions'; +import DeleteButton from '../ui/DeleteButton'; + +// Read fresh data on every request instead of caching at build time. +export const dynamic = 'force-dynamic'; + +const inputStyle = { + width: '100%', + padding: '0.75rem', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '1rem', + boxSizing: 'border-box', +}; + +const labelStyle = { + display: 'block', + marginBottom: '0.5rem', + fontSize: '0.875rem', + fontWeight: '500', + color: '#374151', +}; + +const cellHeader = { + display: 'grid', + gridTemplateColumns: '2fr 2fr 1fr 1fr 1fr', + gap: '1rem', + padding: '1rem 0', + alignItems: 'center', +}; + +export default async function Page() { + const dogs = await listDogs(); + + return ( +
+ {/* Add-a-dog form. Submitting calls the `createDog` server action directly. */} +
+

+ Add New Dog +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + {/* Dogs list */} +
+
+

+ Dogs ({dogs.length}) +

+
+
+
+
Name
+
Breed
+
Age
+
Color
+
Actions
+
+ {dogs.map((dog) => ( +
+
+ + {dog.name} + +
+
{dog.breed}
+
{dog.age} years
+
{dog.color}
+
+ +
+
+ ))} + {dogs.length === 0 && ( +
+ No dogs found. Add one above! +
+ )} +
+
+
+ ); +} diff --git a/template-nextjs/app/layout.js b/template-nextjs/app/layout.js new file mode 100644 index 0000000..3da934a --- /dev/null +++ b/template-nextjs/app/layout.js @@ -0,0 +1,84 @@ +import Link from 'next/link'; + +export const metadata = { + title: 'Doggy Management System', + description: 'A Next.js app powered by Harper', +}; + +export default function RootLayout({ children }) { + return ( + + +
+ +
+
+ {children} +
+
+
+

+ Built with{' '} + + Harper + {' '} + &{' '} + + Next.js + +

+
+
+ + + ); +} diff --git a/template-nextjs/app/not-found.js b/template-nextjs/app/not-found.js new file mode 100644 index 0000000..bcb6761 --- /dev/null +++ b/template-nextjs/app/not-found.js @@ -0,0 +1,10 @@ +export default function NotFound() { + return ( +
+

🐶 404 - Page Not Found

+

+ Sorry, the page you are looking for does not exist. +

+
+ ); +} diff --git a/template-nextjs/app/page.js b/template-nextjs/app/page.js new file mode 100644 index 0000000..9730ebd --- /dev/null +++ b/template-nextjs/app/page.js @@ -0,0 +1,55 @@ +import Link from 'next/link'; + +export default function Page() { + return ( +
+

+ Doggy Management System +

+

+ An application for managing dog records. View, add, and organize your canine database with ease. +

+ +
+ + 🐕 Manage Dogs → + +

+ Add, view, and delete dog records in real time +

+
+
+ ); +} diff --git a/template-nextjs/app/ui/DeleteButton.js b/template-nextjs/app/ui/DeleteButton.js new file mode 100644 index 0000000..25006cb --- /dev/null +++ b/template-nextjs/app/ui/DeleteButton.js @@ -0,0 +1,22 @@ +'use client'; + +import { deleteDog } from '../actions'; + +export default function DeleteButton({ dogId }) { + return ( + + ); +} diff --git a/template-nextjs/config.yaml b/template-nextjs/config.yaml new file mode 100644 index 0000000..a458c97 --- /dev/null +++ b/template-nextjs/config.yaml @@ -0,0 +1,21 @@ +# yaml-language-server: $schema=./node_modules/harper/config-app.schema.json + +# This is the configuration file for the application. +# It specifies built-in Harper components that will load the specified feature and files. +# For more information, see https://docs.harperdb.io/docs/reference/components/built-in-extensions + +# Load Environment Variables from the specified file +# loadEnv: +# files: '.env' + +# Reads GraphQL schemas to define the schema of database/tables/attributes. +graphqlSchema: + files: 'schema.graphql' + +# Runs the Next.js app as a Harper component. Under `harper dev` it serves the Next.js dev server +# with hot-module reloading; under `harper run` it builds and serves the app. Server-side code +# (server actions and server components) can read and write Harper tables directly through the +# `tables` global after importing the `harper` package — see app/actions.js. Because this plugin +# owns HTTP routing for the app, there is no `static` or `rest` handler here. +'@harperfast/nextjs': + package: '@harperfast/nextjs' diff --git a/template-nextjs/eslint.config.mjs b/template-nextjs/eslint.config.mjs new file mode 100644 index 0000000..78d429c --- /dev/null +++ b/template-nextjs/eslint.config.mjs @@ -0,0 +1,18 @@ +import next from 'eslint-config-next'; + +const eslintConfig = [ + ...next, + { + // `tables` is a global injected by the Harper runtime for server-side code. + languageOptions: { + globals: { + tables: 'readonly', + }, + }, + }, + { + ignores: ['.next/', 'node_modules/'], + }, +]; + +export default eslintConfig; diff --git a/template-nextjs/graphql.config.yml b/template-nextjs/graphql.config.yml new file mode 100644 index 0000000..68d14fb --- /dev/null +++ b/template-nextjs/graphql.config.yml @@ -0,0 +1,3 @@ +schema: schema.graphql +include: node_modules/harper/schema.graphql +documents: '**/*.graphql' diff --git a/template-nextjs/jsconfig.json b/template-nextjs/jsconfig.json new file mode 100644 index 0000000..5c64b06 --- /dev/null +++ b/template-nextjs/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./*"] + } + } +} diff --git a/template-nextjs/next.config.mjs b/template-nextjs/next.config.mjs new file mode 100644 index 0000000..68f2254 --- /dev/null +++ b/template-nextjs/next.config.mjs @@ -0,0 +1,6 @@ +import { withHarper } from '@harperfast/nextjs'; + +// `withHarper` wires this Next.js app into Harper: it marks the `harper` package as a server +// external so `import 'harper'` resolves to the running Harper runtime (rather than being bundled), +// giving server-side code access to the `tables` global. Add your own Next.js config inside. +export default withHarper({}); diff --git a/template-nextjs/package.json b/template-nextjs/package.json new file mode 100644 index 0000000..6fdaa3c --- /dev/null +++ b/template-nextjs/package.json @@ -0,0 +1,27 @@ +{ + "name": "your-package-name-here", + "version": "0.0.0", + "private": true, + "repository": "github:HarperFast/create-harper", + "scripts": { + "agent:run": "npx -y @harperfast/agent@latest", + "agent:skills:update": "npx -y skills@latest add harperfast/skills --all --yes", + "start": "harper run .", + "dev": "harper dev .", + "build": "next build", + "lint": "eslint .", + "format": "prettier --write .", + "deploy": "harper deploy_component . restart=true replicated=true" + }, + "dependencies": { + "@harperfast/nextjs": "^2.2.1", + "next": "^16.2.11", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "eslint": "^9.27.0", + "eslint-config-next": "^16.2.11", + "prettier": "^3.8.1" + } +} diff --git a/template-nextjs/schema.graphql b/template-nextjs/schema.graphql new file mode 100644 index 0000000..0253ba0 --- /dev/null +++ b/template-nextjs/schema.graphql @@ -0,0 +1,7 @@ +type Dog @table @export { + id: ID @primaryKey + name: String + breed: String + age: Int + color: String +} From 09dd623c762397bba5297c3fdec72026854eddc2 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 21 Jul 2026 13:07:39 -0400 Subject: [PATCH 02/15] chore(templates): skip Studio build/publish for Next.js templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `studio` flag to the template catalog (default true) and a derived `studioTemplateNames` list. The Studio build/publish scripts now iterate `studioTemplateNames` instead of `templateNames`, and the two nextjs entries set `studio: false`. The @harperfast/nextjs plugin builds Next.js on load, and whether that works in the Studio's deploy-only model is unverified — plus first-time publish perms / npm provenance for the new packages aren't set up yet. Both are easy follow-ups once this lands. Co-Authored-By: Claude Opus 4.8 --- lib/constants/templates.d.ts | 12 ++++++++++++ lib/constants/templates.js | 16 ++++++++++++++++ lib/constants/templates.test.js | 19 ++++++++++++++++++- templates-studio/buildStudioTemplates.js | 4 ++-- templates-studio/publishStudioTemplates.js | 4 ++-- 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/lib/constants/templates.d.ts b/lib/constants/templates.d.ts index 8cd565b..cebe5d7 100644 --- a/lib/constants/templates.d.ts +++ b/lib/constants/templates.d.ts @@ -29,6 +29,11 @@ export interface TemplateInfo { typescript: boolean; /** Whether the template is server-side rendered. */ ssr: boolean; + /** + * Whether a Studio template package is built and published for this template. Defaults to true; + * set to false for templates that don't (yet) run in the Studio's deploy-only model. + */ + studio?: boolean; /** The published Studio template package for this template. */ npmPackage: string; /** A link to the template's source on GitHub. */ @@ -56,3 +61,10 @@ export declare const templateNames: readonly [ 'nextjs-ts', 'nextjs', ]; + +/** + * The subset of {@link templateNames} for which a Studio template package is built and published + * (every template whose catalog entry does not set `studio: false`). Consumed by the Studio + * build/publish scripts. + */ +export declare const studioTemplateNames: readonly TemplateName[]; diff --git a/lib/constants/templates.js b/lib/constants/templates.js index af6be1d..c5d51cc 100644 --- a/lib/constants/templates.js +++ b/lib/constants/templates.js @@ -13,6 +13,9 @@ const CREATE_HARPER_TREE = 'https://github.com/HarperFast/create-harper/tree/mai * @property {string[]} tags - Tags describing the template's stack. * @property {boolean} typescript - Whether the template uses TypeScript. * @property {boolean} ssr - Whether the template is server-side rendered. + * @property {boolean} [studio] - Whether a Studio template package is built and published for this + * template. Defaults to true; set to false for templates that don't (yet) run in the Studio's + * deploy-only model. See {@link studioTemplateNames}. * @property {string} npmPackage - The published Studio template package for this template. * @property {string} githubUrl - A link to the template's source on GitHub. */ @@ -139,6 +142,9 @@ export const templates = [ tags: ['Next.js', 'TypeScript', 'React', 'App Router'], typescript: true, ssr: false, + // The @harperfast/nextjs plugin builds Next.js on load; whether that works in the Studio's + // deploy-only model is unverified, so we don't build/publish a Studio package yet. + studio: false, }), template({ name: 'nextjs', @@ -148,6 +154,7 @@ export const templates = [ tags: ['Next.js', 'React', 'App Router'], typescript: false, ssr: false, + studio: false, }), ]; @@ -158,3 +165,12 @@ export const templates = [ * @type {string[]} */ export const templateNames = templates.map((t) => t.name); + +/** + * The subset of {@link templateNames} for which a Studio template package is built and published + * (i.e. every template except those with `studio: false`). The Studio build/publish scripts iterate + * this instead of {@link templateNames} so opt-out templates are skipped. + * + * @type {string[]} + */ +export const studioTemplateNames = templates.filter((t) => t.studio !== false).map((t) => t.name); diff --git a/lib/constants/templates.test.js b/lib/constants/templates.test.js index 0cadc8b..4829c96 100644 --- a/lib/constants/templates.test.js +++ b/lib/constants/templates.test.js @@ -1,7 +1,7 @@ import colors from 'picocolors'; import { describe, expect, test } from 'vitest'; import { frameworks } from './frameworks.js'; -import { templateNames, templates as catalog } from './templates.js'; +import { studioTemplateNames, templateNames, templates as catalog } from './templates.js'; const { blue, cyan, green, magenta, yellow } = colors; @@ -40,6 +40,23 @@ describe('templates catalog', () => { expect(t.ssr).toBe(t.name.endsWith('-ssr')); } }); + + test('studioTemplateNames excludes the studio: false templates (nextjs)', () => { + expect(studioTemplateNames).toEqual([ + 'vanilla-ts', + 'vanilla', + 'react-ts', + 'react', + 'react-ts-ssr', + 'react-ssr', + 'vue-ts', + 'vue', + 'vue-ts-ssr', + 'vue-ssr', + ]); + expect(studioTemplateNames).not.toContain('nextjs'); + expect(studioTemplateNames).not.toContain('nextjs-ts'); + }); }); describe('frameworks (derived from the catalog)', () => { diff --git a/templates-studio/buildStudioTemplates.js b/templates-studio/buildStudioTemplates.js index 9ff2ccf..dc0467f 100644 --- a/templates-studio/buildStudioTemplates.js +++ b/templates-studio/buildStudioTemplates.js @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { templateNames } from '../lib/constants/templates.js'; +import { studioTemplateNames } from '../lib/constants/templates.js'; import { copyDir } from '../lib/fs/copyDir.js'; import { emptyDir } from '../lib/fs/emptyDir.js'; import { renameFile } from '../lib/fs/renameFile.js'; @@ -11,7 +11,7 @@ import { getOwnVersion } from '../lib/pkg/packageInformation.js'; import { run } from '../lib/run.js'; (async function() { - for (const templateName of templateNames) { + for (const templateName of studioTemplateNames) { const targetTemplate = 'template-' + templateName; const fromTemplate = path.resolve(import.meta.dirname, '..', targetTemplate); const toTemplate = path.resolve(import.meta.dirname, targetTemplate); diff --git a/templates-studio/publishStudioTemplates.js b/templates-studio/publishStudioTemplates.js index 086f1d1..c300159 100644 --- a/templates-studio/publishStudioTemplates.js +++ b/templates-studio/publishStudioTemplates.js @@ -3,11 +3,11 @@ import spawn from 'cross-spawn'; import fs from 'node:fs'; import path from 'node:path'; -import { templateNames } from '../lib/constants/templates.js'; +import { studioTemplateNames } from '../lib/constants/templates.js'; (async function() { let hitError = 0; - for (const templateName of templateNames) { + for (const templateName of studioTemplateNames) { const targetTemplate = 'template-' + templateName; const toTemplate = path.resolve(import.meta.dirname, targetTemplate); if (!fs.existsSync(toTemplate)) { From d2bb8ffcbf06219c4d7283894de54d3ee6ce5caa Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 21 Jul 2026 13:11:29 -0400 Subject: [PATCH 03/15] fix(templates): address Next.js review feedback (age 0, useTransition) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createDog: validate the raw `age` form value instead of the coerced number so a legitimate age of 0 (a puppy) is no longer rejected as a missing field. - DeleteButton: wrap the deleteDog server action in useTransition, disabling the button and showing a pending state while it runs — the recommended React 19 / Next.js pattern for server actions invoked from client events. Verified: repo dprint/oxlint/vitest, plus scaffold tsc --noEmit and eslint for both variants. Co-Authored-By: Claude Opus 4.8 --- template-nextjs-ts/app/actions.ts | 5 +++-- template-nextjs-ts/app/ui/DeleteButton.tsx | 13 ++++++++++--- template-nextjs/app/actions.js | 5 +++-- template-nextjs/app/ui/DeleteButton.js | 13 ++++++++++--- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/template-nextjs-ts/app/actions.ts b/template-nextjs-ts/app/actions.ts index d0e61e9..1172c76 100644 --- a/template-nextjs-ts/app/actions.ts +++ b/template-nextjs-ts/app/actions.ts @@ -23,9 +23,10 @@ export async function getDog(id: string): Promise { export async function createDog(formData: FormData): Promise { const name = formData.get('name'); const breed = formData.get('breed'); - const age = Number(formData.get('age')); + const age = formData.get('age'); const color = formData.get('color'); + // Check the raw values so a valid age of 0 (a puppy!) isn't rejected as "missing". if (!name || !breed || !age || !color) { throw new Error('All fields are required'); } @@ -33,7 +34,7 @@ export async function createDog(formData: FormData): Promise { await tables.Dog.create({ name: String(name), breed: String(breed), - age, + age: Number(age), color: String(color), }); revalidatePath('/dogs'); diff --git a/template-nextjs-ts/app/ui/DeleteButton.tsx b/template-nextjs-ts/app/ui/DeleteButton.tsx index 2b3a10e..3cd8d60 100644 --- a/template-nextjs-ts/app/ui/DeleteButton.tsx +++ b/template-nextjs-ts/app/ui/DeleteButton.tsx @@ -1,11 +1,17 @@ 'use client'; +import { useTransition } from 'react'; import { deleteDog } from '../actions'; export default function DeleteButton({ dogId }: { dogId: string }) { + // useTransition keeps the UI responsive during the server action, shows a pending state, and + // prevents double-submits — the recommended pattern for calling server actions from the client. + const [isPending, startTransition] = useTransition(); + return ( ); } diff --git a/template-nextjs/app/actions.js b/template-nextjs/app/actions.js index 049c12c..8a48db7 100644 --- a/template-nextjs/app/actions.js +++ b/template-nextjs/app/actions.js @@ -23,14 +23,15 @@ export async function getDog(id) { export async function createDog(formData) { const name = formData.get('name'); const breed = formData.get('breed'); - const age = Number(formData.get('age')); + const age = formData.get('age'); const color = formData.get('color'); + // Check the raw values so a valid age of 0 (a puppy!) isn't rejected as "missing". if (!name || !breed || !age || !color) { throw new Error('All fields are required'); } - await tables.Dog.create({ name, breed, age, color }); + await tables.Dog.create({ name, breed, age: Number(age), color }); revalidatePath('/dogs'); } diff --git a/template-nextjs/app/ui/DeleteButton.js b/template-nextjs/app/ui/DeleteButton.js index 25006cb..bec0596 100644 --- a/template-nextjs/app/ui/DeleteButton.js +++ b/template-nextjs/app/ui/DeleteButton.js @@ -1,11 +1,17 @@ 'use client'; +import { useTransition } from 'react'; import { deleteDog } from '../actions'; export default function DeleteButton({ dogId }) { + // useTransition keeps the UI responsive during the server action, shows a pending state, and + // prevents double-submits — the recommended pattern for calling server actions from the client. + const [isPending, startTransition] = useTransition(); + return ( ); } From e6f048a760eeeee2fa662e322776bbb2f88eeaff Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 21 Jul 2026 14:04:54 -0400 Subject: [PATCH 04/15] fix(templates): don't import 'harper' in Next.js server code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local `harper run`/`deploy` was leaving the app unbuilt (every route 404'd) because the template's server modules had a top-level `import 'harper'`. That import breaks the plugin's on-startup production build two ways: Turbopack (Next 16's default) can't resolve the bare `harper` specifier, and even under webpack the import opens Harper's database during Next's build-time page-data collection, colliding with the running instance's LMDB lock. On failure the plugin logs and skips serving — so the app "deploys" but serves nothing (the symptom seen deploying to Studio). Harper already injects `tables` as a global into server-side code, so the import is unnecessary: drop it and use `tables` directly. With that gone the default Turbopack build succeeds and the app builds + serves under `harper run`. Verified end to end on Harper 5.1.22: both variants build + serve via `harper run`, TS type-checks, and a full create/list/detail/delete round-trip (server actions + tables read/write, incl. age 0) works through the UI. Co-Authored-By: Claude Opus 4.8 --- template-nextjs-ts/README.md | 5 +++-- template-nextjs-ts/app/actions.ts | 7 ++++--- template-nextjs-ts/config.yaml | 8 ++++---- template-nextjs-ts/harper.d.ts | 3 --- template-nextjs/README.md | 6 +++--- template-nextjs/app/actions.js | 7 ++++--- template-nextjs/config.yaml | 8 ++++---- 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/template-nextjs-ts/README.md b/template-nextjs-ts/README.md index 023b2e6..8ce05e6 100644 --- a/template-nextjs-ts/README.md +++ b/template-nextjs-ts/README.md @@ -40,12 +40,11 @@ The included **Doggy Management System** demo shows the whole pattern end to end ### Access Harper From Server Code -Any server-side module that uses the Harper globals must first import the `harper` package: +Harper injects a `tables` global into server-side code, so server actions and server components read and write your database directly — no import needed. The `tables` types come from [`harper.d.ts`](./harper.d.ts): ```ts 'use server'; -import 'harper'; import type { DogRecord } from '@/harper'; export async function listDogs(): Promise { @@ -57,6 +56,8 @@ export async function listDogs(): Promise { } ``` +> **Don't** add a top-level `import 'harper'` in these modules. It runs during the Next.js production build (when Next collects page data) and conflicts with the running database — use the injected `tables` global instead. + Following Next.js best practices, put this data access in **server actions** so that both server _and_ client components can share the same functions. ## Deployment diff --git a/template-nextjs-ts/app/actions.ts b/template-nextjs-ts/app/actions.ts index 1172c76..ca1b20f 100644 --- a/template-nextjs-ts/app/actions.ts +++ b/template-nextjs-ts/app/actions.ts @@ -1,12 +1,13 @@ 'use server'; -import 'harper'; import type { DogRecord } from '@/harper'; import { revalidatePath } from 'next/cache'; // Server actions run *inside* Harper, so they read and write tables directly through the `tables` -// global — no network round-trip to a separate API. Importing 'harper' makes the runtime globals -// available. Because these are server actions, both server and client components can call them. +// global — no network round-trip to a separate API. Harper injects `tables` into server-side code, +// so use it directly; do NOT add a top-level `import 'harper'` — that import runs during the +// Next.js production build and conflicts with the running database. Because these are server +// actions, both server and client components can call them. export async function listDogs(): Promise { const dogs: DogRecord[] = []; diff --git a/template-nextjs-ts/config.yaml b/template-nextjs-ts/config.yaml index a458c97..c92a838 100644 --- a/template-nextjs-ts/config.yaml +++ b/template-nextjs-ts/config.yaml @@ -13,9 +13,9 @@ graphqlSchema: files: 'schema.graphql' # Runs the Next.js app as a Harper component. Under `harper dev` it serves the Next.js dev server -# with hot-module reloading; under `harper run` it builds and serves the app. Server-side code -# (server actions and server components) can read and write Harper tables directly through the -# `tables` global after importing the `harper` package — see app/actions.js. Because this plugin -# owns HTTP routing for the app, there is no `static` or `rest` handler here. +# with hot-module reloading; under `harper run` (and `harper deploy`) it builds and serves the app. +# Server-side code (server actions and server components) reads and writes Harper tables directly +# through the injected `tables` global — see app/actions.js. Because this plugin owns HTTP routing +# for the app, there is no `static` or `rest` handler here. '@harperfast/nextjs': package: '@harperfast/nextjs' diff --git a/template-nextjs-ts/harper.d.ts b/template-nextjs-ts/harper.d.ts index 630157e..dbe9947 100644 --- a/template-nextjs-ts/harper.d.ts +++ b/template-nextjs-ts/harper.d.ts @@ -25,6 +25,3 @@ declare global { Dog: HarperTable; }; } - -// `import 'harper'` loads the runtime globals as a side effect; it exposes no typed exports here. -declare module 'harper' {} diff --git a/template-nextjs/README.md b/template-nextjs/README.md index 3910835..5a4f591 100644 --- a/template-nextjs/README.md +++ b/template-nextjs/README.md @@ -40,13 +40,11 @@ These schemas are the heart of a Harper app, specifying which tables you want an ### Access Harper From Server Code -Any server-side module that uses the Harper globals must first import the `harper` package: +Harper injects a `tables` global into server-side code, so server actions and server components read and write your database directly — no import needed: ```js 'use server'; -import 'harper'; - export async function listDogs() { const dogs = []; for await (const dog of tables.Dog.search()) { @@ -56,6 +54,8 @@ export async function listDogs() { } ``` +> **Don't** add a top-level `import 'harper'` in these modules. It runs during the Next.js production build (when Next collects page data) and conflicts with the running database — use the injected `tables` global instead. + Following Next.js best practices, put this data access in **server actions** so that both server _and_ client components can share the same functions. ## Deployment diff --git a/template-nextjs/app/actions.js b/template-nextjs/app/actions.js index 8a48db7..d6f03b2 100644 --- a/template-nextjs/app/actions.js +++ b/template-nextjs/app/actions.js @@ -1,12 +1,13 @@ 'use server'; /* global tables */ -import 'harper'; import { revalidatePath } from 'next/cache'; // Server actions run *inside* Harper, so they read and write tables directly through the `tables` -// global — no network round-trip to a separate API. Importing 'harper' makes the runtime globals -// available. Because these are server actions, both server and client components can call them. +// global — no network round-trip to a separate API. Harper injects `tables` into server-side code, +// so use it directly; do NOT add a top-level `import 'harper'` — that import runs during the +// Next.js production build and conflicts with the running database. Because these are server +// actions, both server and client components can call them. export async function listDogs() { const dogs = []; diff --git a/template-nextjs/config.yaml b/template-nextjs/config.yaml index a458c97..c92a838 100644 --- a/template-nextjs/config.yaml +++ b/template-nextjs/config.yaml @@ -13,9 +13,9 @@ graphqlSchema: files: 'schema.graphql' # Runs the Next.js app as a Harper component. Under `harper dev` it serves the Next.js dev server -# with hot-module reloading; under `harper run` it builds and serves the app. Server-side code -# (server actions and server components) can read and write Harper tables directly through the -# `tables` global after importing the `harper` package — see app/actions.js. Because this plugin -# owns HTTP routing for the app, there is no `static` or `rest` handler here. +# with hot-module reloading; under `harper run` (and `harper deploy`) it builds and serves the app. +# Server-side code (server actions and server components) reads and writes Harper tables directly +# through the injected `tables` global — see app/actions.js. Because this plugin owns HTTP routing +# for the app, there is no `static` or `rest` handler here. '@harperfast/nextjs': package: '@harperfast/nextjs' From 56ed0777612824e6d3459e676ef87f0c4a8d97a1 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 21 Jul 2026 15:24:07 -0400 Subject: [PATCH 05/15] refactor(templates): slim Next.js templates to a persisted-counter starter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the ported "Doggy Management System" CRUD app with a bare starter whose only Harper touch is a counter persisted in a `Count` table: a server component reads it and a server action increments it. This matches the altitude of the other create-harper templates (blank starter + schema scaffolding) and shows the one thing unique to Next-on-Harper — reading/writing `tables` from server code — without shipping a full app. Addresses the PR review (thanks @kriszyp): dropping the CRUD surface removes the unbounded table scan, the unauthenticated destructive `deleteDog` action, the no-confirm delete button, and the bypassable form validation. The remaining mutation is a single-key, non-destructive counter increment (no scan, no user input); the README notes that any client-reachable action needs its own authz. Removed: app/dogs/**, app/ui/DeleteButton, app/not-found. Both JS and TS variants updated (+ harper.d.ts, READMEs). Verified on Harper 5.1.22: both variants build + serve under `harper run`, TS type-checks, and the counter increments and survives a restart (0 -> 1 -> 2, still 2 after restart) — confirming it's persisted in the table. Co-Authored-By: Claude Opus 4.8 --- template-nextjs-ts/README.md | 37 +++-- template-nextjs-ts/app/actions.ts | 52 ++----- template-nextjs-ts/app/dogs/[id]/page.tsx | 44 ------ template-nextjs-ts/app/dogs/page.tsx | 151 --------------------- template-nextjs-ts/app/layout.tsx | 75 +--------- template-nextjs-ts/app/not-found.tsx | 10 -- template-nextjs-ts/app/page.tsx | 76 +++++------ template-nextjs-ts/app/ui/DeleteButton.tsx | 29 ---- template-nextjs-ts/harper.d.ts | 15 +- template-nextjs-ts/schema.graphql | 7 +- template-nextjs/README.md | 37 +++-- template-nextjs/app/actions.js | 48 ++----- template-nextjs/app/dogs/[id]/page.js | 44 ------ template-nextjs/app/dogs/page.js | 150 -------------------- template-nextjs/app/layout.js | 76 +---------- template-nextjs/app/not-found.js | 10 -- template-nextjs/app/page.js | 76 +++++------ template-nextjs/app/ui/DeleteButton.js | 29 ---- template-nextjs/schema.graphql | 7 +- 19 files changed, 151 insertions(+), 822 deletions(-) delete mode 100644 template-nextjs-ts/app/dogs/[id]/page.tsx delete mode 100644 template-nextjs-ts/app/dogs/page.tsx delete mode 100644 template-nextjs-ts/app/not-found.tsx delete mode 100644 template-nextjs-ts/app/ui/DeleteButton.tsx delete mode 100644 template-nextjs/app/dogs/[id]/page.js delete mode 100644 template-nextjs/app/dogs/page.js delete mode 100644 template-nextjs/app/not-found.js delete mode 100644 template-nextjs/app/ui/DeleteButton.js diff --git a/template-nextjs-ts/README.md b/template-nextjs-ts/README.md index 8ce05e6..16cd2af 100644 --- a/template-nextjs-ts/README.md +++ b/template-nextjs-ts/README.md @@ -2,9 +2,9 @@ A type-safe [Next.js](https://nextjs.org) app running on Harper via [`@harperfast/nextjs`](https://github.com/HarperFast/nextjs). Your new app is now ready for development! -Because the app runs _inside_ Harper, server-side code (server actions and server components) can read and write your database directly through the [Resource API](https://docs.harperdb.io/docs/technical-details/reference/resources) — no separate API server and no network round-trip. +Because the app runs _inside_ Harper, server-side code (server actions and server components) reads and writes your database directly through the injected `tables` global — no separate API server and no network round-trip. -Here's what you should do next: +The starter ships one tiny end-to-end example: a counter stored in a Harper table, read by a server component and incremented by a server action. ## Installation @@ -24,19 +24,11 @@ npm run dev Then open [http://localhost:9926](http://localhost:9926) 🎉 -The included **Doggy Management System** demo shows the whole pattern end to end: - -- [`schema.graphql`](./schema.graphql) defines a `Dog` table. -- [`harper.d.ts`](./harper.d.ts) declares the matching types for the `tables` global. -- [`app/actions.ts`](./app/actions.ts) defines server actions (`listDogs`, `getDog`, `createDog`, `deleteDog`) that talk to `tables` with full type safety. -- [`app/dogs/page.tsx`](./app/dogs/page.tsx) (a server component) and [`app/ui/DeleteButton.tsx`](./app/ui/DeleteButton.tsx) (a client component) both call those same actions. +Click the button — the count persists in Harper across reloads and restarts. ### Define Your Schema -1. Edit [`schema.graphql`](./schema.graphql) (or add more `.graphql` files). -2. Craft your schema by hand — every `@table` becomes available on the `tables` global. -3. Mirror the shape in [`harper.d.ts`](./harper.d.ts) so your server code stays type-safe. (The `@harperfast/schema-codegen` component can also generate these types for you.) -4. Save your changes. +Your tables live in [`schema.graphql`](./schema.graphql). The starter defines a single `Count` table; add your own `@table` types there, then mirror their shape in [`harper.d.ts`](./harper.d.ts) so your server code stays type-safe. (The `@harperfast/schema-codegen` component can also generate these types for you.) ### Access Harper From Server Code @@ -45,20 +37,23 @@ Harper injects a `tables` global into server-side code, so server actions and se ```ts 'use server'; -import type { DogRecord } from '@/harper'; +export async function getCount(): Promise { + const record = await tables.Count.get('count'); + return record?.value ?? 0; +} -export async function listDogs(): Promise { - const dogs: DogRecord[] = []; - for await (const dog of tables.Dog.search()) { - dogs.push(dog); - } - return dogs; +export async function increment(): Promise { + const current = await tables.Count.get('count'); + await tables.Count.put('count', { + id: 'count', + value: (current?.value ?? 0) + 1, + }); } ``` > **Don't** add a top-level `import 'harper'` in these modules. It runs during the Next.js production build (when Next collects page data) and conflicts with the running database — use the injected `tables` global instead. -Following Next.js best practices, put this data access in **server actions** so that both server _and_ client components can share the same functions. +Put data access in **server actions** (see [`app/actions.ts`](./app/actions.ts)) so that both server _and_ client components can share the same functions. Any action a client can reach is a public endpoint, so add your own authorization checks before shipping mutations that matter. ## Deployment @@ -76,7 +71,7 @@ Then deploy your app: npm run deploy ``` -`npm run deploy` uploads the component and Harper builds the Next.js app on the server when it starts — no local build required. For faster startups you can instead deploy a prebuilt app: run it once with `npm run dev` (so Harper links its runtime into `node_modules`), then `npm run build`, add `prebuilt: true` under `@harperfast/nextjs` in [`config.yaml`](./config.yaml), and deploy. +`npm run deploy` uploads the component and Harper builds the Next.js app on the server when it starts — no local build required. ## Keep Going! diff --git a/template-nextjs-ts/app/actions.ts b/template-nextjs-ts/app/actions.ts index ca1b20f..642f51f 100644 --- a/template-nextjs-ts/app/actions.ts +++ b/template-nextjs-ts/app/actions.ts @@ -1,47 +1,21 @@ 'use server'; -import type { DogRecord } from '@/harper'; import { revalidatePath } from 'next/cache'; -// Server actions run *inside* Harper, so they read and write tables directly through the `tables` -// global — no network round-trip to a separate API. Harper injects `tables` into server-side code, -// so use it directly; do NOT add a top-level `import 'harper'` — that import runs during the -// Next.js production build and conflicts with the running database. Because these are server -// actions, both server and client components can call them. +// Server actions run *inside* Harper, so they read and write tables directly through the injected +// `tables` global — no separate API and no network round-trip. Harper provides `tables`, so use it +// directly; do NOT add a top-level `import 'harper'` — that import runs during the Next.js +// production build and conflicts with the running database. -export async function listDogs(): Promise { - const dogs: DogRecord[] = []; - for await (const dog of tables.Dog.search()) { - dogs.push({ id: dog.id, name: dog.name, breed: dog.breed, age: dog.age, color: dog.color }); - } - return dogs; +// The whole counter lives in a single row keyed by 'count', so reads and writes are a cheap +// point lookup — no table scans. +export async function getCount(): Promise { + const record = await tables.Count.get('count'); + return record?.value ?? 0; } -export async function getDog(id: string): Promise { - return tables.Dog.get(id); -} - -export async function createDog(formData: FormData): Promise { - const name = formData.get('name'); - const breed = formData.get('breed'); - const age = formData.get('age'); - const color = formData.get('color'); - - // Check the raw values so a valid age of 0 (a puppy!) isn't rejected as "missing". - if (!name || !breed || !age || !color) { - throw new Error('All fields are required'); - } - - await tables.Dog.create({ - name: String(name), - breed: String(breed), - age: Number(age), - color: String(color), - }); - revalidatePath('/dogs'); -} - -export async function deleteDog(id: string): Promise { - await tables.Dog.delete(id); - revalidatePath('/dogs'); +export async function increment(): Promise { + const current = await tables.Count.get('count'); + await tables.Count.put('count', { id: 'count', value: (current?.value ?? 0) + 1 }); + revalidatePath('/'); } diff --git a/template-nextjs-ts/app/dogs/[id]/page.tsx b/template-nextjs-ts/app/dogs/[id]/page.tsx deleted file mode 100644 index ee707d7..0000000 --- a/template-nextjs-ts/app/dogs/[id]/page.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import Link from 'next/link'; -import { notFound } from 'next/navigation'; -import { getDog } from '../../actions'; - -export const dynamic = 'force-dynamic'; - -export default async function Page({ params }: { params: Promise<{ id: string }> }) { - // In the App Router, `params` is a promise — await it before reading route values. - const { id } = await params; - const dog = await getDog(id); - - if (!dog) { - notFound(); - } - - return ( -
- - ← Back to all dogs - -
-

- {dog.name} -

-
-
Breed
-
{dog.breed}
-
Age
-
{dog.age} years
-
Color
-
{dog.color}
-
-
-
- ); -} diff --git a/template-nextjs-ts/app/dogs/page.tsx b/template-nextjs-ts/app/dogs/page.tsx deleted file mode 100644 index 39e94ad..0000000 --- a/template-nextjs-ts/app/dogs/page.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import Link from 'next/link'; -import type { CSSProperties } from 'react'; -import { createDog, listDogs } from '../actions'; -import DeleteButton from '../ui/DeleteButton'; - -// Read fresh data on every request instead of caching at build time. -export const dynamic = 'force-dynamic'; - -const inputStyle: CSSProperties = { - width: '100%', - padding: '0.75rem', - border: '1px solid #d1d5db', - borderRadius: '4px', - fontSize: '1rem', - boxSizing: 'border-box', -}; - -const labelStyle: CSSProperties = { - display: 'block', - marginBottom: '0.5rem', - fontSize: '0.875rem', - fontWeight: '500', - color: '#374151', -}; - -const cellHeader: CSSProperties = { - display: 'grid', - gridTemplateColumns: '2fr 2fr 1fr 1fr 1fr', - gap: '1rem', - padding: '1rem 0', - alignItems: 'center', -}; - -export default async function Page() { - const dogs = await listDogs(); - - return ( -
- {/* Add-a-dog form. Submitting calls the `createDog` server action directly. */} -
-

- Add New Dog -

-
-
- - -
-
- - -
-
- - -
-
- - -
- -
-
- - {/* Dogs list */} -
-
-

- Dogs ({dogs.length}) -

-
-
-
-
Name
-
Breed
-
Age
-
Color
-
Actions
-
- {dogs.map((dog) => ( -
-
- - {dog.name} - -
-
{dog.breed}
-
{dog.age} years
-
{dog.color}
-
- -
-
- ))} - {dogs.length === 0 && ( -
- No dogs found. Add one above! -
- )} -
-
-
- ); -} diff --git a/template-nextjs-ts/app/layout.tsx b/template-nextjs-ts/app/layout.tsx index 2a7263f..29dcd24 100644 --- a/template-nextjs-ts/app/layout.tsx +++ b/template-nextjs-ts/app/layout.tsx @@ -1,84 +1,15 @@ -import Link from 'next/link'; import type { ReactNode } from 'react'; export const metadata = { - title: 'Doggy Management System', + title: 'Next.js on Harper', description: 'A Next.js app powered by Harper', }; export default function RootLayout({ children }: { children: ReactNode }) { return ( - -
- -
-
- {children} -
-
-
-

- Built with{' '} - - Harper - {' '} - &{' '} - - Next.js - -

-
-
+ + {children} ); diff --git a/template-nextjs-ts/app/not-found.tsx b/template-nextjs-ts/app/not-found.tsx deleted file mode 100644 index bcb6761..0000000 --- a/template-nextjs-ts/app/not-found.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export default function NotFound() { - return ( -
-

🐶 404 - Page Not Found

-

- Sorry, the page you are looking for does not exist. -

-
- ); -} diff --git a/template-nextjs-ts/app/page.tsx b/template-nextjs-ts/app/page.tsx index 9730ebd..ea1470b 100644 --- a/template-nextjs-ts/app/page.tsx +++ b/template-nextjs-ts/app/page.tsx @@ -1,55 +1,55 @@ -import Link from 'next/link'; +import { getCount, increment } from './actions'; + +// Read the count fresh on every request rather than caching it at build time. +export const dynamic = 'force-dynamic'; + +export default async function Page() { + const count = await getCount(); -export default function Page() { return ( -
-

- Doggy Management System -

-

- An application for managing dog records. View, add, and organize your canine database with ease. +

Next.js + Harper

+

+ This counter is stored in a Harper table. Clicking the button calls a{' '} + + server action + {' '} + that reads and writes the table directly — no separate API.

- -
- +
-
+ count is {count} + + +

+ Edit the schema in schema.graphql and the logic in app/actions.ts. +

+ ); } diff --git a/template-nextjs-ts/app/ui/DeleteButton.tsx b/template-nextjs-ts/app/ui/DeleteButton.tsx deleted file mode 100644 index 3cd8d60..0000000 --- a/template-nextjs-ts/app/ui/DeleteButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -'use client'; - -import { useTransition } from 'react'; -import { deleteDog } from '../actions'; - -export default function DeleteButton({ dogId }: { dogId: string }) { - // useTransition keeps the UI responsive during the server action, shows a pending state, and - // prevents double-submits — the recommended pattern for calling server actions from the client. - const [isPending, startTransition] = useTransition(); - - return ( - - ); -} diff --git a/template-nextjs-ts/harper.d.ts b/template-nextjs-ts/harper.d.ts index dbe9947..d371b39 100644 --- a/template-nextjs-ts/harper.d.ts +++ b/template-nextjs-ts/harper.d.ts @@ -1,27 +1,22 @@ // Ambient type declarations for the Harper runtime. // -// Harper injects a `tables` global for server-side code, with one entry per `@table` defined in +// Harper injects a `tables` global into server-side code, with one entry per `@table` defined in // schema.graphql. These hand-written declarations give you type-safe access to your data. As you // add tables and columns to the schema, mirror them here. (The `@harperfast/schema-codegen` // component can also generate these for you — see the other create-harper templates.) -export interface DogRecord { +export interface CountRecord { id: string; - name: string; - breed: string; - age: number; - color: string; + value: number; } interface HarperTable { get(id: string): Promise; - search(query?: unknown): AsyncIterable; - create(record: Omit & { id?: string }): Promise; - delete(id: string): Promise; + put(id: string, record: T): Promise; } declare global { const tables: { - Dog: HarperTable; + Count: HarperTable; }; } diff --git a/template-nextjs-ts/schema.graphql b/template-nextjs-ts/schema.graphql index 0253ba0..a5fbc69 100644 --- a/template-nextjs-ts/schema.graphql +++ b/template-nextjs-ts/schema.graphql @@ -1,7 +1,4 @@ -type Dog @table @export { +type Count @table @export { id: ID @primaryKey - name: String - breed: String - age: Int - color: String + value: Int } diff --git a/template-nextjs/README.md b/template-nextjs/README.md index 5a4f591..6364acd 100644 --- a/template-nextjs/README.md +++ b/template-nextjs/README.md @@ -2,9 +2,9 @@ A [Next.js](https://nextjs.org) app running on Harper via [`@harperfast/nextjs`](https://github.com/HarperFast/nextjs). Your new app is now ready for development! -Because the app runs _inside_ Harper, server-side code (server actions and server components) can read and write your database directly through the [Resource API](https://docs.harperdb.io/docs/technical-details/reference/resources) — no separate API server and no network round-trip. +Because the app runs _inside_ Harper, server-side code (server actions and server components) reads and writes your database directly through the injected `tables` global — no separate API server and no network round-trip. -Here's what you should do next: +The starter ships one tiny end-to-end example: a counter stored in a Harper table, read by a server component and incremented by a server action. ## Installation @@ -24,19 +24,11 @@ npm run dev Then open [http://localhost:9926](http://localhost:9926) 🎉 -The included **Doggy Management System** demo shows the whole pattern end to end: - -- [`schema.graphql`](./schema.graphql) defines a `Dog` table. -- [`app/actions.js`](./app/actions.js) defines server actions (`listDogs`, `getDog`, `createDog`, `deleteDog`) that talk to the `tables` global. -- [`app/dogs/page.js`](./app/dogs/page.js) (a server component) and [`app/ui/DeleteButton.js`](./app/ui/DeleteButton.js) (a client component) both call those same actions. +Click the button — the count persists in Harper across reloads and restarts. ### Define Your Schema -1. Edit [`schema.graphql`](./schema.graphql) (or add more `.graphql` files). -2. Craft your schema by hand — every `@table` becomes available on the `tables` global. -3. Save your changes. - -These schemas are the heart of a Harper app, specifying which tables you want and what attributes they should have. +Your tables live in [`schema.graphql`](./schema.graphql). The starter defines a single `Count` table; add your own `@table` types there and they become available on the `tables` global. ### Access Harper From Server Code @@ -45,18 +37,23 @@ Harper injects a `tables` global into server-side code, so server actions and se ```js 'use server'; -export async function listDogs() { - const dogs = []; - for await (const dog of tables.Dog.search()) { - dogs.push(dog); - } - return dogs; +export async function getCount() { + const record = await tables.Count.get('count'); + return record?.value ?? 0; +} + +export async function increment() { + const current = await tables.Count.get('count'); + await tables.Count.put('count', { + id: 'count', + value: (current?.value ?? 0) + 1, + }); } ``` > **Don't** add a top-level `import 'harper'` in these modules. It runs during the Next.js production build (when Next collects page data) and conflicts with the running database — use the injected `tables` global instead. -Following Next.js best practices, put this data access in **server actions** so that both server _and_ client components can share the same functions. +Put data access in **server actions** (see [`app/actions.js`](./app/actions.js)) so that both server _and_ client components can share the same functions. Any action a client can reach is a public endpoint, so add your own authorization checks before shipping mutations that matter. ## Deployment @@ -74,7 +71,7 @@ Then deploy your app: npm run deploy ``` -`npm run deploy` uploads the component and Harper builds the Next.js app on the server when it starts — no local build required. For faster startups you can instead deploy a prebuilt app: run it once with `npm run dev` (so Harper links its runtime into `node_modules`), then `npm run build`, add `prebuilt: true` under `@harperfast/nextjs` in [`config.yaml`](./config.yaml), and deploy. +`npm run deploy` uploads the component and Harper builds the Next.js app on the server when it starts — no local build required. ## Keep Going! diff --git a/template-nextjs/app/actions.js b/template-nextjs/app/actions.js index d6f03b2..18816da 100644 --- a/template-nextjs/app/actions.js +++ b/template-nextjs/app/actions.js @@ -3,40 +3,20 @@ /* global tables */ import { revalidatePath } from 'next/cache'; -// Server actions run *inside* Harper, so they read and write tables directly through the `tables` -// global — no network round-trip to a separate API. Harper injects `tables` into server-side code, -// so use it directly; do NOT add a top-level `import 'harper'` — that import runs during the -// Next.js production build and conflicts with the running database. Because these are server -// actions, both server and client components can call them. - -export async function listDogs() { - const dogs = []; - for await (const dog of tables.Dog.search()) { - dogs.push({ id: dog.id, name: dog.name, breed: dog.breed, age: dog.age, color: dog.color }); - } - return dogs; -} - -export async function getDog(id) { - return tables.Dog.get(id); -} - -export async function createDog(formData) { - const name = formData.get('name'); - const breed = formData.get('breed'); - const age = formData.get('age'); - const color = formData.get('color'); - - // Check the raw values so a valid age of 0 (a puppy!) isn't rejected as "missing". - if (!name || !breed || !age || !color) { - throw new Error('All fields are required'); - } - - await tables.Dog.create({ name, breed, age: Number(age), color }); - revalidatePath('/dogs'); +// Server actions run *inside* Harper, so they read and write tables directly through the injected +// `tables` global — no separate API and no network round-trip. Harper provides `tables`, so use it +// directly; do NOT add a top-level `import 'harper'` — that import runs during the Next.js +// production build and conflicts with the running database. + +// The whole counter lives in a single row keyed by 'count', so reads and writes are a cheap +// point lookup — no table scans. +export async function getCount() { + const record = await tables.Count.get('count'); + return record?.value ?? 0; } -export async function deleteDog(id) { - await tables.Dog.delete(id); - revalidatePath('/dogs'); +export async function increment() { + const current = await tables.Count.get('count'); + await tables.Count.put('count', { id: 'count', value: (current?.value ?? 0) + 1 }); + revalidatePath('/'); } diff --git a/template-nextjs/app/dogs/[id]/page.js b/template-nextjs/app/dogs/[id]/page.js deleted file mode 100644 index 450209a..0000000 --- a/template-nextjs/app/dogs/[id]/page.js +++ /dev/null @@ -1,44 +0,0 @@ -import Link from 'next/link'; -import { notFound } from 'next/navigation'; -import { getDog } from '../../actions'; - -export const dynamic = 'force-dynamic'; - -export default async function Page({ params }) { - // In the App Router, `params` is a promise — await it before reading route values. - const { id } = await params; - const dog = await getDog(id); - - if (!dog) { - notFound(); - } - - return ( -
- - ← Back to all dogs - -
-

- {dog.name} -

-
-
Breed
-
{dog.breed}
-
Age
-
{dog.age} years
-
Color
-
{dog.color}
-
-
-
- ); -} diff --git a/template-nextjs/app/dogs/page.js b/template-nextjs/app/dogs/page.js deleted file mode 100644 index 9f4a5a4..0000000 --- a/template-nextjs/app/dogs/page.js +++ /dev/null @@ -1,150 +0,0 @@ -import Link from 'next/link'; -import { createDog, listDogs } from '../actions'; -import DeleteButton from '../ui/DeleteButton'; - -// Read fresh data on every request instead of caching at build time. -export const dynamic = 'force-dynamic'; - -const inputStyle = { - width: '100%', - padding: '0.75rem', - border: '1px solid #d1d5db', - borderRadius: '4px', - fontSize: '1rem', - boxSizing: 'border-box', -}; - -const labelStyle = { - display: 'block', - marginBottom: '0.5rem', - fontSize: '0.875rem', - fontWeight: '500', - color: '#374151', -}; - -const cellHeader = { - display: 'grid', - gridTemplateColumns: '2fr 2fr 1fr 1fr 1fr', - gap: '1rem', - padding: '1rem 0', - alignItems: 'center', -}; - -export default async function Page() { - const dogs = await listDogs(); - - return ( -
- {/* Add-a-dog form. Submitting calls the `createDog` server action directly. */} -
-

- Add New Dog -

-
-
- - -
-
- - -
-
- - -
-
- - -
- -
-
- - {/* Dogs list */} -
-
-

- Dogs ({dogs.length}) -

-
-
-
-
Name
-
Breed
-
Age
-
Color
-
Actions
-
- {dogs.map((dog) => ( -
-
- - {dog.name} - -
-
{dog.breed}
-
{dog.age} years
-
{dog.color}
-
- -
-
- ))} - {dogs.length === 0 && ( -
- No dogs found. Add one above! -
- )} -
-
-
- ); -} diff --git a/template-nextjs/app/layout.js b/template-nextjs/app/layout.js index 3da934a..8a05229 100644 --- a/template-nextjs/app/layout.js +++ b/template-nextjs/app/layout.js @@ -1,83 +1,13 @@ -import Link from 'next/link'; - export const metadata = { - title: 'Doggy Management System', + title: 'Next.js on Harper', description: 'A Next.js app powered by Harper', }; export default function RootLayout({ children }) { return ( - -
- -
-
- {children} -
-
-
-

- Built with{' '} - - Harper - {' '} - &{' '} - - Next.js - -

-
-
+ + {children} ); diff --git a/template-nextjs/app/not-found.js b/template-nextjs/app/not-found.js deleted file mode 100644 index bcb6761..0000000 --- a/template-nextjs/app/not-found.js +++ /dev/null @@ -1,10 +0,0 @@ -export default function NotFound() { - return ( -
-

🐶 404 - Page Not Found

-

- Sorry, the page you are looking for does not exist. -

-
- ); -} diff --git a/template-nextjs/app/page.js b/template-nextjs/app/page.js index 9730ebd..224f61a 100644 --- a/template-nextjs/app/page.js +++ b/template-nextjs/app/page.js @@ -1,55 +1,55 @@ -import Link from 'next/link'; +import { getCount, increment } from './actions'; + +// Read the count fresh on every request rather than caching it at build time. +export const dynamic = 'force-dynamic'; + +export default async function Page() { + const count = await getCount(); -export default function Page() { return ( -
-

- Doggy Management System -

-

- An application for managing dog records. View, add, and organize your canine database with ease. +

Next.js + Harper

+

+ This counter is stored in a Harper table. Clicking the button calls a{' '} + + server action + {' '} + that reads and writes the table directly — no separate API.

- -
- +
-
+ count is {count} + + +

+ Edit the schema in schema.graphql and the logic in app/actions.js. +

+ ); } diff --git a/template-nextjs/app/ui/DeleteButton.js b/template-nextjs/app/ui/DeleteButton.js deleted file mode 100644 index bec0596..0000000 --- a/template-nextjs/app/ui/DeleteButton.js +++ /dev/null @@ -1,29 +0,0 @@ -'use client'; - -import { useTransition } from 'react'; -import { deleteDog } from '../actions'; - -export default function DeleteButton({ dogId }) { - // useTransition keeps the UI responsive during the server action, shows a pending state, and - // prevents double-submits — the recommended pattern for calling server actions from the client. - const [isPending, startTransition] = useTransition(); - - return ( - - ); -} diff --git a/template-nextjs/schema.graphql b/template-nextjs/schema.graphql index 0253ba0..a5fbc69 100644 --- a/template-nextjs/schema.graphql +++ b/template-nextjs/schema.graphql @@ -1,7 +1,4 @@ -type Dog @table @export { +type Count @table @export { id: ID @primaryKey - name: String - breed: String - age: Int - color: String + value: Int } From 98355e2ada94346666b71588514776f52241aa52 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 21 Jul 2026 15:31:53 -0400 Subject: [PATCH 06/15] ci(integration): cover Next.js variants in the integration matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `nextjs` and `nextjs-ts` to the integration test matrix so CI installs, lints, formats, builds, and boots them like every other template. Two accommodations for the Next.js templates: - The `run test` step is now conditional on a `test` script existing — the Next.js templates ship no unit tests (the runtime smoke is their coverage), while every existing template still runs its tests unchanged. - A dedicated template.tests/nextSmoke.js replaces runtimeSmoke.js for the Next.js variants: they route through the @harperfast/nextjs plugin (no REST resource surface), so the smoke boots the app under Harper and asserts the on-startup build serves `/` and renders the Harper-backed counter. It builds single-threaded for determinism and uses the same isolated-/tmp-root, process-group-cleanup conventions as runtimeSmoke.js. Verified locally on Harper 5.1.22: scaffold → install → lint → format → build (cold `next build`) → nextSmoke all pass for both variants. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/integration.yaml | 37 ++++++-- template.tests/nextSmoke.js | 146 +++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 template.tests/nextSmoke.js diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 8347b75..507239c 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -14,7 +14,16 @@ jobs: fail-fast: false matrix: pkg-manager: [npm, yarn, pnpm] - template: [vanilla, vanilla-ts, react, react-ts, vue, vue-ts] + template: [ + vanilla, + vanilla-ts, + react, + react-ts, + vue, + vue-ts, + nextjs, + nextjs-ts, + ] steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -136,8 +145,12 @@ jobs: echo "Running format check..." ${{ matrix.pkg-manager }} run format - echo "Running tests..." - ${{ matrix.pkg-manager }} run test + # Run tests if the template defines a test script (the Next.js templates don't ship + # unit tests — their runtime smoke below is their coverage). + if grep -q '"test":' package.json; then + echo "Running tests..." + ${{ matrix.pkg-manager }} run test + fi # Check for build script and run it if exists if grep -q '"build":' package.json; then @@ -145,15 +158,25 @@ jobs: ${{ matrix.pkg-manager }} run build fi - # Boot the generated app under a real Harper instance and verify both the REST - # resources and the frontend are reachable (guards against the static handler - # swallowing REST GETs). Once per template — the package manager doesn't matter here. + # Boot the generated app under a real Harper instance and verify its HTTP surface. Once + # per template — the package manager doesn't matter here. The Next.js templates route + # through the @harperfast/nextjs plugin (no REST resources), so they use a dedicated + # smoke that checks the on-startup build serves the Harper-backed counter; every other + # template uses runtimeSmoke, which also guards against the static handler swallowing + # REST GETs. if [ "${{ matrix.pkg-manager }}" = "npm" ]; then echo "Installing Harper..." npm install --global harper echo "Running runtime smoke test..." - node "$REPO_PATH/template.tests/runtimeSmoke.js" . + case "${{ matrix.template }}" in + nextjs | nextjs-ts) + node "$REPO_PATH/template.tests/nextSmoke.js" . + ;; + *) + node "$REPO_PATH/template.tests/runtimeSmoke.js" . + ;; + esac fi echo "Integration test passed for ${{ matrix.pkg-manager }} with ${{ matrix.template }}!" diff --git a/template.tests/nextSmoke.js b/template.tests/nextSmoke.js new file mode 100644 index 0000000..61d91df --- /dev/null +++ b/template.tests/nextSmoke.js @@ -0,0 +1,146 @@ +#!/usr/bin/env node +/** + * Runtime smoke test for a generated Next.js-on-Harper template application. + * + * The `@harperfast/nextjs` plugin owns HTTP routing and builds the Next.js app on startup (under + * `harper run`), so these apps don't expose the REST resource surface that runtimeSmoke.js checks. + * This variant boots the app under a real Harper instance (isolated root, throwaway admin user) + * and verifies the two things every Next.js template must do: + * + * 1. The on-startup production build succeeds and the app is served — `GET /` returns HTML. + * (A failed build makes the plugin skip serving, so every route 404s; this catches that.) + * 2. Server-side Harper access works — the home page reads the `Count` table via a server + * action and renders the persisted count, so the HTML contains "count is". + * + * Usage: node template.tests/nextSmoke.js + * + * The app must already be installed. Requires the `harper` CLI on PATH (or set HARPER_BIN). + * POSIX only. Ports override via SMOKE_HTTP_PORT / SMOKE_OPS_PORT. + */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const appDir = process.argv[2] && path.resolve(process.argv[2]); +if (!appDir || !fs.existsSync(path.join(appDir, 'config.yaml'))) { + console.error('Usage: node template.tests/nextSmoke.js (must contain a config.yaml)'); + process.exit(2); +} + +const httpPort = Number(process.env.SMOKE_HTTP_PORT ?? 19926); +const opsPort = Number(process.env.SMOKE_OPS_PORT ?? 19925); +const baseUrl = `http://127.0.0.1:${httpPort}`; + +// Harper's operations server listens on a unix domain socket inside ROOTPATH, and socket paths +// are limited to ~104 characters on macOS — so keep the scratch root short and in /tmp. +const scratchDir = fs.mkdtempSync('/tmp/harper-next-smoke-'); +const rootPath = path.join(scratchDir, 'hdb'); +const homeDir = path.join(scratchDir, 'home'); +fs.mkdirSync(rootPath); +fs.mkdirSync(homeDir); + +const harperBin = process.env.HARPER_BIN ?? 'harper'; +console.log(`Starting ${harperBin} run ${appDir} (root: ${rootPath}, port: ${httpPort})...`); +const harper = spawn(harperBin, ['run', appDir], { + env: { + ...process.env, + // HOME controls where Harper looks for the boot-properties file of an existing + // installation; pointing it at a scratch dir guarantees an isolated, fresh install. + HOME: homeDir, + TC_AGREEMENT: 'yes', + HDB_ADMIN_USERNAME: 'smoke-admin', + HDB_ADMIN_PASSWORD: 'smoke-password', + ROOTPATH: rootPath, + HTTP_PORT: String(httpPort), + OPERATIONSAPI_NETWORK_PORT: String(opsPort), + // Build the Next.js app single-threaded so the smoke is deterministic (the plugin otherwise + // builds once and dedupes across worker threads; one thread avoids any cross-thread races). + THREADS_COUNT: '1', + }, + // Own process group so cleanup can kill Harper's worker threads/children along with it. + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], +}); + +let harperOutput = ''; +harper.stdout.on('data', (chunk) => (harperOutput += chunk)); +harper.stderr.on('data', (chunk) => (harperOutput += chunk)); +let harperExited = false; +harper.on('exit', () => (harperExited = true)); +harper.on('error', (error) => { + harperOutput += `\nFailed to spawn ${harperBin}: ${error.message}\n`; + harperExited = true; +}); + +function cleanup() { + try { + if (harper.pid) { + process.kill(-harper.pid, 'SIGTERM'); + } + } catch {} + try { + fs.rmSync(scratchDir, { recursive: true, force: true }); + } catch {} +} + +process.on('SIGINT', () => { + cleanup(); + process.exit(130); +}); +process.on('SIGTERM', () => { + cleanup(); + process.exit(143); +}); + +async function waitForServer(timeoutMs = 300_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (harperExited) { + throw new Error(`Harper exited before the HTTP server came up. Output:\n${harperOutput.slice(-4000)}`); + } + try { + const response = await fetch(baseUrl + '/', { signal: AbortSignal.timeout(2000) }); + // The plugin serves once the build finishes; a pre-build request can 404, so wait for 2xx. + if (response.ok) { + return; + } + } catch {} + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + throw new Error(`Harper did not serve the app within ${timeoutMs}ms. Output:\n${harperOutput.slice(-4000)}`); +} + +const failures = []; + +try { + await waitForServer(); + + const response = await fetch(baseUrl + '/', { signal: AbortSignal.timeout(10_000) }); + const body = await response.text(); + const contentType = response.headers.get('content-type') ?? ''; + if (!response.ok) { + failures.push(`expected 200 from GET /, got ${response.status} (build likely failed → not served)`); + } else if (!contentType.includes('text/html')) { + failures.push(`expected an text/html response from GET /, got ${contentType}`); + } else if (!body.toLowerCase().includes(' 0) { + console.error(`\nNext.js runtime smoke test FAILED for ${appDir}:\n\n${failures.join('\n\n')}`); + process.exit(1); +} +console.log(`\nNext.js runtime smoke test passed for ${appDir}.`); +process.exit(0); From 742d52e251317a6cfdd0033bc43f64ee74878c3a Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 21 Jul 2026 15:38:58 -0400 Subject: [PATCH 07/15] fix(templates): make Next.js templates install + lint under pnpm and yarn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration matrix surfaced two package-manager-specific failures the npm path masked: - pnpm `install` exited non-zero with ERR_PNPM_IGNORED_BUILDS for Next.js's native deps (sharp, unrs-resolver). Added `pnpm.onlyBuiltDependencies` to approve exactly those builds (npm/yarn ignore the field). - `eslint .` crashed under yarn/pnpm with "Cannot find module 'typescript'": eslint-config-next pulls typescript-eslint, which requires typescript. npm happened to hoist it transitively; yarn/pnpm don't. Declared `typescript` as a devDependency in the JS template (the TS template already had it) so the Next.js lint config resolves under every package manager. Verified locally with pnpm 10 and yarn 1: scaffold → install → lint → build all pass for both variants. Co-Authored-By: Claude Opus 4.8 --- template-nextjs-ts/package.json | 3 +++ template-nextjs/package.json | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/template-nextjs-ts/package.json b/template-nextjs-ts/package.json index 5444855..33fd34b 100644 --- a/template-nextjs-ts/package.json +++ b/template-nextjs-ts/package.json @@ -27,5 +27,8 @@ "eslint-config-next": "^16.2.11", "prettier": "^3.8.1", "typescript": "^5.9.3" + }, + "pnpm": { + "onlyBuiltDependencies": ["sharp", "unrs-resolver"] } } diff --git a/template-nextjs/package.json b/template-nextjs/package.json index 6fdaa3c..effde4c 100644 --- a/template-nextjs/package.json +++ b/template-nextjs/package.json @@ -22,6 +22,10 @@ "devDependencies": { "eslint": "^9.27.0", "eslint-config-next": "^16.2.11", - "prettier": "^3.8.1" + "prettier": "^3.8.1", + "typescript": "^5.9.3" + }, + "pnpm": { + "onlyBuiltDependencies": ["sharp", "unrs-resolver"] } } From 3a8706dd1b58aff94123b9356272305a8ee88c98 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 21 Jul 2026 15:47:42 -0400 Subject: [PATCH 08/15] fix(templates): pre-approve pnpm builds via pnpm-workspace.yaml allowBuilds The previous package.json `pnpm.onlyBuiltDependencies` field was a no-op on the CI's pnpm (11.13.1): pnpm v11 removed that setting and replaced it with `allowBuilds` in pnpm-workspace.yaml, so `pnpm install` still hard-failed with ERR_PNPM_IGNORED_BUILDS for sharp/unrs-resolver. (My local pnpm 10.32 only warned, which masked it.) Ship a `pnpm-workspace.yaml` with `allowBuilds` in both templates instead, and drop the dead package.json field. Verified with the pinned CI version via `npx pnpm@11.13.1`: a freshly scaffolded nextjs-ts installs, lints, and builds clean (EXIT 0); pnpm 10.32 also tolerates the key; npm and yarn ignore the file. Co-Authored-By: Claude Opus 4.8 --- template-nextjs-ts/package.json | 3 --- template-nextjs-ts/pnpm-workspace.yaml | 5 +++++ template-nextjs/package.json | 3 --- template-nextjs/pnpm-workspace.yaml | 5 +++++ 4 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 template-nextjs-ts/pnpm-workspace.yaml create mode 100644 template-nextjs/pnpm-workspace.yaml diff --git a/template-nextjs-ts/package.json b/template-nextjs-ts/package.json index 33fd34b..5444855 100644 --- a/template-nextjs-ts/package.json +++ b/template-nextjs-ts/package.json @@ -27,8 +27,5 @@ "eslint-config-next": "^16.2.11", "prettier": "^3.8.1", "typescript": "^5.9.3" - }, - "pnpm": { - "onlyBuiltDependencies": ["sharp", "unrs-resolver"] } } diff --git a/template-nextjs-ts/pnpm-workspace.yaml b/template-nextjs-ts/pnpm-workspace.yaml new file mode 100644 index 0000000..87361f1 --- /dev/null +++ b/template-nextjs-ts/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +# Pre-approve the build scripts for Next.js's native dependencies so `pnpm install` doesn't halt +# on them (pnpm 10.12+/11 gate dependency build scripts by default). npm and yarn ignore this file. +allowBuilds: + sharp: true + unrs-resolver: true diff --git a/template-nextjs/package.json b/template-nextjs/package.json index effde4c..b99657a 100644 --- a/template-nextjs/package.json +++ b/template-nextjs/package.json @@ -24,8 +24,5 @@ "eslint-config-next": "^16.2.11", "prettier": "^3.8.1", "typescript": "^5.9.3" - }, - "pnpm": { - "onlyBuiltDependencies": ["sharp", "unrs-resolver"] } } diff --git a/template-nextjs/pnpm-workspace.yaml b/template-nextjs/pnpm-workspace.yaml new file mode 100644 index 0000000..87361f1 --- /dev/null +++ b/template-nextjs/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +# Pre-approve the build scripts for Next.js's native dependencies so `pnpm install` doesn't halt +# on them (pnpm 10.12+/11 gate dependency build scripts by default). npm and yarn ignore this file. +allowBuilds: + sharp: true + unrs-resolver: true From 1738243fc84a4689d9447be781b9c0c933e9f8e2 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 21 Jul 2026 17:09:09 -0400 Subject: [PATCH 09/15] fix(templates): deploy Next.js prebuilt to survive multi-thread clusters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploying the Next.js templates with the default server-side build 404s on a multi-threaded Harper cluster: worker threads run `next build` into the same `.next` concurrently and race (ENOENT on `.next/BUILD_ID`), so the plugin never serves. See HarperFast/nextjs#52. Until the plugin coordinates the build across threads, ship a prebuilt app. - config.yaml: `prebuilt: true` on `@harperfast/nextjs` (serve a prebuilt `.next` instead of building on startup). - scripts: `dev`/`start`/`deploy` now run `next build` first, so `.next` exists before Harper serves it (the plugin honors `prebuilt` even in dev, so a bare `harper dev` would otherwise bail on a missing `.next`). `deploy_component` uploads `.next` because it doesn't consult `.gitignore` — only excludes node_modules — so `.next` can stay gitignored. - nextSmoke.js: drop the forced THREADS_COUNT=1 so CI exercises multi-threaded serving and would catch a regression back to an on-startup build. - README/config comments updated to describe the prebuilt deploy. Verified on the stage cluster: prebuilt deploy serves and the counter reads + writes live (browser). Locally: scaffold → install → build → multi-thread nextSmoke passes for both variants; `next build && harper dev` serves with HMR. Co-Authored-By: Claude Opus 4.8 --- template-nextjs-ts/README.md | 2 +- template-nextjs-ts/config.yaml | 15 ++++++++++----- template-nextjs-ts/package.json | 6 +++--- template-nextjs/README.md | 2 +- template-nextjs/config.yaml | 15 ++++++++++----- template-nextjs/package.json | 6 +++--- template.tests/nextSmoke.js | 19 ++++++++++--------- 7 files changed, 38 insertions(+), 27 deletions(-) diff --git a/template-nextjs-ts/README.md b/template-nextjs-ts/README.md index 16cd2af..5ea83d9 100644 --- a/template-nextjs-ts/README.md +++ b/template-nextjs-ts/README.md @@ -71,7 +71,7 @@ Then deploy your app: npm run deploy ``` -`npm run deploy` uploads the component and Harper builds the Next.js app on the server when it starts — no local build required. +`npm run deploy` runs `next build` and ships the prebuilt output, then Harper serves it — no build runs on the cluster. (Building locally avoids a multi-threaded on-cluster build race; see the note in [`config.yaml`](./config.yaml).) ## Keep Going! diff --git a/template-nextjs-ts/config.yaml b/template-nextjs-ts/config.yaml index c92a838..05d4060 100644 --- a/template-nextjs-ts/config.yaml +++ b/template-nextjs-ts/config.yaml @@ -12,10 +12,15 @@ graphqlSchema: files: 'schema.graphql' -# Runs the Next.js app as a Harper component. Under `harper dev` it serves the Next.js dev server -# with hot-module reloading; under `harper run` (and `harper deploy`) it builds and serves the app. -# Server-side code (server actions and server components) reads and writes Harper tables directly -# through the injected `tables` global — see app/actions.js. Because this plugin owns HTTP routing -# for the app, there is no `static` or `rest` handler here. +# Runs the Next.js app as a Harper component. The npm scripts run `next build` first, then this +# component serves the prebuilt output (see the `prebuilt` note below); `harper dev` also serves it +# with hot-module reloading. Server-side code (server actions and server components) reads and writes +# Harper tables directly through the injected `tables` global — see app/actions.ts. Because this +# plugin owns HTTP routing for the app, there is no `static` or `rest` handler here. '@harperfast/nextjs': package: '@harperfast/nextjs' + # Serve a prebuilt `.next` (the npm scripts run `next build` for you) instead of building on + # startup. On a multi-threaded Harper cluster the on-startup build races across worker threads and + # fails, so the app never serves; prebuilt avoids that. See + # https://github.com/HarperFast/nextjs/issues/52 + prebuilt: true diff --git a/template-nextjs-ts/package.json b/template-nextjs-ts/package.json index 5444855..738d957 100644 --- a/template-nextjs-ts/package.json +++ b/template-nextjs-ts/package.json @@ -6,12 +6,12 @@ "scripts": { "agent:run": "npx -y @harperfast/agent@latest", "agent:skills:update": "npx -y skills@latest add harperfast/skills --all --yes", - "start": "harper run .", - "dev": "harper dev .", + "start": "next build && harper run .", + "dev": "next build && harper dev .", "build": "next build", "lint": "eslint .", "format": "prettier --write .", - "deploy": "harper deploy_component . restart=true replicated=true" + "deploy": "next build && harper deploy_component . restart=true replicated=true" }, "dependencies": { "@harperfast/nextjs": "^2.2.1", diff --git a/template-nextjs/README.md b/template-nextjs/README.md index 6364acd..c55c03f 100644 --- a/template-nextjs/README.md +++ b/template-nextjs/README.md @@ -71,7 +71,7 @@ Then deploy your app: npm run deploy ``` -`npm run deploy` uploads the component and Harper builds the Next.js app on the server when it starts — no local build required. +`npm run deploy` runs `next build` and ships the prebuilt output, then Harper serves it — no build runs on the cluster. (Building locally avoids a multi-threaded on-cluster build race; see the note in [`config.yaml`](./config.yaml).) ## Keep Going! diff --git a/template-nextjs/config.yaml b/template-nextjs/config.yaml index c92a838..401379b 100644 --- a/template-nextjs/config.yaml +++ b/template-nextjs/config.yaml @@ -12,10 +12,15 @@ graphqlSchema: files: 'schema.graphql' -# Runs the Next.js app as a Harper component. Under `harper dev` it serves the Next.js dev server -# with hot-module reloading; under `harper run` (and `harper deploy`) it builds and serves the app. -# Server-side code (server actions and server components) reads and writes Harper tables directly -# through the injected `tables` global — see app/actions.js. Because this plugin owns HTTP routing -# for the app, there is no `static` or `rest` handler here. +# Runs the Next.js app as a Harper component. The npm scripts run `next build` first, then this +# component serves the prebuilt output (see the `prebuilt` note below); `harper dev` also serves it +# with hot-module reloading. Server-side code (server actions and server components) reads and writes +# Harper tables directly through the injected `tables` global — see app/actions.js. Because this +# plugin owns HTTP routing for the app, there is no `static` or `rest` handler here. '@harperfast/nextjs': package: '@harperfast/nextjs' + # Serve a prebuilt `.next` (the npm scripts run `next build` for you) instead of building on + # startup. On a multi-threaded Harper cluster the on-startup build races across worker threads and + # fails, so the app never serves; prebuilt avoids that. See + # https://github.com/HarperFast/nextjs/issues/52 + prebuilt: true diff --git a/template-nextjs/package.json b/template-nextjs/package.json index b99657a..d3a785a 100644 --- a/template-nextjs/package.json +++ b/template-nextjs/package.json @@ -6,12 +6,12 @@ "scripts": { "agent:run": "npx -y @harperfast/agent@latest", "agent:skills:update": "npx -y skills@latest add harperfast/skills --all --yes", - "start": "harper run .", - "dev": "harper dev .", + "start": "next build && harper run .", + "dev": "next build && harper dev .", "build": "next build", "lint": "eslint .", "format": "prettier --write .", - "deploy": "harper deploy_component . restart=true replicated=true" + "deploy": "next build && harper deploy_component . restart=true replicated=true" }, "dependencies": { "@harperfast/nextjs": "^2.2.1", diff --git a/template.tests/nextSmoke.js b/template.tests/nextSmoke.js index 61d91df..4cdf6fc 100644 --- a/template.tests/nextSmoke.js +++ b/template.tests/nextSmoke.js @@ -2,13 +2,17 @@ /** * Runtime smoke test for a generated Next.js-on-Harper template application. * - * The `@harperfast/nextjs` plugin owns HTTP routing and builds the Next.js app on startup (under - * `harper run`), so these apps don't expose the REST resource surface that runtimeSmoke.js checks. - * This variant boots the app under a real Harper instance (isolated root, throwaway admin user) - * and verifies the two things every Next.js template must do: + * The `@harperfast/nextjs` plugin owns HTTP routing, so these apps don't expose the REST resource + * surface that runtimeSmoke.js checks. The templates deploy prebuilt (`prebuilt: true` + a `.next` + * produced by `next build`, which the app's own `build` script runs before this test), so `harper + * run` serves that build rather than compiling on startup. This variant boots the app under a real + * Harper instance (isolated root, throwaway admin user) and verifies the two things every Next.js + * template must do: * - * 1. The on-startup production build succeeds and the app is served — `GET /` returns HTML. - * (A failed build makes the plugin skip serving, so every route 404s; this catches that.) + * 1. The prebuilt app is served — `GET /` returns HTML. (If `.next` is missing, or a regression + * reintroduces an on-startup build that races across worker threads and fails, the plugin + * skips serving and every route 404s; this catches that. Harper runs multi-threaded here on + * purpose so that regression would surface.) * 2. Server-side Harper access works — the home page reads the `Count` table via a server * action and renders the persisted count, so the HTML contains "count is". * @@ -53,9 +57,6 @@ const harper = spawn(harperBin, ['run', appDir], { ROOTPATH: rootPath, HTTP_PORT: String(httpPort), OPERATIONSAPI_NETWORK_PORT: String(opsPort), - // Build the Next.js app single-threaded so the smoke is deterministic (the plugin otherwise - // builds once and dedupes across worker threads; one thread avoids any cross-thread races). - THREADS_COUNT: '1', }, // Own process group so cleanup can kill Harper's worker threads/children along with it. detached: true, From 6bd7435f314cff48b04b32b4335de2011409b997 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 22 Jul 2026 11:14:18 -0400 Subject: [PATCH 10/15] fix(templates): use Harper's atomic addTo for the counter increment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The counter's read-then-write (`get` then `put value+1`) loses updates when requests overlap — two workers read 7 and both write 8. Since this starter deploys replicated and CI now runs multiple Harper workers, switch to Harper's atomic delta inside a transaction (thanks @kriszyp): await transaction(async () => { const record = await tables.Count.update('count'); record.addTo('value', 1); }); `addTo` is safe across worker threads and replicated nodes, and the transaction creates the row on first use. Both `transaction` and `tables` are Harper runtime globals — no `import 'harper'` (which would break the production build). Mirrored across JS + TS: server actions, `harper.d.ts` types (adds `transaction` + `update(id).addTo`), eslint globals (`transaction`), and the README snippet. Verified on a multi-threaded local instance: increments 0 -> 1 -> 2 from an empty table; fresh scaffolds install/lint/tsc/build clean for both variants. Co-Authored-By: Claude Opus 4.8 --- template-nextjs-ts/README.md | 11 +++++------ template-nextjs-ts/app/actions.ts | 18 +++++++++++------- template-nextjs-ts/eslint.config.mjs | 3 ++- template-nextjs-ts/harper.d.ts | 18 +++++++++++++----- template-nextjs/README.md | 11 +++++------ template-nextjs/app/actions.js | 20 ++++++++++++-------- template-nextjs/eslint.config.mjs | 3 ++- 7 files changed, 50 insertions(+), 34 deletions(-) diff --git a/template-nextjs-ts/README.md b/template-nextjs-ts/README.md index 5ea83d9..ba2c25c 100644 --- a/template-nextjs-ts/README.md +++ b/template-nextjs-ts/README.md @@ -32,7 +32,7 @@ Your tables live in [`schema.graphql`](./schema.graphql). The starter defines a ### Access Harper From Server Code -Harper injects a `tables` global into server-side code, so server actions and server components read and write your database directly — no import needed. The `tables` types come from [`harper.d.ts`](./harper.d.ts): +Harper injects `tables` and `transaction` globals into server-side code, so server actions and server components read and write your database directly — no import needed (their types come from [`harper.d.ts`](./harper.d.ts)). Use an atomic `addTo` inside a `transaction` for writes that stay correct when requests overlap across worker threads and replicated nodes (a read-then-write would lose concurrent increments): ```ts 'use server'; @@ -43,15 +43,14 @@ export async function getCount(): Promise { } export async function increment(): Promise { - const current = await tables.Count.get('count'); - await tables.Count.put('count', { - id: 'count', - value: (current?.value ?? 0) + 1, + await transaction(async () => { + const record = await tables.Count.update('count'); + record.addTo('value', 1); }); } ``` -> **Don't** add a top-level `import 'harper'` in these modules. It runs during the Next.js production build (when Next collects page data) and conflicts with the running database — use the injected `tables` global instead. +> **Don't** add a top-level `import 'harper'` in these modules. It runs during the Next.js production build (when Next collects page data) and conflicts with the running database — use the injected globals instead. Put data access in **server actions** (see [`app/actions.ts`](./app/actions.ts)) so that both server _and_ client components can share the same functions. Any action a client can reach is a public endpoint, so add your own authorization checks before shipping mutations that matter. diff --git a/template-nextjs-ts/app/actions.ts b/template-nextjs-ts/app/actions.ts index 642f51f..4555886 100644 --- a/template-nextjs-ts/app/actions.ts +++ b/template-nextjs-ts/app/actions.ts @@ -3,19 +3,23 @@ import { revalidatePath } from 'next/cache'; // Server actions run *inside* Harper, so they read and write tables directly through the injected -// `tables` global — no separate API and no network round-trip. Harper provides `tables`, so use it -// directly; do NOT add a top-level `import 'harper'` — that import runs during the Next.js -// production build and conflicts with the running database. +// globals — no separate API and no network round-trip. Harper provides `tables` and `transaction`, +// so use them directly; do NOT add a top-level `import 'harper'` — that import runs during the +// Next.js production build and conflicts with the running database. -// The whole counter lives in a single row keyed by 'count', so reads and writes are a cheap -// point lookup — no table scans. +// The whole counter lives in a single row keyed 'count', so reads are a cheap point lookup. export async function getCount(): Promise { const record = await tables.Count.get('count'); return record?.value ?? 0; } export async function increment(): Promise { - const current = await tables.Count.get('count'); - await tables.Count.put('count', { id: 'count', value: (current?.value ?? 0) + 1 }); + // Atomic increment: `addTo` inside a transaction is safe when requests overlap across worker + // threads and replicated nodes — a read-then-write would lose concurrent increments. The + // transaction also creates the row on first use. + await transaction(async () => { + const record = await tables.Count.update('count'); + record.addTo('value', 1); + }); revalidatePath('/'); } diff --git a/template-nextjs-ts/eslint.config.mjs b/template-nextjs-ts/eslint.config.mjs index 78d429c..2c10208 100644 --- a/template-nextjs-ts/eslint.config.mjs +++ b/template-nextjs-ts/eslint.config.mjs @@ -3,10 +3,11 @@ import next from 'eslint-config-next'; const eslintConfig = [ ...next, { - // `tables` is a global injected by the Harper runtime for server-side code. + // `tables` and `transaction` are globals injected by the Harper runtime for server-side code. languageOptions: { globals: { tables: 'readonly', + transaction: 'readonly', }, }, }, diff --git a/template-nextjs-ts/harper.d.ts b/template-nextjs-ts/harper.d.ts index d371b39..2a8fe5a 100644 --- a/template-nextjs-ts/harper.d.ts +++ b/template-nextjs-ts/harper.d.ts @@ -1,22 +1,30 @@ // Ambient type declarations for the Harper runtime. // -// Harper injects a `tables` global into server-side code, with one entry per `@table` defined in -// schema.graphql. These hand-written declarations give you type-safe access to your data. As you -// add tables and columns to the schema, mirror them here. (The `@harperfast/schema-codegen` -// component can also generate these for you — see the other create-harper templates.) +// Harper injects globals into server-side code: `tables` (one entry per `@table` in schema.graphql) +// and `transaction` (runs a callback in a committing transaction). These hand-written declarations +// give you type-safe access. As you add tables and columns, mirror them here. (The +// `@harperfast/schema-codegen` component can also generate the table types for you.) export interface CountRecord { id: string; value: number; } +/** An updatable record from `tables..update(id)`, mutated inside a `transaction(...)`. */ +interface UpdatableRecord { + addTo(property: keyof T, value: number): void; + subtractFrom(property: keyof T, value: number): void; +} + interface HarperTable { get(id: string): Promise; - put(id: string, record: T): Promise; + update(id: string): Promise>; } declare global { const tables: { Count: HarperTable; }; + /** Run `callback` in a Harper transaction, committing its writes when it resolves. */ + function transaction(callback: () => T | Promise): Promise; } diff --git a/template-nextjs/README.md b/template-nextjs/README.md index c55c03f..4b95a26 100644 --- a/template-nextjs/README.md +++ b/template-nextjs/README.md @@ -32,7 +32,7 @@ Your tables live in [`schema.graphql`](./schema.graphql). The starter defines a ### Access Harper From Server Code -Harper injects a `tables` global into server-side code, so server actions and server components read and write your database directly — no import needed: +Harper injects `tables` and `transaction` globals into server-side code, so server actions and server components read and write your database directly — no import needed. Use an atomic `addTo` inside a `transaction` for writes that stay correct when requests overlap across worker threads and replicated nodes (a read-then-write would lose concurrent increments): ```js 'use server'; @@ -43,15 +43,14 @@ export async function getCount() { } export async function increment() { - const current = await tables.Count.get('count'); - await tables.Count.put('count', { - id: 'count', - value: (current?.value ?? 0) + 1, + await transaction(async () => { + const record = await tables.Count.update('count'); + record.addTo('value', 1); }); } ``` -> **Don't** add a top-level `import 'harper'` in these modules. It runs during the Next.js production build (when Next collects page data) and conflicts with the running database — use the injected `tables` global instead. +> **Don't** add a top-level `import 'harper'` in these modules. It runs during the Next.js production build (when Next collects page data) and conflicts with the running database — use the injected globals instead. Put data access in **server actions** (see [`app/actions.js`](./app/actions.js)) so that both server _and_ client components can share the same functions. Any action a client can reach is a public endpoint, so add your own authorization checks before shipping mutations that matter. diff --git a/template-nextjs/app/actions.js b/template-nextjs/app/actions.js index 18816da..5158e0c 100644 --- a/template-nextjs/app/actions.js +++ b/template-nextjs/app/actions.js @@ -1,22 +1,26 @@ 'use server'; -/* global tables */ +/* global tables, transaction */ import { revalidatePath } from 'next/cache'; // Server actions run *inside* Harper, so they read and write tables directly through the injected -// `tables` global — no separate API and no network round-trip. Harper provides `tables`, so use it -// directly; do NOT add a top-level `import 'harper'` — that import runs during the Next.js -// production build and conflicts with the running database. +// globals — no separate API and no network round-trip. Harper provides `tables` and `transaction`, +// so use them directly; do NOT add a top-level `import 'harper'` — that import runs during the +// Next.js production build and conflicts with the running database. -// The whole counter lives in a single row keyed by 'count', so reads and writes are a cheap -// point lookup — no table scans. +// The whole counter lives in a single row keyed 'count', so reads are a cheap point lookup. export async function getCount() { const record = await tables.Count.get('count'); return record?.value ?? 0; } export async function increment() { - const current = await tables.Count.get('count'); - await tables.Count.put('count', { id: 'count', value: (current?.value ?? 0) + 1 }); + // Atomic increment: `addTo` inside a transaction is safe when requests overlap across worker + // threads and replicated nodes — a read-then-write would lose concurrent increments. The + // transaction also creates the row on first use. + await transaction(async () => { + const record = await tables.Count.update('count'); + record.addTo('value', 1); + }); revalidatePath('/'); } diff --git a/template-nextjs/eslint.config.mjs b/template-nextjs/eslint.config.mjs index 78d429c..2c10208 100644 --- a/template-nextjs/eslint.config.mjs +++ b/template-nextjs/eslint.config.mjs @@ -3,10 +3,11 @@ import next from 'eslint-config-next'; const eslintConfig = [ ...next, { - // `tables` is a global injected by the Harper runtime for server-side code. + // `tables` and `transaction` are globals injected by the Harper runtime for server-side code. languageOptions: { globals: { tables: 'readonly', + transaction: 'readonly', }, }, }, From ff0d37deba6c379be04aab9afd36715192c16813 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 22 Jul 2026 12:03:06 -0400 Subject: [PATCH 11/15] fix(templates): mark Next.js templates as SSR in the catalog (ssr: true) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Next.js templates render on the server — the page is `force-dynamic` and reads Harper on every request — but the catalog entries had `ssr: false`, so the exported `create-harper/templates` contract reported the wrong capability and the picker omitted "+ SSR" (thanks @kriszyp). Set `ssr: true` for both entries. The `-ssr` name suffix is a Vite-variant convention that doesn't apply to Next.js (which is always server-rendered), so the `templates.test.js` invariant is relaxed to expect ssr=true for the nextjs framework and the suffix rule for the rest. The picker now shows the variants as "TypeScript + SSR" / "JavaScript + SSR". Co-Authored-By: Claude Opus 4.8 --- lib/constants/templates.js | 11 +++++++---- lib/constants/templates.test.js | 10 ++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/lib/constants/templates.js b/lib/constants/templates.js index c5d51cc..411354c 100644 --- a/lib/constants/templates.js +++ b/lib/constants/templates.js @@ -141,9 +141,12 @@ export const templates = [ description: 'A type-safe Next.js app that reads and writes Harper tables directly from server actions.', tags: ['Next.js', 'TypeScript', 'React', 'App Router'], typescript: true, - ssr: false, - // The @harperfast/nextjs plugin builds Next.js on load; whether that works in the Studio's - // deploy-only model is unverified, so we don't build/publish a Studio package yet. + // Next.js renders on the server (the app is force-dynamic and reads Harper per request), so + // this is SSR even though the name has no `-ssr` suffix (that suffix marks the Vite variants). + ssr: true, + // Not built/published as a Studio template: the plugin needs a prebuilt deploy (its on-startup + // build races on multi-thread clusters — HarperFast/nextjs#52) and Studio's edit-in-place + // model doesn't rebuild, so it isn't a fit today. studio: false, }), template({ @@ -153,7 +156,7 @@ export const templates = [ description: "A Next.js app wired to Harper's Resource API, reading and writing tables from server actions.", tags: ['Next.js', 'React', 'App Router'], typescript: false, - ssr: false, + ssr: true, studio: false, }), ]; diff --git a/lib/constants/templates.test.js b/lib/constants/templates.test.js index 4829c96..4783c94 100644 --- a/lib/constants/templates.test.js +++ b/lib/constants/templates.test.js @@ -34,10 +34,12 @@ describe('templates catalog', () => { } }); - test('typescript and ssr flags match the template name suffixes', () => { + test('typescript and ssr flags are correct per template', () => { for (const t of catalog) { expect(t.typescript).toBe(t.name.includes('-ts')); - expect(t.ssr).toBe(t.name.endsWith('-ssr')); + // The `-ssr` suffix marks the Vite SSR variants; Next.js is always server-rendered. + const expectedSsr = t.framework === 'nextjs' ? true : t.name.endsWith('-ssr'); + expect(t.ssr).toBe(expectedSsr); } }); @@ -102,8 +104,8 @@ describe('frameworks (derived from the catalog)', () => { display: 'Next.js', color: magenta, variants: [ - { name: 'nextjs-ts', display: 'TypeScript', color: blue }, - { name: 'nextjs', display: 'JavaScript', color: yellow }, + { name: 'nextjs-ts', display: 'TypeScript + SSR', color: blue }, + { name: 'nextjs', display: 'JavaScript + SSR', color: yellow }, ], }, ]); From f8e4e35d3fcc30fe286bbabb8036cc49f65065f7 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 24 Jul 2026 11:50:36 -0400 Subject: [PATCH 12/15] refactor(templates): drop the prebuilt deploy workaround for Next.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @harperfast/nextjs 2.2.2 fixed the multi-thread build race (serializes the build across worker threads — HarperFast/nextjs#52), which is the only reason these templates shipped `prebuilt: true` + build-first scripts. Revert to the same server-side-build model as the other templates: - config.yaml: drop `prebuilt: true` (Harper builds on `harper run`/deploy). - scripts: `dev` → `harper dev .`, `start` → `harper run .`, `deploy` → `harper deploy_component ...` (no `next build &&` prefix). - bump `@harperfast/nextjs` to `^2.2.3` so the build-serialization fix is required. - README deploy note back to "Harper builds on the server, no local build". Verified on Harper 5.1.22 with @harperfast/nextjs 2.2.3: a fresh scaffold builds and serves multi-threaded via `harper run` (no prebuilt, no race) and the counter works. (Server code still uses the injected `tables`/`transaction` globals, not `import 'harper'` — that stays until HarperFast/nextjs#41 + #51 land.) Co-Authored-By: Claude Opus 4.8 --- template-nextjs-ts/README.md | 2 +- template-nextjs-ts/config.yaml | 15 +++++---------- template-nextjs-ts/package.json | 8 ++++---- template-nextjs/README.md | 2 +- template-nextjs/config.yaml | 15 +++++---------- template-nextjs/package.json | 8 ++++---- 6 files changed, 20 insertions(+), 30 deletions(-) diff --git a/template-nextjs-ts/README.md b/template-nextjs-ts/README.md index ba2c25c..62b2a31 100644 --- a/template-nextjs-ts/README.md +++ b/template-nextjs-ts/README.md @@ -70,7 +70,7 @@ Then deploy your app: npm run deploy ``` -`npm run deploy` runs `next build` and ships the prebuilt output, then Harper serves it — no build runs on the cluster. (Building locally avoids a multi-threaded on-cluster build race; see the note in [`config.yaml`](./config.yaml).) +`npm run deploy` uploads the component and Harper builds the Next.js app on the server when it starts — no local build required. ## Keep Going! diff --git a/template-nextjs-ts/config.yaml b/template-nextjs-ts/config.yaml index 05d4060..ba974d8 100644 --- a/template-nextjs-ts/config.yaml +++ b/template-nextjs-ts/config.yaml @@ -12,15 +12,10 @@ graphqlSchema: files: 'schema.graphql' -# Runs the Next.js app as a Harper component. The npm scripts run `next build` first, then this -# component serves the prebuilt output (see the `prebuilt` note below); `harper dev` also serves it -# with hot-module reloading. Server-side code (server actions and server components) reads and writes -# Harper tables directly through the injected `tables` global — see app/actions.ts. Because this -# plugin owns HTTP routing for the app, there is no `static` or `rest` handler here. +# Runs the Next.js app as a Harper component. Under `harper dev` it serves the Next.js dev server +# with hot-module reloading; under `harper run` (and on `harper deploy`) it builds and serves the +# app. Server-side code (server actions and server components) reads and writes Harper tables +# directly through the injected `tables` and `transaction` globals — see app/actions.ts. Because +# this plugin owns HTTP routing for the app, there is no `static` or `rest` handler here. '@harperfast/nextjs': package: '@harperfast/nextjs' - # Serve a prebuilt `.next` (the npm scripts run `next build` for you) instead of building on - # startup. On a multi-threaded Harper cluster the on-startup build races across worker threads and - # fails, so the app never serves; prebuilt avoids that. See - # https://github.com/HarperFast/nextjs/issues/52 - prebuilt: true diff --git a/template-nextjs-ts/package.json b/template-nextjs-ts/package.json index 738d957..ad6e9fb 100644 --- a/template-nextjs-ts/package.json +++ b/template-nextjs-ts/package.json @@ -6,15 +6,15 @@ "scripts": { "agent:run": "npx -y @harperfast/agent@latest", "agent:skills:update": "npx -y skills@latest add harperfast/skills --all --yes", - "start": "next build && harper run .", - "dev": "next build && harper dev .", + "start": "harper run .", + "dev": "harper dev .", "build": "next build", "lint": "eslint .", "format": "prettier --write .", - "deploy": "next build && harper deploy_component . restart=true replicated=true" + "deploy": "harper deploy_component . restart=true replicated=true" }, "dependencies": { - "@harperfast/nextjs": "^2.2.1", + "@harperfast/nextjs": "^2.2.3", "next": "^16.2.11", "react": "^19.2.8", "react-dom": "^19.2.8" diff --git a/template-nextjs/README.md b/template-nextjs/README.md index 4b95a26..76bf304 100644 --- a/template-nextjs/README.md +++ b/template-nextjs/README.md @@ -70,7 +70,7 @@ Then deploy your app: npm run deploy ``` -`npm run deploy` runs `next build` and ships the prebuilt output, then Harper serves it — no build runs on the cluster. (Building locally avoids a multi-threaded on-cluster build race; see the note in [`config.yaml`](./config.yaml).) +`npm run deploy` uploads the component and Harper builds the Next.js app on the server when it starts — no local build required. ## Keep Going! diff --git a/template-nextjs/config.yaml b/template-nextjs/config.yaml index 401379b..6940d16 100644 --- a/template-nextjs/config.yaml +++ b/template-nextjs/config.yaml @@ -12,15 +12,10 @@ graphqlSchema: files: 'schema.graphql' -# Runs the Next.js app as a Harper component. The npm scripts run `next build` first, then this -# component serves the prebuilt output (see the `prebuilt` note below); `harper dev` also serves it -# with hot-module reloading. Server-side code (server actions and server components) reads and writes -# Harper tables directly through the injected `tables` global — see app/actions.js. Because this -# plugin owns HTTP routing for the app, there is no `static` or `rest` handler here. +# Runs the Next.js app as a Harper component. Under `harper dev` it serves the Next.js dev server +# with hot-module reloading; under `harper run` (and on `harper deploy`) it builds and serves the +# app. Server-side code (server actions and server components) reads and writes Harper tables +# directly through the injected `tables` and `transaction` globals — see app/actions.js. Because +# this plugin owns HTTP routing for the app, there is no `static` or `rest` handler here. '@harperfast/nextjs': package: '@harperfast/nextjs' - # Serve a prebuilt `.next` (the npm scripts run `next build` for you) instead of building on - # startup. On a multi-threaded Harper cluster the on-startup build races across worker threads and - # fails, so the app never serves; prebuilt avoids that. See - # https://github.com/HarperFast/nextjs/issues/52 - prebuilt: true diff --git a/template-nextjs/package.json b/template-nextjs/package.json index d3a785a..f48a804 100644 --- a/template-nextjs/package.json +++ b/template-nextjs/package.json @@ -6,15 +6,15 @@ "scripts": { "agent:run": "npx -y @harperfast/agent@latest", "agent:skills:update": "npx -y skills@latest add harperfast/skills --all --yes", - "start": "next build && harper run .", - "dev": "next build && harper dev .", + "start": "harper run .", + "dev": "harper dev .", "build": "next build", "lint": "eslint .", "format": "prettier --write .", - "deploy": "next build && harper deploy_component . restart=true replicated=true" + "deploy": "harper deploy_component . restart=true replicated=true" }, "dependencies": { - "@harperfast/nextjs": "^2.2.1", + "@harperfast/nextjs": "^2.2.3", "next": "^16.2.11", "react": "^19.2.8", "react-dom": "^19.2.8" From cb67812908db306116c79def6f84d0bce27ec4a4 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 24 Jul 2026 11:50:36 -0400 Subject: [PATCH 13/15] test(nextjs): exercise the increment server action in the runtime smoke Per review: nextSmoke only GET'd `/`, so the write half of the counter (the `transaction` + `addTo` server action and its revalidation) was untested. It now invokes the increment action via its no-JS progressive-enhancement form POST (multipart with the rendered `$ACTION_ID_` field) and asserts a fresh reload shows the persisted count advanced by one. Also updated the header to reflect the server-side (not prebuilt) build. Co-Authored-By: Claude Opus 4.8 --- template.tests/nextSmoke.js | 71 +++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 19 deletions(-) diff --git a/template.tests/nextSmoke.js b/template.tests/nextSmoke.js index 4cdf6fc..2266a3e 100644 --- a/template.tests/nextSmoke.js +++ b/template.tests/nextSmoke.js @@ -3,18 +3,17 @@ * Runtime smoke test for a generated Next.js-on-Harper template application. * * The `@harperfast/nextjs` plugin owns HTTP routing, so these apps don't expose the REST resource - * surface that runtimeSmoke.js checks. The templates deploy prebuilt (`prebuilt: true` + a `.next` - * produced by `next build`, which the app's own `build` script runs before this test), so `harper - * run` serves that build rather than compiling on startup. This variant boots the app under a real - * Harper instance (isolated root, throwaway admin user) and verifies the two things every Next.js - * template must do: + * surface that runtimeSmoke.js checks. `harper run` builds the Next.js app on startup (the plugin + * serializes that build across worker threads — @harperfast/nextjs#52). This variant boots the app + * under a real Harper instance (isolated root, throwaway admin user) and verifies the two things + * every Next.js template must do: * - * 1. The prebuilt app is served — `GET /` returns HTML. (If `.next` is missing, or a regression - * reintroduces an on-startup build that races across worker threads and fails, the plugin - * skips serving and every route 404s; this catches that. Harper runs multi-threaded here on - * purpose so that regression would surface.) - * 2. Server-side Harper access works — the home page reads the `Count` table via a server - * action and renders the persisted count, so the HTML contains "count is". + * 1. The app builds and serves — `GET /` returns HTML rendering the Harper-backed counter (a + * server component reading the Count table). Harper runs multi-threaded here on purpose: a + * build that races/fails leaves the plugin unable to serve, and this catches that. + * 2. The write path works — invoking the increment server action (transaction + addTo) advances + * the persisted count, verified by a fresh reload. GET-only would still pass if the write half + * were broken. * * Usage: node template.tests/nextSmoke.js * @@ -113,25 +112,59 @@ async function waitForServer(timeoutMs = 300_000) { const failures = []; +// Pull the counter value out of the rendered HTML ("count is "; React can split that text node). +function parseCount(html) { + const m = html.replace(/<[^>]+>/g, '').match(/count is\s*(\d+)/i); + return m ? Number(m[1]) : null; +} + try { await waitForServer(); + // 1. The app builds + serves, and the home page renders the Harper-backed counter (a server + // component reading the Count table). const response = await fetch(baseUrl + '/', { signal: AbortSignal.timeout(10_000) }); - const body = await response.text(); + const html = await response.text(); const contentType = response.headers.get('content-type') ?? ''; + const countBefore = parseCount(html); + // Next renders `
` with a hidden `$ACTION_ID_` field; posting it + // as multipart/form-data is the no-JS progressive-enhancement path that invokes the server action. + const actionField = html.match(/name="(\$ACTION_ID_[a-f0-9]+)"/)?.[1]; + if (!response.ok) { failures.push(`expected 200 from GET /, got ${response.status} (build likely failed → not served)`); } else if (!contentType.includes('text/html')) { - failures.push(`expected an text/html response from GET /, got ${contentType}`); - } else if (!body.toLowerCase().includes('") — server-side table read may have failed'); + } else if (!actionField) { + failures.push("GET / HTML is missing the increment form's server-action field"); + } else { + console.log(`✓ app builds, serves, and renders the counter (count is ${countBefore})`); + + // 2. Exercise the write half end to end: invoke the increment server action, then confirm a + // fresh reload reflects the persisted, incremented value (transaction + addTo + revalidate). + const form = new FormData(); + form.append(actionField, ''); + const post = await fetch(baseUrl + '/', { method: 'POST', body: form, signal: AbortSignal.timeout(15_000) }); + if (!post.ok) { + failures.push(`increment server action POST failed: ${post.status}`); + } else { + const reload = await fetch(baseUrl + '/', { signal: AbortSignal.timeout(10_000) }); + const countAfter = parseCount(await reload.text()); + if (countAfter === countBefore + 1) { + console.log(`✓ increment server action advances the persisted count (${countBefore} → ${countAfter})`); + } else { + failures.push( + `increment did not advance the count on reload: ${countBefore} → ${countAfter} ` + + '(server action, transaction/addTo, or revalidation may be broken)', + ); + } + } } } catch (error) { failures.push(String(error?.message ?? error)); From d1a5466a19e096771d9c2cda75ab9f961cea2a2b Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 24 Jul 2026 13:28:23 -0400 Subject: [PATCH 14/15] =?UTF-8?q?fix(templates):=20ship=20Next.js=20prebui?= =?UTF-8?q?lt=20=E2=80=94=20on-cluster=20build=20fails=20(nextjs#57/#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-instates `prebuilt: true` + build-first scripts, reversing f8e4e35. That commit dropped prebuilt assuming @harperfast/nextjs 2.2.2's build-lock (#52) made the on-startup server build viable — but re-testing on a real cluster (stage, Harper 5.1.23) shows the server-side build still fails, regardless of Harper version or bundler: - Turbopack (Next 16 default): `next build` crashes the instant it runs inside a Harper worker thread — `uncaughtException: Worker creator already registered` → build aborts, no .next, app 404s/500s. Filed HarperFast/nextjs#57. - `bundler: webpack`: no crash, but a cold build overruns Harper's 30s `handleApplication` timeout → component load aborts. Filed HarperFast/nextjs#58. - `prebuilt: true`: works — verified live on stage (serves + atomic increment persists across the replicated cluster). Local `harper run`/`dev` and CI stay green because a plain-CLI `next build` (not inside a Harper worker thread) succeeds; only a multi-thread cluster hits the crash. - config.yaml: `prebuilt: true`; comment cites #57/#58 (not the fixed #52). - scripts: `dev`/`start`/`deploy` prefix `next build` again. - nextSmoke.js: build the app if `.next` is absent (harper run serves prebuilt, it won't build on startup); wording updated. Verified locally on 5.1.22. Catalog unchanged — studio: false stays (without an on-cluster build the plugin is a poor Studio experience). Co-Authored-By: Claude Opus 4.8 --- template-nextjs-ts/README.md | 2 +- template-nextjs-ts/config.yaml | 17 +++++++++++----- template-nextjs-ts/package.json | 6 +++--- template-nextjs/README.md | 2 +- template-nextjs/config.yaml | 17 +++++++++++----- template-nextjs/package.json | 6 +++--- template.tests/nextSmoke.js | 35 +++++++++++++++++++++++---------- 7 files changed, 57 insertions(+), 28 deletions(-) diff --git a/template-nextjs-ts/README.md b/template-nextjs-ts/README.md index 62b2a31..6f96d52 100644 --- a/template-nextjs-ts/README.md +++ b/template-nextjs-ts/README.md @@ -70,7 +70,7 @@ Then deploy your app: npm run deploy ``` -`npm run deploy` uploads the component and Harper builds the Next.js app on the server when it starts — no local build required. +`npm run deploy` runs `next build` locally and ships the prebuilt `.next` output, then Harper serves it — no build runs on the cluster. (Building on the cluster currently fails; see the note in [`config.yaml`](./config.yaml).) ## Keep Going! diff --git a/template-nextjs-ts/config.yaml b/template-nextjs-ts/config.yaml index ba974d8..42e3a34 100644 --- a/template-nextjs-ts/config.yaml +++ b/template-nextjs-ts/config.yaml @@ -12,10 +12,17 @@ graphqlSchema: files: 'schema.graphql' -# Runs the Next.js app as a Harper component. Under `harper dev` it serves the Next.js dev server -# with hot-module reloading; under `harper run` (and on `harper deploy`) it builds and serves the -# app. Server-side code (server actions and server components) reads and writes Harper tables -# directly through the injected `tables` and `transaction` globals — see app/actions.ts. Because -# this plugin owns HTTP routing for the app, there is no `static` or `rest` handler here. +# Runs the Next.js app as a Harper component. The npm scripts run `next build` first, then this +# component serves the prebuilt `.next` output (`prebuilt: true` below); `harper dev` builds once +# and then serves with hot-module reloading. Server-side code (server actions and server components) +# reads and writes Harper tables directly through the injected `tables` and `transaction` globals — +# see app/actions.ts. Because this plugin owns HTTP routing for the app, there is no `static` or +# `rest` handler here. '@harperfast/nextjs': package: '@harperfast/nextjs' + # Serve a prebuilt `.next` (the npm scripts run `next build` for you) instead of building on + # startup. Building on a Harper cluster currently fails — the Turbopack build crashes inside a + # worker thread, and a webpack build overruns the component-load timeout — so the app never + # serves. Prebuilding locally sidesteps both. See + # https://github.com/HarperFast/nextjs/issues/57 and https://github.com/HarperFast/nextjs/issues/58 + prebuilt: true diff --git a/template-nextjs-ts/package.json b/template-nextjs-ts/package.json index ad6e9fb..2c62548 100644 --- a/template-nextjs-ts/package.json +++ b/template-nextjs-ts/package.json @@ -6,12 +6,12 @@ "scripts": { "agent:run": "npx -y @harperfast/agent@latest", "agent:skills:update": "npx -y skills@latest add harperfast/skills --all --yes", - "start": "harper run .", - "dev": "harper dev .", + "start": "next build && harper run .", + "dev": "next build && harper dev .", "build": "next build", "lint": "eslint .", "format": "prettier --write .", - "deploy": "harper deploy_component . restart=true replicated=true" + "deploy": "next build && harper deploy_component . restart=true replicated=true" }, "dependencies": { "@harperfast/nextjs": "^2.2.3", diff --git a/template-nextjs/README.md b/template-nextjs/README.md index 76bf304..f274945 100644 --- a/template-nextjs/README.md +++ b/template-nextjs/README.md @@ -70,7 +70,7 @@ Then deploy your app: npm run deploy ``` -`npm run deploy` uploads the component and Harper builds the Next.js app on the server when it starts — no local build required. +`npm run deploy` runs `next build` locally and ships the prebuilt `.next` output, then Harper serves it — no build runs on the cluster. (Building on the cluster currently fails; see the note in [`config.yaml`](./config.yaml).) ## Keep Going! diff --git a/template-nextjs/config.yaml b/template-nextjs/config.yaml index 6940d16..deb38ac 100644 --- a/template-nextjs/config.yaml +++ b/template-nextjs/config.yaml @@ -12,10 +12,17 @@ graphqlSchema: files: 'schema.graphql' -# Runs the Next.js app as a Harper component. Under `harper dev` it serves the Next.js dev server -# with hot-module reloading; under `harper run` (and on `harper deploy`) it builds and serves the -# app. Server-side code (server actions and server components) reads and writes Harper tables -# directly through the injected `tables` and `transaction` globals — see app/actions.js. Because -# this plugin owns HTTP routing for the app, there is no `static` or `rest` handler here. +# Runs the Next.js app as a Harper component. The npm scripts run `next build` first, then this +# component serves the prebuilt `.next` output (`prebuilt: true` below); `harper dev` builds once +# and then serves with hot-module reloading. Server-side code (server actions and server components) +# reads and writes Harper tables directly through the injected `tables` and `transaction` globals — +# see app/actions.js. Because this plugin owns HTTP routing for the app, there is no `static` or +# `rest` handler here. '@harperfast/nextjs': package: '@harperfast/nextjs' + # Serve a prebuilt `.next` (the npm scripts run `next build` for you) instead of building on + # startup. Building on a Harper cluster currently fails — the Turbopack build crashes inside a + # worker thread, and a webpack build overruns the component-load timeout — so the app never + # serves. Prebuilding locally sidesteps both. See + # https://github.com/HarperFast/nextjs/issues/57 and https://github.com/HarperFast/nextjs/issues/58 + prebuilt: true diff --git a/template-nextjs/package.json b/template-nextjs/package.json index f48a804..219de5b 100644 --- a/template-nextjs/package.json +++ b/template-nextjs/package.json @@ -6,12 +6,12 @@ "scripts": { "agent:run": "npx -y @harperfast/agent@latest", "agent:skills:update": "npx -y skills@latest add harperfast/skills --all --yes", - "start": "harper run .", - "dev": "harper dev .", + "start": "next build && harper run .", + "dev": "next build && harper dev .", "build": "next build", "lint": "eslint .", "format": "prettier --write .", - "deploy": "harper deploy_component . restart=true replicated=true" + "deploy": "next build && harper deploy_component . restart=true replicated=true" }, "dependencies": { "@harperfast/nextjs": "^2.2.3", diff --git a/template.tests/nextSmoke.js b/template.tests/nextSmoke.js index 2266a3e..9799873 100644 --- a/template.tests/nextSmoke.js +++ b/template.tests/nextSmoke.js @@ -3,24 +3,26 @@ * Runtime smoke test for a generated Next.js-on-Harper template application. * * The `@harperfast/nextjs` plugin owns HTTP routing, so these apps don't expose the REST resource - * surface that runtimeSmoke.js checks. `harper run` builds the Next.js app on startup (the plugin - * serializes that build across worker threads — @harperfast/nextjs#52). This variant boots the app - * under a real Harper instance (isolated root, throwaway admin user) and verifies the two things - * every Next.js template must do: + * surface that runtimeSmoke.js checks. The templates ship `prebuilt: true`, so `harper run` serves + * a prebuilt `.next` rather than building on startup (an on-cluster build currently fails — + * @harperfast/nextjs#57 and #58). This smoke mirrors the real deploy flow: it builds the app (if it + * isn't already built), then boots it under a real Harper instance (isolated root, throwaway admin + * user, multi-threaded) and verifies the two things every Next.js template must do: * - * 1. The app builds and serves — `GET /` returns HTML rendering the Harper-backed counter (a - * server component reading the Count table). Harper runs multi-threaded here on purpose: a - * build that races/fails leaves the plugin unable to serve, and this catches that. + * 1. The app serves — `GET /` returns HTML rendering the Harper-backed counter (a server + * component reading the Count table). A missing or broken prebuilt leaves the plugin unable to + * serve, and this catches that. * 2. The write path works — invoking the increment server action (transaction + addTo) advances * the persisted count, verified by a fresh reload. GET-only would still pass if the write half * were broken. * * Usage: node template.tests/nextSmoke.js * - * The app must already be installed. Requires the `harper` CLI on PATH (or set HARPER_BIN). - * POSIX only. Ports override via SMOKE_HTTP_PORT / SMOKE_OPS_PORT. + * The app must already be installed (this script runs `next build` if `.next` is absent). Requires + * the `harper` CLI on PATH (or set HARPER_BIN). POSIX only. Ports override via SMOKE_HTTP_PORT / + * SMOKE_OPS_PORT. */ -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -30,6 +32,19 @@ if (!appDir || !fs.existsSync(path.join(appDir, 'config.yaml'))) { process.exit(2); } +// The templates ship `prebuilt: true`: `harper run` serves an existing `.next` and refuses to build +// on startup, so make sure the app is built first. Skip when it already is (CI runs the template's +// `build` script before this smoke); otherwise build here so the script also works standalone. +if (!fs.existsSync(path.join(appDir, '.next', 'BUILD_ID'))) { + console.log('No prebuilt .next found — running `next build`...'); + const nextBin = path.join(appDir, 'node_modules', '.bin', 'next'); + const build = spawnSync(nextBin, ['build'], { cwd: appDir, stdio: 'inherit', env: process.env }); + if (build.status !== 0) { + console.error('`next build` failed; cannot run the prebuilt smoke.'); + process.exit(1); + } +} + const httpPort = Number(process.env.SMOKE_HTTP_PORT ?? 19926); const opsPort = Number(process.env.SMOKE_OPS_PORT ?? 19925); const baseUrl = `http://127.0.0.1:${httpPort}`; From e3615e15aee05fda008feab34f6fd1ee51f9bdd0 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 24 Jul 2026 14:17:14 -0400 Subject: [PATCH 15/15] docs(ci): describe the prebuilt Next.js smoke flow in integration.yaml Follow-up to d1a5466 (prebuilt restore): the integration workflow comment still described the old "on-startup build" model. The Next.js smoke now serves the prebuilt output produced by the `build` step. Split into its own commit because pushing workflow files needs `workflow` OAuth scope (the HTTPS push used for d1a5466 rejects `.github/workflows/**`). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/integration.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 507239c..1448731 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -161,9 +161,9 @@ jobs: # Boot the generated app under a real Harper instance and verify its HTTP surface. Once # per template — the package manager doesn't matter here. The Next.js templates route # through the @harperfast/nextjs plugin (no REST resources), so they use a dedicated - # smoke that checks the on-startup build serves the Harper-backed counter; every other - # template uses runtimeSmoke, which also guards against the static handler swallowing - # REST GETs. + # smoke that serves the prebuilt build (produced by the `build` step above) and checks the + # Harper-backed counter reads and increments; every other template uses runtimeSmoke, + # which also guards against the static handler swallowing REST GETs. if [ "${{ matrix.pkg-manager }}" = "npm" ]; then echo "Installing Harper..." npm install --global harper