@volter/world-core 2.0.37 → 3.0.1
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 +4 -5
- package/app-route.cjs +12 -6
- package/app-route.d.cts +1 -1
- package/dist/app-route.cjs +12 -6
- package/dist/app-route.d.cts +1 -1
- package/dist/generated/pack-facts.json +1410 -3069
- package/dist/inject.cjs +64 -9
- package/dist/pack-facts.cjs +44 -0
- package/dist/src/actions.d.ts +3 -3
- package/dist/src/actions.js +22 -16
- package/dist/src/ancestry.d.ts +14 -2
- package/dist/src/ancestry.js +92 -2
- package/dist/src/anthropic-wire.d.ts +39 -0
- package/dist/src/anthropic-wire.js +136 -0
- package/dist/src/bytes.d.ts +7 -0
- package/dist/src/bytes.js +35 -0
- package/dist/src/changeset.d.ts +1 -1
- package/dist/src/changeset.js +0 -0
- package/dist/src/clickhouse/index.d.ts +3 -0
- package/dist/src/clickhouse/index.js +6 -0
- package/dist/src/clickhouse/sql.d.ts +233 -0
- package/dist/src/clickhouse/sql.js +4329 -0
- package/dist/src/clickhouse/types.d.ts +18 -0
- package/dist/src/clickhouse/types.js +47 -0
- package/dist/src/clickhouse/values.d.ts +146 -0
- package/dist/src/clickhouse/values.js +858 -0
- package/dist/src/client-bundle.js +2 -3
- package/dist/src/cors.d.ts +15 -0
- package/dist/src/cors.js +31 -0
- package/dist/src/derived-core.d.ts +487 -24
- package/dist/src/derived-core.js +788 -144
- package/dist/src/derived-real.d.ts +13 -0
- package/dist/src/derived-real.js +518 -0
- package/dist/src/derived.d.ts +35 -1
- package/dist/src/derived.js +61 -9
- package/dist/src/emit.js +1 -2
- package/dist/src/events.d.ts +206 -0
- package/dist/src/events.js +341 -0
- package/dist/src/executor.d.ts +3 -0
- package/dist/src/executor.js +19 -2
- package/dist/src/file-response.d.ts +6 -0
- package/dist/src/file-response.js +30 -0
- package/dist/src/fork.js +3 -2
- package/dist/src/git/history.d.ts +7 -0
- package/dist/src/git/history.js +24 -0
- package/dist/src/git/index.d.ts +1 -0
- package/dist/src/git/index.js +1 -0
- package/dist/src/git/lfs.d.ts +28 -0
- package/dist/src/git/lfs.js +66 -0
- package/dist/src/git/objects.js +3 -8
- package/dist/src/git/smart-http.d.ts +3 -1
- package/dist/src/git/smart-http.js +67 -6
- package/dist/src/graphql-wire.d.ts +29 -0
- package/dist/src/graphql-wire.js +101 -0
- package/dist/src/grpc-wire.d.ts +67 -0
- package/dist/src/grpc-wire.js +170 -0
- package/dist/src/h2.d.ts +40 -0
- package/dist/src/h2.js +656 -0
- package/dist/src/head.d.ts +32 -3
- package/dist/src/head.js +161 -40
- package/dist/src/history.d.ts +1 -1
- package/dist/src/history.js +6 -6
- package/dist/src/hpack.json +1 -0
- package/dist/src/index.d.ts +64 -75
- package/dist/src/index.js +58 -101
- package/dist/src/log.js +28 -19
- package/dist/src/machines.d.ts +50 -0
- package/dist/src/machines.js +151 -0
- package/dist/src/managed-database.d.ts +86 -0
- package/dist/src/managed-database.js +283 -0
- package/dist/src/multipart.d.ts +11 -0
- package/dist/src/multipart.js +51 -0
- package/dist/src/observe.d.ts +15 -5
- package/dist/src/observe.js +23 -9
- package/dist/src/openai-wire.d.ts +108 -0
- package/dist/src/openai-wire.js +337 -0
- package/dist/src/pack-assets.d.ts +3 -4
- package/dist/src/pack-assets.js +15 -10
- package/dist/src/pack-fetch.d.ts +77 -0
- package/dist/src/pack-fetch.js +449 -0
- package/dist/src/pack-paths.d.ts +12 -0
- package/dist/src/pack-paths.js +86 -0
- package/dist/src/packRegistry.d.ts +69 -162
- package/dist/src/packRegistry.js +55 -20
- package/dist/src/people.d.ts +13 -0
- package/dist/src/people.js +18 -0
- package/dist/src/placeholder-image.d.ts +5 -0
- package/dist/src/placeholder-image.js +114 -0
- package/dist/src/protobuf.d.ts +28 -0
- package/dist/src/protobuf.js +332 -0
- package/dist/src/redis/engine.js +1 -1
- package/dist/src/request-scope.d.ts +1 -1
- package/dist/src/request-scope.js +6 -4
- package/dist/src/resource-blob.d.ts +5 -0
- package/dist/src/resource-blob.js +11 -0
- package/dist/src/runtime.d.ts +85 -0
- package/dist/src/runtime.js +104 -0
- package/dist/src/s3/wire.d.ts +60 -0
- package/dist/src/s3/wire.js +157 -0
- package/dist/src/scenario.d.ts +3 -0
- package/dist/src/scenario.js +2 -0
- package/dist/src/schema-sample.d.ts +1 -0
- package/dist/src/schema-sample.js +21 -0
- package/dist/src/sealed-box.d.ts +14 -0
- package/dist/src/sealed-box.js +225 -0
- package/dist/src/serve-http.d.ts +14 -0
- package/dist/src/serve-http.js +27 -3
- package/dist/src/serve.d.ts +6 -0
- package/dist/src/serve.js +69 -14
- package/dist/src/signing.d.ts +135 -0
- package/dist/src/signing.js +222 -0
- package/dist/src/sigv4.d.ts +48 -0
- package/dist/src/sigv4.js +167 -0
- package/dist/src/smtp.d.ts +16 -0
- package/dist/src/smtp.js +72 -0
- package/dist/src/sockets.d.ts +51 -0
- package/dist/src/sockets.js +90 -0
- package/dist/src/state-system.d.ts +1 -0
- package/dist/src/state-system.js +1 -1
- package/dist/src/storage.d.ts +1 -1
- package/dist/src/storage.js +3 -3
- package/dist/src/trace-context.js +1 -1
- package/dist/src/twin-fetch.d.ts +0 -7
- package/dist/src/twin-fetch.js +0 -14
- package/dist/src/vendor-call.d.ts +6 -0
- package/dist/src/vendor-call.js +41 -0
- package/dist/src/world-store.js +1 -1
- package/dist/vendor-hosts.cjs +36 -125
- package/dist/vendor-hosts.d.cts +8 -0
- package/generated/pack-facts.json +1410 -3069
- package/inject.cjs +64 -9
- package/pack-facts.cjs +44 -0
- package/package.json +17 -3
- package/src/actions.ts +23 -16
- package/src/ancestry.ts +74 -2
- package/src/anthropic-wire.ts +137 -0
- package/src/bytes.ts +42 -0
- package/src/changeset.ts +5 -5
- package/src/clickhouse/index.ts +6 -0
- package/src/clickhouse/sql.ts +3059 -0
- package/src/clickhouse/types.ts +44 -0
- package/src/clickhouse/values.ts +697 -0
- package/src/client-bundle.ts +2 -3
- package/src/cors.ts +34 -0
- package/src/derived-core.ts +1013 -146
- package/src/derived-real.ts +434 -0
- package/src/derived.ts +73 -3
- package/src/emit.ts +1 -2
- package/src/events.ts +449 -0
- package/src/executor.ts +24 -2
- package/src/file-response.ts +27 -0
- package/src/fork.ts +3 -2
- package/src/git/history.ts +19 -0
- package/src/git/index.ts +1 -0
- package/src/git/lfs.ts +67 -0
- package/src/git/objects.ts +3 -5
- package/src/git/smart-http.ts +56 -6
- package/src/graphql-wire.ts +106 -0
- package/src/grpc-wire.ts +159 -0
- package/src/h2.ts +627 -0
- package/src/head.ts +132 -41
- package/src/history.ts +6 -6
- package/src/hpack.json +1 -0
- package/src/index.ts +82 -329
- package/src/log.ts +27 -18
- package/src/machines.ts +151 -0
- package/src/managed-database.ts +299 -0
- package/src/multipart.ts +51 -0
- package/src/observe.ts +31 -15
- package/src/openai-wire.ts +371 -0
- package/src/pack-assets.ts +15 -11
- package/src/pack-fetch.ts +458 -0
- package/src/pack-paths.ts +72 -0
- package/src/packRegistry.ts +79 -167
- package/src/people.ts +31 -0
- package/src/placeholder-image.ts +88 -0
- package/src/protobuf.ts +251 -0
- package/src/redis/engine.ts +1 -1
- package/src/request-scope.ts +8 -4
- package/src/resource-blob.ts +13 -0
- package/src/runtime.ts +344 -0
- package/src/s3/wire.ts +172 -0
- package/src/scenario.ts +4 -0
- package/src/schema-sample.ts +24 -0
- package/src/sealed-box.ts +182 -0
- package/src/serve-http.ts +31 -3
- package/src/serve.ts +58 -14
- package/src/signing.ts +231 -0
- package/src/sigv4.ts +158 -0
- package/src/smtp.ts +76 -0
- package/src/sockets.ts +140 -0
- package/src/state-system.ts +2 -2
- package/src/storage.ts +3 -3
- package/src/trace-context.ts +1 -1
- package/src/twin-fetch.ts +0 -20
- package/src/vendor-call.ts +41 -0
- package/src/world-store.ts +1 -1
- package/vendor-hosts.cjs +36 -125
- package/vendor-hosts.d.cts +8 -0
- package/dist/src/mirror-shell.d.ts +0 -2
- package/dist/src/mirror-shell.js +0 -13
- package/dist/src/v1-removed.d.ts +0 -159
- package/dist/src/v1-removed.js +0 -124
- package/src/mirror-shell.ts +0 -15
- package/src/v1-removed.ts +0 -172
package/src/git/lfs.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// GIT LFS (architecture, "The tree contract": Git LFS is the git library's too): the batch API git-lfs speaks
|
|
2
|
+
// (https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md, the basic transfer), a pointer file read
|
|
3
|
+
// (https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md), and an upload accepted only when its bytes hash to the
|
|
4
|
+
// object's oid and its size is the one declared. The objects are the caller's (a pack's resource blobs, by oid); the
|
|
5
|
+
// caller says where objects go up and come down (the vendor's own hosts) and decides who may do either before it calls.
|
|
6
|
+
// Importing it does nothing.
|
|
7
|
+
|
|
8
|
+
export const LFS_TYPE = 'application/vnd.git-lfs+json';
|
|
9
|
+
export type LfsObject = { oid: string; size: number };
|
|
10
|
+
/** Where a pack keeps its objects: by oid. */
|
|
11
|
+
export type LfsStore = { get(oid: string): Promise<Uint8Array | null>; put(oid: string, bytes: Uint8Array): Promise<void> };
|
|
12
|
+
|
|
13
|
+
const OID = /^[0-9a-f]{64}$/;
|
|
14
|
+
const lfsJson = (status: number, body: unknown): Response => Response.json(body, { status, headers: { 'content-type': LFS_TYPE } });
|
|
15
|
+
|
|
16
|
+
/** A pointer file's object (`version https://git-lfs.github.com/spec/v1`, `oid sha256:<hex>`, `size <n>`), or null
|
|
17
|
+
* for any other file. */
|
|
18
|
+
export function parseLfsPointer(file: Uint8Array | string): LfsObject | null {
|
|
19
|
+
const text = typeof file === 'string' ? file : file.length > 1024 ? '' : new TextDecoder().decode(file);
|
|
20
|
+
if (!text.startsWith('version https://git-lfs.github.com/spec/')) return null;
|
|
21
|
+
const oid = /^oid sha256:([0-9a-f]{64})$/m.exec(text)?.[1];
|
|
22
|
+
const size = /^size (\d+)$/m.exec(text)?.[1];
|
|
23
|
+
return oid && size ? { oid, size: Number(size) } : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A pointer file's text for an object. */
|
|
27
|
+
export const lfsPointer = (o: LfsObject): string => `version https://git-lfs.github.com/spec/v1\noid sha256:${o.oid}\nsize ${o.size}\n`;
|
|
28
|
+
|
|
29
|
+
/** An object's sha256, as its oid. */
|
|
30
|
+
export async function lfsOid(bytes: Uint8Array): Promise<string> {
|
|
31
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes as unknown as ArrayBuffer);
|
|
32
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `POST …/info/lfs/objects/batch`: for each object asked, an upload's action (none when the object is held already) or
|
|
36
|
+
* a download's (an error when it is not held), by the basic transfer. `href` names where an object goes up or comes
|
|
37
|
+
* down; `header` is sent with each action. */
|
|
38
|
+
export async function lfsBatch(body: unknown, opts: { store: LfsStore; href: (operation: 'upload' | 'download', o: LfsObject) => string; header?: Record<string, string> }): Promise<Response> {
|
|
39
|
+
const b = (body && typeof body === 'object' ? body : {}) as { operation?: unknown; transfers?: unknown; objects?: unknown };
|
|
40
|
+
// source: https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md "Should be download or upload."
|
|
41
|
+
if (b.operation !== 'upload' && b.operation !== 'download') return lfsJson(422, { message: 'operation must be download or upload' });
|
|
42
|
+
if (Array.isArray(b.transfers) && b.transfers.length && !b.transfers.includes('basic')) return lfsJson(422, { message: 'no transfer adapter in common: this server speaks basic' });
|
|
43
|
+
if (!Array.isArray(b.objects)) return lfsJson(422, { message: 'objects is required' });
|
|
44
|
+
const operation = b.operation;
|
|
45
|
+
const objects = [];
|
|
46
|
+
for (const raw of b.objects as Array<{ oid?: unknown; size?: unknown }>) {
|
|
47
|
+
const o = { oid: String(raw?.oid ?? ''), size: Number(raw?.size) };
|
|
48
|
+
// source: https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md "The object is invalid (for example, an invalid oid or size)."
|
|
49
|
+
if (!OID.test(o.oid) || !Number.isSafeInteger(o.size) || o.size < 0) { objects.push({ ...o, error: { code: 422, message: 'Invalid object' } }); continue; }
|
|
50
|
+
const held = await opts.store.get(o.oid);
|
|
51
|
+
if (operation === 'upload') {
|
|
52
|
+
objects.push(held ? { ...o, authenticated: true } : { ...o, authenticated: true, actions: { upload: { href: opts.href('upload', o), ...(opts.header ? { header: opts.header } : {}), expires_in: 3600 } } });
|
|
53
|
+
} else {
|
|
54
|
+
// source: https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md "The object does not exist on the server."
|
|
55
|
+
objects.push(held ? { oid: o.oid, size: held.length, authenticated: true, actions: { download: { href: opts.href('download', o), ...(opts.header ? { header: opts.header } : {}), expires_in: 3600 } } } : { ...o, error: { code: 404, message: 'Object does not exist' } });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return lfsJson(200, { transfer: 'basic', objects, hash_algo: 'sha256' });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A basic transfer's upload (`PUT` of the object's bytes): kept when they hash to the oid and have the declared size
|
|
62
|
+
* (the size is the request's Content-Length when the caller has no other), else refused and nothing kept. */
|
|
63
|
+
export async function lfsUpload(bytes: Uint8Array, o: LfsObject, store: LfsStore): Promise<Response> {
|
|
64
|
+
if (bytes.length !== o.size || (await lfsOid(bytes)) !== o.oid) return lfsJson(422, { message: `the object's bytes do not match ${o.oid} (${o.size} bytes)` });
|
|
65
|
+
await store.put(o.oid, bytes);
|
|
66
|
+
return new Response(null, { status: 200 });
|
|
67
|
+
}
|
package/src/git/objects.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// <size>\0<payload>`, zlib-deflated, addressed by the SHA-1 of the uncompressed form — written
|
|
3
3
|
// and read through the active BlobStore under `<prefix>/objects/<ab>/<rest>`, the exact layout
|
|
4
4
|
// a bare repository keeps. Pure JS: SHA-1 through WebCrypto, loose objects stored uncompressed in zlib framing (storeZlib),
|
|
5
|
-
// a pack deflated through the
|
|
5
|
+
// a pack deflated through the kernel's `deflate` (bytes.ts), inflate through ./inflate.ts (it reports consumed bytes, which a packfile
|
|
6
6
|
// reader needs). Bun and workerd alike; no `git` binary anywhere.
|
|
7
7
|
import { inflateZlib } from './inflate.ts';
|
|
8
|
+
import { deflate } from '../bytes.ts';
|
|
8
9
|
import type { BlobStore } from '../blob-store.ts';
|
|
9
10
|
|
|
10
11
|
export type GitObjectType = 'commit' | 'tree' | 'blob' | 'tag';
|
|
@@ -29,10 +30,7 @@ export function decodeLoose(bytes: Uint8Array): { type: GitObjectType; payload:
|
|
|
29
30
|
}
|
|
30
31
|
export async function hashObject(type: GitObjectType, payload: Uint8Array): Promise<string> { return sha1Hex(encodeLoose(type, payload)); }
|
|
31
32
|
|
|
32
|
-
export async function deflateZlib(bytes: Uint8Array): Promise<Uint8Array> {
|
|
33
|
-
const cs = new CompressionStream('deflate'); const w = cs.writable.getWriter(); void w.write(new Uint8Array(bytes) as unknown as BufferSource); void w.close();
|
|
34
|
-
return new Uint8Array(await new Response(cs.readable).arrayBuffer());
|
|
35
|
-
}
|
|
33
|
+
export async function deflateZlib(bytes: Uint8Array): Promise<Uint8Array> { return deflate(bytes); }
|
|
36
34
|
|
|
37
35
|
/** zlib with stored (uncompressed) deflate blocks: what git writes at `core.compression 0`, and what every
|
|
38
36
|
* inflater reads. A loose object is written on every commit a World makes; compressing each one through the
|
package/src/git/smart-http.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// not advertised; a client that asks for it falls back to v0 on this answer, as git specifies.
|
|
6
6
|
import { GitObjectStore, decodeCommit, decodeTag, decodeTree, type GitObjectType } from './objects.ts';
|
|
7
7
|
import { readPack, writePack } from './pack.ts';
|
|
8
|
+
import { gunzip } from '../bytes.ts';
|
|
8
9
|
import type { GitRefs } from './refs.ts';
|
|
9
10
|
|
|
10
11
|
const ENC = new TextEncoder(); const DEC = new TextDecoder();
|
|
@@ -36,7 +37,7 @@ const SHA = /^[0-9a-f]{40}$/;
|
|
|
36
37
|
function cat(...parts: Uint8Array[]): Uint8Array { const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out; }
|
|
37
38
|
|
|
38
39
|
export type Service = 'git-upload-pack' | 'git-receive-pack';
|
|
39
|
-
const CAPS: Record<Service, string> = { 'git-upload-pack': 'ofs-delta no-progress agent=volter-twin', 'git-receive-pack': 'report-status delete-refs ofs-delta agent=volter-twin' };
|
|
40
|
+
const CAPS: Record<Service, string> = { 'git-upload-pack': 'ofs-delta shallow no-progress agent=volter-twin', 'git-receive-pack': 'report-status delete-refs ofs-delta agent=volter-twin' };
|
|
40
41
|
|
|
41
42
|
/** `GET info/refs?service=<service>`: the ref advertisement, HEAD first as a symref. */
|
|
42
43
|
export function advertisement(service: Service, refs: GitRefs): Uint8Array {
|
|
@@ -67,17 +68,66 @@ async function closure(store: GitObjectStore, roots: string[], seen: Set<string>
|
|
|
67
68
|
}
|
|
68
69
|
async function reachable(store: GitObjectStore, roots: string[]): Promise<Set<string>> { const seen = new Set<string>(); await closure(store, roots, seen); return seen; }
|
|
69
70
|
|
|
70
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* A shallow request's commits (gitprotocol-pack, "Shallow requests"): every commit within `depth` of a want (a want is
|
|
73
|
+
* depth 1), and the boundary, the commits whose parents the client will not have. The client's own shallow commits it
|
|
74
|
+
* already has are boundaries it keeps unless this walk passes through them.
|
|
75
|
+
*/
|
|
76
|
+
async function deepened(store: GitObjectStore, wants: string[], depth: number): Promise<{ commits: string[]; boundary: string[] }> {
|
|
77
|
+
const at = new Map<string, number>(); const boundary = new Set<string>();
|
|
78
|
+
let frontier = wants.map((sha) => ({ sha, d: 1 }));
|
|
79
|
+
while (frontier.length) {
|
|
80
|
+
const next: Array<{ sha: string; d: number }> = [];
|
|
81
|
+
for (const { sha, d } of frontier) {
|
|
82
|
+
if ((at.get(sha) ?? Infinity) <= d) continue;
|
|
83
|
+
at.set(sha, d);
|
|
84
|
+
const obj = await store.read(sha);
|
|
85
|
+
if (obj?.type !== 'commit') continue;
|
|
86
|
+
const parents = decodeCommit(obj.payload).parents;
|
|
87
|
+
if (d >= depth) { if (parents.length) boundary.add(sha); continue; }
|
|
88
|
+
boundary.delete(sha);
|
|
89
|
+
for (const p of parents) next.push({ sha: p, d: d + 1 });
|
|
90
|
+
}
|
|
91
|
+
frontier = next;
|
|
92
|
+
}
|
|
93
|
+
return { commits: [...at.keys()], boundary: [...boundary].filter((sha) => at.get(sha)! >= depth) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `POST git-upload-pack`: wants and haves in, NAK and (once `done`) the pack out. A request with `deepen <n>` is shallow:
|
|
97
|
+
* its answer opens with the shallow-update section (`shallow <sha>` for each boundary commit, `unshallow <sha>` for a
|
|
98
|
+
* commit the client held shallow whose parents it now receives, then a flush), and its pack stops at the boundary. */
|
|
71
99
|
export async function uploadPack(body: Uint8Array, store: GitObjectStore): Promise<Uint8Array> {
|
|
72
100
|
const { lines } = readAllPktLines(body);
|
|
73
101
|
const wants = lines.filter((l) => l.startsWith('want ')).map((l) => l.slice(5, 45)).filter((s) => SHA.test(s));
|
|
74
102
|
const haves = lines.filter((l) => l.startsWith('have ')).map((l) => l.slice(5, 45)).filter((s) => SHA.test(s));
|
|
103
|
+
const clientShallow = lines.filter((l) => l.startsWith('shallow ')).map((l) => l.slice(8, 48)).filter((s) => SHA.test(s));
|
|
104
|
+
const depth = Number(/^deepen (\d+)/.exec(lines.find((l) => l.startsWith('deepen ')) ?? '')?.[1] ?? 0);
|
|
75
105
|
const done = lines.some((l) => l.trim() === 'done');
|
|
76
106
|
const known: string[] = []; for (const h of haves) if (await store.has(h)) known.push(h);
|
|
77
107
|
for (const w of wants) if (!(await store.has(w))) return pktLine(`ERR upload-pack: not our ref ${w}\n`);
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
108
|
+
const shallow = depth > 0 ? await deepened(store, wants, depth) : undefined;
|
|
109
|
+
const update = shallow
|
|
110
|
+
? cat(...shallow.boundary.filter((sha) => !clientShallow.includes(sha)).map((sha) => pktLine(`shallow ${sha}\n`)),
|
|
111
|
+
...clientShallow.filter((sha) => shallow.commits.includes(sha) && !shallow.boundary.includes(sha)).map((sha) => pktLine(`unshallow ${sha}\n`)), FLUSH)
|
|
112
|
+
: new Uint8Array();
|
|
113
|
+
// a stateless client sends its wants and `deepen` alone first and reads only the shallow section back (git's
|
|
114
|
+
// fetch-pack, find_common); every later request carries haves or `done`, and its answer repeats the section before NAK
|
|
115
|
+
if (!done && shallow && haves.length === 0) return update;
|
|
116
|
+
if (!done) return cat(update, pktLine('NAK\n'));
|
|
117
|
+
const seen = await reachable(store, known);
|
|
118
|
+
let objects: Array<{ type: GitObjectType; payload: Uint8Array; sha: string }>;
|
|
119
|
+
if (shallow) {
|
|
120
|
+
objects = [];
|
|
121
|
+
for (const sha of shallow.commits) {
|
|
122
|
+
if (seen.has(sha)) continue;
|
|
123
|
+
seen.add(sha);
|
|
124
|
+
const obj = await store.read(sha);
|
|
125
|
+
if (!obj) continue;
|
|
126
|
+
objects.push({ ...obj, sha });
|
|
127
|
+
if (obj.type === 'commit') objects.push(...await closure(store, [decodeCommit(obj.payload).tree], seen));
|
|
128
|
+
}
|
|
129
|
+
} else objects = await closure(store, wants, seen);
|
|
130
|
+
return cat(update, pktLine('NAK\n'), await writePack(objects));
|
|
81
131
|
}
|
|
82
132
|
|
|
83
133
|
export type RefCommand = { old: string; new: string; name: string; /** false when `new` does not descend from `old` (a force push); true for creates, deletes and fast-forwards. */ fastForward: boolean };
|
|
@@ -140,7 +190,7 @@ export async function serveSmartHttp(req: Request, repo: GitRepo, tailPath: stri
|
|
|
140
190
|
/** The POST half of `serveSmartHttp` for a caller that already holds the body: a push pack is
|
|
141
191
|
* the size of the repository, so re-wrapping it in a Request only to read it back is a full copy. */
|
|
142
192
|
export async function serveSmartHttpPost(body: Uint8Array, contentEncoding: string, repo: GitRepo, tailPath: 'git-upload-pack' | 'git-receive-pack'): Promise<Response> {
|
|
143
|
-
if (contentEncoding.includes('gzip')) body =
|
|
193
|
+
if (contentEncoding.includes('gzip')) body = await gunzip(body);
|
|
144
194
|
const headers = { 'content-type': `application/x-${tailPath}-result`, 'cache-control': 'no-cache' };
|
|
145
195
|
if (tailPath === 'git-upload-pack') return new Response((await uploadPack(body, repo.store)) as unknown as BodyInit, { headers });
|
|
146
196
|
const { response, applied } = await receivePack(body, repo.store, repo.refs, repo.policy);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// THE GRAPHQL WIRE — a vendor's GraphQL API served from its published schema over the pack's state
|
|
2
|
+
// (docs/contributing/architecture.md, "Other wires: lanes, headers and line protocols": GraphQL). A query is parsed and
|
|
3
|
+
// validated against the vendor's own schema, so an unknown field is the GraphQL error the vendor gives. A field resolves
|
|
4
|
+
// from the pack's resolver when it writes one (`semantics/graphql.ts`), else from the object its parent resolved: the
|
|
5
|
+
// field's own name, its snake_case form, or the pack's alias for it. A list answering a `…Connection` field is a Relay
|
|
6
|
+
// connection, and an enum resolves case-insensitively. The wire takes a query only when every field it selects is
|
|
7
|
+
// modeled; any other is answered by the manifest's `graphql.unmodeled`, naming the first field nothing models. An error
|
|
8
|
+
// a resolver throws (`GraphqlError`) carries its `type`, as GitHub's GraphQL errors do.
|
|
9
|
+
//
|
|
10
|
+
// The `graphql` protocol library is loaded the first time a GraphQL request comes (installed by the pack's package.json,
|
|
11
|
+
// as node-postgres is for the managed database): the kernel's own dependencies stay B1's, and importing the kernel
|
|
12
|
+
// loads nothing.
|
|
13
|
+
|
|
14
|
+
/** A resolver of one field, `Type.field`: its parent object, its arguments, and the request's handler context. */
|
|
15
|
+
export type GraphqlResolver<C = unknown> = (source: Record<string, unknown>, args: Record<string, unknown>, ctx: C) => unknown;
|
|
16
|
+
|
|
17
|
+
/** What a pack's `semantics/graphql.ts` exports as `graphql`. */
|
|
18
|
+
export type GraphqlPart<C = unknown> = {
|
|
19
|
+
/** resolvers by `Type.field` (`Query.repository`, `PullRequest.state`) */
|
|
20
|
+
resolvers: Record<string, GraphqlResolver<C>>;
|
|
21
|
+
/** per type, a GraphQL field name → the object's field that holds it (`nameWithOwner` → `full_name`) */
|
|
22
|
+
aliases?: Record<string, Record<string, string>>;
|
|
23
|
+
/** per type, the object's fields the default resolver may read as they are */
|
|
24
|
+
stored?: Record<string, string[]>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** An error a resolver answers, with the vendor's `type` for it (GitHub's NOT_FOUND, UNPROCESSABLE). */
|
|
28
|
+
export class GraphqlError extends Error {
|
|
29
|
+
constructor(message: string, readonly type?: string) { super(message); this.name = 'GraphqlError'; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type GraphqlLib = typeof import('graphql');
|
|
33
|
+
let lib: Promise<GraphqlLib> | undefined;
|
|
34
|
+
const graphqlLib = (): Promise<GraphqlLib> => (lib ??= import('graphql') as Promise<GraphqlLib>);
|
|
35
|
+
|
|
36
|
+
const snake = (s: string): string => s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
37
|
+
|
|
38
|
+
/** A list as a Relay connection, paged by `first`/`last` and `after`. */
|
|
39
|
+
function connection(items: unknown[], args: Record<string, unknown>): Record<string, unknown> {
|
|
40
|
+
const first = typeof args.first === 'number' ? args.first : undefined;
|
|
41
|
+
const last = typeof args.last === 'number' ? args.last : undefined;
|
|
42
|
+
const after = typeof args.after === 'string' ? Number(atob(args.after)) + 1 : 0;
|
|
43
|
+
let slice = items.slice(after);
|
|
44
|
+
if (first !== undefined) slice = slice.slice(0, first);
|
|
45
|
+
if (last !== undefined) slice = slice.slice(-last);
|
|
46
|
+
const start = after + (last !== undefined ? Math.max(0, items.slice(after).length - last) : 0);
|
|
47
|
+
const cursor = (i: number): string => btoa(String(start + i));
|
|
48
|
+
return {
|
|
49
|
+
totalCount: items.length, nodes: slice, edges: slice.map((node, i) => ({ node, cursor: cursor(i) })),
|
|
50
|
+
pageInfo: { hasNextPage: start + slice.length < items.length, hasPreviousPage: start > 0, startCursor: slice.length ? cursor(0) : null, endCursor: slice.length ? cursor(slice.length - 1) : null },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A GraphQL endpoint over a schema and a pack's part: answers a request's `{query, variables, operationName}`. The
|
|
55
|
+
* context each resolver receives is made per request (the handler context). `unmodeled` answers a query selecting a
|
|
56
|
+
* field nothing models; `mutationRefused`, when given, a mutation the World may not make (a read-only twin). */
|
|
57
|
+
export function graphqlEndpoint<C>(options: {
|
|
58
|
+
sdl: string;
|
|
59
|
+
part: GraphqlPart<C>;
|
|
60
|
+
context: (request: Request) => Promise<C>;
|
|
61
|
+
unmodeled: (field: { type: string; field: string }) => Response;
|
|
62
|
+
mutationRefused?: (request: Request) => Response | undefined;
|
|
63
|
+
}): (request: Request) => Promise<Response> {
|
|
64
|
+
let schema: import('graphql').GraphQLSchema | undefined;
|
|
65
|
+
const { resolvers, aliases, stored } = options.part;
|
|
66
|
+
const modeled = (type: string, field: string): boolean =>
|
|
67
|
+
field === '__typename' || `${type}.${field}` in resolvers || !!aliases?.[type]?.[field] || !!stored?.[type]?.includes(field) || !!stored?.[type]?.includes(snake(field));
|
|
68
|
+
return async (request) => {
|
|
69
|
+
const g = await graphqlLib();
|
|
70
|
+
schema ??= g.buildSchema(options.sdl, { assumeValidSDL: true });
|
|
71
|
+
const payload = (await request.clone().json().catch(() => ({}))) as { query?: string; variables?: Record<string, unknown>; operationName?: string };
|
|
72
|
+
let document: import('graphql').DocumentNode;
|
|
73
|
+
try { document = g.parse(payload.query ?? ''); } catch (error) { return Response.json({ errors: [{ message: error instanceof Error ? error.message : String(error) }] }); }
|
|
74
|
+
const invalid = g.validate(schema, document);
|
|
75
|
+
if (invalid.length) return Response.json({ errors: invalid.map((e) => ({ message: e.message, locations: e.locations })) });
|
|
76
|
+
const mutation = document.definitions.some((d) => d.kind === 'OperationDefinition' && d.operation === 'mutation');
|
|
77
|
+
if (mutation) { const refused = options.mutationRefused?.(request); if (refused) return refused; }
|
|
78
|
+
const typeInfo = new g.TypeInfo(schema);
|
|
79
|
+
let unmodeled: { type: string; field: string } | undefined;
|
|
80
|
+
g.visit(document, g.visitWithTypeInfo(typeInfo, {
|
|
81
|
+
Field(node) {
|
|
82
|
+
const parent = typeInfo.getParentType();
|
|
83
|
+
if (!unmodeled && parent && g.isObjectType(parent) && !parent.name.endsWith('Connection') && !parent.name.endsWith('Edge') && parent.name !== 'PageInfo' && !modeled(parent.name, node.name.value)) unmodeled = { type: parent.name, field: node.name.value };
|
|
84
|
+
},
|
|
85
|
+
}));
|
|
86
|
+
if (unmodeled) return options.unmodeled(unmodeled);
|
|
87
|
+
const ctx = await options.context(request);
|
|
88
|
+
const result = await g.execute({
|
|
89
|
+
schema, document, variableValues: payload.variables ?? {}, ...(payload.operationName ? { operationName: payload.operationName } : {}), contextValue: ctx,
|
|
90
|
+
fieldResolver: (source, args, context, info) => {
|
|
91
|
+
const own = resolvers[`${info.parentType.name}.${info.fieldName}`];
|
|
92
|
+
const obj = (source ?? {}) as Record<string, unknown>;
|
|
93
|
+
let value = own ? own(obj, args as Record<string, unknown>, context as C) : (obj[aliases?.[info.parentType.name]?.[info.fieldName] ?? info.fieldName] ?? obj[snake(info.fieldName)] ?? null);
|
|
94
|
+
const named = g.getNamedType(info.returnType);
|
|
95
|
+
if (Array.isArray(value) && g.isObjectType(named) && named.name.endsWith('Connection')) value = connection(value, args as Record<string, unknown>);
|
|
96
|
+
if (typeof value === 'string' && g.isEnumType(named)) value = named.getValues().find((v) => v.name.toLowerCase() === String(value).toLowerCase())?.name ?? value;
|
|
97
|
+
return value;
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const errors = (result.errors ?? []).map((e) => {
|
|
101
|
+
const type = e.originalError instanceof GraphqlError ? e.originalError.type : typeof e.extensions?.type === 'string' ? e.extensions.type : undefined;
|
|
102
|
+
return { ...(type ? { type } : {}), message: e.message, ...(e.path ? { path: e.path } : {}), ...(e.locations ? { locations: e.locations } : {}) };
|
|
103
|
+
});
|
|
104
|
+
return Response.json({ ...(result.data !== undefined ? { data: result.data } : {}), ...(errors.length ? { errors } : {}) });
|
|
105
|
+
};
|
|
106
|
+
}
|
package/src/grpc-wire.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// THE gRPC WIRE (architecture, "Other wires": gRPC): unary gRPC calls over the kernel's HTTP/2 server (./h2.ts), each
|
|
2
|
+
// request's one length-prefixed message decoded by the proto unit's schema (./protobuf.ts) and handed to the pack's
|
|
3
|
+
// handler for its operation, whose JSON answer is encoded back with the status trailers
|
|
4
|
+
// (https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md). After the older catalog's temporal pack's
|
|
5
|
+
// `temporal-grpc.ts`, over this repository's codec rather than protobufjs. Everything here is framing: which rpcs exist
|
|
6
|
+
// and what they answer is the pack's. Importing it does nothing.
|
|
7
|
+
import { acceptH2Connection, type H2Handler } from './h2.ts';
|
|
8
|
+
import { nodeBuiltin, WORLD_BOOT_PATH } from './serve-http.ts';
|
|
9
|
+
import { decodeMessage, encodeMessage, type ProtoSchema } from './protobuf.ts';
|
|
10
|
+
|
|
11
|
+
/** gRPC's status codes (https://grpc.github.io/grpc/core/md_doc_statuscodes.html). */
|
|
12
|
+
export const GrpcStatus = {
|
|
13
|
+
OK: 0, CANCELLED: 1, UNKNOWN: 2, INVALID_ARGUMENT: 3, DEADLINE_EXCEEDED: 4, NOT_FOUND: 5, ALREADY_EXISTS: 6,
|
|
14
|
+
PERMISSION_DENIED: 7, RESOURCE_EXHAUSTED: 8, FAILED_PRECONDITION: 9, ABORTED: 10, OUT_OF_RANGE: 11, UNIMPLEMENTED: 12,
|
|
15
|
+
INTERNAL: 13, UNAVAILABLE: 14, DATA_LOSS: 15, UNAUTHENTICATED: 16,
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
/** A call answered with a gRPC status other than OK: its code and its message. */
|
|
19
|
+
export class GrpcError extends Error {
|
|
20
|
+
constructor(readonly code: number, message: string) { super(message); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type GrpcCall = {
|
|
24
|
+
/** the operation the rpc is (the derive's name for it) and its path */
|
|
25
|
+
operation: string; path: string;
|
|
26
|
+
/** the request message, in its proto3 JSON form */
|
|
27
|
+
request: Record<string, unknown>;
|
|
28
|
+
/** the call's metadata (its non-pseudo headers: `authorization`, `x-goog-api-client`, …) */
|
|
29
|
+
metadata: Record<string, string>;
|
|
30
|
+
/** aborted when the client cancels or its deadline passes */
|
|
31
|
+
signal: AbortSignal;
|
|
32
|
+
};
|
|
33
|
+
/** Answers one unary call with its response message's JSON form, or throws a GrpcError. */
|
|
34
|
+
export type GrpcHandler = (call: GrpcCall) => Promise<Record<string, unknown>>;
|
|
35
|
+
|
|
36
|
+
/** `grpc-timeout`: an integer and a unit (H M S m u n), PROTOCOL-HTTP2.md "Timeout". */
|
|
37
|
+
export function parseGrpcTimeout(value: string | undefined): number | undefined {
|
|
38
|
+
const match = /^(\d{1,8})([HMSmun])$/.exec(value ?? '');
|
|
39
|
+
if (!match) return undefined;
|
|
40
|
+
return Number(match[1]) * { H: 3_600_000, M: 60_000, S: 1000, m: 1, u: 0.001, n: 0.000001 }[match[2] as 'H'];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Length-prefixed messages out of a request body: [compressed flag][u32 length][bytes]… */
|
|
44
|
+
export function splitGrpcFrames(body: Uint8Array): Array<{ compressed: boolean; message: Uint8Array }> {
|
|
45
|
+
const frames: Array<{ compressed: boolean; message: Uint8Array }> = [];
|
|
46
|
+
let offset = 0;
|
|
47
|
+
while (offset < body.length) {
|
|
48
|
+
if (body.length - offset < 5) throw new GrpcError(GrpcStatus.INTERNAL, 'truncated gRPC frame header');
|
|
49
|
+
const length = new DataView(body.buffer, body.byteOffset + offset, 5).getUint32(1);
|
|
50
|
+
if (body.length - offset - 5 < length) throw new GrpcError(GrpcStatus.INTERNAL, 'truncated gRPC message');
|
|
51
|
+
frames.push({ compressed: body[offset] === 1, message: body.subarray(offset + 5, offset + 5 + length) });
|
|
52
|
+
offset += 5 + length;
|
|
53
|
+
}
|
|
54
|
+
return frames;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function grpcFrame(message: Uint8Array): Uint8Array {
|
|
58
|
+
const out = new Uint8Array(5 + message.length);
|
|
59
|
+
new DataView(out.buffer).setUint32(1, message.length);
|
|
60
|
+
out.set(message, 5);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** grpc-message is percent-encoded (PROTOCOL-HTTP2.md "Status-Message"). */
|
|
65
|
+
export function encodeGrpcMessage(text: string): string {
|
|
66
|
+
return Array.from(new TextEncoder().encode(text), (b) => (b >= 0x20 && b <= 0x7e && b !== 0x25 ? String.fromCharCode(b) : `%${b.toString(16).toUpperCase().padStart(2, '0')}`)).join('');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The HTTP/2 stream handler that answers the unary rpcs of a proto unit's schema through `handler`. */
|
|
70
|
+
export function grpcStreamHandler(schema: ProtoSchema, handler: GrpcHandler): H2Handler {
|
|
71
|
+
return (request, response) => {
|
|
72
|
+
const headers = request.headers;
|
|
73
|
+
const fail = (code: number, message: string): void => {
|
|
74
|
+
if (response.closed) return;
|
|
75
|
+
response.headers([[':status', '200'], ['content-type', 'application/grpc'], ['grpc-status', String(code)], ['grpc-message', encodeGrpcMessage(message)]], true);
|
|
76
|
+
};
|
|
77
|
+
if (headers[':method'] !== 'POST' || !(headers['content-type'] ?? '').startsWith('application/grpc')) {
|
|
78
|
+
response.headers([[':status', '415'], ['content-type', 'text/plain']], true);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const path = headers[':path'] ?? '';
|
|
82
|
+
const rpc = schema.rpcs[path];
|
|
83
|
+
if (!rpc) { fail(GrpcStatus.UNIMPLEMENTED, `unknown method ${path}`); return; }
|
|
84
|
+
if (rpc.clientStreams || rpc.serverStreams) { fail(GrpcStatus.UNIMPLEMENTED, `${path} streams; this wire answers unary calls`); return; }
|
|
85
|
+
const timeout = parseGrpcTimeout(headers['grpc-timeout']);
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
// a call ends once: its deadline passing answers DEADLINE_EXCEEDED, and the client going away (its stream reset)
|
|
88
|
+
// is CANCELLED, which no one hears; the handler's later answer, either way, is dropped
|
|
89
|
+
// (https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md "Timeout", statuscodes.md "CANCELLED")
|
|
90
|
+
let ended: number | undefined;
|
|
91
|
+
const end = (code: number): void => { if (ended !== undefined) return; ended = code; controller.abort(); if (code === GrpcStatus.DEADLINE_EXCEEDED) fail(code, 'Deadline Exceeded'); };
|
|
92
|
+
const timer = timeout !== undefined ? setTimeout(() => end(GrpcStatus.DEADLINE_EXCEEDED), timeout) : undefined;
|
|
93
|
+
request.signal.addEventListener('abort', () => end(GrpcStatus.CANCELLED), { once: true });
|
|
94
|
+
const metadata = Object.fromEntries(Object.entries(headers).filter(([k]) => !k.startsWith(':')));
|
|
95
|
+
let decoded: Record<string, unknown>;
|
|
96
|
+
try {
|
|
97
|
+
const frames = splitGrpcFrames(request.body);
|
|
98
|
+
if (frames.length !== 1) throw new GrpcError(GrpcStatus.INTERNAL, `a unary call carries exactly one message (got ${frames.length})`);
|
|
99
|
+
if (frames[0]!.compressed) throw new GrpcError(GrpcStatus.UNIMPLEMENTED, 'compressed messages are not accepted (no grpc-encoding is advertised)');
|
|
100
|
+
decoded = decodeMessage(schema, rpc.request, frames[0]!.message) as Record<string, unknown>;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (timer) clearTimeout(timer);
|
|
103
|
+
fail(error instanceof GrpcError ? error.code : GrpcStatus.INTERNAL, (error as Error).message);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
void (async () => {
|
|
107
|
+
try {
|
|
108
|
+
const answer = await handler({ operation: rpc.operation, path, request: decoded, metadata, signal: controller.signal });
|
|
109
|
+
if (ended !== undefined || response.closed) return;
|
|
110
|
+
response.headers([[':status', '200'], ['content-type', 'application/grpc']]);
|
|
111
|
+
response.data(grpcFrame(encodeMessage(schema, rpc.response, answer)));
|
|
112
|
+
response.trailers([['grpc-status', '0']]);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (ended !== undefined) return;
|
|
115
|
+
if (error instanceof GrpcError) fail(error.code, error.message);
|
|
116
|
+
else fail(GrpcStatus.INTERNAL, `internal error: ${(error as Error)?.message ?? String(error)}`);
|
|
117
|
+
} finally {
|
|
118
|
+
if (timer) clearTimeout(timer);
|
|
119
|
+
}
|
|
120
|
+
})();
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A gRPC code for the HTTP status a pack's fetch answered: Google's REST error names it (`error.status`), else the
|
|
125
|
+
* status's customary code (https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto, "HTTP Mapping"). */
|
|
126
|
+
function codeOf(status: number, body: unknown): number {
|
|
127
|
+
const named = (body as { error?: { status?: unknown } } | undefined)?.error?.status;
|
|
128
|
+
if (typeof named === 'string' && named in GrpcStatus) return GrpcStatus[named as keyof typeof GrpcStatus];
|
|
129
|
+
return ({ 400: GrpcStatus.INVALID_ARGUMENT, 401: GrpcStatus.UNAUTHENTICATED, 403: GrpcStatus.PERMISSION_DENIED, 404: GrpcStatus.NOT_FOUND, 409: GrpcStatus.ABORTED, 429: GrpcStatus.RESOURCE_EXHAUSTED, 499: GrpcStatus.CANCELLED, 501: GrpcStatus.UNIMPLEMENTED, 503: GrpcStatus.UNAVAILABLE, 504: GrpcStatus.DEADLINE_EXCEEDED } as Record<number, number>)[status] ?? (status >= 500 ? GrpcStatus.INTERNAL : GrpcStatus.UNKNOWN);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A gRPC call answered by the pack's own fetch, as the derive serves a proto unit's rpc (`POST /<package>.<Service>/
|
|
133
|
+
* <Method>`, the request message's JSON form the body): its JSON answer is the response message; any other status its
|
|
134
|
+
* gRPC code, the error's message as the status message. */
|
|
135
|
+
export function grpcOverFetch(fetch: (request: Request) => Promise<Response>): GrpcHandler {
|
|
136
|
+
return async (call) => {
|
|
137
|
+
const res = await fetch(new Request(`http://twin${call.path}`, { method: 'POST', headers: { ...call.metadata, 'content-type': 'application/json' }, body: JSON.stringify(call.request), signal: call.signal }));
|
|
138
|
+
const text = await res.text();
|
|
139
|
+
let body: unknown;
|
|
140
|
+
try { body = text ? JSON.parse(text) : {}; } catch { body = undefined; }
|
|
141
|
+
if (res.ok) return (body ?? {}) as Record<string, unknown>;
|
|
142
|
+
const message = (body as { error?: { message?: unknown }; message?: unknown } | undefined)?.error?.message ?? (body as { message?: unknown } | undefined)?.message ?? text;
|
|
143
|
+
throw new GrpcError(codeOf(res.status, body), String(message).slice(0, 500));
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A pack that speaks gRPC, served on one port: HTTP/2 (gRPC, by the schema through the pack's fetch) and HTTP/1 (the
|
|
148
|
+
* pack's fetch: its doors, its REST) on the same socket, as the kernel's HTTP/2 server tells them apart. Inside a World
|
|
149
|
+
* it answers the World's boot probe as `serveHttp` does. */
|
|
150
|
+
export async function serveGrpc(options: { port?: number; fetch: (request: Request) => Promise<Response>; schema: ProtoSchema }): Promise<{ port: number; stop: () => void }> {
|
|
151
|
+
const net = nodeBuiltin<typeof import('node:net')>('node:net');
|
|
152
|
+
const worldBoot = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.VOLTER_WORLD_BOOT_ID;
|
|
153
|
+
const fetch = worldBoot ? (request: Request) => (new URL(request.url).pathname === WORLD_BOOT_PATH ? Promise.resolve(Response.json({ boot: worldBoot })) : options.fetch(request)) : options.fetch;
|
|
154
|
+
const handler = grpcStreamHandler(options.schema, grpcOverFetch(fetch));
|
|
155
|
+
const server = net.createServer((socket) => { acceptH2Connection(socket, handler, fetch); });
|
|
156
|
+
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(options.port ?? 0, '127.0.0.1', () => resolve()); });
|
|
157
|
+
const address = server.address();
|
|
158
|
+
return { port: typeof address === 'object' && address ? address.port : 0, stop: () => { server.close(); } };
|
|
159
|
+
}
|