@ultimat3/core 18.0.0 → 19.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "18.0.0",
3
+ "version": "19.1.0",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,6 +37,6 @@
37
37
  "test": "bun test"
38
38
  },
39
39
  "dependencies": {
40
- "@ultimat3/schema": "18.0.0"
40
+ "@ultimat3/schema": "19.1.0"
41
41
  }
42
42
  }
package/src/config-pwa.ts CHANGED
@@ -11,18 +11,37 @@
11
11
  // subjects (a config file, and a function argument a library caller supplies directly).
12
12
 
13
13
  import { describeValue } from './error-render';
14
- // `app.config.ts` CONSUMES the route vocabulary; it does not own it — `config.ts`'s rule, and the
15
- // reason `OfflineStrategy` is imported rather than restated.
16
- import type { OfflineStrategy } from './route-vocabulary';
17
14
 
18
15
  /**
19
16
  * `installPrompt` was removed 2026-08, same rule: `@ultimat3/pwa`'s `createInstallController` is
20
17
  * real and complete, nothing ever threaded the flag into it, and both tracked apps plus every
21
18
  * scaffolded app set a switch with no wire. Call the controller from your own affordance instead.
22
19
  */
20
+ /**
21
+ * What the service worker needs that no route can say for itself.
22
+ *
23
+ * `fallback` is the document a navigation gets when the network is gone and the cache has no
24
+ * answer — the one thing an offline app cannot do without, and the reason this block replaced a
25
+ * bare `offline: OfflineStrategy`. That key was an app-wide DEFAULT for a field `defineRoute`
26
+ * makes **required** on every route (`route.ts` refuses a route without one), so it defaulted
27
+ * nothing, was read by nobody, and could not be given a reader without inventing a meaning for it
28
+ * (#390).
29
+ *
30
+ * The other three are `@ultimat3/pwa`'s `OfflineConfig` verbatim, and each is read by the emitter
31
+ * that builds `sw.js`: a placeholder image, a placeholder font, and the request patterns that must
32
+ * never be answered from a cache — auth and payments, where a stale 200 is worse than a failure.
33
+ */
34
+ export interface PwaOfflineConfig {
35
+ /** Absolute route path of the offline document, e.g. `/offline`. Required once `enabled`. */
36
+ readonly fallback: string | null;
37
+ readonly image: string | null;
38
+ readonly font: string | null;
39
+ readonly neverCache: readonly string[];
40
+ }
41
+
23
42
  export interface PwaConfig {
24
43
  readonly enabled: boolean;
25
- readonly offline: OfflineStrategy;
44
+ readonly offline: PwaOfflineConfig;
26
45
  readonly backgroundSync: boolean;
27
46
  readonly push: boolean;
28
47
  /**
@@ -79,7 +98,7 @@ export const PWA_COLOR_KEYS = ['themeColor', 'backgroundColor'] as const;
79
98
  * boolean needs to see the complete shape and the opt-out in the same line.
80
99
  */
81
100
  export const PWA_FIX =
82
- "in app.config.ts, complete the pwa block: pwa: { enabled: true, offline: 'runtime', name: 'My App', colors: { light: { themeColor: '#1b1f3b', backgroundColor: '#ffffff' }, dark: { themeColor: '#1b1f3b', backgroundColor: '#0b0d1a' } } } — a browser paints the install splash and the address bar from those four values before any stylesheet loads, so there is nothing for the framework to derive them from; or set pwa.enabled: false";
101
+ "in app.config.ts, complete the pwa block: pwa: { enabled: true, offline: { fallback: '/offline' }, name: 'My App', colors: { light: { themeColor: '#1b1f3b', backgroundColor: '#ffffff' }, dark: { themeColor: '#1b1f3b', backgroundColor: '#0b0d1a' } } } — a browser paints the install splash and the address bar from those four values before any stylesheet loads, so there is nothing for the framework to derive them from, and offline.fallback is the document the emitted sw.js serves when the network is gone, so add a route at that path too; or set pwa.enabled: false";
83
102
 
84
103
  /**
85
104
  * Every rule `validate` applies to the block, appended to the caller's own issue list. Answers
@@ -96,6 +115,18 @@ export function pwaIssues(pwa: PwaConfig, issues: string[]): boolean {
96
115
  if (typeof name !== 'string' || name.trim() === '') {
97
116
  issues.push(`pwa.name is required when pwa.enabled is true, and is ${describeValue(name)}`);
98
117
  }
118
+ // An ABSOLUTE path, screened here rather than at emit for this file's own stated reason: a
119
+ // relative one resolves against whatever document registered the worker, so `offline` served
120
+ // under `/posts/1` is `/posts/offline` — a 404 cached as the answer to every offline navigation.
121
+ // `pwa.enabled` means an installable app, and an installable app that shows the browser's error
122
+ // page offline is the failure the whole block exists to prevent, so this is required rather
123
+ // than optional: the alternative is two meanings for one switch (axiom 1).
124
+ const fallback: unknown = pwa.offline?.fallback;
125
+ if (typeof fallback !== 'string' || !fallback.startsWith('/')) {
126
+ issues.push(
127
+ `pwa.offline.fallback is required when pwa.enabled is true and must be an absolute route path like "/offline", and is ${describeValue(fallback)}`,
128
+ );
129
+ }
99
130
  // `typeof !== 'object' || null`, never `=== undefined`: an untyped `app.config.ts` writing
100
131
  // `pwa.colors: null` reached `null[scheme]` one line down and took the boot out with a native
101
132
  // `TypeError`, from the validator whose whole job is producing an instruction instead of one.
package/src/config.ts CHANGED
@@ -7,7 +7,7 @@ import { CURRENCY_CODE_PATTERN } from '@ultimat3/schema';
7
7
  // them. Declaring them here is what let `cache.tiers` and the ladder `@ultimat3/cache` orders by
8
8
  // drift into two vocabularies with no map between them (issue #293).
9
9
  import { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
10
- import type { PwaConfig } from './config-pwa';
10
+ import type { PwaConfig, PwaOfflineConfig } from './config-pwa';
11
11
  import { PWA_FIX, pwaIssues } from './config-pwa';
12
12
  import { describeValue } from './error-render';
13
13
  import { ConfigInvalidError } from './errors';
@@ -197,6 +197,17 @@ export interface AiConfigInput {
197
197
  readonly mcp?: Input<McpConfig> | undefined;
198
198
  }
199
199
 
200
+ /**
201
+ * `offline` is NESTED for `AiConfigInput`'s reason, and here it is load-bearing rather than
202
+ * ergonomic: `section` applies a patch ONE level deep, so an app writing
203
+ * `offline: { fallback: '/offline' }` under a flat `Input<PwaConfig>` would replace the whole
204
+ * default block — leaving `image`, `font` and `neverCache` absent at run time while the type says
205
+ * they are there, which is the exact shape of defect this repo keeps re-shipping.
206
+ */
207
+ export interface PwaConfigInput extends Omit<Input<PwaConfig>, 'offline'> {
208
+ readonly offline?: Input<PwaOfflineConfig> | undefined;
209
+ }
210
+
200
211
  export interface AppConfigInput {
201
212
  readonly name: string;
202
213
  readonly locales?: readonly string[] | undefined;
@@ -205,7 +216,7 @@ export interface AppConfigInput {
205
216
  readonly defaultCurrency?: string | undefined;
206
217
  readonly theme?: Input<ThemeConfig> | undefined;
207
218
  readonly auth?: Input<AuthConfig> | undefined;
208
- readonly pwa?: Input<PwaConfig> | undefined;
219
+ readonly pwa?: PwaConfigInput | undefined;
209
220
  readonly roles?: readonly Role[] | undefined;
210
221
  readonly database?: Input<DatabaseConfig> | undefined;
211
222
  readonly cache?: Input<CacheConfig> | undefined;
@@ -260,7 +271,7 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
260
271
  auth: { signInPath: null },
261
272
  pwa: {
262
273
  enabled: false,
263
- offline: 'network-only',
274
+ offline: { fallback: null, image: null, font: null, neverCache: [] },
264
275
  backgroundSync: false,
265
276
  push: false,
266
277
  name: '',
@@ -405,7 +416,13 @@ export function defineConfig(
405
416
  defaultCurrency: merged.defaultCurrency ?? base.defaultCurrency,
406
417
  theme: section(base.theme, merged.theme),
407
418
  auth: section(base.auth, merged.auth),
408
- pwa: section(base.pwa, merged.pwa),
419
+ // Two `section` calls, one per level: the outer one may not see `offline` at all, or it would
420
+ // drop the nested defaults `PwaConfigInput` exists to keep. Hence the cast — the outer patch is
421
+ // this block minus the key the inner call owns.
422
+ pwa: {
423
+ ...section(base.pwa, { ...merged.pwa, offline: undefined } as Input<PwaConfig>),
424
+ offline: section(base.pwa.offline, merged.pwa?.offline),
425
+ },
409
426
  roles: merged.roles ?? base.roles,
410
427
  database: section(base.database, merged.database),
411
428
  cache: section(base.cache, merged.cache),
package/src/index.ts CHANGED
@@ -5,6 +5,17 @@
5
5
  // secrets — because each is one subject spread over a dozen modules. Every name they carry is
6
6
  // still written out below: `export *` would make the contract something a reader has to resolve.
7
7
 
8
+ // Anchored on purpose, and NOT by the `sideEffects` array: Bun reads any array as `false` and drops
9
+ // the module regardless (oven-sh/bun#40650). This module registers @ultimat3/schema's error TITLES,
10
+ // because schema is tier 0 and cannot register its own — and what reads them is `UltimateError`'s
11
+ // constructor, which never imports this file. Shaken out, every X_VALIDATION_FAILED renders
12
+ // untitled in the browser with nothing to say why. `SIDE_EFFECTS_ANCHORS` carries the argument and
13
+ // `bun run side-effects` enforces it. `context.ts`, `lifecycle-errors.ts` and `secrets-errors.ts`
14
+ // are declared side-effecting too and are deliberately NOT anchored: each is reached by whatever
15
+ // uses it, and anchoring `context.ts` alone measured +3,485 B on a browser chunk for a provider a
16
+ // browser can never fire.
17
+ import './schema-error-codes';
18
+
8
19
  export type {
9
20
  Actor,
10
21
  ActorFactKey,
@@ -66,13 +77,14 @@ export type {
66
77
  JobsConfig,
67
78
  McpConfig,
68
79
  NotifyConfig,
80
+ PwaConfigInput,
69
81
  RealtimeConfig,
70
82
  RealtimeTransport,
71
83
  ThemeConfig,
72
84
  ThemeMode,
73
85
  } from './config';
74
86
  export { defineConfig, INBOX_RETENTION_KEYS } from './config';
75
- export type { PwaColors, PwaConfig, PwaSchemeColors } from './config-pwa';
87
+ export type { PwaColors, PwaConfig, PwaOfflineConfig, PwaSchemeColors } from './config-pwa';
76
88
  export { PWA_COLOR_KEYS, PWA_SCHEMES } from './config-pwa';
77
89
  export type { Ctx, CtxFacts, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';
78
90
  export {
@@ -538,6 +550,7 @@ export { safeUrl, URL_ATTRIBUTES } from './safe-url';
538
550
  export { defineService, resetServices, type ServiceFactory } from './service';
539
551
  export type { FlightJoin, Scheduler, SingleFlight, SingleFlightOptions } from './single-flight';
540
552
  export { createSingleFlight } from './single-flight';
553
+ export { endOfLiteral, maskLiterals, QUOTES, stripComments } from './source-mask';
541
554
  export { timingSafeEqual } from './timing-safe-equal';
542
555
  export {
543
556
  frameworkVersion,
@@ -0,0 +1,118 @@
1
+ // Reading TypeScript source without a parser: comments — and optionally string contents — replaced
2
+ // by spaces, newlines kept so line numbers survive. Moved here from `@ultimat3/cli`'s `ts-scan.ts`
3
+ // on 2026-09-05 because `@ultimat3/i18n`'s key extractor (tier 1) needed exactly this and could not
4
+ // import a tier-5 package: a `// … t('…')` in a comment was a phantom key `x i18n sync` would have
5
+ // written as `"…": "⟦…⟧"`. One masking implementation, at the tier every scanner can reach.
6
+
7
+ // `ReadonlySet`, so a consumer cannot mutate what every scan reads.
8
+ export const QUOTES: ReadonlySet<string> = new Set(["'", '"', '`']);
9
+ const WORD = /[\w$]/;
10
+
11
+ /** After one of these words a `/` opens a regex; after any other identifier it divides. */
12
+ const REGEX_AFTER_WORDS = new Set(
13
+ 'await case delete do else in instanceof new of return throw typeof void yield'.split(' '),
14
+ );
15
+
16
+ /**
17
+ * Index just past the closing quote of the literal opening at `from`, or `from + 1` when a `'`/`"`
18
+ * does not close on its own line — which makes it text, not a literal. Only a template literal may
19
+ * span a newline, so the apostrophe in `<p>Don't panic</p>` is JSX text; read as an opener it ran
20
+ * forward to the next `'` in the FILE (the next `fix:` line) and blanked every declaration between,
21
+ * silently emptying the `errors` gate for the whole file. Same rule `endOfRegex` applies to a `/`.
22
+ * An escaped newline is still a continuation: the escape is consumed before the line test.
23
+ */
24
+ export function endOfLiteral(text: string, from: number): number {
25
+ const quote = text[from] as string;
26
+ const spansLines = quote === '`';
27
+ for (let i = from + 1; i < text.length; i += 1) {
28
+ if (text[i] === '\\') i += 1;
29
+ else if (text[i] === quote) return i + 1;
30
+ else if (!spansLines && text[i] === '\n') return from + 1;
31
+ }
32
+ return spansLines ? text.length : from + 1;
33
+ }
34
+
35
+ /**
36
+ * Whether the `/` at `at` opens a regex rather than divides — the call no scanner without a parser
37
+ * avoids. A regex cannot follow what ends an expression: an identifier that is not one of the words
38
+ * above, a number, `)`, `]`, a string's closing quote. Every other position is an operator's and
39
+ * opens one; `</` and `/>` are JSX delimiters. Read from the masked prefix, so a comment is space.
40
+ */
41
+ function opensRegex(out: readonly string[], at: number): boolean {
42
+ if (out[at + 1] === '>') return false;
43
+ let i = at - 1;
44
+ while (i >= 0 && /\s/.test(out[i] as string)) i -= 1;
45
+ if (i < 0) return true;
46
+ const ch = out[i] as string;
47
+ if (ch === '<' || ch === ')' || ch === ']' || QUOTES.has(ch)) return false;
48
+ if (!WORD.test(ch)) return true;
49
+ let start = i;
50
+ while (start >= 0 && WORD.test(out[start] as string)) start -= 1;
51
+ return REGEX_AFTER_WORDS.has(out.slice(start + 1, i + 1).join(''));
52
+ }
53
+
54
+ /**
55
+ * Index just past the closing `/` of the regex opening at `from`, or `from + 1` when it does not
56
+ * close on its own line — a literal may not span one, so an unterminated candidate was a division
57
+ * or a JSX delimiter after all. A `/` inside a `[…]` class does not close the literal.
58
+ */
59
+ function endOfRegex(text: string, from: number): number {
60
+ let inClass = false;
61
+ let escaped = false;
62
+ for (let i = from + 1; i < text.length; i += 1) {
63
+ const ch = text[i];
64
+ if (ch === '\n') break;
65
+ if (escaped) escaped = false;
66
+ else if (ch === '\\') escaped = true;
67
+ else if (inClass) inClass = ch !== ']';
68
+ else if (ch === '[') inClass = true;
69
+ else if (ch === '/') return i + 1;
70
+ }
71
+ return from + 1;
72
+ }
73
+
74
+ /**
75
+ * Comments — and optionally string contents — replaced by spaces, newlines kept so line numbers
76
+ * survive and quote delimiters kept so the caller can still find where a literal starts and ends.
77
+ * A regex body is masked the same way: `/(['"`])/` holds three quotes that delimit nothing, and
78
+ * reading one as an opening quote desyncs every literal after it.
79
+ */
80
+ function blankRegions(text: string, strings: boolean): string {
81
+ // `split('')` and NOT `[...text]`: the spread yields one element per CODE POINT while every
82
+ // index below runs over UTF-16 units (`text.length`, `text[i]`). One astral character — an emoji
83
+ // in a fixture, `piñata 🎉` — and `out` is shorter than `text`, so every write past it lands a
84
+ // slot early and the returned mask no longer aligns with the input. Measured: 22 files in this
85
+ // tree desynced, shipped source included, and eight rules read this mask.
86
+ const out = text.split('');
87
+ const blank = (from: number, to: number): void => {
88
+ for (let n = from; n < to; n += 1) if (out[n] !== '\n') out[n] = ' ';
89
+ };
90
+ let i = 0;
91
+ while (i < text.length) {
92
+ const ch = text[i] as string;
93
+ if (ch === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) {
94
+ const line = text[i + 1] === '/';
95
+ const end = line ? text.indexOf('\n', i) : text.indexOf('*/', i + 2);
96
+ const stop = end === -1 ? text.length : line ? end : end + 2;
97
+ blank(i, stop);
98
+ i = stop;
99
+ continue;
100
+ }
101
+ // Not code: a regex body or a literal. `end === i + 1` blanks nothing and steps one char.
102
+ const end =
103
+ ch === '/' && opensRegex(out, i)
104
+ ? endOfRegex(text, i)
105
+ : QUOTES.has(ch)
106
+ ? endOfLiteral(text, i)
107
+ : i + 1;
108
+ if (strings) blank(i + 1, end - 1);
109
+ i = end;
110
+ }
111
+ return out.join('');
112
+ }
113
+
114
+ /** Comments gone, string literals intact — what a scan for declared codes or `t()` keys reads. */
115
+ export const stripComments = (text: string): string => blankRegions(text, false);
116
+
117
+ /** Comments and string contents gone, delimiters kept — what a scan for code structure reads. */
118
+ export const maskLiterals = (text: string): string => blankRegions(text, true);