@bakery-framework/core 2.0.0-alpha.9 → 2.0.0-rc.2
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/README.md +8 -8
- package/package.json +2 -5
- package/src/cache/lru.ts +2 -2
- package/src/cache/shared-db.ts +81 -11
- package/src/cache/string.ts +4 -0
- package/src/cache/tiered.ts +11 -5
- package/src/client/globals.d.ts +7 -7
- package/src/client/livereload.ts +2 -2
- package/src/client/utils.ts +11 -6
- package/src/compiler/compiler.ts +48 -44
- package/src/compiler/dev-service.ts +29 -30
- package/src/compiler/prompt-tracker.ts +2 -2
- package/src/compiler/tsconfig-sync.ts +130 -59
- package/src/core/bakery.ts +6 -3
- package/src/core/cache-version.ts +68 -18
- package/src/core/config.ts +16 -16
- package/src/core/context.ts +54 -8
- package/src/core/define-route.ts +5 -6
- package/src/core/index.ts +29 -8
- package/src/core/init.ts +38 -35
- package/src/core/jsx.ts +19 -4
- package/src/core/paths.ts +3 -1
- package/src/core/plugins.ts +15 -4
- package/src/core/port.ts +6 -6
- package/src/global.d.ts +36 -18
- package/src/handlers/assets/google-font.ts +6 -6
- package/src/handlers/assets/image.ts +36 -3
- package/src/handlers/assets/nm.ts +7 -7
- package/src/handlers/assets/static.ts +55 -12
- package/src/handlers/assets/ts.ts +3 -3
- package/src/handlers/assets/tsx.ts +1 -1
- package/src/handlers/assets/virtual-asset.ts +1 -1
- package/src/handlers/core/$base.ts +24 -8
- package/src/handlers/core/$dynamic.ts +66 -23
- package/src/handlers/core/$error.ts +29 -17
- package/src/handlers/core/$middleware.ts +6 -6
- package/src/handlers/core/$mounts.ts +2 -2
- package/src/handlers/core/$registry.ts +16 -6
- package/src/handlers/core/$routing.ts +18 -19
- package/src/handlers/core/$static.ts +11 -12
- package/src/handlers/routes/api.ts +5 -5
- package/src/handlers/routes/html.ts +2 -2
- package/src/handlers/routes/proxy.ts +1 -1
- package/src/logger/clients.ts +2 -2
- package/src/logger/logger.ts +4 -4
- package/src/logger/serve-log.ts +32 -18
- package/src/plugins/index.ts +1 -1
- package/src/plugins/routes.ts +7 -7
- package/src/plugins/types.ts +13 -11
- package/src/router.ts +12 -12
- package/src/session.ts +41 -23
- package/src/shared.d.ts +5 -5
- package/src/startup.ts +3 -3
- package/src/types.d.ts +10 -10
- package/src/utils/common/case.ts +1 -1
- package/src/utils/common/match.ts +1 -1
- package/src/utils/common/misc.ts +1 -1
- package/src/utils/constants.ts +15 -15
- package/src/utils/fs.ts +97 -39
- package/src/utils/http/authorize.ts +24 -17
- package/src/utils/http/cors.ts +6 -6
- package/src/utils/http/credential.ts +4 -4
- package/src/utils/http/csrf.ts +6 -7
- package/src/utils/http/dom.ts +9 -9
- package/src/utils/http/etag.ts +54 -13
- package/src/utils/http/html.ts +29 -20
- package/src/utils/http/ip.ts +1 -1
- package/src/utils/http/sse.ts +11 -11
- package/src/utils/http/url.ts +4 -4
- package/src/utils/http/validate.ts +3 -4
- package/src/utils/isomorphic/escape.ts +1 -1
- package/src/utils/isomorphic/is.ts +2 -2
- package/src/utils/isomorphic/misc.ts +1 -1
- package/src/utils/isomorphic/stringify.ts +2 -3
- package/src/utils/isomorphic/try.ts +1 -1
- package/src/utils/shared-pool.ts +23 -16
- package/tsconfig.app.json +3 -3
- package/tsconfig.vue.json +3 -3
package/src/logger/serve-log.ts
CHANGED
|
@@ -10,10 +10,10 @@ const serveMsgs = {
|
|
|
10
10
|
// the one recorded after the last successful sync, so the boot-time schema
|
|
11
11
|
// sync is skipped. `--sync` bypasses the skip.
|
|
12
12
|
SCHEMA_SYNC_SKIP:
|
|
13
|
-
'I Schema unchanged since last sync
|
|
13
|
+
'I Schema unchanged since last sync: %yskipping schema sync%* (%c--sync%* forces it)',
|
|
14
14
|
// The skip above is precisely when drift goes unnoticed: schema.ts has not
|
|
15
15
|
// changed, so no sync runs, so nothing looks at the database. Something else
|
|
16
|
-
// altered it
|
|
16
|
+
// altered it: a hand-run ALTER, another environment pointed at the same URL,
|
|
17
17
|
// a restore from an older dump.
|
|
18
18
|
SCHEMA_DRIFT:
|
|
19
19
|
'W %yDatabase no longer matches the last schema Bakery applied%*: {reason}. Run %cdb:history%* to see what was applied, or %cdb:sync --dry-run%* to see what the difference means.',
|
|
@@ -27,26 +27,32 @@ const serveMsgs = {
|
|
|
27
27
|
'I Synced %ytsconfig.json%* paths with %yserver.config.ts%*!',
|
|
28
28
|
TSCONFIG_PROJECTS_WRITTEN:
|
|
29
29
|
'I Wrote %y{count}%* tsconfig project(s) to %y.cache/tsconfig/%*',
|
|
30
|
+
// One-time repair. Previous releases wired the generated projects into the
|
|
31
|
+
// app's tsconfig.json as `references`, which broke `tsc -p <app>`
|
|
32
|
+
// (TS6305/6306/6310). Named so the rewrite of a tracked file comes with a
|
|
33
|
+
// line saying why it happened.
|
|
34
|
+
TSCONFIG_REFERENCES_REMOVED:
|
|
35
|
+
'I Removed generated %yreferences%* from %ytsconfig.json%*, the %y.cache/tsconfig/%* projects are standalone, and referencing them broke %ytsc -p%*',
|
|
30
36
|
// A plugin asking for a project name that is taken. Named rather than
|
|
31
37
|
// silent: the symptom otherwise is one plugin's types quietly not applying,
|
|
32
38
|
// discovered much later and blamed on the wrong thing.
|
|
33
39
|
TSCONFIG_PROJECT_CLASH:
|
|
34
|
-
'W Plugin %y{plugin}%* wants tsconfig project %y{project}%*, which already exists
|
|
40
|
+
'W Plugin %y{plugin}%* wants tsconfig project %y{project}%*, which already exists: %rskipped%*',
|
|
35
41
|
// Degrades to "no types" rather than failing the boot: a missing declaration
|
|
36
42
|
// is a worse editor experience, not a broken server.
|
|
37
43
|
TSCONFIG_FILE_UNRESOLVED:
|
|
38
|
-
'W Could not resolve tsconfig files entry %y{entry}
|
|
44
|
+
'W Could not resolve tsconfig files entry %y{entry}%*: %rskipped%*',
|
|
39
45
|
MANUAL_RELOAD: 'I %yManual reload%* triggered from client logger!',
|
|
40
46
|
CONFIG_IMPORT_ERR: 'E Failed to import %yserver.config.ts%*: %r{error}%*',
|
|
41
47
|
// Multi-line on purpose: a present-but-broken config booting on defaults is
|
|
42
|
-
// the kind of failure a single scrolled-away line hides. DEV only
|
|
48
|
+
// the kind of failure a single scrolled-away line hides. DEV only: in PROD
|
|
43
49
|
// the same condition throws out of initConfig() instead of logging.
|
|
44
50
|
CONFIG_BROKEN:
|
|
45
|
-
'E %rserver.config.ts is present but failed to load
|
|
51
|
+
'E %rserver.config.ts is present but failed to load: booting on built-in defaults (no plugins, no hosts, default port)%*\n file: %y{file}%*\n{error}',
|
|
46
52
|
// The banner restatement of CONFIG_BROKEN, so the warning survives console
|
|
47
53
|
// scrollback and sits next to the URLs the developer actually reads.
|
|
48
54
|
CONFIG_BROKEN_BANNER:
|
|
49
|
-
'W %rserver.config.ts failed to load%* %y
|
|
55
|
+
'W %rserver.config.ts failed to load%* %y, running on built-in defaults. See the import error above.%*',
|
|
50
56
|
WEBSOCKET_ERR: 'E WebSocket error from %y{ip}%*: %r{error}%*',
|
|
51
57
|
RATE_LIMITED: 'W Rate limited: %y{ip}%*',
|
|
52
58
|
// Emitted instead of RATE_LIMITED when sampling (cli/rate-limit.ts) held
|
|
@@ -57,32 +63,39 @@ const serveMsgs = {
|
|
|
57
63
|
// limit silently 429s load tests and shared-NAT offices, so its existence
|
|
58
64
|
// gets one announcement. An app-configured value prints nothing.
|
|
59
65
|
RATE_LIMIT_DEFAULT:
|
|
60
|
-
'I Rate limit: %y{max}%* burst / %y{refill}%* req/s per IP (default)
|
|
66
|
+
'I Rate limit: %y{max}%* burst / %y{refill}%* req/s per IP (default). Set %crateLimit: false%* to disable',
|
|
61
67
|
// Multi-worker port sharing rides on kernel-level SO_REUSEPORT load
|
|
62
68
|
// balancing, which only Linux provides; elsewhere N sockets either fail to
|
|
63
69
|
// bind or never receive balanced traffic.
|
|
64
70
|
CLUSTER_CLAMPED:
|
|
65
71
|
'W [Cluster] %y{platform}%* has no kernel-level SO_REUSEPORT load balancing (Linux-only), so %y{requested}%* workers cannot share one port. Running a single worker.',
|
|
66
72
|
WORKER_RESPAWN:
|
|
67
|
-
'W [Cluster] Worker %g{id}%* exited unexpectedly
|
|
73
|
+
'W [Cluster] Worker %g{id}%* exited unexpectedly: restarting in %y{delay}%*ms (consecutive failure #%y{failures}%*).',
|
|
68
74
|
// Thread-worker only: the master should hand over the shared memory pool
|
|
69
75
|
// before the worker starts serving; past the deadline the worker serves on
|
|
70
76
|
// its local pool rather than deadlock.
|
|
71
77
|
SHARED_POOL_TIMEOUT:
|
|
72
|
-
'W [Cluster] No shared memory pool from the master within %y{timeout}%*ms
|
|
78
|
+
'W [Cluster] No shared memory pool from the master within %y{timeout}%*ms: serving on a worker-local pool; cross-worker counters and rate limits will not be shared until it arrives.',
|
|
73
79
|
// The version wipe could not empty the cache directory, so no "current"
|
|
74
80
|
// marker was written and the next boot will try again. Worth a line rather
|
|
75
81
|
// than silence: something is holding those files open, and a stale compiled
|
|
76
82
|
// page surviving an upgrade is the failure the wipe exists to prevent.
|
|
83
|
+
// SQLite would not take WAL for this file, so it is running the DELETE
|
|
84
|
+
// journal instead. Worth one line rather than silence: on a local disk WAL
|
|
85
|
+
// is 100x faster per write and 29x on a transaction, and sessions write on
|
|
86
|
+
// the request path, so this line is the difference between "this
|
|
87
|
+
// filesystem cannot do better" and a deployment quietly paying that.
|
|
88
|
+
JOURNAL_WAL_REFUSED:
|
|
89
|
+
'W SQLite refused WAL for %y{file}%* (answered %y{answer}%*): running the %y{mode}%* journal instead. Expected on a network path; on a local disk it costs every write.',
|
|
77
90
|
CACHE_WIPE_INCOMPLETE:
|
|
78
|
-
'W Cache directory could not be cleared for the version change
|
|
91
|
+
'W Cache directory could not be cleared for the version change: %y{dir}%* still contains %y{files}%*. Retrying on next start; close anything holding those files.',
|
|
79
92
|
} as const
|
|
80
93
|
|
|
81
94
|
export const serveLog = messageLogger(new Logger('serve'), serveMsgs)
|
|
82
95
|
|
|
83
96
|
const handlerMsgs = {
|
|
84
97
|
// Emitted by `DynamicHandler.executeModule`, which every route handler goes
|
|
85
|
-
// through
|
|
98
|
+
// through: an API route, a `.tsx` page and an `error-*.tsx` all land here.
|
|
86
99
|
// It used to say "API module" while printing a `.tsx` path, alongside a
|
|
87
100
|
// `TSX_IMPORT_ERR` that no code ever reached.
|
|
88
101
|
API_IMPORT_ERR: 'E Failed to import route module (%y{file}%*): %r{error}%*',
|
|
@@ -90,7 +103,7 @@ const handlerMsgs = {
|
|
|
90
103
|
// default. `ApiHandler` used to answer that with a bare 404 naming nothing,
|
|
91
104
|
// which reads as "your route file is missing" for a file that is right there.
|
|
92
105
|
API_NO_DEFAULT:
|
|
93
|
-
'E API route has no %cexport default%* (%y{file}%*)
|
|
106
|
+
'E API route has no %cexport default%* (%y{file}%*): the module loaded but exposes no handler',
|
|
94
107
|
// A handler ran and returned neither a value nor a Response. Still a 404 (see
|
|
95
108
|
// `ApiHandler.handle`), but the log names the file rather than leaving the
|
|
96
109
|
// developer to guess which route answered.
|
|
@@ -106,13 +119,13 @@ const handlerMsgs = {
|
|
|
106
119
|
BUNDLE_SIDE_EFFECTS_REPAIRED:
|
|
107
120
|
'I %y{file}%* tree-shook to an empty export list because its package declares %ysideEffects: false%*; re-bundled through a re-export shim, which keeps the code.',
|
|
108
121
|
BUNDLE_EMPTY_EXPORTS:
|
|
109
|
-
'E %y{file}%* bundled to an export list with no code behind it
|
|
122
|
+
'E %y{file}%* bundled to an export list with no code behind it: every name it exports is undefined, so it is rejected rather than served. The bundler reported success, and re-bundling through a re-export shim did not recover it. Import the specific module you need instead of the package root.',
|
|
110
123
|
BUNDLE_CJS_INTEROP:
|
|
111
124
|
'I Generated named exports for the CommonJS package %y{file}%*, so a named import of it works in the browser.',
|
|
112
125
|
BUNDLE_CJS_PROBED:
|
|
113
126
|
'I Could not read %y{file}%* export names statically, so they were probed by importing it in a short-lived child process.',
|
|
114
127
|
BUNDLE_CJS_DEFAULT_ONLY:
|
|
115
|
-
'W %y{file}%* assigns %ymodule.exports%* wholesale and its members could not be read, so its browser bundle exports only %ydefault
|
|
128
|
+
'W %y{file}%* assigns %ymodule.exports%* wholesale and its members could not be read, so its browser bundle exports only %ydefault%*: a named import from it fails in the browser with "does not provide an export named …", and nothing fails here. Import the default and read the property off it, or use an ESM build.',
|
|
116
129
|
} as const
|
|
117
130
|
|
|
118
131
|
export const handlerLog = messageLogger(new Logger('handlers'), handlerMsgs)
|
|
@@ -131,6 +144,7 @@ const pluginMsgs = {
|
|
|
131
144
|
UNHANDLED_ERR: 'E Unhandled Plugin Error: %r{error}%*',
|
|
132
145
|
ANALYTICS_STORE_ERR: 'E Analytics store init failed: %r{error}%*',
|
|
133
146
|
DASHBOARD_BUNDLE_ERR: 'E Failed to bundle %ydashboard.js%*: %r{error}%*',
|
|
147
|
+
EXPLORER_QUERY_ERR: 'E Explorer %y{op}%* failed: %r{error}%*',
|
|
134
148
|
} as const
|
|
135
149
|
|
|
136
150
|
export const pluginLog = messageLogger(new Logger('plugins'), pluginMsgs)
|
|
@@ -142,7 +156,7 @@ export const errorMsg = (err: any) => err?.stack || err?.message || String(err)
|
|
|
142
156
|
*
|
|
143
157
|
* `BuildMessage` and `ResolveMessage` are the two things Bun throws for a
|
|
144
158
|
* syntax error and an unresolvable import. Neither is `instanceof Error`,
|
|
145
|
-
* neither carries a `stack`, and both report *zero* own enumerable keys
|
|
159
|
+
* neither carries a `stack`, and both report *zero* own enumerable keys, so
|
|
146
160
|
* nothing that inspects a thrown value by spreading it or by `instanceof` sees
|
|
147
161
|
* anything at all. `message`, `name` and `position` are the accessors that
|
|
148
162
|
* actually answer.
|
|
@@ -188,7 +202,7 @@ function positionLines(position: Diagnostic['position']): string[] {
|
|
|
188
202
|
* `Error` and useless for the diagnostics above: they have no stack, so it
|
|
189
203
|
* degrades to a bare "Expected identifier but found end of file" with no file
|
|
190
204
|
* and no line. This walks `position` instead, and recurses through the
|
|
191
|
-
* `AggregateError.errors` array that `import()`ing a broken `.tsx` throws
|
|
205
|
+
* `AggregateError.errors` array that `import()`ing a broken `.tsx` throws:
|
|
192
206
|
* that one *is* an `Error`, but it has no stack either, so the summary line
|
|
193
207
|
* ("4 errors building …") was all anyone ever saw.
|
|
194
208
|
*
|
|
@@ -217,7 +231,7 @@ export function errorDetail(err: any): string {
|
|
|
217
231
|
*
|
|
218
232
|
* For the log lines whose template already names the file. Bun's own
|
|
219
233
|
* `position.file` is `input.ts` whenever `transform()` was handed a source
|
|
220
|
-
* string rather than a path
|
|
234
|
+
* string rather than a path (which is every compile this codebase does), so
|
|
221
235
|
* printing it beside the real path would contradict it.
|
|
222
236
|
*/
|
|
223
237
|
export function errorWithPosition(err: any): string {
|
package/src/plugins/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The plugin-authoring surface, as one barrel.
|
|
3
3
|
*
|
|
4
4
|
* Every other public directory in core has an `index.ts` and `plugins/` did
|
|
5
|
-
* not, so `@bakery-framework/core/plugins` pointed straight at `routes.ts
|
|
5
|
+
* not, so `@bakery-framework/core/plugins` pointed straight at `routes.ts`, which meant
|
|
6
6
|
* `definePlugin` and `ServerPlugin` were only reachable through
|
|
7
7
|
* `@bakery-framework/core/plugins/types`, a second subpath for one concept. It also broke
|
|
8
8
|
* under TypeScript's `paths` resolution, which maps `@bakery-framework/core/plugins` to
|
package/src/plugins/routes.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Declarative endpoint tables for plugins.
|
|
3
3
|
*
|
|
4
4
|
* The dashboard already used a `Record<path, handler>` map; analytics used a
|
|
5
|
-
* hand-rolled `if (path === … && method === …)` chain. This
|
|
6
|
-
* dashboard's shape
|
|
5
|
+
* hand-rolled `if (path === … && method === …)` chain. This generalizes the
|
|
6
|
+
* dashboard's shape (the one that was already working), so a third plugin does
|
|
7
7
|
* not arrive with a fourth style of dispatch.
|
|
8
8
|
*
|
|
9
9
|
* Scope is deliberately narrow: exact-match paths only, no params or wildcards.
|
|
@@ -16,7 +16,7 @@ import { response } from '../utils/http/response'
|
|
|
16
16
|
import type { ValidResponses } from './types'
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Handlers may return anything the router knows how to serialize
|
|
19
|
+
* Handlers may return anything the router knows how to serialize: the
|
|
20
20
|
* dashboard's return `JsonResponseData` and `BunFile` as well as `Response`.
|
|
21
21
|
* `ValidResponses` (= `Handler.Response`) is the framework's name for that set.
|
|
22
22
|
*/
|
|
@@ -27,7 +27,7 @@ export type PluginRoute = (req: Request, url: URL) => ValidResponses
|
|
|
27
27
|
* method-qualified path (`'POST /api/_x/reset'`). A method-qualified entry wins
|
|
28
28
|
* over a bare one for the same path.
|
|
29
29
|
*
|
|
30
|
-
* The two forms are guarded differently
|
|
30
|
+
* The two forms are guarded differently. See `guardFor` below. A bare key is
|
|
31
31
|
* same-origin-only; a method-qualified one gets the ordinary CSRF check.
|
|
32
32
|
*/
|
|
33
33
|
export type PluginRouteTable = Record<string, PluginRoute>
|
|
@@ -35,7 +35,7 @@ export type PluginRouteTable = Record<string, PluginRoute>
|
|
|
35
35
|
/**
|
|
36
36
|
* What a dispatch built from `T` actually resolves to: the union of that
|
|
37
37
|
* table's own handler return types, awaited, with the `undefined`/`void` cases
|
|
38
|
-
* replaced by `null
|
|
38
|
+
* replaced by `null`. Exactly what `dispatch` does at runtime with `?? null`.
|
|
39
39
|
*
|
|
40
40
|
* Keyed on the table type rather than flattened to `ValidResponses` because
|
|
41
41
|
* `ValidResponses` contains `object`, which absorbs `Response`, `Bun.BunFile`
|
|
@@ -59,7 +59,7 @@ export type PluginRouteResult<T extends PluginRouteTable> =
|
|
|
59
59
|
* unsafe ones same-origin. `'GET /api/_x/public'` is therefore also the way to
|
|
60
60
|
* declare an endpoint that *is* meant to answer cross-origin.
|
|
61
61
|
*
|
|
62
|
-
* A **bare** key matches every method, which means the author did not say
|
|
62
|
+
* A **bare** key matches every method, which means the author did not say,
|
|
63
63
|
* and that is exactly how the dashboard's mutating endpoints ended up
|
|
64
64
|
* answering a `GET`. `checkCsrf` is no help there: it waves GET through by
|
|
65
65
|
* definition, so a cross-site `<img src="/api/_dashboard/execute-action">`
|
|
@@ -76,7 +76,7 @@ function guardFor(qualified: boolean) {
|
|
|
76
76
|
|
|
77
77
|
export function routeTable<T extends PluginRouteTable>(routes: T) {
|
|
78
78
|
/**
|
|
79
|
-
* Declared as an overload
|
|
79
|
+
* Declared as an overload: the same shape `handleRequest` uses in
|
|
80
80
|
* `router.ts`. The signature carries the precise per-table union; the
|
|
81
81
|
* implementation only ever sees the `PluginRoute` *constraint*, so on its own
|
|
82
82
|
* it could infer no better than the wide `ValidResponses`.
|
package/src/plugins/types.ts
CHANGED
|
@@ -6,18 +6,20 @@ export type ValidResponses = Handler.Response
|
|
|
6
6
|
/**
|
|
7
7
|
* A TypeScript project a plugin contributes to the app.
|
|
8
8
|
*
|
|
9
|
-
* Written to `.cache/tsconfig/<name>.json` on every dev boot
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Written to `.cache/tsconfig/<name>.json` on every dev boot, as a standalone
|
|
10
|
+
* project invoked directly (`vue-tsc -p .cache/tsconfig/vue.json`). It is
|
|
11
|
+
* deliberately *not* referenced from the app's root `tsconfig.json`: a
|
|
12
|
+
* `references` entry to an unbuilt `noEmit` project makes `tsc -p <app>` fail
|
|
13
|
+
* (TS6305/6306/6310; see `compiler/tsconfig-sync.ts`). Regenerated rather
|
|
14
|
+
* than committed: it is derived from the plugin list and the app's config, and
|
|
15
|
+
* `.cache/` is the disposable half of the two runtime directories.
|
|
14
16
|
*/
|
|
15
17
|
export interface PluginTsProject {
|
|
16
18
|
/** File name under `.cache/tsconfig/`, and the project's identity. */
|
|
17
19
|
name: string
|
|
18
20
|
/** Usually one of core's three bases. Written through as-is. */
|
|
19
21
|
extends: string
|
|
20
|
-
/** Globs, relative to the **app root
|
|
22
|
+
/** Globs, relative to the **app root**: the generator rewrites them. */
|
|
21
23
|
include?: string[]
|
|
22
24
|
exclude?: string[]
|
|
23
25
|
/**
|
|
@@ -34,7 +36,7 @@ export interface PluginTsProject {
|
|
|
34
36
|
*
|
|
35
37
|
* Off by default, and that default is the correction: the generator used to
|
|
36
38
|
* write those `paths` into *every* project on the reasoning that an alias is
|
|
37
|
-
* app-wide. It is not. `importMap` is a **browser** import map
|
|
39
|
+
* app-wide. It is not. `importMap` is a **browser** import map: the framework
|
|
38
40
|
* serves it as `<script type="importmap">` and the browser is what resolves
|
|
39
41
|
* its specifiers. An alias in the server project therefore typechecks an
|
|
40
42
|
* import that only the browser can satisfy, which is the same class of bug the
|
|
@@ -47,13 +49,13 @@ export interface PluginTsProject {
|
|
|
47
49
|
* Does code in this project run on the server?
|
|
48
50
|
*
|
|
49
51
|
* Declared, not inferred. The first version read `bun-types` out of the base
|
|
50
|
-
* config and treated that as the marker
|
|
52
|
+
* config and treated that as the marker: true today, and an inference about
|
|
51
53
|
* intent drawn from a detail that exists for another reason. A plugin saying
|
|
52
54
|
* what its project *is* cannot drift out of step with itself.
|
|
53
55
|
*
|
|
54
56
|
* What it controls: the app's schema registration, so the ORM's tables are the
|
|
55
57
|
* app's own rather than the permissive `any` fallback. Off for browser code on
|
|
56
|
-
* purpose
|
|
58
|
+
* purpose: `@bakery-framework/orm` is server-only, so a `.ts` bound for the
|
|
57
59
|
* browser importing `DB` should fail to typecheck rather than be helpfully
|
|
58
60
|
* typed, and the package ships TypeScript source calling `Bun.*`, which a
|
|
59
61
|
* client config cannot compile anyway.
|
|
@@ -63,7 +65,7 @@ export interface PluginTsProject {
|
|
|
63
65
|
|
|
64
66
|
/** What a plugin contributes to the generated tsconfig projects. */
|
|
65
67
|
export interface PluginTsConfig {
|
|
66
|
-
/** A project this plugin owns outright
|
|
68
|
+
/** A project this plugin owns outright: Vue SFCs, for instance. */
|
|
67
69
|
project?: PluginTsProject
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -77,7 +79,7 @@ export interface ServerPlugin {
|
|
|
77
79
|
*
|
|
78
80
|
* A plugin that brings its own file type or its own ambient globals needs a
|
|
79
81
|
* project to typecheck them under. `@bakery-framework/plugin-vue` is the
|
|
80
|
-
* worked example
|
|
82
|
+
* worked example: it owns `.vue` and declares `req`/`body` for SFC scope,
|
|
81
83
|
* globals core deliberately does not provide.
|
|
82
84
|
*/
|
|
83
85
|
tsconfig?: PluginTsConfig
|
package/src/router.ts
CHANGED
|
@@ -27,11 +27,11 @@ import { parsedUrl } from './utils/http/url'
|
|
|
27
27
|
* The origin check lives here rather than in each `canHandle` on purpose:
|
|
28
28
|
* every socket a plugin or an app registers inherits it, instead of every
|
|
29
29
|
* author having to remember. `/_analytics_ws` was the only handler that got it
|
|
30
|
-
* right on its own, and it did so by authenticating
|
|
30
|
+
* right on its own, and it did so by authenticating: `/_livereload` had
|
|
31
31
|
* nothing, and `WebSocketHandler.canHandle` returns `true` by default, so the
|
|
32
32
|
* base class was handing out sockets to anyone who asked.
|
|
33
33
|
*
|
|
34
|
-
* Returning `false` (rather than a 403) keeps the published signature
|
|
34
|
+
* Returning `false` (rather than a 403) keeps the published signature: the
|
|
35
35
|
* caller below turns a refusal into `400 WebSocket Upgrade Failed`, which is
|
|
36
36
|
* also what an unclaimed path gets. The reason reaches the log; the client is
|
|
37
37
|
* told nothing it did not already know.
|
|
@@ -65,10 +65,10 @@ export async function upgradeWebsocket(
|
|
|
65
65
|
* Whether the deny-list applies to whatever this handler is about to answer
|
|
66
66
|
* with. `Bakery.config.blocked` exists to stop files on disk being served, so
|
|
67
67
|
* handlers that read the request path as a route *name* opt out by declaring
|
|
68
|
-
* `servesFiles = false
|
|
68
|
+
* `servesFiles = false`: `ApiHandler`, `ProxyHandler`, `MiddlewareHandler`.
|
|
69
69
|
*
|
|
70
70
|
* Deny by default, and the direction matters. Naming only the obvious file
|
|
71
|
-
* servers
|
|
71
|
+
* servers (Static, Public, NM) would have left `/schema.ts` and
|
|
72
72
|
* `/server.config.ts` reachable through `TSHandler`, which compiles a source
|
|
73
73
|
* file and serves the result. Anything that does not opt out, including a
|
|
74
74
|
* plugin's handler, keeps the check.
|
|
@@ -94,7 +94,7 @@ export async function handleRequest(req: Request) {
|
|
|
94
94
|
// One read of the config getter, not three: `Bakery.serveRoot` walks
|
|
95
95
|
// `hostStore.getStore()?.config ?? getConfig()` on every access, and the
|
|
96
96
|
// blocked-glob check below used to pay the same walk again. And no
|
|
97
|
-
// `fs.resolve` around the target
|
|
97
|
+
// `fs.resolve` around the target: `isForbidden` normalizes both arguments
|
|
98
98
|
// itself, so that was a second full path resolution of the same string on
|
|
99
99
|
// every request.
|
|
100
100
|
const config = Bakery.config
|
|
@@ -102,7 +102,7 @@ export async function handleRequest(req: Request) {
|
|
|
102
102
|
// Before anything else, including the forbidden-path check: a preflight names
|
|
103
103
|
// the route it is asking about in a *header*, not the path, so running it
|
|
104
104
|
// through routing would answer a question nobody asked. The browser sends no
|
|
105
|
-
// credentials with it either, which is why it cannot be
|
|
105
|
+
// credentials with it either, which is why it cannot be authorized.
|
|
106
106
|
//
|
|
107
107
|
// Only when `cors` is configured. Absent, this is not reached at all and the
|
|
108
108
|
// browser's own default applies.
|
|
@@ -136,7 +136,7 @@ export async function handleRequest(req: Request) {
|
|
|
136
136
|
const handler = await Bakery.handlers.fetch.resolve(path, req)
|
|
137
137
|
if (!handler) return new Response('Not Found', { status: 404 })
|
|
138
138
|
|
|
139
|
-
// `matchBlockedCached`
|
|
139
|
+
// `matchBlockedCached` memoizes the verdict on the request store, so the
|
|
140
140
|
// re-check `StaticHandler.handle` keeps for its direct callers costs a map
|
|
141
141
|
// hit instead of a second pair of glob matches.
|
|
142
142
|
if (servesFiles(handler) && matchBlockedCached(config.blocked, path)) {
|
|
@@ -236,14 +236,14 @@ export const serveWebSocket: Bun.WebSocketHandler<any> = {
|
|
|
236
236
|
* Make an error page answer with the error's status.
|
|
237
237
|
*
|
|
238
238
|
* The HTML and TSX error handlers end in `injectIfHtml`, which builds its
|
|
239
|
-
* Response without one
|
|
239
|
+
* Response without one, so an app with `src/error-404.html` served its 404
|
|
240
240
|
* page as `200 OK` and every crawler, cache and monitor believed it. Applied
|
|
241
241
|
* here rather than inside `injectIfHtml` on purpose: the only status signal
|
|
242
242
|
* available down there is `params`, which for a GET is the query string, and
|
|
243
243
|
* `?errorCode=500` is not something a client gets to decide.
|
|
244
244
|
*
|
|
245
245
|
* A `JsonResponseData` (the `/api/` arm) carries its own `.status` that
|
|
246
|
-
* `processResponse` reads, and a BunFile has no status at all
|
|
246
|
+
* `processResponse` reads, and a BunFile has no status at all: both pass
|
|
247
247
|
* through untouched.
|
|
248
248
|
*/
|
|
249
249
|
function applyErrorStatus(
|
|
@@ -277,7 +277,7 @@ export async function handleRequestError(
|
|
|
277
277
|
|
|
278
278
|
// Wrapped for the same reason the registry call below it is: this is app
|
|
279
279
|
// code. An `onError` that throws used to escape `Try.return`'s fallback in
|
|
280
|
-
// the caller
|
|
280
|
+
// the caller (a fallback that itself rejects is not a fallback), so the one
|
|
281
281
|
// hook whose job is to handle failure took the app's error page down with
|
|
282
282
|
// it and the client got Bun's raw 500 instead.
|
|
283
283
|
const [onErrorFailed, configError] = await Try.catch(() =>
|
|
@@ -353,8 +353,8 @@ export async function processResponse(
|
|
|
353
353
|
// (e.g. an auth cookie from a login route) that must not be overwritten.
|
|
354
354
|
sess && resp.headers.append('Set-Cookie', sess)
|
|
355
355
|
|
|
356
|
-
// Every response funnels through here
|
|
357
|
-
// pages
|
|
356
|
+
// Every response funnels through here (pages, API JSON, static files, error
|
|
357
|
+
// pages), so this is the one place that cannot miss one. Applied before ETag
|
|
358
358
|
// so the negotiated `Vary` sees the `Origin` entry and merges with it rather
|
|
359
359
|
// than either overwriting the other.
|
|
360
360
|
const cors = Bakery.config.cors
|
package/src/session.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { DEFAULT_SESSION_PERSIST, DEFAULT_SESSION_TTL } from './utils/constants'
|
|
|
7
7
|
/**
|
|
8
8
|
* Keys under this prefix are framework-internal privilege markers. They share
|
|
9
9
|
* the same bag as application data, so anything that writes a caller-supplied
|
|
10
|
-
* key must refuse this prefix
|
|
10
|
+
* key must refuse this prefix: otherwise a preferences endpoint (or the
|
|
11
11
|
* dashboard's own session editor) becomes a privilege-escalation primitive.
|
|
12
12
|
*/
|
|
13
13
|
export const RESERVED_SESSION_PREFIX = '__bakery.'
|
|
@@ -17,7 +17,7 @@ export function isReservedSessionKey(key: string): boolean {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* Clock seam
|
|
20
|
+
* Clock seam: the cookie half-life bookkeeping is time-based, and its tests
|
|
21
21
|
* inject a clock instead of sleeping (see `__setTestDb` / `__setTestConfig`
|
|
22
22
|
* for the pattern). Only the cookie-reissue math reads this; `createdAt` and
|
|
23
23
|
* TTL expiry keep `Date.now()`.
|
|
@@ -47,7 +47,7 @@ export function newSessionId(): string {
|
|
|
47
47
|
* The cache key for a session id under the current host.
|
|
48
48
|
*
|
|
49
49
|
* There is one `TieredCache('sessions')` for the whole process, and until this
|
|
50
|
-
* existed every id went into it unqualified
|
|
50
|
+
* existed every id went into it unqualified, so under a multi-tenant `hosts`
|
|
51
51
|
* config one tenant's dashboard listed, read and deleted every other tenant's
|
|
52
52
|
* sessions. `hostKey()` is the guard the five other per-tenant caches already
|
|
53
53
|
* use; sessions were the one that never got it.
|
|
@@ -102,6 +102,19 @@ export class Session<
|
|
|
102
102
|
return Session.cache.count
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Attach the session cookie to a response built outside the pipeline.
|
|
107
|
+
*
|
|
108
|
+
* **Takes the `Request`, and that is not incidental.** `getCookie` reads the
|
|
109
|
+
* session through `hasDeferredValue(req, 'session')`, which looks for a
|
|
110
|
+
* symbol the router installs, so the value has to come from the real
|
|
111
|
+
* request. There used to be an instance form, `session.bind(res)`, that
|
|
112
|
+
* called this with `{ session: this }`: a fake carrying no symbol, so the
|
|
113
|
+
* check failed, `getCookie` returned an empty string, and the cookie was
|
|
114
|
+
* never appended. It was a documented method that could not work, and it is
|
|
115
|
+
* gone rather than repaired: the session alone does not know whether the
|
|
116
|
+
* request it belongs to issued a cookie this turn.
|
|
117
|
+
*/
|
|
105
118
|
public static bind(req: Request, response?: Response) {
|
|
106
119
|
if (!response) return response
|
|
107
120
|
|
|
@@ -116,7 +129,7 @@ export class Session<
|
|
|
116
129
|
const session = req.session
|
|
117
130
|
|
|
118
131
|
// Issue only when the session actually changed, or when the read path
|
|
119
|
-
// flagged a half-life refresh (see `markAccessed`)
|
|
132
|
+
// flagged a half-life refresh (see `markAccessed`), not on every read.
|
|
120
133
|
// A per-request Set-Cookie made every session-carrying response unique,
|
|
121
134
|
// defeating If-None-Match, and dirtied merely-read sessions into the
|
|
122
135
|
// flush timer's write batch.
|
|
@@ -138,7 +151,7 @@ export class Session<
|
|
|
138
151
|
? Math.floor(DEFAULT_SESSION_PERSIST / 1000)
|
|
139
152
|
: Math.floor(DEFAULT_SESSION_TTL / 1000)
|
|
140
153
|
|
|
141
|
-
// Only believe x-forwarded-proto behind a trusted proxy
|
|
154
|
+
// Only believe x-forwarded-proto behind a trusted proxy: the same rule
|
|
142
155
|
// getHostname and getClientIp already apply. In production default to
|
|
143
156
|
// Secure, so a terminator that omits the header can't downgrade the cookie.
|
|
144
157
|
const trustProxy = Bakery.config.trustProxy
|
|
@@ -178,7 +191,7 @@ export class Session<
|
|
|
178
191
|
session.rawData = { ...metadata.data }
|
|
179
192
|
session.data = session.initProxy()
|
|
180
193
|
// Rows written before the field existed revive as 0 = "unknown", which
|
|
181
|
-
// `markAccessed` reads as long past half-life
|
|
194
|
+
// `markAccessed` reads as long past half-life, at worst one extra
|
|
182
195
|
// reissue, never a cookie that silently expires under an active user.
|
|
183
196
|
session.cookieIssuedAt = metadata.cookieIssuedAt ?? 0
|
|
184
197
|
return session
|
|
@@ -209,7 +222,7 @@ export class Session<
|
|
|
209
222
|
if (existing.isExpired()) {
|
|
210
223
|
Session.cache.delete(sessionKey(sessionId))
|
|
211
224
|
} else {
|
|
212
|
-
// Accessed, not modified
|
|
225
|
+
// Accessed, not modified. See `markAccessed`. `touch()` here made
|
|
213
226
|
// every session read count as a write.
|
|
214
227
|
existing.markAccessed()
|
|
215
228
|
return existing
|
|
@@ -227,7 +240,7 @@ export class Session<
|
|
|
227
240
|
|
|
228
241
|
/**
|
|
229
242
|
* Epoch ms when `getCookie` last emitted `Set-Cookie` for this id; 0 means
|
|
230
|
-
* never (or unknown
|
|
243
|
+
* never (or unknown: a row written before the field existed). Persisted
|
|
231
244
|
* through `toJSON`, so it only costs a write when a write is already
|
|
232
245
|
* happening; while the instance lives in the memory tier it carries across
|
|
233
246
|
* requests for free.
|
|
@@ -236,7 +249,7 @@ export class Session<
|
|
|
236
249
|
|
|
237
250
|
/**
|
|
238
251
|
* Set by `markAccessed` when the cookie has crossed half its Max-Age;
|
|
239
|
-
* cleared when `getCookie` issues. Deliberately not persisted
|
|
252
|
+
* cleared when `getCookie` issues. Deliberately not persisted: it is
|
|
240
253
|
* per-instance request bookkeeping, and losing it costs nothing (the next
|
|
241
254
|
* read recomputes it from `cookieIssuedAt`).
|
|
242
255
|
*/
|
|
@@ -277,7 +290,7 @@ export class Session<
|
|
|
277
290
|
* Force the session to count as modified: stored on the next flush and its
|
|
278
291
|
* cookie re-issued. This is the published "force a cookie refresh" surface
|
|
279
292
|
* (docs/guides/sessions.md) and keeps its dirty semantics. It is *not* the
|
|
280
|
-
* read path
|
|
293
|
+
* read path: `Session.from` uses `markAccessed`, which slides expiry
|
|
281
294
|
* without paying write costs.
|
|
282
295
|
*/
|
|
283
296
|
public touch(): this {
|
|
@@ -286,18 +299,18 @@ export class Session<
|
|
|
286
299
|
}
|
|
287
300
|
|
|
288
301
|
/**
|
|
289
|
-
* The read-path bump
|
|
302
|
+
* The read-path bump, what `Session.from` does instead of `touch()`.
|
|
290
303
|
* Reading a session must not dirty it: that made every read-only request
|
|
291
304
|
* pay a JSON.stringify + synchronous SQLite write on the next flush and
|
|
292
305
|
* re-issue Set-Cookie, which defeated If-None-Match caching.
|
|
293
306
|
*
|
|
294
307
|
* Liveness needs no work here: the `Session.cache.get` in `from` has
|
|
295
308
|
* already re-stamped the memory tier's accessedAt. What this method owns is
|
|
296
|
-
* sliding cookie expiration
|
|
309
|
+
* sliding cookie expiration: once more than half the cookie's Max-Age has
|
|
297
310
|
* elapsed since it was last issued, flag a reissue. `getCookie` then emits
|
|
298
311
|
* the cookie *and* re-persists the session, which renews the DB row's
|
|
299
312
|
* accessedAt at a cadence of at most maxAge/2 against the pruner's cutoff
|
|
300
|
-
* of maxAge
|
|
313
|
+
* of maxAge, so active sessions never age out server-side either.
|
|
301
314
|
*/
|
|
302
315
|
private markAccessed(): void {
|
|
303
316
|
const maxAgeMs = this.hasPersistedKeys()
|
|
@@ -350,10 +363,10 @@ export class Session<
|
|
|
350
363
|
*
|
|
351
364
|
* This is the missing anti-fixation primitive. `reset()` clears data but
|
|
352
365
|
* keeps the id, so an app that does `req.session.set('userId', …)` on login
|
|
353
|
-
* finishes authentication on the *same* id the visitor arrived with
|
|
366
|
+
* finishes authentication on the *same* id the visitor arrived with,
|
|
354
367
|
* including one an attacker planted. Call it at the privilege boundary:
|
|
355
368
|
*
|
|
356
|
-
* ```ts no-check
|
|
369
|
+
* ```ts no-check: illustrative call site, not a compiled example
|
|
357
370
|
* req.session.regenerate().set('userId', user.id, true)
|
|
358
371
|
* ```
|
|
359
372
|
*
|
|
@@ -362,7 +375,7 @@ export class Session<
|
|
|
362
375
|
* the same object `req.session` already holds, so the rest of the request
|
|
363
376
|
* sees the new id.
|
|
364
377
|
*
|
|
365
|
-
* `createdAt` is deliberately preserved
|
|
378
|
+
* `createdAt` is deliberately preserved: the session continues, only its
|
|
366
379
|
* bearer token is replaced.
|
|
367
380
|
*/
|
|
368
381
|
public regenerate(): this {
|
|
@@ -423,10 +436,6 @@ export class Session<
|
|
|
423
436
|
return this
|
|
424
437
|
}
|
|
425
438
|
|
|
426
|
-
public bind(response?: Response) {
|
|
427
|
-
return Session.bind({ session: this } as any, response)
|
|
428
|
-
}
|
|
429
|
-
|
|
430
439
|
public destroy(): void {
|
|
431
440
|
Session.delete(this.id)
|
|
432
441
|
}
|
|
@@ -454,7 +463,7 @@ export class Session<
|
|
|
454
463
|
|
|
455
464
|
/**
|
|
456
465
|
* Every enumeration below filters to the current host and yields the bare
|
|
457
|
-
* session id, not the cache key
|
|
466
|
+
* session id, not the cache key, so `Session.keys()` still returns ids the
|
|
458
467
|
* dashboard can hand back to `Session.get` / `Session.delete`.
|
|
459
468
|
*/
|
|
460
469
|
static *entries(): IterableIterator<[string, Session<any>]> {
|
|
@@ -487,7 +496,7 @@ export class Session<
|
|
|
487
496
|
// The check is on `hosts` and not on the scope, deliberately: `hostKey`
|
|
488
497
|
// collapses to '' in the unconfigured case too, so the scope alone cannot
|
|
489
498
|
// tell the two situations apart. This method used to compute one anyway and
|
|
490
|
-
// never read it
|
|
499
|
+
// never read it: scoping lives in `Session.entries()`, which the slow path
|
|
491
500
|
// below goes through.
|
|
492
501
|
const hosts = Bakery.config.hosts
|
|
493
502
|
if (!hosts || Object.keys(hosts).length === 0) {
|
|
@@ -495,7 +504,7 @@ export class Session<
|
|
|
495
504
|
}
|
|
496
505
|
|
|
497
506
|
// Scoped path: `TieredCache.search` pages in SQL across the whole table,
|
|
498
|
-
// which is every tenant's sessions, and it has no key-prefix predicate
|
|
507
|
+
// which is every tenant's sessions, and it has no key-prefix predicate, so
|
|
499
508
|
// the filter has to happen before paging, in JS. Bounded by the session
|
|
500
509
|
// table, which the pruner holds down to live sessions.
|
|
501
510
|
const needle = options.search?.trim().toLowerCase() || ''
|
|
@@ -548,6 +557,15 @@ const sessionPruneTimer = setInterval(
|
|
|
548
557
|
1000 * 60 * 15, // prune every 15 minutes
|
|
549
558
|
)
|
|
550
559
|
|
|
560
|
+
// Unref'd, like the two flush timers in `cache/`. A 15-minute prune is not a
|
|
561
|
+
// reason a process cannot exit, and this one is module-level: importing
|
|
562
|
+
// `session.ts` (which `core/index` does) held the event loop open for the
|
|
563
|
+
// life of any process that touched core. The CLI never saw it because every
|
|
564
|
+
// one of its paths ends in `process.exit`; a script, an embedder or a bare
|
|
565
|
+
// `bun -e` that imported the barrel printed its answer and then hung.
|
|
566
|
+
// `onShutdown` still clears it, which is what matters for an orderly stop.
|
|
567
|
+
sessionPruneTimer.unref?.()
|
|
568
|
+
|
|
551
569
|
Bakery.onShutdown(() => {
|
|
552
570
|
clearInterval(sessionPruneTimer)
|
|
553
571
|
})
|
package/src/shared.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { MapOf } from './types'
|
|
|
5
5
|
*
|
|
6
6
|
* These used to be declared twice, verbatim, in `global.d.ts` and
|
|
7
7
|
* `client/globals.d.ts`. Nothing caught it: `skipLibCheck: true` suppresses
|
|
8
|
-
* errors *inside* .d.ts files
|
|
8
|
+
* errors *inside* .d.ts files (including this project's own), so four TS2300
|
|
9
9
|
* duplicate-identifier errors sat there invisibly, and every tsconfig excluded
|
|
10
10
|
* `src/client/**` on top of that.
|
|
11
11
|
*
|
|
@@ -16,7 +16,7 @@ import type { MapOf } from './types'
|
|
|
16
16
|
* declared in two ambient files again.
|
|
17
17
|
*
|
|
18
18
|
* `MapOf` and `Wrapped` have since moved out of the global scope entirely,
|
|
19
|
-
* into `types.d.ts
|
|
19
|
+
* into `types.d.ts`: their names are generic enough to collide with a
|
|
20
20
|
* consuming app's own. What stays global here is framework-specific enough
|
|
21
21
|
* not to, and is needed by browser globals that are bound at runtime rather
|
|
22
22
|
* than imported.
|
|
@@ -27,12 +27,12 @@ import type { MapOf } from './types'
|
|
|
27
27
|
declare global {
|
|
28
28
|
/**
|
|
29
29
|
* Bound by `client/utils.ts` in the browser and `core/init.ts` on the
|
|
30
|
-
* server
|
|
30
|
+
* server: the same isomorphic implementation either side, so code moving
|
|
31
31
|
* between an SFC browser script and a server block keeps the name.
|
|
32
32
|
*/
|
|
33
33
|
var randomId: typeof import('./utils/isomorphic/misc').randomId
|
|
34
34
|
|
|
35
|
-
/** The one JSON envelope
|
|
35
|
+
/** The one JSON envelope. See convention 7. */
|
|
36
36
|
type JsonResponse<T = any> = {
|
|
37
37
|
time: number
|
|
38
38
|
status: number
|
|
@@ -52,7 +52,7 @@ declare global {
|
|
|
52
52
|
(value: any, type: 'undefined'): value is undefined
|
|
53
53
|
// `Function` is the type being narrowed to, and there is no narrower
|
|
54
54
|
// spelling of "callable" a predicate can promise. `noBannedTypes` is off
|
|
55
|
-
// repo-wide, so this needs no suppression
|
|
55
|
+
// repo-wide, so this needs no suppression. See MONOREPO.md for why.
|
|
56
56
|
(value: any, type: 'function'): value is Function
|
|
57
57
|
(value: any, type?: string): boolean
|
|
58
58
|
string(value: any): value is string
|