fastscript 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.
Files changed (96) hide show
  1. package/.github/workflows/ci.yml +17 -0
  2. package/CHANGELOG.md +5 -0
  3. package/Dockerfile +9 -0
  4. package/LICENSE +21 -0
  5. package/README.md +102 -0
  6. package/app/api/auth.js +10 -0
  7. package/app/api/hello.js +3 -0
  8. package/app/api/upload.js +9 -0
  9. package/app/api/webhook.js +10 -0
  10. package/app/db/migrations/001_init.js +6 -0
  11. package/app/db/seed.js +5 -0
  12. package/app/env.schema.js +6 -0
  13. package/app/middleware.fs +7 -0
  14. package/app/pages/404.fs +3 -0
  15. package/app/pages/_layout.fs +17 -0
  16. package/app/pages/benchmarks.fs +15 -0
  17. package/app/pages/docs/index.fs +16 -0
  18. package/app/pages/index.fs +22 -0
  19. package/app/pages/private.fs +12 -0
  20. package/app/pages/showcase.fs +14 -0
  21. package/app/styles.css +14 -0
  22. package/docs/AI_CONTEXT_PACK_V1.md +25 -0
  23. package/docs/DEPLOY_GUIDE.md +14 -0
  24. package/docs/INCIDENT_PLAYBOOK.md +18 -0
  25. package/docs/INTEROP_RULES.md +7 -0
  26. package/docs/PLUGIN_API_CONTRACT.md +22 -0
  27. package/ecosystem.config.cjs +1 -0
  28. package/examples/fullstack/README.md +10 -0
  29. package/examples/fullstack/app/api/orders.js +8 -0
  30. package/examples/fullstack/app/db/migrations/001_init.js +3 -0
  31. package/examples/fullstack/app/db/seed.js +3 -0
  32. package/examples/fullstack/app/jobs/send-order-email.js +4 -0
  33. package/examples/fullstack/app/pages/_layout.fs +1 -0
  34. package/examples/fullstack/app/pages/index.fs +3 -0
  35. package/examples/startup-mvp/README.md +8 -0
  36. package/examples/startup-mvp/app/api/cart.js +9 -0
  37. package/examples/startup-mvp/app/api/checkout.js +8 -0
  38. package/examples/startup-mvp/app/db/migrations/001_products.js +6 -0
  39. package/examples/startup-mvp/app/jobs/send-receipt.js +4 -0
  40. package/examples/startup-mvp/app/pages/_layout.fs +3 -0
  41. package/examples/startup-mvp/app/pages/dashboard/index.fs +9 -0
  42. package/examples/startup-mvp/app/pages/index.fs +18 -0
  43. package/package.json +50 -0
  44. package/scripts/bench-report.mjs +36 -0
  45. package/scripts/release.mjs +21 -0
  46. package/scripts/smoke-dev.mjs +78 -0
  47. package/scripts/smoke-start.mjs +41 -0
  48. package/scripts/test-auth.mjs +26 -0
  49. package/scripts/test-db.mjs +31 -0
  50. package/scripts/test-jobs.mjs +15 -0
  51. package/scripts/test-middleware.mjs +37 -0
  52. package/scripts/test-roundtrip.mjs +44 -0
  53. package/scripts/test-validation.mjs +17 -0
  54. package/scripts/test-webhook-storage.mjs +22 -0
  55. package/spec/FASTSCRIPT_1000_BUILD_LIST.md +1090 -0
  56. package/src/auth-flows.mjs +29 -0
  57. package/src/auth.mjs +115 -0
  58. package/src/bench.mjs +46 -0
  59. package/src/build.mjs +222 -0
  60. package/src/cache.mjs +58 -0
  61. package/src/check.mjs +22 -0
  62. package/src/cli.mjs +71 -0
  63. package/src/compat.mjs +122 -0
  64. package/src/create.mjs +190 -0
  65. package/src/db-cli.mjs +45 -0
  66. package/src/db-postgres.mjs +40 -0
  67. package/src/db.mjs +103 -0
  68. package/src/deploy.mjs +65 -0
  69. package/src/dev.mjs +5 -0
  70. package/src/env.mjs +89 -0
  71. package/src/export.mjs +83 -0
  72. package/src/fs-normalize.mjs +100 -0
  73. package/src/interop.mjs +16 -0
  74. package/src/jobs.mjs +127 -0
  75. package/src/logger.mjs +27 -0
  76. package/src/middleware.mjs +14 -0
  77. package/src/migrate.mjs +81 -0
  78. package/src/observability.mjs +21 -0
  79. package/src/security.mjs +55 -0
  80. package/src/server-runtime.mjs +339 -0
  81. package/src/start.mjs +10 -0
  82. package/src/storage.mjs +56 -0
  83. package/src/validate.mjs +18 -0
  84. package/src/validation.mjs +79 -0
  85. package/src/webhook.mjs +71 -0
  86. package/src/worker.mjs +5 -0
  87. package/vercel.json +15 -0
  88. package/vscode/fastscript-language/README.md +12 -0
  89. package/vscode/fastscript-language/extension.js +24 -0
  90. package/vscode/fastscript-language/language-configuration.json +6 -0
  91. package/vscode/fastscript-language/lsp/server.cjs +27 -0
  92. package/vscode/fastscript-language/lsp/smoke-test.cjs +1 -0
  93. package/vscode/fastscript-language/package.json +36 -0
  94. package/vscode/fastscript-language/snippets/fastscript.code-snippets +24 -0
  95. package/vscode/fastscript-language/syntaxes/fastscript.tmLanguage.json +21 -0
  96. package/wrangler.toml +5 -0
