fastscript 0.1.1 → 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 (108) hide show
  1. package/CHANGELOG.md +31 -2
  2. package/LICENSE +33 -21
  3. package/README.md +568 -59
  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 +88 -8
  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 -222
  70. package/src/cache.mjs +210 -20
  71. package/src/cli.mjs +29 -5
  72. package/src/compat.mjs +7 -1
  73. package/src/create.mjs +65 -11
  74. package/src/csp.mjs +26 -0
  75. package/src/db-cli.mjs +158 -18
  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 +17 -26
  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 +46 -0
  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
package/src/i18n.mjs ADDED
@@ -0,0 +1,25 @@
1
+ export function getI18nConfig(env = process.env) {
2
+ const locales = String(env.I18N_LOCALES || "en").split(",").map((x) => x.trim()).filter(Boolean);
3
+ const defaultLocale = String(env.I18N_DEFAULT_LOCALE || locales[0] || "en").trim() || "en";
4
+ return { locales, defaultLocale };
5
+ }
6
+
7
+ export function resolveLocaleFromPath(pathname, config = getI18nConfig()) {
8
+ const parts = String(pathname || "/").split("/").filter(Boolean);
9
+ const first = parts[0];
10
+ if (first && config.locales.includes(first)) {
11
+ const nextPath = `/${parts.slice(1).join("/")}`;
12
+ return {
13
+ locale: first,
14
+ pathname: nextPath === "/" ? "/" : nextPath.replace(/\/+$/, "") || "/",
15
+ isLocalized: true,
16
+ };
17
+ }
18
+ return { locale: config.defaultLocale, pathname: pathname || "/", isLocalized: false };
19
+ }
20
+
21
+ export function withLocalePath(pathname, locale, config = getI18nConfig()) {
22
+ const normalized = String(pathname || "/").startsWith("/") ? String(pathname || "/") : `/${pathname}`;
23
+ if (!locale || locale === config.defaultLocale) return normalized;
24
+ return `/${locale}${normalized === "/" ? "" : normalized}`;
25
+ }
package/src/jobs.mjs CHANGED
@@ -1,28 +1,47 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { importSourceModule } from "./module-loader.mjs";
5
+ import { nextCronRun, normalizeScheduleEntry } from "./scheduler.mjs";
4
6
 
5
7
  function readJson(path, fallback) {
6
8
  if (!existsSync(path)) return fallback;
7
- try { return JSON.parse(readFileSync(path, "utf8")); } catch { return fallback; }
9
+ try {
10
+ return JSON.parse(readFileSync(path, "utf8"));
11
+ } catch {
12
+ return fallback;
13
+ }
8
14
  }
9
15
 
10
16
  function writeJson(path, value) {
11
17
  writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
12
18
  }
13
19
 
