@shivaedev/work-board 0.4.0 → 0.5.0

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.
Files changed (119) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +27 -16
  3. package/assets/native.js +36 -0
  4. package/dist/board.d.ts +1 -1
  5. package/dist/board.d.ts.map +1 -1
  6. package/dist/board.js +38 -8
  7. package/dist/board.js.map +1 -1
  8. package/dist/browser/client.d.ts +88 -0
  9. package/dist/browser/client.d.ts.map +1 -0
  10. package/dist/browser/client.js +53 -0
  11. package/dist/browser/client.js.map +1 -0
  12. package/dist/browser/session.d.ts +89 -0
  13. package/dist/browser/session.d.ts.map +1 -0
  14. package/dist/browser/session.js +20 -0
  15. package/dist/browser/session.js.map +1 -0
  16. package/dist/cli.js +1 -1
  17. package/dist/files/changes.d.ts +4 -1
  18. package/dist/files/changes.d.ts.map +1 -1
  19. package/dist/files/changes.js +7 -3
  20. package/dist/files/changes.js.map +1 -1
  21. package/dist/files/list.d.ts +1 -1
  22. package/dist/files/list.d.ts.map +1 -1
  23. package/dist/files/list.js +5 -3
  24. package/dist/files/list.js.map +1 -1
  25. package/dist/history/schema.js +1 -1
  26. package/dist/history/schema.js.map +1 -1
  27. package/dist/history/script.d.ts.map +1 -1
  28. package/dist/history/script.js +28 -13
  29. package/dist/history/script.js.map +1 -1
  30. package/dist/http/assets.d.ts +2 -0
  31. package/dist/http/assets.d.ts.map +1 -1
  32. package/dist/http/assets.js +10 -0
  33. package/dist/http/assets.js.map +1 -1
  34. package/dist/http/history.d.ts.map +1 -1
  35. package/dist/http/history.js +1 -13
  36. package/dist/http/history.js.map +1 -1
  37. package/dist/http/sameOrigin.d.ts +3 -0
  38. package/dist/http/sameOrigin.d.ts.map +1 -0
  39. package/dist/http/sameOrigin.js +14 -0
  40. package/dist/http/sameOrigin.js.map +1 -0
  41. package/dist/page/ageScript.d.ts +2 -0
  42. package/dist/page/ageScript.d.ts.map +1 -0
  43. package/dist/page/ageScript.js +23 -0
  44. package/dist/page/ageScript.js.map +1 -0
  45. package/dist/page/client.d.ts +1 -1
  46. package/dist/page/client.d.ts.map +1 -1
  47. package/dist/page/client.js +73 -74
  48. package/dist/page/client.js.map +1 -1
  49. package/dist/page/navigationScript.d.ts.map +1 -1
  50. package/dist/page/navigationScript.js +7 -5
  51. package/dist/page/navigationScript.js.map +1 -1
  52. package/dist/page/searchScript.d.ts.map +1 -1
  53. package/dist/page/searchScript.js +6 -4
  54. package/dist/page/searchScript.js.map +1 -1
  55. package/dist/page/shell.js +1 -1
  56. package/dist/page/shell.js.map +1 -1
  57. package/dist/page/stateScript.js +1 -1
  58. package/dist/page/style.d.ts.map +1 -1
  59. package/dist/page/style.js +1 -0
  60. package/dist/page/style.js.map +1 -1
  61. package/dist/page/swap.d.ts +1 -1
  62. package/dist/page/swap.d.ts.map +1 -1
  63. package/dist/page/swap.js +16 -5
  64. package/dist/page/swap.js.map +1 -1
  65. package/dist/path/isMarkdown.d.ts +2 -0
  66. package/dist/path/isMarkdown.d.ts.map +1 -0
  67. package/dist/path/isMarkdown.js +7 -0
  68. package/dist/path/isMarkdown.js.map +1 -0
  69. package/dist/rpc/affected.d.ts +4 -0
  70. package/dist/rpc/affected.d.ts.map +1 -0
  71. package/dist/rpc/affected.js +75 -0
  72. package/dist/rpc/affected.js.map +1 -0
  73. package/dist/rpc/contract.d.ts +134 -0
  74. package/dist/rpc/contract.d.ts.map +1 -0
  75. package/dist/rpc/contract.js +59 -0
  76. package/dist/rpc/contract.js.map +1 -0
  77. package/dist/rpc/hints.d.ts +17 -0
  78. package/dist/rpc/hints.d.ts.map +1 -0
  79. package/dist/rpc/hints.js +28 -0
  80. package/dist/rpc/hints.js.map +1 -0
  81. package/dist/rpc/keys.d.ts +7 -0
  82. package/dist/rpc/keys.d.ts.map +1 -0
  83. package/dist/rpc/keys.js +9 -0
  84. package/dist/rpc/keys.js.map +1 -0
  85. package/dist/rpc/pageKeys.d.ts +3 -0
  86. package/dist/rpc/pageKeys.d.ts.map +1 -0
  87. package/dist/rpc/pageKeys.js +14 -0
  88. package/dist/rpc/pageKeys.js.map +1 -0
  89. package/dist/rpc/server.d.ts +12 -0
  90. package/dist/rpc/server.d.ts.map +1 -0
  91. package/dist/rpc/server.js +32 -0
  92. package/dist/rpc/server.js.map +1 -0
  93. package/package.json +9 -4
  94. package/src/board.ts +44 -9
  95. package/src/browser/client.ts +59 -0
  96. package/src/browser/session.ts +26 -0
  97. package/src/cli.ts +1 -1
  98. package/src/files/changes.ts +14 -4
  99. package/src/files/list.ts +5 -4
  100. package/src/history/schema.ts +1 -1
  101. package/src/history/script.ts +28 -13
  102. package/src/http/assets.ts +11 -0
  103. package/src/http/history.ts +1 -12
  104. package/src/http/sameOrigin.ts +14 -0
  105. package/src/page/ageScript.ts +22 -0
  106. package/src/page/client.ts +73 -74
  107. package/src/page/navigationScript.ts +7 -5
  108. package/src/page/searchScript.ts +6 -4
  109. package/src/page/shell.ts +1 -1
  110. package/src/page/stateScript.ts +1 -1
  111. package/src/page/style.ts +1 -0
  112. package/src/page/swap.ts +16 -5
  113. package/src/path/isMarkdown.ts +6 -0
  114. package/src/rpc/affected.ts +77 -0
  115. package/src/rpc/contract.ts +62 -0
  116. package/src/rpc/hints.ts +34 -0
  117. package/src/rpc/keys.ts +9 -0
  118. package/src/rpc/pageKeys.ts +14 -0
  119. package/src/rpc/server.ts +55 -0
