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
7 changes: 5 additions & 2 deletions apps/runtime/src/application/widget-dev-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { type NodeServices } from "../services.ts";
import { placeWidget } from "../widget-perform-tool.ts";
import { installPackage, packageInstallDepsOf, type ApprovedInstall } from "./package-install.ts";
import {
WIDGET_DEV_DIRECTORY_SOURCE,
WIDGET_DEV_SNAPSHOTS_MAX,
WIDGET_DEV_STORE_MAX,
readDevSessions,
Expand Down Expand Up @@ -518,15 +519,17 @@ export function createWidgetDevSessions(
const approval = approvalState(consentApproval);
return approval !== undefined && approval.decision === "granted" && approval.operationDigest === scope ? consentApproval : undefined;
})();
// The session installs its own listing, so it names the dev source: a listing another source owns is never taken.
const sourceId = WIDGET_DEV_DIRECTORY_SOURCE.id;
const approved: ApprovedInstall | undefined =
granted === undefined ? undefined : { approvalId: granted, digest: scope, localDigest: latest.generation.digest };
granted === undefined ? undefined : { approvalId: granted, digest: scope, localDigest: latest.generation.digest, sourceId };
const intent = intentOf(stored);

let outcome: Awaited<ReturnType<typeof installPackage>>;
try {
outcome = await installPackage(
packageInstallDepsOf(services()),
{ packageId: latest.listing.packageId, version: latest.listing.version, contentDigest: latest.generation.digest },
{ packageId: latest.listing.packageId, version: latest.listing.version, contentDigest: latest.generation.digest, sourceId },
{ consentScope: scope, ...(approved === undefined ? {} : { approved }), ...(intent === undefined ? {} : { intent }) },
);
} catch (cause) {
Expand Down
37 changes: 30 additions & 7 deletions apps/runtime/src/application/widget-dev-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ import {
widgetDevStopReasonSchema,
type DirectoryEntry,
} from "@clarkcant/contracts";
import { directoryIndexPath, readDirectory, readDirectoryIndex, type DirectoryIndexState } from "@clarkcant/core";
import {
directoryIndexPath,
listingKey,
readDirectory,
readDirectoryIndex,
type DirectoryIndexState,
type DirectoryOrigin,
} from "@clarkcant/core";

/**
* Where a node keeps its live widget authoring sessions, and the directory view they add to.
Expand Down Expand Up @@ -160,29 +167,45 @@ export function devListings(dataDir: string): DirectoryEntry[] {
]);
}

/**
* The source every dev listing names. Like any other source, it is recorded on what is installed from it and checked by
* the install path, so an install from a row that named another source (a marketplace card) never takes a session's
* build of the same id and version, and a session's own install names this source to take its build.
*/
export const WIDGET_DEV_DIRECTORY_SOURCE: DirectoryOrigin = {
id: "widget-dev",
kind: "widget-dev",
label: "this node's widget dev sessions",
};

/**
* The directory as this node resolves installed packages: every configured source (`readDirectory`), with the listings
* of its widget dev sessions in front. With no source configured and no sessions, the answer is the directory's own
* `not-configured`. The person's own index file that cannot be read keeps the directory unreadable rather than shrinking
* to the dev listings; remote sources that have nothing to list yet (never fetched, unreachable) do not hide a session's
* generations, since local development must work without any marketplace.
* of its widget dev sessions in front, each owned by `WIDGET_DEV_DIRECTORY_SOURCE`. With no source configured and no
* sessions, the answer is the directory's own `not-configured`. The person's own index file that cannot be read keeps
* the directory unreadable rather than shrinking to the dev listings; remote sources that have nothing to list yet (never
* fetched, unreachable) do not hide a session's generations, since local development must work without any marketplace.
*/
export function readNodeDirectory(dataDir: string, env: NodeJS.ProcessEnv = process.env): DirectoryIndexState {
const index = readDirectory({ env, dataDir });
const dev = devListings(dataDir);
if (dev.length === 0) return index;
if (index.kind === "not-configured") return { kind: "configured", directory: widgetDevStoreDir(dataDir), entries: dev };
const devOrigins = dev.map((entry): [string, DirectoryOrigin] => [listingKey(entry), WIDGET_DEV_DIRECTORY_SOURCE]);
if (index.kind === "not-configured") {
return { kind: "configured", directory: widgetDevStoreDir(dataDir), entries: dev, origins: new Map(devOrigins) };
}
if (index.kind === "unreadable") {
const indexPath = directoryIndexPath(env);
if (indexPath !== undefined && readDirectoryIndex(indexPath).kind !== "configured") return index;
return {
kind: "configured",
directory: index.directory,
entries: dev,
origins: new Map(devOrigins),
...(index.sources === undefined ? {} : { sources: index.sources }),
};
}
return { ...index, entries: [...dev, ...index.entries] };
// The dev listings come first, so the same listing key is the session's: the entry found first is the one it names.
return { ...index, entries: [...dev, ...index.entries], origins: new Map([...(index.origins ?? []), ...devOrigins]) };
}

/**
Expand Down
137 changes: 135 additions & 2 deletions apps/runtime/test/widget-dev-sessions.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { createServer, type Server } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
DEFAULT_EXECUTION_POLICY_CONFIG,
Expand All @@ -12,7 +13,16 @@ import {
type WidgetDefinition,
type WidgetDevSessionView,
} from "@clarkcant/contracts";
import { EXECUTION_POLICY_PREFERENCE_KEY, setPreference, writeRegisteredPreference } from "@clarkcant/core";
import {
DIRECTORY_FEED_FORMAT,
EXECUTION_POLICY_PREFERENCE_KEY,
OFFICIAL_MARKETPLACE_FEED_URL,
customFeedId,
listInstalledPackages,
refreshDirectory,
setPreference,
writeRegisteredPreference,
} from "@clarkcant/core";

import { createWidgetDevSessions } from "../src/application/widget-dev-sessions.ts";
import { WIDGET_DEV_STORE_MAX, readDevSessions, writeDevSessions } from "../src/application/widget-dev-store.ts";
Expand Down Expand Up @@ -645,3 +655,126 @@ describe("a widget dev session", () => {
expect(stopped).toMatchObject({ status: "stopped", stopReason: "folder-gone", activation: { state: "active", generation: 1 } });
});
});

describe("the directory a widget dev session lists its builds in", () => {
const restoreEnv: Record<string, string | undefined> = {};
const servers: Server[] = [];

/** Set (or, with no value, remove) a directory setting for this test only. */
function setEnv(name: string, value?: string): void {
if (!(name in restoreEnv)) restoreEnv[name] = process.env[name];
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}

/** A marketplace feed on loopback that lists this package's id and version under another publisher's archive. */
async function startFeed(): Promise<string> {
const listing = {
packageId: PACKAGE,
version: VERSION,
displayName: "Timer",
description: "Someone else's timer.",
source: { kind: "npm", name: "timer", version: VERSION },
publisher: { id: "someone-else", sourceUrl: "https://example.org", license: "MIT" },
preview: {},
facets: ["ui"],
isolations: [{ facetKind: "ui", isolation: "isolated-ui" }],
platforms: ["darwin-arm64", "linux-x64", "win32-x64", "web"],
hostApi: { min: 1, max: 1 },
permissionsSummary: [],
riskTier: "isolated-ui",
sizeBytes: 1024,
digest: `sha256:${"1".repeat(64)}`,
};
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ format: DIRECTORY_FEED_FORMAT, entries: [listing], nextCursor: null }));
});
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (address === null || typeof address === "string") throw new Error("the feed did not bind");
return `http://127.0.0.1:${String(address.port)}/api/v1/directory`;
}