14
- export function createJobQueue({ dir = ".fastscript" } = {}) {
20
+ function createFileQueue({ dir = ".fastscript" } = {}) {
15
21
  const root = resolve(dir);
16
22
  mkdirSync(root, { recursive: true });
17
- const path = join(root, "jobs.json");
18
- const state = readJson(path, { jobs: [] });
23
+ const jobsPath = join(root, "jobs.json");
24
+ const deadPath = join(root, "jobs-dead-letter.json");
25
+ const state = readJson(jobsPath, { jobs: [] });
26
+ const dead = readJson(deadPath, { jobs: [] });
19
27
 
20
- function persist() { writeJson(path, state); }
28
+ function persist() {
29
+ writeJson(jobsPath, state);
30
+ }
31
+ function persistDead() {
32
+ writeJson(deadPath, dead);
33
+ }
21
34
 
22
35
  return {
36
+ type: "file",
23
37
  enqueue(name, payload = {}, opts = {}) {
24
38
  const now = Date.now();
25
39
  const delayMs = opts.delayMs ?? 0;
40
+ const dedupeKey = opts.dedupeKey || null;
41
+ if (dedupeKey) {
42
+ const duplicate = state.jobs.find((j) => j.name === name && j.dedupeKey === dedupeKey);
43
+ if (duplicate) return duplicate;
44
+ }
26
45
  const job = {
27
46
  id: randomUUID(),
28
47
  name,
@@ -32,6 +51,9 @@ export function createJobQueue({ dir = ".fastscript" } = {}) {
32
51
  maxAttempts: opts.maxAttempts ?? 3,
33
52
  backoffMs: opts.backoffMs ?? 250,
34
53
  repeatEveryMs: opts.repeatEveryMs ?? 0,
54
+ cron: opts.cron || null,
55
+ timezone: opts.timezone || "UTC",
56
+ dedupeKey,
35
57
  };
36
58
  state.jobs.push(job);
37
59
  persist();
@@ -39,42 +61,219 @@ export function createJobQueue({ dir = ".fastscript" } = {}) {
39
61
  },
40
62
  peekReady(limit = 10) {
41
63
  const now = Date.now();
42
- return state.jobs.filter((j) => j.runAt <= now).slice(0, limit);
64
+ return state.jobs.filter((job) => job.runAt <= now).slice(0, limit);
43
65
  },
44
66
  ack(id) {
45
- state.jobs = state.jobs.filter((j) => j.id !== id);
67
+ state.jobs = state.jobs.filter((job) => job.id !== id);
46
68
  persist();
47
69
  },
48
- fail(job) {
70
+ fail(job, error = null) {
49
71
  const idx = state.jobs.findIndex((j) => j.id === job.id);
50
72
  if (idx < 0) return;
51
73
  const current = state.jobs[idx];
52
74
  current.attempts += 1;
53
75
  if (current.attempts >= current.maxAttempts) {
76
+ dead.jobs.push({
77
+ ...current,
78
+ failedAt: Date.now(),
79
+ error: error ? String(error?.message || error) : null,
80
+ });
54
81
  state.jobs.splice(idx, 1);
82
+ persistDead();
55
83
  } else {
56
84
  current.runAt = Date.now() + current.backoffMs * current.attempts;
57
85
  }
58
86
  persist();
59
87
  },
88
+ deadLetter() {
89
+ return [...dead.jobs];
90
+ },
91
+ replayDeadLetter({ limit = 20, name = null } = {}) {
92
+ const out = [];
93
+ const keep = [];
94
+ for (const row of dead.jobs) {
95
+ if (out.length >= limit) {
96
+ keep.push(row);
97
+ continue;
98
+ }
99
+ if (name && row.name !== name) {
100
+ keep.push(row);
101
+ continue;
102
+ }
103
+ const replay = {
104
+ ...row,
105
+ id: randomUUID(),
106
+ attempts: 0,
107
+ runAt: Date.now(),
108
+ };
109
+ delete replay.failedAt;
110
+ delete replay.error;
111
+ state.jobs.push(replay);
112
+ out.push(replay);
113
+ }
114
+ dead.jobs = keep;
115
+ persist();
116
+ persistDead();
117
+ return out;
118
+ },
60
119
  list() {
61
120
  return [...state.jobs];
62
121
  },
63
122
  };
64
123
  }
65
124
 
125
+ export async function createRedisJobQueue({
126
+ url = process.env.REDIS_URL,
127
+ prefix = "fastscript:queue",
128
+ } = {}) {
129
+ const mod = await import("redis");
130
+ const client = mod.createClient({ url });
131
+ await client.connect();
132
+
133
+ const keys = {
134
+ ready: `${prefix}:ready`,
135
+ dead: `${prefix}:dead`,
136
+ };
137
+ const rowKey = (id) => `${prefix}:job:${id}`;
138
+ const dedupeKey = (name, key) => `${prefix}:dedupe:${name}:${key}`;
139
+
140
+ return {
141
+ type: "redis",
142
+ async enqueue(name, payload = {}, opts = {}) {
143
+ const id = randomUUID();
144
+ const runAt = Date.now() + (opts.delayMs ?? 0);
145
+ const row = {
146
+ id,
147
+ name,
148
+ payload,
149
+ runAt,
150
+ attempts: 0,
151
+ maxAttempts: opts.maxAttempts ?? 3,
152
+ backoffMs: opts.backoffMs ?? 250,
153
+ repeatEveryMs: opts.repeatEveryMs ?? 0,
154
+ cron: opts.cron || null,
155
+ timezone: opts.timezone || "UTC",
156
+ dedupeKey: opts.dedupeKey || null,
157
+ };
158
+ if (row.dedupeKey) {
159
+ const key = dedupeKey(name, row.dedupeKey);
160
+ const existing = await client.get(key);
161
+ if (existing) {
162
+ const existingJob = await client.get(rowKey(existing));
163
+ if (existingJob) return JSON.parse(existingJob);
164
+ }
165
+ await client.set(key, id);
166
+ }
167
+ await client.set(rowKey(id), JSON.stringify(row));
168
+ await client.zAdd(keys.ready, { score: runAt, value: id });
169
+ return row;
170
+ },
171
+ async peekReady(limit = 10) {
172
+ const ids = await client.zRangeByScore(keys.ready, 0, Date.now(), { LIMIT: { offset: 0, count: limit } });
173
+ const out = [];
174
+ for (const id of ids) {
175
+ const raw = await client.get(rowKey(id));
176
+ if (!raw) continue;
177
+ out.push(JSON.parse(raw));
178
+ }
179
+ return out;
180
+ },
181
+ async ack(id) {
182
+ const raw = await client.get(rowKey(id));
183
+ if (raw) {
184
+ const row = JSON.parse(raw);
185
+ if (row.dedupeKey) await client.del(dedupeKey(row.name, row.dedupeKey));
186
+ }
187
+ await client.zRem(keys.ready, id);
188
+ await client.del(rowKey(id));
189
+ },
190
+ async fail(job, error = null) {
191
+ const raw = await client.get(rowKey(job.id));
192
+ if (!raw) return;
193
+ const current = JSON.parse(raw);
194
+ current.attempts += 1;
195
+ if (current.attempts >= current.maxAttempts) {
196
+ current.failedAt = Date.now();
197
+ current.error = error ? String(error?.message || error) : null;
198
+ await client.rPush(keys.dead, JSON.stringify(current));
199
+ await this.ack(current.id);
200
+ } else {
201
+ current.runAt = Date.now() + current.backoffMs * current.attempts;
202
+ await client.set(rowKey(current.id), JSON.stringify(current));
203
+ await client.zAdd(keys.ready, { score: current.runAt, value: current.id });
204
+ }
205
+ },
206
+ async deadLetter() {
207
+ const rows = await client.lRange(keys.dead, 0, -1);
208
+ return rows.map((row) => JSON.parse(row));
209
+ },
210
+ async replayDeadLetter({ limit = 20, name = null } = {}) {
211
+ const rows = await this.deadLetter();
212
+ const picked = [];
213
+ const keep = [];
214
+ for (const row of rows) {
215
+ if (picked.length >= limit) {
216
+ keep.push(row);
217
+ continue;
218
+ }
219
+ if (name && row.name !== name) {
220
+ keep.push(row);
221
+ continue;
222
+ }
223
+ picked.push(row);
224
+ }
225
+ await client.del(keys.dead);
226
+ if (keep.length) await client.rPush(keys.dead, ...keep.map((row) => JSON.stringify(row)));
227
+ for (const row of picked) {
228
+ await this.enqueue(row.name, row.payload, {
229
+ delayMs: 0,
230
+ maxAttempts: row.maxAttempts,
231
+ backoffMs: row.backoffMs,
232
+ repeatEveryMs: row.repeatEveryMs,
233
+ cron: row.cron,
234
+ timezone: row.timezone,
235
+ dedupeKey: row.dedupeKey,
236
+ });
237
+ }
238
+ return picked;
239
+ },
240
+ async list() {
241
+ return this.peekReady(1000);
242
+ },
243
+ async close() {
244
+ await client.quit();
245
+ },
246
+ };
247
+ }
248
+
249
+ export function createJobQueue({ dir = ".fastscript" } = {}) {
250
+ return createFileQueue({ dir });
251
+ }
252
+
253
+ export async function createDistributedJobQueue({ dir = ".fastscript", driver = process.env.JOBS_DRIVER || "file" } = {}) {
254
+ const mode = String(driver).toLowerCase();
255
+ if (mode === "redis") {
256
+ try {
257
+ return await createRedisJobQueue();
258
+ } catch {
259
+ return createFileQueue({ dir });
260
+ }
261
+ }
262
+ return createFileQueue({ dir });
263
+ }
264
+
66
265
  export async function loadJobHandlers({ root = process.cwd() } = {}) {
67
266
  const fs = await import("node:fs");
68
267
  const path = await import("node:path");
69
268
  const jobsDir = path.join(root, "app", "jobs");
70
269
  const handlers = new Map();
71
270
  if (!fs.existsSync(jobsDir)) return handlers;
72
- const files = fs.readdirSync(jobsDir).filter((f) => /\.(js|mjs|cjs)$/.test(f));
271
+ const files = fs.readdirSync(jobsDir).filter((f) => /\.(fs|js|mjs|cjs)$/.test(f));
73
272
  for (const file of files) {
74
- if (file === "schedules.js") continue;
75
- const full = path.join(jobsDir, file).replace(/\\/g, "/");
76
- const mod = await import(`${new URL(`file://${full}`).href}?t=${Date.now()}`);
77
- const name = mod.name || file.replace(/\.(js|mjs|cjs)$/, "");
273
+ if (file === "schedules.js" || file === "schedules.fs") continue;
274
+ const full = path.join(jobsDir, file);
275
+ const mod = await importSourceModule(full, { platform: "node" });
276
+ const name = mod.name || file.replace(/\.(fs|js|mjs|cjs)$/, "");
78
277
  const handle = mod.handle || mod.default;
79
278
  if (typeof handle === "function") handlers.set(name, handle);
80
279
  }
@@ -84,44 +283,96 @@ export async function loadJobHandlers({ root = process.cwd() } = {}) {
84
283
  export async function loadSchedules({ root = process.cwd() } = {}) {
85
284
  const fs = await import("node:fs");
86
285
  const path = await import("node:path");
87
- const file = path.join(root, "app", "jobs", "schedules.js");
286
+ const jsFile = path.join(root, "app", "jobs", "schedules.js");
287
+ const fsFile = path.join(root, "app", "jobs", "schedules.fs");
288
+ const file = fs.existsSync(fsFile) ? fsFile : jsFile;
88
289
  if (!fs.existsSync(file)) return [];
89
- const mod = await import(`${new URL(`file://${file.replace(/\\/g, "/")}`).href}?t=${Date.now()}`);
90
- return mod.schedules || mod.default || [];
290
+ const mod = await importSourceModule(file, { platform: "node" });
291
+ const schedules = mod.schedules || mod.default || [];
292
+ return (Array.isArray(schedules) ? schedules : []).map((row) => normalizeScheduleEntry(row)).filter(Boolean);
91
293
  }
92
294
 
93
- export async function runWorker({ dir = ".fastscript", pollMs = 350 } = {}) {
94
- const queue = createJobQueue({ dir });
295
+ function nextRunForJob(job) {
296
+ if (job.cron) {
297
+ const next = nextCronRun(job.cron, { timezone: job.timezone || "UTC" });
298
+ return next ? next.getTime() : null;
299
+ }
300
+ if (job.repeatEveryMs > 0) return Date.now() + job.repeatEveryMs;
301
+ return null;
302
+ }
303
+
304
+ export async function runWorker({ dir = ".fastscript", pollMs = 350, driver = process.env.JOBS_DRIVER || "file" } = {}) {
305
+ const queue = await createDistributedJobQueue({ dir, driver });
95
306
  const handlers = await loadJobHandlers();
96
307
  const schedules = await loadSchedules();
308
+ let inFlight = 0;
309
+ let stopping = false;
97
310
 
98
311
  for (const s of schedules) {
99
312
  if (!s || !s.name) continue;
100
- const exists = queue.list().some((j) => j.name === s.name && j.runAt > Date.now());
101
- if (!exists) queue.enqueue(s.name, s.payload || {}, { delayMs: s.everyMs ?? 1000, maxAttempts: s.maxAttempts ?? 3, repeatEveryMs: s.everyMs ?? 0 });
313
+ await queue.enqueue(s.name, s.payload || {}, {
314
+ delayMs: Math.max(0, (s.nextRunAt || Date.now()) - Date.now()),
315
+ maxAttempts: s.maxAttempts ?? 3,
316
+ repeatEveryMs: s.everyMs || 0,
317
+ cron: s.cron || null,
318
+ timezone: s.timezone || "UTC",
319
+ dedupeKey: s.dedupeKey || `schedule:${s.name}`,
320
+ });
102
321
  }
103
322
 
104
- console.log(`worker started: handlers=${handlers.size}`);
323
+ console.log(`worker started: handlers=${handlers.size} driver=${driver}`);
105
324
  const timer = setInterval(async () => {
106
- const jobs = queue.peekReady(20);
325
+ if (stopping) return;
326
+ const jobs = await queue.peekReady(20);
107
327
  for (const job of jobs) {
108
328
  const handle = handlers.get(job.name);
109
329
  if (!handle) {
110
- queue.fail(job);
330
+ await queue.fail(job, "handler_not_found");
111
331
  continue;
112
332
  }
333
+ inFlight += 1;
113
334
  try {
114
335
  await handle(job.payload, { queue });
115
- if (job.repeatEveryMs > 0) {
116
- queue.enqueue(job.name, job.payload, { delayMs: job.repeatEveryMs, maxAttempts: job.maxAttempts, backoffMs: job.backoffMs, repeatEveryMs: job.repeatEveryMs });
336
+ const nextRun = nextRunForJob(job);
337
+ if (nextRun) {
338
+ await queue.enqueue(job.name, job.payload, {
339
+ delayMs: Math.max(0, nextRun - Date.now()),
340
+ maxAttempts: job.maxAttempts,
341
+ backoffMs: job.backoffMs,
342
+ repeatEveryMs: job.repeatEveryMs,
343
+ cron: job.cron,
344
+ timezone: job.timezone,
345
+ dedupeKey: job.dedupeKey || null,
346
+ });
117
347
  }
118
- queue.ack(job.id);
119
- } catch {
120
- queue.fail(job);
348
+ await queue.ack(job.id);
349
+ } catch (error) {
350
+ await queue.fail(job, error);
351
+ } finally {
352
+ inFlight -= 1;
121
353
  }
122
354
  }
123
355
  }, pollMs);
124
356
 
125
- process.on("SIGINT", () => { clearInterval(timer); process.exit(0); });
126
- process.on("SIGTERM", () => { clearInterval(timer); process.exit(0); });
127
- }
357
+ async function shutdown() {
358
+ if (stopping) return;
359
+ stopping = true;
360
+ clearInterval(timer);
361
+ const started = Date.now();
362
+ while (inFlight > 0 && Date.now() - started < 3000) {
363
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
364
+ }
365
+ if (queue.close) await queue.close();
366
+ process.exit(0);
367
+ }
368
+
369
+ process.on("SIGINT", shutdown);
370
+ process.on("SIGTERM", shutdown);
371
+ }
372
+
373
+ export async function replayDeadLetter({ dir = ".fastscript", limit = 20, name = null, driver = process.env.JOBS_DRIVER || "file" } = {}) {
374
+ const queue = await createDistributedJobQueue({ dir, driver });
375
+ const replayed = await queue.replayDeadLetter({ limit, name });
376
+ if (queue.close) await queue.close();
377
+ return replayed;
378
+ }
@@ -0,0 +1,45 @@
1
+ import { existsSync, mkdirSync, readFileSync, 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 { return JSON.parse(readFileSync(path, "utf8")); } catch { return fallback; }
7
+ }
8
+
9
+ function writeJson(path, value) {
10
+ writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
11
+ }
12
+
13
+ export function createMetricsStore({ dir = ".fastscript", name = "metrics" } = {}) {
14
+ const root = resolve(dir);
15
+ mkdirSync(root, { recursive: true });
16
+ const file = join(root, `${name}.json`);
17
+ const state = readJson(file, { counters: {}, timings: {} });
18
+
19
+ function persist() {
20
+ writeJson(file, state);
21
+ }
22
+
23
+ return {
24
+ inc(counter, by = 1) {
25
+ state.counters[counter] = (state.counters[counter] || 0) + by;
26
+ persist();
27
+ },
28
+ observe(nameKey, valueMs) {
29
+ const row = state.timings[nameKey] || { count: 0, total: 0, min: null, max: null };
30
+ row.count += 1;
31
+ row.total += valueMs;
32
+ row.min = row.min === null ? valueMs : Math.min(row.min, valueMs);
33
+ row.max = row.max === null ? valueMs : Math.max(row.max, valueMs);
34
+ state.timings[nameKey] = row;
35
+ persist();
36
+ },
37
+ snapshot() {
38
+ const avg = {};
39
+ for (const [k, row] of Object.entries(state.timings)) {
40
+ avg[k] = row.count > 0 ? Number((row.total / row.count).toFixed(2)) : 0;
41
+ }
42
+ return { counters: { ...state.counters }, timings: { ...state.timings }, averages: avg };
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,16 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { normalizeFastScript, stripTypeScriptHints } from "./fs-normalize.mjs";
4
+
5
+ export async function runMigrationWizard(args = []) {
6
+ const pathArg = args[0] || "app/pages/index.js";
7
+ const abs = resolve(pathArg);
8
+ if (!existsSync(abs)) throw new Error(`migration wizard: file not found (${abs})`);
9
+ const raw = readFileSync(abs, "utf8");
10
+ let preview = raw;
11
+ if (/\.(ts|tsx)$/.test(abs)) preview = stripTypeScriptHints(preview);
12
+ preview = normalizeFastScript(preview, { file: abs, mode: "lenient" });
13
+ console.log("=== migration preview ===");
14
+ console.log(preview);
15
+ console.log("=== end preview ===");
16
+ }
@@ -0,0 +1,46 @@
1
+ import { extname } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import esbuild from "esbuild";
4
+ import { normalizeFastScript } from "./fs-normalize.mjs";
5
+ import { assertFastScript } from "./fs-diagnostics.mjs";
6
+
7
+ function fsLoaderPlugin() {
8
+ const compilerMode = (process.env.FASTSCRIPT_COMPILER_MODE || "strict").toLowerCase() === "lenient" ? "lenient" : "strict";
9
+ return {
10
+ name: "fastscript-fs-loader",
11
+ setup(build) {
12
+ build.onLoad({ filter: /\.fs$/ }, async (args) => {
13
+ const { readFile } = await import("node:fs/promises");
14
+ const raw = await readFile(args.path, "utf8");
15
+ assertFastScript(raw, { file: args.path, mode: compilerMode });
16
+ return {
17
+ contents: normalizeFastScript(raw, { file: args.path, mode: compilerMode, sourceMap: "inline" }),
18
+ loader: "js",
19
+ };
20
+ });
21
+ },
22
+ };
23
+ }
24
+
25
+ export async function importSourceModule(filePath, { platform = "node" } = {}) {
26
+ const ext = extname(filePath).toLowerCase();
27
+ if (ext !== ".fs") {
28
+ return import(`${pathToFileURL(filePath).href}?t=${Date.now()}`);
29
+ }
30
+
31
+ const result = await esbuild.build({
32
+ entryPoints: [filePath],
33
+ bundle: true,
34
+ platform,
35
+ format: "esm",
36
+ write: false,
37
+ logLevel: "silent",
38
+ resolveExtensions: [".fs", ".js", ".mjs", ".cjs", ".json"],
39
+ plugins: [fsLoaderPlugin()],
40
+ loader: { ".fs": "js" },
41
+ });
42
+
43
+ const code = result.outputFiles[0].text;
44
+ const dataUrl = `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`;
45
+ return import(dataUrl);
46
+ }
@@ -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
+ }