fastscript 1.0.0 → 2.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.
- package/CHANGELOG.md +32 -7
- package/LICENSE +33 -21
- package/README.md +567 -73
- package/node_modules/@fastscript/core-private/BOUNDARY.json +15 -0
- package/node_modules/@fastscript/core-private/README.md +5 -0
- package/node_modules/@fastscript/core-private/package.json +34 -0
- package/node_modules/@fastscript/core-private/src/asset-optimizer.mjs +67 -0
- package/node_modules/@fastscript/core-private/src/audit-log.mjs +50 -0
- package/node_modules/@fastscript/core-private/src/auth-flows.mjs +29 -0
- package/node_modules/@fastscript/core-private/src/auth.mjs +115 -0
- package/node_modules/@fastscript/core-private/src/bench.mjs +45 -0
- package/node_modules/@fastscript/core-private/src/build.mjs +670 -0
- package/node_modules/@fastscript/core-private/src/cache.mjs +248 -0
- package/node_modules/@fastscript/core-private/src/check.mjs +22 -0
- package/node_modules/@fastscript/core-private/src/cli.mjs +95 -0
- package/node_modules/@fastscript/core-private/src/compat.mjs +128 -0
- package/node_modules/@fastscript/core-private/src/create.mjs +278 -0
- package/node_modules/@fastscript/core-private/src/csp.mjs +26 -0
- package/node_modules/@fastscript/core-private/src/db-cli.mjs +185 -0
- package/node_modules/@fastscript/core-private/src/db-postgres-collection.mjs +110 -0
- package/node_modules/@fastscript/core-private/src/db-postgres.mjs +40 -0
- package/node_modules/@fastscript/core-private/src/db.mjs +103 -0
- package/node_modules/@fastscript/core-private/src/deploy.mjs +662 -0
- package/node_modules/@fastscript/core-private/src/dev.mjs +5 -0
- package/node_modules/@fastscript/core-private/src/docs-search.mjs +35 -0
- package/node_modules/@fastscript/core-private/src/env.mjs +118 -0
- package/node_modules/@fastscript/core-private/src/export.mjs +83 -0
- package/node_modules/@fastscript/core-private/src/fs-diagnostics.mjs +70 -0
- package/node_modules/@fastscript/core-private/src/fs-error-codes.mjs +141 -0
- package/node_modules/@fastscript/core-private/src/fs-formatter.mjs +66 -0
- package/node_modules/@fastscript/core-private/src/fs-linter.mjs +274 -0
- package/node_modules/@fastscript/core-private/src/fs-normalize.mjs +91 -0
- package/node_modules/@fastscript/core-private/src/fs-parser.mjs +980 -0
- package/node_modules/@fastscript/core-private/src/generated/docs-search-index.mjs +3182 -0
- package/node_modules/@fastscript/core-private/src/i18n.mjs +25 -0
- package/node_modules/@fastscript/core-private/src/interop.mjs +16 -0
- package/node_modules/@fastscript/core-private/src/jobs.mjs +378 -0
- package/node_modules/@fastscript/core-private/src/logger.mjs +27 -0
- package/node_modules/@fastscript/core-private/src/metrics.mjs +45 -0
- package/node_modules/@fastscript/core-private/src/middleware.mjs +14 -0
- package/node_modules/@fastscript/core-private/src/migrate.mjs +81 -0
- package/node_modules/@fastscript/core-private/src/migration-wizard.mjs +16 -0
- package/node_modules/@fastscript/core-private/src/module-loader.mjs +46 -0
- package/node_modules/@fastscript/core-private/src/oauth-providers.mjs +103 -0
- package/node_modules/@fastscript/core-private/src/observability.mjs +21 -0
- package/node_modules/@fastscript/core-private/src/plugins.mjs +194 -0
- package/node_modules/@fastscript/core-private/src/retention.mjs +57 -0
- package/node_modules/@fastscript/core-private/src/routes.mjs +178 -0
- package/node_modules/@fastscript/core-private/src/scheduler.mjs +104 -0
- package/node_modules/@fastscript/core-private/src/security.mjs +233 -0
- package/node_modules/@fastscript/core-private/src/server-runtime.mjs +849 -0
- package/node_modules/@fastscript/core-private/src/serverless-handler.mjs +20 -0
- package/node_modules/@fastscript/core-private/src/session-policy.mjs +38 -0
- package/node_modules/@fastscript/core-private/src/start.mjs +10 -0
- package/node_modules/@fastscript/core-private/src/storage.mjs +155 -0
- package/node_modules/@fastscript/core-private/src/style-primitives.mjs +538 -0
- package/node_modules/@fastscript/core-private/src/style-system.mjs +461 -0
- package/node_modules/@fastscript/core-private/src/tenant.mjs +55 -0
- package/node_modules/@fastscript/core-private/src/typecheck.mjs +1464 -0
- package/node_modules/@fastscript/core-private/src/validate.mjs +22 -0
- package/node_modules/@fastscript/core-private/src/validation.mjs +88 -0
- package/node_modules/@fastscript/core-private/src/webhook.mjs +81 -0
- package/node_modules/@fastscript/core-private/src/worker.mjs +24 -0
- package/package.json +86 -13
- package/src/asset-optimizer.mjs +67 -0
- package/src/audit-log.mjs +50 -0
- package/src/auth.mjs +1 -115
- package/src/bench.mjs +20 -7
- package/src/build.mjs +1 -234
- package/src/cache.mjs +210 -20
- package/src/cli.mjs +29 -5
- package/src/compat.mjs +8 -10
- package/src/create.mjs +71 -17
- package/src/csp.mjs +26 -0
- package/src/db-cli.mjs +152 -8
- package/src/db-postgres-collection.mjs +110 -0
- package/src/deploy.mjs +1 -65
- package/src/docs-search.mjs +35 -0
- package/src/env.mjs +34 -5
- package/src/fs-diagnostics.mjs +70 -0
- package/src/fs-error-codes.mjs +126 -0
- package/src/fs-formatter.mjs +66 -0
- package/src/fs-linter.mjs +274 -0
- package/src/fs-normalize.mjs +21 -238
- package/src/fs-parser.mjs +1 -0
- package/src/generated/docs-search-index.mjs +3220 -0
- package/src/i18n.mjs +25 -0
- package/src/jobs.mjs +283 -32
- package/src/metrics.mjs +45 -0
- package/src/migration-wizard.mjs +16 -0
- package/src/module-loader.mjs +11 -12
- package/src/oauth-providers.mjs +103 -0
- package/src/plugins.mjs +194 -0
- package/src/retention.mjs +57 -0
- package/src/routes.mjs +178 -0
- package/src/scheduler.mjs +104 -0
- package/src/security.mjs +197 -19
- package/src/server-runtime.mjs +1 -339
- package/src/serverless-handler.mjs +20 -0
- package/src/session-policy.mjs +38 -0
- package/src/storage.mjs +1 -56
- package/src/style-system.mjs +461 -0
- package/src/tenant.mjs +55 -0
- package/src/typecheck.mjs +1 -0
- package/src/validate.mjs +5 -1
- package/src/validation.mjs +14 -5
- package/src/webhook.mjs +1 -71
- package/src/worker.mjs +23 -4
- package/src/language-spec.mjs +0 -58
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { runBuild } from "./build.mjs";
|
|
4
|
+
import { buildStylePrimitiveServerSource, transformStylePrimitives } from "./style-primitives.mjs";
|
|
5
|
+
|
|
6
|
+
function parseArgs(args = []) {
|
|
7
|
+
let target = "node";
|
|
8
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
9
|
+
if (args[i] === "--target") target = (args[i + 1] || "node").toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
return { target };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function ensureBuildArtifacts(root) {
|
|
15
|
+
const manifestPath = join(root, "dist", "fastscript-manifest.json");
|
|
16
|
+
if (existsSync(manifestPath)) return;
|
|
17
|
+
await runBuild();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function writeNodeAdapter(root) {
|
|
21
|
+
writeFileSync(
|
|
22
|
+
join(root, "ecosystem.config.cjs"),
|
|
23
|
+
`module.exports = {
|
|
24
|
+
apps: [{
|
|
25
|
+
name: "fastscript-app",
|
|
26
|
+
script: "node",
|
|
27
|
+
args: "./src/cli.mjs start",
|
|
28
|
+
instances: "max",
|
|
29
|
+
exec_mode: "cluster",
|
|
30
|
+
env: {
|
|
31
|
+
NODE_ENV: "production",
|
|
32
|
+
PORT: 4173
|
|
33
|
+
}
|
|
34
|
+
}]
|
|
35
|
+
};
|
|
36
|
+
`,
|
|
37
|
+
"utf8",
|
|
38
|
+
);
|
|
39
|
+
writeFileSync(
|
|
40
|
+
join(root, "Dockerfile"),
|
|
41
|
+
`FROM node:20-alpine
|
|
42
|
+
WORKDIR /app
|
|
43
|
+
COPY package*.json ./
|
|
44
|
+
RUN npm ci --omit=dev
|
|
45
|
+
COPY . .
|
|
46
|
+
RUN npm run build
|
|
47
|
+
ENV NODE_ENV=production
|
|
48
|
+
ENV PORT=4173
|
|
49
|
+
EXPOSE 4173
|
|
50
|
+
HEALTHCHECK --interval=30s --timeout=5s CMD wget -q -O /dev/null http://127.0.0.1:4173/__metrics || exit 1
|
|
51
|
+
CMD ["node","./src/cli.mjs","start"]
|
|
52
|
+
`,
|
|
53
|
+
"utf8",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function writeVercelAdapter(root) {
|
|
58
|
+
mkdirSync(join(root, "api"), { recursive: true });
|
|
59
|
+
writeFileSync(
|
|
60
|
+
join(root, "api", "[[...fastscript]].mjs"),
|
|
61
|
+
`import handler from "../src/serverless-handler.mjs";
|
|
62
|
+
export default handler;
|
|
63
|
+
`,
|
|
64
|
+
"utf8",
|
|
65
|
+
);
|
|
66
|
+
writeFileSync(
|
|
67
|
+
join(root, "vercel.json"),
|
|
68
|
+
JSON.stringify(
|
|
69
|
+
{
|
|
70
|
+
version: 2,
|
|
71
|
+
cleanUrls: false,
|
|
72
|
+
trailingSlash: false,
|
|
73
|
+
functions: {
|
|
74
|
+
"api/[[...fastscript]].mjs": {
|
|
75
|
+
runtime: "nodejs20.x",
|
|
76
|
+
maxDuration: 30,
|
|
77
|
+
memory: 1024,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
headers: [
|
|
81
|
+
{
|
|
82
|
+
source: "/(.*)",
|
|
83
|
+
headers: [
|
|
84
|
+
{ key: "x-content-type-options", value: "nosniff" },
|
|
85
|
+
{ key: "x-frame-options", value: "SAMEORIGIN" },
|
|
86
|
+
{ key: "referrer-policy", value: "strict-origin-when-cross-origin" },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
source: "/(.*\\.[a-f0-9]{8}\\.(js|css))",
|
|
91
|
+
headers: [{ key: "cache-control", value: "public, max-age=31536000, immutable" }],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
source: "/(.*\\.(woff|woff2|ttf|otf|svg|png|jpg|jpeg|gif|webp|avif))",
|
|
95
|
+
headers: [{ key: "cache-control", value: "public, max-age=31536000, immutable" }],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
source: "/(fastscript-manifest\\.json|asset-manifest\\.json|manifest\\.webmanifest)",
|
|
99
|
+
headers: [{ key: "cache-control", value: "no-store, max-age=0" }],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
source: "/service-worker\\.js",
|
|
103
|
+
headers: [{ key: "cache-control", value: "no-cache, max-age=0" }],
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
routes: [
|
|
107
|
+
{ src: "/(.*\\.(js|css|json|map|webmanifest|png|jpg|jpeg|svg|gif|woff|woff2|ttf|otf))", dest: "/dist/$1" },
|
|
108
|
+
{ src: "/service-worker.js", dest: "/dist/service-worker.js" },
|
|
109
|
+
{ src: "/manifest.webmanifest", dest: "/dist/manifest.webmanifest" },
|
|
110
|
+
{ src: "/fastscript-manifest.json", dest: "/dist/fastscript-manifest.json" },
|
|
111
|
+
{ src: "/asset-manifest.json", dest: "/dist/asset-manifest.json" },
|
|
112
|
+
{ src: "/(.*)", dest: "/api/[[...fastscript]].mjs" },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
null,
|
|
116
|
+
2,
|
|
117
|
+
),
|
|
118
|
+
"utf8",
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
writeFileSync(
|
|
122
|
+
join(root, "vercel.env.example"),
|
|
123
|
+
`# FastScript production defaults
|
|
124
|
+
NODE_ENV=production
|
|
125
|
+
PORT=4173
|
|
126
|
+
FASTSCRIPT_DEPLOY_TARGET=vercel
|
|
127
|
+
|
|
128
|
+
# Optional drivers
|
|
129
|
+
# DB_DRIVER=postgres
|
|
130
|
+
# DATABASE_URL=postgres://...
|
|
131
|
+
# CACHE_DRIVER=redis
|
|
132
|
+
# REDIS_URL=redis://...
|
|
133
|
+
# STORAGE_DRIVER=s3
|
|
134
|
+
# STORAGE_S3_BUCKET=...
|
|
135
|
+
# STORAGE_S3_ENDPOINT=...
|
|
136
|
+
# STORAGE_S3_PRESIGN_BASE_URL=...
|
|
137
|
+
`,
|
|
138
|
+
"utf8",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function collectCloudflareModules(manifest) {
|
|
143
|
+
const modules = new Set();
|
|
144
|
+
for (const route of manifest.routes || []) modules.add(route.module);
|
|
145
|
+
for (const route of manifest.parallelRoutes || []) modules.add(route.module);
|
|
146
|
+
for (const route of manifest.apiRoutes || []) modules.add(route.module);
|
|
147
|
+
for (const route of manifest.routes || []) {
|
|
148
|
+
for (const layout of route.layouts || []) modules.add(layout);
|
|
149
|
+
}
|
|
150
|
+
if (manifest.layout) modules.add(manifest.layout);
|
|
151
|
+
if (manifest.notFound) modules.add(manifest.notFound);
|
|
152
|
+
if (manifest.middleware) modules.add(manifest.middleware);
|
|
153
|
+
return [...modules].filter(Boolean).sort();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function buildCloudflareWorkerSource({ manifest, assetMap }) {
|
|
157
|
+
const modulePaths = collectCloudflareModules(manifest);
|
|
158
|
+
const imports = [];
|
|
159
|
+
const moduleEntries = [];
|
|
160
|
+
modulePaths.forEach((modulePath, index) => {
|
|
161
|
+
const alias = `m${index}`;
|
|
162
|
+
const spec = `./${String(modulePath).replace(/^\.\//, "")}`;
|
|
163
|
+
imports.push(`import * as ${alias} from "${spec}";`);
|
|
164
|
+
moduleEntries.push(` "${modulePath}": ${alias}`);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const lines = [];
|
|
168
|
+
lines.push(...imports);
|
|
169
|
+
lines.push("");
|
|
170
|
+
lines.push(`const MODULES = {\n${moduleEntries.join(",\n")}\n};`);
|
|
171
|
+
lines.push(`const MANIFEST = ${JSON.stringify(manifest, null, 2)};`);
|
|
172
|
+
lines.push(`const ASSET_MAP = ${JSON.stringify(assetMap || {}, null, 2)};`);
|
|
173
|
+
lines.push(buildStylePrimitiveServerSource());
|
|
174
|
+
lines.push(`
|
|
175
|
+
function moduleFor(path) {
|
|
176
|
+
return MODULES[path] || null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function assetPath(name) {
|
|
180
|
+
return "/" + (ASSET_MAP[name] || name);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseRouteToken(token) {
|
|
184
|
+
const m = /^:([A-Za-z_$][\\w$]*)(\\*)?(\\?)?$/.exec(token || "");
|
|
185
|
+
if (!m) return null;
|
|
186
|
+
return { name: m[1], catchAll: Boolean(m[2]), optional: Boolean(m[3]) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function match(routePath, pathname) {
|
|
190
|
+
const routeParts = String(routePath || "/").split("/").filter(Boolean);
|
|
191
|
+
const pathParts = String(pathname || "/").split("/").filter(Boolean);
|
|
192
|
+
const params = {};
|
|
193
|
+
let ri = 0;
|
|
194
|
+
let pi = 0;
|
|
195
|
+
|
|
196
|
+
while (ri < routeParts.length) {
|
|
197
|
+
const token = routeParts[ri];
|
|
198
|
+
const dyn = parseRouteToken(token);
|
|
199
|
+
if (dyn && dyn.catchAll) {
|
|
200
|
+
const rest = pathParts.slice(pi);
|
|
201
|
+
if (!rest.length && !dyn.optional) return null;
|
|
202
|
+
params[dyn.name] = rest;
|
|
203
|
+
pi = pathParts.length;
|
|
204
|
+
ri = routeParts.length;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
if (dyn) {
|
|
208
|
+
const value = pathParts[pi];
|
|
209
|
+
if (value === undefined) {
|
|
210
|
+
if (dyn.optional) {
|
|
211
|
+
params[dyn.name] = undefined;
|
|
212
|
+
ri += 1;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
params[dyn.name] = value;
|
|
218
|
+
ri += 1;
|
|
219
|
+
pi += 1;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (pathParts[pi] !== token) return null;
|
|
223
|
+
ri += 1;
|
|
224
|
+
pi += 1;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (pi !== pathParts.length) return null;
|
|
228
|
+
return params;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function routePriorityScore(routePath) {
|
|
232
|
+
const parts = String(routePath || "/").split("/").filter(Boolean);
|
|
233
|
+
if (!parts.length) return 1000;
|
|
234
|
+
let score = parts.length;
|
|
235
|
+
for (const part of parts) {
|
|
236
|
+
const dyn = parseRouteToken(part);
|
|
237
|
+
if (!dyn) score += 40;
|
|
238
|
+
else if (dyn.catchAll && dyn.optional) score += 5;
|
|
239
|
+
else if (dyn.catchAll) score += 10;
|
|
240
|
+
else if (dyn.optional) score += 20;
|
|
241
|
+
else score += 30;
|
|
242
|
+
}
|
|
243
|
+
return score;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function resolveRoute(routes, pathname) {
|
|
247
|
+
let best = null;
|
|
248
|
+
for (const route of routes || []) {
|
|
249
|
+
const params = match(route.path, pathname);
|
|
250
|
+
if (!params) continue;
|
|
251
|
+
if (!best) {
|
|
252
|
+
best = { route, params, score: routePriorityScore(route.path) };
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const score = routePriorityScore(route.path);
|
|
256
|
+
if (score > best.score) best = { route, params, score };
|
|
257
|
+
}
|
|
258
|
+
return best ? { route: best.route, params: best.params } : null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parseCookies(header) {
|
|
262
|
+
const out = {};
|
|
263
|
+
String(header || "").split(";").forEach((part) => {
|
|
264
|
+
const idx = part.indexOf("=");
|
|
265
|
+
if (idx <= 0) return;
|
|
266
|
+
const key = decodeURIComponent(part.slice(0, idx).trim());
|
|
267
|
+
const value = decodeURIComponent(part.slice(idx + 1).trim());
|
|
268
|
+
out[key] = value;
|
|
269
|
+
});
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function serializeCookie(name, value, opts = {}) {
|
|
274
|
+
const segs = [\`\${encodeURIComponent(name)}=\${encodeURIComponent(value)}\`];
|
|
275
|
+
if (opts.maxAge !== undefined) segs.push(\`Max-Age=\${Number(opts.maxAge)}\`);
|
|
276
|
+
if (opts.path) segs.push(\`Path=\${opts.path}\`);
|
|
277
|
+
if (opts.httpOnly) segs.push("HttpOnly");
|
|
278
|
+
if (opts.secure) segs.push("Secure");
|
|
279
|
+
if (opts.sameSite) segs.push(\`SameSite=\${opts.sameSite}\`);
|
|
280
|
+
return segs.join("; ");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function readJsonBody(request) {
|
|
284
|
+
const text = await request.text();
|
|
285
|
+
if (!text.trim()) return {};
|
|
286
|
+
try { return JSON.parse(text); }
|
|
287
|
+
catch {
|
|
288
|
+
const error = new Error("Invalid JSON body");
|
|
289
|
+
error.status = 400;
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function validateShape(schema, input, scope = "input") {
|
|
295
|
+
if (!schema || typeof schema !== "object") return { ok: true, value: input ?? {} };
|
|
296
|
+
const errors = [];
|
|
297
|
+
const out = {};
|
|
298
|
+
const source = input && typeof input === "object" ? input : {};
|
|
299
|
+
for (const [key, rule] of Object.entries(schema)) {
|
|
300
|
+
const value = source[key];
|
|
301
|
+
const optional = typeof rule === "string" && rule.endsWith("?");
|
|
302
|
+
const t = typeof rule === "string" ? rule.replace(/\\?$/, "") : String(rule);
|
|
303
|
+
if (value === undefined || value === null) {
|
|
304
|
+
if (!optional) errors.push(\`\${scope}.\${key} is required\`);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (t === "array") {
|
|
308
|
+
if (!Array.isArray(value)) errors.push(\`\${scope}.\${key} must be array\`);
|
|
309
|
+
else out[key] = value;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (t === "int") {
|
|
313
|
+
const n = Number(value);
|
|
314
|
+
if (!Number.isInteger(n)) errors.push(\`\${scope}.\${key} must be integer\`);
|
|
315
|
+
else out[key] = n;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (t === "float" || t === "number") {
|
|
319
|
+
const n = Number(value);
|
|
320
|
+
if (!Number.isFinite(n)) errors.push(\`\${scope}.\${key} must be number\`);
|
|
321
|
+
else out[key] = n;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (t === "bool" || t === "boolean") {
|
|
325
|
+
if (typeof value !== "boolean") errors.push(\`\${scope}.\${key} must be boolean\`);
|
|
326
|
+
else out[key] = value;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (t === "str" || t === "string") {
|
|
330
|
+
if (typeof value !== "string") errors.push(\`\${scope}.\${key} must be string\`);
|
|
331
|
+
else out[key] = value;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
out[key] = value;
|
|
335
|
+
}
|
|
336
|
+
if (errors.length) {
|
|
337
|
+
const error = new Error(\`Validation failed: \${errors.join("; ")}\`);
|
|
338
|
+
error.status = 400;
|
|
339
|
+
error.details = errors;
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
return { ok: true, value: out };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function makeHelpers(responseCookies) {
|
|
346
|
+
return {
|
|
347
|
+
json(body, status = 200, headers = {}) {
|
|
348
|
+
return { status, json: body, headers };
|
|
349
|
+
},
|
|
350
|
+
text(body, status = 200, headers = {}) {
|
|
351
|
+
return { status, body, headers };
|
|
352
|
+
},
|
|
353
|
+
redirect(location, status = 302) {
|
|
354
|
+
return { status, headers: { location } };
|
|
355
|
+
},
|
|
356
|
+
setCookie(name, value, opts = {}) {
|
|
357
|
+
responseCookies.push(serializeCookie(name, value, opts));
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function applySecurityHeaders(headers) {
|
|
363
|
+
headers.set("x-content-type-options", "nosniff");
|
|
364
|
+
headers.set("x-frame-options", "SAMEORIGIN");
|
|
365
|
+
headers.set("referrer-policy", "strict-origin-when-cross-origin");
|
|
366
|
+
return headers;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function toResponse(payload, responseCookies = []) {
|
|
370
|
+
if (!payload) return new Response(null, { status: 204 });
|
|
371
|
+
const status = payload.status ?? 200;
|
|
372
|
+
const headers = applySecurityHeaders(new Headers(payload.headers || {}));
|
|
373
|
+
for (const cookie of responseCookies) headers.append("set-cookie", cookie);
|
|
374
|
+
if (payload.json !== undefined) {
|
|
375
|
+
headers.set("content-type", "application/json; charset=utf-8");
|
|
376
|
+
return new Response(JSON.stringify(payload.json), { status, headers });
|
|
377
|
+
}
|
|
378
|
+
if (!headers.has("content-type")) headers.set("content-type", "text/plain; charset=utf-8");
|
|
379
|
+
return new Response(payload.body ?? "", { status, headers });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function htmlDoc(content, ssrData) {
|
|
383
|
+
const safe = JSON.stringify(ssrData ?? {}).replace(/</g, "\\\\u003c");
|
|
384
|
+
return \`<!doctype html>
|
|
385
|
+
<html>
|
|
386
|
+
<head>
|
|
387
|
+
<meta charset="utf-8" />
|
|
388
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
389
|
+
<title>FastScript</title>
|
|
390
|
+
<link rel="stylesheet" href="\${assetPath("styles.css")}" />
|
|
391
|
+
</head>
|
|
392
|
+
<body>
|
|
393
|
+
<div id="app">\${content}</div>
|
|
394
|
+
<script>window.__FASTSCRIPT_SSR=\${safe}</script>
|
|
395
|
+
<script type="module" src="\${assetPath("router.js")}"></script>
|
|
396
|
+
</body>
|
|
397
|
+
</html>\`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function localeFromPath(pathname) {
|
|
401
|
+
const i18n = MANIFEST.i18n || { locales: ["en"], defaultLocale: "en" };
|
|
402
|
+
const parts = String(pathname || "/").split("/").filter(Boolean);
|
|
403
|
+
const head = parts[0];
|
|
404
|
+
if (head && i18n.locales.includes(head)) {
|
|
405
|
+
const normalized = "/" + parts.slice(1).join("/");
|
|
406
|
+
return { locale: head, pathname: normalized === "/" ? "/" : normalized || "/" };
|
|
407
|
+
}
|
|
408
|
+
return { locale: i18n.defaultLocale || "en", pathname: pathname || "/" };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function maybeServeStatic(request, env, pathname) {
|
|
412
|
+
if (!env || !env.ASSETS) return null;
|
|
413
|
+
const staticLike = /\\.[a-zA-Z0-9]+$/.test(pathname) || pathname === "/manifest.webmanifest" || pathname === "/service-worker.js";
|
|
414
|
+
if (!staticLike) return null;
|
|
415
|
+
const res = await env.ASSETS.fetch(request);
|
|
416
|
+
if (res && res.status !== 404) {
|
|
417
|
+
const headers = applySecurityHeaders(new Headers(res.headers));
|
|
418
|
+
if (/\\.[a-f0-9]{8}\\.(js|css)$/.test(pathname) || /\\.(woff2?|ttf|otf|svg|png|jpe?g|gif|webp|avif)$/.test(pathname)) {
|
|
419
|
+
headers.set("cache-control", "public, max-age=31536000, immutable");
|
|
420
|
+
} else if (pathname === "/service-worker.js") {
|
|
421
|
+
headers.set("cache-control", "no-cache, max-age=0");
|
|
422
|
+
} else if (pathname === "/manifest.webmanifest" || pathname === "/fastscript-manifest.json" || pathname === "/asset-manifest.json") {
|
|
423
|
+
headers.set("cache-control", "no-store, max-age=0");
|
|
424
|
+
}
|
|
425
|
+
return new Response(res.body, { status: res.status, headers });
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function runMiddlewares(ctx, middlewares, done) {
|
|
431
|
+
let idx = -1;
|
|
432
|
+
async function next() {
|
|
433
|
+
idx += 1;
|
|
434
|
+
const mw = middlewares[idx];
|
|
435
|
+
if (!mw) return done();
|
|
436
|
+
return mw(ctx, next);
|
|
437
|
+
}
|
|
438
|
+
return next();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function renderParallel(pathname, locale, baseParams) {
|
|
442
|
+
const slots = {};
|
|
443
|
+
for (const route of MANIFEST.parallelRoutes || []) {
|
|
444
|
+
const hit = match(route.path, pathname);
|
|
445
|
+
if (!hit) continue;
|
|
446
|
+
const mod = moduleFor(route.module);
|
|
447
|
+
if (!mod) continue;
|
|
448
|
+
let data = {};
|
|
449
|
+
if (typeof mod.load === "function") data = (await mod.load({ pathname, params: baseParams, locale, slot: route.slot })) || {};
|
|
450
|
+
const html = mod.default ? mod.default({ ...data, pathname, params: baseParams, locale, slot: route.slot }) : "";
|
|
451
|
+
slots[route.slot || "default"] = html || "";
|
|
452
|
+
}
|
|
453
|
+
return slots;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function applyLayouts(route, pathname, locale, html, params, data, slots) {
|
|
457
|
+
const list = (route.layouts && route.layouts.length ? route.layouts : (MANIFEST.layout ? [MANIFEST.layout] : [])) || [];
|
|
458
|
+
let out = html;
|
|
459
|
+
for (const layoutPath of list) {
|
|
460
|
+
const mod = moduleFor(layoutPath);
|
|
461
|
+
if (!mod || typeof mod.default !== "function") continue;
|
|
462
|
+
out = mod.default({ content: out, pathname, locale, params, data, slots });
|
|
463
|
+
}
|
|
464
|
+
return out;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export default {
|
|
468
|
+
async fetch(request, env) {
|
|
469
|
+
const url = new URL(request.url);
|
|
470
|
+
const localized = localeFromPath(url.pathname);
|
|
471
|
+
const pathname = localized.pathname;
|
|
472
|
+
const method = (request.method || "GET").toUpperCase();
|
|
473
|
+
|
|
474
|
+
const staticResponse = await maybeServeStatic(request, env, url.pathname);
|
|
475
|
+
if (staticResponse) return staticResponse;
|
|
476
|
+
|
|
477
|
+
const responseCookies = [];
|
|
478
|
+
const helpers = makeHelpers(responseCookies);
|
|
479
|
+
const cookies = parseCookies(request.headers.get("cookie") || "");
|
|
480
|
+
const queryObject = Object.fromEntries(url.searchParams.entries());
|
|
481
|
+
const ctx = {
|
|
482
|
+
req: request,
|
|
483
|
+
env,
|
|
484
|
+
pathname,
|
|
485
|
+
method,
|
|
486
|
+
locale: localized.locale,
|
|
487
|
+
params: {},
|
|
488
|
+
query: queryObject,
|
|
489
|
+
cookies,
|
|
490
|
+
input: {
|
|
491
|
+
body: null,
|
|
492
|
+
query: queryObject,
|
|
493
|
+
async readJson() {
|
|
494
|
+
if (this.body !== null) return this.body;
|
|
495
|
+
this.body = await readJsonBody(request);
|
|
496
|
+
return this.body;
|
|
497
|
+
},
|
|
498
|
+
validateQuery(schema) {
|
|
499
|
+
return validateShape(schema, queryObject, "query").value;
|
|
500
|
+
},
|
|
501
|
+
async validateBody(schema) {
|
|
502
|
+
const body = await this.readJson();
|
|
503
|
+
return validateShape(schema, body, "body").value;
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
helpers,
|
|
507
|
+
user: null,
|
|
508
|
+
auth: {
|
|
509
|
+
requireUser() {
|
|
510
|
+
const error = new Error("Unauthorized");
|
|
511
|
+
error.status = 401;
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
const middlewares = [];
|
|
519
|
+
if (MANIFEST.middleware) {
|
|
520
|
+
const mm = moduleFor(MANIFEST.middleware);
|
|
521
|
+
if (Array.isArray(mm?.middlewares)) middlewares.push(...mm.middlewares);
|
|
522
|
+
else if (typeof mm?.middleware === "function") middlewares.push(mm.middleware);
|
|
523
|
+
else if (typeof mm?.default === "function") middlewares.push(mm.default);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const result = await runMiddlewares(ctx, middlewares, async () => {
|
|
527
|
+
if (pathname.startsWith("/api/")) {
|
|
528
|
+
const apiHit = resolveRoute(MANIFEST.apiRoutes || [], pathname);
|
|
529
|
+
if (!apiHit) return { status: 404, json: { ok: false, error: "API route not found" } };
|
|
530
|
+
ctx.params = apiHit.params;
|
|
531
|
+
const mod = moduleFor(apiHit.route.module);
|
|
532
|
+
if (!mod) return { status: 500, json: { ok: false, error: "API module missing" } };
|
|
533
|
+
const handler = mod[method];
|
|
534
|
+
if (typeof handler !== "function") return { status: 405, json: { ok: false, error: \`Method \${method} not allowed\` } };
|
|
535
|
+
if (mod.schemas?.[method]) {
|
|
536
|
+
const body = await ctx.input.readJson();
|
|
537
|
+
validateShape(mod.schemas[method], body, "body");
|
|
538
|
+
}
|
|
539
|
+
return handler(ctx, helpers);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const hit = resolveRoute(MANIFEST.routes || [], pathname);
|
|
543
|
+
if (!hit) {
|
|
544
|
+
if (MANIFEST.notFound) {
|
|
545
|
+
const nf = moduleFor(MANIFEST.notFound);
|
|
546
|
+
const notFoundHtml = nf?.default ? transformStylePrimitives(nf.default({ pathname, locale: ctx.locale })) : "<h1>404</h1>";
|
|
547
|
+
return { status: 404, body: htmlDoc(notFoundHtml, { pathname, data: null }), headers: { "content-type": "text/html; charset=utf-8" } };
|
|
548
|
+
}
|
|
549
|
+
return { status: 404, body: "Not found" };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
ctx.params = hit.params;
|
|
553
|
+
const mod = moduleFor(hit.route.module);
|
|
554
|
+
if (!mod) return { status: 500, body: "Route module missing" };
|
|
555
|
+
|
|
556
|
+
if (!["GET", "HEAD"].includes(method) && typeof mod[method] === "function") {
|
|
557
|
+
if (mod.schemas?.[method]) {
|
|
558
|
+
const body = await ctx.input.readJson();
|
|
559
|
+
validateShape(mod.schemas[method], body, "body");
|
|
560
|
+
}
|
|
561
|
+
return mod[method](ctx, helpers);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
let data = {};
|
|
565
|
+
if (typeof mod.load === "function") data = (await mod.load({ ...ctx, pathname, params: hit.params, locale: ctx.locale })) || {};
|
|
566
|
+
let html = mod.default ? mod.default({ ...data, pathname, params: hit.params, locale: ctx.locale }) : "";
|
|
567
|
+
const slots = await renderParallel(pathname, ctx.locale, hit.params);
|
|
568
|
+
html = await applyLayouts(hit.route, pathname, ctx.locale, html, hit.params, data, slots);
|
|
569
|
+
html = transformStylePrimitives(html);
|
|
570
|
+
return {
|
|
571
|
+
status: 200,
|
|
572
|
+
body: htmlDoc(html, { pathname, data }),
|
|
573
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
574
|
+
};
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
return toResponse(result, responseCookies);
|
|
578
|
+
} catch (error) {
|
|
579
|
+
const status = Number.isInteger(error?.status) ? error.status : 500;
|
|
580
|
+
const headers = applySecurityHeaders(new Headers({ "content-type": "application/json; charset=utf-8" }));
|
|
581
|
+
return new Response(JSON.stringify({ ok: false, error: error?.message || "Unknown error", details: error?.details || null }), {
|
|
582
|
+
status,
|
|
583
|
+
headers,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
`);
|
|
589
|
+
return lines.join("\n");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function writeCloudflareAdapter(root) {
|
|
593
|
+
const distDir = join(root, "dist");
|
|
594
|
+
const manifestPath = join(distDir, "fastscript-manifest.json");
|
|
595
|
+
const assetManifestPath = join(distDir, "asset-manifest.json");
|
|
596
|
+
if (!existsSync(manifestPath)) {
|
|
597
|
+
throw new Error("Cloudflare deploy requires build output. Run `fastscript build` first.");
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
601
|
+
const assetManifest = existsSync(assetManifestPath) ? JSON.parse(readFileSync(assetManifestPath, "utf8")) : { mapping: {} };
|
|
602
|
+
const source = buildCloudflareWorkerSource({ manifest, assetMap: assetManifest.mapping || {} });
|
|
603
|
+
|
|
604
|
+
writeFileSync(join(distDir, "worker.js"), source, "utf8");
|
|
605
|
+
writeFileSync(
|
|
606
|
+
join(root, "wrangler.toml"),
|
|
607
|
+
`name = "fastscript-app"
|
|
608
|
+
main = "dist/worker.js"
|
|
609
|
+
compatibility_date = "2026-04-14"
|
|
610
|
+
compatibility_flags = ["nodejs_compat"]
|
|
611
|
+
workers_dev = true
|
|
612
|
+
|
|
613
|
+
[vars]
|
|
614
|
+
NODE_ENV = "production"
|
|
615
|
+
FASTSCRIPT_DEPLOY_TARGET = "cloudflare"
|
|
616
|
+
|
|
617
|
+
[observability.logs]
|
|
618
|
+
enabled = true
|
|
619
|
+
|
|
620
|
+
[assets]
|
|
621
|
+
directory = "dist"
|
|
622
|
+
binding = "ASSETS"
|
|
623
|
+
`,
|
|
624
|
+
"utf8",
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
writeFileSync(
|
|
628
|
+
join(root, ".dev.vars.example"),
|
|
629
|
+
`NODE_ENV=production
|
|
630
|
+
FASTSCRIPT_DEPLOY_TARGET=cloudflare
|
|
631
|
+
DEFAULT_TENANT_ID=public
|
|
632
|
+
`,
|
|
633
|
+
"utf8",
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export async function runDeploy(args = []) {
|
|
638
|
+
const { target } = parseArgs(args);
|
|
639
|
+
const root = resolve(process.cwd());
|
|
640
|
+
|
|
641
|
+
if (target === "node" || target === "pm2") {
|
|
642
|
+
writeNodeAdapter(root);
|
|
643
|
+
console.log("deploy adapter ready: node/pm2 + docker");
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (target === "vercel") {
|
|
648
|
+
await ensureBuildArtifacts(root);
|
|
649
|
+
writeVercelAdapter(root);
|
|
650
|
+
console.log("deploy adapter ready: vercel (full catch-all SSR/API)");
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (target === "cloudflare") {
|
|
655
|
+
await ensureBuildArtifacts(root);
|
|
656
|
+
writeCloudflareAdapter(root);
|
|
657
|
+
console.log("deploy adapter ready: cloudflare (worker SSR/API + static assets)");
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
throw new Error(`Unknown deploy target: ${target}. Use node|pm2|vercel|cloudflare`);
|
|
662
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
function tokenize(text) {
|
|
5
|
+
return String(text || "")
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
8
|
+
.split(/\s+/)
|
|
9
|
+
.filter(Boolean);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function rankDocs(index = [], query = "", { limit = 20 } = {}) {
|
|
13
|
+
const q = tokenize(query);
|
|
14
|
+
if (!q.length) return index.slice(0, limit);
|
|
15
|
+
return index
|
|
16
|
+
.map((item) => {
|
|
17
|
+
const terms = item.terms || {};
|
|
18
|
+
let score = 0;
|
|
19
|
+
for (const token of q) score += Number(terms[token] || 0);
|
|
20
|
+
return { ...item, score };
|
|
21
|
+
})
|
|
22
|
+
.filter((item) => item.score > 0)
|
|
23
|
+
.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title))
|
|
24
|
+
.slice(0, limit);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function loadDocsIndex(path = "docs/search-index.json") {
|
|
28
|
+
const full = resolve(path);
|
|
29
|
+
if (!existsSync(full)) return [];
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(readFileSync(full, "utf8"));
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|