@atom.io/template-preact-realtime-vector-editor 0.0.0-stage → 0.0.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.
- package/.oxlintrc.json +12 -0
- package/INNOVATIONS.md +43 -0
- package/README.md +16 -2
- package/_gitignore +6 -0
- package/dprint.json +33 -0
- package/eslint.config.ts +37 -0
- package/index.html +13 -0
- package/mise.toml +1 -0
- package/node/server.ts +54 -0
- package/node/service.ts +280 -0
- package/package.json +67 -5
- package/src/VectorWorkspace.module.css +204 -0
- package/src/VectorWorkspace.tsx +395 -0
- package/src/collaboration-client.ts +321 -0
- package/src/design-model.ts +2 -0
- package/src/globals.css +15 -0
- package/src/identities.ts +19 -0
- package/src/main.tsx +35 -0
- package/src/protocol.ts +17 -0
- package/src/session.ts +19 -0
- package/src/vite-env.d.ts +4 -0
- package/tests/collaboration.test.tsx +473 -0
- package/tests/mosaic-domain-conformance-fixture.ts +726 -0
- package/tests/mosaic-domain-conformance.test.tsx +339 -0
- package/tsconfig.app.json +29 -0
- package/tsconfig.json +8 -0
- package/tsconfig.node.json +22 -0
- package/tsconfig.test.json +13 -0
- package/vite.config.ts +16 -0
- package/vitest.config.ts +10 -0
package/.oxlintrc.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
3
|
+
"extends": ["./node_modules/atom.io/oxlint.json"],
|
|
4
|
+
"plugins": ["typescript", "react"],
|
|
5
|
+
"rules": {
|
|
6
|
+
"react/jsx-key": "error",
|
|
7
|
+
"react/jsx-no-duplicate-props": "error",
|
|
8
|
+
"react/no-children-prop": "error",
|
|
9
|
+
"react/no-danger-with-children": "error",
|
|
10
|
+
"react/rules-of-hooks": "error"
|
|
11
|
+
}
|
|
12
|
+
}
|
package/INNOVATIONS.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Innovations and boundaries
|
|
2
|
+
|
|
3
|
+
## Integrity is the model boundary
|
|
4
|
+
|
|
5
|
+
Plane's durable object is one closed contour value. It contains an ordered list of three to sixty-four uniquely identified finite points. The renderer joins adjacent points and closes the final point to the first. There is no durable edge registry, path order, subpath order, curve control, or close command.
|
|
6
|
+
|
|
7
|
+
That representation removes an entire class of reconciliation bugs. Every node has exactly two implicit neighbors. Branching, dangling edges, missing close operations, cross-member identity mismatches, and partially undone structural batches have no representable object state.
|
|
8
|
+
|
|
9
|
+
The schema validates proposals, checkpoint hydration, and direct reducer use. The reducer parses before accumulation, so application code cannot bypass integrity by calling it locally.
|
|
10
|
+
|
|
11
|
+
## One durable member and complete history states
|
|
12
|
+
|
|
13
|
+
The `contour` member is a deterministic register of complete validated contour snapshots. A pointer-up, insertion, deletion, or replacement contributes one operation to that member. Undo and redo compensate only the authenticated actor's operations, then materialize another complete validated snapshot. Foreign snapshots remain active.
|
|
14
|
+
|
|
15
|
+
The collaboration regression exercises the failure that motivated this design: one actor inserts a node, another actor moves that node, and the creator undoes the insertion. The participating actor's complete contour remains visible and valid. Undoing that participation returns to the valid default contour. The test checks the server and both clients and proves every visible node has degree two.
|
|
16
|
+
|
|
17
|
+
The deliberate tradeoff is coarse contention. Simultaneous gestures based on the same contour are complete alternatives, and deterministic register order chooses one. This small example prefers an obvious integrity boundary over teaching an application-specific operation algebra. A product that needs disjoint contour edits to merge should define a single contour model whose operations replay into a validated contour; it should not split topology across independently undoable members.
|
|
18
|
+
|
|
19
|
+
## The richer SVG tutorial stays separate
|
|
20
|
+
|
|
21
|
+
The local `preact-svg-editor` still demonstrates paths, subpaths, line and cubic edges, ordering, and local transactions. Plane uses the constrained `./contour` export from that package. This keeps the reusable contour schema, reducer, Domain, and editor in one module without pretending that the Bezier tutorial's representational freedom is safe for this collaborative product.
|
|
22
|
+
|
|
23
|
+
The legacy multi-member editor also adopts the complete subpath when a participant reorders it. That compatibility hardening prevents a foreign order receipt from outliving the creator's subpath, node, and edge receipts. Plane does not rely on that repair; its normal path has no independent order or edge members.
|
|
24
|
+
|
|
25
|
+
## Presence is lossy and session-scoped
|
|
26
|
+
|
|
27
|
+
Collaborator presence carries simulated identity, logical-coordinate pointer, and selected node ID. Drag presence carries one node ID and preview point. Both are ephemeral, authenticated by actor and session, absent from durable history, and cleared by disconnect or expiry. DOM elements, pointer capture, zoom, pan, and workspace selection remain local.
|
|
28
|
+
|
|
29
|
+
Here, “authenticated” means bound to the actor and session accepted by the Domain controllers. The template handshake only allowlists simulated actors and can be impersonated. A deployed application must derive those values from verified credentials and provide its own batch authorization policy.
|
|
30
|
+
|
|
31
|
+
The UI publishes pointer movement without waiting for acknowledgement. Pointer-up is the durable boundary.
|
|
32
|
+
|
|
33
|
+
## Recovery and restart
|
|
34
|
+
|
|
35
|
+
The Node service accepts a `MosaicDomainCheckpointStorageAdapter`. It serializes proposals with checkpoint publication so member reads belong to the accepted revision being published. Checkpoint compaction keeps the visible contour and protected receipts, and a fresh server generation reconstructs the same validated contour before serving recovery.
|
|
36
|
+
|
|
37
|
+
This template keeps one small contour resident and uses the reference in-memory checkpoint adapter. Large design applications should provide durable storage and application-defined residency cuts. Scale policy belongs outside the contour reducer.
|
|
38
|
+
|
|
39
|
+
## Renderer and transport boundaries
|
|
40
|
+
|
|
41
|
+
The shipped application renders with Preact. Its `multiClient` fixture uses Testing Library's React renderer to drive the same Socket.IO client runtime, so React and ReactDOM remain test-only dependencies.
|
|
42
|
+
|
|
43
|
+
The browser connects to its current origin. Vite proxies `/socket.io` and `/health` to the Node process in development and preview. Event names are application protocol; validation, atomic settlement, recovery, presence expiry, history, and actor authorization remain public Atom.io controllers.
|
package/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Plane: Preact realtime contour editor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Plane is a deliberately small collaborative editor for one closed, straight-line contour. It demonstrates a design application built on an Atom.io Mosaic Domain without asking a tutorial model to carry paths, subpaths, curves, edge handles, and independent topology registries.
|
|
4
|
+
|
|
5
|
+
Run `npm run dev`, then open two tabs and choose different simulated identities from the header. Vite proxies both health and Socket.IO traffic to the Node service, so the browser connects through `window.location.origin`.
|
|
6
|
+
|
|
7
|
+
The identity picker is an exploration aid, not authentication. Before deployment, derive actor and session identity from verified credentials and supply an application authorization policy to the collaboration service.
|
|
8
|
+
|
|
9
|
+
The canvas supports node dragging, node insertion and deletion, private zoom, advisory focus, lossy remote pointers and drag previews, actor-selective undo and redo, offline replay, and visible pending or rejected work. A contour always has three to sixty-four uniquely identified finite points. Straight edges and closure are implicit, so a dangling edge, branch, curve, or node with degree other than two cannot be represented.
|
|
10
|
+
|
|
11
|
+
The separate `preact-svg-editor` application remains the richer local Bezier tutorial. Plane imports its constrained contour module instead of putting that richer topology into a collaborative Domain.
|
|
12
|
+
|
|
13
|
+
Read `INNOVATIONS.md` before adapting the example. It explains why the integrity boundary is one durable aggregate, what selective history can expose, and the deliberate concurrency tradeoff of this spartan model.
|
|
14
|
+
|
|
15
|
+
## Quality Checks
|
|
16
|
+
|
|
17
|
+
Run `npm run lint` before committing. It enforces ESLint, type-aware Oxlint, React hook call-order correctness, TypeScript, and CSS-module reachability with zero warnings. Run `npm run fmt:check` in CI and `npm run fmt` to apply the pinned dprint configuration locally.
|
package/_gitignore
ADDED
package/dprint.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://dprint.dev/schemas/v0.json",
|
|
3
|
+
"lineWidth": 81,
|
|
4
|
+
"useTabs": true,
|
|
5
|
+
"indentWidth": 2,
|
|
6
|
+
"includes": ["**/*.{ts,tsx,js,jsx,mjs,cjs,json,jsonc,md,mdx,css}"],
|
|
7
|
+
"excludes": [
|
|
8
|
+
"**/dist/**",
|
|
9
|
+
"**/node_modules/**",
|
|
10
|
+
"**/playwright-report/**",
|
|
11
|
+
"**/test-results/**"
|
|
12
|
+
],
|
|
13
|
+
"oxc": {
|
|
14
|
+
"associations": ["**/*.{ts,tsx,js,jsx,mjs,cjs}"],
|
|
15
|
+
"semicolons": "asNeeded",
|
|
16
|
+
"trailingCommas": "all"
|
|
17
|
+
},
|
|
18
|
+
"json": {
|
|
19
|
+
"associations": ["**/*.{json,jsonc}"]
|
|
20
|
+
},
|
|
21
|
+
"markdown": {
|
|
22
|
+
"associations": ["**/*.{md,mdx}"]
|
|
23
|
+
},
|
|
24
|
+
"malva": {
|
|
25
|
+
"associations": ["**/*.css"]
|
|
26
|
+
},
|
|
27
|
+
"plugins": [
|
|
28
|
+
"./node_modules/@dprint/oxc/plugin.wasm",
|
|
29
|
+
"./node_modules/@dprint/json/plugin.wasm",
|
|
30
|
+
"./node_modules/@dprint/markdown/plugin.wasm",
|
|
31
|
+
"./node_modules/dprint-plugin-malva/plugin.wasm"
|
|
32
|
+
]
|
|
33
|
+
}
|
package/eslint.config.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as tsParser from "@typescript-eslint/parser"
|
|
2
|
+
import AtomIOPlugin from "@atom.io/eslint-plugin"
|
|
3
|
+
import type { Linter } from "eslint"
|
|
4
|
+
import LasertagPlugin from "lasertag/eslint-plugin"
|
|
5
|
+
|
|
6
|
+
const ERROR = 2
|
|
7
|
+
|
|
8
|
+
export default [
|
|
9
|
+
{ ignores: [`**/dist/**`, `**/node_modules/**`] },
|
|
10
|
+
{
|
|
11
|
+
files: [`**/*.ts`, `**/*.tsx`],
|
|
12
|
+
languageOptions: {
|
|
13
|
+
parser: tsParser,
|
|
14
|
+
parserOptions: { projectService: true, sourceType: `module` },
|
|
15
|
+
},
|
|
16
|
+
plugins: { "atom.io": AtomIOPlugin },
|
|
17
|
+
rules: {
|
|
18
|
+
"atom.io/exact-catch-types": ERROR,
|
|
19
|
+
"atom.io/explicit-state-types": [ERROR, { permitAnnotation: true }],
|
|
20
|
+
"atom.io/naming-convention": ERROR,
|
|
21
|
+
quotes: [ERROR, `backtick`],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
files: [`src/**/*.tsx`],
|
|
26
|
+
plugins: { lasertag: LasertagPlugin },
|
|
27
|
+
rules: {
|
|
28
|
+
"lasertag/access-css-module-class-only": ERROR,
|
|
29
|
+
"lasertag/ban-div": ERROR,
|
|
30
|
+
"lasertag/export-own-component-only": ERROR,
|
|
31
|
+
"lasertag/header-main-footer-as-group": ERROR,
|
|
32
|
+
"lasertag/import-own-css-module-only": ERROR,
|
|
33
|
+
"lasertag/name-imported-css-module-as-css": ERROR,
|
|
34
|
+
"lasertag/render-tag-with-own-name": ERROR,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
] satisfies Linter.Config[]
|
package/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="description" content="Plane, a realtime vector editor built with Atom.io Mosaic Domains." />
|
|
7
|
+
<title>Plane · Shared vectors</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<main id="root"></main>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/mise.toml
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[tools]
|
package/node/server.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createServer } from "node:http"
|
|
2
|
+
|
|
3
|
+
import { Server } from "socket.io"
|
|
4
|
+
|
|
5
|
+
import { identityById } from "../src/identities.ts"
|
|
6
|
+
import { createVectorCollaborationService } from "./service.ts"
|
|
7
|
+
|
|
8
|
+
const PORT = 3000
|
|
9
|
+
const collaboration = await createVectorCollaborationService()
|
|
10
|
+
const httpServer = createServer((request, response) => {
|
|
11
|
+
if (request.url === `/health`) {
|
|
12
|
+
response.writeHead(200, { "content-type": `application/json` })
|
|
13
|
+
response.end(JSON.stringify({ ok: true, revision: collaboration.revision }))
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
response.writeHead(404)
|
|
17
|
+
response.end()
|
|
18
|
+
})
|
|
19
|
+
const socketServer = new Server(httpServer)
|
|
20
|
+
|
|
21
|
+
socketServer.on(`connection`, (socket) => {
|
|
22
|
+
const requestedActor =
|
|
23
|
+
typeof socket.handshake.auth.actor === `string`
|
|
24
|
+
? socket.handshake.auth.actor
|
|
25
|
+
: undefined
|
|
26
|
+
const session =
|
|
27
|
+
typeof socket.handshake.auth.session === `string`
|
|
28
|
+
? socket.handshake.auth.session
|
|
29
|
+
: ``
|
|
30
|
+
const identity = identityById(requestedActor)
|
|
31
|
+
if (
|
|
32
|
+
identity.id !== requestedActor ||
|
|
33
|
+
session.length === 0 ||
|
|
34
|
+
session.includes(`::`)
|
|
35
|
+
) {
|
|
36
|
+
socket.disconnect(true)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
void collaboration.bindSocket({ actor: identity.id, session, socket })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
httpServer.listen(PORT, () => {
|
|
43
|
+
console.log(
|
|
44
|
+
`Vector collaboration server listening on http://localhost:${PORT}`,
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const shutdown = (): void => {
|
|
49
|
+
collaboration[Symbol.dispose]()
|
|
50
|
+
void socketServer.close()
|
|
51
|
+
httpServer.close()
|
|
52
|
+
}
|
|
53
|
+
process.once(`SIGINT`, shutdown)
|
|
54
|
+
process.once(`SIGTERM`, shutdown)
|
package/node/service.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { Silo } from "atom.io"
|
|
2
|
+
import type { Json } from "atom.io/foundations/json"
|
|
3
|
+
import type {
|
|
4
|
+
MosaicDomainBatchProposal,
|
|
5
|
+
MosaicDomainResidencyTransport,
|
|
6
|
+
} from "@atom.io/realtime"
|
|
7
|
+
import {
|
|
8
|
+
bindMosaicDomainHistoryServerSocket,
|
|
9
|
+
bindMosaicDomainPresenceServerSocket,
|
|
10
|
+
createMosaicDomainBatchServer,
|
|
11
|
+
createMosaicDomainCheckpointCoordinator,
|
|
12
|
+
createMosaicDomainHistoryCoordinator,
|
|
13
|
+
createMosaicDomainPresenceServer,
|
|
14
|
+
createMosaicDomainResidencyServer,
|
|
15
|
+
InMemoryMosaicDomainCheckpointStorage,
|
|
16
|
+
type MosaicDomainBatchAuthorizationContext,
|
|
17
|
+
type MosaicDomainBatchConnection,
|
|
18
|
+
type MosaicDomainCheckpointCoordinator,
|
|
19
|
+
type MosaicDomainCheckpointStorageAdapter,
|
|
20
|
+
type MosaicDomainHistoryCoordinator,
|
|
21
|
+
} from "@atom.io/realtime/server"
|
|
22
|
+
import type { Socket as ServerSocket } from "socket.io"
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
activateSvgContourDomain,
|
|
26
|
+
type SvgContourDomain,
|
|
27
|
+
type SvgContourStateAccess,
|
|
28
|
+
} from "../src/design-model.ts"
|
|
29
|
+
import {
|
|
30
|
+
VECTOR_BATCH_EVENTS,
|
|
31
|
+
VECTOR_RESIDENCY_EVENTS,
|
|
32
|
+
type VectorAcknowledgement,
|
|
33
|
+
} from "../src/protocol.ts"
|
|
34
|
+
|
|
35
|
+
export type VectorCollaborationService = Disposable & {
|
|
36
|
+
bindSocket(options: {
|
|
37
|
+
readonly actor: string
|
|
38
|
+
readonly session: string
|
|
39
|
+
readonly socket: ServerSocket
|
|
40
|
+
}): Promise<() => Promise<void>>
|
|
41
|
+
readonly checkpoints: MosaicDomainCheckpointCoordinator<
|
|
42
|
+
SvgContourDomain[`identity`]
|
|
43
|
+
>
|
|
44
|
+
readonly domain: SvgContourDomain
|
|
45
|
+
readonly history: MosaicDomainHistoryCoordinator<SvgContourDomain[`identity`]>
|
|
46
|
+
readonly revision: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function createVectorCollaborationService(
|
|
50
|
+
options: {
|
|
51
|
+
readonly authorize?: (
|
|
52
|
+
context: MosaicDomainBatchAuthorizationContext,
|
|
53
|
+
) => boolean | Promise<boolean>
|
|
54
|
+
readonly silo?: Silo
|
|
55
|
+
readonly storage?: MosaicDomainCheckpointStorageAdapter
|
|
56
|
+
} = {},
|
|
57
|
+
): Promise<VectorCollaborationService> {
|
|
58
|
+
const silo =
|
|
59
|
+
options.silo ??
|
|
60
|
+
new Silo({
|
|
61
|
+
name: `vector-collaboration-server`,
|
|
62
|
+
// Domain families acquire physical members from authenticated addresses.
|
|
63
|
+
lifespan: `ephemeral`,
|
|
64
|
+
isProduction: process.env.NODE_ENV === `production`,
|
|
65
|
+
})
|
|
66
|
+
const storage = options.storage ?? new InMemoryMosaicDomainCheckpointStorage()
|
|
67
|
+
const domain = await activateSvgContourDomain({
|
|
68
|
+
instance: `shared-drawing`,
|
|
69
|
+
silo,
|
|
70
|
+
})
|
|
71
|
+
const readState = silo.getState as SvgContourStateAccess[`getState`]
|
|
72
|
+
const batchServer = createMosaicDomainBatchServer({
|
|
73
|
+
...(options.authorize === undefined ? {} : { authorize: options.authorize }),
|
|
74
|
+
domain,
|
|
75
|
+
storage,
|
|
76
|
+
})
|
|
77
|
+
let history!: MosaicDomainHistoryCoordinator<SvgContourDomain[`identity`]>
|
|
78
|
+
const checkpoints = createMosaicDomainCheckpointCoordinator({
|
|
79
|
+
domain: domain.identity,
|
|
80
|
+
indexes: (context) => history.checkpoint.indexes(context),
|
|
81
|
+
readMember: async ({ address, revision }) => {
|
|
82
|
+
if (revision !== batchServer.revision) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Plane checkpoints require a quiescent accepted revision.`,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
const parsed = await domain.parseAddress(address)
|
|
88
|
+
const acquired = await domain.acquire(parsed)
|
|
89
|
+
return history.checkpoint.compactMember({
|
|
90
|
+
address,
|
|
91
|
+
revision,
|
|
92
|
+
value: readState(acquired.token) as Json.Serializable,
|
|
93
|
+
})
|
|
94
|
+
},
|
|
95
|
+
storage,
|
|
96
|
+
})
|
|
97
|
+
history = createMosaicDomainHistoryCoordinator({
|
|
98
|
+
batches: batchServer,
|
|
99
|
+
checkpoint: checkpoints,
|
|
100
|
+
domain,
|
|
101
|
+
storage,
|
|
102
|
+
})
|
|
103
|
+
const presenceServer = createMosaicDomainPresenceServer({ domain })
|
|
104
|
+
const residencyServer = createMosaicDomainResidencyServer({
|
|
105
|
+
batches: batchServer,
|
|
106
|
+
checkpoint: checkpoints,
|
|
107
|
+
domain,
|
|
108
|
+
maxResidentMembers: 512,
|
|
109
|
+
})
|
|
110
|
+
type VectorResidencyTransport = MosaicDomainResidencyTransport<
|
|
111
|
+
SvgContourDomain[`identity`]
|
|
112
|
+
>
|
|
113
|
+
const cleanups = new Set<() => Promise<void>>()
|
|
114
|
+
let disposed = false
|
|
115
|
+
let serial = Promise.resolve()
|
|
116
|
+
const exclusively = <Value>(work: () => Promise<Value>): Promise<Value> => {
|
|
117
|
+
const result = serial.then(work)
|
|
118
|
+
serial = result.then(
|
|
119
|
+
() => undefined,
|
|
120
|
+
() => undefined,
|
|
121
|
+
)
|
|
122
|
+
return result
|
|
123
|
+
}
|
|
124
|
+
const acknowledge = <Value>(
|
|
125
|
+
work: Promise<Value>,
|
|
126
|
+
respond: (acknowledgement: VectorAcknowledgement<Value>) => void,
|
|
127
|
+
): void => {
|
|
128
|
+
void work.then(
|
|
129
|
+
(value) => {
|
|
130
|
+
respond({ ok: true, value })
|
|
131
|
+
},
|
|
132
|
+
(error: unknown) => {
|
|
133
|
+
respond({
|
|
134
|
+
ok: false,
|
|
135
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
136
|
+
})
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
bindSocket({ actor, session, socket }) {
|
|
143
|
+
if (disposed) {
|
|
144
|
+
return Promise.reject(new Error(`The vector service is disposed.`))
|
|
145
|
+
}
|
|
146
|
+
const batch = batchServer.connect({ actor, session })
|
|
147
|
+
const presence = presenceServer.connect({ actor, session })
|
|
148
|
+
const residency = residencyServer.connect({ actor, session })
|
|
149
|
+
const unbindHistory = bindMosaicDomainHistoryServerSocket(
|
|
150
|
+
history.connect({ actor, session }),
|
|
151
|
+
socket,
|
|
152
|
+
{ session },
|
|
153
|
+
)
|
|
154
|
+
const unsubscribeBatch = batch.subscribe((accepted) => {
|
|
155
|
+
socket.emit(VECTOR_BATCH_EVENTS.accepted, accepted)
|
|
156
|
+
})
|
|
157
|
+
const residencySubscriptions = new Map<string, () => void>()
|
|
158
|
+
const unbindPresence = bindMosaicDomainPresenceServerSocket(
|
|
159
|
+
presence,
|
|
160
|
+
socket,
|
|
161
|
+
)
|
|
162
|
+
const onPropose = (
|
|
163
|
+
proposal: MosaicDomainBatchProposal,
|
|
164
|
+
respond: (
|
|
165
|
+
acknowledgement: VectorAcknowledgement<
|
|
166
|
+
Awaited<ReturnType<MosaicDomainBatchConnection[`propose`]>>
|
|
167
|
+
>,
|
|
168
|
+
) => void,
|
|
169
|
+
): void => {
|
|
170
|
+
acknowledge(
|
|
171
|
+
exclusively(() => batch.propose(proposal)),
|
|
172
|
+
respond,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
const onRecover = (
|
|
176
|
+
afterRevision: number,
|
|
177
|
+
respond: (
|
|
178
|
+
acknowledgement: VectorAcknowledgement<
|
|
179
|
+
Awaited<ReturnType<MosaicDomainBatchConnection[`recover`]>>
|
|
180
|
+
>,
|
|
181
|
+
) => void,
|
|
182
|
+
): void => {
|
|
183
|
+
acknowledge(batch.recover(afterRevision), respond)
|
|
184
|
+
}
|
|
185
|
+
const onResidencyHydrate = (
|
|
186
|
+
requests: unknown,
|
|
187
|
+
respond: (
|
|
188
|
+
acknowledgement: VectorAcknowledgement<
|
|
189
|
+
Awaited<ReturnType<VectorResidencyTransport[`hydrate`]>>
|
|
190
|
+
>,
|
|
191
|
+
) => void,
|
|
192
|
+
): void => {
|
|
193
|
+
acknowledge(residency.hydrate(requests as never), respond)
|
|
194
|
+
}
|
|
195
|
+
const onResidencyPropose = (
|
|
196
|
+
proposal: unknown,
|
|
197
|
+
respond: (
|
|
198
|
+
acknowledgement: VectorAcknowledgement<
|
|
199
|
+
Awaited<ReturnType<VectorResidencyTransport[`propose`]>>
|
|
200
|
+
>,
|
|
201
|
+
) => void,
|
|
202
|
+
): void => {
|
|
203
|
+
acknowledge(residency.propose(proposal as never), respond)
|
|
204
|
+
}
|
|
205
|
+
const onResidencySubscribe = (
|
|
206
|
+
id: string,
|
|
207
|
+
requests: unknown,
|
|
208
|
+
respond: (acknowledgement: VectorAcknowledgement<void>) => void,
|
|
209
|
+
): void => {
|
|
210
|
+
acknowledge(
|
|
211
|
+
Promise.resolve(
|
|
212
|
+
residency.subscribe(requests as never, (accepted) => {
|
|
213
|
+
socket.emit(VECTOR_RESIDENCY_EVENTS.accepted, id, accepted)
|
|
214
|
+
}),
|
|
215
|
+
).then((stop) => {
|
|
216
|
+
residencySubscriptions.get(id)?.()
|
|
217
|
+
residencySubscriptions.set(id, stop)
|
|
218
|
+
}),
|
|
219
|
+
respond,
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
const onResidencyUnsubscribe = (id: string): void => {
|
|
223
|
+
residencySubscriptions.get(id)?.()
|
|
224
|
+
residencySubscriptions.delete(id)
|
|
225
|
+
}
|
|
226
|
+
socket.on(VECTOR_BATCH_EVENTS.propose, onPropose)
|
|
227
|
+
socket.on(VECTOR_BATCH_EVENTS.recover, onRecover)
|
|
228
|
+
socket.on(VECTOR_RESIDENCY_EVENTS.hydrate, onResidencyHydrate)
|
|
229
|
+
socket.on(VECTOR_RESIDENCY_EVENTS.propose, onResidencyPropose)
|
|
230
|
+
socket.on(VECTOR_RESIDENCY_EVENTS.subscribe, onResidencySubscribe)
|
|
231
|
+
socket.on(VECTOR_RESIDENCY_EVENTS.unsubscribe, onResidencyUnsubscribe)
|
|
232
|
+
let cleaned = false
|
|
233
|
+
const cleanup = async (): Promise<void> => {
|
|
234
|
+
if (cleaned) return
|
|
235
|
+
cleaned = true
|
|
236
|
+
socket.off(VECTOR_BATCH_EVENTS.propose, onPropose)
|
|
237
|
+
socket.off(VECTOR_BATCH_EVENTS.recover, onRecover)
|
|
238
|
+
socket.off(`disconnect`, onDisconnect)
|
|
239
|
+
unsubscribeBatch()
|
|
240
|
+
for (const stop of residencySubscriptions.values()) stop()
|
|
241
|
+
residencySubscriptions.clear()
|
|
242
|
+
socket.off(VECTOR_RESIDENCY_EVENTS.hydrate, onResidencyHydrate)
|
|
243
|
+
socket.off(VECTOR_RESIDENCY_EVENTS.propose, onResidencyPropose)
|
|
244
|
+
socket.off(VECTOR_RESIDENCY_EVENTS.subscribe, onResidencySubscribe)
|
|
245
|
+
socket.off(VECTOR_RESIDENCY_EVENTS.unsubscribe, onResidencyUnsubscribe)
|
|
246
|
+
await Promise.resolve(residency.dispose?.()).catch(() => undefined)
|
|
247
|
+
unbindHistory()
|
|
248
|
+
await unbindPresence()
|
|
249
|
+
cleanups.delete(cleanup)
|
|
250
|
+
}
|
|
251
|
+
const onDisconnect = (): void => {
|
|
252
|
+
void cleanup().catch(() => undefined)
|
|
253
|
+
}
|
|
254
|
+
socket.on(`disconnect`, onDisconnect)
|
|
255
|
+
cleanups.add(cleanup)
|
|
256
|
+
return Promise.resolve(cleanup)
|
|
257
|
+
},
|
|
258
|
+
checkpoints: {
|
|
259
|
+
checkpoint: () => exclusively(() => checkpoints.checkpoint()),
|
|
260
|
+
readIndex: checkpoints.readIndex,
|
|
261
|
+
recover: checkpoints.recover,
|
|
262
|
+
},
|
|
263
|
+
domain,
|
|
264
|
+
history,
|
|
265
|
+
get revision() {
|
|
266
|
+
return batchServer.revision
|
|
267
|
+
},
|
|
268
|
+
[Symbol.dispose]() {
|
|
269
|
+
if (disposed) return
|
|
270
|
+
disposed = true
|
|
271
|
+
for (const cleanup of cleanups) void cleanup().catch(() => undefined)
|
|
272
|
+
cleanups.clear()
|
|
273
|
+
presenceServer[Symbol.dispose]()
|
|
274
|
+
residencyServer[Symbol.dispose]()
|
|
275
|
+
history[Symbol.dispose]()
|
|
276
|
+
batchServer.dispose()
|
|
277
|
+
domain[Symbol.dispose]()
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@atom.io/template-preact-realtime-vector-editor",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "A realtime collaborative vector editor built with Atom.io Mosaic Domains and Preact.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/jeremybanka/atom.io.git",
|
|
8
|
+
"directory": "templates/preact-realtime-vector-editor"
|
|
9
|
+
},
|
|
10
|
+
"license": "Apache-2.0",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "concurrently vite \"node --watch node/server.ts\"",
|
|
14
|
+
"build": "tsc -b && vite build",
|
|
15
|
+
"fmt": "dprint fmt .",
|
|
16
|
+
"fmt:check": "dprint check .",
|
|
17
|
+
"lint": "npm run lint:eslint && npm run lint:oxlint && npm run lint:lasertag && npm run lint:types",
|
|
18
|
+
"lint:eslint": "eslint --max-warnings 0 .",
|
|
19
|
+
"lint:lasertag": "lasertag check \"src/**/*.module.css\"",
|
|
20
|
+
"lint:oxlint": "oxlint --deny-warnings .",
|
|
21
|
+
"lint:types": "tsc -b",
|
|
22
|
+
"preview": "concurrently \"vite preview\" \"node node/server.ts\"",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:once": "vitest run"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@atom.io/react": "workspace:*",
|
|
28
|
+
"@atom.io/realtime": "workspace:*",
|
|
29
|
+
"@atom.io/template-preact-svg-editor": "workspace:*",
|
|
30
|
+
"atom.io": "workspace:*",
|
|
31
|
+
"preact": "11.0.1",
|
|
32
|
+
"socket.io": "4.8.4",
|
|
33
|
+
"socket.io-client": "4.8.4"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@atom.io/eslint-plugin": "workspace:*",
|
|
37
|
+
"@dprint/json": "0.25.2",
|
|
38
|
+
"@dprint/markdown": "0.27.1",
|
|
39
|
+
"@dprint/oxc": "0.47.0",
|
|
40
|
+
"@preact/preset-vite": "2.10.6",
|
|
41
|
+
"@testing-library/dom": "10.4.2",
|
|
42
|
+
"@testing-library/react": "16.3.3",
|
|
43
|
+
"@types/node": "26.6.5",
|
|
44
|
+
"@types/react": "19.3.0",
|
|
45
|
+
"@types/react-dom": "19.3.0",
|
|
46
|
+
"@typescript-eslint/parser": "8.72.0",
|
|
47
|
+
"@typescript/native": "npm:typescript@7.0.2",
|
|
48
|
+
"concurrently": "10.0.6",
|
|
49
|
+
"dprint": "0.64.1",
|
|
50
|
+
"dprint-plugin-malva": "0.16.0",
|
|
51
|
+
"eslint": "10.13.0",
|
|
52
|
+
"happy-dom": "21.0.4",
|
|
53
|
+
"lasertag": "0.8.8",
|
|
54
|
+
"oxlint": "1.89.0",
|
|
55
|
+
"oxlint-tsgolint": "7.0.2004",
|
|
56
|
+
"react": "19.3.1",
|
|
57
|
+
"react-dom": "19.3.1",
|
|
58
|
+
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
59
|
+
"vite": "8.3.5",
|
|
60
|
+
"vitest": "5.0.5"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=24.12.0"
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public"
|
|
67
|
+
}
|
|
68
|
+
}
|