Skip to content

Commit e8115f8

Browse files
committed
first commit
0 parents  commit e8115f8

39 files changed

Lines changed: 6869 additions & 0 deletions

.github/workflows/deploy-docs.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Deploy Docs Website to GitHub Pages
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
pages: write
12+
id-token: write
13+
14+
concurrency:
15+
group: pages
16+
cancel-in-progress: true
17+
18+
jobs:
19+
build:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- name: Checkout
23+
uses: actions/checkout@v4
24+
25+
- name: Setup pnpm
26+
uses: pnpm/action-setup@v4
27+
with:
28+
version: 10
29+
30+
- name: Setup Node
31+
uses: actions/setup-node@v4
32+
with:
33+
node-version: 22
34+
cache: pnpm
35+
36+
- name: Install dependencies
37+
run: pnpm install --frozen-lockfile
38+
39+
- name: Build library
40+
run: pnpm build
41+
42+
- name: Build docs website
43+
run: pnpm docs:build
44+
45+
- name: Upload docs website artifact
46+
uses: actions/upload-pages-artifact@v3
47+
with:
48+
path: docs/dist
49+
50+
deploy:
51+
needs: build
52+
runs-on: ubuntu-latest
53+
environment:
54+
name: github-pages
55+
url: ${{ steps.deployment.outputs.page_url }}
56+
steps:
57+
- name: Deploy to GitHub Pages
58+
id: deployment
59+
uses: actions/deploy-pages@v4

.github/workflows/publish.yml

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
name: Publish to npm
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
paths:
8+
- 'package.json'
9+
workflow_dispatch:
10+
inputs:
11+
force_publish:
12+
description: 'Force publish without version check'
13+
required: false
14+
default: false
15+
type: boolean
16+
17+
jobs:
18+
check-version:
19+
runs-on: ubuntu-latest
20+
outputs:
21+
version_changed: ${{ steps.check.outputs.changed }}
22+
new_version: ${{ steps.check.outputs.version }}
23+
steps:
24+
- name: Checkout
25+
uses: actions/checkout@v4
26+
with:
27+
fetch-depth: 0
28+
29+
- name: Check if version changed
30+
id: check
31+
run: |
32+
# Get current version
33+
CURRENT_VERSION=$(jq -r '.version' package.json)
34+
echo "Current version: $CURRENT_VERSION"
35+
36+
# Check if this is a manual trigger with force
37+
if [ "${{ inputs.force_publish }}" == "true" ]; then
38+
echo "Force publish requested"
39+
echo "changed=true" >> $GITHUB_OUTPUT
40+
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
41+
exit 0
42+
fi
43+
44+
# Check if tag already exists
45+
if git rev-parse "v$CURRENT_VERSION" >/dev/null 2>&1; then
46+
echo "Tag v$CURRENT_VERSION already exists, skipping"
47+
echo "changed=false" >> $GITHUB_OUTPUT
48+
exit 0
49+
fi
50+
51+
# Get previous version from last commit
52+
PREVIOUS_VERSION=$(git show HEAD~1:package.json 2>/dev/null | jq -r '.version' 2>/dev/null || echo "0.0.0")
53+
echo "Previous version: $PREVIOUS_VERSION"
54+
55+
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
56+
echo "Version changed from $PREVIOUS_VERSION to $CURRENT_VERSION"
57+
echo "changed=true" >> $GITHUB_OUTPUT
58+
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
59+
else
60+
echo "Version unchanged"
61+
echo "changed=false" >> $GITHUB_OUTPUT
62+
fi
63+
64+
publish:
65+
needs: check-version
66+
if: needs.check-version.outputs.version_changed == 'true'
67+
runs-on: ubuntu-latest
68+
permissions:
69+
contents: write
70+
id-token: write
71+
steps:
72+
- name: Checkout
73+
uses: actions/checkout@v4
74+
75+
- name: Setup pnpm
76+
uses: pnpm/action-setup@v4
77+
with:
78+
version: 10
79+
80+
- name: Setup Node.js
81+
uses: actions/setup-node@v4
82+
with:
83+
node-version: '22'
84+
registry-url: 'https://registry.npmjs.org'
85+
cache: 'pnpm'
86+
87+
- name: Install dependencies
88+
run: pnpm install --frozen-lockfile
89+
90+
- name: Build
91+
run: pnpm build
92+
93+
- name: Publish to npm
94+
run: pnpm publish --access public --no-git-checks
95+
env:
96+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
97+
98+
- name: Create Git tag
99+
run: |
100+
VERSION=${{ needs.check-version.outputs.new_version }}
101+
git config user.name "github-actions[bot]"
102+
git config user.email "github-actions[bot]@users.noreply.github.com"
103+
git tag -a "v$VERSION" -m "Release v$VERSION"
104+
git push origin "v$VERSION"

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
dist
2+
node_modules
3+
.DS_Store

