alepha 0.14.1 → 0.14.2

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 (97) hide show
  1. package/README.md +3 -3
  2. package/dist/api/audits/index.d.ts +342 -342
  3. package/dist/api/audits/index.d.ts.map +1 -1
  4. package/dist/api/audits/index.js.map +1 -1
  5. package/dist/api/files/index.js.map +1 -1
  6. package/dist/api/jobs/index.d.ts +161 -161
  7. package/dist/api/jobs/index.js.map +1 -1
  8. package/dist/api/parameters/index.js.map +1 -1
  9. package/dist/api/users/index.d.ts +791 -791
  10. package/dist/api/users/index.d.ts.map +1 -1
  11. package/dist/api/users/index.js +4 -0
  12. package/dist/api/users/index.js.map +1 -1
  13. package/dist/api/verifications/index.d.ts +128 -128
  14. package/dist/api/verifications/index.d.ts.map +1 -1
  15. package/dist/batch/index.js.map +1 -1
  16. package/dist/cache/core/index.js.map +1 -1
  17. package/dist/cli/index.d.ts +173 -167
  18. package/dist/cli/index.d.ts.map +1 -1
  19. package/dist/cli/index.js +427 -409
  20. package/dist/cli/index.js.map +1 -1
  21. package/dist/command/index.d.ts +5 -5
  22. package/dist/command/index.js.map +1 -1
  23. package/dist/core/index.browser.js.map +1 -1
  24. package/dist/core/index.d.ts.map +1 -1
  25. package/dist/core/index.js +7 -6
  26. package/dist/core/index.js.map +1 -1
  27. package/dist/core/index.native.js +7 -6
  28. package/dist/core/index.native.js.map +1 -1
  29. package/dist/datetime/index.js.map +1 -1
  30. package/dist/fake/index.js.map +1 -1
  31. package/dist/file/index.js.map +1 -1
  32. package/dist/lock/redis/index.js.map +1 -1
  33. package/dist/logger/index.js.map +1 -1
  34. package/dist/mcp/index.js.map +1 -1
  35. package/dist/orm/index.browser.js +26 -5
  36. package/dist/orm/index.browser.js.map +1 -1
  37. package/dist/orm/index.d.ts +115 -90
  38. package/dist/orm/index.d.ts.map +1 -1
  39. package/dist/orm/index.js +37 -12
  40. package/dist/orm/index.js.map +1 -1
  41. package/dist/redis/index.js.map +1 -1
  42. package/dist/retry/index.js.map +1 -1
  43. package/dist/router/index.js.map +1 -1
  44. package/dist/scheduler/index.d.ts +6 -6
  45. package/dist/scheduler/index.js.map +1 -1
  46. package/dist/security/index.d.ts +28 -28
  47. package/dist/security/index.d.ts.map +1 -1
  48. package/dist/security/index.js.map +1 -1
  49. package/dist/server/auth/index.d.ts +155 -155
  50. package/dist/server/auth/index.js.map +1 -1
  51. package/dist/server/cache/index.js.map +1 -1
  52. package/dist/server/cookies/index.browser.js.map +1 -1
  53. package/dist/server/cookies/index.js.map +1 -1
  54. package/dist/server/core/index.browser.js.map +1 -1
  55. package/dist/server/core/index.js.map +1 -1
  56. package/dist/server/health/index.d.ts +17 -17
  57. package/dist/server/helmet/index.js.map +1 -1
  58. package/dist/server/links/index.browser.js.map +1 -1
  59. package/dist/server/links/index.js.map +1 -1
  60. package/dist/server/multipart/index.js.map +1 -1
  61. package/dist/server/rate-limit/index.js.map +1 -1
  62. package/dist/server/security/index.d.ts +9 -9
  63. package/dist/server/security/index.js.map +1 -1
  64. package/dist/server/swagger/index.js.map +1 -1
  65. package/dist/thread/index.js.map +1 -1
  66. package/dist/topic/core/index.js.map +1 -1
  67. package/dist/vite/index.js.map +1 -1
  68. package/dist/websocket/index.browser.js.map +1 -1
  69. package/dist/websocket/index.d.ts +7 -7
  70. package/dist/websocket/index.js.map +1 -1
  71. package/package.json +3 -3
  72. package/src/api/users/index.ts +4 -0
  73. package/src/cli/apps/AlephaCli.ts +31 -14
  74. package/src/cli/apps/AlephaPackageBuilderCli.ts +2 -1
  75. package/src/cli/assets/appRouterTs.ts +1 -1
  76. package/src/cli/commands/{ViteCommands.ts → build.ts} +2 -105
  77. package/src/cli/commands/{ChangelogCommands.ts → changelog.ts} +7 -22
  78. package/src/cli/commands/clean.ts +14 -0
  79. package/src/cli/commands/{DrizzleCommands.ts → db.ts} +10 -117
  80. package/src/cli/commands/{DeployCommands.ts → deploy.ts} +1 -1
  81. package/src/cli/commands/dev.ts +57 -0
  82. package/src/cli/commands/format.ts +17 -0
  83. package/src/cli/commands/{CoreCommands.ts → init.ts} +2 -40
  84. package/src/cli/commands/lint.ts +17 -0
  85. package/src/cli/commands/root.ts +32 -0
  86. package/src/cli/commands/run.ts +24 -0
  87. package/src/cli/commands/test.ts +42 -0
  88. package/src/cli/commands/typecheck.ts +19 -0
  89. package/src/cli/commands/{VerifyCommands.ts → verify.ts} +1 -13
  90. package/src/cli/defineConfig.ts +10 -1
  91. package/src/cli/index.ts +16 -7
  92. package/src/cli/services/GitMessageParser.ts +1 -1
  93. package/src/core/Alepha.ts +7 -4
  94. package/src/orm/index.browser.ts +1 -1
  95. package/src/orm/index.ts +10 -6
  96. package/src/orm/providers/{PostgresTypeProvider.ts → DatabaseTypeProvider.ts} +25 -3
  97. package/src/cli/commands/BiomeCommands.ts +0 -29
@@ -1,4 +1,4 @@
1
- import * as alepha1 from "alepha";
1
+ import * as alepha6 from "alepha";
2
2
  import { Alepha } from "alepha";
3
3
  import * as alepha_server0 from "alepha/server";
4
4
  import { DateTimeProvider } from "alepha/datetime";