@@ -0,0 +1,32 @@
1
+ import { Clock, Effect, Layer, Ref } from "effect";
2
+ import { HttpIncomingMessage, HttpServerResponse } from "effect/unstable/http";
3
+ import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
4
+ import { MAX_BYTES } from "#history/limits.ts";
5
+ import { historyResponse } from "#history/response.ts";
6
+ import { HEADERS } from "#http/respond.ts";
7
+ import { navHtml } from "#page/nav.ts";
8
+ import { searchMatch } from "#search/match.ts";
9
+ import { ReadFailed, workRpcs } from "./contract.js";
10
+ import { hints } from "./hints.js";
11
+ export const server = Effect.fn("WorkBoard.server")(function* ({ changes, home, index, page }) {
12
+ const handlers = workRpcs.toLayer({
13
+ "work-board.history": ({ action, baseline }) => Effect.gen(function* () {
14
+ const now = yield* Clock.currentTimeMillis;
15
+ const snapshot = action === "check" ? undefined : yield* index;
16
+ return historyResponse(baseline, now, snapshot);
17
+ }).pipe(Effect.catch(() => Effect.fail(new ReadFailed({ operation: "history", status: 500 })))),
18
+ "work-board.navigation": () => Effect.map(changes.files, (files) => navHtml(files, "", home)).pipe(Effect.catch(() => Effect.fail(new ReadFailed({ operation: "navigation", status: 500 })))),
19
+ "work-board.page": ({ url }) => page(url).pipe(Effect.flatMap((response) => {
20
+ if (response.status >= 500 || response.body._tag !== "Uint8Array") {
21
+ return Effect.fail(new ReadFailed({ operation: "page", status: response.status }));
22
+ }
23
+ return Effect.succeed({ html: new TextDecoder().decode(response.body.body), status: response.status });
24
+ }), Effect.catch((error) => Effect.fail(error instanceof ReadFailed ? error : new ReadFailed({ operation: "page", status: 500 })))),
25
+ "work-board.search": ({ query }) => Effect.map(index, (snapshot) => ({ ...searchMatch(snapshot.entries, query), unavailable: snapshot.unavailable })).pipe(Effect.catch(() => Effect.fail(new ReadFailed({ operation: "search", status: 500 })))),
26
+ "work-board.subscribe": () => hints(changes, index),
27
+ "work-board.watcher": () => Effect.map(Ref.get(changes.watching), (watching) => ({ watching })),
28
+ });
29
+ const handler = yield* RpcServer.toHttpEffect(workRpcs).pipe(Effect.provide(Layer.merge(handlers, RpcSerialization.layerNdjson)));
30
+ return handler.pipe(Effect.provideService(HttpIncomingMessage.MaxBodySize, BigInt(4 * MAX_BYTES + 4096)), Effect.map((response) => HttpServerResponse.setHeaders(response, HEADERS)));
31
+ });
32
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../../src/rpc/server.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/work-board",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Serve a folder of markdown files as a live local page that updates in place when a file changes",
5
5
  "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/work-board#readme",
