@aponiajs/platform-elysia 0.6.0-alpha.1 → 0.6.0-alpha.14

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
@@ -41,8 +41,12 @@ import { AponiaFactory, ElysiaPluginModule } from "@aponiajs/platform-elysia";
41
41
  @Module({})
42
42
  class AppModule {}
43
43
 
44
- const application = await AponiaFactory.create(AppModule);
45
- await application.listen(3000);
44
+ async function bootstrap(): Promise<void> {
45
+ const application = await AponiaFactory.create(AppModule);
46
+ await application.listen(3000);
47
+ }
48
+
49
+ await bootstrap();
46
50
  ```
47
51
 
48
52
  ## Routes with the native Elysia context
@@ -134,10 +138,131 @@ shared configured module is installed once across diamond imports. A stable
134
138
  `key` keeps module diagnostics deterministic and prevents duplicate
135
139
  registrations with the same key.
136
140
 
141
+ ### Typing what a plugin adds
142
+
143
+ Compiling a decorated controller erases the plugin instances its module imports,
144
+ so no plugin type reaches a handler on its own. Name the plugins in the second
145
+ type argument of `ElysiaRouteContext` and the context types what they add:
146
+
147
+ ```ts
148
+ import { Controller, Ctx, Get } from "@aponiajs/common";
149
+ import { type ElysiaRouteContext } from "@aponiajs/platform-elysia";
150
+ import { Elysia } from "elysia";
151
+
152
+ export const clock = new Elysia({ name: "clock" })
153
+ .decorate("now", () => new Date().toISOString())
154
+ .state("requests", 0)
155
+ .derive({ as: "global" }, () => ({ traceId: crypto.randomUUID() }));
156
+
157
+ @Controller("health")
158
+ class HealthController {
159
+ @Get()
160
+ read(@Ctx() context: ElysiaRouteContext<typeof clock>) {
161
+ context.store.requests += 1;
162
+ return { now: context.now(), traceId: context.traceId };
163
+ }
164
+ }
165
+ ```
166
+
167
+ The first argument takes either the plugins or a route schema, so a handler
168
+ without a schema never writes an empty one. A tuple covers several plugins, and
169
+ the second argument is only needed when both are typed:
170
+
171
+ ```ts
172
+ ElysiaRouteContext<[typeof clock, typeof cache]>;
173
+ ElysiaRouteContext<typeof createUser, typeof clock>;
174
+ ```
175
+
176
+ An application that always mounts the same plugins declares the pairing once and
177
+ keeps every handler short:
178
+
179
+ ```ts
180
+ // src/app.context.ts
181
+ import { type ElysiaInputSchema, type ElysiaRouteContext } from "@aponiajs/platform-elysia";
182
+ import { cache } from "./cache.plugin.ts";
183
+ import { clock } from "./clock.plugin.ts";
184
+
185
+ export type AppContext<TSchema extends ElysiaInputSchema = {}> = ElysiaRouteContext<
186
+ TSchema,
187
+ [typeof clock, typeof cache]
188
+ >;
189
+ ```
190
+
191
+ ```ts
192
+ @Get()
193
+ read(@Ctx() context: AppContext) {}
194
+
195
+ @Post("/", createUser)
196
+ create(@Ctx() context: AppContext<typeof createUser>) {}
197
+ ```
198
+
199
+ ### Dropping `typeof`
200
+
201
+ `defineElysiaPlugin` converts a native plugin into a module import that also
202
+ carries the plugin type. Export it beside a same-named type and the plugin is
203
+ usable in both a value and a type position:
204
+
205
+ ```ts
206
+ // src/clock.plugin.ts
207
+ import { defineElysiaPlugin } from "@aponiajs/platform-elysia";
208
+ import { Elysia } from "elysia";
209
+
210
+ export const clock = defineElysiaPlugin(
211
+ new Elysia({ name: "clock" }).decorate("now", () => new Date().toISOString()),
212
+ { key: "clock" },
213
+ );
214
+ export type clock = typeof clock;
215
+ ```
216
+
217
+ The import goes straight into `imports`, with no `ElysiaPluginModule.register`
218
+ around it, and the annotation needs no `typeof`. Rename the context type on
219
+ import for the shortest form:
220
+
221
+ ```ts
222
+ import { Controller, Ctx, Get, Module } from "@aponiajs/common";
223
+ import { type ElysiaRouteContext as e } from "@aponiajs/platform-elysia";
224
+ import { cache } from "./cache.plugin.ts";
225
+ import { clock } from "./clock.plugin.ts";
226
+
227
+ @Controller("health")
228
+ class HealthController {
229
+ @Get()
230
+ read(@Ctx() context: e<clock>) {
231
+ return { now: context.now() };
232
+ }
233
+
234
+ @Get("cached")
235
+ readCached(@Ctx() context: e<[clock, cache]>) {
236
+ return { cached: context.cache.read("health") };
237
+ }
238
+ }
239
+
240
+ @Module({ imports: [clock, cache], controllers: [HealthController] })
241
+ class HealthModule {}
242
+ ```
243
+
244
+ `ElysiaPluginModule.register` and `registerAsync` stay available and unchanged;
245
+ `defineElysiaPlugin` is `register` plus the plugin it installs, and the context
246
+ type accepts either form. The plugin instance itself remains reachable as
247
+ `clock.plugin`.
248
+
249
+ The mapping follows Elysia's own `.use()` rule, so what is typed is exactly what
250
+ arrives at runtime: `decorate`, `state`, `resolve`, and `derive` declared
251
+ `global`, plus `scoped` derives and resolves. A plugin-local derive stays inside
252
+ the plugin and is absent from both the type and the context. Naming no plugin
253
+ costs nothing at runtime — the values are still there, only untyped.
254
+
137
255
  `configureNative` remains available as an application-level escape hatch. It
138
256
  preserves Elysia's accumulated plugin types on `getNativeApplication()`.
139
- Module-imported plugin state and decorators are available at runtime, but do
140
- not yet flow into decorated controller parameter types.
257
+
258
+ ## Bootstrap diagnostics
259
+
260
+ A controller descriptor with a platform kind other than
261
+ `aponia.elysia.controller` fails with `UNSUPPORTED_CONTROLLER`. A recognized
262
+ Elysia controller whose `buildPlugin` factory returns something other than an
263
+ Elysia instance fails with `INVALID_CONTROLLER`. Both are reported during
264
+ `AponiaFactory.create`, before the application can listen.
141
265
 
142
266
  [npm package](https://www.npmjs.com/package/@aponiajs/platform-elysia) ·
267
+ [native plugin guide](../../docs/native-plugins.md) ·
143
268
  [complete package catalog](../../docs/packages.md)
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Constructor, ControllerDefinition, DynamicModule, LogLevel, LoggerService, ModuleDefinition, ModuleImport, Token, TokenValues } from "@aponiajs/common";
2
2
  import "@aponiajs/core";
3
- import { AnyElysia, Context, Elysia, InputSchema, UnwrapRoute } from "elysia";
3
+ import { AnyElysia, Context, Elysia, InputSchema, SingletonBase, UnwrapRoute } from "elysia";
4
4
  //#region src/decorated-module.d.ts
5
5
  type AponiaRootModule = ModuleImport;
6
6
  declare function compileRootModule(rootModule: AponiaRootModule): ModuleDefinition;
@@ -28,13 +28,75 @@ declare class AponiaFactory {
28
28
  }
29
29
  //#endregion
30
30
  //#region src/route-context.d.ts
31
+ /**
32
+ * One plugin a handler reads from: a native Elysia instance, or the module
33
+ * import `defineElysiaPlugin` produces for it.
34
+ */
35
+ type ElysiaPluginSource = AnyElysia | {
36
+ readonly plugin: AnyElysia;
37
+ };
38
+ /**
39
+ * One plugin, or every plugin whose types a handler depends on.
40
+ */
41
+ type ElysiaPluginTypes = ElysiaPluginSource | readonly ElysiaPluginSource[];
42
+ type ResolvePlugin<TSource> = TSource extends {
43
+ readonly plugin: infer TPlugin extends AnyElysia;
44
+ } ? TPlugin : TSource;
45
+ type PluginUnion<TPlugins extends ElysiaPluginTypes> = ResolvePlugin<TPlugins extends readonly (infer TSource)[] ? TSource : TPlugins>;
46
+ type UnionToIntersection<TUnion> = (TUnion extends unknown ? (value: TUnion) => void : never) extends ((value: infer TIntersection) => void) ? TIntersection : never;
47
+ type MergeRecords<TUnion> = UnionToIntersection<TUnion> extends (infer TMerged extends Record<string, unknown>) ? TMerged : {};
48
+ /**
49
+ * The singleton a controller sees once the plugins are mounted. `use()` merges
50
+ * a plugin's global `~Singleton` into its parent, and its `scoped` `~Ephemeral`
51
+ * derives and resolves reach the routes mounted alongside it, so both are part
52
+ * of the context. Plugin-local derives stay inside the plugin and are excluded.
53
+ */
54
+ type MountedSingleton<TPlugins extends ElysiaPluginTypes> = {
55
+ decorator: MergeRecords<PluginUnion<TPlugins>["~Singleton"]["decorator"]>;
56
+ store: MergeRecords<PluginUnion<TPlugins>["~Singleton"]["store"]>;
57
+ derive: MergeRecords<PluginUnion<TPlugins>["~Singleton"]["derive"] | PluginUnion<TPlugins>["~Ephemeral"]["derive"]>;
58
+ resolve: MergeRecords<PluginUnion<TPlugins>["~Singleton"]["resolve"] | PluginUnion<TPlugins>["~Ephemeral"]["resolve"]>;
59
+ } extends (infer TSingleton extends SingletonBase) ? TSingleton : never;
31
60
  /**
32
61
  * The native Elysia request context for a declared route schema. Handlers that
33
62
  * take the whole context — through `@Ctx()` or a single unannotated parameter —
34
63
  * keep `status`, `set`, `cookie`, `store`, `redirect`, and plugin decorators
35
64
  * fully typed.
65
+ *
66
+ * Compiling a decorated controller erases the plugin instances a module
67
+ * imports, so name the plugins a handler reads from. The first argument takes
68
+ * either a route schema or the plugins, so a handler without a schema never
69
+ * writes an empty one:
70
+ *
71
+ * ```ts
72
+ * read(@Ctx() context: ElysiaRouteContext<typeof clock>) {}
73
+ * read(@Ctx() context: ElysiaRouteContext<[typeof clock, typeof cache]>) {}
74
+ * create(@Ctx() context: ElysiaRouteContext<typeof createUser, typeof clock>) {}
75
+ * ```
76
+ *
77
+ * A plugin exported through `defineElysiaPlugin` alongside a same-named type
78
+ * drops the `typeof`, which reads best under a short import alias:
79
+ *
80
+ * ```ts
81
+ * import { type ElysiaRouteContext as e } from "@aponiajs/platform-elysia";
82
+ *
83
+ * read(@Ctx() context: e<clock>) {}
84
+ * ```
85
+ *
86
+ * An application that always mounts the same plugins declares the pairing once
87
+ * and keeps its handlers short:
88
+ *
89
+ * ```ts
90
+ * export type AppContext<TSchema extends ElysiaInputSchema = {}> =
91
+ * ElysiaRouteContext<TSchema, [typeof clock, typeof cache]>;
92
+ * ```
36
93
  */
37
- type ElysiaRouteContext<TSchema extends InputSchema = {}> = Context<UnwrapRoute<TSchema, {}, string>>;
94
+ type ElysiaRouteContext<TSchemaOrPlugins extends InputSchema | ElysiaPluginTypes = {}, TPlugins extends ElysiaPluginTypes = never> = TSchemaOrPlugins extends ElysiaPluginTypes ? Context<UnwrapRoute<{}, {}, string>, MountedSingleton<TSchemaOrPlugins>> : Context<UnwrapRoute<TSchemaOrPlugins extends InputSchema ? TSchemaOrPlugins : {}, {}, string>, MountedSingleton<TPlugins>>;
95
+ /**
96
+ * Elysia's own route schema shape, re-exported so an application can write its
97
+ * own context alias without importing from `elysia` directly.
98
+ */
99
+ type ElysiaInputSchema = InputSchema;
38
100
  //#endregion
39
101
  //#region src/controller.d.ts
40
102
  declare const ELYSIA_CONTROLLER = "aponia.elysia.controller";
@@ -63,5 +125,22 @@ declare class ElysiaPluginModule {
63
125
  static register<const TPlugin extends NativeElysiaPlugin>(plugin: TPlugin, options?: ElysiaPluginModuleOptions): DynamicModule;
64
126
  static registerAsync<const TDependencies extends readonly Token<unknown>[], const TPlugin extends NativeElysiaPlugin>(options: AsyncElysiaPluginModuleOptions<TDependencies, TPlugin>): DynamicModule;
65
127
  }
128
+ /**
129
+ * A module import that carries the native plugin it installs, so the plugin is
130
+ * both mountable and usable as a context type without a separate reference.
131
+ */
132
+ interface ElysiaPluginImport<TPlugin extends AnyElysia> extends DynamicModule {
133
+ readonly plugin: TPlugin;
134
+ }
135
+ /**
136
+ * Convert a native Elysia plugin into a module import. The result goes straight
137
+ * into `imports` and doubles as the plugin type an `ElysiaRouteContext` reads:
138
+ *
139
+ * ```ts
140
+ * export const clock = defineElysiaPlugin(new Elysia({ name: "clock" }), { key: "clock" });
141
+ * export type clock = typeof clock;
142
+ * ```
143
+ */
144
+ declare function defineElysiaPlugin<const TPlugin extends AnyElysia>(plugin: TPlugin, options?: ElysiaPluginModuleOptions): ElysiaPluginImport<TPlugin>;
66
145
  //#endregion
67
- export { type AponiaApplicationOptions, AponiaElysiaApplication, AponiaFactory, type AponiaRootModule, type AsyncElysiaPluginModuleOptions, type ConfiguredAponiaApplicationOptions, ELYSIA_CONTROLLER, type ElysiaControllerDefinition, ElysiaPluginModule, type ElysiaPluginModuleOptions, type ElysiaRouteContext, type NativeElysiaConfigurator, type NativeElysiaPlugin, compileRootModule, defineElysiaController };
146
+ export { type AponiaApplicationOptions, AponiaElysiaApplication, AponiaFactory, type AponiaRootModule, type AsyncElysiaPluginModuleOptions, type ConfiguredAponiaApplicationOptions, ELYSIA_CONTROLLER, type ElysiaControllerDefinition, type ElysiaInputSchema, type ElysiaPluginImport, ElysiaPluginModule, type ElysiaPluginModuleOptions, type ElysiaPluginSource, type ElysiaPluginTypes, type ElysiaRouteContext, type NativeElysiaConfigurator, type NativeElysiaPlugin, compileRootModule, defineElysiaController, defineElysiaPlugin };
package/dist/index.mjs CHANGED
@@ -198,6 +198,21 @@ var ElysiaPluginModule = @Module({}) class {
198
198
  }, options.key);
199
199
  }
200
200
  };
201
+ /**
202
+ * Convert a native Elysia plugin into a module import. The result goes straight
203
+ * into `imports` and doubles as the plugin type an `ElysiaRouteContext` reads:
204
+ *
205
+ * ```ts
206
+ * export const clock = defineElysiaPlugin(new Elysia({ name: "clock" }), { key: "clock" });
207
+ * export type clock = typeof clock;
208
+ * ```
209
+ */
210
+ function defineElysiaPlugin(plugin, options = {}) {
211
+ return Object.freeze({
212
+ ...ElysiaPluginModule.register(plugin, options),
213
+ plugin
214
+ });
215
+ }
201
216
  function isElysiaPluginModule(module) {
202
217
  return module.providers.some((provider) => provider.provide === ELYSIA_PLUGIN);
203
218
  }
@@ -277,7 +292,7 @@ var AponiaFactory = class {
277
292
  }
278
293
  const instance = container.instantiateController(module, controller);
279
294
  const plugin = Reflect.apply(controller.buildPlugin, void 0, [instance]);
280
- if (!(plugin instanceof Elysia)) throw new AponiaError("UNSUPPORTED_CONTROLLER", `Controller "${tokenName(controller.token)}" did not build an Elysia plugin.`, {
295
+ if (!(plugin instanceof Elysia)) throw new AponiaError("INVALID_CONTROLLER", `Controller "${tokenName(controller.token)}" did not build an Elysia plugin.`, {
281
296
  module: module.id,
282
297
  controller: tokenName(controller.token)
283
298
  });
@@ -306,4 +321,4 @@ function inferControllerPath(plugin) {
306
321
  return plugin.routes[0]?.path ?? "/";
307
322
  }
308
323
  //#endregion
309
- export { AponiaElysiaApplication, AponiaFactory, ELYSIA_CONTROLLER, ElysiaPluginModule, compileRootModule, defineElysiaController };
324
+ export { AponiaElysiaApplication, AponiaFactory, ELYSIA_CONTROLLER, ElysiaPluginModule, compileRootModule, defineElysiaController, defineElysiaPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aponiajs/platform-elysia",
3
- "version": "0.6.0-alpha.1",
3
+ "version": "0.6.0-alpha.14",
4
4
  "description": "Minimal Elysia application platform for Aponia modules and controllers.",
5
5
  "homepage": "https://github.com/aponiajs/aponiajs#readme",
6
6
  "bugs": {
@@ -33,8 +33,8 @@
33
33
  "prepublishOnly": "bun run build"
34
34
  },
35
35
  "dependencies": {
36
- "@aponiajs/common": "0.6.0-alpha.1",
37
- "@aponiajs/core": "0.6.0-alpha.1"
36
+ "@aponiajs/common": "0.6.0-alpha.14",
37
+ "@aponiajs/core": "0.6.0-alpha.14"
38
38
  },
39
39
  "devDependencies": {
40
40
  "arktype": "^2.2.3",