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.
Files changed (109) hide show
  1. package/CHANGELOG.md +32 -7
  2. package/LICENSE +33 -21
  3. package/README.md +567 -73
  4. package/node_modules/@fastscript/core-private/BOUNDARY.json +15 -0
  5. package/node_modules/@fastscript/core-private/README.md +5 -0
  6. package/node_modules/@fastscript/core-private/package.json +34 -0
  7. package/node_modules/@fastscript/core-private/src/asset-optimizer.mjs +67 -0
  8. package/node_modules/@fastscript/core-private/src/audit-log.mjs +50 -0
  9. package/node_modules/@fastscript/core-private/src/auth-flows.mjs +29 -0
  10. package/node_modules/@fastscript/core-private/src/auth.mjs +115 -0
  11. package/node_modules/@fastscript/core-private/src/bench.mjs +45 -0
  12. package/node_modules/@fastscript/core-private/src/build.mjs +670 -0
  13. package/node_modules/@fastscript/core-private/src/cache.mjs +248 -0
  14. package/node_modules/@fastscript/core-private/src/check.mjs +22 -0
  15. package/node_modules/@fastscript/core-private/src/cli.mjs +95 -0
  16. package/node_modules/@fastscript/core-private/src/compat.mjs +128 -0
  17. package/node_modules/@fastscript/core-private/src/create.mjs +278 -0
  18. package/node_modules/@fastscript/core-private/src/csp.mjs +26 -0
  19. package/node_modules/@fastscript/core-private/src/db-cli.mjs +185 -0
  20. package/node_modules/@fastscript/core-private/src/db-postgres-collection.mjs +110 -0
  21. package/node_modules/@fastscript/core-private/src/db-postgres.mjs +40 -0
  22. package/node_modules/@fastscript/core-private/src/db.mjs +103 -0
  23. package/node_modules/@fastscript/core-private/src/deploy.mjs +662 -0
  24. package/node_modules/@fastscript/core-private/src/dev.mjs +5 -0
  25. package/node_modules/@fastscript/core-private/src/docs-search.mjs +35 -0
  26. package/node_modules/@fastscript/core-private/src/env.mjs +118 -0
  27. package/node_modules/@fastscript/core-private/src/export.mjs +83 -0
  28. package/node_modules/@fastscript/core-private/src/fs-diagnostics.mjs +70 -0
  29. package/node_modules/@fastscript/core-private/src/fs-error-codes.mjs +141 -0
  30. package/node_modules/@fastscript/core-private/src/fs-formatter.mjs +66 -0
  31. package/node_modules/@fastscript/core-private/src/fs-linter.mjs +274 -0
  32. package/node_modules/@fastscript/core-private/src/fs-normalize.mjs +91 -0
  33. package/node_modules/@fastscript/core-private/src/fs-parser.mjs +980 -0
  34. package/node_modules/@fastscript/core-private/src/generated/docs-search-index.mjs +3182 -0
  35. package/node_modules/@fastscript/core-private/src/i18n.mjs +25 -0
  36. package/node_modules/@fastscript/core-private/src/interop.mjs +16 -0
  37. package/node_modules/@fastscript/core-private/src/jobs.mjs +378 -0
  38. package/node_modules/@fastscript/core-private/src/logger.mjs +27 -0
  39. package/node_modules/@fastscript/core-private/src/metrics.mjs +45 -0
  40. package/node_modules/@fastscript/core-private/src/middleware.mjs +14 -0
  41. package/node_modules/@fastscript/core-private/src/migrate.mjs +81 -0
  42. package/node_modules/@fastscript/core-private/src/migration-wizard.mjs +16 -0
  43. package/node_modules/@fastscript/core-private/src/module-loader.mjs +46 -0
  44. package/node_modules/@fastscript/core-private/src/oauth-providers.mjs +103 -0
  45. package/node_modules/@fastscript/core-private/src/observability.mjs +21 -0
  46. package/node_modules/@fastscript/core-private/src/plugins.mjs +194 -0
  47. package/node_modules/@fastscript/core-private/src/retention.mjs +57 -0
  48. package/node_modules/@fastscript/core-private/src/routes.mjs +178 -0
  49. package/node_modules/@fastscript/core-private/src/scheduler.mjs +104 -0
  50. package/node_modules/@fastscript/core-private/src/security.mjs +233 -0
  51. package/node_modules/@fastscript/core-private/src/server-runtime.mjs +849 -0
  52. package/node_modules/@fastscript/core-private/src/serverless-handler.mjs +20 -0
  53. package/node_modules/@fastscript/core-private/src/session-policy.mjs +38 -0
  54. package/node_modules/@fastscript/core-private/src/start.mjs +10 -0
  55. package/node_modules/@fastscript/core-private/src/storage.mjs +155 -0
  56. package/node_modules/@fastscript/core-private/src/style-primitives.mjs +538 -0
  57. package/node_modules/@fastscript/core-private/src/style-system.mjs +461 -0
  58. package/node_modules/@fastscript/core-private/src/tenant.mjs +55 -0
  59. package/node_modules/@fastscript/core-private/src/typecheck.mjs +1464 -0
  60. package/node_modules/@fastscript/core-private/src/validate.mjs +22 -0
  61. package/node_modules/@fastscript/core-private/src/validation.mjs +88 -0
  62. package/node_modules/@fastscript/core-private/src/webhook.mjs +81 -0
  63. package/node_modules/@fastscript/core-private/src/worker.mjs +24 -0
  64. package/package.json +86 -13
  65. package/src/asset-optimizer.mjs +67 -0
  66. package/src/audit-log.mjs +50 -0
  67. package/src/auth.mjs +1 -115
  68. package/src/bench.mjs +20 -7
  69. package/src/build.mjs +1 -234
  70. package/src/cache.mjs +210 -20
  71. package/src/cli.mjs +29 -5
  72. package/src/compat.mjs +8 -10
  73. package/src/create.mjs +71 -17
  74. package/src/csp.mjs +26 -0
  75. package/src/db-cli.mjs +152 -8
  76. package/src/db-postgres-collection.mjs +110 -0
  77. package/src/deploy.mjs +1 -65
  78. package/src/docs-search.mjs +35 -0
  79. package/src/env.mjs +34 -5
  80. package/src/fs-diagnostics.mjs +70 -0
  81. package/src/fs-error-codes.mjs +126 -0
  82. package/src/fs-formatter.mjs +66 -0
  83. package/src/fs-linter.mjs +274 -0
  84. package/src/fs-normalize.mjs +21 -238
  85. package/src/fs-parser.mjs +1 -0
  86. package/src/generated/docs-search-index.mjs +3220 -0
  87. package/src/i18n.mjs +25 -0
  88. package/src/jobs.mjs +283 -32
  89. package/src/metrics.mjs +45 -0
  90. package/src/migration-wizard.mjs +16 -0
  91. package/src/module-loader.mjs +11 -12
  92. package/src/oauth-providers.mjs +103 -0
  93. package/src/plugins.mjs +194 -0
  94. package/src/retention.mjs +57 -0
  95. package/src/routes.mjs +178 -0
  96. package/src/scheduler.mjs +104 -0
  97. package/src/security.mjs +197 -19
  98. package/src/server-runtime.mjs +1 -339
  99. package/src/serverless-handler.mjs +20 -0
  100. package/src/session-policy.mjs +38 -0
  101. package/src/storage.mjs +1 -56
  102. package/src/style-system.mjs +461 -0
  103. package/src/tenant.mjs +55 -0
  104. package/src/typecheck.mjs +1 -0
  105. package/src/validate.mjs +5 -1
  106. package/src/validation.mjs +14 -5
  107. package/src/webhook.mjs +1 -71
  108. package/src/worker.mjs +23 -4
  109. package/src/language-spec.mjs +0 -58
