create-ramonyx-app 2.0.1 → 2.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 CHANGED
@@ -2,16 +2,20 @@
2
2
 
3
3
  Scaffolder aplikasi Ramonyx — SSR React + hydration, file routing `src/routes`.
4
4
 
5
- > **Alpha (2.0.1)** — butuh **Node.js ≥ 24**.
5
+ > **Alpha (2.0.2)** — butuh **Node.js ≥ 24**.
6
6
 
7
7
  ## Pemakaian
8
8
 
9
9
  ```bash
10
- pnpm create ramonyx-app aplikasi-saya
11
- # atau tanpa install:
12
- pnpm dlx create-ramonyx-app aplikasi-saya
10
+ npm create ramonyx-app@latest aplikasi-saya
11
+ # atau:
12
+ pnpm create ramonyx-app@latest aplikasi-saya
13
+ yarn create ramonyx-app aplikasi-saya
14
+ bun create ramonyx-app aplikasi-saya
13
15
  ```
14
16
 
15
- Lanjutkan dengan `cd aplikasi-saya && pnpm install && pnpm build && pnpm start`.
17
+ Pilih template dengan `--template minimal|api-auth|cloudflare|docs-blog`.
18
+ Lanjutkan dengan manager yang sama: `npm install && npm run dev`,
19
+ `pnpm install && pnpm dev`, `yarn && yarn dev`, atau `bun install && bun run dev`.
16
20
 
17
21
  Repo: https://github.com/RamonyxJS/ramonyx · Lisensi: MIT