6
6
  "bugs": {
@@ -37,6 +37,7 @@
37
37
  "files": [
38
38
  "dist",
39
39
  "src",
40
+ "assets",
40
41
  "!src/**/*.test.ts",
41
42
  "!src/**/*.spec.ts",
42
43
  "!src/**/test-support",
@@ -44,6 +45,7 @@
44
45
  "README.md"
45
46
  ],
46
47
  "dependencies": {
48
+ "@shivaedev/effect-contract": "0.4.0",
47
49
  "mermaid": "11.16.1",
48
50
  "satteri": "0.9.5",
49
51
  "shiki": "4.4.2",
@@ -52,12 +54,13 @@
52
54
  "devDependencies": {
53
55
  "@effect/platform-node": "4.0.0-rc.112",
54
56
  "@effect/platform-node-shared": "4.0.0-rc.112",
55
- "@shivaedev/quality": "0.14.0",
57
+ "@shivaedev/quality": "0.16.0",
56
58
  "@types/hast": "3.0.5",
57
59
  "@types/mdast": "4.0.4",
58
60
  "@types/node": "24.10.1",
59
61
  "@typescript/native": "npm:typescript@7.0.2",
60
62
  "effect": "4.0.0-rc.112",
63
+ "esbuild": "0.28.1",
61
64
  "happy-dom": "20.11.2",
62
65
  "typescript": "npm:@typescript/typescript6@6.0.2",
63
66
  "vitest": "4.1.11"
@@ -75,9 +78,11 @@
75
78
  "provenance": true
76
79
  },
77
80
  "scripts": {
78
- "build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc6 --project tsconfig.build.json",
81
+ "build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc6 --project tsconfig.build.json && pnpm build:browser",
82
+ "build:browser": "esbuild src/browser/session.ts --bundle --platform=browser --format=esm --conditions=source --minify --outfile=assets/native.js",
79
83
  "ready": "pnpm typecheck && pnpm test && pnpm build",
80
- "test": "vitest run",
84
+ "test": "pnpm test:prepare && vitest run",
85
+ "test:prepare": "pnpm build:browser",
81
86
  "typecheck": "tsc --noEmit"
82
87
  }
83
88
  }
package/src/board.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { Effect, type FileSystem, Layer, type Path, type PlatformError } from "effect";
2
- import { HttpRouter, type HttpServerRequest, type HttpServerResponse } from "effect/unstable/http";
1
+ import { Context, Effect, FileSystem, Layer, Path, type PlatformError, type Scope } from "effect";
2
+ import { HttpRouter, HttpServerRequest, type HttpServerResponse } from "effect/unstable/http";
3
3
  import { watchChanges } from "#files/changes.ts";
4
4
  import { type HomeMissing, homeIn } from "#files/home.ts";
5
- import { ASSETS, MERMAID_ROUTE, type MermaidMissing, mermaidFile, mermaidRoot } from "#http/assets.ts";
5
+ import { ASSETS, MERMAID_ROUTE, type MermaidMissing, mermaidFile, mermaidRoot, NATIVE_ROUTE, nativeAsset } from "#http/assets.ts";
6
6
  import { events } from "#http/events.ts";
7
7
  import { changesPage, history } from "#http/history.ts";
8
8
  import { identity } from "#http/identity.ts";
@@ -10,10 +10,12 @@ import { loopbackOnly } from "#http/loopback.ts";
10
10
  import { overview } from "#http/overview.ts";
11
11
  import { page } from "#http/page.ts";
12
12
  import { respond } from "#http/respond.ts";
13
+ import { sameOrigin } from "#http/sameOrigin.ts";
13
14
  import { search } from "#http/search.ts";
14
15
  import { work } from "#http/work.ts";
15
16
  import type { RenderFailed } from "#render/failed.ts";
16
17
  import { Highlighter } from "#render/highlighter.ts";
18
+ import { server } from "#rpc/server.ts";
17
19
  import { searchSnapshot } from "#search/snapshot.ts";
18
20
 
19
21
  export interface BoardOptions {
@@ -22,7 +24,7 @@ export interface BoardOptions {
22
24
  }
23
25
 
24
26
  type Services = Highlighter | FileSystem.FileSystem | Path.Path;
25
- type Handler<E> = (request: HttpServerRequest.HttpServerRequest) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, Services>;
27
+ type Handler<E> = (request: HttpServerRequest.HttpServerRequest) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, Services | Scope.Scope>;
26
28
 
27
29
  const routes = (options: BoardOptions) =>
28
30
  HttpRouter.use((router) =>
@@ -30,7 +32,7 @@ const routes = (options: BoardOptions) =>
30
32
  const home = yield* homeIn(options.root, options.home);
31
33
  const mermaid = yield* mermaidRoot();
32
34
  const changes = yield* watchChanges(options.root);
33
- const context = yield* Effect.context<Services>();
35
+ const context = (yield* Effect.context<Services>()).pipe(Context.pick(Highlighter, FileSystem.FileSystem, Path.Path));
34
36
  const serve = <E>(route: HttpRouter.PathInput, handler: Handler<E>, method: "GET" | "POST" = "GET") =>
35
37
  router.add(
36
38
  method,
@@ -40,16 +42,49 @@ const routes = (options: BoardOptions) =>
40
42
  yield* serve("/events", events(changes));
41
43
  const index = yield* searchSnapshot(options.root, home, changes);
42
44
  yield* serve("/_board/search", search(index));
43
- yield* serve("/_board/work", work(index, changes, home));
44
- yield* serve("/_board/overview", overview(index, changes, home));
45
- yield* serve("/_board/changes", changesPage(changes, home));
45
+ const workPage = work(index, changes, home);
46
+ const overviewPage = overview(index, changes, home);
47
+ const historyPage = changesPage(changes, home);
48
+ yield* serve("/_board/work", workPage);
49
+ yield* serve("/_board/overview", overviewPage);
50
+ yield* serve("/_board/changes", historyPage);
46
51
  yield* serve("/_board/history", history(index), "POST");
47
52
  for (const [route, body, contentType] of ASSETS) {
48
53
  yield* serve(route, () => Effect.succeed(respond(body, contentType)));
49
54
  }
50
55
  yield* serve(MERMAID_ROUTE, mermaidFile(mermaid));
56
+ yield* serve(NATIVE_ROUTE, nativeAsset);
51
57
  const pages = page({ home, root: options.root }, changes, index);
52
- yield* serve("/_board/item/*", identity(index, pages, changes, home));
58
+ const items = identity(index, pages, changes, home);
59
+ yield* serve("/_board/item/*", items);
60
+ function readPage(url: string) {
61
+ const request = HttpServerRequest.fromWeb(new Request(`http://127.0.0.1${url}`));
62
+ const pathname = new URL(url, "http://127.0.0.1").pathname;
63
+ let selected: ReturnType<typeof pages>;
64
+ switch (pathname) {
65
+ case "/_board/work":
66
+ selected = workPage(request);
67
+ break;
68
+ case "/_board/overview":
69
+ selected = overviewPage();
70
+ break;
71
+ case "/_board/changes":
72
+ selected = historyPage();
73
+ break;
74
+ default:
75
+ selected = pathname.startsWith("/_board/item/") ? items(request) : pages(request);
76
+ }
77
+ return Effect.provideContext(selected, context);
78
+ }
79
+ const rpc = yield* server({ changes, home, index: Effect.provideContext(index, context), page: readPage });
80
+ yield* serve(
81
+ "/_board/rpc",
82
+ (request) =>
83
+ sameOrigin(request)
84
+ ? rpc.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request))
85
+ : Effect.succeed(respond("Local same-origin requests only", "text/plain", 403)),
86
+ "POST",
87
+ );
53
88
  yield* serve("/", pages);
54
89
  yield* serve("/*", pages);
55
90
  }),