README.md

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
# @ideative/next-handler
2+
3+
Typed helpers for Next.js route handlers with:
4+
5+
- request context access (`getRequest()`),
6+
- Zod payload validation (`payload()`),
7+
- serializable errors that round-trip from backend to frontend.
8+
9+
## Install
10+
11+
```bash
12+
pnpm add @ideative/next-handler
13+
```
14+
15+
Peer dependencies:
16+
17+
- `next` >= 15
18+
- `zod` >= 4.3.6
19+
- `react` >= 19.2.4 (for intl context utilities)
20+
- `next-intl` >= 4.8.2 (for intl utilities)
21+
22+
## Backend usage
23+
24+
### Wrap handlers
25+
26+
```ts
27+
import { NextResponse } from "next/server";
28+
import { withApiHandler, payload, getRequest } from "@ideative/next-handler";
29+
import { z } from "zod";
30+
31+
const schema = z.object({ name: z.string(), email: z.string().email() });
32+
33+
export const POST = withApiHandler(async () => {
34+
const body = await payload(schema);
35+
const req = getRequest();
36+
return NextResponse.json({ from: req.url, ...body });
37+
});
38+
```
39+
40+
### Throw typed API errors
41+
42+
```ts
43+
import {
44+
withApiHandler,
45+
BadRequestError,
46+
NotFoundError,
47+
UnauthorizedError,
48+
} from "@ideative/next-handler";
49+
50+
export const GET = withApiHandler(async (req) => {
51+
if (!req.headers.get("authorization")) throw new UnauthorizedError("Missing token");
52+
throw new NotFoundError("User");
53+
});
54+
```
55+
56+
Built-ins:
57+
58+
- `BadRequestError` (400)
59+
- `UnauthorizedError` (401)
60+
- `ForbiddenError` (403)
61+
- `NotFoundError` (404)
62+
- `ConflictError` (409)
63+
- `InternalServerError` (500)
64+
65+
## Wire format contract
66+
67+
Known API errors are returned as a serialized object:
68+
69+
```ts
70+
type SerializedApiError = {
71+
name: string;
72+
uid: string;
73+
message: string;
74+
status?: number;
75+
details?: unknown;
76+
isSerializableError: true;
77+
[key: string]: unknown;
78+
};
79+
```
80+
81+
Unhandled non-library errors return:
82+
83+
```json
84+
{ "error": "An error occurred" }
85+
```
86+
87+
## Frontend integration with ky (`afterResponse`)
88+
89+
```ts
90+
import ky from "ky";
91+
import {
92+
scanResponseAndThrowErrors,
93+
BadRequestError,
94+
NotFoundError,
95+
} from "@ideative/next-handler";
96+
97+
const api = ky.create({
98+
prefixUrl: "/api",
99+
hooks: {
100+
afterResponse: [
101+
async (_req, _opts, response) => {
102+
await scanResponseAndThrowErrors(response);
103+
if (!response.ok) throw new Error(response.statusText);
104+
return response;
105+
},
106+
],
107+
},
108+
});
109+
110+
try {
111+
await api.get("users/123").json();
112+
} catch (e) {
113+
if (e instanceof NotFoundError) console.log(e.resource);
114+
if (e instanceof BadRequestError) console.log(e.details);
115+
}
116+
```
117+
118+
If you already have a `Response`, you can also call:
119+
120+
```ts
121+
import { scanResponseAndThrowErrors } from "@ideative/next-handler";
122+
123+
await scanResponseAndThrowErrors(response);
124+
```
125+
126+
## Frontend integration with axios (response interceptor)
127+
128+
```ts
129+
import axios from "axios";
130+
import { deserializeApiError } from "@ideative/next-handler";
131+
132+
const api = axios.create({ baseURL: "/api" });
133+
134+
api.interceptors.response.use(
135+
(res) => res,
136+
(err) => {
137+
if (!axios.isAxiosError(err) || !err.response) return Promise.reject(err);
138+
const data = err.response.data;
139+
const candidate =
140+
typeof data === "object" && data !== null && "error" in data
141+
? (data as { error: unknown }).error
142+
: data;
143+
const apiError = deserializeApiError(candidate);
144+
return Promise.reject(apiError ?? err);
145+
}
146+
);
147+
```
148+
149+
## Custom error types
150+
151+
`EndpointError` is abstract and expects `(name, status, message, details?)`.
152+
153+
```ts
154+
import {
155+
apiErrorFactory,
156+
EndpointError,
157+
type ErrorDeserializer,
158+
} from "@ideative/next-handler";
159+
160+
class PaymentRequiredError extends EndpointError {
161+
static ErrorName() {
162+
return "PaymentRequiredError";
163+
}
164+
constructor(message = "Payment required") {
165+
super(PaymentRequiredError.ErrorName(), 402, message);
166+
}
167+
}
168+
169+
const deserialize: ErrorDeserializer<PaymentRequiredError> = (d) =>
170+
new PaymentRequiredError(d.message);
171+
172+
apiErrorFactory.register(PaymentRequiredError, deserialize);
173+
```
174+
175+
Register custom errors in both server and client runtime initialization so deserialization works everywhere.
176+
177+
## Intl exports
178+
179+
Import intl helpers from:
180+
181+
- `@ideative/next-handler/intl`
182+
- `@ideative/next-handler/intl/intl-context`
183+
184+
## API summary
185+
186+
| Export | Description |
187+
| --- | --- |
188+
| `withApiHandler(handler)` | Wraps route handlers and converts thrown `EndpointError` values to JSON responses. |
189+
| `payload(schema)` | Reads and validates request JSON with Zod, throws `BadRequestError` on invalid input. |
190+
| `getRequest()` | Gets current `NextRequest` from AsyncLocalStorage context. |
191+
| `serializeApiError(error)` | Converts `SerializableError` to transport-safe payload. |
192+
| `deserializeApiError(data)` | Converts payload back to typed error, or `null` if payload is not recognized. |
193+
| `isSerializedApiError(data)` | Runtime type-guard for serialized payload shape. |
194+
| `scanResponseAndThrowErrors(response)` | Scans non-OK responses and rethrows serialized API errors if present. |
195+
| `apiErrorFactory.register(ctor, deserialize)` | Register custom error classes for round-trip behavior. |
196+
197+
## v0.1.0 release checklist
198+
199+
- `pnpm run build` emits all exported entry points.
200+
- `pnpm test` passes.
201+
- README examples match current runtime contracts.
202+
- `package.json` exports resolve to emitted `dist/` files.

0 commit comments

Comments
 (0)