@warlock.js/core 5.15.0 → 5.17.0
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/CHANGELOG.md +33 -0
- package/esm/cli/commands/migrate.command.mjs +5 -1
- package/esm/cli/commands/migrate.command.mjs.map +1 -1
- package/esm/cli/commands/seed.command.mjs +2 -1
- package/esm/cli/commands/seed.command.mjs.map +1 -1
- package/esm/cli/commands/typings-generator.command.mjs +16 -23
- package/esm/cli/commands/typings-generator.command.mjs.map +1 -1
- package/esm/config/locale-configuration.mjs +7 -1
- package/esm/config/locale-configuration.mjs.map +1 -1
- package/esm/connectors/http-connector.d.mts.map +1 -1
- package/esm/connectors/http-connector.mjs +1 -18
- package/esm/connectors/http-connector.mjs.map +1 -1
- package/esm/connectors/read-bound-port.mjs +32 -0
- package/esm/connectors/read-bound-port.mjs.map +1 -0
- package/esm/dev-server/development-server.mjs +5 -1
- package/esm/dev-server/development-server.mjs.map +1 -1
- package/esm/dev-server/extract-translation-keys.mjs +68 -0
- package/esm/dev-server/extract-translation-keys.mjs.map +1 -0
- package/esm/dev-server/translation-keys-sources.mjs +19 -0
- package/esm/dev-server/translation-keys-sources.mjs.map +1 -0
- package/esm/dev-server/type-generator.mjs +64 -11
- package/esm/dev-server/type-generator.mjs.map +1 -1
- package/esm/generations/features/shared/merge-web-sitemap-config.mjs +118 -0
- package/esm/generations/features/shared/merge-web-sitemap-config.mjs.map +1 -0
- package/esm/generations/features/sitemap.feature.mjs +50 -47
- package/esm/generations/features/sitemap.feature.mjs.map +1 -1
- package/esm/generations/stubs.mjs +7 -6
- package/esm/generations/stubs.mjs.map +1 -1
- package/esm/http/csrf-default-guard.d.mts +32 -0
- package/esm/http/csrf-default-guard.d.mts.map +1 -0
- package/esm/http/csrf-default-guard.mjs +156 -0
- package/esm/http/csrf-default-guard.mjs.map +1 -0
- package/esm/http/csrf-origin-policy.d.mts +53 -0
- package/esm/http/csrf-origin-policy.d.mts.map +1 -0
- package/esm/http/csrf-origin-policy.mjs +104 -0
- package/esm/http/csrf-origin-policy.mjs.map +1 -0
- package/esm/http/error-codes.d.mts +14 -1
- package/esm/http/error-codes.d.mts.map +1 -1
- package/esm/http/error-codes.mjs +13 -0
- package/esm/http/error-codes.mjs.map +1 -1
- package/esm/http/index.d.mts +8 -1
- package/esm/http/index.mjs +7 -1
- package/esm/http/middleware/concurrency-limit.middleware.mjs +1 -1
- package/esm/http/middleware/idempotency.middleware.mjs +1 -1
- package/esm/http/middleware/inject-request-context.d.mts.map +1 -1
- package/esm/http/middleware/inject-request-context.mjs +6 -0
- package/esm/http/middleware/inject-request-context.mjs.map +1 -1
- package/esm/http/middleware/ip-filter.middleware.mjs +1 -1
- package/esm/http/middleware/maintenance.middleware.mjs +1 -1
- package/esm/http/middleware/max-body-size.middleware.mjs +1 -1
- package/esm/http/middleware/rate-limit.middleware.mjs +1 -1
- package/esm/http/parse-urlencoded-body.mjs +27 -0
- package/esm/http/parse-urlencoded-body.mjs.map +1 -0
- package/esm/http/plugins.d.mts.map +1 -1
- package/esm/http/plugins.mjs +8 -0
- package/esm/http/plugins.mjs.map +1 -1
- package/esm/http/request.d.mts +2 -2
- package/esm/http/request.d.mts.map +1 -1
- package/esm/http/request.mjs +7 -3
- package/esm/http/request.mjs.map +1 -1
- package/esm/http/response.d.mts +9 -2
- package/esm/http/response.d.mts.map +1 -1
- package/esm/http/response.mjs +18 -4
- package/esm/http/response.mjs.map +1 -1
- package/esm/http/uploads/detect-image-format.mjs +59 -0
- package/esm/http/uploads/detect-image-format.mjs.map +1 -0
- package/esm/http/uploads/generate-image-variant.mjs +46 -0
- package/esm/http/uploads/generate-image-variant.mjs.map +1 -0
- package/esm/http/uploads/generate-image-variants.d.mts +49 -0
- package/esm/http/uploads/generate-image-variants.d.mts.map +1 -0
- package/esm/http/uploads/generate-image-variants.mjs +141 -0
- package/esm/http/uploads/generate-image-variants.mjs.map +1 -0
- package/esm/http/uploads/image-variant-types.d.mts +93 -0
- package/esm/http/uploads/image-variant-types.d.mts.map +1 -0
- package/esm/http/uploads/image-variants-config-error.d.mts +13 -0
- package/esm/http/uploads/image-variants-config-error.d.mts.map +1 -0
- package/esm/http/uploads/image-variants-config-error.mjs +17 -0
- package/esm/http/uploads/image-variants-config-error.mjs.map +1 -0
- package/esm/http/uploads/index.d.mts +4 -0
- package/esm/http/uploads/index.mjs +5 -0
- package/esm/http/uploads/is-path-inside.mjs +26 -0
- package/esm/http/uploads/is-path-inside.mjs.map +1 -0
- package/esm/http/uploads/load-variant-source.mjs +42 -0
- package/esm/http/uploads/load-variant-source.mjs.map +1 -0
- package/esm/http/uploads/matches-if-none-match.mjs +13 -0
- package/esm/http/uploads/matches-if-none-match.mjs.map +1 -0
- package/esm/http/uploads/parse-uploaded-file-query.mjs +51 -0
- package/esm/http/uploads/parse-uploaded-file-query.mjs.map +1 -0
- package/esm/http/uploads/read-file-head.mjs +20 -0
- package/esm/http/uploads/read-file-head.mjs.map +1 -0
- package/esm/http/uploads/resolve-image-variants-config.mjs +91 -0
- package/esm/http/uploads/resolve-image-variants-config.mjs.map +1 -0
- package/esm/http/uploads/resolve-inline-image-content-type.mjs +53 -0
- package/esm/http/uploads/resolve-inline-image-content-type.mjs.map +1 -0
- package/esm/http/uploads/resolve-original-content-type.mjs +31 -0
- package/esm/http/uploads/resolve-original-content-type.mjs.map +1 -0
- package/esm/http/uploads/resolve-upload-path.mjs +56 -0
- package/esm/http/uploads/resolve-upload-path.mjs.map +1 -0
- package/esm/http/uploads/resolve-variant-candidate.mjs +39 -0
- package/esm/http/uploads/resolve-variant-candidate.mjs.map +1 -0
- package/esm/http/uploads/single-flight.mjs +40 -0
- package/esm/http/uploads/single-flight.mjs.map +1 -0
- package/esm/http/uploads/uploaded-file.controller.d.mts +24 -0
- package/esm/http/uploads/uploaded-file.controller.d.mts.map +1 -0
- package/esm/http/uploads/uploaded-file.controller.mjs +174 -0
- package/esm/http/uploads/uploaded-file.controller.mjs.map +1 -0
- package/esm/http/uploads/variant-cache-key.mjs +45 -0
- package/esm/http/uploads/variant-cache-key.mjs.map +1 -0
- package/esm/http/uploads/variant-generations.mjs +16 -0
- package/esm/http/uploads/variant-generations.mjs.map +1 -0
- package/esm/http/uploads-types.d.mts +102 -1
- package/esm/http/uploads-types.d.mts.map +1 -1
- package/esm/http/xmlable.d.mts +14 -0
- package/esm/http/xmlable.d.mts.map +1 -0
- package/esm/index.d.mts +10 -2
- package/esm/index.mjs +8 -2
- package/esm/router/types.d.mts +23 -0
- package/esm/router/types.d.mts.map +1 -1
- package/esm/storage/index.d.mts +1 -0
- package/esm/storage/index.mjs +1 -0
- package/esm/storage/scoped-storage.d.mts +1 -0
- package/esm/storage/scoped-storage.d.mts.map +1 -1
- package/esm/storage/scoped-storage.mjs +3 -0
- package/esm/storage/scoped-storage.mjs.map +1 -1
- package/esm/storage/storage.d.mts +2 -0
- package/esm/storage/storage.d.mts.map +1 -1
- package/esm/storage/storage.mjs +4 -0
- package/esm/storage/storage.mjs.map +1 -1
- package/esm/storage/utils/storage-not-initialized-error.d.mts +22 -0
- package/esm/storage/utils/storage-not-initialized-error.d.mts.map +1 -0
- package/esm/storage/utils/storage-not-initialized-error.mjs +24 -0
- package/esm/storage/utils/storage-not-initialized-error.mjs.map +1 -0
- package/llms-full.txt +47 -7
- package/llms.txt +1 -1
- package/package.json +22 -21
- package/skills/configure-app/SKILL.md +6 -6
- package/skills/create-controller/SKILL.md +14 -0
- package/skills/health-checks/SKILL.md +8 -0
- package/skills/send-response/SKILL.md +2 -0
- package/skills/use-localization/SKILL.md +16 -0
- package/skills/use-middleware/SKILL.md +1 -1
- package/esm/dev-server/translation-type-generator.mjs +0 -28
- package/esm/dev-server/translation-type-generator.mjs.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
> ⚠ **Versioning: `@warlock.js/*` does not follow SemVer strictly — breaking changes may ship in a minor.** This is a deliberate decision, not an oversight: the framework is pre-adoption and the cost of a major per behaviour fix currently outweighs the benefit. **Pin an exact version or a tilde range (`~4.13.0`) if you need to opt into changes rather than receive them.** Every breaking change is marked **BREAKING** in its entry and summarised in an _Upgrading_ section at the top of the release. **This policy will change once the framework has consumers beyond its author.**
|
|
8
8
|
|
|
9
|
+
## 5.17.0 - 2026-09-21
|
|
10
|
+
|
|
11
|
+
### Security
|
|
12
|
+
|
|
13
|
+
- `uploadedFileController` no longer serves an upload original inline unless its bytes sniff as jpeg, png, webp or avif AND its extension names that same image type (`.jpg`/`.jpeg` for jpeg, `.png` for png, `.webp` for webp, `.avif` for avif) — never sniffed bytes alone, and never the extension alone. An inline response always carries the SNIFFED format's `Content-Type` set explicitly, not an extension-derived guess from `sendFile`. This closes two stored-XSS holes, sniffed-bytes vs. extension mismatched in each direction: an svg (or any other non-raster file) named `.png`/`.jpg` still downloads instead of rendering as its extension's type, and raster bytes named with a non-image or different-image extension — e.g. jpeg magic bytes saved as `x.html` — also download instead of being served under that extension's advertised type (`text/html`, previously inherited from `sendFile`). Every other original — svg, html, xml, text, pdf, unknown, any raster/extension mismatch either way — is sent with `Content-Disposition: attachment`, `Content-Security-Policy: sandbox`, and its extension-derived `Content-Type`, except the svg/html/xml family, which is downgraded to `application/octet-stream`. Every uploads response — originals and variants — now also carries `X-Content-Type-Options: nosniff`.
|
|
14
|
+
- A default CSRF-Origin guard (card 8a752ab2) now runs at the earliest HTTP seam, before route middleware and any app handler, for every unsafe-method (`POST`/`PUT`/`PATCH`/`DELETE`) request that carries a `Cookie` header naming anything other than the framework locale cookie or a validated locale-preference cookie (a malformed `Cookie` header counts as carrying one — fails closed). It requires `Origin` — or, absent that, `Referer` — to name the request's own origin or an entry in `auth.csrf.allowedOrigins`, rejecting with `403` otherwise. Before this, that check only ran inside `authMiddleware("cookie:*")`, so a cookie-authenticated write reached through any other path (e.g. an app-owned optional-auth pattern reading its own `token` cookie) was never checked. A header-only API request (no `Cookie` header, e.g. `Authorization: Bearer`) is unaffected, and a route can opt out with `{ csrf: false }` (`RouteOptions.csrf`) — documented as dangerous, for third-party callbacks and machine-to-machine routes only.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Standalone `warlock generate.typings` includes route `locales.json` keys, honoring `$group` and existing literal translation registrations. The optional Web build helper is resolved from the application's installed package using ESM export conditions, without executing application configuration or the Web root entry.
|
|
19
|
+
- `uploadedFileController`, a ready-made handler for `router.get("/uploads/*", uploadedFileController)` that serves local uploads safely. The request path must resolve inside the storage root, symlinks included, and never into the variant cache. Anything else gets the same 404 as a missing file, so the route cannot be used to probe for files. Originals are sent with a one-year cache.
|
|
20
|
+
- `generateImageVariants(relativePath, options?)`, an opt-in ingest-time counterpart to `uploadedFileController`: call it right after an upload is saved to render every configured `uploads.images` variant (optionally `options.variants` to limit which ones) up front. It shares the route's normalized config, cache key and derivative path, so `GET /uploads/<path>?variant=<name>` is served from cache on its first request, and its source guards and error statuses match the route. It returns a plain, serializable descriptor (`{ src, width, height, variants, formats? }`) matching web's `ImageDescriptor`.
|
|
21
|
+
- Bounded on-demand image variants, configured under `uploads.images`: `?variant=<name>` renders a variant named in `uploads.images.variants` (width and height from 1 to 8192, quality from 1 to 100, `fit` of `cover`, `contain` or `inside`), and `&format=` picks an output format from the `formats` allowlist (`webp`, `avif`). Any other query key, a repeated key, an unknown variant or a format that is not allowed returns 400. Only jpeg, png, webp and avif sources are resized, detected by their magic bytes, never their extension. gif and svg return 415. `maxSourceBytes` (default 25 MB) and `maxSourcePixels` (default 40,000,000) cap the source, and a larger one returns 413. Each derivative is cached on disk under a sha256 of the source path, size, mtime, variant and format, and it is written atomically. Concurrent requests for the same derivative generate it once. It is served with `Cache-Control: public, max-age=31536000, immutable` and an ETag that answers 304. Rewriting the source changes the key, so the next request renders a new derivative. An invalid `uploads.images` config throws `ImageVariantsConfigError` the first time a variant is requested. The variant path needs `sharp`, but originals are served without it.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Request translation helpers follow the request's current locale; Web can supply its immutable route-scoped resolver for `request.t()`, `request.trans()`, and explicit `request.transFrom(locale, key, placeholders?)`. Without that resolver, all retain their global-registry behavior.
|
|
26
|
+
- Locale preference resolution validates the host-only preference before falling through to the legacy cookie, header, and default. `response.setLocale()` clears the preference before writing its authoritative locale cookie. Provisional Web navigation requests do not persist a locale before the browser commits a successful current navigation.
|
|
27
|
+
- Image variants (`uploadedFileController` and `generateImageVariants`) no longer enlarge a source smaller than the variant's target width or height. A variant is now rendered with sharp's `withoutEnlargement: true` by default, so a 100px source requested at `{ width: 320 }` stays 100px wide instead of being upscaled. Opt in per variant with `enlarge: true` in `ImageVariantDefinition` (a non-boolean value throws `ImageVariantsConfigError`); the effective flag is part of the normalized variant and its cache key, so flipping it regenerates the derivative. `generateImageVariants`' descriptor now reports the actual rendered width and height, read from the derivative it wrote, not the requested size.
|
|
28
|
+
- `generateImageVariants` now throws `ImageVariantsConfigError` — not a plain `Error` (no storage root) or `HttpError(400)` (`uploads.images` not configured) — for both server misconfiguration states, matching how a bad `uploads.images` value is already reported; input-derived failures (traversal, missing file, bad format, size limits, unknown variant name) still throw `HttpError`. `uploadedFileController`'s own responses for these requests are unchanged.
|
|
29
|
+
- `warlock seed` (and `warlock migrate`, for data migrations) now preload the `storage` connector, and `Storage`/`ScopedStorage` throw a named `StorageNotInitializedError` — pointing at `src/config/storage.ts` and a custom command's `preload.connectors` — instead of a bare `TypeError: Cannot read properties of null (reading 'name')` when `activeDriver` is read before storage has been initialized in the process.
|
|
30
|
+
|
|
31
|
+
## 5.16.0 - 2026-09-18
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- Core now parses `application/x-www-form-urlencoded` request bodies, so plain HTML forms and OAuth `form_post` callbacks (Apple) reach their routes instead of failing with `FST_ERR_CTP_INVALID_MEDIA_TYPE`. These bodies have the same `http.bodyLimit` as JSON. A key sent more than once becomes an array.
|
|
36
|
+
- `response.xml()` accepts a raw string or any value with a `toXML(): string` method (such as a `@warlock.js/sitemap` `Sitemap`), and sends `application/xml`.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- `warlock add sitemap` now merges a disabled `sitemap` section into `src/config/web.ts`, and creates that file when it is missing. It no longer writes `src/config/sitemap.ts` or registers `sitemapConnector()`. The merge edits only a `sitemap` key directly on the exported config object, and the result is re-parsed before it is written.
|
|
41
|
+
|
|
9
42
|
## 5.15.0 - 2026-09-18
|
|
10
43
|
|
|
11
44
|
### Added
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"migrate.command.mjs","names":[],"sources":["../../../../../../../../core/src/cli/commands/migrate.command.ts"],"sourcesContent":["import { migrateAction } from \"../../database/migrate-action\";\r\nimport { command } from \"../../commands/cli-command\";\r\n\r\nexport const migrateCommand = command({\r\n action: migrateAction,\r\n name: \"migrate\",\r\n description: \"Run database migrations\",\r\n preload: {\r\n config: [\"database\", \"log\"],\r\n connectors: [\"database\", \"logger\"],\r\n },\r\n options: [\r\n {\r\n text: \"--fresh, -f\",\r\n description: \"Drop all tables and re-run migrations\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--rollback, -r\",\r\n description: \"Rollback migrations, drop all tables\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--path, -p\",\r\n description: \"Migration file path, if not provided, all migrations will be wroking\",\r\n type: \"string\",\r\n },\r\n {\r\n text: \"--list, -l\",\r\n description: \"Show migration state: executed migrations, then what will run next\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--pending\",\r\n description:\r\n \"List only what will run next, in execution order. Exits 0 if nothing is pending, 1 if something is, 2 if it could not be determined\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--all, -a\",\r\n description: \"List all migrations files in the app\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--sql, -s\",\r\n description: \"Export all migrations as phase-ordered SQL files instead of executing them\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--pending-only\",\r\n description: \"When used with --sql, exports only pending migrations instead of all\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--compact, -c\",\r\n description:\r\n \"When used with --sql, strips out generated comments and blank lines to reduce file size\",\r\n type: \"boolean\",\r\n },\r\n ],\r\n});\r\n"],"mappings":";;;;AAGA,MAAa,iBAAiB,QAAQ;CACpC,QAAQ;CACR,MAAM;CACN,aAAa;CACb,SAAS;EACP,QAAQ,CAAC,YAAY,KAAK;EAC1B,YAAY
|
|
1
|
+
{"version":3,"file":"migrate.command.mjs","names":[],"sources":["../../../../../../../../core/src/cli/commands/migrate.command.ts"],"sourcesContent":["import { migrateAction } from \"../../database/migrate-action\";\r\nimport { command } from \"../../commands/cli-command\";\r\n\r\nexport const migrateCommand = command({\r\n action: migrateAction,\r\n name: \"migrate\",\r\n description: \"Run database migrations\",\r\n preload: {\r\n config: [\"database\", \"log\"],\r\n connectors: [\"database\", \"logger\", \"storage\"],\r\n },\r\n options: [\r\n {\r\n text: \"--fresh, -f\",\r\n description: \"Drop all tables and re-run migrations\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--rollback, -r\",\r\n description: \"Rollback migrations, drop all tables\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--path, -p\",\r\n description: \"Migration file path, if not provided, all migrations will be wroking\",\r\n type: \"string\",\r\n },\r\n {\r\n text: \"--list, -l\",\r\n description: \"Show migration state: executed migrations, then what will run next\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--pending\",\r\n description:\r\n \"List only what will run next, in execution order. Exits 0 if nothing is pending, 1 if something is, 2 if it could not be determined\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--all, -a\",\r\n description: \"List all migrations files in the app\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--sql, -s\",\r\n description: \"Export all migrations as phase-ordered SQL files instead of executing them\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--pending-only\",\r\n description: \"When used with --sql, exports only pending migrations instead of all\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--compact, -c\",\r\n description:\r\n \"When used with --sql, strips out generated comments and blank lines to reduce file size\",\r\n type: \"boolean\",\r\n },\r\n ],\r\n});\r\n"],"mappings":";;;;AAGA,MAAa,iBAAiB,QAAQ;CACpC,QAAQ;CACR,MAAM;CACN,aAAa;CACb,SAAS;EACP,QAAQ,CAAC,YAAY,KAAK;EAC1B,YAAY;GAAC;GAAY;GAAU;EAAS;CAC9C;CACA,SAAS;EACP;GACE,MAAM;GACN,aAAa;GACb,MAAM;EACR;EACA;GACE,MAAM;GACN,aAAa;GACb,MAAM;EACR;EACA;GACE,MAAM;GACN,aAAa;GACb,MAAM;EACR;EACA;GACE,MAAM;GACN,aAAa;GACb,MAAM;EACR;EACA;GACE,MAAM;GACN,aACE;GACF,MAAM;EACR;EACA;GACE,MAAM;GACN,aAAa;GACb,MAAM;EACR;EACA;GACE,MAAM;GACN,aAAa;GACb,MAAM;EACR;EACA;GACE,MAAM;GACN,aAAa;GACb,MAAM;EACR;EACA;GACE,MAAM;GACN,aACE;GACF,MAAM;EACR;CACF;AACF,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"seed.command.mjs","names":[],"sources":["../../../../../../../../core/src/cli/commands/seed.command.ts"],"sourcesContent":["import { seedCommandAction } from \"../../database/seed-command-action\";\r\nimport { command } from \"../../commands/cli-command\";\r\n\r\nexport const seedCommand = command({\r\n action: seedCommandAction,\r\n name: \"seed\",\r\n description: \"Run database seeds\",\r\n preload: {\r\n config: true,\r\n env: true,\r\n bootstrap: true,\r\n connectors: [\"database\", \"cache\", \"logger\"],\r\n },\r\n options: [\r\n {\r\n text: \"--fresh, -f\",\r\n description: \"Drop all tables records and run seeds\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--list, -l\",\r\n description: \"Display the seeds list in order without execution\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--transaction, -t\",\r\n description: \"Run seeds in a transaction\",\r\n type: \"boolean\",\r\n defaultValue: true,\r\n },\r\n {\r\n text: \"--drop, -d\",\r\n description:\r\n 'Undo seeded data — delete tracked records (reverse order) and reset the seed log; scope to one seeder with --drop=\"Seed Name\"',\r\n type: \"string\",\r\n },\r\n ],\r\n});\r\n"],"mappings":";;;;AAGA,MAAa,cAAc,QAAQ;CACjC,QAAQ;CACR,MAAM;CACN,aAAa;CACb,SAAS;EACP,QAAQ;EACR,KAAK;EACL,WAAW;EACX,YAAY;GAAC;GAAY;GAAS;
|
|
1
|
+
{"version":3,"file":"seed.command.mjs","names":[],"sources":["../../../../../../../../core/src/cli/commands/seed.command.ts"],"sourcesContent":["import { seedCommandAction } from \"../../database/seed-command-action\";\r\nimport { command } from \"../../commands/cli-command\";\r\n\r\nexport const seedCommand = command({\r\n action: seedCommandAction,\r\n name: \"seed\",\r\n description: \"Run database seeds\",\r\n preload: {\r\n config: true,\r\n env: true,\r\n bootstrap: true,\r\n connectors: [\"database\", \"cache\", \"logger\", \"storage\"],\r\n },\r\n options: [\r\n {\r\n text: \"--fresh, -f\",\r\n description: \"Drop all tables records and run seeds\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--list, -l\",\r\n description: \"Display the seeds list in order without execution\",\r\n type: \"boolean\",\r\n },\r\n {\r\n text: \"--transaction, -t\",\r\n description: \"Run seeds in a transaction\",\r\n type: \"boolean\",\r\n defaultValue: true,\r\n },\r\n {\r\n text: \"--drop, -d\",\r\n description:\r\n 'Undo seeded data — delete tracked records (reverse order) and reset the seed log; scope to one seeder with --drop=\"Seed Name\"',\r\n type: \"string\",\r\n },\r\n ],\r\n});\r\n"],"mappings":";;;;AAGA,MAAa,cAAc,QAAQ;CACjC,QAAQ;CACR,MAAM;CACN,aAAa;CACb,SAAS;EACP,QAAQ;EACR,KAAK;EACL,WAAW;EACX,YAAY;GAAC;GAAY;GAAS;GAAU;EAAS;CACvD;CACA,SAAS;EACP;GACE,MAAM;GACN,aAAa;GACb,MAAM;EACR;EACA;GACE,MAAM;GACN,aAAa;GACb,MAAM;EACR;EACA;GACE,MAAM;GACN,aAAa;GACb,MAAM;GACN,cAAc;EAChB;EACA;GACE,MAAM;GACN,aACE;GACF,MAAM;EACR;CACF;AACF,CAAC"}
|
|
@@ -1,34 +1,27 @@
|
|
|
1
|
-
import { Path } from "../../utils/normalized-path.mjs";
|
|
2
|
-
import { srcPath } from "../../utils/paths.mjs";
|
|
3
|
-
import "../../utils/index.mjs";
|
|
4
1
|
import { command } from "../../commands/cli-command.mjs";
|
|
5
|
-
import { getFilesFromDirectory } from "../../dev-server/utils.mjs";
|
|
6
2
|
import { filesOrchestrator } from "../../dev-server/files-orchestrator.mjs";
|
|
7
3
|
import { typeGenerator } from "../../dev-server/type-generator.mjs";
|
|
8
4
|
|
|
9
5
|
//#region ../core/src/cli/commands/typings-generator.command.ts
|
|
6
|
+
/**
|
|
7
|
+
* Standalone `warlock generate.typings`.
|
|
8
|
+
*
|
|
9
|
+
* Runs `filesOrchestrator.initializeAll()` before generating — the same
|
|
10
|
+
* full-project discovery `warlock dev` performs at boot — so this produces
|
|
11
|
+
* the identical registry from a fresh checkout instead of only whatever
|
|
12
|
+
* config files a caller happened to list. A prior version added just the
|
|
13
|
+
* config directory's files, which left `TranslationKeyRegistry` empty on any
|
|
14
|
+
* app whose translation keys came from source outside `src/config`.
|
|
15
|
+
*
|
|
16
|
+
* Not `filesOrchestrator.init()` — that also registers the ESM loader hook
|
|
17
|
+
* for dynamically importing project code, which typings generation, being
|
|
18
|
+
* purely static AST inspection, never does.
|
|
19
|
+
*/
|
|
10
20
|
const typingsGeneratorCommand = command({
|
|
11
21
|
name: "generate.typings",
|
|
12
22
|
description: "Generate type definitions for the project",
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
description: "Files to generate typings for, if not passed, it will generate typings for all files"
|
|
16
|
-
}],
|
|
17
|
-
action: async ({ options }) => {
|
|
18
|
-
const configFilesPaths = [];
|
|
19
|
-
if (options.files) {
|
|
20
|
-
const files = String(options.files).split(",").map((file) => {
|
|
21
|
-
if (file.startsWith("./")) return Path.toAbsolute(file);
|
|
22
|
-
return file;
|
|
23
|
-
});
|
|
24
|
-
if (files?.length) configFilesPaths.push(...files);
|
|
25
|
-
}
|
|
26
|
-
if (configFilesPaths.length === 0) {
|
|
27
|
-
const configFiles = await getFilesFromDirectory(srcPath("config"));
|
|
28
|
-
configFilesPaths.push(...configFiles);
|
|
29
|
-
}
|
|
30
|
-
const failed = (await Promise.allSettled(configFilesPaths.map((path) => filesOrchestrator.add(Path.toRelative(path))))).filter((r) => r.status === "rejected");
|
|
31
|
-
if (failed.length) console.warn(`Failed to process ${failed.length} files`);
|
|
23
|
+
action: async () => {
|
|
24
|
+
await filesOrchestrator.initializeAll();
|
|
32
25
|
await typeGenerator.generateAll();
|
|
33
26
|
}
|
|
34
27
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"typings-generator.command.mjs","names":[],"sources":["../../../../../../../../core/src/cli/commands/typings-generator.command.ts"],"sourcesContent":["import { filesOrchestrator } from \"../../dev-server/files-orchestrator\";\
|
|
1
|
+
{"version":3,"file":"typings-generator.command.mjs","names":[],"sources":["../../../../../../../../core/src/cli/commands/typings-generator.command.ts"],"sourcesContent":["import { filesOrchestrator } from \"../../dev-server/files-orchestrator\";\nimport { typeGenerator } from \"../../dev-server/type-generator\";\nimport { command } from \"../../commands/cli-command\";\n\n/**\n * Standalone `warlock generate.typings`.\n *\n * Runs `filesOrchestrator.initializeAll()` before generating — the same\n * full-project discovery `warlock dev` performs at boot — so this produces\n * the identical registry from a fresh checkout instead of only whatever\n * config files a caller happened to list. A prior version added just the\n * config directory's files, which left `TranslationKeyRegistry` empty on any\n * app whose translation keys came from source outside `src/config`.\n *\n * Not `filesOrchestrator.init()` — that also registers the ESM loader hook\n * for dynamically importing project code, which typings generation, being\n * purely static AST inspection, never does.\n */\nexport const typingsGeneratorCommand = command({\n name: \"generate.typings\",\n description: \"Generate type definitions for the project\",\n action: async () => {\n await filesOrchestrator.initializeAll();\n\n await typeGenerator.generateAll();\n },\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAkBA,MAAa,0BAA0B,QAAQ;CAC7C,MAAM;CACN,aAAa;CACb,QAAQ,YAAY;EAClB,MAAM,kBAAkB,cAAc;EAEtC,MAAM,cAAc,YAAY;CAClC;AACF,CAAC"}
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
* that can silently fall out of sync.
|
|
10
10
|
*/
|
|
11
11
|
const LOCALE_COOKIE_NAME = "locale";
|
|
12
|
+
/**
|
|
13
|
+
* A client-readable, host-only locale preference. It is deliberately separate
|
|
14
|
+
* from `LOCALE_COOKIE_NAME`: the latter remains the server-authoritative
|
|
15
|
+
* HttpOnly locale cookie.
|
|
16
|
+
*/
|
|
17
|
+
const LOCALE_PREFERENCE_COOKIE_NAME = "warlock.locale-preference";
|
|
12
18
|
function isNonEmptyStringArray(value) {
|
|
13
19
|
return Array.isArray(value) && value.length > 0 && value.every((candidate) => typeof candidate === "string");
|
|
14
20
|
}
|
|
@@ -35,5 +41,5 @@ function assertLocaleConfiguration(config) {
|
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
//#endregion
|
|
38
|
-
export { LOCALE_COOKIE_NAME, assertLocaleConfiguration, resolveLocaleConfiguration };
|
|
44
|
+
export { LOCALE_COOKIE_NAME, LOCALE_PREFERENCE_COOKIE_NAME, assertLocaleConfiguration, resolveLocaleConfiguration };
|
|
39
45
|
//# sourceMappingURL=locale-configuration.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"locale-configuration.mjs","names":[],"sources":["../../../../../../../core/src/config/locale-configuration.ts"],"sourcesContent":["/**\n * The name of the cookie the framework uses to persist a request's locale\n * across requests.\n *\n * The ONE place this string is written. `Request.resolveLocale()` reads it,\n * `Response.setLocale()` writes it; neither hardcodes the literal. Changing\n * this constant changes both sides together instead of requiring two edits\n * that can silently fall out of sync.\n */\nexport const LOCALE_COOKIE_NAME = \"locale\";\n\nexport type LocaleConfiguration = {\n defaultLocaleCode: string;\n /**\n * The declared allow-list, or `undefined` when the application declares none.\n *\n * The absence of a list is NOT an implicit list of one. With nothing to\n * validate against, a client locale has nothing it can fail — coercing it to\n * the default would discard what the client actually asked for.\n */\n localeCodes: string[] | undefined;\n};\n\ntype AppLocaleConfiguration = {\n localeCode?: unknown;\n localeCodes?: unknown;\n};\n\nfunction isNonEmptyStringArray(value: unknown): value is string[] {\n return (\n Array.isArray(value) &&\n value.length > 0 &&\n value.every((candidate) => typeof candidate === \"string\")\n );\n}\n\n/**\n * Resolve the locale configuration used by every request.\n *\n * It fails closed only where there is a declared allow-list to fail against;\n * an application that declares none opts out of validation, not into a\n * single-locale list.\n */\nexport function resolveLocaleConfiguration(\n localeCode: unknown,\n localeCodes: unknown,\n): LocaleConfiguration {\n const defaultLocaleCode =\n typeof localeCode === \"string\" && localeCode.length > 0 ? localeCode : \"en\";\n\n return {\n defaultLocaleCode,\n localeCodes: isNonEmptyStringArray(localeCodes) ? localeCodes : undefined,\n };\n}\n\n/**\n * A default outside the declared list makes every fallback contradictory, so\n * configuration loading must stop before the application can accept requests.\n */\nexport function assertLocaleConfiguration(config: AppLocaleConfiguration): void {\n const { defaultLocaleCode } = resolveLocaleConfiguration(config.localeCode, config.localeCodes);\n\n if (\n isNonEmptyStringArray(config.localeCodes) &&\n !config.localeCodes.includes(defaultLocaleCode)\n ) {\n throw new Error(\n `app.localeCodes does not include the resolved default locale \"${defaultLocaleCode}\". ` +\n `Add \"${defaultLocaleCode}\" to app.localeCodes or change app.localeCode; ` +\n `boot cannot continue with a default that the application does not support.`,\n );\n }\n}\n"],"mappings":";;;;;;;;;;AASA,MAAa,qBAAqB;
|
|
1
|
+
{"version":3,"file":"locale-configuration.mjs","names":[],"sources":["../../../../../../../core/src/config/locale-configuration.ts"],"sourcesContent":["/**\n * The name of the cookie the framework uses to persist a request's locale\n * across requests.\n *\n * The ONE place this string is written. `Request.resolveLocale()` reads it,\n * `Response.setLocale()` writes it; neither hardcodes the literal. Changing\n * this constant changes both sides together instead of requiring two edits\n * that can silently fall out of sync.\n */\nexport const LOCALE_COOKIE_NAME = \"locale\";\n\n/**\n * A client-readable, host-only locale preference. It is deliberately separate\n * from `LOCALE_COOKIE_NAME`: the latter remains the server-authoritative\n * HttpOnly locale cookie.\n */\nexport const LOCALE_PREFERENCE_COOKIE_NAME = \"warlock.locale-preference\";\n\nexport type LocaleConfiguration = {\n defaultLocaleCode: string;\n /**\n * The declared allow-list, or `undefined` when the application declares none.\n *\n * The absence of a list is NOT an implicit list of one. With nothing to\n * validate against, a client locale has nothing it can fail — coercing it to\n * the default would discard what the client actually asked for.\n */\n localeCodes: string[] | undefined;\n};\n\ntype AppLocaleConfiguration = {\n localeCode?: unknown;\n localeCodes?: unknown;\n};\n\nfunction isNonEmptyStringArray(value: unknown): value is string[] {\n return (\n Array.isArray(value) &&\n value.length > 0 &&\n value.every((candidate) => typeof candidate === \"string\")\n );\n}\n\n/**\n * Resolve the locale configuration used by every request.\n *\n * It fails closed only where there is a declared allow-list to fail against;\n * an application that declares none opts out of validation, not into a\n * single-locale list.\n */\nexport function resolveLocaleConfiguration(\n localeCode: unknown,\n localeCodes: unknown,\n): LocaleConfiguration {\n const defaultLocaleCode =\n typeof localeCode === \"string\" && localeCode.length > 0 ? localeCode : \"en\";\n\n return {\n defaultLocaleCode,\n localeCodes: isNonEmptyStringArray(localeCodes) ? localeCodes : undefined,\n };\n}\n\n/**\n * A default outside the declared list makes every fallback contradictory, so\n * configuration loading must stop before the application can accept requests.\n */\nexport function assertLocaleConfiguration(config: AppLocaleConfiguration): void {\n const { defaultLocaleCode } = resolveLocaleConfiguration(config.localeCode, config.localeCodes);\n\n if (\n isNonEmptyStringArray(config.localeCodes) &&\n !config.localeCodes.includes(defaultLocaleCode)\n ) {\n throw new Error(\n `app.localeCodes does not include the resolved default locale \"${defaultLocaleCode}\". ` +\n `Add \"${defaultLocaleCode}\" to app.localeCodes or change app.localeCode; ` +\n `boot cannot continue with a default that the application does not support.`,\n );\n }\n}\n"],"mappings":";;;;;;;;;;AASA,MAAa,qBAAqB;;;;;;AAOlC,MAAa,gCAAgC;AAmB7C,SAAS,sBAAsB,OAAmC;CAChE,OACE,MAAM,QAAQ,KAAK,KACnB,MAAM,SAAS,KACf,MAAM,OAAO,cAAc,OAAO,cAAc,QAAQ;AAE5D;;;;;;;;AASA,SAAgB,2BACd,YACA,aACqB;CAIrB,OAAO;EACL,mBAHA,OAAO,eAAe,YAAY,WAAW,SAAS,IAAI,aAAa;EAIvE,aAAa,sBAAsB,WAAW,IAAI,cAAc;CAClE;AACF;;;;;AAMA,SAAgB,0BAA0B,QAAsC;CAC9E,MAAM,EAAE,sBAAsB,2BAA2B,OAAO,YAAY,OAAO,WAAW;CAE9F,IACE,sBAAsB,OAAO,WAAW,KACxC,CAAC,OAAO,YAAY,SAAS,iBAAiB,GAE9C,MAAM,IAAI,MACR,iEAAiE,kBAAkB,UACzE,kBAAkB,0HAE9B;AAEJ"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-connector.d.mts","names":[],"sources":["../../../../../../../core/src/connectors/http-connector.ts"],"mappings":";;;;;;;
|
|
1
|
+
{"version":3,"file":"http-connector.d.mts","names":[],"sources":["../../../../../../../core/src/connectors/http-connector.ts"],"mappings":";;;;;;;AAgDA;;cAAa,aAAA,SAAsB,aAAA;EAAA,SACjB,IAAA;EAAA,SACA,QAAA,GAAQ,iBAAA,CAAA,IAAA;EAAA,SACR,cAAA,GAAc,uBAAA,CAAA,IAAA;EAwCb;;;;;EAAA,mBAjCE,YAAA;EAV2B;;;EAAA,UAepC,IAAA,GAAO,eAAA;EAbO;;;;;;;;;;;;;;;;;EAAA,cAgCV,iBAAA;EAyQP;;;AAAoC;;EAhQ9B,IAAA,IAAI,OAAA;;;;;;;;UAmCT,oBAAA;;;;;;EAgCK,KAAA,IAAS,OAAA;;;;;;EAwJT,OAAA,IAAW,OAAA;;;;EASX,QAAA,IAAY,OAAA;;;;;;EA4BlB,aAAA,CAAc,YAAA;AAAA"}
|
|
@@ -13,29 +13,12 @@ import { ConnectorLifecyclePhase, ConnectorPriority } from "./types.mjs";
|
|
|
13
13
|
import { BaseConnector } from "./base-connector.mjs";
|
|
14
14
|
import { ensureFatalIsVisible } from "../logger/fatal-visibility.mjs";
|
|
15
15
|
import { describeServerAddress } from "./describe-server-address.mjs";
|
|
16
|
+
import { readBoundPort } from "./read-bound-port.mjs";
|
|
16
17
|
import config from "@mongez/config";
|
|
17
18
|
import { log } from "@warlock.js/logger";
|
|
18
19
|
import { colors } from "@mongez/copper";
|
|
19
20
|
|
|
20
21
|
//#region ../core/src/connectors/http-connector.ts
|
|
21
|
-
/**
|
|
22
|
-
* The port actually bound, read back from the address `listen()` resolved
|
|
23
|
-
* with — never the port that was asked for. See the call site in
|
|
24
|
-
* {@link HttpConnector.start} for why this matters (`http.port: 0`).
|
|
25
|
-
*
|
|
26
|
-
* `fallback` only fires if `boundAddress` turns out unparseable as a URL,
|
|
27
|
-
* which does not happen in practice — Fastify's resolved address is always a
|
|
28
|
-
* well-formed `http://host:port` (or `https://`) string with an explicit
|
|
29
|
-
* port — but a best-effort read of a socket address must never throw.
|
|
30
|
-
*/
|
|
31
|
-
function readBoundPort(boundAddress, fallback) {
|
|
32
|
-
try {
|
|
33
|
-
const port = new URL(boundAddress).port;
|
|
34
|
-
return port ? Number(port) : fallback;
|
|
35
|
-
} catch {
|
|
36
|
-
return fallback;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
22
|
function environmentColor(environment) {
|
|
40
23
|
switch (environment) {
|
|
41
24
|
case "development": return colors.magentaBright(environment);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-connector.mjs","names":[],"sources":["../../../../../../../core/src/connectors/http-connector.ts"],"sourcesContent":["import config from \"@mongez/config\";\nimport { colors } from \"@mongez/copper\";\nimport { log } from \"@warlock.js/logger\";\nimport { Application } from \"../application\";\nimport { health } from \"../http/health\";\nimport { assertPortIsAvailable } from \"../http/port-preflight\";\nimport { registerHttpPlugins } from \"../http/plugins\";\nimport { setHttpReadyReport } from \"../http/ready-report\";\nimport { isCanonicalPortValue, resolveBindPort } from \"../http/resolve-bind-port\";\nimport {\n closeServerWithTimeout,\n type FastifyInstance,\n getHttpServer,\n startHttpServer,\n} from \"../http/server\";\nimport { ensureFatalIsVisible } from \"../logger/fatal-visibility\";\nimport { router } from \"../router/router\";\nimport { type Environment } from \"../utils\";\nimport { setBaseUrl } from \"../utils/urls\";\nimport { container } from \"./../container\";\nimport { BaseConnector } from \"./base-connector\";\nimport { describeServerAddress } from \"./describe-server-address\";\nimport { ConnectorLifecyclePhase, ConnectorPriority } from \"./types\";\n\n/**\n * The port actually bound, read back from the address `listen()` resolved\n * with — never the port that was asked for. See the call site in\n * {@link HttpConnector.start} for why this matters (`http.port: 0`).\n *\n * `fallback` only fires if `boundAddress` turns out unparseable as a URL,\n * which does not happen in practice — Fastify's resolved address is always a\n * well-formed `http://host:port` (or `https://`) string with an explicit\n * port — but a best-effort read of a socket address must never throw.\n */\nfunction readBoundPort(boundAddress: string, fallback: number): number {\n try {\n const port = new URL(boundAddress).port;\n\n return port ? Number(port) : fallback;\n } catch {\n return fallback;\n }\n}\n\nfunction environmentColor(environment: Environment) {\n switch (environment) {\n case \"development\":\n return colors.magentaBright(environment);\n case \"test\":\n return colors.yellowBright(environment);\n case \"production\":\n return colors.greenBright(environment);\n default:\n return colors.white(environment);\n }\n}\n\n/**\n * Default time to wait for in-flight requests to drain on shutdown before the\n * HTTP server is force-closed. Override via `http.gracefulShutdown.timeout`.\n */\nconst DEFAULT_SHUTDOWN_TIMEOUT = 10_000;\n\n/**\n * HTTP Connector\n * Manages HTTP server (Fastify) lifecycle\n */\nexport class HttpConnector extends BaseConnector {\n public readonly name = \"http\";\n public readonly priority = ConnectorPriority.HTTP;\n public readonly lifecyclePhase = ConnectorLifecyclePhase.Late;\n\n /**\n * Files that trigger HTTP server restart\n * Note: routes.ts changes will be handled by HMR with wildcard routing\n * Connectors receive config file paths directly (not .env) thanks to layer-executor\n */\n protected readonly watchedFiles = [\"src/config/http.ts\", \"src/config/http.tsx\"];\n\n /**\n * Fastify Server instance\n */\n protected http?: FastifyInstance;\n\n /**\n * Whether someone downstream will render the combined ready block.\n *\n * Only the dev server does (`dev-server/ready-block.ts`), and only it can:\n * the block reports facts — boot duration, the web surface — that this\n * connector does not own and must not wait for. When it will, this connector\n * records and stays quiet; otherwise it narrates as it always has.\n *\n * Keyed on the runtime strategy rather than on `NODE_ENV`, because that is\n * what actually distinguishes \"a dev server is driving this boot\" from \"an\n * app happens to be running with development config\".\n *\n * `!isBooted` is the second half and it is not a detail. The block summarises\n * a BOOT. A `restart()` after boot — an edited `src/config/http.ts` — is an\n * EVENT, and there is no block coming to carry it, so suppressing the lines\n * there would rebind the port in total silence.\n */\n protected get rendersReadyBlock(): boolean {\n return Application.runtimeStrategy === \"development\" && !Application.isBooted;\n }\n\n /**\n * Boot the connector — construction only (create Fastify, register\n * plugins, populate container). Route scanning is deferred to\n * `start()` so it reads the router after app code has registered.\n */\n public async boot() {\n const httpConfig = config.get(\"http\");\n\n if (!httpConfig) return;\n\n const port = httpConfig.port;\n log.info(\n `http`,\n \"connection\",\n `Starting http server on port ${port} in ${environmentColor(Application.environment)} mode`,\n );\n\n this.http = startHttpServer(httpConfig.serverOptions);\n\n container.set(\"http.server\", this.http);\n\n await registerHttpPlugins(this.http);\n\n const baseUrl = config.get(\"app.baseUrl\");\n\n // update base url\n setBaseUrl(baseUrl);\n\n // Liveness/readiness endpoints — registered on Fastify before start() scans\n // the app router, so they exist by the time the server listens.\n this.registerHealthRoutes();\n }\n\n /**\n * Register the built-in liveness (`/health`) and readiness (`/ready`)\n * endpoints directly on the Fastify instance — infra routes, kept off the app\n * router so they're immune to HMR and never collide with route scanning. Opt\n * out via `http.health.enabled = false`; rename via `http.health.path` /\n * `http.health.readinessPath`.\n */\n private registerHealthRoutes(): void {\n if (!this.http) {\n return;\n }\n\n const healthConfig = config.get(\"http.health\");\n\n if (healthConfig?.enabled === false) {\n return;\n }\n\n const livenessPath = healthConfig?.path ?? \"/health\";\n const readinessPath = healthConfig?.readinessPath ?? \"/ready\";\n\n this.http.get(livenessPath, async (_request: any, reply: any) => {\n const result = health.liveness();\n\n return reply.code(result.status === \"ok\" ? 200 : 503).send(result);\n });\n\n this.http.get(readinessPath, async (_request: any, reply: any) => {\n const result = await health.readiness();\n\n return reply.code(result.status === \"ok\" ? 200 : 503).send(result);\n });\n }\n\n /**\n * Initialize HTTP server — bind app-registered routes to Fastify\n * then listen. Scanning here (not in `boot`) lets HTTP boot before\n * app code without losing routes.\n */\n public async start(): Promise<void> {\n const httpConfig = config.get(\"http\");\n\n if (!httpConfig || !this.http) return;\n\n // Read once. `Application.isBooted` flips during the same boot this method\n // belongs to, and a predicate that answered differently before and after\n // `listen()` would print half a report.\n const deferToReadyBlock = this.rendersReadyBlock;\n\n if (Application.runtimeStrategy === \"development\") {\n router.scanDevServer(this.http);\n } else {\n router.scan(this.http);\n }\n\n // Surface the route table as a readiness signal + a boot log, so an empty\n // or partial route surface (e.g. a route module that failed to register) is\n // detectable instead of silently 404ing. `addRoutesRegisteredCheck` is\n // keyed by name, so re-running on restart is idempotent.\n health.addRoutesRegisteredCheck(() => router.routeCount());\n\n const routeCount = router.routeCount();\n\n // In development this fact is one line of the single ready block rendered\n // after boot completes (`dev-server/ready-block.ts`); printing it here as\n // well would put the same number on screen twice, in two formats. Outside\n // development the line is load-bearing — a supervisor or CI log greps it —\n // so it stays exactly as it was.\n if (!deferToReadyBlock) {\n log.info(\"http\", \"routes\", `${routeCount} route(s) registered`);\n }\n\n // Resolved ONCE, here, at the top of the bind sequence: `httpConfig.port`\n // may still be a string that round-tripped `env()`'s number coercion\n // (`\"03999\"`, `\" 3999\"`, `\"1e3\"`) — `net.Server.listen({ port })` binds\n // all of those without complaint, silently, sometimes to the WRONG port\n // (`\"1e3\"` -> 1000). Every downstream use of the port — the preflight\n // probe and `listen()` itself — must agree on the same resolved number.\n //\n // This is still the port ASKED for, not the port BOUND — `port: 0` asks\n // for \"any free port\" and this stays `0` for that case. What gets\n // published to `warlock:ready` is read back from `listen()`'s resolved\n // address further down, never this value.\n const configuredPort = resolveBindPort(httpConfig.port);\n\n if (!isCanonicalPortValue(httpConfig.port, configuredPort)) {\n log.info(\n \"http\",\n \"connection\",\n `Configured http.port ${JSON.stringify(httpConfig.port)} normalised to ${configuredPort}.`,\n );\n }\n\n try {\n // Preflight the bind before `listen()` so a collision surfaces as the\n // instruction in `PortInUseError.message` (which names the port) instead\n // of a raw `EADDRINUSE` from deep inside Fastify/libuv.\n //\n // Port 0 is exempt: it asks the OS for a free port, so probing it binds\n // some unrelated ephemeral port and \"passes\" while proving nothing about\n // the port this server will actually take.\n if (configuredPort !== 0) {\n await assertPortIsAvailable(configuredPort, httpConfig.host || \"localhost\");\n }\n\n // `listen()` RESOLVES with the address it actually bound — which is the\n // only address worth announcing. See `describe-server-address.ts` for why\n // announcing `app.baseUrl` here instead was a defect rather than a\n // shortcut.\n const boundAddress = await this.http.listen({\n port: configuredPort,\n host: httpConfig.host || \"localhost\",\n });\n\n // Read the ACTUALLY-bound port back out of what `listen()` resolved,\n // rather than republishing `configuredPort`. With `http.port: 0` the OS\n // picks the port, so `configuredPort` stays `0` forever — publishing it\n // instead of the real port is exactly the \"Server ready at a URL that\n // gives connection refused\" defect `describe-server-address.ts` already\n // fixed for the display string. `boundAddress` always carries an\n // explicit port (Fastify never omits one), so `configuredPort` is only\n // a defensive fallback for an address this parse cannot make sense of.\n const boundPort = readBoundPort(boundAddress, configuredPort);\n\n Application.setServedPort(boundPort);\n\n const address = describeServerAddress(\n boundAddress,\n config.get(\"app.baseUrl\"),\n // Passed because `listen()` does not report it back: Fastify resolves a\n // `0.0.0.0` bind to `http://127.0.0.1:<port>`, so the wildcard is only\n // recoverable from what we ASKED for.\n httpConfig.host || \"localhost\",\n );\n\n // Recorded unconditionally: the ready block reads it in development, and\n // in every other mode it is simply the one place that knows what was\n // bound, which is worth having whether or not anything renders it.\n setHttpReadyReport({\n boundAddress: address.boundAddress,\n url: address.url,\n wildcardBind: address.wildcardBind,\n port: boundPort,\n routeCount,\n publicUrl: address.publicUrl,\n });\n\n if (!deferToReadyBlock) {\n log.success(`http`, \"connection\", address.ready);\n\n if (address.publicUrl) {\n log.info(`http`, \"connection\", address.publicUrl);\n }\n }\n\n // NEVER conditional on the block. A base URL pointing at a port nothing\n // is listening on is the single most misleading state this connector can\n // be in, and a warning a status block swallows is a warning nobody reads\n // — so it is logged where it lands ABOVE the block, in both modes.\n if (address.warning) {\n log.warn(`http`, \"connection\", address.warning);\n }\n } catch (error) {\n // A failed listen()/port-bind at boot means the app can't serve — fatal.\n // `log.fatal` reaches Sentry/file; `await log.flush()` drains buffered and\n // async channels before the process exits.\n await log.fatal(\"http\", \"connection\", error);\n\n // …and `log.fatal` reaches NOBODY when the app's log config declares no\n // terminal channel — the ordinary production shape (`production: {\n // channels: [new FileLog()] }`), and the shape of every app with no `log`\n // config at all, since `LoggerConnector.start()` then returns before\n // `log.configure`. That is how an `EADDRINUSE` at boot became a process\n // that exited 1 in total silence: the error was constructed, thrown and\n // caught exactly as designed, and then written to zero channels. stderr\n // is the one destination that is always there.\n ensureFatalIsVisible(error);\n\n await log.flush();\n\n process.exit(1); // stop the process, exit with error\n }\n\n this.active = true;\n }\n\n /**\n * Restart — needs a fresh Fastify instance since `start()` now\n * re-runs `router.scan()`, and re-scanning the same Fastify would\n * register duplicate route handlers.\n */\n public async restart(): Promise<void> {\n await this.shutdown();\n await this.boot();\n await this.start();\n }\n\n /**\n * Shutdown HTTP server\n */\n public async shutdown(): Promise<void> {\n if (!this.active) {\n return;\n }\n\n const server = getHttpServer();\n\n if (server) {\n const timeout = config.get(\"http.gracefulShutdown.timeout\", DEFAULT_SHUTDOWN_TIMEOUT);\n const drained = await closeServerWithTimeout(server, timeout);\n\n if (!drained) {\n log.warn(\n \"http\",\n \"shutdown\",\n `In-flight requests did not drain within ${timeout}ms; closing anyway`,\n );\n }\n }\n\n this.active = false;\n }\n\n /**\n * Override shouldRestart to handle routes.ts specially\n * routes.ts changes should NOT restart the server (use HMR instead)\n * Now receives config file paths directly from layer-executor\n */\n public shouldRestart(changedFiles: string[]): boolean {\n // Only restart for config changes, not routes\n return changedFiles.some((file) => {\n const relativePath = file.replace(/\\\\/g, \"/\");\n return this.watchedFiles.includes(relativePath);\n });\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,SAAS,cAAc,cAAsB,UAA0B;CACrE,IAAI;EACF,MAAM,OAAO,IAAI,IAAI,YAAY,CAAC,CAAC;EAEnC,OAAO,OAAO,OAAO,IAAI,IAAI;CAC/B,QAAQ;EACN,OAAO;CACT;AACF;AAEA,SAAS,iBAAiB,aAA0B;CAClD,QAAQ,aAAR;EACE,KAAK,eACH,OAAO,OAAO,cAAc,WAAW;EACzC,KAAK,QACH,OAAO,OAAO,aAAa,WAAW;EACxC,KAAK,cACH,OAAO,OAAO,YAAY,WAAW;EACvC,SACE,OAAO,OAAO,MAAM,WAAW;CACnC;AACF;;;;;AAMA,MAAM,2BAA2B;;;;;AAMjC,IAAa,gBAAb,cAAmC,cAAc;;;cACxB;;;sBASW,CAAC,sBAAsB,qBAAqB;;;;;;;;;;;;;;;;;;;CAwB9E,IAAc,oBAA6B;EACzC,OAAO,YAAY,oBAAoB,iBAAiB,CAAC,YAAY;CACvE;;;;;;CAOA,MAAa,OAAO;EAClB,MAAM,aAAa,OAAO,IAAI,MAAM;EAEpC,IAAI,CAAC,YAAY;EAEjB,MAAM,OAAO,WAAW;EACxB,IAAI,KACF,QACA,cACA,gCAAgC,KAAK,MAAM,iBAAiB,YAAY,WAAW,EAAE,MACvF;EAEA,KAAK,OAAO,gBAAgB,WAAW,aAAa;EAEpD,UAAU,IAAI,eAAe,KAAK,IAAI;EAEtC,MAAM,oBAAoB,KAAK,IAAI;EAKnC,WAHgB,OAAO,IAAI,aAGV,CAAC;EAIlB,KAAK,qBAAqB;CAC5B;;;;;;;;CASA,AAAQ,uBAA6B;EACnC,IAAI,CAAC,KAAK,MACR;EAGF,MAAM,eAAe,OAAO,IAAI,aAAa;EAE7C,IAAI,cAAc,YAAY,OAC5B;EAGF,MAAM,eAAe,cAAc,QAAQ;EAC3C,MAAM,gBAAgB,cAAc,iBAAiB;EAErD,KAAK,KAAK,IAAI,cAAc,OAAO,UAAe,UAAe;GAC/D,MAAM,SAAS,OAAO,SAAS;GAE/B,OAAO,MAAM,KAAK,OAAO,WAAW,OAAO,MAAM,GAAG,CAAC,CAAC,KAAK,MAAM;EACnE,CAAC;EAED,KAAK,KAAK,IAAI,eAAe,OAAO,UAAe,UAAe;GAChE,MAAM,SAAS,MAAM,OAAO,UAAU;GAEtC,OAAO,MAAM,KAAK,OAAO,WAAW,OAAO,MAAM,GAAG,CAAC,CAAC,KAAK,MAAM;EACnE,CAAC;CACH;;;;;;CAOA,MAAa,QAAuB;EAClC,MAAM,aAAa,OAAO,IAAI,MAAM;EAEpC,IAAI,CAAC,cAAc,CAAC,KAAK,MAAM;EAK/B,MAAM,oBAAoB,KAAK;EAE/B,IAAI,YAAY,oBAAoB,eAClC,OAAO,cAAc,KAAK,IAAI;OAE9B,OAAO,KAAK,KAAK,IAAI;EAOvB,OAAO,+BAA+B,OAAO,WAAW,CAAC;EAEzD,MAAM,aAAa,OAAO,WAAW;EAOrC,IAAI,CAAC,mBACH,IAAI,KAAK,QAAQ,UAAU,GAAG,WAAW,qBAAqB;EAchE,MAAM,iBAAiB,gBAAgB,WAAW,IAAI;EAEtD,IAAI,CAAC,qBAAqB,WAAW,MAAM,cAAc,GACvD,IAAI,KACF,QACA,cACA,wBAAwB,KAAK,UAAU,WAAW,IAAI,EAAE,iBAAiB,eAAe,EAC1F;EAGF,IAAI;GAQF,IAAI,mBAAmB,GACrB,MAAM,sBAAsB,gBAAgB,WAAW,QAAQ,WAAW;GAO5E,MAAM,eAAe,MAAM,KAAK,KAAK,OAAO;IAC1C,MAAM;IACN,MAAM,WAAW,QAAQ;GAC3B,CAAC;GAUD,MAAM,YAAY,cAAc,cAAc,cAAc;GAE5D,YAAY,cAAc,SAAS;GAEnC,MAAM,UAAU,sBACd,cACA,OAAO,IAAI,aAAa,GAIxB,WAAW,QAAQ,WACrB;GAKA,mBAAmB;IACjB,cAAc,QAAQ;IACtB,KAAK,QAAQ;IACb,cAAc,QAAQ;IACtB,MAAM;IACN;IACA,WAAW,QAAQ;GACrB,CAAC;GAED,IAAI,CAAC,mBAAmB;IACtB,IAAI,QAAQ,QAAQ,cAAc,QAAQ,KAAK;IAE/C,IAAI,QAAQ,WACV,IAAI,KAAK,QAAQ,cAAc,QAAQ,SAAS;GAEpD;GAMA,IAAI,QAAQ,SACV,IAAI,KAAK,QAAQ,cAAc,QAAQ,OAAO;EAElD,SAAS,OAAO;GAId,MAAM,IAAI,MAAM,QAAQ,cAAc,KAAK;GAU3C,qBAAqB,KAAK;GAE1B,MAAM,IAAI,MAAM;GAEhB,QAAQ,KAAK,CAAC;EAChB;EAEA,KAAK,SAAS;CAChB;;;;;;CAOA,MAAa,UAAyB;EACpC,MAAM,KAAK,SAAS;EACpB,MAAM,KAAK,KAAK;EAChB,MAAM,KAAK,MAAM;CACnB;;;;CAKA,MAAa,WAA0B;EACrC,IAAI,CAAC,KAAK,QACR;EAGF,MAAM,SAAS,cAAc;EAE7B,IAAI,QAAQ;GACV,MAAM,UAAU,OAAO,IAAI,iCAAiC,wBAAwB;GAGpF,IAAI,CAAC,MAFiB,uBAAuB,QAAQ,OAAO,GAG1D,IAAI,KACF,QACA,YACA,2CAA2C,QAAQ,mBACrD;EAEJ;EAEA,KAAK,SAAS;CAChB;;;;;;CAOA,AAAO,cAAc,cAAiC;EAEpD,OAAO,aAAa,MAAM,SAAS;GACjC,MAAM,eAAe,KAAK,QAAQ,OAAO,GAAG;GAC5C,OAAO,KAAK,aAAa,SAAS,YAAY;EAChD,CAAC;CACH;AACF"}
|
|
1
|
+
{"version":3,"file":"http-connector.mjs","names":[],"sources":["../../../../../../../core/src/connectors/http-connector.ts"],"sourcesContent":["import config from \"@mongez/config\";\nimport { colors } from \"@mongez/copper\";\nimport { log } from \"@warlock.js/logger\";\nimport { Application } from \"../application\";\nimport { health } from \"../http/health\";\nimport { assertPortIsAvailable } from \"../http/port-preflight\";\nimport { registerHttpPlugins } from \"../http/plugins\";\nimport { setHttpReadyReport } from \"../http/ready-report\";\nimport { isCanonicalPortValue, resolveBindPort } from \"../http/resolve-bind-port\";\nimport {\n closeServerWithTimeout,\n type FastifyInstance,\n getHttpServer,\n startHttpServer,\n} from \"../http/server\";\nimport { ensureFatalIsVisible } from \"../logger/fatal-visibility\";\nimport { router } from \"../router/router\";\nimport { type Environment } from \"../utils\";\nimport { setBaseUrl } from \"../utils/urls\";\nimport { container } from \"./../container\";\nimport { BaseConnector } from \"./base-connector\";\nimport { describeServerAddress } from \"./describe-server-address\";\nimport { readBoundPort } from \"./read-bound-port\";\nimport { ConnectorLifecyclePhase, ConnectorPriority } from \"./types\";\n\nfunction environmentColor(environment: Environment) {\n switch (environment) {\n case \"development\":\n return colors.magentaBright(environment);\n case \"test\":\n return colors.yellowBright(environment);\n case \"production\":\n return colors.greenBright(environment);\n default:\n return colors.white(environment);\n }\n}\n\n/**\n * Default time to wait for in-flight requests to drain on shutdown before the\n * HTTP server is force-closed. Override via `http.gracefulShutdown.timeout`.\n */\nconst DEFAULT_SHUTDOWN_TIMEOUT = 10_000;\n\n/**\n * HTTP Connector\n * Manages HTTP server (Fastify) lifecycle\n */\nexport class HttpConnector extends BaseConnector {\n public readonly name = \"http\";\n public readonly priority = ConnectorPriority.HTTP;\n public readonly lifecyclePhase = ConnectorLifecyclePhase.Late;\n\n /**\n * Files that trigger HTTP server restart\n * Note: routes.ts changes will be handled by HMR with wildcard routing\n * Connectors receive config file paths directly (not .env) thanks to layer-executor\n */\n protected readonly watchedFiles = [\"src/config/http.ts\", \"src/config/http.tsx\"];\n\n /**\n * Fastify Server instance\n */\n protected http?: FastifyInstance;\n\n /**\n * Whether someone downstream will render the combined ready block.\n *\n * Only the dev server does (`dev-server/ready-block.ts`), and only it can:\n * the block reports facts — boot duration, the web surface — that this\n * connector does not own and must not wait for. When it will, this connector\n * records and stays quiet; otherwise it narrates as it always has.\n *\n * Keyed on the runtime strategy rather than on `NODE_ENV`, because that is\n * what actually distinguishes \"a dev server is driving this boot\" from \"an\n * app happens to be running with development config\".\n *\n * `!isBooted` is the second half and it is not a detail. The block summarises\n * a BOOT. A `restart()` after boot — an edited `src/config/http.ts` — is an\n * EVENT, and there is no block coming to carry it, so suppressing the lines\n * there would rebind the port in total silence.\n */\n protected get rendersReadyBlock(): boolean {\n return Application.runtimeStrategy === \"development\" && !Application.isBooted;\n }\n\n /**\n * Boot the connector — construction only (create Fastify, register\n * plugins, populate container). Route scanning is deferred to\n * `start()` so it reads the router after app code has registered.\n */\n public async boot() {\n const httpConfig = config.get(\"http\");\n\n if (!httpConfig) return;\n\n const port = httpConfig.port;\n log.info(\n `http`,\n \"connection\",\n `Starting http server on port ${port} in ${environmentColor(Application.environment)} mode`,\n );\n\n this.http = startHttpServer(httpConfig.serverOptions);\n\n container.set(\"http.server\", this.http);\n\n await registerHttpPlugins(this.http);\n\n const baseUrl = config.get(\"app.baseUrl\");\n\n // update base url\n setBaseUrl(baseUrl);\n\n // Liveness/readiness endpoints — registered on Fastify before start() scans\n // the app router, so they exist by the time the server listens.\n this.registerHealthRoutes();\n }\n\n /**\n * Register the built-in liveness (`/health`) and readiness (`/ready`)\n * endpoints directly on the Fastify instance — infra routes, kept off the app\n * router so they're immune to HMR and never collide with route scanning. Opt\n * out via `http.health.enabled = false`; rename via `http.health.path` /\n * `http.health.readinessPath`.\n */\n private registerHealthRoutes(): void {\n if (!this.http) {\n return;\n }\n\n const healthConfig = config.get(\"http.health\");\n\n if (healthConfig?.enabled === false) {\n return;\n }\n\n const livenessPath = healthConfig?.path ?? \"/health\";\n const readinessPath = healthConfig?.readinessPath ?? \"/ready\";\n\n this.http.get(livenessPath, async (_request: any, reply: any) => {\n const result = health.liveness();\n\n return reply.code(result.status === \"ok\" ? 200 : 503).send(result);\n });\n\n this.http.get(readinessPath, async (_request: any, reply: any) => {\n const result = await health.readiness();\n\n return reply.code(result.status === \"ok\" ? 200 : 503).send(result);\n });\n }\n\n /**\n * Initialize HTTP server — bind app-registered routes to Fastify\n * then listen. Scanning here (not in `boot`) lets HTTP boot before\n * app code without losing routes.\n */\n public async start(): Promise<void> {\n const httpConfig = config.get(\"http\");\n\n if (!httpConfig || !this.http) return;\n\n // Read once. `Application.isBooted` flips during the same boot this method\n // belongs to, and a predicate that answered differently before and after\n // `listen()` would print half a report.\n const deferToReadyBlock = this.rendersReadyBlock;\n\n if (Application.runtimeStrategy === \"development\") {\n router.scanDevServer(this.http);\n } else {\n router.scan(this.http);\n }\n\n // Surface the route table as a readiness signal + a boot log, so an empty\n // or partial route surface (e.g. a route module that failed to register) is\n // detectable instead of silently 404ing. `addRoutesRegisteredCheck` is\n // keyed by name, so re-running on restart is idempotent.\n health.addRoutesRegisteredCheck(() => router.routeCount());\n\n const routeCount = router.routeCount();\n\n // In development this fact is one line of the single ready block rendered\n // after boot completes (`dev-server/ready-block.ts`); printing it here as\n // well would put the same number on screen twice, in two formats. Outside\n // development the line is load-bearing — a supervisor or CI log greps it —\n // so it stays exactly as it was.\n if (!deferToReadyBlock) {\n log.info(\"http\", \"routes\", `${routeCount} route(s) registered`);\n }\n\n // Resolved ONCE, here, at the top of the bind sequence: `httpConfig.port`\n // may still be a string that round-tripped `env()`'s number coercion\n // (`\"03999\"`, `\" 3999\"`, `\"1e3\"`) — `net.Server.listen({ port })` binds\n // all of those without complaint, silently, sometimes to the WRONG port\n // (`\"1e3\"` -> 1000). Every downstream use of the port — the preflight\n // probe and `listen()` itself — must agree on the same resolved number.\n //\n // This is still the port ASKED for, not the port BOUND — `port: 0` asks\n // for \"any free port\" and this stays `0` for that case. What gets\n // published to `warlock:ready` is read back from `listen()`'s resolved\n // address further down, never this value.\n const configuredPort = resolveBindPort(httpConfig.port);\n\n if (!isCanonicalPortValue(httpConfig.port, configuredPort)) {\n log.info(\n \"http\",\n \"connection\",\n `Configured http.port ${JSON.stringify(httpConfig.port)} normalised to ${configuredPort}.`,\n );\n }\n\n try {\n // Preflight the bind before `listen()` so a collision surfaces as the\n // instruction in `PortInUseError.message` (which names the port) instead\n // of a raw `EADDRINUSE` from deep inside Fastify/libuv.\n //\n // Port 0 is exempt: it asks the OS for a free port, so probing it binds\n // some unrelated ephemeral port and \"passes\" while proving nothing about\n // the port this server will actually take.\n if (configuredPort !== 0) {\n await assertPortIsAvailable(configuredPort, httpConfig.host || \"localhost\");\n }\n\n // `listen()` RESOLVES with the address it actually bound — which is the\n // only address worth announcing. See `describe-server-address.ts` for why\n // announcing `app.baseUrl` here instead was a defect rather than a\n // shortcut.\n const boundAddress = await this.http.listen({\n port: configuredPort,\n host: httpConfig.host || \"localhost\",\n });\n\n // Read the ACTUALLY-bound port back out of what `listen()` resolved,\n // rather than republishing `configuredPort`. With `http.port: 0` the OS\n // picks the port, so `configuredPort` stays `0` forever — publishing it\n // instead of the real port is exactly the \"Server ready at a URL that\n // gives connection refused\" defect `describe-server-address.ts` already\n // fixed for the display string. `boundAddress` always carries an\n // explicit port (Fastify never omits one), so `configuredPort` is only\n // a defensive fallback for an address this parse cannot make sense of.\n const boundPort = readBoundPort(boundAddress, configuredPort);\n\n Application.setServedPort(boundPort);\n\n const address = describeServerAddress(\n boundAddress,\n config.get(\"app.baseUrl\"),\n // Passed because `listen()` does not report it back: Fastify resolves a\n // `0.0.0.0` bind to `http://127.0.0.1:<port>`, so the wildcard is only\n // recoverable from what we ASKED for.\n httpConfig.host || \"localhost\",\n );\n\n // Recorded unconditionally: the ready block reads it in development, and\n // in every other mode it is simply the one place that knows what was\n // bound, which is worth having whether or not anything renders it.\n setHttpReadyReport({\n boundAddress: address.boundAddress,\n url: address.url,\n wildcardBind: address.wildcardBind,\n port: boundPort,\n routeCount,\n publicUrl: address.publicUrl,\n });\n\n if (!deferToReadyBlock) {\n log.success(`http`, \"connection\", address.ready);\n\n if (address.publicUrl) {\n log.info(`http`, \"connection\", address.publicUrl);\n }\n }\n\n // NEVER conditional on the block. A base URL pointing at a port nothing\n // is listening on is the single most misleading state this connector can\n // be in, and a warning a status block swallows is a warning nobody reads\n // — so it is logged where it lands ABOVE the block, in both modes.\n if (address.warning) {\n log.warn(`http`, \"connection\", address.warning);\n }\n } catch (error) {\n // A failed listen()/port-bind at boot means the app can't serve — fatal.\n // `log.fatal` reaches Sentry/file; `await log.flush()` drains buffered and\n // async channels before the process exits.\n await log.fatal(\"http\", \"connection\", error);\n\n // …and `log.fatal` reaches NOBODY when the app's log config declares no\n // terminal channel — the ordinary production shape (`production: {\n // channels: [new FileLog()] }`), and the shape of every app with no `log`\n // config at all, since `LoggerConnector.start()` then returns before\n // `log.configure`. That is how an `EADDRINUSE` at boot became a process\n // that exited 1 in total silence: the error was constructed, thrown and\n // caught exactly as designed, and then written to zero channels. stderr\n // is the one destination that is always there.\n ensureFatalIsVisible(error);\n\n await log.flush();\n\n process.exit(1); // stop the process, exit with error\n }\n\n this.active = true;\n }\n\n /**\n * Restart — needs a fresh Fastify instance since `start()` now\n * re-runs `router.scan()`, and re-scanning the same Fastify would\n * register duplicate route handlers.\n */\n public async restart(): Promise<void> {\n await this.shutdown();\n await this.boot();\n await this.start();\n }\n\n /**\n * Shutdown HTTP server\n */\n public async shutdown(): Promise<void> {\n if (!this.active) {\n return;\n }\n\n const server = getHttpServer();\n\n if (server) {\n const timeout = config.get(\"http.gracefulShutdown.timeout\", DEFAULT_SHUTDOWN_TIMEOUT);\n const drained = await closeServerWithTimeout(server, timeout);\n\n if (!drained) {\n log.warn(\n \"http\",\n \"shutdown\",\n `In-flight requests did not drain within ${timeout}ms; closing anyway`,\n );\n }\n }\n\n this.active = false;\n }\n\n /**\n * Override shouldRestart to handle routes.ts specially\n * routes.ts changes should NOT restart the server (use HMR instead)\n * Now receives config file paths directly from layer-executor\n */\n public shouldRestart(changedFiles: string[]): boolean {\n // Only restart for config changes, not routes\n return changedFiles.some((file) => {\n const relativePath = file.replace(/\\\\/g, \"/\");\n return this.watchedFiles.includes(relativePath);\n });\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region ../core/src/connectors/read-bound-port.ts
|
|
2
|
+
/**
|
|
3
|
+
* The port actually bound, read back from the address `listen()` resolved
|
|
4
|
+
* with — never the port that was asked for.
|
|
5
|
+
*
|
|
6
|
+
* `http.port: 0` is the case that makes the distinction load-bearing: it asks
|
|
7
|
+
* the OS for any free port, so the CONFIGURED value stays `0` forever.
|
|
8
|
+
* Publishing that produces the "Server ready at a URL that gives connection
|
|
9
|
+
* refused" defect, and downstream `http://host:0`.
|
|
10
|
+
*
|
|
11
|
+
* `fallback` only fires if `boundAddress` turns out unparseable as a URL,
|
|
12
|
+
* which does not happen in practice — Fastify's resolved address is always a
|
|
13
|
+
* well-formed `http://host:port` (or `https://`) string with an explicit
|
|
14
|
+
* port — but a best-effort read of a socket address must never throw.
|
|
15
|
+
*
|
|
16
|
+
* Lives in its own file so it can be exercised directly. It used to be a
|
|
17
|
+
* module-private function inside `http-connector.ts`, where reaching it from a
|
|
18
|
+
* test meant importing the whole connector graph, and the central claim of the
|
|
19
|
+
* `port: 0` fix consequently had no test at all.
|
|
20
|
+
*/
|
|
21
|
+
function readBoundPort(boundAddress, fallback) {
|
|
22
|
+
try {
|
|
23
|
+
const port = new URL(boundAddress).port;
|
|
24
|
+
return port ? Number(port) : fallback;
|
|
25
|
+
} catch {
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
//#endregion
|
|
31
|
+
export { readBoundPort };
|
|
32
|
+
//# sourceMappingURL=read-bound-port.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"read-bound-port.mjs","names":[],"sources":["../../../../../../../core/src/connectors/read-bound-port.ts"],"sourcesContent":["/**\n * The port actually bound, read back from the address `listen()` resolved\n * with — never the port that was asked for.\n *\n * `http.port: 0` is the case that makes the distinction load-bearing: it asks\n * the OS for any free port, so the CONFIGURED value stays `0` forever.\n * Publishing that produces the \"Server ready at a URL that gives connection\n * refused\" defect, and downstream `http://host:0`.\n *\n * `fallback` only fires if `boundAddress` turns out unparseable as a URL,\n * which does not happen in practice — Fastify's resolved address is always a\n * well-formed `http://host:port` (or `https://`) string with an explicit\n * port — but a best-effort read of a socket address must never throw.\n *\n * Lives in its own file so it can be exercised directly. It used to be a\n * module-private function inside `http-connector.ts`, where reaching it from a\n * test meant importing the whole connector graph, and the central claim of the\n * `port: 0` fix consequently had no test at all.\n */\nexport function readBoundPort(boundAddress: string, fallback: number): number {\n try {\n const port = new URL(boundAddress).port;\n\n return port ? Number(port) : fallback;\n } catch {\n return fallback;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,cAAc,cAAsB,UAA0B;CAC5E,IAAI;EACF,MAAM,OAAO,IAAI,IAAI,YAAY,CAAC,CAAC;EAEnC,OAAO,OAAO,OAAO,IAAI,IAAI;CAC/B,QAAQ;EACN,OAAO;CACT;AACF"}
|
|
@@ -88,7 +88,11 @@ var DevelopmentServer = class {
|
|
|
88
88
|
const codeFiles = [...batch.added, ...batch.changed].filter((p) => !isEnvPath(p) && p !== "warlock.config.ts");
|
|
89
89
|
try {
|
|
90
90
|
await this.layerExecutor.executeBatchReload(codeFiles, filesOrchestrator.getFiles(), batch.deleted, batch.changed, batch.timings);
|
|
91
|
-
typeGenerator.executeTypingsGenerator([
|
|
91
|
+
typeGenerator.executeTypingsGenerator([
|
|
92
|
+
...batch.added,
|
|
93
|
+
...batch.changed,
|
|
94
|
+
...batch.deleted
|
|
95
|
+
]);
|
|
92
96
|
filesOrchestrator.checkHealth(batch);
|
|
93
97
|
} catch (error) {
|
|
94
98
|
devServeLog(colors.redBright(`Failed to execute batch reload: ${error}`));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"development-server.mjs","names":[],"sources":["../../../../../../../core/src/dev-server/development-server.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\r\nimport events from \"@mongez/events\";\r\nimport { fileExistsAsync, unlinkAsync } from \"@warlock.js/fs\";\r\nimport { Application } from \"../application\";\r\nimport { connectorsManager } from \"../connectors/connectors-manager\";\r\nimport { ConnectorLifecyclePhase } from \"../connectors/types\";\r\nimport { warlockConfigManager } from \"../warlock-config\";\r\nimport { BootPreconditionError } from \"./boot-precondition-error\";\r\nimport { devLogInfo, devLogSection, devLogWarn, devServeLog } from \"./dev-logger\";\r\nimport { filesOrchestrator } from \"./files-orchestrator\";\r\nimport { MANIFEST_PATH } from \"./flags\";\r\nimport type { IncomingReloadTimings } from \"./layer-executor\";\r\nimport { LayerExecutor } from \"./layer-executor\";\r\nimport { printReadyBlock } from \"./ready-block\";\r\nimport { restartDevServer } from \"./restart-dev-server\";\r\nimport { devServerShortcuts } from \"./shortcuts\";\r\nimport type { StartDevServerOptions } from \"./start-development-server\";\r\nimport { typeGenerator } from \"./type-generator\";\r\n\r\ntype Batch = {\r\n added: string[];\r\n changed: string[];\r\n deleted: string[];\r\n timings?: IncomingReloadTimings;\r\n};\r\n\r\n/**\r\n * Top-level coordinator for `warlock dev`. Wires the file orchestrator, the\r\n * connectors, and the layer executor together, and listens for the watcher's\r\n * batched events to drive HMR.\r\n */\r\nexport class DevelopmentServer {\r\n private layerExecutor?: LayerExecutor;\r\n private running = false;\r\n private readonly options: StartDevServerOptions;\r\n\r\n public constructor(options: StartDevServerOptions = {}) {\r\n this.options = options;\r\n devLogSection(\"Starting Development Server...\");\r\n }\r\n\r\n public async start(): Promise<void> {\r\n try {\r\n const startedAt = performance.now();\r\n\r\n // --fresh deletes the manifest so reconciliation re-parses every file\r\n // from disk. Transpile caching is owned by the loader hook (in-memory).\r\n if (this.options.fresh && (await fileExistsAsync(MANIFEST_PATH))) {\r\n await unlinkAsync(MANIFEST_PATH);\r\n devServeLog(colors.cyanBright(\"Cleared manifest (--fresh)\"));\r\n }\r\n\r\n await filesOrchestrator.init();\r\n await filesOrchestrator.initializeAll();\r\n await filesOrchestrator.watchFiles();\r\n\r\n filesOrchestrator.specialFilesCollector.collect(filesOrchestrator.getFiles());\r\n\r\n this.setupEventListeners();\r\n\r\n // Decorator-driven registries (models, etc.) must be populated before\r\n // routes/services can resolve symbols by name.\r\n await this.autoDiscoverFiles();\r\n\r\n await filesOrchestrator.moduleLoader.loadAll();\r\n\r\n // A rejecting validator (Application.onValidateBoot) must abort boot\r\n // before late-phase connectors bind a port — this is what makes the\r\n // hook actually prevent serving instead of merely being defined.\r\n //\r\n // Wrapped and re-thrown as a BootPreconditionError: a rejected\r\n // validator is, by definition, a precondition whose cause cannot\r\n // change because the worker tried again — the supervisor must not\r\n // restart into the same rejection. See boot-precondition-error.ts.\r\n try {\r\n await Application.runStartupValidators();\r\n } catch (error) {\r\n throw new BootPreconditionError((error as Error).message, { cause: error });\r\n }\r\n\r\n // Late-phase connectors (http, socket) bind after app code has\r\n // registered routes/listeners.\r\n await connectorsManager.startPhase(ConnectorLifecyclePhase.Late);\r\n\r\n this.layerExecutor = new LayerExecutor(\r\n filesOrchestrator.getDependencyGraph(),\r\n filesOrchestrator.specialFilesCollector,\r\n filesOrchestrator.moduleLoader,\r\n (absolutePath) => filesOrchestrator.bumpVersion(absolutePath),\r\n () => filesOrchestrator.flushVersionBumps(),\r\n );\r\n\r\n this.running = true;\r\n\r\n const duration = performance.now() - startedAt;\r\n\r\n // App modules are loaded and both connector phases are active — signal a\r\n // complete boot so `Application.onceBooted(...)` listeners fire.\r\n Application.markBooted({\r\n environment: Application.environment,\r\n runtimeStrategy: Application.runtimeStrategy,\r\n bootDurationMs: duration,\r\n });\r\n\r\n // ONE block, AFTER the boot it summarises, replacing the scattered\r\n // \"N route(s) registered\" / \"Server ready at …\" / \"ready in …\" lines that\r\n // used to arrive in completion order. Warnings logged during boot sit\r\n // above it and stay there — see `ready-block.ts`.\r\n printReadyBlock(duration);\r\n\r\n // Precedence: explicit CLI option > devServer.* config > default.\r\n const devServerConfig = await warlockConfigManager.get(\"devServer\");\r\n const generateTypings =\r\n this.options.generateTypings ?? devServerConfig?.generateTypings ?? true;\r\n const healthCheckers = this.options.healthCheckers ?? devServerConfig?.healthCheckers ?? true;\r\n\r\n if (generateTypings) typeGenerator.executeGenerateAllCommand();\r\n\r\n if (healthCheckers) {\r\n filesOrchestrator.startCheckingHealth(healthCheckers === true ? undefined : healthCheckers);\r\n }\r\n } catch (error) {\r\n devServeLog(colors.redBright(`Failed to start Development Server: ${error}`));\r\n await this.shutdown();\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * Eagerly import files whose decorators populate global registries so any\r\n * symbol-by-name resolution later in boot finds them.\r\n */\r\n private async autoDiscoverFiles(): Promise<void> {\r\n const discoveryTypes = [\"model\"] as const;\r\n for (const file of filesOrchestrator.files.values()) {\r\n if (file.type && (discoveryTypes as readonly string[]).includes(file.type)) {\r\n await filesOrchestrator.moduleLoader.loadModule(file, file.type);\r\n }\r\n }\r\n }\r\n\r\n private setupEventListeners(): void {\r\n events.on(\"dev-server:batch-complete\", (batch: Batch) => this.handleBatchComplete(batch));\r\n }\r\n\r\n private async handleBatchComplete(batch: Batch): Promise<void> {\r\n if (!this.running || !this.layerExecutor) return;\r\n\r\n // warlock.config.ts holds settings read at boot (CLI commands, build\r\n // options, watch patterns, scheduled jobs) and .env feeds every config\r\n // that reads it. Neither can be hot-reloaded without leaving running\r\n // services configured with stale values, so the only honest response is\r\n // a full restart.\r\n const restartTrigger = this.findRestartTrigger(batch);\r\n\r\n if (restartTrigger) {\r\n // On success this never comes back — the process exits and the\r\n // supervisor replaces it. Reaching the next line means the restart was\r\n // declined by config or wasn't possible, so we fall through and still\r\n // hot-reload whatever ordinary code shared the batch.\r\n await this.restartForConfigChange(restartTrigger);\r\n }\r\n\r\n // No-op saves (an editor that fsyncs without a content write) are already\r\n // filtered upstream by the file hash in FileEventHandler, so batch.changed\r\n // only contains genuinely-changed paths by the time it reaches here.\r\n\r\n const total = batch.added.length + batch.changed.length + batch.deleted.length;\r\n if (total === 0) return;\r\n\r\n // Boot-time files still ride along in `changed` when a restart was\r\n // declined (`restartOnConfigChange: false`) or wasn't possible — they are\r\n // never hot-reloaded, so keep them out of the reload set either way.\r\n const codeFiles = [...batch.added, ...batch.changed].filter(\r\n (p) => !isEnvPath(p) && p !== \"warlock.config.ts\",\r\n );\r\n\r\n try {\r\n await this.layerExecutor.executeBatchReload(\r\n codeFiles,\r\n filesOrchestrator.getFiles(),\r\n batch.deleted,\r\n batch.changed,\r\n batch.timings,\r\n );\r\n\r\n typeGenerator.executeTypingsGenerator([...batch.added, ...batch.changed]);\r\n\r\n filesOrchestrator.checkHealth(batch);\r\n } catch (error) {\r\n devServeLog(colors.redBright(`Failed to execute batch reload: ${error}`));\r\n }\r\n }\r\n\r\n /**\r\n * The boot-time file in this batch that can only be applied by restarting,\r\n * or `undefined` when the batch is ordinary application code.\r\n */\r\n private findRestartTrigger(batch: Batch): string | undefined {\r\n if (batch.changed.includes(\"warlock.config.ts\")) {\r\n return \"warlock.config.ts\";\r\n }\r\n\r\n return [...batch.added, ...batch.changed].find(isEnvPath);\r\n }\r\n\r\n /**\r\n * Restart so a changed boot-time file actually takes effect.\r\n *\r\n * Opt out with `devServer.restartOnConfigChange: false` and the old\r\n * behaviour returns: a warning telling you to restart yourself. If the\r\n * restart can't be performed at all (no supervisor, a shutdown that threw),\r\n * `restartDevServer` reports it and we fall back to the same warning rather\r\n * than leaving the server running against config it isn't using.\r\n */\r\n private async restartForConfigChange(file: string): Promise<void> {\r\n const devServerConfig = await warlockConfigManager.get(\"devServer\");\r\n\r\n if (devServerConfig?.restartOnConfigChange === false) {\r\n devLogWarn(`${file} changed — restart the dev server to apply.`);\r\n return;\r\n }\r\n\r\n devLogInfo(`${file} changed — restarting to apply.`);\r\n\r\n const restarted = await restartDevServer(this);\r\n\r\n if (!restarted) {\r\n devLogWarn(`${file} changed — restart the dev server to apply.`);\r\n }\r\n }\r\n\r\n public async shutdown(): Promise<void> {\r\n // Always hand the terminal back, even on a repeat/no-op shutdown — a\r\n // process that exits while stdin is still in raw mode leaves the user's\r\n // shell without line editing or Ctrl+C.\r\n devServerShortcuts.release();\r\n\r\n if (!this.running) return;\r\n devServeLog(colors.redBright(\"Shutting down Development Server...\"));\r\n this.running = false;\r\n await connectorsManager.shutdown();\r\n devServeLog(colors.greenBright(\"Development Server stopped\"));\r\n }\r\n\r\n public isRunning(): boolean {\r\n return this.running;\r\n }\r\n}\r\n\r\nfunction isEnvPath(path: string): boolean {\r\n const basename = path.split(\"/\").pop() ?? path;\r\n return basename === \".env\" || basename.startsWith(\".env.\");\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"development-server.mjs","names":[],"sources":["../../../../../../../core/src/dev-server/development-server.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\r\nimport events from \"@mongez/events\";\r\nimport { fileExistsAsync, unlinkAsync } from \"@warlock.js/fs\";\r\nimport { Application } from \"../application\";\r\nimport { connectorsManager } from \"../connectors/connectors-manager\";\r\nimport { ConnectorLifecyclePhase } from \"../connectors/types\";\r\nimport { warlockConfigManager } from \"../warlock-config\";\r\nimport { BootPreconditionError } from \"./boot-precondition-error\";\r\nimport { devLogInfo, devLogSection, devLogWarn, devServeLog } from \"./dev-logger\";\r\nimport { filesOrchestrator } from \"./files-orchestrator\";\r\nimport { MANIFEST_PATH } from \"./flags\";\r\nimport type { IncomingReloadTimings } from \"./layer-executor\";\r\nimport { LayerExecutor } from \"./layer-executor\";\r\nimport { printReadyBlock } from \"./ready-block\";\r\nimport { restartDevServer } from \"./restart-dev-server\";\r\nimport { devServerShortcuts } from \"./shortcuts\";\r\nimport type { StartDevServerOptions } from \"./start-development-server\";\r\nimport { typeGenerator } from \"./type-generator\";\r\n\r\ntype Batch = {\r\n added: string[];\r\n changed: string[];\r\n deleted: string[];\r\n timings?: IncomingReloadTimings;\r\n};\r\n\r\n/**\r\n * Top-level coordinator for `warlock dev`. Wires the file orchestrator, the\r\n * connectors, and the layer executor together, and listens for the watcher's\r\n * batched events to drive HMR.\r\n */\r\nexport class DevelopmentServer {\r\n private layerExecutor?: LayerExecutor;\r\n private running = false;\r\n private readonly options: StartDevServerOptions;\r\n\r\n public constructor(options: StartDevServerOptions = {}) {\r\n this.options = options;\r\n devLogSection(\"Starting Development Server...\");\r\n }\r\n\r\n public async start(): Promise<void> {\r\n try {\r\n const startedAt = performance.now();\r\n\r\n // --fresh deletes the manifest so reconciliation re-parses every file\r\n // from disk. Transpile caching is owned by the loader hook (in-memory).\r\n if (this.options.fresh && (await fileExistsAsync(MANIFEST_PATH))) {\r\n await unlinkAsync(MANIFEST_PATH);\r\n devServeLog(colors.cyanBright(\"Cleared manifest (--fresh)\"));\r\n }\r\n\r\n await filesOrchestrator.init();\r\n await filesOrchestrator.initializeAll();\r\n await filesOrchestrator.watchFiles();\r\n\r\n filesOrchestrator.specialFilesCollector.collect(filesOrchestrator.getFiles());\r\n\r\n this.setupEventListeners();\r\n\r\n // Decorator-driven registries (models, etc.) must be populated before\r\n // routes/services can resolve symbols by name.\r\n await this.autoDiscoverFiles();\r\n\r\n await filesOrchestrator.moduleLoader.loadAll();\r\n\r\n // A rejecting validator (Application.onValidateBoot) must abort boot\r\n // before late-phase connectors bind a port — this is what makes the\r\n // hook actually prevent serving instead of merely being defined.\r\n //\r\n // Wrapped and re-thrown as a BootPreconditionError: a rejected\r\n // validator is, by definition, a precondition whose cause cannot\r\n // change because the worker tried again — the supervisor must not\r\n // restart into the same rejection. See boot-precondition-error.ts.\r\n try {\r\n await Application.runStartupValidators();\r\n } catch (error) {\r\n throw new BootPreconditionError((error as Error).message, { cause: error });\r\n }\r\n\r\n // Late-phase connectors (http, socket) bind after app code has\r\n // registered routes/listeners.\r\n await connectorsManager.startPhase(ConnectorLifecyclePhase.Late);\r\n\r\n this.layerExecutor = new LayerExecutor(\r\n filesOrchestrator.getDependencyGraph(),\r\n filesOrchestrator.specialFilesCollector,\r\n filesOrchestrator.moduleLoader,\r\n (absolutePath) => filesOrchestrator.bumpVersion(absolutePath),\r\n () => filesOrchestrator.flushVersionBumps(),\r\n );\r\n\r\n this.running = true;\r\n\r\n const duration = performance.now() - startedAt;\r\n\r\n // App modules are loaded and both connector phases are active — signal a\r\n // complete boot so `Application.onceBooted(...)` listeners fire.\r\n Application.markBooted({\r\n environment: Application.environment,\r\n runtimeStrategy: Application.runtimeStrategy,\r\n bootDurationMs: duration,\r\n });\r\n\r\n // ONE block, AFTER the boot it summarises, replacing the scattered\r\n // \"N route(s) registered\" / \"Server ready at …\" / \"ready in …\" lines that\r\n // used to arrive in completion order. Warnings logged during boot sit\r\n // above it and stay there — see `ready-block.ts`.\r\n printReadyBlock(duration);\r\n\r\n // Precedence: explicit CLI option > devServer.* config > default.\r\n const devServerConfig = await warlockConfigManager.get(\"devServer\");\r\n const generateTypings =\r\n this.options.generateTypings ?? devServerConfig?.generateTypings ?? true;\r\n const healthCheckers = this.options.healthCheckers ?? devServerConfig?.healthCheckers ?? true;\r\n\r\n if (generateTypings) typeGenerator.executeGenerateAllCommand();\r\n\r\n if (healthCheckers) {\r\n filesOrchestrator.startCheckingHealth(healthCheckers === true ? undefined : healthCheckers);\r\n }\r\n } catch (error) {\r\n devServeLog(colors.redBright(`Failed to start Development Server: ${error}`));\r\n await this.shutdown();\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * Eagerly import files whose decorators populate global registries so any\r\n * symbol-by-name resolution later in boot finds them.\r\n */\r\n private async autoDiscoverFiles(): Promise<void> {\r\n const discoveryTypes = [\"model\"] as const;\r\n for (const file of filesOrchestrator.files.values()) {\r\n if (file.type && (discoveryTypes as readonly string[]).includes(file.type)) {\r\n await filesOrchestrator.moduleLoader.loadModule(file, file.type);\r\n }\r\n }\r\n }\r\n\r\n private setupEventListeners(): void {\r\n events.on(\"dev-server:batch-complete\", (batch: Batch) => this.handleBatchComplete(batch));\r\n }\r\n\r\n private async handleBatchComplete(batch: Batch): Promise<void> {\r\n if (!this.running || !this.layerExecutor) return;\r\n\r\n // warlock.config.ts holds settings read at boot (CLI commands, build\r\n // options, watch patterns, scheduled jobs) and .env feeds every config\r\n // that reads it. Neither can be hot-reloaded without leaving running\r\n // services configured with stale values, so the only honest response is\r\n // a full restart.\r\n const restartTrigger = this.findRestartTrigger(batch);\r\n\r\n if (restartTrigger) {\r\n // On success this never comes back — the process exits and the\r\n // supervisor replaces it. Reaching the next line means the restart was\r\n // declined by config or wasn't possible, so we fall through and still\r\n // hot-reload whatever ordinary code shared the batch.\r\n await this.restartForConfigChange(restartTrigger);\r\n }\r\n\r\n // No-op saves (an editor that fsyncs without a content write) are already\r\n // filtered upstream by the file hash in FileEventHandler, so batch.changed\r\n // only contains genuinely-changed paths by the time it reaches here.\r\n\r\n const total = batch.added.length + batch.changed.length + batch.deleted.length;\r\n if (total === 0) return;\r\n\r\n // Boot-time files still ride along in `changed` when a restart was\r\n // declined (`restartOnConfigChange: false`) or wasn't possible — they are\r\n // never hot-reloaded, so keep them out of the reload set either way.\r\n const codeFiles = [...batch.added, ...batch.changed].filter(\r\n (p) => !isEnvPath(p) && p !== \"warlock.config.ts\",\r\n );\r\n\r\n try {\r\n await this.layerExecutor.executeBatchReload(\r\n codeFiles,\r\n filesOrchestrator.getFiles(),\r\n batch.deleted,\r\n batch.changed,\r\n batch.timings,\r\n );\r\n\r\n typeGenerator.executeTypingsGenerator([...batch.added, ...batch.changed, ...batch.deleted]);\r\n\r\n filesOrchestrator.checkHealth(batch);\r\n } catch (error) {\r\n devServeLog(colors.redBright(`Failed to execute batch reload: ${error}`));\r\n }\r\n }\r\n\r\n /**\r\n * The boot-time file in this batch that can only be applied by restarting,\r\n * or `undefined` when the batch is ordinary application code.\r\n */\r\n private findRestartTrigger(batch: Batch): string | undefined {\r\n if (batch.changed.includes(\"warlock.config.ts\")) {\r\n return \"warlock.config.ts\";\r\n }\r\n\r\n return [...batch.added, ...batch.changed].find(isEnvPath);\r\n }\r\n\r\n /**\r\n * Restart so a changed boot-time file actually takes effect.\r\n *\r\n * Opt out with `devServer.restartOnConfigChange: false` and the old\r\n * behaviour returns: a warning telling you to restart yourself. If the\r\n * restart can't be performed at all (no supervisor, a shutdown that threw),\r\n * `restartDevServer` reports it and we fall back to the same warning rather\r\n * than leaving the server running against config it isn't using.\r\n */\r\n private async restartForConfigChange(file: string): Promise<void> {\r\n const devServerConfig = await warlockConfigManager.get(\"devServer\");\r\n\r\n if (devServerConfig?.restartOnConfigChange === false) {\r\n devLogWarn(`${file} changed — restart the dev server to apply.`);\r\n return;\r\n }\r\n\r\n devLogInfo(`${file} changed — restarting to apply.`);\r\n\r\n const restarted = await restartDevServer(this);\r\n\r\n if (!restarted) {\r\n devLogWarn(`${file} changed — restart the dev server to apply.`);\r\n }\r\n }\r\n\r\n public async shutdown(): Promise<void> {\r\n // Always hand the terminal back, even on a repeat/no-op shutdown — a\r\n // process that exits while stdin is still in raw mode leaves the user's\r\n // shell without line editing or Ctrl+C.\r\n devServerShortcuts.release();\r\n\r\n if (!this.running) return;\r\n devServeLog(colors.redBright(\"Shutting down Development Server...\"));\r\n this.running = false;\r\n await connectorsManager.shutdown();\r\n devServeLog(colors.greenBright(\"Development Server stopped\"));\r\n }\r\n\r\n public isRunning(): boolean {\r\n return this.running;\r\n }\r\n}\r\n\r\nfunction isEnvPath(path: string): boolean {\r\n const basename = path.split(\"/\").pop() ?? path;\r\n return basename === \".env\" || basename.startsWith(\".env.\");\r\n}\r\n"],"mappings":"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"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
//#region ../core/src/dev-server/extract-translation-keys.ts
|
|
4
|
+
/**
|
|
5
|
+
* Extract literal lookup keys registered through `groupedTranslations` calls,
|
|
6
|
+
* statically, without executing the source.
|
|
7
|
+
*
|
|
8
|
+
* Handles both call shapes:
|
|
9
|
+
* - Named-group: `groupedTranslations("site", { home: { en, ar } })`
|
|
10
|
+
* - Object: `groupedTranslations({ site: { home: { en, ar } } })`
|
|
11
|
+
*
|
|
12
|
+
* Groups nest to any depth (`{ detail: { title: { en, ar } } }` yields
|
|
13
|
+
* `products.detail.title`) — an object is a leaf once none of its own
|
|
14
|
+
* properties hold a further object literal.
|
|
15
|
+
*
|
|
16
|
+
* A dynamic group name, dictionary, or property key (an identifier, spread,
|
|
17
|
+
* or computed name instead of a literal) cannot be resolved statically and is
|
|
18
|
+
* silently skipped; such keys must be documented by the app author instead.
|
|
19
|
+
*/
|
|
20
|
+
function extractTranslationKeys(sourceFile) {
|
|
21
|
+
const keys = /* @__PURE__ */ new Set();
|
|
22
|
+
const visit = (node) => {
|
|
23
|
+
if (isGroupedTranslationsCall(node)) collectFromCall(node, keys);
|
|
24
|
+
ts.forEachChild(node, visit);
|
|
25
|
+
};
|
|
26
|
+
ts.forEachChild(sourceFile, visit);
|
|
27
|
+
return Array.from(keys).sort();
|
|
28
|
+
}
|
|
29
|
+
function isGroupedTranslationsCall(node) {
|
|
30
|
+
return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "groupedTranslations";
|
|
31
|
+
}
|
|
32
|
+
function collectFromCall(call, keys) {
|
|
33
|
+
const [first, second] = call.arguments;
|
|
34
|
+
if (first === void 0) return;
|
|
35
|
+
if (second !== void 0) {
|
|
36
|
+
if (ts.isStringLiteral(first) && ts.isObjectLiteralExpression(second)) collectLeaves(second, first.text, keys);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (ts.isObjectLiteralExpression(first)) for (const property of first.properties) {
|
|
40
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
41
|
+
const groupName = getPropertyName(property.name);
|
|
42
|
+
if (groupName === void 0) continue;
|
|
43
|
+
if (ts.isObjectLiteralExpression(property.initializer)) collectLeaves(property.initializer, groupName, keys);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function collectLeaves(object, prefix, keys) {
|
|
47
|
+
for (const property of object.properties) {
|
|
48
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
49
|
+
const key = getPropertyName(property.name);
|
|
50
|
+
if (key === void 0) continue;
|
|
51
|
+
if (!ts.isObjectLiteralExpression(property.initializer)) continue;
|
|
52
|
+
const path = `${prefix}.${key}`;
|
|
53
|
+
if (isLeaf(property.initializer)) keys.add(path);
|
|
54
|
+
else collectLeaves(property.initializer, path, keys);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** A locale map (`{ en: "...", ar: "..." }`) has no object-literal children. */
|
|
58
|
+
function isLeaf(object) {
|
|
59
|
+
return !object.properties.some((property) => ts.isPropertyAssignment(property) && ts.isObjectLiteralExpression(property.initializer));
|
|
60
|
+
}
|
|
61
|
+
function getPropertyName(name) {
|
|
62
|
+
if (name === void 0) return;
|
|
63
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
//#endregion
|
|
67
|
+
export { extractTranslationKeys };
|
|
68
|
+
//# sourceMappingURL=extract-translation-keys.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"extract-translation-keys.mjs","names":[],"sources":["../../../../../../../core/src/dev-server/extract-translation-keys.ts"],"sourcesContent":["import ts from \"typescript\";\n\n/**\n * Extract literal lookup keys registered through `groupedTranslations` calls,\n * statically, without executing the source.\n *\n * Handles both call shapes:\n * - Named-group: `groupedTranslations(\"site\", { home: { en, ar } })`\n * - Object: `groupedTranslations({ site: { home: { en, ar } } })`\n *\n * Groups nest to any depth (`{ detail: { title: { en, ar } } }` yields\n * `products.detail.title`) — an object is a leaf once none of its own\n * properties hold a further object literal.\n *\n * A dynamic group name, dictionary, or property key (an identifier, spread,\n * or computed name instead of a literal) cannot be resolved statically and is\n * silently skipped; such keys must be documented by the app author instead.\n */\nexport function extractTranslationKeys(sourceFile: ts.SourceFile): string[] {\n const keys = new Set<string>();\n\n const visit = (node: ts.Node): void => {\n if (isGroupedTranslationsCall(node)) {\n collectFromCall(node, keys);\n }\n\n ts.forEachChild(node, visit);\n };\n\n ts.forEachChild(sourceFile, visit);\n\n return Array.from(keys).sort();\n}\n\nfunction isGroupedTranslationsCall(node: ts.Node): node is ts.CallExpression {\n return (\n ts.isCallExpression(node) &&\n ts.isIdentifier(node.expression) &&\n node.expression.text === \"groupedTranslations\"\n );\n}\n\nfunction collectFromCall(call: ts.CallExpression, keys: Set<string>): void {\n const [first, second] = call.arguments;\n\n if (first === undefined) return;\n\n if (second !== undefined) {\n if (ts.isStringLiteral(first) && ts.isObjectLiteralExpression(second)) {\n collectLeaves(second, first.text, keys);\n }\n return;\n }\n\n if (ts.isObjectLiteralExpression(first)) {\n for (const property of first.properties) {\n if (!ts.isPropertyAssignment(property)) continue;\n\n const groupName = getPropertyName(property.name);\n if (groupName === undefined) continue;\n\n if (ts.isObjectLiteralExpression(property.initializer)) {\n collectLeaves(property.initializer, groupName, keys);\n }\n }\n }\n}\n\nfunction collectLeaves(\n object: ts.ObjectLiteralExpression,\n prefix: string,\n keys: Set<string>,\n): void {\n for (const property of object.properties) {\n if (!ts.isPropertyAssignment(property)) continue;\n\n const key = getPropertyName(property.name);\n if (key === undefined) continue;\n\n if (!ts.isObjectLiteralExpression(property.initializer)) continue;\n\n const path = `${prefix}.${key}`;\n\n if (isLeaf(property.initializer)) {\n keys.add(path);\n } else {\n collectLeaves(property.initializer, path, keys);\n }\n }\n}\n\n/** A locale map (`{ en: \"...\", ar: \"...\" }`) has no object-literal children. */\nfunction isLeaf(object: ts.ObjectLiteralExpression): boolean {\n return !object.properties.some(\n (property) =>\n ts.isPropertyAssignment(property) && ts.isObjectLiteralExpression(property.initializer),\n );\n}\n\nfunction getPropertyName(name: ts.PropertyName | undefined): string | undefined {\n if (name === undefined) {\n return undefined;\n }\n\n if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {\n return name.text;\n }\n\n return undefined;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAkBA,SAAgB,uBAAuB,YAAqC;CAC1E,MAAM,uBAAO,IAAI,IAAY;CAE7B,MAAM,SAAS,SAAwB;EACrC,IAAI,0BAA0B,IAAI,GAChC,gBAAgB,MAAM,IAAI;EAG5B,GAAG,aAAa,MAAM,KAAK;CAC7B;CAEA,GAAG,aAAa,YAAY,KAAK;CAEjC,OAAO,MAAM,KAAK,IAAI,CAAC,CAAC,KAAK;AAC/B;AAEA,SAAS,0BAA0B,MAA0C;CAC3E,OACE,GAAG,iBAAiB,IAAI,KACxB,GAAG,aAAa,KAAK,UAAU,KAC/B,KAAK,WAAW,SAAS;AAE7B;AAEA,SAAS,gBAAgB,MAAyB,MAAyB;CACzE,MAAM,CAAC,OAAO,UAAU,KAAK;CAE7B,IAAI,UAAU,QAAW;CAEzB,IAAI,WAAW,QAAW;EACxB,IAAI,GAAG,gBAAgB,KAAK,KAAK,GAAG,0BAA0B,MAAM,GAClE,cAAc,QAAQ,MAAM,MAAM,IAAI;EAExC;CACF;CAEA,IAAI,GAAG,0BAA0B,KAAK,GACpC,KAAK,MAAM,YAAY,MAAM,YAAY;EACvC,IAAI,CAAC,GAAG,qBAAqB,QAAQ,GAAG;EAExC,MAAM,YAAY,gBAAgB,SAAS,IAAI;EAC/C,IAAI,cAAc,QAAW;EAE7B,IAAI,GAAG,0BAA0B,SAAS,WAAW,GACnD,cAAc,SAAS,aAAa,WAAW,IAAI;CAEvD;AAEJ;AAEA,SAAS,cACP,QACA,QACA,MACM;CACN,KAAK,MAAM,YAAY,OAAO,YAAY;EACxC,IAAI,CAAC,GAAG,qBAAqB,QAAQ,GAAG;EAExC,MAAM,MAAM,gBAAgB,SAAS,IAAI;EACzC,IAAI,QAAQ,QAAW;EAEvB,IAAI,CAAC,GAAG,0BAA0B,SAAS,WAAW,GAAG;EAEzD,MAAM,OAAO,GAAG,OAAO,GAAG;EAE1B,IAAI,OAAO,SAAS,WAAW,GAC7B,KAAK,IAAI,IAAI;OAEb,cAAc,SAAS,aAAa,MAAM,IAAI;CAElD;AACF;;AAGA,SAAS,OAAO,QAA6C;CAC3D,OAAO,CAAC,OAAO,WAAW,MACvB,aACC,GAAG,qBAAqB,QAAQ,KAAK,GAAG,0BAA0B,SAAS,WAAW,CAC1F;AACF;AAEA,SAAS,gBAAgB,MAAuD;CAC9E,IAAI,SAAS,QACX;CAGF,IAAI,GAAG,aAAa,IAAI,KAAK,GAAG,gBAAgB,IAAI,KAAK,GAAG,iBAAiB,IAAI,GAC/E,OAAO,KAAK;AAIhB"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region ../core/src/dev-server/translation-keys-sources.ts
|
|
2
|
+
const GROUPED_TRANSLATIONS_MARKER = "groupedTranslations";
|
|
3
|
+
/**
|
|
4
|
+
* Cheap pre-filter for candidate translation-registering source files.
|
|
5
|
+
*
|
|
6
|
+
* `groupedTranslations` calls used to be scanned for on dedicated
|
|
7
|
+
* `src/**/utils/locales.*` files only, so a web module registering keys
|
|
8
|
+
* elsewhere (e.g. `src/web/layout.register.ts`) was silently invisible to the
|
|
9
|
+
* generator. Any app source file can call it, so discovery has to consider
|
|
10
|
+
* every file — this text check keeps that cheap by skipping the AST parse
|
|
11
|
+
* for the files that plainly do not call it, without executing app code.
|
|
12
|
+
*/
|
|
13
|
+
function isTranslationRegisteringSource(source) {
|
|
14
|
+
return source.includes(GROUPED_TRANSLATIONS_MARKER);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
18
|
+
export { isTranslationRegisteringSource };
|
|
19
|
+
//# sourceMappingURL=translation-keys-sources.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"translation-keys-sources.mjs","names":[],"sources":["../../../../../../../core/src/dev-server/translation-keys-sources.ts"],"sourcesContent":["const GROUPED_TRANSLATIONS_MARKER = \"groupedTranslations\";\n\n/**\n * Cheap pre-filter for candidate translation-registering source files.\n *\n * `groupedTranslations` calls used to be scanned for on dedicated\n * `src/**/utils/locales.*` files only, so a web module registering keys\n * elsewhere (e.g. `src/web/layout.register.ts`) was silently invisible to the\n * generator. Any app source file can call it, so discovery has to consider\n * every file — this text check keeps that cheap by skipping the AST parse\n * for the files that plainly do not call it, without executing app code.\n */\nexport function isTranslationRegisteringSource(source: string): boolean {\n return source.includes(GROUPED_TRANSLATIONS_MARKER);\n}\n"],"mappings":";AAAA,MAAM,8BAA8B;;;;;;;;;;;AAYpC,SAAgB,+BAA+B,QAAyB;CACtE,OAAO,OAAO,SAAS,2BAA2B;AACpD"}
|