@@ -14,19 +14,19 @@ declare class ServerHealthProvider {
14
14
  protected readonly time: DateTimeProvider;
15
15
  protected readonly alepha: Alepha;
16
16
  readonly health: alepha_server0.RoutePrimitive<{
17
- response: alepha1.TObject<{
18
- message: alepha1.TString;
19
- uptime: alepha1.TNumber;
20
- date: alepha1.TString;
21
- ready: alepha1.TBoolean;
17
+ response: alepha6.TObject<{
18
+ message: alepha6.TString;
19
+ uptime: alepha6.TNumber;
20
+ date: alepha6.TString;
21
+ ready: alepha6.TBoolean;
22
22
  }>;
23
23
  }>;
24
24
  readonly healthz: alepha_server0.RoutePrimitive<{
25
- response: alepha1.TObject<{
26
- message: alepha1.TString;
27
- uptime: alepha1.TNumber;
28
- date: alepha1.TString;
29
- ready: alepha1.TBoolean;
25
+ response: alepha6.TObject<{
26
+ message: alepha6.TString;
27
+ uptime: alepha6.TNumber;
28
+ date: alepha6.TString;
29
+ ready: alepha6.TBoolean;
30
30
  }>;
31
31
  }>;
32
32
  protected healthCheck(): {
@@ -38,11 +38,11 @@ declare class ServerHealthProvider {
38
38
  }
39
39
  //#endregion
40
40
  //#region ../../src/server/health/schemas/healthSchema.d.ts
41
- declare const healthSchema: alepha1.TObject<{
42
- message: alepha1.TString;
43
- uptime: alepha1.TNumber;
44
- date: alepha1.TString;
45
- ready: alepha1.TBoolean;
41
+ declare const healthSchema: alepha6.TObject<{
42
+ message: alepha6.TString;
43
+ uptime: alepha6.TNumber;
44
+ date: alepha6.TString;
45
+ ready: alepha6.TBoolean;
46
46
  }>;
47
47
  //#endregion
48
48
  //#region ../../src/server/health/index.d.ts
@@ -52,7 +52,7 @@ declare const healthSchema: alepha1.TObject<{
52
52
  * @see {@link ServerHealthProvider}
53
53
  * @module alepha.server.health
54
54
  */
55
- declare const AlephaServerHealth: alepha1.Service<alepha1.Module>;
55
+ declare const AlephaServerHealth: alepha6.Service<alepha6.Module>;
56
56
  //#endregion
57
57
  export { AlephaServerHealth, ServerHealthProvider, healthSchema };
58
58
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["headers: Record<string, string>"],"sources":["../../../src/server/helmet/providers/ServerHelmetProvider.ts","../../../src/server/helmet/index.ts"],"sourcesContent":["import { $atom, $hook, $inject, $use, Alepha, type Static, t } from \"alepha\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\n/**\n * Helmet security headers configuration atom\n */\nexport const helmetOptions = $atom({\n name: \"alepha.server.helmet.options\",\n schema: t.object({\n isSecure: t.optional(t.boolean()),\n strictTransportSecurity: t.optional(\n t.object({\n maxAge: t.optional(t.number()),\n includeSubDomains: t.optional(t.boolean()),\n preload: t.optional(t.boolean()),\n }),\n ),\n xContentTypeOptions: t.optional(t.boolean()),\n xFrameOptions: t.optional(t.enum([\"DENY\", \"SAMEORIGIN\"])),\n xXssProtection: t.optional(t.boolean()),\n contentSecurityPolicy: t.optional(\n t.object({\n directives: t.record(t.string(), t.any()),\n }),\n ),\n referrerPolicy: t.optional(\n t.enum([\n \"no-referrer\",\n \"no-referrer-when-downgrade\",\n \"origin\",\n \"origin-when-cross-origin\",\n \"same-origin\",\n \"strict-origin\",\n \"strict-origin-when-cross-origin\",\n \"unsafe-url\",\n ]),\n ),\n }),\n default: {\n strictTransportSecurity: { maxAge: 15552000, includeSubDomains: true },\n xFrameOptions: \"SAMEORIGIN\",\n xXssProtection: false,\n referrerPolicy: \"strict-origin-when-cross-origin\",\n },\n});\n\nexport type HelmetOptions = Static<typeof helmetOptions.schema>;\n\ndeclare module \"alepha\" {\n interface State {\n [helmetOptions.key]: HelmetOptions;\n }\n}\n\n// ---------------------------------------------------------------------------------------------------------------------\n\ntype CspDirective = string | string[];\n\nexport interface CspDirectives {\n \"default-src\"?: CspDirective;\n \"script-src\"?: CspDirective;\n \"style-src\"?: CspDirective;\n \"img-src\"?: CspDirective;\n \"connect-src\"?: CspDirective;\n \"font-src\"?: CspDirective;\n \"object-src\"?: CspDirective;\n \"media-src\"?: CspDirective;\n \"frame-src\"?: CspDirective;\n sandbox?: CspDirective | boolean;\n \"report-uri\"?: string;\n \"child-src\"?: CspDirective;\n \"form-action\"?: CspDirective;\n \"frame-ancestors\"?: CspDirective;\n \"plugin-types\"?: CspDirective;\n \"base-uri\"?: CspDirective;\n [key: string]: CspDirective | undefined | boolean;\n}\n\nexport interface CspOptions {\n directives: CspDirectives;\n}\n\nexport interface HstsOptions {\n maxAge?: number;\n includeSubDomains?: boolean;\n preload?: boolean;\n}\n\n/**\n * Provides a configurable way to apply essential HTTP security headers\n * to every server response, without external dependencies.\n */\nexport class ServerHelmetProvider {\n protected readonly alepha = $inject(Alepha);\n\n /**\n * The configuration options loaded from the atom.\n */\n protected readonly options = $use(helmetOptions);\n\n protected defaultCspDirectives(): CspDirectives {\n return {\n \"default-src\": [\"'self'\"],\n \"base-uri\": [\"'self'\"],\n \"font-src\": [\"'self'\", \"https:\", \"data:\"],\n \"form-action\": [\"'self'\"],\n \"frame-ancestors\": [\"'self'\"],\n \"img-src\": [\"'self'\", \"data:\"],\n \"object-src\": [\"'none'\"],\n \"script-src\": [\"'self'\"],\n \"script-src-attr\": [\"'none'\"],\n \"style-src\": [\"'self'\", \"https:\", \"'unsafe-inline'\"],\n \"upgrade-insecure-requests\": [],\n };\n }\n\n protected buildHeaders(): Record<string, string> {\n const headers: Record<string, string> = {};\n const {\n strictTransportSecurity: hsts,\n xContentTypeOptions,\n xFrameOptions,\n xXssProtection,\n contentSecurityPolicy: csp,\n referrerPolicy,\n } = this.options;\n\n // Strict-Transport-Security\n if (hsts) {\n let value = `max-age=${hsts.maxAge ?? 15552000}`;\n if (hsts.includeSubDomains) value += \"; includeSubDomains\";\n if (hsts.preload) value += \"; preload\";\n headers[\"strict-transport-security\"] = value;\n }\n\n // X-Content-Type-Options\n if (xContentTypeOptions !== false) {\n headers[\"x-content-type-options\"] = \"nosniff\";\n }\n\n // X-Frame-Options\n if (xFrameOptions) {\n headers[\"x-frame-options\"] = xFrameOptions;\n }\n\n // X-XSS-Protection\n if (xXssProtection !== false) {\n headers[\"x-xss-protection\"] = \"1; mode=block\";\n }\n\n // Referrer-Policy\n if (referrerPolicy) {\n headers[\"referrer-policy\"] = referrerPolicy;\n }\n\n // Content-Security-Policy\n if (csp) {\n const directives =\n Object.keys(csp).length === 0\n ? this.defaultCspDirectives()\n : csp.directives;\n headers[\"content-security-policy\"] = Object.entries(directives)\n .map(([key, value]) => {\n const kebabKey = key.replace(\n /[A-Z]/g,\n (letter) => `-${letter.toLowerCase()}`,\n );\n if (Array.isArray(value)) {\n return `${kebabKey} ${value.join(\" \")}`;\n }\n if (typeof value === \"boolean\" && value) {\n return kebabKey;\n }\n return `${kebabKey} ${value}`;\n })\n .join(\"; \");\n }\n\n return headers;\n }\n\n protected readonly onResponse = $hook({\n on: \"server:onResponse\",\n priority: \"first\",\n handler: ({ response }) => {\n // this check is important. Only add HSTS on HTTPS requests.\n const isSecure =\n response.headers[\"x-forwarded-proto\"] === \"https\" ||\n this.options.isSecure ||\n this.alepha.isProduction();\n\n const headersToSet = this.buildHeaders();\n\n for (const [key, value] of Object.entries(headersToSet)) {\n if (key === \"strict-transport-security\" && !isSecure) {\n continue;\n }\n // don't overwrite headers if they are already set\n if (!response.headers[key]) {\n response.headers[key] = value;\n }\n }\n },\n });\n}\n","import { $module } from \"alepha\";\nimport { AlephaServer } from \"alepha/server\";\nimport { ServerHelmetProvider } from \"./providers/ServerHelmetProvider.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport * from \"./providers/ServerHelmetProvider.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\n/**\n * Automatically adds important HTTP security headers to every response\n * to help protect your application from common web vulnerabilities.\n *\n * @see {@link ServerHelmetProvider}\n * @module alepha.server.helmet\n */\nexport const AlephaServerHelmet = $module({\n name: \"alepha.server.helmet\",\n services: [AlephaServer, ServerHelmetProvider],\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/server/helmet/providers/ServerHelmetProvider.ts","../../../src/server/helmet/index.ts"],"sourcesContent":["import { $atom, $hook, $inject, $use, Alepha, type Static, t } from \"alepha\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\n/**\n * Helmet security headers configuration atom\n */\nexport const helmetOptions = $atom({\n name: \"alepha.server.helmet.options\",\n schema: t.object({\n isSecure: t.optional(t.boolean()),\n strictTransportSecurity: t.optional(\n t.object({\n maxAge: t.optional(t.number()),\n includeSubDomains: t.optional(t.boolean()),\n preload: t.optional(t.boolean()),\n }),\n ),\n xContentTypeOptions: t.optional(t.boolean()),\n xFrameOptions: t.optional(t.enum([\"DENY\", \"SAMEORIGIN\"])),\n xXssProtection: t.optional(t.boolean()),\n contentSecurityPolicy: t.optional(\n t.object({\n directives: t.record(t.string(), t.any()),\n }),\n ),\n referrerPolicy: t.optional(\n t.enum([\n \"no-referrer\",\n \"no-referrer-when-downgrade\",\n \"origin\",\n \"origin-when-cross-origin\",\n \"same-origin\",\n \"strict-origin\",\n \"strict-origin-when-cross-origin\",\n \"unsafe-url\",\n ]),\n ),\n }),\n default: {\n strictTransportSecurity: { maxAge: 15552000, includeSubDomains: true },\n xFrameOptions: \"SAMEORIGIN\",\n xXssProtection: false,\n referrerPolicy: \"strict-origin-when-cross-origin\",\n },\n});\n\nexport type HelmetOptions = Static<typeof helmetOptions.schema>;\n\ndeclare module \"alepha\" {\n interface State {\n [helmetOptions.key]: HelmetOptions;\n }\n}\n\n// ---------------------------------------------------------------------------------------------------------------------\n\ntype CspDirective = string | string[];\n\nexport interface CspDirectives {\n \"default-src\"?: CspDirective;\n \"script-src\"?: CspDirective;\n \"style-src\"?: CspDirective;\n \"img-src\"?: CspDirective;\n \"connect-src\"?: CspDirective;\n \"font-src\"?: CspDirective;\n \"object-src\"?: CspDirective;\n \"media-src\"?: CspDirective;\n \"frame-src\"?: CspDirective;\n sandbox?: CspDirective | boolean;\n \"report-uri\"?: string;\n \"child-src\"?: CspDirective;\n \"form-action\"?: CspDirective;\n \"frame-ancestors\"?: CspDirective;\n \"plugin-types\"?: CspDirective;\n \"base-uri\"?: CspDirective;\n [key: string]: CspDirective | undefined | boolean;\n}\n\nexport interface CspOptions {\n directives: CspDirectives;\n}\n\nexport interface HstsOptions {\n maxAge?: number;\n includeSubDomains?: boolean;\n preload?: boolean;\n}\n\n/**\n * Provides a configurable way to apply essential HTTP security headers\n * to every server response, without external dependencies.\n */\nexport class ServerHelmetProvider {\n protected readonly alepha = $inject(Alepha);\n\n /**\n * The configuration options loaded from the atom.\n */\n protected readonly options = $use(helmetOptions);\n\n protected defaultCspDirectives(): CspDirectives {\n return {\n \"default-src\": [\"'self'\"],\n \"base-uri\": [\"'self'\"],\n \"font-src\": [\"'self'\", \"https:\", \"data:\"],\n \"form-action\": [\"'self'\"],\n \"frame-ancestors\": [\"'self'\"],\n \"img-src\": [\"'self'\", \"data:\"],\n \"object-src\": [\"'none'\"],\n \"script-src\": [\"'self'\"],\n \"script-src-attr\": [\"'none'\"],\n \"style-src\": [\"'self'\", \"https:\", \"'unsafe-inline'\"],\n \"upgrade-insecure-requests\": [],\n };\n }\n\n protected buildHeaders(): Record<string, string> {\n const headers: Record<string, string> = {};\n const {\n strictTransportSecurity: hsts,\n xContentTypeOptions,\n xFrameOptions,\n xXssProtection,\n contentSecurityPolicy: csp,\n referrerPolicy,\n } = this.options;\n\n // Strict-Transport-Security\n if (hsts) {\n let value = `max-age=${hsts.maxAge ?? 15552000}`;\n if (hsts.includeSubDomains) value += \"; includeSubDomains\";\n if (hsts.preload) value += \"; preload\";\n headers[\"strict-transport-security\"] = value;\n }\n\n // X-Content-Type-Options\n if (xContentTypeOptions !== false) {\n headers[\"x-content-type-options\"] = \"nosniff\";\n }\n\n // X-Frame-Options\n if (xFrameOptions) {\n headers[\"x-frame-options\"] = xFrameOptions;\n }\n\n // X-XSS-Protection\n if (xXssProtection !== false) {\n headers[\"x-xss-protection\"] = \"1; mode=block\";\n }\n\n // Referrer-Policy\n if (referrerPolicy) {\n headers[\"referrer-policy\"] = referrerPolicy;\n }\n\n // Content-Security-Policy\n if (csp) {\n const directives =\n Object.keys(csp).length === 0\n ? this.defaultCspDirectives()\n : csp.directives;\n headers[\"content-security-policy\"] = Object.entries(directives)\n .map(([key, value]) => {\n const kebabKey = key.replace(\n /[A-Z]/g,\n (letter) => `-${letter.toLowerCase()}`,\n );\n if (Array.isArray(value)) {\n return `${kebabKey} ${value.join(\" \")}`;\n }\n if (typeof value === \"boolean\" && value) {\n return kebabKey;\n }\n return `${kebabKey} ${value}`;\n })\n .join(\"; \");\n }\n\n return headers;\n }\n\n protected readonly onResponse = $hook({\n on: \"server:onResponse\",\n priority: \"first\",\n handler: ({ response }) => {\n // this check is important. Only add HSTS on HTTPS requests.\n const isSecure =\n response.headers[\"x-forwarded-proto\"] === \"https\" ||\n this.options.isSecure ||\n this.alepha.isProduction();\n\n const headersToSet = this.buildHeaders();\n\n for (const [key, value] of Object.entries(headersToSet)) {\n if (key === \"strict-transport-security\" && !isSecure) {\n continue;\n }\n // don't overwrite headers if they are already set\n if (!response.headers[key]) {\n response.headers[key] = value;\n }\n }\n },\n });\n}\n","import { $module } from \"alepha\";\nimport { AlephaServer } from \"alepha/server\";\nimport { ServerHelmetProvider } from \"./providers/ServerHelmetProvider.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport * from \"./providers/ServerHelmetProvider.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\n/**\n * Automatically adds important HTTP security headers to every response\n * to help protect your application from common web vulnerabilities.\n *\n * @see {@link ServerHelmetProvider}\n * @module alepha.server.helmet\n */\nexport const AlephaServerHelmet = $module({\n name: \"alepha.server.helmet\",\n services: [AlephaServer, ServerHelmetProvider],\n});\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.browser.js","names":["$: VirtualAction<T>"],"sources":["../../../src/server/links/schemas/apiLinksResponseSchema.ts","../../../src/server/links/providers/LinkProvider.ts","../../../src/server/links/primitives/$client.ts","../../../src/server/links/primitives/$remote.ts","../../../src/server/links/index.browser.ts"],"sourcesContent":["import type { Static } from \"alepha\";\nimport { t } from \"alepha\";\n\nexport const apiLinkSchema = t.object({\n name: t.text({\n description: \"Name of the API link, used for identification.\",\n }),\n\n group: t.optional(\n t.text({\n description:\n \"Group to which the API link belongs, used for categorization.\",\n }),\n ),\n\n path: t.text({\n description: \"Pathname used to access the API link.\",\n }),\n\n method: t.optional(\n t.text({\n description:\n \"HTTP method used for the API link, e.g., GET, POST, etc. If not specified, defaults to GET.\",\n }),\n ),\n\n requestBodyType: t.optional(\n t.text({\n description:\n \"Type of the request body for the API link. Default is application/json for POST/PUT/PATCH, null for others.\",\n }),\n ),\n\n service: t.optional(\n t.text({\n description:\n \"Service name associated with the API link, used for service discovery.\",\n }),\n ),\n});\n\nexport const apiLinksResponseSchema = t.object({\n prefix: t.optional(t.text()),\n links: t.array(apiLinkSchema),\n});\n\nexport type ApiLinksResponse = Static<typeof apiLinksResponseSchema>;\nexport type ApiLink = Static<typeof apiLinkSchema>;\n","import { $inject, Alepha, AlephaError, type Async, t } from \"alepha\";\nimport { $logger } from \"alepha/logger\";\nimport {\n type ActionPrimitive,\n type ClientRequestEntry,\n type ClientRequestOptions,\n type ClientRequestResponse,\n type FetchResponse,\n HttpClient,\n type RequestConfigSchema,\n ServerReply,\n type ServerRequest,\n type ServerRequestConfigEntry,\n type ServerResponseBody,\n UnauthorizedError,\n} from \"alepha/server\";\nimport type { ServerRouteSecure } from \"alepha/server/security\";\nimport {\n type ApiLink,\n apiLinksResponseSchema,\n} from \"../schemas/apiLinksResponseSchema.ts\";\n\n/**\n * Browser, SSR friendly, service to handle links.\n */\nexport class LinkProvider {\n static path = {\n apiLinks: \"/api/_links\",\n apiSchema: \"/api/_links/:name/schema\",\n };\n\n protected readonly log = $logger();\n protected readonly alepha = $inject(Alepha);\n protected readonly httpClient = $inject(HttpClient);\n\n // all server links (local + remote)\n // THIS IS NOT USER LINKS! (which are filtered by permissions)\n protected serverLinks: Array<HttpClientLink> = [];\n\n /**\n * Get applicative links registered on the server.\n * This does not include lazy-loaded remote links.\n */\n public getServerLinks(): HttpClientLink[] {\n if (this.alepha.isBrowser()) {\n this.log.warn(\n \"Getting server links in the browser is not supported. Use `fetchLinks` to get links from the server.\",\n );\n return [];\n }\n\n return this.serverLinks;\n }\n\n /**\n * Register a new link for the application.\n */\n public registerLink(link: HttpClientLink): void {\n if (this.alepha.isBrowser()) {\n this.log.warn(\n \"Registering links in the browser is not supported. Use `fetchLinks` to get links from the server.\",\n );\n return;\n }\n\n if (!link.handler && !link.host) {\n throw new AlephaError(\n \"Can't create link - 'handler' or 'host' is required\",\n );\n }\n\n if (this.serverLinks.some((l) => l.name === link.name)) {\n // remove existing link with the same name\n this.serverLinks = this.serverLinks.filter((l) => l.name !== link.name);\n }\n\n this.serverLinks.push(link);\n }\n\n public get links(): HttpClientLink[] {\n // TODO: not performant at all, use a map instead for ServerLinks\n const apiLinks = this.alepha.store.get(\n \"alepha.server.request.apiLinks\",\n )?.links;\n if (apiLinks) {\n if (this.alepha.isBrowser()) {\n return apiLinks;\n }\n\n const links = [];\n for (const link of apiLinks) {\n const originalLink = this.serverLinks.find((l) => l.name === link.name);\n if (originalLink) {\n links.push(originalLink);\n }\n }\n return links;\n }\n\n return this.serverLinks ?? [];\n }\n\n /**\n * Force browser to refresh links from the server.\n */\n public async fetchLinks(): Promise<HttpClientLink[]> {\n const { data } = await this.httpClient.fetch(\n `${LinkProvider.path.apiLinks}`,\n {\n method: \"GET\",\n schema: {\n response: apiLinksResponseSchema,\n },\n },\n );\n\n this.alepha.store.set(\"alepha.server.request.apiLinks\", data);\n\n return data.links;\n }\n\n /**\n * Create a virtual client that can be used to call actions.\n *\n * Use js Proxy under the hood.\n */\n public client<T extends object>(\n scope: ClientScope = {},\n ): HttpVirtualClient<T> {\n return new Proxy<HttpVirtualClient<T>>({} as HttpVirtualClient<T>, {\n get: (_, prop) => {\n if (typeof prop !== \"string\") {\n return;\n }\n\n return this.createVirtualAction<RequestConfigSchema>(prop, scope);\n },\n });\n }\n\n /**\n * Check if a link with the given name exists.\n * @param name\n */\n public can(name: string): boolean {\n return this.links.some((link) => link.name === name);\n }\n\n /**\n * Resolve a link by its name and call it.\n * - If link is local, it will call the local handler.\n * - If link is remote, it will make a fetch request to the remote server.\n */\n public async follow(\n name: string,\n config: Partial<ServerRequestConfigEntry> = {},\n options: ClientRequestOptions & ClientScope = {},\n ): Promise<any> {\n this.log.trace(\"Following link\", { name, config, options });\n const link = await this.getLinkByName(name, options);\n\n // if a handler is defined, use it (ssr)\n if (link.handler && !options.request) {\n this.log.trace(\"Local link found\", { name });\n return link.handler(\n {\n method: link.method,\n url: new URL(`http://localhost${link.path}`),\n query: config.query ?? {},\n body: config.body ?? {},\n params: config.params ?? {},\n headers: config.headers ?? {},\n metadata: {},\n reply: new ServerReply(),\n } as Partial<ServerRequest> as ServerRequest,\n options,\n );\n }\n\n this.log.trace(\"Remote link found\", {\n name,\n host: link.host,\n service: link.service,\n });\n\n return this.followRemote(link, config, options).then(\n (response) => response.data,\n );\n }\n\n protected createVirtualAction<T extends RequestConfigSchema>(\n name: string,\n scope: ClientScope = {},\n ): VirtualAction<T> {\n const $: VirtualAction<T> = async (\n config: any = {},\n options: ClientRequestOptions = {},\n ) => {\n return this.follow(name, config, {\n ...scope,\n ...options,\n });\n };\n\n Object.defineProperty($, \"name\", {\n value: name,\n writable: false,\n });\n\n $.run = async (config: any = {}, options: ClientRequestOptions = {}) => {\n return this.follow(name, config, {\n ...scope,\n ...options,\n });\n };\n\n $.fetch = async (config: any = {}, options: ClientRequestOptions = {}) => {\n const link = await this.getLinkByName(name, scope);\n return this.followRemote(link, config, options);\n };\n\n $.can = () => {\n return this.can(name);\n };\n\n return $;\n }\n\n protected async followRemote(\n link: HttpClientLink,\n config: Partial<ServerRequestConfigEntry> = {},\n options: ClientRequestOptions = {},\n ): Promise<FetchResponse> {\n options.request ??= {};\n options.request.headers = new Headers(options.request.headers);\n\n const als = this.alepha.context.get<ServerRequest>(\"request\");\n if (als?.headers.authorization) {\n options.request.headers.set(\"authorization\", als.headers.authorization);\n }\n\n const context = this.alepha.context.get(\"context\");\n if (typeof context === \"string\") {\n options.request.headers.set(\"x-request-id\", context);\n }\n\n const action = {\n ...link,\n // schema is not used in the client,\n // we assume that typescript will check\n schema: {\n body: t.any(),\n response: t.any(),\n },\n };\n\n // prefix with service when host is not defined (e.g. browser)\n if (!link.host && link.service) {\n action.path = `/${link.service}${action.path}`;\n }\n\n action.path = `${action.prefix ?? \"/api\"}${action.path}`;\n action.prefix = undefined; // prefix is not used in the client\n\n // else, make a request\n return this.httpClient.fetchAction({\n host: link.host,\n config,\n options,\n action: action as any, // schema.body TAny is not accepted\n });\n }\n\n protected async getLinkByName(\n name: string,\n options: ClientScope = {},\n ): Promise<HttpClientLink> {\n if (\n this.alepha.isBrowser() &&\n !this.alepha.store.get(\"alepha.server.request.apiLinks\")\n ) {\n await this.fetchLinks();\n }\n\n const link = this.links.find(\n (a) =>\n a.name === name &&\n (!options.group || a.group === options.group) &&\n (!options.service || options.service === a.service),\n );\n\n if (!link) {\n const error = new UnauthorizedError(`Action ${name} not found.`);\n // mimic http error handling\n await this.alepha.events.emit(\"client:onError\", {\n route: link,\n error,\n });\n throw error;\n }\n\n if (options.hostname) {\n return {\n ...link,\n host: options.hostname,\n };\n }\n\n return link;\n }\n}\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport interface HttpClientLink extends ApiLink {\n secured?: boolean | ServerRouteSecure;\n prefix?: string;\n // -- server only --\n // only for remote actions\n host?: string;\n service?: string;\n // used only for local actions, not for remote actions\n schema?: RequestConfigSchema;\n handler?: (\n request: ServerRequest,\n options: ClientRequestOptions,\n ) => Async<ServerResponseBody>;\n}\n\nexport interface ClientScope {\n group?: string;\n service?: string;\n hostname?: string;\n}\n\nexport type HttpVirtualClient<T> = {\n [K in keyof T as T[K] extends ActionPrimitive<RequestConfigSchema>\n ? K\n : never]: T[K] extends ActionPrimitive<infer Schema>\n ? VirtualAction<Schema>\n : never;\n};\n\nexport interface VirtualAction<T extends RequestConfigSchema>\n extends Pick<ActionPrimitive<T>, \"name\" | \"run\" | \"fetch\"> {\n (\n config?: ClientRequestEntry<T>,\n opts?: ClientRequestOptions,\n ): Promise<ClientRequestResponse<T>>;\n can: () => boolean;\n}\n","import { $inject, KIND } from \"alepha\";\nimport {\n type ClientScope,\n type HttpVirtualClient,\n LinkProvider,\n} from \"../providers/LinkProvider.ts\";\n\n/**\n * Create a new client.\n */\nexport const $client = <T extends object>(\n scope?: ClientScope,\n): HttpVirtualClient<T> => {\n return $inject(LinkProvider).client<T>(scope);\n};\n\n$client[KIND] = \"$client\";\n","import { createPrimitive, KIND, Primitive } from \"alepha\";\nimport type { ServiceAccountPrimitive } from \"alepha/security\";\nimport type { ProxyPrimitiveOptions } from \"alepha/server/proxy\";\n\n/**\n * $remote is a primitive that allows you to define remote service access.\n *\n * Use it only when you have 2 or more services that need to communicate with each other.\n *\n * All remote services can be exposed as actions, ... or not.\n *\n * You can add a service account if you want to use a security layer.\n */\nexport const $remote = (options: RemotePrimitiveOptions) => {\n return createPrimitive(RemotePrimitive, options);\n};\n\nexport interface RemotePrimitiveOptions {\n /**\n * The URL of the remote service.\n * You can use a function to generate the URL dynamically.\n * You probably should use $env(env) to get the URL from the environment.\n *\n * @example\n * ```ts\n * import { $remote } from \"alepha/server\";\n * import { $inject, t } from \"alepha\";\n *\n * class App {\n * env = $env(t.object({\n * REMOTE_URL: t.text({default: \"http://localhost:3000\"}),\n * }));\n * remote = $remote({\n * url: this.env.REMOTE_URL,\n * });\n * }\n * ```\n */\n url: string | (() => string);\n\n /**\n * The name of the remote service.\n *\n * @default Member of the class containing the remote service.\n */\n name?: string;\n\n /**\n * If true, all methods of the remote service will be exposed as actions in this context.\n * > Note: Proxy will never use the service account, it just... proxies the request.\n */\n proxy?:\n | boolean\n | Partial<\n ProxyPrimitiveOptions & {\n /**\n * If true, the remote service won't be available internally, only through the proxy.\n */\n noInternal: boolean;\n }\n >;\n\n /**\n * For communication between the server and the remote service with a security layer.\n * This will be used for internal communication and will not be exposed to the client.\n */\n serviceAccount?: ServiceAccountPrimitive;\n}\n\nexport class RemotePrimitive extends Primitive<RemotePrimitiveOptions> {\n public get name(): string {\n return this.options.name ?? this.config.propertyKey;\n }\n}\n\n$remote[KIND] = RemotePrimitive;\n","import { $module } from \"alepha\";\nimport { $client } from \"./primitives/$client.ts\";\nimport { $remote } from \"./primitives/$remote.ts\";\nimport { LinkProvider } from \"./providers/LinkProvider.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport * from \"./primitives/$client.ts\";\nexport * from \"./primitives/$remote.ts\";\nexport * from \"./providers/LinkProvider.ts\";\nexport * from \"./schemas/apiLinksResponseSchema.ts\";\n\n// ---------------------------------------------------------------- -----------------------------------------------------\n\nexport const AlephaServerLinks = $module({\n name: \"alepha.server.links\",\n primitives: [$remote, $client],\n services: [LinkProvider],\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.browser.js","names":[],"sources":["../../../src/server/links/schemas/apiLinksResponseSchema.ts","../../../src/server/links/providers/LinkProvider.ts","../../../src/server/links/primitives/$client.ts","../../../src/server/links/primitives/$remote.ts","../../../src/server/links/index.browser.ts"],"sourcesContent":["import type { Static } from \"alepha\";\nimport { t } from \"alepha\";\n\nexport const apiLinkSchema = t.object({\n name: t.text({\n description: \"Name of the API link, used for identification.\",\n }),\n\n group: t.optional(\n t.text({\n description:\n \"Group to which the API link belongs, used for categorization.\",\n }),\n ),\n\n path: t.text({\n description: \"Pathname used to access the API link.\",\n }),\n\n method: t.optional(\n t.text({\n description:\n \"HTTP method used for the API link, e.g., GET, POST, etc. If not specified, defaults to GET.\",\n }),\n ),\n\n requestBodyType: t.optional(\n t.text({\n description:\n \"Type of the request body for the API link. Default is application/json for POST/PUT/PATCH, null for others.\",\n }),\n ),\n\n service: t.optional(\n t.text({\n description:\n \"Service name associated with the API link, used for service discovery.\",\n }),\n ),\n});\n\nexport const apiLinksResponseSchema = t.object({\n prefix: t.optional(t.text()),\n links: t.array(apiLinkSchema),\n});\n\nexport type ApiLinksResponse = Static<typeof apiLinksResponseSchema>;\nexport type ApiLink = Static<typeof apiLinkSchema>;\n","import { $inject, Alepha, AlephaError, type Async, t } from \"alepha\";\nimport { $logger } from \"alepha/logger\";\nimport {\n type ActionPrimitive,\n type ClientRequestEntry,\n type ClientRequestOptions,\n type ClientRequestResponse,\n type FetchResponse,\n HttpClient,\n type RequestConfigSchema,\n ServerReply,\n type ServerRequest,\n type ServerRequestConfigEntry,\n type ServerResponseBody,\n UnauthorizedError,\n} from \"alepha/server\";\nimport type { ServerRouteSecure } from \"alepha/server/security\";\nimport {\n type ApiLink,\n apiLinksResponseSchema,\n} from \"../schemas/apiLinksResponseSchema.ts\";\n\n/**\n * Browser, SSR friendly, service to handle links.\n */\nexport class LinkProvider {\n static path = {\n apiLinks: \"/api/_links\",\n apiSchema: \"/api/_links/:name/schema\",\n };\n\n protected readonly log = $logger();\n protected readonly alepha = $inject(Alepha);\n protected readonly httpClient = $inject(HttpClient);\n\n // all server links (local + remote)\n // THIS IS NOT USER LINKS! (which are filtered by permissions)\n protected serverLinks: Array<HttpClientLink> = [];\n\n /**\n * Get applicative links registered on the server.\n * This does not include lazy-loaded remote links.\n */\n public getServerLinks(): HttpClientLink[] {\n if (this.alepha.isBrowser()) {\n this.log.warn(\n \"Getting server links in the browser is not supported. Use `fetchLinks` to get links from the server.\",\n );\n return [];\n }\n\n return this.serverLinks;\n }\n\n /**\n * Register a new link for the application.\n */\n public registerLink(link: HttpClientLink): void {\n if (this.alepha.isBrowser()) {\n this.log.warn(\n \"Registering links in the browser is not supported. Use `fetchLinks` to get links from the server.\",\n );\n return;\n }\n\n if (!link.handler && !link.host) {\n throw new AlephaError(\n \"Can't create link - 'handler' or 'host' is required\",\n );\n }\n\n if (this.serverLinks.some((l) => l.name === link.name)) {\n // remove existing link with the same name\n this.serverLinks = this.serverLinks.filter((l) => l.name !== link.name);\n }\n\n this.serverLinks.push(link);\n }\n\n public get links(): HttpClientLink[] {\n // TODO: not performant at all, use a map instead for ServerLinks\n const apiLinks = this.alepha.store.get(\n \"alepha.server.request.apiLinks\",\n )?.links;\n if (apiLinks) {\n if (this.alepha.isBrowser()) {\n return apiLinks;\n }\n\n const links = [];\n for (const link of apiLinks) {\n const originalLink = this.serverLinks.find((l) => l.name === link.name);\n if (originalLink) {\n links.push(originalLink);\n }\n }\n return links;\n }\n\n return this.serverLinks ?? [];\n }\n\n /**\n * Force browser to refresh links from the server.\n */\n public async fetchLinks(): Promise<HttpClientLink[]> {\n const { data } = await this.httpClient.fetch(\n `${LinkProvider.path.apiLinks}`,\n {\n method: \"GET\",\n schema: {\n response: apiLinksResponseSchema,\n },\n },\n );\n\n this.alepha.store.set(\"alepha.server.request.apiLinks\", data);\n\n return data.links;\n }\n\n /**\n * Create a virtual client that can be used to call actions.\n *\n * Use js Proxy under the hood.\n */\n public client<T extends object>(\n scope: ClientScope = {},\n ): HttpVirtualClient<T> {\n return new Proxy<HttpVirtualClient<T>>({} as HttpVirtualClient<T>, {\n get: (_, prop) => {\n if (typeof prop !== \"string\") {\n return;\n }\n\n return this.createVirtualAction<RequestConfigSchema>(prop, scope);\n },\n });\n }\n\n /**\n * Check if a link with the given name exists.\n * @param name\n */\n public can(name: string): boolean {\n return this.links.some((link) => link.name === name);\n }\n\n /**\n * Resolve a link by its name and call it.\n * - If link is local, it will call the local handler.\n * - If link is remote, it will make a fetch request to the remote server.\n */\n public async follow(\n name: string,\n config: Partial<ServerRequestConfigEntry> = {},\n options: ClientRequestOptions & ClientScope = {},\n ): Promise<any> {\n this.log.trace(\"Following link\", { name, config, options });\n const link = await this.getLinkByName(name, options);\n\n // if a handler is defined, use it (ssr)\n if (link.handler && !options.request) {\n this.log.trace(\"Local link found\", { name });\n return link.handler(\n {\n method: link.method,\n url: new URL(`http://localhost${link.path}`),\n query: config.query ?? {},\n body: config.body ?? {},\n params: config.params ?? {},\n headers: config.headers ?? {},\n metadata: {},\n reply: new ServerReply(),\n } as Partial<ServerRequest> as ServerRequest,\n options,\n );\n }\n\n this.log.trace(\"Remote link found\", {\n name,\n host: link.host,\n service: link.service,\n });\n\n return this.followRemote(link, config, options).then(\n (response) => response.data,\n );\n }\n\n protected createVirtualAction<T extends RequestConfigSchema>(\n name: string,\n scope: ClientScope = {},\n ): VirtualAction<T> {\n const $: VirtualAction<T> = async (\n config: any = {},\n options: ClientRequestOptions = {},\n ) => {\n return this.follow(name, config, {\n ...scope,\n ...options,\n });\n };\n\n Object.defineProperty($, \"name\", {\n value: name,\n writable: false,\n });\n\n $.run = async (config: any = {}, options: ClientRequestOptions = {}) => {\n return this.follow(name, config, {\n ...scope,\n ...options,\n });\n };\n\n $.fetch = async (config: any = {}, options: ClientRequestOptions = {}) => {\n const link = await this.getLinkByName(name, scope);\n return this.followRemote(link, config, options);\n };\n\n $.can = () => {\n return this.can(name);\n };\n\n return $;\n }\n\n protected async followRemote(\n link: HttpClientLink,\n config: Partial<ServerRequestConfigEntry> = {},\n options: ClientRequestOptions = {},\n ): Promise<FetchResponse> {\n options.request ??= {};\n options.request.headers = new Headers(options.request.headers);\n\n const als = this.alepha.context.get<ServerRequest>(\"request\");\n if (als?.headers.authorization) {\n options.request.headers.set(\"authorization\", als.headers.authorization);\n }\n\n const context = this.alepha.context.get(\"context\");\n if (typeof context === \"string\") {\n options.request.headers.set(\"x-request-id\", context);\n }\n\n const action = {\n ...link,\n // schema is not used in the client,\n // we assume that typescript will check\n schema: {\n body: t.any(),\n response: t.any(),\n },\n };\n\n // prefix with service when host is not defined (e.g. browser)\n if (!link.host && link.service) {\n action.path = `/${link.service}${action.path}`;\n }\n\n action.path = `${action.prefix ?? \"/api\"}${action.path}`;\n action.prefix = undefined; // prefix is not used in the client\n\n // else, make a request\n return this.httpClient.fetchAction({\n host: link.host,\n config,\n options,\n action: action as any, // schema.body TAny is not accepted\n });\n }\n\n protected async getLinkByName(\n name: string,\n options: ClientScope = {},\n ): Promise<HttpClientLink> {\n if (\n this.alepha.isBrowser() &&\n !this.alepha.store.get(\"alepha.server.request.apiLinks\")\n ) {\n await this.fetchLinks();\n }\n\n const link = this.links.find(\n (a) =>\n a.name === name &&\n (!options.group || a.group === options.group) &&\n (!options.service || options.service === a.service),\n );\n\n if (!link) {\n const error = new UnauthorizedError(`Action ${name} not found.`);\n // mimic http error handling\n await this.alepha.events.emit(\"client:onError\", {\n route: link,\n error,\n });\n throw error;\n }\n\n if (options.hostname) {\n return {\n ...link,\n host: options.hostname,\n };\n }\n\n return link;\n }\n}\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport interface HttpClientLink extends ApiLink {\n secured?: boolean | ServerRouteSecure;\n prefix?: string;\n // -- server only --\n // only for remote actions\n host?: string;\n service?: string;\n // used only for local actions, not for remote actions\n schema?: RequestConfigSchema;\n handler?: (\n request: ServerRequest,\n options: ClientRequestOptions,\n ) => Async<ServerResponseBody>;\n}\n\nexport interface ClientScope {\n group?: string;\n service?: string;\n hostname?: string;\n}\n\nexport type HttpVirtualClient<T> = {\n [K in keyof T as T[K] extends ActionPrimitive<RequestConfigSchema>\n ? K\n : never]: T[K] extends ActionPrimitive<infer Schema>\n ? VirtualAction<Schema>\n : never;\n};\n\nexport interface VirtualAction<T extends RequestConfigSchema>\n extends Pick<ActionPrimitive<T>, \"name\" | \"run\" | \"fetch\"> {\n (\n config?: ClientRequestEntry<T>,\n opts?: ClientRequestOptions,\n ): Promise<ClientRequestResponse<T>>;\n can: () => boolean;\n}\n","import { $inject, KIND } from \"alepha\";\nimport {\n type ClientScope,\n type HttpVirtualClient,\n LinkProvider,\n} from \"../providers/LinkProvider.ts\";\n\n/**\n * Create a new client.\n */\nexport const $client = <T extends object>(\n scope?: ClientScope,\n): HttpVirtualClient<T> => {\n return $inject(LinkProvider).client<T>(scope);\n};\n\n$client[KIND] = \"$client\";\n","import { createPrimitive, KIND, Primitive } from \"alepha\";\nimport type { ServiceAccountPrimitive } from \"alepha/security\";\nimport type { ProxyPrimitiveOptions } from \"alepha/server/proxy\";\n\n/**\n * $remote is a primitive that allows you to define remote service access.\n *\n * Use it only when you have 2 or more services that need to communicate with each other.\n *\n * All remote services can be exposed as actions, ... or not.\n *\n * You can add a service account if you want to use a security layer.\n */\nexport const $remote = (options: RemotePrimitiveOptions) => {\n return createPrimitive(RemotePrimitive, options);\n};\n\nexport interface RemotePrimitiveOptions {\n /**\n * The URL of the remote service.\n * You can use a function to generate the URL dynamically.\n * You probably should use $env(env) to get the URL from the environment.\n *\n * @example\n * ```ts\n * import { $remote } from \"alepha/server\";\n * import { $inject, t } from \"alepha\";\n *\n * class App {\n * env = $env(t.object({\n * REMOTE_URL: t.text({default: \"http://localhost:3000\"}),\n * }));\n * remote = $remote({\n * url: this.env.REMOTE_URL,\n * });\n * }\n * ```\n */\n url: string | (() => string);\n\n /**\n * The name of the remote service.\n *\n * @default Member of the class containing the remote service.\n */\n name?: string;\n\n /**\n * If true, all methods of the remote service will be exposed as actions in this context.\n * > Note: Proxy will never use the service account, it just... proxies the request.\n */\n proxy?:\n | boolean\n | Partial<\n ProxyPrimitiveOptions & {\n /**\n * If true, the remote service won't be available internally, only through the proxy.\n */\n noInternal: boolean;\n }\n >;\n\n /**\n * For communication between the server and the remote service with a security layer.\n * This will be used for internal communication and will not be exposed to the client.\n */\n serviceAccount?: ServiceAccountPrimitive;\n}\n\nexport class RemotePrimitive extends Primitive<RemotePrimitiveOptions> {\n public get name(): string {\n return this.options.name ?? this.config.propertyKey;\n }\n}\n\n$remote[KIND] = RemotePrimitive;\n","import { $module } from \"alepha\";\nimport { $client } from \"./primitives/$client.ts\";\nimport { $remote } from \"./primitives/$remote.ts\";\nimport { LinkProvider } from \"./providers/LinkProvider.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport * from \"./primitives/$client.ts\";\nexport * from \"./primitives/$remote.ts\";\nexport * from \"./providers/LinkProvider.ts\";\nexport * from \"./schemas/apiLinksResponseSchema.ts\";\n\n// ---------------------------------------------------------------- -----------------------------------------------------\n\nexport const AlephaServerLinks = $module({\n name: \"alepha.server.links\",\n primitives: [$remote, $client],\n services: [LinkProvider],\n});\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["$: VirtualAction<T>","envSchema","remote: ServerRemote","name","url","permissions: Permission[] | undefined","permissionMap: Map<string, Permission> | undefined","userLinks: ApiLink[]"],"sources":["../../../src/server/links/schemas/apiLinksResponseSchema.ts","../../../src/server/links/providers/LinkProvider.ts","../../../src/server/links/primitives/$client.ts","../../../src/server/links/primitives/$remote.ts","../../../src/server/links/providers/RemotePrimitiveProvider.ts","../../../src/server/links/providers/ServerLinksProvider.ts","../../../src/server/links/index.ts"],"sourcesContent":["import type { Static } from \"alepha\";\nimport { t } from \"alepha\";\n\nexport const apiLinkSchema = t.object({\n name: t.text({\n description: \"Name of the API link, used for identification.\",\n }),\n\n group: t.optional(\n t.text({\n description:\n \"Group to which the API link belongs, used for categorization.\",\n }),\n ),\n\n path: t.text({\n description: \"Pathname used to access the API link.\",\n }),\n\n method: t.optional(\n t.text({\n description:\n \"HTTP method used for the API link, e.g., GET, POST, etc. If not specified, defaults to GET.\",\n }),\n ),\n\n requestBodyType: t.optional(\n t.text({\n description:\n \"Type of the request body for the API link. Default is application/json for POST/PUT/PATCH, null for others.\",\n }),\n ),\n\n service: t.optional(\n t.text({\n description:\n \"Service name associated with the API link, used for service discovery.\",\n }),\n ),\n});\n\nexport const apiLinksResponseSchema = t.object({\n prefix: t.optional(t.text()),\n links: t.array(apiLinkSchema),\n});\n\nexport type ApiLinksResponse = Static<typeof apiLinksResponseSchema>;\nexport type ApiLink = Static<typeof apiLinkSchema>;\n","import { $inject, Alepha, AlephaError, type Async, t } from \"alepha\";\nimport { $logger } from \"alepha/logger\";\nimport {\n type ActionPrimitive,\n type ClientRequestEntry,\n type ClientRequestOptions,\n type ClientRequestResponse,\n type FetchResponse,\n HttpClient,\n type RequestConfigSchema,\n ServerReply,\n type ServerRequest,\n type ServerRequestConfigEntry,\n type ServerResponseBody,\n UnauthorizedError,\n} from \"alepha/server\";\nimport type { ServerRouteSecure } from \"alepha/server/security\";\nimport {\n type ApiLink,\n apiLinksResponseSchema,\n} from \"../schemas/apiLinksResponseSchema.ts\";\n\n/**\n * Browser, SSR friendly, service to handle links.\n */\nexport class LinkProvider {\n static path = {\n apiLinks: \"/api/_links\",\n apiSchema: \"/api/_links/:name/schema\",\n };\n\n protected readonly log = $logger();\n protected readonly alepha = $inject(Alepha);\n protected readonly httpClient = $inject(HttpClient);\n\n // all server links (local + remote)\n // THIS IS NOT USER LINKS! (which are filtered by permissions)\n protected serverLinks: Array<HttpClientLink> = [];\n\n /**\n * Get applicative links registered on the server.\n * This does not include lazy-loaded remote links.\n */\n public getServerLinks(): HttpClientLink[] {\n if (this.alepha.isBrowser()) {\n this.log.warn(\n \"Getting server links in the browser is not supported. Use `fetchLinks` to get links from the server.\",\n );\n return [];\n }\n\n return this.serverLinks;\n }\n\n /**\n * Register a new link for the application.\n */\n public registerLink(link: HttpClientLink): void {\n if (this.alepha.isBrowser()) {\n this.log.warn(\n \"Registering links in the browser is not supported. Use `fetchLinks` to get links from the server.\",\n );\n return;\n }\n\n if (!link.handler && !link.host) {\n throw new AlephaError(\n \"Can't create link - 'handler' or 'host' is required\",\n );\n }\n\n if (this.serverLinks.some((l) => l.name === link.name)) {\n // remove existing link with the same name\n this.serverLinks = this.serverLinks.filter((l) => l.name !== link.name);\n }\n\n this.serverLinks.push(link);\n }\n\n public get links(): HttpClientLink[] {\n // TODO: not performant at all, use a map instead for ServerLinks\n const apiLinks = this.alepha.store.get(\n \"alepha.server.request.apiLinks\",\n )?.links;\n if (apiLinks) {\n if (this.alepha.isBrowser()) {\n return apiLinks;\n }\n\n const links = [];\n for (const link of apiLinks) {\n const originalLink = this.serverLinks.find((l) => l.name === link.name);\n if (originalLink) {\n links.push(originalLink);\n }\n }\n return links;\n }\n\n return this.serverLinks ?? [];\n }\n\n /**\n * Force browser to refresh links from the server.\n */\n public async fetchLinks(): Promise<HttpClientLink[]> {\n const { data } = await this.httpClient.fetch(\n `${LinkProvider.path.apiLinks}`,\n {\n method: \"GET\",\n schema: {\n response: apiLinksResponseSchema,\n },\n },\n );\n\n this.alepha.store.set(\"alepha.server.request.apiLinks\", data);\n\n return data.links;\n }\n\n /**\n * Create a virtual client that can be used to call actions.\n *\n * Use js Proxy under the hood.\n */\n public client<T extends object>(\n scope: ClientScope = {},\n ): HttpVirtualClient<T> {\n return new Proxy<HttpVirtualClient<T>>({} as HttpVirtualClient<T>, {\n get: (_, prop) => {\n if (typeof prop !== \"string\") {\n return;\n }\n\n return this.createVirtualAction<RequestConfigSchema>(prop, scope);\n },\n });\n }\n\n /**\n * Check if a link with the given name exists.\n * @param name\n */\n public can(name: string): boolean {\n return this.links.some((link) => link.name === name);\n }\n\n /**\n * Resolve a link by its name and call it.\n * - If link is local, it will call the local handler.\n * - If link is remote, it will make a fetch request to the remote server.\n */\n public async follow(\n name: string,\n config: Partial<ServerRequestConfigEntry> = {},\n options: ClientRequestOptions & ClientScope = {},\n ): Promise<any> {\n this.log.trace(\"Following link\", { name, config, options });\n const link = await this.getLinkByName(name, options);\n\n // if a handler is defined, use it (ssr)\n if (link.handler && !options.request) {\n this.log.trace(\"Local link found\", { name });\n return link.handler(\n {\n method: link.method,\n url: new URL(`http://localhost${link.path}`),\n query: config.query ?? {},\n body: config.body ?? {},\n params: config.params ?? {},\n headers: config.headers ?? {},\n metadata: {},\n reply: new ServerReply(),\n } as Partial<ServerRequest> as ServerRequest,\n options,\n );\n }\n\n this.log.trace(\"Remote link found\", {\n name,\n host: link.host,\n service: link.service,\n });\n\n return this.followRemote(link, config, options).then(\n (response) => response.data,\n );\n }\n\n protected createVirtualAction<T extends RequestConfigSchema>(\n name: string,\n scope: ClientScope = {},\n ): VirtualAction<T> {\n const $: VirtualAction<T> = async (\n config: any = {},\n options: ClientRequestOptions = {},\n ) => {\n return this.follow(name, config, {\n ...scope,\n ...options,\n });\n };\n\n Object.defineProperty($, \"name\", {\n value: name,\n writable: false,\n });\n\n $.run = async (config: any = {}, options: ClientRequestOptions = {}) => {\n return this.follow(name, config, {\n ...scope,\n ...options,\n });\n };\n\n $.fetch = async (config: any = {}, options: ClientRequestOptions = {}) => {\n const link = await this.getLinkByName(name, scope);\n return this.followRemote(link, config, options);\n };\n\n $.can = () => {\n return this.can(name);\n };\n\n return $;\n }\n\n protected async followRemote(\n link: HttpClientLink,\n config: Partial<ServerRequestConfigEntry> = {},\n options: ClientRequestOptions = {},\n ): Promise<FetchResponse> {\n options.request ??= {};\n options.request.headers = new Headers(options.request.headers);\n\n const als = this.alepha.context.get<ServerRequest>(\"request\");\n if (als?.headers.authorization) {\n options.request.headers.set(\"authorization\", als.headers.authorization);\n }\n\n const context = this.alepha.context.get(\"context\");\n if (typeof context === \"string\") {\n options.request.headers.set(\"x-request-id\", context);\n }\n\n const action = {\n ...link,\n // schema is not used in the client,\n // we assume that typescript will check\n schema: {\n body: t.any(),\n response: t.any(),\n },\n };\n\n // prefix with service when host is not defined (e.g. browser)\n if (!link.host && link.service) {\n action.path = `/${link.service}${action.path}`;\n }\n\n action.path = `${action.prefix ?? \"/api\"}${action.path}`;\n action.prefix = undefined; // prefix is not used in the client\n\n // else, make a request\n return this.httpClient.fetchAction({\n host: link.host,\n config,\n options,\n action: action as any, // schema.body TAny is not accepted\n });\n }\n\n protected async getLinkByName(\n name: string,\n options: ClientScope = {},\n ): Promise<HttpClientLink> {\n if (\n this.alepha.isBrowser() &&\n !this.alepha.store.get(\"alepha.server.request.apiLinks\")\n ) {\n await this.fetchLinks();\n }\n\n const link = this.links.find(\n (a) =>\n a.name === name &&\n (!options.group || a.group === options.group) &&\n (!options.service || options.service === a.service),\n );\n\n if (!link) {\n const error = new UnauthorizedError(`Action ${name} not found.`);\n // mimic http error handling\n await this.alepha.events.emit(\"client:onError\", {\n route: link,\n error,\n });\n throw error;\n }\n\n if (options.hostname) {\n return {\n ...link,\n host: options.hostname,\n };\n }\n\n return link;\n }\n}\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport interface HttpClientLink extends ApiLink {\n secured?: boolean | ServerRouteSecure;\n prefix?: string;\n // -- server only --\n // only for remote actions\n host?: string;\n service?: string;\n // used only for local actions, not for remote actions\n schema?: RequestConfigSchema;\n handler?: (\n request: ServerRequest,\n options: ClientRequestOptions,\n ) => Async<ServerResponseBody>;\n}\n\nexport interface ClientScope {\n group?: string;\n service?: string;\n hostname?: string;\n}\n\nexport type HttpVirtualClient<T> = {\n [K in keyof T as T[K] extends ActionPrimitive<RequestConfigSchema>\n ? K\n : never]: T[K] extends ActionPrimitive<infer Schema>\n ? VirtualAction<Schema>\n : never;\n};\n\nexport interface VirtualAction<T extends RequestConfigSchema>\n extends Pick<ActionPrimitive<T>, \"name\" | \"run\" | \"fetch\"> {\n (\n config?: ClientRequestEntry<T>,\n opts?: ClientRequestOptions,\n ): Promise<ClientRequestResponse<T>>;\n can: () => boolean;\n}\n","import { $inject, KIND } from \"alepha\";\nimport {\n type ClientScope,\n type HttpVirtualClient,\n LinkProvider,\n} from \"../providers/LinkProvider.ts\";\n\n/**\n * Create a new client.\n */\nexport const $client = <T extends object>(\n scope?: ClientScope,\n): HttpVirtualClient<T> => {\n return $inject(LinkProvider).client<T>(scope);\n};\n\n$client[KIND] = \"$client\";\n","import { createPrimitive, KIND, Primitive } from \"alepha\";\nimport type { ServiceAccountPrimitive } from \"alepha/security\";\nimport type { ProxyPrimitiveOptions } from \"alepha/server/proxy\";\n\n/**\n * $remote is a primitive that allows you to define remote service access.\n *\n * Use it only when you have 2 or more services that need to communicate with each other.\n *\n * All remote services can be exposed as actions, ... or not.\n *\n * You can add a service account if you want to use a security layer.\n */\nexport const $remote = (options: RemotePrimitiveOptions) => {\n return createPrimitive(RemotePrimitive, options);\n};\n\nexport interface RemotePrimitiveOptions {\n /**\n * The URL of the remote service.\n * You can use a function to generate the URL dynamically.\n * You probably should use $env(env) to get the URL from the environment.\n *\n * @example\n * ```ts\n * import { $remote } from \"alepha/server\";\n * import { $inject, t } from \"alepha\";\n *\n * class App {\n * env = $env(t.object({\n * REMOTE_URL: t.text({default: \"http://localhost:3000\"}),\n * }));\n * remote = $remote({\n * url: this.env.REMOTE_URL,\n * });\n * }\n * ```\n */\n url: string | (() => string);\n\n /**\n * The name of the remote service.\n *\n * @default Member of the class containing the remote service.\n */\n name?: string;\n\n /**\n * If true, all methods of the remote service will be exposed as actions in this context.\n * > Note: Proxy will never use the service account, it just... proxies the request.\n */\n proxy?:\n | boolean\n | Partial<\n ProxyPrimitiveOptions & {\n /**\n * If true, the remote service won't be available internally, only through the proxy.\n */\n noInternal: boolean;\n }\n >;\n\n /**\n * For communication between the server and the remote service with a security layer.\n * This will be used for internal communication and will not be exposed to the client.\n */\n serviceAccount?: ServiceAccountPrimitive;\n}\n\nexport class RemotePrimitive extends Primitive<RemotePrimitiveOptions> {\n public get name(): string {\n return this.options.name ?? this.config.propertyKey;\n }\n}\n\n$remote[KIND] = RemotePrimitive;\n","import { $env, $hook, $inject, Alepha, t } from \"alepha\";\nimport { $logger } from \"alepha/logger\";\nimport { $retry } from \"alepha/retry\";\nimport type { ServiceAccountPrimitive } from \"alepha/security\";\nimport { ServerProxyProvider } from \"alepha/server/proxy\";\nimport { $remote, type RemotePrimitive } from \"../primitives/$remote.ts\";\nimport {\n type ApiLinksResponse,\n apiLinksResponseSchema,\n} from \"../schemas/apiLinksResponseSchema.ts\";\nimport { LinkProvider } from \"./LinkProvider.ts\";\n\nconst envSchema = t.object({\n SERVER_API_PREFIX: t.text({\n description: \"Prefix for all API routes (e.g. $action).\",\n default: \"/api\",\n }),\n});\n\nexport class RemotePrimitiveProvider {\n protected readonly env = $env(envSchema);\n protected readonly alepha = $inject(Alepha);\n protected readonly proxyProvider = $inject(ServerProxyProvider);\n protected readonly linkProvider = $inject(LinkProvider);\n protected readonly remotes: Array<ServerRemote> = [];\n protected readonly log = $logger();\n\n public getRemotes(): ServerRemote[] {\n return this.remotes;\n }\n\n public readonly configure = $hook({\n on: \"configure\",\n handler: async () => {\n const remotes = this.alepha.primitives($remote);\n for (const remote of remotes) {\n await this.registerRemote(remote);\n }\n },\n });\n\n public readonly start = $hook({\n on: \"start\",\n handler: async () => {\n for (const remote of this.remotes) {\n const token =\n typeof remote.serviceAccount?.token === \"function\"\n ? await remote.serviceAccount.token()\n : undefined;\n\n if (!remote.internal) {\n continue; // skip download links for remotes that are not internal\n }\n\n const { links } = await remote.links({ authorization: token });\n\n for (const link of links) {\n let path = link.path.replace(remote.prefix, \"\");\n if (link.service) {\n path = `/${link.service}${path}`;\n }\n\n this.linkProvider.registerLink({\n ...link,\n prefix: remote.prefix,\n path,\n method: link.method ?? \"GET\",\n host: remote.url,\n service: remote.name,\n });\n }\n\n this.log.info(`Remote '${remote.name}' OK`, {\n links: remote.links.length,\n prefix: remote.prefix,\n });\n }\n },\n });\n\n public async registerRemote(value: RemotePrimitive): Promise<void> {\n const options = value.options;\n const url = typeof options.url === \"string\" ? options.url : options.url();\n const linkPath = LinkProvider.path.apiLinks;\n const name = value.name;\n const proxy = typeof options.proxy === \"object\" ? options.proxy : {};\n\n const remote: ServerRemote = {\n url,\n name,\n prefix: \"/api\",\n serviceAccount: options.serviceAccount,\n proxy: !!options.proxy,\n internal: !proxy.noInternal,\n schema: async (opts) => {\n const { authorization, name } = opts;\n return await fetch(`${url}${linkPath}/${name}/schema`, {\n headers: new Headers(\n authorization\n ? {\n authorization,\n }\n : {},\n ),\n }).then((it) => it.json()); // TODO: use schema validation for response\n },\n links: async (opts) => {\n const { authorization } = opts;\n const remoteApi = await this.fetchLinks.run({\n service: name,\n url: `${url}${linkPath}`,\n authorization,\n });\n\n if (remoteApi.prefix != null) {\n remote.prefix = remoteApi.prefix; // monkey patch the prefix, not ideal but works\n }\n\n return remoteApi;\n },\n };\n\n this.remotes.push(remote);\n\n if (options.proxy) {\n this.proxyProvider.createProxy({\n path: `${this.env.SERVER_API_PREFIX}/${name}/*`,\n target: url,\n rewrite: (url) => {\n url.pathname = url.pathname.replace(\n `${this.env.SERVER_API_PREFIX}/${name}`,\n remote.prefix,\n );\n },\n ...proxy,\n });\n }\n }\n\n protected readonly fetchLinks = $retry({\n max: 10,\n backoff: {\n initial: 1000,\n },\n onError: (_, attempt, { service, url }) => {\n this.log.warn(`Failed to fetch links, retry (${attempt})...`, {\n service,\n url,\n });\n },\n handler: async (opts: FetchLinksOptions): Promise<ApiLinksResponse> => {\n const { url, authorization } = opts;\n const response = await fetch(url, {\n headers: new Headers(\n authorization\n ? {\n authorization,\n }\n : {},\n ),\n });\n\n if (!response.ok) {\n throw new Error(`Failed to fetch links from ${url}`);\n }\n\n return this.alepha.codec.decode(\n apiLinksResponseSchema,\n await response.json(),\n );\n },\n });\n}\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport interface FetchLinksOptions {\n /**\n * Name of the remote service.\n */\n service: string;\n\n /**\n * URL to fetch links from.\n */\n url: string;\n\n /**\n * Authorization header containing access token.\n */\n authorization?: string;\n}\n\nexport interface ServerRemote {\n /**\n * URL of the remote service.\n */\n url: string;\n\n /**\n * Name of the remote service.\n */\n name: string;\n\n /**\n * Expose links as endpoint. It's not only internal.\n */\n proxy: boolean;\n\n /**\n * It's only used inside the application.\n */\n internal: boolean;\n\n /**\n * Links fetcher.\n */\n links: (args: { authorization?: string }) => Promise<ApiLinksResponse>;\n\n /**\n * Fetches schema for the remote service.\n */\n schema: (args: { name: string; authorization?: string }) => Promise<any>;\n\n /**\n * Force a default access token provider when not provided.\n */\n serviceAccount?: ServiceAccountPrimitive;\n\n /**\n * Prefix for the remote service links.\n */\n prefix: string;\n}\n","import { $env, $hook, $inject, Alepha, t } from \"alepha\";\nimport {\n type Permission,\n SecurityProvider,\n type UserAccountToken,\n} from \"alepha/security\";\nimport {\n $action,\n $route,\n type ClientRequestEntry,\n type ClientRequestOptions,\n type RequestConfigSchema,\n ServerTimingProvider,\n} from \"alepha/server\";\nimport {\n type ApiLink,\n type ApiLinksResponse,\n apiLinksResponseSchema,\n} from \"../schemas/apiLinksResponseSchema.ts\";\nimport { LinkProvider } from \"./LinkProvider.ts\";\nimport { RemotePrimitiveProvider } from \"./RemotePrimitiveProvider.ts\";\n\nconst envSchema = t.object({\n SERVER_API_PREFIX: t.text({\n description: \"Prefix for all API routes (e.g. $action).\",\n default: \"/api\",\n }),\n});\n\nexport class ServerLinksProvider {\n protected readonly env = $env(envSchema);\n protected readonly alepha = $inject(Alepha);\n protected readonly linkProvider = $inject(LinkProvider);\n protected readonly remoteProvider = $inject(RemotePrimitiveProvider);\n protected readonly serverTimingProvider = $inject(ServerTimingProvider);\n\n public get prefix() {\n return this.env.SERVER_API_PREFIX;\n }\n\n public readonly onRoute = $hook({\n on: \"configure\",\n handler: () => {\n // convert all $action to local links\n for (const action of this.alepha.primitives($action)) {\n this.linkProvider.registerLink({\n name: action.name,\n group: action.group,\n schema: action.options.schema,\n requestBodyType: action.getBodyContentType(),\n secured: action.options.secure ?? true,\n method: action.method === \"GET\" ? undefined : action.method,\n prefix: action.prefix,\n path: action.path,\n // by local, we mean that it can be called directly via the handler\n handler: (\n config: ClientRequestEntry<RequestConfigSchema>,\n options: ClientRequestOptions = {},\n ) => action.run(config, options),\n });\n }\n },\n });\n\n /**\n * First API - Get all API links for the user.\n *\n * This is based on the user's permissions.\n */\n public readonly links = $route({\n path: LinkProvider.path.apiLinks,\n schema: {\n response: apiLinksResponseSchema,\n },\n handler: ({ user, headers }) => {\n return this.getUserApiLinks({\n user,\n authorization: headers.authorization,\n });\n },\n });\n\n /**\n * Second API - Get schema for a specific API link.\n *\n * Note: Body/Response schema are not included in `links` API because it's TOO BIG.\n * I mean for 150+ links, you got 50ms of serialization time.\n */\n public readonly schema = $route({\n path: LinkProvider.path.apiSchema,\n schema: {\n params: t.object({\n name: t.text(),\n }),\n response: t.json(),\n },\n handler: ({ params, user, headers }) => {\n return this.getSchemaByName(params.name, {\n user,\n authorization: headers.authorization,\n });\n },\n });\n\n public async getSchemaByName(\n name: string,\n options: GetApiLinksOptions = {},\n ): Promise<RequestConfigSchema> {\n const authorization = options.authorization;\n const api = await this.getUserApiLinks({\n user: options.user,\n authorization,\n });\n\n for (const link of api.links) {\n if (link.name === name) {\n if (link.service) {\n // remote\n return this.remoteProvider\n .getRemotes()\n .find((it) => it.name === link.service)\n ?.schema({ name: name, authorization });\n }\n\n // local\n return (\n this.linkProvider.getServerLinks().find((it) => it.name === name)\n ?.schema ?? {}\n );\n }\n }\n\n return {};\n }\n\n /**\n * Retrieves API links for the user based on their permissions.\n * Will check on local links and remote links.\n */\n public async getUserApiLinks(\n options: GetApiLinksOptions,\n ): Promise<ApiLinksResponse> {\n const { user } = options;\n let permissions: Permission[] | undefined;\n let permissionMap: Map<string, Permission> | undefined;\n const hasSecurity = this.alepha.has(SecurityProvider);\n if (hasSecurity && user) {\n permissions = this.alepha.inject(SecurityProvider).getPermissions(user);\n permissionMap = new Map(\n permissions.map((it) => [`${it.group}:${it.name}`, it]),\n );\n }\n\n const userLinks: ApiLink[] = [];\n\n // bonus: add permissions not related to $action\n for (const permission of permissions ?? []) {\n if (\n !permission.path &&\n !permission.method &&\n permission.name &&\n permission.group\n ) {\n userLinks.push({\n path: \"\", // this is a placeholder for links without specific path\n name: permission.name,\n group: permission.group,\n });\n }\n }\n\n // add local links\n for (const link of this.linkProvider.getServerLinks()) {\n // SKIP REMOTE LINKS, remote links are handled separately for security\n if (link.host) continue;\n\n if (hasSecurity && link.secured) {\n // skip secured links if user is not provided\n if (!user) {\n continue;\n }\n\n if (typeof link.secured === \"object\" && link.secured.realm) {\n // realm check\n if (user.realm !== link.secured.realm) {\n continue;\n }\n } else if (permissionMap) {\n // small permissions check, can be optimized later ... :')\n\n if (!permissionMap.has(`${link.group}:${link.name}`)) {\n continue;\n }\n }\n }\n\n userLinks.push({\n name: link.name,\n group: link.group,\n requestBodyType: link.requestBodyType,\n method: link.method,\n path: link.path,\n });\n }\n\n this.serverTimingProvider.beginTiming(\"fetchRemoteLinks\");\n // this does not scale well, but it's working for now\n // TODO: remote links can be cached by user.roles\n const promises = this.remoteProvider\n .getRemotes()\n .filter((it) => it.proxy) // add only \"proxy\" remotes\n .map(async (remote) => {\n const { links, prefix } = await remote.links(options);\n return links.map((link) => {\n let path = link.path.replace(prefix ?? \"/api\", \"\");\n if (link.service) {\n path = `/${link.service}${path}`;\n }\n\n return {\n ...link,\n path,\n proxy: true,\n service: remote.name,\n };\n });\n });\n\n userLinks.push(...(await Promise.all(promises)).flat());\n this.serverTimingProvider.endTiming(\"fetchRemoteLinks\");\n\n return {\n prefix: this.env.SERVER_API_PREFIX,\n links: userLinks,\n };\n }\n}\n\nexport interface GetApiLinksOptions {\n user?: UserAccountToken;\n authorization?: string;\n}\n","import \"alepha/server/security\";\nimport { $module } from \"alepha\";\nimport { AlephaServer } from \"alepha/server\";\nimport { $client } from \"./primitives/$client.ts\";\nimport { $remote } from \"./primitives/$remote.ts\";\nimport { LinkProvider } from \"./providers/LinkProvider.ts\";\nimport { RemotePrimitiveProvider } from \"./providers/RemotePrimitiveProvider.ts\";\nimport { ServerLinksProvider } from \"./providers/ServerLinksProvider.ts\";\nimport type { ApiLinksResponse } from \"./schemas/apiLinksResponseSchema.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport * from \"./primitives/$client.ts\";\nexport * from \"./primitives/$remote.ts\";\nexport * from \"./providers/LinkProvider.ts\";\nexport * from \"./providers/RemotePrimitiveProvider.ts\";\nexport * from \"./providers/ServerLinksProvider.ts\";\nexport * from \"./schemas/apiLinksResponseSchema.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\ndeclare module \"alepha\" {\n interface State {\n /**\n * API links attached to the server request state.\n *\n * @see {@link ApiLinksResponse}\n * @internal\n */\n \"alepha.server.request.apiLinks\"?: ApiLinksResponse;\n }\n}\n\n// ---------------------------------------------------------------------------------------------------------------------\n\n/**\n * Provides server-side link management and remote capabilities for client-server interactions.\n *\n * The server-links module enables declarative link definitions using `$remote` and `$client` primitives,\n * facilitating seamless API endpoint management and client-server communication. It integrates with server\n * security features to ensure safe and controlled access to resources.\n *\n * @see {@link $remote}\n * @see {@link $client}\n * @module alepha.server.links\n */\nexport const AlephaServerLinks = $module({\n name: \"alepha.server.links\",\n primitives: [$remote, $client],\n services: [\n AlephaServer,\n ServerLinksProvider,\n RemotePrimitiveProvider,\n LinkProvider,\n ],\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":["envSchema","name","url"],"sources":["../../../src/server/links/schemas/apiLinksResponseSchema.ts","../../../src/server/links/providers/LinkProvider.ts","../../../src/server/links/primitives/$client.ts","../../../src/server/links/primitives/$remote.ts","../../../src/server/links/providers/RemotePrimitiveProvider.ts","../../../src/server/links/providers/ServerLinksProvider.ts","../../../src/server/links/index.ts"],"sourcesContent":["import type { Static } from \"alepha\";\nimport { t } from \"alepha\";\n\nexport const apiLinkSchema = t.object({\n name: t.text({\n description: \"Name of the API link, used for identification.\",\n }),\n\n group: t.optional(\n t.text({\n description:\n \"Group to which the API link belongs, used for categorization.\",\n }),\n ),\n\n path: t.text({\n description: \"Pathname used to access the API link.\",\n }),\n\n method: t.optional(\n t.text({\n description:\n \"HTTP method used for the API link, e.g., GET, POST, etc. If not specified, defaults to GET.\",\n }),\n ),\n\n requestBodyType: t.optional(\n t.text({\n description:\n \"Type of the request body for the API link. Default is application/json for POST/PUT/PATCH, null for others.\",\n }),\n ),\n\n service: t.optional(\n t.text({\n description:\n \"Service name associated with the API link, used for service discovery.\",\n }),\n ),\n});\n\nexport const apiLinksResponseSchema = t.object({\n prefix: t.optional(t.text()),\n links: t.array(apiLinkSchema),\n});\n\nexport type ApiLinksResponse = Static<typeof apiLinksResponseSchema>;\nexport type ApiLink = Static<typeof apiLinkSchema>;\n","import { $inject, Alepha, AlephaError, type Async, t } from \"alepha\";\nimport { $logger } from \"alepha/logger\";\nimport {\n type ActionPrimitive,\n type ClientRequestEntry,\n type ClientRequestOptions,\n type ClientRequestResponse,\n type FetchResponse,\n HttpClient,\n type RequestConfigSchema,\n ServerReply,\n type ServerRequest,\n type ServerRequestConfigEntry,\n type ServerResponseBody,\n UnauthorizedError,\n} from \"alepha/server\";\nimport type { ServerRouteSecure } from \"alepha/server/security\";\nimport {\n type ApiLink,\n apiLinksResponseSchema,\n} from \"../schemas/apiLinksResponseSchema.ts\";\n\n/**\n * Browser, SSR friendly, service to handle links.\n */\nexport class LinkProvider {\n static path = {\n apiLinks: \"/api/_links\",\n apiSchema: \"/api/_links/:name/schema\",\n };\n\n protected readonly log = $logger();\n protected readonly alepha = $inject(Alepha);\n protected readonly httpClient = $inject(HttpClient);\n\n // all server links (local + remote)\n // THIS IS NOT USER LINKS! (which are filtered by permissions)\n protected serverLinks: Array<HttpClientLink> = [];\n\n /**\n * Get applicative links registered on the server.\n * This does not include lazy-loaded remote links.\n */\n public getServerLinks(): HttpClientLink[] {\n if (this.alepha.isBrowser()) {\n this.log.warn(\n \"Getting server links in the browser is not supported. Use `fetchLinks` to get links from the server.\",\n );\n return [];\n }\n\n return this.serverLinks;\n }\n\n /**\n * Register a new link for the application.\n */\n public registerLink(link: HttpClientLink): void {\n if (this.alepha.isBrowser()) {\n this.log.warn(\n \"Registering links in the browser is not supported. Use `fetchLinks` to get links from the server.\",\n );\n return;\n }\n\n if (!link.handler && !link.host) {\n throw new AlephaError(\n \"Can't create link - 'handler' or 'host' is required\",\n );\n }\n\n if (this.serverLinks.some((l) => l.name === link.name)) {\n // remove existing link with the same name\n this.serverLinks = this.serverLinks.filter((l) => l.name !== link.name);\n }\n\n this.serverLinks.push(link);\n }\n\n public get links(): HttpClientLink[] {\n // TODO: not performant at all, use a map instead for ServerLinks\n const apiLinks = this.alepha.store.get(\n \"alepha.server.request.apiLinks\",\n )?.links;\n if (apiLinks) {\n if (this.alepha.isBrowser()) {\n return apiLinks;\n }\n\n const links = [];\n for (const link of apiLinks) {\n const originalLink = this.serverLinks.find((l) => l.name === link.name);\n if (originalLink) {\n links.push(originalLink);\n }\n }\n return links;\n }\n\n return this.serverLinks ?? [];\n }\n\n /**\n * Force browser to refresh links from the server.\n */\n public async fetchLinks(): Promise<HttpClientLink[]> {\n const { data } = await this.httpClient.fetch(\n `${LinkProvider.path.apiLinks}`,\n {\n method: \"GET\",\n schema: {\n response: apiLinksResponseSchema,\n },\n },\n );\n\n this.alepha.store.set(\"alepha.server.request.apiLinks\", data);\n\n return data.links;\n }\n\n /**\n * Create a virtual client that can be used to call actions.\n *\n * Use js Proxy under the hood.\n */\n public client<T extends object>(\n scope: ClientScope = {},\n ): HttpVirtualClient<T> {\n return new Proxy<HttpVirtualClient<T>>({} as HttpVirtualClient<T>, {\n get: (_, prop) => {\n if (typeof prop !== \"string\") {\n return;\n }\n\n return this.createVirtualAction<RequestConfigSchema>(prop, scope);\n },\n });\n }\n\n /**\n * Check if a link with the given name exists.\n * @param name\n */\n public can(name: string): boolean {\n return this.links.some((link) => link.name === name);\n }\n\n /**\n * Resolve a link by its name and call it.\n * - If link is local, it will call the local handler.\n * - If link is remote, it will make a fetch request to the remote server.\n */\n public async follow(\n name: string,\n config: Partial<ServerRequestConfigEntry> = {},\n options: ClientRequestOptions & ClientScope = {},\n ): Promise<any> {\n this.log.trace(\"Following link\", { name, config, options });\n const link = await this.getLinkByName(name, options);\n\n // if a handler is defined, use it (ssr)\n if (link.handler && !options.request) {\n this.log.trace(\"Local link found\", { name });\n return link.handler(\n {\n method: link.method,\n url: new URL(`http://localhost${link.path}`),\n query: config.query ?? {},\n body: config.body ?? {},\n params: config.params ?? {},\n headers: config.headers ?? {},\n metadata: {},\n reply: new ServerReply(),\n } as Partial<ServerRequest> as ServerRequest,\n options,\n );\n }\n\n this.log.trace(\"Remote link found\", {\n name,\n host: link.host,\n service: link.service,\n });\n\n return this.followRemote(link, config, options).then(\n (response) => response.data,\n );\n }\n\n protected createVirtualAction<T extends RequestConfigSchema>(\n name: string,\n scope: ClientScope = {},\n ): VirtualAction<T> {\n const $: VirtualAction<T> = async (\n config: any = {},\n options: ClientRequestOptions = {},\n ) => {\n return this.follow(name, config, {\n ...scope,\n ...options,\n });\n };\n\n Object.defineProperty($, \"name\", {\n value: name,\n writable: false,\n });\n\n $.run = async (config: any = {}, options: ClientRequestOptions = {}) => {\n return this.follow(name, config, {\n ...scope,\n ...options,\n });\n };\n\n $.fetch = async (config: any = {}, options: ClientRequestOptions = {}) => {\n const link = await this.getLinkByName(name, scope);\n return this.followRemote(link, config, options);\n };\n\n $.can = () => {\n return this.can(name);\n };\n\n return $;\n }\n\n protected async followRemote(\n link: HttpClientLink,\n config: Partial<ServerRequestConfigEntry> = {},\n options: ClientRequestOptions = {},\n ): Promise<FetchResponse> {\n options.request ??= {};\n options.request.headers = new Headers(options.request.headers);\n\n const als = this.alepha.context.get<ServerRequest>(\"request\");\n if (als?.headers.authorization) {\n options.request.headers.set(\"authorization\", als.headers.authorization);\n }\n\n const context = this.alepha.context.get(\"context\");\n if (typeof context === \"string\") {\n options.request.headers.set(\"x-request-id\", context);\n }\n\n const action = {\n ...link,\n // schema is not used in the client,\n // we assume that typescript will check\n schema: {\n body: t.any(),\n response: t.any(),\n },\n };\n\n // prefix with service when host is not defined (e.g. browser)\n if (!link.host && link.service) {\n action.path = `/${link.service}${action.path}`;\n }\n\n action.path = `${action.prefix ?? \"/api\"}${action.path}`;\n action.prefix = undefined; // prefix is not used in the client\n\n // else, make a request\n return this.httpClient.fetchAction({\n host: link.host,\n config,\n options,\n action: action as any, // schema.body TAny is not accepted\n });\n }\n\n protected async getLinkByName(\n name: string,\n options: ClientScope = {},\n ): Promise<HttpClientLink> {\n if (\n this.alepha.isBrowser() &&\n !this.alepha.store.get(\"alepha.server.request.apiLinks\")\n ) {\n await this.fetchLinks();\n }\n\n const link = this.links.find(\n (a) =>\n a.name === name &&\n (!options.group || a.group === options.group) &&\n (!options.service || options.service === a.service),\n );\n\n if (!link) {\n const error = new UnauthorizedError(`Action ${name} not found.`);\n // mimic http error handling\n await this.alepha.events.emit(\"client:onError\", {\n route: link,\n error,\n });\n throw error;\n }\n\n if (options.hostname) {\n return {\n ...link,\n host: options.hostname,\n };\n }\n\n return link;\n }\n}\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport interface HttpClientLink extends ApiLink {\n secured?: boolean | ServerRouteSecure;\n prefix?: string;\n // -- server only --\n // only for remote actions\n host?: string;\n service?: string;\n // used only for local actions, not for remote actions\n schema?: RequestConfigSchema;\n handler?: (\n request: ServerRequest,\n options: ClientRequestOptions,\n ) => Async<ServerResponseBody>;\n}\n\nexport interface ClientScope {\n group?: string;\n service?: string;\n hostname?: string;\n}\n\nexport type HttpVirtualClient<T> = {\n [K in keyof T as T[K] extends ActionPrimitive<RequestConfigSchema>\n ? K\n : never]: T[K] extends ActionPrimitive<infer Schema>\n ? VirtualAction<Schema>\n : never;\n};\n\nexport interface VirtualAction<T extends RequestConfigSchema>\n extends Pick<ActionPrimitive<T>, \"name\" | \"run\" | \"fetch\"> {\n (\n config?: ClientRequestEntry<T>,\n opts?: ClientRequestOptions,\n ): Promise<ClientRequestResponse<T>>;\n can: () => boolean;\n}\n","import { $inject, KIND } from \"alepha\";\nimport {\n type ClientScope,\n type HttpVirtualClient,\n LinkProvider,\n} from \"../providers/LinkProvider.ts\";\n\n/**\n * Create a new client.\n */\nexport const $client = <T extends object>(\n scope?: ClientScope,\n): HttpVirtualClient<T> => {\n return $inject(LinkProvider).client<T>(scope);\n};\n\n$client[KIND] = \"$client\";\n","import { createPrimitive, KIND, Primitive } from \"alepha\";\nimport type { ServiceAccountPrimitive } from \"alepha/security\";\nimport type { ProxyPrimitiveOptions } from \"alepha/server/proxy\";\n\n/**\n * $remote is a primitive that allows you to define remote service access.\n *\n * Use it only when you have 2 or more services that need to communicate with each other.\n *\n * All remote services can be exposed as actions, ... or not.\n *\n * You can add a service account if you want to use a security layer.\n */\nexport const $remote = (options: RemotePrimitiveOptions) => {\n return createPrimitive(RemotePrimitive, options);\n};\n\nexport interface RemotePrimitiveOptions {\n /**\n * The URL of the remote service.\n * You can use a function to generate the URL dynamically.\n * You probably should use $env(env) to get the URL from the environment.\n *\n * @example\n * ```ts\n * import { $remote } from \"alepha/server\";\n * import { $inject, t } from \"alepha\";\n *\n * class App {\n * env = $env(t.object({\n * REMOTE_URL: t.text({default: \"http://localhost:3000\"}),\n * }));\n * remote = $remote({\n * url: this.env.REMOTE_URL,\n * });\n * }\n * ```\n */\n url: string | (() => string);\n\n /**\n * The name of the remote service.\n *\n * @default Member of the class containing the remote service.\n */\n name?: string;\n\n /**\n * If true, all methods of the remote service will be exposed as actions in this context.\n * > Note: Proxy will never use the service account, it just... proxies the request.\n */\n proxy?:\n | boolean\n | Partial<\n ProxyPrimitiveOptions & {\n /**\n * If true, the remote service won't be available internally, only through the proxy.\n */\n noInternal: boolean;\n }\n >;\n\n /**\n * For communication between the server and the remote service with a security layer.\n * This will be used for internal communication and will not be exposed to the client.\n */\n serviceAccount?: ServiceAccountPrimitive;\n}\n\nexport class RemotePrimitive extends Primitive<RemotePrimitiveOptions> {\n public get name(): string {\n return this.options.name ?? this.config.propertyKey;\n }\n}\n\n$remote[KIND] = RemotePrimitive;\n","import { $env, $hook, $inject, Alepha, t } from \"alepha\";\nimport { $logger } from \"alepha/logger\";\nimport { $retry } from \"alepha/retry\";\nimport type { ServiceAccountPrimitive } from \"alepha/security\";\nimport { ServerProxyProvider } from \"alepha/server/proxy\";\nimport { $remote, type RemotePrimitive } from \"../primitives/$remote.ts\";\nimport {\n type ApiLinksResponse,\n apiLinksResponseSchema,\n} from \"../schemas/apiLinksResponseSchema.ts\";\nimport { LinkProvider } from \"./LinkProvider.ts\";\n\nconst envSchema = t.object({\n SERVER_API_PREFIX: t.text({\n description: \"Prefix for all API routes (e.g. $action).\",\n default: \"/api\",\n }),\n});\n\nexport class RemotePrimitiveProvider {\n protected readonly env = $env(envSchema);\n protected readonly alepha = $inject(Alepha);\n protected readonly proxyProvider = $inject(ServerProxyProvider);\n protected readonly linkProvider = $inject(LinkProvider);\n protected readonly remotes: Array<ServerRemote> = [];\n protected readonly log = $logger();\n\n public getRemotes(): ServerRemote[] {\n return this.remotes;\n }\n\n public readonly configure = $hook({\n on: \"configure\",\n handler: async () => {\n const remotes = this.alepha.primitives($remote);\n for (const remote of remotes) {\n await this.registerRemote(remote);\n }\n },\n });\n\n public readonly start = $hook({\n on: \"start\",\n handler: async () => {\n for (const remote of this.remotes) {\n const token =\n typeof remote.serviceAccount?.token === \"function\"\n ? await remote.serviceAccount.token()\n : undefined;\n\n if (!remote.internal) {\n continue; // skip download links for remotes that are not internal\n }\n\n const { links } = await remote.links({ authorization: token });\n\n for (const link of links) {\n let path = link.path.replace(remote.prefix, \"\");\n if (link.service) {\n path = `/${link.service}${path}`;\n }\n\n this.linkProvider.registerLink({\n ...link,\n prefix: remote.prefix,\n path,\n method: link.method ?? \"GET\",\n host: remote.url,\n service: remote.name,\n });\n }\n\n this.log.info(`Remote '${remote.name}' OK`, {\n links: remote.links.length,\n prefix: remote.prefix,\n });\n }\n },\n });\n\n public async registerRemote(value: RemotePrimitive): Promise<void> {\n const options = value.options;\n const url = typeof options.url === \"string\" ? options.url : options.url();\n const linkPath = LinkProvider.path.apiLinks;\n const name = value.name;\n const proxy = typeof options.proxy === \"object\" ? options.proxy : {};\n\n const remote: ServerRemote = {\n url,\n name,\n prefix: \"/api\",\n serviceAccount: options.serviceAccount,\n proxy: !!options.proxy,\n internal: !proxy.noInternal,\n schema: async (opts) => {\n const { authorization, name } = opts;\n return await fetch(`${url}${linkPath}/${name}/schema`, {\n headers: new Headers(\n authorization\n ? {\n authorization,\n }\n : {},\n ),\n }).then((it) => it.json()); // TODO: use schema validation for response\n },\n links: async (opts) => {\n const { authorization } = opts;\n const remoteApi = await this.fetchLinks.run({\n service: name,\n url: `${url}${linkPath}`,\n authorization,\n });\n\n if (remoteApi.prefix != null) {\n remote.prefix = remoteApi.prefix; // monkey patch the prefix, not ideal but works\n }\n\n return remoteApi;\n },\n };\n\n this.remotes.push(remote);\n\n if (options.proxy) {\n this.proxyProvider.createProxy({\n path: `${this.env.SERVER_API_PREFIX}/${name}/*`,\n target: url,\n rewrite: (url) => {\n url.pathname = url.pathname.replace(\n `${this.env.SERVER_API_PREFIX}/${name}`,\n remote.prefix,\n );\n },\n ...proxy,\n });\n }\n }\n\n protected readonly fetchLinks = $retry({\n max: 10,\n backoff: {\n initial: 1000,\n },\n onError: (_, attempt, { service, url }) => {\n this.log.warn(`Failed to fetch links, retry (${attempt})...`, {\n service,\n url,\n });\n },\n handler: async (opts: FetchLinksOptions): Promise<ApiLinksResponse> => {\n const { url, authorization } = opts;\n const response = await fetch(url, {\n headers: new Headers(\n authorization\n ? {\n authorization,\n }\n : {},\n ),\n });\n\n if (!response.ok) {\n throw new Error(`Failed to fetch links from ${url}`);\n }\n\n return this.alepha.codec.decode(\n apiLinksResponseSchema,\n await response.json(),\n );\n },\n });\n}\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport interface FetchLinksOptions {\n /**\n * Name of the remote service.\n */\n service: string;\n\n /**\n * URL to fetch links from.\n */\n url: string;\n\n /**\n * Authorization header containing access token.\n */\n authorization?: string;\n}\n\nexport interface ServerRemote {\n /**\n * URL of the remote service.\n */\n url: string;\n\n /**\n * Name of the remote service.\n */\n name: string;\n\n /**\n * Expose links as endpoint. It's not only internal.\n */\n proxy: boolean;\n\n /**\n * It's only used inside the application.\n */\n internal: boolean;\n\n /**\n * Links fetcher.\n */\n links: (args: { authorization?: string }) => Promise<ApiLinksResponse>;\n\n /**\n * Fetches schema for the remote service.\n */\n schema: (args: { name: string; authorization?: string }) => Promise<any>;\n\n /**\n * Force a default access token provider when not provided.\n */\n serviceAccount?: ServiceAccountPrimitive;\n\n /**\n * Prefix for the remote service links.\n */\n prefix: string;\n}\n","import { $env, $hook, $inject, Alepha, t } from \"alepha\";\nimport {\n type Permission,\n SecurityProvider,\n type UserAccountToken,\n} from \"alepha/security\";\nimport {\n $action,\n $route,\n type ClientRequestEntry,\n type ClientRequestOptions,\n type RequestConfigSchema,\n ServerTimingProvider,\n} from \"alepha/server\";\nimport {\n type ApiLink,\n type ApiLinksResponse,\n apiLinksResponseSchema,\n} from \"../schemas/apiLinksResponseSchema.ts\";\nimport { LinkProvider } from \"./LinkProvider.ts\";\nimport { RemotePrimitiveProvider } from \"./RemotePrimitiveProvider.ts\";\n\nconst envSchema = t.object({\n SERVER_API_PREFIX: t.text({\n description: \"Prefix for all API routes (e.g. $action).\",\n default: \"/api\",\n }),\n});\n\nexport class ServerLinksProvider {\n protected readonly env = $env(envSchema);\n protected readonly alepha = $inject(Alepha);\n protected readonly linkProvider = $inject(LinkProvider);\n protected readonly remoteProvider = $inject(RemotePrimitiveProvider);\n protected readonly serverTimingProvider = $inject(ServerTimingProvider);\n\n public get prefix() {\n return this.env.SERVER_API_PREFIX;\n }\n\n public readonly onRoute = $hook({\n on: \"configure\",\n handler: () => {\n // convert all $action to local links\n for (const action of this.alepha.primitives($action)) {\n this.linkProvider.registerLink({\n name: action.name,\n group: action.group,\n schema: action.options.schema,\n requestBodyType: action.getBodyContentType(),\n secured: action.options.secure ?? true,\n method: action.method === \"GET\" ? undefined : action.method,\n prefix: action.prefix,\n path: action.path,\n // by local, we mean that it can be called directly via the handler\n handler: (\n config: ClientRequestEntry<RequestConfigSchema>,\n options: ClientRequestOptions = {},\n ) => action.run(config, options),\n });\n }\n },\n });\n\n /**\n * First API - Get all API links for the user.\n *\n * This is based on the user's permissions.\n */\n public readonly links = $route({\n path: LinkProvider.path.apiLinks,\n schema: {\n response: apiLinksResponseSchema,\n },\n handler: ({ user, headers }) => {\n return this.getUserApiLinks({\n user,\n authorization: headers.authorization,\n });\n },\n });\n\n /**\n * Second API - Get schema for a specific API link.\n *\n * Note: Body/Response schema are not included in `links` API because it's TOO BIG.\n * I mean for 150+ links, you got 50ms of serialization time.\n */\n public readonly schema = $route({\n path: LinkProvider.path.apiSchema,\n schema: {\n params: t.object({\n name: t.text(),\n }),\n response: t.json(),\n },\n handler: ({ params, user, headers }) => {\n return this.getSchemaByName(params.name, {\n user,\n authorization: headers.authorization,\n });\n },\n });\n\n public async getSchemaByName(\n name: string,\n options: GetApiLinksOptions = {},\n ): Promise<RequestConfigSchema> {\n const authorization = options.authorization;\n const api = await this.getUserApiLinks({\n user: options.user,\n authorization,\n });\n\n for (const link of api.links) {\n if (link.name === name) {\n if (link.service) {\n // remote\n return this.remoteProvider\n .getRemotes()\n .find((it) => it.name === link.service)\n ?.schema({ name: name, authorization });\n }\n\n // local\n return (\n this.linkProvider.getServerLinks().find((it) => it.name === name)\n ?.schema ?? {}\n );\n }\n }\n\n return {};\n }\n\n /**\n * Retrieves API links for the user based on their permissions.\n * Will check on local links and remote links.\n */\n public async getUserApiLinks(\n options: GetApiLinksOptions,\n ): Promise<ApiLinksResponse> {\n const { user } = options;\n let permissions: Permission[] | undefined;\n let permissionMap: Map<string, Permission> | undefined;\n const hasSecurity = this.alepha.has(SecurityProvider);\n if (hasSecurity && user) {\n permissions = this.alepha.inject(SecurityProvider).getPermissions(user);\n permissionMap = new Map(\n permissions.map((it) => [`${it.group}:${it.name}`, it]),\n );\n }\n\n const userLinks: ApiLink[] = [];\n\n // bonus: add permissions not related to $action\n for (const permission of permissions ?? []) {\n if (\n !permission.path &&\n !permission.method &&\n permission.name &&\n permission.group\n ) {\n userLinks.push({\n path: \"\", // this is a placeholder for links without specific path\n name: permission.name,\n group: permission.group,\n });\n }\n }\n\n // add local links\n for (const link of this.linkProvider.getServerLinks()) {\n // SKIP REMOTE LINKS, remote links are handled separately for security\n if (link.host) continue;\n\n if (hasSecurity && link.secured) {\n // skip secured links if user is not provided\n if (!user) {\n continue;\n }\n\n if (typeof link.secured === \"object\" && link.secured.realm) {\n // realm check\n if (user.realm !== link.secured.realm) {\n continue;\n }\n } else if (permissionMap) {\n // small permissions check, can be optimized later ... :')\n\n if (!permissionMap.has(`${link.group}:${link.name}`)) {\n continue;\n }\n }\n }\n\n userLinks.push({\n name: link.name,\n group: link.group,\n requestBodyType: link.requestBodyType,\n method: link.method,\n path: link.path,\n });\n }\n\n this.serverTimingProvider.beginTiming(\"fetchRemoteLinks\");\n // this does not scale well, but it's working for now\n // TODO: remote links can be cached by user.roles\n const promises = this.remoteProvider\n .getRemotes()\n .filter((it) => it.proxy) // add only \"proxy\" remotes\n .map(async (remote) => {\n const { links, prefix } = await remote.links(options);\n return links.map((link) => {\n let path = link.path.replace(prefix ?? \"/api\", \"\");\n if (link.service) {\n path = `/${link.service}${path}`;\n }\n\n return {\n ...link,\n path,\n proxy: true,\n service: remote.name,\n };\n });\n });\n\n userLinks.push(...(await Promise.all(promises)).flat());\n this.serverTimingProvider.endTiming(\"fetchRemoteLinks\");\n\n return {\n prefix: this.env.SERVER_API_PREFIX,\n links: userLinks,\n };\n }\n}\n\nexport interface GetApiLinksOptions {\n user?: UserAccountToken;\n authorization?: string;\n}\n","import \"alepha/server/security\";\nimport { $module } from \"alepha\";\nimport { AlephaServer } from \"alepha/server\";\nimport { $client } from \"./primitives/$client.ts\";\nimport { $remote } from \"./primitives/$remote.ts\";\nimport { LinkProvider } from \"./providers/LinkProvider.ts\";\nimport { RemotePrimitiveProvider } from \"./providers/RemotePrimitiveProvider.ts\";\nimport { ServerLinksProvider } from \"./providers/ServerLinksProvider.ts\";\nimport type { ApiLinksResponse } from \"./schemas/apiLinksResponseSchema.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport * from \"./primitives/$client.ts\";\nexport * from \"./primitives/$remote.ts\";\nexport * from \"./providers/LinkProvider.ts\";\nexport * from \"./providers/RemotePrimitiveProvider.ts\";\nexport * from \"./providers/ServerLinksProvider.ts\";\nexport * from \"./schemas/apiLinksResponseSchema.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\ndeclare module \"alepha\" {\n interface State {\n /**\n * API links attached to the server request state.\n *\n * @see {@link ApiLinksResponse}\n * @internal\n */\n \"alepha.server.request.apiLinks\"?: ApiLinksResponse;\n }\n}\n\n// ---------------------------------------------------------------------------------------------------------------------\n\n/**\n * Provides server-side link management and remote capabilities for client-server interactions.\n *\n * The server-links module enables declarative link definitions using `$remote` and `$client` primitives,\n * facilitating seamless API endpoint management and client-server communication. It integrates with server\n * security features to ensure safe and controlled access to resources.\n *\n * @see {@link $remote}\n * @see {@link $client}\n * @module alepha.server.links\n */\nexport const AlephaServerLinks = $module({\n name: \"alepha.server.links\",\n primitives: [$remote, $client],\n services: [\n AlephaServer,\n ServerLinksProvider,\n RemotePrimitiveProvider,\n LinkProvider,\n ],\n});\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["webRequest: Request | undefined","WebStream","formData: FormData","body: Record<string, any>","tempFiles: HybridFile[]"],"sources":["../../../src/server/multipart/providers/ServerMultipartProvider.ts","../../../src/server/multipart/index.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { createReadStream } from \"node:fs\";\nimport { readFile, unlink, writeFile } from \"node:fs/promises\";\nimport * as os from \"node:os\";\nimport { ReadableStream as WebStream } from \"node:stream/web\";\nimport {\n $env,\n $hook,\n $inject,\n Alepha,\n type FileLike,\n isTypeFile,\n t,\n} from \"alepha\";\nimport { $logger } from \"alepha/logger\";\nimport { HttpError, isMultipart, type ServerRoute } from \"alepha/server\";\n\nconst envSchema = t.object({\n SERVER_MULTIPART_LIMIT: t.integer({\n default: 10_000_000, // 10MB total\n min: 0,\n description: \"Maximum total size of multipart request body in bytes.\",\n }),\n SERVER_MULTIPART_FILE_LIMIT: t.integer({\n default: 5_000_000, // 5MB per file\n min: 0,\n description: \"Maximum size of a single file in bytes.\",\n }),\n SERVER_MULTIPART_FILE_COUNT: t.integer({\n default: 10,\n min: 1,\n description: \"Maximum number of files allowed in a single request.\",\n }),\n});\n\nexport class ServerMultipartProvider {\n protected readonly alepha = $inject(Alepha);\n protected readonly env = $env(envSchema);\n protected readonly log = $logger();\n\n public readonly onRequest = $hook({\n on: \"server:onRequest\",\n handler: async ({ route, request }) => {\n // already parsed (e.g. by body parser)\n if (request.body) {\n return;\n }\n\n // we do not parse body if no schema\n if (!route.schema?.body) {\n return;\n }\n\n let webRequest: Request | undefined;\n\n if (request.raw.web?.req) {\n webRequest = request.raw.web.req;\n } else if (request.raw.node?.req) {\n webRequest = new Request(request.url, {\n method: request.method,\n headers: request.headers,\n body: WebStream.from(\n request.raw.node.req,\n ) as unknown as ReadableStream,\n duplex: \"half\",\n } as RequestInit & { duplex: \"half\" });\n }\n\n if (!webRequest) {\n return;\n }\n\n const contentType = request.headers[\"content-type\"];\n\n // Check content-length before processing to fail fast on oversized requests\n const contentLength = request.headers[\"content-length\"];\n if (contentLength) {\n const size = Number.parseInt(contentLength, 10);\n if (!Number.isNaN(size) && size > this.env.SERVER_MULTIPART_LIMIT) {\n this.log.error(\n `Multipart request size limit exceeded: ${size} > ${this.env.SERVER_MULTIPART_LIMIT}`,\n );\n throw new HttpError({\n status: 413,\n message: `Request body size limit exceeded. Maximum allowed: ${this.env.SERVER_MULTIPART_LIMIT} bytes`,\n });\n }\n }\n\n if (!contentType?.startsWith(\"multipart/form-data\")) {\n if (!isMultipart(route)) {\n return;\n }\n\n // route expects multipart but content-type is not correct! reject with 415\n throw new HttpError({\n status: 415,\n message: `Invalid content-type: ${contentType} - only \"multipart/form-data\" is accepted`,\n });\n }\n\n const { body, cleanup } = await this.handleMultipartBodyFromWeb(\n route,\n webRequest,\n );\n\n request.body = body;\n request.metadata.multipart = { cleanup };\n },\n });\n\n public readonly onResponse = $hook({\n on: \"server:onResponse\",\n handler: async ({ request }) => {\n const cleanup = request.metadata.multipart?.cleanup;\n if (typeof cleanup === \"function\") {\n await cleanup();\n }\n },\n });\n\n public async handleMultipartBodyFromWeb(\n route: ServerRoute,\n request: Request,\n ): Promise<{\n body: Record<string, unknown>;\n cleanup: () => Promise<void>;\n }> {\n let formData: FormData;\n\n try {\n // Parse the FormData from the request\n formData = await request.formData();\n } catch (error) {\n throw new HttpError(\n {\n status: 400,\n message: \"Malformed multipart/form-data\",\n },\n error,\n );\n }\n\n const body: Record<string, any> = {};\n const tempFiles: HybridFile[] = [];\n\n // Helper to clean up temp files on error\n const cleanupOnError = async () => {\n for (const file of tempFiles) {\n try {\n await file.cleanup();\n } catch {\n // Ignore cleanup errors during error handling\n }\n }\n };\n\n try {\n let fileCount = 0;\n let totalSize = 0;\n\n if (route.schema?.body && t.schema.isObject(route.schema.body)) {\n for (const [key, value] of Object.entries(\n route.schema.body.properties,\n )) {\n if (t.schema.isSchema(value)) {\n if (isTypeFile(value)) {\n const file = formData.get(key);\n // Check if file is a Blob (File extends Blob in Web APIs)\n if (file && typeof file === \"object\" && \"arrayBuffer\" in file) {\n const blob = file as Blob;\n\n // Validate file count\n fileCount++;\n if (fileCount > this.env.SERVER_MULTIPART_FILE_COUNT) {\n this.log.error(\n `Too many files in multipart request: ${fileCount} > ${this.env.SERVER_MULTIPART_FILE_COUNT}`,\n );\n throw new HttpError({\n status: 413,\n message: `Too many files. Maximum allowed: ${this.env.SERVER_MULTIPART_FILE_COUNT}`,\n });\n }\n\n // Validate individual file size\n if (blob.size > this.env.SERVER_MULTIPART_FILE_LIMIT) {\n this.log.error(\n `File \"${key}\" exceeds size limit: ${blob.size} > ${this.env.SERVER_MULTIPART_FILE_LIMIT}`,\n );\n throw new HttpError({\n status: 413,\n message: `File \"${key}\" exceeds size limit. Maximum allowed: ${this.env.SERVER_MULTIPART_FILE_LIMIT} bytes`,\n });\n }\n\n // Validate total size\n totalSize += blob.size;\n if (totalSize > this.env.SERVER_MULTIPART_LIMIT) {\n this.log.error(\n `Total multipart size exceeds limit: ${totalSize} > ${this.env.SERVER_MULTIPART_LIMIT}`,\n );\n throw new HttpError({\n status: 413,\n message: `Total request size exceeds limit. Maximum allowed: ${this.env.SERVER_MULTIPART_LIMIT} bytes`,\n });\n }\n\n const hybridFile = await this.createHybridFile(blob, key);\n body[key] = hybridFile;\n tempFiles.push(hybridFile);\n }\n } else {\n const fieldValue = formData.get(key);\n if (fieldValue !== null) {\n // FormData values are either string or File/Blob\n const stringValue =\n typeof fieldValue === \"string\" ? fieldValue : \"\";\n body[key] = this.alepha.codec.decode(value, stringValue);\n }\n }\n }\n }\n }\n\n return {\n body,\n cleanup: async () => {\n for (const file of tempFiles) {\n await file.cleanup();\n }\n },\n };\n } catch (error) {\n // Clean up any temp files that were created before the error\n await cleanupOnError();\n throw error;\n }\n }\n\n /**\n * This is a legacy code, previously we used \"busboy\" to parse multipart in Node.js environment.\n * Now we rely on Web Request's formData() method, which is supported in modern Node.js versions.\n * However, we still need to create temporary files for uploaded files to provide a consistent File-like interface.\n *\n * TODO: In future, we might want to refactor this to avoid using temporary files if not necessary?\n */\n protected async createHybridFile(\n file: Blob,\n fieldName: string,\n ): Promise<HybridFile> {\n const tmpPath = `${os.tmpdir()}/${randomUUID()}`;\n\n // Get file data\n const arrayBuffer = await file.arrayBuffer();\n const buffer = Buffer.from(arrayBuffer);\n\n // Write to temp file\n await writeFile(tmpPath, buffer);\n\n // Get file name - check if it has name property (File type)\n const fileName = (file as any).name || `${fieldName}_${Date.now()}`;\n\n const hybridFile: HybridFile = {\n _state: {\n cleanup: false,\n size: file.size,\n tmpPath,\n },\n name: fileName,\n type: file.type || \"application/octet-stream\",\n lastModified: (file as any).lastModified || Date.now(),\n filepath: tmpPath,\n get size() {\n return this._state.size;\n },\n stream() {\n return createReadStream(tmpPath);\n },\n async arrayBuffer() {\n const content = await readFile(tmpPath);\n return content.buffer.slice(\n content.byteOffset,\n content.byteOffset + content.byteLength,\n ) as ArrayBuffer;\n },\n text: async () => {\n return await readFile(tmpPath, \"utf-8\");\n },\n async cleanup() {\n if (this._state.cleanup) {\n return;\n }\n\n await unlink(tmpPath); // clean up the temp file\n this._state.cleanup = true;\n },\n };\n\n return hybridFile;\n }\n}\n\ninterface HybridFile extends FileLike {\n cleanup(): Promise<void>;\n _state: {\n cleanup: boolean;\n size: number;\n tmpPath: string;\n };\n}\n","import { $module } from \"alepha\";\nimport { AlephaServer } from \"alepha/server\";\nimport { ServerMultipartProvider } from \"./providers/ServerMultipartProvider.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport * from \"./providers/ServerMultipartProvider.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\n/**\n * This module provides support for handling multipart/form-data requests.\n * It allows to parse body data containing t.file().\n *\n * @see {@link ServerMultipartProvider}\n * @module alepha.server.multipart\n */\nexport const AlephaServerMultipart = $module({\n name: \"alepha.server.multipart\",\n services: [AlephaServer, ServerMultipartProvider],\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":["WebStream"],"sources":["../../../src/server/multipart/providers/ServerMultipartProvider.ts","../../../src/server/multipart/index.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { createReadStream } from \"node:fs\";\nimport { readFile, unlink, writeFile } from \"node:fs/promises\";\nimport * as os from \"node:os\";\nimport { ReadableStream as WebStream } from \"node:stream/web\";\nimport {\n $env,\n $hook,\n $inject,\n Alepha,\n type FileLike,\n isTypeFile,\n t,\n} from \"alepha\";\nimport { $logger } from \"alepha/logger\";\nimport { HttpError, isMultipart, type ServerRoute } from \"alepha/server\";\n\nconst envSchema = t.object({\n SERVER_MULTIPART_LIMIT: t.integer({\n default: 10_000_000, // 10MB total\n min: 0,\n description: \"Maximum total size of multipart request body in bytes.\",\n }),\n SERVER_MULTIPART_FILE_LIMIT: t.integer({\n default: 5_000_000, // 5MB per file\n min: 0,\n description: \"Maximum size of a single file in bytes.\",\n }),\n SERVER_MULTIPART_FILE_COUNT: t.integer({\n default: 10,\n min: 1,\n description: \"Maximum number of files allowed in a single request.\",\n }),\n});\n\nexport class ServerMultipartProvider {\n protected readonly alepha = $inject(Alepha);\n protected readonly env = $env(envSchema);\n protected readonly log = $logger();\n\n public readonly onRequest = $hook({\n on: \"server:onRequest\",\n handler: async ({ route, request }) => {\n // already parsed (e.g. by body parser)\n if (request.body) {\n return;\n }\n\n // we do not parse body if no schema\n if (!route.schema?.body) {\n return;\n }\n\n let webRequest: Request | undefined;\n\n if (request.raw.web?.req) {\n webRequest = request.raw.web.req;\n } else if (request.raw.node?.req) {\n webRequest = new Request(request.url, {\n method: request.method,\n headers: request.headers,\n body: WebStream.from(\n request.raw.node.req,\n ) as unknown as ReadableStream,\n duplex: \"half\",\n } as RequestInit & { duplex: \"half\" });\n }\n\n if (!webRequest) {\n return;\n }\n\n const contentType = request.headers[\"content-type\"];\n\n // Check content-length before processing to fail fast on oversized requests\n const contentLength = request.headers[\"content-length\"];\n if (contentLength) {\n const size = Number.parseInt(contentLength, 10);\n if (!Number.isNaN(size) && size > this.env.SERVER_MULTIPART_LIMIT) {\n this.log.error(\n `Multipart request size limit exceeded: ${size} > ${this.env.SERVER_MULTIPART_LIMIT}`,\n );\n throw new HttpError({\n status: 413,\n message: `Request body size limit exceeded. Maximum allowed: ${this.env.SERVER_MULTIPART_LIMIT} bytes`,\n });\n }\n }\n\n if (!contentType?.startsWith(\"multipart/form-data\")) {\n if (!isMultipart(route)) {\n return;\n }\n\n // route expects multipart but content-type is not correct! reject with 415\n throw new HttpError({\n status: 415,\n message: `Invalid content-type: ${contentType} - only \"multipart/form-data\" is accepted`,\n });\n }\n\n const { body, cleanup } = await this.handleMultipartBodyFromWeb(\n route,\n webRequest,\n );\n\n request.body = body;\n request.metadata.multipart = { cleanup };\n },\n });\n\n public readonly onResponse = $hook({\n on: \"server:onResponse\",\n handler: async ({ request }) => {\n const cleanup = request.metadata.multipart?.cleanup;\n if (typeof cleanup === \"function\") {\n await cleanup();\n }\n },\n });\n\n public async handleMultipartBodyFromWeb(\n route: ServerRoute,\n request: Request,\n ): Promise<{\n body: Record<string, unknown>;\n cleanup: () => Promise<void>;\n }> {\n let formData: FormData;\n\n try {\n // Parse the FormData from the request\n formData = await request.formData();\n } catch (error) {\n throw new HttpError(\n {\n status: 400,\n message: \"Malformed multipart/form-data\",\n },\n error,\n );\n }\n\n const body: Record<string, any> = {};\n const tempFiles: HybridFile[] = [];\n\n // Helper to clean up temp files on error\n const cleanupOnError = async () => {\n for (const file of tempFiles) {\n try {\n await file.cleanup();\n } catch {\n // Ignore cleanup errors during error handling\n }\n }\n };\n\n try {\n let fileCount = 0;\n let totalSize = 0;\n\n if (route.schema?.body && t.schema.isObject(route.schema.body)) {\n for (const [key, value] of Object.entries(\n route.schema.body.properties,\n )) {\n if (t.schema.isSchema(value)) {\n if (isTypeFile(value)) {\n const file = formData.get(key);\n // Check if file is a Blob (File extends Blob in Web APIs)\n if (file && typeof file === \"object\" && \"arrayBuffer\" in file) {\n const blob = file as Blob;\n\n // Validate file count\n fileCount++;\n if (fileCount > this.env.SERVER_MULTIPART_FILE_COUNT) {\n this.log.error(\n `Too many files in multipart request: ${fileCount} > ${this.env.SERVER_MULTIPART_FILE_COUNT}`,\n );\n throw new HttpError({\n status: 413,\n message: `Too many files. Maximum allowed: ${this.env.SERVER_MULTIPART_FILE_COUNT}`,\n });\n }\n\n // Validate individual file size\n if (blob.size > this.env.SERVER_MULTIPART_FILE_LIMIT) {\n this.log.error(\n `File \"${key}\" exceeds size limit: ${blob.size} > ${this.env.SERVER_MULTIPART_FILE_LIMIT}`,\n );\n throw new HttpError({\n status: 413,\n message: `File \"${key}\" exceeds size limit. Maximum allowed: ${this.env.SERVER_MULTIPART_FILE_LIMIT} bytes`,\n });\n }\n\n // Validate total size\n totalSize += blob.size;\n if (totalSize > this.env.SERVER_MULTIPART_LIMIT) {\n this.log.error(\n `Total multipart size exceeds limit: ${totalSize} > ${this.env.SERVER_MULTIPART_LIMIT}`,\n );\n throw new HttpError({\n status: 413,\n message: `Total request size exceeds limit. Maximum allowed: ${this.env.SERVER_MULTIPART_LIMIT} bytes`,\n });\n }\n\n const hybridFile = await this.createHybridFile(blob, key);\n body[key] = hybridFile;\n tempFiles.push(hybridFile);\n }\n } else {\n const fieldValue = formData.get(key);\n if (fieldValue !== null) {\n // FormData values are either string or File/Blob\n const stringValue =\n typeof fieldValue === \"string\" ? fieldValue : \"\";\n body[key] = this.alepha.codec.decode(value, stringValue);\n }\n }\n }\n }\n }\n\n return {\n body,\n cleanup: async () => {\n for (const file of tempFiles) {\n await file.cleanup();\n }\n },\n };\n } catch (error) {\n // Clean up any temp files that were created before the error\n await cleanupOnError();\n throw error;\n }\n }\n\n /**\n * This is a legacy code, previously we used \"busboy\" to parse multipart in Node.js environment.\n * Now we rely on Web Request's formData() method, which is supported in modern Node.js versions.\n * However, we still need to create temporary files for uploaded files to provide a consistent File-like interface.\n *\n * TODO: In future, we might want to refactor this to avoid using temporary files if not necessary?\n */\n protected async createHybridFile(\n file: Blob,\n fieldName: string,\n ): Promise<HybridFile> {\n const tmpPath = `${os.tmpdir()}/${randomUUID()}`;\n\n // Get file data\n const arrayBuffer = await file.arrayBuffer();\n const buffer = Buffer.from(arrayBuffer);\n\n // Write to temp file\n await writeFile(tmpPath, buffer);\n\n // Get file name - check if it has name property (File type)\n const fileName = (file as any).name || `${fieldName}_${Date.now()}`;\n\n const hybridFile: HybridFile = {\n _state: {\n cleanup: false,\n size: file.size,\n tmpPath,\n },\n name: fileName,\n type: file.type || \"application/octet-stream\",\n lastModified: (file as any).lastModified || Date.now(),\n filepath: tmpPath,\n get size() {\n return this._state.size;\n },\n stream() {\n return createReadStream(tmpPath);\n },\n async arrayBuffer() {\n const content = await readFile(tmpPath);\n return content.buffer.slice(\n content.byteOffset,\n content.byteOffset + content.byteLength,\n ) as ArrayBuffer;\n },\n text: async () => {\n return await readFile(tmpPath, \"utf-8\");\n },\n async cleanup() {\n if (this._state.cleanup) {\n return;\n }\n\n await unlink(tmpPath); // clean up the temp file\n this._state.cleanup = true;\n },\n };\n\n return hybridFile;\n }\n}\n\ninterface HybridFile extends FileLike {\n cleanup(): Promise<void>;\n _state: {\n cleanup: boolean;\n size: number;\n tmpPath: string;\n };\n}\n","import { $module } from \"alepha\";\nimport { AlephaServer } from \"alepha/server\";\nimport { ServerMultipartProvider } from \"./providers/ServerMultipartProvider.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport * from \"./providers/ServerMultipartProvider.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\n/**\n * This module provides support for handling multipart/form-data requests.\n * It allows to parse body data containing t.file().\n *\n * @see {@link ServerMultipartProvider}\n * @module alepha.server.multipart\n */\nexport const AlephaServerMultipart = $module({\n name: \"alepha.server.multipart\",\n services: [AlephaServer, ServerMultipartProvider],\n});\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["result: RateLimitResult"],"sources":["../../../src/server/rate-limit/providers/ServerRateLimitProvider.ts","../../../src/server/rate-limit/primitives/$rateLimit.ts","../../../src/server/rate-limit/index.ts"],"sourcesContent":["import { $atom, $env, $hook, $inject, $use, type Static, t } from \"alepha\";\nimport { $cache } from \"alepha/cache\";\nimport { $logger } from \"alepha/logger\";\nimport {\n HttpError,\n type ServerRequest,\n ServerRouterProvider,\n} from \"alepha/server\";\nimport type { RateLimitOptions } from \"../index.ts\";\nimport type { RateLimitPrimitiveOptions } from \"../primitives/$rateLimit.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport interface RateLimitResult {\n allowed: boolean;\n limit: number;\n remaining: number;\n resetTime: number;\n retryAfter?: number;\n}\n\n/**\n * Rate limit configuration atom (global defaults)\n */\nexport const rateLimitOptions = $atom({\n name: \"alepha.server.rate-limit.options\",\n schema: t.object({\n windowMs: t.optional(\n t.number({\n description: \"Window duration in milliseconds\",\n }),\n ),\n max: t.optional(\n t.number({\n description: \"Maximum number of requests per window\",\n }),\n ),\n skipFailedRequests: t.optional(\n t.boolean({\n description: \"Skip rate limiting for failed requests\",\n }),\n ),\n skipSuccessfulRequests: t.optional(\n t.boolean({\n description: \"Skip rate limiting for successful requests\",\n }),\n ),\n }),\n default: {},\n});\n\nexport type RateLimitAtomOptions = Static<typeof rateLimitOptions.schema>;\n\ndeclare module \"alepha\" {\n interface State {\n [rateLimitOptions.key]: RateLimitAtomOptions;\n }\n}\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nconst envSchema = t.object({\n RATE_LIMIT_WINDOW_MS: t.number({\n default: 15 * 60 * 1000, // 15 minutes\n description: \"Rate limit window in milliseconds\",\n }),\n RATE_LIMIT_MAX_REQUESTS: t.number({\n default: 100,\n description: \"Maximum requests per window\",\n }),\n});\n\nexport class ServerRateLimitProvider {\n protected readonly log = $logger();\n protected readonly serverRouterProvider = $inject(ServerRouterProvider);\n protected readonly env = $env(envSchema);\n\n protected readonly cache = $cache<RateLimitData>({\n name: \"server-rate-limit\",\n ttl: [this.env.RATE_LIMIT_WINDOW_MS, \"milliseconds\"],\n });\n\n protected readonly globalOptions = $use(rateLimitOptions);\n\n /**\n * Registered rate limit configurations with their path patterns\n */\n public readonly registeredConfigs: RateLimitPrimitiveOptions[] = [];\n\n /**\n * Register a rate limit configuration (called by primitives)\n */\n public registerRateLimit(config: RateLimitPrimitiveOptions): void {\n this.registeredConfigs.push(config);\n }\n\n protected readonly onStart = $hook({\n on: \"start\",\n handler: async () => {\n // Apply path-specific rate limit configs to routes\n for (const config of this.registeredConfigs) {\n if (config.paths) {\n for (const pattern of config.paths) {\n const matchedRoutes = this.serverRouterProvider.getRoutes(pattern);\n for (const route of matchedRoutes) {\n route.rateLimit = this.buildRateLimitOptions(config);\n }\n }\n }\n }\n\n if (this.registeredConfigs.length > 0) {\n this.log.info(\n `Initialized with ${this.registeredConfigs.length} registered rate-limit configurations.`,\n );\n }\n },\n });\n\n public readonly onRequest = $hook({\n on: \"server:onRequest\",\n handler: async ({ route, request }) => {\n // Use route-specific rate limit if defined, otherwise use global options\n const rateLimitConfig = route.rateLimit ?? this.globalOptions;\n\n // Skip if no rate limiting configured\n if (!rateLimitConfig.max && !rateLimitConfig.windowMs) {\n return;\n }\n\n const result = await this.checkLimit(request, rateLimitConfig);\n this.setRateLimitHeaders(request, result);\n\n if (!result.allowed) {\n throw new HttpError({\n status: 429,\n message: \"Too Many Requests\",\n });\n }\n },\n });\n\n public readonly onActionRequest = $hook({\n on: \"action:onRequest\",\n handler: async ({ action, request }) => {\n // Check if this action has rate limiting enabled\n const rateLimit = action.options?.rateLimit;\n if (!rateLimit) {\n return; // No rate limiting for this action\n }\n\n const result = await this.checkLimit(request, rateLimit);\n\n if (!result.allowed) {\n // Actions are internal - don't set HTTP headers\n // Only throw error to prevent action execution\n throw new HttpError({\n status: 429,\n message: \"Too Many Requests\",\n });\n }\n\n // Action allowed - no headers to set since actions are internal\n },\n });\n\n /**\n * Build complete rate limit options by merging with global defaults\n */\n protected buildRateLimitOptions(\n config: RateLimitPrimitiveOptions,\n ): RateLimitOptions {\n return {\n max: config.max ?? this.globalOptions.max,\n windowMs: config.windowMs ?? this.globalOptions.windowMs,\n keyGenerator: config.keyGenerator,\n skipFailedRequests:\n config.skipFailedRequests ?? this.globalOptions.skipFailedRequests,\n skipSuccessfulRequests:\n config.skipSuccessfulRequests ??\n this.globalOptions.skipSuccessfulRequests,\n };\n }\n\n /**\n * Set rate limit headers on the response\n */\n protected setRateLimitHeaders(\n request: ServerRequest,\n result: RateLimitResult,\n ): void {\n request.reply.setHeader(\"X-RateLimit-Limit\", result.limit.toString());\n request.reply.setHeader(\n \"X-RateLimit-Remaining\",\n result.remaining.toString(),\n );\n request.reply.setHeader(\n \"X-RateLimit-Reset\",\n Math.ceil(result.resetTime / 1000).toString(),\n );\n\n if (!result.allowed && result.retryAfter) {\n request.reply.setHeader(\"Retry-After\", result.retryAfter.toString());\n }\n }\n\n public async checkLimit(\n req: ServerRequest,\n options: RateLimitOptions = {},\n ): Promise<RateLimitResult> {\n const windowMs = options.windowMs ?? this.env.RATE_LIMIT_WINDOW_MS;\n const max = options.max ?? this.env.RATE_LIMIT_MAX_REQUESTS;\n const key = this.generateKey(req);\n\n const now = Date.now();\n const windowStart = now - windowMs;\n\n // Get current rate limit data\n const currentData = (await this.cache.get(key)) || {\n count: 0,\n windowStart: now,\n hits: [],\n };\n\n // Clean old hits outside the current window\n const validHits = currentData.hits.filter(\n (hit: number) => hit >= windowStart,\n );\n\n // Check if limit exceeded\n const allowed = validHits.length < max;\n const remaining = Math.max(0, max - validHits.length);\n const resetTime = Math.max(...validHits, windowStart) + windowMs;\n\n // If allowed, record this request\n if (allowed) {\n validHits.push(now);\n await this.cache.set(key, {\n count: validHits.length,\n windowStart: Math.min(currentData.windowStart, windowStart),\n hits: validHits,\n });\n }\n\n const result: RateLimitResult = {\n allowed,\n limit: max,\n remaining: allowed ? remaining - 1 : remaining,\n resetTime,\n };\n\n if (!allowed) {\n result.retryAfter = Math.ceil((resetTime - now) / 1000);\n }\n\n return result;\n }\n\n protected generateKey(req: ServerRequest): string {\n // Default to IP-based rate limiting\n const ip = this.getClientIP(req);\n return `ip:${ip}`;\n }\n\n protected getClientIP(req: ServerRequest): string {\n // Check x-forwarded-for header first (for proxies/load balancers)\n const forwarded = req.headers?.[\"x-forwarded-for\"];\n if (forwarded) {\n // x-forwarded-for can contain multiple IPs, get the first one (original client)\n const firstIp = forwarded.split(\",\")[0].trim();\n if (firstIp) return firstIp;\n }\n\n return req.ip || \"unknown\";\n }\n}\n\ninterface RateLimitData {\n count: number;\n windowStart: number;\n hits: number[];\n}\n","import { $inject, createPrimitive, KIND, Primitive } from \"alepha\";\nimport type { ServerRequest } from \"alepha/server\";\nimport type { RateLimitOptions } from \"../index.ts\";\nimport {\n type RateLimitResult,\n ServerRateLimitProvider,\n} from \"../providers/ServerRateLimitProvider.ts\";\n\n/**\n * Declares rate limiting for server routes or custom usage.\n * This primitive provides methods to check rate limits and configure behavior\n * within the server request/response cycle.\n *\n * @example\n * ```ts\n * class ApiService {\n * // Apply rate limiting to specific paths\n * apiRateLimit = $rateLimit({\n * paths: [\"/api/*\"],\n * max: 100,\n * windowMs: 15 * 60 * 1000, // 15 minutes\n * });\n *\n * // Or use check() method for manual rate limiting\n * customAction = $action({\n * handler: async (req) => {\n * const result = await this.apiRateLimit.check(req);\n * if (!result.allowed) throw new Error(\"Rate limited\");\n * return \"ok\";\n * },\n * });\n * }\n * ```\n */\nexport const $rateLimit = (\n options: RateLimitPrimitiveOptions = {},\n): AbstractRateLimitPrimitive => {\n return createPrimitive(RateLimitPrimitive, options);\n};\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport interface RateLimitPrimitiveOptions extends RateLimitOptions {\n /** Name identifier for this rate limit (default: property key) */\n name?: string;\n /** Path patterns to match (supports wildcards like /api/*) */\n paths?: string[];\n}\n\nexport interface AbstractRateLimitPrimitive {\n readonly name: string;\n readonly options: RateLimitPrimitiveOptions;\n check(\n request: ServerRequest,\n options?: RateLimitOptions,\n ): Promise<RateLimitResult>;\n}\n\nexport class RateLimitPrimitive\n extends Primitive<RateLimitPrimitiveOptions>\n implements AbstractRateLimitPrimitive\n{\n protected readonly serverRateLimitProvider = $inject(ServerRateLimitProvider);\n\n public get name(): string {\n return this.options.name ?? `${this.config.propertyKey}`;\n }\n\n protected onInit() {\n // Register this rate limit configuration with the provider\n this.serverRateLimitProvider.registerRateLimit(this.options);\n }\n\n /**\n * Checks rate limit for the given request using this primitive's configuration.\n */\n public async check(\n request: ServerRequest,\n options?: RateLimitOptions,\n ): Promise<RateLimitResult> {\n const mergedOptions = { ...this.options, ...options };\n return this.serverRateLimitProvider.checkLimit(request, mergedOptions);\n }\n}\n\n$rateLimit[KIND] = RateLimitPrimitive;\n","import { $module } from \"alepha\";\nimport { AlephaServer } from \"alepha/server\";\nimport { $rateLimit } from \"./primitives/$rateLimit.ts\";\nimport { ServerRateLimitProvider } from \"./providers/ServerRateLimitProvider.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport * from \"./primitives/$rateLimit.ts\";\nexport * from \"./providers/ServerRateLimitProvider.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\ndeclare module \"alepha/server\" {\n interface ActionPrimitiveOptions<TConfig> {\n /**\n * Rate limiting configuration for this action.\n * When specified, the action will be rate limited according to these settings.\n */\n rateLimit?: RateLimitOptions;\n }\n\n interface ServerRoute {\n /**\n * Route-specific rate limit configuration.\n * If set, overrides the global rate limit options for this route.\n */\n rateLimit?: RateLimitOptions;\n }\n}\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport interface RateLimitOptions {\n /** Maximum number of requests per window (default: 100) */\n max?: number;\n /** Window duration in milliseconds (default: 15 minutes) */\n windowMs?: number;\n /** Custom key generator function */\n keyGenerator?: (req: any) => string;\n /** Skip rate limiting for failed requests */\n skipFailedRequests?: boolean;\n /** Skip rate limiting for successful requests */\n skipSuccessfulRequests?: boolean;\n}\n\n/**\n * Provides rate limiting capabilities for server routes and actions with configurable limits and windows.\n *\n * The server-rate-limit module enables per-route and per-action rate limiting using either:\n * - The `$rateLimit` primitive with `paths` option for path-based rate limiting\n * - The `rateLimit` option in action primitives for action-specific limiting\n *\n * It offers sliding window rate limiting, custom key generation, and seamless integration with server routes.\n *\n * @example\n * ```ts\n * import { $rateLimit, AlephaServerRateLimit } from \"alepha/server/rate-limit\";\n *\n * class ApiService {\n * // Path-specific rate limiting\n * apiRateLimit = $rateLimit({\n * paths: [\"/api/*\"],\n * max: 100,\n * windowMs: 15 * 60 * 1000, // 15 minutes\n * });\n * }\n * ```\n *\n * @see {@link $rateLimit}\n * @module alepha.server.rate-limit\n */\nexport const AlephaServerRateLimit = $module({\n name: \"alepha.server.rate-limit\",\n primitives: [$rateLimit],\n services: [AlephaServer, ServerRateLimitProvider],\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/server/rate-limit/providers/ServerRateLimitProvider.ts","../../../src/server/rate-limit/primitives/$rateLimit.ts","../../../src/server/rate-limit/index.ts"],"sourcesContent":["import { $atom, $env, $hook, $inject, $use, type Static, t } from \"alepha\";\nimport { $cache } from \"alepha/cache\";\nimport { $logger } from \"alepha/logger\";\nimport {\n HttpError,\n type ServerRequest,\n ServerRouterProvider,\n} from \"alepha/server\";\nimport type { RateLimitOptions } from \"../index.ts\";\nimport type { RateLimitPrimitiveOptions } from \"../primitives/$rateLimit.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport interface RateLimitResult {\n allowed: boolean;\n limit: number;\n remaining: number;\n resetTime: number;\n retryAfter?: number;\n}\n\n/**\n * Rate limit configuration atom (global defaults)\n */\nexport const rateLimitOptions = $atom({\n name: \"alepha.server.rate-limit.options\",\n schema: t.object({\n windowMs: t.optional(\n t.number({\n description: \"Window duration in milliseconds\",\n }),\n ),\n max: t.optional(\n t.number({\n description: \"Maximum number of requests per window\",\n }),\n ),\n skipFailedRequests: t.optional(\n t.boolean({\n description: \"Skip rate limiting for failed requests\",\n }),\n ),\n skipSuccessfulRequests: t.optional(\n t.boolean({\n description: \"Skip rate limiting for successful requests\",\n }),\n ),\n }),\n default: {},\n});\n\nexport type RateLimitAtomOptions = Static<typeof rateLimitOptions.schema>;\n\ndeclare module \"alepha\" {\n interface State {\n [rateLimitOptions.key]: RateLimitAtomOptions;\n }\n}\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nconst envSchema = t.object({\n RATE_LIMIT_WINDOW_MS: t.number({\n default: 15 * 60 * 1000, // 15 minutes\n description: \"Rate limit window in milliseconds\",\n }),\n RATE_LIMIT_MAX_REQUESTS: t.number({\n default: 100,\n description: \"Maximum requests per window\",\n }),\n});\n\nexport class ServerRateLimitProvider {\n protected readonly log = $logger();\n protected readonly serverRouterProvider = $inject(ServerRouterProvider);\n protected readonly env = $env(envSchema);\n\n protected readonly cache = $cache<RateLimitData>({\n name: \"server-rate-limit\",\n ttl: [this.env.RATE_LIMIT_WINDOW_MS, \"milliseconds\"],\n });\n\n protected readonly globalOptions = $use(rateLimitOptions);\n\n /**\n * Registered rate limit configurations with their path patterns\n */\n public readonly registeredConfigs: RateLimitPrimitiveOptions[] = [];\n\n /**\n * Register a rate limit configuration (called by primitives)\n */\n public registerRateLimit(config: RateLimitPrimitiveOptions): void {\n this.registeredConfigs.push(config);\n }\n\n protected readonly onStart = $hook({\n on: \"start\",\n handler: async () => {\n // Apply path-specific rate limit configs to routes\n for (const config of this.registeredConfigs) {\n if (config.paths) {\n for (const pattern of config.paths) {\n const matchedRoutes = this.serverRouterProvider.getRoutes(pattern);\n for (const route of matchedRoutes) {\n route.rateLimit = this.buildRateLimitOptions(config);\n }\n }\n }\n }\n\n if (this.registeredConfigs.length > 0) {\n this.log.info(\n `Initialized with ${this.registeredConfigs.length} registered rate-limit configurations.`,\n );\n }\n },\n });\n\n public readonly onRequest = $hook({\n on: \"server:onRequest\",\n handler: async ({ route, request }) => {\n // Use route-specific rate limit if defined, otherwise use global options\n const rateLimitConfig = route.rateLimit ?? this.globalOptions;\n\n // Skip if no rate limiting configured\n if (!rateLimitConfig.max && !rateLimitConfig.windowMs) {\n return;\n }\n\n const result = await this.checkLimit(request, rateLimitConfig);\n this.setRateLimitHeaders(request, result);\n\n if (!result.allowed) {\n throw new HttpError({\n status: 429,\n message: \"Too Many Requests\",\n });\n }\n },\n });\n\n public readonly onActionRequest = $hook({\n on: \"action:onRequest\",\n handler: async ({ action, request }) => {\n // Check if this action has rate limiting enabled\n const rateLimit = action.options?.rateLimit;\n if (!rateLimit) {\n return; // No rate limiting for this action\n }\n\n const result = await this.checkLimit(request, rateLimit);\n\n if (!result.allowed) {\n // Actions are internal - don't set HTTP headers\n // Only throw error to prevent action execution\n throw new HttpError({\n status: 429,\n message: \"Too Many Requests\",\n });\n }\n\n // Action allowed - no headers to set since actions are internal\n },\n });\n\n /**\n * Build complete rate limit options by merging with global defaults\n */\n protected buildRateLimitOptions(\n config: RateLimitPrimitiveOptions,\n ): RateLimitOptions {\n return {\n max: config.max ?? this.globalOptions.max,\n windowMs: config.windowMs ?? this.globalOptions.windowMs,\n keyGenerator: config.keyGenerator,\n skipFailedRequests:\n config.skipFailedRequests ?? this.globalOptions.skipFailedRequests,\n skipSuccessfulRequests:\n config.skipSuccessfulRequests ??\n this.globalOptions.skipSuccessfulRequests,\n };\n }\n\n /**\n * Set rate limit headers on the response\n */\n protected setRateLimitHeaders(\n request: ServerRequest,\n result: RateLimitResult,\n ): void {\n request.reply.setHeader(\"X-RateLimit-Limit\", result.limit.toString());\n request.reply.setHeader(\n \"X-RateLimit-Remaining\",\n result.remaining.toString(),\n );\n request.reply.setHeader(\n \"X-RateLimit-Reset\",\n Math.ceil(result.resetTime / 1000).toString(),\n );\n\n if (!result.allowed && result.retryAfter) {\n request.reply.setHeader(\"Retry-After\", result.retryAfter.toString());\n }\n }\n\n public async checkLimit(\n req: ServerRequest,\n options: RateLimitOptions = {},\n ): Promise<RateLimitResult> {\n const windowMs = options.windowMs ?? this.env.RATE_LIMIT_WINDOW_MS;\n const max = options.max ?? this.env.RATE_LIMIT_MAX_REQUESTS;\n const key = this.generateKey(req);\n\n const now = Date.now();\n const windowStart = now - windowMs;\n\n // Get current rate limit data\n const currentData = (await this.cache.get(key)) || {\n count: 0,\n windowStart: now,\n hits: [],\n };\n\n // Clean old hits outside the current window\n const validHits = currentData.hits.filter(\n (hit: number) => hit >= windowStart,\n );\n\n // Check if limit exceeded\n const allowed = validHits.length < max;\n const remaining = Math.max(0, max - validHits.length);\n const resetTime = Math.max(...validHits, windowStart) + windowMs;\n\n // If allowed, record this request\n if (allowed) {\n validHits.push(now);\n await this.cache.set(key, {\n count: validHits.length,\n windowStart: Math.min(currentData.windowStart, windowStart),\n hits: validHits,\n });\n }\n\n const result: RateLimitResult = {\n allowed,\n limit: max,\n remaining: allowed ? remaining - 1 : remaining,\n resetTime,\n };\n\n if (!allowed) {\n result.retryAfter = Math.ceil((resetTime - now) / 1000);\n }\n\n return result;\n }\n\n protected generateKey(req: ServerRequest): string {\n // Default to IP-based rate limiting\n const ip = this.getClientIP(req);\n return `ip:${ip}`;\n }\n\n protected getClientIP(req: ServerRequest): string {\n // Check x-forwarded-for header first (for proxies/load balancers)\n const forwarded = req.headers?.[\"x-forwarded-for\"];\n if (forwarded) {\n // x-forwarded-for can contain multiple IPs, get the first one (original client)\n const firstIp = forwarded.split(\",\")[0].trim();\n if (firstIp) return firstIp;\n }\n\n return req.ip || \"unknown\";\n }\n}\n\ninterface RateLimitData {\n count: number;\n windowStart: number;\n hits: number[];\n}\n","import { $inject, createPrimitive, KIND, Primitive } from \"alepha\";\nimport type { ServerRequest } from \"alepha/server\";\nimport type { RateLimitOptions } from \"../index.ts\";\nimport {\n type RateLimitResult,\n ServerRateLimitProvider,\n} from \"../providers/ServerRateLimitProvider.ts\";\n\n/**\n * Declares rate limiting for server routes or custom usage.\n * This primitive provides methods to check rate limits and configure behavior\n * within the server request/response cycle.\n *\n * @example\n * ```ts\n * class ApiService {\n * // Apply rate limiting to specific paths\n * apiRateLimit = $rateLimit({\n * paths: [\"/api/*\"],\n * max: 100,\n * windowMs: 15 * 60 * 1000, // 15 minutes\n * });\n *\n * // Or use check() method for manual rate limiting\n * customAction = $action({\n * handler: async (req) => {\n * const result = await this.apiRateLimit.check(req);\n * if (!result.allowed) throw new Error(\"Rate limited\");\n * return \"ok\";\n * },\n * });\n * }\n * ```\n */\nexport const $rateLimit = (\n options: RateLimitPrimitiveOptions = {},\n): AbstractRateLimitPrimitive => {\n return createPrimitive(RateLimitPrimitive, options);\n};\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport interface RateLimitPrimitiveOptions extends RateLimitOptions {\n /** Name identifier for this rate limit (default: property key) */\n name?: string;\n /** Path patterns to match (supports wildcards like /api/*) */\n paths?: string[];\n}\n\nexport interface AbstractRateLimitPrimitive {\n readonly name: string;\n readonly options: RateLimitPrimitiveOptions;\n check(\n request: ServerRequest,\n options?: RateLimitOptions,\n ): Promise<RateLimitResult>;\n}\n\nexport class RateLimitPrimitive\n extends Primitive<RateLimitPrimitiveOptions>\n implements AbstractRateLimitPrimitive\n{\n protected readonly serverRateLimitProvider = $inject(ServerRateLimitProvider);\n\n public get name(): string {\n return this.options.name ?? `${this.config.propertyKey}`;\n }\n\n protected onInit() {\n // Register this rate limit configuration with the provider\n this.serverRateLimitProvider.registerRateLimit(this.options);\n }\n\n /**\n * Checks rate limit for the given request using this primitive's configuration.\n */\n public async check(\n request: ServerRequest,\n options?: RateLimitOptions,\n ): Promise<RateLimitResult> {\n const mergedOptions = { ...this.options, ...options };\n return this.serverRateLimitProvider.checkLimit(request, mergedOptions);\n }\n}\n\n$rateLimit[KIND] = RateLimitPrimitive;\n","import { $module } from \"alepha\";\nimport { AlephaServer } from \"alepha/server\";\nimport { $rateLimit } from \"./primitives/$rateLimit.ts\";\nimport { ServerRateLimitProvider } from \"./providers/ServerRateLimitProvider.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport * from \"./primitives/$rateLimit.ts\";\nexport * from \"./providers/ServerRateLimitProvider.ts\";\n\n// ---------------------------------------------------------------------------------------------------------------------\n\ndeclare module \"alepha/server\" {\n interface ActionPrimitiveOptions<TConfig> {\n /**\n * Rate limiting configuration for this action.\n * When specified, the action will be rate limited according to these settings.\n */\n rateLimit?: RateLimitOptions;\n }\n\n interface ServerRoute {\n /**\n * Route-specific rate limit configuration.\n * If set, overrides the global rate limit options for this route.\n */\n rateLimit?: RateLimitOptions;\n }\n}\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport interface RateLimitOptions {\n /** Maximum number of requests per window (default: 100) */\n max?: number;\n /** Window duration in milliseconds (default: 15 minutes) */\n windowMs?: number;\n /** Custom key generator function */\n keyGenerator?: (req: any) => string;\n /** Skip rate limiting for failed requests */\n skipFailedRequests?: boolean;\n /** Skip rate limiting for successful requests */\n skipSuccessfulRequests?: boolean;\n}\n\n/**\n * Provides rate limiting capabilities for server routes and actions with configurable limits and windows.\n *\n * The server-rate-limit module enables per-route and per-action rate limiting using either:\n * - The `$rateLimit` primitive with `paths` option for path-based rate limiting\n * - The `rateLimit` option in action primitives for action-specific limiting\n *\n * It offers sliding window rate limiting, custom key generation, and seamless integration with server routes.\n *\n * @example\n * ```ts\n * import { $rateLimit, AlephaServerRateLimit } from \"alepha/server/rate-limit\";\n *\n * class ApiService {\n * // Path-specific rate limiting\n * apiRateLimit = $rateLimit({\n * paths: [\"/api/*\"],\n * max: 100,\n * windowMs: 15 * 60 * 1000, // 15 minutes\n * });\n * }\n * ```\n *\n * @see {@link $rateLimit}\n * @module alepha.server.rate-limit\n */\nexport const AlephaServerRateLimit = $module({\n name: \"alepha.server.rate-limit\",\n primitives: [$rateLimit],\n services: [AlephaServer, ServerRateLimitProvider],\n});\n"],"mappings":"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"}