@tatara/cli 0.6.0 → 0.8.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 (26) hide show
  1. package/dist/bin.mjs +328 -17
  2. package/package.json +1 -1
  3. package/templates/features/email/files/src/core/email/send.ts +8 -17
  4. package/templates/features/i18n/files/src/core/i18n/index.ts +5 -35
  5. package/templates/features/jobs/files/src/apps/cron/index.ts +4 -21
  6. package/templates/features/jobs/files/src/apps/worker/index.ts +11 -19
  7. package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +1 -1
  8. package/templates/features/jobs/files/src/core/jobs/index.ts +10 -0
  9. package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +1 -1
  10. package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +1 -1
  11. package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +4 -4
  12. package/templates/features/storage/files/src/core/storage/commands/check.ts +3 -3
  13. package/templates/features/storage/files/src/core/storage/index.ts +19 -5
  14. package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +1 -1
  15. package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +1 -1
  16. package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +1 -1
  17. package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +1 -1
  18. package/templates/versions.json +11 -7
  19. package/templates/features/jobs/files/src/core/jobs/define.ts +0 -37
  20. package/templates/features/jobs/files/src/core/jobs/lock.ts +0 -17
  21. package/templates/features/jobs/files/src/core/jobs/process.ts +0 -19
  22. package/templates/features/jobs/files/src/core/jobs/queue.ts +0 -29
  23. package/templates/features/jobs/files/src/core/jobs/redis.ts +0 -17
  24. package/templates/features/storage/files/src/core/storage/local.ts +0 -34
  25. package/templates/features/storage/files/src/core/storage/s3.ts +0 -66
  26. package/templates/features/storage/files/src/core/storage/storage.ts +0 -21
package/dist/bin.mjs CHANGED
@@ -190,6 +190,18 @@ function ignored(v, text) {
190
190
  return new RegExp(`tatara-check-ignore\\s+${v.rule}:\\s*\\S`).test(previous);
191
191
  }
192
192
  //#endregion
193
+ //#region src/project/ejected.ts
194
+ /** Packages the app ejected (`tatara eject`); their code lives in src/ejected/<name>. */
195
+ function ejectedPackages(config) {
196
+ return Object.keys(config.ejected ?? {});
197
+ }
198
+ /** Points `@tatara/<name>` and `@tatara/<name>/<entry>` imports at the app's ejected copy. */
199
+ function pointAtEjected(text, names) {
200
+ if (names.length === 0) return text;
201
+ const pattern = new RegExp(`(["'])@tatara/(${names.join("|")})(?:/([\\w-]+))?\\1`, "g");
202
+ return text.replace(pattern, (_match, quote, name, entry) => `${quote}#ejected/${name}/${entry ?? "index"}${quote}`);
203
+ }
204
+ //#endregion
193
205
  //#region src/commands/sync.ts