@@ -0,0 +1,59 @@
1
+ import { Effect, Layer, Stream } from "effect";
2
+ import { FetchHttpClient } from "effect/unstable/http";
3
+ import type * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4
+ import type * as Atom from "effect/unstable/reactivity/Atom";
5
+ import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry";
6
+ import * as AtomRpc from "effect/unstable/reactivity/AtomRpc";
7
+ import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
8
+ import { bind } from "@shivaedev/effect-contract/bind.ts";
9
+ import { live } from "@shivaedev/effect-contract/live.ts";
10
+ import { type ResumeOptions, resumeSignal } from "@shivaedev/effect-contract/resume.ts";
11
+ import { workContract, workRpcs } from "#rpc/contract.ts";
12
+ import { workspace } from "#rpc/keys.ts";
13
+
14
+ export interface Reader {
15
+ readonly reader: unique symbol;
16
+ }
17
+
18
+ export function browserClient(url: string, options: ResumeOptions = {}) {
19
+ class Client extends AtomRpc.Service<Reader>()("WorkBoard/Reader", {
20
+ group: workRpcs,
21
+ protocol: RpcClient.layerProtocolHttp({ url: `${url}/_board/rpc` }).pipe(Layer.provide([FetchHttpClient.layer, RpcSerialization.layerNdjson])),
22
+ }) {}
23
+ const api = bind(workContract, Client);
24
+ const registry = AtomRegistry.make({ "defaultIdleTTL": 0, timeoutResolution: 1 });
25
+ const updates = live(Client.runtime, {
26
+ resume: resumeSignal(options),
27
+ resyncKeys: [workspace.list],
28
+ stream: Stream.unwrap(Client.use((client) => Effect.succeed(client("work-board.subscribe", undefined)))),
29
+ });
30
+ registry.mount(updates.connection);
31
+ async function read<A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>, signal?: AbortSignal) {
32
+ try {
33
+ return await Effect.runPromise(AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true }), { signal });
34
+ } catch (error) {
35
+ if (signal?.aborted) {
36
+ const canceled = new Error("Reading canceled", { cause: error });
37
+ canceled.name = "AbortError";
38
+ throw canceled;
39
+ }
40
+ throw error;
41
+ }
42
+ }
43
+ async function run<A, E>(effect: Effect.Effect<A, E, Reader>, signal?: AbortSignal) {
44
+ try {
45
+ return await Effect.runPromise(
46
+ Effect.flatMap(AtomRegistry.getResult(registry, Client.runtime), (context) => Effect.provideContext(effect, context)),
47
+ { signal },
48
+ );
49
+ } catch (error) {
50
+ if (signal?.aborted) {
51
+ const canceled = new Error("Reading canceled", { cause: error });
52
+ canceled.name = "AbortError";
53
+ throw canceled;
54
+ }
55
+ throw error;
56
+ }
57
+ }
58
+ return { api, read, registry, run, updates };
59
+ }
@@ -0,0 +1,26 @@
1
+ import type { ResumeWindow } from "@shivaedev/effect-contract/resume.ts";
2
+ import { browserClient } from "./client.ts";
3
+
4
+ type BrowserEnvironment = ResumeWindow & {
5
+ readonly location: { readonly origin: string };
6
+ readonly addEventListener: (type: "pagehide", listener: () => void) => void;
7
+ readonly removeEventListener: (type: "pagehide", listener: () => void) => void;
8
+ };
9
+ const sessions = new WeakMap<BrowserEnvironment, ReturnType<typeof browserClient>>();
10
+ export function session(environment: BrowserEnvironment) {
11
+ let client = sessions.get(environment);
12
+ if (client === undefined) {
13
+ client = browserClient(environment.location.origin, { window: environment });
14
+ sessions.set(environment, client);
15
+ const owned = client;
16
+ function dispose() {
17
+ owned.registry.dispose();
18
+ if (sessions.get(environment) === owned) {
19
+ sessions.delete(environment);
20
+ }
21
+ environment.removeEventListener("pagehide", dispose);
22
+ }
23
+ environment.addEventListener("pagehide", dispose);
24
+ }
25
+ return client;
26
+ }
package/src/cli.ts CHANGED
@@ -4,7 +4,7 @@ import { Effect, Layer, Option, Path } from "effect";
4
4
  import { Argument, Command, Flag } from "effect/unstable/cli";