package/dist/cli.js CHANGED
@@ -6,18 +6,33 @@ import { basename, dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  var packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
8
8
  var templateDir = join(packageRoot, "template");
9
+ var templatesDir = join(packageRoot, "templates");
9
10
  var agentTemplateDir = join(packageRoot, "template-agent");
11
+ var TEMPLATES = {
12
+ minimal: "Welcome screen: SSR + hydration + API + form action",
13
+ "api-auth": "Session auth: login/logout, protected API, server action",
14
+ cloudflare: "Cloudflare Worker: R2 + KV binding example and wrangler config",
15
+ "docs-blog": "Static content: blog index + dynamic posts + SSG export"
16
+ };
17
+ var TEMPLATE_IDS = Object.keys(TEMPLATES);
10
18
  var USAGE = `create-ramonyx-app \u2014 buat aplikasi Ramonyx baru
11
19
 
12
20
  Penggunaan:
13
- pnpm create ramonyx-app <dir> # dari registry (setelah publikasi)
14
- pnpm dlx create-ramonyx-app <dir> # tanpa install
15
- pnpm create:app <dir> # di dalam repo (workspace)
21
+ npm create ramonyx-app@latest <dir> # registry
22
+ pnpm create ramonyx-app@latest <dir> # registry
23
+ yarn create ramonyx-app <dir> # registry
24
+ bun create ramonyx-app <dir> # registry
25
+ pnpm create:app <dir> # di dalam repo (workspace)
16
26
 
17
27
  Opsi:
18
28
  --force, -f Timpa berkas template walau direktori tujuan tidak kosong
19
29
  --with-agent-docs Sertakan AGENTS.md + skill agent opt-in (tidak default)
30
+ --template <id> Template: minimal (default), api-auth, cloudflare, docs-blog
31
+ --list-templates Tampilkan template yang tersedia
20
32
  --help, -h Tampilkan bantuan ini
33
+
34
+ Catatan pnpm: bila scaffold masih 1.x, pakai
35
+ pnpm create ramonyx-app@2.0.1 <dir>
21
36
  `;
22
37
  function usage(exitCode) {
23
38
  const write = exitCode === 0 ? console.log : console.error;
@@ -27,12 +42,42 @@ function usage(exitCode) {
27
42
  var args = process.argv.slice(2);
28
43
  var force = false;
29
44
  var withAgentDocs = false;
45
+ var template = "minimal";
30
46
  var target;
31
- for (const arg of args) {
47
+ for (let index = 0; index < args.length; index += 1) {
48
+ const arg = args[index] ?? "";
49
+ const next = () => {
50
+ const value = args[index + 1];
51
+ if (value === void 0 || value.startsWith("-")) {
52
+ console.error(`error: ${arg} membutuhkan nilai`);
53
+ usage(1);
54
+ }
55
+ index += 1;
56
+ return value;
57
+ };
32
58
  if (arg === "--force" || arg === "-f") {
33
59
  force = true;
34
60
  } else if (arg === "--with-agent-docs") {
35
61
  withAgentDocs = true;
62
+ } else if (arg === "--list-templates") {
63
+ for (const id of TEMPLATE_IDS) console.log(`${id.padEnd(12)} ${TEMPLATES[id]}`);
64
+ process.exit(0);
65
+ } else if (arg === "--template") {
66
+ const value = next();
67
+ if (!TEMPLATE_IDS.includes(value)) {
68
+ console.error(`error: template tidak dikenal: ${value}`);
69
+ console.error(`tersedia: ${TEMPLATE_IDS.join(", ")}`);
70
+ usage(1);
71
+ }
72
+ template = value;
73
+ } else if (arg.startsWith("--template=")) {
74
+ const value = arg.slice("--template=".length);
75
+ if (!TEMPLATE_IDS.includes(value)) {
76
+ console.error(`error: template tidak dikenal: ${value}`);
77
+ console.error(`tersedia: ${TEMPLATE_IDS.join(", ")}`);
78
+ usage(1);
79
+ }
80
+ template = value;
36
81
  } else if (arg === "--help" || arg === "-h") {
37
82
  usage(0);
38
83
  } else if (arg.startsWith("-")) {
@@ -77,14 +122,29 @@ function copy(source, destination) {
77
122
  writeFileSync(join(dirname(destination), name), text);
78
123
  }
79
124
  copy(templateDir, targetDir);
125
+ if (template !== "minimal") {
126
+ const overlay = join(templatesDir, template);
127
+ if (!existsSync(overlay)) {
128
+ console.error(`error: berkas template ${template} tidak ditemukan di ${overlay}`);
129
+ process.exit(1);
130
+ }
131
+ copy(overlay, targetDir);
132
+ }
80
133
  if (withAgentDocs) {
81
134
  copy(agentTemplateDir, targetDir);
82
135
  }
83
- console.log(`\u2713 App "${appName}" dibuat di ${targetDir}
136
+ console.log(`\u2713 App "${appName}" dibuat di ${targetDir} (template: ${template})
84
137
 
85
138
  Langkah berikutnya:
86
139
 
87
140
  cd ${target}
88
- pnpm install # atau npm install
89
- pnpm start # http://127.0.0.1:3000
141
+
142
+ Pilih SATU package manager (jangan campur lockfile):
143
+
144
+ npm install && npm run build && npm start
145
+ pnpm install && pnpm build && pnpm start
146
+ yarn && yarn build && yarn start
147
+ bun install && bun run build && bun run start
148
+
149
+ Dev server: ganti "build && start" dengan "run dev" / "dev".
90
150
  ${withAgentDocs ? "\nAgent docs: AGENTS.md + .opencode/skills/ramonyx/SKILL.md sudah disertakan.\n" : ""}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ramonyx-app",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Scaffolder aplikasi Ramonyx (SSR React + hydration, file routing src/routes).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "dist",
12
12
  "template",
13
+ "templates",
13
14
  "template-agent",
14
15
  "README.md",
15
16
  "LICENSE"
@@ -7,16 +7,25 @@ Aplikasi [Ramonyx](https://github.com/ramonyx/ramonyx) — framework full-stack
7
7
  ## Menjalankan
8
8
 
9
9
  ```bash
10
- pnpm install # atau: bun install / npm install
11
- pnpm dev # Vite + HMR + SSR (http://localhost:3000, satu port)
12
- pnpm build # bundel klien (wajib sebelum start produksi)
13
- pnpm start # http://127.0.0.1:3000 (NODE_ENV=production otomatis)
10
+ npm install
11
+ npm run dev # Vite + HMR + SSR (http://localhost:3000, satu port)
12
+ npm run build # bundel klien (wajib sebelum start produksi)
13
+ npm start # http://127.0.0.1:3000 (NODE_ENV=production otomatis)
14
14
  ```
15
15
 
16
- Dengan Bun: `bun run dev`, `bun run build`, `bun run start`. Perintah memakai CLI **`ramonyx`**
17
- (`@ramonyx/devtools`) yang terpasang **lokal** di `node_modules/.bin`. Untuk memanggil CLI di luar
18
- package script, gunakan runner package manager: `bunx ramonyx …`, `pnpm exec ramonyx …`, atau
19
- `npx ramonyx …`. `ramonyx …` tanpa runner hanya tersedia setelah instalasi global.
16
+ Semua package manager yang diuji bisa dipakai:
17
+
18
+ | Manager | Install | Dev |
19
+ | --- | --- | --- |
20
+ | npm | `npm install` | `npm run dev` |
21
+ | pnpm | `pnpm install` | `pnpm dev` |
22
+ | Yarn | `yarn` | `yarn dev` |
23
+ | Bun | `bun install` | `bun run dev` |
24
+
25
+ Perintah memakai CLI **`ramonyx`** (`@ramonyx/devtools`) yang terpasang **lokal** di
26
+ `node_modules/.bin`. Untuk memanggil CLI di luar package script, gunakan runner package manager:
27
+ `npx ramonyx …`, `pnpm exec ramonyx …`, atau `bunx ramonyx …`. `ramonyx …` tanpa runner hanya
28
+ tersedia setelah instalasi global.
20
29
 
21
30
  ## Struktur
22
31
 
@@ -11,15 +11,15 @@
11
11
  "dev": "ramonyx dev"
12
12
  },
13
13
  "dependencies": {
14
- "@ramonyx/adapter-runtime": "^2.0.1",
15
- "@ramonyx/bundler": "^2.0.1",
16
- "@ramonyx/compiler": "^2.0.1",
17
- "@ramonyx/devtools": "^2.0.1",
18
- "@ramonyx/react": "^2.0.1",
19
- "@ramonyx/security": "^2.0.1",
20
- "@ramonyx/server": "^2.0.1",
21
- "@ramonyx/ssr": "^2.0.1",
22
- "ramonyx": "^2.0.1",
14
+ "@ramonyx/adapter-runtime": "^2.0.2",
15
+ "@ramonyx/bundler": "^2.0.2",
16
+ "@ramonyx/compiler": "^2.0.2",
17
+ "@ramonyx/devtools": "^2.0.2",
18
+ "@ramonyx/react": "^2.0.2",
19
+ "@ramonyx/security": "^2.0.2",
20
+ "@ramonyx/server": "^2.0.2",
21
+ "@ramonyx/ssr": "^2.0.2",
22
+ "ramonyx": "^2.0.2",
23
23
  "react": "19.3.1",
24
24
  "react-dom": "19.3.1"
25
25
  },
@@ -0,0 +1,33 @@
1
+ # __APP_NAME__ — API auth starter
2
+
3
+ A production-shaped starting point for session auth:
4
+
5
+ - `POST /` — login server action (progressive enhancement; no JS required)
6
+ - `GET /api/me` — current user or `401`
7
+ - `POST /api/auth/logout` — revoke the cookie
8
+ - `src/lib/auth.server.ts` — server-side session store, password verification, demo user store
9
+ - `src/routes/middleware.ts` — security headers, CSP nonce, and CSRF (`_csrf` / `x-csrf-token`)
10
+ - CSRF cookie is not `Secure` by default so HTTP dev works; enable `csrfProtection({ secure: true })`
11
+ in production or terminate TLS with a Secure-only policy.
12
+
13
+ ## Run
14
+
15
+ ```bash
16
+ pnpm install
17
+ pnpm dev
18
+ ```
19
+
20
+ Demo credentials: `demo` / `demo-password-123`.
21
+
22
+ ## Before production
23
+
24
+ 1. Set a random `SESSION_SECRET` (32+ characters) in the environment or binding.
25
+ 2. Replace `createMemoryUserStore()` and `createMemorySessionStore()` with database/KV adapters;
26
+ memory loses users and sessions on restart.
27
+ 3. Add login rate limiting and a production auth provider (Better Auth is documented in the
28
+ integration recipes). CSRF is enabled; JSON clients must send the cookie plus `x-csrf-token`.
29
+ 4. Serve over HTTPS so the session cookie's `Secure` attribute takes effect.
30
+
31
+ The template uses a server-side session store so logout revokes the server record (not just the
32
+ cookie). It deliberately keeps auth code in `src/lib/auth.server.ts`; never import it from a client
33
+ component.
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "__APP_NAME__",
3
+ "private": true,
4
+ "type": "module",
5
+ "engines": {
6
+ "node": ">=24"
7
+ },
8
+ "scripts": {
9
+ "build": "ramonyx build",
10
+ "start": "ramonyx start",
11
+ "dev": "ramonyx dev"
12
+ },
13
+ "dependencies": {
14
+ "@ramonyx/adapter-runtime": "^2.0.2",
15
+ "@ramonyx/bundler": "^2.0.2",
16
+ "@ramonyx/compiler": "^2.0.2",
17
+ "@ramonyx/devtools": "^2.0.2",
18
+ "@ramonyx/react": "^2.0.2",
19
+ "@ramonyx/security": "^2.0.2",
20
+ "@ramonyx/server": "^2.0.2",
21
+ "@ramonyx/ssr": "^2.0.2",
22
+ "ramonyx": "^2.0.2",
23
+ "react": "19.3.1",
24
+ "react-dom": "19.3.1"
25
+ },
26
+ "devDependencies": {
27
+ "@types/react": "19.3.0",
28
+ "@types/react-dom": "19.3.0"
29
+ },
30
+ "imports": {
31
+ "#routes": "./.ramonyx/routes.ts"
32
+ }
33
+ }
@@ -0,0 +1,81 @@
1
+ // Server-only auth demo. Replace the memory stores with a database/KV adapter before production.
2
+ import {
3
+ type AuthUser,
4
+ createMemorySessionStore,
5
+ createMemoryUserStore,
6
+ createSession,
7
+ hashPassword,
8
+ verifyPassword,
9
+ } from "@ramonyx/security";
10
+ import type { EnvReader } from "ramonyx";
11
+
12
+ const users = createMemoryUserStore();
13
+ // Server-side sessions make logout/revocation real. Replace with Redis/KV/DB in production.
14
+ const sessions = createMemorySessionStore();
15
+ const DEMO_USERNAME = "demo";
16
+ const DEMO_PASSWORD = "demo-password-123";
17
+ const DEV_SECRET = "dev-only-session-secret-change-me-32-chars";
18
+
19
+ let seeded: Promise<void> | undefined;
20
+
21
+ function ensureDemoUser(): Promise<void> {
22
+ seeded ??= (async () => {
23
+ if ((await users.getByUsername(DEMO_USERNAME)) !== null) return;
24
+ await users.create({
25
+ username: DEMO_USERNAME,
26
+ passwordHash: await hashPassword(DEMO_PASSWORD),
27
+ roles: ["user"],
28
+ });
29
+ })();
30
+ return seeded;
31
+ }
32
+
33
+ function sessionSecret(env: EnvReader): string {
34
+ const secret = env.get("SESSION_SECRET");
35
+ if (secret !== undefined && secret.length >= 32) return secret;
36
+ if (env.get("NODE_ENV") === "production") {
37
+ throw new Error("SESSION_SECRET wajib diisi (minimal 32 karakter) di production");
38
+ }
39
+ return DEV_SECRET;
40
+ }
41
+
42
+ export function sessionFor(env: EnvReader) {
43
+ return createSession({
44
+ secret: sessionSecret(env),
45
+ store: sessions,
46
+ cookie: { secure: env.get("NODE_ENV") === "production" },
47
+ });
48
+ }
49
+
50
+ export async function currentUser(env: EnvReader, request: Request): Promise<AuthUser | null> {
51
+ const data = await sessionFor(env).read(request);
52
+ const userId = typeof data?.userId === "string" ? data.userId : null;
53
+ return userId === null ? null : users.getById(userId);
54
+ }
55
+
56
+ export type LoginResult =
57
+ | { readonly ok: true; readonly cookie: string; readonly user: AuthUser }
58
+ | { readonly ok: false; readonly error: string };
59
+
60
+ export async function login(env: EnvReader, formData: FormData): Promise<LoginResult> {
61
+ await ensureDemoUser();
62
+ const username = String(formData.get("username") ?? "").trim();
63
+ const password = String(formData.get("password") ?? "");
64
+ const user = await users.getByUsername(username);
65
+ if (user === null || user.passwordHash === undefined) {
66
+ return { ok: false, error: "Invalid username or password." };
67
+ }
68
+ const valid = password.length > 0 && (await verifyPassword(password, user.passwordHash));
69
+ if (!valid) {
70
+ return { ok: false, error: "Invalid username or password." };
71
+ }
72
+ const cookie = await sessionFor(env).commit({
73
+ userId: user.id,
74
+ username: user.username,
75
+ });
76
+ return { ok: true, cookie, user };
77
+ }
78
+
79
+ export async function logoutCookie(env: EnvReader, request: Request): Promise<string> {
80
+ return sessionFor(env).revoke(request);
81
+ }
@@ -0,0 +1,7 @@
1
+ import { route } from "ramonyx";
2
+ import { logoutCookie } from "../../../lib/auth.server.ts";
3
+
4
+ export const POST = route({
5
+ handler: async ({ env, request, json }) =>
6
+ json({ ok: true }, { headers: { "set-cookie": await logoutCookie(env, request) } }),
7
+ });
@@ -0,0 +1,12 @@
1
+ import { route } from "ramonyx";
2
+ import { currentUser } from "../../lib/auth.server.ts";
3
+
4
+ export const GET = route({
5
+ handler: async ({ env, request, json }) => {
6
+ const user = await currentUser(env, request);
7
+ if (user === null) {
8
+ return json({ error: "Unauthorized" }, 401);
9
+ }
10
+ return json({ user: { id: user.id, username: user.username } });
11
+ },
12
+ });
@@ -0,0 +1,6 @@
1
+ import { cspNonce, csrfProtection, securityHeaders } from "@ramonyx/security";
2
+ import type { Middleware } from "ramonyx";
3
+
4
+ // Root middleware applies to every page/API route (ADR-0061).
5
+ // CSRF is double-submit: GET issues a cookie, forms echo `_csrf`, JSON clients send x-csrf-token.
6
+ export default [securityHeaders(), cspNonce(), csrfProtection()] satisfies Middleware[];
@@ -0,0 +1,92 @@
1
+ import type { PageProps } from "@ramonyx/react";
2
+ import { CSRF_REQUEST_HEADER } from "@ramonyx/security";
3
+ import type { PageActionContext, PageLoaderContext } from "ramonyx";
4
+ import { currentUser, login } from "../lib/auth.server.ts";
5
+
6
+ export const metadata = {
7
+ title: "__APP_NAME__ — account",
8
+ description: "Session auth starter with a server action, protected API, and logout.",
9
+ };
10
+
11
+ export async function loader({ env, request }: PageLoaderContext) {
12
+ const user = await currentUser(env, request);
13
+ return {
14
+ user: user === null ? null : { id: user.id, username: user.username },
15
+ csrf: request.headers.get(CSRF_REQUEST_HEADER) ?? "",
16
+ };
17
+ }
18
+
19
+ export async function action({ env, formData }: PageActionContext) {
20
+ const result = await login(env, formData);
21
+ if (!result.ok) {
22
+ return { errors: { form: result.error } };
23
+ }
24
+ return { redirect: "/", headers: { "set-cookie": result.cookie } };
25
+ }
26
+
27
+ export default function Account({
28
+ data,
29
+ action,
30
+ }: PageProps<{ user: { id: string; username: string } | null }>) {
31
+ return (
32
+ <main className="rw-main">
33
+ <section className="rw-hero">
34
+ <div className="rw-hero-copy">
35
+ <p className="rw-kicker">API auth starter</p>
36
+ <h1>
37
+ Sessions that survive a <span>server restart boundary.</span>
38
+ </h1>
39
+ <p className="rw-lede">
40
+ Signed, HTTP-only session cookies, a password-verified server action, a protected API,
41
+ and logout. The demo user store is in-memory; replace it before deploying.
42
+ </p>
43
+
44
+ {data.user === null ? (
45
+ <form method="post" className="rw-form">
46
+ <input type="hidden" name="_csrf" value={data.csrf} />
47
+ <label htmlFor="username">Username</label>
48
+ <input id="username" name="username" autoComplete="username" defaultValue="demo" />
49
+ <label htmlFor="password">Password</label>
50
+ <input
51
+ id="password"
52
+ name="password"
53
+ type="password"
54
+ autoComplete="current-password"
55
+ defaultValue="demo-password-123"
56
+ />
57
+ <button type="submit">Sign in</button>
58
+ </form>
59
+ ) : (
60
+ <div className="rw-actions">
61
+ <p className="rw-success" role="status">
62
+ Signed in as <strong>{data.user.username}</strong>
63
+ </p>
64
+ <form method="post" action="/api/auth/logout">
65
+ <input type="hidden" name="_csrf" value={data.csrf} />
66
+ <button type="submit" className="rw-quiet">
67
+ Sign out
68
+ </button>
69
+ </form>
70
+ <a className="rw-button" href="/api/me">
71
+ Open /api/me
72
+ </a>
73
+ </div>
74
+ )}
75
+
76
+ {action?.errors?.form !== undefined ? (
77
+ <p className="rw-error" role="alert">
78
+ {action.errors.form}
79
+ </p>
80
+ ) : null}
81
+ </div>
82
+
83
+ <aside className="rw-status">
84
+ <p className="rw-label">DEMO CREDENTIALS</p>
85
+ <p className="rw-url">demo</p>
86
+ <p className="rw-status-sub">demo-password-123</p>
87
+ <p className="rw-status-sub">Set SESSION_SECRET before production.</p>
88
+ </aside>
89
+ </section>
90
+ </main>
91
+ );
92
+ }
@@ -0,0 +1,43 @@
1
+ # __APP_NAME__ — Cloudflare R2 + KV starter
2
+
3
+ Deploy the same Ramonyx route tree as a Cloudflare Worker with real R2/KV bindings.
4
+
5
+ - `GET /api/storage?key=demo` — read KV + R2
6
+ - `POST /api/storage` — `{ "key": "demo", "value": "hello" }`
7
+ - `GET /` — HTML overview
8
+
9
+ ## Configure
10
+
11
+ Edit `wrangler.toml` and replace the placeholder KV namespace ID. Create the resources first:
12
+
13
+ ```bash
14
+ npx wrangler kv namespace create KV_STORE
15
+ npx wrangler r2 bucket create __APP_NAME__-files
16
+ npx wrangler secret put STORAGE_TOKEN # random token, 16+ chars
17
+ ```
18
+
19
+ Then:
20
+
21
+ ```bash
22
+ npm install
23
+ npm run dev # Node: binding routes return 503
24
+ npm run build:edge # dist/edge/index.js
25
+ npm run deploy # build + wrangler deploy
26
+
27
+ curl -H "Authorization: Bearer $STORAGE_TOKEN" \
28
+ https://your-worker.example/api/storage?key=demo
29
+ ```
30
+
31
+ `npm` is shown for brevity. `pnpm install && pnpm dev`, `yarn && yarn dev`, and
32
+ `bun install && bun run dev` work too; use the same manager for scripts.
33
+
34
+ R2/KV are Cloudflare bindings, not Node packages. Use `wrangler dev`/Miniflare or the deployed
35
+ Worker to exercise them; plain `ramonyx dev` has no bindings. The storage API requires
36
+ `Authorization: Bearer <STORAGE_TOKEN>`; without the secret it returns 503. For local integration
37
+ tests, the repo uses the edge smoke with Miniflare.
38
+
39
+ ## Notes
40
+
41
+ - Keep the R2 bucket private unless you add a public custom domain.
42
+ - Use random object keys; do not trust a client-provided filename.
43
+ - The recipe `docs/recipes/cloudflare-r2-kv` covers upload/download streaming and limits.
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "__APP_NAME__",
3
+ "private": true,
4
+ "type": "module",
5
+ "engines": {
6
+ "node": ">=24"
7
+ },
8
+ "scripts": {
9
+ "build": "ramonyx build",
10
+ "start": "ramonyx start",
11
+ "dev": "ramonyx dev",
12
+ "build:edge": "ramonyx build --target edge",
13
+ "deploy": "ramonyx build --target edge && npx wrangler deploy"
14
+ },
15
+ "dependencies": {
16
+ "@ramonyx/adapter-runtime": "^2.0.2",
17
+ "@ramonyx/bundler": "^2.0.2",
18
+ "@ramonyx/compiler": "^2.0.2",
19
+ "@ramonyx/devtools": "^2.0.2",
20
+ "@ramonyx/react": "^2.0.2",
21
+ "@ramonyx/security": "^2.0.2",
22
+ "@ramonyx/server": "^2.0.2",
23
+ "@ramonyx/ssr": "^2.0.2",
24
+ "ramonyx": "^2.0.2",
25
+ "react": "19.3.1",
26
+ "react-dom": "19.3.1"
27
+ },
28
+ "devDependencies": {
29
+ "@types/react": "19.3.0",
30
+ "@types/react-dom": "19.3.0"
31
+ },
32
+ "imports": {
33
+ "#routes": "./.ramonyx/routes.ts"
34
+ }
35
+ }
@@ -0,0 +1,97 @@
1
+ import { type EnvReader, HttpError, route } from "ramonyx";
2
+
3
+ // Cloudflare KV + R2 bindings (ADR-0068). Node dev has no bindings → 503.
4
+ interface KVNamespaceLike {
5
+ get(key: string): Promise<string | null>;
6
+ put(key: string, value: string, options?: { readonly expirationTtl?: number }): Promise<void>;
7
+ }
8
+
9
+ interface R2ObjectLike {
10
+ readonly size?: number;
11
+ readonly httpEtag?: string;
12
+ text(): Promise<string>;
13
+ }
14
+
15
+ interface R2BucketLike {
16
+ get(key: string): Promise<R2ObjectLike | null>;
17
+ put(
18
+ key: string,
19
+ value: string,
20
+ options?: { readonly httpMetadata?: { readonly contentType?: string } },
21
+ ): Promise<unknown>;
22
+ }
23
+
24
+ function sameSecret(a: string, b: string): boolean {
25
+ let diff = a.length ^ b.length;
26
+ const length = Math.max(a.length, b.length);
27
+ for (let index = 0; index < length; index += 1) {
28
+ diff |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0);
29
+ }
30
+ return diff === 0;
31
+ }
32
+
33
+ function authorize(env: EnvReader, request: Request): void {
34
+ const expected = env.get("STORAGE_TOKEN");
35
+ if (expected === undefined || expected.length < 16) {
36
+ throw new HttpError(503, "STORAGE_TOKEN belum dikonfigurasi");
37
+ }
38
+ const header = request.headers.get("authorization") ?? "";
39
+ const supplied = header.startsWith("Bearer ") ? header.slice(7) : "";
40
+ if (!sameSecret(expected, supplied)) {
41
+ throw new HttpError(401, "Unauthorized");
42
+ }
43
+ }
44
+
45
+ function bindings(env: EnvReader): { kv: KVNamespaceLike; bucket: R2BucketLike } {
46
+ const kv = env.getRaw?.("KV_STORE") as KVNamespaceLike | undefined;
47
+ const bucket = env.getRaw?.("R2_BUCKET") as R2BucketLike | undefined;
48
+ if (kv === undefined || bucket === undefined) {
49
+ throw new HttpError(503, "Binding KV_STORE/R2_BUCKET tidak tersedia");
50
+ }
51
+ return { kv, bucket };
52
+ }
53
+
54
+ function keyFrom(value: unknown): string {
55
+ const key = String(value ?? "demo").trim();
56
+ if (key.length === 0 || key.length > 100) {
57
+ throw new HttpError(400, "key wajib 1–100 karakter");
58
+ }
59
+ return key;
60
+ }
61
+
62
+ export const GET = route({
63
+ handler: async ({ env, request, json }) => {
64
+ authorize(env, request);
65
+ const key = keyFrom(new URL(request.url).searchParams.get("key"));
66
+ const { kv, bucket } = bindings(env);
67
+ const [kvValue, object] = await Promise.all([kv.get(key), bucket.get(key)]);
68
+ if (kvValue === null && object === null) {
69
+ return json({ error: "Not Found" }, 404);
70
+ }
71
+ return json({
72
+ key,
73
+ kv: kvValue,
74
+ r2: object === null ? null : await object.text(),
75
+ size: object?.size ?? null,
76
+ etag: object?.httpEtag ?? null,
77
+ });
78
+ },
79
+ });
80
+
81
+ export const POST = route({
82
+ handler: async ({ env, request, json }) => {
83
+ authorize(env, request);
84
+ const body = (await request.json()) as { key?: unknown; value?: unknown };
85
+ const key = keyFrom(body.key);
86
+ const value = String(body.value ?? "");
87
+ if (value.length > 1024) {
88
+ return json({ error: "value maksimal 1024 karakter" }, 400);
89
+ }
90
+ const { kv, bucket } = bindings(env);
91
+ await Promise.all([
92
+ kv.put(key, value, { expirationTtl: 3600 }),
93
+ bucket.put(key, value, { httpMetadata: { contentType: "text/plain; charset=utf-8" } }),
94
+ ]);
95
+ return json({ ok: true, key }, 201);
96
+ },
97
+ });
@@ -0,0 +1,48 @@
1
+ export const metadata = {
2
+ title: "__APP_NAME__ — Cloudflare storage",
3
+ description: "Ramonyx on Cloudflare Workers with R2 object storage and KV metadata.",
4
+ };
5
+
6
+ export default function Home() {
7
+ return (
8
+ <main className="rw-main">
9
+ <section className="rw-hero">
10
+ <div className="rw-hero-copy">
11
+ <p className="rw-kicker">Cloudflare-native</p>
12
+ <h1>
13
+ Files in R2.
14
+ <br />
15
+ <span>Metadata in KV.</span>
16
+ </h1>
17
+ <p className="rw-lede">
18
+ This template ships a Worker artifact and real binding routes. The API reads and writes
19
+ both stores; the browser page is only a map of the surface.
20
+ </p>
21
+ <div className="rw-actions">
22
+ <a className="rw-button" href="/api/storage?key=demo">
23
+ Try the storage API
24
+ </a>
25
+ <a
26
+ className="rw-quiet"
27
+ href="https://ramonyx.devmoon.net/docs/recipes/cloudflare-r2-kv"
28
+ >
29
+ Read the R2 + KV recipe ↗
30
+ </a>
31
+ </div>
32
+ <pre className="rw-tree">{`GET /api/storage?key=demo
33
+ POST /api/storage
34
+ { "key": "demo", "value": "hello" }
35
+
36
+ pnpm build:edge
37
+ pnpm deploy`}</pre>
38
+ </div>
39
+ <aside className="rw-status">
40
+ <p className="rw-label">BINDINGS</p>
41
+ <p className="rw-url">KV_STORE</p>
42
+ <p className="rw-status-sub">R2_BUCKET</p>
43
+ <p className="rw-status-sub">Configure in wrangler.toml</p>
44
+ </aside>
45
+ </section>
46
+ </main>
47
+ );
48
+ }
@@ -0,0 +1,15 @@
1
+ name = "__APP_NAME__"
2
+ main = "dist/edge/index.js"
3
+ compatibility_date = "2026-10-18"
4
+
5
+ # Static client assets produced by `ramonyx build --target edge`.
6
+ [assets]
7
+ directory = "dist/client"
8
+
9
+ [[kv_namespaces]]
10
+ binding = "KV_STORE"
11
+ id = "replace-with-your-kv-namespace-id"
12
+
13
+ [[r2_buckets]]
14
+ binding = "R2_BUCKET"
15
+ bucket_name = "__APP_NAME__-files"
@@ -0,0 +1,21 @@
1
+ # __APP_NAME__ — docs/blog starter
2
+
3
+ A content-first Ramonyx app with a static export:
4
+
5
+ - `/` — post index
6
+ - `/blog/[slug]` — prerendered posts via `getStaticPaths()`
7
+ - `pnpm build:static` — HTML into `dist/static`, ready for Pages, Workers Assets, or any static host
8
+
9
+ ## Run
10
+
11
+ ```bash
12
+ pnpm install
13
+ pnpm dev
14
+ pnpm build:static
15
+ ```
16
+
17
+ Add posts in `src/data/posts.ts`; the dynamic route and static path list update together. Replace
18
+ the in-memory post list with markdown/files or a CMS when you need real content workflow.
19
+
20
+ The docs site (`apps/docs` in the repository) is a larger example of the same SSR + static export
21
+ model.
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "__APP_NAME__",
3
+ "private": true,
4
+ "type": "module",
5
+ "engines": {
6
+ "node": ">=24"
7
+ },
8
+ "scripts": {
9
+ "build": "ramonyx build",
10
+ "start": "ramonyx start",
11
+ "dev": "ramonyx dev",
12
+ "build:static": "ramonyx build --static"
13
+ },
14
+ "dependencies": {
15
+ "@ramonyx/adapter-runtime": "^2.0.2",
16
+ "@ramonyx/bundler": "^2.0.2",
17
+ "@ramonyx/compiler": "^2.0.2",
18
+ "@ramonyx/devtools": "^2.0.2",
19
+ "@ramonyx/react": "^2.0.2",
20
+ "@ramonyx/security": "^2.0.2",
21
+ "@ramonyx/server": "^2.0.2",
22
+ "@ramonyx/ssr": "^2.0.2",
23
+ "ramonyx": "^2.0.2",
24
+ "react": "19.3.1",
25
+ "react-dom": "19.3.1"
26
+ },
27
+ "devDependencies": {
28
+ "@types/react": "19.3.0",
29
+ "@types/react-dom": "19.3.0"
30
+ },
31
+ "imports": {
32
+ "#routes": "./.ramonyx/routes.ts"
33
+ }
34
+ }
@@ -0,0 +1,45 @@
1
+ export interface Post {
2
+ readonly slug: string;
3
+ readonly title: string;
4
+ readonly summary: string;
5
+ readonly body: readonly string[];
6
+ readonly published: string;
7
+ }
8
+
9
+ export const posts: readonly Post[] = [
10
+ {
11
+ slug: "hello-ramonyx",
12
+ title: "A content site with no client runtime",
13
+ summary: "Every page is server-rendered HTML that can also be exported statically.",
14
+ published: "2026-10-24",
15
+ body: [
16
+ "This starter renders a blog index and a dynamic post route on the server. No client-side data fetching is required for reading.",
17
+ "Run `pnpm build:static` to prerender every post into dist/static. The same route tree also runs with `pnpm build && pnpm start` for SSR.",
18
+ ],
19
+ },
20
+ {
21
+ slug: "markdown-next",
22
+ title: "Replace the demo data with Markdown",
23
+ summary:
24
+ "The post list is deliberately small and typed so a file/markdown source can replace it.",
25
+ published: "2026-10-24",
26
+ body: [
27
+ "`src/data/posts.ts` is the only content source. Swap it for a filesystem loader or a CMS client and keep the page contract.",
28
+ "Keep slugs validated and escape rendered HTML; never interpolate untrusted Markdown output without sanitizing it.",
29
+ ],
30
+ },
31
+ {
32
+ slug: "deploy-anywhere",
33
+ title: "Deploy to static hosting or a Node server",
34
+ summary: "The same pages can be exported, served by Ramonyx, or bundled for an edge target.",
35
+ published: "2026-10-24",
36
+ body: [
37
+ "Static export produces HTML and hashed assets, so it works on Pages, Workers Assets, or any object store with a CDN.",
38
+ "For dynamic features later, switch to the Node or edge target and add API routes without changing the blog URLs.",
39
+ ],
40
+ },
41
+ ];
42
+
43
+ export function findPost(slug: string): Post | null {
44
+ return posts.find((post) => post.slug === slug) ?? null;
45
+ }
@@ -0,0 +1,41 @@
1
+ import type { PageProps } from "@ramonyx/react";
2
+ import { HttpError, type PageLoaderContext } from "ramonyx";
3
+ import { findPost, type Post, posts } from "../../../data/posts.ts";
4
+
5
+ export const metadata = {
6
+ title: "__APP_NAME__ — post",
7
+ description: "A prerendered Ramonyx blog post.",
8
+ };
9
+
10
+ export function getStaticPaths() {
11
+ return posts.map((post) => ({ slug: post.slug }));
12
+ }
13
+
14
+ export function loader({ params }: PageLoaderContext<{ slug: string }>) {
15
+ const post = findPost(params.slug);
16
+ if (post === null) {
17
+ throw new HttpError(404, "Post not found");
18
+ }
19
+ return { post };
20
+ }
21
+
22
+ export default function PostPage({ data }: PageProps<{ post: Post }>) {
23
+ return (
24
+ <main className="rw-main">
25
+ <article className="rw-section" style={{ maxWidth: "72ch" }}>
26
+ <p className="rw-label">{data.post.published}</p>
27
+ <h1>{data.post.title}</h1>
28
+ {data.post.body.map((paragraph) => (
29
+ <p key={paragraph} className="rw-lede">
30
+ {paragraph}
31
+ </p>
32
+ ))}
33
+ <p>
34
+ <a className="rw-quiet" href="/">
35
+ ← All notes
36
+ </a>
37
+ </p>
38
+ </article>
39
+ </main>
40
+ );
41
+ }
@@ -0,0 +1,47 @@
1
+ import { posts } from "../data/posts.ts";
2
+
3
+ export const metadata = {
4
+ title: "__APP_NAME__ — notes",
5
+ description: "A static-first blog and docs starter built with Ramonyx.",
6
+ };
7
+
8
+ export default function Home() {
9
+ return (
10
+ <main className="rw-main">
11
+ <section className="rw-hero">
12
+ <div className="rw-hero-copy">
13
+ <p className="rw-kicker">Docs / blog starter</p>
14
+ <h1>
15
+ Write once.
16
+ <br />
17
+ <span>Render as HTML.</span>
18
+ </h1>
19
+ <p className="rw-lede">
20
+ Typed posts, a dynamic route, and a static export. The reading experience needs no
21
+ client JavaScript.
22
+ </p>
23
+ <div className="rw-actions">
24
+ <a className="rw-button" href="/blog/hello-ramonyx">
25
+ Read the first post
26
+ </a>
27
+ <a className="rw-quiet" href="https://ramonyx.devmoon.net/docs/deployment">
28
+ Deployment guide ↗
29
+ </a>
30
+ </div>
31
+ <div className="rw-steps">
32
+ {posts.map((post, index) => (
33
+ <article className="rw-step" key={post.slug}>
34
+ <span className="rw-step-index">{String(index + 1).padStart(2, "0")}</span>
35
+ <h2>
36
+ <a href={`/blog/${post.slug}`}>{post.title}</a>
37
+ </h2>
38
+ <p>{post.summary}</p>
39
+ <code>{post.published}</code>
40
+ </article>
41
+ ))}
42
+ </div>
43
+ </div>
44
+ </section>
45
+ </main>
46
+ );
47
+ }