cursedops 0.2.2 → 0.2.4
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 +42 -1
- package/package.json +8 -2
- package/src/apiFloor.ts +148 -0
- package/src/serve.ts +7 -127
package/README.md
CHANGED
|
@@ -11,7 +11,8 @@ bun add cursedops
|
|
|
11
11
|
| `cursedops/roots` | finding a generation's roots, and a checkout's package root, without knowing a path |
|
|
12
12
|
| `cursedops/launchd` | installing, replacing and removing a macOS launchd user agent |
|
|
13
13
|
| `cursedops/smoke` | the scaffolding of a deployed smoke — the ledger, the fetch, the DNS hint, the exit code — and the one check no app owns: every address of a deployment serving the same built client |
|
|
14
|
-
| `cursedops/serve` | the static tier's four helpers — the path-traversal guard, the MIME table, the hashed-asset test, the crash handlers
|
|
14
|
+
| `cursedops/serve` | the static tier's four helpers — the path-traversal guard, the MIME table, the hashed-asset test, the crash handlers |
|
|
15
|
+
| `cursedops/api-floor` | the rule that an unmatched `/api/...` is a phrase and never the app shell — the namespace predicates, the trailing-slash normaliser and the default 404 body. No `node:` import, so it mounts inside a Worker |
|
|
15
16
|
|
|
16
17
|
Bun, zero runtime dependencies, ships TypeScript source. Nothing here knows an app's
|
|
17
18
|
name, a hostname, a port or a route.
|
|
@@ -143,6 +144,46 @@ Nothing else. The checks are the part `desk` and `flix` wrote differently on pur
|
|
|
143
144
|
and a shared kit that starts absorbing route lists and health-payload shapes is how the
|
|
144
145
|
last one reached 277 files.
|
|
145
146
|
|
|
147
|
+
## `cursedops/api-floor`
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { API_PREFIX, apiNotFoundBody, canonicalApiPath, canonicalApiRequest } from "cursedops/api-floor";
|
|
151
|
+
|
|
152
|
+
// AFTER the app's routes, BEFORE the static catch-all, and `all` not `get`.
|
|
153
|
+
const floor = (c) => {
|
|
154
|
+
const urlPath = new URL(c.req.url).pathname;
|
|
155
|
+
// A trailing slash is never meaningful under /api/ — re-dispatch, once.
|
|
156
|
+
if (canonicalApiPath(urlPath) !== null) return app.fetch(canonicalApiRequest(c.req.raw));
|
|
157
|
+
return Response.json(apiNotFoundBody(c.req.method, urlPath, "myapp", commit), { status: 404 });
|
|
158
|
+
};
|
|
159
|
+
app.all(API_PREFIX, floor);
|
|
160
|
+
app.all(`${API_PREFIX}/*`, floor);
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
🔴 Measured 2026-09-17 on `station`: a page that had never loaded on any commit, because
|
|
164
|
+
its client asked for `/api/openclaw/`, Hono mounts a sub-app's `get("/")` at the mount
|
|
165
|
+
point without the trailing slash and matches strictly, and the miss fell into
|
|
166
|
+
`app.get("*")` and came back as 1.7 KB of `index.html` **with a 200**. The client read
|
|
167
|
+
that HTML honestly — *"an API older than this page"* — and sent the owner to deploy a
|
|
168
|
+
commit that could not have helped.
|
|
169
|
+
|
|
170
|
+
🔴 Wire it **inside the router**, never as a wrapper around the host's `fetch`. A router
|
|
171
|
+
has more callers than `Bun.serve` — a Worker's `export default`, and every harness that
|
|
172
|
+
drives `app.fetch` — and a wrap installed at one of them is absent from the rest. That is
|
|
173
|
+
the exact fault the previous generation's artifact shipped for six days with a green
|
|
174
|
+
harness.
|
|
175
|
+
|
|
176
|
+
Both patterns are registered although `"/api/*"` alone already matches a bare `/api` on
|
|
177
|
+
hono@4.13.7 (measured 2026-09-18) — that is an undocumented property of one matcher
|
|
178
|
+
version, and one extra route registration is cheaper than a floor with a hole in it the
|
|
179
|
+
day it tightens.
|
|
180
|
+
|
|
181
|
+
No `node:` import, no `Bun.` global, no `process` — asserted by `apiFloor.test.ts`, not
|
|
182
|
+
by this paragraph — so it mounts unchanged inside a Cloudflare Worker, which is where
|
|
183
|
+
`patterns` mounts it. `apiNotFoundBody` is the DEFAULT body and not the only one: an app
|
|
184
|
+
whose API namespace is not behind a gate keeps a terser phrase of its own and takes the
|
|
185
|
+
predicates.
|
|
186
|
+
|
|
146
187
|
## `cursedops/serve`
|
|
147
188
|
|
|
148
189
|
```ts
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots without knowing a path, macOS launchd agent install/replace/remove, the scaffolding of a deployed smoke, and the static-serving helpers eight apps copied — the path-traversal guard and the API floor
|
|
3
|
+
"version": "0.2.4",
|
|
4
|
+
"description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots without knowing a path, macOS launchd agent install/replace/remove, the scaffolding of a deployed smoke, and the static-serving helpers eight apps copied — the path-traversal guard among them — and the API floor that keeps an unmatched /api/... from ever being answered with the app shell. Mechanism only — no app knows its name from here. Bun, zero dependencies, ships source.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
"source": "./src/launchd.ts",
|
|
25
25
|
"import": "./src/launchd.ts"
|
|
26
26
|
},
|
|
27
|
+
"./api-floor": {
|
|
28
|
+
"types": "./src/apiFloor.ts",
|
|
29
|
+
"bun": "./src/apiFloor.ts",
|
|
30
|
+
"source": "./src/apiFloor.ts",
|
|
31
|
+
"import": "./src/apiFloor.ts"
|
|
32
|
+
},
|
|
27
33
|
"./serve": {
|
|
28
34
|
"types": "./src/serve.ts",
|
|
29
35
|
"bun": "./src/serve.ts",
|
package/src/apiFloor.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedops/api-floor` — the rule that an unmatched `/api/...` is answered with a
|
|
3
|
+
* phrase and NEVER with the single-page app shell.
|
|
4
|
+
*
|
|
5
|
+
* It arrives by this library's own entry arithmetic, not by taste. `station` and
|
|
6
|
+
* `patterns` each wrote a floor — a rule that an unmatched `/api/...` is answered with a
|
|
7
|
+
* phrase and never with the single-page shell — independently, in different shapes, for
|
|
8
|
+
* the same incident. That is entry rule 1 twice over. Rule 2 holds because nothing below
|
|
9
|
+
* looks anything up: the app's name, its commit and the body it chooses to send all
|
|
10
|
+
* arrive as arguments, and there is no Hono type in this file, so the four lines of
|
|
11
|
+
* `app.all(...)` wiring stay in the app that owns the router. Rule 3 is the measurement:
|
|
12
|
+
*
|
|
13
|
+
* > 2026-09-17. `station`'s Openclaw page had never loaded on any commit. The client
|
|
14
|
+
* > asked for `/api/openclaw/`; Hono mounts that sub-app's `get("/")` at
|
|
15
|
+
* > `/api/openclaw` — `mergePath("/api/openclaw", "/")` drops the trailing slash — and
|
|
16
|
+
* > then matches strictly, so the request matched nothing, fell into `app.get("*")`,
|
|
17
|
+
* > and came back as 1.7 KB of `index.html` with a **200**. The client's honest reading
|
|
18
|
+
* > of an HTML body was *"that is what an API older than this page looks like — deploy
|
|
19
|
+
* > this commit and reload"*, which sent the owner at a deploy that could not have
|
|
20
|
+
* > helped.
|
|
21
|
+
*
|
|
22
|
+
* Both halves of that are here, because a floor without the normaliser fixes the symptom
|
|
23
|
+
* the owner did not have: {@link canonicalApiPath} makes the trailing slash a non-event,
|
|
24
|
+
* and {@link isApiPath} + {@link apiNotFoundBody} make every remaining miss a JSON 404
|
|
25
|
+
* that names the route, the app and the commit answering. A wrong path says "wrong path";
|
|
26
|
+
* a stale process says which commit it is.
|
|
27
|
+
*
|
|
28
|
+
* 🔴 **{@link apiNotFoundBody} is the DEFAULT body, not the only one.** `patterns` sends
|
|
29
|
+
* `{status, code, error}` through its own `reply.refuse` and deliberately says less —
|
|
30
|
+
* its floor faces the public internet, where the app's name and commit are one bit more
|
|
31
|
+
* than a scanner should get. An app that knows something this library cannot keeps its
|
|
32
|
+
* own body and takes the two predicates; that is the `cwip/asset-budget` shape, and it is
|
|
33
|
+
* what stops this growing into the kit it replaced.
|
|
34
|
+
*
|
|
35
|
+
* ## 🔴 Why this is its own subpath and not part of `cursedops/serve`
|
|
36
|
+
*
|
|
37
|
+
* Nothing below imports `node:` anything, and that is a requirement rather than an
|
|
38
|
+
* accident: `patterns` mounts its floor inside a Cloudflare Worker, where the static
|
|
39
|
+
* tier next door — `fileWithin`, `statSync`, a disk to read — does not exist and must
|
|
40
|
+
* not be dragged into the bundle to reach four pure functions. `nodejs_compat` would
|
|
41
|
+
* have RESOLVED the import and shipped a partial `node:fs` shim to the edge, so the
|
|
42
|
+
* failure would have been silent weight rather than a build error. One subpath per
|
|
43
|
+
* runtime requirement is what keeps that honest; `subpathsReachNoBunBuiltin`-shaped
|
|
44
|
+
* checks can then say so rather than a comment.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/** The API namespace. See the module header: nothing under it may answer with the shell. */
|
|
48
|
+
export const API_PREFIX = "/api";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Is this path inside the API namespace? `/api` itself counts; `/apiary` does not.
|
|
52
|
+
*
|
|
53
|
+
* 🔴 `/apiary` and `/api-docs` are the cases a `startsWith("/api")` gets wrong, and they
|
|
54
|
+
* are page URLs — a floor that swallowed them would answer JSON where the client expects
|
|
55
|
+
* its own route. The namespace ITSELF counting is the other half: a caller that types
|
|
56
|
+
* `/api` by hand is inside it.
|
|
57
|
+
*
|
|
58
|
+
* 🔴 **This does NOT mirror what Hono's router does, and the difference is the reason to
|
|
59
|
+
* register both patterns.** Measured 2026-09-18 against hono@4.13.7: `app.all("/api/*")`
|
|
60
|
+
* already matches a bare `/api` — the `*` matches zero segments including the slash in
|
|
61
|
+
* front of it. So `app.all(API_PREFIX, floor)` is redundant TODAY. It is in the wiring
|
|
62
|
+
* anyway because that is an undocumented property of one matcher version, it costs one
|
|
63
|
+
* route registration, and the alternative is a floor with a hole in it the day the
|
|
64
|
+
* matcher tightens. What proves the rule is each app's spec asking for `/api` and
|
|
65
|
+
* getting the phrase — never this arithmetic.
|
|
66
|
+
*/
|
|
67
|
+
export const isApiPath = (urlPath: string): boolean =>
|
|
68
|
+
urlPath === API_PREFIX || urlPath.startsWith(`${API_PREFIX}/`);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The path an API request SHOULD have asked for, or `null` when it already did.
|
|
72
|
+
*
|
|
73
|
+
* 🔴 One rule, and it is the one that broke the Openclaw page: a trailing slash on an API
|
|
74
|
+
* path is never meaningful. Hono mounts a sub-app's `get("/")` at the mount point WITHOUT
|
|
75
|
+
* a trailing slash and then matches strictly, so `/api/openclaw/` is a different path
|
|
76
|
+
* from `/api/openclaw` and matched nothing at all. Normalising once, in front of the
|
|
77
|
+
* router, means no caller anywhere can spell it the losing way again — which a fix in the
|
|
78
|
+
* one client module that happened to do it would not have bought.
|
|
79
|
+
*
|
|
80
|
+
* The query string is untouched: only the path is rewritten.
|
|
81
|
+
*/
|
|
82
|
+
export function canonicalApiPath(urlPath: string): string | null {
|
|
83
|
+
if (!isApiPath(urlPath) || !urlPath.endsWith("/")) return null;
|
|
84
|
+
const trimmed = urlPath.replace(/\/+$/, "");
|
|
85
|
+
return trimmed === urlPath ? null : trimmed;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The same normalisation, applied to a whole request. Returns the request unchanged when
|
|
90
|
+
* there is nothing to normalise, so a caller that guards on {@link canonicalApiPath}
|
|
91
|
+
* allocates nothing.
|
|
92
|
+
*
|
|
93
|
+
* 🔴 **Call it from INSIDE the floor handler and re-dispatch, not from the host's
|
|
94
|
+
* `fetch`.** Both work; only one of them cannot be wired up wrong. A router has many
|
|
95
|
+
* callers — `Bun.serve`, a Worker's `export default`, and every test harness that drives
|
|
96
|
+
* `app.fetch` directly — and a wrap installed at one of them is absent from the others.
|
|
97
|
+
* That is the shape of the fault this whole family exists to close: the previous
|
|
98
|
+
* generation's artifact had a floor its harness mounted correctly and production did not,
|
|
99
|
+
* and it stayed green for six days. A floor that answers
|
|
100
|
+
*
|
|
101
|
+
* const canonical = canonicalApiPath(new URL(c.req.url).pathname);
|
|
102
|
+
* if (canonical !== null) return app.fetch(canonicalApiRequest(c.req.raw));
|
|
103
|
+
* return c.json(apiNotFoundBody(...), 404, ...);
|
|
104
|
+
*
|
|
105
|
+
* is reached by every caller there will ever be, costs nothing on the hot path — only a
|
|
106
|
+
* MISS gets here — and cannot loop, because the canonical path has no trailing slash left
|
|
107
|
+
* to strip and a second miss falls straight to the body.
|
|
108
|
+
*
|
|
109
|
+
* It must not be a Hono middleware either: Hono matches the route before the chain runs,
|
|
110
|
+
* so a middleware cannot change which handler answers, which is the entire job here.
|
|
111
|
+
*/
|
|
112
|
+
export function canonicalApiRequest(request: Request): Request {
|
|
113
|
+
const url = new URL(request.url);
|
|
114
|
+
const canonical = canonicalApiPath(url.pathname);
|
|
115
|
+
if (canonical === null) return request;
|
|
116
|
+
url.pathname = canonical;
|
|
117
|
+
return new Request(url, request);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* What an unmatched API path answers — a JSON 404 that names itself.
|
|
122
|
+
*
|
|
123
|
+
* 🔴 The commit is in the body on purpose. The two reasons a route is missing are "this
|
|
124
|
+
* path never existed" and "this process is older than the page that asked", and they need
|
|
125
|
+
* different hands; without the commit a client can only guess, and the guess one printed
|
|
126
|
+
* for the owner sent him to deploy a commit that would not have fixed anything.
|
|
127
|
+
*
|
|
128
|
+
* 🔴 Safe to publish only where the floor sits BEHIND the app's gate, which is where every
|
|
129
|
+
* caller puts it — an unauthenticated request is answered 401 by the gate, matched or not.
|
|
130
|
+
* An app whose API namespace is open sends its own, terser body instead; see the module
|
|
131
|
+
* header.
|
|
132
|
+
*/
|
|
133
|
+
export function apiNotFoundBody(
|
|
134
|
+
method: string,
|
|
135
|
+
urlPath: string,
|
|
136
|
+
app: string,
|
|
137
|
+
commit: string | null | undefined,
|
|
138
|
+
): { error: string; route: string; app: string; commit: string | null } {
|
|
139
|
+
return {
|
|
140
|
+
error:
|
|
141
|
+
`no such route: ${method} ${urlPath}. ${app} is serving ${commit ?? "an unreadable commit"} ` +
|
|
142
|
+
"and has no such API route — either the path is wrong, or this process is older than the page " +
|
|
143
|
+
"that asked for it. It is NOT the app shell: nothing under /api/ is ever answered with HTML.",
|
|
144
|
+
route: `${method} ${urlPath}`,
|
|
145
|
+
app,
|
|
146
|
+
commit: commit ?? null,
|
|
147
|
+
};
|
|
148
|
+
}
|
package/src/serve.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The helpers every app's `src/server/serve.ts` wrote the same way — the
|
|
3
|
-
* path-traversal guard among them
|
|
2
|
+
* The four helpers every app's `src/server/serve.ts` wrote the same way — the
|
|
3
|
+
* path-traversal guard among them.
|
|
4
4
|
*
|
|
5
5
|
* ## Why this is a library and not eight copies
|
|
6
6
|
*
|
|
@@ -48,37 +48,11 @@
|
|
|
48
48
|
* `serve.test.ts` drives both halves, including the symlink escape, because a guard
|
|
49
49
|
* that has only ever been seen passing is a guard nobody has proved can fail.
|
|
50
50
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* the same incident. That is entry rule 1 twice over. Rule 2 holds because nothing below
|
|
57
|
-
* looks anything up: the app's name, its commit and the body it chooses to send all
|
|
58
|
-
* arrive as arguments, and there is no Hono type in this file, so the three lines of
|
|
59
|
-
* `app.all(...)` wiring stay in the app that owns the router. Rule 3 is the measurement:
|
|
60
|
-
*
|
|
61
|
-
* > 2026-09-17. `station`'s Openclaw page had never loaded on any commit. The client
|
|
62
|
-
* > asked for `/api/openclaw/`; Hono mounts that sub-app's `get("/")` at
|
|
63
|
-
* > `/api/openclaw` — `mergePath("/api/openclaw", "/")` drops the trailing slash — and
|
|
64
|
-
* > then matches strictly, so the request matched nothing, fell into `app.get("*")`,
|
|
65
|
-
* > and came back as 1.7 KB of `index.html` with a **200**. The client's honest reading
|
|
66
|
-
* > of an HTML body was *"that is what an API older than this page looks like — deploy
|
|
67
|
-
* > this commit and reload"*, which sent the owner at a deploy that could not have
|
|
68
|
-
* > helped.
|
|
69
|
-
*
|
|
70
|
-
* Both halves of that are here, because a floor without the normaliser fixes the symptom
|
|
71
|
-
* the owner did not have: {@link canonicalApiPath} makes the trailing slash a non-event,
|
|
72
|
-
* and {@link isApiPath} + {@link apiNotFoundBody} make every remaining miss a JSON 404
|
|
73
|
-
* that names the route, the app and the commit answering. A wrong path says "wrong path";
|
|
74
|
-
* a stale process says which commit it is.
|
|
75
|
-
*
|
|
76
|
-
* 🔴 **{@link apiNotFoundBody} is the DEFAULT body, not the only one.** `patterns` sends
|
|
77
|
-
* `{status, code, error}` through its own `reply.refuse` and deliberately says less —
|
|
78
|
-
* its floor faces the public internet, where the app's name and commit are one bit more
|
|
79
|
-
* than a scanner should get. An app that knows something this library cannot keeps its
|
|
80
|
-
* own body and takes the two predicates; that is the `cwip/asset-budget` shape, and it is
|
|
81
|
-
* what stops this growing into the kit it replaced.
|
|
51
|
+
* 🔴 **The API floor is deliberately NOT here — it is `cursedops/api-floor`.** It is the
|
|
52
|
+
* same family of answer (an app's HTTP tier, written the same way twice) and it has a
|
|
53
|
+
* requirement this module cannot meet: `patterns` mounts its floor inside a Cloudflare
|
|
54
|
+
* Worker, and everything in this file exists to read a disk. Splitting the subpath is
|
|
55
|
+
* what stops `node:fs` being bundled to the edge to reach four pure functions.
|
|
82
56
|
*/
|
|
83
57
|
|
|
84
58
|
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
@@ -173,100 +147,6 @@ export function fileWithin(root: string, urlPath: string): string | null {
|
|
|
173
147
|
* the whole of rule 2: the only app-shaped thing these four helpers touch arrives
|
|
174
148
|
* as an argument.
|
|
175
149
|
*/
|
|
176
|
-
/** The API namespace. See the module header: nothing under it may answer with the shell. */
|
|
177
|
-
export const API_PREFIX = "/api";
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Is this path inside the API namespace? `/api` itself counts; `/apiary` does not.
|
|
181
|
-
*
|
|
182
|
-
* 🔴 The namespace ITSELF is the case every hand-rolled floor missed. A Hono
|
|
183
|
-
* `app.all("/api/*")` does not match a bare `/api` — the pattern requires the slash — so
|
|
184
|
-
* the one path most likely to be typed by hand fell through to the shell in the two apps
|
|
185
|
-
* that already believed they had a floor.
|
|
186
|
-
*/
|
|
187
|
-
export const isApiPath = (urlPath: string): boolean =>
|
|
188
|
-
urlPath === API_PREFIX || urlPath.startsWith(`${API_PREFIX}/`);
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* The path an API request SHOULD have asked for, or `null` when it already did.
|
|
192
|
-
*
|
|
193
|
-
* 🔴 One rule, and it is the one that broke the Openclaw page: a trailing slash on an API
|
|
194
|
-
* path is never meaningful. Hono mounts a sub-app's `get("/")` at the mount point WITHOUT
|
|
195
|
-
* a trailing slash and then matches strictly, so `/api/openclaw/` is a different path
|
|
196
|
-
* from `/api/openclaw` and matched nothing at all. Normalising once, in front of the
|
|
197
|
-
* router, means no caller anywhere can spell it the losing way again — which a fix in the
|
|
198
|
-
* one client module that happened to do it would not have bought.
|
|
199
|
-
*
|
|
200
|
-
* The query string is untouched: only the path is rewritten.
|
|
201
|
-
*/
|
|
202
|
-
export function canonicalApiPath(urlPath: string): string | null {
|
|
203
|
-
if (!isApiPath(urlPath) || !urlPath.endsWith("/")) return null;
|
|
204
|
-
const trimmed = urlPath.replace(/\/+$/, "");
|
|
205
|
-
return trimmed === urlPath ? null : trimmed;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* The same normalisation, applied to a whole request. Returns the request unchanged when
|
|
210
|
-
* there is nothing to normalise, so a caller that guards on {@link canonicalApiPath}
|
|
211
|
-
* allocates nothing.
|
|
212
|
-
*
|
|
213
|
-
* 🔴 **Call it from INSIDE the floor handler and re-dispatch, not from the host's
|
|
214
|
-
* `fetch`.** Both work; only one of them cannot be wired up wrong. A router has many
|
|
215
|
-
* callers — `Bun.serve`, a Worker's `export default`, and every test harness that drives
|
|
216
|
-
* `app.fetch` directly — and a wrap installed at one of them is absent from the others.
|
|
217
|
-
* That is the shape of the fault this whole family exists to close: the previous
|
|
218
|
-
* generation's artifact had a floor its harness mounted correctly and production did not,
|
|
219
|
-
* and it stayed green for six days. A floor that answers
|
|
220
|
-
*
|
|
221
|
-
* const canonical = canonicalApiPath(new URL(c.req.url).pathname);
|
|
222
|
-
* if (canonical !== null) return app.fetch(canonicalApiRequest(c.req.raw));
|
|
223
|
-
* return c.json(apiNotFoundBody(...), 404, ...);
|
|
224
|
-
*
|
|
225
|
-
* is reached by every caller there will ever be, costs nothing on the hot path — only a
|
|
226
|
-
* MISS gets here — and cannot loop, because the canonical path has no trailing slash left
|
|
227
|
-
* to strip and a second miss falls straight to the body.
|
|
228
|
-
*
|
|
229
|
-
* It must not be a Hono middleware either: Hono matches the route before the chain runs,
|
|
230
|
-
* so a middleware cannot change which handler answers, which is the entire job here.
|
|
231
|
-
*/
|
|
232
|
-
export function canonicalApiRequest(request: Request): Request {
|
|
233
|
-
const url = new URL(request.url);
|
|
234
|
-
const canonical = canonicalApiPath(url.pathname);
|
|
235
|
-
if (canonical === null) return request;
|
|
236
|
-
url.pathname = canonical;
|
|
237
|
-
return new Request(url, request);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* What an unmatched API path answers — a JSON 404 that names itself.
|
|
242
|
-
*
|
|
243
|
-
* 🔴 The commit is in the body on purpose. The two reasons a route is missing are "this
|
|
244
|
-
* path never existed" and "this process is older than the page that asked", and they need
|
|
245
|
-
* different hands; without the commit a client can only guess, and the guess one printed
|
|
246
|
-
* for the owner sent him to deploy a commit that would not have fixed anything.
|
|
247
|
-
*
|
|
248
|
-
* 🔴 Safe to publish only where the floor sits BEHIND the app's gate, which is where every
|
|
249
|
-
* caller puts it — an unauthenticated request is answered 401 by the gate, matched or not.
|
|
250
|
-
* An app whose API namespace is open sends its own, terser body instead; see the module
|
|
251
|
-
* header.
|
|
252
|
-
*/
|
|
253
|
-
export function apiNotFoundBody(
|
|
254
|
-
method: string,
|
|
255
|
-
urlPath: string,
|
|
256
|
-
app: string,
|
|
257
|
-
commit: string | null | undefined,
|
|
258
|
-
): { error: string; route: string; app: string; commit: string | null } {
|
|
259
|
-
return {
|
|
260
|
-
error:
|
|
261
|
-
`no such route: ${method} ${urlPath}. ${app} is serving ${commit ?? "an unreadable commit"} ` +
|
|
262
|
-
"and has no such API route — either the path is wrong, or this process is older than the page " +
|
|
263
|
-
"that asked for it. It is NOT the app shell: nothing under /api/ is ever answered with HTML.",
|
|
264
|
-
route: `${method} ${urlPath}`,
|
|
265
|
-
app,
|
|
266
|
-
commit: commit ?? null,
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
|
|
270
150
|
export function installCrashHandlers(name: string): void {
|
|
271
151
|
process.on("uncaughtException", (error) => {
|
|
272
152
|
console.error(`[${name}] uncaught exception — exiting`, error);
|