frappebun 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.
Files changed (66) hide show
  1. package/README.md +72 -0
  2. package/package.json +59 -0
  3. package/src/api/auth.ts +76 -0
  4. package/src/api/index.ts +10 -0
  5. package/src/api/resource.ts +177 -0
  6. package/src/api/route.ts +301 -0
  7. package/src/app/index.ts +6 -0
  8. package/src/app/loader.ts +218 -0
  9. package/src/auth/auth.ts +247 -0
  10. package/src/auth/index.ts +2 -0
  11. package/src/cli/args.ts +40 -0
  12. package/src/cli/bin.ts +12 -0
  13. package/src/cli/commands/add-api.ts +32 -0
  14. package/src/cli/commands/add-doctype.ts +43 -0
  15. package/src/cli/commands/add-page.ts +33 -0
  16. package/src/cli/commands/add-user.ts +96 -0
  17. package/src/cli/commands/dev.ts +71 -0
  18. package/src/cli/commands/drop-site.ts +27 -0
  19. package/src/cli/commands/init.ts +98 -0
  20. package/src/cli/commands/migrate.ts +110 -0
  21. package/src/cli/commands/new-site.ts +61 -0
  22. package/src/cli/commands/routes.ts +56 -0
  23. package/src/cli/commands/use.ts +30 -0
  24. package/src/cli/index.ts +73 -0
  25. package/src/cli/log.ts +13 -0
  26. package/src/cli/scaffold/templates.ts +189 -0
  27. package/src/context.ts +162 -0
  28. package/src/core/doctype/migration/migration.ts +17 -0
  29. package/src/core/doctype/role/role.ts +7 -0
  30. package/src/core/doctype/session/session.ts +16 -0
  31. package/src/core/doctype/user/user.controller.ts +11 -0
  32. package/src/core/doctype/user/user.ts +22 -0
  33. package/src/core/doctype/user_role/user_role.ts +9 -0
  34. package/src/core/doctypes.ts +25 -0
  35. package/src/core/index.ts +1 -0
  36. package/src/database/database.ts +359 -0
  37. package/src/database/filters.ts +131 -0
  38. package/src/database/index.ts +30 -0
  39. package/src/database/query-builder.ts +1118 -0
  40. package/src/database/schema.ts +188 -0
  41. package/src/doctype/define.ts +45 -0
  42. package/src/doctype/discovery.ts +57 -0
  43. package/src/doctype/field.ts +160 -0
  44. package/src/doctype/index.ts +20 -0
  45. package/src/doctype/layout.ts +62 -0
  46. package/src/doctype/query-builder-stub.ts +16 -0
  47. package/src/doctype/registry.ts +106 -0
  48. package/src/doctype/types.ts +407 -0
  49. package/src/document/document.ts +593 -0
  50. package/src/document/index.ts +6 -0
  51. package/src/document/naming.ts +56 -0
  52. package/src/errors.ts +53 -0
  53. package/src/frappe.d.ts +128 -0
  54. package/src/globals.ts +72 -0
  55. package/src/index.ts +112 -0
  56. package/src/migrations/index.ts +11 -0
  57. package/src/migrations/runner.ts +256 -0
  58. package/src/permissions/index.ts +265 -0
  59. package/src/response.ts +100 -0
  60. package/src/server.ts +210 -0
  61. package/src/site.ts +126 -0
  62. package/src/ssr/handler.ts +56 -0
  63. package/src/ssr/index.ts +11 -0
  64. package/src/ssr/page-loader.ts +200 -0
  65. package/src/ssr/renderer.ts +94 -0
  66. package/src/ssr/use-context.ts +41 -0
