Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
tlgimenes committed Aug 21, 2024
1 parent c898d5b commit 067789a
Show file tree
Hide file tree
Showing 14 changed files with 536 additions and 33 deletions.
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,8 @@
},
"[typescriptreact]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"[html]": {
"editor.defaultFormatter": "vscode.html-language-features"
}
}
4 changes: 3 additions & 1 deletion daemon/async.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Mutex } from "@core/asyncutil/mutex";
import type { MiddlewareHandler } from "@hono/hono";

const createReadWriteLock = () => {
export const createReadWriteLock = () => {
const read = new Mutex();
const write = new Mutex();

Expand Down Expand Up @@ -37,6 +37,8 @@ const createReadWriteLock = () => {
return { wlock, rlock };
};

export type RwLock = ReturnType<typeof createReadWriteLock>;

export const createLocker = () => {
const lock = createReadWriteLock();

Expand Down
2 changes: 2 additions & 0 deletions daemon/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createAuth } from "./auth.ts";
import { createGitAPIS } from "./git.ts";
import { logs } from "./loggings/stream.ts";
import { createRealtimeAPIs } from "./realtime/app.ts";
import { createFSAPIs } from "./fs/api.ts";

export const DECO_SITE_NAME = Deno.env.get(ENV_SITE_NAME);
export const DECO_ENV_NAME = Deno.env.get("DECO_ENV_NAME");
Expand Down Expand Up @@ -71,6 +72,7 @@ export const createDaemonAPIs = (
});

app.route("/volumes/:id/files", createRealtimeAPIs());
app.route("/fs", createFSAPIs());

return async (c, next) => {
const isDaemonAPI = c.req.header(DAEMON_API_SPECIFIER) ??
Expand Down
352 changes: 352 additions & 0 deletions daemon/fs/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,352 @@
import * as colors from "@std/fmt/colors";
import { ensureFile, walk } from "@std/fs";
import { join, SEPARATOR } from "@std/path";
import { createReadWriteLock, type RwLock } from "deco/daemon/async.ts";
import type { StatusResult } from "simple-git";
import { Hono } from "../../runtime/deps.ts";
import { sha1 } from "../../utils/sha1.ts";
import { git, lockerGitAPI } from "../git.ts";
import {
applyPatch,
type FSEvent,
type Metadata,
type Patch,
type UpdateResponse,
} from "./common.ts";

const WELL_KNOWN_BLOCK_TYPES = new Set([
"accounts",
"actions",
"apps",
"flags",
"handlers",
"loaders",
"matchers",
"pages",
"sections",
"workflows",
]);

const inferBlockType = (resolveType: string) =>
resolveType.split("/").find((s) => WELL_KNOWN_BLOCK_TYPES.has(s));

const inferMetadata = async (filepath: string): Promise<Metadata | null> => {
try {
const { __resolveType, name, path } = JSON.parse(
await Deno.readTextFile(filepath),
);
const blockType = inferBlockType(__resolveType);

if (!blockType) {
return { kind: "file" };
}

if (blockType === "pages") {
return {
kind: "block",
name: name,
path: path,
blockType,
__resolveType,
};
}

return {
kind: "block",
blockType,
__resolveType,
};
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return null;
}
return { kind: "file" };
}
};

const stat = async (filepath: string) => {
try {
const stats = await Deno.stat(filepath);

return stats;
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return { mtime: new Date() };
}
throw error;
}
};

export interface ListAPI {
response: {
metas: Array<{
filepath: string;
metadata: Metadata;
timestamp: number;
}>;
status: StatusResult;
};
}

export interface ReadAPI {
response: {
content: string;
metadata: Metadata;
timestamp: number;
};
}

export interface PatchAPI {
response: UpdateResponse;
body: { patch: Patch; hash: string };
}

export interface DeleteAPI {
response: UpdateResponse;
}

const shouldIgnore = (path: string) =>
path.includes("/.git/") ||
path.includes("/node_modules/");

export const createFSAPIs = () => {
const app = new Hono();
const cwd = Deno.cwd();

const sockets: WebSocket[] = [];

const lockByPath = new Map<string, RwLock>();
const getRwLock = (filepath: string) => {
if (!lockByPath.has(filepath)) {
lockByPath.set(filepath, createReadWriteLock());
}

return lockByPath.get(filepath);
};

const send = (msg: FSEvent, socket: WebSocket) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(msg));
}
};

const broadcast = (msg: FSEvent) => {
const msgStr = JSON.stringify(msg);
for (const socket of sockets) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(msgStr);
}
}
};

