backeneko 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 backeneko contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,136 @@
1
- # Temporary Holding Version
1
+ # backeneko
2
+ ```text
3
+ /\_/\
4
+ ( =.= ) ( (
5
+ / \ / /
6
+ (_|__|_)/_/
7
+ ```
2
8
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
9
+
10
+ Mock GraphQL backend generated from `.graphql` schema files, with hot reload.
11
+
12
+ ## Requirements
13
+
14
+ Bun >= 1.1 — backeneko runs on Bun (`Bun.serve`, `Bun.build`, recursive
15
+ `fs.watch`).
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ bun add -g backeneko # or: npm i -g backeneko
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ ```bash
26
+ backeneko --source=./schemas --port=4000
27
+ ```
28
+
29
+ `--source` is scanned recursively for `*.graphql` / `*.gql`. Open
30
+ <http://localhost:4000/> for a minimal query inspector, or POST to `/graphql`:
31
+
32
+ ```bash
33
+ curl -s http://localhost:4000/graphql \
34
+ -H 'content-type: application/json' \
35
+ -d '{"query":"{ users { id name role } }"}'
36
+ ```
37
+
38
+ ## How it works
39
+
40
+ On the first run backeneko scans `--source`, merges the files into one schema
41
+ and creates a workspace:
42
+
43
+ ```
44
+ .backeneko/
45
+ ├── schema.graphql # merged schema (rewritten each start)
46
+ ├── tsconfig.json # @<kind>/* aliases: override → generated
47
+ ├── generated/ # disposable mirror, rewritten each start
48
+ │ ├── entities/
49
+ │ │ ├── User.ts # mock entity (default export)
50
+ │ │ └── Post.ts
51
+ │ ├── queries/
52
+ │ │ ├── users.ts # one file per Query field: (parent, args, ...) => value
53
+ │ │ └── user.ts
54
+ │ └── mutations/
55
+ │ └── createUser.ts
56
+ ├── override/ # your files win; never written by backeneko
57
+ │ └── entities/User.ts # shadows generated/entities/User.ts
58
+ └── cache/ # bundled mock modules (per content hash)
59
+ ```
60
+
61
+ - Every root field gets its own TS file exporting a resolver function.
62
+ - Every entity type gets a file exporting a `default` mock value.
63
+ - `generated/` is disposable: with `--source` it is rewritten on every start
64
+ (added/removed fields and types are synced) and files you edited there are
65
+ overwritten.
66
+ - Put lasting changes in `override/` with the same path — an override file
67
+ always shadows the generated one and is never written or removed. For
68
+ example, create `override/entities/User.ts` to replace the generated
69
+ `generated/entities/User.ts`.
70
+ - Relations are imported through `@entities/...`, which resolves override-first,
71
+ so a generated entity picks up your overridden related entity automatically.
72
+ - Edit any mock file — the backend reloads automatically.
73
+ - Entity named exports matching a field name override that field's resolver.
74
+
75
+ Without `--source`, an existing `.backeneko` is served as-is.
76
+
77
+ ## Mock directives
78
+
79
+ Pin an entity field's generated value straight from the schema with
80
+ `@backeneko(value: "...")` or its alias `@mock(value: "...")`:
81
+
82
+ ```graphql
83
+ type Product {
84
+ id: ID!
85
+ title: String @backeneko(value: "Pinned Title")
86
+ price: Int @mock(value: "199")
87
+ inStock: Boolean @backeneko(value: "true")
88
+ }
89
+ ```
90
+
91
+ - No need to declare the directive — backeneko provides it on its own.
92
+ - The value is a string and is cast to the field's type where possible:
93
+ `Int`/`Float` → number, `Boolean` → boolean, `String`/`ID`/enum/custom
94
+ scalars → string. An uncastable value is ignored with a warning and the type
95
+ default is kept.
96
+ - Only single scalar/enum fields are supported. On list or object/interface/
97
+ union fields the directive is ignored with a warning.
98
+ - Generated values are only defaults: an `override/<kind>/<name>.ts` file still
99
+ replaces them entirely.
100
+
101
+ ## CLI
102
+
103
+ ```
104
+ backeneko --source=<dir> [--port=4000] [--dir=.backeneko] [--no-validate]
105
+ ```
106
+
107
+ - `--source=<dir>` — directory with schema files (recursive).
108
+ - `--port=<n>` — HTTP port (default `4000`).
109
+ - `--dir=<dir>` — workspace directory (default `.backeneko`).
110
+ - `--no-validate` — skip validating incoming queries against the schema.
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ bun install
116
+ bun run demo # run against examples/source on :4000
117
+ bun run typecheck
118
+ bun test
119
+ ```
120
+
121
+ ## Publishing
122
+
123
+ Releases are published to npm by the [Publish](.github/workflows/publish.yml)
124
+ GitHub Actions workflow. To cut a release:
125
+
126
+ 1. Bump `version` in `package.json` and commit it.
127
+ 2. Push a matching tag: `git tag v0.1.0 && git push origin v0.1.0`.
128
+
129
+ The workflow runs typecheck + tests and publishes with provenance. It needs:
130
+
131
+ - an `NPM_TOKEN` repository secret (an npm automation token with publish rights);
132
+ - a `repository` field in `package.json` pointing at the public GitHub repo
133
+ (required by npm provenance) — the `repository` field already points at
134
+ `iliyat/backeneko`.
135
+
136
+ See `AGENTS.md` for contributor guidance.
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "backeneko",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Mock GraphQL backend generated from .graphql schema files, with hot reload",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/iliyat/backeneko.git"
9
+ },
10
+ "homepage": "https://github.com/iliyat/backeneko#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/iliyat/backeneko/issues"
13
+ },
14
+ "bin": {
15
+ "backeneko": "./src/cli.ts"
16
+ },
17
+ "files": [
18
+ "src"
19
+ ],
20
+ "engines": {
21
+ "bun": ">=1.1.0"
22
+ },
23
+ "packageManager": "bun@1.3.14",
24
+ "keywords": [
25
+ "graphql",
26
+ "mock",
27
+ "schema",
28
+ "bun",
29
+ "backend",
30
+ "hot-reload",
31
+ "sdl"
32
+ ],
33
+ "license": "MIT",
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "start": "bun run src/cli.ts",
39
+ "demo": "bun run src/cli.ts --source=examples/source --port=4000",
40
+ "typecheck": "tsc --noEmit",
41
+ "test": "bun test",
42
+ "prepublishOnly": "bun run typecheck && bun test"
43
+ },
44
+ "dependencies": {
45
+ "graphql": "^16.11.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/bun": "^1.3.4",
49
+ "typescript": "^5.9.3"
50
+ }
51
+ }
package/src/args.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { parseArgs } from "node:util";
2
+
3
+ export interface CliOptions {
4
+ source: string | null;
5
+ port: number;
6
+ dir: string;
7
+ validate: boolean;
8
+ help: boolean;
9
+ }
10
+
11
+ export const DEFAULT_PORT = 4000;
12
+ export const DEFAULT_WORKSPACE_DIR = ".backeneko";
13
+
14
+ export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions {
15
+ const { values } = parseArgs({
16
+ args: argv,
17
+ options: {
18
+ source: { type: "string" },
19
+ port: { type: "string" },
20
+ dir: { type: "string" },
21
+ "no-validate": { type: "boolean" },
22
+ help: { type: "boolean", short: "h" },
23
+ },
24
+ allowPositionals: true,
25
+ strict: true,
26
+ });
27
+
28
+ const port = values.port === undefined ? DEFAULT_PORT : Number(values.port);
29
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
30
+ throw new Error(`invalid --port value: ${values.port}`);
31
+ }
32
+
33
+ return {
34
+ source: values.source ?? null,
35
+ port,
36
+ dir: values.dir ?? DEFAULT_WORKSPACE_DIR,
37
+ validate: !(values["no-validate"] ?? false),
38
+ help: values.help ?? false,
39
+ };
40
+ }
41
+
42
+ export function printHelp(): void {
43
+ console.log(`backeneko — mock GraphQL backend generated from .graphql schema files
44
+
45
+ Usage:
46
+ backeneko [--source=<schema-directory>] [options]
47
+
48
+ Options:
49
+ --source=<dir> Directory with .graphql/.gql schema files. Regenerates the
50
+ workspace: overwrites schema.graphql and everything under
51
+ generated/, leaves override/ alone. Omit to serve <dir> as-is.
52
+ --port=<n> HTTP port (default: ${DEFAULT_PORT})
53
+ --dir=<dir> Workspace directory (default: ${DEFAULT_WORKSPACE_DIR})
54
+ --no-validate Skip validating queries against the schema (default: validate)
55
+ -h, --help Show this help
56
+
57
+ Behavior:
58
+ * with --source: schema.graphql and generated/ are rewritten on every start,
59
+ including files whose type or field disappeared from the schema
60
+ * override/entities|queries|mutations|subscriptions/<name>.ts wins over
61
+ generated/<same path>; overrides are loaded, never written or removed
62
+ * without --source: an existing <dir> is served unchanged
63
+ * mock files and schema hot-reload on change`);
64
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync } from "node:fs";
3
+ import { readdir, readFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { parseCliArgs, printHelp } from "./args.ts";
6
+ import { collectSchemaFiles } from "./collect.ts";
7
+ import { compileDocument } from "./compile.ts";
8
+ import {
9
+ GENERATED_DIR,
10
+ OVERRIDE_DIR,
11
+ ensureWorkspaceTsconfig,
12
+ generateWorkspace,
13
+ } from "./generate.ts";
14
+ import { mergeSchemaDocuments, renderSdl } from "./merge.ts";
15
+ import { loadMockModules } from "./mocks.ts";
16
+ import { buildExecutableSchema } from "./resolvers.ts";
17
+ import { runtime } from "./runtime.ts";
18
+ import { startServer } from "./server.ts";
19
+ import { watchWorkspace } from "./watch.ts";
20
+
21
+ function colorEnabled(stream: { isTTY?: boolean }): boolean {
22
+ if (process.env.NO_COLOR) return false;
23
+ const force = process.env.FORCE_COLOR;
24
+ if (force !== undefined && force !== "" && force !== "0") return true;
25
+ return Boolean(stream.isTTY);
26
+ }
27
+
28
+ function paint(stream: { isTTY?: boolean }, code: string, text: string): string {
29
+ return colorEnabled(stream) ? `\u001b[${code}m${text}\u001b[0m` : text;
30
+ }
31
+
32
+ function log(message: string): void {
33
+ console.log(`[backeneko] ${message}`);
34
+ }
35
+
36
+ function warn(message: string): void {
37
+ console.warn(paint(process.stderr, "1;33", `[backeneko] warning: ${message}`));
38
+ }
39
+
40
+ function logError(message: string): void {
41
+ console.error(paint(process.stderr, "1;31", `[backeneko] error: ${message}`));
42
+ }
43
+
44
+ function fail(message: string): never {
45
+ console.error(paint(process.stderr, "1;31", `[backeneko] error: ${message}`));
46
+ process.exit(1);
47
+ }
48
+
49
+ const LOGO = [
50
+ " /\\_/\\",
51
+ " ( =.= ) ( (",
52
+ " / \\ / /",
53
+ " (_|__|_)/_/",
54
+ ].join("\n");
55
+
56
+ function printLogo(): void {
57
+ console.log(paint(process.stdout, "36", LOGO));
58
+ console.log("");
59
+ }
60
+
61
+ async function main(): Promise<void> {
62
+ printLogo();
63
+
64
+ const options = parseCliArgs();
65
+
66
+ if (options.help) {
67
+ printHelp();
68
+ return;
69
+ }
70
+
71
+ const cwd = process.cwd();
72
+ const targetDir = path.resolve(cwd, options.dir);
73
+ const schemaPath = path.join(targetDir, "schema.graphql");
74
+
75
+ if (options.source) {
76
+ await generateFromSource(path.resolve(cwd, options.source), targetDir);
77
+ } else if (!existsSync(targetDir)) {
78
+ printHelp();
79
+ fail(`--source is required when ${options.dir} does not exist`);
80
+ } else {
81
+ log(`serving existing ${options.dir} as-is (no --source, schema not regenerated)`);
82
+ await ensureWorkspaceTsconfig(targetDir);
83
+ }
84
+
85
+ if (!existsSync(schemaPath)) {
86
+ fail(`no schema found at ${path.relative(cwd, schemaPath)}`);
87
+ }
88
+
89
+ await loadSchema(schemaPath);
90
+ runtime.modules = await loadMockModules(targetDir);
91
+ await logSummary(targetDir);
92
+
93
+ const server = startServer(options.port, { validate: options.validate });
94
+ log(`mock GraphQL backend on http://localhost:${server.port}/graphql`);
95
+ if (!options.validate) warn("query validation disabled");
96
+ log(`hot reload watching ${path.relative(cwd, targetDir)}`);
97
+
98
+ watchWorkspace(targetDir, async (changed) => {
99
+ const rel = changed.map((file) => path.relative(cwd, path.join(targetDir, file))).join(", ");
100
+ if (changed.some((file) => path.basename(file) === "schema.graphql")) {
101
+ try {
102
+ await loadSchema(schemaPath);
103
+ log(`schema reloaded (${rel})`);
104
+ } catch (error) {
105
+ console.error(
106
+ paint(
107
+ process.stderr,
108
+ "1;31",
109
+ `[backeneko] schema reload failed: ${error instanceof Error ? error.message : error}`,
110
+ ),
111
+ );
112
+ }
113
+ }
114
+ const previous = runtime.modules;
115
+ runtime.modules = await loadMockModules(targetDir, previous);
116
+ log(`mocks reloaded (${rel})`);
117
+ });
118
+
119
+ process.on("SIGINT", () => {
120
+ server.stop();
121
+ process.exit(0);
122
+ });
123
+ process.on("SIGTERM", () => {
124
+ server.stop();
125
+ process.exit(0);
126
+ });
127
+ }
128
+
129
+ async function generateFromSource(sourceDir: string, targetDir: string): Promise<void> {
130
+ if (!existsSync(sourceDir)) {
131
+ fail(`source directory not found: ${sourceDir}`);
132
+ }
133
+
134
+ const files = await collectSchemaFiles(sourceDir);
135
+ if (files.length === 0) {
136
+ fail(`no .graphql/.gql files found in ${sourceDir}`);
137
+ }
138
+
139
+ const merged = mergeSchemaDocuments(files);
140
+ for (const error of merged.errors) logError(error);
141
+ if (merged.skippedExecutables.length > 0) {
142
+ log(`ignored ${merged.skippedExecutables.length} executable definition(s) (queries/fragments are not part of the schema)`);
143
+ for (const entry of merged.skippedExecutables) warn(`skipped ${entry}`);
144
+ }
145
+
146
+ const compiled = compileDocument(merged.document);
147
+ for (const entry of compiled.skipped) warn(`skipped "${entry.name}": ${entry.reason}`);
148
+ for (const warning of compiled.warnings) warn(warning);
149
+
150
+ const sdl = renderSdl(
151
+ compiled.document,
152
+ files.map((file) => file.relativePath),
153
+ compiled.skipped,
154
+ );
155
+ const result = await generateWorkspace({ targetDir, sdl });
156
+
157
+ for (const warning of result.warnings) warn(warning);
158
+ log(`collected ${files.length} schema file(s) from ${sourceDir}`);
159
+ log(`generated ${path.basename(result.schemaPath)}`);
160
+ log(
161
+ `${GENERATED_DIR}/: ${result.created.length} created, ${result.updated.length} updated, ` +
162
+ `${result.unchanged.length} unchanged, ${result.removed.length} removed`,
163
+ );
164
+ if (result.removed.length > 0) log(`pruned ${result.removed.join(", ")}`);
165
+ log(`${OVERRIDE_DIR}/ is never written`);
166
+ }
167
+
168
+ async function logSummary(targetDir: string): Promise<void> {
169
+ const { entities, root } = runtime.modules;
170
+ const parts = [`${entities.size} entit${entities.size === 1 ? "y" : "ies"}`];
171
+ for (const [typeName, resolvers] of root) {
172
+ parts.push(`${resolvers.size} ${typeName.toLowerCase()} resolver(s)`);
173
+ }
174
+ log(`loaded ${parts.join(", ")} from ${path.relative(process.cwd(), targetDir)}`);
175
+
176
+ const overrides = await countFiles(path.join(targetDir, OVERRIDE_DIR));
177
+ if (overrides > 0) {
178
+ log(`${overrides} override file(s) in ${path.relative(process.cwd(), path.join(targetDir, OVERRIDE_DIR))}`);
179
+ }
180
+ }
181
+
182
+ async function countFiles(directory: string): Promise<number> {
183
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
184
+ let count = 0;
185
+ for (const entry of entries) {
186
+ if (entry.isDirectory()) count += await countFiles(path.join(directory, entry.name));
187
+ else count += 1;
188
+ }
189
+ return count;
190
+ }
191
+
192
+ async function loadSchema(schemaPath: string): Promise<void> {
193
+ const sdl = await readFile(schemaPath, "utf8");
194
+ runtime.schema = buildExecutableSchema(sdl);
195
+ }
196
+
197
+ main().catch((error) => {
198
+ fail(error instanceof Error ? error.message : String(error));
199
+ });
package/src/collect.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const SCHEMA_EXTENSIONS = new Set([".graphql", ".gql"]);
5
+ const IGNORED_DIRECTORIES = new Set(["node_modules", ".git", "dist"]);
6
+
7
+ export interface SchemaSourceFile {
8
+ absolutePath: string;
9
+ relativePath: string;
10
+ content: string;
11
+ }
12
+
13
+ export async function collectSchemaFiles(root: string): Promise<SchemaSourceFile[]> {
14
+ const files: SchemaSourceFile[] = [];
15
+
16
+ async function walk(dir: string, prefix: string): Promise<void> {
17
+ const entries = await readdir(dir, { withFileTypes: true });
18
+ entries.sort((a, b) => a.name.localeCompare(b.name));
19
+ for (const entry of entries) {
20
+ const relative = prefix + entry.name;
21
+ if (entry.isDirectory()) {
22
+ if (entry.name.startsWith(".") || IGNORED_DIRECTORIES.has(entry.name)) continue;
23
+ await walk(path.join(dir, entry.name), relative + "/");
24
+ continue;
25
+ }
26
+ if (!entry.isFile() || !SCHEMA_EXTENSIONS.has(path.extname(entry.name))) continue;
27
+ const absolutePath = path.join(dir, entry.name);
28
+ files.push({
29
+ absolutePath,
30
+ relativePath: relative,
31
+ content: await readFile(absolutePath, "utf8"),
32
+ });
33
+ }
34
+ }
35
+
36
+ await walk(root, "");
37
+ return files;
38
+ }