Files
webstudio/apps/builder/app/shared/sync/multiplayer-client.ts
Oleg Isonen b832a8e808 refactor: Centralize Builder ID generation (#6323)
## Outcome

Builder-owned code now routes standard ID generation through
`@webstudio-is/sdk/createId` while preserving existing NanoID and UUID
formats.

Closes #6322

## Technical choices

- Use `createId("nano")` for Builder records, assets, transaction IDs,
and other existing NanoID-backed values.
- Use `createId()` for UUID-backed database records and browser-side
client IDs.
- Keep `BuilderRuntimeContext.createId` as an injectable dependency.
Runtime operations, copy/paste, and deterministic tests rely on that
injection.
- Keep Node's `randomUUID` in CLI paths that explicitly support running
without the Web Crypto global.
- Keep specialized generators for custom-alphabet project domains and
actual hash generation.
- Remove direct `nanoid` dependencies from packages where no specialized
use remains.
- Do not migrate existing data or change database schemas.

## Todo

- [x] Migrate Builder app ID creation.
- [x] Migrate project runtime and template defaults.
- [x] Migrate asset, project, domain, and authorization-token IDs.
- [x] Preserve runtime ID injection.
- [x] Review remaining direct generators as explicit exceptions.
- [x] Add NanoID-format regression coverage for the default runtime
context.
- [x] Review documentation impact: no user-facing documentation changes
are needed.
- [x] Review fixture impact: no fixture input or generated output format
changes are needed.
- [x] Run targeted tests, typechecks, lint, and package-boundary checks.

## Verification

- Targeted Builder tests: 153 passed after preserving runtime-context
injection.
- Project-build runtime tests: 270 passed.
- Asset uploader tests: 61 passed.
- Authorization-token tests: 17 passed.
- Project tests: 52 passed.
- Domain tests: 18 passed.
- CLI tests: 32 passed.
- Typechecks passed for Builder, project-build, asset-uploader,
authorization-token, project, and domain.
- `pnpm lint` passed.
- `pnpm check:package-boundaries` passed.
- The runtime-context format test was demonstrated to fail with a UUID
default and pass with the NanoID default.
- Agent evaluations were not run because they require separate explicit
approval.
2026-09-03 15:56:23 +02:00

108 lines
2.8 KiB
TypeScript

import type { Project } from "@webstudio-is/project";
import { createId } from "@webstudio-is/sdk";
import type { AuthPermit } from "@webstudio-is/trpc-interface/index.server";
import { toast } from "@webstudio-is/design-system";
import { nativeClient } from "~/shared/trpc/trpc-client";
import { loadBuilderData, type LoadedBuilderData } from "~/shared/builder-data";
import {
createMultiplayerSyncEmitter,
startMultiplayerPresenceSync,
} from "./multiplayer-sync";
import type { SyncStorage } from "~/shared/sync-client";
export type MultiplayerSyncClient = {
clientId: string;
emitter: ReturnType<typeof createMultiplayerSyncEmitter>;
storages: SyncStorage[];
destroy(): void;
onDataLoaded(data: LoadedBuilderData): void;
};
const createCollabAuthTokenLoader = ({
authPermit,
authToken,
buildId,
projectId,
}: {
authPermit: AuthPermit;
authToken: string | undefined;
buildId: () => string | undefined;
projectId: Project["id"];
}): (() => Promise<string | undefined>) => {
if (authPermit === "view") {
return async () => undefined;
}
if (authToken !== undefined) {
return async () => authToken;
}
return async () => {
const currentBuildId = buildId();
if (currentBuildId === undefined) {
throw new Error("Build id is not ready for collaboration token");
}
const result = await nativeClient.build.createCollabToken.query({
buildId: currentBuildId,
projectId,
});
return result.token;
};
};
export const createMultiplayerSyncClient = ({
applyBuilderData,
authPermit,
authToken,
projectId,
relayUrl,
signal,
}: {
applyBuilderData: (data: LoadedBuilderData) => void;
authPermit: AuthPermit;
authToken: string | undefined;
projectId: Project["id"];
relayUrl: string;
signal: AbortSignal;
}): MultiplayerSyncClient => {
let buildId: string | undefined;
let unsubscribePresence: (() => void) | undefined;
const clientId = createId();
const emitter = createMultiplayerSyncEmitter({
getAuthToken: createCollabAuthTokenLoader({
authPermit,
authToken,
buildId: () => buildId,
projectId,
}),
clientId,
url: relayUrl,
onReload: async () => {
try {
const reloaded = await loadBuilderData({ projectId, signal });
applyBuilderData(reloaded);
} catch (error) {
console.error("[builder-sync] multiplayer reload failed:", error);
}
},
onUserMessage: (message) => {
toast.error(message, { id: "multiplayer-sync-message" });
},
});
return {
clientId,
emitter,
storages: [],
destroy() {
emitter.close();
unsubscribePresence?.();
unsubscribePresence = undefined;
},
onDataLoaded(data) {
buildId = data.id;
emitter.connect(data.id);
unsubscribePresence?.();
unsubscribePresence = startMultiplayerPresenceSync(emitter);
},
};
};