const installedSource = () =>
listInstalledPackages({
db: services.runtime.db,
nodeId: services.runtime.identity.nodeId,
now: () => new Date().toISOString() as Instant,
newId: (prefix: string) => `${prefix}_x`,
}).find((installed) => installed.packageId === PACKAGE)?.directorySource;

/** Every address the node fetched that is not this machine's loopback; none of them is reached. */
let offLoopback: string[];

beforeEach(() => {
// The node's defaults: no index file, no feed of the person's own, and the official Marketplace on.
setEnv("CC_DIRECTORY_INDEX");
setEnv("CC_DIRECTORY_MARKETPLACES");
setEnv("CC_OFFICIAL_MARKETPLACE");
// A test run never reaches the live Marketplace: it answers as an unavailable one, and only loopback feeds are real.
offLoopback = [];
const realFetch = globalThis.fetch;
vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => {
const url = input instanceof Request ? input.url : String(input);
if (new URL(url).origin.startsWith("http://127.0.0.1")) return realFetch(input, init);
offLoopback.push(url);
return Promise.resolve(new Response("unavailable in tests", { status: 503 }));
});
});

afterEach(async () => {
vi.restoreAllMocks();
for (const [name, value] of Object.entries(restoreEnv)) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
delete restoreEnv[name];
}
await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve))));
});

