@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 +2 -2
- package/src/config-pwa.ts +36 -5
- package/src/config.ts +21 -4
- package/src/index.ts +14 -1
- package/src/source-mask.ts +118 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "
|
|
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": "
|
|
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:
|
|
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: '
|
|
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?:
|
|
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:
|
|
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
|
-
|
|
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);
|