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.
Files changed (106) hide show
  1. package/README.md +3 -3
  2. package/dist/agent-files.d.ts +41 -26
  3. package/dist/agent-files.d.ts.map +1 -1
  4. package/dist/agent-files.js +80 -47
  5. package/dist/agent-files.js.map +1 -1
  6. package/dist/agents.d.ts +3 -3
  7. package/dist/agents.d.ts.map +1 -1
  8. package/dist/agents.js +65 -69
  9. package/dist/agents.js.map +1 -1
  10. package/dist/auth.d.ts +5 -4
  11. package/dist/auth.d.ts.map +1 -1
  12. package/dist/auth.js +27 -14
  13. package/dist/auth.js.map +1 -1
  14. package/dist/cli.d.ts +23 -25
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +141 -130
  17. package/dist/cli.js.map +1 -1
  18. package/dist/db.d.ts.map +1 -1
  19. package/dist/db.js +49 -48
  20. package/dist/db.js.map +1 -1
  21. package/dist/scaffold/frameworks.d.ts +13 -38
  22. package/dist/scaffold/frameworks.d.ts.map +1 -1
  23. package/dist/scaffold/frameworks.js +9 -32
  24. package/dist/scaffold/frameworks.js.map +1 -1
  25. package/dist/scaffold/route-types.d.ts +9 -0
  26. package/dist/scaffold/route-types.d.ts.map +1 -0
  27. package/dist/scaffold/route-types.js +61 -0
  28. package/dist/scaffold/route-types.js.map +1 -0
  29. package/dist/scaffold/site-files.d.ts +14 -8
  30. package/dist/scaffold/site-files.d.ts.map +1 -1
  31. package/dist/scaffold/site-files.js +29 -60
  32. package/dist/scaffold/site-files.js.map +1 -1
  33. package/dist/scaffold/site.d.ts +8 -5
  34. package/dist/scaffold/site.d.ts.map +1 -1
  35. package/dist/scaffold/site.js +47 -34
  36. package/dist/scaffold/site.js.map +1 -1
  37. package/dist/scaffold/targets.d.ts +39 -0
  38. package/dist/scaffold/targets.d.ts.map +1 -0
  39. package/dist/scaffold/targets.js +116 -0
  40. package/dist/scaffold/targets.js.map +1 -0
  41. package/package.json +4 -1
  42. package/src/agent-files.ts +89 -47
  43. package/template/README.md +12 -6
  44. package/template/{src → backend}/app.test.ts +2 -2
  45. package/template/{src → backend}/app.ts +8 -3
  46. package/template/{src → backend}/index.ts +2 -2
  47. package/template/{src → backend}/routes.ts +1 -1
  48. package/template/nifra.assurance.ts +16 -14
  49. package/template/package.json +7 -7
  50. package/template/tsconfig.json +1 -1
  51. package/template-batteries/README.md +10 -6
  52. package/template-batteries/{src → backend}/app.test.ts +23 -23
  53. package/template-batteries/{src → backend}/app.ts +8 -3
  54. package/template-batteries/{src → backend}/index.ts +2 -2
  55. package/template-batteries/{src → backend}/notes.ts +1 -1
  56. package/template-batteries/nifra.assurance.ts +16 -14
  57. package/template-batteries/package.json +10 -9
  58. package/template-batteries/tsconfig.json +1 -1
  59. package/template-isr/README.md +3 -0
  60. package/template-isr/{backend.ts → backend/app.ts} +2 -0
  61. package/template-isr/{server.ts → backend/dev-server.ts} +4 -4
  62. package/template-isr/{worker.ts → backend/worker.ts} +4 -2
  63. package/template-isr/build.ts +3 -1
  64. package/template-isr/gitignore +7 -1
  65. package/template-isr/nifra.assurance.ts +15 -13
  66. package/template-isr/package.json +10 -9
  67. package/template-isr/routes/index.backend.ts +14 -0
  68. package/template-isr/routes/index.tsx +5 -17
  69. package/template-isr/tsconfig.json +5 -4
  70. package/template-isr/wrangler.toml +6 -1
  71. package/template-site/README.md +24 -23
  72. package/template-site/{backend.ts → backend/app.ts} +6 -4
  73. package/template-site/gitignore +5 -10
  74. package/template-site/nifra.assurance.ts +15 -13
  75. package/template-site/nifra.config.ts +4 -4
  76. package/template-site/routes/index.backend.ts +18 -0
  77. package/template-site/routes/index.tsx +2 -15
  78. package/template-site-preact/README.md +21 -20
  79. package/template-site-preact/nifra.config.ts +4 -4
  80. package/template-site-preact/routes/_layout.tsx +3 -1
  81. package/template-site-preact/routes/index.tsx +2 -15
  82. package/template-site-solid/README.md +21 -19
  83. package/template-site-solid/nifra.config.ts +5 -4
  84. package/template-site-solid/routes/index.tsx +2 -15
  85. package/template-site-svelte/README.md +21 -19
  86. package/template-site-svelte/nifra.config.ts +3 -2
  87. package/template-site-svelte/routes/index.svelte +3 -16
  88. package/template-site-vue/README.md +21 -20
  89. package/template-site-vue/nifra.config.ts +4 -3
  90. package/template-site-vue/routes/_layout.vue +21 -25
  91. package/template-site-vue/routes/index.vue +12 -18
  92. package/dist/scaffold/site-build.d.ts +0 -60
  93. package/dist/scaffold/site-build.d.ts.map +0 -1
  94. package/dist/scaffold/site-build.js +0 -198
  95. package/dist/scaffold/site-build.js.map +0 -1
  96. package/template-site/.dockerignore +0 -14
  97. package/template-site/Dockerfile +0 -25
  98. package/template-site/_worker.ts +0 -17
  99. package/template-site/deno.json +0 -7
  100. package/template-site/server-bun.ts +0 -35
  101. package/template-site/server-deno.ts +0 -38
  102. package/template-site/server-node.ts +0 -40
  103. package/template-site/server-vercel.ts +0 -17
  104. package/template-site/wrangler.toml +0 -4
  105. /package/template-isr/{page.ts → backend/page.ts} +0 -0
  106. /package/template-site/{counter.ts → backend/counter.ts} +0 -0
