@affordances/next 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,33 @@
1
- # Temporary Holding Version
1
+ # @affordances/next
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Affordances for Next.js: `withAffordances` for `next.config.ts`, codegen that finds each route's `actions.ts` and `state.ts`, the route handler, and the `affordances` CLI.
4
+
5
+ ```bash
6
+ vlt install @affordances/next @affordances/react zod
7
+ vlx @affordances/cli init
8
+ ```
9
+
10
+ `init` writes `app/api/affordances/graph/[[...path]]/route.ts`, wraps your config, and generates `.affordances/manifest.ts`. Then describe a page next to it:
11
+
12
+ ```ts
13
+ // app/guestbook/actions.ts
14
+ 'use server'
15
+
16
+ import { z } from 'zod'
17
+ import { defineAction } from '@affordances/next'
18
+ import guestbook from './state'
19
+
20
+ export const sign = defineAction({
21
+ name: 'sign',
22
+ description: 'Add a message to the guestbook',
23
+ input: z.object({ name: z.string().min(1), message: z.string().max(280) }),
24
+ state: guestbook,
25
+ expose: 'public',
26
+ effects: ['entries'],
27
+ run: (input) => entries.add(input),
28
+ })
29
+ ```
30
+
31
+ `curl -H 'Accept: application/vnd.affordances+json' localhost:3000/guestbook` returns the page's graph, and a POST with `Content-Type: application/vnd.affordances+json` calls `sign`.
32
+
33
+ Docs: [https://github.com/vltpkg/affordances/blob/main/apps/docs/content/quickstart.mdx](https://github.com/vltpkg/affordances/blob/main/apps/docs/content/quickstart.mdx) and [https://github.com/vltpkg/affordances/blob/main/apps/docs/content/guides/nextjs/index.mdx](https://github.com/vltpkg/affordances/blob/main/apps/docs/content/guides/nextjs/index.mdx)
@@ -0,0 +1,22 @@
1
+ export type RouteEntry = {
2
+ route: string;
3
+ state?: string;
4
+ actions?: {
5
+ file: string;
6
+ exports: {
7
+ local: string;
8
+ name: string;
9
+ }[];
10
+ };
11
+ };
12
+ export declare class CodegenError extends Error {
13
+ name: string;
14
+ }
15
+ export declare const findAppDir: (root: string) => string;
16
+ export declare const scan: (root: string) => RouteEntry[];
17
+ export declare const render: (entries: RouteEntry[], options?: {
18
+ name?: string;
19
+ }) => string;
20
+ export declare const generate: (root: string, options?: {
21
+ name?: string;
22
+ }) => RouteEntry[];
@@ -0,0 +1,115 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { parseSync } from "oxc-parser";
4
+ // src/codegen.ts
5
+ var CodegenError = class extends Error {
6
+ name = "AffordancesCodegenError";
7
+ };
8
+ const sources = [
9
+ ".ts",
10
+ ".tsx",
11
+ ".js",
12
+ ".jsx"
13
+ ];
14
+ const findAppDir = (root) => {
15
+ for (const dir of ["app", "src/app"]) if (existsSync(path.join(root, dir))) return path.join(root, dir);
16
+ throw new CodegenError(`No app directory in ${root}`);
17
+ };
18
+ const toRoute = (segments) => `/${segments.filter((s) => !/^\(.*\)$/.test(s) && !s.startsWith("@")).join("/")}`;
19
+ const findFile = (dir, base) => sources.map((ext) => path.join(dir, base + ext)).find((file) => existsSync(file));
20
+ const parseActions = (file, source) => {
21
+ const { program, errors } = parseSync(file, source);
22
+ if (errors.length) throw new CodegenError(`${file}: ${errors[0].message}`);
23
+ const body = program.body;
24
+ if (!body.some((node) => node.type === "ExpressionStatement" && node.directive === "use server")) throw new CodegenError(`${file}: actions files must start with 'use server'`);
25
+ const found = [];
26
+ for (const node of body) {
27
+ if (node.type !== "ExportNamedDeclaration") continue;
28
+ const declaration = node.declaration;
29
+ if (declaration?.type !== "VariableDeclaration") continue;
30
+ for (const declarator of declaration.declarations) {
31
+ const init = declarator.init;
32
+ if (init?.type !== "CallExpression") continue;
33
+ const callee = init.callee;
34
+ if ((callee.type === "Identifier" ? callee.name : callee.property?.name) !== "defineAction") continue;
35
+ const local = declarator.id.name;
36
+ const config = init.arguments[0];
37
+ const value = (config?.type === "ObjectExpression" ? config.properties.find((p) => p.type === "Property" && p.key.name === "name") : void 0)?.value;
38
+ if (value?.type !== "Literal" || typeof value.value !== "string") throw new CodegenError(`${file}: ${local} needs a string literal \`name\``);
39
+ found.push({
40
+ local,
41
+ name: value.value
42
+ });
43
+ }
44
+ }
45
+ return found;
46
+ };
47
+ const scan = (root) => {
48
+ const appDir = findAppDir(root);
49
+ const entries = [];
50
+ const relative = (file) => path.relative(root, file).replace(/\.[jt]sx?$/, "");
51
+ const walk = (dir, segments) => {
52
+ const stateFile = findFile(dir, "state");
53
+ const actionsFile = findFile(dir, "actions");
54
+ if (stateFile || actionsFile) {
55
+ const entry = { route: toRoute(segments) };
56
+ if (stateFile) {
57
+ const { program } = parseSync(stateFile, readFileSync(stateFile, "utf8"));
58
+ if (!program.body.some((node) => node.type === "ExportDefaultDeclaration")) throw new CodegenError(`${stateFile}: state files must \`export default defineState(…)\``);
59
+ entry.state = relative(stateFile);
60
+ }
61
+ if (actionsFile) {
62
+ const exports = parseActions(actionsFile, readFileSync(actionsFile, "utf8"));
63
+ const seen = /* @__PURE__ */ new Set();
64
+ for (const { name } of exports) {
65
+ if (seen.has(name)) throw new CodegenError(`${actionsFile}: two actions are named ${name} on ${entry.route}`);
66
+ seen.add(name);
67
+ }
68
+ entry.actions = {
69
+ file: relative(actionsFile),
70
+ exports
71
+ };
72
+ }
73
+ entries.push(entry);
74
+ }
75
+ for (const child of readdirSync(dir, { withFileTypes: true })) {
76
+ if (!child.isDirectory() || child.name.startsWith("_") || child.name === "node_modules") continue;
77
+ walk(path.join(dir, child.name), [...segments, child.name]);
78
+ }
79
+ };
80
+ walk(appDir, []);
81
+ return entries.toSorted((a, b) => a.route.localeCompare(b.route));
82
+ };
83
+ const render = (entries, options = {}) => {
84
+ const imports = [];
85
+ const routes = entries.map((entry, i) => {
86
+ const parts = [];
87
+ if (entry.state) {
88
+ imports.push(`import state${i} from '../${entry.state}'`);
89
+ parts.push(`state: state${i}`);
90
+ }
91
+ const names = entry.actions?.exports.map((e) => e.local) ?? [];
92
+ if (names.length) imports.push(`import { ${names.join(", ")} } from '../${entry.actions.file}'`);
93
+ parts.push(`actions: [${names.join(", ")}]`);
94
+ return ` ${JSON.stringify(entry.route)}: { ${parts.join(", ")} },`;
95
+ });
96
+ return `// generated by @affordances/next from app/**/{actions,state}.ts; don't edit
97
+ import type { Manifest } from '@affordances/next'
98
+ ${imports.join("\n")}
99
+
100
+ export const manifest = {${options.name ? `\n name: ${JSON.stringify(options.name)},` : ""}
101
+ routes: {
102
+ ${routes.join("\n")}
103
+ },
104
+ } satisfies Manifest
105
+ `;
106
+ };
107
+ const generate = (root, options = {}) => {
108
+ const entries = scan(root);
109
+ const out = path.join(root, ".affordances", "manifest.ts");
110
+ const code = render(entries, options);
111
+ mkdirSync(path.dirname(out), { recursive: true });
112
+ if (!existsSync(out) || readFileSync(out, "utf8") !== code) writeFileSync(out, code);
113
+ return entries;
114
+ };
115
+ export { CodegenError, findAppDir, generate, render, scan };
@@ -0,0 +1,28 @@
1
+ import { NextConfig } from "next";
2
+ export declare const API_PATH = "/api/affordances/graph";
3
+ export type Rewrite = {
4
+ source: string;
5
+ destination: string;
6
+ has?: {
7
+ type: 'header' | 'query';
8
+ key: string;
9
+ value?: string;
10
+ }[];
11
+ };
12
+ export type Rewrites = {
13
+ beforeFiles: Rewrite[];
14
+ afterFiles: Rewrite[];
15
+ fallback: Rewrite[];
16
+ };
17
+ export declare const rewrites: Rewrite[];
18
+ export declare const toSource: (route: string) => string;
19
+ export type AffordancesPlugin = <C extends Config>(config: C) => C;
20
+ export type AffordancesConfig = {
21
+ name?: string;
22
+ root?: string;
23
+ plugins?: AffordancesPlugin[];
24
+ };
25
+ export type Config = Pick<NextConfig, 'rewrites' | 'headers' | 'turbopack'> & {
26
+ webpack?: ((config: any, context: any) => any) | null;
27
+ };
28
+ export declare const withAffordances: <C extends Config>(nextConfig?: C, options?: AffordancesConfig) => C;
package/dist/config.js ADDED
@@ -0,0 +1,95 @@
1
+ import { findAppDir, generate } from "./codegen.js";
2
+ import { t as API_PATH } from "./constants-BXXx4qVl.js";
3
+ import { watch } from "node:fs";
4
+ import path from "node:path";
5
+ import { MEDIA_TYPE, WELL_KNOWN_PATH } from "@affordances/protocol";
6
+ // src/config.ts
7
+ const escaped = MEDIA_TYPE.replace(/[.+]/g, "\\$&");
8
+ const page = "/:path((?!api/|_next/).+)";
9
+ const conditions = [
10
+ [{
11
+ type: "header",
12
+ key: "accept",
13
+ value: `.*${escaped}.*`
14
+ }],
15
+ [{
16
+ type: "header",
17
+ key: "content-type",
18
+ value: `.*${escaped}.*`
19
+ }],
20
+ [{
21
+ type: "query",
22
+ key: "affordances"
23
+ }]
24
+ ];
25
+ const rewrites = [{
26
+ source: WELL_KNOWN_PATH,
27
+ destination: `${API_PATH}${WELL_KNOWN_PATH}`
28
+ }, ...conditions.flatMap((has) => [{
29
+ source: "/",
30
+ has,
31
+ destination: API_PATH
32
+ }, {
33
+ source: page,
34
+ has,
35
+ destination: `${API_PATH}/:path`
36
+ }])];
37
+ const toSource = (route) => route.replace(/\[\[\.\.\.(\w+)\]\]/g, ":$1*").replace(/\[\.\.\.(\w+)\]/g, ":$1+").replace(/\[(\w+)\]/g, ":$1");
38
+ const normalize = (value) => Array.isArray(value) ? {
39
+ beforeFiles: [],
40
+ afterFiles: value,
41
+ fallback: []
42
+ } : {
43
+ beforeFiles: value?.beforeFiles ?? [],
44
+ afterFiles: value?.afterFiles ?? [],
45
+ fallback: value?.fallback ?? []
46
+ };
47
+ const withAffordances = (nextConfig = {}, options = {}) => {
48
+ const root = options.root ?? process.cwd();
49
+ const entries = generate(root, options);
50
+ if (process.env.NODE_ENV === "development") {
51
+ let timer;
52
+ watch(findAppDir(root), { recursive: true }, (_, file) => {
53
+ if (!file || !/(^|\/)(actions|state)\.[jt]sx?$/.test(file)) return;
54
+ clearTimeout(timer);
55
+ timer = setTimeout(() => {
56
+ try {
57
+ generate(root, options);
58
+ } catch (error) {
59
+ console.error(`[affordances] ${error.message}`);
60
+ }
61
+ }, 50);
62
+ }).unref();
63
+ }
64
+ const manifest = path.join(root, ".affordances", "manifest.ts");
65
+ const afforded = {
66
+ ...nextConfig,
67
+ turbopack: {
68
+ ...nextConfig.turbopack,
69
+ resolveAlias: {
70
+ ...nextConfig.turbopack?.resolveAlias,
71
+ "@affordances/next/manifest": `./${path.relative(process.cwd(), manifest)}`
72
+ }
73
+ },
74
+ webpack: (config, context) => {
75
+ config.resolve.alias["@affordances/next/manifest"] = manifest;
76
+ return nextConfig.webpack ? nextConfig.webpack(config, context) : config;
77
+ },
78
+ rewrites: async () => {
79
+ const user = normalize(await nextConfig.rewrites?.());
80
+ return {
81
+ ...user,
82
+ beforeFiles: [...rewrites, ...user.beforeFiles]
83
+ };
84
+ },
85
+ headers: async () => [...entries.map(({ route }) => ({
86
+ source: toSource(route),
87
+ headers: [{
88
+ key: "Link",
89
+ value: "<?affordances=1>; rel=\"affordances\""
90
+ }]
91
+ })), ...await nextConfig.headers?.() ?? []]
92
+ };
93
+ return (options.plugins ?? []).reduce((config, plugin) => plugin(config), afforded);
94
+ };
95
+ export { API_PATH, rewrites, toSource, withAffordances };
@@ -0,0 +1,3 @@
1
+ // src/constants.ts
2
+ const API_PATH = "/api/affordances/graph";
3
+ export { API_PATH as t };
@@ -0,0 +1,13 @@
1
+ import { AffordancesError, AffordancesOptions, Ctx, Manifest, redirect } from "@affordances/core";
2
+ export declare const createAffordances: <Session = never>(options?: Pick<AffordancesOptions<Session>, 'auth'>) => {
3
+ defineAction: <Schema extends import("@standard-schema/spec").StandardSchemaV1, Output, State extends import("@affordances/core").Entities = import("@affordances/core").Entities>(config: import("@affordances/core").ActionConfig<Schema, Output, Session, State>) => import("@affordances/core").Action<import("@standard-schema/spec").StandardSchemaV1.InferInput<Schema>, Output>;
4
+ defineState: <State extends import("@affordances/core").Entities>(load: import("@affordances/core").StateLoader<State, Session>, extra?: {
5
+ nav?: ((state: State) => import("@affordances/protocol").NavItem[]) | undefined;
6
+ description?: string;
7
+ } | undefined) => import("@affordances/core").StateDef<State>;
8
+ };
9
+ export declare const defineAction: <Schema extends import("@standard-schema/spec").StandardSchemaV1, Output, State extends import("@affordances/core").Entities = import("@affordances/core").Entities>(config: import("@affordances/core").ActionConfig<Schema, Output, never, State>) => import("@affordances/core").Action<import("@standard-schema/spec").StandardSchemaV1.InferInput<Schema>, Output>, defineState: <State extends import("@affordances/core").Entities>(load: import("@affordances/core").StateLoader<State, never>, extra?: {
10
+ nav?: ((state: State) => import("@affordances/protocol").NavItem[]) | undefined;
11
+ description?: string;
12
+ } | undefined) => import("@affordances/core").StateDef<State>;
13
+ export { AffordancesError, type Ctx, type Manifest, redirect };
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ import { headers } from "next/headers";
2
+ import { redirect as redirect$1 } from "next/navigation";
3
+ import { AffordancesError, createAffordances as createAffordances$1, redirect } from "@affordances/core";
4
+ // src/index.ts
5
+ const request = async () => {
6
+ const list = await headers();
7
+ const host = list.get("x-forwarded-host") ?? list.get("host") ?? "localhost";
8
+ const proto = list.get("x-forwarded-proto") ?? "http";
9
+ return new Request(`${proto}://${host}/`, { headers: list });
10
+ };
11
+ const createAffordances = (options = {}) => createAffordances$1({
12
+ ...options,
13
+ request,
14
+ redirect: redirect$1
15
+ });
16
+ const { defineAction, defineState } = createAffordances();
17
+ export { AffordancesError, createAffordances, defineAction, defineState, redirect };
@@ -0,0 +1,2 @@
1
+ import { Manifest } from "@affordances/core";
2
+ export declare const manifest: Manifest | undefined;
@@ -0,0 +1,3 @@
1
+ // src/manifest.ts
2
+ const manifest = void 0;
3
+ export { manifest };
@@ -0,0 +1,7 @@
1
+ import { HandlerOptions, Manifest } from "@affordances/core";
2
+ export type RouteHandlerOptions = Omit<HandlerOptions, 'manifest' | 'basePath'> & {
3
+ manifest?: Manifest;
4
+ };
5
+ export declare const createRouteHandler: (options?: RouteHandlerOptions) => (request: Request) => Promise<Response>;
6
+ export declare const GET: (request: Request) => Promise<Response>;
7
+ export declare const POST: (request: Request) => Promise<Response>;
package/dist/route.js ADDED
@@ -0,0 +1,28 @@
1
+ import { t as API_PATH } from "./constants-BXXx4qVl.js";
2
+ import { createHandler } from "@affordances/core";
3
+ // src/route.ts
4
+ const redirectFrom = (error) => {
5
+ const digest = error?.digest;
6
+ if (typeof digest === "string" && digest.startsWith("NEXT_REDIRECT;")) return digest.split(";")[2];
7
+ };
8
+ const appManifest = async () => {
9
+ const { manifest } = await import("@affordances/next/manifest");
10
+ if (!manifest) throw new Error("No Affordances manifest: wrap next.config with withAffordances from @affordances/next/config");
11
+ return manifest;
12
+ };
13
+ const createRouteHandler = (options = {}) => {
14
+ const { manifest, ...rest } = options;
15
+ let handler;
16
+ return async (request) => {
17
+ handler ??= (manifest ? Promise.resolve(manifest) : appManifest()).then((m) => createHandler({
18
+ redirectFrom,
19
+ ...rest,
20
+ manifest: m,
21
+ basePath: API_PATH
22
+ }));
23
+ return (await handler)(request);
24
+ };
25
+ };
26
+ const GET = createRouteHandler();
27
+ const POST = GET;
28
+ export { GET, POST, createRouteHandler };
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@affordances/next",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.0",
4
+ "description": "Affordances for Next.js: withAffordances, codegen, and the route handler",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/vltpkg/affordances.git",
9
+ "directory": "packages/next"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "./config": {
22
+ "types": "./dist/config.d.ts",
23
+ "default": "./dist/config.js"
24
+ },
25
+ "./route": {
26
+ "types": "./dist/route.d.ts",
27
+ "default": "./dist/route.js"
28
+ },
29
+ "./manifest": {
30
+ "types": "./dist/manifest.d.ts",
31
+ "default": "./dist/manifest.js"
32
+ },
33
+ "./codegen": {
34
+ "types": "./dist/codegen.d.ts",
35
+ "default": "./dist/codegen.js"
36
+ }
37
+ },
38
+ "scripts": {
39
+ "build": "tsdown",
40
+ "typecheck": "tsc --noEmit",
41
+ "test": "vitest run --passWithNoTests"
42
+ },
43
+ "dependencies": {
44
+ "@affordances/core": "0.0.0",
45
+ "@affordances/protocol": "0.0.0",
46
+ "oxc-parser": "^0.156.0"
47
+ },
48
+ "devDependencies": {
49
+ "next": "^16.4.3",
50
+ "react": "^19.3.1",
51
+ "react-dom": "^19.3.1",
52
+ "zod": "^4.6.7"
53
+ },
54
+ "peerDependencies": {
55
+ "next": ">=16",
56
+ "react": ">=19"
57
+ }
58
+ }