@wolfstar/create-http-framework 2.5.2 → 2.6.0-next-20260926200949

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,13 +34,16 @@ The CLI will guide you through the following prompts:
34
34
  - **Project name** — an npm-compatible name for your bot
35
35
  - **Package manager** — npm, yarn, pnpm, or bun
36
36
  - **Language** — TypeScript or JavaScript
37
- - **Build tool** — tsdown, TypeScript 6, or the TypeScript 7 release candidate
37
+ - **Build tool** — tsdown, Vite, Vite + Nitro, TypeScript 6, or the TypeScript 7 release candidate (Vite and Vite + Nitro are experimental)
38
38
  - **Linter and formatter** — Oxlint / ESLint and Oxfmt / Prettier
39
39
  - **Port** — the port the HTTP server will listen on (default: `3000`)
40
40
  - **Optional features** (multiselect) —
41
41
  - **i18n** — add [`@wolfstar/plugin-i18next`](https://github.com/wolfstar-project/stars-components/tree/main/packages/plugin-i18next)
42
42
  - **Subcommands** — add an example command that uses subcommands
43
43
  - **Testing** — set up Vitest with [`@wolfstar/http-framework-test-utils`](https://github.com/wolfstar-project/stars-components/tree/main/packages/http-framework-test-utils)
44
+ - **Gateway** — receive gateway events next to the HTTP interactions with [`@wolfstar/plugin-gateway`](https://github.com/wolfstar-project/plugins/tree/main/packages/plugin-gateway)
45
+ - **Cache** — cache gateway entities with [`@wolfstar/plugin-cache`](https://github.com/wolfstar-project/plugins/tree/main/packages/plugin-cache), in memory or in Redis (asked as a follow-up)
46
+ - **Sharder** — spread gateway shards across cluster workers with [`@wolfstar/plugin-sharder`](https://github.com/wolfstar-project/plugins/tree/main/packages/plugin-sharder)
44
47
  - **Auto-install** — install dependencies immediately after scaffolding
45
48
 
46
49
  ## Options
@@ -52,13 +55,17 @@ The CLI will guide you through the following prompts:
52
55
  | `--interactive` | `-i` | Force interactive prompts even when an AI agent is detected |
53
56
  | `--package-manager <pm>` | | Choose npm, yarn, pnpm, or bun |
54
57
  | `--language <lang>` | | Choose TypeScript (`ts`) or JavaScript (`js`) |
55
- | `--build <tool>` | | Choose `tsc6`, `tsc7`, or `tsdown` for TypeScript |
58
+ | `--build <tool>` | | Choose `tsc6`, `tsc7`, `tsdown`, `vite`, or `vite-nitro` for TypeScript |
56
59
  | `--lint <linter>` | | Choose `none`, `eslint`, or `oxlint` |
57
60
  | `--format <formatter>` | | Choose `none`, `prettier`, or `oxfmt` |
58
61
  | `--port <number>` | | Set the HTTP port (default: `3000`) |
59
62
  | `--i18n` / `--no-i18n` | | Enable or disable `@wolfstar/plugin-i18next` scaffolding |
60
63
  | `--subcommands` / `--no-subcommands` | | Enable or disable the example subcommand command |
61
64
  | `--testing` / `--no-testing` | | Enable or disable the Vitest + `@wolfstar/http-framework-test-utils` setup |
65
+ | `--gateway` / `--no-gateway` | | Enable or disable `@wolfstar/plugin-gateway` scaffolding |
66
+ | `--cache` / `--no-cache` | | Enable or disable `@wolfstar/plugin-cache` (turns `--gateway` on) |
67
+ | `--redis` / `--no-redis` | | Cache in Redis instead of memory (turns `--cache` on) |
68
+ | `--sharder` / `--no-sharder` | | Enable or disable `@wolfstar/plugin-sharder` (turns `--gateway` on) |
62
69
  | `--install` / `--no-install` | | Enable or disable dependency installation |
63
70
  | `--help` | `-h` | Print usage and exit |
64
71
 
@@ -82,6 +89,7 @@ my-discord-bot/
82
89
  │ │ ├── setup/
83
90
  │ │ │ ├── all.ts # Aggregates setup imports
84
91
  │ │ │ └── logger.ts # Logger configuration
92
+ │ │ ├── cache.ts # Gateway entity cache (--cache)
85
93
  │ │ └── types/
86
94
  │ │ └── augments.ts # Module augmentations (TypeScript only)
87
95
  │ ├── locales/
@@ -90,11 +98,13 @@ my-discord-bot/
90
98
  │ │ └── ping.json # Example locale resource (--i18n)
91
99
  │ ├── @types/
92
100
  │ │ └── i18next.d.ts # Generated i18next augmentation (--i18n)
101
+ │ ├── shard.ts # Gateway client run by each shard worker (--sharder)
93
102
  │ └── main.ts # Entry point — starts the HTTP server
94
103
  ├── tests/
95
104
  │ └── ping.test.ts # Example test (--testing)
96
105
  ├── vitest.config.ts # Vitest configuration (--testing)
97
106
  ├── vitest.setup.ts # Vitest setup file (--testing)
107
+ ├── compose.yaml # Local Redis server (--redis)
98
108
  ├── README.md # Generated project README
99
109
  ├── .env # Environment variables (DISCORD_TOKEN, DISCORD_PUBLIC_KEY)
100
110
  ├── .gitignore
@@ -105,8 +115,27 @@ my-discord-bot/
105
115
  - `src/locales/en-US/commands/ping.json` and `src/@types/i18next.d.ts` are only generated when **i18n** is enabled.
106
116
  - `src/@types/i18next.d.ts` is produced by `@wolfstar/i18next-type-generator`; the CLI runs it automatically at the end of scaffolding, and it can be re-run any time locale files change (see [`generate:i18n`](#i18n-type-generation) below).
107
117
  - `src/commands/math.ts` is only generated when **Subcommands** is enabled.
118
+ - `src/lib/cache.ts` is only generated with **Cache**, `src/shard.ts` with **Sharder**, and `compose.yaml` with **Redis**.
108
119
  - `tests/ping.test.ts`, `vitest.config.ts`, and `vitest.setup.ts` are only generated when **Testing** is enabled.
109
120
 
121
+ ### Vite and Nitro
122
+
123
+ `--build vite` bundles the bot with [Vite](https://vite.dev) into a single `dist/main.js`, and `--build vite-nitro` hands it to [Nitro](https://nitro.build), which writes a deployable `.output/` (the `node-server` preset, pinned in `stars.config.ts`). Both are TypeScript only and turn on `experimental.enableVite` (and `enableNitro`) in the generated `stars.config.ts`.
124
+
125
+ - A bundle has no `commands` directory to scan, so `src/main.ts` imports and loads the example commands explicitly. Add your own commands there.
126
+ - With Nitro, `src/main.ts` default-exports the client instead of calling `listen()`, and the port comes from `PORT` in `.env`.
127
+ - `main` and `start` are wired to the `node-server` preset's output, `.output/server/index.mjs`. Changing `nitro.preset` to target another platform means deploying that platform's `.output/` instead of running `npm start` — and with `--gateway`, only a preset that keeps a server alive will hold the gateway connection open.
128
+ - Nitro cannot be combined with `--sharder`: the sharder's manager process has no client for Nitro to forward requests to.
129
+
130
+ ### Gateway, cache and sharder
131
+
132
+ The gateway plugins are for bots that also need gateway events, so they require a long-lived process and Node.js `>=24.17.0`.
133
+
134
+ - `--gateway` makes `src/main.*` create a `GatewayClient` (it still answers HTTP interactions).
135
+ - `--cache` adds `src/lib/cache.*`, an in-memory cache by default. `--redis` (or the follow-up prompt) swaps it for `ioredis`, adds a `compose.yaml` to start a local Redis, and `REDIS_URL` to `.env`.
136
+ - `--sharder` turns `src/main.*` into the shard manager and adds `src/shard.*`, which runs in every cluster worker (`SHARDER_CLUSTERS` in `.env`).
137
+ - `--cache` and `--sharder` switch `--gateway` on, and `--redis` switches `--cache` on.
138
+
110
139
  ### i18n type generation
111
140
 
112
141
  When i18n is enabled, the generated `package.json` includes a `generate:i18n` script that (re)generates `src/@types/i18next.d.ts` from the JSON files under `src/locales/`:
@@ -119,4 +148,4 @@ The CLI runs this script automatically once at the end of scaffolding; re-run it
119
148
 
120
149
  ## Requirements
121
150
 
122
- - Node.js `>=20`
151
+ - Node.js `>=20` (`>=24.17.0` for projects using the gateway)
package/dist/index.js CHANGED
@@ -45,8 +45,40 @@ const LANGUAGES = ["ts", "js"];
45
45
  const BUILD_TOOLS = [
46
46
  "tsc6",
47
47
  "tsc7",
48
- "tsdown"
48
+ "tsdown",
49
+ "vite",
50
+ "vite-nitro"
49
51
  ];
52
+ /** `vite` and `vite-nitro` both bundle the app with Vite, so pieces are loaded explicitly instead of scanned from disk. */
53
+ function isViteBuild(buildTool) {
54
+ return buildTool === "vite" || buildTool === "vite-nitro";
55
+ }
56
+ /** `vite-nitro` hands the built client to Nitro, which owns the HTTP server. */
57
+ function isNitroBuild(buildTool) {
58
+ return buildTool === "vite-nitro";
59
+ }
60
+ /**
61
+ * Applies the dependencies between the gateway features: Redis needs the cache, and the cache and the sharder both
62
+ * need the gateway client. `implied` names the features that were switched on to satisfy another one.
63
+ */
64
+ function resolveGatewayFeatures(features) {
65
+ const resolved = { ...features };
66
+ const implied = [];
67
+ if (resolved.redis && !resolved.cache) {
68
+ resolved.cache = true;
69
+ implied.push("cache");
70
+ }
71
+ if ((resolved.cache || resolved.sharder) && !resolved.gateway) {
72
+ resolved.gateway = true;
73
+ implied.push("gateway");
74
+ }
75
+ return {
76
+ features: resolved,
77
+ implied
78
+ };
79
+ }
80
+ /** The Nitro entry must default-export the HTTP client, which the sharder's manager process never has. */
81
+ const SHARDER_WITH_NITRO_ERROR = "--sharder cannot be combined with --build vite-nitro: Nitro needs a client to forward requests to in every process.";
50
82
  const LINTERS = [
51
83
  "none",
52
84
  "eslint",
@@ -74,7 +106,7 @@ async function fetchVersion(packageName) {
74
106
  /**
75
107
  * Resolves the latest versions of only the packages required by the chosen selections.
76
108
  * `ts-node` is intentionally never included. TypeScript 7.0 (tsc7) is pinned to the rc instead
77
- * of being fetched, because `typescript@latest` resolves to the 6.x line.
109
+ * of being fetched, because `typescript@latest` resolves to the 6.x line. Nitro v3 is still a beta, so callers pin it exactly.
78
110
  */
79
111
  async function fetchDependencyVersions(selections) {
80
112
  const names = /* @__PURE__ */ new Set([
@@ -88,6 +120,10 @@ async function fetchDependencyVersions(selections) {
88
120
  ]);
89
121
  if (selections.i18n) names.add("@wolfstar/plugin-i18next").add("@wolfstar/i18next-type-generator");
90
122
  if (selections.testing) names.add("vitest").add("@wolfstar/http-framework-test-utils");
123
+ if (selections.gateway) names.add("@wolfstar/plugin-gateway");
124
+ if (selections.cache) names.add("@wolfstar/plugin-cache");
125
+ if (selections.redis) names.add("ioredis");
126
+ if (selections.sharder) names.add("@wolfstar/plugin-sharder");
91
127
  if (selections.language === "ts") {
92
128
  names.add("@types/node");
93
129
  switch (selections.buildTool) {
@@ -95,7 +131,13 @@ async function fetchDependencyVersions(selections) {
95
131
  names.add("typescript");
96
132
  break;
97
133
  case "tsc7": break;
98
- case "tsdown": names.add("tsdown").add("typescript");
134
+ case "tsdown":
135
+ names.add("tsdown").add("typescript");
136
+ break;
137
+ case "vite":
138
+ case "vite-nitro":
139
+ names.add("vite").add("typescript");
140
+ if (isNitroBuild(selections.buildTool)) names.add("nitro");
99
141
  }
100
142
  }
101
143
  if (selections.linter === "eslint") {
@@ -183,12 +225,17 @@ function sortKeys(record) {
183
225
  function json(value) {
184
226
  return `${JSON.stringify(value, null, " ")}\n`;
185
227
  }
228
+ /** The file `start` runs and `main` points at: sources for JavaScript, the build output for TypeScript. */
229
+ function entryFile(ctx) {
230
+ if (ctx.language === "js") return "src/main.js";
231
+ return isNitroBuild(ctx.buildTool) ? ".output/server/index.mjs" : "dist/main.js";
232
+ }
186
233
  function buildScripts(ctx) {
187
234
  const scripts = {
188
235
  dev: "stars dev",
189
- ...ctx.language === "ts" && ctx.buildTool === "tsdown" ? { postinstall: "stars prepare" } : {},
236
+ ...ctx.language === "ts" && (ctx.buildTool === "tsdown" || isViteBuild(ctx.buildTool)) ? { postinstall: "stars prepare" } : {},
190
237
  ...ctx.language === "js" ? {} : { build: "stars build" },
191
- start: ctx.language === "js" ? "node src/main.js" : "node dist/main.js"
238
+ start: `node ${entryFile(ctx)}`
192
239
  };
193
240
  if (ctx.linter === "oxlint") {
194
241
  scripts["lint"] = "oxlint src";
@@ -219,6 +266,10 @@ function buildDependencies(ctx) {
219
266
  "gradient-string": caret(v["gradient-string"])
220
267
  };
221
268
  if (ctx.i18n) dependencies["@wolfstar/plugin-i18next"] = caret(v["@wolfstar/plugin-i18next"]);
269
+ if (ctx.gateway) dependencies["@wolfstar/plugin-gateway"] = caret(v["@wolfstar/plugin-gateway"]);
270
+ if (ctx.cache) dependencies["@wolfstar/plugin-cache"] = caret(v["@wolfstar/plugin-cache"]);
271
+ if (ctx.redis) dependencies["ioredis"] = caret(v["ioredis"]);
272
+ if (ctx.sharder) dependencies["@wolfstar/plugin-sharder"] = caret(v["@wolfstar/plugin-sharder"]);
222
273
  return sortKeys(dependencies);
223
274
  }
224
275
  function buildDevDependencies(ctx) {
@@ -236,6 +287,12 @@ function buildDevDependencies(ctx) {
236
287
  case "tsdown":
237
288
  dev["tsdown"] = caret(v["tsdown"]);
238
289
  dev["typescript"] = caret(v["typescript"]);
290
+ break;
291
+ case "vite":
292
+ case "vite-nitro":
293
+ dev["vite"] = caret(v["vite"]);
294
+ dev["typescript"] = caret(v["typescript"]);
295
+ if (isNitroBuild(ctx.buildTool)) dev["nitro"] = v["nitro"];
239
296
  }
240
297
  }
241
298
  if (ctx.linter === "eslint") {
@@ -254,7 +311,7 @@ function buildDevDependencies(ctx) {
254
311
  }
255
312
  function packageJson(ctx) {
256
313
  const devDependencies = buildDevDependencies(ctx);
257
- const main = ctx.language === "js" ? "src/main.js" : "dist/main.js";
314
+ const main = entryFile(ctx);
258
315
  return json({
259
316
  name: ctx.name,
260
317
  version: "1.0.0",
@@ -264,7 +321,7 @@ function packageJson(ctx) {
264
321
  scripts: buildScripts(ctx),
265
322
  dependencies: buildDependencies(ctx),
266
323
  ...Object.keys(devDependencies).length > 0 ? { devDependencies } : {},
267
- engines: { node: ">=20" }
324
+ engines: { node: ctx.gateway ? ">=24.17.0" : ">=20" }
268
325
  });
269
326
  }
270
327
  const sharedCompilerOptions = {
@@ -283,6 +340,19 @@ const sharedCompilerOptions = {
283
340
  /** Writes the tsconfig(s). The tsc branches use a composite build so `tsc -b src` resolves `src/tsconfig.json`. */
284
341
  function writeTsconfig(targetDir, ctx) {
285
342
  if (ctx.language === "js") return;
343
+ if (isViteBuild(ctx.buildTool)) {
344
+ writeFile(join(targetDir, "tsconfig.json"), json({
345
+ extends: "./.stars/tsconfig.json",
346
+ compilerOptions: { types: ["node"] },
347
+ include: ["src/**/*.ts", ".stars/*.d.ts"],
348
+ exclude: [
349
+ "node_modules",
350
+ "dist",
351
+ ".output"
352
+ ]
353
+ }));
354
+ return;
355
+ }
286
356
  if (ctx.buildTool === "tsdown") {
287
357
  writeFile(join(targetDir, "tsconfig.json"), json({
288
358
  extends: "./.stars/tsconfig.json",
@@ -312,14 +382,16 @@ function writeTsconfig(targetDir, ctx) {
312
382
  }
313
383
  /**
314
384
  * Writes the `stars.config.*` file read by the `stars` CLI (`dev`, `build`, `info`, `codegen` scripts). Conventional
315
- * JavaScript and tsdown projects need no options; an explicit tsc selection is the only generated override.
385
+ * JavaScript and tsdown projects need no options; an explicit tsc, Vite or Nitro selection is the only generated override.
316
386
  */
317
387
  function writeStarsConfig(targetDir, ctx) {
318
388
  const isJs = ctx.language === "js";
389
+ let options = !isJs && (ctx.buildTool === "tsc6" || ctx.buildTool === "tsc7") ? "{ build: { tool: 'tsc' } }" : "{}";
390
+ if (!isJs && isViteBuild(ctx.buildTool)) options = `{ build: { tool: 'vite' }, experimental: { enableVite: true${isNitroBuild(ctx.buildTool) ? ", enableNitro: true, nitro: { preset: 'node-server' }" : ""} } }`;
319
391
  const content = [
320
392
  "import { defineConfig } from '@wolfstar/http-framework/config';",
321
393
  "",
322
- `export default defineConfig(${!isJs && ctx.buildTool !== "tsdown" ? "{ build: { tool: 'tsc' } }" : "{}"});`,
394
+ `export default defineConfig(${options});`,
323
395
  ""
324
396
  ].join("\n");
325
397
  writeFile(join(targetDir, isJs ? "stars.config.js" : "stars.config.ts"), content);
@@ -421,6 +493,31 @@ function removeI18nDeclaration(outputDir) {
421
493
  const typesDir = dirname(target);
422
494
  if (existsSync(typesDir) && readdirSync(typesDir).length === 0) rmSync(typesDir, { recursive: true });
423
495
  }
496
+ /**
497
+ * What the Handlebars sources see: the persisted {@link TemplateContext} plus values derived from it. Deriving at render
498
+ * time keeps the manifest small and lets manifests written before these fields existed still render.
499
+ */
500
+ function toRenderContext(context) {
501
+ const typescript = context.language === "ts";
502
+ const commands = [{
503
+ className: "PingCommand",
504
+ file: "ping"
505
+ }];
506
+ if (context.subcommandsAdvanced) commands.push({
507
+ className: "SettingsCommand",
508
+ file: "settings"
509
+ });
510
+ else if (context.subcommands) commands.push({
511
+ className: "MathCommand",
512
+ file: "math"
513
+ });
514
+ return {
515
+ ...context,
516
+ vite: typescript && isViteBuild(context.buildTool),
517
+ nitro: typescript && isNitroBuild(context.buildTool),
518
+ commands
519
+ };
520
+ }
424
521
  function walkDir(dir) {
425
522
  const entries = readdirSync(dir);
426
523
  const files = [];
@@ -438,6 +535,10 @@ function walkDir(dir) {
438
535
  function resolveFeatureDirs(ctx) {
439
536
  const dirs = [];
440
537
  if (ctx.i18n) dirs.push("i18n");
538
+ if (ctx.gateway) dirs.push("gateway");
539
+ if (ctx.cache) dirs.push("cache");
540
+ if (ctx.redis) dirs.push("redis");
541
+ if (ctx.sharder) dirs.push("sharder");
441
542
  if (ctx.subcommandsAdvanced) dirs.push(ctx.i18n ? "subcommands-advanced-i18n" : "subcommands-advanced");
442
543
  else if (ctx.subcommands) dirs.push(ctx.i18n ? "subcommands-i18n" : "subcommands");
443
544
  if (ctx.testing) {
@@ -464,7 +565,7 @@ function collectOutputPaths(root, language) {
464
565
  }
465
566
  function renderSource(absoluteSource, context) {
466
567
  const rawContent = readFileSync(absoluteSource, "utf-8");
467
- return absoluteSource.endsWith(".hbs") ? Handlebars.compile(rawContent)(context) : rawContent;
568
+ return absoluteSource.endsWith(".hbs") ? Handlebars.compile(rawContent)(toRenderContext(context)) : rawContent;
468
569
  }
469
570
  function escapeRegExp(text) {
470
571
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -483,8 +584,7 @@ function isSimpleInterpolationOnly(rawSource) {
483
584
  * existed — every run after this one writes a manifest, closing the gap for good.
484
585
  *
485
586
  * `null` for files with block helpers (`{{#if}}`, …), which can't be turned into a simple wildcard
486
- * pattern this way; none currently end up in "stale candidate" position, but this keeps the caller's
487
- * exact-render fallback available if that changes.
587
+ * pattern this way; the caller compares those against {@link buildLegacyVariantMatchers} instead.
488
588
  */
489
589
  function buildStaleFileMatcher(absoluteSource) {
490
590
  const rawContent = readFileSync(absoluteSource, "utf-8");
@@ -494,6 +594,33 @@ function buildStaleFileMatcher(absoluteSource) {
494
594
  return new RegExp(`^${literalChunks.join("[\\s\\S]*?")}$`);
495
595
  }
496
596
  /**
597
+ * Matchers for a source using block helpers (`main.*` is the one that matters), covering exactly the ways the generator
598
+ * that predated the manifest could have written it: `tsdown`, with or without i18n, and none of the later features.
599
+ * Anything older than that renders differently and is preserved rather than cleaned, which is the safe direction to
600
+ * be wrong in. Like {@link buildStaleFileMatcher} it leaves the interpolated name and port as wildcards, by rendering
601
+ * placeholders there.
602
+ */
603
+ function buildLegacyVariantMatchers(absoluteSource, context) {
604
+ const placeholder = "\0";
605
+ const legacy = {
606
+ ...context,
607
+ name: placeholder,
608
+ port: placeholder,
609
+ gateway: false,
610
+ cache: false,
611
+ redis: false,
612
+ sharder: false
613
+ };
614
+ return [false, true].map((i18n) => {
615
+ const rendered = renderSource(absoluteSource, {
616
+ ...legacy,
617
+ buildTool: "tsdown",
618
+ i18n
619
+ });
620
+ return new RegExp(`^${rendered.split(placeholder).map(escapeRegExp).join("[\\s\\S]*?")}$`);
621
+ });
622
+ }
623
+ /**
497
624
  * Maps every output-relative path `root` could ever produce — for either language — to the source
498
625
  * file(s) that render it. Unlike {@link collectOutputPaths} this ignores `context.language`, so it
499
626
  * also surfaces paths left behind by a previous run under the *other* language (e.g. `src/main.ts`
@@ -548,7 +675,7 @@ function removeStaleGeneratedFiles(outputDir, context) {
548
675
  const actual = readFileSync(target, "utf-8");
549
676
  if (manifestContext ? sources.some((source) => renderSource(source, manifestContext) === actual) : sources.some((source) => {
550
677
  const matcher = buildStaleFileMatcher(source);
551
- return matcher ? matcher.test(actual) : renderSource(source, context) === actual;
678
+ return (matcher ? [matcher] : buildLegacyVariantMatchers(source, context)).some((candidate) => candidate.test(actual));
552
679
  })) rmSync(target);
553
680
  else preserved.push(path);
554
681
  }
@@ -569,7 +696,7 @@ function processDir(root, outputDir, context) {
569
696
  const rawContent = readFileSync(absoluteSource, "utf-8");
570
697
  const isHandlebars = absoluteSource.endsWith(".hbs");
571
698
  const outputPath = join(outputDir, outputRelative);
572
- const content = isHandlebars ? Handlebars.compile(rawContent)(context) : rawContent;
699
+ const content = isHandlebars ? Handlebars.compile(rawContent)(toRenderContext(context)) : rawContent;
573
700
  writeFile(outputPath, content);
574
701
  }
575
702
  }
@@ -608,7 +735,7 @@ Options:
608
735
  --interactive, -i Force interactive prompts even when an AI agent is detected
609
736
  --package-manager <pm> npm | yarn | pnpm | bun (defaults to the detected one)
610
737
  --language <lang> ts | js (default: ts)
611
- --build <tool> tsc6 | tsc7 | tsdown — TypeScript only (default: tsdown)
738
+ --build <tool> tsc6 | tsc7 | tsdown | vite | vite-nitro — TypeScript only (default: tsdown)
612
739
  --lint <linter> none | eslint | oxlint (default: oxlint)
613
740
  --format <formatter> none | prettier | oxfmt (default: oxfmt)
614
741
  --port <number> HTTP port (default: 3000)
@@ -616,6 +743,10 @@ Options:
616
743
  --subcommands / --no-subcommands Toggle the subcommands example command (default: off)
617
744
  --subcommands-advanced / --no-subcommands-advanced Toggle the subcommand groups example command (default: off; overrides --subcommands)
618
745
  --testing / --no-testing Toggle the testing setup (vitest) (default: off)
746
+ --gateway / --no-gateway Toggle @wolfstar/plugin-gateway, gateway events next to the HTTP interactions (default: off)
747
+ --cache / --no-cache Toggle @wolfstar/plugin-cache, the gateway entity cache (default: off; enables --gateway)
748
+ --redis / --no-redis Store the cache in Redis instead of memory (default: off; enables --cache)
749
+ --sharder / --no-sharder Toggle @wolfstar/plugin-sharder, gateway shards across cluster workers (default: off; enables --gateway)
619
750
  --install / --no-install Toggle dependency installation (default: on)
620
751
  --ignore Write into an existing, non-empty directory without clearing it
621
752
  --help, -h Print this message and exit
@@ -670,6 +801,10 @@ async function main() {
670
801
  const cliSubcommands = argv["subcommands"];
671
802
  const cliSubcommandsAdvanced = argv["subcommands-advanced"];
672
803
  const cliTesting = argv["testing"];
804
+ const cliGateway = argv["gateway"];
805
+ const cliCache = argv["cache"];
806
+ const cliRedis = argv["redis"];
807
+ const cliSharder = argv["sharder"];
673
808
  const cliInstall = argv["install"];
674
809
  const { isAgent, agent } = await determineAgent();
675
810
  const agentMode = isAgent && flagInteractive !== true;
@@ -816,6 +951,14 @@ async function main() {
816
951
  {
817
952
  value: "tsc7",
818
953
  label: "TypeScript 7.0 rc (tsc)"
954
+ },
955
+ {
956
+ value: "vite",
957
+ label: "Vite (experimental)"
958
+ },
959
+ {
960
+ value: "vite-nitro",
961
+ label: "Vite + Nitro (experimental)"
819
962
  }
820
963
  ],
821
964
  initialValue: "tsdown"
@@ -885,11 +1028,20 @@ async function main() {
885
1028
  let wantsSubcommands;
886
1029
  let wantsSubcommandsAdvanced;
887
1030
  let wantsTesting;
1031
+ let wantsGateway;
1032
+ let wantsCache;
1033
+ let wantsRedis;
1034
+ let wantsSharder;
1035
+ const nitro = language === "ts" && isNitroBuild(buildTool);
888
1036
  if (nonInteractive) {
889
1037
  wantsI18n = cliI18n ?? false;
890
1038
  wantsSubcommands = cliSubcommands ?? false;
891
1039
  wantsSubcommandsAdvanced = cliSubcommandsAdvanced ?? false;
892
1040
  wantsTesting = cliTesting ?? false;
1041
+ wantsGateway = cliGateway ?? false;
1042
+ wantsCache = cliCache ?? false;
1043
+ wantsRedis = cliRedis ?? false;
1044
+ wantsSharder = cliSharder ?? false;
893
1045
  } else {
894
1046
  const featuresResult = await multiselect({
895
1047
  message: "Which optional features would you like to add?",
@@ -909,7 +1061,19 @@ async function main() {
909
1061
  {
910
1062
  value: "testing",
911
1063
  label: "Testing setup (vitest)"
912
- }
1064
+ },
1065
+ {
1066
+ value: "gateway",
1067
+ label: "Gateway events (@wolfstar/plugin-gateway)"
1068
+ },
1069
+ {
1070
+ value: "cache",
1071
+ label: "Gateway entity cache (@wolfstar/plugin-cache)"
1072
+ },
1073
+ ...nitro ? [] : [{
1074
+ value: "sharder",
1075
+ label: "Gateway sharding across cluster workers (@wolfstar/plugin-sharder)"
1076
+ }]
913
1077
  ],
914
1078
  initialValues: [],
915
1079
  required: false
@@ -923,11 +1087,38 @@ async function main() {
923
1087
  wantsSubcommands = features.has("subcommands");
924
1088
  wantsSubcommandsAdvanced = features.has("subcommands-advanced");
925
1089
  wantsTesting = features.has("testing");
1090
+ wantsGateway = features.has("gateway");
1091
+ wantsCache = features.has("cache");
1092
+ wantsSharder = features.has("sharder");
1093
+ wantsRedis = cliRedis ?? false;
1094
+ if (wantsCache && cliRedis === void 0) {
1095
+ const redisResult = await confirm({
1096
+ message: "Would you like to store the cache in Redis (ioredis) instead of memory?",
1097
+ initialValue: false
1098
+ });
1099
+ if (isCancel(redisResult)) {
1100
+ cancel("Operation cancelled.");
1101
+ process.exit(0);
1102
+ }
1103
+ wantsRedis = Boolean(redisResult);
1104
+ }
926
1105
  }
927
1106
  if (wantsSubcommands && wantsSubcommandsAdvanced) {
928
1107
  log.warn("Both --subcommands and --subcommands-advanced were selected; using the advanced example (with groups).");
929
1108
  wantsSubcommands = false;
930
1109
  }
1110
+ if (wantsSharder && nitro) {
1111
+ cancel(SHARDER_WITH_NITRO_ERROR);
1112
+ process.exit(1);
1113
+ }
1114
+ const gatewayFeatures = resolveGatewayFeatures({
1115
+ gateway: wantsGateway,
1116
+ cache: wantsCache,
1117
+ redis: wantsRedis,
1118
+ sharder: wantsSharder
1119
+ });
1120
+ if (gatewayFeatures.implied.length > 0) log.warn(`Enabling ${gatewayFeatures.implied.map((feature) => `--${feature}`).join(" and ")}, which the selected features depend on.`);
1121
+ ({gateway: wantsGateway, cache: wantsCache, redis: wantsRedis, sharder: wantsSharder} = gatewayFeatures.features);
931
1122
  let wantsInstall;
932
1123
  if (nonInteractive) wantsInstall = cliInstall;
933
1124
  else {
@@ -948,6 +1139,10 @@ async function main() {
948
1139
  subcommands: wantsSubcommands,
949
1140
  subcommandsAdvanced: wantsSubcommandsAdvanced,
950
1141
  testing: wantsTesting,
1142
+ gateway: wantsGateway,
1143
+ cache: wantsCache,
1144
+ redis: wantsRedis,
1145
+ sharder: wantsSharder,
951
1146
  language,
952
1147
  buildTool,
953
1148
  linter,
@@ -962,7 +1157,12 @@ async function main() {
962
1157
  i18n: wantsI18n,
963
1158
  subcommands: wantsSubcommands,
964
1159
  subcommandsAdvanced: wantsSubcommandsAdvanced,
965
- testing: wantsTesting
1160
+ testing: wantsTesting,
1161
+ gateway: wantsGateway,
1162
+ cache: wantsCache,
1163
+ redis: wantsRedis,
1164
+ sharder: wantsSharder,
1165
+ buildTool
966
1166
  });
967
1167
  writeProjectFiles(targetDir, {
968
1168
  name: projectName,
@@ -971,6 +1171,10 @@ async function main() {
971
1171
  subcommands: wantsSubcommands,
972
1172
  subcommandsAdvanced: wantsSubcommandsAdvanced,
973
1173
  testing: wantsTesting,
1174
+ gateway: wantsGateway,
1175
+ cache: wantsCache,
1176
+ redis: wantsRedis,
1177
+ sharder: wantsSharder,
974
1178
  packageManager,
975
1179
  language,
976
1180
  buildTool,
@@ -1019,6 +1223,7 @@ async function main() {
1019
1223
  }
1020
1224
  const extraNotes = [];
1021
1225
  if (wantsI18n) extraNotes.push(` After editing locale files, regenerate i18next types with: ${getRunScript(packageManager, "generate:i18n")}`);
1226
+ if (wantsRedis) extraNotes.push(" Start a local Redis server with: docker compose up -d");
1022
1227
  if (wantsTesting) extraNotes.push(` Run the test suite with: ${getRunScript(packageManager, "test")}`);
1023
1228
  outro(`Done! To get started:\n\n cd ${projectName}\n${wantsInstall ? "" : ` ${getInstallScript(packageManager)}\n`} ${getRunScript(packageManager, "dev")}${extraNotes.length ? `\n\n${extraNotes.join("\n")}` : ""}`);
1024
1229
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wolfstar/create-http-framework",
3
- "version": "2.5.2",
3
+ "version": "2.6.0-next-20260926200949",
4
4
  "description": "Create a new WolfStar HTTP Framework bot project",
5
5
  "keywords": [
6
6
  "cli",
@@ -46,11 +46,11 @@
46
46
  "@types/handlebars": "^4.1.0",
47
47
  "@types/mri": "^1.2.0",
48
48
  "@types/node": "22.15.21",
49
- "@vitest/coverage-v8": "^4.1.11",
49
+ "@vitest/coverage-v8": "^5.0.0",
50
50
  "golar": "^0.1.10",
51
51
  "tsdown": "^0.23.0",
52
52
  "typescript": "~7.0.2",
53
- "vitest": "^4.1.11"
53
+ "vitest": "^5.0.0"
54
54
  },
55
55
  "engines": {
56
56
  "node": ">=20"
@@ -1,6 +1,16 @@
1
1
  DISCORD_TOKEN=your_bot_token_here
2
2
  DISCORD_PUBLIC_KEY=your_public_key_here
3
3
  DISCORD_CLIENT_ID=your_client_id_here
4
+ {{#if nitro}}
5
+ PORT={{port}}
6
+ {{else}}
4
7
  HTTP_ADDRESS=0.0.0.0
5
8
  HTTP_PORT={{port}}
9
+ {{/if}}
6
10
  HTTP_POST_PATH=/
11
+ {{#if redis}}
12
+ REDIS_URL=redis://localhost:6379
13
+ {{/if}}
14
+ {{#if sharder}}
15
+ SHARDER_CLUSTERS=2
16
+ {{/if}}
@@ -1,5 +1,6 @@
1
1
  node_modules/
2
2
  dist/
3
+ .output/
3
4
  .stars/
4
5
  .env
5
6
  *.js.map
@@ -10,13 +10,38 @@ A Discord bot built with [`@wolfstar/http-framework`](https://www.npmjs.com/pack
10
10
  {{#if i18n}}
11
11
  3. This project uses [`@wolfstar/plugin-i18next`](https://www.npmjs.com/package/@wolfstar/plugin-i18next). Locale resources live under `src/locales/`; after editing them, regenerate the typed i18next resources with `npm run generate:i18n`.
12
12
  {{/if}}
13
+ {{#if gateway}}
14
+
15
+ This project also connects to the Discord gateway with [`@wolfstar/plugin-gateway`](https://www.npmjs.com/package/@wolfstar/plugin-gateway): `src/main.*` creates a `GatewayClient` that answers HTTP interactions and emits gateway events. It needs a long-lived process and Node.js 24.17 or newer. Add the intents your listeners need to the client options, privileged ones must also be enabled in the Discord Developer Portal.
16
+ {{#if cache}}
17
+ {{#if redis}}
18
+
19
+ Entities are cached in Redis with [`@wolfstar/plugin-cache`](https://www.npmjs.com/package/@wolfstar/plugin-cache): start a local server with `docker compose up -d` (it listens on `REDIS_URL` from `.env`), or point `REDIS_URL` at your own.
20
+ {{else}}
21
+
22
+ Entities are cached in memory with [`@wolfstar/plugin-cache`](https://www.npmjs.com/package/@wolfstar/plugin-cache), so nothing outlives the process. Swap `createInMemoryCache` for `createRedisCache` in `src/lib/cache.*` to persist them.
23
+ {{/if}}
24
+ {{/if}}
25
+ {{#if sharder}}
26
+
27
+ Gateway shards are spread across `SHARDER_CLUSTERS` cluster workers by [`@wolfstar/plugin-sharder`](https://www.npmjs.com/package/@wolfstar/plugin-sharder): `src/main.*` is the manager and `src/shard.*` runs in every worker.
28
+ {{/if}}
29
+ {{/if}}
30
+ {{#if vite}}
31
+
32
+ The project is bundled with [Vite](https://vite.dev) into a single file, so the commands are loaded explicitly in `src/main.*`: import every new command there and load it with `container.stores.loadPiece`.
33
+ {{/if}}
34
+ {{#if nitro}}
35
+
36
+ [Nitro](https://nitro.build) serves the bot: `npm run build` writes a deployable `.output/` and `PORT` in `.env` sets the port. `package.json`'s `main` and `start` point at the `node-server` preset's output, `.output/server/index.mjs`, so `npm start` only works on that preset. Targeting another platform means changing `nitro.preset` in `stars.config.ts` and deploying the `.output/` that preset writes, not running it with `npm start`.{{#if gateway}} The gateway connection is held open by the process, so a preset that keeps a server alive is required — the edge presets do not qualify.{{/if}}
37
+ {{/if}}
13
38
 
14
39
  ## Scripts
15
40
 
16
41
  The `dev` and `build` scripts are powered by the [`stars`](https://www.npmjs.com/package/@wolfstar/cli) CLI and read the `stars.config` file at the project root.
17
42
 
18
43
  - `npm run dev` — build, run and restart the bot on changes, with an interactive terminal UI (`npm run dev -- --no-tui` for plain logs).
19
- - `npm run build` — compile the project (TypeScript projects only).
44
+ - `npm run build` — {{#if nitro}}build the deployable `.output/` directory{{else}}compile the project{{/if}} (TypeScript projects only).
20
45
  - `npm start` — run the bot.
21
46
  - `npx stars info` — print the resolved configuration.
22
47
  {{#if testing}}
@@ -33,4 +58,8 @@ The `dev` and `build` scripts are powered by the [`stars`](https://www.npmjs.com
33
58
  - `/settings profile get` / `/settings profile set` / `/settings notifications enable` / `/settings notifications disable` — subcommand groups example.
34
59
  {{/if}}
35
60
 
61
+ {{#if nitro}}
62
+ The HTTP server listens on `PORT` from `.env` — `{{port}}` by default.
63
+ {{else}}
36
64
  The HTTP server listens on `HTTP_ADDRESS`:`HTTP_PORT` from `.env` — `{{port}}` by default.
65
+ {{/if}}
@@ -9,5 +9,13 @@ declare module '@wolfstar/env-utilities' {
9
9
  HTTP_ADDRESS: string;
10
10
  HTTP_PORT: IntegerString;
11
11
  HTTP_POST_PATH: string;
12
+ {{#if redis}}
13
+
14
+ REDIS_URL: string;
15
+ {{/if}}
16
+ {{#if sharder}}
17
+
18
+ SHARDER_CLUSTERS: IntegerString;
19
+ {{/if}}
12
20
  }
13
21
  }
@@ -1,3 +1,6 @@
1
+ {{#if i18n}}
2
+ import '@wolfstar/plugin-i18next/register';
3
+ {{/if}}
1
4
  import { setup } from './lib/setup/all.js';
2
5
  import { envParseInteger, envParseString } from '@wolfstar/env-utilities';
3
6
  import { Client, container } from '@wolfstar/http-framework';
@@ -6,7 +9,12 @@ import { morning } from 'gradient-string';
6
9
 
7
10
  await setup();
8
11
 
9
- const client = new Client();
12
+ const client = new Client({{#if i18n}}{
13
+ i18n: {
14
+ defaultLanguageDirectory: new URL('locales', import.meta.url).pathname,
15
+ defaultName: 'en-US'
16
+ }
17
+ }{{/if}});
10
18
  await client.load();
11
19
 
12
20
  const address = envParseString('HTTP_ADDRESS', '0.0.0.0');
@@ -1,13 +1,59 @@
1
+ {{#if nitro}}
2
+ {{#if i18n}}
3
+ import { existsSync } from 'node:fs';
4
+ {{/if}}
5
+ {{/if}}
6
+ {{#if i18n}}
7
+ import '@wolfstar/plugin-i18next/register';
8
+ {{/if}}
1
9
  import { setup } from './lib/setup/all.js';
10
+ {{#unless nitro}}
2
11
  import { envParseInteger, envParseString } from '@wolfstar/env-utilities';
12
+ {{/unless}}
3
13
  import { Client, container } from '@wolfstar/http-framework';
14
+ {{#unless nitro}}
4
15
  import { createStarsBanner } from '@wolfstar/start-banner';
5
16
  import { morning } from 'gradient-string';
17
+ {{/unless}}
18
+ {{#if vite}}
19
+ {{#each commands}}
20
+ import { {{className}} } from './commands/{{file}}.js';
21
+ {{/each}}
22
+ {{/if}}
6
23
 
7
24
  await setup();
25
+ {{#if nitro}}
26
+ {{#if i18n}}
8
27
 
9
- const client = new Client();
28
+ // Nitro bundles this file into `.output/server`, next to the `.output/locales` copy `stars build` makes; in development
29
+ // it runs from `src`, where the locales sit beside it.
30
+ const bundledLocales = new URL('../locales', import.meta.url);
31
+ const localesDirectory = existsSync(bundledLocales) ? bundledLocales.pathname : new URL('locales', import.meta.url).pathname;
32
+ {{/if}}
33
+ {{/if}}
34
+
35
+ const client = new Client({{#if i18n}}{
36
+ i18n: {
37
+ defaultLanguageDirectory: {{#if nitro}}localesDirectory{{else}}new URL('locales', import.meta.url).pathname{{/if}},
38
+ defaultName: 'en-US'
39
+ }
40
+ }{{/if}});
41
+ {{#if vite}}
42
+
43
+ // The bundle is a single file, so there is no `commands` directory next to it to scan: load the pieces explicitly.
44
+ {{#each commands}}
45
+ await container.stores.loadPiece({ name: '{{file}}', piece: {{className}}, store: 'commands' });
46
+ {{/each}}
47
+ await client.load({ baseUserDirectory: null });
48
+ {{else}}
10
49
  await client.load();
50
+ {{/if}}
51
+ {{#if nitro}}
52
+
53
+ // No `listen()`: the server entry Nitro generates imports this default export and forwards every request to
54
+ // `client.fetch(request)`, which verifies the signature and answers the interaction. Nitro serves on `PORT`.
55
+ export default client;
56
+ {{else}}
11
57
 
12
58
  const address = envParseString('HTTP_ADDRESS', '0.0.0.0');
13
59
  const port = envParseInteger('HTTP_PORT', {{port}});
@@ -28,3 +74,4 @@ console.log(
28
74
  );
29
75
 
30
76
  container.logger.info('Ready');
77
+ {{/if}}
@@ -0,0 +1,27 @@
1
+ {{#if redis}}
2
+ import { envParseString } from '@wolfstar/env-utilities';
3
+ {{/if}}
4
+ import { {{#if redis}}createRedisCache{{else}}createInMemoryCache{{/if}} } from '@wolfstar/plugin-cache';
5
+ {{#if redis}}
6
+ import { Redis } from 'ioredis';
7
+ {{/if}}
8
+
9
+ {{#if redis}}
10
+ /** Caches the gateway's entities in the Redis server at `REDIS_URL`, see `compose.yaml` to start one locally. */
11
+ export function createCache() {
12
+ return createRedisCache({
13
+ redis: new Redis(envParseString('REDIS_URL')),
14
+ // Entries live at `<prefix>:<entity>:<key>`, so several bots can share one database.
15
+ prefix: '{{name}}'
16
+ });
17
+ }
18
+ {{else}}
19
+ /**
20
+ * Keeps the gateway's entities in memory: nothing outlives the process. Swapping the store never changes the call sites,
21
+ * the gateway client and its managers only see the `Cache` interface (`createRedisCache` is the persistent one).
22
+ */
23
+ export function createCache() {
24
+ // Keep the latest 1,000 messages, every other entity is unbounded.
25
+ return createInMemoryCache({ maxSize: { messages: 1_000 } });
26
+ }
27
+ {{/if}}
@@ -0,0 +1,27 @@
1
+ {{#if redis}}
2
+ import { envParseString } from '@wolfstar/env-utilities';
3
+ {{/if}}
4
+ import { {{#if redis}}createRedisCache{{else}}createInMemoryCache{{/if}}, type Cache } from '@wolfstar/plugin-cache';
5
+ {{#if redis}}
6
+ import { Redis } from 'ioredis';
7
+ {{/if}}
8
+
9
+ {{#if redis}}
10
+ /** Caches the gateway's entities in the Redis server at `REDIS_URL`, see `compose.yaml` to start one locally. */
11
+ export function createCache(): Cache {
12
+ return createRedisCache({
13
+ redis: new Redis(envParseString('REDIS_URL')),
14
+ // Entries live at `<prefix>:<entity>:<key>`, so several bots can share one database.
15
+ prefix: '{{name}}'
16
+ });
17
+ }
18
+ {{else}}
19
+ /**
20
+ * Keeps the gateway's entities in memory: nothing outlives the process. Swapping the store never changes the call sites,
21
+ * the gateway client and its managers only see the `Cache` interface (`createRedisCache` is the persistent one).
22
+ */
23
+ export function createCache(): Cache {
24
+ // Keep the latest 1,000 messages, every other entity is unbounded.
25
+ return createInMemoryCache({ maxSize: { messages: 1_000 } });
26
+ }
27
+ {{/if}}
@@ -0,0 +1,54 @@
1
+ {{#if i18n}}
2
+ import '@wolfstar/plugin-i18next/register';
3
+ {{/if}}
4
+ import { setup } from './lib/setup/all.js';
5
+ {{#if cache}}
6
+ import { createCache } from './lib/cache.js';
7
+ {{/if}}
8
+ import { envParseInteger, envParseString } from '@wolfstar/env-utilities';
9
+ import { container } from '@wolfstar/http-framework';
10
+ import { GatewayClient } from '@wolfstar/plugin-gateway';
11
+ import { createStarsBanner } from '@wolfstar/start-banner';
12
+ import { GatewayIntentBits } from 'discord-api-types/v10';
13
+ import { morning } from 'gradient-string';
14
+
15
+ await setup();
16
+
17
+ const client = new GatewayClient({
18
+ {{#if i18n}}
19
+ i18n: {
20
+ defaultLanguageDirectory: new URL('locales', import.meta.url).pathname,
21
+ defaultName: 'en-US'
22
+ },
23
+ {{/if}}
24
+ {{#if cache}}
25
+ // Every dispatch is written into the cache before its event is emitted.
26
+ cache: createCache(),
27
+ {{/if}}
28
+ // Add the intents your listeners need, privileged ones (`MessageContent`, `GuildMembers`, `GuildPresences`) must also
29
+ // be enabled under Bot → Privileged Gateway Intents in the Developer Portal.
30
+ intents: GatewayIntentBits.Guilds
31
+ });
32
+
33
+ client.on('error', (error) => container.logger.error(error));
34
+
35
+ const address = envParseString('HTTP_ADDRESS', '0.0.0.0');
36
+ const port = envParseInteger('HTTP_PORT', {{port}});
37
+ // Loads the pieces, starts the HTTP interactions endpoint, then connects every gateway shard.
38
+ await client.start({ listen: { address, port } });
39
+
40
+ console.log(
41
+ morning.multiline(
42
+ createStarsBanner({
43
+ name: ['{{name}}'],
44
+ extra: [
45
+ '',
46
+ `Loaded: ${container.stores.get('commands').size} commands`,
47
+ ` : ${container.stores.get('interaction-handlers').size} interaction handlers`,
48
+ `Listening: ${address}:${port}`
49
+ ]
50
+ })
51
+ )
52
+ );
53
+
54
+ container.logger.info('Ready');
@@ -0,0 +1,96 @@
1
+ {{#if nitro}}
2
+ {{#if i18n}}
3
+ import { existsSync } from 'node:fs';
4
+ {{/if}}
5
+ {{/if}}
6
+ {{#if i18n}}
7
+ import '@wolfstar/plugin-i18next/register';
8
+ {{/if}}
9
+ import { setup } from './lib/setup/all.js';
10
+ {{#if cache}}
11
+ import { createCache } from './lib/cache.js';
12
+ {{/if}}
13
+ {{#unless nitro}}
14
+ import { envParseInteger, envParseString } from '@wolfstar/env-utilities';
15
+ {{/unless}}
16
+ import { container } from '@wolfstar/http-framework';
17
+ import { GatewayClient } from '@wolfstar/plugin-gateway';
18
+ {{#unless nitro}}
19
+ import { createStarsBanner } from '@wolfstar/start-banner';
20
+ {{/unless}}
21
+ import { GatewayIntentBits } from 'discord-api-types/v10';
22
+ {{#unless nitro}}
23
+ import { morning } from 'gradient-string';
24
+ {{/unless}}
25
+ {{#if vite}}
26
+ {{#each commands}}
27
+ import { {{className}} } from './commands/{{file}}.js';
28
+ {{/each}}
29
+ {{/if}}
30
+
31
+ await setup();
32
+ {{#if nitro}}
33
+ {{#if i18n}}
34
+
35
+ // Nitro bundles this file into `.output/server`, next to the `.output/locales` copy `stars build` makes; in development
36
+ // it runs from `src`, where the locales sit beside it.
37
+ const bundledLocales = new URL('../locales', import.meta.url);
38
+ const localesDirectory = existsSync(bundledLocales) ? bundledLocales.pathname : new URL('locales', import.meta.url).pathname;
39
+ {{/if}}
40
+ {{/if}}
41
+
42
+ const client = new GatewayClient({
43
+ {{#if i18n}}
44
+ i18n: {
45
+ defaultLanguageDirectory: {{#if nitro}}localesDirectory{{else}}new URL('locales', import.meta.url).pathname{{/if}},
46
+ defaultName: 'en-US'
47
+ },
48
+ {{/if}}
49
+ {{#if cache}}
50
+ // Every dispatch is written into the cache before its event is emitted.
51
+ cache: createCache(),
52
+ {{/if}}
53
+ // Add the intents your listeners need, privileged ones (`MessageContent`, `GuildMembers`, `GuildPresences`) must also
54
+ // be enabled under Bot → Privileged Gateway Intents in the Developer Portal.
55
+ intents: GatewayIntentBits.Guilds
56
+ });
57
+
58
+ client.on('error', (error) => container.logger.error(error));
59
+ {{#if vite}}
60
+
61
+ // The bundle is a single file, so there is no `commands` directory next to it to scan: load the pieces explicitly.
62
+ {{#each commands}}
63
+ await container.stores.loadPiece({ name: '{{file}}', piece: {{className}}, store: 'commands' });
64
+ {{/each}}
65
+ {{/if}}
66
+ {{#if nitro}}
67
+
68
+ await client.load({ baseUserDirectory: null });
69
+ // Nitro answers the HTTP interactions (see the default export), the gateway connection stays with this process: run it
70
+ // on a preset that keeps a server alive, such as `node-server`.
71
+ await client.connect();
72
+
73
+ export default client;
74
+ {{else}}
75
+
76
+ const address = envParseString('HTTP_ADDRESS', '0.0.0.0');
77
+ const port = envParseInteger('HTTP_PORT', {{port}});
78
+ // Loads the pieces, starts the HTTP interactions endpoint, then connects every gateway shard.
79
+ await client.start({ listen: { address, port }{{#if vite}}, load: { baseUserDirectory: null }{{/if}} });
80
+
81
+ console.log(
82
+ morning.multiline(
83
+ createStarsBanner({
84
+ name: ['{{name}}'],
85
+ extra: [
86
+ '',
87
+ `Loaded: ${container.stores.get('commands').size} commands`,
88
+ ` : ${container.stores.get('interaction-handlers').size} interaction handlers`,
89
+ `Listening: ${address}:${port}`
90
+ ]
91
+ })
92
+ )
93
+ );
94
+
95
+ container.logger.info('Ready');
96
+ {{/if}}
@@ -0,0 +1,5 @@
1
+ services:
2
+ redis:
3
+ image: redis:8-alpine
4
+ ports:
5
+ - '6379:6379'
@@ -0,0 +1,25 @@
1
+ import { setup } from './lib/setup/all.js';
2
+ import { envParseInteger } from '@wolfstar/env-utilities';
3
+ import { ShardClient, ShardManager } from '@wolfstar/plugin-sharder';
4
+
5
+ await setup();
6
+
7
+ // The manager and the shards share this script: `ShardClient.context` is only set in the processes the manager
8
+ // spawned.
9
+ if (ShardClient.context === null) {
10
+ // Discord's recommended gateway shard count (fetched with `DISCORD_TOKEN`), split across `SHARDER_CLUSTERS`
11
+ // `node:cluster` workers. Cluster workers share the HTTP port, so Node balances the interactions across them.
12
+ const manager = new ShardManager({
13
+ strategy: 'cluster',
14
+ clusters: envParseInteger('SHARDER_CLUSTERS', 2),
15
+ // Identifies are paced by the manager across every worker, see `identifyThrottler` in `src/shard.ts`.
16
+ spawn: { delay: 0 }
17
+ });
18
+
19
+ manager.on('shardReady', (channel) => console.log(`Worker ${channel.id} is ready (gateway shards ${channel.shards.join(', ')})`));
20
+ manager.on('shardGiveUp', (channel) => console.error(`Worker ${channel.id} keeps crashing, giving up on it`));
21
+
22
+ await manager.spawn();
23
+ } else {
24
+ await import('./shard.js');
25
+ }
@@ -0,0 +1,25 @@
1
+ import { setup } from './lib/setup/all.js';
2
+ import { envParseInteger } from '@wolfstar/env-utilities';
3
+ import { ShardClient, ShardManager } from '@wolfstar/plugin-sharder';
4
+
5
+ await setup();
6
+
7
+ // The manager and the shards share this script: `ShardClient.context` is only set in the processes the manager
8
+ // spawned.
9
+ if (ShardClient.context === null) {
10
+ // Discord's recommended gateway shard count (fetched with `DISCORD_TOKEN`), split across `SHARDER_CLUSTERS`
11
+ // `node:cluster` workers. Cluster workers share the HTTP port, so Node balances the interactions across them.
12
+ const manager = new ShardManager({
13
+ strategy: 'cluster',
14
+ clusters: envParseInteger('SHARDER_CLUSTERS', 2),
15
+ // Identifies are paced by the manager across every worker, see `identifyThrottler` in `src/shard.ts`.
16
+ spawn: { delay: 0 }
17
+ });
18
+
19
+ manager.on('shardReady', (channel) => console.log(`Worker ${channel.id} is ready (gateway shards ${channel.shards.join(', ')})`));
20
+ manager.on('shardGiveUp', (channel) => console.error(`Worker ${channel.id} keeps crashing, giving up on it`));
21
+
22
+ await manager.spawn();
23
+ } else {
24
+ await import('./shard.js');
25
+ }
@@ -0,0 +1,49 @@
1
+ {{#if i18n}}
2
+ import '@wolfstar/plugin-i18next/register';
3
+ {{/if}}
4
+ {{#if cache}}
5
+ import { createCache } from './lib/cache.js';
6
+ {{/if}}
7
+ import { envParseInteger, envParseString } from '@wolfstar/env-utilities';
8
+ import { container } from '@wolfstar/http-framework';
9
+ import { GatewayClient } from '@wolfstar/plugin-gateway';
10
+ import { ShardClient } from '@wolfstar/plugin-sharder';
11
+ import { GatewayIntentBits } from 'discord-api-types/v10';
12
+
13
+ const shard = new ShardClient();
14
+
15
+ const client = new GatewayClient({
16
+ {{#if i18n}}
17
+ i18n: {
18
+ defaultLanguageDirectory: new URL('locales', import.meta.url).pathname,
19
+ defaultName: 'en-US'
20
+ },
21
+ {{/if}}
22
+ {{#if cache}}
23
+ cache: createCache(),
24
+ {{/if}}
25
+ // The gateway shards the manager assigned to this worker, and the total across every worker.
26
+ ...shard.gatewayOptions,
27
+ // Identifies go through the manager, which grants one per `max_concurrency` bucket across every worker.
28
+ gateway: { buildIdentifyThrottler: () => shard.identifyThrottler },
29
+ intents: GatewayIntentBits.Guilds
30
+ });
31
+
32
+ // Reuse the manager's `GET /gateway/bot` rather than requesting it again from every worker.
33
+ client.gateway.fetchGatewayInformation = () => shard.fetchGatewayInformation();
34
+ client.on('error', (error) => container.logger.error(error));
35
+
36
+ shard.setCloseHandler(() => client.destroy());
37
+
38
+ // The worker is ready once every gateway shard it runs is. The manager only starts the next worker once this one
39
+ // reports in, so a worker it assigned no gateway shard to (a layout can hand every shard to one channel of the
40
+ // cluster) reports in right away instead of waiting for a `shardReady` that never fires.
41
+ let pending = shard.shards.length;
42
+ if (pending === 0) void shard.ready();
43
+ client.on('shardReady', () => {
44
+ if (--pending === 0) void shard.ready();
45
+ });
46
+
47
+ const address = envParseString('HTTP_ADDRESS', '0.0.0.0');
48
+ const port = envParseInteger('HTTP_PORT', {{port}});
49
+ await client.start({ listen: { address, port } });
@@ -0,0 +1,61 @@
1
+ {{#if i18n}}
2
+ import '@wolfstar/plugin-i18next/register';
3
+ {{/if}}
4
+ {{#if cache}}
5
+ import { createCache } from './lib/cache.js';
6
+ {{/if}}
7
+ import { envParseInteger, envParseString } from '@wolfstar/env-utilities';
8
+ import { container } from '@wolfstar/http-framework';
9
+ import { GatewayClient } from '@wolfstar/plugin-gateway';
10
+ import { ShardClient } from '@wolfstar/plugin-sharder';
11
+ import { GatewayIntentBits } from 'discord-api-types/v10';
12
+ {{#if vite}}
13
+ {{#each commands}}
14
+ import { {{className}} } from './commands/{{file}}.js';
15
+ {{/each}}
16
+ {{/if}}
17
+
18
+ const shard = new ShardClient();
19
+
20
+ const client = new GatewayClient({
21
+ {{#if i18n}}
22
+ i18n: {
23
+ defaultLanguageDirectory: new URL('locales', import.meta.url).pathname,
24
+ defaultName: 'en-US'
25
+ },
26
+ {{/if}}
27
+ {{#if cache}}
28
+ cache: createCache(),
29
+ {{/if}}
30
+ // The gateway shards the manager assigned to this worker, and the total across every worker.
31
+ ...shard.gatewayOptions,
32
+ // Identifies go through the manager, which grants one per `max_concurrency` bucket across every worker.
33
+ gateway: { buildIdentifyThrottler: () => shard.identifyThrottler },
34
+ intents: GatewayIntentBits.Guilds
35
+ });
36
+
37
+ // Reuse the manager's `GET /gateway/bot` rather than requesting it again from every worker.
38
+ client.gateway.fetchGatewayInformation = () => shard.fetchGatewayInformation();
39
+ client.on('error', (error) => container.logger.error(error));
40
+
41
+ shard.setCloseHandler(() => client.destroy());
42
+
43
+ // The worker is ready once every gateway shard it runs is. The manager only starts the next worker once this one
44
+ // reports in, so a worker it assigned no gateway shard to (a layout can hand every shard to one channel of the
45
+ // cluster) reports in right away instead of waiting for a `shardReady` that never fires.
46
+ let pending = shard.shards.length;
47
+ if (pending === 0) void shard.ready();
48
+ client.on('shardReady', () => {
49
+ if (--pending === 0) void shard.ready();
50
+ });
51
+ {{#if vite}}
52
+
53
+ // The bundle is a single file, so there is no `commands` directory next to it to scan: load the pieces explicitly.
54
+ {{#each commands}}
55
+ await container.stores.loadPiece({ name: '{{file}}', piece: {{className}}, store: 'commands' });
56
+ {{/each}}
57
+ {{/if}}
58
+
59
+ const address = envParseString('HTTP_ADDRESS', '0.0.0.0');
60
+ const port = envParseInteger('HTTP_PORT', {{port}});
61
+ await client.start({ listen: { address, port }{{#if vite}}, load: { baseUserDirectory: null }{{/if}} });
@@ -1,36 +0,0 @@
1
- import '@wolfstar/plugin-i18next/register';
2
- import { setup } from './lib/setup/all.js';
3
- import { envParseInteger, envParseString } from '@wolfstar/env-utilities';
4
- import { Client, container } from '@wolfstar/http-framework';
5
- import { createStarsBanner } from '@wolfstar/start-banner';
6
- import { morning } from 'gradient-string';
7
-
8
- await setup();
9
-
10
- const client = new Client({
11
- i18n: {
12
- defaultLanguageDirectory: new URL('locales', import.meta.url).pathname,
13
- defaultName: 'en-US'
14
- }
15
- });
16
- await client.load();
17
-
18
- const address = envParseString('HTTP_ADDRESS', '0.0.0.0');
19
- const port = envParseInteger('HTTP_PORT', {{port}});
20
- await client.listen({ address, port });
21
-
22
- console.log(
23
- morning.multiline(
24
- createStarsBanner({
25
- name: ['{{name}}'],
26
- extra: [
27
- '',
28
- `Loaded: ${container.stores.get('commands').size} commands`,
29
- ` : ${container.stores.get('interaction-handlers').size} interaction handlers`,
30
- `Listening: ${address}:${port}`
31
- ]
32
- })
33
- )
34
- );
35
-
36
- container.logger.info('Ready');
@@ -1,36 +0,0 @@
1
- import '@wolfstar/plugin-i18next/register';
2
- import { setup } from './lib/setup/all.js';
3
- import { envParseInteger, envParseString } from '@wolfstar/env-utilities';
4
- import { Client, container } from '@wolfstar/http-framework';
5
- import { createStarsBanner } from '@wolfstar/start-banner';
6
- import { morning } from 'gradient-string';
7
-
8
- await setup();
9
-
10
- const client = new Client({
11
- i18n: {
12
- defaultLanguageDirectory: new URL('locales', import.meta.url).pathname,
13
- defaultName: 'en-US'
14
- }
15
- });
16
- await client.load();
17
-
18
- const address = envParseString('HTTP_ADDRESS', '0.0.0.0');
19
- const port = envParseInteger('HTTP_PORT', {{port}});
20
- await client.listen({ address, port });
21
-
22
- console.log(
23
- morning.multiline(
24
- createStarsBanner({
25
- name: ['{{name}}'],
26
- extra: [
27
- '',
28
- `Loaded: ${container.stores.get('commands').size} commands`,
29
- ` : ${container.stores.get('interaction-handlers').size} interaction handlers`,
30
- `Listening: ${address}:${port}`
31
- ]
32
- })
33
- )
34
- );
35
-
36
- container.logger.info('Ready');