const watchFS = async () => {
const watcher = Deno.watchFs(cwd, { recursive: true });

for await (const { kind, paths } of watcher) {
if (kind !== "create" && kind !== "remove" && kind !== "modify") {
continue;
}

const [filepath] = paths;

if (shouldIgnore(filepath)) {
continue;
}

const [status, metadata, stats] = await Promise.all([
git.status(),
inferMetadata(filepath),
stat(filepath),
]);

broadcast({
type: "sync",
detail: {
status,
metadata,
timestamp: stats.mtime?.getTime() ?? Date.now(),
filepath: browserPathFromSystem(filepath),
},
});
}
};

app.use(lockerGitAPI.rlock);

app.get("/watch", (c) => {
const since = Number(c.req.query("since"));

if (c.req.header("Upgrade") !== "websocket") {
return new Response("Missing header Upgrade: websocket ", {
status: 400,
});
}

const { response, socket } = Deno.upgradeWebSocket(c.req.raw);

socket.addEventListener(
"close",
() => {
const index = sockets.findIndex((s) => s === socket);
console.log(
colors.bold(`[admin.deco.cx][${index}]:`),
"socket is",
colors.red("closed"),
);

if (index > -1) {
sockets.splice(index, 1);
}
},
);

socket.addEventListener("open", async () => {
const index = sockets.findIndex((s) => s === socket);
console.log(
colors.bold(`[admin.deco.cx][${index}]:`),
"socket is",
colors.green("open"),
);

const walker = walk(cwd, { includeDirs: false, includeFiles: true });

for await (const entry of walker) {
if (shouldIgnore(entry.path)) {
continue;
}

const [metadata, stats] = await Promise.all([
inferMetadata(entry.path),
stat(entry.path),
]);

const mtime = stats.mtime?.getTime() ?? Date.now();

if (
!metadata || mtime < since
) {
continue;
}

const filepath = browserPathFromSystem(entry.path);
send({
type: "sync",
detail: { metadata, filepath, timestamp: mtime },
}, socket);
}

if (socket.readyState !== WebSocket.OPEN) {
return;
}

send({
type: "snapshot",
detail: { timestamp: Date.now(), status: await git.status() },
}, socket);
});

sockets.push(socket);

return response;
});

const systemPathFromBrowser = (url: string) => {
const [_, ...segments] = url.split("/file");
const s = segments.join("/file");

return join(cwd, "/", s);
};

const browserPathFromSystem = (filepath: string) =>
filepath.replace(cwd, "").replaceAll(SEPARATOR, "/");

app.get("/file/*", async (c) => {
const filepath = systemPathFromBrowser(c.req.raw.url);
using _ = await getRwLock(filepath)?.rlock();

try {
const [
metadata,
content,
stats,
] = await Promise.all([
inferMetadata(filepath),
Deno.readTextFile(filepath),
Deno.stat(filepath),
]);

const timestamp = stats.mtime?.getTime() ?? Date.now();

return c.json({ content, metadata, timestamp });
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
c.status(404);
return c.json({ timestamp: Date.now() });
}
throw error;
}
});

app.patch("/file/*", async (c) => {
const filepath = systemPathFromBrowser(c.req.raw.url);
const { patch, hash } = await c.req.json<PatchAPI["body"]>();
using _ = await getRwLock(filepath)?.wlock();

const content = await Deno.readTextFile(filepath).catch((e) => {
if (e instanceof Deno.errors.NotFound) {
return null;
}
throw e;
});

const result = applyPatch(content, patch);

if (!result.conflict) {
await ensureFile(filepath);
await Deno.writeTextFile(filepath, result.content);
}

const [status, metadata, stats] = await Promise.all([
git.status(),
inferMetadata(filepath),
Deno.stat(filepath),
]);

const timestamp = stats.mtime?.getTime() ?? Date.now();

const update: UpdateResponse = result.conflict
? { conflict: true, status, metadata, timestamp, content }
: {
conflict: false,
status,
metadata,
timestamp,
content: hash !== await sha1(result.content)
? result.content
: undefined,
};

return c.json(update);
});

app.delete("/file/*", async (c) => {
const filepath = systemPathFromBrowser(c.req.raw.url);
using _ = await getRwLock(filepath)?.wlock();

await Deno.remove(filepath);

const update: UpdateResponse = {
conflict: false,
status: await git.status(),
metadata: null,
timestamp: Date.now(),
content: undefined,
};

return c.json(update);
});

watchFS().catch(console.error);

return app;
};
Loading

0 comments on commit 067789a

Please sign in to comment.