create-nifra 3.5.0 → 4.0.2
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 +3 -3
- package/dist/agent-files.d.ts +41 -26
- package/dist/agent-files.d.ts.map +1 -1
- package/dist/agent-files.js +80 -47
- package/dist/agent-files.js.map +1 -1
- package/dist/agents.d.ts +3 -3
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +65 -69
- package/dist/agents.js.map +1 -1
- package/dist/auth.d.ts +5 -4
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +27 -14
- package/dist/auth.js.map +1 -1
- package/dist/cli.d.ts +23 -25
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +141 -130
- package/dist/cli.js.map +1 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +49 -48
- package/dist/db.js.map +1 -1
- package/dist/scaffold/frameworks.d.ts +13 -38
- package/dist/scaffold/frameworks.d.ts.map +1 -1
- package/dist/scaffold/frameworks.js +9 -32
- package/dist/scaffold/frameworks.js.map +1 -1
- package/dist/scaffold/route-types.d.ts +9 -0
- package/dist/scaffold/route-types.d.ts.map +1 -0
- package/dist/scaffold/route-types.js +61 -0
- package/dist/scaffold/route-types.js.map +1 -0
- package/dist/scaffold/site-files.d.ts +14 -8
- package/dist/scaffold/site-files.d.ts.map +1 -1
- package/dist/scaffold/site-files.js +29 -60
- package/dist/scaffold/site-files.js.map +1 -1
- package/dist/scaffold/site.d.ts +8 -5
- package/dist/scaffold/site.d.ts.map +1 -1
- package/dist/scaffold/site.js +47 -34
- package/dist/scaffold/site.js.map +1 -1
- package/dist/scaffold/targets.d.ts +39 -0
- package/dist/scaffold/targets.d.ts.map +1 -0
- package/dist/scaffold/targets.js +116 -0
- package/dist/scaffold/targets.js.map +1 -0
- package/package.json +4 -1
- package/src/agent-files.ts +89 -47
- package/template/README.md +12 -6
- package/template/{src → backend}/app.test.ts +2 -2
- package/template/{src → backend}/app.ts +8 -3
- package/template/{src → backend}/index.ts +2 -2
- package/template/{src → backend}/routes.ts +1 -1
- package/template/nifra.assurance.ts +16 -14
- package/template/package.json +7 -7
- package/template/tsconfig.json +1 -1
- package/template-batteries/README.md +10 -6
- package/template-batteries/{src → backend}/app.test.ts +23 -23
- package/template-batteries/{src → backend}/app.ts +8 -3
- package/template-batteries/{src → backend}/index.ts +2 -2
- package/template-batteries/{src → backend}/notes.ts +1 -1
- package/template-batteries/nifra.assurance.ts +16 -14
- package/template-batteries/package.json +10 -9
- package/template-batteries/tsconfig.json +1 -1
- package/template-isr/README.md +3 -0
- package/template-isr/{backend.ts → backend/app.ts} +2 -0
- package/template-isr/{server.ts → backend/dev-server.ts} +4 -4
- package/template-isr/{worker.ts → backend/worker.ts} +4 -2
- package/template-isr/build.ts +3 -1
- package/template-isr/gitignore +7 -1
- package/template-isr/nifra.assurance.ts +15 -13
- package/template-isr/package.json +10 -9
- package/template-isr/routes/index.backend.ts +14 -0
- package/template-isr/routes/index.tsx +5 -17
- package/template-isr/tsconfig.json +5 -4
- package/template-isr/wrangler.toml +6 -1
- package/template-site/README.md +24 -23
- package/template-site/{backend.ts → backend/app.ts} +6 -4
- package/template-site/gitignore +5 -10
- package/template-site/nifra.assurance.ts +15 -13
- package/template-site/nifra.config.ts +4 -4
- package/template-site/routes/index.backend.ts +18 -0
- package/template-site/routes/index.tsx +2 -15
- package/template-site-preact/README.md +21 -20
- package/template-site-preact/nifra.config.ts +4 -4
- package/template-site-preact/routes/_layout.tsx +3 -1
- package/template-site-preact/routes/index.tsx +2 -15
- package/template-site-solid/README.md +21 -19
- package/template-site-solid/nifra.config.ts +5 -4
- package/template-site-solid/routes/index.tsx +2 -15
- package/template-site-svelte/README.md +21 -19
- package/template-site-svelte/nifra.config.ts +3 -2
- package/template-site-svelte/routes/index.svelte +3 -16
- package/template-site-vue/README.md +21 -20
- package/template-site-vue/nifra.config.ts +4 -3
- package/template-site-vue/routes/_layout.vue +21 -25
- package/template-site-vue/routes/index.vue +12 -18
- package/dist/scaffold/site-build.d.ts +0 -60
- package/dist/scaffold/site-build.d.ts.map +0 -1
- package/dist/scaffold/site-build.js +0 -198
- package/dist/scaffold/site-build.js.map +0 -1
- package/template-site/.dockerignore +0 -14
- package/template-site/Dockerfile +0 -25
- package/template-site/_worker.ts +0 -17
- package/template-site/deno.json +0 -7
- package/template-site/server-bun.ts +0 -35
- package/template-site/server-deno.ts +0 -38
- package/template-site/server-node.ts +0 -40
- package/template-site/server-vercel.ts +0 -17
- package/template-site/wrangler.toml +0 -4
- /package/template-isr/{page.ts → backend/page.ts} +0 -0
- /package/template-site/{counter.ts → backend/counter.ts} +0 -0
package/src/agent-files.ts
CHANGED
|
@@ -1,25 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The canonical generators for the agent
|
|
3
|
-
*
|
|
2
|
+
* The canonical generators for the agent files an app ships. `AGENTS.md` is the single source of agent
|
|
3
|
+
* guidance; every other agent's file is a thin pointer to it, so no two copies can drift:
|
|
4
4
|
*
|
|
5
|
-
* .
|
|
6
|
-
* .
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* AGENTS.md - the guidance (read natively by Codex, Cursor, Copilot and others)
|
|
6
|
+
* CLAUDE.md - Claude Code: `@AGENTS.md` import
|
|
7
|
+
* GEMINI.md - Gemini CLI: `@./AGENTS.md` import
|
|
8
|
+
* .cursor/rules/nifra.mdc - Cursor: an always-applied rule pointing at AGENTS.md
|
|
9
|
+
* .github/copilot-instructions.md - GitHub Copilot: points at AGENTS.md
|
|
10
|
+
* .mcp.json, .cursor/mcp.json - the nifra MCP server, registered for Claude Code and Cursor
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Both `create-nifra` (scaffold time) and `@nifrajs/cli`'s `nifra init-agents` (retrofit an existing app)
|
|
13
|
+
* import these, so the files can never drift apart. This module is dependency-free (pure string + object
|
|
14
|
+
* generation) on purpose - `create-nifra` ships with no runtime deps, and `@nifrajs/cli` imports it as a
|
|
15
|
+
* workspace dependency.
|
|
14
16
|
*
|
|
15
17
|
* Why `bunx @nifrajs/cli mcp` (not `bunx nifra mcp`): the `nifra` binary is provided by the
|
|
16
|
-
* `@nifrajs/cli` package
|
|
17
|
-
*
|
|
18
|
-
* `@nifrajs/cli
|
|
19
|
-
*
|
|
20
|
-
* bin-owning package, `bunx @nifrajs/cli mcp`, resolves the locally-installed bin when present and
|
|
21
|
-
* otherwise fetches the package that actually provides the `nifra` command - robust across every
|
|
22
|
-
* template and for any existing app.
|
|
18
|
+
* `@nifrajs/cli` package, and the bare npm package literally named `nifra` (this monorepo's
|
|
19
|
+
* `@nifrajs/web` shim) exposes NO `nifra` bin, so `bunx nifra mcp` would fetch the wrong package in an
|
|
20
|
+
* app that does not carry `@nifrajs/cli`. Naming the bin-owning package resolves the locally-installed
|
|
21
|
+
* bin when present and otherwise fetches the package that actually provides the `nifra` command.
|
|
23
22
|
*/
|
|
24
23
|
|
|
25
24
|
import { readFileSync } from "node:fs"
|
|
@@ -47,7 +46,8 @@ export const MCP_SERVER_COMMAND = "bunx" as const
|
|
|
47
46
|
* keeps respawning the stale binary even after a newer one is published (the MCP server silently runs
|
|
48
47
|
* old code). Pinning the exact version makes the version part of the cache key, so each release fetches
|
|
49
48
|
* fresh and a stale cache can never shadow it. The cost: an already-scaffolded app's `.mcp.json` freezes
|
|
50
|
-
* at its scaffold-time version until `nifra init-agents`
|
|
49
|
+
* at its scaffold-time version until `nifra init-agents --sync-mcp` re-pins it to the nifra the project
|
|
50
|
+
* installs - an acceptable, deterministic trade.
|
|
51
51
|
*/
|
|
52
52
|
export const MCP_CLI_VERSION: string = (
|
|
53
53
|
JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
|
|
@@ -80,35 +80,27 @@ export function mcpJson(): string {
|
|
|
80
80
|
return `${JSON.stringify(MCP_CONFIG, null, 2)}\n`
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
* own line - `@AGENTS.md` - so the conventions live in exactly one place and can't drift between the
|
|
88
|
-
* two files. Keep this short; the depth is in `AGENTS.md`.
|
|
89
|
-
*/
|
|
83
|
+
const POINTER_INTRO =
|
|
84
|
+
"This project's agent guidance - commands, the project structure, the rules, and the nifra MCP server - lives in AGENTS.md, the single source every agent reads."
|
|
85
|
+
|
|
86
|
+
/** `CLAUDE.md`: Claude Code expands the `@AGENTS.md` import in place. */
|
|
90
87
|
export function claudeMd(): string {
|
|
91
|
-
return `# CLAUDE.md
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
The full nifra cookbook for this app - backend rules, the typed never-throwing client, file routing, and
|
|
108
|
-
the gotchas - lives in \`AGENTS.md\`, imported here so it stays the single source of truth:
|
|
109
|
-
|
|
110
|
-
@AGENTS.md
|
|
111
|
-
`
|
|
88
|
+
return `# CLAUDE.md\n\n${POINTER_INTRO}\n\n@AGENTS.md\n`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `GEMINI.md`: Gemini CLI expands `@./AGENTS.md` imports in its context files. */
|
|
92
|
+
export function geminiMd(): string {
|
|
93
|
+
return `# GEMINI.md\n\n${POINTER_INTRO}\n\n@./AGENTS.md\n`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `.cursor/rules/nifra.mdc`: an always-applied Cursor rule; `@AGENTS.md` attaches the file. */
|
|
97
|
+
export function cursorRule(): string {
|
|
98
|
+
return `---\ndescription: nifra project guidance\nalwaysApply: true\n---\n\n${POINTER_INTRO} Read it before changing code:\n\n@AGENTS.md\n`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `.github/copilot-instructions.md`: Copilot has no import syntax, so this names the file. */
|
|
102
|
+
export function copilotInstructions(): string {
|
|
103
|
+
return `${POINTER_INTRO} Read AGENTS.md at the repository root before changing code, and follow it.\n`
|
|
112
104
|
}
|
|
113
105
|
|
|
114
106
|
/**
|
|
@@ -132,6 +124,44 @@ project + the installed version, so they beat training-data recall:
|
|
|
132
124
|
failing check means it isn't done. (\`nifra check --json\` in a terminal does the same.)`
|
|
133
125
|
}
|
|
134
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The "## Project structure" section of a web app's `AGENTS.md`: the zones the build enforces. Shared so
|
|
129
|
+
* a scaffolded app and one `nifra init-agents` retrofits teach the same rules.
|
|
130
|
+
*/
|
|
131
|
+
export function agentsStructureSection(): string {
|
|
132
|
+
return `${STRUCTURE_HEADING}
|
|
133
|
+
|
|
134
|
+
Every file a build loads belongs to one side, and the build refuses an import that crosses the wrong
|
|
135
|
+
way - it fails naming the import chain rather than shipping server code to the browser.
|
|
136
|
+
|
|
137
|
+
| Where | Holds | Reaches the browser |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| \`routes/x.tsx\` (\`.svelte\`, \`.vue\`, \`.mdx\`) | a page: the component, \`meta\` | yes |
|
|
140
|
+
| \`routes/x.backend.ts\` | that page's \`loader\`, \`action\`, \`loaderOutput\`, \`actionOutput\`, \`middleware\` | never |
|
|
141
|
+
| \`frontend/\` | components, hooks, browser-only code | yes |
|
|
142
|
+
| \`backend/\` | \`app.ts\` (the API), \`framework.ts\`, the database, auth, secrets | never |
|
|
143
|
+
| \`shared/\` | schemas, types and pure helpers both sides import | yes |
|
|
144
|
+
| \`public/\` | static files served as they are | yes |
|
|
145
|
+
|
|
146
|
+
A file outside those folders joins a side with a \`.frontend.ts\`, \`.backend.ts\` or \`.shared.ts\` suffix.
|
|
147
|
+
|
|
148
|
+
- **A route is two files.** \`routes/blog/[slug].tsx\` renders; \`routes/blog/[slug].backend.ts\` loads.
|
|
149
|
+
A server-only export (\`loader\`, \`action\`, ...) in the \`.tsx\` file is a build error.
|
|
150
|
+
- **Frontend code never imports backend code** - not \`backend/\`, not a \`.backend.ts\` file, not a
|
|
151
|
+
database driver or \`node:\` built-in. Data reaches a page only as its loader's return value.
|
|
152
|
+
- **Every loader and action declares what it sends**: \`export const loaderOutput = t.object({ ... })\`
|
|
153
|
+
(and \`actionOutput\`). Only what the schema declares reaches the browser, and a loader without one
|
|
154
|
+
is refused.
|
|
155
|
+
- **Type a route with its generated \`./+types/<name>\`**: \`import type { Route } from "./+types/[slug]"\`
|
|
156
|
+
gives \`Route.LoaderArgs\` (with the typed \`api\`) in the backend half and \`Route.ComponentProps\` in
|
|
157
|
+
the page. \`nifra dev\`, \`nifra build\`, \`nifra check\` and \`nifra types\` write them.
|
|
158
|
+
- **Secrets stay in \`backend/\`.** A value only the server may see is read there (\`process.env\`), never
|
|
159
|
+
in a frontend file; a build that finds what looks like a credential in browser output fails.`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The heading `nifra init-agents` looks for before appending {@link agentsStructureSection}. */
|
|
163
|
+
export const STRUCTURE_HEADING = "## Project structure"
|
|
164
|
+
|
|
135
165
|
/** Identifies a generated agent-discovery file: where it goes (relative to the project root) and how to
|
|
136
166
|
* produce its content. `merge` is for files that augment an existing one (AGENTS.md) rather than own it. */
|
|
137
167
|
export interface AgentFileSpec {
|
|
@@ -142,8 +172,20 @@ export interface AgentFileSpec {
|
|
|
142
172
|
}
|
|
143
173
|
|
|
144
174
|
/** The standalone files this module fully owns (whole-file generators). AGENTS.md is handled separately
|
|
145
|
-
* because create-nifra builds it from `agents.ts` and the retrofit command appends
|
|
175
|
+
* because create-nifra builds it from `agents.ts` and the retrofit command appends sections to it. */
|
|
146
176
|
export const MCP_JSON_PATH = ".mcp.json"
|
|
147
177
|
export const CURSOR_MCP_JSON_PATH = ".cursor/mcp.json"
|
|
148
178
|
export const CLAUDE_MD_PATH = "CLAUDE.md"
|
|
179
|
+
export const GEMINI_MD_PATH = "GEMINI.md"
|
|
180
|
+
export const CURSOR_RULE_PATH = ".cursor/rules/nifra.mdc"
|
|
181
|
+
export const COPILOT_INSTRUCTIONS_PATH = ".github/copilot-instructions.md"
|
|
149
182
|
export const AGENTS_MD_PATH = "AGENTS.md"
|
|
183
|
+
|
|
184
|
+
/** Every agent's pointer to `AGENTS.md`, in the order they are written and reported. */
|
|
185
|
+
export const AGENT_POINTERS: readonly { readonly path: string; readonly content: () => string }[] =
|
|
186
|
+
[
|
|
187
|
+
{ path: CLAUDE_MD_PATH, content: claudeMd },
|
|
188
|
+
{ path: GEMINI_MD_PATH, content: geminiMd },
|
|
189
|
+
{ path: CURSOR_RULE_PATH, content: cursorRule },
|
|
190
|
+
{ path: COPILOT_INSTRUCTIONS_PATH, content: copilotInstructions },
|
|
191
|
+
]
|
package/template/README.md
CHANGED
|
@@ -15,16 +15,22 @@ rate-limit store intentionally refuses production unless `NIFRA_ALLOW_MEMORY_RAT
|
|
|
15
15
|
shared store for a multi-instance deployment. If you add cookie sessions, install the signed CSRF
|
|
16
16
|
middleware and keep authenticated mutation routes covered by both authenticated and CSRF assurance.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
- `src/app.test.ts` - an example test.
|
|
18
|
+
Server code lives in `backend/`, the same folder a site keeps its backend in, so pages can be added
|
|
19
|
+
later without moving anything:
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
- `backend/app.ts` - exports `backend`, your API: it composes the route modules (exported without
|
|
22
|
+
`listen`, so tests drive it via `backend.fetch`). `nifra contracts` and `nifra sdk` read it from
|
|
23
|
+
here.
|
|
24
|
+
- `backend/routes.ts` - the routes.
|
|
25
|
+
- `backend/index.ts` - boots the server.
|
|
26
|
+
- `backend/app.test.ts` - an example test.
|
|
27
|
+
|
|
28
|
+
Add a typed client from `typeof backend`:
|
|
23
29
|
|
|
24
30
|
```ts
|
|
25
31
|
import { client } from "@nifrajs/client"
|
|
26
|
-
import type {
|
|
32
|
+
import type { Backend } from "./backend/app.ts"
|
|
27
33
|
|
|
28
|
-
const api = client<
|
|
34
|
+
const api = client<Backend>("http://localhost:3000")
|
|
29
35
|
const { data } = await api.users({ id: "42" }).get()
|
|
30
36
|
```
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { testClient } from "@nifrajs/client"
|
|
2
2
|
import { expect, test } from "bun:test"
|
|
3
|
-
import {
|
|
3
|
+
import { backend } from "./app.ts"
|
|
4
4
|
|
|
5
5
|
// The typed in-process client: no server, no port, the full real lifecycle (validation,
|
|
6
6
|
// middleware), and `res.data` typed from the route's schemas. Calls never throw - branch on `ok`.
|
|
7
|
-
const api = testClient<typeof
|
|
7
|
+
const api = testClient<typeof backend>(backend)
|
|
8
8
|
|
|
9
9
|
test("GET /users/:id echoes the id", async () => {
|
|
10
10
|
const res = await api.users({ id: "42" }).get()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { server } from "@nifrajs/core/server"
|
|
2
|
+
import { responseContract } from "@nifrajs/core/response-contract"
|
|
2
3
|
import {
|
|
3
4
|
MemoryStore,
|
|
4
5
|
cors,
|
|
@@ -8,7 +9,10 @@ import {
|
|
|
8
9
|
} from "@nifrajs/middleware"
|
|
9
10
|
import { routes } from "./routes.ts"
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Your API. Every `nifra` command finds it here (`backend/app.ts` exporting `backend`), and it is
|
|
14
|
+
* exported without `listen` so tests can drive it in-process.
|
|
15
|
+
*/
|
|
12
16
|
|
|
13
17
|
// Composition only: this module merges route modules and registers none of its own.
|
|
14
18
|
//
|
|
@@ -24,10 +28,11 @@ const rateStore = new MemoryStore({
|
|
|
24
28
|
allowInProduction: process.env.NIFRA_ALLOW_MEMORY_RATE_LIMIT === "true",
|
|
25
29
|
})
|
|
26
30
|
|
|
27
|
-
export const
|
|
31
|
+
export const backend = server({
|
|
28
32
|
requestTimeoutMs: 30_000,
|
|
29
33
|
admission: createAdmissionController({ maxInFlight: 128, maxQueue: 0 }),
|
|
30
34
|
})
|
|
35
|
+
.use(responseContract("enforce"))
|
|
31
36
|
.use(securityHeaders())
|
|
32
37
|
.use(
|
|
33
38
|
cors({
|
|
@@ -38,4 +43,4 @@ export const app = server({
|
|
|
38
43
|
.use(rateLimit({ store: rateStore, max: 120, windowMs: 60_000 }))
|
|
39
44
|
.merge(routes)
|
|
40
45
|
|
|
41
|
-
export type
|
|
46
|
+
export type Backend = typeof backend
|
|
@@ -3,7 +3,7 @@ import { t } from "@nifrajs/schema"
|
|
|
3
3
|
|
|
4
4
|
// Contract-first: the schema validates the body at the boundary, types `c.body` in the
|
|
5
5
|
// handler, types the response, and flows into the typed client (`testClient` in app.test.ts,
|
|
6
|
-
// `client<
|
|
6
|
+
// `client<Backend>(baseUrl)` in a frontend) - no codegen step.
|
|
7
7
|
const EchoInput = t.object({ message: t.string({ minLength: 1 }) })
|
|
8
8
|
const EchoReply = t.object({ echoed: t.string() })
|
|
9
9
|
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
* Rules are first-match-wins, so order matters: put narrow exemptions above the general rule.
|
|
12
12
|
*/
|
|
13
13
|
import { defineAssuranceConfig, NIFRA_ASSURANCE } from "@nifrajs/core/assurance"
|
|
14
|
-
import {
|
|
14
|
+
import { backend } from "./backend/app.ts"
|
|
15
15
|
|
|
16
16
|
export default defineAssuranceConfig({
|
|
17
|
-
source:
|
|
17
|
+
source: backend,
|
|
18
18
|
// What each effect IS, so the policy below can be written about a CLASS of effect rather than a list
|
|
19
19
|
// of token names: `{ access: "write", zone: "domain" }` covers `payments.charge` the day someone adds
|
|
20
20
|
// it, where a rule naming `db.write` would not.
|
|
@@ -34,19 +34,21 @@ export default defineAssuranceConfig({
|
|
|
34
34
|
// the route modules - is what keeps every route's declaration equal to its reach. The drivers
|
|
35
35
|
// below are the backstop for code that goes around the seam; they grant both, because an import
|
|
36
36
|
// cannot tell a read from a write.
|
|
37
|
+
// Shared starter catalog: an absent database integration is optional, but any matching
|
|
38
|
+
// import still grants its capabilities. Remove optional for seams this app owns.
|
|
37
39
|
imports: [
|
|
38
|
-
{ specifier: "./read.ts", capabilities: ["db.read"] },
|
|
39
|
-
{ specifier: "./write.ts", capabilities: ["db.write"] },
|
|
40
|
-
{ specifier: "bun:sqlite", capabilities: ["db.read", "db.write"] },
|
|
41
|
-
{ specifier: "node:sqlite", capabilities: ["db.read", "db.write"] },
|
|
42
|
-
{ specifier: "pg", capabilities: ["db.read", "db.write"] },
|
|
43
|
-
{ specifier: "postgres", capabilities: ["db.read", "db.write"] },
|
|
44
|
-
{ specifier: "mysql2", capabilities: ["db.read", "db.write"] },
|
|
45
|
-
{ specifier: "kysely", capabilities: ["db.read", "db.write"] },
|
|
46
|
-
{ specifier: "drizzle-orm", capabilities: ["db.read", "db.write"] },
|
|
47
|
-
{ specifier: "drizzle-orm/*", capabilities: ["db.read", "db.write"] },
|
|
48
|
-
{ specifier: "@libsql/*", capabilities: ["db.read", "db.write"] },
|
|
49
|
-
{ specifier: "@prisma/client", capabilities: ["db.read", "db.write"] },
|
|
40
|
+
{ specifier: "./read.ts", capabilities: ["db.read"], optional: true },
|
|
41
|
+
{ specifier: "./write.ts", capabilities: ["db.write"], optional: true },
|
|
42
|
+
{ specifier: "bun:sqlite", capabilities: ["db.read", "db.write"], optional: true },
|
|
43
|
+
{ specifier: "node:sqlite", capabilities: ["db.read", "db.write"], optional: true },
|
|
44
|
+
{ specifier: "pg", capabilities: ["db.read", "db.write"], optional: true },
|
|
45
|
+
{ specifier: "postgres", capabilities: ["db.read", "db.write"], optional: true },
|
|
46
|
+
{ specifier: "mysql2", capabilities: ["db.read", "db.write"], optional: true },
|
|
47
|
+
{ specifier: "kysely", capabilities: ["db.read", "db.write"], optional: true },
|
|
48
|
+
{ specifier: "drizzle-orm", capabilities: ["db.read", "db.write"], optional: true },
|
|
49
|
+
{ specifier: "drizzle-orm/*", capabilities: ["db.read", "db.write"], optional: true },
|
|
50
|
+
{ specifier: "@libsql/*", capabilities: ["db.read", "db.write"], optional: true },
|
|
51
|
+
{ specifier: "@prisma/client", capabilities: ["db.read", "db.write"], optional: true },
|
|
50
52
|
],
|
|
51
53
|
// Add a driver here to force every query through the seam, and the check will name any route that
|
|
52
54
|
// reaches around it: `{ specifier: "pg", reason: "query through db/read.ts or db/write.ts" }`.
|
package/template/package.json
CHANGED
|
@@ -4,20 +4,20 @@
|
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": true,
|
|
6
6
|
"scripts": {
|
|
7
|
-
"dev": "bun --watch run
|
|
8
|
-
"start": "bun run
|
|
7
|
+
"dev": "bun --watch run backend/index.ts",
|
|
8
|
+
"start": "bun run backend/index.ts",
|
|
9
9
|
"check": "nifra check && nifra assure",
|
|
10
10
|
"test": "bun test",
|
|
11
11
|
"typecheck": "tsc --noEmit"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@nifrajs/client": "^
|
|
15
|
-
"@nifrajs/core": "^
|
|
16
|
-
"@nifrajs/middleware": "^
|
|
17
|
-
"@nifrajs/schema": "^
|
|
14
|
+
"@nifrajs/client": "^4.0.2",
|
|
15
|
+
"@nifrajs/core": "^4.0.2",
|
|
16
|
+
"@nifrajs/middleware": "^4.0.2",
|
|
17
|
+
"@nifrajs/schema": "^4.0.2"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
|
-
"@nifrajs/cli": "^
|
|
20
|
+
"@nifrajs/cli": "^4.0.2",
|
|
21
21
|
"@types/bun": "latest",
|
|
22
22
|
"typescript": "^6.0.3"
|
|
23
23
|
}
|
package/template/tsconfig.json
CHANGED
|
@@ -24,19 +24,23 @@ rate-limit store intentionally refuses production unless `NIFRA_ALLOW_MEMORY_RAT
|
|
|
24
24
|
shared store for a multi-instance deployment. If you add cookie sessions, install the signed CSRF
|
|
25
25
|
middleware and keep authenticated mutation routes covered by both authenticated and CSRF assurance.
|
|
26
26
|
|
|
27
|
-
- `
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
- `backend/app.ts` - exports `backend`, your API: it composes the route modules (exported without
|
|
28
|
+
`listen`, so tests drive it via `backend.fetch`). `nifra contracts` and `nifra sdk` read it from
|
|
29
|
+
here.
|
|
30
|
+
- `backend/notes.ts` - the `notes` domain: routes, the job queue, the cache and storage.
|
|
31
|
+
- `backend/index.ts` - boots the server and starts the job worker (`queue.start()`).
|
|
32
|
+
- `backend/app.test.ts` - exercises pagination, the background job (`queue.drain()`), the cache, and
|
|
33
|
+
storage.
|
|
30
34
|
|
|
31
35
|
Swap the in-memory `notes` array for your database, the `MemoryStorage` for `FileStorage`/`R2Storage`, and
|
|
32
36
|
the default in-memory cache/job stores for shared (Redis / CF KV) ones when you go multi-process.
|
|
33
37
|
|
|
34
|
-
Add a typed client from `typeof
|
|
38
|
+
Add a typed client from `typeof backend`:
|
|
35
39
|
|
|
36
40
|
```ts
|
|
37
41
|
import { client } from "@nifrajs/client"
|
|
38
|
-
import type {
|
|
42
|
+
import type { Backend } from "./backend/app.ts"
|
|
39
43
|
|
|
40
|
-
const api = client<
|
|
44
|
+
const api = client<Backend>("http://localhost:3000")
|
|
41
45
|
const { data } = await api.notes.get({ query: { limit: 20 } })
|
|
42
46
|
```
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { expect, test } from "bun:test"
|
|
2
|
-
import {
|
|
2
|
+
import { backend, queue, wasIndexed } from "./app.ts"
|
|
3
|
+
|
|
4
|
+
// `listen()` tells the app who is calling; a bare `backend.fetch` does not, and `rateLimit()` refuses a
|
|
5
|
+
// request it cannot attribute. Each test request therefore arrives from a local peer, as it would
|
|
6
|
+
// through a socket.
|
|
7
|
+
const send = (path: string, init?: RequestInit) =>
|
|
8
|
+
backend.fetch(new Request(`http://localhost${path}`, init), { clientIp: "127.0.0.1" })
|
|
3
9
|
|
|
4
10
|
const post = (body: unknown) =>
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}),
|
|
11
|
-
)
|
|
11
|
+
send("/notes", {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: { "content-type": "application/json" },
|
|
14
|
+
body: JSON.stringify(body),
|
|
15
|
+
})
|
|
12
16
|
|
|
13
17
|
test("create + background index job", async () => {
|
|
14
18
|
const res = await post({ title: "first", body: "hello" })
|
|
@@ -28,40 +32,36 @@ test("cursor pagination walks every page", async () => {
|
|
|
28
32
|
await post({ title: "b", body: "" })
|
|
29
33
|
await post({ title: "c", body: "" })
|
|
30
34
|
|
|
31
|
-
const first = (await (
|
|
32
|
-
await app.fetch(new Request("http://localhost/notes?limit=2"))
|
|
33
|
-
).json()) as Page
|
|
35
|
+
const first = (await (await send("/notes?limit=2")).json()) as Page
|
|
34
36
|
expect(first.items).toHaveLength(2)
|
|
35
37
|
expect(first.nextCursor).not.toBeNull()
|
|
36
38
|
|
|
37
|
-
const
|
|
38
|
-
const second = (await (await
|
|
39
|
+
const cursor = encodeURIComponent(first.nextCursor ?? "")
|
|
40
|
+
const second = (await (await send(`/notes?limit=2&cursor=${cursor}`)).json()) as Page
|
|
39
41
|
expect(second.items.length).toBeGreaterThan(0)
|
|
40
42
|
})
|
|
41
43
|
|
|
42
44
|
test("limit over the cap is a 422", async () => {
|
|
43
|
-
const res = await
|
|
45
|
+
const res = await send("/notes?limit=999")
|
|
44
46
|
expect(res.status).toBe(422)
|
|
45
47
|
})
|
|
46
48
|
|
|
47
49
|
test("get one is served (and cached); missing is 404", async () => {
|
|
48
50
|
const created = (await (await post({ title: "cached", body: "x" })).json()) as { id: number }
|
|
49
|
-
const hit = await
|
|
51
|
+
const hit = await send(`/notes/${created.id}`)
|
|
50
52
|
expect(((await hit.json()) as { id: number }).id).toBe(created.id)
|
|
51
53
|
|
|
52
|
-
const miss = await
|
|
54
|
+
const miss = await send("/notes/999999")
|
|
53
55
|
expect(miss.status).toBe(404)
|
|
54
56
|
})
|
|
55
57
|
|
|
56
58
|
test("attachment round-trips through storage", async () => {
|
|
57
59
|
const created = (await (await post({ title: "with-file", body: "x" })).json()) as { id: number }
|
|
58
|
-
const res = await
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}),
|
|
64
|
-
)
|
|
60
|
+
const res = await send(`/notes/${created.id}/attachment`, {
|
|
61
|
+
method: "PUT",
|
|
62
|
+
headers: { "content-type": "text/plain" },
|
|
63
|
+
body: "hello bytes",
|
|
64
|
+
})
|
|
65
65
|
expect(res.status).toBe(201)
|
|
66
66
|
expect(((await res.json()) as { bytes: number }).bytes).toBe("hello bytes".length)
|
|
67
67
|
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { responseContract } from "@nifrajs/core/response-contract"
|
|
1
2
|
import { server } from "@nifrajs/core/server"
|
|
2
3
|
import {
|
|
3
4
|
cors,
|
|
@@ -8,7 +9,10 @@ import {
|
|
|
8
9
|
} from "@nifrajs/middleware"
|
|
9
10
|
import { noteRoutes } from "./notes.ts"
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Your API. Every `nifra` command finds it here (`backend/app.ts` exporting `backend`), and it is
|
|
14
|
+
* exported without `listen` so tests can drive it in-process.
|
|
15
|
+
*/
|
|
12
16
|
|
|
13
17
|
// Composition only: this module merges route modules and registers none of its own.
|
|
14
18
|
//
|
|
@@ -22,10 +26,11 @@ const rateStore = new MemoryStore({
|
|
|
22
26
|
allowInProduction: process.env.NIFRA_ALLOW_MEMORY_RATE_LIMIT === "true",
|
|
23
27
|
})
|
|
24
28
|
|
|
25
|
-
export const
|
|
29
|
+
export const backend = server({
|
|
26
30
|
requestTimeoutMs: 30_000,
|
|
27
31
|
admission: createAdmissionController({ maxInFlight: 128, maxQueue: 0 }),
|
|
28
32
|
})
|
|
33
|
+
.use(responseContract("enforce"))
|
|
29
34
|
.use(securityHeaders())
|
|
30
35
|
.use(
|
|
31
36
|
cors({
|
|
@@ -36,7 +41,7 @@ export const app = server({
|
|
|
36
41
|
.use(rateLimit({ store: rateStore, max: 120, windowMs: 60_000 }))
|
|
37
42
|
.merge(noteRoutes)
|
|
38
43
|
|
|
39
|
-
export type
|
|
44
|
+
export type Backend = typeof backend
|
|
40
45
|
|
|
41
46
|
export type { Note } from "./notes.ts"
|
|
42
47
|
// Re-exported so `index.ts` can start the worker and `app.test.ts` can inspect it, without either
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { backend, queue } from "./app.ts"
|
|
2
2
|
|
|
3
3
|
// Start the in-process job worker (leases due jobs, runs them with retries/backoff). On Cloudflare
|
|
4
4
|
// Workers, drive a durable store from a CF Queues consumer with `queue.process()` instead - see the
|
|
@@ -6,5 +6,5 @@ import { app, queue } from "./app.ts"
|
|
|
6
6
|
queue.start()
|
|
7
7
|
|
|
8
8
|
const port = Number(process.env.PORT ?? 3000)
|
|
9
|
-
|
|
9
|
+
backend.listen(port)
|
|
10
10
|
console.log(`▲ nifra full-stack app on http://localhost:${port}`)
|
|
@@ -10,7 +10,7 @@ import { MemoryStorage } from "@nifrajs/storage"
|
|
|
10
10
|
//
|
|
11
11
|
// A full-stack starter: cursor pagination (@nifrajs/schema), background jobs (@nifrajs/jobs), a typed
|
|
12
12
|
// cache (@nifrajs/cache), and blob storage (@nifrajs/storage) - over an in-memory "notes" store you'd
|
|
13
|
-
// swap for your database. Everything is exported so `app.test.ts` can drive it via `
|
|
13
|
+
// swap for your database. Everything is exported so `app.test.ts` can drive it via `backend.fetch`.
|
|
14
14
|
|
|
15
15
|
export interface Note {
|
|
16
16
|
id: number
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
* an exemption you get by leaving the rule off is one nobody ever sees.
|
|
10
10
|
*/
|
|
11
11
|
import { defineAssuranceConfig, NIFRA_ASSURANCE } from "@nifrajs/core/assurance"
|
|
12
|
-
import {
|
|
12
|
+
import { backend } from "./backend/app.ts"
|
|
13
13
|
|
|
14
14
|
export default defineAssuranceConfig({
|
|
15
|
-
source:
|
|
15
|
+
source: backend,
|
|
16
16
|
// What each effect IS, so the policy below can be written about a CLASS of effect rather than a list
|
|
17
17
|
// of token names: `{ access: "write", zone: "domain" }` covers `payments.charge` the day someone adds
|
|
18
18
|
// it, where a rule naming `db.write` would not.
|
|
@@ -32,19 +32,21 @@ export default defineAssuranceConfig({
|
|
|
32
32
|
// the route modules - is what keeps every route's declaration equal to its reach. The drivers
|
|
33
33
|
// below are the backstop for code that goes around the seam; they grant both, because an import
|
|
34
34
|
// cannot tell a read from a write.
|
|
35
|
+
// Shared starter catalog: an absent database integration is optional, but any matching
|
|
36
|
+
// import still grants its capabilities. Remove optional for seams this app owns.
|
|
35
37
|
imports: [
|
|
36
|
-
{ specifier: "./read.ts", capabilities: ["db.read"] },
|
|
37
|
-
{ specifier: "./write.ts", capabilities: ["db.write"] },
|
|
38
|
-
{ specifier: "bun:sqlite", capabilities: ["db.read", "db.write"] },
|
|
39
|
-
{ specifier: "node:sqlite", capabilities: ["db.read", "db.write"] },
|
|
40
|
-
{ specifier: "pg", capabilities: ["db.read", "db.write"] },
|
|
41
|
-
{ specifier: "postgres", capabilities: ["db.read", "db.write"] },
|
|
42
|
-
{ specifier: "mysql2", capabilities: ["db.read", "db.write"] },
|
|
43
|
-
{ specifier: "kysely", capabilities: ["db.read", "db.write"] },
|
|
44
|
-
{ specifier: "drizzle-orm", capabilities: ["db.read", "db.write"] },
|
|
45
|
-
{ specifier: "drizzle-orm/*", capabilities: ["db.read", "db.write"] },
|
|
46
|
-
{ specifier: "@libsql/*", capabilities: ["db.read", "db.write"] },
|
|
47
|
-
{ specifier: "@prisma/client", capabilities: ["db.read", "db.write"] },
|
|
38
|
+
{ specifier: "./read.ts", capabilities: ["db.read"], optional: true },
|
|
39
|
+
{ specifier: "./write.ts", capabilities: ["db.write"], optional: true },
|
|
40
|
+
{ specifier: "bun:sqlite", capabilities: ["db.read", "db.write"], optional: true },
|
|
41
|
+
{ specifier: "node:sqlite", capabilities: ["db.read", "db.write"], optional: true },
|
|
42
|
+
{ specifier: "pg", capabilities: ["db.read", "db.write"], optional: true },
|
|
43
|
+
{ specifier: "postgres", capabilities: ["db.read", "db.write"], optional: true },
|
|
44
|
+
{ specifier: "mysql2", capabilities: ["db.read", "db.write"], optional: true },
|
|
45
|
+
{ specifier: "kysely", capabilities: ["db.read", "db.write"], optional: true },
|
|
46
|
+
{ specifier: "drizzle-orm", capabilities: ["db.read", "db.write"], optional: true },
|
|
47
|
+
{ specifier: "drizzle-orm/*", capabilities: ["db.read", "db.write"], optional: true },
|
|
48
|
+
{ specifier: "@libsql/*", capabilities: ["db.read", "db.write"], optional: true },
|
|
49
|
+
{ specifier: "@prisma/client", capabilities: ["db.read", "db.write"], optional: true },
|
|
48
50
|
],
|
|
49
51
|
// Add a driver here to force every query through the seam, and the check will name any route that
|
|
50
52
|
// reaches around it: `{ specifier: "pg", reason: "query through db/read.ts or db/write.ts" }`.
|
|
@@ -4,22 +4,23 @@
|
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": true,
|
|
6
6
|
"scripts": {
|
|
7
|
-
"dev": "bun --watch run
|
|
8
|
-
"start": "bun run
|
|
7
|
+
"dev": "bun --watch run backend/index.ts",
|
|
8
|
+
"start": "bun run backend/index.ts",
|
|
9
9
|
"check": "nifra check && nifra assure",
|
|
10
10
|
"test": "bun test",
|
|
11
11
|
"typecheck": "tsc --noEmit"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@nifrajs/cache": "^
|
|
15
|
-
"@nifrajs/client": "^
|
|
16
|
-
"@nifrajs/core": "^
|
|
17
|
-
"@nifrajs/jobs": "^
|
|
18
|
-
"@nifrajs/
|
|
19
|
-
"@nifrajs/
|
|
14
|
+
"@nifrajs/cache": "^4.0.2",
|
|
15
|
+
"@nifrajs/client": "^4.0.2",
|
|
16
|
+
"@nifrajs/core": "^4.0.2",
|
|
17
|
+
"@nifrajs/jobs": "^4.0.2",
|
|
18
|
+
"@nifrajs/middleware": "^4.0.2",
|
|
19
|
+
"@nifrajs/schema": "^4.0.2",
|
|
20
|
+
"@nifrajs/storage": "^4.0.2"
|
|
20
21
|
},
|
|
21
22
|
"devDependencies": {
|
|
22
|
-
"@nifrajs/cli": "^
|
|
23
|
+
"@nifrajs/cli": "^4.0.2",
|
|
23
24
|
"@types/bun": "latest",
|
|
24
25
|
"typescript": "^6.0.3"
|
|
25
26
|
}
|