@@ -0,0 +1,103 @@
1
+ import { buildOAuthAuthorizeUrl, createOAuthState } from "./auth-flows.mjs";
2
+
3
+ const PROVIDERS = {
4
+ github: {
5
+ name: "GitHub",
6
+ authorizeUrl: "https://github.com/login/oauth/authorize",
7
+ tokenUrl: "https://github.com/login/oauth/access_token",
8
+ userUrl: "https://api.github.com/user",
9
+ scope: "read:user user:email",
10
+ },
11
+ google: {
12
+ name: "Google",
13
+ authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
14
+ tokenUrl: "https://oauth2.googleapis.com/token",
15
+ userUrl: "https://www.googleapis.com/oauth2/v3/userinfo",
16
+ scope: "openid email profile",
17
+ },
18
+ microsoft: {
19
+ name: "Microsoft",
20
+ authorizeUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
21
+ tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
22
+ userUrl: "https://graph.microsoft.com/v1.0/me",
23
+ scope: "openid profile email User.Read",
24
+ },
25
+ };
26
+
27
+ export function listAuthProviders() {
28
+ return Object.keys(PROVIDERS);
29
+ }
30
+
31
+ export function getAuthProvider(name) {
32
+ const key = String(name || "").toLowerCase();
33
+ const provider = PROVIDERS[key];
34
+ if (!provider) {
35
+ const error = new Error(`Unknown OAuth provider: ${name}`);
36
+ error.status = 400;
37
+ throw error;
38
+ }
39
+ return { id: key, ...provider };
40
+ }
41
+
42
+ export function createOAuthAdapter(name, {
43
+ clientId = process.env[`${String(name || "").toUpperCase()}_CLIENT_ID`],
44
+ clientSecret = process.env[`${String(name || "").toUpperCase()}_CLIENT_SECRET`],
45
+ redirectUri = process.env[`${String(name || "").toUpperCase()}_REDIRECT_URI`],
46
+ } = {}) {
47
+ const provider = getAuthProvider(name);
48
+ return {
49
+ ...provider,
50
+ clientId,
51
+ clientSecret,
52
+ redirectUri,
53
+ authorizeUrl({ state = createOAuthState(), scope = provider.scope } = {}) {
54
+ return {
55
+ state,
56
+ url: buildOAuthAuthorizeUrl({
57
+ authorizeUrl: provider.authorizeUrl,
58
+ clientId,
59
+ redirectUri,
60
+ scope,
61
+ state,
62
+ }),
63
+ };
64
+ },
65
+ async exchangeCode(code) {
66
+ const body = new URLSearchParams({
67
+ grant_type: "authorization_code",
68
+ code,
69
+ client_id: clientId,
70
+ client_secret: clientSecret,
71
+ redirect_uri: redirectUri,
72
+ });
73
+ const response = await fetch(provider.tokenUrl, {
74
+ method: "POST",
75
+ headers: {
76
+ "content-type": "application/x-www-form-urlencoded",
77
+ accept: "application/json",
78
+ },
79
+ body,
80
+ });
81
+ if (!response.ok) {
82
+ const error = new Error(`OAuth token exchange failed (${response.status})`);
83
+ error.status = 502;
84
+ throw error;
85
+ }
86
+ return response.json();
87
+ },
88
+ async fetchUser(accessToken) {
89
+ const response = await fetch(provider.userUrl, {
90
+ headers: {
91
+ authorization: `Bearer ${accessToken}`,
92
+ accept: "application/json",
93
+ },
94
+ });
95
+ if (!response.ok) {
96
+ const error = new Error(`OAuth user fetch failed (${response.status})`);
97
+ error.status = 502;
98
+ throw error;
99
+ }
100
+ return response.json();
101
+ },
102
+ };
103
+ }
@@ -0,0 +1,21 @@
1
+ export function createTracer({ service = "fastscript" } = {}) {
2
+ return {
3
+ span(name, attrs = {}) {
4
+ const start = Date.now();
5
+ return {
6
+ end(extra = {}) {
7
+ console.log(JSON.stringify({ type: "trace", service, name, ms: Date.now() - start, ...attrs, ...extra }));
8
+ },
9
+ };
10
+ },
11
+ };
12
+ }
13
+
14
+ export async function createOtelExporter() {
15
+ try {
16
+ const api = await import("@opentelemetry/api");
17
+ return api.trace.getTracer("fastscript");
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
@@ -0,0 +1,194 @@
1
+ import { existsSync } from "node:fs";
2
+ import { extname, join, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { importSourceModule } from "./module-loader.mjs";
5
+
6
+ export const PLUGIN_API_VERSION = 1;
7
+ const DEFAULT_PLUGIN_TIMEOUT_MS = Number(process.env.PLUGIN_TIMEOUT_MS || 2500);
8
+
9
+ const CONFIG_CANDIDATES = [
10
+ "fastscript.plugins.fs",
11
+ "fastscript.plugins.js",
12
+ "fastscript.plugins.mjs",
13
+ "fastscript.plugins.cjs",
14
+ "app/plugins.fs",
15
+ "app/plugins.js",
16
+ "app/plugins.mjs",
17
+ "app/plugins.cjs",
18
+ ];
19
+
20
+ function asArray(value) {
21
+ if (!value) return [];
22
+ return Array.isArray(value) ? value : [value];
23
+ }
24
+
25
+ function createNullLogger() {
26
+ return {
27
+ info() {},
28
+ warn() {},
29
+ error() {},
30
+ };
31
+ }
32
+
33
+ function withTimeout(task, ms, label) {
34
+ if (!Number.isFinite(ms) || ms <= 0) return task;
35
+ let timer = null;
36
+ const timeout = new Promise((_, reject) => {
37
+ timer = setTimeout(() => {
38
+ const error = new Error(`Plugin hook timeout (${ms}ms): ${label}`);
39
+ error.code = "PLUGIN_HOOK_TIMEOUT";
40
+ reject(error);
41
+ }, ms);
42
+ });
43
+ return Promise.race([task.finally(() => clearTimeout(timer)), timeout]);
44
+ }
45
+
46
+ async function importPluginModule(file) {
47
+ const abs = resolve(file);
48
+ const ext = extname(abs).toLowerCase();
49
+ if (ext === ".fs") return importSourceModule(abs, { platform: "node" });
50
+ return import(`${pathToFileURL(abs).href}?t=${Date.now()}`);
51
+ }
52
+
53
+ function normalizePluginEntries(mod) {
54
+ const entries = [];
55
+ entries.push(...asArray(mod?.plugins));
56
+ entries.push(...asArray(mod?.default));
57
+ return entries;
58
+ }
59
+
60
+ function normalizePlugin(raw, fallbackName) {
61
+ if (!raw) return null;
62
+ if (typeof raw === "function") {
63
+ return { name: fallbackName, apiVersion: PLUGIN_API_VERSION, setup: raw };
64
+ }
65
+ if (typeof raw !== "object") return null;
66
+ const setup = typeof raw.setup === "function" ? raw.setup : null;
67
+ const plugin = {
68
+ name: raw.name || fallbackName,
69
+ apiVersion: Number.isInteger(raw.apiVersion) ? raw.apiVersion : PLUGIN_API_VERSION,
70
+ setup,
71
+ };
72
+ if (typeof raw.middleware === "function") plugin.middleware = raw.middleware;
73
+ if (typeof raw.onBuildStart === "function") plugin.onBuildStart = raw.onBuildStart;
74
+ if (typeof raw.onBuildEnd === "function") plugin.onBuildEnd = raw.onBuildEnd;
75
+ if (typeof raw.onRequestStart === "function") plugin.onRequestStart = raw.onRequestStart;
76
+ if (typeof raw.onRequestEnd === "function") plugin.onRequestEnd = raw.onRequestEnd;
77
+ return plugin;
78
+ }
79
+
80
+ function createRegistry() {
81
+ return {
82
+ middleware: [],
83
+ onBuildStart: [],
84
+ onBuildEnd: [],
85
+ onRequestStart: [],
86
+ onRequestEnd: [],
87
+ };
88
+ }
89
+
90
+ async function runHookList(hooks, ctx, logger, stage) {
91
+ for (const hook of hooks) {
92
+ const label = `${hook.plugin}:${stage}`;
93
+ try {
94
+ await withTimeout(Promise.resolve(hook.fn(ctx)), hook.timeoutMs ?? DEFAULT_PLUGIN_TIMEOUT_MS, label);
95
+ } catch (error) {
96
+ logger.warn("plugin_hook_failed", {
97
+ plugin: hook.plugin,
98
+ stage,
99
+ error: error?.message || String(error),
100
+ });
101
+ }
102
+ }
103
+ }
104
+
105
+ function createHookApi(registry, pluginName) {
106
+ return {
107
+ middleware(fn, opts = {}) {
108
+ if (typeof fn !== "function") return;
109
+ registry.middleware.push({ plugin: pluginName, fn, timeoutMs: opts.timeoutMs });
110
+ },
111
+ onBuildStart(fn, opts = {}) {
112
+ if (typeof fn !== "function") return;
113
+ registry.onBuildStart.push({ plugin: pluginName, fn, timeoutMs: opts.timeoutMs });
114
+ },
115
+ onBuildEnd(fn, opts = {}) {
116
+ if (typeof fn !== "function") return;
117
+ registry.onBuildEnd.push({ plugin: pluginName, fn, timeoutMs: opts.timeoutMs });
118
+ },
119
+ onRequestStart(fn, opts = {}) {
120
+ if (typeof fn !== "function") return;
121
+ registry.onRequestStart.push({ plugin: pluginName, fn, timeoutMs: opts.timeoutMs });
122
+ },
123
+ onRequestEnd(fn, opts = {}) {
124
+ if (typeof fn !== "function") return;
125
+ registry.onRequestEnd.push({ plugin: pluginName, fn, timeoutMs: opts.timeoutMs });
126
+ },
127
+ };
128
+ }
129
+
130
+ function configFiles(root) {
131
+ return CONFIG_CANDIDATES.map((file) => join(root, file)).filter((p) => existsSync(p));
132
+ }
133
+
134
+ export async function createPluginRuntime({ root = process.cwd(), logger = createNullLogger() } = {}) {
135
+ const files = configFiles(root);
136
+ const registry = createRegistry();
137
+ const loaded = [];
138
+
139
+ for (const file of files) {
140
+ let mod;
141
+ try {
142
+ mod = await importPluginModule(file);
143
+ } catch (error) {
144
+ logger.warn("plugin_module_load_failed", { file, error: error?.message || String(error) });
145
+ continue;
146
+ }
147
+ const rawEntries = normalizePluginEntries(mod);
148
+ rawEntries.forEach((raw, idx) => {
149
+ const plugin = normalizePlugin(raw, `${file}#${idx + 1}`);
150
+ if (!plugin) return;
151
+ if (plugin.apiVersion !== PLUGIN_API_VERSION) {
152
+ logger.warn("plugin_api_version_mismatch", {
153
+ plugin: plugin.name,
154
+ expected: PLUGIN_API_VERSION,
155
+ received: plugin.apiVersion,
156
+ });
157
+ return;
158
+ }
159
+
160
+ const hooks = createHookApi(registry, plugin.name);
161
+ try {
162
+ if (plugin.middleware) hooks.middleware(plugin.middleware);
163
+ if (plugin.onBuildStart) hooks.onBuildStart(plugin.onBuildStart);
164
+ if (plugin.onBuildEnd) hooks.onBuildEnd(plugin.onBuildEnd);
165
+ if (plugin.onRequestStart) hooks.onRequestStart(plugin.onRequestStart);
166
+ if (plugin.onRequestEnd) hooks.onRequestEnd(plugin.onRequestEnd);
167
+ if (plugin.setup) plugin.setup({ hooks, apiVersion: PLUGIN_API_VERSION });
168
+ loaded.push({ name: plugin.name, file });
169
+ } catch (error) {
170
+ logger.warn("plugin_setup_failed", { plugin: plugin.name, file, error: error?.message || String(error) });
171
+ }
172
+ });
173
+ }
174
+
175
+ return {
176
+ apiVersion: PLUGIN_API_VERSION,
177
+ loaded,
178
+ middleware() {
179
+ return registry.middleware.map((row) => row.fn);
180
+ },
181
+ async onBuildStart(ctx = {}) {
182
+ await runHookList(registry.onBuildStart, ctx, logger, "onBuildStart");
183
+ },
184
+ async onBuildEnd(ctx = {}) {
185
+ await runHookList(registry.onBuildEnd, ctx, logger, "onBuildEnd");
186
+ },
187
+ async onRequestStart(ctx = {}) {
188
+ await runHookList(registry.onRequestStart, ctx, logger, "onRequestStart");
189
+ },
190
+ async onRequestEnd(ctx = {}) {
191
+ await runHookList(registry.onRequestEnd, ctx, logger, "onRequestEnd");
192
+ },
193
+ };
194
+ }
@@ -0,0 +1,57 @@
1
+ import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } 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
+ export function applyJsonArrayRetention(path, { timestampField = "ts", maxAgeMs = 1000 * 60 * 60 * 24 * 30 } = {}) {
14
+ const rows = readJson(path, []);
15
+ if (!Array.isArray(rows)) return { kept: 0, removed: 0 };
16
+ const cutoff = Date.now() - maxAgeMs;
17
+ const kept = rows.filter((row) => {
18
+ const raw = row?.[timestampField] || row?.failedAt || row?.createdAt || null;
19
+ const n = typeof raw === "number" ? raw : Date.parse(raw || "");
20
+ return Number.isFinite(n) ? n >= cutoff : true;
21
+ });
22
+ const removed = rows.length - kept.length;
23
+ if (removed > 0) writeFileSync(path, JSON.stringify(kept, null, 2), "utf8");
24
+ return { kept: kept.length, removed };
25
+ }
26
+
27
+ export function sweepFileAges(dir, { maxAgeMs = 1000 * 60 * 60 * 24 * 30 } = {}) {
28
+ const root = resolve(dir);
29
+ if (!existsSync(root)) return { removed: 0 };
30
+ const cutoff = Date.now() - maxAgeMs;
31
+ let removed = 0;
32
+ for (const name of readdirSync(root)) {
33
+ const full = join(root, name);
34
+ const st = statSync(full);
35
+ if (st.isDirectory()) continue;
36
+ if (st.mtimeMs < cutoff) {
37
+ rmSync(full, { force: true });
38
+ removed += 1;
39
+ }
40
+ }
41
+ return { removed };
42
+ }
43
+
44
+ export function runRetentionSweep({ root = ".fastscript" } = {}) {
45
+ const resolved = resolve(root);
46
+ const cacheResult = sweepFileAges(join(resolved, "cache"), {
47
+ maxAgeMs: Number(process.env.RETENTION_CACHE_MS || 1000 * 60 * 60 * 24 * 14),
48
+ });
49
+ const jobsResult = applyJsonArrayRetention(join(resolved, "jobs-dead-letter.json"), {
50
+ timestampField: "failedAt",
51
+ maxAgeMs: Number(process.env.RETENTION_DEAD_LETTER_MS || 1000 * 60 * 60 * 24 * 30),
52
+ });
53
+ return {
54
+ cache: cacheResult,
55
+ deadLetter: jobsResult,
56
+ };
57
+ }
@@ -0,0 +1,178 @@
1
+ import { dirname, extname, relative } from "node:path";
2
+
3
+ function cleanSegments(file, pagesDir) {
4
+ const rel = relative(pagesDir, file).replace(/\\/g, "/").replace(extname(file), "");
5
+ return rel.split("/").filter(Boolean);
6
+ }
7
+
8
+ function isGroupSegment(segment) {
9
+ return segment.startsWith("(") && segment.endsWith(")");
10
+ }
11
+
12
+ function isParallelSegment(segment) {
13
+ return segment.startsWith("@");
14
+ }
15
+
16
+ function normalizeParamType(type) {
17
+ const raw = String(type || "string").toLowerCase();
18
+ if (raw === "int" || raw === "float" || raw === "number") return "number";
19
+ if (raw === "bool" || raw === "boolean") return "boolean";
20
+ if (raw === "str" || raw === "string") return "string";
21
+ return "string";
22
+ }
23
+
24
+ function segmentPriority(segment) {
25
+ if (!segment) return 0;
26
+ const token = /^:([A-Za-z_$][\w$]*)(\*)?(\?)?$/.exec(segment);
27
+ if (!token) return 40;
28
+ const catchAll = Boolean(token[2]);
29
+ const optional = Boolean(token[3]);
30
+ if (catchAll && optional) return 5;
31
+ if (catchAll) return 10;
32
+ if (optional) return 20;
33
+ return 30;
34
+ }
35
+
36
+ function routePriority(pathname) {
37
+ const parts = String(pathname || "/").split("/").filter(Boolean);
38
+ if (!parts.length) return 1000;
39
+ return parts.reduce((sum, part) => sum + segmentPriority(part), 0) + parts.length;
40
+ }
41
+
42
+ function routeSegmentValue(segment) {
43
+ if (!segment) return null;
44
+ if (isGroupSegment(segment)) return null;
45
+ if (isParallelSegment(segment)) return null;
46
+ if (segment === "index") return null;
47
+ const optionalCatchAll = /^\[\[\.\.\.([A-Za-z_$][\w$]*)\]\]$/.exec(segment);
48
+ if (optionalCatchAll) {
49
+ return {
50
+ value: `:${optionalCatchAll[1]}*?`,
51
+ param: { name: optionalCatchAll[1], type: "string[] | undefined", optional: true, catchAll: true },
52
+ };
53
+ }
54
+ const requiredCatchAll = /^\[\.\.\.([A-Za-z_$][\w$]*)\]$/.exec(segment);
55
+ if (requiredCatchAll) {
56
+ return {
57
+ value: `:${requiredCatchAll[1]}*`,
58
+ param: { name: requiredCatchAll[1], type: "string[]", optional: false, catchAll: true },
59
+ };
60
+ }
61
+ const optionalTyped = /^\[\[([A-Za-z_$][\w$]*)(?::([A-Za-z]+))?\]\]$/.exec(segment);
62
+ if (optionalTyped) {
63
+ const type = normalizeParamType(optionalTyped[2]);
64
+ return {
65
+ value: `:${optionalTyped[1]}?`,
66
+ param: { name: optionalTyped[1], type: `${type} | undefined`, optional: true, catchAll: false },
67
+ };
68
+ }
69
+ const simpleTyped = /^\[([A-Za-z_$][\w$]*)(?::([A-Za-z]+))?\]$/.exec(segment);
70
+ if (simpleTyped) {
71
+ const type = normalizeParamType(simpleTyped[2]);
72
+ return {
73
+ value: `:${simpleTyped[1]}`,
74
+ param: { name: simpleTyped[1], type, optional: false, catchAll: false },
75
+ };
76
+ }
77
+ return { value: segment, param: null };
78
+ }
79
+
80
+ export function inferRouteMeta(file, pagesDir) {
81
+ const parts = cleanSegments(file, pagesDir);
82
+ const pageFile = parts[parts.length - 1] || "index";
83
+ const parallel = parts.find((segment) => isParallelSegment(segment));
84
+ const slot = parallel ? parallel.slice(1) : null;
85
+ const routeParts = [];
86
+ const params = [];
87
+ const paramTypes = {};
88
+
89
+ for (const part of parts) {
90
+ const mapped = routeSegmentValue(part);
91
+ if (!mapped?.value) continue;
92
+ routeParts.push(mapped.value);
93
+ if (mapped.param) {
94
+ params.push(mapped.param.name);
95
+ paramTypes[mapped.param.name] = mapped.param.type;
96
+ }
97
+ }
98
+
99
+ const routePath = routeParts.length ? `/${routeParts.join("/")}` : "/";
100
+ const normalizedParts = parts.filter((segment) => !isGroupSegment(segment) && !isParallelSegment(segment));
101
+
102
+ return {
103
+ file,
104
+ pageFile,
105
+ routePath,
106
+ params,
107
+ paramTypes,
108
+ slot,
109
+ segments: normalizedParts,
110
+ directory: dirname(relative(pagesDir, file).replace(/\\/g, "/")),
111
+ };
112
+ }
113
+
114
+ export function inferRouteParamTypes(routePath, paramTypes = null) {
115
+ if (paramTypes && typeof paramTypes === "object" && Object.keys(paramTypes).length) {
116
+ return { ...paramTypes };
117
+ }
118
+ const params = {};
119
+ for (const segment of String(routePath || "").split("/")) {
120
+ const token = /^:([A-Za-z_$][\w$]*)(\*)?(\?)?$/.exec(segment);
121
+ if (!token) continue;
122
+ const name = token[1];
123
+ const catchAll = Boolean(token[2]);
124
+ const optional = Boolean(token[3]);
125
+ if (catchAll && optional) params[name] = "string[] | undefined";
126
+ else if (catchAll) params[name] = "string[]";
127
+ else if (optional) params[name] = "string | undefined";
128
+ else params[name] = "string";
129
+ }
130
+ return params;
131
+ }
132
+
133
+ export function inferRouteLayouts(file, pagesDir, layoutFiles = new Set()) {
134
+ const relDir = dirname(relative(pagesDir, file).replace(/\\/g, "/"));
135
+ const chain = [];
136
+ const dirs = relDir === "." ? [] : relDir.split("/").filter(Boolean);
137
+ const probes = [""];
138
+ for (const dir of dirs) {
139
+ probes.push(probes[probes.length - 1] ? `${probes[probes.length - 1]}/${dir}` : dir);
140
+ }
141
+
142
+ for (const probe of probes) {
143
+ const fsLayout = probe ? `${probe}/_layout.fs` : "_layout.fs";
144
+ const jsLayout = probe ? `${probe}/_layout.js` : "_layout.js";
145
+ if (layoutFiles.has(fsLayout)) chain.push(fsLayout);
146
+ else if (layoutFiles.has(jsLayout)) chain.push(jsLayout);
147
+ }
148
+ return chain;
149
+ }
150
+
151
+ export function compareRoutePriority(a, b) {
152
+ const scoreA = routePriority(a?.path || a?.routePath || "/");
153
+ const scoreB = routePriority(b?.path || b?.routePath || "/");
154
+ if (scoreA !== scoreB) return scoreB - scoreA;
155
+
156
+ const lenA = String(a?.path || a?.routePath || "/").split("/").filter(Boolean).length;
157
+ const lenB = String(b?.path || b?.routePath || "/").split("/").filter(Boolean).length;
158
+ if (lenA !== lenB) return lenB - lenA;
159
+
160
+ const slotA = String(a?.slot || "default");
161
+ const slotB = String(b?.slot || "default");
162
+ if (slotA !== slotB) return slotA.localeCompare(slotB);
163
+ return String(a?.path || a?.routePath || "/").localeCompare(String(b?.path || b?.routePath || "/"));
164
+ }
165
+
166
+ export function sortRoutesByPriority(routes = []) {
167
+ return [...routes].sort(compareRoutePriority);
168
+ }
169
+
170
+ export function isLayoutFile(file, pagesDir) {
171
+ const rel = relative(pagesDir, file).replace(/\\/g, "/");
172
+ return rel.endsWith("/_layout.fs") || rel.endsWith("/_layout.js") || rel === "_layout.fs" || rel === "_layout.js";
173
+ }
174
+
175
+ export function isNotFoundFile(file, pagesDir) {
176
+ const rel = relative(pagesDir, file).replace(/\\/g, "/");
177
+ return rel.endsWith("/404.fs") || rel.endsWith("/404.js") || rel === "404.fs" || rel === "404.js";
178
+ }
@@ -0,0 +1,104 @@
1
+ function parseField(field, min, max) {
2
+ const out = new Set();
3
+ const raw = String(field || "*").trim();
4
+ if (raw === "*") {
5
+ for (let i = min; i <= max; i += 1) out.add(i);
6
+ return out;
7
+ }
8
+ for (const part of raw.split(",")) {
9
+ const item = part.trim();
10
+ if (!item) continue;
11
+ const stepSplit = item.split("/");
12
+ const base = stepSplit[0];
13
+ const step = Number(stepSplit[1] || 1);
14
+ const [startRaw, endRaw] = base === "*" ? [String(min), String(max)] : base.split("-");
15
+ const start = Number(startRaw);
16
+ const end = Number(endRaw || startRaw);
17
+ for (let n = start; n <= end; n += step) {
18
+ if (n >= min && n <= max) out.add(n);
19
+ }
20
+ }
21
+ return out;
22
+ }
23
+
24
+ function datePartsInTimezone(date, timezone) {
25
+ const fmt = new Intl.DateTimeFormat("en-US", {
26
+ timeZone: timezone,
27
+ year: "numeric",
28
+ month: "2-digit",
29
+ day: "2-digit",
30
+ hour: "2-digit",
31
+ minute: "2-digit",
32
+ hour12: false,
33
+ weekday: "short",
34
+ });
35
+ const parts = Object.fromEntries(fmt.formatToParts(date).map((p) => [p.type, p.value]));
36
+ const weekdayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
37
+ return {
38
+ minute: Number(parts.minute),
39
+ hour: Number(parts.hour),
40
+ day: Number(parts.day),
41
+ month: Number(parts.month),
42
+ weekday: weekdayMap[parts.weekday],
43
+ };
44
+ }
45
+
46
+ export function parseCronExpression(expr) {
47
+ const [minuteRaw, hourRaw, dayRaw, monthRaw, weekdayRaw] = String(expr || "").trim().split(/\s+/);
48
+ if (!weekdayRaw) {
49
+ const error = new Error(`Invalid cron expression: ${expr}`);
50
+ error.status = 400;
51
+ throw error;
52
+ }
53
+ return {
54
+ minute: parseField(minuteRaw, 0, 59),
55
+ hour: parseField(hourRaw, 0, 23),
56
+ day: parseField(dayRaw, 1, 31),
57
+ month: parseField(monthRaw, 1, 12),
58
+ weekday: parseField(weekdayRaw, 0, 6),
59
+ };
60
+ }
61
+
62
+ export function nextCronRun(expr, { from = new Date(), timezone = "UTC", horizonMinutes = 60 * 24 * 30 } = {}) {
63
+ const parsed = parseCronExpression(expr);
64
+ const cursor = new Date(from.getTime());
65
+ cursor.setSeconds(0, 0);
66
+ cursor.setMinutes(cursor.getMinutes() + 1);
67
+ for (let i = 0; i < horizonMinutes; i += 1) {
68
+ const parts = datePartsInTimezone(cursor, timezone);
69
+ if (
70
+ parsed.minute.has(parts.minute) &&
71
+ parsed.hour.has(parts.hour) &&
72
+ parsed.day.has(parts.day) &&
73
+ parsed.month.has(parts.month) &&
74
+ parsed.weekday.has(parts.weekday)
75
+ ) {
76
+ return new Date(cursor.getTime());
77
+ }
78
+ cursor.setMinutes(cursor.getMinutes() + 1);
79
+ }
80
+ return null;
81
+ }
82
+
83
+ export function normalizeScheduleEntry(entry = {}) {
84
+ if (!entry || typeof entry !== "object") return null;
85
+ if (entry.cron) {
86
+ const timezone = entry.timezone || "UTC";
87
+ const next = nextCronRun(entry.cron, { timezone });
88
+ if (!next) return null;
89
+ return {
90
+ ...entry,
91
+ timezone,
92
+ nextRunAt: next.getTime(),
93
+ everyMs: null,
94
+ dedupeKey: entry.dedupeKey || `cron:${entry.name || "job"}:${timezone}:${entry.cron}`,
95
+ };
96
+ }
97
+ return {
98
+ ...entry,
99
+ timezone: entry.timezone || "UTC",
100
+ everyMs: entry.everyMs ?? 1000,
101
+ nextRunAt: Date.now() + (entry.everyMs ?? 1000),
102
+ dedupeKey: entry.dedupeKey || `interval:${entry.name || "job"}`,
103
+ };
104
+ }