@@ -1,25 +1,24 @@
1
1
  /**
2
- * The canonical generators for the agent-discovery files an app ships so a coding agent auto-discovers
3
- * the project's nifra MCP server and prefers it over writing nifra from memory:
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
- * .mcp.json - Claude Code's project MCP registry ({ mcpServers: { nifra: { command, args } } })
6
- * .cursor/mcp.json - Cursor's MCP registry (same server config)
7
- * CLAUDE.md - Claude's preamble (use the MCP first; gate on `nifra check`) + `@AGENTS.md` import
8
- * AGENTS.md "MCP" - a section for non-Claude agents pointing at the same server
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
- * Single source of truth: both `create-nifra` (scaffold time) and `@nifrajs/cli`'s `nifra init-agents`
11
- * (retrofit an existing app) import these, so the four files can never drift apart. This module is
12
- * dependency-free (pure string + object generation) on purpose - `create-nifra` ships with no runtime
13
- * deps, and `@nifrajs/cli` imports it as a workspace dependency.
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. A scaffolded `site` app carries `@nifrajs/cli` as a devDependency, so either
17
- * spelling would resolve `node_modules/.bin/nifra` locally - BUT the `api`/`isr` templates do NOT carry
18
- * `@nifrajs/cli`, and the bare npm package literally named `nifra` (this monorepo's `@nifrajs/web` shim)
19
- * exposes NO `nifra` bin. So `bunx nifra mcp` would fetch the wrong package and fail there. Naming the
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` is re-run - an acceptable, deterministic trade.
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
- * `CLAUDE.md` - Claude Code reads this automatically. It is deliberately NOT a copy of `AGENTS.md`:
85
- * a short preamble that (1) tells Claude this project ships a nifra MCP, registered in `.mcp.json`, and
86
- * to PREFER it, and (2) pulls in the full cookbook with Claude Code's `@file` import directive on its
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
- This project ships a **nifra MCP server**, registered for Claude Code in \`.mcp.json\` (it launches with
94
- \`${MCP_SERVER_COMMAND} ${MCP_SERVER_ARGS.join(" ")}\`). **Prefer the MCP tools** over writing nifra from memory - they are
95
- typechecked against *this* project and this installed version, so they beat training-data recall:
96
-
97
- - \`nifra_docs\` / \`nifra_example\` - exact signatures + verified, compiling snippets. Reach for these
98
- before hand-writing a route, a loader, or a client call; a remembered API is often stale or wrong.
99
- - \`nifra_context\` - this project's route index + conventions, so changes fit the existing surface.
100
- - \`nifra_types\` - the EXACT TypeScript of any \`@nifrajs/*\` symbol (e.g. \`nifra_types({ name: "RateLimitStore" })\`).
101
- Call it for a precise type; **never read \`node_modules/@nifrajs/**/*.d.ts\`** - the tool is the authoritative, complete source.
102
- - **\`nifra_check\` is the done-gate.** Before declaring any change complete, run \`nifra_check\` (or
103
- \`nifra check --json\` in a terminal). It typechecks the frontend↔backend contract and flags drift
104
- (hand-rolled \`fetch()\` to this app's own API, server-only imports in \`routes/\`). A failing check
105
- means the work isn't done - fix it, don't ship around it.
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 a section to it. */
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
+ ]
@@ -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
- - `src/app.ts` - your routes (exported without `listen`, so tests drive it via `app.fetch`).
19
- - `src/index.ts` - boots the server.
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
- Add a typed client from `typeof app`:
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 { App } from "./src/app.ts"
32
+ import type { Backend } from "./backend/app.ts"
27
33
 
28
- const api = client<App>("http://localhost:3000")
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 { app } from "./app.ts"
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 app>(app)
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
- /** Your app. Exported (without `listen`) so tests can drive it via the in-process test client. */
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 app = server({
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 App = typeof app
46
+ export type Backend = typeof backend
@@ -1,5 +1,5 @@
1
- import { app } from "./app.ts"
1
+ import { backend } from "./app.ts"
2
2
 
3
3
  const port = Number(process.env.PORT ?? 3000)
4
- app.listen(port)
4
+ backend.listen(port)
5
5
  console.log(`▲ nifra listening on http://localhost:${port}`)
