@fourier-labs/harbour 0.1.27 → 0.1.28
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/packages/harbour-cli/src/agent-setup.js +4 -2
- package/dist/packages/harbour-cli/src/app-schema.js +8 -44
- package/dist/packages/harbour-cli/src/check.js +185 -145
- package/dist/packages/harbour-cli/src/cli.js +2 -2
- package/dist/packages/harbour-cli/src/dev.js +8 -0
- package/dist/packages/harbour-cli/src/forwarder.js +15 -11
- package/dist/packages/harbour-cli/src/integrations.js +26 -1
- package/dist/packages/harbour-cli/src/kit-bundle.js +9 -3
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +7 -7
- package/dist/packages/harbour-cli/src/kit.js +13 -56
- package/dist/packages/harbour-cli/src/local-runtime.js +43 -23
- package/dist/packages/harbour-cli/src/productionise.js +21 -22
- package/dist/packages/harbour-cli/src/retained-checks.js +60 -22
- package/dist/packages/harbour-cli/src/source-inventory.js +171 -0
- package/dist/packages/harbour-cli/src/starter.js +53 -4
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/package.json +2 -2
- package/dist/packages/harbour-cli/src/database-gate.js +0 -61
- package/dist/packages/harbour-cli/src/flow-coverage.js +0 -490
|
@@ -1,490 +0,0 @@
|
|
|
1
|
-
import { createServer, request as httpRequest } from "node:http";
|
|
2
|
-
import { connect } from "node:net";
|
|
3
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
4
|
-
import { extname, join, relative, sep } from "node:path";
|
|
5
|
-
/**
|
|
6
|
-
* The pipeline's operation-coverage gate, run locally.
|
|
7
|
-
*
|
|
8
|
-
* The deployment pipeline replays `.harbour/checks/` against a real App Gateway
|
|
9
|
-
* and then refuses the deployment (`flow.check-failed`) unless every database
|
|
10
|
-
* operation the browser code performs, and every capability the kit lane
|
|
11
|
-
* derives from that code, was actually exercised while those checks ran. Before
|
|
12
|
-
* this module `harbour check` ran the same journey scripts but asserted nothing
|
|
13
|
-
* about what they exercised, so an app whose checks never touched a converted
|
|
14
|
-
* operation passed locally and failed in the pipeline — the parity gap this
|
|
15
|
-
* closes.
|
|
16
|
-
*
|
|
17
|
-
* The inventory and the wording below deliberately mirror the data plane's
|
|
18
|
-
* packages/toolkit/transformbuild (write_probe.go `inventorySDKTableVerbs`,
|
|
19
|
-
* local_operation_coverage.go `requireOperationCoverage`) so a local failure
|
|
20
|
-
* reads exactly like the pipeline's.
|
|
21
|
-
*/
|
|
22
|
-
/** Capabilities the coverage gate demands evidence for, in the pipeline's order. */
|
|
23
|
-
export const COVERED_CAPABILITIES = ["data", "files", "actions", "telemetry", "realtime"];
|
|
24
|
-
/** Every capability the kit lane derives from the browser SDK surface. */
|
|
25
|
-
const SDK_CAPABILITIES = ["data", "files", "actions", "realtime", "telemetry", "integrations"];
|
|
26
|
-
const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs"]);
|
|
27
|
-
const SKIPPED_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".harbour"]);
|
|
28
|
-
/**
|
|
29
|
-
* TypeScript call sites carry explicit type arguments between a method name and
|
|
30
|
-
* its call — `harbour.data.from<Note>("notes")`, `.select<Row>(` — and the
|
|
31
|
-
* starter's own list is one of them. Matching only the bare `.from(` made every
|
|
32
|
-
* typed call site invisible, so a freshly generated starter inventoried
|
|
33
|
-
* `notes: delete, insert, update` with no SELECT: the gate was reading the
|
|
34
|
-
* starter's own capabilities wrong. This is the pipeline's own
|
|
35
|
-
* `appSDKTypeArguments` (transformbuild/source_inspection.go), one nesting
|
|
36
|
-
* level of generics, so both scanners see the same call sites.
|
|
37
|
-
*/
|
|
38
|
-
const TYPE_ARGUMENTS = String.raw `(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\s*`;
|
|
39
|
-
const TABLE_CHAIN_SOURCE = String.raw `\.from${TYPE_ARGUMENTS}\(\s*["'\`]([A-Za-z0-9_]+)["'\`]\s*\)`;
|
|
40
|
-
const TABLE_CHAIN = new RegExp(TABLE_CHAIN_SOURCE, "g");
|
|
41
|
-
/** The same chain, unanchored and non-global: where THIS chain's verbs stop. */
|
|
42
|
-
const NEXT_TABLE_CHAIN = new RegExp(TABLE_CHAIN_SOURCE);
|
|
43
|
-
const VERB_CALL = new RegExp(String.raw `\.(select|insert|update|delete|upsert)\s*${TYPE_ARGUMENTS}\(`, "g");
|
|
44
|
-
/** `createClient()` bindings name the identifier a capability call must be made on. */
|
|
45
|
-
const CLIENT_BINDING = /(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:await\s+)?createClient\s*\(/g;
|
|
46
|
-
/** `<anything>.<namespace>.<method>(` — the receiver is deliberately not constrained; see capabilityCallSurface. */
|
|
47
|
-
const CAPABILITY_CALL = new RegExp(String.raw `\.\s*(${SDK_CAPABILITIES.join("|")})\s*\.\s*[A-Za-z_$][A-Za-z0-9_$]*\s*${TYPE_ARGUMENTS}\(`, "g");
|
|
48
|
-
/** Records the gateway operations observed while the retained checks ran. */
|
|
49
|
-
export class CoverageLedger {
|
|
50
|
-
seen = new Set();
|
|
51
|
-
/** capability -> the retained checks that asked the gateway for it. */
|
|
52
|
-
asked = new Map();
|
|
53
|
-
running;
|
|
54
|
-
add(key) { this.seen.add(key); }
|
|
55
|
-
has(key) { return this.seen.has(key); }
|
|
56
|
-
keys() { return [...this.seen].sort(); }
|
|
57
|
-
/**
|
|
58
|
-
* Names the retained check whose gateway traffic follows. The journeys run
|
|
59
|
-
* one at a time, so a request the proxy sees between this call and the next
|
|
60
|
-
* belongs to this check — which is how a refusal can name the file to delete.
|
|
61
|
-
*/
|
|
62
|
-
nowRunning(check) { this.running = check; }
|
|
63
|
-
/**
|
|
64
|
-
* One request the running check sent at a capability-gated path, recorded
|
|
65
|
-
* whatever the answer was. The deployed gateway resolves the capability
|
|
66
|
-
* BEFORE any handler runs and answers 404 when the app did not declare it
|
|
67
|
-
* (appgateway/resolver.go "capability is not declared"), so the request
|
|
68
|
-
* itself — not its local success — is what decides the check's fate there.
|
|
69
|
-
*/
|
|
70
|
-
askedFor(path) {
|
|
71
|
-
const capability = capabilityForRequest(path);
|
|
72
|
-
if (!capability || !this.running)
|
|
73
|
-
return;
|
|
74
|
-
const checks = this.asked.get(capability) ?? new Set();
|
|
75
|
-
checks.add(this.running);
|
|
76
|
-
this.asked.set(capability, checks);
|
|
77
|
-
}
|
|
78
|
-
/** capability -> the checks that asked for it, for the reverse coverage question. */
|
|
79
|
-
capabilitiesAsked() { return this.asked; }
|
|
80
|
-
/**
|
|
81
|
-
* The gateway's data boundary. A write that changed no row proves nothing, so
|
|
82
|
-
* only a select — or a mutation that returned rows — counts (observeData in
|
|
83
|
-
* local_operation_coverage.go).
|
|
84
|
-
*/
|
|
85
|
-
observeData(table, operation, rows) {
|
|
86
|
-
if (operation !== "select" && rows === 0)
|
|
87
|
-
return;
|
|
88
|
-
this.add("data");
|
|
89
|
-
this.add(`data:${table}:${operation}`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
async function sourceFiles(root) {
|
|
93
|
-
const found = [];
|
|
94
|
-
const walk = async (directory) => {
|
|
95
|
-
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
96
|
-
for (const entry of entries) {
|
|
97
|
-
const path = join(directory, entry.name);
|
|
98
|
-
if (entry.isDirectory()) {
|
|
99
|
-
if (!SKIPPED_DIRECTORIES.has(entry.name))
|
|
100
|
-
await walk(path);
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
if (SOURCE_EXTENSIONS.has(extname(entry.name)))
|
|
104
|
-
found.push(path);
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
await walk(root);
|
|
108
|
-
return found.sort();
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* Every `<table> <verb>` the browser code performs, with the files performing
|
|
112
|
-
* it. The verb chain is what follows THIS `.from(...)` up to the end of its
|
|
113
|
-
* statement or the next `.from(` — never a later chain's verbs.
|
|
114
|
-
*
|
|
115
|
-
* This reading is what the gate demands evidence for, so `retained-checks.ts`
|
|
116
|
-
* generates from exactly it: a generated journey can then only ever cover what
|
|
117
|
-
* the gate asks about, in both directions, with no template in between.
|
|
118
|
-
*/
|
|
119
|
-
export async function sourceTableUsage(root) {
|
|
120
|
-
const inventory = new Map();
|
|
121
|
-
for (const path of await sourceFiles(root)) {
|
|
122
|
-
const text = await readFile(path, "utf8");
|
|
123
|
-
for (const match of text.matchAll(TABLE_CHAIN)) {
|
|
124
|
-
const start = match.index ?? 0;
|
|
125
|
-
if (text.slice(0, start).trimEnd().endsWith(".storage"))
|
|
126
|
-
continue;
|
|
127
|
-
let tail = text.slice(start + match[0].length);
|
|
128
|
-
const semicolon = tail.indexOf(";");
|
|
129
|
-
if (/[;\n]/.test(tail) && semicolon >= 0)
|
|
130
|
-
tail = tail.slice(0, semicolon);
|
|
131
|
-
const next = tail.search(NEXT_TABLE_CHAIN);
|
|
132
|
-
if (next > 0)
|
|
133
|
-
tail = tail.slice(0, next);
|
|
134
|
-
tail = tail.slice(0, 200);
|
|
135
|
-
const table = match[1];
|
|
136
|
-
const usage = inventory.get(table) ?? { verbs: new Set(), files: [] };
|
|
137
|
-
for (const verb of tail.matchAll(VERB_CALL))
|
|
138
|
-
usage.verbs.add(verb[1]);
|
|
139
|
-
const file = appPath(root, path);
|
|
140
|
-
if (!usage.files.includes(file))
|
|
141
|
-
usage.files.push(file);
|
|
142
|
-
inventory.set(table, usage);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
return inventory;
|
|
146
|
-
}
|
|
147
|
-
/** `<table> <verb>` alone, as the coverage gate compares it. */
|
|
148
|
-
export async function sourceTableVerbs(root) {
|
|
149
|
-
return new Map([...await sourceTableUsage(root)].map(([table, usage]) => [table, usage.verbs]));
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* The capabilities the kit lane declares for this app, with the files declaring
|
|
153
|
-
* them: a namespace called as `<client>.<namespace>.<method>(` on an identifier
|
|
154
|
-
* bound to `createClient()`. TypeScript type arguments may sit between the
|
|
155
|
-
* method and its call.
|
|
156
|
-
*/
|
|
157
|
-
export async function capabilityUsage(root) {
|
|
158
|
-
const files = await sourceFiles(root);
|
|
159
|
-
const texts = await Promise.all(files.map(path => readFile(path, "utf8").catch(() => "")));
|
|
160
|
-
const clients = new Set();
|
|
161
|
-
for (const text of texts)
|
|
162
|
-
for (const match of text.matchAll(CLIENT_BINDING))
|
|
163
|
-
clients.add(match[1]);
|
|
164
|
-
const used = new Map();
|
|
165
|
-
if (!clients.size)
|
|
166
|
-
return used;
|
|
167
|
-
const namespaces = SDK_CAPABILITIES.join("|");
|
|
168
|
-
for (const client of clients) {
|
|
169
|
-
const call = new RegExp(`\\b${client}\\s*\\.\\s*(${namespaces})\\s*\\.\\s*[A-Za-z_$][A-Za-z0-9_$]*\\s*(?:<[^<>()]*>\\s*)?\\(`, "g");
|
|
170
|
-
for (const [index, text] of texts.entries()) {
|
|
171
|
-
const file = appPath(root, files[index]);
|
|
172
|
-
for (const match of text.matchAll(call)) {
|
|
173
|
-
const seen = used.get(match[1]) ?? [];
|
|
174
|
-
if (!seen.includes(file))
|
|
175
|
-
seen.push(file);
|
|
176
|
-
used.set(match[1], seen);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
return used;
|
|
181
|
-
}
|
|
182
|
-
/** The capability names alone, as the coverage gate compares them. */
|
|
183
|
-
export async function declaredCapabilities(root) {
|
|
184
|
-
return new Set((await capabilityUsage(root)).keys());
|
|
185
|
-
}
|
|
186
|
-
/** A source file as the app itself names it: relative to the root, `/`-separated on every platform. */
|
|
187
|
-
const appPath = (root, path) => relative(root, path).split(sep).join("/");
|
|
188
|
-
/**
|
|
189
|
-
* Every capability namespace the app's own source calls a method on, whatever
|
|
190
|
-
* object the call is made on: `harbour.files.list(`, `client().files.list(`,
|
|
191
|
-
* `(harbour as {files: F}).files.list(`.
|
|
192
|
-
*
|
|
193
|
-
* This is deliberately looser than `declaredCapabilities`, and the two answer
|
|
194
|
-
* different questions. The forward gate asks "was this capability exercised?"
|
|
195
|
-
* and must be precise about what the app really uses. The reverse gate below
|
|
196
|
-
* asks "is this check exercising something the app no longer has?" — a
|
|
197
|
-
* question whose wrong answer refuses a good app — so it needs a lower bound on
|
|
198
|
-
* what is unused, never an upper bound on what is used.
|
|
199
|
-
*
|
|
200
|
-
* The pipeline's own evidence for a capability is `<client>.<namespace>.<method>(`
|
|
201
|
-
* with the client traced through the module graph (transformbuild
|
|
202
|
-
* analyzeAppSDKClientCalls / analyzeAppSDKNamespaceWrappers). Dropping the
|
|
203
|
-
* receiver makes this a superset of that set — every call site the pipeline can
|
|
204
|
-
* trace, plus ones it cannot — so a capability this cannot see is one the kit
|
|
205
|
-
* lane cannot declare either, and the reverse gate refuses only trees the
|
|
206
|
-
* pipeline already refuses. Where it errs it errs by staying quiet: a
|
|
207
|
-
* commented-out call still counts here, and the pipeline masks comments.
|
|
208
|
-
*/
|
|
209
|
-
export async function capabilityCallSurface(root) {
|
|
210
|
-
const surface = new Set();
|
|
211
|
-
for (const path of await sourceFiles(root))
|
|
212
|
-
for (const capability of capabilitiesCalled(await readFile(path, "utf8").catch(() => "")))
|
|
213
|
-
surface.add(capability);
|
|
214
|
-
return surface;
|
|
215
|
-
}
|
|
216
|
-
/** The same reading of one piece of text, so a retained check is judged by exactly the rule the app's own source is read with (retained-checks.ts). */
|
|
217
|
-
export function capabilitiesCalled(text) {
|
|
218
|
-
return new Set([...text.matchAll(CAPABILITY_CALL)].map(match => match[1]));
|
|
219
|
-
}
|
|
220
|
-
/**
|
|
221
|
-
* The gateway's own path -> capability map (appgateway/gateway.go
|
|
222
|
-
* `capabilityForRequest`). Every one of these paths is resolved against the
|
|
223
|
-
* app's declared capability set before it is served.
|
|
224
|
-
*/
|
|
225
|
-
export function capabilityForRequest(path) {
|
|
226
|
-
if (path === "/_harbour/realtime")
|
|
227
|
-
return "realtime";
|
|
228
|
-
if (path.startsWith("/_harbour/files"))
|
|
229
|
-
return "files";
|
|
230
|
-
if (path.startsWith("/_harbour/actions/"))
|
|
231
|
-
return "actions";
|
|
232
|
-
if (path === "/_harbour/telemetry")
|
|
233
|
-
return "telemetry";
|
|
234
|
-
if (path.startsWith("/_harbour/integrations/"))
|
|
235
|
-
return "integrations";
|
|
236
|
-
if (path.startsWith("/_harbour/data/"))
|
|
237
|
-
return "data";
|
|
238
|
-
return undefined;
|
|
239
|
-
}
|
|
240
|
-
/**
|
|
241
|
-
* The other direction, and the one that let a real deploy fail twice: a
|
|
242
|
-
* retained check exercising a capability the app does not have any more.
|
|
243
|
-
*
|
|
244
|
-
* A builder's agent replaced the starter's notes schema and deleted the
|
|
245
|
-
* "Private files" section but left `.harbour/checks/files-journey.mjs` behind.
|
|
246
|
-
* The kit lane derives `.harbour/app-capabilities.json` from the browser SDK
|
|
247
|
-
* surface, the cell's App Gateway serves only what that file declares, and the
|
|
248
|
-
* journey's first `harbour.files.*` call came back 404 — `flow.check-failed …
|
|
249
|
-
* files-journey.mjs: exit status 1`, twice, 2m23s and 2m12s. Locally the check
|
|
250
|
-
* was green: `harbour dev` hard-codes data/files/realtime/telemetry
|
|
251
|
-
* (local-runtime.ts `gatewayConfig`) and always provisions MinIO, so the
|
|
252
|
-
* orphaned journey passed on its own terms.
|
|
253
|
-
*
|
|
254
|
-
* This reproduces the pipeline's refusal rather than inventing a second
|
|
255
|
-
* opinion, at the pipeline's own granularity — the capability, not the table.
|
|
256
|
-
* The pipeline does have a table-level reverse check
|
|
257
|
-
* (`rejectServerTierOperationsDrivenFromTheBrowser`), but it is scoped to apps
|
|
258
|
-
* that declare a workload directory; a kit app has none, so a check that reads
|
|
259
|
-
* a table the browser no longer reads is served there and must be served here.
|
|
260
|
-
*/
|
|
261
|
-
export function orphanedChecks(surface, ledger) {
|
|
262
|
-
const orphaned = [];
|
|
263
|
-
for (const [capability, checks] of ledger.capabilitiesAsked()) {
|
|
264
|
-
if (surface.has(capability))
|
|
265
|
-
continue;
|
|
266
|
-
for (const check of checks) {
|
|
267
|
-
orphaned.push(`${check}: exercises the ${capability} capability, which no code in this app calls any more. ` +
|
|
268
|
-
`The deployed gateway serves only the capabilities the kit lane derives from the source, so it refuses this check as ` +
|
|
269
|
-
`flow.check-failed: ${check}: exit status 1. Delete .harbour/checks/${check}, or restore the harbour.${capability}.* feature it was written for.`);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
return orphaned.sort();
|
|
273
|
-
}
|
|
274
|
-
/** The `flow` check's detail, in the pipeline's words, for both directions. */
|
|
275
|
-
export function flowDetail(missing, orphaned) {
|
|
276
|
-
const parts = [];
|
|
277
|
-
if (missing.length)
|
|
278
|
-
parts.push(`${missing.join("; ")}. Exercise the converted application operations with source-valid inputs and assertions; an unrelated passing check is insufficient.`);
|
|
279
|
-
if (orphaned.length)
|
|
280
|
-
parts.push(orphaned.join("; "));
|
|
281
|
-
if (!parts.length)
|
|
282
|
-
return "every operation the app performs and every capability it declares was exercised by a retained check, and no retained check exercises a capability the app no longer has";
|
|
283
|
-
return `${parts.join(" ")} The pipeline refuses this deployment as kit.check-failed: flow.check-failed.`;
|
|
284
|
-
}
|
|
285
|
-
/**
|
|
286
|
-
* What the pipeline would refuse, in its own words: an inventoried operation or
|
|
287
|
-
* a declared capability with no successful observation while the checks ran.
|
|
288
|
-
*/
|
|
289
|
-
export function missingFlowOperations(inventory, capabilities, ledger) {
|
|
290
|
-
const missing = [];
|
|
291
|
-
for (const [table, verbs] of inventory) {
|
|
292
|
-
for (const verb of verbs) {
|
|
293
|
-
if (!ledger.has(`data:${table}:${verb}`))
|
|
294
|
-
missing.push(`${table} ${verb.toUpperCase()}: no successful converted application operation observed`);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
for (const capability of COVERED_CAPABILITIES) {
|
|
298
|
-
if (capabilities.has(capability) && !ledger.has(capability))
|
|
299
|
-
missing.push(`${capability}: retained checks did not exercise this declared component`);
|
|
300
|
-
}
|
|
301
|
-
return missing.sort();
|
|
302
|
-
}
|
|
303
|
-
const HOP_HEADERS = new Set(["connection", "keep-alive", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length"]);
|
|
304
|
-
/**
|
|
305
|
-
* A loopback reverse proxy in front of the running `harbour dev` origin that
|
|
306
|
-
* records what the retained checks exercise. The pipeline observes the same
|
|
307
|
-
* boundary from inside its own gateway; observing it here needs no second
|
|
308
|
-
* gateway and no change to the checks, which keep using HARBOUR_APP_URL.
|
|
309
|
-
*/
|
|
310
|
-
export function createCoverageProxy(origin, ledger) {
|
|
311
|
-
const target = new URL(origin);
|
|
312
|
-
const port = Number(target.port || 80);
|
|
313
|
-
const host = target.hostname;
|
|
314
|
-
const upstreamOrigin = target.origin;
|
|
315
|
-
const server = createServer((incoming, response) => {
|
|
316
|
-
const path = (incoming.url ?? "/").split("?")[0];
|
|
317
|
-
// Before the answer: the deployed gateway refuses an undeclared capability
|
|
318
|
-
// at this path whatever the handler would have said, so what the check
|
|
319
|
-
// asked for is the evidence the reverse gate needs.
|
|
320
|
-
ledger.askedFor(path);
|
|
321
|
-
const chunks = [];
|
|
322
|
-
incoming.on("data", chunk => { if (chunks.length < 64)
|
|
323
|
-
chunks.push(chunk); });
|
|
324
|
-
const upstream = httpRequest({ host, port, method: incoming.method, path: incoming.url, headers: forwardable(incoming.headers, upstreamOrigin) }, upstreamResponse => {
|
|
325
|
-
const body = [];
|
|
326
|
-
upstreamResponse.on("data", chunk => { if (body.length < 64)
|
|
327
|
-
body.push(chunk); });
|
|
328
|
-
upstreamResponse.on("end", () => {
|
|
329
|
-
const status = upstreamResponse.statusCode ?? 0;
|
|
330
|
-
if (status >= 200 && status < 300)
|
|
331
|
-
record(ledger, path, incoming.method ?? "GET", Buffer.concat(chunks).toString("utf8"), Buffer.concat(body).toString("utf8"));
|
|
332
|
-
});
|
|
333
|
-
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
|
|
334
|
-
upstreamResponse.pipe(response);
|
|
335
|
-
});
|
|
336
|
-
upstream.on("error", () => { if (!response.headersSent)
|
|
337
|
-
response.writeHead(502); response.end(); });
|
|
338
|
-
incoming.pipe(upstream);
|
|
339
|
-
});
|
|
340
|
-
// Realtime is a WebSocket: the proxy tunnels it untouched and reads the
|
|
341
|
-
// server's frames as they pass. Only a delivered event counts — the gateway
|
|
342
|
-
// fires OnRealtimeDelivery for postgres_changes, broadcast and presence, and
|
|
343
|
-
// never for the `subscribed` acknowledgement, so a check that subscribes and
|
|
344
|
-
// receives nothing does not clear the capability here either.
|
|
345
|
-
server.on("upgrade", (incoming, socket, head) => {
|
|
346
|
-
const path = (incoming.url ?? "/").split("?")[0];
|
|
347
|
-
ledger.askedFor(path);
|
|
348
|
-
const upstream = connect(port, host, () => {
|
|
349
|
-
const headers = Object.entries({ ...forwardable(incoming.headers, upstreamOrigin), host: `${host}:${port}`, connection: "Upgrade", upgrade: "websocket" })
|
|
350
|
-
.map(([key, value]) => `${key}: ${value}`);
|
|
351
|
-
upstream.write(`${incoming.method} ${incoming.url} HTTP/1.1\r\n${headers.join("\r\n")}\r\n\r\n`);
|
|
352
|
-
if (head?.length)
|
|
353
|
-
upstream.write(head);
|
|
354
|
-
if (path === "/_harbour/realtime") {
|
|
355
|
-
const frames = websocketTextFrames();
|
|
356
|
-
upstream.on("data", (chunk) => { for (const message of frames(chunk))
|
|
357
|
-
if (isRealtimeDelivery(message))
|
|
358
|
-
ledger.add("realtime"); });
|
|
359
|
-
}
|
|
360
|
-
upstream.pipe(socket);
|
|
361
|
-
socket.pipe(upstream);
|
|
362
|
-
});
|
|
363
|
-
upstream.on("error", () => socket.destroy());
|
|
364
|
-
socket.on("error", () => upstream.destroy());
|
|
365
|
-
});
|
|
366
|
-
return {
|
|
367
|
-
listen: () => new Promise((resolve, reject) => {
|
|
368
|
-
server.on("error", reject);
|
|
369
|
-
server.listen(0, "127.0.0.1", () => resolve(`http://127.0.0.1:${server.address().port}`));
|
|
370
|
-
}),
|
|
371
|
-
close: () => new Promise(resolve => { server.closeAllConnections?.(); server.close(() => resolve()); })
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
/**
|
|
375
|
-
* `harbour dev` serves one origin and refuses anything that does not carry it,
|
|
376
|
-
* so the proxy presents the upstream's own Host and Origin. Forwarding its own
|
|
377
|
-
* address instead is what made the realtime upgrade fail before this: the
|
|
378
|
-
* forwarder dropped the socket and the channel never subscribed.
|
|
379
|
-
*/
|
|
380
|
-
function forwardable(headers, origin) {
|
|
381
|
-
const result = {};
|
|
382
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
383
|
-
if (HOP_HEADERS.has(key.toLowerCase()) || value === undefined)
|
|
384
|
-
continue;
|
|
385
|
-
result[key] = key.toLowerCase() === "origin" ? origin : Array.isArray(value) ? value.join(", ") : value;
|
|
386
|
-
}
|
|
387
|
-
return result;
|
|
388
|
-
}
|
|
389
|
-
/** One successful gateway request, classified exactly as the pipeline's coverage handler does. */
|
|
390
|
-
export function record(ledger, path, method, requestBody, responseBody) {
|
|
391
|
-
const data = /^\/_harbour\/data\/([A-Za-z0-9_]+)\/query$/.exec(path);
|
|
392
|
-
if (data) {
|
|
393
|
-
const operation = String(readJson(requestBody)?.operation ?? "");
|
|
394
|
-
if (!operation)
|
|
395
|
-
return;
|
|
396
|
-
const envelope = readJson(responseBody)?.data;
|
|
397
|
-
const rows = Array.isArray(envelope?.data) ? envelope.data.length : envelope?.count ?? 0;
|
|
398
|
-
if (operation === "rpc") {
|
|
399
|
-
ledger.add("data");
|
|
400
|
-
ledger.add(`rpc:${data[1]}`);
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
ledger.observeData(data[1], operation, rows);
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
if (path.startsWith("/_harbour/actions/")) {
|
|
407
|
-
ledger.add("actions");
|
|
408
|
-
ledger.add(`action:${path.slice("/_harbour/actions/".length)}`);
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
if (path === "/_harbour/files/uploads/complete") {
|
|
412
|
-
ledger.add("files");
|
|
413
|
-
ledger.add("files:upload");
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
if (path === "/_harbour/files" && method === "GET") {
|
|
417
|
-
ledger.add("files");
|
|
418
|
-
ledger.add("files:list");
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
if (path === "/_harbour/files/remove") {
|
|
422
|
-
ledger.add("files");
|
|
423
|
-
ledger.add("files:remove");
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
if (path === "/_harbour/telemetry")
|
|
427
|
-
ledger.add("telemetry");
|
|
428
|
-
}
|
|
429
|
-
/** The event types the gateway counts as a delivery (appgateway/realtime.go). */
|
|
430
|
-
export function isRealtimeDelivery(message) {
|
|
431
|
-
const type = readJson(message)?.type;
|
|
432
|
-
return type === "postgres_changes" || type === "broadcast" || type === "presence";
|
|
433
|
-
}
|
|
434
|
-
/**
|
|
435
|
-
* Reads complete text frames out of a server-to-client WebSocket byte stream.
|
|
436
|
-
* Server frames are never masked, so the payload is the bytes themselves; the
|
|
437
|
-
* head of the stream is the HTTP 101 response, which carries no frame.
|
|
438
|
-
*/
|
|
439
|
-
export function websocketTextFrames() {
|
|
440
|
-
let buffer = Buffer.alloc(0);
|
|
441
|
-
let started = false;
|
|
442
|
-
return chunk => {
|
|
443
|
-
buffer = Buffer.concat([buffer, chunk]);
|
|
444
|
-
if (!started) {
|
|
445
|
-
const end = buffer.indexOf("\r\n\r\n");
|
|
446
|
-
if (end < 0)
|
|
447
|
-
return [];
|
|
448
|
-
buffer = buffer.subarray(end + 4);
|
|
449
|
-
started = true;
|
|
450
|
-
}
|
|
451
|
-
const messages = [];
|
|
452
|
-
for (;;) {
|
|
453
|
-
if (buffer.length < 2)
|
|
454
|
-
break;
|
|
455
|
-
const opcode = buffer[0] & 0x0f;
|
|
456
|
-
const masked = (buffer[1] & 0x80) !== 0;
|
|
457
|
-
let length = buffer[1] & 0x7f;
|
|
458
|
-
let offset = 2;
|
|
459
|
-
if (length === 126) {
|
|
460
|
-
if (buffer.length < 4)
|
|
461
|
-
break;
|
|
462
|
-
length = buffer.readUInt16BE(2);
|
|
463
|
-
offset = 4;
|
|
464
|
-
}
|
|
465
|
-
else if (length === 127) {
|
|
466
|
-
if (buffer.length < 10)
|
|
467
|
-
break;
|
|
468
|
-
length = Number(buffer.readBigUInt64BE(2));
|
|
469
|
-
offset = 10;
|
|
470
|
-
}
|
|
471
|
-
if (masked)
|
|
472
|
-
offset += 4;
|
|
473
|
-
if (buffer.length < offset + length)
|
|
474
|
-
break;
|
|
475
|
-
if (opcode === 1 && !masked)
|
|
476
|
-
messages.push(buffer.subarray(offset, offset + length).toString("utf8"));
|
|
477
|
-
buffer = buffer.subarray(offset + length);
|
|
478
|
-
}
|
|
479
|
-
return messages;
|
|
480
|
-
};
|
|
481
|
-
}
|
|
482
|
-
function readJson(body) {
|
|
483
|
-
try {
|
|
484
|
-
const value = JSON.parse(body);
|
|
485
|
-
return value && typeof value === "object" ? value : undefined;
|
|
486
|
-
}
|
|
487
|
-
catch {
|
|
488
|
-
return undefined;
|
|
489
|
-
}
|
|
490
|
-
}
|