it("activates and serves the folder on a fresh node whose Marketplace was never fetched", async () => {
const conversationId = await conversation();
const started = session(await call("POST", "/widget-dev/sessions", { root, conversationId }));
expect(started).toMatchObject({ status: "live", activation: { state: "active", generation: 1 } });

const frame = await live(conversationId, started.placed?.instanceId ?? "");
expect(await served(frame.frame.url)).toContain("first");
});

it("refuses to activate while the person's index file cannot be read", async () => {
const indexPath = join(dir, "index.json");
writeFileSync(indexPath, "[ {broken");
setEnv("CC_DIRECTORY_INDEX", indexPath);

const started = session(await call("POST", "/widget-dev/sessions", { root }));
expect(started.activation).toMatchObject({ state: "refused", code: "DIRECTORY_UNREADABLE" });
expect(started.running).toBeUndefined();
// The install looked for the listing in the Marketplace too, so the Marketplace was on.
expect(offLoopback.some((url) => url.startsWith(OFFICIAL_MARKETPLACE_FEED_URL))).toBe(true);
});

it("names its own source, so an install from a row naming a marketplace never takes the session's build", async () => {
const feedUrl = await startFeed();
setEnv("CC_DIRECTORY_MARKETPLACES", feedUrl);

// The feed was never fetched, so nothing tells the session the id and version are listed elsewhere.
const started = session(await call("POST", "/widget-dev/sessions", { root }));
expect(started.activation).toMatchObject({ state: "active", generation: 1 });
expect(installedSource()).toMatchObject({ id: "widget-dev", kind: "widget-dev" });

// Fetched now, the marketplace lists the same id and version; the person presses Install on its card.
await refreshDirectory({ env: process.env, dataDir: join(dir, "node") });
expect(offLoopback.some((url) => url.startsWith(OFFICIAL_MARKETPLACE_FEED_URL))).toBe(true);
const pressed = await call("POST", "/packages/install", { packageId: PACKAGE, version: VERSION, sourceId: customFeedId(feedUrl) });
expect(pressed.status).toBe(409);
expect(pressed.body).toMatchObject({ code: "DIRECTORY_SOURCE_CHANGED" });
expect(installedSource()?.id).toBe("widget-dev");

// And the session's next build is refused for the id the directory now lists.
writePackage("<!doctype html><p>second</p>\n");
const rebuilt = session(await call("POST", `/widget-dev/sessions/${started.sessionId}/rebuild`));
expect(rebuilt.activation).toMatchObject({ code: "PACKAGE_LISTED" });
});
});
8 changes: 4 additions & 4 deletions docs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,13 @@
},
{
"path": "widget-development.md",
"bytes": 300030,
"sha256": "c2f3681dd6d6a83a3001a2421c2bd86cf227c79f27c1e137abf6f686f788424c"
"bytes": 300253,
"sha256": "3ae20bf49fadcf93457363532f9de0addb596a49e049c79f8feaac60014c6c33"
},
{
"path": "widget-development.vi.md",
"bytes": 361790,
"sha256": "fd65b7be725718d7eefb3fcbbb4a4696c8e0d077c9c361232b7d104a7b408ef1"
"bytes": 362022,
"sha256": "ae79a88ee73c168b5fcf47e3fa22bb26069d8a27daf0ed7a2235c78210787402"
},
{
"path": "widgets-and-extensions.md",
Expand Down
6 changes: 5 additions & 1 deletion docs/open-interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,11 @@ row. Without one, it answers `409 DIRECTORY_SOURCE_UNREAD` when an earlier sourc
`409 DIRECTORY_SOURCE_CHANGED` when the package is installed from a different source. With one, it answers
`409 DIRECTORY_SOURCE_CHANGED` when another source owns the listing by now. The node records the source on the
installed generation (`directorySource`), and update notices come only from that source; a generation installed
before sources were recorded counts as installed from the index file. An install approval holds the source that owned
before sources were recorded counts as installed from the index file. The builds of the node's
[widget dev sessions](#widget-dev-sessions) are listed in front of every other source under their own source,
`widget-dev` (kind `widget-dev`), and only on this node, never in a search: an install whose `sourceId` names another
source answers `409 DIRECTORY_SOURCE_CHANGED` rather than taking a session's build of the same id and version. An
install approval holds the source that owned
the listing when the person was asked, and `POST /packages/approvals/{id}/decision` with `granted` answers
`409 DIRECTORY_SOURCE_CHANGED` when another source owns it by then. A listing from a marketplace installs through
exactly the same checks as one from a file.
Expand Down
5 changes: 4 additions & 1 deletion docs/open-interfaces.vi.md
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,10 @@ nguồn mà người dùng đã chọn khi bấm Cài trên một dòng. Không
nguồn đứng trước không đọc được, và `409 DIRECTORY_SOURCE_CHANGED` khi gói đang được cài từ một nguồn khác. Có nó,
route trả `409 DIRECTORY_SOURCE_CHANGED` khi giờ đây một nguồn khác sở hữu listing đó. Node ghi nguồn lên generation
đã cài (`directorySource`), và thông báo cập nhật chỉ đến từ chính nguồn đó; generation được cài trước khi nguồn được
ghi lại được coi là cài từ file index. Một yêu cầu chấp thuận cài đặt giữ nguồn sở hữu listing lúc người dùng được hỏi,
ghi lại được coi là cài từ file index. Các bản build của [phiên phát triển widget](#phiên-phát-triển-widget) trên node được
liệt kê trước mọi nguồn khác dưới nguồn riêng của chúng, `widget-dev` (kind `widget-dev`), và chỉ trên node này, không
bao giờ xuất hiện trong kết quả tìm kiếm: một lần cài có `sourceId` chỉ tới nguồn khác sẽ trả
`409 DIRECTORY_SOURCE_CHANGED` thay vì lấy bản build của phiên có cùng id và version. Một yêu cầu chấp thuận cài đặt giữ nguồn sở hữu listing lúc người dùng được hỏi,
và `POST /packages/approvals/{id}/decision` với `granted` trả `409 DIRECTORY_SOURCE_CHANGED` khi lúc đó một nguồn khác
đã sở hữu listing. Một listing từ marketplace được cài qua đúng những bước kiểm tra như listing từ file.

Expand Down
4 changes: 3 additions & 1 deletion docs/widget-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -2892,7 +2892,9 @@ widget"), or call `POST /widget-dev/sessions` as the owner
([open interfaces](open-interfaces.md#widget-dev-sessions)). The node watches the folder with the same build engine
the dev host uses: every change that reads as a package becomes an immutable generation named by the digest of its
files, is copied into the node's package cache and listed for this node alone, and is installed through the ordinary
install path. Nothing goes through npm, the Marketplace or a directory index, and nothing is published. A root
install path. Those listings name their own source, `widget-dev`, which the installed generation records, so pressing
Install on a marketplace row that lists the same id and version is refused rather than installing the session's files.
Nothing goes through npm, the Marketplace or a directory index, and nothing is published. A root
`node_modules` folder is left out of each build; built output such as `dist` is kept.

Clark develops widgets in its own widget workspace (`<dataDir>/widget-workspace`), where it scaffolds a new widget. To
Expand Down
4 changes: 3 additions & 1 deletion docs/widget-development.vi.md
Original file line number Diff line number Diff line change
Expand Up @@ -2878,7 +2878,9 @@ Dev host mô phỏng một node. Để xem widget trên chính node, hãy nhờ
hẹn giờ"), hoặc gọi `POST /widget-dev/sessions` với tư cách chủ máy
([giao diện mở](open-interfaces.vi.md#phiên-phát-triển-widget)). Node theo dõi thư mục bằng cùng build engine mà dev host
dùng: mỗi thay đổi đọc được thành một gói trở thành một generation bất biến mang tên theo digest các tệp của nó, được sao
chép vào bộ nhớ đệm gói của node, chỉ được liệt kê cho riêng node này, và được cài qua đường cài thông thường. Không có gì
chép vào bộ nhớ đệm gói của node, chỉ được liệt kê cho riêng node này, và được cài qua đường cài thông thường. Các listing
đó mang nguồn riêng, `widget-dev`, được ghi lên generation đã cài, nên bấm Cài trên một dòng marketplace liệt kê cùng id
và version sẽ bị từ chối thay vì cài các tệp của phiên. Không có gì
đi qua npm, Marketplace hay một directory index, và không có gì được phát hành. Thư mục `node_modules` ở gốc bị loại khỏi
mỗi lần dựng; đầu ra đã dựng như `dist` vẫn được giữ.

Expand Down
Loading
Loading