@@ -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<App>(baseUrl)` in a frontend) - no codegen step.
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 { app } from "./src/app.ts"
14
+ import { backend } from "./backend/app.ts"
15
15
 
16
16
  export default defineAssuranceConfig({
17
- source: app,
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" }`.
@@ -4,20 +4,20 @@
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "scripts": {
7
- "dev": "bun --watch run src/index.ts",
8
- "start": "bun run src/index.ts",
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": "^3.5.0",
15
- "@nifrajs/core": "^3.5.0",
16
- "@nifrajs/middleware": "^3.5.0",
17
- "@nifrajs/schema": "^3.5.0"
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": "^3.5.0",
20
+ "@nifrajs/cli": "^4.0.2",
21
21
  "@types/bun": "latest",
22
22
  "typescript": "^6.0.3"
23
23
  }
@@ -12,5 +12,5 @@
12
12
  "types": ["bun"],
13
13
  "skipLibCheck": true
14
14
  },
15
- "include": ["src/**/*"]
15
+ "include": ["backend/**/*"]
16
16
  }
@@ -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
- - `src/app.ts` - routes + the `notes` domain, exported (without `listen`) so tests drive it via `app.fetch`.
28
- - `src/index.ts` - boots the server and starts the job worker (`queue.start()`).
29
- - `src/app.test.ts` - exercises pagination, the background job (`queue.drain()`), the cache, and storage.
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 app`:
38
+ Add a typed client from `typeof backend`:
35
39
 
36
40
  ```ts
37
41
  import { client } from "@nifrajs/client"
38
- import type { App } from "./src/app.ts"
42
+ import type { Backend } from "./backend/app.ts"
39
43
 
40
- const api = client<App>("http://localhost:3000")
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 { app, queue, wasIndexed } from "./app.ts"
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
- app.fetch(
6
- new Request("http://localhost/notes", {
7
- method: "POST",
8
- headers: { "content-type": "application/json" },
9
- body: JSON.stringify(body),
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 url = `http://localhost/notes?limit=2&cursor=${encodeURIComponent(first.nextCursor ?? "")}`
38
- const second = (await (await app.fetch(new Request(url))).json()) as Page
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 app.fetch(new Request("http://localhost/notes?limit=999"))
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 app.fetch(new Request(`http://localhost/notes/${created.id}`))
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 app.fetch(new Request("http://localhost/notes/999999"))
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 app.fetch(
59
- new Request(`http://localhost/notes/${created.id}/attachment`, {
60
- method: "PUT",
61
- headers: { "content-type": "text/plain" },
62
- body: "hello bytes",
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
- /** Your app. Exported (without `listen`) so tests can drive it via the in-process test client. */
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 app = server({
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 App = typeof app
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 { app, queue } from "./app.ts"
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
- app.listen(port)
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 `app.fetch`.
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 { app } from "./src/app.ts"
12
+ import { backend } from "./backend/app.ts"
13
13
 
14
14
  export default defineAssuranceConfig({
15
- source: app,
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 src/index.ts",
8
- "start": "bun run src/index.ts",
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": "^3.5.0",
15
- "@nifrajs/client": "^3.5.0",
16
- "@nifrajs/core": "^3.5.0",
17
- "@nifrajs/jobs": "^3.5.0",
18
- "@nifrajs/schema": "^3.5.0",
19
- "@nifrajs/storage": "^3.5.0"
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": "^3.5.0",
23
+ "@nifrajs/cli": "^4.0.2",
23
24
  "@types/bun": "latest",
24
25
  "typescript": "^6.0.3"
25
26
  }