@uploadista/server 0.0.10 → 0.0.12

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.
@@ -0,0 +1,217 @@
1
+ import type { UploadistaError } from "@uploadista/core";
2
+ import type {
3
+ CredentialProviderLayer,
4
+ ExtractLayerServices,
5
+ Flow,
6
+ ImageAiPluginLayer,
7
+ ImagePluginLayer,
8
+ ZipPluginLayer,
9
+ } from "@uploadista/core/flow";
10
+ import type { Effect, Layer } from "effect";
11
+ import type { z } from "zod";
12
+
13
+ /**
14
+ * Utility type to extract all services from a tuple of layers.
15
+ * Given [Layer<A>, Layer<B>], extracts A | B.
16
+ *
17
+ * This is a wrapper around the shared ExtractLayerServices utility from @uploadista/core.
18
+ *
19
+ * @deprecated Use ExtractLayerServices from @uploadista/core/flow/types instead.
20
+ * This will be removed in a future version.
21
+ */
22
+ // biome-ignore lint/suspicious/noExplicitAny: Utility type for extracting services from any layer tuple
23
+ export type ExtractServicesFromLayers<
24
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint must accept any layer configuration
25
+ T extends readonly Layer.Layer<any, any, any>[],
26
+ > = ExtractLayerServices<T>;
27
+
28
+ /**
29
+ * Known plugin layer types for better type inference.
30
+ * This union helps TypeScript understand which plugins are available.
31
+ */
32
+ export type KnownPluginLayer =
33
+ | ImagePluginLayer
34
+ | ImageAiPluginLayer
35
+ | CredentialProviderLayer
36
+ | ZipPluginLayer;
37
+
38
+ /**
39
+ * Type-safe plugin tuple that only accepts known plugin layers.
40
+ * This provides autocomplete and validation for plugin arrays.
41
+ */
42
+ export type PluginTuple = readonly KnownPluginLayer[];
43
+
44
+ /**
45
+ * Extracts the union of all plugin services from a plugin tuple.
46
+ *
47
+ * Uses the shared ExtractLayerServices utility from @uploadista/core for consistency.
48
+ *
49
+ * @example
50
+ * ```typescript
51
+ * type Plugins = [ImagePluginLayer, ZipPluginLayer];
52
+ * type Services = PluginServices<Plugins>;
53
+ * // Services = ImagePlugin | ZipPlugin
54
+ * ```
55
+ */
56
+ export type PluginServices<TPlugins extends PluginTuple> =
57
+ ExtractLayerServices<TPlugins>;
58
+
59
+ /**
60
+ * Type-safe flow function that declares its plugin requirements.
61
+ *
62
+ * @template TRequirements - Union of plugin services this flow needs
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * // Flow that requires ImagePlugin
67
+ * const myFlow: TypeSafeFlowFunction<ImagePlugin> = (flowId, clientId) =>
68
+ * Effect.gen(function* () {
69
+ * const imageService = yield* ImagePlugin;
70
+ * // ...
71
+ * });
72
+ * ```
73
+ */
74
+ export type TypeSafeFlowFunction<TRequirements = never> = (
75
+ flowId: string,
76
+ clientId: string | null,
77
+ ) => Effect.Effect<
78
+ Flow<z.ZodSchema<unknown>, z.ZodSchema<unknown>, TRequirements>,
79
+ UploadistaError,
80
+ TRequirements
81
+ >;
82
+
83
+ /**
84
+ * Validates that plugins satisfy flow requirements.
85
+ *
86
+ * This type creates a compile-time error if required plugins are missing.
87
+ * When validation fails, it returns an error object with detailed information
88
+ * including a human-readable message.
89
+ *
90
+ * @template TPlugins - The plugin tuple provided
91
+ * @template TRequirements - The services required by flows
92
+ *
93
+ * @example
94
+ * ```typescript
95
+ * // ✅ Valid: ImagePlugin is provided and required
96
+ * type Valid = ValidatePlugins<[ImagePluginLayer], ImagePlugin>;
97
+ * // Result: true
98
+ *
99
+ * // ❌ Error: ImagePlugin required but not provided
100
+ * type Invalid = ValidatePlugins<[], ImagePlugin>;
101
+ * // Result: {
102
+ * // __error: "MISSING_REQUIRED_PLUGINS";
103
+ * // __message: "Missing required plugins: ...";
104
+ * // __required: ImagePlugin;
105
+ * // __provided: never;
106
+ * // __missing: ImagePlugin;
107
+ * // }
108
+ * ```
109
+ */
110
+ export type ValidatePlugins<
111
+ TPlugins extends PluginTuple,
112
+ TRequirements,
113
+ > = TRequirements extends never
114
+ ? true // No requirements, always valid
115
+ : TRequirements extends PluginServices<TPlugins>
116
+ ? true // All requirements satisfied
117
+ : {
118
+ readonly __error: "MISSING_REQUIRED_PLUGINS";
119
+ readonly __message: "Missing required plugins. Check __missing field for details.";
120
+ readonly __required: TRequirements;
121
+ readonly __provided: PluginServices<TPlugins>;
122
+ readonly __missing: Exclude<TRequirements, PluginServices<TPlugins>>;
123
+ readonly __hint: "Add the missing plugins to your server configuration's plugins array.";
124
+ };
125
+
126
+ /**
127
+ * Type-safe server configuration with compile-time plugin validation.
128
+ *
129
+ * This ensures that all plugins required by flows are actually provided.
130
+ *
131
+ * @template TPlugins - Tuple of plugin layers
132
+ * @template TFlowRequirements - Union of services that flows need
133
+ *
134
+ * @example
135
+ * ```typescript
136
+ * // ✅ Compiles: ImagePlugin provided and required
137
+ * const config: TypeSafePluginConfig<
138
+ * [ImagePluginLayer],
139
+ * ImagePlugin
140
+ * > = {
141
+ * plugins: [sharpImagePlugin],
142
+ * flows: (flowId, clientId) => imageFlow
143
+ * };
144
+ *
145
+ * // ❌ Compile error: ImagePlugin required but not provided
146
+ * const bad: TypeSafePluginConfig<
147
+ * [],
148
+ * ImagePlugin
149
+ * > = {
150
+ * plugins: [],
151
+ * flows: (flowId, clientId) => imageFlow
152
+ * };
153
+ * ```
154
+ */
155
+ export type TypeSafePluginConfig<
156
+ TPlugins extends PluginTuple,
157
+ TFlowRequirements,
158
+ > = ValidatePlugins<TPlugins, TFlowRequirements> extends true
159
+ ? {
160
+ plugins: TPlugins;
161
+ flows: TypeSafeFlowFunction<TFlowRequirements>;
162
+ }
163
+ : ValidatePlugins<TPlugins, TFlowRequirements>; // Returns error object
164
+
165
+ /**
166
+ * Extracts plugin requirements from a flow function type.
167
+ *
168
+ * This navigates through the flow function signature to extract the requirements
169
+ * from the Flow type it returns, excluding UploadServer (provided by runtime).
170
+ *
171
+ * @template TFlowFn - The flow function type to extract requirements from
172
+ *
173
+ * @example
174
+ * ```typescript
175
+ * const myFlow = (flowId: string, clientId: string | null) =>
176
+ * Effect.succeed(
177
+ * createFlow({ ... }) // Returns Flow<..., ..., ImagePlugin | ZipPlugin>
178
+ * );
179
+ *
180
+ * type Requirements = ExtractFlowPluginRequirements<typeof myFlow>;
181
+ * // Requirements = ImagePlugin | ZipPlugin
182
+ * ```
183
+ */
184
+ export type ExtractFlowPluginRequirements<
185
+ TFlowFn extends (
186
+ flowId: string,
187
+ clientId: string | null,
188
+ ) => Effect.Effect<unknown, unknown, unknown>,
189
+ > = ReturnType<TFlowFn> extends Effect.Effect<infer TFlow, any, any>
190
+ ? TFlow extends Flow<any, any, infer TRequirements>
191
+ ? Exclude<TRequirements, never> // Exclude UploadServer is handled by FlowPluginRequirements in core
192
+ : never
193
+ : never;
194
+
195
+ /**
196
+ * Helper type to infer plugin requirements from a flow function.
197
+ *
198
+ * @example
199
+ * ```typescript
200
+ * const myFlow: TypeSafeFlowFunction<ImagePlugin | ZipPlugin> = ...;
201
+ * type Requirements = InferFlowRequirements<typeof myFlow>;
202
+ * // Requirements = ImagePlugin | ZipPlugin
203
+ * ```
204
+ */
205
+ export type InferFlowRequirements<T> = T extends TypeSafeFlowFunction<infer R>
206
+ ? R
207
+ : never;
208
+
209
+ /**
210
+ * Converts PluginLayer types to Layer.Layer<any, never, any> for runtime use.
211
+ * Maintains type safety at compile time while allowing flexible runtime composition.
212
+ */
213
+ export type RuntimePluginLayers<T extends PluginTuple> = {
214
+ [K in keyof T]: T[K] extends Layer.Layer<infer S, infer E, infer R>
215
+ ? Layer.Layer<S, E, R>
216
+ : never;
217
+ };
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Runtime plugin validation utilities.
3
+ *
4
+ * This module provides runtime validation to ensure that all plugins required
5
+ * by flows are actually provided to the server. While Effect-TS will catch
6
+ * missing dependencies at runtime, this validation provides better error messages
7
+ * and fails fast during server initialization.
8
+ *
9
+ * @module plugin-validation
10
+ */
11
+
12
+ import type { PluginLayer } from "@uploadista/core";
13
+ import { Effect } from "effect";
14
+
15
+ /**
16
+ * Result of plugin validation.
17
+ */
18
+ export type PluginValidationResult =
19
+ | {
20
+ success: true;
21
+ }
22
+ | {
23
+ success: false;
24
+ required: string[];
25
+ provided: string[];
26
+ missing: string[];
27
+ suggestions: Array<{
28
+ name: string;
29
+ packageName: string;
30
+ importStatement: string;
31
+ }>;
32
+ };
33
+
34
+ /**
35
+ * Known plugin mapping for generating helpful error messages.
36
+ *
37
+ * This maps service identifiers to their package names and variable names
38
+ * for generating import suggestions.
39
+ */
40
+ const KNOWN_PLUGINS: Record<
41
+ string,
42
+ { packageName: string; variableName: string }
43
+ > = {
44
+ ImagePlugin: {
45
+ packageName: "@uploadista/flow-images-sharp",
46
+ variableName: "sharpImagePlugin",
47
+ },
48
+ ImageAiPlugin: {
49
+ packageName: "@uploadista/flow-images-replicate",
50
+ variableName: "replicateImagePlugin",
51
+ },
52
+ ZipPlugin: {
53
+ packageName: "@uploadista/flow-utility-zipjs",
54
+ variableName: "zipPlugin",
55
+ },
56
+ CredentialProvider: {
57
+ packageName: "@uploadista/core",
58
+ variableName: "credentialProviderLayer",
59
+ },
60
+ };
61
+
62
+ /**
63
+ * Extracts service identifier from a plugin layer.
64
+ *
65
+ * This attempts to identify the service provided by a layer using various
66
+ * heuristics. The exact implementation depends on how Effect-TS exposes
67
+ * layer metadata.
68
+ *
69
+ * @param layer - The plugin layer to inspect
70
+ * @returns Service identifier string or null if not identifiable
71
+ */
72
+ function extractServiceIdentifier(layer: PluginLayer): string | null {
73
+ // Attempt to extract service identifier from layer
74
+ // Note: Effect-TS doesn't expose this information in a standard way,
75
+ // so we use Symbol.toStringTag or constructor name as fallbacks
76
+
77
+ try {
78
+ // Try to get the service tag if available
79
+ // biome-ignore lint/suspicious/noExplicitAny: Layer introspection requires accessing internal properties
80
+ const layerAny = layer as any;
81
+
82
+ // Check for common patterns in Effect layers
83
+ if (layerAny._tag) {
84
+ return layerAny._tag;
85
+ }
86
+
87
+ if (layerAny.constructor?.name) {
88
+ return layerAny.constructor.name;
89
+ }
90
+
91
+ // Try to extract from the layer's context if available
92
+ if (layerAny.context?.services) {
93
+ const services = Array.from(layerAny.context.services.keys());
94
+ if (services.length > 0) {
95
+ // biome-ignore lint/suspicious/noExplicitAny: Service introspection requires accessing internal properties
96
+ const firstService = services[0] as any;
97
+ if (firstService.key) {
98
+ return firstService.key;
99
+ }
100
+ }
101
+ }
102
+
103
+ return null;
104
+ } catch {
105
+ // If we can't extract the identifier, return null
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Extracts service identifiers from an array of plugin layers.
112
+ *
113
+ * @param plugins - Array of plugin layers
114
+ * @returns Array of service identifier strings
115
+ */
116
+ export function extractServiceIdentifiers(
117
+ plugins: readonly PluginLayer[],
118
+ ): string[] {
119
+ return plugins
120
+ .map((plugin) => extractServiceIdentifier(plugin))
121
+ .filter((id): id is string => id !== null);
122
+ }
123
+
124
+ /**
125
+ * Validates that all required plugins are provided.
126
+ *
127
+ * This is a runtime validation function that checks if the plugins array
128
+ * contains all services required by the flows. It's called during server
129
+ * initialization to provide early, clear error messages.
130
+ *
131
+ * Note: This validation is best-effort because we can't reliably extract
132
+ * requirements from flow functions at runtime without executing them.
133
+ * The main validation happens via Effect-TS's dependency injection.
134
+ *
135
+ * @param config - Validation configuration
136
+ * @returns Validation result with detailed error information if validation fails
137
+ *
138
+ * @example
139
+ * ```typescript
140
+ * const result = validatePluginRequirements({
141
+ * plugins: [sharpImagePlugin, zipPlugin],
142
+ * expectedServices: ['ImagePlugin', 'ZipPlugin']
143
+ * });
144
+ *
145
+ * if (!result.success) {
146
+ * console.error('Missing plugins:', result.missing);
147
+ * console.error('Suggestions:', result.suggestions);
148
+ * }
149
+ * ```
150
+ */
151
+ export function validatePluginRequirements(config: {
152
+ plugins: readonly PluginLayer[];
153
+ expectedServices?: string[];
154
+ }): PluginValidationResult {
155
+ const { plugins, expectedServices = [] } = config;
156
+
157
+ // Extract identifiers from provided plugins
158
+ const providedServices = extractServiceIdentifiers(plugins);
159
+
160
+ // Check for missing services
161
+ const missing = expectedServices.filter(
162
+ (required) => !providedServices.includes(required),
163
+ );
164
+
165
+ if (missing.length === 0) {
166
+ return { success: true };
167
+ }
168
+
169
+ // Generate suggestions for missing plugins
170
+ const suggestions = missing
171
+ .map((service) => {
172
+ const knownPlugin = KNOWN_PLUGINS[service];
173
+ if (!knownPlugin) {
174
+ return null;
175
+ }
176
+
177
+ return {
178
+ name: service,
179
+ packageName: knownPlugin.packageName,
180
+ importStatement: `import { ${knownPlugin.variableName} } from '${knownPlugin.packageName}';`,
181
+ };
182
+ })
183
+ .filter((s): s is NonNullable<typeof s> => s !== null);
184
+
185
+ return {
186
+ success: false,
187
+ required: expectedServices,
188
+ provided: providedServices,
189
+ missing,
190
+ suggestions,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Creates a formatted error message for plugin validation failures.
196
+ *
197
+ * This generates a detailed, human-readable error message that includes:
198
+ * - List of required plugins
199
+ * - List of provided plugins
200
+ * - List of missing plugins
201
+ * - Import statements for missing plugins (if known)
202
+ * - Example server configuration
203
+ *
204
+ * @param result - Failed validation result
205
+ * @returns Formatted error message string
206
+ *
207
+ * @example
208
+ * ```typescript
209
+ * const result = validatePluginRequirements({ ... });
210
+ * if (!result.success) {
211
+ * const message = formatPluginValidationError(result);
212
+ * throw new Error(message);
213
+ * }
214
+ * ```
215
+ */
216
+ export function formatPluginValidationError(
217
+ result: Extract<PluginValidationResult, { success: false }>,
218
+ ): string {
219
+ const lines: string[] = [
220
+ "Server initialization failed: Missing required plugins",
221
+ "",
222
+ `Required: ${result.required.join(", ")}`,
223
+ `Provided: ${result.provided.length > 0 ? result.provided.join(", ") : "(none)"}`,
224
+ `Missing: ${result.missing.join(", ")}`,
225
+ "",
226
+ ];
227
+
228
+ if (result.suggestions.length > 0) {
229
+ lines.push("Add the missing plugins to your configuration:");
230
+ lines.push("");
231
+ for (const suggestion of result.suggestions) {
232
+ lines.push(` ${suggestion.importStatement}`);
233
+ }
234
+ lines.push("");
235
+ lines.push(" const server = await createUploadistaServer({");
236
+ lines.push(
237
+ ` plugins: [${[...result.provided, ...result.missing.map((m) => KNOWN_PLUGINS[m]?.variableName || m)].join(", ")}],`,
238
+ );
239
+ lines.push(" // ...");
240
+ lines.push(" });");
241
+ } else {
242
+ lines.push(
243
+ "Note: Could not determine package names for missing plugins.",
244
+ );
245
+ lines.push("Please ensure all required plugin layers are provided.");
246
+ }
247
+
248
+ return lines.join("\n");
249
+ }
250
+
251
+ /**
252
+ * Effect-based plugin validation that can be composed with other Effects.
253
+ *
254
+ * This provides an Effect-TS native way to validate plugins, allowing it
255
+ * to be composed with other Effects in the server initialization pipeline.
256
+ *
257
+ * @param config - Validation configuration
258
+ * @returns Effect that succeeds if validation passes, fails with UploadistaError if not
259
+ *
260
+ * @example
261
+ * ```typescript
262
+ * const validatedServer = Effect.gen(function* () {
263
+ * yield* validatePluginRequirementsEffect({
264
+ * plugins: [sharpImagePlugin],
265
+ * expectedServices: ['ImagePlugin', 'ZipPlugin']
266
+ * });
267
+ *
268
+ * return yield* createServerEffect(...);
269
+ * });
270
+ * ```
271
+ */
272
+ export function validatePluginRequirementsEffect(config: {
273
+ plugins: readonly PluginLayer[];
274
+ expectedServices?: string[];
275
+ }): Effect.Effect<void, Error> {
276
+ return Effect.sync(() => {
277
+ const result = validatePluginRequirements(config);
278
+
279
+ if (!result.success) {
280
+ const message = formatPluginValidationError(result);
281
+ throw new Error(message);
282
+ }
283
+ });
284
+ }
285
+
286
+ /**
287
+ * Validates plugin configuration at runtime during server initialization.
288
+ *
289
+ * This is a convenience function that performs validation and throws a
290
+ * descriptive error if validation fails. Use this at the beginning of
291
+ * createUploadistaServer to fail fast with clear error messages.
292
+ *
293
+ * @param config - Validation configuration
294
+ * @throws Error with detailed message if validation fails
295
+ *
296
+ * @example
297
+ * ```typescript
298
+ * export const createUploadistaServer = async (config) => {
299
+ * // Validate plugins early
300
+ * validatePluginsOrThrow({
301
+ * plugins: config.plugins,
302
+ * expectedServices: ['ImagePlugin', 'ZipPlugin']
303
+ * });
304
+ *
305
+ * // Continue with server creation...
306
+ * };
307
+ * ```
308
+ */
309
+ export function validatePluginsOrThrow(config: {
310
+ plugins: readonly PluginLayer[];
311
+ expectedServices?: string[];
312
+ }): void {
313
+ const result = validatePluginRequirements(config);
314
+
315
+ if (!result.success) {
316
+ const message = formatPluginValidationError(result);
317
+ throw new Error(message);
318
+ }
319
+ }