5
5
  import { HOST, serveBoard } from "./serve.ts";
6
6
 
7
- const VERSION = "0.4.0";
7
+ const VERSION = "0.5.0";
8
8
 
9
9
  const serve = Effect.fn("WorkBoard.serve")(function* (input: { readonly dir: string; readonly port: number; readonly home: Option.Option<string> }) {
10
10
  const path = yield* Path.Path;
@@ -2,13 +2,16 @@ import { Deferred, Effect, FileSystem, Option, Path, type PlatformError, PubSub,
2
2
  import { listMarkdown, type MarkdownFile } from "./list.ts";
3
3
  import { watchDirectories } from "./watchDirectories.ts";
4
4
 
5
- export type Change = { readonly _tag: "Changed"; readonly paths: readonly string[] } | { readonly _tag: "Watching"; readonly watching: boolean };
5
+ export type Change = ({ readonly _tag: "Changed"; readonly paths: readonly string[] } | { readonly _tag: "Watching"; readonly watching: boolean }) & {
6
+ readonly sequence?: number;
7
+ };
6
8
 
7
9
  export interface Changes {
8
10
  readonly events: PubSub.PubSub<Change>;
9
11
  readonly files: Effect.Effect<readonly MarkdownFile[], PlatformError.PlatformError>;
10
12
  readonly realRoot: string;
11
13
  readonly revision: Effect.Effect<number>;
14
+ readonly sequence?: Effect.Effect<number>;
12
15
  readonly watching: Ref.Ref<boolean>;
13
16
  }
14
17
 
@@ -27,17 +30,24 @@ export const watchChanges = Effect.fn("WorkBoard.watchChanges")(function* (root:
27
30
  const events = yield* Effect.acquireRelease(PubSub.sliding<Change>(BACKLOG), PubSub.shutdown);
28
31
  const watching = yield* Ref.make(false);
29
32
  const generation = yield* Ref.make(0);
33
+ const sequence = yield* Ref.make(0);
34
+ function publish(change: Change) {
35
+ return Effect.flatMap(
36
+ Ref.updateAndGet(sequence, (value) => value + 1),
37
+ (value) => PubSub.publish(events, { ...change, sequence: value }),
38
+ );
39
+ }
30
40
  const cached = yield* Ref.make(Option.none<Cached>());
31
41
  const settled = yield* Deferred.make<void>();
32
42
  const stale = Ref.update(generation, (count) => count + 1);
33
43
  const setWatching = (now: boolean) =>
34
44
  Effect.andThen(
35
45
  Effect.flatMap(Ref.getAndSet(watching, now), (was) =>
36
- was === now ? Effect.void : Effect.andThen(stale, PubSub.publish(events, { _tag: "Watching", watching: now })),
46
+ was === now ? Effect.void : Effect.andThen(stale, publish({ _tag: "Watching", watching: now })),
37
47
  ),
38
48
  Deferred.succeed(settled, undefined),
39
49
  );
40
- const attempt = watchDirectories(root, realRoot, stale, setWatching, (paths) => PubSub.publish(events, { _tag: "Changed", paths }));
50
+ const attempt = watchDirectories(root, realRoot, stale, setWatching, (paths) => publish({ _tag: "Changed", paths }));
41
51
  yield* attempt.pipe(
42
52
  Effect.sandbox,
43
53
  Effect.tapError((cause) => Effect.andThen(setWatching(false), Effect.logWarning("Watching the folder failed; retrying", cause))),
@@ -55,6 +65,6 @@ export const watchChanges = Effect.fn("WorkBoard.watchChanges")(function* (root:
55
65
  yield* Ref.set(cached, Option.some({ files: listed, generation: now }));
56
66
  return listed;
57
67
  });
58
- const changes: Changes = { events, files, realRoot, revision: Ref.get(generation), watching };
68
+ const changes: Changes = { events, files, realRoot, revision: Ref.get(generation), sequence: Ref.get(sequence), watching };
59
69
  return changes;
60
70
  });
package/src/files/list.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import { Effect, FileSystem, Option, Path, type PlatformError } from "effect";
2
+ import { isMarkdown as markdown } from "#path/isMarkdown.ts";
2
3
 
3
4
  export interface MarkdownFile {
4
5
  readonly modified: number;
5
6
  readonly path: string;
6
7
  }
7
8
 
8
- const skipped = (segment: string): boolean => segment.startsWith(".") || segment === "node_modules";
9
-
10
- export const isMarkdown = (path: string): boolean => path.endsWith(".md") && !path.split("/").some(skipped);
9
+ export function isMarkdown(path: string): boolean {
10
+ return markdown(path);
11
+ }
11
12
 
12
13
  export const within = (realRoot: string, separator: string, real: string): boolean => real === realRoot || real.startsWith(`${realRoot}${separator}`);
13
14
 
@@ -52,7 +53,7 @@ export const scanMarkdown = Effect.fn("WorkBoard.scanMarkdown")(function* (root:
52
53
  const branch = new Set([...ancestors, real]);
53
54
  const names = yield* fs.readDirectory(real);
54
55
  const found = yield* Effect.forEach(
55
- names.filter((name) => !skipped(name)),
56
+ names.filter((name) => !name.startsWith(".") && name !== "node_modules"),
56
57
  (name) => entry(relative === "" ? name : `${relative}/${name}`, boundary, branch, real),
57
58
  );
58
59
  return found.flat();
@@ -1,5 +1,5 @@
1
1
  import { Schema } from "effect";
2
- import { isMarkdown } from "#files/list.ts";
2
+ import { isMarkdown } from "#path/isMarkdown.ts";
3
3
 
4
4
  const File = Schema.String.check(
5
5
  Schema.makeFilter(
@@ -2,12 +2,14 @@ export function historyScript(): string {
2
2
  return `
3
3
  import { swap, remember } from "/_board/swap.js";
4
4
  import { captureReading, restoreReading } from "/_board/reading-state.js";
5
+ import { session } from "/_board/native.js";
5
6
  const key = "work-board:changes:" + document.documentElement.dataset.workspace;
6
7
  let memory;
7
8
  let storageNotice = "";
8
9
  let unavailableHtml = "";
9
10
  let generation = 0;
10
11
  let controller;
12
+ let observing = null;
11
13
  const baseline = () => {
12
14
  if (memory !== undefined) return memory;
13
15
  try { return localStorage.getItem(key); }
@@ -52,22 +54,23 @@ const query = async (action) => {
52
54
  controller?.abort();
53
55
  controller = new AbortController();
54
56
  const raw = baseline();
55
- const response = await fetch("/_board/history", {
56
- method: "POST", headers: { "content-type": "application/json" },
57
- body: JSON.stringify({ action, baseline: raw }), signal: controller.signal, cache: "no-store",
58
- });
59
- if (!response.ok) throw new Error("History query failed");
60
- const result = await response.json();
57
+ const native = session(window);
58
+ const payload = { action, baseline: raw };
59
+ const atom = native.api.history.query(payload);
60
+ if (action !== "observe") native.registry.refresh(atom);
61
+ const result = action === "observe" ? await native.run(native.api.history.run(payload), controller.signal) : await native.read(atom, controller.signal);
61
62
  if (mine !== generation) return null;
62
63
  if (result.discardBaseline && baseline() === raw) { forget(); unavailableHtml = result.html; }
63
64
  return result;
64
65
  };
65
- const refresh = async () => {
66
- const visible = document.getElementById("doc")?.dataset.view === "changes";
66
+ const refresh = async (manual = false) => {
67
+ if (observing !== null) return;
68
+ const paused = session(window).registry.get(session(window).updates.paused) && manual !== true;
69
+ const visible = document.getElementById("doc")?.dataset.view === "changes" && !paused;
67
70
  try {
68
71
  const result = await query(visible ? "compare" : "check");
69
72
  if (!result) return;
70
- if (visible && document.getElementById("doc")?.dataset.view === "changes") {
73
+ if ((visible || result.discardBaseline) && document.getElementById("doc")?.dataset.view === "changes") {
71
74
  render(baseline() === null && unavailableHtml ? unavailableHtml : result.html);
72
75
  controls(result.snapshot !== null);
73
76
  }
@@ -81,13 +84,17 @@ const refresh = async () => {
81
84
  };
82
85
  document.addEventListener("click", async (event) => {
83
86
  if (event.target.closest?.("#history-clear")) {
87
+ observing = null;
84
88
  ++generation;
85
89
  controller?.abort();
86
90
  forget();
87
91
  unavailableHtml = "";
88
- await refresh();
92
+ await refresh(true);
89
93
  }
90
94
  if (event.target.closest?.("#history-mark")) {
95
+ if (observing !== null) return;
96
+ const mine = generation + 1;
97
+ observing = mine;
91
98
  controls(false, true);
92
99
  try {
93
100
  const result = await query("observe");
@@ -95,16 +102,24 @@ document.addEventListener("click", async (event) => {
95
102
  if (result.snapshot === null) { render(result.html); controls(false); return; }
96
103
  keep(result.snapshot);
97
104
  unavailableHtml = "";
98
- await refresh();
99
105
  } catch (error) {
100
106
  if (error.name !== "AbortError") { render("<p>Could not remember the current observation. Your previous baseline is preserved.</p>"); controls(false); }
101
- }
107
+ return;
108
+ } finally { if (observing === mine) observing = null; }
109
+ await refresh(true);
102
110
  }
103
111
  });
112
+ window.addEventListener("pagehide", () => { ++generation; observing = null; controller?.abort(); });
104
113
  document.addEventListener("board-page", refresh);
114
+ document.addEventListener("board-updates-resumed", refresh);
105
115
  document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
106
116
  window.addEventListener("storage", (event) => {
107
- if (event.key === key || event.key === null) { memory = undefined; storageNotice = ""; unavailableHtml = ""; refresh(); }
117
+ if (event.key === key || event.key === null) {
118
+ observing = null;
119
+ ++generation;
120
+ controller?.abort();
121
+ memory = undefined; storageNotice = ""; unavailableHtml = ""; refresh(true);
122
+ }
108
123
  });
109
124
  setInterval(() => { if (baseline() !== null) refresh(); }, 60000);
110
125
  refresh();
@@ -2,6 +2,7 @@ import { createRequire } from "node:module";
2
2
  import { Data, Effect, FileSystem, Path } from "effect";
3
3
  import type { HttpRouter, HttpServerRequest } from "effect/unstable/http";
4
4
  import { historyScript } from "#history/script.ts";
5
+ import { ageScript } from "#page/ageScript.ts";
5
6
  import { client, preferences } from "#page/client.ts";
6
7
  import { diagrams } from "#page/diagrams.ts";
7
8
  import { libraryScript } from "#page/libraryScript.ts";
@@ -19,6 +20,7 @@ export const ASSETS: ReadonlyArray<readonly [HttpRouter.PathInput, string, strin
19
20
  ["/_board/style.css", style, "text/css"],
20
21
  ["/_board/saved-views.js", savedScript(), "text/javascript"],
21
22
  ["/_board/client.js", client, "text/javascript"],
23
+ ["/_board/ages.js", ageScript(), "text/javascript"],
22
24
  ["/_board/history.js", historyScript(), "text/javascript"],
23
25
  ["/_board/preferences.js", preferences, "text/javascript"],
24
26
  ["/_board/search.js", searchScript(), "text/javascript"],
@@ -53,3 +55,12 @@ export const mermaidFile = (mermaidRoot: string) =>
53
55
  });
54
56
 
55
57
  export const MERMAID_ROUTE: HttpRouter.PathInput = `${MERMAID}*`;
58
+
59
+ export const NATIVE_ROUTE: HttpRouter.PathInput = "/_board/native.js";
60
+ export const nativeAsset = Effect.fn("WorkBoard.nativeAsset")(function* () {
61
+ const fs = yield* FileSystem.FileSystem;
62
+ const path = yield* Path.Path;
63
+ const module = yield* path.fromFileUrl(new URL(import.meta.url)).pipe(Effect.orDie);
64
+ const asset = path.resolve(path.dirname(module), "../../assets/native.js");
65
+ return respond(yield* fs.readFile(asset), "text/javascript");
66
+ });
@@ -9,6 +9,7 @@ import { navHtml } from "#page/nav.ts";
9
9
  import { shell } from "#page/shell.ts";
10
10
  import type { searchSnapshot } from "#search/snapshot.ts";
11
11
  import { respond } from "./respond.ts";
12
+ import { sameOrigin } from "./sameOrigin.ts";
12
13
 
13
14
  export function changesPage(changes: Changes, home: string | undefined) {
14
15
  return Effect.fn("WorkBoard.changesPage")(function* () {
@@ -16,18 +17,6 @@ export function changesPage(changes: Changes, home: string | undefined) {
16
17
  return respond(shell("Changes", nav, changesHtml(), changes.realRoot, true, undefined, "changes"), "text/html");
17
18
  });
18
19
  }
19
- function sameOrigin(request: HttpServerRequest.HttpServerRequest): boolean {
20
- const origin = request.headers.origin;
21
- if (origin === undefined) {
22
- return true;
23
- }
24
- try {
25
- const url = new URL(origin);
26
- return url.protocol === "http:" && url.host === request.headers.host;
27
- } catch {
28
- return false;
29
- }
30
- }
31
20
  export function history(index: Effect.Success<ReturnType<typeof searchSnapshot>>) {
32
21
  return Effect.fn("WorkBoard.history")(function* (request: HttpServerRequest.HttpServerRequest) {
33
22
  if (!sameOrigin(request)) {
@@ -0,0 +1,14 @@
1
+ import type { HttpServerRequest } from "effect/unstable/http";
2
+
3
+ export function sameOrigin(request: HttpServerRequest.HttpServerRequest): boolean {
4
+ const origin = request.headers.origin;
5
+ if (origin === undefined) {
6
+ return true;
7
+ }
8
+ try {
9
+ const url = new URL(origin);
10
+ return url.protocol === "http:" && url.host === request.headers.host;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
@@ -0,0 +1,22 @@
1
+ export function ageScript() {
2
+ return `
3
+ const since = (modified) => {
4
+ const seconds = Math.max(0, Math.round((Date.now() - modified) / 1000));
5
+ if (seconds < 60) return seconds + "s";
6
+ if (seconds < 3600) return Math.round(seconds / 60) + "m";
7
+ if (seconds < 172800) return Math.round(seconds / 3600) + "h";
8
+ return Math.round(seconds / 86400) + "d";
9
+ };
10
+
11
+ export const tick = () => {
12
+ for (const age of document.querySelectorAll("[data-modified]")) {
13
+ const ago = since(Number(age.dataset.modified));
14
+ age.textContent = age.classList.contains("short") ? ago : "updated " + ago + " ago";
15
+ }
16
+ };
17
+
18
+ document.addEventListener("board-page", tick);
19
+ tick();
20
+ setInterval(tick, 5000);
21
+ `;
22
+ }