cursedbelt-core 2.8.0 → 2.10.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/dist/core/offline/shellWorker.d.ts +21 -2
- package/dist/core/offline/shellWorker.js +43 -2
- package/dist/core/searchPattern.d.ts +115 -0
- package/dist/core/searchPattern.js +146 -0
- package/package.json +7 -1
- package/src/core/offline/shellWorker.spec.ts +43 -8
- package/src/core/offline/shellWorker.ts +45 -3
- package/src/core/offline/shellWorker.webworker.spec.ts +4 -2
- package/src/core/searchPattern.spec.ts +130 -0
- package/src/core/searchPattern.ts +179 -0
- package/src/leafSubpathsImportNothing.spec.ts +10 -6
- package/src/lockfileIsTracked.spec.ts +9 -5
- package/src/shippedFilesAreTracked.spec.ts +8 -8
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* - `install` — precache the shell and the config's `precache` list, then `skipWaiting`.
|
|
14
14
|
* - `activate` — delete every cache named `<cachePrefix>*` that is not this version's,
|
|
15
15
|
* then `clients.claim()`.
|
|
16
|
-
* - `fetch` — same-origin static ASSETS cache-first
|
|
16
|
+
* - `fetch` — same-origin static ASSETS: cache-first when the NAME carries a content
|
|
17
|
+
* hash, otherwise revalidated with the network and the cache as the
|
|
18
|
+
* offline fallback (see {@link isContentHashed}); document NAVIGATIONS
|
|
17
19
|
* network-first, falling back offline to the exact URL, then the same
|
|
18
20
|
* path ignoring the query, then the shell. Everything else is left on
|
|
19
21
|
* the network: `respondWith` is never called for it.
|
|
@@ -44,7 +46,7 @@
|
|
|
44
46
|
export type ShellRoute =
|
|
45
47
|
/** A document navigation — network-first, cached-document fallback offline. */
|
|
46
48
|
'navigate'
|
|
47
|
-
/** A same-origin static asset (JS/CSS/WASM/font/image/JSON) — cache-first. */
|
|
49
|
+
/** A same-origin static asset (JS/CSS/WASM/font/image/JSON) — cache-first if content-hashed, else revalidated. */
|
|
48
50
|
| 'asset'
|
|
49
51
|
/** An auth-gated API call — never cached, never answered from a cache. */
|
|
50
52
|
| 'api'
|
|
@@ -110,6 +112,23 @@ export interface ResolvedShellWorkerConfig {
|
|
|
110
112
|
/** roms' `ASSET_EXT`, verbatim. */
|
|
111
113
|
export declare const DEFAULT_ASSET_EXT: RegExp;
|
|
112
114
|
/** Always an API prefix, whatever the config says. */
|
|
115
|
+
/**
|
|
116
|
+
* A built file whose NAME carries its content hash — Vite's `index-BcHBclrL.js` — so
|
|
117
|
+
* the bytes under that name can never change, and cache-first is exact.
|
|
118
|
+
*
|
|
119
|
+
* 🔴 Every other asset is REVALIDATED (network, then the cache when offline), because
|
|
120
|
+
* cache-first on a fixed name is "the first copy this device ever saw, for ever":
|
|
121
|
+
* the cache is named by `version`, which an app bumps by hand, and nothing else
|
|
122
|
+
* evicts an entry. Measured on roms 2026-09-27: its emulator cores
|
|
123
|
+
* (`/vendor/cores/melonds_libretro.js`) and frame runtime (`/emulator-runtime.js`)
|
|
124
|
+
* are fixed names under a `v4` cache, so the DS audio fix deployed that day — and
|
|
125
|
+
* every core or runtime fix before it — could never reach a device that had already
|
|
126
|
+
* played the console. The edge answers those names `no-cache` with an ETag, so a
|
|
127
|
+
* revalidation is a 304 and no body.
|
|
128
|
+
*/
|
|
129
|
+
export declare const CONTENT_HASHED_NAME: RegExp;
|
|
130
|
+
/** Whether this pathname's last segment is a content-hashed build output. */
|
|
131
|
+
export declare function isContentHashed(pathname: string): boolean;
|
|
113
132
|
export declare const ALWAYS_API_PREFIX = "/api/";
|
|
114
133
|
/**
|
|
115
134
|
* Classify a request. Order matters and is roms' order: non-GET and cross-origin
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* - `install` — precache the shell and the config's `precache` list, then `skipWaiting`.
|
|
14
14
|
* - `activate` — delete every cache named `<cachePrefix>*` that is not this version's,
|
|
15
15
|
* then `clients.claim()`.
|
|
16
|
-
* - `fetch` — same-origin static ASSETS cache-first
|
|
16
|
+
* - `fetch` — same-origin static ASSETS: cache-first when the NAME carries a content
|
|
17
|
+
* hash, otherwise revalidated with the network and the cache as the
|
|
18
|
+
* offline fallback (see {@link isContentHashed}); document NAVIGATIONS
|
|
17
19
|
* network-first, falling back offline to the exact URL, then the same
|
|
18
20
|
* path ignoring the query, then the shell. Everything else is left on
|
|
19
21
|
* the network: `respondWith` is never called for it.
|
|
@@ -43,6 +45,25 @@
|
|
|
43
45
|
/** roms' `ASSET_EXT`, verbatim. */
|
|
44
46
|
export const DEFAULT_ASSET_EXT = /\.(?:js|mjs|css|wasm|woff2?|ttf|png|jpe?g|webp|svg|ico|json)$/i;
|
|
45
47
|
/** Always an API prefix, whatever the config says. */
|
|
48
|
+
/**
|
|
49
|
+
* A built file whose NAME carries its content hash — Vite's `index-BcHBclrL.js` — so
|
|
50
|
+
* the bytes under that name can never change, and cache-first is exact.
|
|
51
|
+
*
|
|
52
|
+
* 🔴 Every other asset is REVALIDATED (network, then the cache when offline), because
|
|
53
|
+
* cache-first on a fixed name is "the first copy this device ever saw, for ever":
|
|
54
|
+
* the cache is named by `version`, which an app bumps by hand, and nothing else
|
|
55
|
+
* evicts an entry. Measured on roms 2026-09-27: its emulator cores
|
|
56
|
+
* (`/vendor/cores/melonds_libretro.js`) and frame runtime (`/emulator-runtime.js`)
|
|
57
|
+
* are fixed names under a `v4` cache, so the DS audio fix deployed that day — and
|
|
58
|
+
* every core or runtime fix before it — could never reach a device that had already
|
|
59
|
+
* played the console. The edge answers those names `no-cache` with an ETag, so a
|
|
60
|
+
* revalidation is a 304 and no body.
|
|
61
|
+
*/
|
|
62
|
+
export const CONTENT_HASHED_NAME = /-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/;
|
|
63
|
+
/** Whether this pathname's last segment is a content-hashed build output. */
|
|
64
|
+
export function isContentHashed(pathname) {
|
|
65
|
+
return CONTENT_HASHED_NAME.test(pathname);
|
|
66
|
+
}
|
|
46
67
|
export const ALWAYS_API_PREFIX = '/api/';
|
|
47
68
|
const apiPrefixesOf = (config) => {
|
|
48
69
|
const extra = config.apiPrefixes ?? [];
|
|
@@ -143,6 +164,26 @@ export function installShellWorker(scope, config) {
|
|
|
143
164
|
store(event, 'asset', response);
|
|
144
165
|
return response;
|
|
145
166
|
};
|
|
167
|
+
// A fixed-name asset: the network's answer when it has a good one (stored for
|
|
168
|
+
// offline), else the cached copy — offline, or an edge error — else whatever the
|
|
169
|
+
// network said. Never the cache first: see `isContentHashed`.
|
|
170
|
+
const revalidate = async (event) => {
|
|
171
|
+
let response;
|
|
172
|
+
try {
|
|
173
|
+
response = await scope.fetch(event.request);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
const cached = await caches.match(event.request);
|
|
177
|
+
if (cached)
|
|
178
|
+
return cached;
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
if (response.ok) {
|
|
182
|
+
store(event, 'asset', response);
|
|
183
|
+
return response;
|
|
184
|
+
}
|
|
185
|
+
return (await caches.match(event.request)) ?? response;
|
|
186
|
+
};
|
|
146
187
|
const networkFirst = async (event) => {
|
|
147
188
|
const { request } = event;
|
|
148
189
|
try {
|
|
@@ -190,7 +231,7 @@ export function installShellWorker(scope, config) {
|
|
|
190
231
|
isNavigate: event.request.mode === 'navigate',
|
|
191
232
|
}, resolved);
|
|
192
233
|
if (route === 'asset')
|
|
193
|
-
event.respondWith(cacheFirst(event));
|
|
234
|
+
event.respondWith(isContentHashed(url.pathname) ? cacheFirst(event) : revalidate(event));
|
|
194
235
|
else if (route === 'navigate')
|
|
195
236
|
event.respondWith(networkFirst(event));
|
|
196
237
|
// `api` and `passthrough`: no respondWith, so the browser goes to the network as
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a search box's text MEANS — plain words, a `*` wildcard, or a regular
|
|
3
|
+
* expression — decided once, in one place, for the browser and the server alike.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this exists (owner request via flix, 2026-09-28) ────────────────────
|
|
6
|
+
* *"type * to mean any character in the search. Then if I know two words in a
|
|
7
|
+
* movie but not what is between them I can put the * between them like
|
|
8
|
+
* "the*the" could pull up "The hunt for the red October" … but not … "The mask".
|
|
9
|
+
* Also "hunt*red" would find "The hunt for the red October".*
|
|
10
|
+
*
|
|
11
|
+
* A word index cannot answer that: it knows the title contains `the`, not that it
|
|
12
|
+
* contains it TWICE, and not in which order its words came. So a `*` turns the
|
|
13
|
+
* search into an ordered substring match — `the*the` is "`the`, then anything,
|
|
14
|
+
* then `the`" — and a search without one is untouched, because every app already
|
|
15
|
+
* has a plain search it has tuned (flix ranks by FTS5 + bm25; see its
|
|
16
|
+
* `src/server/library.ts`).
|
|
17
|
+
*
|
|
18
|
+
* ── The semantics, all of them ──────────────────────────────────────────────
|
|
19
|
+
* - **Contains, never anchored.** `hunt*red` matches *The Hunt for Red October*
|
|
20
|
+
* although the title does not START with `hunt`. So leading and trailing stars
|
|
21
|
+
* are no-ops: `*clov*` ≡ `clov*` ≡ `clov` in what they match — but only the
|
|
22
|
+
* starred forms are WILDCARD searches, which is what lets an app answer `*over*`
|
|
23
|
+
* with *Cloverfield* (a substring inside a word) where its word index could not.
|
|
24
|
+
* - **Literal between the stars.** Spaces included, so `the *the` asks for a
|
|
25
|
+
* `the` followed by a space — a power user's word boundary, and nobody else
|
|
26
|
+
* notices. A part that is ONLY whitespace (`hunt * * red`) is dropped, because
|
|
27
|
+
* it would demand two separate gaps and fail on titles it plainly describes.
|
|
28
|
+
* - **Case-insensitive, always** — in the matcher and the regex alike. A search
|
|
29
|
+
* box that cares about capitals reads as broken.
|
|
30
|
+
* - **Regex is opt-in** (`regex: true`), and only in the unmistakable `/…/flags`
|
|
31
|
+
* form, so `AC/DC` is never mistaken for one. Its matcher runs where the data is
|
|
32
|
+
* in memory; there is deliberately no SQL form (SQLite and D1 ship no REGEXP),
|
|
33
|
+
* and a server that ever evaluates one must bound it — a viewer-typed regex on a
|
|
34
|
+
* shared server is a denial-of-service vector.
|
|
35
|
+
*
|
|
36
|
+
* ── How an app uses it ──────────────────────────────────────────────────────
|
|
37
|
+
* {@link parseSearchPattern} is the whole decision; everything else derives from
|
|
38
|
+
* its result. An app that filters in memory calls {@link createSearchMatcher}. An
|
|
39
|
+
* app that queries SQL asks {@link searchPatternToLike} for a `LIKE` pattern, and
|
|
40
|
+
* keeps its own tuned path for `kind: 'plain'`. The React `SearchInput` in
|
|
41
|
+
* `cursedbelt/react` parses with the SAME function, so the badge it shows is the
|
|
42
|
+
* syntax the query actually ran in.
|
|
43
|
+
*
|
|
44
|
+
* Isomorphic and dependency-free — it imports nothing.
|
|
45
|
+
*/
|
|
46
|
+
/** A syntax an app can switch on beyond plain words. */
|
|
47
|
+
export type SearchSyntax = 'wildcard' | 'regex';
|
|
48
|
+
/** Which syntaxes a search box understands. Absent means the defaults below. */
|
|
49
|
+
export interface SearchSyntaxOptions {
|
|
50
|
+
/** `*` stands for any run of characters (including none). Default **on**. */
|
|
51
|
+
wildcard?: boolean;
|
|
52
|
+
/** `/source/flags` is a regular expression. Default **off**. */
|
|
53
|
+
regex?: boolean;
|
|
54
|
+
}
|
|
55
|
+
export declare const DEFAULT_SEARCH_SYNTAX: Readonly<Required<SearchSyntaxOptions>>;
|
|
56
|
+
/** The wildcard character. One constant, so the help text can never disagree with the parser. */
|
|
57
|
+
export declare const SEARCH_WILDCARD = "*";
|
|
58
|
+
export type SearchPattern =
|
|
59
|
+
/** Nothing to search for — show everything. A lone `*` is this too. */
|
|
60
|
+
{
|
|
61
|
+
kind: 'empty';
|
|
62
|
+
raw: string;
|
|
63
|
+
}
|
|
64
|
+
/** Ordinary words; `text` is trimmed with runs of whitespace collapsed. */
|
|
65
|
+
| {
|
|
66
|
+
kind: 'plain';
|
|
67
|
+
raw: string;
|
|
68
|
+
text: string;
|
|
69
|
+
}
|
|
70
|
+
/** `parts` must appear in this order, anything between them. Never empty. */
|
|
71
|
+
| {
|
|
72
|
+
kind: 'wildcard';
|
|
73
|
+
raw: string;
|
|
74
|
+
text: string;
|
|
75
|
+
parts: string[];
|
|
76
|
+
} | {
|
|
77
|
+
kind: 'regex';
|
|
78
|
+
raw: string;
|
|
79
|
+
source: string;
|
|
80
|
+
flags: string;
|
|
81
|
+
}
|
|
82
|
+
/** The viewer's text uses a syntax but cannot be compiled — say why, match nothing. */
|
|
83
|
+
| {
|
|
84
|
+
kind: 'invalid';
|
|
85
|
+
raw: string;
|
|
86
|
+
syntax: SearchSyntax;
|
|
87
|
+
message: string;
|
|
88
|
+
};
|
|
89
|
+
/** Decide what a search box's text means under `options`. */
|
|
90
|
+
export declare function parseSearchPattern(raw: string, options?: SearchSyntaxOptions): SearchPattern;
|
|
91
|
+
/**
|
|
92
|
+
* The pattern as a `RegExp`, or `null` when it matches everything (`empty`) or
|
|
93
|
+
* nothing (`invalid`). Plain text becomes a case-insensitive contains.
|
|
94
|
+
*/
|
|
95
|
+
export declare function searchPatternToRegExp(pattern: SearchPattern): RegExp | null;
|
|
96
|
+
/** Does `text` match? `empty` matches everything; `invalid` matches nothing. */
|
|
97
|
+
export declare function matchesSearchPattern(pattern: SearchPattern, text: string): boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Parse once, compile once, then test many — the shape an in-memory list filter
|
|
100
|
+
* wants. `matcher(title)` for every row costs one regex test, not one parse.
|
|
101
|
+
*/
|
|
102
|
+
export declare function createSearchMatcher(raw: string, options?: SearchSyntaxOptions): {
|
|
103
|
+
pattern: SearchPattern;
|
|
104
|
+
test: (text: string) => boolean;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* The pattern as a SQL `LIKE` pattern, escaped with `escape` — so the query must
|
|
108
|
+
* say `LIKE ? ESCAPE '\'` (or whatever `escape` is). `null` for `empty`, `regex`
|
|
109
|
+
* and `invalid`: none of those has a `LIKE` form, and the caller decides what
|
|
110
|
+
* each means rather than being handed a pattern that silently matches too much.
|
|
111
|
+
*
|
|
112
|
+
* Case is left as typed. SQLite's `LIKE` is already case-insensitive for ASCII;
|
|
113
|
+
* a database whose `LIKE` is not (Postgres) should use `ILIKE` with the same string.
|
|
114
|
+
*/
|
|
115
|
+
export declare function searchPatternToLike(pattern: SearchPattern, escape?: string): string | null;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a search box's text MEANS — plain words, a `*` wildcard, or a regular
|
|
3
|
+
* expression — decided once, in one place, for the browser and the server alike.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this exists (owner request via flix, 2026-09-28) ────────────────────
|
|
6
|
+
* *"type * to mean any character in the search. Then if I know two words in a
|
|
7
|
+
* movie but not what is between them I can put the * between them like
|
|
8
|
+
* "the*the" could pull up "The hunt for the red October" … but not … "The mask".
|
|
9
|
+
* Also "hunt*red" would find "The hunt for the red October".*
|
|
10
|
+
*
|
|
11
|
+
* A word index cannot answer that: it knows the title contains `the`, not that it
|
|
12
|
+
* contains it TWICE, and not in which order its words came. So a `*` turns the
|
|
13
|
+
* search into an ordered substring match — `the*the` is "`the`, then anything,
|
|
14
|
+
* then `the`" — and a search without one is untouched, because every app already
|
|
15
|
+
* has a plain search it has tuned (flix ranks by FTS5 + bm25; see its
|
|
16
|
+
* `src/server/library.ts`).
|
|
17
|
+
*
|
|
18
|
+
* ── The semantics, all of them ──────────────────────────────────────────────
|
|
19
|
+
* - **Contains, never anchored.** `hunt*red` matches *The Hunt for Red October*
|
|
20
|
+
* although the title does not START with `hunt`. So leading and trailing stars
|
|
21
|
+
* are no-ops: `*clov*` ≡ `clov*` ≡ `clov` in what they match — but only the
|
|
22
|
+
* starred forms are WILDCARD searches, which is what lets an app answer `*over*`
|
|
23
|
+
* with *Cloverfield* (a substring inside a word) where its word index could not.
|
|
24
|
+
* - **Literal between the stars.** Spaces included, so `the *the` asks for a
|
|
25
|
+
* `the` followed by a space — a power user's word boundary, and nobody else
|
|
26
|
+
* notices. A part that is ONLY whitespace (`hunt * * red`) is dropped, because
|
|
27
|
+
* it would demand two separate gaps and fail on titles it plainly describes.
|
|
28
|
+
* - **Case-insensitive, always** — in the matcher and the regex alike. A search
|
|
29
|
+
* box that cares about capitals reads as broken.
|
|
30
|
+
* - **Regex is opt-in** (`regex: true`), and only in the unmistakable `/…/flags`
|
|
31
|
+
* form, so `AC/DC` is never mistaken for one. Its matcher runs where the data is
|
|
32
|
+
* in memory; there is deliberately no SQL form (SQLite and D1 ship no REGEXP),
|
|
33
|
+
* and a server that ever evaluates one must bound it — a viewer-typed regex on a
|
|
34
|
+
* shared server is a denial-of-service vector.
|
|
35
|
+
*
|
|
36
|
+
* ── How an app uses it ──────────────────────────────────────────────────────
|
|
37
|
+
* {@link parseSearchPattern} is the whole decision; everything else derives from
|
|
38
|
+
* its result. An app that filters in memory calls {@link createSearchMatcher}. An
|
|
39
|
+
* app that queries SQL asks {@link searchPatternToLike} for a `LIKE` pattern, and
|
|
40
|
+
* keeps its own tuned path for `kind: 'plain'`. The React `SearchInput` in
|
|
41
|
+
* `cursedbelt/react` parses with the SAME function, so the badge it shows is the
|
|
42
|
+
* syntax the query actually ran in.
|
|
43
|
+
*
|
|
44
|
+
* Isomorphic and dependency-free — it imports nothing.
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_SEARCH_SYNTAX = Object.freeze({
|
|
47
|
+
wildcard: true,
|
|
48
|
+
regex: false,
|
|
49
|
+
});
|
|
50
|
+
/** The wildcard character. One constant, so the help text can never disagree with the parser. */
|
|
51
|
+
export const SEARCH_WILDCARD = '*';
|
|
52
|
+
const REGEX_FORM = /^\/(.+)\/([a-z]*)$/;
|
|
53
|
+
/** Flags that change what matches. `g` and `y` are dropped: they make `test()` stateful. */
|
|
54
|
+
const REGEX_FLAGS = new Set(['i', 'm', 's', 'u', 'g', 'y']);
|
|
55
|
+
const collapse = (text) => text.replace(/\s+/g, ' ');
|
|
56
|
+
/** Decide what a search box's text means under `options`. */
|
|
57
|
+
export function parseSearchPattern(raw, options = {}) {
|
|
58
|
+
const syntax = { ...DEFAULT_SEARCH_SYNTAX, ...options };
|
|
59
|
+
const text = collapse(raw.trim());
|
|
60
|
+
if (text.length === 0)
|
|
61
|
+
return { kind: 'empty', raw };
|
|
62
|
+
if (syntax.regex) {
|
|
63
|
+
const form = REGEX_FORM.exec(text);
|
|
64
|
+
if (form) {
|
|
65
|
+
const [, source = '', given = ''] = form;
|
|
66
|
+
const unknown = [...given].find((f) => !REGEX_FLAGS.has(f));
|
|
67
|
+
if (unknown) {
|
|
68
|
+
return { kind: 'invalid', raw, syntax: 'regex', message: `Unknown regex flag "${unknown}"` };
|
|
69
|
+
}
|
|
70
|
+
const flags = [...new Set(['i', ...given])].filter((f) => f !== 'g' && f !== 'y').join('');
|
|
71
|
+
try {
|
|
72
|
+
new RegExp(source, flags);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
+
return { kind: 'invalid', raw, syntax: 'regex', message };
|
|
77
|
+
}
|
|
78
|
+
return { kind: 'regex', raw, source, flags };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (syntax.wildcard && text.includes(SEARCH_WILDCARD)) {
|
|
82
|
+
const parts = text.split(SEARCH_WILDCARD).filter((part) => part.trim().length > 0);
|
|
83
|
+
if (parts.length === 0)
|
|
84
|
+
return { kind: 'empty', raw };
|
|
85
|
+
return { kind: 'wildcard', raw, text, parts };
|
|
86
|
+
}
|
|
87
|
+
return { kind: 'plain', raw, text };
|
|
88
|
+
}
|
|
89
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
90
|
+
/**
|
|
91
|
+
* The pattern as a `RegExp`, or `null` when it matches everything (`empty`) or
|
|
92
|
+
* nothing (`invalid`). Plain text becomes a case-insensitive contains.
|
|
93
|
+
*/
|
|
94
|
+
export function searchPatternToRegExp(pattern) {
|
|
95
|
+
switch (pattern.kind) {
|
|
96
|
+
case 'empty':
|
|
97
|
+
case 'invalid':
|
|
98
|
+
return null;
|
|
99
|
+
case 'plain':
|
|
100
|
+
return new RegExp(escapeRegExp(pattern.text), 'i');
|
|
101
|
+
case 'wildcard':
|
|
102
|
+
// `[\s\S]*?` rather than `.*` so a gap may cross a newline in a
|
|
103
|
+
// multi-line field (a summary, a note) without needing the `s` flag.
|
|
104
|
+
return new RegExp(pattern.parts.map(escapeRegExp).join('[\\s\\S]*?'), 'i');
|
|
105
|
+
case 'regex':
|
|
106
|
+
return new RegExp(pattern.source, pattern.flags);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Does `text` match? `empty` matches everything; `invalid` matches nothing. */
|
|
110
|
+
export function matchesSearchPattern(pattern, text) {
|
|
111
|
+
if (pattern.kind === 'empty')
|
|
112
|
+
return true;
|
|
113
|
+
return searchPatternToRegExp(pattern)?.test(text) ?? false;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Parse once, compile once, then test many — the shape an in-memory list filter
|
|
117
|
+
* wants. `matcher(title)` for every row costs one regex test, not one parse.
|
|
118
|
+
*/
|
|
119
|
+
export function createSearchMatcher(raw, options = {}) {
|
|
120
|
+
const pattern = parseSearchPattern(raw, options);
|
|
121
|
+
if (pattern.kind === 'empty')
|
|
122
|
+
return { pattern, test: () => true };
|
|
123
|
+
const re = searchPatternToRegExp(pattern);
|
|
124
|
+
return { pattern, test: re ? (text) => re.test(text) : () => false };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The pattern as a SQL `LIKE` pattern, escaped with `escape` — so the query must
|
|
128
|
+
* say `LIKE ? ESCAPE '\'` (or whatever `escape` is). `null` for `empty`, `regex`
|
|
129
|
+
* and `invalid`: none of those has a `LIKE` form, and the caller decides what
|
|
130
|
+
* each means rather than being handed a pattern that silently matches too much.
|
|
131
|
+
*
|
|
132
|
+
* Case is left as typed. SQLite's `LIKE` is already case-insensitive for ASCII;
|
|
133
|
+
* a database whose `LIKE` is not (Postgres) should use `ILIKE` with the same string.
|
|
134
|
+
*/
|
|
135
|
+
export function searchPatternToLike(pattern, escape = '\\') {
|
|
136
|
+
const literal = (text) => text.replace(/[%_]/g, (c) => `${escape}${c}`);
|
|
137
|
+
const escapeAll = (text) => literal(text.split(escape).join(`${escape}${escape}`));
|
|
138
|
+
switch (pattern.kind) {
|
|
139
|
+
case 'plain':
|
|
140
|
+
return `%${escapeAll(pattern.text)}%`;
|
|
141
|
+
case 'wildcard':
|
|
142
|
+
return `%${pattern.parts.map(escapeAll).join('%')}%`;
|
|
143
|
+
default:
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Framework-agnostic core of the cursedbelt split — isomorphic domain logic. cwip below it, cursedbelt-server and cursedbelt (react) above it.",
|
|
@@ -288,6 +288,12 @@
|
|
|
288
288
|
"source": "./src/core/passwordPolicy.ts",
|
|
289
289
|
"import": "./dist/core/passwordPolicy.js"
|
|
290
290
|
},
|
|
291
|
+
"./search-pattern": {
|
|
292
|
+
"types": "./dist/core/searchPattern.d.ts",
|
|
293
|
+
"bun": "./src/core/searchPattern.ts",
|
|
294
|
+
"source": "./src/core/searchPattern.ts",
|
|
295
|
+
"import": "./dist/core/searchPattern.js"
|
|
296
|
+
},
|
|
291
297
|
"./session/claims": {
|
|
292
298
|
"types": "./dist/core/session/claims.d.ts",
|
|
293
299
|
"bun": "./src/core/session/claims.ts",
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test';
|
|
|
2
2
|
import {
|
|
3
3
|
classifyRequest,
|
|
4
4
|
installShellWorker,
|
|
5
|
+
isContentHashed,
|
|
5
6
|
mayFallBackToShell,
|
|
6
7
|
type RequestFacts,
|
|
7
8
|
resolveShellWorkerConfig,
|
|
@@ -353,16 +354,50 @@ describe('installShellWorker — fetch', () => {
|
|
|
353
354
|
);
|
|
354
355
|
});
|
|
355
356
|
|
|
356
|
-
it('serves assets cache-first and caches only an ok response', async () => {
|
|
357
|
+
it('serves CONTENT-HASHED assets cache-first and caches only an ok response', async () => {
|
|
357
358
|
const world = await installedRoms();
|
|
358
|
-
world.server.set(`${ORIGIN}/assets/app-
|
|
359
|
-
expect((await world.dispatch(req('/assets/app-
|
|
360
|
-
expect((await world.dispatch(req('/assets/missing.js')).settled())?.ok).toBe(false);
|
|
361
|
-
expect(world.cacheWrites).toEqual([`${ORIGIN}/assets/app-
|
|
362
|
-
world.goOffline();
|
|
359
|
+
world.server.set(`${ORIGIN}/assets/app-BcHBclrL.js`, new FakeResponse('app'));
|
|
360
|
+
expect((await world.dispatch(req('/assets/app-BcHBclrL.js')).settled())?.body).toBe('app');
|
|
361
|
+
expect((await world.dispatch(req('/assets/missing-DeadBeef.js')).settled())?.ok).toBe(false);
|
|
362
|
+
expect(world.cacheWrites).toEqual([`${ORIGIN}/assets/app-BcHBclrL.js`]);
|
|
363
363
|
const calls = world.networkCalls.length;
|
|
364
|
-
expect((await world.dispatch(req('/assets/app-
|
|
365
|
-
expect(world.networkCalls.length).toBe(calls); // from the cache, not the network
|
|
364
|
+
expect((await world.dispatch(req('/assets/app-BcHBclrL.js')).settled())?.body).toBe('app');
|
|
365
|
+
expect(world.networkCalls.length).toBe(calls); // from the cache, not the network — even online
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('🔴 a FIXED-NAME asset reaches the device when its bytes change — the roms core that never updated (2026-09-27)', async () => {
|
|
369
|
+
const world = await installedRoms();
|
|
370
|
+
const core = `${ORIGIN}/vendor/cores/melonds_libretro.js`;
|
|
371
|
+
world.server.set(core, new FakeResponse('old driver'));
|
|
372
|
+
expect((await world.dispatch(req('/vendor/cores/melonds_libretro.js')).settled())?.body).toBe('old driver');
|
|
373
|
+
world.server.set(core, new FakeResponse('new driver')); // a deploy
|
|
374
|
+
expect((await world.dispatch(req('/vendor/cores/melonds_libretro.js')).settled())?.body).toBe('new driver');
|
|
375
|
+
// …and the runtime precached at install is refreshed the same way.
|
|
376
|
+
world.server.set(`${ORIGIN}/emulator-runtime.js`, new FakeResponse('runtime 2'));
|
|
377
|
+
expect((await world.dispatch(req('/emulator-runtime.js')).settled())?.body).toBe('runtime 2');
|
|
378
|
+
// Offline, the LATEST copy answers, not the first one.
|
|
379
|
+
world.goOffline();
|
|
380
|
+
expect((await world.dispatch(req('/vendor/cores/melonds_libretro.js')).settled())?.body).toBe('new driver');
|
|
381
|
+
expect((await world.dispatch(req('/emulator-runtime.js')).settled())?.body).toBe('runtime 2');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('a fixed-name asset the edge errors on is served from the cache; with no copy, the error stands', async () => {
|
|
385
|
+
const world = await installedRoms();
|
|
386
|
+
world.server.set(`${ORIGIN}/controller-ds.webp`, new FakeResponse('pad'));
|
|
387
|
+
await world.dispatch(req('/controller-ds.webp')).settled();
|
|
388
|
+
world.server.set(`${ORIGIN}/controller-ds.webp`, new FakeResponse('bad gateway', false));
|
|
389
|
+
expect((await world.dispatch(req('/controller-ds.webp')).settled())?.body).toBe('pad');
|
|
390
|
+
expect((await world.dispatch(req('/favicon.svg')).settled())?.ok).toBe(false);
|
|
391
|
+
world.goOffline();
|
|
392
|
+
await expect(world.dispatch(req('/favicon.svg')).response as Promise<ShellWorkerResponse>).rejects.toThrow('Failed to fetch');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('isContentHashed: Vite names are, fixed names are not', () => {
|
|
396
|
+
expect(isContentHashed('/assets/index-BcHBclrL.js')).toBe(true);
|
|
397
|
+
expect(isContentHashed('/assets/ps2-C7Rs0q_e.css')).toBe(true);
|
|
398
|
+
expect(isContentHashed('/vendor/cores/melonds_libretro.wasm')).toBe(false);
|
|
399
|
+
expect(isContentHashed('/emulator-runtime.js')).toBe(false);
|
|
400
|
+
expect(isContentHashed('/controller-ds.webp')).toBe(false);
|
|
366
401
|
});
|
|
367
402
|
|
|
368
403
|
it('🔴 never intercepts, reads or caches /api/ — even with a stale copy planted in the cache', async () => {
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* - `install` — precache the shell and the config's `precache` list, then `skipWaiting`.
|
|
14
14
|
* - `activate` — delete every cache named `<cachePrefix>*` that is not this version's,
|
|
15
15
|
* then `clients.claim()`.
|
|
16
|
-
* - `fetch` — same-origin static ASSETS cache-first
|
|
16
|
+
* - `fetch` — same-origin static ASSETS: cache-first when the NAME carries a content
|
|
17
|
+
* hash, otherwise revalidated with the network and the cache as the
|
|
18
|
+
* offline fallback (see {@link isContentHashed}); document NAVIGATIONS
|
|
17
19
|
* network-first, falling back offline to the exact URL, then the same
|
|
18
20
|
* path ignoring the query, then the shell. Everything else is left on
|
|
19
21
|
* the network: `respondWith` is never called for it.
|
|
@@ -45,7 +47,7 @@
|
|
|
45
47
|
export type ShellRoute =
|
|
46
48
|
/** A document navigation — network-first, cached-document fallback offline. */
|
|
47
49
|
| 'navigate'
|
|
48
|
-
/** A same-origin static asset (JS/CSS/WASM/font/image/JSON) — cache-first. */
|
|
50
|
+
/** A same-origin static asset (JS/CSS/WASM/font/image/JSON) — cache-first if content-hashed, else revalidated. */
|
|
49
51
|
| 'asset'
|
|
50
52
|
/** An auth-gated API call — never cached, never answered from a cache. */
|
|
51
53
|
| 'api'
|
|
@@ -116,6 +118,27 @@ export interface ResolvedShellWorkerConfig {
|
|
|
116
118
|
export const DEFAULT_ASSET_EXT = /\.(?:js|mjs|css|wasm|woff2?|ttf|png|jpe?g|webp|svg|ico|json)$/i;
|
|
117
119
|
|
|
118
120
|
/** Always an API prefix, whatever the config says. */
|
|
121
|
+
/**
|
|
122
|
+
* A built file whose NAME carries its content hash — Vite's `index-BcHBclrL.js` — so
|
|
123
|
+
* the bytes under that name can never change, and cache-first is exact.
|
|
124
|
+
*
|
|
125
|
+
* 🔴 Every other asset is REVALIDATED (network, then the cache when offline), because
|
|
126
|
+
* cache-first on a fixed name is "the first copy this device ever saw, for ever":
|
|
127
|
+
* the cache is named by `version`, which an app bumps by hand, and nothing else
|
|
128
|
+
* evicts an entry. Measured on roms 2026-09-27: its emulator cores
|
|
129
|
+
* (`/vendor/cores/melonds_libretro.js`) and frame runtime (`/emulator-runtime.js`)
|
|
130
|
+
* are fixed names under a `v4` cache, so the DS audio fix deployed that day — and
|
|
131
|
+
* every core or runtime fix before it — could never reach a device that had already
|
|
132
|
+
* played the console. The edge answers those names `no-cache` with an ETag, so a
|
|
133
|
+
* revalidation is a 304 and no body.
|
|
134
|
+
*/
|
|
135
|
+
export const CONTENT_HASHED_NAME = /-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/;
|
|
136
|
+
|
|
137
|
+
/** Whether this pathname's last segment is a content-hashed build output. */
|
|
138
|
+
export function isContentHashed(pathname: string): boolean {
|
|
139
|
+
return CONTENT_HASHED_NAME.test(pathname);
|
|
140
|
+
}
|
|
141
|
+
|
|
119
142
|
export const ALWAYS_API_PREFIX = '/api/';
|
|
120
143
|
|
|
121
144
|
const apiPrefixesOf = (config: Pick<ShellWorkerConfig, 'apiPrefixes'>): readonly string[] => {
|
|
@@ -279,6 +302,25 @@ export function installShellWorker(scope: ShellWorkerScope, config: ShellWorkerC
|
|
|
279
302
|
return response;
|
|
280
303
|
};
|
|
281
304
|
|
|
305
|
+
// A fixed-name asset: the network's answer when it has a good one (stored for
|
|
306
|
+
// offline), else the cached copy — offline, or an edge error — else whatever the
|
|
307
|
+
// network said. Never the cache first: see `isContentHashed`.
|
|
308
|
+
const revalidate = async (event: ShellFetchEvent): Promise<ShellWorkerResponse> => {
|
|
309
|
+
let response: ShellWorkerResponse;
|
|
310
|
+
try {
|
|
311
|
+
response = await scope.fetch(event.request);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
const cached = await caches.match(event.request);
|
|
314
|
+
if (cached) return cached;
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
if (response.ok) {
|
|
318
|
+
store(event, 'asset', response);
|
|
319
|
+
return response;
|
|
320
|
+
}
|
|
321
|
+
return (await caches.match(event.request)) ?? response;
|
|
322
|
+
};
|
|
323
|
+
|
|
282
324
|
const networkFirst = async (event: ShellFetchEvent): Promise<ShellWorkerResponse> => {
|
|
283
325
|
const { request } = event;
|
|
284
326
|
try {
|
|
@@ -336,7 +378,7 @@ export function installShellWorker(scope: ShellWorkerScope, config: ShellWorkerC
|
|
|
336
378
|
},
|
|
337
379
|
resolved,
|
|
338
380
|
);
|
|
339
|
-
if (route === 'asset') event.respondWith(cacheFirst(event));
|
|
381
|
+
if (route === 'asset') event.respondWith(isContentHashed(url.pathname) ? cacheFirst(event) : revalidate(event));
|
|
340
382
|
else if (route === 'navigate') event.respondWith(networkFirst(event));
|
|
341
383
|
// `api` and `passthrough`: no respondWith, so the browser goes to the network as
|
|
342
384
|
// if this worker did not exist. Never a cache read, never a cache write.
|
|
@@ -54,11 +54,13 @@ const { caches: _dropped, ...noCaches } = sw;
|
|
|
54
54
|
installShellWorker(noCaches, { cachePrefix: 'app-shell-', version: 'v1', precache: ['/'] });
|
|
55
55
|
`,
|
|
56
56
|
);
|
|
57
|
-
const child = Bun.
|
|
57
|
+
const child = Bun.spawn([process.execPath, `${REPO}/node_modules/typescript/bin/tsc`, '-p', root], {
|
|
58
|
+
stdin: 'ignore',
|
|
58
59
|
stdout: 'pipe',
|
|
59
60
|
stderr: 'pipe',
|
|
60
61
|
});
|
|
61
|
-
|
|
62
|
+
const [stdout, stderr] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
63
|
+
return `${stdout}${stderr}`;
|
|
62
64
|
};
|
|
63
65
|
|
|
64
66
|
const output = await typecheck();
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The owner's own examples are the acceptance test — each one is a row below,
|
|
3
|
+
* against the titles he named.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from 'bun:test';
|
|
6
|
+
import {
|
|
7
|
+
createSearchMatcher,
|
|
8
|
+
matchesSearchPattern,
|
|
9
|
+
parseSearchPattern,
|
|
10
|
+
searchPatternToLike,
|
|
11
|
+
searchPatternToRegExp,
|
|
12
|
+
} from './searchPattern';
|
|
13
|
+
|
|
14
|
+
const TITLES = [
|
|
15
|
+
'The Hunt for Red October',
|
|
16
|
+
'The Mask',
|
|
17
|
+
'10 Cloverfield Lane',
|
|
18
|
+
'Cloverfield',
|
|
19
|
+
'The Agony and the Ecstasy',
|
|
20
|
+
'Other People',
|
|
21
|
+
'M*A*S*H',
|
|
22
|
+
'100% Wolf',
|
|
23
|
+
'Snake_Eyes',
|
|
24
|
+
];
|
|
25
|
+
const find = (raw: string, options = {}) => {
|
|
26
|
+
const { test } = createSearchMatcher(raw, options);
|
|
27
|
+
return TITLES.filter(test);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('the owner’s examples (flix, 2026-09-28)', () => {
|
|
31
|
+
it('"the*the" finds a title with `the` twice, and not one with it once', () => {
|
|
32
|
+
// The real title is "The Hunt for Red October" — ONE `the` — so it is
|
|
33
|
+
// correctly absent; the request remembered it as "…for the red October".
|
|
34
|
+
expect(find('the*the')).toEqual(['The Agony and the Ecstasy']);
|
|
35
|
+
expect(createSearchMatcher('the*the').test('The hunt for the red October')).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
it('"hunt*red" finds The Hunt for Red October — contains, not starts-with', () => {
|
|
38
|
+
expect(find('hunt*red')).toEqual(['The Hunt for Red October']);
|
|
39
|
+
});
|
|
40
|
+
it('"*clov*" finds 10 Cloverfield Lane, exactly as "clov" does', () => {
|
|
41
|
+
expect(find('*clov*')).toEqual(['10 Cloverfield Lane', 'Cloverfield']);
|
|
42
|
+
expect(find('clov')).toEqual(find('*clov*'));
|
|
43
|
+
});
|
|
44
|
+
it('order matters: "red*hunt" does not find The Hunt for Red October', () => {
|
|
45
|
+
expect(find('red*hunt')).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
it('a star reaches INSIDE a word — "*over*" finds Cloverfield', () => {
|
|
48
|
+
expect(find('*over*')).toEqual(['10 Cloverfield Lane', 'Cloverfield']);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('parseSearchPattern', () => {
|
|
53
|
+
it('is empty for blank text and for stars alone', () => {
|
|
54
|
+
expect(parseSearchPattern(' ').kind).toBe('empty');
|
|
55
|
+
expect(parseSearchPattern('*').kind).toBe('empty');
|
|
56
|
+
expect(parseSearchPattern(' ** ').kind).toBe('empty');
|
|
57
|
+
});
|
|
58
|
+
it('is plain without a star, trimmed and whitespace-collapsed', () => {
|
|
59
|
+
expect(parseSearchPattern(' the office ')).toEqual({ kind: 'plain', raw: ' the office ', text: 'the office' });
|
|
60
|
+
});
|
|
61
|
+
it('splits on the star and keeps spaces as literal', () => {
|
|
62
|
+
const p = parseSearchPattern('the *the');
|
|
63
|
+
expect(p).toMatchObject({ kind: 'wildcard', parts: ['the ', 'the'] });
|
|
64
|
+
});
|
|
65
|
+
it('drops a whitespace-only part, which would otherwise demand two gaps', () => {
|
|
66
|
+
expect(parseSearchPattern('hunt * * red')).toMatchObject({ kind: 'wildcard', parts: ['hunt ', ' red'] });
|
|
67
|
+
expect(find('hunt * * red')).toEqual(['The Hunt for Red October']);
|
|
68
|
+
});
|
|
69
|
+
it('with wildcard OFF, a star is plain text', () => {
|
|
70
|
+
expect(parseSearchPattern('m*a*s*h', { wildcard: false })).toMatchObject({ kind: 'plain', text: 'm*a*s*h' });
|
|
71
|
+
expect(find('m*a*s*h', { wildcard: false })).toEqual(['M*A*S*H']);
|
|
72
|
+
});
|
|
73
|
+
it('regex is OFF by default — /…/ is plain text', () => {
|
|
74
|
+
expect(parseSearchPattern('/hunt.+red/').kind).toBe('plain');
|
|
75
|
+
expect(parseSearchPattern('/hunt.*red/').kind).toBe('wildcard');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('regex (opt-in)', () => {
|
|
80
|
+
const on = { regex: true };
|
|
81
|
+
it('compiles /source/flags, always case-insensitive, never stateful', () => {
|
|
82
|
+
expect(parseSearchPattern('/^the (mask|agony)/g', on)).toMatchObject({ kind: 'regex', source: '^the (mask|agony)', flags: 'i' });
|
|
83
|
+
expect(find('/^the (mask|agony)/g', on)).toEqual(['The Mask', 'The Agony and the Ecstasy']);
|
|
84
|
+
});
|
|
85
|
+
it('is invalid — and matches nothing — when it will not compile', () => {
|
|
86
|
+
const p = parseSearchPattern('/(unclosed/', on);
|
|
87
|
+
expect(p).toMatchObject({ kind: 'invalid', syntax: 'regex' });
|
|
88
|
+
expect(matchesSearchPattern(p, 'anything')).toBe(false);
|
|
89
|
+
expect(find('/(unclosed/', on)).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
it('names an unknown flag', () => {
|
|
92
|
+
expect(parseSearchPattern('/a/q', on)).toMatchObject({ kind: 'invalid', message: 'Unknown regex flag "q"' });
|
|
93
|
+
});
|
|
94
|
+
it('does not mistake a slash inside a title for a regex', () => {
|
|
95
|
+
expect(parseSearchPattern('AC/DC', on).kind).toBe('plain');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('searchPatternToRegExp', () => {
|
|
100
|
+
it('escapes regex metacharacters in plain and wildcard text', () => {
|
|
101
|
+
expect(find('100%')).toEqual(['100% Wolf']);
|
|
102
|
+
expect(searchPatternToRegExp(parseSearchPattern('a.b*(c)'))?.test('A.B then (C)')).toBe(true);
|
|
103
|
+
expect(searchPatternToRegExp(parseSearchPattern('a.b*(c)'))?.test('axb then (c)')).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
it('lets a gap cross a newline', () => {
|
|
106
|
+
expect(matchesSearchPattern(parseSearchPattern('first*second'), 'first line\nsecond line')).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
it('is null for empty and invalid', () => {
|
|
109
|
+
expect(searchPatternToRegExp(parseSearchPattern(''))).toBeNull();
|
|
110
|
+
expect(searchPatternToRegExp(parseSearchPattern('/(/', { regex: true }))).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('searchPatternToLike', () => {
|
|
115
|
+
it('is a contains for plain text and an ordered contains for a wildcard', () => {
|
|
116
|
+
expect(searchPatternToLike(parseSearchPattern('the office'))).toBe('%the office%');
|
|
117
|
+
expect(searchPatternToLike(parseSearchPattern('the*the'))).toBe('%the%the%');
|
|
118
|
+
expect(searchPatternToLike(parseSearchPattern('*clov*'))).toBe('%clov%');
|
|
119
|
+
});
|
|
120
|
+
it('escapes LIKE’s own wildcards and the escape character itself', () => {
|
|
121
|
+
expect(searchPatternToLike(parseSearchPattern('100%*snake_eyes'))).toBe('%100\\%%snake\\_eyes%');
|
|
122
|
+
expect(searchPatternToLike(parseSearchPattern('a\\b'))).toBe('%a\\\\b%');
|
|
123
|
+
expect(searchPatternToLike(parseSearchPattern('50%'), '!')).toBe('%50!%%');
|
|
124
|
+
});
|
|
125
|
+
it('has no form for empty, regex or invalid — the caller decides', () => {
|
|
126
|
+
expect(searchPatternToLike(parseSearchPattern(''))).toBeNull();
|
|
127
|
+
expect(searchPatternToLike(parseSearchPattern('/a/', { regex: true }))).toBeNull();
|
|
128
|
+
expect(searchPatternToLike(parseSearchPattern('/(/', { regex: true }))).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a search box's text MEANS — plain words, a `*` wildcard, or a regular
|
|
3
|
+
* expression — decided once, in one place, for the browser and the server alike.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this exists (owner request via flix, 2026-09-28) ────────────────────
|
|
6
|
+
* *"type * to mean any character in the search. Then if I know two words in a
|
|
7
|
+
* movie but not what is between them I can put the * between them like
|
|
8
|
+
* "the*the" could pull up "The hunt for the red October" … but not … "The mask".
|
|
9
|
+
* Also "hunt*red" would find "The hunt for the red October".*
|
|
10
|
+
*
|
|
11
|
+
* A word index cannot answer that: it knows the title contains `the`, not that it
|
|
12
|
+
* contains it TWICE, and not in which order its words came. So a `*` turns the
|
|
13
|
+
* search into an ordered substring match — `the*the` is "`the`, then anything,
|
|
14
|
+
* then `the`" — and a search without one is untouched, because every app already
|
|
15
|
+
* has a plain search it has tuned (flix ranks by FTS5 + bm25; see its
|
|
16
|
+
* `src/server/library.ts`).
|
|
17
|
+
*
|
|
18
|
+
* ── The semantics, all of them ──────────────────────────────────────────────
|
|
19
|
+
* - **Contains, never anchored.** `hunt*red` matches *The Hunt for Red October*
|
|
20
|
+
* although the title does not START with `hunt`. So leading and trailing stars
|
|
21
|
+
* are no-ops: `*clov*` ≡ `clov*` ≡ `clov` in what they match — but only the
|
|
22
|
+
* starred forms are WILDCARD searches, which is what lets an app answer `*over*`
|
|
23
|
+
* with *Cloverfield* (a substring inside a word) where its word index could not.
|
|
24
|
+
* - **Literal between the stars.** Spaces included, so `the *the` asks for a
|
|
25
|
+
* `the` followed by a space — a power user's word boundary, and nobody else
|
|
26
|
+
* notices. A part that is ONLY whitespace (`hunt * * red`) is dropped, because
|
|
27
|
+
* it would demand two separate gaps and fail on titles it plainly describes.
|
|
28
|
+
* - **Case-insensitive, always** — in the matcher and the regex alike. A search
|
|
29
|
+
* box that cares about capitals reads as broken.
|
|
30
|
+
* - **Regex is opt-in** (`regex: true`), and only in the unmistakable `/…/flags`
|
|
31
|
+
* form, so `AC/DC` is never mistaken for one. Its matcher runs where the data is
|
|
32
|
+
* in memory; there is deliberately no SQL form (SQLite and D1 ship no REGEXP),
|
|
33
|
+
* and a server that ever evaluates one must bound it — a viewer-typed regex on a
|
|
34
|
+
* shared server is a denial-of-service vector.
|
|
35
|
+
*
|
|
36
|
+
* ── How an app uses it ──────────────────────────────────────────────────────
|
|
37
|
+
* {@link parseSearchPattern} is the whole decision; everything else derives from
|
|
38
|
+
* its result. An app that filters in memory calls {@link createSearchMatcher}. An
|
|
39
|
+
* app that queries SQL asks {@link searchPatternToLike} for a `LIKE` pattern, and
|
|
40
|
+
* keeps its own tuned path for `kind: 'plain'`. The React `SearchInput` in
|
|
41
|
+
* `cursedbelt/react` parses with the SAME function, so the badge it shows is the
|
|
42
|
+
* syntax the query actually ran in.
|
|
43
|
+
*
|
|
44
|
+
* Isomorphic and dependency-free — it imports nothing.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/** A syntax an app can switch on beyond plain words. */
|
|
48
|
+
export type SearchSyntax = 'wildcard' | 'regex';
|
|
49
|
+
|
|
50
|
+
/** Which syntaxes a search box understands. Absent means the defaults below. */
|
|
51
|
+
export interface SearchSyntaxOptions {
|
|
52
|
+
/** `*` stands for any run of characters (including none). Default **on**. */
|
|
53
|
+
wildcard?: boolean;
|
|
54
|
+
/** `/source/flags` is a regular expression. Default **off**. */
|
|
55
|
+
regex?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const DEFAULT_SEARCH_SYNTAX: Readonly<Required<SearchSyntaxOptions>> = Object.freeze({
|
|
59
|
+
wildcard: true,
|
|
60
|
+
regex: false,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** The wildcard character. One constant, so the help text can never disagree with the parser. */
|
|
64
|
+
export const SEARCH_WILDCARD = '*';
|
|
65
|
+
|
|
66
|
+
export type SearchPattern =
|
|
67
|
+
/** Nothing to search for — show everything. A lone `*` is this too. */
|
|
68
|
+
| { kind: 'empty'; raw: string }
|
|
69
|
+
/** Ordinary words; `text` is trimmed with runs of whitespace collapsed. */
|
|
70
|
+
| { kind: 'plain'; raw: string; text: string }
|
|
71
|
+
/** `parts` must appear in this order, anything between them. Never empty. */
|
|
72
|
+
| { kind: 'wildcard'; raw: string; text: string; parts: string[] }
|
|
73
|
+
| { kind: 'regex'; raw: string; source: string; flags: string }
|
|
74
|
+
/** The viewer's text uses a syntax but cannot be compiled — say why, match nothing. */
|
|
75
|
+
| { kind: 'invalid'; raw: string; syntax: SearchSyntax; message: string };
|
|
76
|
+
|
|
77
|
+
const REGEX_FORM = /^\/(.+)\/([a-z]*)$/;
|
|
78
|
+
/** Flags that change what matches. `g` and `y` are dropped: they make `test()` stateful. */
|
|
79
|
+
const REGEX_FLAGS = new Set(['i', 'm', 's', 'u', 'g', 'y']);
|
|
80
|
+
|
|
81
|
+
const collapse = (text: string): string => text.replace(/\s+/g, ' ');
|
|
82
|
+
|
|
83
|
+
/** Decide what a search box's text means under `options`. */
|
|
84
|
+
export function parseSearchPattern(raw: string, options: SearchSyntaxOptions = {}): SearchPattern {
|
|
85
|
+
const syntax = { ...DEFAULT_SEARCH_SYNTAX, ...options };
|
|
86
|
+
const text = collapse(raw.trim());
|
|
87
|
+
if (text.length === 0) return { kind: 'empty', raw };
|
|
88
|
+
|
|
89
|
+
if (syntax.regex) {
|
|
90
|
+
const form = REGEX_FORM.exec(text);
|
|
91
|
+
if (form) {
|
|
92
|
+
const [, source = '', given = ''] = form;
|
|
93
|
+
const unknown = [...given].find((f) => !REGEX_FLAGS.has(f));
|
|
94
|
+
if (unknown) {
|
|
95
|
+
return { kind: 'invalid', raw, syntax: 'regex', message: `Unknown regex flag "${unknown}"` };
|
|
96
|
+
}
|
|
97
|
+
const flags = [...new Set(['i', ...given])].filter((f) => f !== 'g' && f !== 'y').join('');
|
|
98
|
+
try {
|
|
99
|
+
new RegExp(source, flags);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
102
|
+
return { kind: 'invalid', raw, syntax: 'regex', message };
|
|
103
|
+
}
|
|
104
|
+
return { kind: 'regex', raw, source, flags };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (syntax.wildcard && text.includes(SEARCH_WILDCARD)) {
|
|
109
|
+
const parts = text.split(SEARCH_WILDCARD).filter((part) => part.trim().length > 0);
|
|
110
|
+
if (parts.length === 0) return { kind: 'empty', raw };
|
|
111
|
+
return { kind: 'wildcard', raw, text, parts };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { kind: 'plain', raw, text };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The pattern as a `RegExp`, or `null` when it matches everything (`empty`) or
|
|
121
|
+
* nothing (`invalid`). Plain text becomes a case-insensitive contains.
|
|
122
|
+
*/
|
|
123
|
+
export function searchPatternToRegExp(pattern: SearchPattern): RegExp | null {
|
|
124
|
+
switch (pattern.kind) {
|
|
125
|
+
case 'empty':
|
|
126
|
+
case 'invalid':
|
|
127
|
+
return null;
|
|
128
|
+
case 'plain':
|
|
129
|
+
return new RegExp(escapeRegExp(pattern.text), 'i');
|
|
130
|
+
case 'wildcard':
|
|
131
|
+
// `[\s\S]*?` rather than `.*` so a gap may cross a newline in a
|
|
132
|
+
// multi-line field (a summary, a note) without needing the `s` flag.
|
|
133
|
+
return new RegExp(pattern.parts.map(escapeRegExp).join('[\\s\\S]*?'), 'i');
|
|
134
|
+
case 'regex':
|
|
135
|
+
return new RegExp(pattern.source, pattern.flags);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Does `text` match? `empty` matches everything; `invalid` matches nothing. */
|
|
140
|
+
export function matchesSearchPattern(pattern: SearchPattern, text: string): boolean {
|
|
141
|
+
if (pattern.kind === 'empty') return true;
|
|
142
|
+
return searchPatternToRegExp(pattern)?.test(text) ?? false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parse once, compile once, then test many — the shape an in-memory list filter
|
|
147
|
+
* wants. `matcher(title)` for every row costs one regex test, not one parse.
|
|
148
|
+
*/
|
|
149
|
+
export function createSearchMatcher(
|
|
150
|
+
raw: string,
|
|
151
|
+
options: SearchSyntaxOptions = {},
|
|
152
|
+
): { pattern: SearchPattern; test: (text: string) => boolean } {
|
|
153
|
+
const pattern = parseSearchPattern(raw, options);
|
|
154
|
+
if (pattern.kind === 'empty') return { pattern, test: () => true };
|
|
155
|
+
const re = searchPatternToRegExp(pattern);
|
|
156
|
+
return { pattern, test: re ? (text) => re.test(text) : () => false };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The pattern as a SQL `LIKE` pattern, escaped with `escape` — so the query must
|
|
161
|
+
* say `LIKE ? ESCAPE '\'` (or whatever `escape` is). `null` for `empty`, `regex`
|
|
162
|
+
* and `invalid`: none of those has a `LIKE` form, and the caller decides what
|
|
163
|
+
* each means rather than being handed a pattern that silently matches too much.
|
|
164
|
+
*
|
|
165
|
+
* Case is left as typed. SQLite's `LIKE` is already case-insensitive for ASCII;
|
|
166
|
+
* a database whose `LIKE` is not (Postgres) should use `ILIKE` with the same string.
|
|
167
|
+
*/
|
|
168
|
+
export function searchPatternToLike(pattern: SearchPattern, escape = '\\'): string | null {
|
|
169
|
+
const literal = (text: string) => text.replace(/[%_]/g, (c) => `${escape}${c}`);
|
|
170
|
+
const escapeAll = (text: string) => literal(text.split(escape).join(`${escape}${escape}`));
|
|
171
|
+
switch (pattern.kind) {
|
|
172
|
+
case 'plain':
|
|
173
|
+
return `%${escapeAll(pattern.text)}%`;
|
|
174
|
+
case 'wildcard':
|
|
175
|
+
return `%${pattern.parts.map(escapeAll).join('%')}%`;
|
|
176
|
+
default:
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -250,16 +250,20 @@ console.log(JSON.stringify(out));
|
|
|
250
250
|
`,
|
|
251
251
|
);
|
|
252
252
|
|
|
253
|
-
const child = Bun.
|
|
253
|
+
const child = Bun.spawn(['bun', 'run', 'entry.ts'], {
|
|
254
254
|
cwd: root,
|
|
255
|
+
stdin: 'ignore',
|
|
255
256
|
stdout: 'pipe',
|
|
256
257
|
stderr: 'pipe',
|
|
257
258
|
});
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
259
|
+
const [stdoutRaw, stderr, exitCode] = await Promise.all([
|
|
260
|
+
new Response(child.stdout).text(),
|
|
261
|
+
new Response(child.stderr).text(),
|
|
262
|
+
child.exited,
|
|
263
|
+
]);
|
|
264
|
+
const stdout = stdoutRaw.trim();
|
|
265
|
+
if (exitCode !== 0 || stdout === '') {
|
|
266
|
+
throw new Error(`the fixture could not run at all (exit ${exitCode}) — that is a broken check, not a passing one:\n${stderr}`);
|
|
263
267
|
}
|
|
264
268
|
return JSON.parse(stdout) as Record<string, Outcome>;
|
|
265
269
|
};
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
* to skip it. Ignored-vs-not is the property that actually regresses.
|
|
24
24
|
*/
|
|
25
25
|
import { describe, expect, test } from 'bun:test'
|
|
26
|
-
import { spawnSync } from 'node:child_process'
|
|
27
26
|
import { existsSync } from 'node:fs'
|
|
28
27
|
import { fileURLToPath } from 'node:url'
|
|
29
28
|
|
|
@@ -37,13 +36,18 @@ describe('bun.lock is part of the checkout', () => {
|
|
|
37
36
|
).toBe(true)
|
|
38
37
|
})
|
|
39
38
|
|
|
40
|
-
test('git is not told to ignore it', () => {
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
test('git is not told to ignore it', async () => {
|
|
40
|
+
let status: number
|
|
41
|
+
try {
|
|
42
|
+
const child = Bun.spawn(['git', 'check-ignore', '-q', 'bun.lock'], { cwd: repoRoot, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
|
|
43
|
+
status = await child.exited
|
|
44
|
+
} catch {
|
|
45
|
+
return // no git on this host: nothing to assert about ignore rules
|
|
46
|
+
}
|
|
43
47
|
|
|
44
48
|
// exit 0 = the path IS ignored; 1 = it is not; anything else = git could not tell us.
|
|
45
49
|
expect(
|
|
46
|
-
|
|
50
|
+
status,
|
|
47
51
|
'bun.lock is gitignored, so a fresh clone has no lockfile and `bun install --frozen-lockfile` cannot pass there. Delete the stanza from .gitignore rather than the assertion.',
|
|
48
52
|
).not.toBe(0)
|
|
49
53
|
})
|
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
* when `git` cannot answer — a check that silently degrades to green is worse than none.
|
|
26
26
|
*/
|
|
27
27
|
import { describe, expect, test } from 'bun:test';
|
|
28
|
-
import { spawnSync } from 'node:child_process';
|
|
29
28
|
import { existsSync } from 'node:fs';
|
|
30
29
|
import { fileURLToPath } from 'node:url';
|
|
31
30
|
import pkg from '../package.json';
|
|
@@ -36,14 +35,15 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
|
36
35
|
const shippedDirs = (): string[] =>
|
|
37
36
|
((pkg as { files?: string[] }).files ?? []).filter((f) => f !== 'dist' && existsSync(`${repoRoot}/${f}`));
|
|
38
37
|
|
|
39
|
-
const git = (...args: string[]): { ok: boolean; out: string } => {
|
|
40
|
-
const
|
|
41
|
-
|
|
38
|
+
const git = async (...args: string[]): Promise<{ ok: boolean; out: string }> => {
|
|
39
|
+
const child = Bun.spawn(['git', '-C', repoRoot, ...args], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
|
|
40
|
+
const [out, , code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
41
|
+
return { ok: code === 0, out: out.trim() };
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
describe('every file inside the published surface is tracked', () => {
|
|
45
|
-
test('git can answer at all — a degraded check must refuse, not pass', () => {
|
|
46
|
-
expect(git('rev-parse', '--is-inside-work-tree').ok, 'not a git checkout, so this check proves nothing').toBe(true);
|
|
45
|
+
test('git can answer at all — a degraded check must refuse, not pass', async () => {
|
|
46
|
+
expect((await git('rev-parse', '--is-inside-work-tree')).ok, 'not a git checkout, so this check proves nothing').toBe(true);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
test('package.json names a shippable directory to scan', () => {
|
|
@@ -52,10 +52,10 @@ describe('every file inside the published surface is tracked', () => {
|
|
|
52
52
|
expect(shippedDirs().length, '`files` names no committed directory — nothing would be scanned').toBeGreaterThan(0);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
test('no untracked file sits inside a directory `files` ships', () => {
|
|
55
|
+
test('no untracked file sits inside a directory `files` ships', async () => {
|
|
56
56
|
// --others = untracked; --exclude-standard honours .gitignore, so deliberately
|
|
57
57
|
// ignored paths (.agent.noindex, node_modules) are not offences.
|
|
58
|
-
const { ok, out } = git('ls-files', '--others', '--exclude-standard', '--', ...shippedDirs());
|
|
58
|
+
const { ok, out } = await git('ls-files', '--others', '--exclude-standard', '--', ...shippedDirs());
|
|
59
59
|
expect(ok, 'git ls-files failed').toBe(true);
|
|
60
60
|
const untracked = out === '' ? [] : out.split('\n');
|
|
61
61
|
expect(
|