@luckystack/core 0.7.3 → 0.7.5
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 +23 -0
- package/CLAUDE.md +6 -4
- package/README.md +1 -1
- package/dist/{chunk-R7IRJFVC.js → chunk-DVGSWLLS.js} +1 -1
- package/dist/chunk-DVGSWLLS.js.map +1 -0
- package/dist/{config-DZCzBNwP.d.ts → config-Dl2CBINY.d.ts} +8 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/index.d.ts +21 -3
- package/dist/index.js +19 -6
- package/dist/index.js.map +1 -1
- package/docs/app-bootstrap.md +11 -3
- package/docs/redis-adapter.md +1 -1
- package/package.json +1 -1
- package/dist/chunk-R7IRJFVC.js.map +0 -1
package/docs/app-bootstrap.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# App Bootstrap
|
|
2
2
|
|
|
3
|
-
> Deep specs for the registries + utilities a project entry wires at boot: bind address, runtime maps, api-method map, prisma/redis clients, notifier, email senders, logger + redacted log keys, locale reloader, boot UUID, synchronized env hashes, plus the cross-cutting helpers `tryCatch`, `sleep`, `getParams`, `serveAvatar`. Source: `packages/core/src/`. Bijgewerkt: 2026-
|
|
3
|
+
> Deep specs for the registries + utilities a project entry wires at boot: bind address, runtime maps, api-method map, prisma/redis clients, notifier, email senders, logger + redacted log keys, locale reloader, boot UUID, synchronized env hashes, plus the cross-cutting helpers `tryCatch`, `sleep`, `getParams`, `serveAvatar`. Source: `packages/core/src/`. Bijgewerkt: 2026-07-20.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
@@ -22,15 +22,23 @@ This doc groups every slot into sections:
|
|
|
22
22
|
|
|
23
23
|
### `registerBindAddress(address: { ip: string; port: number }): void`
|
|
24
24
|
|
|
25
|
-
Stores the listen address used by `createLuckyStackServer
|
|
25
|
+
Stores the intended pre-listen address used by `createLuckyStackServer` and resets the OAuth pre-hop baseline. Framework code never needs to infer this from env vars.
|
|
26
|
+
|
|
27
|
+
### `registerBoundAddress(address: { ip: string; port: number }): void`
|
|
28
|
+
|
|
29
|
+
Called from the successful `node:http.listen` callback with `httpServer.address().port`. Updates the address all runtime readers see while preserving the intended address. The distinction matters for `listen(0)` and for a dev auto-increment hop.
|
|
26
30
|
|
|
27
31
|
### `getBindAddress(): { ip: string; port: string }`
|
|
28
32
|
|
|
29
33
|
**Behavior (resolution order):**
|
|
30
|
-
1.
|
|
34
|
+
1. Actually-bound registered value (returns `{ ip: registered.ip, port: String(registered.port) }`).
|
|
31
35
|
2. `process.env.SERVER_IP` / `process.env.SERVER_PORT`.
|
|
32
36
|
3. Fallback to `'127.0.0.1'` / `''`.
|
|
33
37
|
|
|
38
|
+
### `resolveDevCallbackUrl(callbackUrl: string): string`
|
|
39
|
+
|
|
40
|
+
In non-production environments, rewrites a `localhost`, `127.0.0.1`, or `[::1]` OAuth callback only when its effective port still equals the intended pre-listen port and the server actually bound another port. Authorize and token exchange both call this helper, so their `redirect_uri` remains byte-identical. An explicitly configured local router/reverse-proxy port is left untouched. Production, remote hosts, malformed URLs, and already-current callbacks are no-ops.
|
|
41
|
+
|
|
34
42
|
## API Reference — Runtime Maps
|
|
35
43
|
|
|
36
44
|
### Types
|
package/docs/redis-adapter.md
CHANGED
|
@@ -22,7 +22,7 @@ export default redis
|
|
|
22
22
|
|
|
23
23
|
**Behavior:**
|
|
24
24
|
- Every method/property access is forwarded to whatever `getRedisClient()` currently returns.
|
|
25
|
-
- When the consumer has called `registerRedisClient(...)`, that client wins.
|
|
25
|
+
- When the consumer has called `registerRedisClient(...)`, that client wins — including across automatic secret-manager refresh. Core replaces only a framework-owned default; a cluster/sentinel/TLS/custom registration must rebuild itself from its own secrets-resolved listener if its credentials rotate.
|
|
26
26
|
- Otherwise the proxy lazily constructs a default ioredis instance from `env.REDIS_HOST` / `env.REDIS_PORT` (plus optional `REDIS_USER` / `REDIS_PASSWORD`) on first access.
|
|
27
27
|
- The default client installs a `connect` listener that logs `"Connected to Redis"` via `getLogger().info` and an `error` listener that routes to `getLogger().error`.
|
|
28
28
|
- The default client uses an exponential `retryStrategy(times => Math.min(times * 50, 2000))`.
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/deployConfigRegistry.ts","../src/servicesConfigRegistry.ts","../src/secretsResolved.ts"],"sourcesContent":["//? Deploy-time topology registered by the project at boot. Mirrors the\n//? `projectConfig` / `runtimeMapsRegistry` / `notifier` pattern: framework\n//? packages read deploy data through `getDeployConfig()` so they don't\n//? `import '../../../deploy.config'`.\n//?\n//? Only the fields core actually consumes are declared here. The project's\n//? richer `DeployConfig` (environments, bindings, routing, etc.) stays in\n//? `deploy.config.ts` and is consumed directly by the router package, which\n//? is project-glue (tier-B) and intentionally stays project-coupled.\n//?\n//? Project entrypoint registers via the direct file path\n//? (`./packages/core/src/deployConfigRegistry`) — same Vite-bundle rule we\n//? already use for projectConfig: importing the core barrel from a\n//? client-bundled file pulls server-only modules (Redis, paths, etc.).\n\nexport interface DeployResourceShape {\n type: 'redis' | 'mongo';\n urlEnvKey: string;\n synchronizedEnvKeys?: string[];\n}\n\nexport interface DeployEnvironmentShape {\n redis: string;\n mongo: string;\n fallback?: string;\n bindings: Record<string, string>;\n}\n\nexport interface DeployRoutingShape {\n onMissingService?: 'hard-error' | 'proxy-fallback';\n missingServiceErrorCode?: string;\n enableUnhealthyFallback?: boolean;\n strictBootHandshake?: boolean;\n /**\n * Opt INTO the boot-handshake's synchronized-env (cookie/session secret) drift\n * check. DEFAULT false — the router stays a dumb proxy that holds NO backend\n * secrets. Comparing synchronized-env hashes requires the router process to\n * carry the same SESSION_SECRET etc. as the backends, which most deployments\n * deliberately avoid; the always-on shared-Redis handshake is unaffected. Set\n * true only when you intentionally give the router the synchronized secrets.\n */\n verifySynchronizedEnv?: boolean;\n /** Default port the router listens on when ROUTER_PORT env var is unset (default 4000). */\n defaultRouterPort?: number;\n /** Default ms between health polls when not specified per-call (default 5000). */\n defaultHealthPollMs?: number;\n /** ms before the fallback `/_health` probe times out (default 3000). */\n healthProbeTimeoutMs?: number;\n /** Seconds the boot-UUID Redis key lives for (default 3600 = 1h). */\n bootKeyTtlSeconds?: number;\n /**\n * ms before the router gives up proxying to an upstream backend (SEC-30 /\n * router-DeployRoutingShape). Guards against slow-loris upstreams holding a\n * router worker open. The router reads this when building its proxy request.\n * DEFAULT undefined → router falls back to its own built-in default.\n */\n upstreamTimeoutMs?: number;\n /**\n * Name of the service that terminates websocket (Socket.io) traffic. The\n * router pins WS upgrade requests to this service (they cannot be load-balanced\n * round-robin without sticky sessions). DEFAULT undefined → router uses its\n * `system` convention.\n */\n websocketService?: string;\n /**\n * Path the router exposes for its OWN health/liveness (distinct from the\n * backend `/_health` boot-handshake endpoint). DEFAULT undefined → router\n * uses its built-in default.\n */\n routerHealthPath?: string;\n /**\n * Max request body size (bytes) the router will buffer/forward before\n * rejecting with 413. DEFAULT undefined → router uses its built-in default.\n */\n maxRequestBodyBytes?: number;\n /**\n * Max size (bytes) of the upgrade `head` buffer the WS proxy forwards to the\n * upstream during a WebSocket handshake. An over-cap head is rejected (the\n * client socket is destroyed) BEFORE the upstream leg opens, so a client\n * cannot push an unbounded pre-upgrade buffer through the router. DEFAULT\n * undefined → router uses its built-in default (64 KiB).\n */\n wsMaxHeadBytes?: number;\n /**\n * Idle-timeout (ms) for an UPGRADED WebSocket pipe: when no bytes flow in\n * either direction for this long, the router tears the proxied pair down.\n * Bounds an otherwise-unbounded pre/post-auth pipe a client could hold open\n * against the router. The timer resets on any activity. DEFAULT undefined →\n * router uses its built-in default (120000 ms = 2 min). Set `0` to disable.\n */\n wsIdleTimeoutMs?: number;\n /**\n * Per-connection byte budget for an UPGRADED WebSocket pipe: when the total\n * bytes piped across both legs exceed this cap, the router tears the proxied\n * pair down. Bounds how much a single connection can stream through the\n * router. DEFAULT undefined → router uses its built-in default (100 MiB). Set\n * `false` to disable the cap (unbounded).\n */\n wsMaxBytesPerConnection?: number | false;\n}\n\nexport interface DeployDevelopmentShape {\n enableFallbackRouting?: boolean;\n healthPollMs?: number;\n switchNewTrafficToLocalWhenHealthy?: boolean;\n}\n\nexport interface DeployConfigShape {\n resources: Record<string, DeployResourceShape>;\n environments?: Record<string, DeployEnvironmentShape>;\n routing?: DeployRoutingShape;\n development?: DeployDevelopmentShape;\n}\n\nimport { createRegistry } from './createRegistry';\n\nconst DEFAULT_DEPLOY_CONFIG: DeployConfigShape = {\n resources: {},\n};\n\nconst registry = createRegistry<DeployConfigShape>(DEFAULT_DEPLOY_CONFIG);\n\nexport const registerDeployConfig = (config: DeployConfigShape): DeployConfigShape =>\n registry.register(config);\n\nexport const getDeployConfig = (): DeployConfigShape => registry.get();\n\nexport const isDeployConfigRegistered = (): boolean => registry.isRegistered();\n","//? Services config registry. The router (and presetLoader) consume the\n//? consumer's services topology — which services exist, which presets bundle\n//? them together — through this registry instead of a relative\n//? `../../../services.config` import.\n//?\n//? The consumer's `services.config.ts` registers its config at module import\n//? (side-effect pattern, identical to `deploy.config.ts:151`). Router code\n//? calls `getServicesConfig()` at request time.\n\nexport interface ServiceDefinition {\n /** 'root' -> reserved for the system service. Otherwise a folder name under srcDir. */\n // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents -- 'root' is documented as the reserved value; the union surfaces it in autocomplete\n source: 'root' | string;\n}\n\nexport interface PresetDefinition {\n description?: string;\n /** Services that are bundled together into one backend artifact. */\n services: string[];\n}\n\nexport interface ServicesConfigShape {\n services: Record<string, ServiceDefinition>;\n presets: Record<string, PresetDefinition>;\n}\n\nimport { createRegistry } from './createRegistry';\n\nconst registry = createRegistry<ServicesConfigShape>(\n //? Unregistered sentinel — `get` never returns this because `resolveDefault`\n //? throws first. Typed as the real shape so the slot stays well-typed.\n { services: {}, presets: {} },\n {\n resolveDefault: (): ServicesConfigShape => {\n throw new Error(\n '@luckystack/core: services config has not been registered. Call ' +\n '`registerServicesConfig({...})` from your `services.config.ts` (which should be ' +\n 'imported as a side-effect during boot).'\n );\n },\n },\n);\n\nexport const registerServicesConfig = (config: ServicesConfigShape): ServicesConfigShape =>\n registry.register(config);\n\nexport const getServicesConfig = (): ServicesConfigShape => registry.get();\n\nexport const isServicesConfigRegistered = (): boolean => registry.isRegistered();\n","//? Decoupled \"secrets were (re)resolved\" notification channel.\n//?\n//? A secret resolver fires `notifySecretsResolved(changedKeys)` right after it\n//? overwrites `process.env` with the real values (at boot AND on rotation).\n//? `@luckystack/secret-manager` fires it AUTOMATICALLY after every resolve (via\n//? the global-symbol channel at the bottom of this file — no import edge, no\n//? `onApplied` wiring needed).\n//? Listeners then drop any client they built from the PRE-resolution env so the\n//? next use rebuilds with the resolved secret.\n//?\n//? This is deliberately generic and lives in core with ZERO dependency on\n//? secret-manager: core self-registers a Redis listener (see `redis.ts`), and\n//? any other cached-client owner (a Prisma pool, an SDK client) can subscribe\n//? the same way without coupling to the resolver package.\n\nexport type SecretsResolvedListener = (changedKeys: readonly string[] | undefined) => void;\n\nconst listeners = new Set<SecretsResolvedListener>();\n\n//? Subscribe to secret-resolution events. Returns an unsubscribe function.\nexport const registerSecretsResolvedListener = (listener: SecretsResolvedListener): (() => void) => {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n};\n\n//? Fire all listeners. `changedKeys` = the env NAMES whose value actually\n//? changed (never the secret values). Omit it (`undefined`) when the caller\n//? doesn't know which keys changed — listeners then treat it as \"assume\n//? relevant\" and reset defensively.\nexport const notifySecretsResolved = (changedKeys?: readonly string[]): void => {\n for (const listener of listeners) {\n try {\n listener(changedKeys);\n } catch {\n //? A misbehaving listener must never break the resolve/boot path.\n }\n }\n};\n\n//? Cross-PACKAGE channel: publish THIS core instance's `notifySecretsResolved`\n//? on a well-known global-symbol array so a decoupled package that must not\n//? depend on `@luckystack/core` — notably `@luckystack/secret-manager`, the\n//? zero-dependency resolver — can fire it right after it resolves secrets, with\n//? NO import edge. An ARRAY (not a single slot) so that if the process somehow\n//? has more than one `@luckystack/core` instance (dual-package / mixed src+dist\n//? resolution — a real footgun that also causes the env-revert), EVERY instance's\n//? redis registry gets the rebuilt client, not just the last one loaded.\nconst GLOBAL_LISTENERS_SYMBOL = Symbol.for('luckystack.secretsResolved.listeners');\n\nconst getGlobalSecretsResolvedListeners = (): SecretsResolvedListener[] => {\n const existing: unknown = Reflect.get(globalThis, GLOBAL_LISTENERS_SYMBOL);\n if (Array.isArray(existing)) return existing as SecretsResolvedListener[];\n const created: SecretsResolvedListener[] = [];\n Reflect.set(globalThis, GLOBAL_LISTENERS_SYMBOL, created);\n return created;\n};\n\ngetGlobalSecretsResolvedListeners().push(notifySecretsResolved);\n"],"mappings":";;;;;AAoHA,IAAM,wBAA2C;AAAA,EAC/C,WAAW,CAAC;AACd;AAEA,IAAM,WAAW,eAAkC,qBAAqB;AAEjE,IAAM,uBAAuB,CAAC,WACnC,SAAS,SAAS,MAAM;AAEnB,IAAM,kBAAkB,MAAyB,SAAS,IAAI;AAE9D,IAAM,2BAA2B,MAAe,SAAS,aAAa;;;ACnG7E,IAAMA,YAAW;AAAA;AAAA;AAAA,EAGf,EAAE,UAAU,CAAC,GAAG,SAAS,CAAC,EAAE;AAAA,EAC5B;AAAA,IACE,gBAAgB,MAA2B;AACzC,YAAM,IAAI;AAAA,QACR;AAAA,MAGF;AAAA,IACF;AAAA,EACF;AACF;AAEO,IAAM,yBAAyB,CAAC,WACrCA,UAAS,SAAS,MAAM;AAEnB,IAAM,oBAAoB,MAA2BA,UAAS,IAAI;AAElE,IAAM,6BAA6B,MAAeA,UAAS,aAAa;;;AC/B/E,IAAM,YAAY,oBAAI,IAA6B;AAG5C,IAAM,kCAAkC,CAAC,aAAoD;AAClG,YAAU,IAAI,QAAQ;AACtB,SAAO,MAAM;AACX,cAAU,OAAO,QAAQ;AAAA,EAC3B;AACF;AAMO,IAAM,wBAAwB,CAAC,gBAA0C;AAC9E,aAAW,YAAY,WAAW;AAChC,QAAI;AACF,eAAS,WAAW;AAAA,IACtB,QAAQ;AAAA,IAER;AAAA,EACF;AACF;AAUA,IAAM,0BAA0B,uBAAO,IAAI,sCAAsC;AAEjF,IAAM,oCAAoC,MAAiC;AACzE,QAAM,WAAoB,QAAQ,IAAI,YAAY,uBAAuB;AACzE,MAAI,MAAM,QAAQ,QAAQ,EAAG,QAAO;AACpC,QAAM,UAAqC,CAAC;AAC5C,UAAQ,IAAI,YAAY,yBAAyB,OAAO;AACxD,SAAO;AACT;AAEA,kCAAkC,EAAE,KAAK,qBAAqB;","names":["registry"]}
|