package/src/create.mjs ADDED
@@ -0,0 +1,190 @@
1
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ function ensureDir(path) {
5
+ if (!existsSync(path)) mkdirSync(path, { recursive: true });
6
+ }
7
+
8
+ export async function createApp(target = "app") {
9
+ const root = process.cwd();
10
+ const appRoot = join(root, target);
11
+ const pagesRoot = join(appRoot, "pages");
12
+
13
+ ensureDir(pagesRoot);
14
+ ensureDir(join(appRoot, "api"));
15
+ ensureDir(join(appRoot, "db", "migrations"));
16
+
17
+ const files = [
18
+ {
19
+ path: join(pagesRoot, "index.fs"),
20
+ content: `export default function Home() {
21
+ ~title = "Build full-stack apps with plain JavaScript."
22
+ return \`
23
+ <section class="hero">
24
+ <p class="eyebrow">FastScript</p>
25
+ <h1>\${title}</h1>
26
+ <p>Simple syntax, fast compiler pipeline, deploy anywhere.</p>
27
+ <button data-fs-counter>Counter: <span data-fs-counter-value>0</span></button>
28
+ </section>
29
+ \`;
30
+ }
31
+
32
+ export function hydrate({ root }) {
33
+ const btn = root.querySelector("[data-fs-counter]");
34
+ const value = root.querySelector("[data-fs-counter-value]");
35
+ if (!btn || !value) return;
36
+ let n = Number(value.textContent || "0");
37
+ btn.addEventListener("click", () => {
38
+ n += 1;
39
+ value.textContent = String(n);
40
+ });
41
+ }
42
+ `,
43
+ },
44
+ {
45
+ path: join(pagesRoot, "_layout.fs"),
46
+ content: `export default function Layout({ content, pathname, user }) {
47
+ return \`
48
+ <header class="nav">
49
+ <a href="/">FastScript</a>
50
+ <nav>
51
+ <a href="/">Home</a>
52
+ <a href="/private">Private</a>
53
+ </nav>
54
+ <small>\${user ? "Signed in" : "Guest"}</small>
55
+ </header>
56
+ <main class="page">\${content}</main>
57
+ <footer class="footer">Built with FastScript</footer>
58
+ \`;
59
+ }
60
+ `,
61
+ },
62
+ {
63
+ path: join(pagesRoot, "404.fs"),
64
+ content: `export default function NotFound() {
65
+ return \`<section><h1>404</h1><p>Page not found.</p></section>\`;
66
+ }
67
+ `,
68
+ },
69
+ {
70
+ path: join(pagesRoot, "private.fs"),
71
+ content: `export default function PrivatePage({ user }) {
72
+ return \`<section><h1>Private</h1><p>Hello \${user?.name ?? "anonymous"}</p></section>\`;
73
+ }
74
+
75
+ export async function GET(ctx) {
76
+ try {
77
+ const user = ctx.auth.requireUser();
78
+ return ctx.helpers.json({ ok: true, user });
79
+ } catch {
80
+ return ctx.helpers.redirect("/");
81
+ }
82
+ }
83
+ `,
84
+ },
85
+ {
86
+ path: join(appRoot, "styles.css"),
87
+ content: `:root { color-scheme: dark; }
88
+ * { box-sizing: border-box; }
89
+ body { margin: 0; font: 16px/1.6 ui-sans-serif, system-ui; background: #050505; color: #fff; }
90
+ .nav { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 16px 24px; border-bottom: 1px solid #1f1f1f; }
91
+ .nav a { color: #d3d3ff; text-decoration: none; margin-right: 12px; }
92
+ .page { max-width: 980px; margin: 0 auto; padding: 40px 24px; }
93
+ .hero h1 { font-size: clamp(2rem, 6vw, 4rem); line-height: 1.05; margin: 0 0 10px; }
94
+ .eyebrow { color: #9f92ff; font-size: 12px; text-transform: uppercase; letter-spacing: .12em; }
95
+ .hero button { padding: 8px 12px; border: 1px solid #333; background: #0f0f0f; color: #fff; border-radius: 8px; cursor: pointer; }
96
+ .footer { border-top: 1px solid #1f1f1f; padding: 24px; color: #8a8a8a; }
97
+ `,
98
+ },
99
+ {
100
+ path: join(appRoot, "api", "hello.js"),
101
+ content: `export async function GET() {
102
+ return { status: 200, json: { ok: true, message: "Hello from FastScript API" } };
103
+ }
104
+ `,
105
+ },
106
+ {
107
+ path: join(appRoot, "api", "auth.js"),
108
+ content: `export const schemas = {
109
+ POST: { name: "string?" }
110
+ };
111
+
112
+ export async function POST(ctx) {
113
+ const body = await ctx.input.validateBody(schemas.POST);
114
+ const user = { id: "u_1", name: body.name || "Dev" };
115
+ ctx.auth.login(user);
116
+ return ctx.helpers.json({ ok: true, user });
117
+ }
118
+
119
+ export async function DELETE(ctx) {
120
+ ctx.auth.logout();
121
+ return ctx.helpers.json({ ok: true });
122
+ }
123
+ `,
124
+ },
125
+ {
126
+ path: join(appRoot, "api", "upload.js"),
127
+ content: `export const schemas = {
128
+ POST: { key: "string", content: "string" }
129
+ };
130
+
131
+ export async function POST(ctx) {
132
+ const body = await ctx.input.validateBody(schemas.POST);
133
+ const put = ctx.storage.put(body.key, Buffer.from(body.content, "utf8"));
134
+ return ctx.helpers.json({ ok: true, ...put, url: ctx.storage.url(body.key) });
135
+ }
136
+ `,
137
+ },
138
+ {
139
+ path: join(appRoot, "api", "webhook.js"),
140
+ content: `import { verifyWebhookRequest } from "../../src/webhook.mjs";
141
+
142
+ export async function POST(ctx) {
143
+ const result = await verifyWebhookRequest(ctx.req, {
144
+ secret: process.env.WEBHOOK_SECRET || "dev-secret",
145
+ replayDir: ".fastscript"
146
+ });
147
+ if (!result.ok) return ctx.helpers.json({ ok: false, reason: result.reason }, 401);
148
+ return ctx.helpers.json({ ok: true });
149
+ }
150
+ `,
151
+ },
152
+ {
153
+ path: join(appRoot, "middleware.fs"),
154
+ content: `export async function middleware(ctx, next) {
155
+ const protectedRoute = ctx.pathname.startsWith("/private");
156
+ if (protectedRoute && !ctx.user) {
157
+ return ctx.helpers.redirect("/");
158
+ }
159
+ return next();
160
+ }
161
+ `,
162
+ },
163
+ {
164
+ path: join(appRoot, "db", "migrations", "001_init.js"),
165
+ content: `export async function up(db) {
166
+ const users = db.collection("users");
167
+ if (!users.get("u_1")) {
168
+ users.set("u_1", { id: "u_1", name: "Dev" });
169
+ }
170
+ }
171
+ `,
172
+ },
173
+ {
174
+ path: join(appRoot, "db", "seed.js"),
175
+ content: `export async function seed(db) {
176
+ db.transaction((tx) => {
177
+ tx.collection("posts").set("hello", { id: "hello", title: "First Post", published: true });
178
+ });
179
+ }
180
+ `,
181
+ },
182
+ ];
183
+
184
+ for (const file of files) {
185
+ if (!existsSync(file.path)) writeFileSync(file.path, file.content, "utf8");
186
+ }
187
+
188
+ const count = readdirSync(pagesRoot).length;
189
+ console.log(`created ${target} with ${count} page file(s)`);
190
+ }
package/src/db-cli.mjs ADDED
@@ -0,0 +1,45 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { createFileDatabase } from "./db.mjs";
5
+
6
+ const MIGRATIONS_DIR = resolve("app/db/migrations");
7
+ const SEED_FILE = resolve("app/db/seed.js");
8
+
9
+ async function importFresh(path) {
10
+ return import(`${pathToFileURL(path).href}?t=${Date.now()}`);
11
+ }
12
+
13
+ export async function runDbMigrate() {
14
+ const db = createFileDatabase({ dir: ".fastscript", name: "appdb" });
15
+ if (!existsSync(MIGRATIONS_DIR)) {
16
+ console.log("db migrate: no app/db/migrations directory");
17
+ return;
18
+ }
19
+ const files = readdirSync(MIGRATIONS_DIR).filter((f) => /\.(js|mjs|cjs)$/.test(f)).sort();
20
+ let count = 0;
21
+ for (const file of files) {
22
+ const mod = await importFresh(join(MIGRATIONS_DIR, file));
23
+ const fn = mod.up ?? mod.default;
24
+ if (typeof fn === "function") {
25
+ await fn(db);
26
+ count += 1;
27
+ console.log(`db migrate: applied ${file}`);
28
+ }
29
+ }
30
+ console.log(`db migrate complete: ${count} migration(s)`);
31
+ }
32
+
33
+ export async function runDbSeed() {
34
+ const db = createFileDatabase({ dir: ".fastscript", name: "appdb" });
35
+ if (!existsSync(SEED_FILE)) {
36
+ console.log("db seed: no app/db/seed.js file");
37
+ return;
38
+ }
39
+ const mod = await importFresh(SEED_FILE);
40
+ const fn = mod.seed ?? mod.default;
41
+ if (typeof fn !== "function") throw new Error("app/db/seed.js must export seed(db) or default(db)");
42
+ await fn(db);
43
+ console.log("db seed complete");
44
+ }
45
+
@@ -0,0 +1,40 @@
1
+ export async function createPostgresAdapter({ connectionString = process.env.DATABASE_URL } = {}) {
2
+ const { Client } = await import("pg");
3
+ const client = new Client({ connectionString });
4
+ await client.connect();
5
+
6
+ return {
7
+ async query(sql, params = []) {
8
+ const res = await client.query(sql, params);
9
+ return res.rows;
10
+ },
11
+ async transaction(fn) {
12
+ await client.query("BEGIN");
13
+ try {
14
+ const out = await fn({ query: (sql, params = []) => client.query(sql, params).then((r) => r.rows) });
15
+ await client.query("COMMIT");
16
+ return out;
17
+ } catch (e) {
18
+ await client.query("ROLLBACK");
19
+ throw e;
20
+ }
21
+ },
22
+ async migrate(lockId = 4839201, migrations = []) {
23
+ await client.query("SELECT pg_advisory_lock($1)", [lockId]);
24
+ try {
25
+ await client.query("CREATE TABLE IF NOT EXISTS fs_migrations (id text primary key, applied_at timestamptz not null default now())");
26
+ const done = new Set((await client.query("SELECT id FROM fs_migrations")).rows.map((r) => r.id));
27
+ for (const m of migrations) {
28
+ if (done.has(m.id)) continue;
29
+ await m.up({ query: (sql, params = []) => client.query(sql, params).then((r) => r.rows) });
30
+ await client.query("INSERT INTO fs_migrations(id) VALUES($1)", [m.id]);
31
+ }
32
+ } finally {
33
+ await client.query("SELECT pg_advisory_unlock($1)", [lockId]);
34
+ }
35
+ },
36
+ async close() {
37
+ await client.end();
38
+ },
39
+ };
40
+ }
package/src/db.mjs ADDED
@@ -0,0 +1,103 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ function readJson(path, fallback) {
5
+ if (!existsSync(path)) return fallback;
6
+ try {
7
+ return JSON.parse(readFileSync(path, "utf8"));
8
+ } catch {
9
+ return fallback;
10
+ }
11
+ }
12
+
13
+ function writeJson(path, value) {
14
+ writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
15
+ }
16
+
17
+ export function createFileDatabase({ dir = ".fastscript", name = "appdb" } = {}) {
18
+ mkdirSync(dir, { recursive: true });
19
+ const path = join(dir, `${name}.json`);
20
+ const state = readJson(path, { collections: {} });
21
+
22
+ function persist() {
23
+ writeJson(path, state);
24
+ }
25
+
26
+ function ensureCollection(collection) {
27
+ if (!state.collections[collection]) state.collections[collection] = {};
28
+ return state.collections[collection];
29
+ }
30
+
31
+ return {
32
+ collection(name) {
33
+ return {
34
+ get(id) {
35
+ const col = ensureCollection(name);
36
+ return col[id] ?? null;
37
+ },
38
+ set(id, value) {
39
+ const col = ensureCollection(name);
40
+ col[id] = value;
41
+ persist();
42
+ return col[id];
43
+ },
44
+ delete(id) {
45
+ const col = ensureCollection(name);
46
+ delete col[id];
47
+ persist();
48
+ },
49
+ all() {
50
+ const col = ensureCollection(name);
51
+ return Object.values(col);
52
+ },
53
+ upsert(id, updater) {
54
+ const col = ensureCollection(name);
55
+ const prev = col[id] ?? null;
56
+ const next = typeof updater === "function" ? updater(prev) : updater;
57
+ col[id] = next;
58
+ persist();
59
+ return next;
60
+ },
61
+ first(predicate) {
62
+ const col = ensureCollection(name);
63
+ return Object.values(col).find(predicate) ?? null;
64
+ },
65
+ where(filters) {
66
+ const col = ensureCollection(name);
67
+ if (typeof filters === "function") return Object.values(col).filter(filters);
68
+ return Object.values(col).filter((row) =>
69
+ Object.entries(filters || {}).every(([k, v]) => row?.[k] === v),
70
+ );
71
+ },
72
+ };
73
+ },
74
+ transaction(fn) {
75
+ const snapshot = JSON.stringify(state);
76
+ try {
77
+ const res = fn(this);
78
+ persist();
79
+ return res;
80
+ } catch (error) {
81
+ const rollback = JSON.parse(snapshot);
82
+ state.collections = rollback.collections ?? {};
83
+ persist();
84
+ throw error;
85
+ }
86
+ },
87
+ query(collection, predicate) {
88
+ const col = ensureCollection(collection);
89
+ return Object.values(col).filter(predicate);
90
+ },
91
+ first(collection, predicate) {
92
+ const col = ensureCollection(collection);
93
+ return Object.values(col).find(predicate) ?? null;
94
+ },
95
+ where(collection, filters) {
96
+ const col = ensureCollection(collection);
97
+ if (typeof filters === "function") return Object.values(col).filter(filters);
98
+ return Object.values(col).filter((row) =>
99
+ Object.entries(filters || {}).every(([k, v]) => row?.[k] === v),
100
+ );
101
+ },
102
+ };
103
+ }
package/src/deploy.mjs ADDED
@@ -0,0 +1,65 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { resolve, join } from "node:path";
3
+
4
+ export async function runDeploy(args = []) {
5
+ let target = "node";
6
+ for (let i = 0; i < args.length; i += 1) {
7
+ if (args[i] === "--target") target = (args[i + 1] || "node").toLowerCase();
8
+ }
9
+
10
+ const root = resolve(process.cwd());
11
+
12
+ if (target === "node" || target === "pm2") {
13
+ const file = join(root, "ecosystem.config.cjs");
14
+ writeFileSync(
15
+ file,
16
+ `module.exports = { apps: [{ name: "fastscript-app", script: "node", args: "./src/cli.mjs start", env: { NODE_ENV: "production", PORT: 4173 } }] };\n`,
17
+ "utf8",
18
+ );
19
+ writeFileSync(
20
+ join(root, "Dockerfile"),
21
+ `FROM node:20-alpine\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --omit=dev\nCOPY . .\nRUN npm run build\nENV NODE_ENV=production\nEXPOSE 4173\nCMD [\"node\",\"./src/cli.mjs\",\"start\"]\n`,
22
+ "utf8",
23
+ );
24
+ console.log("deploy adapter ready: ecosystem.config.cjs (PM2/Node)");
25
+ return;
26
+ }
27
+
28
+ if (target === "vercel") {
29
+ const file = join(root, "vercel.json");
30
+ writeFileSync(
31
+ file,
32
+ JSON.stringify(
33
+ {
34
+ version: 2,
35
+ builds: [{ src: "package.json", use: "@vercel/node" }],
36
+ routes: [{ src: "/(.*)", dest: "/src/cli.mjs" }],
37
+ },
38
+ null,
39
+ 2,
40
+ ),
41
+ "utf8",
42
+ );
43
+ console.log("deploy adapter ready: vercel.json");
44
+ return;
45
+ }
46
+
47
+ if (target === "cloudflare") {
48
+ const wrangler = join(root, "wrangler.toml");
49
+ writeFileSync(
50
+ wrangler,
51
+ `name = "fastscript-app"\nmain = "dist/worker.js"\ncompatibility_date = "2026-01-01"\n[assets]\ndirectory = "dist"\n`,
52
+ "utf8",
53
+ );
54
+ mkdirSync(join(root, "dist"), { recursive: true });
55
+ writeFileSync(
56
+ join(root, "dist", "worker.js"),
57
+ `export default { async fetch(req, env) { return env.ASSETS.fetch(req); } };\n`,
58
+ "utf8",
59
+ );
60
+ console.log("deploy adapter ready: wrangler.toml + dist/worker.js");
61
+ return;
62
+ }
63
+
64
+ throw new Error(`Unknown deploy target: ${target}. Use node|pm2|vercel|cloudflare`);
65
+ }
package/src/dev.mjs ADDED
@@ -0,0 +1,5 @@
1
+ import { runServer } from "./server-runtime.mjs";
2
+
3
+ export async function runDev() {
4
+ await runServer({ mode: "development", watchMode: true, buildOnStart: true, port: 4173 });
5
+ }
package/src/env.mjs ADDED
@@ -0,0 +1,89 @@
1
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ function parseDotEnv(raw) {
6
+ const out = {};
7
+ for (const line of raw.split(/\r?\n/)) {
8
+ const trimmed = line.trim();
9
+ if (!trimmed || trimmed.startsWith("#")) continue;
10
+ const idx = trimmed.indexOf("=");
11
+ if (idx <= 0) continue;
12
+ const key = trimmed.slice(0, idx).trim();
13
+ let val = trimmed.slice(idx + 1).trim();
14
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
15
+ out[key] = val;
16
+ }
17
+ return out;
18
+ }
19
+
20
+ export function loadEnv({ root = process.cwd(), mode = process.env.NODE_ENV || "development" } = {}) {
21
+ const files = [".env", `.env.${mode}`, mode === "production" ? ".env.local" : null].filter(Boolean);
22
+ const merged = {};
23
+ for (const file of files) {
24
+ const path = join(root, file);
25
+ if (!existsSync(path)) continue;
26
+ Object.assign(merged, parseDotEnv(readFileSync(path, "utf8")));
27
+ }
28
+ for (const [k, v] of Object.entries(merged)) if (process.env[k] === undefined) process.env[k] = v;
29
+ return { mode, values: merged };
30
+ }
31
+
32
+ export function validateEnv(schema = {}, env = process.env) {
33
+ const errors = [];
34
+ const out = {};
35
+ for (const [key, ruleRaw] of Object.entries(schema || {})) {
36
+ const rule = typeof ruleRaw === "string" ? { type: ruleRaw } : { ...(ruleRaw || {}) };
37
+ const type = String(rule.type || "string").replace(/\?$/, "");
38
+ const optional = String(rule.type || "").endsWith("?") || rule.optional === true;
39
+ const value = env[key];
40
+ if (value === undefined || value === "") {
41
+ if (!optional) errors.push(`${key} is required`);
42
+ continue;
43
+ }
44
+ if (type === "int") {
45
+ const n = Number(value);
46
+ if (!Number.isInteger(n)) errors.push(`${key} must be int`); else out[key] = n;
47
+ continue;
48
+ }
49
+ if (type === "float" || type === "number") {
50
+ const n = Number(value);
51
+ if (!Number.isFinite(n)) errors.push(`${key} must be number`); else out[key] = n;
52
+ continue;
53
+ }
54
+ if (type === "bool" || type === "boolean") {
55
+ if (!["true", "false", true, false].includes(value)) errors.push(`${key} must be boolean`);
56
+ else out[key] = value === true || value === "true";
57
+ continue;
58
+ }
59
+ out[key] = String(value);
60
+ }
61
+ if (errors.length) {
62
+ const error = new Error(`Env validation failed: ${errors.join("; ")}`);
63
+ error.status = 500;
64
+ error.details = errors;
65
+ throw error;
66
+ }
67
+ return out;
68
+ }
69
+
70
+ export async function validateAppEnv({ root = process.cwd() } = {}) {
71
+ const schemaPath = join(root, "app", "env.schema.js");
72
+ if (!existsSync(schemaPath)) return null;
73
+ const mod = await import(`${pathToFileURL(schemaPath).href}?t=${Date.now()}`);
74
+ const schema = mod.schema || mod.default || {};
75
+ return validateEnv(schema, process.env);
76
+ }
77
+
78
+ export function ensureEnvExample() {
79
+ const example = join(process.cwd(), ".env.example");
80
+ if (existsSync(example)) return;
81
+ writeFileSync(example, "# FastScript env template\nSESSION_SECRET=change_me\n", "utf8");
82
+ }
83
+
84
+ export function appendEnvIfMissing(key, value) {
85
+ const path = join(process.cwd(), ".env");
86
+ const current = existsSync(path) ? readFileSync(path, "utf8") : "";
87
+ if (current.includes(`${key}=`)) return;
88
+ appendFileSync(path, `${key}=${value}\n`, "utf8");
89
+ }
package/src/export.mjs ADDED
@@ -0,0 +1,83 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, extname, join, relative, resolve } from "node:path";
3
+ import { normalizeFastScript } from "./fs-normalize.mjs";
4
+
5
+ const VALID_TARGETS = new Set(["js", "ts"]);
6
+
7
+ function walk(dir) {
8
+ const out = [];
9
+ if (!existsSync(dir)) return out;
10
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
11
+ const full = join(dir, entry.name);
12
+ if (entry.isDirectory()) out.push(...walk(full));
13
+ else if (entry.isFile()) out.push(full);
14
+ }
15
+ return out;
16
+ }
17
+
18
+ function rewriteImportsForTarget(source, to) {
19
+ let out = source
20
+ .replace(/from\s+["'](\.\/[^"']+)\.fs["']/g, `from "$1.${to}"`)
21
+ .replace(/from\s+["'](\.\.\/[^"']+)\.fs["']/g, `from "$1.${to}"`)
22
+ .replace(/import\(\s*["'](\.\/[^"']+)\.fs["']\s*\)/g, `import("$1.${to}")`)
23
+ .replace(/import\(\s*["'](\.\.\/[^"']+)\.fs["']\s*\)/g, `import("$1.${to}")`);
24
+ out = out
25
+ .replace(/require\(\s*["'](\.\/[^"']+)\.fs["']\s*\)/g, `require("$1.${to}")`)
26
+ .replace(/require\(\s*["'](\.\.\/[^"']+)\.fs["']\s*\)/g, `require("$1.${to}")`);
27
+ return out;
28
+ }
29
+
30
+ function toTargetPath(file, to) {
31
+ if (extname(file) === ".fs") return file.replace(/\.fs$/, `.${to}`);
32
+ return file;
33
+ }
34
+
35
+ export async function runExport(args = []) {
36
+ let to = "js";
37
+ let out = "exported-app";
38
+
39
+ for (let i = 0; i < args.length; i += 1) {
40
+ const arg = args[i];
41
+ if (arg === "--to") to = (args[i + 1] || "").toLowerCase();
42
+ if (arg === "--out") out = args[i + 1] || out;
43
+ }
44
+
45
+ if (!VALID_TARGETS.has(to)) {
46
+ throw new Error('Invalid export target. Use: fastscript export --to js|ts');
47
+ }
48
+
49
+ const appDir = resolve("app");
50
+ const outDir = resolve(out);
51
+ if (!existsSync(appDir)) throw new Error("Missing app directory.");
52
+
53
+ rmSync(outDir, { recursive: true, force: true });
54
+ mkdirSync(outDir, { recursive: true });
55
+
56
+ // Copy non-page assets/directories first.
57
+ for (const entry of readdirSync(appDir, { withFileTypes: true })) {
58
+ if (entry.name === "pages") continue;
59
+ const src = join(appDir, entry.name);
60
+ const dest = join(outDir, entry.name);
61
+ if (entry.isDirectory()) cpSync(src, dest, { recursive: true });
62
+ else cpSync(src, dest);
63
+ }
64
+
65
+ const pageRoot = join(appDir, "pages");
66
+ const pageFiles = walk(pageRoot);
67
+ for (const file of pageFiles) {
68
+ const rel = relative(appDir, file);
69
+ const nextPath = toTargetPath(rel, to);
70
+ const outFile = join(outDir, nextPath);
71
+ mkdirSync(dirname(outFile), { recursive: true });
72
+
73
+ const ext = extname(file);
74
+ let source = readFileSync(file, "utf8");
75
+ if (ext === ".fs") source = normalizeFastScript(source);
76
+ source = rewriteImportsForTarget(source, to);
77
+ if (to === "ts") source = `// @ts-nocheck\n${source}`;
78
+
79
+ writeFileSync(outFile, source, "utf8");
80
+ }
81
+
82
+ console.log(`export complete: app -> ${out} (target: ${to})`);
83
+ }