Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ WORKDIR /app
COPY --from=build /work/build/server ./server
COPY LICENSE /app/LICENSE
COPY licenses/ /app/licenses/
# Mount point for STORE_DIR (/notes storage), owned by the runtime user so a
# fresh named volume inherits it. Unused unless STORE_DIR is set.
RUN install -d -o 65532 -g 65532 /data
ENV PORT=8080
EXPOSE 8080
USER 65532:65532
Expand Down
65 changes: 53 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ One small JSON API, one shared Almide implementation, three execution routes:
- **Wasm + compiler-generated JavaScript host**: Cloudflare Workers
- **Wasm + Node function adapters**: AWS Lambda, Google Cloud Run functions, Azure Functions

The first example proves portable application logic. It is not a cloud SDK,
production framework, storage abstraction, or claim that all Almide I/O works on
The example proves portable application logic, including logic that reads and
writes storage: `/notes` keeps a small list in Workers KV, Cloud Storage or a
file, and the same Almide code decides every read and write on every route. It
is not a cloud SDK, production framework, or claim that all Almide I/O works on
every provider.

## Verified scope
Expand Down Expand Up @@ -38,6 +40,14 @@ See [verification notes](docs/verification.md#compiler-pin-moved-to-the-v0660-re
| Google Cloud Run functions, Node 24 | Source package, local Functions Framework, and managed source build via `deploy.sh --execute` | 18 direct adapter cases; in the cloud, 18 octet-stream cases passed and JSON showed the same 3 framework rejections as locally | Deployed temporarily with internal ingress + IAM, verified, deleted |
| Azure Functions v4, Node 24 | Source package, adapter/config tests and actual SDK request objects tested | 18 common cases; Functions host/key enforcement not run | Not deployed |

The `/notes` storage scenario ([tests/notes.mjs](tests/notes.mjs), 10 requests in
order against an empty store) passed locally on every route (native files and a
restart, Wasm, Node adapters, local workerd KV, Compose with a named volume and a
container restart) and live on Cloudflare Workers with KV, on the Cloud Run
container with Almide reading and writing Cloud Storage itself, and on Cloud Run
functions with Cloud Storage; each store held exactly the two saved notes
afterwards. The ConoHa VPS run predates `/notes`.

GitHub Actions (`ubuntu-24.04`, compiler install and every reproduction step) has
passed. See [verification notes](docs/verification.md) for exact commands and limits.

Expand Down Expand Up @@ -124,21 +134,52 @@ authentication emulators.
## What is shared

[src/api.almd](src/api.almd) owns path routing, input validation, response status,
JSON encoding, and method errors. Its public boundary is three strings in and
one JSON envelope out: `handle(method, target, body) -> String`.
JSON encoding, method errors and what to store. Its public boundary is strings in
and one JSON envelope out: `step(method, target, body, reads) -> String`
(`handle(method, target, body)` is the same without storage).

- [src/native.almd](src/native.almd) maps Almide HTTP requests/responses
- [src/wasm.almd](src/wasm.almd) exposes the same handler to generated JS
- [worker.js](providers/cloudflare-workers/worker.js) maps Workers Request/Response
- [src/native.almd](src/native.almd) maps Almide HTTP requests/responses, and performs storage itself
- [src/wasm.almd](src/wasm.almd) exposes the same functions to generated JS
- [worker.js](providers/cloudflare-workers/worker.js) maps Workers Request/Response, with KV as the store
- [adapters/node-wasm.mjs](adapters/node-wasm.mjs) shares one lazy Wasm initializer across Node function adapters
- [tests/cases.mjs](tests/cases.mjs) is the shared expected-behavior fixture
- [adapters/step.mjs](adapters/step.mjs) runs `step` for the JS hosts; [adapters/gcs-store.mjs](adapters/gcs-store.mjs) is Cloud Storage for Node
- [tests/cases.mjs](tests/cases.mjs) and [tests/notes.mjs](tests/notes.mjs) are the shared expected-behavior fixtures

The HTTP response contains the envelope's `body`; `status` and optional `allow`
become HTTP metadata. The host adapters do not duplicate the greeting logic.
become HTTP metadata. The host adapters do not duplicate the application logic.

### Storage: Almide decides, the host performs

The generated JS host has no asynchronous I/O, and a stock Wasm build refuses
Almide's HTTP client (`http.get` is E081 on `--target wasm`), while Workers KV,
`fetch` and Cloud Storage are asynchronous. So the shared code describes storage
instead of doing it:

1. The host calls `step(method, target, body, "{}")`.
2. If the envelope has `"read": ["notes"]`, the host reads those keys and calls
`step` again with `reads` = `{"notes": "<stored text>" | null}`.
3. The envelope with `"status"` is final. If it has `"write": {"key", "value"}`,
the host stores it before answering; a failed read or write answers 503
`storage_unavailable`.

| Route | Store | Who performs it |
| --- | --- | --- |
| Native (ConoHa, Cloud Run container) | `STORE_DIR` files, or `GCS_BUCKET` objects | Almide: `fs`, or `http.request` with a metadata-server token |
| Cloudflare Workers | KV binding `NOTES` | `worker.js` |
| Cloud Run functions | `GCS_BUCKET` objects | `adapters/gcs-store.mjs` (`fetch`, no SDK) |
| Lambda, Azure Functions | none configured | `/notes` answers 503 |

`/notes` keeps one JSON list under the key `notes`: `POST {"text": ...}` (1–280
code points) adds `{n, text}` and answers 201; `GET` lists the newest first. Only
the newest 50 are kept. A request is a read-modify-write of that one key: native
`http.serve` handles one request at a time, so one native process never
interleaves two, but concurrent instances (Workers isolates, Cloud Run instances,
several replicas) can lose a write. There is no locking, versioning or
conditional write; this is a demonstration of the boundary, not a database.

## Deliberately small contract

- `/health`: GET; `/greet`: POST; query strings are ignored
- `/health`: GET; `/greet`: POST; `/notes`: GET and POST; query strings are ignored
- `name` must be a string of 1–100 Unicode code points; it is not trimmed
- Request body limit in shared logic: 8,192 Unicode code points
- Application errors are JSON: 400, 404, 405 with `Allow`, and 413
Expand All @@ -149,8 +190,8 @@ become HTTP metadata. The host adapters do not duplicate the greeting logic.
ceiling and 30-second read/response limits. Rejections before the shared handler
need not have the same JSON shape as application errors
- Host-specific request normalization, streaming, transport limits, HEAD behavior,
concurrency/load, TLS, storage, retries, Secrets and outbound async I/O are
outside the portable contract. Provider authentication configuration is
concurrency/load, TLS, storage consistency, retries and Secrets are outside the
portable contract. Provider authentication configuration is
included, but cloud enforcement has not been verified

The JS host supports scalar/String boundaries, not arbitrary records and lists.
Expand Down
32 changes: 32 additions & 0 deletions adapters/gcs-store.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Cloud Storage as a key -> string store for the Node adapters, with the runtime
// identity's token from the metadata server. Uses only fetch: no SDK dependency.
const METADATA_TOKEN = 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token';

async function token() {
const response = await fetch(METADATA_TOKEN, { headers: { 'Metadata-Flavor': 'Google' } });
if (!response.ok) throw new Error(`metadata token: HTTP ${response.status}`);
return (await response.json()).access_token;
}

export function gcsStore(bucket) {
const base = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucket)}/o`;
const upload = `https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(bucket)}/o`;
return {
async get(key) {
const response = await fetch(`${base}/${encodeURIComponent(key)}?alt=media`, {
headers: { Authorization: `Bearer ${await token()}` },
});
if (response.status === 404) return null;
if (!response.ok) throw new Error(`GCS read: HTTP ${response.status}`);
return response.text();
},
async put(key, value) {
const response = await fetch(`${upload}?uploadType=media&name=${encodeURIComponent(key)}`, {
method: 'POST',
headers: { Authorization: `Bearer ${await token()}`, 'Content-Type': 'application/json' },
body: value,
});
if (!response.ok) throw new Error(`GCS write: HTTP ${response.status}`);
},
};
}
11 changes: 8 additions & 3 deletions adapters/node-wasm.mjs
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
// Common Node host for the three function adapters. The compiler-generated JS
// owns the Wasm ABI; every request uses the same synchronous Almide function.
import { readFile } from 'node:fs/promises';
import { init, handle } from '../build/app.js';
import { init, step } from '../build/app.js';
import { runStep } from './step.mjs';
import { gcsStore } from './gcs-store.mjs';

let initialization;

export async function callApi(method, target, body = '') {
// GCS_BUCKET selects Cloud Storage; without it, requests that need storage get 503.
const defaultStore = process.env.GCS_BUCKET ? gcsStore(process.env.GCS_BUCKET) : null;

export async function callApi(method, target, body = '', { store = defaultStore } = {}) {
if (![method, target, body].every(value => typeof value === 'string')) {
throw new TypeError('callApi expects method, target and body strings');
}
initialization ??= readFile(new URL('../build/app.wasm', import.meta.url))
.then(bytes => init(bytes));
await initialization;
return JSON.parse(handle(method, target, body));
return runStep(step, method, target, body, store);
}
38 changes: 38 additions & 0 deletions adapters/step.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Runs the shared Almide `step` to a final envelope. Almide decides what to read
// and write; the host performs it. `store` is { get(key) -> string | null,
// put(key, value) } (sync or async), or null when the deployment has none.
// No Node or provider APIs here, so Workers and the Node adapters share it.
export const UNAVAILABLE = Object.freeze({ status: 503, body: { error: 'storage_unavailable' } });

export async function runStep(step, method, target, body, store) {
const reads = {};
for (let round = 0; ; round++) {
const envelope = JSON.parse(step(method, target, body, JSON.stringify(reads)));
if (Array.isArray(envelope.read)) {
if (!store || round >= 2) return UNAVAILABLE;
try {
for (const key of envelope.read) reads[key] = (await store.get(key)) ?? null;
} catch (error) {
console.error('storage read failed:', error?.message ?? error);
return UNAVAILABLE;
}
continue;
}
const { write, ...result } = envelope;
if (write) {
if (!store) return UNAVAILABLE;
try {
await store.put(write.key, write.value);
} catch (error) {
console.error('storage write failed:', error?.message ?? error);
return UNAVAILABLE;
}
}
return result;
}
}

export function memoryStore(initial = {}) {
const data = new Map(Object.entries(initial));
return { data, get: key => data.get(key) ?? null, put: (key, value) => { data.set(key, value); } };
}
43 changes: 43 additions & 0 deletions docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,52 @@ locally and used through `dev_overrides`), region c3j1.
`CapDrop=[ALL]`; `docker compose down` took 0.5 s
6. `terraform destroy` removed all 5 resources; the VPS existed about 23 minutes

## /notes: storage decided by Almide, performed by the host

Date: 2026-10-04, Almide `v0.66.0` release binary. The probe that settled the
design: `http.get` builds natively but a `--target wasm --host js` build refuses
it (E081), while a synchronous `@extern(wasm, "js", ...)` builds. JS storage APIs
are asynchronous, so `api.step` returns the reads it needs and the write to make,
and each host performs them (root README, "Storage: Almide decides, the host
performs").

Local (macOS arm64, Node 24.21.0):

1. `npm test` 156/156: the 10-request scenario through generated Wasm with an
in-memory store, the stored document, 503 without a store, 500 on a corrupt
document, the 50-note cap; native with `STORE_DIR` (and notes surviving a
server restart) and native without storage (503); the Node function host with
an injected store, and Lambda without one (503)
2. `test:workers` 29/29 with a local KV (`--persist-to` a fresh directory);
`test:google-framework` 39/39; `test:staged-functions` 38/38
3. Compose with the named volume: 18 cases + the scenario, then `down` / `up`
kept both notes; root filesystem still read-only, UID 65532

Live:

1. Cloudflare Workers: `wrangler deploy` provisioned the KV namespace
`almide-cloud-example-notes` from the id-less binding. 18 cases + the scenario
passed (29/29); `wrangler kv key get notes --remote` returned exactly the two
saved notes. `wrangler delete` left the namespace; it was deleted separately
2. Google, a new disposable project (deleted afterwards), one bucket per service
with public access prevention, `roles/storage.objectUser` for the runtime
account on each bucket only:
- Cloud Run container with `GCS_BUCKET`: the Almide server took the
metadata-server token and read/wrote Cloud Storage with `http.request`.
From the in-VPC VM with an ID token: 18/18 and the scenario 11/11
- Cloud Run functions with `GCS_BUCKET` via `deploy.sh`: the Node host read
and wrote with `fetch`. 18/18 and the scenario 11/11 (octet-stream bodies)
- Each bucket's `notes` object held exactly the two saved notes
(`application/json`, 53 bytes); unauthenticated requests got 403
3. The ConoHa VPS was not rerun with `/notes` (its run predates it)

Not shown: behavior under concurrent writers. The single-key read-modify-write
has no conditional write, so concurrent instances can lose a note.

## Not established

- Native x86_64 Docker build outside the ConoHa VPS
- `/notes` on the ConoHa VPS, and `/notes` under concurrent writers on any route
- ConoHa TLS/reverse proxy, restart behavior, production load, or plans smaller
than `g2l-t-c4m4`
- Azure Container Apps or ECS Fargate provider validation/deployment
Expand Down
5 changes: 5 additions & 0 deletions providers/aws-lambda/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ cleanup. Reserved concurrency is not a spending cap.

## Configuration, secrets and logs

`/notes` has no store on this route: the adapter passes none, so `/notes`
answers 503 `storage_unavailable` (tested). A store would be a host-side
`{ get, put }` passed to `callApi`, as Cloud Run functions does with Cloud Storage.


Environment variables configured on Lambda are visible to the Node host through
`process.env`. The current shared `callApi` boundary passes only method, target
and body; it does not import host environment values into Wasm. There are no
Expand Down
6 changes: 6 additions & 0 deletions providers/azure-functions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ small package into an app containing unrelated functions.

## Configuration, secrets, logs and cleanup

`/notes` has no store on this route: the adapter passes none, so `/notes`
answers 503 `storage_unavailable` (the shared `callApi` default, tested
through the Lambda adapter). A store would be a host-side
`{ get, put }` passed to `callApi`, as Cloud Run functions does with Cloud Storage.


Azure application settings appear in the Node host environment. `callApi`
does not pass those settings into Wasm; it currently sends only method, target
and body. No application secret retrieval is implemented. A later Key Vault
Expand Down
27 changes: 24 additions & 3 deletions providers/cloudflare-workers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,30 @@ after a first deploy, the workers.dev route can briefly answer
quotas and behavior under real load were not tested. See the root support matrix
for the exact evidence.

Bindings, Secrets, outbound asynchronous `fetch`, D1, R2 and KV are intentionally
outside this first example. They would belong in host adapters. The generated JS
host's environment is not a transparent bridge to Workers bindings.
## /notes and KV

`/notes` is stored in the KV binding `NOTES`. The shared Almide `step` says which
key to read and what to write; `worker.js` performs it with `env.NOTES` (see the
root README, "Storage: Almide decides, the host performs"). The generated JS
host's environment is not a transparent bridge to Workers bindings, so the
adapter, not Almide, touches KV.

`wrangler.jsonc` names the binding without an `id`, so `wrangler deploy`
provisions a namespace called `almide-cloud-example-notes`, and `wrangler dev
--local` simulates one (the tests use `--persist-to` with a fresh directory).
**`wrangler delete` does not delete that namespace**; remove it separately:

```sh
npx wrangler kv namespace list
npx wrangler kv namespace delete --namespace-id <id>
```

On 2026-10-04 the scenario in `tests/notes.mjs` passed against the deployed
Worker, the namespace then held exactly the two saved notes
(`wrangler kv key get notes --remote`), and the Worker and namespace were deleted.
KV is eventually consistent across locations and has no conditional write, so
concurrent POSTs from different isolates can lose one. Secrets, D1, R2 and
outbound `fetch` remain outside the example.

## Official references

Expand Down
10 changes: 7 additions & 3 deletions providers/cloudflare-workers/worker.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import module from '../../build/app.wasm';
import { init, handle } from '../../build/app.js';
import { init, step } from '../../build/app.js';
import { runStep } from '../../adapters/step.mjs';

// One init per isolate. No filesystem/URL fallback and no handwritten Wasm ABI.
const ready = init(module);

// The NOTES KV namespace is the store; without the binding, /notes answers 503.
const kvStore = kv => kv && { get: key => kv.get(key), put: (key, value) => kv.put(key, value) };

export default {
async fetch(request) {
async fetch(request, env) {
await ready;
const url = new URL(request.url);
const body = request.method === 'GET' || request.method === 'HEAD'
? '' : await request.text();
const response = JSON.parse(handle(request.method, url.pathname, body));
const response = await runStep(step, request.method, url.pathname, body, kvStore(env.NOTES));
return Response.json(response.body, {
status: response.status,
...(response.allow ? { headers: { Allow: response.allow } } : {}),
Expand Down
5 changes: 4 additions & 1 deletion providers/cloudflare-workers/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@
// Almide's generated glue has a top-level import.meta.url and an unused
// node:fs/promises fallback. Keep both requirements explicit and tested.
"compatibility_flags": ["nodejs_compat", "new_module_registry"],
"workers_dev": true
"workers_dev": true,
// /notes storage. Without an id, `wrangler deploy` provisions a namespace;
// `wrangler dev --local` simulates it. Remove the binding and /notes answers 503.
"kv_namespaces": [{ "binding": "NOTES" }]
}
7 changes: 7 additions & 0 deletions providers/conoha/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ curl --fail http://127.0.0.1:8080/greet \
docker compose -f providers/conoha/compose.yaml down
```

`/notes` is stored by the Almide server itself as files under `STORE_DIR=/data`,
a named volume (`notes`) that outlives the container; the image creates `/data`
owned by the runtime user, so the read-only root filesystem stays read-only.
`docker compose down` keeps the volume; `docker compose down -v` deletes the notes.
Locally (macOS arm64) the notes scenario passed and the notes survived
`down` / `up`; the ConoHa VPS run above predates `/notes`.

Compose binds the app only to the host loopback address. For public access, place
a TLS reverse proxy in front and configure the intended ConoHa security group
and guest firewall yourself. Docker-published ports can bypass UFW; UFW alone is
Expand Down
6 changes: 6 additions & 0 deletions providers/conoha/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@ services:
context: ../..
environment:
PORT: "8080"
STORE_DIR: /data # /notes storage, in the named volume below
# Keep the sample private on the VPS. Add a TLS reverse proxy deliberately.
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
volumes:
- notes:/data

volumes:
notes:
Loading
Loading