@fungi.computer/oyster 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +1082 -2
- package/dist/agents.d.ts +114 -0
- package/dist/agents.js +322 -0
- package/dist/app-builds.d.ts +151 -0
- package/dist/app-builds.js +355 -0
- package/dist/app-sandbox.d.ts +2 -0
- package/dist/app-sandbox.js +2 -0
- package/dist/app-sources.d.ts +46 -0
- package/dist/app-sources.js +335 -0
- package/dist/app-storage.d.ts +141 -0
- package/dist/app-storage.js +281 -0
- package/dist/apps/gate.d.ts +105 -0
- package/dist/apps/gate.js +273 -0
- package/dist/apps/index.d.ts +5 -0
- package/dist/apps/index.js +5 -0
- package/dist/apps/installation.d.ts +248 -0
- package/dist/apps/installation.js +307 -0
- package/dist/apps/operations.d.ts +8 -0
- package/dist/apps/operations.js +98 -0
- package/dist/apps/tools.d.ts +33 -0
- package/dist/apps/tools.js +17 -0
- package/dist/auth.d.ts +11 -0
- package/dist/auth.js +93 -0
- package/dist/backend-request.d.ts +3 -0
- package/dist/backend-request.js +53 -0
- package/dist/build/build-jobs.d.ts +128 -0
- package/dist/build/build-jobs.js +197 -0
- package/dist/build/build-step.d.ts +26 -0
- package/dist/build/build-step.js +20 -0
- package/dist/build/bundled-html.d.ts +7 -0
- package/dist/build/bundled-html.js +64 -0
- package/dist/build/definition-emission.d.ts +34 -0
- package/dist/build/definition-emission.js +201 -0
- package/dist/build/effect.d.ts +231 -0
- package/dist/build/effect.js +396 -0
- package/dist/build/git-environment.d.ts +6 -0
- package/dist/build/git-environment.js +48 -0
- package/dist/build/html-recipe.d.ts +20 -0
- package/dist/build/html-recipe.js +51 -0
- package/dist/build/local-build.d.ts +57 -0
- package/dist/build/local-build.js +1078 -0
- package/dist/build/r2-artifact-writer.d.ts +31 -0
- package/dist/build/r2-artifact-writer.js +179 -0
- package/dist/build/snapshot-package-store.d.ts +2 -0
- package/dist/build/snapshot-package-store.js +21 -0
- package/dist/build/snapshot-program-scripts.d.ts +6 -0
- package/dist/build/snapshot-program-scripts.js +2 -0
- package/dist/build/submission-contract.d.ts +384 -0
- package/dist/build/submission-contract.js +70 -0
- package/dist/build/workspace-build.d.ts +50 -0
- package/dist/build/workspace-build.js +156 -0
- package/dist/build-contract.d.ts +4 -0
- package/dist/build-contract.js +4 -0
- package/dist/build-jobs.d.ts +2 -0
- package/dist/build-jobs.js +2 -0
- package/dist/build.d.ts +11 -0
- package/dist/build.js +13 -0
- package/dist/capability-descriptor.d.ts +46 -0
- package/dist/capability-descriptor.js +51 -0
- package/dist/catalog-frame.d.ts +39 -0
- package/dist/catalog-frame.js +72 -0
- package/dist/cause-class.d.ts +19 -0
- package/dist/cause-class.js +54 -0
- package/dist/cloudflare.d.ts +123 -0
- package/dist/cloudflare.js +391 -0
- package/dist/computer-terminal.d.ts +38 -0
- package/dist/computer-terminal.js +410 -0
- package/dist/computer.d.ts +60 -0
- package/dist/computer.js +33 -0
- package/dist/event-recipients.d.ts +57 -0
- package/dist/event-recipients.js +144 -0
- package/dist/facet-invocation.d.ts +4 -0
- package/dist/facet-invocation.js +45 -0
- package/dist/files.d.ts +42 -0
- package/dist/files.js +123 -0
- package/dist/frame-binding-wire-script.d.ts +2 -0
- package/dist/frame-binding-wire-script.js +1 -0
- package/dist/frame-binding-wire.d.ts +28 -0
- package/dist/frame-binding-wire.js +54 -0
- package/dist/git-admission.d.ts +52 -0
- package/dist/git-admission.js +188 -0
- package/dist/git-auth.d.ts +11 -0
- package/dist/git-auth.js +27 -0
- package/dist/git-door.d.ts +35 -0
- package/dist/git-door.js +108 -0
- package/dist/git-journal.d.ts +31 -0
- package/dist/git-journal.js +188 -0
- package/dist/git.d.ts +31 -0
- package/dist/git.js +64 -0
- package/dist/guest-policy.d.ts +9 -0
- package/dist/guest-policy.js +13 -0
- package/dist/host.d.ts +244 -0
- package/dist/host.js +1043 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +320 -0
- package/dist/installation-consent.d.ts +79 -0
- package/dist/installation-consent.js +82 -0
- package/dist/internal/background-origins.d.ts +3 -0
- package/dist/internal/background-origins.js +8 -0
- package/dist/internal/delivery.d.ts +12 -0
- package/dist/internal/delivery.js +167 -0
- package/dist/internal/host-gate.d.ts +14 -0
- package/dist/internal/host-gate.js +62 -0
- package/dist/internal/registry-installations.d.ts +6 -0
- package/dist/internal/registry-installations.js +12 -0
- package/dist/invocation.d.ts +251 -0
- package/dist/invocation.js +377 -0
- package/dist/keys.d.ts +48 -0
- package/dist/keys.js +52 -0
- package/dist/mcp-owner.d.ts +27 -0
- package/dist/mcp-owner.js +143 -0
- package/dist/mcp.d.ts +89 -0
- package/dist/mcp.js +429 -0
- package/dist/media.d.ts +23 -0
- package/dist/media.js +12 -0
- package/dist/pack.d.ts +76 -0
- package/dist/pack.js +134 -0
- package/dist/page/capabilities.d.ts +23 -0
- package/dist/page/capabilities.js +256 -0
- package/dist/page/computer-handles.d.ts +79 -0
- package/dist/page/computer-handles.js +44 -0
- package/dist/page/lifetime.d.ts +6 -0
- package/dist/page/lifetime.js +19 -0
- package/dist/page/mount.d.ts +163 -0
- package/dist/page/mount.js +390 -0
- package/dist/page/storage.d.ts +3 -0
- package/dist/page/storage.js +82 -0
- package/dist/page/terminal.d.ts +5 -0
- package/dist/page/terminal.js +7 -0
- package/dist/page-script.d.ts +14 -0
- package/dist/page-script.js +5 -0
- package/dist/r2-files.d.ts +3 -0
- package/dist/r2-files.js +79 -0
- package/dist/release.d.ts +72 -0
- package/dist/release.js +164 -0
- package/dist/root-data.d.ts +86 -0
- package/dist/root-data.js +69 -0
- package/dist/shell/app-directory.d.ts +537 -0
- package/dist/shell/app-directory.js +195 -0
- package/dist/shell/browser-actions.d.ts +7 -0
- package/dist/shell/browser-actions.js +56 -0
- package/dist/shell/command-host.d.ts +112 -0
- package/dist/shell/command-host.js +502 -0
- package/dist/shell/commands.d.ts +25 -0
- package/dist/shell/commands.js +78 -0
- package/dist/shell/index.d.ts +8 -0
- package/dist/shell/index.js +8 -0
- package/dist/shell/keys.d.ts +41 -0
- package/dist/shell/keys.js +134 -0
- package/dist/shell/shell-module.d.ts +31 -0
- package/dist/shell/shell-module.js +37 -0
- package/dist/shell/window-manager.d.ts +124 -0
- package/dist/shell/window-manager.js +711 -0
- package/dist/shell/workbench-contract.d.ts +68 -0
- package/dist/shell/workbench-contract.js +1 -0
- package/dist/shell/workbench-host-types.d.ts +96 -0
- package/dist/shell/workbench-host-types.js +1 -0
- package/dist/shell/workbench-host.d.ts +34 -0
- package/dist/shell/workbench-host.js +347 -0
- package/dist/shell/workbench-model.d.ts +192 -0
- package/dist/shell/workbench-model.js +53 -0
- package/dist/shell/workbench.d.ts +4 -0
- package/dist/shell/workbench.js +2 -0
- package/dist/source/fork.d.ts +27 -0
- package/dist/source/fork.js +46 -0
- package/dist/source/index.d.ts +11 -0
- package/dist/source/index.js +11 -0
- package/dist/source/seed-receipt.d.ts +53 -0
- package/dist/source/seed-receipt.js +18 -0
- package/dist/source/team-source.d.ts +32 -0
- package/dist/source/team-source.js +42 -0
- package/dist/source/template-repository.d.ts +2 -0
- package/dist/source/template-repository.js +8 -0
- package/dist/source/template-write.d.ts +69 -0
- package/dist/source/template-write.js +344 -0
- package/dist/storage-frame.d.ts +64 -0
- package/dist/storage-frame.js +28 -0
- package/dist/tenant.d.ts +81 -0
- package/dist/tenant.js +71 -0
- package/dist/testing/capabilities.d.ts +29 -0
- package/dist/testing/capabilities.js +130 -0
- package/dist/testing/catalog.d.ts +3 -0
- package/dist/testing/catalog.js +53 -0
- package/dist/testing/index.d.ts +10 -0
- package/dist/testing/index.js +10 -0
- package/dist/testing/installation.d.ts +11 -0
- package/dist/testing/installation.js +25 -0
- package/dist/testing/native-worker.d.ts +21 -0
- package/dist/testing/native-worker.js +106 -0
- package/dist/testing/worker.d.ts +18 -0
- package/dist/testing/worker.js +67 -0
- package/dist/testing/workers-runtime.d.ts +24 -0
- package/dist/testing/workers-runtime.js +33 -0
- package/dist/webhook-delivery.d.ts +14 -0
- package/dist/webhook-delivery.js +17 -0
- package/dist/workers-git.d.ts +285 -0
- package/dist/workers-git.js +1174 -0
- package/dist/workers.d.ts +352 -0
- package/dist/workers.js +866 -0
- package/docs/api/README.md +82 -0
- package/docs/api/agents.md +332 -0
- package/docs/api/app-storage.md +209 -0
- package/docs/api/apps.md +1375 -0
- package/docs/api/auth.md +63 -0
- package/docs/api/build-contract.md +507 -0
- package/docs/api/build.md +1476 -0
- package/docs/api/cloudflare.md +421 -0
- package/docs/api/computer.md +203 -0
- package/docs/api/files.md +117 -0
- package/docs/api/git.md +470 -0
- package/docs/api/host.md +1328 -0
- package/docs/api/index.md +430 -0
- package/docs/api/invocation.md +738 -0
- package/docs/api/keys.md +175 -0
- package/docs/api/mcp.md +301 -0
- package/docs/api/media.md +40 -0
- package/docs/api/modules.md +42 -0
- package/docs/api/pack.md +138 -0
- package/docs/api/page/mount.md +298 -0
- package/docs/api/root-data.md +111 -0
- package/docs/api/shell/app-directory.md +332 -0
- package/docs/api/shell/command-host.md +224 -0
- package/docs/api/shell/commands.md +197 -0
- package/docs/api/shell/keys.md +183 -0
- package/docs/api/shell/shell-module.md +143 -0
- package/docs/api/shell/window-manager.md +267 -0
- package/docs/api/shell/workbench.md +186 -0
- package/docs/api/shell.md +1788 -0
- package/docs/api/tenant.md +194 -0
- package/docs/api/workers-git.md +1527 -0
- package/docs/api/workers.md +1678 -0
- package/docs/media/architecture.mmd +32 -0
- package/docs/media/architecture.png +0 -0
- package/docs/media/bonsai-network.png +0 -0
- package/docs/media/bonsai-repository.png +0 -0
- package/docs/site/agent-quickstart.mdx +349 -0
- package/examples/git/worker.ts +39 -0
- package/examples/git/wrangler.jsonc +32 -0
- package/examples/standalone/worker.ts +14 -0
- package/examples/standalone/wrangler.jsonc +21 -0
- package/package.json +249 -3
package/dist/pack.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile, } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join, relative, resolve } from "node:path";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Schema from "effect/Schema";
|
|
6
|
+
import { releaseSchema } from "@fungi.computer/puffball/effect";
|
|
7
|
+
import { parseArtifactRef, parseReleaseId, parseSha256Digest, parseSourceRevision, } from "@fungi.computer/puffball";
|
|
8
|
+
import { commitPreparedCandidate, createAppCandidatePreparer, createArtifactsSourceCapture, createFirstPartyAppSourceDistribution, createLocalArtifactWriter, createLocalBuildExecutor, createLocalCommitSourceCapture, } from "./build.js";
|
|
9
|
+
import { templateWriteInputSchema } from "./source/template-write.js";
|
|
10
|
+
import { sha256 } from "./release.js";
|
|
11
|
+
async function filesUnder(directory) {
|
|
12
|
+
const entries = await readdir(directory, {
|
|
13
|
+
recursive: true,
|
|
14
|
+
withFileTypes: true,
|
|
15
|
+
});
|
|
16
|
+
return entries
|
|
17
|
+
.filter((entry) => entry.isFile())
|
|
18
|
+
.map((entry) => join(entry.parentPath, entry.name))
|
|
19
|
+
.sort();
|
|
20
|
+
}
|
|
21
|
+
async function packCapturedSource(options, sourceCapture) {
|
|
22
|
+
const revision = parseSourceRevision(options.sourceRevision);
|
|
23
|
+
const out = resolve(options.outDirectory);
|
|
24
|
+
const artifactsDirectory = join(out, "artifacts");
|
|
25
|
+
const preparer = createAppCandidatePreparer({
|
|
26
|
+
config: { appId: options.appId, recipe: options.recipe },
|
|
27
|
+
sourceCapture,
|
|
28
|
+
buildExecutor: createLocalBuildExecutor({
|
|
29
|
+
packageStoreDirectory: options.packageStoreDirectory,
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
const prepared = await Effect.runPromise(Effect.scoped(preparer.prepareCandidate(parseReleaseId(options.releaseId ??
|
|
33
|
+
`rel_${revision.slice("git:".length, "git:".length + 12)}`))));
|
|
34
|
+
const release = await Effect.runPromise(commitPreparedCandidate(prepared, {
|
|
35
|
+
artifactWriter: createLocalArtifactWriter(artifactsDirectory, options.recipe.assets),
|
|
36
|
+
releaseCatalog: { createRelease: (value) => Promise.resolve(value) },
|
|
37
|
+
}));
|
|
38
|
+
await mkdir(out, { recursive: true });
|
|
39
|
+
await writeFile(join(out, "release.json"), `${Schema.encodeUnknownSync(Schema.fromJsonString(releaseSchema))(release)}\n`);
|
|
40
|
+
const artifacts = (await filesUnder(artifactsDirectory)).map((path) => ({
|
|
41
|
+
artifactRef: parseArtifactRef(`artifact:${relative(artifactsDirectory, path)}`),
|
|
42
|
+
path,
|
|
43
|
+
}));
|
|
44
|
+
return { release, artifacts };
|
|
45
|
+
}
|
|
46
|
+
/** Build an exact local Git commit with the canonical compiler and artifact writer. */
|
|
47
|
+
export async function packRelease(options) {
|
|
48
|
+
return packCapturedSource(options, createLocalCommitSourceCapture(resolve(options.sourceDirectory), parseSourceRevision(options.sourceRevision)));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Capture an Artifacts Git commit, build its Release, upload it to an Oyster,
|
|
52
|
+
* and install it, or switch an existing installation to it
|
|
53
|
+
* ({@link installRelease}).
|
|
54
|
+
*/
|
|
55
|
+
export async function installFromSource(input) {
|
|
56
|
+
const outDirectory = await mkdtemp(join(tmpdir(), "oyster-source-release-"));
|
|
57
|
+
try {
|
|
58
|
+
await packCapturedSource({
|
|
59
|
+
appId: input.appId,
|
|
60
|
+
releaseId: input.releaseId,
|
|
61
|
+
recipe: input.recipe,
|
|
62
|
+
sourceRevision: input.source.sourceRevision,
|
|
63
|
+
packageStoreDirectory: input.packageStoreDirectory,
|
|
64
|
+
outDirectory,
|
|
65
|
+
}, createArtifactsSourceCapture({
|
|
66
|
+
...input.source,
|
|
67
|
+
sourceRevision: parseSourceRevision(input.source.sourceRevision),
|
|
68
|
+
}));
|
|
69
|
+
return await installRelease({
|
|
70
|
+
directory: outDirectory,
|
|
71
|
+
url: input.url,
|
|
72
|
+
key: input.key,
|
|
73
|
+
installationId: input.installationId,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
await rm(outDirectory, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Upload a packed Release to an Oyster and install it. The host installs a
|
|
82
|
+
* new installation (201) or switches an existing one to this Release (200),
|
|
83
|
+
* keeping its state; both go through its one install-or-select owner.
|
|
84
|
+
*/
|
|
85
|
+
export async function installRelease(input) {
|
|
86
|
+
const directory = resolve(input.directory);
|
|
87
|
+
const release = Schema.decodeUnknownSync(Schema.fromJsonString(releaseSchema))(await readFile(join(directory, "release.json"), "utf8"));
|
|
88
|
+
const authorization = { Authorization: `Bearer ${input.key}` };
|
|
89
|
+
for (const file of await filesUnder(join(directory, "artifacts"))) {
|
|
90
|
+
const bytes = new Uint8Array(await readFile(file));
|
|
91
|
+
const artifactRef = `artifact:${relative(join(directory, "artifacts"), file)}`;
|
|
92
|
+
const digest = parseSha256Digest(await sha256(bytes));
|
|
93
|
+
const response = await fetch(new URL(`/api/artifacts/${encodeURIComponent(artifactRef)}?digest=${digest}`, input.url), { method: "PUT", headers: authorization, body: bytes });
|
|
94
|
+
if (!response.ok)
|
|
95
|
+
throw new Error(`Artifact upload failed (${String(response.status)}): ${artifactRef}`);
|
|
96
|
+
}
|
|
97
|
+
return fetch(new URL("/api/installations", input.url), {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { ...authorization, "Content-Type": "application/json" },
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
release: Schema.encodeUnknownSync(releaseSchema)(release),
|
|
102
|
+
installationId: input.installationId,
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/** Project one exact first-party commit's editable source and seed it into
|
|
107
|
+
* an Oyster's own template, so `APPS.fork` can later fork it. The operator
|
|
108
|
+
* owner key authenticates; no provider credential ever leaves this call. */
|
|
109
|
+
export async function publishSource(input) {
|
|
110
|
+
const revision = parseSourceRevision(input.sourceRevision);
|
|
111
|
+
const distribution = await Effect.runPromise(Effect.scoped(Effect.gen(function* () {
|
|
112
|
+
const source = yield* createLocalCommitSourceCapture(resolve(input.repositoryPath), revision).capture();
|
|
113
|
+
return yield* createFirstPartyAppSourceDistribution({
|
|
114
|
+
appId: input.appId,
|
|
115
|
+
entry: input.entry,
|
|
116
|
+
source,
|
|
117
|
+
});
|
|
118
|
+
})));
|
|
119
|
+
const body = Schema.encodeUnknownSync(templateWriteInputSchema)({
|
|
120
|
+
appId: input.appId,
|
|
121
|
+
sourceRevision: revision,
|
|
122
|
+
sourceTreeDigest: distribution.sourceTreeDigest,
|
|
123
|
+
recipe: input.recipe,
|
|
124
|
+
files: distribution.files,
|
|
125
|
+
});
|
|
126
|
+
return fetch(new URL(`/api/apps/${encodeURIComponent(input.appId)}/source`, input.url), {
|
|
127
|
+
method: "PUT",
|
|
128
|
+
headers: {
|
|
129
|
+
Authorization: `Bearer ${input.key}`,
|
|
130
|
+
"Content-Type": "application/json",
|
|
131
|
+
},
|
|
132
|
+
body: JSON.stringify(body),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import { type CapabilityContract } from "@fungi.computer/agar";
|
|
3
|
+
import type { AppHostLaunch } from "@fungi.computer/agar/host";
|
|
4
|
+
import type { CapabilityDescriptor } from "../capability-descriptor.js";
|
|
5
|
+
/** Agar's opaque host handle, shared by the page's exported factory contracts. */
|
|
6
|
+
export type HostCapabilityHandle = AppHostLaunch["capabilities"][number]["handle"];
|
|
7
|
+
/** Bind a page-local JSON caller; cancellation never enters serialized input. */
|
|
8
|
+
export declare function capabilityJsonCaller(descriptor: CapabilityDescriptor, installationId: string): (method: string, raw?: unknown, options?: {
|
|
9
|
+
readonly signal?: AbortSignal;
|
|
10
|
+
}) => Promise<import("zod").JSONType | undefined>;
|
|
11
|
+
/** Local JSON calls carry cancellation separately from their serialized input. */
|
|
12
|
+
export type CapabilityJsonCaller = ReturnType<typeof capabilityJsonCaller>;
|
|
13
|
+
/** Local factories may open only transports present in their descriptor. */
|
|
14
|
+
export type CapabilityTransportOpener = (method: string, input?: Schema.Json) => WebSocket;
|
|
15
|
+
/** Build the App's RPC handle from its admitted descriptor and installation. */
|
|
16
|
+
export declare function capabilityHandle(descriptor: CapabilityDescriptor, installationId: string): HostCapabilityHandle;
|
|
17
|
+
/** A supported contract may adapt local JSON calls into its App-facing handle. */
|
|
18
|
+
export type CapabilityRegistration = Readonly<{
|
|
19
|
+
contract: CapabilityContract<unknown>;
|
|
20
|
+
createHandle?: (call: CapabilityJsonCaller, transport: CapabilityTransportOpener) => HostCapabilityHandle | Promise<HostCapabilityHandle>;
|
|
21
|
+
}>;
|
|
22
|
+
/** Create a registered handle, using the descriptor's ordinary RPC handle by default. */
|
|
23
|
+
export declare function registeredCapabilityHandle(registration: CapabilityRegistration, descriptor: CapabilityDescriptor, installationId: string): Promise<HostCapabilityHandle>;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { RpcTarget } from "@fungi.computer/capnweb";
|
|
2
|
+
import { compile, parse, pathToRegexp } from "path-to-regexp";
|
|
3
|
+
import * as Schema from "effect/Schema";
|
|
4
|
+
import { AppError } from "@fungi.computer/agar";
|
|
5
|
+
import { parseJsonValue } from "@fungi.computer/whistle/wire";
|
|
6
|
+
const queryInput = Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Number, Schema.Boolean]));
|
|
7
|
+
const capabilityInputSchema = Schema.Union([Schema.Json, Schema.Undefined]);
|
|
8
|
+
function resolveRoute(path, input) {
|
|
9
|
+
const template = parse(path);
|
|
10
|
+
const names = new Set(pathToRegexp(template).keys.map((key) => key.name));
|
|
11
|
+
if (names.size === 0)
|
|
12
|
+
return { path: compile(template)(), input };
|
|
13
|
+
const fields = Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Json))(input);
|
|
14
|
+
const params = Object.fromEntries(Object.entries(fields)
|
|
15
|
+
.filter(([name]) => names.has(name))
|
|
16
|
+
.map(([name, value]) => [
|
|
17
|
+
name,
|
|
18
|
+
String(Schema.decodeUnknownSync(Schema.Union([Schema.String, Schema.Number, Schema.Boolean]))(value)),
|
|
19
|
+
]));
|
|
20
|
+
return {
|
|
21
|
+
path: compile(template)(params),
|
|
22
|
+
input: Object.fromEntries(Object.entries(fields).filter(([name]) => !names.has(name))),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function query(input) {
|
|
26
|
+
if (input === undefined)
|
|
27
|
+
return "";
|
|
28
|
+
const entries = Object.entries(Schema.decodeUnknownSync(queryInput)(input));
|
|
29
|
+
const params = new URLSearchParams(entries.map(([name, value]) => [name, String(value)]));
|
|
30
|
+
return params.size === 0 ? "" : `?${params.toString()}`;
|
|
31
|
+
}
|
|
32
|
+
function check(response) {
|
|
33
|
+
if (response.ok)
|
|
34
|
+
return;
|
|
35
|
+
throw new AppError("Capability request failed", {
|
|
36
|
+
code: response.status === 401 || response.status === 403
|
|
37
|
+
? "CAPABILITY_DENIED"
|
|
38
|
+
: response.status === 413
|
|
39
|
+
? "FILE_TOO_LARGE"
|
|
40
|
+
: response.status === 404
|
|
41
|
+
? "INVALID_ARGUMENT"
|
|
42
|
+
: "HOST_FAILURE",
|
|
43
|
+
retryable: response.status >= 500,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// Chromium can expose an empty stream for bodyless HTTP responses. Cap'n Web
|
|
47
|
+
// reconstructs Responses in the App; their constructor rejects a stream for
|
|
48
|
+
// 204/205/304, which would otherwise break the entire App RPC connection.
|
|
49
|
+
async function fetchResponse(url, init) {
|
|
50
|
+
const response = await fetch(url, init);
|
|
51
|
+
if (init.method === "HEAD" ||
|
|
52
|
+
response.status === 204 ||
|
|
53
|
+
response.status === 205 ||
|
|
54
|
+
response.status === 304) {
|
|
55
|
+
await response.body?.cancel();
|
|
56
|
+
return new Response(null, response);
|
|
57
|
+
}
|
|
58
|
+
return response;
|
|
59
|
+
}
|
|
60
|
+
class ReadHandle extends RpcTarget {
|
|
61
|
+
#abort = new AbortController();
|
|
62
|
+
#url;
|
|
63
|
+
#init;
|
|
64
|
+
constructor(url, init) {
|
|
65
|
+
super();
|
|
66
|
+
this.#url = url;
|
|
67
|
+
this.#init = init;
|
|
68
|
+
}
|
|
69
|
+
async response() {
|
|
70
|
+
const response = await fetchResponse(this.#url, {
|
|
71
|
+
...this.#init,
|
|
72
|
+
signal: this.#abort.signal,
|
|
73
|
+
});
|
|
74
|
+
check(response);
|
|
75
|
+
return response;
|
|
76
|
+
}
|
|
77
|
+
close() {
|
|
78
|
+
this.#abort.abort();
|
|
79
|
+
return Promise.resolve();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
class WriteHandle extends RpcTarget {
|
|
83
|
+
#abort = new AbortController();
|
|
84
|
+
#url;
|
|
85
|
+
#init;
|
|
86
|
+
constructor(url, init) {
|
|
87
|
+
super();
|
|
88
|
+
this.#url = url;
|
|
89
|
+
this.#init = init;
|
|
90
|
+
}
|
|
91
|
+
async write(content) {
|
|
92
|
+
// R2 requires a known length, as on the existing file upload route.
|
|
93
|
+
const body = await new Response(content).arrayBuffer();
|
|
94
|
+
check(await fetch(this.#url, {
|
|
95
|
+
...this.#init,
|
|
96
|
+
body,
|
|
97
|
+
signal: this.#abort.signal,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
close() {
|
|
101
|
+
this.#abort.abort();
|
|
102
|
+
return Promise.resolve();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
class TransportHandle extends RpcTarget {
|
|
106
|
+
#abort = new AbortController();
|
|
107
|
+
#url;
|
|
108
|
+
#init;
|
|
109
|
+
constructor(url, init) {
|
|
110
|
+
super();
|
|
111
|
+
this.#url = url;
|
|
112
|
+
this.#init = init;
|
|
113
|
+
}
|
|
114
|
+
async fetch(raw) {
|
|
115
|
+
const request = Schema.decodeUnknownSync(Schema.instanceOf(Request))(raw);
|
|
116
|
+
if (this.#init.method !== "ANY" && request.method !== this.#init.method)
|
|
117
|
+
throw new AppError("This transport does not admit that HTTP method", {
|
|
118
|
+
code: "CAPABILITY_DENIED",
|
|
119
|
+
retryable: false,
|
|
120
|
+
});
|
|
121
|
+
const incoming = new URL(request.url);
|
|
122
|
+
const target = new URL(this.#url, "https://oyster.invalid");
|
|
123
|
+
target.pathname = `${target.pathname.replace(/\/$/u, "")}${incoming.pathname}`;
|
|
124
|
+
for (const [name, value] of incoming.searchParams) {
|
|
125
|
+
if (target.searchParams.has(name))
|
|
126
|
+
throw new AppError("A transport request cannot replace its handle arguments", {
|
|
127
|
+
code: "INVALID_ARGUMENT",
|
|
128
|
+
retryable: false,
|
|
129
|
+
});
|
|
130
|
+
target.searchParams.append(name, value);
|
|
131
|
+
}
|
|
132
|
+
const headers = new Headers(this.#init.headers);
|
|
133
|
+
for (const name of [
|
|
134
|
+
"accept",
|
|
135
|
+
"content-type",
|
|
136
|
+
"last-event-id",
|
|
137
|
+
"acp-connection-id",
|
|
138
|
+
"acp-session-id",
|
|
139
|
+
]) {
|
|
140
|
+
const value = request.headers.get(name);
|
|
141
|
+
if (value !== null)
|
|
142
|
+
headers.set(name, value);
|
|
143
|
+
}
|
|
144
|
+
const init = {
|
|
145
|
+
...this.#init,
|
|
146
|
+
method: request.method,
|
|
147
|
+
headers,
|
|
148
|
+
signal: AbortSignal.any([request.signal, this.#abort.signal]),
|
|
149
|
+
};
|
|
150
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
151
|
+
const body = await request.arrayBuffer();
|
|
152
|
+
if (body.byteLength > 512 * 1024)
|
|
153
|
+
throw new AppError("Transport request is too large", {
|
|
154
|
+
code: "INVALID_ARGUMENT",
|
|
155
|
+
retryable: false,
|
|
156
|
+
});
|
|
157
|
+
init.body = body;
|
|
158
|
+
}
|
|
159
|
+
return fetchResponse(`${target.pathname}${target.search}`, init);
|
|
160
|
+
}
|
|
161
|
+
close() {
|
|
162
|
+
this.#abort.abort();
|
|
163
|
+
return Promise.resolve();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function capabilityRequest(descriptor, installationId, route, raw) {
|
|
167
|
+
const resolved = resolveRoute(route.path, raw);
|
|
168
|
+
const input = resolved.input;
|
|
169
|
+
const headers = new Headers({ "X-Oyster-Installation": installationId });
|
|
170
|
+
const url = `${descriptor.base}${resolved.path}`;
|
|
171
|
+
const target = new URL(url, "https://oyster.invalid");
|
|
172
|
+
if (target.origin !== "https://oyster.invalid" || target.pathname !== url)
|
|
173
|
+
throw new AppError("Capability arguments change the route", {
|
|
174
|
+
code: "INVALID_ARGUMENT",
|
|
175
|
+
retryable: false,
|
|
176
|
+
});
|
|
177
|
+
const init = {
|
|
178
|
+
method: route.method,
|
|
179
|
+
headers,
|
|
180
|
+
credentials: "same-origin",
|
|
181
|
+
cache: "no-store",
|
|
182
|
+
redirect: "error",
|
|
183
|
+
};
|
|
184
|
+
if (route.kind === "json" && route.method !== "GET") {
|
|
185
|
+
headers.set("Content-Type", "application/json");
|
|
186
|
+
init.body = JSON.stringify(input);
|
|
187
|
+
return { url, init };
|
|
188
|
+
}
|
|
189
|
+
return { url: `${url}${query(input)}`, init };
|
|
190
|
+
}
|
|
191
|
+
async function jsonReply(url, init) {
|
|
192
|
+
const response = await fetch(url, init);
|
|
193
|
+
check(response);
|
|
194
|
+
if (response.status === 204)
|
|
195
|
+
return undefined;
|
|
196
|
+
return parseJsonValue(await response.json());
|
|
197
|
+
}
|
|
198
|
+
/** Bind a page-local JSON caller; cancellation never enters serialized input. */
|
|
199
|
+
export function capabilityJsonCaller(descriptor, installationId) {
|
|
200
|
+
return async (method, raw, options) => {
|
|
201
|
+
const input = Schema.decodeUnknownSync(capabilityInputSchema)(raw);
|
|
202
|
+
const route = descriptor.methods[method];
|
|
203
|
+
if (route?.kind !== "json")
|
|
204
|
+
throw new AppError("This method is not a JSON capability method", {
|
|
205
|
+
code: "INVALID_ARGUMENT",
|
|
206
|
+
retryable: false,
|
|
207
|
+
});
|
|
208
|
+
const { url, init } = capabilityRequest(descriptor, installationId, route, input);
|
|
209
|
+
return jsonReply(url, { ...init, signal: options?.signal });
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/** Open a browser transport using the page's admitted installation. */
|
|
213
|
+
function capabilityTransportOpener(descriptor, installationId) {
|
|
214
|
+
return (method, raw) => {
|
|
215
|
+
const route = descriptor.methods[method];
|
|
216
|
+
if (route?.kind !== "transport" ||
|
|
217
|
+
(route.method !== "GET" && route.method !== "ANY"))
|
|
218
|
+
throw new AppError("This method does not admit a WebSocket transport", {
|
|
219
|
+
code: "INVALID_ARGUMENT",
|
|
220
|
+
retryable: false,
|
|
221
|
+
});
|
|
222
|
+
const { url } = capabilityRequest(descriptor, installationId, route, Schema.decodeUnknownSync(capabilityInputSchema)(raw));
|
|
223
|
+
const target = new URL(url, "https://oyster.invalid");
|
|
224
|
+
// Browser WebSockets cannot send the installation header used by Fetch.
|
|
225
|
+
target.searchParams.set("oysterInstallation", installationId);
|
|
226
|
+
return new WebSocket(`${target.pathname}${target.search}`);
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** Build the App's RPC handle from its admitted descriptor and installation. */
|
|
230
|
+
export function capabilityHandle(descriptor, installationId) {
|
|
231
|
+
// Cap'n Web exposes prototype methods, never an instance's own properties.
|
|
232
|
+
// Only input crosses RPC; AbortSignal options belong to the local caller.
|
|
233
|
+
class CapabilityHandle extends RpcTarget {
|
|
234
|
+
}
|
|
235
|
+
Object.assign(CapabilityHandle.prototype, Object.fromEntries(Object.entries(descriptor.methods).map(([name, route]) => [
|
|
236
|
+
name,
|
|
237
|
+
async (raw) => {
|
|
238
|
+
const input = Schema.decodeUnknownSync(capabilityInputSchema)(raw);
|
|
239
|
+
const { url, init } = capabilityRequest(descriptor, installationId, route, input);
|
|
240
|
+
if (route.kind === "read")
|
|
241
|
+
return new ReadHandle(url, init);
|
|
242
|
+
if (route.kind === "write")
|
|
243
|
+
return new WriteHandle(url, init);
|
|
244
|
+
if (route.kind === "transport")
|
|
245
|
+
return new TransportHandle(url, init);
|
|
246
|
+
return jsonReply(url, init);
|
|
247
|
+
},
|
|
248
|
+
])));
|
|
249
|
+
return new CapabilityHandle();
|
|
250
|
+
}
|
|
251
|
+
/** Create a registered handle, using the descriptor's ordinary RPC handle by default. */
|
|
252
|
+
export async function registeredCapabilityHandle(registration, descriptor, installationId) {
|
|
253
|
+
if (registration.createHandle === undefined)
|
|
254
|
+
return capabilityHandle(descriptor, installationId);
|
|
255
|
+
return await registration.createHandle(capabilityJsonCaller(descriptor, installationId), capabilityTransportOpener(descriptor, installationId));
|
|
256
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { RpcTarget } from "@fungi.computer/capnweb";
|
|
2
|
+
import type { CapabilityContract } from "@fungi.computer/agar";
|
|
3
|
+
import { type ComputerProcessStart, type computerPorts, type computerProcesses } from "@fungi.computer/agar/computer";
|
|
4
|
+
import type { CapabilityJsonCaller } from "./capabilities.js";
|
|
5
|
+
/** The App-facing handle a capability contract describes. */
|
|
6
|
+
export type HandleOf<Contract> = Contract extends CapabilityContract<infer Handle> ? Handle : never;
|
|
7
|
+
/**
|
|
8
|
+
* Agar's `computer.ports` handle over the host's wire. The App passes a bare
|
|
9
|
+
* port; Hub's routes and the Computer owner take `{ port }` as the body, so
|
|
10
|
+
* this handle is the one place that maps between the two.
|
|
11
|
+
*/
|
|
12
|
+
export declare class ComputerPortsHandle extends RpcTarget implements HandleOf<typeof computerPorts> {
|
|
13
|
+
#private;
|
|
14
|
+
constructor(call: CapabilityJsonCaller);
|
|
15
|
+
list(): Promise<{
|
|
16
|
+
readonly ports: readonly {
|
|
17
|
+
readonly port: number;
|
|
18
|
+
readonly previewUrl: string;
|
|
19
|
+
readonly openMode: "inline" | "external-only";
|
|
20
|
+
readonly expiresAt: number;
|
|
21
|
+
}[];
|
|
22
|
+
} | {
|
|
23
|
+
readonly error: "port_previews_not_configured";
|
|
24
|
+
}>;
|
|
25
|
+
expose(port: number): Promise<{
|
|
26
|
+
readonly port: number;
|
|
27
|
+
readonly exposed: boolean;
|
|
28
|
+
}>;
|
|
29
|
+
unexpose(port: number): Promise<{
|
|
30
|
+
readonly port: number;
|
|
31
|
+
readonly removed: boolean;
|
|
32
|
+
}>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Agar's `computer.processes` handle over the host's wire. The App passes a
|
|
36
|
+
* bare process ID to `kill`; the host route takes `{ id }` as the body.
|
|
37
|
+
*/
|
|
38
|
+
export declare class ComputerProcessesHandle extends RpcTarget implements HandleOf<typeof computerProcesses> {
|
|
39
|
+
#private;
|
|
40
|
+
constructor(call: CapabilityJsonCaller);
|
|
41
|
+
start(input: ComputerProcessStart): Promise<{
|
|
42
|
+
readonly port: number | null;
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly command: string;
|
|
45
|
+
readonly cwd: string | null;
|
|
46
|
+
readonly startedAt: number;
|
|
47
|
+
readonly state: "running" | "exited" | "uncertain";
|
|
48
|
+
readonly exitCode: number | null;
|
|
49
|
+
readonly sync: "synced" | "pending" | "unknown";
|
|
50
|
+
readonly endedAt: number | null;
|
|
51
|
+
}>;
|
|
52
|
+
list(): Promise<{
|
|
53
|
+
readonly processes: readonly {
|
|
54
|
+
readonly port: number | null;
|
|
55
|
+
readonly id: string;
|
|
56
|
+
readonly command: string;
|
|
57
|
+
readonly cwd: string | null;
|
|
58
|
+
readonly startedAt: number;
|
|
59
|
+
readonly state: "running" | "exited" | "uncertain";
|
|
60
|
+
readonly exitCode: number | null;
|
|
61
|
+
readonly sync: "synced" | "pending" | "unknown";
|
|
62
|
+
readonly endedAt: number | null;
|
|
63
|
+
}[];
|
|
64
|
+
}>;
|
|
65
|
+
kill(id: string): Promise<{
|
|
66
|
+
readonly process: {
|
|
67
|
+
readonly port: number | null;
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly command: string;
|
|
70
|
+
readonly cwd: string | null;
|
|
71
|
+
readonly startedAt: number;
|
|
72
|
+
readonly state: "running" | "exited" | "uncertain";
|
|
73
|
+
readonly exitCode: number | null;
|
|
74
|
+
readonly sync: "synced" | "pending" | "unknown";
|
|
75
|
+
readonly endedAt: number | null;
|
|
76
|
+
};
|
|
77
|
+
readonly signalled: boolean;
|
|
78
|
+
}>;
|
|
79
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { RpcTarget } from "@fungi.computer/capnweb";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { computerPortExposureSchema, computerPortRemovalSchema, computerPortsResultSchema, computerProcessKillSchema, computerProcessSchema, computerProcessesSchema, } from "@fungi.computer/agar/computer";
|
|
4
|
+
/**
|
|
5
|
+
* Agar's `computer.ports` handle over the host's wire. The App passes a bare
|
|
6
|
+
* port; Hub's routes and the Computer owner take `{ port }` as the body, so
|
|
7
|
+
* this handle is the one place that maps between the two.
|
|
8
|
+
*/
|
|
9
|
+
export class ComputerPortsHandle extends RpcTarget {
|
|
10
|
+
#call;
|
|
11
|
+
constructor(call) {
|
|
12
|
+
super();
|
|
13
|
+
this.#call = call;
|
|
14
|
+
}
|
|
15
|
+
async list() {
|
|
16
|
+
return Schema.decodeUnknownSync(computerPortsResultSchema)(await this.#call("list"));
|
|
17
|
+
}
|
|
18
|
+
async expose(port) {
|
|
19
|
+
return Schema.decodeUnknownSync(computerPortExposureSchema)(await this.#call("expose", { port }));
|
|
20
|
+
}
|
|
21
|
+
async unexpose(port) {
|
|
22
|
+
return Schema.decodeUnknownSync(computerPortRemovalSchema)(await this.#call("unexpose", { port }));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Agar's `computer.processes` handle over the host's wire. The App passes a
|
|
27
|
+
* bare process ID to `kill`; the host route takes `{ id }` as the body.
|
|
28
|
+
*/
|
|
29
|
+
export class ComputerProcessesHandle extends RpcTarget {
|
|
30
|
+
#call;
|
|
31
|
+
constructor(call) {
|
|
32
|
+
super();
|
|
33
|
+
this.#call = call;
|
|
34
|
+
}
|
|
35
|
+
async start(input) {
|
|
36
|
+
return Schema.decodeUnknownSync(computerProcessSchema)(await this.#call("start", input));
|
|
37
|
+
}
|
|
38
|
+
async list() {
|
|
39
|
+
return Schema.decodeUnknownSync(computerProcessesSchema)(await this.#call("list"));
|
|
40
|
+
}
|
|
41
|
+
async kill(id) {
|
|
42
|
+
return Schema.decodeUnknownSync(computerProcessKillSchema)(await this.#call("kill", { id }));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Owns the page's App connections and services until disconnect or pagehide. */
|
|
2
|
+
export function createPageLifetime(page) {
|
|
3
|
+
const controller = new AbortController();
|
|
4
|
+
const dispose = () => {
|
|
5
|
+
controller.abort();
|
|
6
|
+
page.removeEventListener("pagehide", dispose);
|
|
7
|
+
};
|
|
8
|
+
page.addEventListener("pagehide", dispose, { once: true });
|
|
9
|
+
return {
|
|
10
|
+
signal: controller.signal,
|
|
11
|
+
own: (release) => {
|
|
12
|
+
if (controller.signal.aborted)
|
|
13
|
+
release();
|
|
14
|
+
else
|
|
15
|
+
controller.signal.addEventListener("abort", release, { once: true });
|
|
16
|
+
},
|
|
17
|
+
dispose,
|
|
18
|
+
};
|
|
19
|
+
}
|