create-fungi-app 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +140 -2
- package/dist/app-cli.d.ts +2 -0
- package/dist/app-cli.js +242 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +84 -0
- package/dist/ids.d.ts +4 -0
- package/dist/ids.js +6 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +27 -0
- package/docs/api/README.md +38 -0
- package/package.json +37 -3
- package/template/README.md +99 -0
- package/template/gitignore +5 -0
- package/template/index.html +12 -0
- package/template/oyster/worker.ts +10 -0
- package/template/oyster/wrangler.json +40 -0
- package/template/package.json +58 -0
- package/template/src/App.tsx +49 -0
- package/template/src/backend/index.ts +4 -0
- package/template/src/backend/methods.ts +21 -0
- package/template/src/main.tsx +13 -0
- package/template/tsconfig.backend.json +8 -0
- package/template/tsconfig.json +13 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jamstack Consulting LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,141 @@
|
|
|
1
|
-
#
|
|
1
|
+
# create-fungi-app
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
create-fungi-app creates a small App with Caps UI and one typed Agar backend
|
|
4
|
+
method, running in a local standalone Oyster. Most people want
|
|
5
|
+
[Oyster](https://docs.fungi.computer/oyster/) to run Apps or
|
|
6
|
+
[Shiitake](https://docs.fungi.computer/shiitake/) to run Agents.
|
|
7
|
+
|
|
8
|
+
## Quick start
|
|
9
|
+
|
|
10
|
+
Build and test an App locally without Docker, a Fungi Computer, or a Cloudflare
|
|
11
|
+
account.
|
|
12
|
+
|
|
13
|
+
Use Node 24.20.0, npm 11.19.0 and Git for Oyster's pinned build toolchain.
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm create fungi-app@latest my-app
|
|
17
|
+
cd my-app
|
|
18
|
+
npm run dev
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Use `-- --no-install` to generate files without installing dependencies. In a
|
|
22
|
+
terminal, running `npm create fungi-app@latest` with no directory asks for the
|
|
23
|
+
project name and whether to install; without a terminal, pass the directory
|
|
24
|
+
argument. Existing directories are refused.
|
|
25
|
+
|
|
26
|
+
Open the loopback URL printed by the development command. Click **Say hello** to
|
|
27
|
+
call the backend. Save an edit in `src/` to rebuild the App and reload its page.
|
|
28
|
+
|
|
29
|
+
You can also test the typed backend over HTTP while development is running:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
curl --fail-with-body http://127.0.0.1:61343/apps/app/methods/greet \
|
|
33
|
+
-H 'Content-Type: application/json' \
|
|
34
|
+
-d '{"name":"Oyster"}'
|
|
35
|
+
# {"message":"Hello, Oyster!"}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Stop development with Ctrl+C. To build a Release without starting a server:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npm run build
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
This writes `dist/release.json` and `dist/artifacts/`; it does not deploy or
|
|
45
|
+
publish anything.
|
|
46
|
+
|
|
47
|
+
### What runs locally
|
|
48
|
+
|
|
49
|
+
- `npm run dev` uses the existing Oyster builder, then runs
|
|
50
|
+
`wrangler dev --local` on loopback. Workerd executes the host Worker and the
|
|
51
|
+
App's Durable Object backend through Worker Loader. Local R2 serves the built
|
|
52
|
+
UI and assets; Wrangler keeps local state under `.wrangler/`.
|
|
53
|
+
- `npm run build` runs Node/npm and the App compiler on your machine against an
|
|
54
|
+
isolated snapshot of saved files. It does not allocate a build Computer.
|
|
55
|
+
- The template is React/Caps UI, one typed Agar `greet` method, a Durable Object
|
|
56
|
+
facet, and a minimal Oyster Worker/config. It declares no Computer or
|
|
57
|
+
container.
|
|
58
|
+
|
|
59
|
+
Wrangler configurations that include a container-backed Computer need Docker to
|
|
60
|
+
build and run that Computer locally. That is separate from compiling and running
|
|
61
|
+
this App.
|
|
62
|
+
|
|
63
|
+
Without an attached Computer, Computer files, shell commands, processes and port
|
|
64
|
+
previews are unavailable. Hosted repository build jobs also need their
|
|
65
|
+
configured execution backend. The starter does not emulate Team sign-in/grants
|
|
66
|
+
or funded provider services, and does not map your laptop's files into a
|
|
67
|
+
Computer. Development admits local requests as its owner; use it for trusted
|
|
68
|
+
source, not as a production authentication or isolation proof.
|
|
69
|
+
|
|
70
|
+
### Measured startup
|
|
71
|
+
|
|
72
|
+
In a Linux trial on October 28, 2026, the locally packed candidate took **53.8
|
|
73
|
+
seconds** from starting the scaffold command to the first working browser
|
|
74
|
+
greeting, under the conditions below:
|
|
75
|
+
|
|
76
|
+
| Step | Elapsed |
|
|
77
|
+
| -------------------------------------- | ------- |
|
|
78
|
+
| Scaffold and install | 42.3 s |
|
|
79
|
+
| Scaffold → first working App greeting | 53.8 s |
|
|
80
|
+
| Saved UI/backend edit → changed result | 11.0 s |
|
|
81
|
+
| Separate `npm run build` | 10.6 s |
|
|
82
|
+
|
|
83
|
+
The cold run used a fresh HOME and empty npm cache on Linux, with Docker blocked
|
|
84
|
+
and no inherited Fungi or Cloudflare credentials. Node/npm/Git were already
|
|
85
|
+
installed. The packed-release proof served Fungi packages from local tarballs
|
|
86
|
+
and downloaded other dependencies from npm. Packing and toolchain/browser
|
|
87
|
+
installation were outside the clock. The saved-edit measurement reused the
|
|
88
|
+
running App. These are observed times, not a two-minute guarantee on another
|
|
89
|
+
machine or network.
|
|
90
|
+
|
|
91
|
+
## Examples
|
|
92
|
+
|
|
93
|
+
To create the App without installing dependencies:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
npm create fungi-app@latest my-app -- --no-install
|
|
97
|
+
cd my-app
|
|
98
|
+
npm install
|
|
99
|
+
npm run dev
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
To build a Release from your saved files, run this inside the generated App:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
npm run build
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The build writes `dist/release.json` and `dist/artifacts/`. The generated
|
|
109
|
+
`README.md` shows `oyster install ./dist` (or `./dist/release.json`) using
|
|
110
|
+
`OYSTER_URL` and `OYSTER_OWNER_KEY`. The CLI delegates to the existing
|
|
111
|
+
`@fungi.computer/oyster/pack` uploader; it does not deploy an Oyster.
|
|
112
|
+
|
|
113
|
+
To use committed source instead, push the generated App to a Bonsai repository
|
|
114
|
+
and choose **Make this an App** in repository Settings. The root
|
|
115
|
+
`package.json#fungi.build` supplies the recipe. Registration does not build or
|
|
116
|
+
install. Subsequent accepted main pushes build the new commit on a hosted
|
|
117
|
+
backend and install only after success and within existing approval. New
|
|
118
|
+
permissions require owner consent and leave the previous Release unchanged.
|
|
119
|
+
|
|
120
|
+
## Core concepts
|
|
121
|
+
|
|
122
|
+
The template declares its backend definition, declaration, and build recipe in
|
|
123
|
+
`package.json`. `fungi.build` owns the UI entry, backend entry, asset bounds,
|
|
124
|
+
and recipe kind. Paths are relative to the App directory. Build and dev read
|
|
125
|
+
that metadata from your saved files; there is no second recipe inside the
|
|
126
|
+
command. `src/backend/methods.ts` owns the input, output and implementation of
|
|
127
|
+
`greet`; the UI calls that method through Agar's host-admitted capability.
|
|
128
|
+
|
|
129
|
+
`npm run dev` uses Oyster's builder and a standalone Worker on Wrangler. Its
|
|
130
|
+
development host admits local requests as its owner. The generated
|
|
131
|
+
`oyster/worker.ts` uses key authentication for a deployed host.
|
|
132
|
+
|
|
133
|
+
`npm run build` captures saved files, including uncommitted edits, in an
|
|
134
|
+
isolated Git snapshot. It leaves your repository history alone and records that
|
|
135
|
+
snapshot in the Release's provenance. Dependency installation uses
|
|
136
|
+
`package-lock.json`. Building creates the Release locally. Installing it into an
|
|
137
|
+
Oyster and publishing packages are separate operations.
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT licensed. See [LICENSE](LICENSE).
|
package/dist/app-cli.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { starterIds } from "./ids.js";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { watch } from "node:fs";
|
|
6
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { resolve, join } from "node:path";
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
10
|
+
import { parseArgs } from "node:util";
|
|
11
|
+
import { Cli } from "@fungi.computer/lantern";
|
|
12
|
+
import { readBuildManifest, appBuildRecipe, createAppPublisher, createLocalArtifactWriter, createLocalBuildExecutor, createLocalDirectorySourceCapture, } from "@fungi.computer/oyster/build";
|
|
13
|
+
import { InstallationObservationError, installRelease, } from "@fungi.computer/oyster/pack";
|
|
14
|
+
import { parseReleaseId } from "@fungi.computer/puffball";
|
|
15
|
+
import { releaseSchema } from "@fungi.computer/puffball/effect";
|
|
16
|
+
import * as Effect from "effect/Effect";
|
|
17
|
+
import * as Redacted from "effect/Redacted";
|
|
18
|
+
import * as Result from "effect/Result";
|
|
19
|
+
import * as Schema from "effect/Schema";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
const cli = new Cli({ brand: "fungi" });
|
|
22
|
+
const root = process.cwd();
|
|
23
|
+
const lifecycle = new AbortController();
|
|
24
|
+
const shutdown = { interrupted: false };
|
|
25
|
+
process.once("SIGINT", () => {
|
|
26
|
+
shutdown.interrupted = true;
|
|
27
|
+
lifecycle.abort();
|
|
28
|
+
});
|
|
29
|
+
process.once("SIGTERM", () => {
|
|
30
|
+
shutdown.interrupted = true;
|
|
31
|
+
lifecycle.abort();
|
|
32
|
+
});
|
|
33
|
+
async function build(out) {
|
|
34
|
+
const manifest = readBuildManifest(await readFile(join(root, "package.json"), "utf8"));
|
|
35
|
+
const appId = manifest.fungi.appId;
|
|
36
|
+
if (appId === undefined)
|
|
37
|
+
throw new Error("App manifest requires fungi.appId");
|
|
38
|
+
const recipe = appBuildRecipe(manifest, "");
|
|
39
|
+
const publisher = createAppPublisher({
|
|
40
|
+
config: { appId, recipe },
|
|
41
|
+
sourceCapture: createLocalDirectorySourceCapture(root),
|
|
42
|
+
buildExecutor: createLocalBuildExecutor(),
|
|
43
|
+
artifactWriter: createLocalArtifactWriter(join(out, "artifacts"), recipe.assets),
|
|
44
|
+
releaseCatalog: { createRelease: (release) => Promise.resolve(release) },
|
|
45
|
+
});
|
|
46
|
+
const release = await Effect.runPromise(Effect.scoped(publisher.createCandidate(parseReleaseId(starterIds.release.generate()))), { signal: lifecycle.signal });
|
|
47
|
+
await mkdir(out, { recursive: true });
|
|
48
|
+
const encoded = Schema.encodeUnknownSync(Schema.fromJsonString(releaseSchema))(release);
|
|
49
|
+
await writeFile(join(out, "release.json"), `${encoded}\n`);
|
|
50
|
+
return encoded;
|
|
51
|
+
}
|
|
52
|
+
async function dev(port) {
|
|
53
|
+
const directory = join(root, ".botanical/dev");
|
|
54
|
+
const out = join(directory, "release");
|
|
55
|
+
const key = randomBytes(24).toString("hex");
|
|
56
|
+
const origin = `http://127.0.0.1:${String(port)}`;
|
|
57
|
+
await mkdir(directory, { recursive: true });
|
|
58
|
+
await writeFile(join(directory, ".dev.vars"), `OYSTER_OWNER_KEY=${key}\nOYSTER_ORIGIN=${origin}\n`, {
|
|
59
|
+
mode: 0o600,
|
|
60
|
+
});
|
|
61
|
+
const config = z
|
|
62
|
+
.record(z.string(), z.unknown())
|
|
63
|
+
.parse(JSON.parse(await readFile(join(root, "oyster/wrangler.json"), "utf8")));
|
|
64
|
+
await writeFile(join(directory, "wrangler.json"), JSON.stringify({ ...config, main: "dev-worker.ts" }));
|
|
65
|
+
// Wrangler injects its reload script into the local host page. The ordinary
|
|
66
|
+
// standalone Worker retains Oyster's production CSP.
|
|
67
|
+
const developmentCsp = `default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'unsafe-inline'; frame-src 'self'; connect-src 'self' ws://127.0.0.1:${String(port)}; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`;
|
|
68
|
+
const worker = (release) => `import worker from "../../oyster/worker.ts";
|
|
69
|
+
export { OysterBinding, OysterRegistry, OysterInstance, OysterMcp, OysterInstallationExecutor } from "../../oyster/worker.ts";
|
|
70
|
+
export default { async fetch(request, env, context) {
|
|
71
|
+
const headers = new Headers(request.headers);
|
|
72
|
+
headers.set("Authorization", "Bearer " + env.OYSTER_OWNER_KEY);
|
|
73
|
+
const response = await worker.fetch(new Request(request, { headers }), env, context);
|
|
74
|
+
const result = new Response(response.body, response);
|
|
75
|
+
if (new URL(request.url).pathname === "/apps/app/") result.headers.set("Content-Security-Policy", ${JSON.stringify(developmentCsp)});
|
|
76
|
+
result.headers.set("X-Fungi-App-Release", ${JSON.stringify(z.object({ releaseId: z.string() }).parse(JSON.parse(release)).releaseId)});
|
|
77
|
+
return result;
|
|
78
|
+
} };
|
|
79
|
+
`;
|
|
80
|
+
let release = await cli.task("Building App", () => build(out));
|
|
81
|
+
await writeFile(join(directory, "dev-worker.ts"), worker(release));
|
|
82
|
+
const child = spawn(join(root, "node_modules/.bin/wrangler"), [
|
|
83
|
+
"dev",
|
|
84
|
+
"--local",
|
|
85
|
+
"--ip",
|
|
86
|
+
"127.0.0.1",
|
|
87
|
+
"--port",
|
|
88
|
+
String(port),
|
|
89
|
+
"--live-reload",
|
|
90
|
+
"--config",
|
|
91
|
+
join(directory, "wrangler.json"),
|
|
92
|
+
"--persist-to",
|
|
93
|
+
join(root, ".wrangler"),
|
|
94
|
+
], {
|
|
95
|
+
cwd: root,
|
|
96
|
+
env: { ...process.env, WRANGLER_SEND_METRICS: "false" },
|
|
97
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
98
|
+
detached: true,
|
|
99
|
+
});
|
|
100
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
101
|
+
createInterface({ input: stream }).on("line", (line) => {
|
|
102
|
+
cli.info(line.replaceAll(key, "[redacted]"));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const stopped = new Promise((done, reject) => {
|
|
106
|
+
child.once("error", reject);
|
|
107
|
+
child.once("exit", (code) => {
|
|
108
|
+
if (lifecycle.signal.aborted || code === 0)
|
|
109
|
+
done();
|
|
110
|
+
else
|
|
111
|
+
reject(new Error("Wrangler stopped before the App was closed."));
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
// Attach the rejection handler immediately while startup and compilation run.
|
|
115
|
+
void stopped.catch((cause) => {
|
|
116
|
+
lifecycle.abort(cause);
|
|
117
|
+
});
|
|
118
|
+
const stop = () => {
|
|
119
|
+
if (child.exitCode === null &&
|
|
120
|
+
child.signalCode === null &&
|
|
121
|
+
child.pid !== undefined)
|
|
122
|
+
process.kill(-child.pid, "SIGTERM");
|
|
123
|
+
};
|
|
124
|
+
lifecycle.signal.addEventListener("abort", stop, { once: true });
|
|
125
|
+
async function install() {
|
|
126
|
+
const response = await installRelease({
|
|
127
|
+
directory: out,
|
|
128
|
+
url: origin,
|
|
129
|
+
key,
|
|
130
|
+
installationId: "app",
|
|
131
|
+
signal: lifecycle.signal,
|
|
132
|
+
onAdmitted: ({ installationId, requestId }) => {
|
|
133
|
+
cli.info(`Installation ${installationId} accepted (request ${requestId}); observing authoritative status.`);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
if (!response.ok)
|
|
137
|
+
throw new Error(`Oyster refused the App installation (${String(response.status)}).`);
|
|
138
|
+
}
|
|
139
|
+
let timer;
|
|
140
|
+
let rebuilding = false;
|
|
141
|
+
let pending = false;
|
|
142
|
+
const rebuild = async () => {
|
|
143
|
+
pending = true;
|
|
144
|
+
if (rebuilding)
|
|
145
|
+
return;
|
|
146
|
+
rebuilding = true;
|
|
147
|
+
try {
|
|
148
|
+
while (pending && !lifecycle.signal.aborted) {
|
|
149
|
+
pending = false;
|
|
150
|
+
release = await cli.task("Rebuilding App", () => build(out));
|
|
151
|
+
await install();
|
|
152
|
+
await writeFile(join(directory, "dev-worker.ts"), worker(release));
|
|
153
|
+
cli.info("App rebuilt; Wrangler is reloading the page.");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (cause) {
|
|
157
|
+
if (!lifecycle.signal.aborted) {
|
|
158
|
+
Effect.runSync(Effect.logError("App rebuild failed.", Redacted.make(cause)));
|
|
159
|
+
if (cause instanceof InstallationObservationError)
|
|
160
|
+
reportUnconfirmedInstallation(cause);
|
|
161
|
+
else
|
|
162
|
+
cli.info("App rebuild failed. Fix the source and save again.");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
rebuilding = false;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const schedule = () => {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
timer = setTimeout(() => {
|
|
172
|
+
void rebuild();
|
|
173
|
+
}, 150);
|
|
174
|
+
};
|
|
175
|
+
try {
|
|
176
|
+
for (let attempt = 0;; attempt++) {
|
|
177
|
+
lifecycle.signal.throwIfAborted();
|
|
178
|
+
const readiness = await Effect.runPromise(Effect.result(Effect.tryPromise({
|
|
179
|
+
try: () => fetch(`${origin}/login`, { signal: lifecycle.signal }),
|
|
180
|
+
catch: (cause) => new Error("Wrangler readiness request failed.", { cause }),
|
|
181
|
+
})));
|
|
182
|
+
lifecycle.signal.throwIfAborted();
|
|
183
|
+
if (Result.isSuccess(readiness) && readiness.success.ok)
|
|
184
|
+
break;
|
|
185
|
+
if (attempt === 119)
|
|
186
|
+
throw new Error("Wrangler did not start within one minute.", {
|
|
187
|
+
cause: Result.isFailure(readiness)
|
|
188
|
+
? readiness.failure
|
|
189
|
+
: new Error(`Wrangler returned HTTP ${String(readiness.success.status)}.`),
|
|
190
|
+
});
|
|
191
|
+
await delay(500, undefined, { signal: lifecycle.signal });
|
|
192
|
+
}
|
|
193
|
+
await install();
|
|
194
|
+
watch(join(root, "src"), { recursive: true, signal: lifecycle.signal }, schedule);
|
|
195
|
+
watch(root, { signal: lifecycle.signal }, (_event, filename) => {
|
|
196
|
+
if (filename !== null &&
|
|
197
|
+
["index.html", "package.json", "package-lock.json"].includes(filename))
|
|
198
|
+
schedule();
|
|
199
|
+
});
|
|
200
|
+
cli.outro(`App ready at ${origin}/apps/app/`);
|
|
201
|
+
await stopped;
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
lifecycle.abort();
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
stop();
|
|
207
|
+
await stopped;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function reportUnconfirmedInstallation(cause) {
|
|
211
|
+
cli.info(`Installation outcome unconfirmed (request ${cause.requestId}). Oyster may still finish; stopping observation does not cancel it.`);
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const { positionals, values } = parseArgs({
|
|
215
|
+
allowPositionals: true,
|
|
216
|
+
options: {
|
|
217
|
+
port: { type: "string", default: "61343" },
|
|
218
|
+
help: { type: "boolean", short: "h" },
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
if (values.help)
|
|
222
|
+
cli.info("fungi-app build | fungi-app dev [--port 61343]");
|
|
223
|
+
else if (positionals.length !== 1)
|
|
224
|
+
throw new Error("Choose fungi-app build or fungi-app dev.");
|
|
225
|
+
else if (positionals[0] === "build") {
|
|
226
|
+
await cli.task("Building Release", () => build(resolve(root, "dist")));
|
|
227
|
+
cli.outro("Release ready in dist/release.json and dist/artifacts/.");
|
|
228
|
+
}
|
|
229
|
+
else if (positionals[0] === "dev")
|
|
230
|
+
await dev(z.coerce.number().int().min(1).max(65535).parse(values.port));
|
|
231
|
+
else
|
|
232
|
+
throw new Error("Choose fungi-app build or fungi-app dev.");
|
|
233
|
+
}
|
|
234
|
+
catch (cause) {
|
|
235
|
+
if (!shutdown.interrupted) {
|
|
236
|
+
if (cause instanceof InstallationObservationError)
|
|
237
|
+
reportUnconfirmedInstallation(cause);
|
|
238
|
+
Effect.runSync(Effect.logError("App command failed.", Redacted.make(cause)));
|
|
239
|
+
cli.cancel("App command failed.");
|
|
240
|
+
process.exitCode = 1;
|
|
241
|
+
}
|
|
242
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { basename, resolve } from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { Cli, isCancel } from "@fungi.computer/lantern";
|
|
6
|
+
import { projectName, scaffoldApp } from "./index.js";
|
|
7
|
+
import * as Effect from "effect/Effect";
|
|
8
|
+
import * as Redacted from "effect/Redacted";
|
|
9
|
+
class InputError extends Error {
|
|
10
|
+
}
|
|
11
|
+
const cli = new Cli({ brand: "fungi" });
|
|
12
|
+
try {
|
|
13
|
+
const { positionals, values } = parseArgs({
|
|
14
|
+
allowPositionals: true,
|
|
15
|
+
options: {
|
|
16
|
+
"no-install": { type: "boolean" },
|
|
17
|
+
help: { type: "boolean", short: "h" },
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
if (values.help) {
|
|
21
|
+
cli.info("npm create fungi-app@latest my-app [-- --no-install]");
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
if (positionals.length > 1)
|
|
25
|
+
throw new InputError("Provide one project directory, such as my-app.");
|
|
26
|
+
if (positionals.length === 0 && !cli.interactive)
|
|
27
|
+
throw new InputError("Provide a project directory when running without a terminal: npm create fungi-app@latest my-app");
|
|
28
|
+
cli.welcome("Create a Fungi App");
|
|
29
|
+
const name = positionals[0] ??
|
|
30
|
+
(await cli.text({
|
|
31
|
+
message: "Where should we create your App?",
|
|
32
|
+
placeholder: "my-app",
|
|
33
|
+
validate: (value) => projectName.safeParse(value === undefined ? undefined : basename(resolve(value))).success
|
|
34
|
+
? undefined
|
|
35
|
+
: "Choose a directory with a valid npm package name.",
|
|
36
|
+
}));
|
|
37
|
+
if (isCancel(name)) {
|
|
38
|
+
cli.cancel("App creation cancelled.");
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const directory = name;
|
|
42
|
+
if (!projectName.safeParse(basename(resolve(directory))).success)
|
|
43
|
+
throw new InputError("Choose a directory with a valid npm package name, such as my-app.");
|
|
44
|
+
await cli.task("Creating your App", async () => scaffoldApp(directory));
|
|
45
|
+
const install = values["no-install"]
|
|
46
|
+
? false
|
|
47
|
+
: cli.interactive
|
|
48
|
+
? await cli.confirm({
|
|
49
|
+
message: "Install dependencies with npm?",
|
|
50
|
+
initialValue: true,
|
|
51
|
+
})
|
|
52
|
+
: true;
|
|
53
|
+
if (!isCancel(install) && install) {
|
|
54
|
+
await cli.task("Installing dependencies", () => new Promise((done, reject) => {
|
|
55
|
+
const child = spawn("npm", ["install"], {
|
|
56
|
+
cwd: resolve(directory),
|
|
57
|
+
stdio: "inherit",
|
|
58
|
+
});
|
|
59
|
+
child.once("error", reject);
|
|
60
|
+
child.once("exit", (code) => {
|
|
61
|
+
if (code === 0)
|
|
62
|
+
done();
|
|
63
|
+
else
|
|
64
|
+
reject(new Error("Dependency installation failed."));
|
|
65
|
+
});
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
cli.outro(`Ready. Run:\n cd ${directory}\n${install === true ? "" : " npm install\n"} npm run dev`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch (cause) {
|
|
73
|
+
const code = cause instanceof Error && "code" in cause ? cause.code : undefined;
|
|
74
|
+
const message = cause instanceof InputError
|
|
75
|
+
? cause.message
|
|
76
|
+
: code === "ERR_PARSE_ARGS_UNKNOWN_OPTION"
|
|
77
|
+
? "Unknown option. Run create-fungi-app --help for usage."
|
|
78
|
+
: code === "EEXIST"
|
|
79
|
+
? "That directory already exists. Choose a new directory."
|
|
80
|
+
: "App creation failed.";
|
|
81
|
+
Effect.runSync(Effect.logError("App creation failed.", Redacted.make(cause)));
|
|
82
|
+
cli.cancel(message);
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
}
|
package/dist/ids.d.ts
ADDED
package/dist/ids.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
/** A project directory name, also used as its npm package name. */
|
|
3
|
+
export declare const projectName: z.ZodString;
|
|
4
|
+
/** Create a starter App in a new directory. Existing destinations are never overwritten. */
|
|
5
|
+
export declare function scaffoldApp(directory: string): Promise<void>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { starterIds } from "./ids.js";
|
|
2
|
+
import { parseAppId } from "@fungi.computer/puffball";
|
|
3
|
+
import { cp, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import * as z from "zod";
|
|
7
|
+
import validatePackageName from "validate-npm-package-name";
|
|
8
|
+
/** A project directory name, also used as its npm package name. */
|
|
9
|
+
export const projectName = z
|
|
10
|
+
.string()
|
|
11
|
+
.refine((name) => validatePackageName(name).validForNewPackages, {
|
|
12
|
+
message: "Choose a valid npm package name for your project directory.",
|
|
13
|
+
});
|
|
14
|
+
/** Create a starter App in a new directory. Existing destinations are never overwritten. */
|
|
15
|
+
export async function scaffoldApp(directory) {
|
|
16
|
+
const target = resolve(directory);
|
|
17
|
+
const name = projectName.parse(basename(target));
|
|
18
|
+
const template = fileURLToPath(new URL("../template/", import.meta.url));
|
|
19
|
+
await mkdir(target);
|
|
20
|
+
await cp(template, target, { recursive: true });
|
|
21
|
+
await rename(resolve(target, "gitignore"), resolve(target, ".gitignore"));
|
|
22
|
+
const manifest = z
|
|
23
|
+
.object({ fungi: z.record(z.string(), z.unknown()) })
|
|
24
|
+
.loose()
|
|
25
|
+
.parse(JSON.parse(await readFile(resolve(target, "package.json"), "utf8")));
|
|
26
|
+
await writeFile(resolve(target, "package.json"), `${JSON.stringify({ ...manifest, name, fungi: { ...manifest.fungi, appId: parseAppId(starterIds.app.generate()) } }, null, 2)}\n`);
|
|
27
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!-- Generated by Botanical API docs (TypeDoc + typedoc-plugin-markdown). Do not edit. -->
|
|
2
|
+
|
|
3
|
+
**create-fungi-app**
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# create-fungi-app
|
|
8
|
+
|
|
9
|
+
## Variables
|
|
10
|
+
|
|
11
|
+
### projectName
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
const projectName: ZodString;
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
A project directory name, also used as its npm package name.
|
|
18
|
+
|
|
19
|
+
## Functions
|
|
20
|
+
|
|
21
|
+
### scaffoldApp()
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
function scaffoldApp(directory): Promise<void>;
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Create a starter App in a new directory. Existing destinations are never
|
|
28
|
+
overwritten.
|
|
29
|
+
|
|
30
|
+
#### Parameters
|
|
31
|
+
|
|
32
|
+
| Parameter | Type |
|
|
33
|
+
| ----------- | -------- |
|
|
34
|
+
| `directory` | `string` |
|
|
35
|
+
|
|
36
|
+
#### Returns
|
|
37
|
+
|
|
38
|
+
`Promise`\<`void`\>
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-fungi-app",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Create a Caps and Agar App that runs on a standalone Oyster",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=24.20.0"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"create-fungi-app": "dist/cli.js",
|
|
12
|
+
"fungi-app": "dist/app-cli.js"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"template",
|
|
23
|
+
"docs/api",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@fungi.computer/lantern": "0.4.2",
|
|
29
|
+
"zod": "4.4.3",
|
|
30
|
+
"@fungi.computer/oyster": "0.15.0",
|
|
31
|
+
"@fungi.computer/puffball": "0.5.4",
|
|
32
|
+
"effect": "4.0.2",
|
|
33
|
+
"validate-npm-package-name": "7.0.2",
|
|
34
|
+
"@fungi.computer/cairn": "0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/fungi-computer/create-fungi-app.git"
|
|
39
|
+
}
|
|
6
40
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# My App
|
|
2
|
+
|
|
3
|
+
This App has Caps UI and one typed backend method. Oyster hosts both surfaces;
|
|
4
|
+
the UI calls `greet` through Agar's `app.methods` capability.
|
|
5
|
+
|
|
6
|
+
Use Node 24.20.0, npm 11.19.0 and Git, then:
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install
|
|
10
|
+
npm run dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
No Docker, Fungi Computer, or Cloudflare account is needed. Open the printed
|
|
14
|
+
loopback URL and click **Say hello**, or test the same backend directly:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
curl --fail-with-body http://127.0.0.1:61343/apps/app/methods/greet \
|
|
18
|
+
-H 'Content-Type: application/json' \
|
|
19
|
+
-d '{"name":"Oyster"}'
|
|
20
|
+
# {"message":"Hello, Oyster!"}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Development runs a standalone Oyster on loopback with Wrangler. Save an edit in
|
|
24
|
+
`src/` to rebuild the App and reload its page. The development host admits local
|
|
25
|
+
requests as its owner; use the ordinary key-authenticated `oyster/worker.ts` for
|
|
26
|
+
a deployed host, with `OYSTER_OWNER_KEY` as a secret and `OYSTER_ORIGIN` set to
|
|
27
|
+
its public origin. Each App Instance records that origin when it is installed.
|
|
28
|
+
The host serves `/mcp` (send `Authorization: Bearer $OYSTER_OWNER_KEY`) only for
|
|
29
|
+
requests whose host matches `OYSTER_ORIGIN`; any other host gets a plain 404.
|
|
30
|
+
|
|
31
|
+
The host Worker, Durable Objects, Worker Loader and Release assets run locally;
|
|
32
|
+
state stays under `.wrangler/`. No Computer or container is configured. Computer
|
|
33
|
+
files, shell commands, processes and port previews need an attached Computer.
|
|
34
|
+
Team sign-in/grants and funded provider services are not emulated. See the
|
|
35
|
+
[create-fungi-app quickstart](https://docs.fungi.computer/create-fungi-app/quickstart/)
|
|
36
|
+
for the measured cold-start time and the local/hosted boundary.
|
|
37
|
+
|
|
38
|
+
`src/backend/methods.ts` declares the method's input and output schemas and its
|
|
39
|
+
handler. `src/backend/index.ts` exports the Durable Object facet. The
|
|
40
|
+
`fungi.backendDefinition` manifest entry tells Oyster's builder where the
|
|
41
|
+
definition lives; the builder derives the App's API and declaration from it and
|
|
42
|
+
the manifest's `fungi.app`.
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
npm run build
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
This writes `dist/release.json` and `dist/artifacts/`. Build captures the
|
|
49
|
+
current saved files into an isolated Git snapshot, including uncommitted edits;
|
|
50
|
+
it does not modify your repository or create a customer commit. The Release's
|
|
51
|
+
provenance names that exact snapshot. Dependency installation uses
|
|
52
|
+
`package-lock.json`.
|
|
53
|
+
|
|
54
|
+
To install the Release into an existing Oyster, set its URL and owner key, then
|
|
55
|
+
use the ordinary Oyster CLI:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
export OYSTER_URL=https://your-oyster.example
|
|
59
|
+
export OYSTER_OWNER_KEY='<your owner key>'
|
|
60
|
+
npx oyster install ./dist --installation-id my-app
|
|
61
|
+
# The manifest path is also accepted:
|
|
62
|
+
npx oyster install ./dist/release.json --installation-id my-app
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Both forms use `installRelease` from `@fungi.computer/oyster/pack`: artifacts
|
|
66
|
+
upload before the installation request. A failed upload or installation exits
|
|
67
|
+
nonzero; the command does not provision or deploy an Oyster.
|
|
68
|
+
|
|
69
|
+
For repository-driven authoring, commit the App's root `package.json` and its
|
|
70
|
+
`fungi.build` recipe on main, push to a repository in Bonsai, then open its
|
|
71
|
+
Settings and choose **Make this an App**. Registration reads committed metadata,
|
|
72
|
+
not dirty working files, and does not build or install. Later accepted main
|
|
73
|
+
pushes build that exact commit on an attached hosted backend. Successful builds
|
|
74
|
+
may reuse existing approval; new permissions produce `needs_consent` and leave
|
|
75
|
+
any previous Release running. Local-only Computers return `no_backend`.
|
|
76
|
+
|
|
77
|
+
The same Release format is used by Fungi. Building creates the Release locally;
|
|
78
|
+
it does not install or publish it.
|
|
79
|
+
|
|
80
|
+
## Models and mascots
|
|
81
|
+
|
|
82
|
+
When adding Quaternius art, add `@fungi.computer/art-library` and `three` as
|
|
83
|
+
dependencies. Discover models through `@fungi.computer/art-library/models`: use
|
|
84
|
+
`models` and `searchModels(models, query)` to find candidates, then
|
|
85
|
+
`requireModel(models, id)` to pin a catalog ID rather than guessing filenames.
|
|
86
|
+
|
|
87
|
+
Load the selected asset with `modelLoader()` from
|
|
88
|
+
`@fungi.computer/art-library/three` and its `loadAsync(modelUrl)` method. This
|
|
89
|
+
loader wires the Meshopt decoder needed by compressed models; a bare
|
|
90
|
+
`GLTFLoader` does not.
|
|
91
|
+
|
|
92
|
+
Include the chosen model (and any referenced buffers/textures) in the Release's
|
|
93
|
+
built assets and use the emitted asset URL; the catalog row's `file` is not a
|
|
94
|
+
public Oyster URL. Keep `fungi.build.assetBase` as `"./"` and URLs
|
|
95
|
+
release-scoped under `/ui/<installation>/<release>/`. Resolve emitted relative
|
|
96
|
+
paths against the App document base, for example
|
|
97
|
+
`new URL("./assets/mascot.glb", document.baseURI).href` if that is the emitted
|
|
98
|
+
path. Never hard-code `/models/...`: its leading slash escapes the Release
|
|
99
|
+
prefix and requests an asset from the host root.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="latte">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>My App</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="app"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createOysterWorker } from "@fungi.computer/oyster/workers";
|
|
2
|
+
// Every invocation frame mints its binding stubs from this one entrypoint.
|
|
3
|
+
export {
|
|
4
|
+
OysterBinding,
|
|
5
|
+
OysterInstance,
|
|
6
|
+
OysterMcp,
|
|
7
|
+
OysterRegistry,
|
|
8
|
+
OysterInstallationExecutor,
|
|
9
|
+
} from "@fungi.computer/oyster/workers";
|
|
10
|
+
export default createOysterWorker();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../node_modules/wrangler/config-schema.json",
|
|
3
|
+
"name": "my-app-oyster",
|
|
4
|
+
"main": "worker.ts",
|
|
5
|
+
"compatibility_date": "2026-09-04",
|
|
6
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
7
|
+
"worker_loaders": [
|
|
8
|
+
{
|
|
9
|
+
"binding": "LOADER"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"r2_buckets": [
|
|
13
|
+
{
|
|
14
|
+
"binding": "RELEASES",
|
|
15
|
+
"bucket_name": "app-releases"
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"durable_objects": {
|
|
19
|
+
"bindings": [
|
|
20
|
+
{
|
|
21
|
+
"name": "MCP",
|
|
22
|
+
"class_name": "OysterMcp"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "REGISTRY",
|
|
26
|
+
"class_name": "OysterRegistry"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "INSTANCES",
|
|
30
|
+
"class_name": "OysterInstance"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"migrations": [
|
|
35
|
+
{
|
|
36
|
+
"tag": "v1",
|
|
37
|
+
"new_sqlite_classes": ["OysterRegistry", "OysterInstance", "OysterMcp"]
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"@fungi.computer/agar": "0.8.0",
|
|
4
|
+
"@fungi.computer/caps": "0.8.0",
|
|
5
|
+
"react": "18.3.1",
|
|
6
|
+
"react-dom": "18.3.1",
|
|
7
|
+
"zod": "4.4.3"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"create-fungi-app": "0.0.2",
|
|
11
|
+
"@fungi.computer/oyster": "0.15.0",
|
|
12
|
+
"typescript": "5.9.3",
|
|
13
|
+
"@types/react": "18.3.3",
|
|
14
|
+
"@types/react-dom": "18.3.0",
|
|
15
|
+
"@cloudflare/workers-types": "5.20260828.1",
|
|
16
|
+
"wrangler": "4.127.1",
|
|
17
|
+
"vite": "8.2.2",
|
|
18
|
+
"@vitejs/plugin-react": "6.1.1"
|
|
19
|
+
},
|
|
20
|
+
"name": "my-app",
|
|
21
|
+
"version": "0.0.0",
|
|
22
|
+
"private": true,
|
|
23
|
+
"type": "module",
|
|
24
|
+
"scripts": {
|
|
25
|
+
"dev": "fungi-app dev",
|
|
26
|
+
"build": "fungi-app build"
|
|
27
|
+
},
|
|
28
|
+
"fungi": {
|
|
29
|
+
"app": {
|
|
30
|
+
"displayName": "My App",
|
|
31
|
+
"capabilities": [],
|
|
32
|
+
"handlers": [],
|
|
33
|
+
"roles": []
|
|
34
|
+
},
|
|
35
|
+
"backendDefinition": "src/backend/methods.ts",
|
|
36
|
+
"build": {
|
|
37
|
+
"kind": "source-html",
|
|
38
|
+
"entry": "index.html",
|
|
39
|
+
"assetBase": "./",
|
|
40
|
+
"assets": {
|
|
41
|
+
"maxManifestBytes": 1048576,
|
|
42
|
+
"maxObjects": 1024,
|
|
43
|
+
"maxObjectBytes": 16777216,
|
|
44
|
+
"maxTotalBytes": 67108864
|
|
45
|
+
},
|
|
46
|
+
"maxOutputBytes": 67108864,
|
|
47
|
+
"backend": {
|
|
48
|
+
"entry": "src/backend/index.ts",
|
|
49
|
+
"exportName": "MyApp",
|
|
50
|
+
"compatibilityDate": "2026-09-04",
|
|
51
|
+
"runtime": {
|
|
52
|
+
"kind": "durable-facet",
|
|
53
|
+
"stateSchema": 1
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { AppRoot } from "@fungi.computer/agar";
|
|
3
|
+
import { appMethods } from "@fungi.computer/agar/backend";
|
|
4
|
+
import { Button } from "@fungi.computer/caps/components/button";
|
|
5
|
+
import {
|
|
6
|
+
CardContent,
|
|
7
|
+
CardFooter,
|
|
8
|
+
CardHeader,
|
|
9
|
+
CardTitle,
|
|
10
|
+
CenteredCard,
|
|
11
|
+
} from "@fungi.computer/caps/components/card";
|
|
12
|
+
import { greetingInput, greetingOutput } from "./backend/methods.js";
|
|
13
|
+
|
|
14
|
+
export function App({ session }: { session: AppRoot }) {
|
|
15
|
+
const [message, setMessage] = useState("Your App is running inside Oyster.");
|
|
16
|
+
const [busy, setBusy] = useState(false);
|
|
17
|
+
const [failure, setFailure] = useState<Error | null>(null);
|
|
18
|
+
async function greet() {
|
|
19
|
+
setBusy(true);
|
|
20
|
+
setFailure(null);
|
|
21
|
+
try {
|
|
22
|
+
const result = await session.capabilities
|
|
23
|
+
.require(appMethods)
|
|
24
|
+
.call("greet", JSON.stringify(greetingInput.parse({ name: "friend" })));
|
|
25
|
+
setMessage(greetingOutput.parse(result).message);
|
|
26
|
+
} catch (cause) {
|
|
27
|
+
setFailure(
|
|
28
|
+
new Error("Could not reach the backend. Try again.", { cause }),
|
|
29
|
+
);
|
|
30
|
+
} finally {
|
|
31
|
+
setBusy(false);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return (
|
|
35
|
+
<CenteredCard>
|
|
36
|
+
<CardHeader>
|
|
37
|
+
<CardTitle level={2}>My App</CardTitle>
|
|
38
|
+
</CardHeader>
|
|
39
|
+
<CardContent>
|
|
40
|
+
<p role="status">{failure?.message ?? message}</p>
|
|
41
|
+
</CardContent>
|
|
42
|
+
<CardFooter>
|
|
43
|
+
<Button loading={busy} disabled={busy} onClick={() => void greet()}>
|
|
44
|
+
Say hello
|
|
45
|
+
</Button>
|
|
46
|
+
</CardFooter>
|
|
47
|
+
</CenteredCard>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import {
|
|
3
|
+
defineAppBackend,
|
|
4
|
+
defineAppMethod,
|
|
5
|
+
} from "@fungi.computer/agar/backend-definition";
|
|
6
|
+
|
|
7
|
+
export const greetingInput = z.object({ name: z.string().trim().min(1) });
|
|
8
|
+
export const greetingOutput = z.object({ message: z.string() });
|
|
9
|
+
|
|
10
|
+
export const appBackend = defineAppBackend({
|
|
11
|
+
methods: {
|
|
12
|
+
greet: defineAppMethod({
|
|
13
|
+
description: "Say hello",
|
|
14
|
+
effect: "read",
|
|
15
|
+
requires: [],
|
|
16
|
+
input: greetingInput,
|
|
17
|
+
output: greetingOutput,
|
|
18
|
+
handler: ({ name }) => ({ message: `Hello, ${name}!` }),
|
|
19
|
+
}),
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { connectApp } from "@fungi.computer/agar";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { App } from "./App.js";
|
|
4
|
+
import "@fungi.computer/caps/styles.css";
|
|
5
|
+
import "@fungi.computer/caps/fonts.css";
|
|
6
|
+
|
|
7
|
+
const root = document.getElementById("app");
|
|
8
|
+
if (root === null) throw new Error("App mount is missing.");
|
|
9
|
+
const session = await connectApp({
|
|
10
|
+
host: "parent",
|
|
11
|
+
});
|
|
12
|
+
window.addEventListener("pagehide", () => session.dispose(), { once: true });
|
|
13
|
+
createRoot(root).render(<App session={session} />);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ES2022", "DOM", "ESNext.Disposable"],
|
|
7
|
+
"types": [],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"jsx": "react-jsx",
|
|
10
|
+
"noEmit": true
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/App.tsx", "src/main.tsx", "src/backend/methods.ts"]
|
|
13
|
+
}
|