@@ -0,0 +1,301 @@
1
+ /**
2
+ * `route()` — marks an export as an HTTP endpoint with access-control metadata.
3
+ *
4
+ * ─── Two URL conventions ────────────────────────────────────────────────────
5
+ *
6
+ * 1. CONVENTION — no `path:` → URL derived from file path + export name
7
+ *
8
+ * // api/reports.ts
9
+ * export const getOutstanding = route(async ({ customer }) => { ... })
10
+ * // → GET /api/myapp/reports/get-outstanding?customer=CUST-001
11
+ *
12
+ * 2. EXPLICIT — `path:` is the full URL relative to /api/{appname}/
13
+ *
14
+ * // any file
15
+ * export const getProduct = route({
16
+ * path: "/products/:id",
17
+ * method: "GET",
18
+ * async handler({ id }: { id: string }) { ... },
19
+ * })
20
+ * // → GET /api/myapp/products/:id
21
+ *
22
+ * export const getProductAction = route({
23
+ * path: "/products/:id/details/:action",
24
+ * method: "GET",
25
+ * async handler({ id, action }: { id: string; action: string }) { ... },
26
+ * })
27
+ * // → GET /api/myapp/products/:id/details/:action
28
+ *
29
+ * When `path:` is set the file's location has no effect on the URL.
30
+ * Path params (`:name` segments) are merged with query-string / body params;
31
+ * path params win on collision.
32
+ *
33
+ * ─── Return types ────────────────────────────────────────────────────────────
34
+ *
35
+ * - Plain value → wrapped in `{ data: <value> }` with status 200
36
+ * - `Response` → passed through as-is (full control over status / headers)
37
+ */
38
+
39
+ import { contextStore } from "../context"
40
+ import type { FrappeContext } from "../context"
41
+ import { response as _response } from "../response"
42
+
43
+ export type AuthMode = "required" | "public" | "guest-only"
44
+ export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
45
+
46
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
+ export type RouteHandler = (params: any) => Promise<unknown> | unknown
48
+
49
+ export interface RouteConfig {
50
+ handler: RouteHandler
51
+ /**
52
+ * App-relative URL pattern, starting with `/`.
53
+ * Params are declared as `:name` segments and received in the handler.
54
+ *
55
+ * Examples:
56
+ * "/products/:id" → /api/{app}/products/:id
57
+ * "/products/:id/details/:action" → /api/{app}/products/:id/details/:action
58
+ * "/orders/:orderId/items/:itemId" → /api/{app}/orders/:orderId/items/:itemId
59
+ *
60
+ * When omitted the URL is derived from the file path + export name convention.
61
+ */
62
+ path?: string
63
+ auth?: AuthMode
64
+ method?: HttpMethod
65
+ roles?: string[]
66
+ rateLimit?: { limit: number; window: number }
67
+ }
68
+
69
+ export interface RouteDefinition {
70
+ __isRoute: true
71
+ config: RouteConfig
72
+ }
73
+
74
+ // ─── route() factory ──────────────────────────────────────
75
+
76
+ /**
77
+ * Infer HTTP method from an export name:
78
+ * get / list / fetch / search / check / is / has → GET
79
+ * everything else → POST
80
+ */
81
+ export function inferMethod(exportName: string): HttpMethod {
82
+ if (!exportName) return "POST"
83
+ return /^(get|list|fetch|search|check|is|has)/i.test(exportName) ? "GET" : "POST"
84
+ }
85
+
86
+ /** Convert camelCase to kebab-case: `getOutstanding` → `get-outstanding` */
87
+ export function camelToKebab(s: string): string {
88
+ return s.replace(/([A-Z])/g, (_, c: string) => `-${c.toLowerCase()}`)
89
+ }
90
+
91
+ /**
92
+ * Compute the full server URL path for a route definition.
93
+ *
94
+ * Strategy 1 — explicit `path:`:
95
+ * `appPrefix` + `def.config.path`
96
+ * e.g. "/api/myapp" + "/products/:id" → "/api/myapp/products/:id"
97
+ *
98
+ * Strategy 2 — convention (no `path:`):
99
+ * `appPrefix` / `fileSegment` / kebab(`exportName`)
100
+ * e.g. "/api/myapp" / "reports" / "get-outstanding" → "/api/myapp/reports/get-outstanding"
101
+ *
102
+ * @param appPrefix App namespace, e.g. `"/api/myapp"`
103
+ * @param def The RouteDefinition
104
+ * @param exportName JS export name — used for method inference and convention URL
105
+ * @param fileSegment File URL segment, e.g. `"reports"` for `api/reports.ts`
106
+ */
107
+ export function resolveRoutePath(
108
+ appPrefix: string,
109
+ def: RouteDefinition,
110
+ exportName: string,
111
+ fileSegment?: string,
112
+ ): string {
113
+ if (def.config.path) return `${appPrefix}${def.config.path}`
114
+ const parts = [appPrefix, fileSegment, camelToKebab(exportName)].filter(Boolean)
115
+ return parts.join("/")
116
+ }
117
+
118
+ /**
119
+ * Mark a function as an HTTP endpoint.
120
+ *
121
+ * route(fn) — simple form, sensible defaults
122
+ * route({ ... }) — full form, all options explicit
123
+ */
124
+ export function route(fnOrConfig: RouteHandler | RouteConfig): RouteDefinition {
125
+ const config: RouteConfig =
126
+ typeof fnOrConfig === "function" ? { handler: fnOrConfig } : fnOrConfig
127
+
128
+ return { __isRoute: true, config: { auth: "required", ...config } }
129
+ }
130
+
131
+ export function isRouteDefinition(v: unknown): v is RouteDefinition {
132
+ return typeof v === "object" && v !== null && (v as RouteDefinition).__isRoute === true
133
+ }
134
+
135
+ // ─── Route registry ───────────────────────────────────────
136
+
137
+ interface RegistryEntry {
138
+ def: RouteDefinition
139
+ method: HttpMethod
140
+ segments: string[]
141
+ }
142
+
143
+ /**
144
+ * Registry of all discovered user-defined routes.
145
+ * Framework routes (auth, resource) are dispatched directly by the server.
146
+ */
147
+ class RouteRegistry {
148
+ private fixed: RegistryEntry[] = [] // framework routes — survive clear()
149
+ private dynamic: RegistryEntry[] = [] // user / test routes — cleared by clear()
150
+
151
+ /**
152
+ * Register a framework (system) route.
153
+ * These are never removed by clear() and have priority over user routes.
154
+ */
155
+ registerSystem(fullPath: string, def: RouteDefinition, exportName = ""): void {
156
+ this.fixed.push(this._entry(fullPath, def, exportName))
157
+ }
158
+
159
+ /**
160
+ * Register a user-defined route.
161
+ * Use resolveRoutePath() to compute fullPath from a RouteDefinition.
162
+ */
163
+ register(fullPath: string, def: RouteDefinition, exportName = ""): void {
164
+ this.dynamic.push(this._entry(fullPath, def, exportName))
165
+ }
166
+
167
+ /**
168
+ * Match an incoming request against registered patterns.
169
+ * Fixed (framework) routes are checked first.
170
+ */
171
+ match(
172
+ pathname: string,
173
+ httpMethod: string,
174
+ ): { def: RouteDefinition; params: Record<string, string> } | null {
175
+ const incoming = pathname.split("/").filter(Boolean)
176
+ const method = httpMethod.toUpperCase() as HttpMethod
177
+
178
+ for (const entry of [...this.fixed, ...this.dynamic]) {
179
+ if (entry.method !== method) continue
180
+ if (entry.segments.length !== incoming.length) continue
181
+
182
+ const params: Record<string, string> = {}
183
+ let ok = true
184
+
185
+ for (let i = 0; i < entry.segments.length; i++) {
186
+ const seg = entry.segments[i]!
187
+ if (seg.startsWith(":")) {
188
+ params[seg.slice(1)] = decodeURIComponent(incoming[i]!)
189
+ } else if (seg !== incoming[i]) {
190
+ ok = false
191
+ break
192
+ }
193
+ }
194
+
195
+ if (ok) return { def: entry.def, params }
196
+ }
197
+
198
+ return null
199
+ }
200
+
201
+ /** Clear user-defined routes only. Framework routes are preserved. */
202
+ clear(): void {
203
+ this.dynamic = []
204
+ }
205
+
206
+ /** Return all registered user routes as a flat list for introspection. */
207
+ list(): Array<{ method: HttpMethod; path: string; auth: AuthMode }> {
208
+ return this.dynamic.map((e) => ({
209
+ method: e.method,
210
+ path: "/" + e.segments.join("/"),
211
+ auth: e.def.config.auth ?? "required",
212
+ }))
213
+ }
214
+
215
+ private _entry(fullPath: string, def: RouteDefinition, exportName: string): RegistryEntry {
216
+ return {
217
+ def,
218
+ method: def.config.method ?? inferMethod(exportName),
219
+ segments: fullPath.split("/").filter(Boolean),
220
+ }
221
+ }
222
+ }
223
+
224
+ /** Singleton route registry — app route files register into this at startup. */
225
+ export const routeRegistry = new RouteRegistry()
226
+
227
+ // ─── invokeRoute ──────────────────────────────────────────
228
+
229
+ /**
230
+ * Execute a RouteDefinition:
231
+ * 1. Enforce auth / role checks
232
+ * 2. Merge params (path > query-string > body)
233
+ * 3. Call handler
234
+ * 4. Plain return values → `{ data: ... }`; Response objects → pass through
235
+ *
236
+ * @param def The RouteDefinition to invoke
237
+ * @param pathParams Path parameters extracted from the URL (highest priority)
238
+ */
239
+ export async function invokeRoute(
240
+ def: RouteDefinition,
241
+ pathParams: Record<string, unknown> = {},
242
+ ): Promise<Response> {
243
+ const ctx = contextStore.getStore()
244
+ const authMode = def.config.auth ?? "required"
245
+
246
+ // ── Auth ────────────────────────────────────────────
247
+ if (authMode === "required" && (!ctx || ctx.user === "Guest")) {
248
+ return _response.unauthorized()
249
+ }
250
+ if (authMode === "guest-only" && ctx?.user !== "Guest") {
251
+ return _response.error("Already logged in", 400)
252
+ }
253
+
254
+ // ── Roles ────────────────────────────────────────────
255
+ if (def.config.roles?.length && ctx) {
256
+ if (!def.config.roles.some((r) => ctx.perms.hasRole(r))) {
257
+ return _response.forbidden()
258
+ }
259
+ }
260
+
261
+ // ── Params: query-string + body + path params ────────
262
+ const params = await mergeParams(pathParams, ctx)
263
+
264
+ // ── Execute ──────────────────────────────────────────
265
+ const result = await def.config.handler(params)
266
+
267
+ if (result instanceof Response) return result
268
+ return _response.ok(result)
269
+ }
270
+
271
+ // ─── Internal helpers ─────────────────────────────────────
272
+
273
+ async function mergeParams(
274
+ pathParams: Record<string, unknown>,
275
+ ctx: FrappeContext | undefined,
276
+ ): Promise<Record<string, unknown>> {
277
+ const req = ctx?.request
278
+ if (!req) return { ...pathParams }
279
+
280
+ const params: Record<string, unknown> = {}
281
+
282
+ // 1. Query-string (lowest priority)
283
+ for (const [k, v] of new URL(req.url).searchParams) params[k] = v
284
+
285
+ // 2. JSON body for write methods
286
+ const method = req.method.toUpperCase()
287
+ if (method === "POST" || method === "PUT" || method === "PATCH") {
288
+ if ((req.headers.get("content-type") ?? "").includes("application/json")) {
289
+ try {
290
+ Object.assign(params, (await req.clone().json()) as Record<string, unknown>)
291
+ } catch {
292
+ /* non-JSON body */
293
+ }
294
+ }
295
+ }
296
+
297
+ // 3. Path params (highest priority)
298
+ Object.assign(params, pathParams)
299
+
300
+ return params
301
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * App module — project/app discovery and loading.
3
+ */
4
+
5
+ export { loadApp, readAppMetadata, appNameFromPackage, findProjectRoot, appLabel } from "./loader"
6
+ export type { LoadedApp, AppMetadata } from "./loader"
@@ -0,0 +1,218 @@
1
+ /**
2
+ * App loader — discover the app module from package.json and scan
3
+ * its conventional directories (doctype/, api/, pages/, extensions/, ...).
4
+ *
5
+ * One app per project. The project root contains a `package.json` with a
6
+ * `"name"` field — that name becomes the app module directory name.
7
+ * Scoped names (`@scope/name`) use only the name part.
8
+ */
9
+
10
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
11
+ import { join, basename } from "node:path"
12
+
13
+ import { isRouteDefinition, resolveRoutePath, routeRegistry } from "../api/route"
14
+ import type { RouteDefinition } from "../api/route"
15
+ import { discoverDocTypes } from "../doctype/discovery"
16
+
17
+ // ─── Types ────────────────────────────────────────────────
18
+
19
+ export interface AppMetadata {
20
+ name: string
21
+ version: string
22
+ title: string
23
+ description?: string
24
+ }
25
+
26
+ export interface LoadedApp {
27
+ /** Project root — directory containing package.json. */
28
+ readonly root: string
29
+
30
+ /** App module name — derived from package.json "name". */
31
+ readonly name: string
32
+
33
+ /** Absolute path to the app module directory — `<root>/<name>/`. */
34
+ readonly moduleDir: string
35
+
36
+ /** Metadata from package.json. */
37
+ readonly metadata: AppMetadata
38
+
39
+ /** Counts of what was loaded — for dev-server logging. */
40
+ readonly loaded: {
41
+ doctypes: number
42
+ apiRoutes: number
43
+ pages: number
44
+ }
45
+ }
46
+
47
+ // ─── package.json parsing ─────────────────────────────────
48
+
49
+ interface PackageJson {
50
+ name?: string
51
+ version?: string
52
+ frappe?: { title?: string; description?: string }
53
+ }
54
+
55
+ /** Resolve the app module directory name from a package name. */
56
+ export function appNameFromPackage(pkgName: string): string {
57
+ // "@scope/hrms" → "hrms", "myapp" → "myapp"
58
+ return pkgName.startsWith("@") ? (pkgName.split("/")[1] ?? pkgName) : pkgName
59
+ }
60
+
61
+ /** Read and parse the project's package.json. */
62
+ export function readAppMetadata(root: string): AppMetadata {
63
+ const pkgPath = join(root, "package.json")
64
+ if (!existsSync(pkgPath)) {
65
+ throw new Error(`No package.json found at ${root}`)
66
+ }
67
+
68
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as PackageJson
69
+
70
+ if (!pkg.name) {
71
+ throw new Error(`package.json at ${root} has no "name" field`)
72
+ }
73
+
74
+ return {
75
+ name: appNameFromPackage(pkg.name),
76
+ version: pkg.version ?? "0.0.0",
77
+ title: pkg.frappe?.title ?? pkg.name,
78
+ description: pkg.frappe?.description,
79
+ }
80
+ }
81
+
82
+ // ─── Loading ──────────────────────────────────────────────
83
+
84
+ /**
85
+ * Load the app at `root` — read its metadata, then scan all conventional
86
+ * directories inside the app module for DocTypes, API routes, and pages.
87
+ *
88
+ * Missing directories are silently skipped.
89
+ */
90
+ export async function loadApp(root: string): Promise<LoadedApp> {
91
+ const metadata = readAppMetadata(root)
92
+ const moduleDir = join(root, metadata.name)
93
+
94
+ if (!existsSync(moduleDir)) {
95
+ throw new Error(
96
+ `App module directory "${metadata.name}/" not found at ${root}. ` +
97
+ `Expected ${moduleDir}/ — it must match package.json "name".`,
98
+ )
99
+ }
100
+
101
+ const doctypes = await loadDocTypes(moduleDir)
102
+ const apiRoutes = await loadApiRoutes(moduleDir, metadata.name)
103
+ const pages = countPages(moduleDir)
104
+
105
+ return { root, name: metadata.name, moduleDir, metadata, loaded: { doctypes, apiRoutes, pages } }
106
+ }
107
+
108
+ // ─── DocTypes ─────────────────────────────────────────────
109
+
110
+ async function loadDocTypes(moduleDir: string): Promise<number> {
111
+ const dir = join(moduleDir, "doctype")
112
+ if (!existsSync(dir)) return 0
113
+ await discoverDocTypes(dir)
114
+ return readdirSync(dir).filter((e) => statSync(join(dir, e)).isDirectory()).length
115
+ }
116
+
117
+ // ─── API routes ───────────────────────────────────────────
118
+
119
+ /**
120
+ * Discover API route files under `<moduleDir>/api/` and register them.
121
+ *
122
+ * File path → URL segment:
123
+ * api/reports.ts → /api/<app>/reports/<kebab-export>
124
+ * api/webhooks/stripe.ts → /api/<app>/webhooks/stripe/<kebab-export>
125
+ */
126
+ async function loadApiRoutes(moduleDir: string, appName: string): Promise<number> {
127
+ const apiDir = join(moduleDir, "api")
128
+ if (!existsSync(apiDir)) return 0
129
+
130
+ const appPrefix = `/api/${appName}`
131
+ let count = 0
132
+
133
+ for (const file of walkTsFiles(apiDir)) {
134
+ const fileSegment = toRouteSegment(file.relPath)
135
+ const mod = (await import(file.absPath)) as Record<string, unknown>
136
+
137
+ for (const [exportName, value] of Object.entries(mod)) {
138
+ if (!isRouteDefinition(value)) continue
139
+
140
+ // `export default route(...)` owns the file-level URL — no export-name
141
+ // segment appended. e.g. api/hello.ts → POST /api/myapp/hello
142
+ const isDefault = exportName === "default"
143
+ const fullPath = isDefault
144
+ ? `${appPrefix}/${fileSegment}`
145
+ : resolveRoutePath(appPrefix, value as RouteDefinition, exportName, fileSegment)
146
+
147
+ routeRegistry.register(fullPath, value as RouteDefinition, isDefault ? "" : exportName)
148
+ count++
149
+ }
150
+ }
151
+
152
+ return count
153
+ }
154
+
155
+ // ─── Pages (counted only — SSR loads them separately) ────
156
+
157
+ function countPages(moduleDir: string): number {
158
+ const pagesDir = join(moduleDir, "pages")
159
+ if (!existsSync(pagesDir)) return 0
160
+ let count = 0
161
+ for (const _ of walkVueFiles(pagesDir)) count++
162
+ return count
163
+ }
164
+
165
+ // ─── Filesystem helpers ───────────────────────────────────
166
+
167
+ interface WalkedFile {
168
+ absPath: string
169
+ relPath: string // relative to walk root, no leading slash
170
+ }
171
+
172
+ function* walkTsFiles(root: string, prefix = ""): Generator<WalkedFile> {
173
+ for (const entry of readdirSync(root)) {
174
+ const abs = join(root, entry)
175
+ const rel = prefix ? `${prefix}/${entry}` : entry
176
+ if (statSync(abs).isDirectory()) {
177
+ yield* walkTsFiles(abs, rel)
178
+ } else if (entry.endsWith(".ts") && !entry.endsWith(".d.ts") && !entry.endsWith(".test.ts")) {
179
+ yield { absPath: abs, relPath: rel }
180
+ }
181
+ }
182
+ }
183
+
184
+ function* walkVueFiles(root: string, prefix = ""): Generator<WalkedFile> {
185
+ for (const entry of readdirSync(root)) {
186
+ const abs = join(root, entry)
187
+ const rel = prefix ? `${prefix}/${entry}` : entry
188
+ if (statSync(abs).isDirectory()) {
189
+ yield* walkVueFiles(abs, rel)
190
+ } else if (entry.endsWith(".vue")) {
191
+ yield { absPath: abs, relPath: rel }
192
+ }
193
+ }
194
+ }
195
+
196
+ /** Convert an api/ relative file path to its URL segment. */
197
+ function toRouteSegment(relPath: string): string {
198
+ // "webhooks/stripe.ts" → "webhooks/stripe"
199
+ // "reports.ts" → "reports"
200
+ return relPath.replace(/\.ts$/, "")
201
+ }
202
+
203
+ /** Convenience: infer project root as the nearest ancestor containing package.json. */
204
+ export function findProjectRoot(start: string = process.cwd()): string {
205
+ let dir = start
206
+ while (dir !== "/" && dir !== "") {
207
+ if (existsSync(join(dir, "package.json"))) return dir
208
+ const parent = join(dir, "..")
209
+ if (parent === dir) break
210
+ dir = parent
211
+ }
212
+ throw new Error(`Could not find a package.json starting from ${start}`)
213
+ }
214
+
215
+ /** Friendly basename — used for logging. */
216
+ export function appLabel(app: LoadedApp): string {
217
+ return basename(app.root)
218
+ }