194
206
  var sync_exports = /* @__PURE__ */ __exportAll({
195
207
  specifier: () => specifier,
@@ -235,19 +247,21 @@ async function writeIfChanged(path, content, written) {
235
247
  }
236
248
  async function sync(project = loadProject()) {
237
249
  const { root } = project;
250
+ const ejected = ejectedPackages(project.config);
251
+ const write = (path, content, out) => writeIfChanged(path, pointAtEjected(content, ejected), out);
238
252
  const out = join(root, "src/generated");
239
253
  await mkdir(out, { recursive: true });
240
254
  const written = [];
241
255
  const dirs = ["src/core/*", "src/modules/*"];
242
256
  const routes = await find(root, dirs.map((d) => `${d}/routes/*.routes.ts`));
243
- await writeIfChanged(join(out, "routes.ts"), listModule(routes, {
257
+ await write(join(out, "routes.ts"), listModule(routes, {
244
258
  prefix: "routes",
245
259
  typeImport: "import type { RouteDefinition } from \"#core/http/routes\";",
246
260
  typeName: "RouteDefinition",
247
261
  exportName: "routes"
248
262
  }), written);
249
263
  const commands = await find(root, dirs.map((d) => `${d}/commands/*.ts`));
250
- await writeIfChanged(join(out, "commands.ts"), listModule(commands, {
264
+ await write(join(out, "commands.ts"), listModule(commands, {
251
265
  prefix: "command",
252
266
  typeImport: "import type { CommandDefinition } from \"@tatara/core\";",
253
267
  typeName: "CommandDefinition",
@@ -255,16 +269,16 @@ async function sync(project = loadProject()) {
255
269
  }), written);
256
270
  if (project.has("jobs")) {
257
271
  const consumers = await find(root, dirs.map((d) => `${d}/consumers/*.ts`));
258
- await writeIfChanged(join(out, "consumers.ts"), listModule(consumers, {
272
+ await write(join(out, "consumers.ts"), listModule(consumers, {
259
273
  prefix: "consumer",
260
- typeImport: "import type { ConsumerDefinition } from \"#core/jobs/define\";",
274
+ typeImport: "import type { ConsumerDefinition } from \"@tatara/jobs\";",
261
275
  typeName: "ConsumerDefinition",
262
276
  exportName: "consumers"
263
277
  }), written);
264
278
  const schedules = await find(root, dirs.map((d) => `${d}/schedules/*.ts`));
265
- await writeIfChanged(join(out, "schedules.ts"), listModule(schedules, {
279
+ await write(join(out, "schedules.ts"), listModule(schedules, {
266
280
  prefix: "schedule",
267
- typeImport: "import type { ScheduleDefinition } from \"#core/jobs/define\";",
281
+ typeImport: "import type { ScheduleDefinition } from \"@tatara/jobs\";",
268
282
  typeName: "ScheduleDefinition",
269
283
  exportName: "schedules"
270
284
  }), written);
@@ -273,7 +287,7 @@ async function sync(project = loadProject()) {
273
287
  const permissions = await find(root, ["src/modules/*/permissions/*.permissions.ts"]);
274
288
  const imports = permissions.map((f) => `import ${identifier(f, "permissions")} from "${specifier(f)}";`);
275
289
  const names = permissions.map((f) => identifier(f, "permissions"));
276
- await writeIfChanged(join(out, "permissions.ts"), `${HEADER}import { authzPermissions, mergeCatalogs, type PermissionKeysOf } from "@tatara/authz";
290
+ await write(join(out, "permissions.ts"), `${HEADER}import { authzPermissions, mergeCatalogs, type PermissionKeysOf } from "@tatara/authz";
277
291
  ${imports.join("\n")}${imports.length ? "\n" : ""}
278
292
  export const catalog = mergeCatalogs(authzPermissions${names.map((n) => `, ${n}`).join("")});
279
293
 
@@ -283,7 +297,7 @@ export type PermissionKey =
283
297
  }
284
298
  if (project.config.database !== "none") {
285
299
  const seeds = await find(root, dirs.map((d) => `${d}/db/seed.ts`));
286
- await writeIfChanged(join(out, "seeds.ts"), listModule(seeds, {
300
+ await write(join(out, "seeds.ts"), listModule(seeds, {
287
301
  prefix: "seed",
288
302
  typeImport: "import type { SeedDefinition } from \"@tatara/db\";",
289
303
  typeName: "SeedDefinition",
@@ -292,7 +306,7 @@ export type PermissionKey =
292
306
  }
293
307
  if (project.config.orm === "drizzle") {
294
308
  const schemas = await find(root, dirs.map((d) => `${d}/db/*.schema.ts`));
295
- await writeIfChanged(join(out, "schema.ts"), `${HEADER}${schemas.map((f) => `export * from "${specifier(f)}";`).join("\n")}\nexport {};\n`, written);
309
+ await write(join(out, "schema.ts"), `${HEADER}${schemas.map((f) => `export * from "${specifier(f)}";`).join("\n")}\nexport {};\n`, written);
296
310
  }
297
311
  if (project.config.orm === "prisma") {
298
312
  const bin = join(root, "node_modules/.bin/prisma");
@@ -650,6 +664,121 @@ function printDoctor(checks) {
650
664
  }
651
665
  }
652
666
  //#endregion
667
+ //#region src/commands/eject.ts
668
+ var EjectError = class extends Error {};
669
+ const short = (name) => name.replace(/^@tatara\//, "");
670
+ const full = (name) => `@tatara/${short(name)}`;
671
+ const NOT_LIBRARIES = /* @__PURE__ */ new Set(["cli", "create-tatara"]);
672
+ async function readJson(path) {
673
+ return JSON.parse(await readFile(path, "utf8"));
674
+ }
675
+ /**
676
+ * Copies a package's source into src/ejected/<name>, points the app's imports at it and records
677
+ * it in tatara.json. From then on the app owns that code and `tatara upgrade` leaves it alone.
678
+ */
679
+ async function eject(project, name, opts = {}) {
680
+ const target = short(name);
681
+ if (NOT_LIBRARIES.has(target)) throw new EjectError(`${full(target)} is a tool, not a library`);
682
+ const appPkgPath = join(project.root, "package.json");
683
+ const appPkg = await readJson(appPkgPath);
684
+ const installed = Object.keys({
685
+ ...appPkg.dependencies,
686
+ ...appPkg.devDependencies
687
+ }).filter((dep) => dep.startsWith("@tatara/") && !NOT_LIBRARIES.has(short(dep))).map(short);
688
+ const already = ejectedPackages(project.config);
689
+ if (already.includes(target)) throw new EjectError(`${full(target)} is already ejected`);
690
+ if (!installed.includes(target)) throw new EjectError(`${full(target)} is not a dependency of this app`);
691
+ const manifests = /* @__PURE__ */ new Map();
692
+ for (const dep of installed) {
693
+ const path = join(project.root, "node_modules", full(dep), "package.json");
694
+ if (existsSync(path)) manifests.set(dep, await readJson(path));
695
+ }
696
+ const chain = [target];
697
+ for (let i = 0; i < chain.length; i++) for (const [dep, manifest] of manifests) {
698
+ if (chain.includes(dep)) continue;
699
+ if (full(chain[i]) in (manifest.peerDependencies ?? {})) chain.push(dep);
700
+ }
701
+ const dependents = chain.slice(1);
702
+ if (dependents.length > 0 && !opts.withDependents) throw new EjectError(`${dependents.map(full).join(", ")} depend on ${full(target)}. Eject them too with --with-dependents.`);
703
+ const report = {
704
+ ejected: chain,
705
+ copied: [],
706
+ rewritten: [],
707
+ added: []
708
+ };
709
+ const versions = {};
710
+ for (const dep of chain) {
711
+ const manifest = manifests.get(dep);
712
+ const source = join(project.root, "node_modules", full(dep), "src");
713
+ if (!manifest || !existsSync(source)) throw new EjectError(`${full(dep)} ships no source to eject; update it to a version that does, then run this again`);
714
+ versions[dep] = manifest.version;
715
+ const files = await glob(["**/*.ts"], {
716
+ cwd: source,
717
+ ignore: ["**/*.test.ts"]
718
+ });
719
+ for (const file of files) {
720
+ const to = `src/ejected/${dep}/${file}`;
721
+ await mkdir(dirname(join(project.root, to)), { recursive: true });
722
+ await cp(join(source, file), join(project.root, to));
723
+ report.copied.push(to);
724
+ }
725
+ }
726
+ const ejected = [...already, ...chain];
727
+ for (const file of await glob(["src/**/*.ts", "tests/**/*.ts"], { cwd: project.root })) {
728
+ const path = join(project.root, file);
729
+ const text = await readFile(path, "utf8");
730
+ const next = pointAtEjected(text, ejected);
731
+ if (next !== text) {
732
+ await writeFile(path, next);
733
+ if (!report.copied.includes(file)) report.rewritten.push(file);
734
+ }
735
+ }
736
+ const deps = { ...appPkg.dependencies };
737
+ const listed = new Set(Object.keys({
738
+ ...appPkg.dependencies,
739
+ ...appPkg.devDependencies
740
+ }));
741
+ for (const dep of chain) {
742
+ const manifest = manifests.get(dep);
743
+ for (const [peer, range] of Object.entries(manifest.peerDependencies ?? {})) {
744
+ if (listed.has(peer) || manifest.peerDependenciesMeta?.[peer]?.optional) continue;
745
+ if (peer.startsWith("@tatara/") && ejected.includes(short(peer))) continue;
746
+ deps[peer] = range;
747
+ listed.add(peer);
748
+ report.added.push(peer);
749
+ }
750
+ }
751
+ for (const dep of chain) delete deps[full(dep)];
752
+ const devDeps = { ...appPkg.devDependencies };
753
+ for (const dep of chain) delete devDeps[full(dep)];
754
+ const nextPkg = {
755
+ ...appPkg,
756
+ imports: {
757
+ ...appPkg.imports,
758
+ "#ejected/*": "./src/ejected/*.ts"
759
+ },
760
+ dependencies: Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b))),
761
+ devDependencies: devDeps
762
+ };
763
+ await writeFile(appPkgPath, `${JSON.stringify(nextPkg, null, " ")}\n`);
764
+ const configPath = join(project.root, "tatara.json");
765
+ const config = await readJson(configPath);
766
+ await writeFile(configPath, `${JSON.stringify({
767
+ ...config,
768
+ ejected: {
769
+ ...config.ejected,
770
+ ...versions
771
+ }
772
+ }, null, " ")}\n`);
773
+ if (opts.formatFile) for (const file of [
774
+ ...report.copied,
775
+ ...report.rewritten,
776
+ "package.json",
777
+ "tatara.json"
778
+ ]) await opts.formatFile(file, project);
779
+ return report;
780
+ }
781
+ //#endregion
653
782
  //#region src/paths.ts
654
783
  /** Package root: one level above both src/ (dev) and dist/ (published, bundled). */
655
784
  const PACKAGE_ROOT = resolve(import.meta.dirname, "..");
@@ -731,6 +860,7 @@ const jobs = {
731
860
  kind: "addon",
732
861
  label: "Jobs + cron (BullMQ)",
733
862
  dependencies: [
863
+ "@tatara/jobs",
734
864
  "bullmq",
735
865
  "ioredis",
736
866
  "croner"
@@ -803,8 +933,8 @@ const jobs = {
803
933
  slot: "sections",
804
934
  content: `## Jobs and schedules
805
935
 
806
- - Background work: \`enqueue(topic, payload)\` from \`#core/jobs/queue\`; a module handles it in
807
- \`consumers/<name>.ts\` (\`defineConsumer({ topic, schema, handle })\`). Payloads are validated by the
936
+ - Background work: \`enqueue(topic, payload)\` from \`#core/jobs/index\`; a module handles it in
937
+ \`consumers/<name>.ts\` (\`defineConsumer({ topic, schema, handle })\` from \`@tatara/jobs\`). Payloads are validated by the
808
938
  consumer's zod schema. Handlers must be idempotent: jobs retry.
809
939
  - Recurring work: \`schedules/<name>.ts\` (\`defineSchedule({ name, cron, run })\`). A Redis lock makes
810
940
  each tick run once across every cron process.
@@ -871,7 +1001,11 @@ const storage = {
871
1001
  id: "storage",
872
1002
  kind: "addon",
873
1003
  label: "File storage",
874
- dependencies: ["@aws-sdk/client-s3", "@aws-sdk/s3-request-presigner"],
1004
+ dependencies: [
1005
+ "@tatara/storage",
1006
+ "@aws-sdk/client-s3",
1007
+ "@aws-sdk/s3-request-presigner"
1008
+ ],
875
1009
  env: [
876
1010
  {
877
1011
  key: "STORAGE_DRIVER",
@@ -973,7 +1107,7 @@ const email = {
973
1107
  id: "email",
974
1108
  kind: "addon",
975
1109
  label: "Email",
976
- dependencies: ["nodemailer"],
1110
+ dependencies: ["@tatara/email", "nodemailer"],
977
1111
  devDependencies: ["@types/nodemailer"],
978
1112
  env: [
979
1113
  {
@@ -1033,6 +1167,7 @@ const i18n = {
1033
1167
  id: "i18n",
1034
1168
  kind: "addon",
1035
1169
  label: "i18n",
1170
+ dependencies: ["@tatara/i18n"],
1036
1171
  env: [{
1037
1172
  key: "DEFAULT_LANGUAGE",
1038
1173
  schema: "z.enum([\"en\", \"ar\"]).default(\"en\")",
@@ -2574,7 +2709,7 @@ async function generate(project, request) {
2574
2709
  for (const [rel, text] of rendered) {
2575
2710
  const full = join(project.root, rel);
2576
2711
  await mkdir(dirname(full), { recursive: true });
2577
- await writeFile(full, text);
2712
+ await writeFile(full, pointAtEjected(text, ejectedPackages(project.config)));
2578
2713
  }
2579
2714
  if (!request.skipPostProcess) await postProcess(project, files);
2580
2715
  return {
@@ -3216,6 +3351,163 @@ function importsOf(text, specifier) {
3216
3351
  return found ? names : null;
3217
3352
  }
3218
3353
  //#endregion
3354
+ //#region src/upgrade/codemods/groups.ts
3355
+ async function read$4(ctx, path) {
3356
+ return ctx.read(path).catch(() => void 0);
3357
+ }
3358
+ function sourceFiles(ctx) {
3359
+ return glob(["src/**/*.ts", "tests/**/*.ts"], {
3360
+ cwd: ctx.project.root,
3361
+ ignore: ["src/generated/**"]
3362
+ });
3363
+ }
3364
+ /** Why the group can't move ([] when it can). Changes nothing. */
3365
+ async function blockersOf$1(ctx, group, sources) {
3366
+ const blockers = [];
3367
+ for (const path of [...group.regenerate, ...group.remove]) {
3368
+ const text = await read$4(ctx, path);
3369
+ if (text === void 0) continue;
3370
+ if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
3371
+ }
3372
+ const own = /* @__PURE__ */ new Set([...group.regenerate, ...group.remove]);
3373
+ for (const path of sources) {
3374
+ if (own.has(path)) continue;
3375
+ const text = await ctx.read(path);
3376
+ for (const rewrite of group.rewrites) {
3377
+ const names = importsOf(text, rewrite.from);
3378
+ if (!names) continue;
3379
+ const allowed = rewrite.names;
3380
+ const missing = names.filter((n) => n === "*" || allowed !== void 0 && !allowed.includes(n));
3381
+ if (missing.length > 0) blockers.push(`${path} imports ${missing.join(", ")} from ${rewrite.from}`);
3382
+ }
3383
+ }
3384
+ return blockers;
3385
+ }
3386
+ async function applyGroup(ctx, group) {
3387
+ for (const path of group.regenerate) {
3388
+ const next = await ctx.renderCurrent(path);
3389
+ if (next !== void 0) await ctx.write(path, next);
3390
+ }
3391
+ for (const path of group.remove) await ctx.removeIfUnchanged(path);
3392
+ for (const rewrite of group.rewrites) await ctx.rewriteImports(rewrite.from, rewrite.to);
3393
+ }
3394
+ //#endregion
3395
+ //#region src/upgrade/codemods/addon-packages.ts
3396
+ const groups$1 = {
3397
+ storage: {
3398
+ name: "storage",
3399
+ regenerate: [
3400
+ "src/core/storage/index.ts",
3401
+ "src/core/storage/commands/check.ts",
3402
+ "src/core/storage/tests/storage.test.ts"
3403
+ ],
3404
+ remove: [
3405
+ "storage.ts",
3406
+ "local.ts",
3407
+ "s3.ts"
3408
+ ].map((f) => `src/core/storage/${f}`),
3409
+ rewrites: [
3410
+ {
3411
+ from: "#core/storage/storage",
3412
+ to: "@tatara/storage"
3413
+ },
3414
+ {
3415
+ from: "#core/storage/local",
3416
+ to: "@tatara/storage",
3417
+ names: []
3418
+ },
3419
+ {
3420
+ from: "#core/storage/s3",
3421
+ to: "#core/storage/index",
3422
+ names: ["s3"]
3423
+ }
3424
+ ]
3425
+ },
3426
+ email: {
3427
+ name: "email",
3428
+ regenerate: ["src/core/email/send.ts"],
3429
+ remove: [],
3430
+ rewrites: []
3431
+ },
3432
+ i18n: {
3433
+ name: "i18n",
3434
+ regenerate: ["src/core/i18n/index.ts"],
3435
+ remove: [],
3436
+ rewrites: []
3437
+ },
3438
+ jobs: {
3439
+ name: "jobs",
3440
+ regenerate: [
3441
+ "src/core/jobs/index.ts",
3442
+ "src/apps/worker/index.ts",
3443
+ "src/apps/cron/index.ts",
3444
+ "src/core/jobs/commands/enqueue.ts",
3445
+ "src/modules/example/tests/jobs.test.ts"
3446
+ ],
3447
+ remove: [
3448
+ "define.ts",
3449
+ "process.ts",
3450
+ "lock.ts",
3451
+ "redis.ts",
3452
+ "queue.ts"
3453
+ ].map((f) => `src/core/jobs/${f}`),
3454
+ rewrites: [
3455
+ {
3456
+ from: "#core/jobs/define",
3457
+ to: "@tatara/jobs"
3458
+ },
3459
+ {
3460
+ from: "#core/jobs/process",
3461
+ to: "@tatara/jobs"
3462
+ },
3463
+ {
3464
+ from: "#core/jobs/queue",
3465
+ to: "#core/jobs/index",
3466
+ names: ["enqueue", "QUEUE_NAME"]
3467
+ },
3468
+ {
3469
+ from: "#core/jobs/redis",
3470
+ to: "#core/jobs/index",
3471
+ names: ["redis"]
3472
+ },
3473
+ {
3474
+ from: "#core/jobs/lock",
3475
+ to: "@tatara/jobs",
3476
+ names: []
3477
+ }
3478
+ ]
3479
+ }
3480
+ };
3481
+ /** Written even though the app has no copy yet; other files are only replaced when present. */
3482
+ const NEW = /* @__PURE__ */ new Set(["src/core/jobs/index.ts"]);
3483
+ const addonPackages = {
3484
+ version: "0.7.0",
3485
+ id: "addon-packages",
3486
+ description: "Use @tatara/storage, @tatara/email, @tatara/i18n and @tatara/jobs",
3487
+ async run(ctx) {
3488
+ const addons = ctx.project.config.addons;
3489
+ const sources = await sourceFiles(ctx);
3490
+ const ready = [];
3491
+ for (const [addon, group] of Object.entries(groups$1)) {
3492
+ if (!addons.includes(addon)) continue;
3493
+ await ctx.addDependency(`@tatara/${addon}`, versionOf(`@tatara/${addon}`));
3494
+ const blockers = await blockersOf$1(ctx, group, sources);
3495
+ if (blockers.length > 0) {
3496
+ ctx.report.notes.push(`${addon} was not moved onto @tatara/${addon}: ${blockers.join("; ")}. Compare with a newly generated app and move it by hand.`);
3497
+ if (!ctx.report.blocked.includes("addon-packages")) ctx.report.blocked.push("addon-packages");
3498
+ continue;
3499
+ }
3500
+ const present = [];
3501
+ for (const path of group.regenerate) if (await ctx.read(path).then(() => true, () => false) || NEW.has(path)) present.push(path);
3502
+ ready.push({
3503
+ ...group,
3504
+ regenerate: present
3505
+ });
3506
+ }
3507
+ for (const group of ready) await applyGroup(ctx, group);
3508
+ }
3509
+ };
3510
+ //#endregion
3219
3511
  //#region src/upgrade/codemods/auth-package.ts
3220
3512
  const AUTH = "@tatara/auth";
3221
3513
  const KEYCLOAK = "@tatara/keycloak";
@@ -3772,7 +4064,8 @@ const CODEMODS = [
3772
4064
  },
3773
4065
  dbTesting,
3774
4066
  authPackage,
3775
- authzPackage
4067
+ authzPackage,
4068
+ addonPackages
3776
4069
  ];
3777
4070
  //#endregion
3778
4071
  //#region src/upgrade/context.ts
@@ -3789,7 +4082,9 @@ function createContext(project, baseline, opts = {}) {
3789
4082
  };
3790
4083
  const abs = (path) => join(project.root, path);
3791
4084
  let rendered;
3792
- const write = async (path, text) => {
4085
+ const ejected = ejectedPackages(project.config);
4086
+ const write = async (path, written) => {
4087
+ const text = path.endsWith(".ts") ? pointAtEjected(written, ejected) : written;
3793
4088
  if (!report.written.includes(path)) {
3794
4089
  report.written.push(path);
3795
4090
  const before = existsSync(abs(path)) ? await readFile(abs(path), "utf8") : void 0;
@@ -3850,6 +4145,11 @@ function createContext(project, baseline, opts = {}) {
3850
4145
  return (await rendered).get(path);
3851
4146
  },
3852
4147
  async addDependency(name, range, { dev = false } = {}) {
4148
+ if (ejected.includes(name.replace(/^@tatara\//, ""))) {
4149
+ const note = `${name} is ejected; it was not added (its code is in src/ejected)`;
4150
+ if (!report.notes.includes(note)) report.notes.push(note);
4151
+ return;
4152
+ }
3853
4153
  const pkg = JSON.parse(await readFile(abs("package.json"), "utf8"));
3854
4154
  const key = dev ? "devDependencies" : "dependencies";
3855
4155
  pkg[key] = {
@@ -4018,6 +4318,17 @@ program.command("upgrade").description("Run this CLI version's codemods on the a
4018
4318
  printUpgrade(await upgrade(project, { dryRun: flags.dryRun }), from, cliVersion());
4019
4319
  if (!flags.dryRun && flags.verify !== false && !await verify(loadProject())) process.exitCode = 1;
4020
4320
  });
4321
+ program.command("eject <package>").description("Copy a @tatara/* package's source into src/ejected; the app owns it from then on").option("--with-dependents", "also eject installed @tatara/* packages that depend on it").action(async (name, flags) => {
4322
+ const report = await eject(loadProject(), name, {
4323
+ withDependents: flags.withDependents,
4324
+ formatFile: biomeFormat
4325
+ });
4326
+ const names = report.ejected.map((n) => `@tatara/${n}`).join(", ");
4327
+ console.log(pc.green(`ejected ${names} into src/ejected (${report.copied.length} files)`));
4328
+ if (report.rewritten.length > 0) console.log(` ${report.rewritten.length} files now import the ejected copy`);
4329
+ if (report.added.length > 0) console.log(` added dependencies: ${report.added.join(", ")}`);
4330
+ console.log(` run your package manager's install, then verify; tatara upgrade skips ${names} from now on`);
4331
+ });
4021
4332
  program.command("doctor").description("Check the toolchain, services, env and layout").option("--json", "machine-readable output").action(async (flags) => {
4022
4333
  const checks = await doctor(loadProject());
4023
4334
  if (flags.json) console.log(JSON.stringify(checks, null, 2));
@@ -4025,7 +4336,7 @@ program.command("doctor").description("Check the toolchain, services, env and la
4025
4336
  if (checks.some((c) => !c.ok)) process.exitCode = 1;
4026
4337
  });
4027
4338
  program.parseAsync().catch((error) => {
4028
- if (error instanceof OptionsError || error instanceof NotATataraProject || error instanceof GeneratorError || error instanceof DbError) console.error(pc.red(error.message));
4339
+ if (error instanceof OptionsError || error instanceof EjectError || error instanceof NotATataraProject || error instanceof GeneratorError || error instanceof DbError) console.error(pc.red(error.message));
4029
4340
  else console.error(error);
4030
4341
  process.exit(1);
4031
4342
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/cli",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Create and grow opinionated Hono backends",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,22 +1,13 @@
1
- import { createTransport, type Transporter } from "nodemailer";
1
+ import { createMailer } from "@tatara/email";
2
2
  import { env } from "#core/config/env";
3
3
  import { logger } from "#core/logging/logger";
4
4
 
5
- export interface EmailMessage {
6
- to: string;
7
- subject: string;
8
- text: string;
9
- html?: string;
10
- }
11
-
12
- let transport: Transporter | undefined;
5
+ export type { EmailMessage } from "@tatara/email";
13
6
 
14
7
  /** Sends through SMTP (Mailpit locally) or logs it when EMAIL_DRIVER=console. */
15
- export async function sendEmail(message: EmailMessage): Promise<void> {
16
- if (env.EMAIL_DRIVER === "console") {
17
- logger.info({ to: message.to, subject: message.subject }, "email (console driver)");
18
- return;
19
- }
20
- transport ??= createTransport(env.SMTP_URL);
21
- await transport.sendMail({ from: env.EMAIL_FROM, ...message });
22
- }
8
+ export const { send: sendEmail } = createMailer({
9
+ driver: env.EMAIL_DRIVER,
10
+ smtpUrl: env.SMTP_URL,
11
+ from: env.EMAIL_FROM,
12
+ logger,
13
+ });
@@ -1,41 +1,11 @@
1
- import { createMiddleware } from "hono/factory";
1
+ import { createI18n } from "@tatara/i18n";
2
2
  import { env } from "#core/config/env";
3
- import type { AppEnv } from "#core/http/context";
4
3
  import ar from "#locales/ar" with { type: "json" };
5
4
  import en from "#locales/en" with { type: "json" };
6
5
 
7
- type Dictionary = { [key: string]: string | Dictionary };
6
+ const i18n = createI18n({ dictionaries: { en, ar }, defaultLanguage: env.DEFAULT_LANGUAGE });
8
7
 
9
- const dictionaries: Record<string, Dictionary> = { en, ar };
8
+ export const SUPPORTED_LANGUAGES = i18n.languages;
10
9
 
11
- export const SUPPORTED_LANGUAGES = Object.keys(dictionaries);
12
-
13
- function lookup(dict: Dictionary | undefined, key: string): string | undefined {
14
- let node: string | Dictionary | undefined = dict;
15
- for (const part of key.split(".")) {
16
- if (!node || typeof node === "string") return undefined;
17
- node = node[part];
18
- }
19
- return typeof node === "string" ? node : undefined;
20
- }
21
-
22
- /** Translates `key` ("errors.NOT_FOUND"), falling back to the default language, then `fallback`. */
23
- export function t(language: string, key: string, fallback = key, vars: Record<string, string> = {}): string {
24
- const text = lookup(dictionaries[language], key) ?? lookup(dictionaries[env.DEFAULT_LANGUAGE], key) ?? fallback;
25
- return text.replace(/\{(\w+)\}/g, (_, name: string) => vars[name] ?? `{${name}}`);
26
- }
27
-
28
- /** ?lang, then the `lang` cookie, then Accept-Language; sets `c.var.language`. */
29
- export const detectLanguage = createMiddleware<AppEnv>(async (c, next) => {
30
- const candidates = [
31
- c.req.query("lang"),
32
- /(?:^|;\s*)lang=([^;]+)/.exec(c.req.header("cookie") ?? "")?.[1],
33
- ...(c.req.header("accept-language") ?? "")
34
- .split(",")
35
- .map((part) => part.split(";")[0]?.trim().toLowerCase().split("-")[0]),
36
- ];
37
- const language = candidates.find((l) => l && SUPPORTED_LANGUAGES.includes(l)) ?? env.DEFAULT_LANGUAGE;
38
- c.set("language", language);
39
- c.header("content-language", language);
40
- await next();
41
- });
10
+ /** `t(language, "errors.NOT_FOUND")`, and the middleware that sets `c.var.language`. */
11
+ export const { t, detectLanguage } = i18n;
@@ -1,25 +1,8 @@
1
- import { Cron } from "croner";
2
- import { withLock } from "#core/jobs/lock";
1
+ import { handleSignals } from "@tatara/core";
2
+ import { startCron } from "@tatara/jobs";
3
+ import { redis } from "#core/jobs/index";
3
4
  import { logger } from "#core/logging/logger";
4
- import { handleSignals, onShutdown } from "@tatara/core";
5
5
  import { schedules } from "#generated/schedules";
6
6
 
7
- const jobs = schedules.map((schedule) => {
8
- const log = logger.child({ schedule: schedule.name });
9
- return new Cron(schedule.cron, { timezone: "UTC", protect: true, name: schedule.name }, async (self) => {
10
- const tick = self.currentRun()?.toISOString() ?? new Date().toISOString();
11
- const ran = await withLock(`schedule:${schedule.name}:${tick}`, schedule.lockTtlMs ?? 60_000, () =>
12
- schedule.run({ logger: log }),
13
- ).catch((error: unknown) => {
14
- log.error({ err: error }, "schedule failed");
15
- return true;
16
- });
17
- if (!ran) log.debug("skipped: another process holds this tick");
18
- });
19
- });
20
-
21
- onShutdown("cron", () => {
22
- for (const job of jobs) job.stop();
23
- });
7
+ startCron({ schedules, redis, logger });
24
8
  handleSignals(logger);
25
- logger.info({ schedules: schedules.map((s) => `${s.name} (${s.cron})`) }, "cron started");
@@ -1,24 +1,16 @@
1
- import { Worker } from "bullmq";
1
+ import { handleSignals } from "@tatara/core";
2
+ import { startWorker } from "@tatara/jobs";
2
3
  import { env } from "#core/config/env";
3
- import { processJob } from "#core/jobs/process";
4
- import { QUEUE_NAME } from "#core/jobs/queue";
5
- import { redis } from "#core/jobs/redis";
4
+ import { QUEUE_NAME, redis } from "#core/jobs/index";
6
5
  import { logger } from "#core/logging/logger";
7
- import { handleSignals, onShutdown } from "@tatara/core";
8
6
  import { consumers } from "#generated/consumers";
9
7
 
10
- const worker = new Worker(
11
- QUEUE_NAME,
12
- (job) =>
13
- processJob(consumers, job.name, job.data, {
14
- logger: logger.child({ jobId: job.id, topic: job.name }),
15
- jobId: job.id ?? "",
16
- attempt: job.attemptsMade + 1,
17
- }),
18
- { connection: redis(), prefix: env.JOBS_PREFIX, concurrency: env.WORKER_CONCURRENCY },
19
- );
20
-
21
- worker.on("failed", (job, err) => logger.error({ err, jobId: job?.id, topic: job?.name }, "job failed"));
22
- onShutdown("worker", () => worker.close());
8
+ startWorker({
9
+ queue: QUEUE_NAME,
10
+ prefix: env.JOBS_PREFIX,
11
+ redis: redis(),
12
+ concurrency: env.WORKER_CONCURRENCY,
13
+ consumers,
14
+ logger,
15
+ });
23
16
  handleSignals(logger);
24
- logger.info({ topics: consumers.map((c) => c.topic) }, "worker started");
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { defineCommand } from "@tatara/core";
3
- import { enqueue } from "#core/jobs/queue";
3
+ import { enqueue } from "#core/jobs/index";
4
4
 
5
5
  export default defineCommand({
6
6
  name: "jobs:enqueue",
@@ -0,0 +1,10 @@
1
+ import { createQueue, lazyRedis } from "@tatara/jobs";
2
+ import { env } from "#core/config/env";
3
+
4
+ export const QUEUE_NAME = "__APP_NAME__-jobs";
5
+
6
+ /** The shared Redis connection, opened on first use. */
7
+ export const redis = lazyRedis(env.REDIS_URL);
8
+
9
+ /** `enqueue(topic, data)`: the consumer with that topic validates the payload. */
10
+ export const { enqueue } = createQueue({ name: QUEUE_NAME, prefix: env.JOBS_PREFIX, redis });
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { defineConsumer } from "#core/jobs/define";
2
+ import { defineConsumer } from "@tatara/jobs";
3
3
 
4
4
  export default defineConsumer({
5
5
  topic: "example.send-welcome",
@@ -1,4 +1,4 @@
1
- import { defineSchedule } from "#core/jobs/define";
1
+ import { defineSchedule } from "@tatara/jobs";
2
2
 
3
3
  export default defineSchedule({
4
4
  name: "example.heartbeat",
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { withLock } from "#core/jobs/lock";
3
- import { processJob } from "#core/jobs/process";
2
+ import { processJob, withLock } from "@tatara/jobs";
3
+ import { redis } from "#core/jobs/index";
4
4
  import { logger } from "#core/logging/logger";
5
5
  import { consumers } from "#generated/consumers";
6
6
 
@@ -20,8 +20,8 @@ describe("jobs", () => {
20
20
  it("lets only one holder run a locked section", async () => {
21
21
  const key = `test-${Date.now()}-${Math.random()}`;
22
22
  let inner: boolean | undefined;
23
- const outer = await withLock(key, 5000, async () => {
24
- inner = await withLock(key, 5000, async () => {});
23
+ const outer = await withLock(redis(), key, 5000, async () => {
24
+ inner = await withLock(redis(), key, 5000, async () => {});
25
25
  });
26
26
  expect(outer).toBe(true);
27
27
  expect(inner).toBe(false);
@@ -1,13 +1,13 @@
1
1
  import { defineCommand } from "@tatara/core";
2
2
  import { env } from "#core/config/env";
3
- import { storage } from "#core/storage/index";
4
- import { ensureBucket } from "#core/storage/s3";
3
+ import { ensureBucket } from "@tatara/storage/s3";
4
+ import { s3, storage } from "#core/storage/index";
5
5
 
6
6
  export default defineCommand({
7
7
  name: "storage:check",
8
8
  description: "Create the bucket if needed, then write, read and delete a probe object",
9
9
  async run(_args, { logger }) {
10
- if (env.STORAGE_DRIVER === "s3" && (await ensureBucket())) logger.info({ bucket: env.S3_BUCKET }, "bucket created");
10
+ if (env.STORAGE_DRIVER === "s3" && (await ensureBucket(s3, env.S3_BUCKET))) logger.info({ bucket: env.S3_BUCKET }, "bucket created");
11
11
  const key = `tatara-check/${Date.now()}.txt`;
12
12
  await storage.put(key, "ok", "text/plain");
13
13
  const body = await storage.get(key);
@@ -1,8 +1,22 @@
1
+ import { S3Client } from "@aws-sdk/client-s3";
2
+ import { localStorage, type Storage } from "@tatara/storage";
3
+ import { s3Storage } from "@tatara/storage/s3";
1
4
  import { env } from "#core/config/env";
2
- import { localStorage } from "#core/storage/local";
3
- import { s3Storage } from "#core/storage/s3";
4
- import type { Storage } from "#core/storage/storage";
5
5
 
6
- export type { Storage } from "#core/storage/storage";
6
+ export type { Storage } from "@tatara/storage";
7
7
 
8
- export const storage: Storage = env.STORAGE_DRIVER === "s3" ? s3Storage() : localStorage();
8
+ export const s3 = new S3Client({
9
+ region: env.S3_REGION,
10
+ ...(env.S3_ENDPOINT ? { endpoint: env.S3_ENDPOINT } : {}),
11
+ forcePathStyle: env.S3_FORCE_PATH_STYLE,
12
+ ...(env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY
13
+ ? { credentials: { accessKeyId: env.S3_ACCESS_KEY_ID, secretAccessKey: env.S3_SECRET_ACCESS_KEY } }
14
+ : {}),
15
+ });
16
+
17
+ /** Object storage for modules; pick the driver with STORAGE_DRIVER. */
18
+ export const storage: Storage =
19
+ env.STORAGE_DRIVER === "s3"
20
+ ? // local development: the bucket is created on first write
21
+ s3Storage({ client: s3, bucket: env.S3_BUCKET, createBucket: env.NODE_ENV === "development" })
22
+ : localStorage(env.STORAGE_LOCAL_DIR);
@@ -2,7 +2,7 @@ import { mkdtemp } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { describe, expect, it } from "vitest";
5
- import { localStorage } from "#core/storage/local";
5
+ import { localStorage } from "@tatara/storage";
6
6
 
7
7
  describe("local storage", () => {
8
8
  it("round-trips an object", async () => {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { defineConsumer } from "#core/jobs/define";
2
+ import { defineConsumer } from "@tatara/jobs";
3
3
 
4
4
  export const __nameCamel__Payload = z.object({ id: z.string().min(1) });
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { processJob } from "#core/jobs/process";
2
+ import { processJob } from "@tatara/jobs";
3
3
  import { logger } from "#core/logging/logger";
4
4
  import { consumers } from "#generated/consumers";
5
5
 
@@ -1,4 +1,4 @@
1
- import { defineSchedule } from "#core/jobs/define";
1
+ import { defineSchedule } from "@tatara/jobs";
2
2
 
3
3
  export default defineSchedule({
4
4
  name: "__moduleKebab__.__nameKebab__",
@@ -31,11 +31,15 @@
31
31
  "@aws-sdk/s3-request-presigner": "3.1152.0",
32
32
  "nodemailer": "10.1.1",
33
33
  "@types/nodemailer": "8.0.2",
34
- "@tatara/core": "0.1.0",
35
- "@tatara/http": "0.1.0",
36
- "@tatara/db": "0.1.0",
37
- "@tatara/testing": "0.1.0",
38
- "@tatara/auth": "0.1.0",
39
- "@tatara/keycloak": "0.1.0",
40
- "@tatara/authz": "0.1.0"
34
+ "@tatara/core": "0.1.1",
35
+ "@tatara/http": "0.1.1",
36
+ "@tatara/db": "0.1.1",
37
+ "@tatara/testing": "0.1.1",
38
+ "@tatara/auth": "0.1.1",
39
+ "@tatara/keycloak": "0.1.1",
40
+ "@tatara/authz": "0.1.0",
41
+ "@tatara/storage": "0.1.0",
42
+ "@tatara/email": "0.1.0",
43
+ "@tatara/i18n": "0.1.0",
44
+ "@tatara/jobs": "0.1.0"
41
45
  }
@@ -1,37 +0,0 @@
1
- import type { z } from "zod";
2
- import type { Logger } from "#core/logging/logger";
3
-
4
- export interface JobContext {
5
- logger: Logger;
6
- jobId: string;
7
- attempt: number;
8
- }
9
-
10
- export interface ConsumerDefinition<S extends z.ZodType = z.ZodType> {
11
- /** job name, e.g. "billing.invoice-paid" */
12
- topic: string;
13
- description: string;
14
- schema: S;
15
- /** retries before the job is marked failed (default 3) */
16
- attempts?: number;
17
- handle(data: z.infer<S>, ctx: JobContext): Promise<void>;
18
- }
19
-
20
- /** Default export of every `consumers/*.ts`; run by the worker app. */
21
- export function defineConsumer<S extends z.ZodType>(definition: ConsumerDefinition<S>) {
22
- return definition;
23
- }
24
-
25
- export interface ScheduleDefinition {
26
- name: string;
27
- /** cron expression (seconds optional), evaluated in UTC */
28
- cron: string;
29
- /** how long the run holds its lock; one run per tick across all cron processes */
30
- lockTtlMs?: number;
31
- run(ctx: { logger: Logger }): Promise<void>;
32
- }
33
-
34
- /** Default export of every `schedules/*.ts`; run by the cron app. */
35
- export function defineSchedule(definition: ScheduleDefinition): ScheduleDefinition {
36
- return definition;
37
- }
@@ -1,17 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { redis } from "#core/jobs/redis";
3
-
4
- const RELEASE = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
5
-
6
- /** Runs `fn` only if this process wins the lock; returns false when another holder has it. */
7
- export async function withLock(key: string, ttlMs: number, fn: () => Promise<void>): Promise<boolean> {
8
- const token = randomUUID();
9
- const acquired = await redis().set(`lock:${key}`, token, "PX", ttlMs, "NX");
10
- if (acquired !== "OK") return false;
11
- try {
12
- await fn();
13
- } finally {
14
- await redis().eval(RELEASE, 1, `lock:${key}`, token);
15
- }
16
- return true;
17
- }
@@ -1,19 +0,0 @@
1
- import type { ConsumerDefinition, JobContext } from "#core/jobs/define";
2
-
3
- export class UnknownTopicError extends Error {}
4
-
5
- /** Validates and runs one job; shared by the worker and by tests. */
6
- export async function processJob(
7
- consumers: ConsumerDefinition[],
8
- topic: string,
9
- data: unknown,
10
- ctx: JobContext,
11
- ): Promise<void> {
12
- const consumer = consumers.find((c) => c.topic === topic);
13
- if (!consumer) throw new UnknownTopicError(`No consumer for topic "${topic}"`);
14
- const parsed = consumer.schema.safeParse(data);
15
- if (!parsed.success) {
16
- throw new Error(`Invalid payload for ${topic}: ${parsed.error.message}`);
17
- }
18
- await consumer.handle(parsed.data, ctx);
19
- }
@@ -1,29 +0,0 @@
1
- import { type JobsOptions, Queue } from "bullmq";
2
- import { env } from "#core/config/env";
3
- import { redis } from "#core/jobs/redis";
4
- import { onShutdown } from "@tatara/core";
5
-
6
- export const QUEUE_NAME = "__APP_NAME__-jobs";
7
-
8
- let queue: Queue | undefined;
9
-
10
- function getQueue(): Queue {
11
- if (!queue) {
12
- const q = new Queue(QUEUE_NAME, { connection: redis(), prefix: env.JOBS_PREFIX });
13
- queue = q;
14
- onShutdown("queue", () => q.close());
15
- }
16
- return queue;
17
- }
18
-
19
- /** Enqueues a job for the consumer with this topic. The consumer validates the payload. */
20
- export async function enqueue(topic: string, data: unknown, options: JobsOptions = {}): Promise<string> {
21
- const job = await getQueue().add(topic, data, {
22
- attempts: 3,
23
- backoff: { type: "exponential", delay: 1000 },
24
- removeOnComplete: 1000,
25
- removeOnFail: 5000,
26
- ...options,
27
- });
28
- return job.id ?? "";
29
- }
@@ -1,17 +0,0 @@
1
- import { Redis } from "ioredis";
2
- import { env } from "#core/config/env";
3
- import { onShutdown } from "@tatara/core";
4
-
5
- let client: Redis | undefined;
6
-
7
- /** Shared connection (BullMQ requires maxRetriesPerRequest: null). */
8
- export function redis(): Redis {
9
- if (!client) {
10
- const connection = new Redis(env.REDIS_URL, { maxRetriesPerRequest: null });
11
- client = connection;
12
- onShutdown("redis", async () => {
13
- await connection.quit();
14
- });
15
- }
16
- return client;
17
- }
@@ -1,34 +0,0 @@
1
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
- import { dirname, resolve, sep } from "node:path";
3
- import { env } from "#core/config/env";
4
- import { assertKey, type Storage } from "#core/storage/storage";
5
-
6
- /** Files on disk; for development and tests. */
7
- export function localStorage(root = env.STORAGE_LOCAL_DIR): Storage {
8
- const base = resolve(root);
9
- const path = (key: string) => {
10
- const full = resolve(base, assertKey(key));
11
- if (!full.startsWith(base + sep)) throw new Error(`Invalid storage key "${key}"`);
12
- return full;
13
- };
14
- return {
15
- async put(key, body) {
16
- await mkdir(dirname(path(key)), { recursive: true });
17
- await writeFile(path(key), body);
18
- },
19
- async get(key) {
20
- try {
21
- return new Uint8Array(await readFile(path(key)));
22
- } catch (error) {
23
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
24
- throw error;
25
- }
26
- },
27
- async delete(key) {
28
- await rm(path(key), { force: true });
29
- },
30
- async url(key) {
31
- return `file://${path(key)}`;
32
- },
33
- };
34
- }
@@ -1,66 +0,0 @@
1
- import {
2
- CreateBucketCommand,
3
- DeleteObjectCommand,
4
- GetObjectCommand,
5
- HeadBucketCommand,
6
- NoSuchKey,
7
- PutObjectCommand,
8
- S3Client,
9
- } from "@aws-sdk/client-s3";
10
- import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
11
- import { env } from "#core/config/env";
12
- import { assertKey, type Storage } from "#core/storage/storage";
13
-
14
- export const s3 = new S3Client({
15
- region: env.S3_REGION,
16
- ...(env.S3_ENDPOINT ? { endpoint: env.S3_ENDPOINT } : {}),
17
- forcePathStyle: env.S3_FORCE_PATH_STYLE,
18
- ...(env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY
19
- ? { credentials: { accessKeyId: env.S3_ACCESS_KEY_ID, secretAccessKey: env.S3_SECRET_ACCESS_KEY } }
20
- : {}),
21
- });
22
-
23
- /** Any S3-compatible store (AWS S3, R2, RustFS locally). */
24
- export function s3Storage(bucket = env.S3_BUCKET): Storage {
25
- let bucketReady: Promise<boolean> | undefined;
26
- return {
27
- async put(key, body, contentType) {
28
- // local development: create the bucket on first write
29
- if (env.NODE_ENV === "development") {
30
- bucketReady ??= ensureBucket(bucket);
31
- await bucketReady;
32
- }
33
- await s3.send(
34
- new PutObjectCommand({ Bucket: bucket, Key: assertKey(key), Body: body, ContentType: contentType }),
35
- );
36
- },
37
- async get(key) {
38
- try {
39
- const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: assertKey(key) }));
40
- return res.Body ? await res.Body.transformToByteArray() : null;
41
- } catch (error) {
42
- if (error instanceof NoSuchKey) return null;
43
- throw error;
44
- }
45
- },
46
- async delete(key) {
47
- await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: assertKey(key) }));
48
- },
49
- async url(key, expiresInSeconds = 900) {
50
- return getSignedUrl(s3, new GetObjectCommand({ Bucket: bucket, Key: assertKey(key) }), {
51
- expiresIn: expiresInSeconds,
52
- });
53
- },
54
- };
55
- }
56
-
57
- /** Creates the bucket when it does not exist (local development). */
58
- export async function ensureBucket(bucket = env.S3_BUCKET): Promise<boolean> {
59
- try {
60
- await s3.send(new HeadBucketCommand({ Bucket: bucket }));
61
- return false;
62
- } catch {
63
- await s3.send(new CreateBucketCommand({ Bucket: bucket }));
64
- return true;
65
- }
66
- }
@@ -1,21 +0,0 @@
1
- /** Object storage used by modules; pick the driver with STORAGE_DRIVER. */
2
- export interface Storage {
3
- put(key: string, body: Uint8Array | string, contentType?: string): Promise<void>;
4
- get(key: string): Promise<Uint8Array | null>;
5
- delete(key: string): Promise<void>;
6
- /** a URL clients can fetch the object from (signed for S3) */
7
- url(key: string, expiresInSeconds?: number): Promise<string>;
8
- }
9
-
10
- /** Keys are relative, forward-slash paths without "..", backslashes, NUL bytes or drive prefixes. */
11
- export function assertKey(key: string): string {
12
- const invalid =
13
- !key ||
14
- key.startsWith("/") ||
15
- key.includes("\\") ||
16
- key.includes("\0") ||
17
- /^[A-Za-z]:/.test(key) ||
18
- key.split("/").some((segment) => segment === ".." || segment === ".");
19
- if (invalid) throw new Error(`Invalid storage key "${key}"`);
20
- return key;
21
- }