alepha 0.15.1 → 0.15.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -80
- package/dist/api/audits/index.d.ts +10 -33
- package/dist/api/audits/index.d.ts.map +1 -1
- package/dist/api/audits/index.js +10 -33
- package/dist/api/audits/index.js.map +1 -1
- package/dist/api/files/index.d.ts +10 -3
- package/dist/api/files/index.d.ts.map +1 -1
- package/dist/api/files/index.js +10 -3
- package/dist/api/files/index.js.map +1 -1
- package/dist/api/jobs/index.d.ts +162 -155
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/jobs/index.js +10 -3
- package/dist/api/jobs/index.js.map +1 -1
- package/dist/api/keys/index.d.ts +413 -0
- package/dist/api/keys/index.d.ts.map +1 -0
- package/dist/api/keys/index.js +476 -0
- package/dist/api/keys/index.js.map +1 -0
- package/dist/api/notifications/index.d.ts +10 -4
- package/dist/api/notifications/index.d.ts.map +1 -1
- package/dist/api/notifications/index.js +10 -4
- package/dist/api/notifications/index.js.map +1 -1
- package/dist/api/parameters/index.d.ts +43 -50
- package/dist/api/parameters/index.d.ts.map +1 -1
- package/dist/api/parameters/index.js +30 -37
- package/dist/api/parameters/index.js.map +1 -1
- package/dist/api/users/index.d.ts +1081 -760
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +2539 -218
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.d.ts +138 -132
- package/dist/api/verifications/index.d.ts.map +1 -1
- package/dist/api/verifications/index.js +12 -4
- package/dist/api/verifications/index.js.map +1 -1
- package/dist/batch/index.d.ts +20 -40
- package/dist/batch/index.d.ts.map +1 -1
- package/dist/batch/index.js +31 -44
- package/dist/batch/index.js.map +1 -1
- package/dist/bucket/index.d.ts +440 -8
- package/dist/bucket/index.d.ts.map +1 -1
- package/dist/bucket/index.js +1861 -12
- package/dist/bucket/index.js.map +1 -1
- package/dist/cache/core/index.d.ts +179 -7
- package/dist/cache/core/index.d.ts.map +1 -1
- package/dist/cache/core/index.js +213 -7
- package/dist/cache/core/index.js.map +1 -1
- package/dist/cache/redis/index.d.ts +1 -0
- package/dist/cache/redis/index.d.ts.map +1 -1
- package/dist/cache/redis/index.js +4 -0
- package/dist/cache/redis/index.js.map +1 -1
- package/dist/cli/index.d.ts +638 -5645
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2550 -368
- package/dist/cli/index.js.map +1 -1
- package/dist/command/index.d.ts +203 -45
- package/dist/command/index.d.ts.map +1 -1
- package/dist/command/index.js +2060 -71
- package/dist/command/index.js.map +1 -1
- package/dist/core/index.browser.js +70 -40
- package/dist/core/index.browser.js.map +1 -1
- package/dist/core/index.d.ts +34 -13
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +90 -40
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.native.js +70 -40
- package/dist/core/index.native.js.map +1 -1
- package/dist/datetime/index.d.ts +15 -0
- package/dist/datetime/index.d.ts.map +1 -1
- package/dist/datetime/index.js +15 -0
- package/dist/datetime/index.js.map +1 -1
- package/dist/email/index.d.ts +323 -20
- package/dist/email/index.d.ts.map +1 -1
- package/dist/email/index.js +1857 -7
- package/dist/email/index.js.map +1 -1
- package/dist/fake/index.d.ts +90 -8
- package/dist/fake/index.d.ts.map +1 -1
- package/dist/fake/index.js +91 -20
- package/dist/fake/index.js.map +1 -1
- package/dist/lock/core/index.d.ts +11 -4
- package/dist/lock/core/index.d.ts.map +1 -1
- package/dist/lock/core/index.js +11 -4
- package/dist/lock/core/index.js.map +1 -1
- package/dist/logger/index.d.ts +17 -66
- package/dist/logger/index.d.ts.map +1 -1
- package/dist/logger/index.js +14 -63
- package/dist/logger/index.js.map +1 -1
- package/dist/mcp/index.d.ts +10 -30
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +12 -35
- package/dist/mcp/index.js.map +1 -1
- package/dist/orm/index.browser.js +3 -3
- package/dist/orm/index.browser.js.map +1 -1
- package/dist/orm/index.bun.js +39 -20
- package/dist/orm/index.bun.js.map +1 -1
- package/dist/orm/index.d.ts +517 -540
- package/dist/orm/index.d.ts.map +1 -1
- package/dist/orm/index.js +58 -71
- package/dist/orm/index.js.map +1 -1
- package/dist/queue/core/index.d.ts +18 -10
- package/dist/queue/core/index.d.ts.map +1 -1
- package/dist/queue/core/index.js +14 -6
- package/dist/queue/core/index.js.map +1 -1
- package/dist/react/auth/index.browser.js +108 -0
- package/dist/react/auth/index.browser.js.map +1 -0
- package/dist/react/auth/index.d.ts +100 -0
- package/dist/react/auth/index.d.ts.map +1 -0
- package/dist/react/auth/index.js +145 -0
- package/dist/react/auth/index.js.map +1 -0
- package/dist/react/core/index.d.ts +469 -0
- package/dist/react/core/index.d.ts.map +1 -0
- package/dist/react/core/index.js +464 -0
- package/dist/react/core/index.js.map +1 -0
- package/dist/react/form/index.d.ts +232 -0
- package/dist/react/form/index.d.ts.map +1 -0
- package/dist/react/form/index.js +432 -0
- package/dist/react/form/index.js.map +1 -0
- package/dist/react/head/index.browser.js +423 -0
- package/dist/react/head/index.browser.js.map +1 -0
- package/dist/react/head/index.d.ts +288 -0
- package/dist/react/head/index.d.ts.map +1 -0
- package/dist/react/head/index.js +465 -0
- package/dist/react/head/index.js.map +1 -0
- package/dist/react/i18n/index.d.ts +175 -0
- package/dist/react/i18n/index.d.ts.map +1 -0
- package/dist/react/i18n/index.js +224 -0
- package/dist/react/i18n/index.js.map +1 -0
- package/dist/react/router/index.browser.js +1974 -0
- package/dist/react/router/index.browser.js.map +1 -0
- package/dist/react/router/index.d.ts +1956 -0
- package/dist/react/router/index.d.ts.map +1 -0
- package/dist/react/router/index.js +4722 -0
- package/dist/react/router/index.js.map +1 -0
- package/dist/react/websocket/index.d.ts +117 -0
- package/dist/react/websocket/index.d.ts.map +1 -0
- package/dist/react/websocket/index.js +107 -0
- package/dist/react/websocket/index.js.map +1 -0
- package/dist/redis/index.bun.js +4 -0
- package/dist/redis/index.bun.js.map +1 -1
- package/dist/redis/index.d.ts +41 -44
- package/dist/redis/index.d.ts.map +1 -1
- package/dist/redis/index.js +16 -25
- package/dist/redis/index.js.map +1 -1
- package/dist/retry/index.d.ts +11 -2
- package/dist/retry/index.d.ts.map +1 -1
- package/dist/retry/index.js +11 -2
- package/dist/retry/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +11 -2
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +11 -2
- package/dist/scheduler/index.js.map +1 -1
- package/dist/security/index.d.ts +140 -49
- package/dist/security/index.d.ts.map +1 -1
- package/dist/security/index.js +164 -32
- package/dist/security/index.js.map +1 -1
- package/dist/server/auth/index.d.ts +12 -7
- package/dist/server/auth/index.d.ts.map +1 -1
- package/dist/server/auth/index.js +12 -7
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/cache/index.d.ts +7 -22
- package/dist/server/cache/index.d.ts.map +1 -1
- package/dist/server/cache/index.js +7 -22
- package/dist/server/cache/index.js.map +1 -1
- package/dist/server/compress/index.d.ts +10 -2
- package/dist/server/compress/index.d.ts.map +1 -1
- package/dist/server/compress/index.js +10 -2
- package/dist/server/compress/index.js.map +1 -1
- package/dist/server/cookies/index.d.ts +40 -16
- package/dist/server/cookies/index.d.ts.map +1 -1
- package/dist/server/cookies/index.js +7 -5
- package/dist/server/cookies/index.js.map +1 -1
- package/dist/server/core/index.d.ts +124 -23
- package/dist/server/core/index.d.ts.map +1 -1
- package/dist/server/core/index.js +231 -14
- package/dist/server/core/index.js.map +1 -1
- package/dist/server/cors/index.d.ts +13 -23
- package/dist/server/cors/index.d.ts.map +1 -1
- package/dist/server/cors/index.js +7 -21
- package/dist/server/cors/index.js.map +1 -1
- package/dist/server/health/index.d.ts +8 -2
- package/dist/server/health/index.d.ts.map +1 -1
- package/dist/server/health/index.js +8 -2
- package/dist/server/health/index.js.map +1 -1
- package/dist/server/helmet/index.d.ts +11 -3
- package/dist/server/helmet/index.d.ts.map +1 -1
- package/dist/server/helmet/index.js +11 -3
- package/dist/server/helmet/index.js.map +1 -1
- package/dist/server/links/index.d.ts +11 -6
- package/dist/server/links/index.d.ts.map +1 -1
- package/dist/server/links/index.js +11 -6
- package/dist/server/links/index.js.map +1 -1
- package/dist/server/metrics/index.d.ts +10 -3
- package/dist/server/metrics/index.d.ts.map +1 -1
- package/dist/server/metrics/index.js +10 -3
- package/dist/server/metrics/index.js.map +1 -1
- package/dist/server/multipart/index.d.ts +9 -3
- package/dist/server/multipart/index.d.ts.map +1 -1
- package/dist/server/multipart/index.js +9 -3
- package/dist/server/multipart/index.js.map +1 -1
- package/dist/server/proxy/index.d.ts +8 -2
- package/dist/server/proxy/index.d.ts.map +1 -1
- package/dist/server/proxy/index.js +8 -2
- package/dist/server/proxy/index.js.map +1 -1
- package/dist/server/rate-limit/index.d.ts +30 -35
- package/dist/server/rate-limit/index.d.ts.map +1 -1
- package/dist/server/rate-limit/index.js +18 -55
- package/dist/server/rate-limit/index.js.map +1 -1
- package/dist/server/static/index.d.ts +137 -4
- package/dist/server/static/index.d.ts.map +1 -1
- package/dist/server/static/index.js +1853 -5
- package/dist/server/static/index.js.map +1 -1
- package/dist/server/swagger/index.d.ts +309 -6
- package/dist/server/swagger/index.d.ts.map +1 -1
- package/dist/server/swagger/index.js +1854 -6
- package/dist/server/swagger/index.js.map +1 -1
- package/dist/sms/index.d.ts +309 -7
- package/dist/sms/index.d.ts.map +1 -1
- package/dist/sms/index.js +1856 -7
- package/dist/sms/index.js.map +1 -1
- package/dist/system/index.browser.js +1218 -0
- package/dist/system/index.browser.js.map +1 -0
- package/dist/{file → system}/index.d.ts +343 -16
- package/dist/system/index.d.ts.map +1 -0
- package/dist/{file → system}/index.js +419 -22
- package/dist/system/index.js.map +1 -0
- package/dist/thread/index.d.ts +11 -2
- package/dist/thread/index.d.ts.map +1 -1
- package/dist/thread/index.js +11 -2
- package/dist/thread/index.js.map +1 -1
- package/dist/topic/core/index.d.ts +12 -5
- package/dist/topic/core/index.d.ts.map +1 -1
- package/dist/topic/core/index.js +12 -5
- package/dist/topic/core/index.js.map +1 -1
- package/dist/vite/index.d.ts +5 -6272
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +23 -10
- package/dist/vite/index.js.map +1 -1
- package/dist/websocket/index.d.ts +12 -8
- package/dist/websocket/index.d.ts.map +1 -1
- package/dist/websocket/index.js +12 -8
- package/dist/websocket/index.js.map +1 -1
- package/package.json +82 -11
- package/src/api/audits/index.ts +10 -33
- package/src/api/files/__tests__/$bucket.spec.ts +1 -1
- package/src/api/files/controllers/AdminFileStatsController.spec.ts +1 -1
- package/src/api/files/controllers/FileController.spec.ts +1 -1
- package/src/api/files/index.ts +10 -3
- package/src/api/files/jobs/FileJobs.spec.ts +1 -1
- package/src/api/files/services/FileService.spec.ts +1 -1
- package/src/api/jobs/index.ts +10 -3
- package/src/api/keys/controllers/AdminApiKeyController.ts +75 -0
- package/src/api/keys/controllers/ApiKeyController.ts +103 -0
- package/src/api/keys/entities/apiKeyEntity.ts +41 -0
- package/src/api/keys/index.ts +49 -0
- package/src/api/keys/schemas/adminApiKeyQuerySchema.ts +7 -0
- package/src/api/keys/schemas/adminApiKeyResourceSchema.ts +17 -0
- package/src/api/keys/schemas/createApiKeyBodySchema.ts +7 -0
- package/src/api/keys/schemas/createApiKeyResponseSchema.ts +11 -0
- package/src/api/keys/schemas/listApiKeyResponseSchema.ts +15 -0
- package/src/api/keys/schemas/revokeApiKeyParamsSchema.ts +5 -0
- package/src/api/keys/schemas/revokeApiKeyResponseSchema.ts +5 -0
- package/src/api/keys/services/ApiKeyService.spec.ts +553 -0
- package/src/api/keys/services/ApiKeyService.ts +306 -0
- package/src/api/logs/TODO.md +55 -0
- package/src/api/notifications/index.ts +10 -4
- package/src/api/parameters/index.ts +9 -30
- package/src/api/parameters/primitives/$config.ts +12 -4
- package/src/api/parameters/services/ConfigStore.ts +9 -3
- package/src/api/users/__tests__/ApiKeys-integration.spec.ts +1035 -0
- package/src/api/users/__tests__/ApiKeys.spec.ts +401 -0
- package/src/api/users/index.ts +14 -3
- package/src/api/users/primitives/$realm.ts +33 -5
- package/src/api/users/providers/RealmProvider.ts +1 -12
- package/src/api/users/services/SessionService.ts +1 -1
- package/src/api/verifications/controllers/VerificationController.ts +2 -0
- package/src/api/verifications/index.ts +10 -4
- package/src/batch/index.ts +9 -36
- package/src/batch/primitives/$batch.ts +0 -8
- package/src/batch/providers/BatchProvider.ts +29 -2
- package/src/bucket/__tests__/shared.ts +1 -1
- package/src/bucket/index.ts +13 -6
- package/src/bucket/primitives/$bucket.ts +1 -1
- package/src/bucket/providers/LocalFileStorageProvider.ts +1 -1
- package/src/bucket/providers/MemoryFileStorageProvider.ts +1 -1
- package/src/cache/core/__tests__/shared.ts +30 -0
- package/src/cache/core/index.ts +11 -6
- package/src/cache/core/primitives/$cache.spec.ts +5 -0
- package/src/cache/core/providers/CacheProvider.ts +17 -0
- package/src/cache/core/providers/MemoryCacheProvider.ts +300 -1
- package/src/cache/redis/__tests__/cache-redis.spec.ts +5 -0
- package/src/cache/redis/providers/RedisCacheProvider.ts +9 -0
- package/src/cli/apps/AlephaCli.ts +1 -14
- package/src/cli/apps/AlephaPackageBuilderCli.ts +10 -1
- package/src/cli/atoms/buildOptions.ts +99 -9
- package/src/cli/commands/build.ts +150 -37
- package/src/cli/commands/db.ts +22 -18
- package/src/cli/commands/deploy.ts +1 -1
- package/src/cli/commands/dev.ts +1 -20
- package/src/cli/commands/gen/env.ts +5 -2
- package/src/cli/commands/gen/openapi.ts +5 -2
- package/src/cli/commands/init.spec.ts +588 -0
- package/src/cli/commands/init.ts +115 -58
- package/src/cli/commands/lint.ts +7 -1
- package/src/cli/commands/typecheck.ts +11 -0
- package/src/cli/providers/AppEntryProvider.ts +1 -1
- package/src/cli/providers/ViteBuildProvider.ts +8 -50
- package/src/cli/providers/ViteDevServerProvider.ts +35 -16
- package/src/cli/services/AlephaCliUtils.ts +52 -121
- package/src/cli/services/PackageManagerUtils.ts +129 -11
- package/src/cli/services/ProjectScaffolder.spec.ts +97 -0
- package/src/cli/services/ProjectScaffolder.ts +148 -81
- package/src/cli/services/ViteUtils.ts +82 -0
- package/src/cli/{assets/claudeMd.ts → templates/agentMd.ts} +37 -24
- package/src/cli/templates/apiAppSecurityTs.ts +11 -0
- package/src/cli/templates/apiIndexTs.ts +30 -0
- package/src/cli/templates/gitignore.ts +39 -0
- package/src/cli/{assets → templates}/mainCss.ts +11 -2
- package/src/cli/templates/mainServerTs.ts +33 -0
- package/src/cli/templates/webAppRouterTs.ts +74 -0
- package/src/cli/templates/webHelloComponentTsx.ts +30 -0
- package/src/command/helpers/Runner.spec.ts +139 -0
- package/src/command/helpers/Runner.ts +7 -22
- package/src/command/index.ts +12 -4
- package/src/command/providers/CliProvider.spec.ts +1392 -0
- package/src/command/providers/CliProvider.ts +320 -47
- package/src/core/Alepha.ts +34 -27
- package/src/core/__tests__/Alepha-start.spec.ts +4 -4
- package/src/core/helpers/jsonSchemaToTypeBox.spec.ts +771 -0
- package/src/core/helpers/jsonSchemaToTypeBox.ts +62 -10
- package/src/core/index.shared.ts +1 -0
- package/src/core/index.ts +20 -0
- package/src/core/providers/EventManager.spec.ts +0 -71
- package/src/core/providers/EventManager.ts +3 -15
- package/src/core/providers/Json.ts +2 -14
- package/src/datetime/index.ts +15 -0
- package/src/email/index.ts +10 -5
- package/src/email/providers/LocalEmailProvider.spec.ts +1 -1
- package/src/email/providers/LocalEmailProvider.ts +1 -1
- package/src/fake/__tests__/keyName.example.ts +1 -1
- package/src/fake/__tests__/keyName.spec.ts +5 -5
- package/src/fake/index.ts +9 -6
- package/src/fake/providers/FakeProvider.spec.ts +258 -40
- package/src/fake/providers/FakeProvider.ts +133 -19
- package/src/lock/core/index.ts +11 -4
- package/src/logger/index.ts +17 -66
- package/src/mcp/index.ts +10 -27
- package/src/mcp/transports/SseMcpTransport.ts +0 -11
- package/src/orm/__tests__/PostgresProvider.spec.ts +2 -2
- package/src/orm/index.browser.ts +2 -2
- package/src/orm/index.bun.ts +5 -3
- package/src/orm/index.ts +23 -53
- package/src/orm/providers/drivers/BunSqliteProvider.ts +5 -1
- package/src/orm/providers/drivers/CloudflareD1Provider.ts +57 -30
- package/src/orm/providers/drivers/DatabaseProvider.ts +9 -1
- package/src/orm/providers/drivers/NodeSqliteProvider.ts +4 -1
- package/src/orm/services/Repository.ts +7 -3
- package/src/queue/core/index.ts +14 -6
- package/src/react/auth/__tests__/$auth.spec.ts +202 -0
- package/src/react/auth/hooks/useAuth.ts +32 -0
- package/src/react/auth/index.browser.ts +13 -0
- package/src/react/auth/index.shared.ts +2 -0
- package/src/react/auth/index.ts +48 -0
- package/src/react/auth/providers/ReactAuthProvider.ts +16 -0
- package/src/react/auth/services/ReactAuth.ts +135 -0
- package/src/react/core/__tests__/Router.spec.tsx +169 -0
- package/src/react/core/components/ClientOnly.tsx +49 -0
- package/src/react/core/components/ErrorBoundary.tsx +73 -0
- package/src/react/core/contexts/AlephaContext.ts +7 -0
- package/src/react/core/contexts/AlephaProvider.tsx +42 -0
- package/src/react/core/hooks/useAction.browser.spec.tsx +569 -0
- package/src/react/core/hooks/useAction.ts +480 -0
- package/src/react/core/hooks/useAlepha.ts +26 -0
- package/src/react/core/hooks/useClient.ts +17 -0
- package/src/react/core/hooks/useEvents.ts +51 -0
- package/src/react/core/hooks/useInject.ts +12 -0
- package/src/react/core/hooks/useStore.ts +52 -0
- package/src/react/core/index.ts +90 -0
- package/src/react/form/components/FormState.tsx +17 -0
- package/src/react/form/errors/FormValidationError.ts +18 -0
- package/src/react/form/hooks/useForm.browser.spec.tsx +366 -0
- package/src/react/form/hooks/useForm.ts +47 -0
- package/src/react/form/hooks/useFormState.ts +130 -0
- package/src/react/form/index.ts +44 -0
- package/src/react/form/services/FormModel.ts +614 -0
- package/src/react/head/helpers/SeoExpander.spec.ts +203 -0
- package/src/react/head/helpers/SeoExpander.ts +142 -0
- package/src/react/head/hooks/useHead.spec.tsx +288 -0
- package/src/react/head/hooks/useHead.ts +62 -0
- package/src/react/head/index.browser.ts +26 -0
- package/src/react/head/index.ts +44 -0
- package/src/react/head/interfaces/Head.ts +105 -0
- package/src/react/head/primitives/$head.ts +25 -0
- package/src/react/head/providers/BrowserHeadProvider.browser.spec.ts +196 -0
- package/src/react/head/providers/BrowserHeadProvider.ts +212 -0
- package/src/react/head/providers/HeadProvider.ts +168 -0
- package/src/react/head/providers/ServerHeadProvider.ts +31 -0
- package/src/react/i18n/__tests__/integration.spec.tsx +239 -0
- package/src/react/i18n/components/Localize.spec.tsx +357 -0
- package/src/react/i18n/components/Localize.tsx +35 -0
- package/src/react/i18n/hooks/useI18n.browser.spec.tsx +438 -0
- package/src/react/i18n/hooks/useI18n.ts +18 -0
- package/src/react/i18n/index.ts +41 -0
- package/src/react/i18n/primitives/$dictionary.ts +69 -0
- package/src/react/i18n/providers/I18nProvider.spec.ts +389 -0
- package/src/react/i18n/providers/I18nProvider.ts +278 -0
- package/src/react/router/__tests__/page-head-browser.browser.spec.ts +95 -0
- package/src/react/router/__tests__/page-head.spec.ts +48 -0
- package/src/react/router/__tests__/seo-head.spec.ts +125 -0
- package/src/react/router/atoms/ssrManifestAtom.ts +58 -0
- package/src/react/router/components/ErrorViewer.tsx +872 -0
- package/src/react/router/components/Link.tsx +23 -0
- package/src/react/router/components/NestedView.tsx +223 -0
- package/src/react/router/components/NotFound.tsx +30 -0
- package/src/react/router/constants/PAGE_PRELOAD_KEY.ts +6 -0
- package/src/react/router/contexts/RouterLayerContext.ts +12 -0
- package/src/react/router/errors/Redirection.ts +28 -0
- package/src/react/router/hooks/useActive.ts +52 -0
- package/src/react/router/hooks/useQueryParams.ts +63 -0
- package/src/react/router/hooks/useRouter.ts +20 -0
- package/src/react/router/hooks/useRouterState.ts +11 -0
- package/src/react/router/index.browser.ts +45 -0
- package/src/react/router/index.shared.ts +19 -0
- package/src/react/router/index.ts +146 -0
- package/src/react/router/primitives/$page.browser.spec.tsx +851 -0
- package/src/react/router/primitives/$page.spec.tsx +676 -0
- package/src/react/router/primitives/$page.ts +489 -0
- package/src/react/router/providers/ReactBrowserProvider.ts +312 -0
- package/src/react/router/providers/ReactBrowserRendererProvider.ts +25 -0
- package/src/react/router/providers/ReactBrowserRouterProvider.ts +168 -0
- package/src/react/router/providers/ReactPageProvider.ts +726 -0
- package/src/react/router/providers/ReactPreloadProvider.spec.ts +142 -0
- package/src/react/router/providers/ReactPreloadProvider.ts +85 -0
- package/src/react/router/providers/ReactServerProvider.spec.tsx +316 -0
- package/src/react/router/providers/ReactServerProvider.ts +487 -0
- package/src/react/router/providers/ReactServerTemplateProvider.spec.ts +210 -0
- package/src/react/router/providers/ReactServerTemplateProvider.ts +542 -0
- package/src/react/router/providers/SSRManifestProvider.ts +334 -0
- package/src/react/router/services/ReactPageServerService.ts +48 -0
- package/src/react/router/services/ReactPageService.ts +27 -0
- package/src/react/router/services/ReactRouter.ts +262 -0
- package/src/react/websocket/hooks/useRoom.tsx +242 -0
- package/src/react/websocket/index.ts +7 -0
- package/src/redis/__tests__/redis.spec.ts +13 -0
- package/src/redis/index.ts +9 -25
- package/src/redis/providers/BunRedisProvider.ts +9 -0
- package/src/redis/providers/NodeRedisProvider.ts +8 -0
- package/src/redis/providers/RedisProvider.ts +16 -0
- package/src/retry/index.ts +11 -2
- package/src/router/index.ts +15 -0
- package/src/scheduler/index.ts +11 -2
- package/src/security/__tests__/BasicAuth.spec.ts +2 -0
- package/src/security/__tests__/ServerSecurityProvider.spec.ts +90 -5
- package/src/security/index.ts +15 -10
- package/src/security/interfaces/IssuerResolver.ts +27 -0
- package/src/security/primitives/$issuer.ts +55 -0
- package/src/security/providers/SecurityProvider.ts +179 -0
- package/src/security/providers/ServerBasicAuthProvider.ts +6 -2
- package/src/security/providers/ServerSecurityProvider.ts +63 -41
- package/src/server/auth/index.ts +12 -7
- package/src/server/cache/index.ts +7 -22
- package/src/server/compress/index.ts +10 -2
- package/src/server/cookies/index.ts +7 -5
- package/src/server/cookies/primitives/$cookie.ts +33 -11
- package/src/server/core/index.ts +16 -6
- package/src/server/core/interfaces/ServerRequest.ts +83 -1
- package/src/server/core/primitives/$action.spec.ts +1 -1
- package/src/server/core/primitives/$action.ts +8 -3
- package/src/server/core/providers/NodeHttpServerProvider.spec.ts +9 -3
- package/src/server/core/providers/NodeHttpServerProvider.ts +9 -3
- package/src/server/core/services/ServerRequestParser.spec.ts +520 -0
- package/src/server/core/services/ServerRequestParser.ts +306 -13
- package/src/server/cors/index.ts +7 -21
- package/src/server/cors/primitives/$cors.ts +6 -2
- package/src/server/health/index.ts +8 -2
- package/src/server/helmet/index.ts +11 -3
- package/src/server/links/index.ts +11 -6
- package/src/server/metrics/index.ts +10 -3
- package/src/server/multipart/index.ts +9 -3
- package/src/server/proxy/index.ts +8 -2
- package/src/server/rate-limit/index.ts +21 -25
- package/src/server/rate-limit/primitives/$rateLimit.ts +6 -2
- package/src/server/rate-limit/providers/ServerRateLimitProvider.spec.ts +38 -14
- package/src/server/rate-limit/providers/ServerRateLimitProvider.ts +22 -56
- package/src/server/static/index.ts +8 -2
- package/src/server/static/providers/ServerStaticProvider.ts +1 -1
- package/src/server/swagger/index.ts +9 -4
- package/src/server/swagger/providers/ServerSwaggerProvider.ts +1 -1
- package/src/sms/index.ts +9 -5
- package/src/sms/providers/LocalSmsProvider.spec.ts +1 -1
- package/src/sms/providers/LocalSmsProvider.ts +1 -1
- package/src/system/index.browser.ts +36 -0
- package/src/system/index.ts +62 -0
- package/src/system/index.workerd.ts +1 -0
- package/src/{file → system}/providers/FileSystemProvider.ts +24 -0
- package/src/{file → system}/providers/MemoryFileSystemProvider.ts +116 -3
- package/src/system/providers/MemoryShellProvider.ts +164 -0
- package/src/{file → system}/providers/NodeFileSystemProvider.spec.ts +2 -2
- package/src/{file → system}/providers/NodeFileSystemProvider.ts +47 -2
- package/src/system/providers/NodeShellProvider.ts +184 -0
- package/src/system/providers/ShellProvider.ts +74 -0
- package/src/{file → system}/services/FileDetector.spec.ts +2 -2
- package/src/thread/index.ts +11 -2
- package/src/topic/core/index.ts +12 -5
- package/src/vite/tasks/buildClient.ts +2 -7
- package/src/vite/tasks/buildServer.ts +19 -13
- package/src/vite/tasks/generateCloudflare.ts +10 -7
- package/src/vite/tasks/generateDocker.ts +4 -0
- package/src/websocket/index.ts +12 -8
- package/dist/file/index.d.ts.map +0 -1
- package/dist/file/index.js.map +0 -1
- package/src/cli/assets/apiIndexTs.ts +0 -16
- package/src/cli/assets/mainServerTs.ts +0 -24
- package/src/cli/assets/webAppRouterTs.ts +0 -16
- package/src/cli/assets/webHelloComponentTsx.ts +0 -20
- package/src/cli/providers/ViteTemplateProvider.ts +0 -27
- package/src/file/index.ts +0 -43
- /package/src/cli/{assets → templates}/apiHelloControllerTs.ts +0 -0
- /package/src/cli/{assets → templates}/biomeJson.ts +0 -0
- /package/src/cli/{assets → templates}/dummySpecTs.ts +0 -0
- /package/src/cli/{assets → templates}/editorconfig.ts +0 -0
- /package/src/cli/{assets → templates}/mainBrowserTs.ts +0 -0
- /package/src/cli/{assets → templates}/tsconfigJson.ts +0 -0
- /package/src/cli/{assets → templates}/webIndexTs.ts +0 -0
- /package/src/{file → system}/errors/FileError.ts +0 -0
- /package/src/{file → system}/services/FileDetector.ts +0 -0
|
@@ -0,0 +1,4722 @@
|
|
|
1
|
+
import { $atom, $env, $hook, $inject, $module, $use, Alepha, AlephaError, Json, KIND, Primitive, createPrimitive, isFileLike, t } from "alepha";
|
|
2
|
+
import { AlephaDateTime, DateTimeProvider } from "alepha/datetime";
|
|
3
|
+
import { AlephaContext, AlephaReact, ClientOnly, ErrorBoundary, useAlepha, useEvents, useInject, useStore } from "alepha/react";
|
|
4
|
+
import { AlephaServer, ServerProvider, ServerRouterProvider } from "alepha/server";
|
|
5
|
+
import { AlephaServerCache } from "alepha/server/cache";
|
|
6
|
+
import { AlephaServerLinks, LinkProvider, ServerLinksProvider } from "alepha/server/links";
|
|
7
|
+
import { $logger } from "alepha/logger";
|
|
8
|
+
import { StrictMode, createContext, createElement, memo, use, useEffect, useRef, useState } from "react";
|
|
9
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { BrowserHeadProvider, ServerHeadProvider } from "alepha/react/head";
|
|
12
|
+
import { ServerStaticProvider } from "alepha/server/static";
|
|
13
|
+
import { createReadStream } from "node:fs";
|
|
14
|
+
import { access, copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
15
|
+
import { PassThrough, Readable } from "node:stream";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { exec, spawn } from "node:child_process";
|
|
18
|
+
import { renderToReadableStream, renderToString } from "react-dom/server";
|
|
19
|
+
import { RouterProvider } from "alepha/router";
|
|
20
|
+
|
|
21
|
+
//#region ../../src/react/router/constants/PAGE_PRELOAD_KEY.ts
|
|
22
|
+
/**
|
|
23
|
+
* Symbol key for SSR module preloading path.
|
|
24
|
+
* Using Symbol.for() allows the Vite plugin to inject this at build time.
|
|
25
|
+
* @internal
|
|
26
|
+
*/
|
|
27
|
+
const PAGE_PRELOAD_KEY = Symbol.for("alepha.page.preload");
|
|
28
|
+
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region ../../src/react/router/services/ReactPageService.ts
|
|
31
|
+
/**
|
|
32
|
+
* $page methods interface.
|
|
33
|
+
*/
|
|
34
|
+
var ReactPageService = class {
|
|
35
|
+
fetch(pathname, options = {}) {
|
|
36
|
+
throw new AlephaError("Fetch is not available for this environment.");
|
|
37
|
+
}
|
|
38
|
+
render(name, options = {}) {
|
|
39
|
+
throw new AlephaError("Render is not available for this environment.");
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region ../../src/react/router/primitives/$page.ts
|
|
45
|
+
/**
|
|
46
|
+
* Main primitive for defining a React route in the application.
|
|
47
|
+
*
|
|
48
|
+
* The $page primitive is the core building block for creating type-safe, SSR-enabled React routes.
|
|
49
|
+
* It provides a declarative way to define pages with powerful features:
|
|
50
|
+
*
|
|
51
|
+
* **Routing & Navigation**
|
|
52
|
+
* - URL pattern matching with parameters (e.g., `/users/:id`)
|
|
53
|
+
* - Nested routing with parent-child relationships
|
|
54
|
+
* - Type-safe URL parameter and query string validation
|
|
55
|
+
*
|
|
56
|
+
* **Data Loading**
|
|
57
|
+
* - Server-side data fetching with the `loader` function
|
|
58
|
+
* - Automatic serialization and hydration for SSR
|
|
59
|
+
* - Access to request context, URL params, and parent data
|
|
60
|
+
*
|
|
61
|
+
* **Component Loading**
|
|
62
|
+
* - Direct component rendering or lazy loading for code splitting
|
|
63
|
+
* - Client-only rendering when browser APIs are needed
|
|
64
|
+
* - Automatic fallback handling during hydration
|
|
65
|
+
*
|
|
66
|
+
* **Performance Optimization**
|
|
67
|
+
* - Static generation for pre-rendered pages at build time
|
|
68
|
+
* - Server-side caching with configurable TTL and providers
|
|
69
|
+
* - Code splitting through lazy component loading
|
|
70
|
+
*
|
|
71
|
+
* **Error Handling**
|
|
72
|
+
* - Custom error handlers with support for redirects
|
|
73
|
+
* - Hierarchical error handling (child → parent)
|
|
74
|
+
* - HTTP status code handling (404, 401, etc.)
|
|
75
|
+
*
|
|
76
|
+
* **Page Animations**
|
|
77
|
+
* - CSS-based enter/exit animations
|
|
78
|
+
* - Dynamic animations based on page state
|
|
79
|
+
* - Custom timing and easing functions
|
|
80
|
+
*
|
|
81
|
+
* **Lifecycle Management**
|
|
82
|
+
* - Server response hooks for headers and status codes
|
|
83
|
+
* - Page leave handlers for cleanup (browser only)
|
|
84
|
+
* - Permission-based access control
|
|
85
|
+
*
|
|
86
|
+
* @example Simple page with data fetching
|
|
87
|
+
* ```typescript
|
|
88
|
+
* const userProfile = $page({
|
|
89
|
+
* path: "/users/:id",
|
|
90
|
+
* schema: {
|
|
91
|
+
* params: t.object({ id: t.integer() }),
|
|
92
|
+
* query: t.object({ tab: t.optional(t.text()) })
|
|
93
|
+
* },
|
|
94
|
+
* loader: async ({ params }) => {
|
|
95
|
+
* const user = await userApi.getUser(params.id);
|
|
96
|
+
* return { user };
|
|
97
|
+
* },
|
|
98
|
+
* lazy: () => import("./UserProfile.tsx")
|
|
99
|
+
* });
|
|
100
|
+
* ```
|
|
101
|
+
*
|
|
102
|
+
* @example Nested routing with error handling
|
|
103
|
+
* ```typescript
|
|
104
|
+
* const projectSection = $page({
|
|
105
|
+
* path: "/projects/:id",
|
|
106
|
+
* children: () => [projectBoard, projectSettings],
|
|
107
|
+
* loader: async ({ params }) => {
|
|
108
|
+
* const project = await projectApi.get(params.id);
|
|
109
|
+
* return { project };
|
|
110
|
+
* },
|
|
111
|
+
* errorHandler: (error) => {
|
|
112
|
+
* if (HttpError.is(error, 404)) {
|
|
113
|
+
* return <ProjectNotFound />;
|
|
114
|
+
* }
|
|
115
|
+
* }
|
|
116
|
+
* });
|
|
117
|
+
* ```
|
|
118
|
+
*
|
|
119
|
+
* @example Static generation with caching
|
|
120
|
+
* ```typescript
|
|
121
|
+
* const blogPost = $page({
|
|
122
|
+
* path: "/blog/:slug",
|
|
123
|
+
* static: {
|
|
124
|
+
* entries: posts.map(p => ({ params: { slug: p.slug } }))
|
|
125
|
+
* },
|
|
126
|
+
* loader: async ({ params }) => {
|
|
127
|
+
* const post = await loadPost(params.slug);
|
|
128
|
+
* return { post };
|
|
129
|
+
* }
|
|
130
|
+
* });
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
const $page = (options) => {
|
|
134
|
+
return createPrimitive(PagePrimitive, options);
|
|
135
|
+
};
|
|
136
|
+
var PagePrimitive = class extends Primitive {
|
|
137
|
+
reactPageService = $inject(ReactPageService);
|
|
138
|
+
onInit() {
|
|
139
|
+
if (this.options.static) this.options.cache ??= { store: {
|
|
140
|
+
provider: "memory",
|
|
141
|
+
ttl: [1, "week"]
|
|
142
|
+
} };
|
|
143
|
+
}
|
|
144
|
+
get name() {
|
|
145
|
+
return this.options.name ?? this.config.propertyKey;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* For testing or build purposes.
|
|
149
|
+
*
|
|
150
|
+
* This will render the page (HTML layout included or not) and return the HTML + context.
|
|
151
|
+
* Only valid for server-side rendering, it will throw an error if called on the client-side.
|
|
152
|
+
*/
|
|
153
|
+
async render(options) {
|
|
154
|
+
return this.reactPageService.render(this.name, options);
|
|
155
|
+
}
|
|
156
|
+
async fetch(options) {
|
|
157
|
+
return this.reactPageService.fetch(this.options.path || "", options);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
$page[KIND] = PagePrimitive;
|
|
161
|
+
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region ../../src/react/router/components/ErrorViewer.tsx
|
|
164
|
+
const isBrowser = typeof window !== "undefined";
|
|
165
|
+
/**
|
|
166
|
+
* Error viewer component - Terminal/brutalist aesthetic
|
|
167
|
+
*/
|
|
168
|
+
const ErrorViewer = ({ error, alepha }) => {
|
|
169
|
+
const [expanded, setExpanded] = useState(false);
|
|
170
|
+
const [showNodeModules, setShowNodeModules] = useState(false);
|
|
171
|
+
const [visible, setVisible] = useState(false);
|
|
172
|
+
const containerRef = useRef(null);
|
|
173
|
+
const isProduction = alepha.isProduction();
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
const timer = setTimeout(() => setVisible(true), 10);
|
|
176
|
+
return () => clearTimeout(timer);
|
|
177
|
+
}, []);
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (!isBrowser) return;
|
|
180
|
+
const handler = (e) => {
|
|
181
|
+
if (e.key === "c" && !e.metaKey && !e.ctrlKey) copyToClipboard(error.stack || error.message);
|
|
182
|
+
};
|
|
183
|
+
window.addEventListener("keydown", handler);
|
|
184
|
+
return () => window.removeEventListener("keydown", handler);
|
|
185
|
+
}, [error]);
|
|
186
|
+
if (isProduction) return /* @__PURE__ */ jsx(ErrorViewerProduction, {});
|
|
187
|
+
const frames = parseStackTrace(error.stack);
|
|
188
|
+
const appFrames = frames.filter((f) => !f.isNodeModules);
|
|
189
|
+
const nodeModulesFrames = frames.filter((f) => f.isNodeModules);
|
|
190
|
+
const visibleAppFrames = expanded ? appFrames : appFrames.slice(0, 5);
|
|
191
|
+
const hiddenAppCount = appFrames.length - 5;
|
|
192
|
+
const timestamp = (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", {
|
|
193
|
+
hour12: false,
|
|
194
|
+
hour: "2-digit",
|
|
195
|
+
minute: "2-digit",
|
|
196
|
+
second: "2-digit"
|
|
197
|
+
});
|
|
198
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
199
|
+
ref: containerRef,
|
|
200
|
+
style: {
|
|
201
|
+
...styles.overlay,
|
|
202
|
+
opacity: visible ? 1 : 0
|
|
203
|
+
},
|
|
204
|
+
role: "alertdialog",
|
|
205
|
+
"aria-modal": "true",
|
|
206
|
+
"aria-labelledby": "error-viewer-title",
|
|
207
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
208
|
+
style: styles.scanlines,
|
|
209
|
+
"aria-hidden": "true"
|
|
210
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
211
|
+
style: {
|
|
212
|
+
...styles.container,
|
|
213
|
+
transform: visible ? "translateY(0)" : "translateY(-20px)",
|
|
214
|
+
opacity: visible ? 1 : 0
|
|
215
|
+
},
|
|
216
|
+
children: [
|
|
217
|
+
/* @__PURE__ */ jsxs("div", {
|
|
218
|
+
style: styles.terminalBar,
|
|
219
|
+
children: [
|
|
220
|
+
/* @__PURE__ */ jsxs("div", {
|
|
221
|
+
style: styles.terminalDots,
|
|
222
|
+
children: [
|
|
223
|
+
/* @__PURE__ */ jsx("span", { style: {
|
|
224
|
+
...styles.dot,
|
|
225
|
+
backgroundColor: "#ff5f57"
|
|
226
|
+
} }),
|
|
227
|
+
/* @__PURE__ */ jsx("span", { style: {
|
|
228
|
+
...styles.dot,
|
|
229
|
+
backgroundColor: "#febc2e"
|
|
230
|
+
} }),
|
|
231
|
+
/* @__PURE__ */ jsx("span", { style: {
|
|
232
|
+
...styles.dot,
|
|
233
|
+
backgroundColor: "#28c840"
|
|
234
|
+
} })
|
|
235
|
+
]
|
|
236
|
+
}),
|
|
237
|
+
/* @__PURE__ */ jsx("div", {
|
|
238
|
+
style: styles.terminalTitle,
|
|
239
|
+
children: /* @__PURE__ */ jsxs("span", {
|
|
240
|
+
style: styles.terminalTitleText,
|
|
241
|
+
children: ["error — ", timestamp]
|
|
242
|
+
})
|
|
243
|
+
}),
|
|
244
|
+
/* @__PURE__ */ jsxs("div", {
|
|
245
|
+
style: styles.terminalActions,
|
|
246
|
+
children: [/* @__PURE__ */ jsx("kbd", {
|
|
247
|
+
style: styles.kbd,
|
|
248
|
+
children: "C"
|
|
249
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
250
|
+
style: styles.kbdLabel,
|
|
251
|
+
children: "copy"
|
|
252
|
+
})]
|
|
253
|
+
})
|
|
254
|
+
]
|
|
255
|
+
}),
|
|
256
|
+
/* @__PURE__ */ jsx(Header, { error }),
|
|
257
|
+
/* @__PURE__ */ jsxs("div", {
|
|
258
|
+
style: styles.stackSection,
|
|
259
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
260
|
+
style: styles.stackHeader,
|
|
261
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
262
|
+
style: styles.stackHeaderText,
|
|
263
|
+
children: "STACK TRACE"
|
|
264
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
265
|
+
style: styles.stackCount,
|
|
266
|
+
children: [
|
|
267
|
+
appFrames.length,
|
|
268
|
+
" frames",
|
|
269
|
+
nodeModulesFrames.length > 0 && ` · ${nodeModulesFrames.length} in node_modules`
|
|
270
|
+
]
|
|
271
|
+
})]
|
|
272
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
273
|
+
style: styles.frameList,
|
|
274
|
+
children: [
|
|
275
|
+
visibleAppFrames.map((frame, i) => /* @__PURE__ */ jsx(StackFrameRow, {
|
|
276
|
+
frame,
|
|
277
|
+
index: i
|
|
278
|
+
}, `${frame.raw}-${i}`)),
|
|
279
|
+
hiddenAppCount > 0 && !expanded && /* @__PURE__ */ jsx(ExpandButton, {
|
|
280
|
+
onClick: () => setExpanded(true),
|
|
281
|
+
label: `Show ${hiddenAppCount} more frames`
|
|
282
|
+
}),
|
|
283
|
+
expanded && hiddenAppCount > 0 && /* @__PURE__ */ jsx(ExpandButton, {
|
|
284
|
+
onClick: () => setExpanded(false),
|
|
285
|
+
label: "Collapse"
|
|
286
|
+
}),
|
|
287
|
+
nodeModulesFrames.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("button", {
|
|
288
|
+
type: "button",
|
|
289
|
+
onClick: () => setShowNodeModules(!showNodeModules),
|
|
290
|
+
style: styles.nodeModulesToggle,
|
|
291
|
+
children: [
|
|
292
|
+
/* @__PURE__ */ jsx("span", {
|
|
293
|
+
style: styles.nodeModulesIcon,
|
|
294
|
+
children: showNodeModules ? "▼" : "▶"
|
|
295
|
+
}),
|
|
296
|
+
/* @__PURE__ */ jsx("span", {
|
|
297
|
+
style: styles.nodeModulesLabel,
|
|
298
|
+
children: "node_modules"
|
|
299
|
+
}),
|
|
300
|
+
/* @__PURE__ */ jsx("span", {
|
|
301
|
+
style: styles.nodeModulesCount,
|
|
302
|
+
children: nodeModulesFrames.length
|
|
303
|
+
})
|
|
304
|
+
]
|
|
305
|
+
}), showNodeModules && /* @__PURE__ */ jsx("div", {
|
|
306
|
+
style: styles.nodeModulesFrames,
|
|
307
|
+
children: nodeModulesFrames.map((frame, i) => /* @__PURE__ */ jsx(StackFrameRow, {
|
|
308
|
+
frame,
|
|
309
|
+
index: appFrames.length + i,
|
|
310
|
+
dimmed: true
|
|
311
|
+
}, `nm-${frame.raw}-${i}`))
|
|
312
|
+
})] })
|
|
313
|
+
]
|
|
314
|
+
})]
|
|
315
|
+
}),
|
|
316
|
+
/* @__PURE__ */ jsx("div", {
|
|
317
|
+
style: styles.footer,
|
|
318
|
+
children: /* @__PURE__ */ jsxs("span", {
|
|
319
|
+
style: styles.footerText,
|
|
320
|
+
children: [
|
|
321
|
+
"Press ",
|
|
322
|
+
/* @__PURE__ */ jsx("kbd", {
|
|
323
|
+
style: styles.kbdInline,
|
|
324
|
+
children: "C"
|
|
325
|
+
}),
|
|
326
|
+
" to copy stack trace"
|
|
327
|
+
]
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
]
|
|
331
|
+
})]
|
|
332
|
+
});
|
|
333
|
+
};
|
|
334
|
+
var ErrorViewer_default = ErrorViewer;
|
|
335
|
+
function parseStackTrace(stack) {
|
|
336
|
+
if (!stack) return [];
|
|
337
|
+
const lines = stack.split("\n").slice(1);
|
|
338
|
+
const frames = [];
|
|
339
|
+
for (const line of lines) {
|
|
340
|
+
const trimmed = line.trim();
|
|
341
|
+
if (!trimmed.startsWith("at ")) continue;
|
|
342
|
+
const frame = parseStackLine(trimmed);
|
|
343
|
+
if (frame) frames.push(frame);
|
|
344
|
+
}
|
|
345
|
+
return frames;
|
|
346
|
+
}
|
|
347
|
+
function parseStackLine(line) {
|
|
348
|
+
const isNodeModules = line.includes("node_modules") || line.includes("node:");
|
|
349
|
+
const withFn = line.match(/^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/);
|
|
350
|
+
if (withFn) return {
|
|
351
|
+
fn: withFn[1],
|
|
352
|
+
file: withFn[2],
|
|
353
|
+
line: withFn[3],
|
|
354
|
+
col: withFn[4],
|
|
355
|
+
raw: line,
|
|
356
|
+
isNodeModules
|
|
357
|
+
};
|
|
358
|
+
const withoutFn = line.match(/^at\s+(.+):(\d+):(\d+)$/);
|
|
359
|
+
if (withoutFn) return {
|
|
360
|
+
fn: "<anonymous>",
|
|
361
|
+
file: withoutFn[1],
|
|
362
|
+
line: withoutFn[2],
|
|
363
|
+
col: withoutFn[3],
|
|
364
|
+
raw: line,
|
|
365
|
+
isNodeModules
|
|
366
|
+
};
|
|
367
|
+
return {
|
|
368
|
+
fn: "",
|
|
369
|
+
file: line.replace(/^at\s+/, ""),
|
|
370
|
+
line: "",
|
|
371
|
+
col: "",
|
|
372
|
+
raw: line,
|
|
373
|
+
isNodeModules
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
function copyToClipboard(text) {
|
|
377
|
+
if (!isBrowser || !navigator.clipboard) return Promise.resolve(false);
|
|
378
|
+
return navigator.clipboard.writeText(text).then(() => true).catch(() => false);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Header with error badge and message
|
|
382
|
+
*/
|
|
383
|
+
function Header({ error }) {
|
|
384
|
+
const [copied, setCopied] = useState(false);
|
|
385
|
+
const [hovered, setHovered] = useState(false);
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
if (!copied) return;
|
|
388
|
+
const timer = setTimeout(() => setCopied(false), 2e3);
|
|
389
|
+
return () => clearTimeout(timer);
|
|
390
|
+
}, [copied]);
|
|
391
|
+
const handleCopy = async () => {
|
|
392
|
+
if (await copyToClipboard(error.stack || error.message)) setCopied(true);
|
|
393
|
+
};
|
|
394
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
395
|
+
style: styles.header,
|
|
396
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
397
|
+
style: styles.headerRow,
|
|
398
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
399
|
+
style: styles.errorIndicator,
|
|
400
|
+
children: [/* @__PURE__ */ jsx("div", { style: styles.errorGlow }), /* @__PURE__ */ jsx("div", {
|
|
401
|
+
style: styles.errorBadge,
|
|
402
|
+
children: error.name
|
|
403
|
+
})]
|
|
404
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
405
|
+
type: "button",
|
|
406
|
+
onClick: handleCopy,
|
|
407
|
+
onMouseEnter: () => setHovered(true),
|
|
408
|
+
onMouseLeave: () => setHovered(false),
|
|
409
|
+
style: {
|
|
410
|
+
...styles.copyBtn,
|
|
411
|
+
...hovered ? styles.copyBtnHover : {}
|
|
412
|
+
},
|
|
413
|
+
children: copied ? "✓ Copied" : "Copy"
|
|
414
|
+
})]
|
|
415
|
+
}), /* @__PURE__ */ jsx("h1", {
|
|
416
|
+
id: "error-viewer-title",
|
|
417
|
+
style: styles.message,
|
|
418
|
+
children: error.message
|
|
419
|
+
})]
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Single stack frame row
|
|
424
|
+
*/
|
|
425
|
+
function StackFrameRow({ frame, index, dimmed = false }) {
|
|
426
|
+
const [hovered, setHovered] = useState(false);
|
|
427
|
+
const isFirst = index === 0 && !dimmed;
|
|
428
|
+
const fileName = frame.file.split("/").pop() || frame.file;
|
|
429
|
+
const dirPath = frame.file.substring(0, frame.file.length - fileName.length);
|
|
430
|
+
const vsCodeLink = frame.file && frame.line ? `vscode://file${frame.file}:${frame.line}:${frame.col || 1}` : null;
|
|
431
|
+
const content = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
|
|
432
|
+
style: {
|
|
433
|
+
...styles.frameIndex,
|
|
434
|
+
color: isFirst ? "#ff6b6b" : dimmed ? "#555" : "#666"
|
|
435
|
+
},
|
|
436
|
+
children: String(index + 1).padStart(2, "0")
|
|
437
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
438
|
+
style: styles.frameContent,
|
|
439
|
+
children: [frame.fn && /* @__PURE__ */ jsx("div", {
|
|
440
|
+
style: {
|
|
441
|
+
...styles.fnName,
|
|
442
|
+
color: dimmed ? "#888" : "#f0f0f0"
|
|
443
|
+
},
|
|
444
|
+
children: formatFunctionName(frame.fn)
|
|
445
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
446
|
+
style: styles.filePath,
|
|
447
|
+
children: [
|
|
448
|
+
/* @__PURE__ */ jsx("span", {
|
|
449
|
+
style: {
|
|
450
|
+
...styles.dirPath,
|
|
451
|
+
opacity: dimmed ? .6 : .8
|
|
452
|
+
},
|
|
453
|
+
children: dirPath
|
|
454
|
+
}),
|
|
455
|
+
/* @__PURE__ */ jsx("span", {
|
|
456
|
+
style: {
|
|
457
|
+
...styles.fileName,
|
|
458
|
+
color: dimmed ? "#5a9aba" : "#7cc4eb"
|
|
459
|
+
},
|
|
460
|
+
children: fileName
|
|
461
|
+
}),
|
|
462
|
+
frame.line && /* @__PURE__ */ jsxs("span", {
|
|
463
|
+
style: {
|
|
464
|
+
...styles.lineCol,
|
|
465
|
+
color: dimmed ? "#9a8a40" : "#e5b83a"
|
|
466
|
+
},
|
|
467
|
+
children: [
|
|
468
|
+
":",
|
|
469
|
+
frame.line,
|
|
470
|
+
frame.col && `:${frame.col}`
|
|
471
|
+
]
|
|
472
|
+
})
|
|
473
|
+
]
|
|
474
|
+
})]
|
|
475
|
+
})] });
|
|
476
|
+
const rowStyles = {
|
|
477
|
+
...styles.frame,
|
|
478
|
+
...isFirst ? styles.frameFirst : {},
|
|
479
|
+
backgroundColor: hovered ? "rgba(255,255,255,0.03)" : "transparent"
|
|
480
|
+
};
|
|
481
|
+
if (vsCodeLink && isBrowser) return /* @__PURE__ */ jsx("a", {
|
|
482
|
+
href: vsCodeLink,
|
|
483
|
+
style: {
|
|
484
|
+
...rowStyles,
|
|
485
|
+
textDecoration: "none"
|
|
486
|
+
},
|
|
487
|
+
onMouseEnter: () => setHovered(true),
|
|
488
|
+
onMouseLeave: () => setHovered(false),
|
|
489
|
+
children: content
|
|
490
|
+
});
|
|
491
|
+
return /* @__PURE__ */ jsx("div", {
|
|
492
|
+
style: rowStyles,
|
|
493
|
+
children: content
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Format function name with syntax highlighting
|
|
498
|
+
*/
|
|
499
|
+
function formatFunctionName(fn) {
|
|
500
|
+
const asyncMatch = fn.match(/^(async\s+)?(.+)$/);
|
|
501
|
+
if (asyncMatch?.[1]) return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
|
|
502
|
+
style: { color: "#c678dd" },
|
|
503
|
+
children: "async "
|
|
504
|
+
}), /* @__PURE__ */ jsx("span", { children: asyncMatch[2] })] });
|
|
505
|
+
const methodMatch = fn.match(/^(.+)\.([^.]+)$/);
|
|
506
|
+
if (methodMatch) return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
507
|
+
/* @__PURE__ */ jsx("span", {
|
|
508
|
+
style: { color: "#e5c07b" },
|
|
509
|
+
children: methodMatch[1]
|
|
510
|
+
}),
|
|
511
|
+
/* @__PURE__ */ jsx("span", {
|
|
512
|
+
style: { color: "#666" },
|
|
513
|
+
children: "."
|
|
514
|
+
}),
|
|
515
|
+
/* @__PURE__ */ jsx("span", { children: methodMatch[2] })
|
|
516
|
+
] });
|
|
517
|
+
return fn;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Expand/collapse button
|
|
521
|
+
*/
|
|
522
|
+
function ExpandButton({ onClick, label }) {
|
|
523
|
+
const [hovered, setHovered] = useState(false);
|
|
524
|
+
return /* @__PURE__ */ jsx("button", {
|
|
525
|
+
type: "button",
|
|
526
|
+
onClick,
|
|
527
|
+
onMouseEnter: () => setHovered(true),
|
|
528
|
+
onMouseLeave: () => setHovered(false),
|
|
529
|
+
style: {
|
|
530
|
+
...styles.expandBtn,
|
|
531
|
+
backgroundColor: hovered ? "rgba(255,255,255,0.05)" : "transparent",
|
|
532
|
+
color: hovered ? "#aaa" : "#777"
|
|
533
|
+
},
|
|
534
|
+
children: label
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Production error view - minimal, user-friendly
|
|
539
|
+
*/
|
|
540
|
+
function ErrorViewerProduction() {
|
|
541
|
+
const [hovered, setHovered] = useState(false);
|
|
542
|
+
const handleReload = () => {
|
|
543
|
+
if (isBrowser) window.location.reload();
|
|
544
|
+
};
|
|
545
|
+
return /* @__PURE__ */ jsx("div", {
|
|
546
|
+
style: styles.overlay,
|
|
547
|
+
role: "alertdialog",
|
|
548
|
+
"aria-modal": "true",
|
|
549
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
550
|
+
style: styles.prodContainer,
|
|
551
|
+
children: [
|
|
552
|
+
/* @__PURE__ */ jsx("div", {
|
|
553
|
+
style: styles.prodIcon,
|
|
554
|
+
children: /* @__PURE__ */ jsxs("svg", {
|
|
555
|
+
width: "32",
|
|
556
|
+
height: "32",
|
|
557
|
+
viewBox: "0 0 24 24",
|
|
558
|
+
fill: "none",
|
|
559
|
+
stroke: "currentColor",
|
|
560
|
+
strokeWidth: "2",
|
|
561
|
+
children: [
|
|
562
|
+
/* @__PURE__ */ jsx("circle", {
|
|
563
|
+
cx: "12",
|
|
564
|
+
cy: "12",
|
|
565
|
+
r: "10"
|
|
566
|
+
}),
|
|
567
|
+
/* @__PURE__ */ jsx("line", {
|
|
568
|
+
x1: "12",
|
|
569
|
+
y1: "8",
|
|
570
|
+
x2: "12",
|
|
571
|
+
y2: "12"
|
|
572
|
+
}),
|
|
573
|
+
/* @__PURE__ */ jsx("line", {
|
|
574
|
+
x1: "12",
|
|
575
|
+
y1: "16",
|
|
576
|
+
x2: "12.01",
|
|
577
|
+
y2: "16"
|
|
578
|
+
})
|
|
579
|
+
]
|
|
580
|
+
})
|
|
581
|
+
}),
|
|
582
|
+
/* @__PURE__ */ jsx("h1", {
|
|
583
|
+
style: styles.prodTitle,
|
|
584
|
+
children: "Something went wrong"
|
|
585
|
+
}),
|
|
586
|
+
/* @__PURE__ */ jsx("p", {
|
|
587
|
+
style: styles.prodMessage,
|
|
588
|
+
children: "We encountered an unexpected error. Please try again."
|
|
589
|
+
}),
|
|
590
|
+
/* @__PURE__ */ jsx("button", {
|
|
591
|
+
type: "button",
|
|
592
|
+
onClick: handleReload,
|
|
593
|
+
onMouseEnter: () => setHovered(true),
|
|
594
|
+
onMouseLeave: () => setHovered(false),
|
|
595
|
+
style: {
|
|
596
|
+
...styles.prodButton,
|
|
597
|
+
backgroundColor: hovered ? "#333" : "#222",
|
|
598
|
+
borderColor: hovered ? "#555" : "#444"
|
|
599
|
+
},
|
|
600
|
+
children: "Reload page"
|
|
601
|
+
})
|
|
602
|
+
]
|
|
603
|
+
})
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
const MONO_FONT = "ui-monospace, \"JetBrains Mono\", \"Fira Code\", SFMono-Regular, Menlo, Monaco, Consolas, monospace";
|
|
607
|
+
const styles = {
|
|
608
|
+
overlay: {
|
|
609
|
+
position: "fixed",
|
|
610
|
+
inset: 0,
|
|
611
|
+
backgroundColor: "rgba(0, 0, 0, 0.92)",
|
|
612
|
+
display: "flex",
|
|
613
|
+
alignItems: "flex-start",
|
|
614
|
+
justifyContent: "center",
|
|
615
|
+
padding: "40px 20px",
|
|
616
|
+
overflow: "auto",
|
|
617
|
+
fontFamily: MONO_FONT,
|
|
618
|
+
fontSize: "13px",
|
|
619
|
+
zIndex: 99999,
|
|
620
|
+
transition: "opacity 0.2s ease-out"
|
|
621
|
+
},
|
|
622
|
+
scanlines: {
|
|
623
|
+
position: "fixed",
|
|
624
|
+
inset: 0,
|
|
625
|
+
background: "repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.1) 2px, rgba(0,0,0,0.1) 4px)",
|
|
626
|
+
pointerEvents: "none",
|
|
627
|
+
zIndex: 1e5
|
|
628
|
+
},
|
|
629
|
+
container: {
|
|
630
|
+
width: "100%",
|
|
631
|
+
maxWidth: "900px",
|
|
632
|
+
backgroundColor: "#0d0d0d",
|
|
633
|
+
borderRadius: "8px",
|
|
634
|
+
overflow: "hidden",
|
|
635
|
+
boxShadow: "0 0 0 1px #333, 0 25px 80px -12px rgba(0, 0, 0, 0.8)",
|
|
636
|
+
transition: "transform 0.3s ease-out, opacity 0.3s ease-out"
|
|
637
|
+
},
|
|
638
|
+
terminalBar: {
|
|
639
|
+
display: "flex",
|
|
640
|
+
alignItems: "center",
|
|
641
|
+
padding: "12px 16px",
|
|
642
|
+
backgroundColor: "#1a1a1a",
|
|
643
|
+
borderBottom: "1px solid #333"
|
|
644
|
+
},
|
|
645
|
+
terminalDots: {
|
|
646
|
+
display: "flex",
|
|
647
|
+
gap: "8px"
|
|
648
|
+
},
|
|
649
|
+
dot: {
|
|
650
|
+
width: "12px",
|
|
651
|
+
height: "12px",
|
|
652
|
+
borderRadius: "50%"
|
|
653
|
+
},
|
|
654
|
+
terminalTitle: {
|
|
655
|
+
flex: 1,
|
|
656
|
+
textAlign: "center"
|
|
657
|
+
},
|
|
658
|
+
terminalTitleText: {
|
|
659
|
+
color: "#777",
|
|
660
|
+
fontSize: "12px",
|
|
661
|
+
letterSpacing: "0.5px"
|
|
662
|
+
},
|
|
663
|
+
terminalActions: {
|
|
664
|
+
display: "flex",
|
|
665
|
+
alignItems: "center",
|
|
666
|
+
gap: "6px"
|
|
667
|
+
},
|
|
668
|
+
kbd: {
|
|
669
|
+
display: "inline-block",
|
|
670
|
+
padding: "2px 6px",
|
|
671
|
+
backgroundColor: "#2a2a2a",
|
|
672
|
+
borderRadius: "4px",
|
|
673
|
+
fontSize: "11px",
|
|
674
|
+
color: "#aaa",
|
|
675
|
+
border: "1px solid #444"
|
|
676
|
+
},
|
|
677
|
+
kbdInline: {
|
|
678
|
+
display: "inline-block",
|
|
679
|
+
padding: "1px 5px",
|
|
680
|
+
backgroundColor: "#222",
|
|
681
|
+
borderRadius: "3px",
|
|
682
|
+
fontSize: "11px",
|
|
683
|
+
color: "#888",
|
|
684
|
+
border: "1px solid #444",
|
|
685
|
+
marginLeft: "4px",
|
|
686
|
+
marginRight: "4px"
|
|
687
|
+
},
|
|
688
|
+
kbdLabel: {
|
|
689
|
+
color: "#777",
|
|
690
|
+
fontSize: "11px"
|
|
691
|
+
},
|
|
692
|
+
header: {
|
|
693
|
+
padding: "24px",
|
|
694
|
+
borderBottom: "1px solid #333"
|
|
695
|
+
},
|
|
696
|
+
headerRow: {
|
|
697
|
+
display: "flex",
|
|
698
|
+
alignItems: "center",
|
|
699
|
+
justifyContent: "space-between",
|
|
700
|
+
marginBottom: "16px"
|
|
701
|
+
},
|
|
702
|
+
errorIndicator: {
|
|
703
|
+
position: "relative",
|
|
704
|
+
display: "inline-flex"
|
|
705
|
+
},
|
|
706
|
+
errorGlow: {
|
|
707
|
+
position: "absolute",
|
|
708
|
+
inset: "-4px",
|
|
709
|
+
background: "radial-gradient(ellipse at center, rgba(255,80,80,0.3) 0%, transparent 70%)",
|
|
710
|
+
borderRadius: "12px",
|
|
711
|
+
filter: "blur(8px)"
|
|
712
|
+
},
|
|
713
|
+
errorBadge: {
|
|
714
|
+
position: "relative",
|
|
715
|
+
display: "inline-block",
|
|
716
|
+
padding: "6px 14px",
|
|
717
|
+
backgroundColor: "#3d1a1a",
|
|
718
|
+
color: "#ff7b7b",
|
|
719
|
+
fontSize: "12px",
|
|
720
|
+
fontWeight: 600,
|
|
721
|
+
borderRadius: "6px",
|
|
722
|
+
border: "1px solid #5a2828",
|
|
723
|
+
letterSpacing: "0.5px"
|
|
724
|
+
},
|
|
725
|
+
copyBtn: {
|
|
726
|
+
padding: "8px 14px",
|
|
727
|
+
backgroundColor: "transparent",
|
|
728
|
+
color: "#888",
|
|
729
|
+
fontSize: "12px",
|
|
730
|
+
fontWeight: 500,
|
|
731
|
+
borderWidth: "1px",
|
|
732
|
+
borderStyle: "solid",
|
|
733
|
+
borderColor: "#444",
|
|
734
|
+
borderRadius: "6px",
|
|
735
|
+
cursor: "pointer",
|
|
736
|
+
transition: "all 0.15s",
|
|
737
|
+
fontFamily: MONO_FONT
|
|
738
|
+
},
|
|
739
|
+
copyBtnHover: {
|
|
740
|
+
backgroundColor: "#252525",
|
|
741
|
+
color: "#bbb",
|
|
742
|
+
borderColor: "#555"
|
|
743
|
+
},
|
|
744
|
+
message: {
|
|
745
|
+
margin: 0,
|
|
746
|
+
fontSize: "18px",
|
|
747
|
+
fontWeight: 400,
|
|
748
|
+
color: "#e8e8e8",
|
|
749
|
+
lineHeight: 1.6,
|
|
750
|
+
wordBreak: "break-word",
|
|
751
|
+
fontFamily: MONO_FONT
|
|
752
|
+
},
|
|
753
|
+
stackSection: { borderTop: "1px solid #2a2a2a" },
|
|
754
|
+
stackHeader: {
|
|
755
|
+
display: "flex",
|
|
756
|
+
alignItems: "center",
|
|
757
|
+
justifyContent: "space-between",
|
|
758
|
+
padding: "14px 24px",
|
|
759
|
+
borderBottom: "1px solid #2a2a2a"
|
|
760
|
+
},
|
|
761
|
+
stackHeaderText: {
|
|
762
|
+
fontSize: "10px",
|
|
763
|
+
fontWeight: 600,
|
|
764
|
+
color: "#666",
|
|
765
|
+
letterSpacing: "1.5px"
|
|
766
|
+
},
|
|
767
|
+
stackCount: {
|
|
768
|
+
fontSize: "11px",
|
|
769
|
+
color: "#555"
|
|
770
|
+
},
|
|
771
|
+
frameList: {
|
|
772
|
+
display: "flex",
|
|
773
|
+
flexDirection: "column"
|
|
774
|
+
},
|
|
775
|
+
frame: {
|
|
776
|
+
display: "flex",
|
|
777
|
+
alignItems: "flex-start",
|
|
778
|
+
padding: "12px 24px",
|
|
779
|
+
borderBottom: "1px solid #222",
|
|
780
|
+
transition: "background-color 0.1s",
|
|
781
|
+
cursor: "pointer"
|
|
782
|
+
},
|
|
783
|
+
frameFirst: {
|
|
784
|
+
backgroundColor: "rgba(255, 80, 80, 0.08)",
|
|
785
|
+
borderLeft: "2px solid #ff6b6b"
|
|
786
|
+
},
|
|
787
|
+
frameIndex: {
|
|
788
|
+
width: "28px",
|
|
789
|
+
flexShrink: 0,
|
|
790
|
+
fontSize: "11px",
|
|
791
|
+
fontWeight: 500,
|
|
792
|
+
fontFamily: MONO_FONT
|
|
793
|
+
},
|
|
794
|
+
frameContent: {
|
|
795
|
+
flex: 1,
|
|
796
|
+
minWidth: 0
|
|
797
|
+
},
|
|
798
|
+
fnName: {
|
|
799
|
+
fontSize: "13px",
|
|
800
|
+
fontWeight: 500,
|
|
801
|
+
marginBottom: "4px",
|
|
802
|
+
fontFamily: MONO_FONT
|
|
803
|
+
},
|
|
804
|
+
filePath: {
|
|
805
|
+
fontSize: "12px",
|
|
806
|
+
color: "#888",
|
|
807
|
+
fontFamily: MONO_FONT,
|
|
808
|
+
wordBreak: "break-all"
|
|
809
|
+
},
|
|
810
|
+
dirPath: { color: "#666" },
|
|
811
|
+
fileName: { color: "#7cc4eb" },
|
|
812
|
+
lineCol: { color: "#e5b83a" },
|
|
813
|
+
expandBtn: {
|
|
814
|
+
width: "100%",
|
|
815
|
+
padding: "14px 24px",
|
|
816
|
+
backgroundColor: "transparent",
|
|
817
|
+
color: "#777",
|
|
818
|
+
fontSize: "12px",
|
|
819
|
+
fontWeight: 500,
|
|
820
|
+
border: "none",
|
|
821
|
+
borderTop: "1px solid #2a2a2a",
|
|
822
|
+
cursor: "pointer",
|
|
823
|
+
textAlign: "left",
|
|
824
|
+
transition: "all 0.15s",
|
|
825
|
+
fontFamily: MONO_FONT
|
|
826
|
+
},
|
|
827
|
+
nodeModulesToggle: {
|
|
828
|
+
display: "flex",
|
|
829
|
+
alignItems: "center",
|
|
830
|
+
gap: "10px",
|
|
831
|
+
width: "100%",
|
|
832
|
+
padding: "12px 24px",
|
|
833
|
+
backgroundColor: "#0a0a0a",
|
|
834
|
+
color: "#666",
|
|
835
|
+
fontSize: "11px",
|
|
836
|
+
fontWeight: 500,
|
|
837
|
+
border: "none",
|
|
838
|
+
borderTop: "1px solid #2a2a2a",
|
|
839
|
+
cursor: "pointer",
|
|
840
|
+
textAlign: "left",
|
|
841
|
+
fontFamily: MONO_FONT
|
|
842
|
+
},
|
|
843
|
+
nodeModulesIcon: {
|
|
844
|
+
fontSize: "8px",
|
|
845
|
+
color: "#555"
|
|
846
|
+
},
|
|
847
|
+
nodeModulesLabel: {
|
|
848
|
+
flex: 1,
|
|
849
|
+
letterSpacing: "0.5px"
|
|
850
|
+
},
|
|
851
|
+
nodeModulesCount: { color: "#555" },
|
|
852
|
+
nodeModulesFrames: { backgroundColor: "#080808" },
|
|
853
|
+
footer: {
|
|
854
|
+
padding: "14px 24px",
|
|
855
|
+
borderTop: "1px solid #2a2a2a",
|
|
856
|
+
backgroundColor: "#0a0a0a"
|
|
857
|
+
},
|
|
858
|
+
footerText: {
|
|
859
|
+
fontSize: "11px",
|
|
860
|
+
color: "#555"
|
|
861
|
+
},
|
|
862
|
+
prodContainer: {
|
|
863
|
+
textAlign: "center",
|
|
864
|
+
padding: "60px 40px",
|
|
865
|
+
backgroundColor: "#0d0d0d",
|
|
866
|
+
borderRadius: "8px",
|
|
867
|
+
maxWidth: "400px",
|
|
868
|
+
border: "1px solid #333"
|
|
869
|
+
},
|
|
870
|
+
prodIcon: {
|
|
871
|
+
width: "64px",
|
|
872
|
+
height: "64px",
|
|
873
|
+
margin: "0 auto 24px",
|
|
874
|
+
color: "#666",
|
|
875
|
+
display: "flex",
|
|
876
|
+
alignItems: "center",
|
|
877
|
+
justifyContent: "center"
|
|
878
|
+
},
|
|
879
|
+
prodTitle: {
|
|
880
|
+
margin: "0 0 12px",
|
|
881
|
+
fontSize: "18px",
|
|
882
|
+
fontWeight: 500,
|
|
883
|
+
color: "#f0f0f0",
|
|
884
|
+
fontFamily: MONO_FONT
|
|
885
|
+
},
|
|
886
|
+
prodMessage: {
|
|
887
|
+
margin: "0 0 28px",
|
|
888
|
+
fontSize: "13px",
|
|
889
|
+
color: "#888",
|
|
890
|
+
lineHeight: 1.6,
|
|
891
|
+
fontFamily: MONO_FONT
|
|
892
|
+
},
|
|
893
|
+
prodButton: {
|
|
894
|
+
padding: "12px 24px",
|
|
895
|
+
backgroundColor: "#222",
|
|
896
|
+
color: "#bbb",
|
|
897
|
+
fontSize: "13px",
|
|
898
|
+
fontWeight: 500,
|
|
899
|
+
borderWidth: "1px",
|
|
900
|
+
borderStyle: "solid",
|
|
901
|
+
borderColor: "#444",
|
|
902
|
+
borderRadius: "6px",
|
|
903
|
+
cursor: "pointer",
|
|
904
|
+
transition: "all 0.15s",
|
|
905
|
+
fontFamily: MONO_FONT
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
//#endregion
|
|
910
|
+
//#region ../../src/react/router/contexts/RouterLayerContext.ts
|
|
911
|
+
const RouterLayerContext = createContext(void 0);
|
|
912
|
+
|
|
913
|
+
//#endregion
|
|
914
|
+
//#region ../../src/react/router/errors/Redirection.ts
|
|
915
|
+
/**
|
|
916
|
+
* Used for Redirection during the page loading.
|
|
917
|
+
*
|
|
918
|
+
* Depends on the context, it can be thrown or just returned.
|
|
919
|
+
*
|
|
920
|
+
* @example
|
|
921
|
+
* ```ts
|
|
922
|
+
* import { Redirection } from "alepha/react";
|
|
923
|
+
*
|
|
924
|
+
* const MyPage = $page({
|
|
925
|
+
* loader: async () => {
|
|
926
|
+
* if (needRedirect) {
|
|
927
|
+
* throw new Redirection("/new-path");
|
|
928
|
+
* }
|
|
929
|
+
* },
|
|
930
|
+
* });
|
|
931
|
+
* ```
|
|
932
|
+
*/
|
|
933
|
+
var Redirection = class extends AlephaError {
|
|
934
|
+
redirect;
|
|
935
|
+
constructor(redirect) {
|
|
936
|
+
super("Redirection");
|
|
937
|
+
this.redirect = redirect;
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
|
|
941
|
+
//#endregion
|
|
942
|
+
//#region ../../src/react/router/hooks/useRouterState.ts
|
|
943
|
+
const useRouterState = () => {
|
|
944
|
+
const [state] = useStore("alepha.react.router.state");
|
|
945
|
+
if (!state) throw new AlephaError("Missing react router state");
|
|
946
|
+
return state;
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
//#endregion
|
|
950
|
+
//#region ../../src/react/router/components/NestedView.tsx
|
|
951
|
+
/**
|
|
952
|
+
* A component that renders the current view of the nested router layer.
|
|
953
|
+
*
|
|
954
|
+
* To be simple, it renders the `element` of the current child page of a parent page.
|
|
955
|
+
*
|
|
956
|
+
* @example
|
|
957
|
+
* ```tsx
|
|
958
|
+
* import { NestedView } from "alepha/react";
|
|
959
|
+
*
|
|
960
|
+
* class App {
|
|
961
|
+
* parent = $page({
|
|
962
|
+
* component: () => <NestedView />,
|
|
963
|
+
* });
|
|
964
|
+
*
|
|
965
|
+
* child = $page({
|
|
966
|
+
* parent: this.root,
|
|
967
|
+
* component: () => <div>Child Page</div>,
|
|
968
|
+
* });
|
|
969
|
+
* }
|
|
970
|
+
* ```
|
|
971
|
+
*/
|
|
972
|
+
const NestedView = (props) => {
|
|
973
|
+
const routerLayer = use(RouterLayerContext);
|
|
974
|
+
const index = routerLayer?.index ?? 0;
|
|
975
|
+
const onError = routerLayer?.onError;
|
|
976
|
+
const state = useRouterState();
|
|
977
|
+
const alepha = useAlepha();
|
|
978
|
+
const [view, setView] = useState(state.layers[index]?.element);
|
|
979
|
+
const [animation, setAnimation] = useState("");
|
|
980
|
+
const animationExitDuration = useRef(0);
|
|
981
|
+
const animationExitNow = useRef(0);
|
|
982
|
+
useEvents({
|
|
983
|
+
"react:transition:begin": async ({ previous, state }) => {
|
|
984
|
+
const layer = previous.layers[index];
|
|
985
|
+
if (!layer) return;
|
|
986
|
+
if (`${state.url.pathname}/`.startsWith(`${layer.path}/`)) return;
|
|
987
|
+
const animationExit = parseAnimation(layer.route?.animation, state, "exit");
|
|
988
|
+
if (animationExit) {
|
|
989
|
+
const duration = animationExit.duration || 200;
|
|
990
|
+
animationExitNow.current = Date.now();
|
|
991
|
+
animationExitDuration.current = duration;
|
|
992
|
+
setAnimation(animationExit.animation);
|
|
993
|
+
} else {
|
|
994
|
+
animationExitNow.current = 0;
|
|
995
|
+
animationExitDuration.current = 0;
|
|
996
|
+
setAnimation("");
|
|
997
|
+
}
|
|
998
|
+
},
|
|
999
|
+
"react:transition:end": async ({ state }) => {
|
|
1000
|
+
const layer = state.layers[index];
|
|
1001
|
+
if (animationExitNow.current) {
|
|
1002
|
+
const duration = animationExitDuration.current;
|
|
1003
|
+
const diff = Date.now() - animationExitNow.current;
|
|
1004
|
+
if (diff < duration) await new Promise((resolve) => setTimeout(resolve, duration - diff));
|
|
1005
|
+
}
|
|
1006
|
+
if (!layer?.cache) {
|
|
1007
|
+
setView(layer?.element);
|
|
1008
|
+
const animationEnter = parseAnimation(layer?.route?.animation, state, "enter");
|
|
1009
|
+
if (animationEnter) setAnimation(animationEnter.animation);
|
|
1010
|
+
else setAnimation("");
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}, []);
|
|
1014
|
+
let element = view ?? props.children ?? null;
|
|
1015
|
+
if (animation) element = /* @__PURE__ */ jsx("div", {
|
|
1016
|
+
style: {
|
|
1017
|
+
display: "flex",
|
|
1018
|
+
flex: 1,
|
|
1019
|
+
height: "100%",
|
|
1020
|
+
width: "100%",
|
|
1021
|
+
position: "relative",
|
|
1022
|
+
overflow: "hidden"
|
|
1023
|
+
},
|
|
1024
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1025
|
+
style: {
|
|
1026
|
+
height: "100%",
|
|
1027
|
+
width: "100%",
|
|
1028
|
+
display: "flex",
|
|
1029
|
+
animation
|
|
1030
|
+
},
|
|
1031
|
+
children: element
|
|
1032
|
+
})
|
|
1033
|
+
});
|
|
1034
|
+
if (props.errorBoundary === false) return /* @__PURE__ */ jsx(Fragment, { children: element });
|
|
1035
|
+
if (props.errorBoundary) return /* @__PURE__ */ jsx(ErrorBoundary, {
|
|
1036
|
+
fallback: props.errorBoundary,
|
|
1037
|
+
children: element
|
|
1038
|
+
});
|
|
1039
|
+
const fallback = (error) => {
|
|
1040
|
+
const result = onError?.(error, state) ?? /* @__PURE__ */ jsx(ErrorViewer_default, {
|
|
1041
|
+
error,
|
|
1042
|
+
alepha
|
|
1043
|
+
});
|
|
1044
|
+
if (result instanceof Redirection) return "Redirection inside ErrorBoundary is not allowed.";
|
|
1045
|
+
return result;
|
|
1046
|
+
};
|
|
1047
|
+
return /* @__PURE__ */ jsx(ErrorBoundary, {
|
|
1048
|
+
fallback,
|
|
1049
|
+
children: element
|
|
1050
|
+
});
|
|
1051
|
+
};
|
|
1052
|
+
var NestedView_default = memo(NestedView);
|
|
1053
|
+
function parseAnimation(animationLike, state, type = "enter") {
|
|
1054
|
+
if (!animationLike) return;
|
|
1055
|
+
const DEFAULT_DURATION = 300;
|
|
1056
|
+
const animation = typeof animationLike === "function" ? animationLike(state) : animationLike;
|
|
1057
|
+
if (typeof animation === "string") {
|
|
1058
|
+
if (type === "exit") return;
|
|
1059
|
+
return {
|
|
1060
|
+
duration: DEFAULT_DURATION,
|
|
1061
|
+
animation: `${DEFAULT_DURATION}ms ${animation}`
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
if (typeof animation === "object") {
|
|
1065
|
+
const anim = animation[type];
|
|
1066
|
+
const duration = typeof anim === "object" ? anim.duration ?? DEFAULT_DURATION : DEFAULT_DURATION;
|
|
1067
|
+
const name = typeof anim === "object" ? anim.name : anim;
|
|
1068
|
+
if (type === "exit") return {
|
|
1069
|
+
duration,
|
|
1070
|
+
animation: `${duration}ms ${typeof anim === "object" ? anim.timing ?? "" : ""} ${name}`
|
|
1071
|
+
};
|
|
1072
|
+
return {
|
|
1073
|
+
duration,
|
|
1074
|
+
animation: `${duration}ms ${typeof anim === "object" ? anim.timing ?? "" : ""} ${name}`
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
//#endregion
|
|
1080
|
+
//#region ../../src/react/router/components/NotFound.tsx
|
|
1081
|
+
/**
|
|
1082
|
+
* Default 404 Not Found page component.
|
|
1083
|
+
*/
|
|
1084
|
+
const NotFound = (props) => /* @__PURE__ */ jsxs("div", {
|
|
1085
|
+
style: {
|
|
1086
|
+
width: "100%",
|
|
1087
|
+
minHeight: "90vh",
|
|
1088
|
+
boxSizing: "border-box",
|
|
1089
|
+
display: "flex",
|
|
1090
|
+
flexDirection: "column",
|
|
1091
|
+
justifyContent: "center",
|
|
1092
|
+
alignItems: "center",
|
|
1093
|
+
textAlign: "center",
|
|
1094
|
+
fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif",
|
|
1095
|
+
padding: "2rem",
|
|
1096
|
+
...props.style
|
|
1097
|
+
},
|
|
1098
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1099
|
+
style: {
|
|
1100
|
+
fontSize: "6rem",
|
|
1101
|
+
fontWeight: 200,
|
|
1102
|
+
lineHeight: 1
|
|
1103
|
+
},
|
|
1104
|
+
children: "404"
|
|
1105
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
1106
|
+
style: {
|
|
1107
|
+
fontSize: "0.875rem",
|
|
1108
|
+
marginTop: "1rem",
|
|
1109
|
+
opacity: .6
|
|
1110
|
+
},
|
|
1111
|
+
children: "Page not found"
|
|
1112
|
+
})]
|
|
1113
|
+
});
|
|
1114
|
+
var NotFound_default = NotFound;
|
|
1115
|
+
|
|
1116
|
+
//#endregion
|
|
1117
|
+
//#region ../../src/react/router/providers/ReactPageProvider.ts
|
|
1118
|
+
const envSchema$1 = t.object({ REACT_STRICT_MODE: t.boolean({ default: true }) });
|
|
1119
|
+
/**
|
|
1120
|
+
* Handle page routes for React applications. (Browser and Server)
|
|
1121
|
+
*/
|
|
1122
|
+
var ReactPageProvider = class {
|
|
1123
|
+
log = $logger();
|
|
1124
|
+
env = $env(envSchema$1);
|
|
1125
|
+
alepha = $inject(Alepha);
|
|
1126
|
+
pages = [];
|
|
1127
|
+
getPages() {
|
|
1128
|
+
return this.pages;
|
|
1129
|
+
}
|
|
1130
|
+
getConcretePages() {
|
|
1131
|
+
const pages = [];
|
|
1132
|
+
for (const page of this.pages) {
|
|
1133
|
+
if (page.children && page.children.length > 0) continue;
|
|
1134
|
+
const fullPath = this.pathname(page.name);
|
|
1135
|
+
if (fullPath.includes(":") || fullPath.includes("*")) {
|
|
1136
|
+
if (typeof page.static === "object") {
|
|
1137
|
+
const entries = page.static.entries;
|
|
1138
|
+
if (entries && entries.length > 0) for (const entry of entries) {
|
|
1139
|
+
const params = entry.params;
|
|
1140
|
+
const path = this.compile(page.path ?? "", params);
|
|
1141
|
+
if (!path.includes(":") && !path.includes("*")) pages.push({
|
|
1142
|
+
...page,
|
|
1143
|
+
name: params[Object.keys(params)[0]],
|
|
1144
|
+
staticName: page.name,
|
|
1145
|
+
path,
|
|
1146
|
+
...entry
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
pages.push(page);
|
|
1153
|
+
}
|
|
1154
|
+
return pages;
|
|
1155
|
+
}
|
|
1156
|
+
page(name) {
|
|
1157
|
+
for (const page of this.pages) if (page.name === name) return page;
|
|
1158
|
+
throw new AlephaError(`Page '${name}' not found`);
|
|
1159
|
+
}
|
|
1160
|
+
pathname(name, options = {}) {
|
|
1161
|
+
const page = this.page(name);
|
|
1162
|
+
if (!page) throw new AlephaError(`Page ${name} not found`);
|
|
1163
|
+
let url = page.path ?? "";
|
|
1164
|
+
let parent = page.parent;
|
|
1165
|
+
while (parent) {
|
|
1166
|
+
url = `${parent.path ?? ""}/${url}`;
|
|
1167
|
+
parent = parent.parent;
|
|
1168
|
+
}
|
|
1169
|
+
url = this.compile(url, options.params ?? {});
|
|
1170
|
+
if (options.query) {
|
|
1171
|
+
const query = new URLSearchParams(options.query);
|
|
1172
|
+
if (query.toString()) url += `?${query.toString()}`;
|
|
1173
|
+
}
|
|
1174
|
+
return url.replace(/\/\/+/g, "/") || "/";
|
|
1175
|
+
}
|
|
1176
|
+
url(name, options = {}) {
|
|
1177
|
+
return new URL(this.pathname(name, options), options.host ?? `http://localhost`);
|
|
1178
|
+
}
|
|
1179
|
+
root(state) {
|
|
1180
|
+
const root = createElement(AlephaContext.Provider, { value: this.alepha }, createElement(NestedView_default, {}, state.layers[0]?.element));
|
|
1181
|
+
if (this.env.REACT_STRICT_MODE) return createElement(StrictMode, {}, root);
|
|
1182
|
+
return root;
|
|
1183
|
+
}
|
|
1184
|
+
convertStringObjectToObject = (schema, value) => {
|
|
1185
|
+
if (t.schema.isObject(schema) && typeof value === "object") {
|
|
1186
|
+
for (const key in schema.properties) if (t.schema.isObject(schema.properties[key]) && typeof value[key] === "string") try {
|
|
1187
|
+
value[key] = this.alepha.codec.decode(schema.properties[key], decodeURIComponent(value[key]));
|
|
1188
|
+
} catch (e) {}
|
|
1189
|
+
}
|
|
1190
|
+
return value;
|
|
1191
|
+
};
|
|
1192
|
+
/**
|
|
1193
|
+
* Create a new RouterState based on a given route and request.
|
|
1194
|
+
* This method resolves the layers for the route, applying any query and params schemas defined in the route.
|
|
1195
|
+
* It also handles errors and redirects.
|
|
1196
|
+
*/
|
|
1197
|
+
async createLayers(route, state, previous = []) {
|
|
1198
|
+
let context = {};
|
|
1199
|
+
const stack = [{ route }];
|
|
1200
|
+
let parent = route.parent;
|
|
1201
|
+
while (parent) {
|
|
1202
|
+
stack.unshift({ route: parent });
|
|
1203
|
+
parent = parent.parent;
|
|
1204
|
+
}
|
|
1205
|
+
let forceRefresh = false;
|
|
1206
|
+
for (let i = 0; i < stack.length; i++) {
|
|
1207
|
+
const it = stack[i];
|
|
1208
|
+
const route = it.route;
|
|
1209
|
+
const config = {};
|
|
1210
|
+
try {
|
|
1211
|
+
this.convertStringObjectToObject(route.schema?.query, state.query);
|
|
1212
|
+
config.query = route.schema?.query ? this.alepha.codec.decode(route.schema.query, state.query) : {};
|
|
1213
|
+
} catch (e) {
|
|
1214
|
+
it.error = e;
|
|
1215
|
+
break;
|
|
1216
|
+
}
|
|
1217
|
+
try {
|
|
1218
|
+
config.params = route.schema?.params ? this.alepha.codec.decode(route.schema.params, state.params) : {};
|
|
1219
|
+
} catch (e) {
|
|
1220
|
+
it.error = e;
|
|
1221
|
+
break;
|
|
1222
|
+
}
|
|
1223
|
+
it.config = { ...config };
|
|
1224
|
+
if (previous?.[i] && !forceRefresh && previous[i].name === route.name) {
|
|
1225
|
+
const url = (str) => str ? str.replace(/\/\/+/g, "/") : "/";
|
|
1226
|
+
if (JSON.stringify({
|
|
1227
|
+
part: url(previous[i].part),
|
|
1228
|
+
params: previous[i].config?.params ?? {}
|
|
1229
|
+
}) === JSON.stringify({
|
|
1230
|
+
part: url(route.path),
|
|
1231
|
+
params: config.params ?? {}
|
|
1232
|
+
})) {
|
|
1233
|
+
it.props = previous[i].props;
|
|
1234
|
+
it.error = previous[i].error;
|
|
1235
|
+
it.cache = true;
|
|
1236
|
+
context = {
|
|
1237
|
+
...context,
|
|
1238
|
+
...it.props
|
|
1239
|
+
};
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
forceRefresh = true;
|
|
1243
|
+
}
|
|
1244
|
+
if (!route.loader) continue;
|
|
1245
|
+
try {
|
|
1246
|
+
const args = Object.create(state);
|
|
1247
|
+
Object.assign(args, config, context);
|
|
1248
|
+
const props = await route.loader?.(args) ?? {};
|
|
1249
|
+
it.props = { ...props };
|
|
1250
|
+
context = {
|
|
1251
|
+
...context,
|
|
1252
|
+
...props
|
|
1253
|
+
};
|
|
1254
|
+
} catch (e) {
|
|
1255
|
+
if (e instanceof Redirection) return { redirect: e.redirect };
|
|
1256
|
+
this.log.error("Page loader has failed", e);
|
|
1257
|
+
it.error = e;
|
|
1258
|
+
break;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
let acc = "";
|
|
1262
|
+
for (let i = 0; i < stack.length; i++) {
|
|
1263
|
+
const it = stack[i];
|
|
1264
|
+
const props = it.props ?? {};
|
|
1265
|
+
const params = { ...it.config?.params };
|
|
1266
|
+
for (const key of Object.keys(params)) params[key] = String(params[key]);
|
|
1267
|
+
acc += "/";
|
|
1268
|
+
acc += it.route.path ? this.compile(it.route.path, params) : "";
|
|
1269
|
+
const path = acc.replace(/\/+/, "/");
|
|
1270
|
+
const localErrorHandler = this.getErrorHandler(it.route);
|
|
1271
|
+
if (localErrorHandler) {
|
|
1272
|
+
const onErrorParent = state.onError;
|
|
1273
|
+
state.onError = (error, context) => {
|
|
1274
|
+
const result = localErrorHandler(error, context);
|
|
1275
|
+
if (result === void 0) return onErrorParent(error, context);
|
|
1276
|
+
return result;
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
if (!it.error) try {
|
|
1280
|
+
const element = await this.createElement(it.route, {
|
|
1281
|
+
...it.route.props ? it.route.props() : {},
|
|
1282
|
+
...props,
|
|
1283
|
+
...context
|
|
1284
|
+
});
|
|
1285
|
+
state.layers.push({
|
|
1286
|
+
name: it.route.name,
|
|
1287
|
+
props,
|
|
1288
|
+
part: it.route.path,
|
|
1289
|
+
config: it.config,
|
|
1290
|
+
element: this.renderView(i + 1, path, element, it.route),
|
|
1291
|
+
index: i + 1,
|
|
1292
|
+
path,
|
|
1293
|
+
route: it.route,
|
|
1294
|
+
cache: it.cache
|
|
1295
|
+
});
|
|
1296
|
+
} catch (e) {
|
|
1297
|
+
it.error = e;
|
|
1298
|
+
}
|
|
1299
|
+
if (it.error) try {
|
|
1300
|
+
let element = await state.onError(it.error, state);
|
|
1301
|
+
if (element === void 0) throw it.error;
|
|
1302
|
+
if (element instanceof Redirection) return { redirect: element.redirect };
|
|
1303
|
+
if (element === null) element = this.renderError(it.error);
|
|
1304
|
+
state.layers.push({
|
|
1305
|
+
props,
|
|
1306
|
+
error: it.error,
|
|
1307
|
+
name: it.route.name,
|
|
1308
|
+
part: it.route.path,
|
|
1309
|
+
config: it.config,
|
|
1310
|
+
element: this.renderView(i + 1, path, element, it.route),
|
|
1311
|
+
index: i + 1,
|
|
1312
|
+
path,
|
|
1313
|
+
route: it.route
|
|
1314
|
+
});
|
|
1315
|
+
break;
|
|
1316
|
+
} catch (e) {
|
|
1317
|
+
if (e instanceof Redirection) return { redirect: e.redirect };
|
|
1318
|
+
throw e;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
return { state };
|
|
1322
|
+
}
|
|
1323
|
+
getErrorHandler(route) {
|
|
1324
|
+
if (route.errorHandler) return route.errorHandler;
|
|
1325
|
+
let parent = route.parent;
|
|
1326
|
+
while (parent) {
|
|
1327
|
+
if (parent.errorHandler) return parent.errorHandler;
|
|
1328
|
+
parent = parent.parent;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
async createElement(page, props) {
|
|
1332
|
+
if (page.lazy && page.component) this.log.warn(`Page ${page.name} has both lazy and component options, lazy will be used`);
|
|
1333
|
+
if (page.lazy) return createElement((await page.lazy()).default, props);
|
|
1334
|
+
if (page.component) return createElement(page.component, props);
|
|
1335
|
+
}
|
|
1336
|
+
renderError(error) {
|
|
1337
|
+
return createElement(ErrorViewer_default, {
|
|
1338
|
+
error,
|
|
1339
|
+
alepha: this.alepha
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
renderEmptyView() {
|
|
1343
|
+
return createElement(NestedView_default, {});
|
|
1344
|
+
}
|
|
1345
|
+
href(page, params = {}) {
|
|
1346
|
+
const found = this.pages.find((it) => it.name === page.options.name);
|
|
1347
|
+
if (!found) throw new AlephaError(`Page ${page.options.name} not found`);
|
|
1348
|
+
let url = found.path ?? "";
|
|
1349
|
+
let parent = found.parent;
|
|
1350
|
+
while (parent) {
|
|
1351
|
+
url = `${parent.path ?? ""}/${url}`;
|
|
1352
|
+
parent = parent.parent;
|
|
1353
|
+
}
|
|
1354
|
+
url = this.compile(url, params);
|
|
1355
|
+
return url.replace(/\/\/+/g, "/") || "/";
|
|
1356
|
+
}
|
|
1357
|
+
compile(path, params = {}) {
|
|
1358
|
+
for (const [key, value] of Object.entries(params)) path = path.replace(`:${key}`, value);
|
|
1359
|
+
return path;
|
|
1360
|
+
}
|
|
1361
|
+
renderView(index, path, view, page) {
|
|
1362
|
+
view ??= this.renderEmptyView();
|
|
1363
|
+
const element = page.client ? createElement(ClientOnly, typeof page.client === "object" ? page.client : {}, view) : view;
|
|
1364
|
+
return createElement(RouterLayerContext.Provider, { value: {
|
|
1365
|
+
index,
|
|
1366
|
+
path,
|
|
1367
|
+
onError: this.getErrorHandler(page) ?? ((error) => this.renderError(error))
|
|
1368
|
+
} }, element);
|
|
1369
|
+
}
|
|
1370
|
+
configure = $hook({
|
|
1371
|
+
on: "configure",
|
|
1372
|
+
handler: () => {
|
|
1373
|
+
let hasNotFoundHandler = false;
|
|
1374
|
+
const pages = this.alepha.primitives($page);
|
|
1375
|
+
const hasParent = (it) => {
|
|
1376
|
+
if (it.options.parent) return true;
|
|
1377
|
+
for (const page of pages) if ((page.options.children ? Array.isArray(page.options.children) ? page.options.children : page.options.children() : []).includes(it)) return true;
|
|
1378
|
+
};
|
|
1379
|
+
for (const page of pages) {
|
|
1380
|
+
if (page.options.path === "/*") hasNotFoundHandler = true;
|
|
1381
|
+
if (hasParent(page)) continue;
|
|
1382
|
+
this.add(this.map(pages, page));
|
|
1383
|
+
}
|
|
1384
|
+
if (!hasNotFoundHandler && pages.length > 0) this.add({
|
|
1385
|
+
path: "/*",
|
|
1386
|
+
name: "notFound",
|
|
1387
|
+
cache: true,
|
|
1388
|
+
component: NotFound_default,
|
|
1389
|
+
onServerResponse: ({ reply }) => {
|
|
1390
|
+
reply.status = 404;
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
map(pages, target) {
|
|
1396
|
+
const children = target.options.children ? Array.isArray(target.options.children) ? target.options.children : target.options.children() : [];
|
|
1397
|
+
const getChildrenFromParent = (it) => {
|
|
1398
|
+
const children = [];
|
|
1399
|
+
for (const page of pages) if (page.options.parent === it) children.push(page);
|
|
1400
|
+
return children;
|
|
1401
|
+
};
|
|
1402
|
+
children.push(...getChildrenFromParent(target));
|
|
1403
|
+
return {
|
|
1404
|
+
...target.options,
|
|
1405
|
+
name: target.name,
|
|
1406
|
+
parent: void 0,
|
|
1407
|
+
children: children.map((it) => this.map(pages, it))
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
add(entry) {
|
|
1411
|
+
if (this.alepha.isReady()) throw new AlephaError("Router is already initialized");
|
|
1412
|
+
entry.name ??= this.nextId();
|
|
1413
|
+
const page = entry;
|
|
1414
|
+
page.match = this.createMatch(page);
|
|
1415
|
+
this.pages.push(page);
|
|
1416
|
+
if (page.children) for (const child of page.children) {
|
|
1417
|
+
child.parent = page;
|
|
1418
|
+
this.add(child);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
createMatch(page) {
|
|
1422
|
+
let url = page.path ?? "/";
|
|
1423
|
+
let target = page.parent;
|
|
1424
|
+
while (target) {
|
|
1425
|
+
url = `${target.path ?? ""}/${url}`;
|
|
1426
|
+
target = target.parent;
|
|
1427
|
+
}
|
|
1428
|
+
let path = url.replace(/\/\/+/g, "/");
|
|
1429
|
+
if (path.endsWith("/") && path !== "/") path = path.slice(0, -1);
|
|
1430
|
+
return path;
|
|
1431
|
+
}
|
|
1432
|
+
_next = 0;
|
|
1433
|
+
nextId() {
|
|
1434
|
+
this._next += 1;
|
|
1435
|
+
return `P${this._next}`;
|
|
1436
|
+
}
|
|
1437
|
+
};
|
|
1438
|
+
const isPageRoute = (it) => {
|
|
1439
|
+
return it && typeof it === "object" && typeof it.path === "string" && typeof it.page === "object";
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
//#endregion
|
|
1443
|
+
//#region ../../src/react/router/atoms/ssrManifestAtom.ts
|
|
1444
|
+
/**
|
|
1445
|
+
* Schema for the SSR manifest atom.
|
|
1446
|
+
*/
|
|
1447
|
+
const ssrManifestAtomSchema = t.object({
|
|
1448
|
+
base: t.optional(t.string()),
|
|
1449
|
+
preload: t.optional(t.record(t.string(), t.string())),
|
|
1450
|
+
client: t.optional(t.record(t.string(), t.object({
|
|
1451
|
+
file: t.string(),
|
|
1452
|
+
isEntry: t.optional(t.boolean()),
|
|
1453
|
+
imports: t.optional(t.array(t.string())),
|
|
1454
|
+
css: t.optional(t.array(t.string()))
|
|
1455
|
+
})))
|
|
1456
|
+
});
|
|
1457
|
+
/**
|
|
1458
|
+
* SSR Manifest atom containing all manifest data for SSR module preloading.
|
|
1459
|
+
*
|
|
1460
|
+
* This atom is populated at build time by embedding manifest data into the
|
|
1461
|
+
* generated index.js. This approach is optimal for serverless deployments
|
|
1462
|
+
* as it eliminates filesystem reads at runtime.
|
|
1463
|
+
*
|
|
1464
|
+
* The manifest includes:
|
|
1465
|
+
* - preload: Maps short hash keys to source paths (from viteAlephaSsrPreload)
|
|
1466
|
+
* - client: Maps source files to their output info (file, imports, css)
|
|
1467
|
+
*/
|
|
1468
|
+
const ssrManifestAtom = $atom({
|
|
1469
|
+
name: "alepha.react.ssr.manifest",
|
|
1470
|
+
description: "SSR manifest for module preloading",
|
|
1471
|
+
schema: ssrManifestAtomSchema,
|
|
1472
|
+
default: {}
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
//#endregion
|
|
1476
|
+
//#region ../../src/react/router/providers/SSRManifestProvider.ts
|
|
1477
|
+
/**
|
|
1478
|
+
* Provider for SSR manifest data used for module preloading.
|
|
1479
|
+
*
|
|
1480
|
+
* The manifest is populated at build time by embedding data into the
|
|
1481
|
+
* generated index.js via the ssrManifestAtom. This eliminates filesystem
|
|
1482
|
+
* reads at runtime, making it optimal for serverless deployments.
|
|
1483
|
+
*
|
|
1484
|
+
* Manifest files are generated during `vite build`:
|
|
1485
|
+
* - manifest.json (client manifest)
|
|
1486
|
+
* - preload-manifest.json (from viteAlephaSsrPreload plugin)
|
|
1487
|
+
*/
|
|
1488
|
+
var SSRManifestProvider = class {
|
|
1489
|
+
alepha = $inject(Alepha);
|
|
1490
|
+
/**
|
|
1491
|
+
* Get the manifest from the store at runtime.
|
|
1492
|
+
* This ensures the manifest is available even when set after module load.
|
|
1493
|
+
*/
|
|
1494
|
+
get manifest() {
|
|
1495
|
+
return this.alepha.store.get(ssrManifestAtom) ?? {};
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Get the base path for assets (from Vite's base config).
|
|
1499
|
+
* Returns empty string if base is "/" (default), otherwise returns the base path.
|
|
1500
|
+
*/
|
|
1501
|
+
get base() {
|
|
1502
|
+
return this.manifest.base ?? "";
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Get the preload manifest.
|
|
1506
|
+
*/
|
|
1507
|
+
get preloadManifest() {
|
|
1508
|
+
return this.manifest.preload;
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Get the client manifest.
|
|
1512
|
+
*/
|
|
1513
|
+
get clientManifest() {
|
|
1514
|
+
return this.manifest.client;
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Resolve a preload key to its source path.
|
|
1518
|
+
*
|
|
1519
|
+
* The key is a short hash injected by viteAlephaSsrPreload plugin,
|
|
1520
|
+
* which maps to the full source path in the preload manifest.
|
|
1521
|
+
*
|
|
1522
|
+
* @param key - Short hash key (e.g., "a1b2c3d4")
|
|
1523
|
+
* @returns Source path (e.g., "src/pages/UserDetail.tsx") or undefined
|
|
1524
|
+
*/
|
|
1525
|
+
resolvePreloadKey(key) {
|
|
1526
|
+
return this.preloadManifest?.[key];
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Get all chunks required for a source file, including transitive dependencies.
|
|
1530
|
+
*
|
|
1531
|
+
* Uses the client manifest to recursively resolve all imported chunks.
|
|
1532
|
+
*
|
|
1533
|
+
* @param sourcePath - Source file path (e.g., "src/pages/Home.tsx")
|
|
1534
|
+
* @returns Array of chunk URLs to preload, or empty array if not found
|
|
1535
|
+
*/
|
|
1536
|
+
getChunks(sourcePath) {
|
|
1537
|
+
if (!this.clientManifest) return [];
|
|
1538
|
+
if (!this.findManifestEntry(sourcePath)) return [];
|
|
1539
|
+
const chunks = /* @__PURE__ */ new Set();
|
|
1540
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1541
|
+
this.collectChunksRecursive(sourcePath, chunks, visited);
|
|
1542
|
+
return Array.from(chunks);
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Find manifest entry for a source path, trying different extensions.
|
|
1546
|
+
*/
|
|
1547
|
+
findManifestEntry(sourcePath) {
|
|
1548
|
+
if (!this.clientManifest) return void 0;
|
|
1549
|
+
if (this.clientManifest[sourcePath]) return this.clientManifest[sourcePath];
|
|
1550
|
+
const basePath = sourcePath.replace(/\.[^.]+$/, "");
|
|
1551
|
+
for (const ext of [
|
|
1552
|
+
".tsx",
|
|
1553
|
+
".ts",
|
|
1554
|
+
".jsx",
|
|
1555
|
+
".js"
|
|
1556
|
+
]) {
|
|
1557
|
+
const pathWithExt = basePath + ext;
|
|
1558
|
+
if (this.clientManifest[pathWithExt]) return this.clientManifest[pathWithExt];
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Recursively collect all chunk URLs for a manifest entry.
|
|
1563
|
+
*/
|
|
1564
|
+
collectChunksRecursive(key, chunks, visited) {
|
|
1565
|
+
if (visited.has(key)) return;
|
|
1566
|
+
visited.add(key);
|
|
1567
|
+
if (!this.clientManifest) return;
|
|
1568
|
+
const entry = this.clientManifest[key];
|
|
1569
|
+
if (!entry) return;
|
|
1570
|
+
const base = this.base;
|
|
1571
|
+
if (entry.file) chunks.add(`${base}/${entry.file}`);
|
|
1572
|
+
if (entry.css) for (const css of entry.css) chunks.add(`${base}/${css}`);
|
|
1573
|
+
if (entry.imports) for (const imp of entry.imports) {
|
|
1574
|
+
if (imp === "index.html" || imp.endsWith(".html")) continue;
|
|
1575
|
+
this.collectChunksRecursive(imp, chunks, visited);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* Collect modulepreload links for a route and its parent chain.
|
|
1580
|
+
*/
|
|
1581
|
+
collectPreloadLinks(route) {
|
|
1582
|
+
if (!this.isAvailable()) return [];
|
|
1583
|
+
const preloadPaths = [];
|
|
1584
|
+
let current = route;
|
|
1585
|
+
while (current) {
|
|
1586
|
+
const preloadKey = current[PAGE_PRELOAD_KEY];
|
|
1587
|
+
if (preloadKey) {
|
|
1588
|
+
const sourcePath = this.resolvePreloadKey(preloadKey);
|
|
1589
|
+
if (sourcePath) preloadPaths.push(sourcePath);
|
|
1590
|
+
}
|
|
1591
|
+
current = current.parent;
|
|
1592
|
+
}
|
|
1593
|
+
if (preloadPaths.length === 0) return [];
|
|
1594
|
+
return this.getChunksForMultiple(preloadPaths).map((href) => {
|
|
1595
|
+
if (href.endsWith(".css")) return {
|
|
1596
|
+
rel: "preload",
|
|
1597
|
+
href,
|
|
1598
|
+
as: "style",
|
|
1599
|
+
crossorigin: ""
|
|
1600
|
+
};
|
|
1601
|
+
return {
|
|
1602
|
+
rel: "modulepreload",
|
|
1603
|
+
href
|
|
1604
|
+
};
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Get all chunks for multiple source files.
|
|
1609
|
+
*
|
|
1610
|
+
* @param sourcePaths - Array of source file paths
|
|
1611
|
+
* @returns Deduplicated array of chunk URLs
|
|
1612
|
+
*/
|
|
1613
|
+
getChunksForMultiple(sourcePaths) {
|
|
1614
|
+
const allChunks = /* @__PURE__ */ new Set();
|
|
1615
|
+
for (const path of sourcePaths) {
|
|
1616
|
+
const chunks = this.getChunks(path);
|
|
1617
|
+
for (const chunk of chunks) allChunks.add(chunk);
|
|
1618
|
+
}
|
|
1619
|
+
return Array.from(allChunks);
|
|
1620
|
+
}
|
|
1621
|
+
/**
|
|
1622
|
+
* Check if manifest is loaded and available.
|
|
1623
|
+
*/
|
|
1624
|
+
isAvailable() {
|
|
1625
|
+
return this.clientManifest !== void 0;
|
|
1626
|
+
}
|
|
1627
|
+
/**
|
|
1628
|
+
* Cached entry assets - computed once at first access.
|
|
1629
|
+
*/
|
|
1630
|
+
cachedEntryAssets = null;
|
|
1631
|
+
/**
|
|
1632
|
+
* Get the entry point assets (main entry.js and associated CSS files).
|
|
1633
|
+
*
|
|
1634
|
+
* These assets are always required for all pages and can be preloaded
|
|
1635
|
+
* before page-specific loaders run.
|
|
1636
|
+
*
|
|
1637
|
+
* @returns Entry assets with js and css paths, or null if manifest unavailable
|
|
1638
|
+
*/
|
|
1639
|
+
getEntryAssets() {
|
|
1640
|
+
if (this.cachedEntryAssets) return this.cachedEntryAssets;
|
|
1641
|
+
if (!this.clientManifest) return null;
|
|
1642
|
+
const base = this.base;
|
|
1643
|
+
for (const [key, entry] of Object.entries(this.clientManifest)) if (entry.isEntry) {
|
|
1644
|
+
this.cachedEntryAssets = {
|
|
1645
|
+
js: `${base}/${entry.file}`,
|
|
1646
|
+
css: entry.css?.map((css) => `${base}/${css}`) ?? []
|
|
1647
|
+
};
|
|
1648
|
+
return this.cachedEntryAssets;
|
|
1649
|
+
}
|
|
1650
|
+
return null;
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Build preload link tags for entry assets.
|
|
1654
|
+
*
|
|
1655
|
+
* @returns Array of link objects ready to be rendered
|
|
1656
|
+
*/
|
|
1657
|
+
getEntryPreloadLinks() {
|
|
1658
|
+
const assets = this.getEntryAssets();
|
|
1659
|
+
if (!assets) return [];
|
|
1660
|
+
const links = [];
|
|
1661
|
+
for (const css of assets.css) links.push({
|
|
1662
|
+
rel: "stylesheet",
|
|
1663
|
+
href: css,
|
|
1664
|
+
crossorigin: ""
|
|
1665
|
+
});
|
|
1666
|
+
if (assets.js) links.push({
|
|
1667
|
+
rel: "modulepreload",
|
|
1668
|
+
href: assets.js
|
|
1669
|
+
});
|
|
1670
|
+
return links;
|
|
1671
|
+
}
|
|
1672
|
+
};
|
|
1673
|
+
|
|
1674
|
+
//#endregion
|
|
1675
|
+
//#region ../../src/react/router/providers/ReactPreloadProvider.ts
|
|
1676
|
+
/**
|
|
1677
|
+
* Adds HTTP Link headers for preloading entry assets.
|
|
1678
|
+
*
|
|
1679
|
+
* Benefits:
|
|
1680
|
+
* - Early Hints (103): Servers can send preload hints before the full response
|
|
1681
|
+
* - CDN optimization: Many CDNs use Link headers to optimize asset delivery
|
|
1682
|
+
* - Browser prefetching: Browsers can start fetching resources earlier
|
|
1683
|
+
*
|
|
1684
|
+
* The Link header is computed once at first request and cached for reuse.
|
|
1685
|
+
*/
|
|
1686
|
+
var ReactPreloadProvider = class {
|
|
1687
|
+
alepha = $inject(Alepha);
|
|
1688
|
+
ssrManifest = $inject(SSRManifestProvider);
|
|
1689
|
+
/**
|
|
1690
|
+
* Cached Link header value - computed once, reused for all requests.
|
|
1691
|
+
*/
|
|
1692
|
+
cachedLinkHeader;
|
|
1693
|
+
/**
|
|
1694
|
+
* Build the Link header string from entry assets.
|
|
1695
|
+
*
|
|
1696
|
+
* Format: <url>; rel=preload; as=type, <url>; rel=modulepreload
|
|
1697
|
+
*
|
|
1698
|
+
* @returns Link header string or null if no assets
|
|
1699
|
+
*/
|
|
1700
|
+
buildLinkHeader() {
|
|
1701
|
+
const assets = this.ssrManifest.getEntryAssets();
|
|
1702
|
+
if (!assets) return null;
|
|
1703
|
+
const links = [];
|
|
1704
|
+
for (const css of assets.css) links.push(`<${css}>; rel=preload; as=style`);
|
|
1705
|
+
if (assets.js) links.push(`<${assets.js}>; rel=modulepreload`);
|
|
1706
|
+
return links.length > 0 ? links.join(", ") : null;
|
|
1707
|
+
}
|
|
1708
|
+
/**
|
|
1709
|
+
* Get the cached Link header, computing it on first access.
|
|
1710
|
+
*/
|
|
1711
|
+
getLinkHeader() {
|
|
1712
|
+
if (this.cachedLinkHeader === void 0) this.cachedLinkHeader = this.buildLinkHeader();
|
|
1713
|
+
return this.cachedLinkHeader;
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Add Link header to HTML responses for asset preloading.
|
|
1717
|
+
*/
|
|
1718
|
+
onResponse = $hook({
|
|
1719
|
+
on: "server:onResponse",
|
|
1720
|
+
priority: "first",
|
|
1721
|
+
handler: ({ response }) => {
|
|
1722
|
+
const contentType = response.headers["content-type"];
|
|
1723
|
+
if (!contentType || !contentType.includes("text/html")) return;
|
|
1724
|
+
const linkHeader = this.getLinkHeader();
|
|
1725
|
+
if (!linkHeader) return;
|
|
1726
|
+
if (response.headers.link) response.headers.link = `${response.headers.link}, ${linkHeader}`;
|
|
1727
|
+
else response.headers.link = linkHeader;
|
|
1728
|
+
}
|
|
1729
|
+
});
|
|
1730
|
+
};
|
|
1731
|
+
|
|
1732
|
+
//#endregion
|
|
1733
|
+
//#region ../../src/system/providers/FileSystemProvider.ts
|
|
1734
|
+
/**
|
|
1735
|
+
* FileSystem interface providing utilities for working with files.
|
|
1736
|
+
*/
|
|
1737
|
+
var FileSystemProvider = class {};
|
|
1738
|
+
|
|
1739
|
+
//#endregion
|
|
1740
|
+
//#region ../../src/system/providers/MemoryFileSystemProvider.ts
|
|
1741
|
+
/**
|
|
1742
|
+
* In-memory implementation of FileSystemProvider for testing.
|
|
1743
|
+
*
|
|
1744
|
+
* This provider stores all files and directories in memory, making it ideal for
|
|
1745
|
+
* unit tests that need to verify file operations without touching the real file system.
|
|
1746
|
+
*
|
|
1747
|
+
* @example
|
|
1748
|
+
* ```typescript
|
|
1749
|
+
* // In tests, substitute the real FileSystemProvider with MemoryFileSystemProvider
|
|
1750
|
+
* const alepha = Alepha.create().with({
|
|
1751
|
+
* provide: FileSystemProvider,
|
|
1752
|
+
* use: MemoryFileSystemProvider,
|
|
1753
|
+
* });
|
|
1754
|
+
*
|
|
1755
|
+
* // Run code that uses FileSystemProvider
|
|
1756
|
+
* const service = alepha.inject(MyService);
|
|
1757
|
+
* await service.saveFile("test.txt", "Hello World");
|
|
1758
|
+
*
|
|
1759
|
+
* // Verify the file was written
|
|
1760
|
+
* const memoryFs = alepha.inject(MemoryFileSystemProvider);
|
|
1761
|
+
* expect(memoryFs.files.get("test.txt")?.toString()).toBe("Hello World");
|
|
1762
|
+
* ```
|
|
1763
|
+
*/
|
|
1764
|
+
var MemoryFileSystemProvider = class {
|
|
1765
|
+
json = $inject(Json);
|
|
1766
|
+
/**
|
|
1767
|
+
* In-memory storage for files (path -> content)
|
|
1768
|
+
*/
|
|
1769
|
+
files = /* @__PURE__ */ new Map();
|
|
1770
|
+
/**
|
|
1771
|
+
* In-memory storage for directories
|
|
1772
|
+
*/
|
|
1773
|
+
directories = /* @__PURE__ */ new Set();
|
|
1774
|
+
/**
|
|
1775
|
+
* Track mkdir calls for test assertions
|
|
1776
|
+
*/
|
|
1777
|
+
mkdirCalls = [];
|
|
1778
|
+
/**
|
|
1779
|
+
* Track writeFile calls for test assertions
|
|
1780
|
+
*/
|
|
1781
|
+
writeFileCalls = [];
|
|
1782
|
+
/**
|
|
1783
|
+
* Track readFile calls for test assertions
|
|
1784
|
+
*/
|
|
1785
|
+
readFileCalls = [];
|
|
1786
|
+
/**
|
|
1787
|
+
* Track rm calls for test assertions
|
|
1788
|
+
*/
|
|
1789
|
+
rmCalls = [];
|
|
1790
|
+
/**
|
|
1791
|
+
* Track join calls for test assertions
|
|
1792
|
+
*/
|
|
1793
|
+
joinCalls = [];
|
|
1794
|
+
/**
|
|
1795
|
+
* Error to throw on mkdir (for testing error handling)
|
|
1796
|
+
*/
|
|
1797
|
+
mkdirError = null;
|
|
1798
|
+
/**
|
|
1799
|
+
* Error to throw on writeFile (for testing error handling)
|
|
1800
|
+
*/
|
|
1801
|
+
writeFileError = null;
|
|
1802
|
+
/**
|
|
1803
|
+
* Error to throw on readFile (for testing error handling)
|
|
1804
|
+
*/
|
|
1805
|
+
readFileError = null;
|
|
1806
|
+
constructor(options = {}) {
|
|
1807
|
+
this.mkdirError = options.mkdirError ?? null;
|
|
1808
|
+
this.writeFileError = options.writeFileError ?? null;
|
|
1809
|
+
this.readFileError = options.readFileError ?? null;
|
|
1810
|
+
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Join path segments using forward slashes.
|
|
1813
|
+
* Uses Node's path.join for proper normalization (handles .. and .)
|
|
1814
|
+
*/
|
|
1815
|
+
join(...paths) {
|
|
1816
|
+
this.joinCalls.push(paths);
|
|
1817
|
+
return join(...paths);
|
|
1818
|
+
}
|
|
1819
|
+
/**
|
|
1820
|
+
* Create a FileLike object from various sources.
|
|
1821
|
+
*/
|
|
1822
|
+
createFile(options) {
|
|
1823
|
+
if ("path" in options) {
|
|
1824
|
+
const filePath = options.path;
|
|
1825
|
+
const buffer = this.files.get(filePath);
|
|
1826
|
+
if (buffer === void 0) throw new Error(`ENOENT: no such file or directory, open '${filePath}'`);
|
|
1827
|
+
return {
|
|
1828
|
+
name: options.name ?? filePath.split("/").pop() ?? "file",
|
|
1829
|
+
type: options.type ?? "application/octet-stream",
|
|
1830
|
+
size: buffer.byteLength,
|
|
1831
|
+
lastModified: Date.now(),
|
|
1832
|
+
stream: () => {
|
|
1833
|
+
throw new Error("Stream not implemented in MemoryFileSystemProvider");
|
|
1834
|
+
},
|
|
1835
|
+
arrayBuffer: async () => buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
|
|
1836
|
+
text: async () => buffer.toString("utf-8")
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
if ("buffer" in options) {
|
|
1840
|
+
const buffer = options.buffer;
|
|
1841
|
+
return {
|
|
1842
|
+
name: options.name ?? "file",
|
|
1843
|
+
type: options.type ?? "application/octet-stream",
|
|
1844
|
+
size: buffer.byteLength,
|
|
1845
|
+
lastModified: Date.now(),
|
|
1846
|
+
stream: () => {
|
|
1847
|
+
throw new Error("Stream not implemented in MemoryFileSystemProvider");
|
|
1848
|
+
},
|
|
1849
|
+
arrayBuffer: async () => buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
|
|
1850
|
+
text: async () => buffer.toString("utf-8")
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
if ("text" in options) {
|
|
1854
|
+
const buffer = Buffer.from(options.text, "utf-8");
|
|
1855
|
+
return {
|
|
1856
|
+
name: options.name ?? "file.txt",
|
|
1857
|
+
type: options.type ?? "text/plain",
|
|
1858
|
+
size: buffer.byteLength,
|
|
1859
|
+
lastModified: Date.now(),
|
|
1860
|
+
stream: () => {
|
|
1861
|
+
throw new Error("Stream not implemented in MemoryFileSystemProvider");
|
|
1862
|
+
},
|
|
1863
|
+
arrayBuffer: async () => buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
|
|
1864
|
+
text: async () => options.text
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
throw new Error("MemoryFileSystemProvider.createFile: unsupported options. Only buffer and text are supported.");
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* Remove a file or directory from memory.
|
|
1871
|
+
*/
|
|
1872
|
+
async rm(path, options) {
|
|
1873
|
+
this.rmCalls.push({
|
|
1874
|
+
path,
|
|
1875
|
+
options
|
|
1876
|
+
});
|
|
1877
|
+
if (!(this.files.has(path) || this.directories.has(path)) && !options?.force) throw new Error(`ENOENT: no such file or directory, rm '${path}'`);
|
|
1878
|
+
if (this.directories.has(path)) if (options?.recursive) {
|
|
1879
|
+
this.directories.delete(path);
|
|
1880
|
+
for (const filePath of this.files.keys()) if (filePath.startsWith(`${path}/`)) this.files.delete(filePath);
|
|
1881
|
+
for (const dirPath of this.directories) if (dirPath.startsWith(`${path}/`)) this.directories.delete(dirPath);
|
|
1882
|
+
} else throw new Error(`EISDIR: illegal operation on a directory, rm '${path}'`);
|
|
1883
|
+
else this.files.delete(path);
|
|
1884
|
+
}
|
|
1885
|
+
/**
|
|
1886
|
+
* Copy a file or directory in memory.
|
|
1887
|
+
*/
|
|
1888
|
+
async cp(src, dest, options) {
|
|
1889
|
+
if (this.directories.has(src)) {
|
|
1890
|
+
if (!options?.recursive) throw new Error(`Cannot copy directory without recursive option: ${src}`);
|
|
1891
|
+
this.directories.add(dest);
|
|
1892
|
+
for (const [filePath, content] of this.files) if (filePath.startsWith(`${src}/`)) {
|
|
1893
|
+
const newPath = filePath.replace(src, dest);
|
|
1894
|
+
this.files.set(newPath, Buffer.from(content));
|
|
1895
|
+
}
|
|
1896
|
+
} else if (this.files.has(src)) {
|
|
1897
|
+
const content = this.files.get(src);
|
|
1898
|
+
this.files.set(dest, Buffer.from(content));
|
|
1899
|
+
} else throw new Error(`ENOENT: no such file or directory, cp '${src}'`);
|
|
1900
|
+
}
|
|
1901
|
+
/**
|
|
1902
|
+
* Move/rename a file or directory in memory.
|
|
1903
|
+
*/
|
|
1904
|
+
async mv(src, dest) {
|
|
1905
|
+
if (this.directories.has(src)) {
|
|
1906
|
+
this.directories.delete(src);
|
|
1907
|
+
this.directories.add(dest);
|
|
1908
|
+
for (const [filePath, content] of this.files) if (filePath.startsWith(`${src}/`)) {
|
|
1909
|
+
const newPath = filePath.replace(src, dest);
|
|
1910
|
+
this.files.delete(filePath);
|
|
1911
|
+
this.files.set(newPath, content);
|
|
1912
|
+
}
|
|
1913
|
+
} else if (this.files.has(src)) {
|
|
1914
|
+
const content = this.files.get(src);
|
|
1915
|
+
this.files.delete(src);
|
|
1916
|
+
this.files.set(dest, content);
|
|
1917
|
+
} else throw new Error(`ENOENT: no such file or directory, mv '${src}'`);
|
|
1918
|
+
}
|
|
1919
|
+
/**
|
|
1920
|
+
* Create a directory in memory.
|
|
1921
|
+
*/
|
|
1922
|
+
async mkdir(path, options) {
|
|
1923
|
+
this.mkdirCalls.push({
|
|
1924
|
+
path,
|
|
1925
|
+
options
|
|
1926
|
+
});
|
|
1927
|
+
if (this.mkdirError) throw this.mkdirError;
|
|
1928
|
+
if (this.directories.has(path) && !options?.recursive) throw new Error(`EEXIST: file already exists, mkdir '${path}'`);
|
|
1929
|
+
this.directories.add(path);
|
|
1930
|
+
if (options?.recursive) {
|
|
1931
|
+
const parts = path.split("/").filter(Boolean);
|
|
1932
|
+
let current = "";
|
|
1933
|
+
for (const part of parts) {
|
|
1934
|
+
current = current ? `${current}/${part}` : part;
|
|
1935
|
+
this.directories.add(current);
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* List files in a directory.
|
|
1941
|
+
*/
|
|
1942
|
+
async ls(path, options) {
|
|
1943
|
+
const normalizedPath = path.replace(/\/$/, "");
|
|
1944
|
+
const entries = /* @__PURE__ */ new Set();
|
|
1945
|
+
for (const filePath of this.files.keys()) if (filePath.startsWith(`${normalizedPath}/`)) {
|
|
1946
|
+
const relativePath = filePath.slice(normalizedPath.length + 1);
|
|
1947
|
+
const parts = relativePath.split("/");
|
|
1948
|
+
if (options?.recursive) entries.add(relativePath);
|
|
1949
|
+
else entries.add(parts[0]);
|
|
1950
|
+
}
|
|
1951
|
+
for (const dirPath of this.directories) if (dirPath.startsWith(`${normalizedPath}/`) && dirPath !== normalizedPath) {
|
|
1952
|
+
const relativePath = dirPath.slice(normalizedPath.length + 1);
|
|
1953
|
+
const parts = relativePath.split("/");
|
|
1954
|
+
if (options?.recursive) entries.add(relativePath);
|
|
1955
|
+
else if (parts.length === 1) entries.add(parts[0]);
|
|
1956
|
+
}
|
|
1957
|
+
let result = Array.from(entries);
|
|
1958
|
+
if (!options?.hidden) result = result.filter((entry) => !entry.startsWith("."));
|
|
1959
|
+
return result.sort();
|
|
1960
|
+
}
|
|
1961
|
+
/**
|
|
1962
|
+
* Check if a file or directory exists in memory.
|
|
1963
|
+
*/
|
|
1964
|
+
async exists(path) {
|
|
1965
|
+
return this.files.has(path) || this.directories.has(path);
|
|
1966
|
+
}
|
|
1967
|
+
/**
|
|
1968
|
+
* Read a file from memory.
|
|
1969
|
+
*/
|
|
1970
|
+
async readFile(path) {
|
|
1971
|
+
this.readFileCalls.push(path);
|
|
1972
|
+
if (this.readFileError) throw this.readFileError;
|
|
1973
|
+
const content = this.files.get(path);
|
|
1974
|
+
if (!content) throw new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
1975
|
+
return content;
|
|
1976
|
+
}
|
|
1977
|
+
/**
|
|
1978
|
+
* Read a file from memory as text.
|
|
1979
|
+
*/
|
|
1980
|
+
async readTextFile(path) {
|
|
1981
|
+
return (await this.readFile(path)).toString("utf-8");
|
|
1982
|
+
}
|
|
1983
|
+
/**
|
|
1984
|
+
* Read a file from memory as JSON.
|
|
1985
|
+
*/
|
|
1986
|
+
async readJsonFile(path) {
|
|
1987
|
+
const text = await this.readTextFile(path);
|
|
1988
|
+
return this.json.parse(text);
|
|
1989
|
+
}
|
|
1990
|
+
/**
|
|
1991
|
+
* Write a file to memory.
|
|
1992
|
+
*/
|
|
1993
|
+
async writeFile(path, data) {
|
|
1994
|
+
const dataStr = typeof data === "string" ? data : data instanceof Buffer || data instanceof Uint8Array ? data.toString("utf-8") : await data.text();
|
|
1995
|
+
this.writeFileCalls.push({
|
|
1996
|
+
path,
|
|
1997
|
+
data: dataStr
|
|
1998
|
+
});
|
|
1999
|
+
if (this.writeFileError) throw this.writeFileError;
|
|
2000
|
+
const buffer = typeof data === "string" ? Buffer.from(data, "utf-8") : data instanceof Buffer ? data : data instanceof Uint8Array ? Buffer.from(data) : Buffer.from(await data.text(), "utf-8");
|
|
2001
|
+
this.files.set(path, buffer);
|
|
2002
|
+
}
|
|
2003
|
+
/**
|
|
2004
|
+
* Reset all in-memory state (useful between tests).
|
|
2005
|
+
*/
|
|
2006
|
+
reset() {
|
|
2007
|
+
this.files.clear();
|
|
2008
|
+
this.directories.clear();
|
|
2009
|
+
this.mkdirCalls = [];
|
|
2010
|
+
this.writeFileCalls = [];
|
|
2011
|
+
this.readFileCalls = [];
|
|
2012
|
+
this.rmCalls = [];
|
|
2013
|
+
this.joinCalls = [];
|
|
2014
|
+
this.mkdirError = null;
|
|
2015
|
+
this.writeFileError = null;
|
|
2016
|
+
this.readFileError = null;
|
|
2017
|
+
}
|
|
2018
|
+
/**
|
|
2019
|
+
* Check if a file was written during the test.
|
|
2020
|
+
*
|
|
2021
|
+
* @example
|
|
2022
|
+
* ```typescript
|
|
2023
|
+
* expect(fs.wasWritten("/project/tsconfig.json")).toBe(true);
|
|
2024
|
+
* ```
|
|
2025
|
+
*/
|
|
2026
|
+
wasWritten(path) {
|
|
2027
|
+
return this.writeFileCalls.some((call) => call.path === path);
|
|
2028
|
+
}
|
|
2029
|
+
/**
|
|
2030
|
+
* Check if a file was written with content matching a pattern.
|
|
2031
|
+
*
|
|
2032
|
+
* @example
|
|
2033
|
+
* ```typescript
|
|
2034
|
+
* expect(fs.wasWrittenMatching("/project/tsconfig.json", /extends/)).toBe(true);
|
|
2035
|
+
* ```
|
|
2036
|
+
*/
|
|
2037
|
+
wasWrittenMatching(path, pattern) {
|
|
2038
|
+
const call = this.writeFileCalls.find((c) => c.path === path);
|
|
2039
|
+
return call ? pattern.test(call.data) : false;
|
|
2040
|
+
}
|
|
2041
|
+
/**
|
|
2042
|
+
* Check if a file was read during the test.
|
|
2043
|
+
*
|
|
2044
|
+
* @example
|
|
2045
|
+
* ```typescript
|
|
2046
|
+
* expect(fs.wasRead("/project/package.json")).toBe(true);
|
|
2047
|
+
* ```
|
|
2048
|
+
*/
|
|
2049
|
+
wasRead(path) {
|
|
2050
|
+
return this.readFileCalls.includes(path);
|
|
2051
|
+
}
|
|
2052
|
+
/**
|
|
2053
|
+
* Check if a file was deleted during the test.
|
|
2054
|
+
*
|
|
2055
|
+
* @example
|
|
2056
|
+
* ```typescript
|
|
2057
|
+
* expect(fs.wasDeleted("/project/old-file.txt")).toBe(true);
|
|
2058
|
+
* ```
|
|
2059
|
+
*/
|
|
2060
|
+
wasDeleted(path) {
|
|
2061
|
+
return this.rmCalls.some((call) => call.path === path);
|
|
2062
|
+
}
|
|
2063
|
+
/**
|
|
2064
|
+
* Get the content of a file as a string (convenience method for testing).
|
|
2065
|
+
*/
|
|
2066
|
+
getFileContent(path) {
|
|
2067
|
+
return this.files.get(path)?.toString("utf-8");
|
|
2068
|
+
}
|
|
2069
|
+
};
|
|
2070
|
+
|
|
2071
|
+
//#endregion
|
|
2072
|
+
//#region ../../src/system/providers/MemoryShellProvider.ts
|
|
2073
|
+
/**
|
|
2074
|
+
* In-memory implementation of ShellProvider for testing.
|
|
2075
|
+
*
|
|
2076
|
+
* Records all commands that would be executed without actually running them.
|
|
2077
|
+
* Can be configured to return specific outputs or throw errors for testing.
|
|
2078
|
+
*
|
|
2079
|
+
* @example
|
|
2080
|
+
* ```typescript
|
|
2081
|
+
* // In tests, substitute the real ShellProvider with MemoryShellProvider
|
|
2082
|
+
* const alepha = Alepha.create().with({
|
|
2083
|
+
* provide: ShellProvider,
|
|
2084
|
+
* use: MemoryShellProvider,
|
|
2085
|
+
* });
|
|
2086
|
+
*
|
|
2087
|
+
* // Configure mock behavior
|
|
2088
|
+
* const shell = alepha.inject(MemoryShellProvider);
|
|
2089
|
+
* shell.configure({
|
|
2090
|
+
* outputs: { "echo hello": "hello\n" },
|
|
2091
|
+
* errors: { "failing-cmd": "Command failed" },
|
|
2092
|
+
* });
|
|
2093
|
+
*
|
|
2094
|
+
* // Or use the fluent API
|
|
2095
|
+
* shell.outputs.set("another-cmd", "output");
|
|
2096
|
+
* shell.errors.set("another-error", "Error message");
|
|
2097
|
+
*
|
|
2098
|
+
* // Run code that uses ShellProvider
|
|
2099
|
+
* const service = alepha.inject(MyService);
|
|
2100
|
+
* await service.doSomething();
|
|
2101
|
+
*
|
|
2102
|
+
* // Verify commands were called
|
|
2103
|
+
* expect(shell.calls).toHaveLength(2);
|
|
2104
|
+
* expect(shell.calls[0].command).toBe("yarn install");
|
|
2105
|
+
* ```
|
|
2106
|
+
*/
|
|
2107
|
+
var MemoryShellProvider = class {
|
|
2108
|
+
/**
|
|
2109
|
+
* All recorded shell calls.
|
|
2110
|
+
*/
|
|
2111
|
+
calls = [];
|
|
2112
|
+
/**
|
|
2113
|
+
* Simulated outputs for specific commands.
|
|
2114
|
+
*/
|
|
2115
|
+
outputs = /* @__PURE__ */ new Map();
|
|
2116
|
+
/**
|
|
2117
|
+
* Commands that should throw an error.
|
|
2118
|
+
*/
|
|
2119
|
+
errors = /* @__PURE__ */ new Map();
|
|
2120
|
+
/**
|
|
2121
|
+
* Commands considered installed in the system PATH.
|
|
2122
|
+
*/
|
|
2123
|
+
installedCommands = /* @__PURE__ */ new Set();
|
|
2124
|
+
/**
|
|
2125
|
+
* Configure the mock with predefined outputs, errors, and installed commands.
|
|
2126
|
+
*/
|
|
2127
|
+
configure(options) {
|
|
2128
|
+
if (options.outputs) for (const [cmd, output] of Object.entries(options.outputs)) this.outputs.set(cmd, output);
|
|
2129
|
+
if (options.errors) for (const [cmd, error] of Object.entries(options.errors)) this.errors.set(cmd, error);
|
|
2130
|
+
if (options.installedCommands) for (const cmd of options.installedCommands) this.installedCommands.add(cmd);
|
|
2131
|
+
return this;
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* Record command and return simulated output.
|
|
2135
|
+
*/
|
|
2136
|
+
async run(command, options = {}) {
|
|
2137
|
+
this.calls.push({
|
|
2138
|
+
command,
|
|
2139
|
+
options
|
|
2140
|
+
});
|
|
2141
|
+
const errorMsg = this.errors.get(command);
|
|
2142
|
+
if (errorMsg) throw new Error(errorMsg);
|
|
2143
|
+
return this.outputs.get(command) ?? "";
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Check if a specific command was called.
|
|
2147
|
+
*/
|
|
2148
|
+
wasCalled(command) {
|
|
2149
|
+
return this.calls.some((call) => call.command === command);
|
|
2150
|
+
}
|
|
2151
|
+
/**
|
|
2152
|
+
* Check if a command matching a pattern was called.
|
|
2153
|
+
*/
|
|
2154
|
+
wasCalledMatching(pattern) {
|
|
2155
|
+
return this.calls.some((call) => pattern.test(call.command));
|
|
2156
|
+
}
|
|
2157
|
+
/**
|
|
2158
|
+
* Get all calls matching a pattern.
|
|
2159
|
+
*/
|
|
2160
|
+
getCallsMatching(pattern) {
|
|
2161
|
+
return this.calls.filter((call) => pattern.test(call.command));
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* Check if a command is installed.
|
|
2165
|
+
*/
|
|
2166
|
+
async isInstalled(command) {
|
|
2167
|
+
return this.installedCommands.has(command);
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Reset all recorded state.
|
|
2171
|
+
*/
|
|
2172
|
+
reset() {
|
|
2173
|
+
this.calls = [];
|
|
2174
|
+
this.outputs.clear();
|
|
2175
|
+
this.errors.clear();
|
|
2176
|
+
this.installedCommands.clear();
|
|
2177
|
+
}
|
|
2178
|
+
};
|
|
2179
|
+
|
|
2180
|
+
//#endregion
|
|
2181
|
+
//#region ../../src/system/services/FileDetector.ts
|
|
2182
|
+
/**
|
|
2183
|
+
* Service for detecting file types and getting content types.
|
|
2184
|
+
*
|
|
2185
|
+
* @example
|
|
2186
|
+
* ```typescript
|
|
2187
|
+
* const detector = alepha.inject(FileDetector);
|
|
2188
|
+
*
|
|
2189
|
+
* // Get content type from filename
|
|
2190
|
+
* const mimeType = detector.getContentType("image.png"); // "image/png"
|
|
2191
|
+
*
|
|
2192
|
+
* // Detect file type by magic bytes
|
|
2193
|
+
* const stream = createReadStream('image.png');
|
|
2194
|
+
* const result = await detector.detectFileType(stream, 'image.png');
|
|
2195
|
+
* console.log(result.mimeType); // 'image/png'
|
|
2196
|
+
* console.log(result.verified); // true if magic bytes match
|
|
2197
|
+
* ```
|
|
2198
|
+
*/
|
|
2199
|
+
var FileDetector = class FileDetector {
|
|
2200
|
+
/**
|
|
2201
|
+
* Magic byte signatures for common file formats.
|
|
2202
|
+
* Each signature is represented as an array of bytes or null (wildcard).
|
|
2203
|
+
*/
|
|
2204
|
+
static MAGIC_BYTES = {
|
|
2205
|
+
png: [{
|
|
2206
|
+
signature: [
|
|
2207
|
+
137,
|
|
2208
|
+
80,
|
|
2209
|
+
78,
|
|
2210
|
+
71,
|
|
2211
|
+
13,
|
|
2212
|
+
10,
|
|
2213
|
+
26,
|
|
2214
|
+
10
|
|
2215
|
+
],
|
|
2216
|
+
mimeType: "image/png"
|
|
2217
|
+
}],
|
|
2218
|
+
jpg: [
|
|
2219
|
+
{
|
|
2220
|
+
signature: [
|
|
2221
|
+
255,
|
|
2222
|
+
216,
|
|
2223
|
+
255,
|
|
2224
|
+
224
|
|
2225
|
+
],
|
|
2226
|
+
mimeType: "image/jpeg"
|
|
2227
|
+
},
|
|
2228
|
+
{
|
|
2229
|
+
signature: [
|
|
2230
|
+
255,
|
|
2231
|
+
216,
|
|
2232
|
+
255,
|
|
2233
|
+
225
|
|
2234
|
+
],
|
|
2235
|
+
mimeType: "image/jpeg"
|
|
2236
|
+
},
|
|
2237
|
+
{
|
|
2238
|
+
signature: [
|
|
2239
|
+
255,
|
|
2240
|
+
216,
|
|
2241
|
+
255,
|
|
2242
|
+
226
|
|
2243
|
+
],
|
|
2244
|
+
mimeType: "image/jpeg"
|
|
2245
|
+
},
|
|
2246
|
+
{
|
|
2247
|
+
signature: [
|
|
2248
|
+
255,
|
|
2249
|
+
216,
|
|
2250
|
+
255,
|
|
2251
|
+
227
|
|
2252
|
+
],
|
|
2253
|
+
mimeType: "image/jpeg"
|
|
2254
|
+
},
|
|
2255
|
+
{
|
|
2256
|
+
signature: [
|
|
2257
|
+
255,
|
|
2258
|
+
216,
|
|
2259
|
+
255,
|
|
2260
|
+
232
|
|
2261
|
+
],
|
|
2262
|
+
mimeType: "image/jpeg"
|
|
2263
|
+
}
|
|
2264
|
+
],
|
|
2265
|
+
jpeg: [
|
|
2266
|
+
{
|
|
2267
|
+
signature: [
|
|
2268
|
+
255,
|
|
2269
|
+
216,
|
|
2270
|
+
255,
|
|
2271
|
+
224
|
|
2272
|
+
],
|
|
2273
|
+
mimeType: "image/jpeg"
|
|
2274
|
+
},
|
|
2275
|
+
{
|
|
2276
|
+
signature: [
|
|
2277
|
+
255,
|
|
2278
|
+
216,
|
|
2279
|
+
255,
|
|
2280
|
+
225
|
|
2281
|
+
],
|
|
2282
|
+
mimeType: "image/jpeg"
|
|
2283
|
+
},
|
|
2284
|
+
{
|
|
2285
|
+
signature: [
|
|
2286
|
+
255,
|
|
2287
|
+
216,
|
|
2288
|
+
255,
|
|
2289
|
+
226
|
|
2290
|
+
],
|
|
2291
|
+
mimeType: "image/jpeg"
|
|
2292
|
+
},
|
|
2293
|
+
{
|
|
2294
|
+
signature: [
|
|
2295
|
+
255,
|
|
2296
|
+
216,
|
|
2297
|
+
255,
|
|
2298
|
+
227
|
|
2299
|
+
],
|
|
2300
|
+
mimeType: "image/jpeg"
|
|
2301
|
+
},
|
|
2302
|
+
{
|
|
2303
|
+
signature: [
|
|
2304
|
+
255,
|
|
2305
|
+
216,
|
|
2306
|
+
255,
|
|
2307
|
+
232
|
|
2308
|
+
],
|
|
2309
|
+
mimeType: "image/jpeg"
|
|
2310
|
+
}
|
|
2311
|
+
],
|
|
2312
|
+
gif: [{
|
|
2313
|
+
signature: [
|
|
2314
|
+
71,
|
|
2315
|
+
73,
|
|
2316
|
+
70,
|
|
2317
|
+
56,
|
|
2318
|
+
55,
|
|
2319
|
+
97
|
|
2320
|
+
],
|
|
2321
|
+
mimeType: "image/gif"
|
|
2322
|
+
}, {
|
|
2323
|
+
signature: [
|
|
2324
|
+
71,
|
|
2325
|
+
73,
|
|
2326
|
+
70,
|
|
2327
|
+
56,
|
|
2328
|
+
57,
|
|
2329
|
+
97
|
|
2330
|
+
],
|
|
2331
|
+
mimeType: "image/gif"
|
|
2332
|
+
}],
|
|
2333
|
+
webp: [{
|
|
2334
|
+
signature: [
|
|
2335
|
+
82,
|
|
2336
|
+
73,
|
|
2337
|
+
70,
|
|
2338
|
+
70,
|
|
2339
|
+
null,
|
|
2340
|
+
null,
|
|
2341
|
+
null,
|
|
2342
|
+
null,
|
|
2343
|
+
87,
|
|
2344
|
+
69,
|
|
2345
|
+
66,
|
|
2346
|
+
80
|
|
2347
|
+
],
|
|
2348
|
+
mimeType: "image/webp"
|
|
2349
|
+
}],
|
|
2350
|
+
bmp: [{
|
|
2351
|
+
signature: [66, 77],
|
|
2352
|
+
mimeType: "image/bmp"
|
|
2353
|
+
}],
|
|
2354
|
+
ico: [{
|
|
2355
|
+
signature: [
|
|
2356
|
+
0,
|
|
2357
|
+
0,
|
|
2358
|
+
1,
|
|
2359
|
+
0
|
|
2360
|
+
],
|
|
2361
|
+
mimeType: "image/x-icon"
|
|
2362
|
+
}],
|
|
2363
|
+
tiff: [{
|
|
2364
|
+
signature: [
|
|
2365
|
+
73,
|
|
2366
|
+
73,
|
|
2367
|
+
42,
|
|
2368
|
+
0
|
|
2369
|
+
],
|
|
2370
|
+
mimeType: "image/tiff"
|
|
2371
|
+
}, {
|
|
2372
|
+
signature: [
|
|
2373
|
+
77,
|
|
2374
|
+
77,
|
|
2375
|
+
0,
|
|
2376
|
+
42
|
|
2377
|
+
],
|
|
2378
|
+
mimeType: "image/tiff"
|
|
2379
|
+
}],
|
|
2380
|
+
tif: [{
|
|
2381
|
+
signature: [
|
|
2382
|
+
73,
|
|
2383
|
+
73,
|
|
2384
|
+
42,
|
|
2385
|
+
0
|
|
2386
|
+
],
|
|
2387
|
+
mimeType: "image/tiff"
|
|
2388
|
+
}, {
|
|
2389
|
+
signature: [
|
|
2390
|
+
77,
|
|
2391
|
+
77,
|
|
2392
|
+
0,
|
|
2393
|
+
42
|
|
2394
|
+
],
|
|
2395
|
+
mimeType: "image/tiff"
|
|
2396
|
+
}],
|
|
2397
|
+
pdf: [{
|
|
2398
|
+
signature: [
|
|
2399
|
+
37,
|
|
2400
|
+
80,
|
|
2401
|
+
68,
|
|
2402
|
+
70,
|
|
2403
|
+
45
|
|
2404
|
+
],
|
|
2405
|
+
mimeType: "application/pdf"
|
|
2406
|
+
}],
|
|
2407
|
+
zip: [
|
|
2408
|
+
{
|
|
2409
|
+
signature: [
|
|
2410
|
+
80,
|
|
2411
|
+
75,
|
|
2412
|
+
3,
|
|
2413
|
+
4
|
|
2414
|
+
],
|
|
2415
|
+
mimeType: "application/zip"
|
|
2416
|
+
},
|
|
2417
|
+
{
|
|
2418
|
+
signature: [
|
|
2419
|
+
80,
|
|
2420
|
+
75,
|
|
2421
|
+
5,
|
|
2422
|
+
6
|
|
2423
|
+
],
|
|
2424
|
+
mimeType: "application/zip"
|
|
2425
|
+
},
|
|
2426
|
+
{
|
|
2427
|
+
signature: [
|
|
2428
|
+
80,
|
|
2429
|
+
75,
|
|
2430
|
+
7,
|
|
2431
|
+
8
|
|
2432
|
+
],
|
|
2433
|
+
mimeType: "application/zip"
|
|
2434
|
+
}
|
|
2435
|
+
],
|
|
2436
|
+
rar: [{
|
|
2437
|
+
signature: [
|
|
2438
|
+
82,
|
|
2439
|
+
97,
|
|
2440
|
+
114,
|
|
2441
|
+
33,
|
|
2442
|
+
26,
|
|
2443
|
+
7
|
|
2444
|
+
],
|
|
2445
|
+
mimeType: "application/vnd.rar"
|
|
2446
|
+
}],
|
|
2447
|
+
"7z": [{
|
|
2448
|
+
signature: [
|
|
2449
|
+
55,
|
|
2450
|
+
122,
|
|
2451
|
+
188,
|
|
2452
|
+
175,
|
|
2453
|
+
39,
|
|
2454
|
+
28
|
|
2455
|
+
],
|
|
2456
|
+
mimeType: "application/x-7z-compressed"
|
|
2457
|
+
}],
|
|
2458
|
+
tar: [{
|
|
2459
|
+
signature: [
|
|
2460
|
+
117,
|
|
2461
|
+
115,
|
|
2462
|
+
116,
|
|
2463
|
+
97,
|
|
2464
|
+
114
|
|
2465
|
+
],
|
|
2466
|
+
mimeType: "application/x-tar"
|
|
2467
|
+
}],
|
|
2468
|
+
gz: [{
|
|
2469
|
+
signature: [31, 139],
|
|
2470
|
+
mimeType: "application/gzip"
|
|
2471
|
+
}],
|
|
2472
|
+
tgz: [{
|
|
2473
|
+
signature: [31, 139],
|
|
2474
|
+
mimeType: "application/gzip"
|
|
2475
|
+
}],
|
|
2476
|
+
mp3: [
|
|
2477
|
+
{
|
|
2478
|
+
signature: [255, 251],
|
|
2479
|
+
mimeType: "audio/mpeg"
|
|
2480
|
+
},
|
|
2481
|
+
{
|
|
2482
|
+
signature: [255, 243],
|
|
2483
|
+
mimeType: "audio/mpeg"
|
|
2484
|
+
},
|
|
2485
|
+
{
|
|
2486
|
+
signature: [255, 242],
|
|
2487
|
+
mimeType: "audio/mpeg"
|
|
2488
|
+
},
|
|
2489
|
+
{
|
|
2490
|
+
signature: [
|
|
2491
|
+
73,
|
|
2492
|
+
68,
|
|
2493
|
+
51
|
|
2494
|
+
],
|
|
2495
|
+
mimeType: "audio/mpeg"
|
|
2496
|
+
}
|
|
2497
|
+
],
|
|
2498
|
+
wav: [{
|
|
2499
|
+
signature: [
|
|
2500
|
+
82,
|
|
2501
|
+
73,
|
|
2502
|
+
70,
|
|
2503
|
+
70,
|
|
2504
|
+
null,
|
|
2505
|
+
null,
|
|
2506
|
+
null,
|
|
2507
|
+
null,
|
|
2508
|
+
87,
|
|
2509
|
+
65,
|
|
2510
|
+
86,
|
|
2511
|
+
69
|
|
2512
|
+
],
|
|
2513
|
+
mimeType: "audio/wav"
|
|
2514
|
+
}],
|
|
2515
|
+
ogg: [{
|
|
2516
|
+
signature: [
|
|
2517
|
+
79,
|
|
2518
|
+
103,
|
|
2519
|
+
103,
|
|
2520
|
+
83
|
|
2521
|
+
],
|
|
2522
|
+
mimeType: "audio/ogg"
|
|
2523
|
+
}],
|
|
2524
|
+
flac: [{
|
|
2525
|
+
signature: [
|
|
2526
|
+
102,
|
|
2527
|
+
76,
|
|
2528
|
+
97,
|
|
2529
|
+
67
|
|
2530
|
+
],
|
|
2531
|
+
mimeType: "audio/flac"
|
|
2532
|
+
}],
|
|
2533
|
+
mp4: [
|
|
2534
|
+
{
|
|
2535
|
+
signature: [
|
|
2536
|
+
null,
|
|
2537
|
+
null,
|
|
2538
|
+
null,
|
|
2539
|
+
null,
|
|
2540
|
+
102,
|
|
2541
|
+
116,
|
|
2542
|
+
121,
|
|
2543
|
+
112
|
|
2544
|
+
],
|
|
2545
|
+
mimeType: "video/mp4"
|
|
2546
|
+
},
|
|
2547
|
+
{
|
|
2548
|
+
signature: [
|
|
2549
|
+
null,
|
|
2550
|
+
null,
|
|
2551
|
+
null,
|
|
2552
|
+
null,
|
|
2553
|
+
102,
|
|
2554
|
+
116,
|
|
2555
|
+
121,
|
|
2556
|
+
112,
|
|
2557
|
+
105,
|
|
2558
|
+
115,
|
|
2559
|
+
111,
|
|
2560
|
+
109
|
|
2561
|
+
],
|
|
2562
|
+
mimeType: "video/mp4"
|
|
2563
|
+
},
|
|
2564
|
+
{
|
|
2565
|
+
signature: [
|
|
2566
|
+
null,
|
|
2567
|
+
null,
|
|
2568
|
+
null,
|
|
2569
|
+
null,
|
|
2570
|
+
102,
|
|
2571
|
+
116,
|
|
2572
|
+
121,
|
|
2573
|
+
112,
|
|
2574
|
+
109,
|
|
2575
|
+
112,
|
|
2576
|
+
52,
|
|
2577
|
+
50
|
|
2578
|
+
],
|
|
2579
|
+
mimeType: "video/mp4"
|
|
2580
|
+
}
|
|
2581
|
+
],
|
|
2582
|
+
webm: [{
|
|
2583
|
+
signature: [
|
|
2584
|
+
26,
|
|
2585
|
+
69,
|
|
2586
|
+
223,
|
|
2587
|
+
163
|
|
2588
|
+
],
|
|
2589
|
+
mimeType: "video/webm"
|
|
2590
|
+
}],
|
|
2591
|
+
avi: [{
|
|
2592
|
+
signature: [
|
|
2593
|
+
82,
|
|
2594
|
+
73,
|
|
2595
|
+
70,
|
|
2596
|
+
70,
|
|
2597
|
+
null,
|
|
2598
|
+
null,
|
|
2599
|
+
null,
|
|
2600
|
+
null,
|
|
2601
|
+
65,
|
|
2602
|
+
86,
|
|
2603
|
+
73,
|
|
2604
|
+
32
|
|
2605
|
+
],
|
|
2606
|
+
mimeType: "video/x-msvideo"
|
|
2607
|
+
}],
|
|
2608
|
+
mov: [{
|
|
2609
|
+
signature: [
|
|
2610
|
+
null,
|
|
2611
|
+
null,
|
|
2612
|
+
null,
|
|
2613
|
+
null,
|
|
2614
|
+
102,
|
|
2615
|
+
116,
|
|
2616
|
+
121,
|
|
2617
|
+
112,
|
|
2618
|
+
113,
|
|
2619
|
+
116,
|
|
2620
|
+
32,
|
|
2621
|
+
32
|
|
2622
|
+
],
|
|
2623
|
+
mimeType: "video/quicktime"
|
|
2624
|
+
}],
|
|
2625
|
+
mkv: [{
|
|
2626
|
+
signature: [
|
|
2627
|
+
26,
|
|
2628
|
+
69,
|
|
2629
|
+
223,
|
|
2630
|
+
163
|
|
2631
|
+
],
|
|
2632
|
+
mimeType: "video/x-matroska"
|
|
2633
|
+
}],
|
|
2634
|
+
docx: [{
|
|
2635
|
+
signature: [
|
|
2636
|
+
80,
|
|
2637
|
+
75,
|
|
2638
|
+
3,
|
|
2639
|
+
4
|
|
2640
|
+
],
|
|
2641
|
+
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
2642
|
+
}],
|
|
2643
|
+
xlsx: [{
|
|
2644
|
+
signature: [
|
|
2645
|
+
80,
|
|
2646
|
+
75,
|
|
2647
|
+
3,
|
|
2648
|
+
4
|
|
2649
|
+
],
|
|
2650
|
+
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
2651
|
+
}],
|
|
2652
|
+
pptx: [{
|
|
2653
|
+
signature: [
|
|
2654
|
+
80,
|
|
2655
|
+
75,
|
|
2656
|
+
3,
|
|
2657
|
+
4
|
|
2658
|
+
],
|
|
2659
|
+
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
2660
|
+
}],
|
|
2661
|
+
doc: [{
|
|
2662
|
+
signature: [
|
|
2663
|
+
208,
|
|
2664
|
+
207,
|
|
2665
|
+
17,
|
|
2666
|
+
224,
|
|
2667
|
+
161,
|
|
2668
|
+
177,
|
|
2669
|
+
26,
|
|
2670
|
+
225
|
|
2671
|
+
],
|
|
2672
|
+
mimeType: "application/msword"
|
|
2673
|
+
}],
|
|
2674
|
+
xls: [{
|
|
2675
|
+
signature: [
|
|
2676
|
+
208,
|
|
2677
|
+
207,
|
|
2678
|
+
17,
|
|
2679
|
+
224,
|
|
2680
|
+
161,
|
|
2681
|
+
177,
|
|
2682
|
+
26,
|
|
2683
|
+
225
|
|
2684
|
+
],
|
|
2685
|
+
mimeType: "application/vnd.ms-excel"
|
|
2686
|
+
}],
|
|
2687
|
+
ppt: [{
|
|
2688
|
+
signature: [
|
|
2689
|
+
208,
|
|
2690
|
+
207,
|
|
2691
|
+
17,
|
|
2692
|
+
224,
|
|
2693
|
+
161,
|
|
2694
|
+
177,
|
|
2695
|
+
26,
|
|
2696
|
+
225
|
|
2697
|
+
],
|
|
2698
|
+
mimeType: "application/vnd.ms-powerpoint"
|
|
2699
|
+
}]
|
|
2700
|
+
};
|
|
2701
|
+
/**
|
|
2702
|
+
* All possible format signatures for checking against actual file content
|
|
2703
|
+
*/
|
|
2704
|
+
static ALL_SIGNATURES = Object.entries(FileDetector.MAGIC_BYTES).flatMap(([ext, signatures]) => signatures.map((sig) => ({
|
|
2705
|
+
ext,
|
|
2706
|
+
...sig
|
|
2707
|
+
})));
|
|
2708
|
+
/**
|
|
2709
|
+
* MIME type map for file extensions.
|
|
2710
|
+
*
|
|
2711
|
+
* Can be used to get the content type of file based on its extension.
|
|
2712
|
+
* Feel free to add more mime types in your project!
|
|
2713
|
+
*/
|
|
2714
|
+
static mimeMap = {
|
|
2715
|
+
json: "application/json",
|
|
2716
|
+
txt: "text/plain",
|
|
2717
|
+
html: "text/html",
|
|
2718
|
+
htm: "text/html",
|
|
2719
|
+
xml: "application/xml",
|
|
2720
|
+
csv: "text/csv",
|
|
2721
|
+
pdf: "application/pdf",
|
|
2722
|
+
md: "text/markdown",
|
|
2723
|
+
markdown: "text/markdown",
|
|
2724
|
+
rtf: "application/rtf",
|
|
2725
|
+
css: "text/css",
|
|
2726
|
+
js: "application/javascript",
|
|
2727
|
+
mjs: "application/javascript",
|
|
2728
|
+
ts: "application/typescript",
|
|
2729
|
+
jsx: "text/jsx",
|
|
2730
|
+
tsx: "text/tsx",
|
|
2731
|
+
zip: "application/zip",
|
|
2732
|
+
rar: "application/vnd.rar",
|
|
2733
|
+
"7z": "application/x-7z-compressed",
|
|
2734
|
+
tar: "application/x-tar",
|
|
2735
|
+
gz: "application/gzip",
|
|
2736
|
+
tgz: "application/gzip",
|
|
2737
|
+
png: "image/png",
|
|
2738
|
+
jpg: "image/jpeg",
|
|
2739
|
+
jpeg: "image/jpeg",
|
|
2740
|
+
gif: "image/gif",
|
|
2741
|
+
webp: "image/webp",
|
|
2742
|
+
svg: "image/svg+xml",
|
|
2743
|
+
bmp: "image/bmp",
|
|
2744
|
+
ico: "image/x-icon",
|
|
2745
|
+
tiff: "image/tiff",
|
|
2746
|
+
tif: "image/tiff",
|
|
2747
|
+
mp3: "audio/mpeg",
|
|
2748
|
+
wav: "audio/wav",
|
|
2749
|
+
ogg: "audio/ogg",
|
|
2750
|
+
m4a: "audio/mp4",
|
|
2751
|
+
aac: "audio/aac",
|
|
2752
|
+
flac: "audio/flac",
|
|
2753
|
+
mp4: "video/mp4",
|
|
2754
|
+
webm: "video/webm",
|
|
2755
|
+
avi: "video/x-msvideo",
|
|
2756
|
+
mov: "video/quicktime",
|
|
2757
|
+
wmv: "video/x-ms-wmv",
|
|
2758
|
+
flv: "video/x-flv",
|
|
2759
|
+
mkv: "video/x-matroska",
|
|
2760
|
+
doc: "application/msword",
|
|
2761
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
2762
|
+
xls: "application/vnd.ms-excel",
|
|
2763
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
2764
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
2765
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
2766
|
+
woff: "font/woff",
|
|
2767
|
+
woff2: "font/woff2",
|
|
2768
|
+
ttf: "font/ttf",
|
|
2769
|
+
otf: "font/otf",
|
|
2770
|
+
eot: "application/vnd.ms-fontobject"
|
|
2771
|
+
};
|
|
2772
|
+
/**
|
|
2773
|
+
* Reverse MIME type map for looking up extensions from MIME types.
|
|
2774
|
+
* Prefers shorter, more common extensions when multiple exist.
|
|
2775
|
+
*/
|
|
2776
|
+
static reverseMimeMap = (() => {
|
|
2777
|
+
const reverse = {};
|
|
2778
|
+
for (const [ext, mimeType] of Object.entries(FileDetector.mimeMap)) if (!reverse[mimeType]) reverse[mimeType] = ext;
|
|
2779
|
+
return reverse;
|
|
2780
|
+
})();
|
|
2781
|
+
/**
|
|
2782
|
+
* Returns the file extension for a given MIME type.
|
|
2783
|
+
*
|
|
2784
|
+
* @param mimeType - The MIME type to look up
|
|
2785
|
+
* @returns The file extension (without dot), or "bin" if not found
|
|
2786
|
+
*
|
|
2787
|
+
* @example
|
|
2788
|
+
* ```typescript
|
|
2789
|
+
* const detector = alepha.inject(FileDetector);
|
|
2790
|
+
* const ext = detector.getExtensionFromMimeType("image/png"); // "png"
|
|
2791
|
+
* const ext2 = detector.getExtensionFromMimeType("application/octet-stream"); // "bin"
|
|
2792
|
+
* ```
|
|
2793
|
+
*/
|
|
2794
|
+
getExtensionFromMimeType(mimeType) {
|
|
2795
|
+
return FileDetector.reverseMimeMap[mimeType] || "bin";
|
|
2796
|
+
}
|
|
2797
|
+
/**
|
|
2798
|
+
* Returns the content type of file based on its filename.
|
|
2799
|
+
*
|
|
2800
|
+
* @param filename - The filename to check
|
|
2801
|
+
* @returns The MIME type
|
|
2802
|
+
*
|
|
2803
|
+
* @example
|
|
2804
|
+
* ```typescript
|
|
2805
|
+
* const detector = alepha.inject(FileDetector);
|
|
2806
|
+
* const mimeType = detector.getContentType("image.png"); // "image/png"
|
|
2807
|
+
* ```
|
|
2808
|
+
*/
|
|
2809
|
+
getContentType(filename) {
|
|
2810
|
+
const ext = filename.toLowerCase().split(".").pop() || "";
|
|
2811
|
+
return FileDetector.mimeMap[ext] || "application/octet-stream";
|
|
2812
|
+
}
|
|
2813
|
+
/**
|
|
2814
|
+
* Detects the file type by checking magic bytes against the stream content.
|
|
2815
|
+
*
|
|
2816
|
+
* @param stream - The readable stream to check
|
|
2817
|
+
* @param filename - The filename (used to get the extension)
|
|
2818
|
+
* @returns File type information including MIME type, extension, and verification status
|
|
2819
|
+
*
|
|
2820
|
+
* @example
|
|
2821
|
+
* ```typescript
|
|
2822
|
+
* const detector = alepha.inject(FileDetector);
|
|
2823
|
+
* const stream = createReadStream('image.png');
|
|
2824
|
+
* const result = await detector.detectFileType(stream, 'image.png');
|
|
2825
|
+
* console.log(result.mimeType); // 'image/png'
|
|
2826
|
+
* console.log(result.verified); // true if magic bytes match
|
|
2827
|
+
* ```
|
|
2828
|
+
*/
|
|
2829
|
+
async detectFileType(stream, filename) {
|
|
2830
|
+
const expectedMimeType = this.getContentType(filename);
|
|
2831
|
+
const lastDotIndex = filename.lastIndexOf(".");
|
|
2832
|
+
const ext = lastDotIndex > 0 ? filename.substring(lastDotIndex + 1).toLowerCase() : "";
|
|
2833
|
+
const { buffer, stream: newStream } = await this.peekBytes(stream, 16);
|
|
2834
|
+
const expectedSignatures = FileDetector.MAGIC_BYTES[ext];
|
|
2835
|
+
if (expectedSignatures) {
|
|
2836
|
+
for (const { signature, mimeType } of expectedSignatures) if (this.matchesSignature(buffer, signature)) return {
|
|
2837
|
+
mimeType,
|
|
2838
|
+
extension: ext,
|
|
2839
|
+
verified: true,
|
|
2840
|
+
stream: newStream
|
|
2841
|
+
};
|
|
2842
|
+
}
|
|
2843
|
+
for (const { ext: detectedExt, signature, mimeType } of FileDetector.ALL_SIGNATURES) if (detectedExt !== ext && this.matchesSignature(buffer, signature)) return {
|
|
2844
|
+
mimeType,
|
|
2845
|
+
extension: detectedExt,
|
|
2846
|
+
verified: true,
|
|
2847
|
+
stream: newStream
|
|
2848
|
+
};
|
|
2849
|
+
return {
|
|
2850
|
+
mimeType: expectedMimeType,
|
|
2851
|
+
extension: ext,
|
|
2852
|
+
verified: false,
|
|
2853
|
+
stream: newStream
|
|
2854
|
+
};
|
|
2855
|
+
}
|
|
2856
|
+
/**
|
|
2857
|
+
* Reads all bytes from a stream and returns the first N bytes along with a new stream containing all data.
|
|
2858
|
+
* This approach reads the entire stream upfront to avoid complex async handling issues.
|
|
2859
|
+
*
|
|
2860
|
+
* @protected
|
|
2861
|
+
*/
|
|
2862
|
+
async peekBytes(stream, numBytes) {
|
|
2863
|
+
const chunks = [];
|
|
2864
|
+
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
2865
|
+
const allData = Buffer.concat(chunks);
|
|
2866
|
+
return {
|
|
2867
|
+
buffer: allData.subarray(0, numBytes),
|
|
2868
|
+
stream: Readable.from(allData)
|
|
2869
|
+
};
|
|
2870
|
+
}
|
|
2871
|
+
/**
|
|
2872
|
+
* Checks if a buffer matches a magic byte signature.
|
|
2873
|
+
*
|
|
2874
|
+
* @protected
|
|
2875
|
+
*/
|
|
2876
|
+
matchesSignature(buffer, signature) {
|
|
2877
|
+
if (buffer.length < signature.length) return false;
|
|
2878
|
+
for (let i = 0; i < signature.length; i++) if (signature[i] !== null && buffer[i] !== signature[i]) return false;
|
|
2879
|
+
return true;
|
|
2880
|
+
}
|
|
2881
|
+
};
|
|
2882
|
+
|
|
2883
|
+
//#endregion
|
|
2884
|
+
//#region ../../src/system/providers/NodeFileSystemProvider.ts
|
|
2885
|
+
/**
|
|
2886
|
+
* Node.js implementation of FileSystem interface.
|
|
2887
|
+
*
|
|
2888
|
+
* @example
|
|
2889
|
+
* ```typescript
|
|
2890
|
+
* const fs = alepha.inject(NodeFileSystemProvider);
|
|
2891
|
+
*
|
|
2892
|
+
* // Create from URL
|
|
2893
|
+
* const file1 = fs.createFile({ url: "file:///path/to/file.png" });
|
|
2894
|
+
*
|
|
2895
|
+
* // Create from Buffer
|
|
2896
|
+
* const file2 = fs.createFile({ buffer: Buffer.from("hello"), name: "hello.txt" });
|
|
2897
|
+
*
|
|
2898
|
+
* // Create from text
|
|
2899
|
+
* const file3 = fs.createFile({ text: "Hello, world!", name: "greeting.txt" });
|
|
2900
|
+
*
|
|
2901
|
+
* // File operations
|
|
2902
|
+
* await fs.mkdir("/tmp/mydir", { recursive: true });
|
|
2903
|
+
* await fs.cp("/src/file.txt", "/dest/file.txt");
|
|
2904
|
+
* await fs.mv("/old/path.txt", "/new/path.txt");
|
|
2905
|
+
* const files = await fs.ls("/tmp");
|
|
2906
|
+
* await fs.rm("/tmp/file.txt");
|
|
2907
|
+
* ```
|
|
2908
|
+
*/
|
|
2909
|
+
var NodeFileSystemProvider = class {
|
|
2910
|
+
detector = $inject(FileDetector);
|
|
2911
|
+
json = $inject(Json);
|
|
2912
|
+
join(...paths) {
|
|
2913
|
+
return join(...paths);
|
|
2914
|
+
}
|
|
2915
|
+
/**
|
|
2916
|
+
* Creates a FileLike object from various sources.
|
|
2917
|
+
*
|
|
2918
|
+
* @param options - Options for creating the file
|
|
2919
|
+
* @returns A FileLike object
|
|
2920
|
+
*
|
|
2921
|
+
* @example
|
|
2922
|
+
* ```typescript
|
|
2923
|
+
* const fs = alepha.inject(NodeFileSystemProvider);
|
|
2924
|
+
*
|
|
2925
|
+
* // From URL
|
|
2926
|
+
* const file1 = fs.createFile({ url: "https://example.com/image.png" });
|
|
2927
|
+
*
|
|
2928
|
+
* // From Buffer
|
|
2929
|
+
* const file2 = fs.createFile({
|
|
2930
|
+
* buffer: Buffer.from("hello"),
|
|
2931
|
+
* name: "hello.txt",
|
|
2932
|
+
* type: "text/plain"
|
|
2933
|
+
* });
|
|
2934
|
+
*
|
|
2935
|
+
* // From text
|
|
2936
|
+
* const file3 = fs.createFile({ text: "Hello!", name: "greeting.txt" });
|
|
2937
|
+
*
|
|
2938
|
+
* // From stream with detection
|
|
2939
|
+
* const stream = createReadStream("/path/to/file.png");
|
|
2940
|
+
* const file4 = fs.createFile({ stream, name: "image.png" });
|
|
2941
|
+
* ```
|
|
2942
|
+
*/
|
|
2943
|
+
createFile(options) {
|
|
2944
|
+
if ("path" in options) {
|
|
2945
|
+
const path = options.path;
|
|
2946
|
+
const filename = path.split("/").pop() || "file";
|
|
2947
|
+
return this.createFileFromUrl(`file://${path}`, {
|
|
2948
|
+
type: options.type,
|
|
2949
|
+
name: options.name || filename
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2952
|
+
if ("url" in options) return this.createFileFromUrl(options.url, {
|
|
2953
|
+
type: options.type,
|
|
2954
|
+
name: options.name
|
|
2955
|
+
});
|
|
2956
|
+
if ("response" in options) {
|
|
2957
|
+
if (!options.response.body) throw new AlephaError("Response has no body stream");
|
|
2958
|
+
const res = options.response;
|
|
2959
|
+
const sizeHeader = res.headers.get("content-length");
|
|
2960
|
+
const size = sizeHeader ? parseInt(sizeHeader, 10) : void 0;
|
|
2961
|
+
let name = options.name;
|
|
2962
|
+
const contentDisposition = res.headers.get("content-disposition");
|
|
2963
|
+
if (contentDisposition && !name) {
|
|
2964
|
+
const match = contentDisposition.match(/filename="?([^"]+)"?/);
|
|
2965
|
+
if (match) name = match[1];
|
|
2966
|
+
}
|
|
2967
|
+
const type = options.type || res.headers.get("content-type") || void 0;
|
|
2968
|
+
return this.createFileFromStream(options.response.body, {
|
|
2969
|
+
type,
|
|
2970
|
+
name,
|
|
2971
|
+
size
|
|
2972
|
+
});
|
|
2973
|
+
}
|
|
2974
|
+
if ("file" in options) return this.createFileFromWebFile(options.file, {
|
|
2975
|
+
type: options.type,
|
|
2976
|
+
name: options.name,
|
|
2977
|
+
size: options.size
|
|
2978
|
+
});
|
|
2979
|
+
if ("buffer" in options) return this.createFileFromBuffer(options.buffer, {
|
|
2980
|
+
type: options.type,
|
|
2981
|
+
name: options.name
|
|
2982
|
+
});
|
|
2983
|
+
if ("arrayBuffer" in options) return this.createFileFromBuffer(Buffer.from(options.arrayBuffer), {
|
|
2984
|
+
type: options.type,
|
|
2985
|
+
name: options.name
|
|
2986
|
+
});
|
|
2987
|
+
if ("text" in options) return this.createFileFromBuffer(Buffer.from(options.text, "utf-8"), {
|
|
2988
|
+
type: options.type || "text/plain",
|
|
2989
|
+
name: options.name || "file.txt"
|
|
2990
|
+
});
|
|
2991
|
+
if ("stream" in options) return this.createFileFromStream(options.stream, {
|
|
2992
|
+
type: options.type,
|
|
2993
|
+
name: options.name,
|
|
2994
|
+
size: options.size
|
|
2995
|
+
});
|
|
2996
|
+
throw new AlephaError("Invalid createFile options: no valid source provided");
|
|
2997
|
+
}
|
|
2998
|
+
/**
|
|
2999
|
+
* Removes a file or directory.
|
|
3000
|
+
*
|
|
3001
|
+
* @param path - The path to remove
|
|
3002
|
+
* @param options - Remove options
|
|
3003
|
+
*
|
|
3004
|
+
* @example
|
|
3005
|
+
* ```typescript
|
|
3006
|
+
* const fs = alepha.inject(NodeFileSystemProvider);
|
|
3007
|
+
*
|
|
3008
|
+
* // Remove a file
|
|
3009
|
+
* await fs.rm("/tmp/file.txt");
|
|
3010
|
+
*
|
|
3011
|
+
* // Remove a directory recursively
|
|
3012
|
+
* await fs.rm("/tmp/mydir", { recursive: true });
|
|
3013
|
+
*
|
|
3014
|
+
* // Remove with force (no error if doesn't exist)
|
|
3015
|
+
* await fs.rm("/tmp/maybe-exists.txt", { force: true });
|
|
3016
|
+
* ```
|
|
3017
|
+
*/
|
|
3018
|
+
async rm(path, options) {
|
|
3019
|
+
await rm(path, options);
|
|
3020
|
+
}
|
|
3021
|
+
/**
|
|
3022
|
+
* Copies a file or directory.
|
|
3023
|
+
*
|
|
3024
|
+
* @param src - Source path
|
|
3025
|
+
* @param dest - Destination path
|
|
3026
|
+
* @param options - Copy options
|
|
3027
|
+
*
|
|
3028
|
+
* @example
|
|
3029
|
+
* ```typescript
|
|
3030
|
+
* const fs = alepha.inject(NodeFileSystemProvider);
|
|
3031
|
+
*
|
|
3032
|
+
* // Copy a file
|
|
3033
|
+
* await fs.cp("/src/file.txt", "/dest/file.txt");
|
|
3034
|
+
*
|
|
3035
|
+
* // Copy a directory recursively
|
|
3036
|
+
* await fs.cp("/src/dir", "/dest/dir", { recursive: true });
|
|
3037
|
+
*
|
|
3038
|
+
* // Copy with force (overwrite existing)
|
|
3039
|
+
* await fs.cp("/src/file.txt", "/dest/file.txt", { force: true });
|
|
3040
|
+
* ```
|
|
3041
|
+
*/
|
|
3042
|
+
async cp(src, dest, options) {
|
|
3043
|
+
if ((await stat(src)).isDirectory()) {
|
|
3044
|
+
if (!options?.recursive) throw new Error(`Cannot copy directory without recursive option: ${src}`);
|
|
3045
|
+
await cp(src, dest, {
|
|
3046
|
+
recursive: true,
|
|
3047
|
+
force: options?.force ?? false
|
|
3048
|
+
});
|
|
3049
|
+
} else await copyFile(src, dest);
|
|
3050
|
+
}
|
|
3051
|
+
/**
|
|
3052
|
+
* Moves/renames a file or directory.
|
|
3053
|
+
*
|
|
3054
|
+
* @param src - Source path
|
|
3055
|
+
* @param dest - Destination path
|
|
3056
|
+
*
|
|
3057
|
+
* @example
|
|
3058
|
+
* ```typescript
|
|
3059
|
+
* const fs = alepha.inject(NodeFileSystemProvider);
|
|
3060
|
+
*
|
|
3061
|
+
* // Move/rename a file
|
|
3062
|
+
* await fs.mv("/old/path.txt", "/new/path.txt");
|
|
3063
|
+
*
|
|
3064
|
+
* // Move a directory
|
|
3065
|
+
* await fs.mv("/old/dir", "/new/dir");
|
|
3066
|
+
* ```
|
|
3067
|
+
*/
|
|
3068
|
+
async mv(src, dest) {
|
|
3069
|
+
await rename(src, dest);
|
|
3070
|
+
}
|
|
3071
|
+
/**
|
|
3072
|
+
* Creates a directory.
|
|
3073
|
+
*
|
|
3074
|
+
* @param path - The directory path to create
|
|
3075
|
+
* @param options - Mkdir options
|
|
3076
|
+
*
|
|
3077
|
+
* @example
|
|
3078
|
+
* ```typescript
|
|
3079
|
+
* const fs = alepha.inject(NodeFileSystemProvider);
|
|
3080
|
+
*
|
|
3081
|
+
* // Create a directory
|
|
3082
|
+
* await fs.mkdir("/tmp/mydir");
|
|
3083
|
+
*
|
|
3084
|
+
* // Create nested directories
|
|
3085
|
+
* await fs.mkdir("/tmp/path/to/dir", { recursive: true });
|
|
3086
|
+
*
|
|
3087
|
+
* // Create with specific permissions
|
|
3088
|
+
* await fs.mkdir("/tmp/mydir", { mode: 0o755 });
|
|
3089
|
+
* ```
|
|
3090
|
+
*/
|
|
3091
|
+
async mkdir(path, options = {}) {
|
|
3092
|
+
const p = mkdir(path, {
|
|
3093
|
+
recursive: options.recursive ?? true,
|
|
3094
|
+
mode: options.mode
|
|
3095
|
+
});
|
|
3096
|
+
if (options.force === false) await p;
|
|
3097
|
+
else await p.catch(() => {});
|
|
3098
|
+
}
|
|
3099
|
+
/**
|
|
3100
|
+
* Lists files in a directory.
|
|
3101
|
+
*
|
|
3102
|
+
* @param path - The directory path to list
|
|
3103
|
+
* @param options - List options
|
|
3104
|
+
* @returns Array of filenames
|
|
3105
|
+
*
|
|
3106
|
+
* @example
|
|
3107
|
+
* ```typescript
|
|
3108
|
+
* const fs = alepha.inject(NodeFileSystemProvider);
|
|
3109
|
+
*
|
|
3110
|
+
* // List files in a directory
|
|
3111
|
+
* const files = await fs.ls("/tmp");
|
|
3112
|
+
* console.log(files); // ["file1.txt", "file2.txt", "subdir"]
|
|
3113
|
+
*
|
|
3114
|
+
* // List with hidden files
|
|
3115
|
+
* const allFiles = await fs.ls("/tmp", { hidden: true });
|
|
3116
|
+
*
|
|
3117
|
+
* // List recursively
|
|
3118
|
+
* const allFilesRecursive = await fs.ls("/tmp", { recursive: true });
|
|
3119
|
+
* ```
|
|
3120
|
+
*/
|
|
3121
|
+
async ls(path, options) {
|
|
3122
|
+
const entries = await readdir(path);
|
|
3123
|
+
const filteredEntries = options?.hidden ? entries : entries.filter((e) => !e.startsWith("."));
|
|
3124
|
+
if (options?.recursive) {
|
|
3125
|
+
const allFiles = [];
|
|
3126
|
+
for (const entry of filteredEntries) {
|
|
3127
|
+
const fullPath = join(path, entry);
|
|
3128
|
+
if ((await stat(fullPath)).isDirectory()) {
|
|
3129
|
+
allFiles.push(entry);
|
|
3130
|
+
const subFiles = await this.ls(fullPath, options);
|
|
3131
|
+
allFiles.push(...subFiles.map((f) => join(entry, f)));
|
|
3132
|
+
} else allFiles.push(entry);
|
|
3133
|
+
}
|
|
3134
|
+
return allFiles;
|
|
3135
|
+
}
|
|
3136
|
+
return filteredEntries;
|
|
3137
|
+
}
|
|
3138
|
+
/**
|
|
3139
|
+
* Checks if a file or directory exists.
|
|
3140
|
+
*
|
|
3141
|
+
* @param path - The path to check
|
|
3142
|
+
* @returns True if the path exists, false otherwise
|
|
3143
|
+
*
|
|
3144
|
+
* @example
|
|
3145
|
+
* ```typescript
|
|
3146
|
+
* const fs = alepha.inject(NodeFileSystemProvider);
|
|
3147
|
+
*
|
|
3148
|
+
* if (await fs.exists("/tmp/file.txt")) {
|
|
3149
|
+
* console.log("File exists");
|
|
3150
|
+
* }
|
|
3151
|
+
* ```
|
|
3152
|
+
*/
|
|
3153
|
+
async exists(path) {
|
|
3154
|
+
try {
|
|
3155
|
+
await access(path);
|
|
3156
|
+
return true;
|
|
3157
|
+
} catch {
|
|
3158
|
+
return false;
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
/**
|
|
3162
|
+
* Reads the content of a file.
|
|
3163
|
+
*
|
|
3164
|
+
* @param path - The file path to read
|
|
3165
|
+
* @returns The file content as a Buffer
|
|
3166
|
+
*
|
|
3167
|
+
* @example
|
|
3168
|
+
* ```typescript
|
|
3169
|
+
* const fs = alepha.inject(NodeFileSystemProvider);
|
|
3170
|
+
*
|
|
3171
|
+
* const buffer = await fs.readFile("/tmp/file.txt");
|
|
3172
|
+
* console.log(buffer.toString("utf-8"));
|
|
3173
|
+
* ```
|
|
3174
|
+
*/
|
|
3175
|
+
async readFile(path) {
|
|
3176
|
+
return await readFile(path);
|
|
3177
|
+
}
|
|
3178
|
+
/**
|
|
3179
|
+
* Writes data to a file.
|
|
3180
|
+
*
|
|
3181
|
+
* @param path - The file path to write to
|
|
3182
|
+
* @param data - The data to write (Buffer or string)
|
|
3183
|
+
*
|
|
3184
|
+
* @example
|
|
3185
|
+
* ```typescript
|
|
3186
|
+
* const fs = alepha.inject(NodeFileSystemProvider);
|
|
3187
|
+
*
|
|
3188
|
+
* // Write string
|
|
3189
|
+
* await fs.writeFile("/tmp/file.txt", "Hello, world!");
|
|
3190
|
+
*
|
|
3191
|
+
* // Write Buffer
|
|
3192
|
+
* await fs.writeFile("/tmp/file.bin", Buffer.from([0x01, 0x02, 0x03]));
|
|
3193
|
+
* ```
|
|
3194
|
+
*/
|
|
3195
|
+
async writeFile(path, data) {
|
|
3196
|
+
if (isFileLike(data)) {
|
|
3197
|
+
await writeFile(path, Readable.from(data.stream()));
|
|
3198
|
+
return;
|
|
3199
|
+
}
|
|
3200
|
+
await writeFile(path, data);
|
|
3201
|
+
}
|
|
3202
|
+
/**
|
|
3203
|
+
* Reads the content of a file as a string.
|
|
3204
|
+
*
|
|
3205
|
+
* @param path - The file path to read
|
|
3206
|
+
* @returns The file content as a string
|
|
3207
|
+
*
|
|
3208
|
+
* @example
|
|
3209
|
+
* ```typescript
|
|
3210
|
+
* const fs = alepha.inject(NodeFileSystemProvider);
|
|
3211
|
+
* const content = await fs.readTextFile("/tmp/file.txt");
|
|
3212
|
+
* ```
|
|
3213
|
+
*/
|
|
3214
|
+
async readTextFile(path) {
|
|
3215
|
+
return (await this.readFile(path)).toString("utf-8");
|
|
3216
|
+
}
|
|
3217
|
+
/**
|
|
3218
|
+
* Reads the content of a file as JSON.
|
|
3219
|
+
*
|
|
3220
|
+
* @param path - The file path to read
|
|
3221
|
+
* @returns The parsed JSON content
|
|
3222
|
+
*
|
|
3223
|
+
* @example
|
|
3224
|
+
* ```typescript
|
|
3225
|
+
* const fs = alepha.inject(NodeFileSystemProvider);
|
|
3226
|
+
* const config = await fs.readJsonFile<{ name: string }>("/tmp/config.json");
|
|
3227
|
+
* ```
|
|
3228
|
+
*/
|
|
3229
|
+
async readJsonFile(path) {
|
|
3230
|
+
const text = await this.readTextFile(path);
|
|
3231
|
+
return this.json.parse(text);
|
|
3232
|
+
}
|
|
3233
|
+
/**
|
|
3234
|
+
* Creates a FileLike object from a Web File.
|
|
3235
|
+
*
|
|
3236
|
+
* @protected
|
|
3237
|
+
*/
|
|
3238
|
+
createFileFromWebFile(source, options = {}) {
|
|
3239
|
+
const name = options.name ?? source.name;
|
|
3240
|
+
return {
|
|
3241
|
+
name,
|
|
3242
|
+
type: options.type ?? (source.type || this.detector.getContentType(name)),
|
|
3243
|
+
size: options.size ?? source.size ?? 0,
|
|
3244
|
+
lastModified: source.lastModified || Date.now(),
|
|
3245
|
+
stream: () => source.stream(),
|
|
3246
|
+
arrayBuffer: async () => {
|
|
3247
|
+
return await source.arrayBuffer();
|
|
3248
|
+
},
|
|
3249
|
+
text: async () => {
|
|
3250
|
+
return await source.text();
|
|
3251
|
+
}
|
|
3252
|
+
};
|
|
3253
|
+
}
|
|
3254
|
+
/**
|
|
3255
|
+
* Creates a FileLike object from a Buffer.
|
|
3256
|
+
*
|
|
3257
|
+
* @protected
|
|
3258
|
+
*/
|
|
3259
|
+
createFileFromBuffer(source, options = {}) {
|
|
3260
|
+
const name = options.name ?? "file";
|
|
3261
|
+
return {
|
|
3262
|
+
name,
|
|
3263
|
+
type: options.type ?? this.detector.getContentType(options.name ?? name),
|
|
3264
|
+
size: source.byteLength,
|
|
3265
|
+
lastModified: Date.now(),
|
|
3266
|
+
stream: () => Readable.from(source),
|
|
3267
|
+
arrayBuffer: async () => {
|
|
3268
|
+
return this.bufferToArrayBuffer(source);
|
|
3269
|
+
},
|
|
3270
|
+
text: async () => {
|
|
3271
|
+
return source.toString("utf-8");
|
|
3272
|
+
}
|
|
3273
|
+
};
|
|
3274
|
+
}
|
|
3275
|
+
/**
|
|
3276
|
+
* Creates a FileLike object from a stream.
|
|
3277
|
+
*
|
|
3278
|
+
* @protected
|
|
3279
|
+
*/
|
|
3280
|
+
createFileFromStream(source, options = {}) {
|
|
3281
|
+
let buffer = null;
|
|
3282
|
+
return {
|
|
3283
|
+
name: options.name ?? "file",
|
|
3284
|
+
type: options.type ?? this.detector.getContentType(options.name ?? "file"),
|
|
3285
|
+
size: options.size ?? 0,
|
|
3286
|
+
lastModified: Date.now(),
|
|
3287
|
+
stream: () => source,
|
|
3288
|
+
_buffer: null,
|
|
3289
|
+
arrayBuffer: async () => {
|
|
3290
|
+
buffer ??= await this.streamToBuffer(source);
|
|
3291
|
+
return this.bufferToArrayBuffer(buffer);
|
|
3292
|
+
},
|
|
3293
|
+
text: async () => {
|
|
3294
|
+
buffer ??= await this.streamToBuffer(source);
|
|
3295
|
+
return buffer.toString("utf-8");
|
|
3296
|
+
}
|
|
3297
|
+
};
|
|
3298
|
+
}
|
|
3299
|
+
/**
|
|
3300
|
+
* Creates a FileLike object from a URL.
|
|
3301
|
+
*
|
|
3302
|
+
* @protected
|
|
3303
|
+
*/
|
|
3304
|
+
createFileFromUrl(url, options = {}) {
|
|
3305
|
+
const parsedUrl = new URL(url);
|
|
3306
|
+
const filename = options.name || parsedUrl.pathname.split("/").pop() || "file";
|
|
3307
|
+
let buffer = null;
|
|
3308
|
+
return {
|
|
3309
|
+
name: filename,
|
|
3310
|
+
type: options.type ?? this.detector.getContentType(filename),
|
|
3311
|
+
size: 0,
|
|
3312
|
+
lastModified: Date.now(),
|
|
3313
|
+
stream: () => this.createStreamFromUrl(url),
|
|
3314
|
+
arrayBuffer: async () => {
|
|
3315
|
+
buffer ??= await this.loadFromUrl(url);
|
|
3316
|
+
return this.bufferToArrayBuffer(buffer);
|
|
3317
|
+
},
|
|
3318
|
+
text: async () => {
|
|
3319
|
+
buffer ??= await this.loadFromUrl(url);
|
|
3320
|
+
return buffer.toString("utf-8");
|
|
3321
|
+
},
|
|
3322
|
+
filepath: url
|
|
3323
|
+
};
|
|
3324
|
+
}
|
|
3325
|
+
/**
|
|
3326
|
+
* Gets a streaming response from a URL.
|
|
3327
|
+
*
|
|
3328
|
+
* @protected
|
|
3329
|
+
*/
|
|
3330
|
+
getStreamingResponse(url) {
|
|
3331
|
+
const stream = new PassThrough();
|
|
3332
|
+
fetch(url).then((res) => Readable.fromWeb(res.body).pipe(stream)).catch((err) => stream.destroy(err));
|
|
3333
|
+
return stream;
|
|
3334
|
+
}
|
|
3335
|
+
/**
|
|
3336
|
+
* Loads data from a URL.
|
|
3337
|
+
*
|
|
3338
|
+
* @protected
|
|
3339
|
+
*/
|
|
3340
|
+
async loadFromUrl(url) {
|
|
3341
|
+
const parsedUrl = new URL(url);
|
|
3342
|
+
if (parsedUrl.protocol === "file:") return await readFile(fileURLToPath(url));
|
|
3343
|
+
else if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") {
|
|
3344
|
+
const response = await fetch(url);
|
|
3345
|
+
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
|
3346
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
3347
|
+
return Buffer.from(arrayBuffer);
|
|
3348
|
+
} else throw new Error(`Unsupported protocol: ${parsedUrl.protocol}`);
|
|
3349
|
+
}
|
|
3350
|
+
/**
|
|
3351
|
+
* Creates a stream from a URL.
|
|
3352
|
+
*
|
|
3353
|
+
* @protected
|
|
3354
|
+
*/
|
|
3355
|
+
createStreamFromUrl(url) {
|
|
3356
|
+
const parsedUrl = new URL(url);
|
|
3357
|
+
if (parsedUrl.protocol === "file:") return createReadStream(fileURLToPath(url));
|
|
3358
|
+
else if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") return this.getStreamingResponse(url);
|
|
3359
|
+
else throw new AlephaError(`Unsupported protocol: ${parsedUrl.protocol}`);
|
|
3360
|
+
}
|
|
3361
|
+
/**
|
|
3362
|
+
* Converts a stream-like object to a Buffer.
|
|
3363
|
+
*
|
|
3364
|
+
* @protected
|
|
3365
|
+
*/
|
|
3366
|
+
async streamToBuffer(streamLike) {
|
|
3367
|
+
const stream = streamLike instanceof Readable ? streamLike : Readable.fromWeb(streamLike);
|
|
3368
|
+
return new Promise((resolve, reject) => {
|
|
3369
|
+
const buffer = [];
|
|
3370
|
+
stream.on("data", (chunk) => buffer.push(Buffer.from(chunk)));
|
|
3371
|
+
stream.on("end", () => resolve(Buffer.concat(buffer)));
|
|
3372
|
+
stream.on("error", (err) => reject(new AlephaError("Error converting stream", { cause: err })));
|
|
3373
|
+
});
|
|
3374
|
+
}
|
|
3375
|
+
/**
|
|
3376
|
+
* Converts a Node.js Buffer to an ArrayBuffer.
|
|
3377
|
+
*
|
|
3378
|
+
* @protected
|
|
3379
|
+
*/
|
|
3380
|
+
bufferToArrayBuffer(buffer) {
|
|
3381
|
+
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
|
3382
|
+
}
|
|
3383
|
+
};
|
|
3384
|
+
|
|
3385
|
+
//#endregion
|
|
3386
|
+
//#region ../../src/system/providers/NodeShellProvider.ts
|
|
3387
|
+
/**
|
|
3388
|
+
* Node.js implementation of ShellProvider.
|
|
3389
|
+
*
|
|
3390
|
+
* Executes shell commands using Node.js child_process module.
|
|
3391
|
+
* Supports binary resolution from node_modules/.bin for local packages.
|
|
3392
|
+
*/
|
|
3393
|
+
var NodeShellProvider = class {
|
|
3394
|
+
log = $logger();
|
|
3395
|
+
fs = $inject(FileSystemProvider);
|
|
3396
|
+
/**
|
|
3397
|
+
* Run a shell command or binary.
|
|
3398
|
+
*/
|
|
3399
|
+
async run(command, options = {}) {
|
|
3400
|
+
const { resolve = false, capture = false, root, env } = options;
|
|
3401
|
+
const cwd = root ?? process.cwd();
|
|
3402
|
+
this.log.debug(`Shell: ${command}`, {
|
|
3403
|
+
cwd,
|
|
3404
|
+
resolve,
|
|
3405
|
+
capture
|
|
3406
|
+
});
|
|
3407
|
+
let executable;
|
|
3408
|
+
let args;
|
|
3409
|
+
if (resolve) {
|
|
3410
|
+
const [bin, ...rest] = command.split(" ");
|
|
3411
|
+
executable = await this.resolveExecutable(bin, cwd);
|
|
3412
|
+
args = rest;
|
|
3413
|
+
} else [executable, ...args] = command.split(" ");
|
|
3414
|
+
if (capture) return this.execCapture(command, {
|
|
3415
|
+
cwd,
|
|
3416
|
+
env
|
|
3417
|
+
});
|
|
3418
|
+
return this.execInherit(executable, args, {
|
|
3419
|
+
cwd,
|
|
3420
|
+
env
|
|
3421
|
+
});
|
|
3422
|
+
}
|
|
3423
|
+
/**
|
|
3424
|
+
* Execute command with inherited stdio (streams to terminal).
|
|
3425
|
+
*/
|
|
3426
|
+
async execInherit(executable, args, options) {
|
|
3427
|
+
const proc = spawn(executable, args, {
|
|
3428
|
+
stdio: "inherit",
|
|
3429
|
+
cwd: options.cwd,
|
|
3430
|
+
env: {
|
|
3431
|
+
...process.env,
|
|
3432
|
+
...options.env
|
|
3433
|
+
}
|
|
3434
|
+
});
|
|
3435
|
+
return new Promise((resolve, reject) => {
|
|
3436
|
+
proc.on("exit", (code) => {
|
|
3437
|
+
if (code === 0 || code === null) resolve("");
|
|
3438
|
+
else reject(new AlephaError(`Command exited with code ${code}`));
|
|
3439
|
+
});
|
|
3440
|
+
proc.on("error", reject);
|
|
3441
|
+
});
|
|
3442
|
+
}
|
|
3443
|
+
/**
|
|
3444
|
+
* Execute command and capture stdout.
|
|
3445
|
+
*/
|
|
3446
|
+
execCapture(command, options) {
|
|
3447
|
+
return new Promise((resolve, reject) => {
|
|
3448
|
+
exec(command, {
|
|
3449
|
+
cwd: options.cwd,
|
|
3450
|
+
env: {
|
|
3451
|
+
...process.env,
|
|
3452
|
+
LOG_FORMAT: "pretty",
|
|
3453
|
+
...options.env
|
|
3454
|
+
}
|
|
3455
|
+
}, (err, stdout) => {
|
|
3456
|
+
if (err) {
|
|
3457
|
+
err.stdout = stdout;
|
|
3458
|
+
reject(err);
|
|
3459
|
+
} else resolve(stdout);
|
|
3460
|
+
});
|
|
3461
|
+
});
|
|
3462
|
+
}
|
|
3463
|
+
/**
|
|
3464
|
+
* Resolve executable path from node_modules/.bin.
|
|
3465
|
+
*
|
|
3466
|
+
* Search order:
|
|
3467
|
+
* 1. Local: node_modules/.bin/
|
|
3468
|
+
* 2. Pnpm nested: node_modules/alepha/node_modules/.bin/
|
|
3469
|
+
* 3. Monorepo: Walk up to 3 parent directories
|
|
3470
|
+
*/
|
|
3471
|
+
async resolveExecutable(name, root) {
|
|
3472
|
+
const suffix = process.platform === "win32" ? ".cmd" : "";
|
|
3473
|
+
let execPath = await this.findExecutable(root, `node_modules/.bin/${name}${suffix}`);
|
|
3474
|
+
if (!execPath) execPath = await this.findExecutable(root, `node_modules/alepha/node_modules/.bin/${name}${suffix}`);
|
|
3475
|
+
if (!execPath) {
|
|
3476
|
+
let parentDir = this.fs.join(root, "..");
|
|
3477
|
+
for (let i = 0; i < 3; i++) {
|
|
3478
|
+
execPath = await this.findExecutable(parentDir, `node_modules/.bin/${name}${suffix}`);
|
|
3479
|
+
if (execPath) break;
|
|
3480
|
+
parentDir = this.fs.join(parentDir, "..");
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
if (!execPath) throw new AlephaError(`Could not find executable for '${name}'. Make sure the package is installed.`);
|
|
3484
|
+
return execPath;
|
|
3485
|
+
}
|
|
3486
|
+
/**
|
|
3487
|
+
* Check if executable exists at path.
|
|
3488
|
+
*/
|
|
3489
|
+
async findExecutable(root, relativePath) {
|
|
3490
|
+
const fullPath = this.fs.join(root, relativePath);
|
|
3491
|
+
if (await this.fs.exists(fullPath)) return fullPath;
|
|
3492
|
+
}
|
|
3493
|
+
/**
|
|
3494
|
+
* Check if a command is installed and available in the system PATH.
|
|
3495
|
+
*/
|
|
3496
|
+
isInstalled(command) {
|
|
3497
|
+
return new Promise((resolve) => {
|
|
3498
|
+
exec(process.platform === "win32" ? `where ${command}` : `command -v ${command}`, (error) => resolve(!error));
|
|
3499
|
+
});
|
|
3500
|
+
}
|
|
3501
|
+
};
|
|
3502
|
+
|
|
3503
|
+
//#endregion
|
|
3504
|
+
//#region ../../src/system/providers/ShellProvider.ts
|
|
3505
|
+
/**
|
|
3506
|
+
* Abstract provider for executing shell commands and binaries.
|
|
3507
|
+
*
|
|
3508
|
+
* Implementations:
|
|
3509
|
+
* - `NodeShellProvider` - Real shell execution using Node.js child_process
|
|
3510
|
+
* - `MemoryShellProvider` - In-memory mock for testing
|
|
3511
|
+
*
|
|
3512
|
+
* @example
|
|
3513
|
+
* ```typescript
|
|
3514
|
+
* class MyService {
|
|
3515
|
+
* protected readonly shell = $inject(ShellProvider);
|
|
3516
|
+
*
|
|
3517
|
+
* async build() {
|
|
3518
|
+
* // Run shell command directly
|
|
3519
|
+
* await this.shell.run("yarn install");
|
|
3520
|
+
*
|
|
3521
|
+
* // Run local binary with resolution
|
|
3522
|
+
* await this.shell.run("vite build", { resolve: true });
|
|
3523
|
+
*
|
|
3524
|
+
* // Capture output
|
|
3525
|
+
* const output = await this.shell.run("echo hello", { capture: true });
|
|
3526
|
+
* }
|
|
3527
|
+
* }
|
|
3528
|
+
* ```
|
|
3529
|
+
*/
|
|
3530
|
+
var ShellProvider = class {};
|
|
3531
|
+
|
|
3532
|
+
//#endregion
|
|
3533
|
+
//#region ../../src/system/index.ts
|
|
3534
|
+
/**
|
|
3535
|
+
* | type | quality | stability |
|
|
3536
|
+
* |------|---------|-----------|
|
|
3537
|
+
* | tooling | standard | stable |
|
|
3538
|
+
*
|
|
3539
|
+
* System-level abstractions for portable code across runtimes.
|
|
3540
|
+
*
|
|
3541
|
+
* **Features:**
|
|
3542
|
+
* - File system operations (read, write, exists, etc.)
|
|
3543
|
+
* - Shell command execution
|
|
3544
|
+
* - File type detection and MIME utilities
|
|
3545
|
+
* - Memory implementations for testing
|
|
3546
|
+
*
|
|
3547
|
+
* @module alepha.system
|
|
3548
|
+
*/
|
|
3549
|
+
const AlephaSystem = $module({
|
|
3550
|
+
name: "alepha.system",
|
|
3551
|
+
primitives: [],
|
|
3552
|
+
services: [
|
|
3553
|
+
FileDetector,
|
|
3554
|
+
FileSystemProvider,
|
|
3555
|
+
MemoryFileSystemProvider,
|
|
3556
|
+
NodeFileSystemProvider,
|
|
3557
|
+
ShellProvider,
|
|
3558
|
+
MemoryShellProvider,
|
|
3559
|
+
NodeShellProvider
|
|
3560
|
+
],
|
|
3561
|
+
register: (alepha) => alepha.with({
|
|
3562
|
+
optional: true,
|
|
3563
|
+
provide: FileSystemProvider,
|
|
3564
|
+
use: NodeFileSystemProvider
|
|
3565
|
+
}).with({
|
|
3566
|
+
optional: true,
|
|
3567
|
+
provide: ShellProvider,
|
|
3568
|
+
use: alepha.isTest() ? MemoryShellProvider : NodeShellProvider
|
|
3569
|
+
})
|
|
3570
|
+
});
|
|
3571
|
+
|
|
3572
|
+
//#endregion
|
|
3573
|
+
//#region ../../src/react/router/providers/ReactServerTemplateProvider.ts
|
|
3574
|
+
/**
|
|
3575
|
+
* Handles HTML streaming for SSR.
|
|
3576
|
+
*
|
|
3577
|
+
* Uses hardcoded HTML structure - all customization via $head primitive.
|
|
3578
|
+
* Pre-encodes static parts as Uint8Array for zero-copy streaming.
|
|
3579
|
+
*/
|
|
3580
|
+
var ReactServerTemplateProvider = class {
|
|
3581
|
+
log = $logger();
|
|
3582
|
+
alepha = $inject(Alepha);
|
|
3583
|
+
/**
|
|
3584
|
+
* Shared TextEncoder - reused across all requests.
|
|
3585
|
+
*/
|
|
3586
|
+
encoder = new TextEncoder();
|
|
3587
|
+
/**
|
|
3588
|
+
* Pre-encoded static HTML parts for zero-copy streaming.
|
|
3589
|
+
*/
|
|
3590
|
+
SLOTS = {
|
|
3591
|
+
DOCTYPE: this.encoder.encode("<!DOCTYPE html>\n"),
|
|
3592
|
+
HTML_OPEN: this.encoder.encode("<html"),
|
|
3593
|
+
HTML_CLOSE: this.encoder.encode(">\n"),
|
|
3594
|
+
HEAD_OPEN: this.encoder.encode("<head>"),
|
|
3595
|
+
HEAD_CLOSE: this.encoder.encode("</head>\n"),
|
|
3596
|
+
BODY_OPEN: this.encoder.encode("<body"),
|
|
3597
|
+
BODY_CLOSE: this.encoder.encode(">\n"),
|
|
3598
|
+
ROOT_OPEN: this.encoder.encode("<div id=\"root\">"),
|
|
3599
|
+
ROOT_CLOSE: this.encoder.encode("</div>\n"),
|
|
3600
|
+
BODY_HTML_CLOSE: this.encoder.encode("</body>\n</html>"),
|
|
3601
|
+
HYDRATION_PREFIX: this.encoder.encode("<script>window.__ssr="),
|
|
3602
|
+
HYDRATION_SUFFIX: this.encoder.encode("<\/script>")
|
|
3603
|
+
};
|
|
3604
|
+
/**
|
|
3605
|
+
* Early head content (charset, viewport, entry assets).
|
|
3606
|
+
* Set once during configuration, reused for all requests.
|
|
3607
|
+
*/
|
|
3608
|
+
earlyHeadContent = "";
|
|
3609
|
+
/**
|
|
3610
|
+
* Root element ID for React mounting.
|
|
3611
|
+
*/
|
|
3612
|
+
rootId = "root";
|
|
3613
|
+
/**
|
|
3614
|
+
* Regex for extracting root div content from HTML.
|
|
3615
|
+
*/
|
|
3616
|
+
rootDivRegex = new RegExp(`<div[^>]*\\s+id=["']${this.rootId}["'][^>]*>([\\s\\S]*?)<\\/div>`, "i");
|
|
3617
|
+
/**
|
|
3618
|
+
* Extract content inside the root div from HTML.
|
|
3619
|
+
*/
|
|
3620
|
+
extractRootContent(html) {
|
|
3621
|
+
return html.match(this.rootDivRegex)?.[1];
|
|
3622
|
+
}
|
|
3623
|
+
/**
|
|
3624
|
+
* Set early head content (charset, viewport, entry assets).
|
|
3625
|
+
* Called once during server configuration.
|
|
3626
|
+
*/
|
|
3627
|
+
setEarlyHeadContent(entryAssets, globalHead) {
|
|
3628
|
+
const charset = globalHead?.charset ?? "UTF-8";
|
|
3629
|
+
const viewport = globalHead?.viewport ?? "width=device-width, initial-scale=1";
|
|
3630
|
+
this.earlyHeadContent = `<meta charset="${this.escapeHtml(charset)}">\n<meta name="viewport" content="${this.escapeHtml(viewport)}">\n` + entryAssets;
|
|
3631
|
+
}
|
|
3632
|
+
/**
|
|
3633
|
+
* Render attributes record to HTML string.
|
|
3634
|
+
*/
|
|
3635
|
+
renderAttributes(attrs) {
|
|
3636
|
+
if (!attrs) return "";
|
|
3637
|
+
const entries = Object.entries(attrs);
|
|
3638
|
+
if (entries.length === 0) return "";
|
|
3639
|
+
return entries.map(([key, value]) => ` ${key}="${this.escapeHtml(value)}"`).join("");
|
|
3640
|
+
}
|
|
3641
|
+
/**
|
|
3642
|
+
* Render head content (title, meta, link, script tags).
|
|
3643
|
+
*/
|
|
3644
|
+
renderHeadContent(head) {
|
|
3645
|
+
if (!head) return "";
|
|
3646
|
+
let content = "";
|
|
3647
|
+
if (head.title) content += `<title>${this.escapeHtml(head.title)}</title>\n`;
|
|
3648
|
+
if (head.meta) {
|
|
3649
|
+
for (const meta of head.meta) if (meta.property) content += `<meta property="${this.escapeHtml(meta.property)}" content="${this.escapeHtml(meta.content)}">\n`;
|
|
3650
|
+
else if (meta.name) content += `<meta name="${this.escapeHtml(meta.name)}" content="${this.escapeHtml(meta.content)}">\n`;
|
|
3651
|
+
}
|
|
3652
|
+
if (head.link) for (const link of head.link) {
|
|
3653
|
+
content += `<link rel="${this.escapeHtml(link.rel)}" href="${this.escapeHtml(link.href)}"`;
|
|
3654
|
+
if (link.type) content += ` type="${this.escapeHtml(link.type)}"`;
|
|
3655
|
+
if (link.as) content += ` as="${this.escapeHtml(link.as)}"`;
|
|
3656
|
+
if (link.crossorigin != null) content += " crossorigin=\"\"";
|
|
3657
|
+
content += ">\n";
|
|
3658
|
+
}
|
|
3659
|
+
if (head.script) for (const script of head.script) if (typeof script === "string") content += `<script>${script}<\/script>\n`;
|
|
3660
|
+
else {
|
|
3661
|
+
const { content: scriptContent, ...rest } = script;
|
|
3662
|
+
const attrs = Object.entries(rest).filter(([, v]) => v !== false && v !== void 0).map(([k, v]) => v === true ? k : `${k}="${this.escapeHtml(String(v))}"`).join(" ");
|
|
3663
|
+
content += scriptContent ? `<script ${attrs}>${scriptContent}<\/script>\n` : `<script ${attrs}><\/script>\n`;
|
|
3664
|
+
}
|
|
3665
|
+
return content;
|
|
3666
|
+
}
|
|
3667
|
+
/**
|
|
3668
|
+
* Escape HTML special characters.
|
|
3669
|
+
*/
|
|
3670
|
+
escapeHtml(str) {
|
|
3671
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3672
|
+
}
|
|
3673
|
+
/**
|
|
3674
|
+
* Safely serialize data to JSON for embedding in HTML.
|
|
3675
|
+
*/
|
|
3676
|
+
safeJsonSerialize(data) {
|
|
3677
|
+
return JSON.stringify(data).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
|
3678
|
+
}
|
|
3679
|
+
/**
|
|
3680
|
+
* Build hydration data from router state.
|
|
3681
|
+
*/
|
|
3682
|
+
buildHydrationData(state) {
|
|
3683
|
+
const { request, context, ...store } = this.alepha.context.als?.getStore() ?? {};
|
|
3684
|
+
const hydrationData = { layers: state.layers.map((layer) => ({
|
|
3685
|
+
part: layer.part,
|
|
3686
|
+
name: layer.name,
|
|
3687
|
+
config: layer.config,
|
|
3688
|
+
props: layer.props,
|
|
3689
|
+
error: layer.error ? {
|
|
3690
|
+
...layer.error,
|
|
3691
|
+
name: layer.error.name,
|
|
3692
|
+
message: layer.error.message,
|
|
3693
|
+
stack: !this.alepha.isProduction() ? layer.error.stack : void 0
|
|
3694
|
+
} : void 0
|
|
3695
|
+
})) };
|
|
3696
|
+
for (const [key, value] of Object.entries(store)) if (key.charAt(0) !== "_" && key !== "alepha.react.router.state" && key !== "registry") hydrationData[key] = value;
|
|
3697
|
+
return hydrationData;
|
|
3698
|
+
}
|
|
3699
|
+
/**
|
|
3700
|
+
* Pipe React stream to controller with backpressure handling.
|
|
3701
|
+
* Returns true if stream completed successfully, false if error occurred.
|
|
3702
|
+
*/
|
|
3703
|
+
async pipeReactStream(controller, reactStream, state) {
|
|
3704
|
+
const reader = reactStream.getReader();
|
|
3705
|
+
try {
|
|
3706
|
+
while (true) {
|
|
3707
|
+
if (controller.desiredSize !== null && controller.desiredSize <= 0) await new Promise((resolve) => queueMicrotask(resolve));
|
|
3708
|
+
const { done, value } = await reader.read();
|
|
3709
|
+
if (done) break;
|
|
3710
|
+
controller.enqueue(value);
|
|
3711
|
+
}
|
|
3712
|
+
return true;
|
|
3713
|
+
} catch (error) {
|
|
3714
|
+
this.log.error("React stream error", error);
|
|
3715
|
+
controller.enqueue(this.encoder.encode(this.renderErrorToString(error instanceof Error ? error : new Error(String(error)), state)));
|
|
3716
|
+
return false;
|
|
3717
|
+
} finally {
|
|
3718
|
+
reader.releaseLock();
|
|
3719
|
+
}
|
|
3720
|
+
}
|
|
3721
|
+
/**
|
|
3722
|
+
* Stream complete HTML document (head already closed).
|
|
3723
|
+
* Used by both createHtmlStream and late phase of createEarlyHtmlStream.
|
|
3724
|
+
*/
|
|
3725
|
+
async streamBodyAndClose(controller, reactStream, state, hydration) {
|
|
3726
|
+
const { encoder, SLOTS: slots } = this;
|
|
3727
|
+
controller.enqueue(slots.BODY_OPEN);
|
|
3728
|
+
controller.enqueue(encoder.encode(this.renderAttributes(state.head?.bodyAttributes)));
|
|
3729
|
+
controller.enqueue(slots.BODY_CLOSE);
|
|
3730
|
+
controller.enqueue(slots.ROOT_OPEN);
|
|
3731
|
+
await this.pipeReactStream(controller, reactStream, state);
|
|
3732
|
+
controller.enqueue(slots.ROOT_CLOSE);
|
|
3733
|
+
if (hydration) {
|
|
3734
|
+
controller.enqueue(slots.HYDRATION_PREFIX);
|
|
3735
|
+
controller.enqueue(encoder.encode(this.safeJsonSerialize(this.buildHydrationData(state))));
|
|
3736
|
+
controller.enqueue(slots.HYDRATION_SUFFIX);
|
|
3737
|
+
}
|
|
3738
|
+
controller.enqueue(slots.BODY_HTML_CLOSE);
|
|
3739
|
+
}
|
|
3740
|
+
/**
|
|
3741
|
+
* Create HTML stream with early head optimization.
|
|
3742
|
+
*
|
|
3743
|
+
* Flow:
|
|
3744
|
+
* 1. Send DOCTYPE, <html>, <head> open, entry preloads (IMMEDIATE)
|
|
3745
|
+
* 2. Run async work (page loaders)
|
|
3746
|
+
* 3. Send rest of head, body, React content, hydration
|
|
3747
|
+
*/
|
|
3748
|
+
createEarlyHtmlStream(globalHead, asyncWork, options = {}) {
|
|
3749
|
+
const { hydration = true, onError } = options;
|
|
3750
|
+
const { encoder, SLOTS: slots } = this;
|
|
3751
|
+
let headClosed = false;
|
|
3752
|
+
let bodyStarted = false;
|
|
3753
|
+
let routerState;
|
|
3754
|
+
return new ReadableStream({ start: async (controller) => {
|
|
3755
|
+
try {
|
|
3756
|
+
controller.enqueue(slots.DOCTYPE);
|
|
3757
|
+
controller.enqueue(slots.HTML_OPEN);
|
|
3758
|
+
controller.enqueue(encoder.encode(this.renderAttributes(globalHead?.htmlAttributes)));
|
|
3759
|
+
controller.enqueue(slots.HTML_CLOSE);
|
|
3760
|
+
controller.enqueue(slots.HEAD_OPEN);
|
|
3761
|
+
if (this.earlyHeadContent) controller.enqueue(encoder.encode(this.earlyHeadContent));
|
|
3762
|
+
const result = await asyncWork();
|
|
3763
|
+
if (!result || "redirect" in result) {
|
|
3764
|
+
if (result && "redirect" in result) {
|
|
3765
|
+
this.log.debug("Loader redirect, using meta refresh", { redirect: result.redirect });
|
|
3766
|
+
controller.enqueue(encoder.encode(`<meta http-equiv="refresh" content="0; url=${this.escapeHtml(result.redirect)}">\n`));
|
|
3767
|
+
}
|
|
3768
|
+
controller.enqueue(slots.HEAD_CLOSE);
|
|
3769
|
+
controller.enqueue(encoder.encode("<body></body></html>"));
|
|
3770
|
+
controller.close();
|
|
3771
|
+
return;
|
|
3772
|
+
}
|
|
3773
|
+
const { state, reactStream } = result;
|
|
3774
|
+
routerState = state;
|
|
3775
|
+
controller.enqueue(encoder.encode(this.renderHeadContent(state.head)));
|
|
3776
|
+
controller.enqueue(slots.HEAD_CLOSE);
|
|
3777
|
+
headClosed = true;
|
|
3778
|
+
bodyStarted = true;
|
|
3779
|
+
await this.streamBodyAndClose(controller, reactStream, state, hydration);
|
|
3780
|
+
controller.close();
|
|
3781
|
+
} catch (error) {
|
|
3782
|
+
onError?.(error);
|
|
3783
|
+
try {
|
|
3784
|
+
this.injectErrorHtml(controller, error, routerState, {
|
|
3785
|
+
headClosed,
|
|
3786
|
+
bodyStarted
|
|
3787
|
+
});
|
|
3788
|
+
controller.close();
|
|
3789
|
+
} catch {
|
|
3790
|
+
controller.error(error);
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
} });
|
|
3794
|
+
}
|
|
3795
|
+
/**
|
|
3796
|
+
* Create HTML stream (non-early version, for testing/prerender).
|
|
3797
|
+
*/
|
|
3798
|
+
createHtmlStream(reactStream, state, options = {}) {
|
|
3799
|
+
const { hydration = true, onError } = options;
|
|
3800
|
+
const { encoder, SLOTS: slots } = this;
|
|
3801
|
+
return new ReadableStream({ start: async (controller) => {
|
|
3802
|
+
try {
|
|
3803
|
+
controller.enqueue(slots.DOCTYPE);
|
|
3804
|
+
controller.enqueue(slots.HTML_OPEN);
|
|
3805
|
+
controller.enqueue(encoder.encode(this.renderAttributes(state.head?.htmlAttributes)));
|
|
3806
|
+
controller.enqueue(slots.HTML_CLOSE);
|
|
3807
|
+
controller.enqueue(slots.HEAD_OPEN);
|
|
3808
|
+
if (this.earlyHeadContent) controller.enqueue(encoder.encode(this.earlyHeadContent));
|
|
3809
|
+
controller.enqueue(encoder.encode(this.renderHeadContent(state.head)));
|
|
3810
|
+
controller.enqueue(slots.HEAD_CLOSE);
|
|
3811
|
+
await this.streamBodyAndClose(controller, reactStream, state, hydration);
|
|
3812
|
+
controller.close();
|
|
3813
|
+
} catch (error) {
|
|
3814
|
+
onError?.(error);
|
|
3815
|
+
controller.error(error);
|
|
3816
|
+
}
|
|
3817
|
+
} });
|
|
3818
|
+
}
|
|
3819
|
+
/**
|
|
3820
|
+
* Inject error HTML when streaming fails.
|
|
3821
|
+
*/
|
|
3822
|
+
injectErrorHtml(controller, error, routerState, streamState) {
|
|
3823
|
+
const { encoder, SLOTS: slots } = this;
|
|
3824
|
+
if (!streamState.headClosed) {
|
|
3825
|
+
controller.enqueue(encoder.encode(this.renderHeadContent(routerState?.head)));
|
|
3826
|
+
controller.enqueue(slots.HEAD_CLOSE);
|
|
3827
|
+
}
|
|
3828
|
+
if (!streamState.bodyStarted) {
|
|
3829
|
+
controller.enqueue(slots.BODY_OPEN);
|
|
3830
|
+
controller.enqueue(encoder.encode(this.renderAttributes(routerState?.head?.bodyAttributes)));
|
|
3831
|
+
controller.enqueue(slots.BODY_CLOSE);
|
|
3832
|
+
controller.enqueue(slots.ROOT_OPEN);
|
|
3833
|
+
}
|
|
3834
|
+
controller.enqueue(encoder.encode(this.renderErrorToString(error instanceof Error ? error : new Error(String(error)), routerState)));
|
|
3835
|
+
controller.enqueue(slots.ROOT_CLOSE);
|
|
3836
|
+
controller.enqueue(slots.BODY_HTML_CLOSE);
|
|
3837
|
+
}
|
|
3838
|
+
/**
|
|
3839
|
+
* Render error to HTML string.
|
|
3840
|
+
*/
|
|
3841
|
+
renderErrorToString(error, routerState) {
|
|
3842
|
+
this.log.error("SSR rendering error", error);
|
|
3843
|
+
let errorElement;
|
|
3844
|
+
if (routerState?.onError) try {
|
|
3845
|
+
const result = routerState.onError(error, routerState);
|
|
3846
|
+
if (result instanceof Redirection) this.log.warn("Error handler returned Redirection but headers sent", { redirect: result.redirect });
|
|
3847
|
+
else if (result != null) errorElement = result;
|
|
3848
|
+
} catch (handlerError) {
|
|
3849
|
+
this.log.error("Error handler threw", handlerError);
|
|
3850
|
+
}
|
|
3851
|
+
if (!errorElement) errorElement = createElement(ErrorViewer_default, {
|
|
3852
|
+
error,
|
|
3853
|
+
alepha: this.alepha
|
|
3854
|
+
});
|
|
3855
|
+
const wrappedElement = createElement(AlephaContext.Provider, { value: this.alepha }, errorElement);
|
|
3856
|
+
try {
|
|
3857
|
+
return renderToString(wrappedElement);
|
|
3858
|
+
} catch (renderError) {
|
|
3859
|
+
this.log.error("Failed to render error component", renderError);
|
|
3860
|
+
return error.message;
|
|
3861
|
+
}
|
|
3862
|
+
}
|
|
3863
|
+
};
|
|
3864
|
+
|
|
3865
|
+
//#endregion
|
|
3866
|
+
//#region ../../src/react/router/providers/ReactServerProvider.ts
|
|
3867
|
+
/**
|
|
3868
|
+
* React server provider responsible for SSR and static file serving.
|
|
3869
|
+
*
|
|
3870
|
+
* Coordinates between:
|
|
3871
|
+
* - ReactPageProvider: Page routing and layer resolution
|
|
3872
|
+
* - ReactServerTemplateProvider: HTML template parsing and streaming
|
|
3873
|
+
* - ServerHeadProvider: Head content management
|
|
3874
|
+
* - SSRManifestProvider: Module preload link collection
|
|
3875
|
+
*
|
|
3876
|
+
* Uses `react-dom/server` under the hood.
|
|
3877
|
+
*/
|
|
3878
|
+
var ReactServerProvider = class {
|
|
3879
|
+
/**
|
|
3880
|
+
* SSR response headers - pre-allocated to avoid object creation per request.
|
|
3881
|
+
*/
|
|
3882
|
+
SSR_HEADERS = {
|
|
3883
|
+
"content-type": "text/html",
|
|
3884
|
+
"cache-control": "no-store, no-cache, must-revalidate, proxy-revalidate",
|
|
3885
|
+
pragma: "no-cache",
|
|
3886
|
+
expires: "0"
|
|
3887
|
+
};
|
|
3888
|
+
fs = $inject(FileSystemProvider);
|
|
3889
|
+
log = $logger();
|
|
3890
|
+
alepha = $inject(Alepha);
|
|
3891
|
+
env = $env(envSchema);
|
|
3892
|
+
pageApi = $inject(ReactPageProvider);
|
|
3893
|
+
templateProvider = $inject(ReactServerTemplateProvider);
|
|
3894
|
+
serverHeadProvider = $inject(ServerHeadProvider);
|
|
3895
|
+
serverStaticProvider = $inject(ServerStaticProvider);
|
|
3896
|
+
serverRouterProvider = $inject(ServerRouterProvider);
|
|
3897
|
+
ssrManifestProvider = $inject(SSRManifestProvider);
|
|
3898
|
+
/**
|
|
3899
|
+
* Cached check for ServerLinksProvider - avoids has() lookup per request.
|
|
3900
|
+
*/
|
|
3901
|
+
hasServerLinksProvider = false;
|
|
3902
|
+
options = $use(reactServerOptions);
|
|
3903
|
+
/**
|
|
3904
|
+
* Configure the React server provider.
|
|
3905
|
+
*/
|
|
3906
|
+
onConfigure = $hook({
|
|
3907
|
+
on: "configure",
|
|
3908
|
+
handler: async () => {
|
|
3909
|
+
const ssrEnabled = this.alepha.primitives($page).length > 0 && this.env.REACT_SSR_ENABLED !== false;
|
|
3910
|
+
this.alepha.store.set("alepha.react.server.ssr", ssrEnabled);
|
|
3911
|
+
let root = "";
|
|
3912
|
+
if (!this.alepha.isServerless() && !this.alepha.isViteDev()) {
|
|
3913
|
+
root = await this.getPublicDirectory();
|
|
3914
|
+
if (!root) this.log.warn("Missing static files, static file server will be disabled");
|
|
3915
|
+
else {
|
|
3916
|
+
this.log.debug(`Using static files from: ${root}`);
|
|
3917
|
+
await this.configureStaticServer(root);
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
if (ssrEnabled) {
|
|
3921
|
+
this.registerPages();
|
|
3922
|
+
this.log.info("SSR OK");
|
|
3923
|
+
return;
|
|
3924
|
+
}
|
|
3925
|
+
this.log.info("SSR is disabled");
|
|
3926
|
+
}
|
|
3927
|
+
});
|
|
3928
|
+
/**
|
|
3929
|
+
* Register all pages as server routes.
|
|
3930
|
+
*/
|
|
3931
|
+
registerPages() {
|
|
3932
|
+
this.setupEarlyHeadContent();
|
|
3933
|
+
this.hasServerLinksProvider = this.alepha.has(ServerLinksProvider);
|
|
3934
|
+
for (const page of this.pageApi.getPages()) if (page.component || page.lazy) {
|
|
3935
|
+
this.log.debug(`+ ${page.match} -> ${page.name}`);
|
|
3936
|
+
this.serverRouterProvider.createRoute({
|
|
3937
|
+
...page,
|
|
3938
|
+
schema: void 0,
|
|
3939
|
+
method: "GET",
|
|
3940
|
+
path: page.match,
|
|
3941
|
+
handler: this.createHandler(page)
|
|
3942
|
+
});
|
|
3943
|
+
}
|
|
3944
|
+
}
|
|
3945
|
+
/**
|
|
3946
|
+
* Set up early head content with entry assets.
|
|
3947
|
+
*
|
|
3948
|
+
* This content is sent immediately when streaming starts, before page loaders run,
|
|
3949
|
+
* allowing the browser to start downloading entry.js and CSS files early.
|
|
3950
|
+
*/
|
|
3951
|
+
setupEarlyHeadContent() {
|
|
3952
|
+
const assets = this.ssrManifestProvider.getEntryAssets();
|
|
3953
|
+
const globalHead = this.serverHeadProvider.resolveGlobalHead();
|
|
3954
|
+
const parts = [];
|
|
3955
|
+
if (assets) {
|
|
3956
|
+
for (const css of assets.css) parts.push(`<link rel="stylesheet" href="${css}" crossorigin="">`);
|
|
3957
|
+
if (assets.js) parts.push(`<script type="module" crossorigin="" src="${assets.js}"><\/script>`);
|
|
3958
|
+
}
|
|
3959
|
+
this.templateProvider.setEarlyHeadContent(parts.length > 0 ? `${parts.join("\n")}\n` : "", globalHead);
|
|
3960
|
+
this.log.debug("Early head content set", {
|
|
3961
|
+
css: assets?.css.length ?? 0,
|
|
3962
|
+
js: assets?.js ? 1 : 0
|
|
3963
|
+
});
|
|
3964
|
+
}
|
|
3965
|
+
/**
|
|
3966
|
+
* Get the public directory path where static files are located.
|
|
3967
|
+
*/
|
|
3968
|
+
async getPublicDirectory() {
|
|
3969
|
+
const maybe = [join(process.cwd(), `dist/${this.options.publicDir}`), join(process.cwd(), this.options.publicDir)];
|
|
3970
|
+
for (const it of maybe) if (await this.fs.exists(it)) return it;
|
|
3971
|
+
return "";
|
|
3972
|
+
}
|
|
3973
|
+
/**
|
|
3974
|
+
* Configure the static file server to serve files from the given root directory.
|
|
3975
|
+
*/
|
|
3976
|
+
async configureStaticServer(root) {
|
|
3977
|
+
await this.serverStaticProvider.createStaticServer({
|
|
3978
|
+
root,
|
|
3979
|
+
cacheControl: {
|
|
3980
|
+
maxAge: 3600,
|
|
3981
|
+
immutable: true
|
|
3982
|
+
},
|
|
3983
|
+
...this.options.staticServer
|
|
3984
|
+
});
|
|
3985
|
+
}
|
|
3986
|
+
/**
|
|
3987
|
+
* Create the request handler for a page route.
|
|
3988
|
+
*/
|
|
3989
|
+
createHandler(route) {
|
|
3990
|
+
return async (serverRequest) => {
|
|
3991
|
+
const { url, reply, query, params } = serverRequest;
|
|
3992
|
+
this.log.trace("Rendering page", { name: route.name });
|
|
3993
|
+
const state = {
|
|
3994
|
+
url,
|
|
3995
|
+
params,
|
|
3996
|
+
query,
|
|
3997
|
+
name: route.name,
|
|
3998
|
+
onError: () => null,
|
|
3999
|
+
layers: [],
|
|
4000
|
+
meta: {},
|
|
4001
|
+
head: {}
|
|
4002
|
+
};
|
|
4003
|
+
if (this.hasServerLinksProvider) this.alepha.store.set("alepha.server.request.apiLinks", await this.alepha.inject(ServerLinksProvider).getUserApiLinks({
|
|
4004
|
+
user: serverRequest.user,
|
|
4005
|
+
authorization: serverRequest.headers.authorization
|
|
4006
|
+
}));
|
|
4007
|
+
let target = route;
|
|
4008
|
+
while (target) {
|
|
4009
|
+
if (route.can && !route.can()) {
|
|
4010
|
+
this.log.warn(`Access to page '${route.name}' is forbidden by can() check`);
|
|
4011
|
+
reply.status = 403;
|
|
4012
|
+
reply.headers["content-type"] = "text/plain";
|
|
4013
|
+
return "Forbidden";
|
|
4014
|
+
}
|
|
4015
|
+
target = target.parent;
|
|
4016
|
+
}
|
|
4017
|
+
await this.alepha.events.emit("react:server:render:begin", {
|
|
4018
|
+
request: serverRequest,
|
|
4019
|
+
state
|
|
4020
|
+
});
|
|
4021
|
+
Object.assign(reply.headers, this.SSR_HEADERS);
|
|
4022
|
+
const globalHead = this.serverHeadProvider.resolveGlobalHead();
|
|
4023
|
+
const htmlStream = this.templateProvider.createEarlyHtmlStream(globalHead, async () => {
|
|
4024
|
+
const result = await this.renderPage(route, state);
|
|
4025
|
+
if (result.redirect) return { redirect: result.redirect };
|
|
4026
|
+
return {
|
|
4027
|
+
state,
|
|
4028
|
+
reactStream: result.reactStream
|
|
4029
|
+
};
|
|
4030
|
+
}, {
|
|
4031
|
+
hydration: true,
|
|
4032
|
+
onError: (error) => {
|
|
4033
|
+
if (error instanceof Redirection) this.log.debug("Streaming resulted in redirection", { redirect: error.redirect });
|
|
4034
|
+
}
|
|
4035
|
+
});
|
|
4036
|
+
this.log.trace("Page streaming started (early head optimization)");
|
|
4037
|
+
route.onServerResponse?.(serverRequest);
|
|
4038
|
+
reply.body = htmlStream;
|
|
4039
|
+
};
|
|
4040
|
+
}
|
|
4041
|
+
/**
|
|
4042
|
+
* Core page rendering logic shared between SSR handler and static prerendering.
|
|
4043
|
+
*
|
|
4044
|
+
* Handles:
|
|
4045
|
+
* - Layer resolution (loaders)
|
|
4046
|
+
* - Redirect detection
|
|
4047
|
+
* - Head content filling
|
|
4048
|
+
* - Preload link collection
|
|
4049
|
+
* - React stream rendering
|
|
4050
|
+
*
|
|
4051
|
+
* @param route - The page route to render
|
|
4052
|
+
* @param state - The router state
|
|
4053
|
+
* @returns Render result with redirect or React stream
|
|
4054
|
+
*/
|
|
4055
|
+
async renderPage(route, state) {
|
|
4056
|
+
const { redirect } = await this.pageApi.createLayers(route, state);
|
|
4057
|
+
if (redirect) {
|
|
4058
|
+
this.log.debug("Resolver resulted in redirection", { redirect });
|
|
4059
|
+
return { redirect };
|
|
4060
|
+
}
|
|
4061
|
+
this.serverHeadProvider.fillHead(state);
|
|
4062
|
+
const preloadLinks = this.ssrManifestProvider.collectPreloadLinks(route);
|
|
4063
|
+
if (preloadLinks.length > 0) {
|
|
4064
|
+
state.head ??= {};
|
|
4065
|
+
state.head.link = [...state.head.link ?? [], ...preloadLinks];
|
|
4066
|
+
}
|
|
4067
|
+
const element = this.pageApi.root(state);
|
|
4068
|
+
this.alepha.store.set("alepha.react.router.state", state);
|
|
4069
|
+
return { reactStream: await renderToReadableStream(element, { onError: (error) => {
|
|
4070
|
+
if (error instanceof Redirection) this.log.warn("Redirect during streaming ignored", { redirect: error.redirect });
|
|
4071
|
+
} }) };
|
|
4072
|
+
}
|
|
4073
|
+
/**
|
|
4074
|
+
* For testing purposes, renders a page to HTML string.
|
|
4075
|
+
* Uses the same streaming code path as production, then collects to string.
|
|
4076
|
+
*
|
|
4077
|
+
* @param name - Page name to render
|
|
4078
|
+
* @param options - Render options (params, query, html, hydration)
|
|
4079
|
+
*/
|
|
4080
|
+
async render(name, options = {}) {
|
|
4081
|
+
const page = this.pageApi.page(name);
|
|
4082
|
+
const url = new URL(this.pageApi.url(name, options));
|
|
4083
|
+
const state = {
|
|
4084
|
+
url,
|
|
4085
|
+
params: options.params ?? {},
|
|
4086
|
+
query: options.query ?? {},
|
|
4087
|
+
onError: () => null,
|
|
4088
|
+
layers: [],
|
|
4089
|
+
meta: {},
|
|
4090
|
+
head: {}
|
|
4091
|
+
};
|
|
4092
|
+
this.log.trace("Rendering", { url });
|
|
4093
|
+
await this.alepha.events.emit("react:server:render:begin", { state });
|
|
4094
|
+
const result = await this.renderPage(page, state);
|
|
4095
|
+
if (result.redirect) return {
|
|
4096
|
+
state,
|
|
4097
|
+
html: "",
|
|
4098
|
+
redirect: result.redirect
|
|
4099
|
+
};
|
|
4100
|
+
const reactStream = result.reactStream;
|
|
4101
|
+
if (!options.html) return {
|
|
4102
|
+
state,
|
|
4103
|
+
html: await this.streamToString(reactStream)
|
|
4104
|
+
};
|
|
4105
|
+
const htmlStream = this.templateProvider.createHtmlStream(reactStream, state, { hydration: options.hydration ?? true });
|
|
4106
|
+
const html = await this.streamToString(htmlStream);
|
|
4107
|
+
await this.alepha.events.emit("react:server:render:end", {
|
|
4108
|
+
state,
|
|
4109
|
+
html
|
|
4110
|
+
});
|
|
4111
|
+
return {
|
|
4112
|
+
state,
|
|
4113
|
+
html
|
|
4114
|
+
};
|
|
4115
|
+
}
|
|
4116
|
+
/**
|
|
4117
|
+
* Collect a ReadableStream into a string.
|
|
4118
|
+
*/
|
|
4119
|
+
async streamToString(stream) {
|
|
4120
|
+
const reader = stream.getReader();
|
|
4121
|
+
const decoder = new TextDecoder();
|
|
4122
|
+
const chunks = [];
|
|
4123
|
+
try {
|
|
4124
|
+
while (true) {
|
|
4125
|
+
const { done, value } = await reader.read();
|
|
4126
|
+
if (done) break;
|
|
4127
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
4128
|
+
}
|
|
4129
|
+
chunks.push(decoder.decode());
|
|
4130
|
+
} finally {
|
|
4131
|
+
reader.releaseLock();
|
|
4132
|
+
}
|
|
4133
|
+
return chunks.join("");
|
|
4134
|
+
}
|
|
4135
|
+
};
|
|
4136
|
+
const envSchema = t.object({ REACT_SSR_ENABLED: t.optional(t.boolean()) });
|
|
4137
|
+
/**
|
|
4138
|
+
* React server provider configuration atom
|
|
4139
|
+
*/
|
|
4140
|
+
const reactServerOptions = $atom({
|
|
4141
|
+
name: "alepha.react.server.options",
|
|
4142
|
+
schema: t.object({
|
|
4143
|
+
publicDir: t.string(),
|
|
4144
|
+
staticServer: t.object({
|
|
4145
|
+
disabled: t.boolean(),
|
|
4146
|
+
path: t.string({ description: "URL path where static files will be served." })
|
|
4147
|
+
})
|
|
4148
|
+
}),
|
|
4149
|
+
default: {
|
|
4150
|
+
publicDir: "public",
|
|
4151
|
+
staticServer: {
|
|
4152
|
+
disabled: false,
|
|
4153
|
+
path: "/"
|
|
4154
|
+
}
|
|
4155
|
+
}
|
|
4156
|
+
});
|
|
4157
|
+
|
|
4158
|
+
//#endregion
|
|
4159
|
+
//#region ../../src/react/router/services/ReactPageServerService.ts
|
|
4160
|
+
/**
|
|
4161
|
+
* $page methods for server-side.
|
|
4162
|
+
*/
|
|
4163
|
+
var ReactPageServerService = class extends ReactPageService {
|
|
4164
|
+
reactServerProvider = $inject(ReactServerProvider);
|
|
4165
|
+
templateProvider = $inject(ReactServerTemplateProvider);
|
|
4166
|
+
serverProvider = $inject(ServerProvider);
|
|
4167
|
+
async render(name, options = {}) {
|
|
4168
|
+
return this.reactServerProvider.render(name, options);
|
|
4169
|
+
}
|
|
4170
|
+
async fetch(pathname, options = {}) {
|
|
4171
|
+
const response = await fetch(`${this.serverProvider.hostname}/${pathname}`);
|
|
4172
|
+
const html = await response.text();
|
|
4173
|
+
if (options?.html) return {
|
|
4174
|
+
html,
|
|
4175
|
+
response
|
|
4176
|
+
};
|
|
4177
|
+
const rootContent = this.templateProvider.extractRootContent(html);
|
|
4178
|
+
if (rootContent !== void 0) return {
|
|
4179
|
+
html: rootContent,
|
|
4180
|
+
response
|
|
4181
|
+
};
|
|
4182
|
+
throw new AlephaError("Invalid HTML response");
|
|
4183
|
+
}
|
|
4184
|
+
};
|
|
4185
|
+
|
|
4186
|
+
//#endregion
|
|
4187
|
+
//#region ../../src/react/router/providers/ReactBrowserRouterProvider.ts
|
|
4188
|
+
/**
|
|
4189
|
+
* Implementation of AlephaRouter for React in browser environment.
|
|
4190
|
+
*/
|
|
4191
|
+
var ReactBrowserRouterProvider = class extends RouterProvider {
|
|
4192
|
+
log = $logger();
|
|
4193
|
+
alepha = $inject(Alepha);
|
|
4194
|
+
pageApi = $inject(ReactPageProvider);
|
|
4195
|
+
browserHeadProvider = $inject(BrowserHeadProvider);
|
|
4196
|
+
add(entry) {
|
|
4197
|
+
this.pageApi.add(entry);
|
|
4198
|
+
}
|
|
4199
|
+
configure = $hook({
|
|
4200
|
+
on: "configure",
|
|
4201
|
+
handler: async () => {
|
|
4202
|
+
for (const page of this.pageApi.getPages()) if (page.component || page.lazy) this.push({
|
|
4203
|
+
path: page.match,
|
|
4204
|
+
page
|
|
4205
|
+
});
|
|
4206
|
+
}
|
|
4207
|
+
});
|
|
4208
|
+
async transition(url, previous = [], meta = {}) {
|
|
4209
|
+
const { pathname, search } = url;
|
|
4210
|
+
const state = {
|
|
4211
|
+
url,
|
|
4212
|
+
query: {},
|
|
4213
|
+
params: {},
|
|
4214
|
+
layers: [],
|
|
4215
|
+
onError: () => null,
|
|
4216
|
+
meta
|
|
4217
|
+
};
|
|
4218
|
+
await this.alepha.events.emit("react:action:begin", { type: "transition" });
|
|
4219
|
+
await this.alepha.events.emit("react:transition:begin", {
|
|
4220
|
+
previous: this.alepha.store.get("alepha.react.router.state"),
|
|
4221
|
+
state
|
|
4222
|
+
});
|
|
4223
|
+
try {
|
|
4224
|
+
const { route, params } = this.match(pathname);
|
|
4225
|
+
const query = {};
|
|
4226
|
+
if (search) for (const [key, value] of new URLSearchParams(search).entries()) query[key] = String(value);
|
|
4227
|
+
state.name = route?.page.name;
|
|
4228
|
+
state.query = query;
|
|
4229
|
+
state.params = params ?? {};
|
|
4230
|
+
if (isPageRoute(route)) {
|
|
4231
|
+
const { redirect } = await this.pageApi.createLayers(route.page, state, previous);
|
|
4232
|
+
if (redirect) return redirect;
|
|
4233
|
+
}
|
|
4234
|
+
if (state.layers.length === 0) state.layers.push({
|
|
4235
|
+
name: "not-found",
|
|
4236
|
+
element: createElement(NotFound_default),
|
|
4237
|
+
index: 0,
|
|
4238
|
+
path: "/"
|
|
4239
|
+
});
|
|
4240
|
+
await this.alepha.events.emit("react:action:success", { type: "transition" });
|
|
4241
|
+
await this.alepha.events.emit("react:transition:success", { state });
|
|
4242
|
+
} catch (e) {
|
|
4243
|
+
this.log.error("Transition has failed", e);
|
|
4244
|
+
state.layers = [{
|
|
4245
|
+
name: "error",
|
|
4246
|
+
element: this.pageApi.renderError(e),
|
|
4247
|
+
index: 0,
|
|
4248
|
+
path: "/"
|
|
4249
|
+
}];
|
|
4250
|
+
await this.alepha.events.emit("react:action:error", {
|
|
4251
|
+
type: "transition",
|
|
4252
|
+
error: e
|
|
4253
|
+
});
|
|
4254
|
+
await this.alepha.events.emit("react:transition:error", {
|
|
4255
|
+
error: e,
|
|
4256
|
+
state
|
|
4257
|
+
});
|
|
4258
|
+
}
|
|
4259
|
+
if (previous) for (let i = 0; i < previous.length; i++) {
|
|
4260
|
+
const layer = previous[i];
|
|
4261
|
+
if (state.layers[i]?.name !== layer.name) this.pageApi.page(layer.name)?.onLeave?.();
|
|
4262
|
+
}
|
|
4263
|
+
for (let i = 0; i < state.layers.length; i++) {
|
|
4264
|
+
const layer = state.layers[i];
|
|
4265
|
+
if (previous?.[i]?.name !== layer.name) this.pageApi.page(layer.name)?.onEnter?.();
|
|
4266
|
+
}
|
|
4267
|
+
this.alepha.store.set("alepha.react.router.state", state);
|
|
4268
|
+
await this.alepha.events.emit("react:action:end", { type: "transition" });
|
|
4269
|
+
await this.alepha.events.emit("react:transition:end", { state });
|
|
4270
|
+
this.browserHeadProvider.fillAndRenderHead(state);
|
|
4271
|
+
}
|
|
4272
|
+
root(state) {
|
|
4273
|
+
return this.pageApi.root(state);
|
|
4274
|
+
}
|
|
4275
|
+
};
|
|
4276
|
+
|
|
4277
|
+
//#endregion
|
|
4278
|
+
//#region ../../src/react/router/providers/ReactBrowserProvider.ts
|
|
4279
|
+
/**
|
|
4280
|
+
* React browser renderer configuration atom
|
|
4281
|
+
*/
|
|
4282
|
+
const reactBrowserOptions = $atom({
|
|
4283
|
+
name: "alepha.react.browser.options",
|
|
4284
|
+
schema: t.object({ scrollRestoration: t.enum(["top", "manual"]) }),
|
|
4285
|
+
default: { scrollRestoration: "top" }
|
|
4286
|
+
});
|
|
4287
|
+
var ReactBrowserProvider = class {
|
|
4288
|
+
log = $logger();
|
|
4289
|
+
client = $inject(LinkProvider);
|
|
4290
|
+
alepha = $inject(Alepha);
|
|
4291
|
+
router = $inject(ReactBrowserRouterProvider);
|
|
4292
|
+
dateTimeProvider = $inject(DateTimeProvider);
|
|
4293
|
+
browserHeadProvider = $inject(BrowserHeadProvider);
|
|
4294
|
+
options = $use(reactBrowserOptions);
|
|
4295
|
+
get rootId() {
|
|
4296
|
+
return "root";
|
|
4297
|
+
}
|
|
4298
|
+
getRootElement() {
|
|
4299
|
+
const root = this.document.getElementById(this.rootId);
|
|
4300
|
+
if (root) return root;
|
|
4301
|
+
const div = this.document.createElement("div");
|
|
4302
|
+
div.id = this.rootId;
|
|
4303
|
+
this.document.body.prepend(div);
|
|
4304
|
+
return div;
|
|
4305
|
+
}
|
|
4306
|
+
transitioning;
|
|
4307
|
+
get state() {
|
|
4308
|
+
return this.alepha.store.get("alepha.react.router.state");
|
|
4309
|
+
}
|
|
4310
|
+
/**
|
|
4311
|
+
* Accessor for Document DOM API.
|
|
4312
|
+
*/
|
|
4313
|
+
get document() {
|
|
4314
|
+
return window.document;
|
|
4315
|
+
}
|
|
4316
|
+
/**
|
|
4317
|
+
* Accessor for History DOM API.
|
|
4318
|
+
*/
|
|
4319
|
+
get history() {
|
|
4320
|
+
return window.history;
|
|
4321
|
+
}
|
|
4322
|
+
/**
|
|
4323
|
+
* Accessor for Location DOM API.
|
|
4324
|
+
*/
|
|
4325
|
+
get location() {
|
|
4326
|
+
return window.location;
|
|
4327
|
+
}
|
|
4328
|
+
get base() {
|
|
4329
|
+
const base = import.meta.env?.BASE_URL;
|
|
4330
|
+
if (!base || base === "/") return "";
|
|
4331
|
+
return base;
|
|
4332
|
+
}
|
|
4333
|
+
get url() {
|
|
4334
|
+
const url = this.location.pathname + this.location.search;
|
|
4335
|
+
if (this.base) return url.replace(this.base, "");
|
|
4336
|
+
return url;
|
|
4337
|
+
}
|
|
4338
|
+
pushState(path, replace) {
|
|
4339
|
+
const url = this.base + path;
|
|
4340
|
+
if (replace) this.history.replaceState({}, "", url);
|
|
4341
|
+
else this.history.pushState({}, "", url);
|
|
4342
|
+
}
|
|
4343
|
+
async invalidate(props) {
|
|
4344
|
+
const previous = [];
|
|
4345
|
+
this.log.trace("Invalidating layers");
|
|
4346
|
+
if (props) {
|
|
4347
|
+
const [key] = Object.keys(props);
|
|
4348
|
+
const value = props[key];
|
|
4349
|
+
for (const layer of this.state.layers) {
|
|
4350
|
+
if (layer.props?.[key]) {
|
|
4351
|
+
previous.push({
|
|
4352
|
+
...layer,
|
|
4353
|
+
props: {
|
|
4354
|
+
...layer.props,
|
|
4355
|
+
[key]: value
|
|
4356
|
+
}
|
|
4357
|
+
});
|
|
4358
|
+
break;
|
|
4359
|
+
}
|
|
4360
|
+
previous.push(layer);
|
|
4361
|
+
}
|
|
4362
|
+
}
|
|
4363
|
+
await this.render({ previous });
|
|
4364
|
+
}
|
|
4365
|
+
async push(url, options = {}) {
|
|
4366
|
+
this.log.trace(`Going to ${url}`, {
|
|
4367
|
+
url,
|
|
4368
|
+
options
|
|
4369
|
+
});
|
|
4370
|
+
await this.render({
|
|
4371
|
+
url,
|
|
4372
|
+
previous: options.force ? [] : this.state.layers,
|
|
4373
|
+
meta: options.meta
|
|
4374
|
+
});
|
|
4375
|
+
if (this.state.url.pathname + this.state.url.search !== url) {
|
|
4376
|
+
this.pushState(this.state.url.pathname + this.state.url.search);
|
|
4377
|
+
return;
|
|
4378
|
+
}
|
|
4379
|
+
this.pushState(url, options.replace);
|
|
4380
|
+
}
|
|
4381
|
+
async render(options = {}) {
|
|
4382
|
+
const previous = options.previous ?? this.state.layers;
|
|
4383
|
+
const url = options.url ?? this.url;
|
|
4384
|
+
const start = this.dateTimeProvider.now();
|
|
4385
|
+
this.transitioning = {
|
|
4386
|
+
to: url,
|
|
4387
|
+
from: this.state?.url.pathname
|
|
4388
|
+
};
|
|
4389
|
+
this.log.debug("Transitioning...", { to: url });
|
|
4390
|
+
const redirect = await this.router.transition(new URL(`http://localhost${url}`), previous, options.meta);
|
|
4391
|
+
if (redirect) {
|
|
4392
|
+
this.log.info("Redirecting to", { redirect });
|
|
4393
|
+
if (redirect.startsWith("http")) window.location.href = redirect;
|
|
4394
|
+
else return await this.render({ url: redirect });
|
|
4395
|
+
}
|
|
4396
|
+
const ms = this.dateTimeProvider.now().diff(start);
|
|
4397
|
+
this.log.info(`Transition OK [${ms}ms]`, this.transitioning);
|
|
4398
|
+
this.transitioning = void 0;
|
|
4399
|
+
}
|
|
4400
|
+
/**
|
|
4401
|
+
* Get embedded layers from the server.
|
|
4402
|
+
*/
|
|
4403
|
+
getHydrationState() {
|
|
4404
|
+
try {
|
|
4405
|
+
if ("__ssr" in window && typeof window.__ssr === "object") return window.__ssr;
|
|
4406
|
+
} catch (error) {
|
|
4407
|
+
console.error(error);
|
|
4408
|
+
}
|
|
4409
|
+
}
|
|
4410
|
+
onTransitionEnd = $hook({
|
|
4411
|
+
on: "react:transition:end",
|
|
4412
|
+
handler: () => {
|
|
4413
|
+
if (this.options.scrollRestoration === "top" && typeof window !== "undefined" && !this.alepha.isTest()) {
|
|
4414
|
+
this.log.trace("Restoring scroll position to top");
|
|
4415
|
+
window.scrollTo(0, 0);
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4418
|
+
});
|
|
4419
|
+
ready = $hook({
|
|
4420
|
+
on: "ready",
|
|
4421
|
+
handler: async () => {
|
|
4422
|
+
const hydration = this.getHydrationState();
|
|
4423
|
+
const previous = hydration?.layers ?? [];
|
|
4424
|
+
if (hydration) {
|
|
4425
|
+
for (const [key, value] of Object.entries(hydration)) if (key !== "layers") this.alepha.set(key, value);
|
|
4426
|
+
}
|
|
4427
|
+
await this.render({ previous });
|
|
4428
|
+
const element = this.router.root(this.state);
|
|
4429
|
+
await this.alepha.events.emit("react:browser:render", {
|
|
4430
|
+
element,
|
|
4431
|
+
root: this.getRootElement(),
|
|
4432
|
+
hydration,
|
|
4433
|
+
state: this.state
|
|
4434
|
+
});
|
|
4435
|
+
this.browserHeadProvider.fillAndRenderHead(this.state);
|
|
4436
|
+
window.addEventListener("popstate", () => {
|
|
4437
|
+
if (this.base + this.state.url.pathname === this.location.pathname) return;
|
|
4438
|
+
this.log.debug("Popstate event triggered - rendering new state", { url: this.location.pathname + this.location.search });
|
|
4439
|
+
this.render();
|
|
4440
|
+
});
|
|
4441
|
+
}
|
|
4442
|
+
});
|
|
4443
|
+
};
|
|
4444
|
+
|
|
4445
|
+
//#endregion
|
|
4446
|
+
//#region ../../src/react/router/services/ReactRouter.ts
|
|
4447
|
+
/**
|
|
4448
|
+
* Friendly browser router API.
|
|
4449
|
+
*
|
|
4450
|
+
* Can be safely used server-side, but most methods will be no-op.
|
|
4451
|
+
*/
|
|
4452
|
+
var ReactRouter = class {
|
|
4453
|
+
alepha = $inject(Alepha);
|
|
4454
|
+
pageApi = $inject(ReactPageProvider);
|
|
4455
|
+
get state() {
|
|
4456
|
+
return this.alepha.store.get("alepha.react.router.state");
|
|
4457
|
+
}
|
|
4458
|
+
get pages() {
|
|
4459
|
+
return this.pageApi.getPages();
|
|
4460
|
+
}
|
|
4461
|
+
get concretePages() {
|
|
4462
|
+
return this.pageApi.getConcretePages();
|
|
4463
|
+
}
|
|
4464
|
+
get browser() {
|
|
4465
|
+
if (this.alepha.isBrowser()) return this.alepha.inject(ReactBrowserProvider);
|
|
4466
|
+
}
|
|
4467
|
+
isActive(href, options = {}) {
|
|
4468
|
+
const current = this.state.url.pathname;
|
|
4469
|
+
let isActive = current === href || current === `${href}/` || `${current}/` === href;
|
|
4470
|
+
if (options.startWith && !isActive) isActive = current.startsWith(href);
|
|
4471
|
+
return isActive;
|
|
4472
|
+
}
|
|
4473
|
+
node(name, config = {}) {
|
|
4474
|
+
const page = this.pageApi.page(name);
|
|
4475
|
+
if (!page.lazy && !page.component) return {
|
|
4476
|
+
...page,
|
|
4477
|
+
label: page.label ?? page.name,
|
|
4478
|
+
children: void 0
|
|
4479
|
+
};
|
|
4480
|
+
return {
|
|
4481
|
+
...page,
|
|
4482
|
+
label: page.label ?? page.name,
|
|
4483
|
+
href: this.path(name, config),
|
|
4484
|
+
children: void 0
|
|
4485
|
+
};
|
|
4486
|
+
}
|
|
4487
|
+
path(name, config = {}) {
|
|
4488
|
+
return this.pageApi.pathname(name, {
|
|
4489
|
+
params: {
|
|
4490
|
+
...this.state?.params,
|
|
4491
|
+
...config.params
|
|
4492
|
+
},
|
|
4493
|
+
query: config.query
|
|
4494
|
+
});
|
|
4495
|
+
}
|
|
4496
|
+
/**
|
|
4497
|
+
* Reload the current page.
|
|
4498
|
+
* This is equivalent to calling `go()` with the current pathname and search.
|
|
4499
|
+
*/
|
|
4500
|
+
async reload() {
|
|
4501
|
+
if (!this.browser) return;
|
|
4502
|
+
await this.push(this.location.pathname + this.location.search, {
|
|
4503
|
+
replace: true,
|
|
4504
|
+
force: true
|
|
4505
|
+
});
|
|
4506
|
+
}
|
|
4507
|
+
getURL() {
|
|
4508
|
+
if (!this.browser) return this.state.url;
|
|
4509
|
+
return new URL(this.location.href);
|
|
4510
|
+
}
|
|
4511
|
+
get location() {
|
|
4512
|
+
if (!this.browser) throw new Error("Browser is required");
|
|
4513
|
+
return this.browser.location;
|
|
4514
|
+
}
|
|
4515
|
+
get current() {
|
|
4516
|
+
return this.state;
|
|
4517
|
+
}
|
|
4518
|
+
get pathname() {
|
|
4519
|
+
return this.state.url.pathname;
|
|
4520
|
+
}
|
|
4521
|
+
get query() {
|
|
4522
|
+
const query = {};
|
|
4523
|
+
for (const [key, value] of new URLSearchParams(this.state.url.search).entries()) query[key] = String(value);
|
|
4524
|
+
return query;
|
|
4525
|
+
}
|
|
4526
|
+
async back() {
|
|
4527
|
+
this.browser?.history.back();
|
|
4528
|
+
}
|
|
4529
|
+
async forward() {
|
|
4530
|
+
this.browser?.history.forward();
|
|
4531
|
+
}
|
|
4532
|
+
async invalidate(props) {
|
|
4533
|
+
await this.browser?.invalidate(props);
|
|
4534
|
+
}
|
|
4535
|
+
async push(path, options) {
|
|
4536
|
+
for (const page of this.pages) if (page.name === path) {
|
|
4537
|
+
await this.browser?.push(this.path(path, options), options);
|
|
4538
|
+
return;
|
|
4539
|
+
}
|
|
4540
|
+
await this.browser?.push(path, options);
|
|
4541
|
+
}
|
|
4542
|
+
anchor(path, options = {}) {
|
|
4543
|
+
let href = path;
|
|
4544
|
+
for (const page of this.pages) if (page.name === path) {
|
|
4545
|
+
href = this.path(path, options);
|
|
4546
|
+
break;
|
|
4547
|
+
}
|
|
4548
|
+
return {
|
|
4549
|
+
href: this.base(href),
|
|
4550
|
+
onClick: (ev) => {
|
|
4551
|
+
ev.stopPropagation();
|
|
4552
|
+
ev.preventDefault();
|
|
4553
|
+
this.push(href, options).catch(console.error);
|
|
4554
|
+
}
|
|
4555
|
+
};
|
|
4556
|
+
}
|
|
4557
|
+
base(path) {
|
|
4558
|
+
const base = import.meta.env?.BASE_URL;
|
|
4559
|
+
if (!base || base === "/") return path;
|
|
4560
|
+
return base + path;
|
|
4561
|
+
}
|
|
4562
|
+
/**
|
|
4563
|
+
* Set query params.
|
|
4564
|
+
*
|
|
4565
|
+
* @param record
|
|
4566
|
+
* @param options
|
|
4567
|
+
*/
|
|
4568
|
+
setQueryParams(record, options = {}) {
|
|
4569
|
+
const func = typeof record === "function" ? record : () => record;
|
|
4570
|
+
const search = new URLSearchParams(func(this.query)).toString();
|
|
4571
|
+
const state = search ? `${this.pathname}?${search}` : this.pathname;
|
|
4572
|
+
if (options.push) window.history.pushState({}, "", state);
|
|
4573
|
+
else window.history.replaceState({}, "", state);
|
|
4574
|
+
}
|
|
4575
|
+
};
|
|
4576
|
+
|
|
4577
|
+
//#endregion
|
|
4578
|
+
//#region ../../src/react/router/hooks/useRouter.ts
|
|
4579
|
+
/**
|
|
4580
|
+
* Use this hook to access the React Router instance.
|
|
4581
|
+
*
|
|
4582
|
+
* You can add a type parameter to specify the type of your application.
|
|
4583
|
+
* This will allow you to use the router in a typesafe way.
|
|
4584
|
+
*
|
|
4585
|
+
* @example
|
|
4586
|
+
* class App {
|
|
4587
|
+
* home = $page();
|
|
4588
|
+
* }
|
|
4589
|
+
*
|
|
4590
|
+
* const router = useRouter<App>();
|
|
4591
|
+
* router.push("home"); // typesafe
|
|
4592
|
+
*/
|
|
4593
|
+
const useRouter = () => {
|
|
4594
|
+
return useInject(ReactRouter);
|
|
4595
|
+
};
|
|
4596
|
+
|
|
4597
|
+
//#endregion
|
|
4598
|
+
//#region ../../src/react/router/components/Link.tsx
|
|
4599
|
+
/**
|
|
4600
|
+
* Link component for client-side navigation.
|
|
4601
|
+
*
|
|
4602
|
+
* It's a simple wrapper around an anchor (`<a>`) element using the `useRouter` hook.
|
|
4603
|
+
*/
|
|
4604
|
+
const Link = (props) => {
|
|
4605
|
+
const router = useRouter();
|
|
4606
|
+
return createElement("a", {
|
|
4607
|
+
...props,
|
|
4608
|
+
...router.anchor(props.href)
|
|
4609
|
+
}, props.children);
|
|
4610
|
+
};
|
|
4611
|
+
var Link_default = Link;
|
|
4612
|
+
|
|
4613
|
+
//#endregion
|
|
4614
|
+
//#region ../../src/react/router/hooks/useActive.ts
|
|
4615
|
+
/**
|
|
4616
|
+
* Hook to determine if a given route is active and to provide anchor props for navigation.
|
|
4617
|
+
* This hook refreshes on router state changes.
|
|
4618
|
+
*/
|
|
4619
|
+
const useActive = (args) => {
|
|
4620
|
+
useRouterState();
|
|
4621
|
+
const router = useRouter();
|
|
4622
|
+
const [isPending, setPending] = useState(false);
|
|
4623
|
+
const options = typeof args === "string" ? { href: args } : {
|
|
4624
|
+
...args,
|
|
4625
|
+
href: args.href
|
|
4626
|
+
};
|
|
4627
|
+
const href = options.href;
|
|
4628
|
+
const isActive = router.isActive(href, options);
|
|
4629
|
+
return {
|
|
4630
|
+
isPending,
|
|
4631
|
+
isActive,
|
|
4632
|
+
anchorProps: {
|
|
4633
|
+
href: router.base(href),
|
|
4634
|
+
onClick: async (ev) => {
|
|
4635
|
+
ev?.stopPropagation();
|
|
4636
|
+
ev?.preventDefault();
|
|
4637
|
+
if (isActive) return;
|
|
4638
|
+
if (isPending) return;
|
|
4639
|
+
setPending(true);
|
|
4640
|
+
try {
|
|
4641
|
+
await router.push(href);
|
|
4642
|
+
} finally {
|
|
4643
|
+
setPending(false);
|
|
4644
|
+
}
|
|
4645
|
+
}
|
|
4646
|
+
}
|
|
4647
|
+
};
|
|
4648
|
+
};
|
|
4649
|
+
|
|
4650
|
+
//#endregion
|
|
4651
|
+
//#region ../../src/react/router/hooks/useQueryParams.ts
|
|
4652
|
+
/**
|
|
4653
|
+
* Hook to manage query parameters in the URL using a defined schema.
|
|
4654
|
+
*/
|
|
4655
|
+
const useQueryParams = (schema, options = {}) => {
|
|
4656
|
+
const alepha = useAlepha();
|
|
4657
|
+
const key = options.key ?? "q";
|
|
4658
|
+
const router = useRouter();
|
|
4659
|
+
const querystring = router.query[key];
|
|
4660
|
+
const [queryParams = {}, setQueryParams] = useState(decode(alepha, schema, router.query[key]));
|
|
4661
|
+
useEffect(() => {
|
|
4662
|
+
setQueryParams(decode(alepha, schema, querystring));
|
|
4663
|
+
}, [querystring]);
|
|
4664
|
+
return [queryParams, (queryParams) => {
|
|
4665
|
+
setQueryParams(queryParams);
|
|
4666
|
+
router.setQueryParams((data) => {
|
|
4667
|
+
return {
|
|
4668
|
+
...data,
|
|
4669
|
+
[key]: encode(alepha, schema, queryParams)
|
|
4670
|
+
};
|
|
4671
|
+
});
|
|
4672
|
+
}];
|
|
4673
|
+
};
|
|
4674
|
+
const encode = (alepha, schema, data) => {
|
|
4675
|
+
return btoa(JSON.stringify(alepha.codec.decode(schema, data)));
|
|
4676
|
+
};
|
|
4677
|
+
const decode = (alepha, schema, data) => {
|
|
4678
|
+
try {
|
|
4679
|
+
return alepha.codec.decode(schema, JSON.parse(atob(decodeURIComponent(data))));
|
|
4680
|
+
} catch {
|
|
4681
|
+
return;
|
|
4682
|
+
}
|
|
4683
|
+
};
|
|
4684
|
+
|
|
4685
|
+
//#endregion
|
|
4686
|
+
//#region ../../src/react/router/index.ts
|
|
4687
|
+
/**
|
|
4688
|
+
* Provides declarative routing with the `$page` primitive for building type-safe React routes.
|
|
4689
|
+
*
|
|
4690
|
+
* This module enables:
|
|
4691
|
+
* - URL pattern matching with parameters (e.g., `/users/:id`)
|
|
4692
|
+
* - Nested routing with parent-child relationships
|
|
4693
|
+
* - Type-safe URL parameter and query string validation
|
|
4694
|
+
* - Server-side data fetching with the `loader` function
|
|
4695
|
+
* - Lazy loading and code splitting
|
|
4696
|
+
* - Page animations and error handling
|
|
4697
|
+
*
|
|
4698
|
+
* @see {@link $page}
|
|
4699
|
+
* @module alepha.react.router
|
|
4700
|
+
*/
|
|
4701
|
+
const AlephaReactRouter = $module({
|
|
4702
|
+
name: "alepha.react.router",
|
|
4703
|
+
primitives: [$page],
|
|
4704
|
+
services: [
|
|
4705
|
+
ReactPageProvider,
|
|
4706
|
+
ReactPageService,
|
|
4707
|
+
ReactPreloadProvider,
|
|
4708
|
+
ReactRouter,
|
|
4709
|
+
ReactServerProvider,
|
|
4710
|
+
ReactServerTemplateProvider,
|
|
4711
|
+
SSRManifestProvider,
|
|
4712
|
+
ReactPageServerService
|
|
4713
|
+
],
|
|
4714
|
+
register: (alepha) => alepha.with(AlephaReact).with(AlephaDateTime).with(AlephaServer).with(AlephaServerCache).with(AlephaServerLinks).with({
|
|
4715
|
+
provide: ReactPageService,
|
|
4716
|
+
use: ReactPageServerService
|
|
4717
|
+
}).with(SSRManifestProvider).with(ReactServerTemplateProvider).with(ReactPreloadProvider).with(ReactServerProvider).with(ReactPageProvider).with(ReactRouter)
|
|
4718
|
+
});
|
|
4719
|
+
|
|
4720
|
+
//#endregion
|
|
4721
|
+
export { $page, AlephaReactRouter, ErrorViewer_default as ErrorViewer, Link_default as Link, NestedView_default as NestedView, NotFound_default as NotFound, PAGE_PRELOAD_KEY, PagePrimitive, ReactBrowserProvider, ReactPageProvider, ReactPageService, ReactPreloadProvider, ReactRouter, ReactServerProvider, ReactServerTemplateProvider, Redirection, RouterLayerContext, SSRManifestProvider, isPageRoute, reactBrowserOptions, reactServerOptions, useActive, useQueryParams, useRouter, useRouterState };
|
|
4722
|
+
//# sourceMappingURL=index.js.map
|