From 11aad692691c63cd12af2716ce5183fe1e00d85c Mon Sep 17 00:00:00 2001 From: isletspace Date: Tue, 8 Sep 2026 12:42:39 +0800 Subject: [PATCH] feat(realtime): live-refresh project issue views over websocket (PLANE-22) - api: broadcast issue change events from the issue_activity celery task (plus bulk delete) to the live server via a shared-key internal endpoint - live: new POST /broadcasts/issue-events controller relaying events to the issue-events: hocuspocus channel over Redis; database and title-sync extensions skip the ephemeral channel namespace - web: useIssueRealtime hook subscribes from the project layout root and soft-refreshes issues (no loader flash) on remote changes, debounced - disabled unless LIVE_INTERNAL_API_KEY is set on api/worker and live --- apps/api/.env.example | 3 + apps/api/plane/app/views/issue/base.py | 9 ++ .../plane/bgtasks/issue_activities_task.py | 9 ++ apps/api/plane/settings/common.py | 3 + apps/api/plane/utils/issue_events.py | 44 ++++++++ apps/live/.env.example | 3 + .../src/controllers/broadcast.controller.ts | 85 +++++++++++++++ apps/live/src/controllers/index.ts | 9 +- apps/live/src/env.ts | 2 + apps/live/src/extensions/database.ts | 11 ++ apps/live/src/extensions/title-sync.ts | 4 + .../roots/project-layout-root.tsx | 8 ++ apps/web/core/hooks/use-issue-realtime.ts | 102 ++++++++++++++++++ apps/web/package.json | 1 + deployments/aio/community/variables.env | 4 + deployments/cli/community/docker-compose.yml | 2 + deployments/cli/community/variables.env | 4 + pnpm-lock.yaml | 4 + 18 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 apps/api/plane/utils/issue_events.py create mode 100644 apps/live/src/controllers/broadcast.controller.ts create mode 100644 apps/web/core/hooks/use-issue-realtime.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 4c84bd68373..b32f3d02f7e 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -65,6 +65,9 @@ LIVE_BASE_PATH="/live" LIVE_SERVER_SECRET_KEY="secret-key" +# Internal API key for server-to-server broadcast endpoints (must match the live server's LIVE_INTERNAL_API_KEY) +LIVE_INTERNAL_API_KEY="" + # Hard delete files after days HARD_DELETE_AFTER_DAYS=60 diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index 3b8b05a6a2f..ffcc6fdf463 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -69,6 +69,7 @@ issue_queryset_grouper, ) from plane.utils.host import base_host +from plane.utils.issue_events import broadcast_issue_event from plane.utils.issue_filters import issue_filters from plane.utils.order_queryset import order_issue_queryset from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator @@ -791,6 +792,14 @@ def delete(self, request, slug, project_id): # Finally, delete the issues themselves issues.delete() + # Broadcast the bulk deletion to connected clients via the live server + broadcast_issue_event( + project_id=project_id, + issue_id=issue_ids[0], + type="issue.deleted_bulk", + actor_id=str(request.user.id), + ) + return Response( {"message": f"{total_issues} issues were deleted"}, status=status.HTTP_200_OK, diff --git a/apps/api/plane/bgtasks/issue_activities_task.py b/apps/api/plane/bgtasks/issue_activities_task.py index 032feb02a60..6dd83e977c1 100644 --- a/apps/api/plane/bgtasks/issue_activities_task.py +++ b/apps/api/plane/bgtasks/issue_activities_task.py @@ -34,6 +34,7 @@ ) from plane.settings.redis import redis_instance from plane.utils.exception_logger import log_exception +from plane.utils.issue_events import broadcast_issue_event from plane.utils.issue_relation_mapper import get_inverse_relation from plane.utils.uuid import is_valid_uuid @@ -1524,6 +1525,14 @@ def issue_activity( project = Project.objects.get(pk=project_id) workspace_id = project.workspace_id + # Broadcast the change to connected clients via the live server + broadcast_issue_event( + project_id=project_id, + issue_id=issue_id, + type=type, + actor_id=actor_id, + ) + if issue_id is not None: if origin: ri = redis_instance() diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 25a212e7639..274048c8a63 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -418,6 +418,9 @@ LIVE_URL = urljoin(LIVE_BASE_URL, LIVE_BASE_PATH) if LIVE_BASE_URL else None +# Internal API key for server-to-server calls to the live server +LIVE_INTERNAL_API_KEY = os.environ.get("LIVE_INTERNAL_API_KEY", "") + # WEB URL WEB_URL = os.environ.get("WEB_URL") diff --git a/apps/api/plane/utils/issue_events.py b/apps/api/plane/utils/issue_events.py new file mode 100644 index 00000000000..b190b43d3de --- /dev/null +++ b/apps/api/plane/utils/issue_events.py @@ -0,0 +1,44 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Python imports +import logging + +# Third party imports +import requests + +# Django imports +from django.conf import settings + +# Module imports +from plane.utils.url import normalize_url_path + +logger = logging.getLogger("plane.worker") + + +def broadcast_issue_event(project_id, issue_id=None, type=None, actor_id=None): + """ + Fire-and-forget broadcast of an issue change event to the live server, + which relays it to all connected clients of the project over websocket. + Disabled when LIVE_URL or LIVE_INTERNAL_API_KEY is not configured. + """ + live_url = getattr(settings, "LIVE_URL", None) + internal_api_key = getattr(settings, "LIVE_INTERNAL_API_KEY", "") + if not live_url or not internal_api_key: + return + + try: + requests.post( + normalize_url_path(f"{live_url}/broadcasts/issue-events"), + json={ + "project_id": str(project_id), + "issue_id": str(issue_id) if issue_id else None, + "type": type, + "actor_id": str(actor_id) if actor_id else None, + }, + headers={"x-internal-api-key": internal_api_key}, + timeout=2, + ) + except requests.RequestException as e: + logger.warning(f"Failed to broadcast issue event for project {project_id}: {e}") diff --git a/apps/live/.env.example b/apps/live/.env.example index 5fc90d75fcf..46e5a29ca3e 100644 --- a/apps/live/.env.example +++ b/apps/live/.env.example @@ -8,6 +8,9 @@ LIVE_BASE_PATH="/live" LIVE_SERVER_SECRET_KEY="secret-key" +# Internal API key for server-to-server broadcast endpoints (must match the API server's LIVE_INTERNAL_API_KEY) +LIVE_INTERNAL_API_KEY="" + # If you prefer not to provide a Redis URL, you can set the REDIS_HOST and REDIS_PORT environment variables instead. REDIS_PORT=6379 REDIS_HOST=localhost diff --git a/apps/live/src/controllers/broadcast.controller.ts b/apps/live/src/controllers/broadcast.controller.ts new file mode 100644 index 00000000000..28b90e50e20 --- /dev/null +++ b/apps/live/src/controllers/broadcast.controller.ts @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Hocuspocus } from "@hocuspocus/server"; +import type { Request, Response } from "express"; +import { z } from "zod"; +// plane imports +import { Controller, Post } from "@plane/decorators"; +import { logger } from "@plane/logger"; +// env +import { env } from "@/env"; +// extensions +import { Redis } from "@/extensions/redis"; + +const issueEventSchema = z.object({ + project_id: z.string().min(1, "project_id is required"), + issue_id: z.string().nullish(), + type: z.string().nullish(), + actor_id: z.string().nullish(), +}); + +@Controller("/broadcasts") +export class BroadcastController { + [key: string]: unknown; + private readonly hocuspocusServer: Hocuspocus; + + constructor(hocuspocusServer: Hocuspocus) { + this.hocuspocusServer = hocuspocusServer; + } + + @Post("/issue-events") + async broadcastIssueEvent(req: Request, res: Response) { + // verify the internal api key for server-to-server calls + const internalApiKey = req.headers["x-internal-api-key"]; + if (!env.LIVE_INTERNAL_API_KEY || internalApiKey !== env.LIVE_INTERNAL_API_KEY) { + return res.status(401).json({ + message: "Unauthorized", + }); + } + + // validate request body + const parsedBody = issueEventSchema.safeParse(req.body); + if (!parsedBody.success) { + return res.status(400).json({ + message: "project_id is required", + }); + } + + const { project_id, issue_id, type, actor_id } = parsedBody.data; + const documentName = `issue-events:${project_id}`; + const payload = JSON.stringify({ + event: "issue_changed", + data: { + project_id, + issue_id: issue_id ?? null, + type: type ?? null, + actor_id: actor_id ?? null, + }, + }); + + const redisExtension = this.hocuspocusServer.configuration.extensions.find((ext) => ext instanceof Redis); + if (!redisExtension) { + logger.error("BROADCAST_CONTROLLER: Redis extension not found"); + return res.status(500).json({ + message: "Broadcast infrastructure unavailable", + }); + } + + try { + const receivers = await redisExtension.broadcastToDocument(documentName, payload); + return res.status(200).json({ + message: "Broadcasted", + receivers, + }); + } catch (error) { + logger.error(`BROADCAST_CONTROLLER: Error broadcasting to ${documentName}:`, error); + return res.status(500).json({ + message: "Internal server error", + }); + } + } +} diff --git a/apps/live/src/controllers/index.ts b/apps/live/src/controllers/index.ts index 2ae3bcea07b..bcce8f3727d 100644 --- a/apps/live/src/controllers/index.ts +++ b/apps/live/src/controllers/index.ts @@ -4,9 +4,16 @@ * See the LICENSE file for details. */ +import { BroadcastController } from "./broadcast.controller"; import { CollaborationController } from "./collaboration.controller"; import { DocumentController } from "./document.controller"; import { HealthController } from "./health.controller"; import { PdfExportController } from "./pdf-export.controller"; -export const CONTROLLERS = [CollaborationController, DocumentController, HealthController, PdfExportController]; +export const CONTROLLERS = [ + BroadcastController, + CollaborationController, + DocumentController, + HealthController, + PdfExportController, +]; diff --git a/apps/live/src/env.ts b/apps/live/src/env.ts index c9b61bd433f..9728ee4ab12 100644 --- a/apps/live/src/env.ts +++ b/apps/live/src/env.ts @@ -24,6 +24,8 @@ const envSchema = z.object({ COMPRESSION_THRESHOLD: z.string().default("5000").transform(Number), // secret LIVE_SERVER_SECRET_KEY: z.string(), + // internal api key for server-to-server broadcast endpoints + LIVE_INTERNAL_API_KEY: z.string().default(""), // Redis configuration REDIS_HOST: z.string().optional(), REDIS_PORT: z.string().default("6379").transform(Number), diff --git a/apps/live/src/extensions/database.ts b/apps/live/src/extensions/database.ts index becefc8e114..2dfdeecd560 100644 --- a/apps/live/src/extensions/database.ts +++ b/apps/live/src/extensions/database.ts @@ -5,6 +5,7 @@ */ import { Database as HocuspocusDatabase } from "@hocuspocus/extension-database"; +import * as Y from "yjs"; // plane imports import { getAllDocumentFormatsFromDocumentEditorBinaryData, @@ -23,7 +24,13 @@ import { broadcastError } from "@/utils/broadcast-error"; // force close utility import { forceCloseDocumentAcrossServers } from "./force-close-handler"; +const ISSUE_EVENTS_DOCUMENT_PREFIX = "issue-events:"; + const fetchDocument = async ({ context, documentName: pageId, instance }: FetchPayloadWithContext) => { + // issue-events documents are ephemeral broadcast channels without any persisted content + if (pageId.startsWith(ISSUE_EVENTS_DOCUMENT_PREFIX)) { + return Y.encodeStateAsUpdate(new Y.Doc()); + } try { const service = getPageService(context.documentType, context); // fetch details @@ -75,6 +82,10 @@ const storeDocument = async ({ documentName: pageId, instance, }: StorePayloadWithContext) => { + // issue-events documents are ephemeral broadcast channels without any persisted content + if (pageId.startsWith(ISSUE_EVENTS_DOCUMENT_PREFIX)) { + return; + } try { const service = getPageService(context.documentType, context); // convert binary data to all formats diff --git a/apps/live/src/extensions/title-sync.ts b/apps/live/src/extensions/title-sync.ts index c86b749860f..4ddf556804b 100644 --- a/apps/live/src/extensions/title-sync.ts +++ b/apps/live/src/extensions/title-sync.ts @@ -28,6 +28,8 @@ import { TitleUpdateManager } from "./title-update/title-update-manager"; * Hocuspocus extension for synchronizing document titles */ export class TitleSyncExtension implements Extension { + // Documents under this prefix are ephemeral broadcast channels without titles + private readonly ISSUE_EVENTS_DOCUMENT_PREFIX = "issue-events:"; // Maps document names to their observers and update managers private titleObservers: Map[]) => void> = new Map(); private titleUpdateManagers: Map = new Map(); @@ -46,6 +48,7 @@ export class TitleSyncExtension implements Extension { * Handle document loading - migrate old titles if needed */ async onLoadDocument({ context, document, documentName }: OnLoadDocumentPayloadWithContext) { + if (documentName.startsWith(this.ISSUE_EVENTS_DOCUMENT_PREFIX)) return; try { // initially for on demand migration of old titles to a new title field // in the yjs binary @@ -79,6 +82,7 @@ export class TitleSyncExtension implements Extension { context: HocusPocusServerContext; instance: Hocuspocus; }) { + if (documentName.startsWith(this.ISSUE_EVENTS_DOCUMENT_PREFIX)) return; // Create a title update manager for this document const updateManager = new TitleUpdateManager(documentName, context); diff --git a/apps/web/core/components/issues/issue-layouts/roots/project-layout-root.tsx b/apps/web/core/components/issues/issue-layouts/roots/project-layout-root.tsx index 79696b807e0..10c7043f667 100644 --- a/apps/web/core/components/issues/issue-layouts/roots/project-layout-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/roots/project-layout-root.tsx @@ -17,6 +17,7 @@ import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row"; // hooks import { useIssues } from "@/hooks/store/use-issues"; import { IssuesStoreContext } from "@/hooks/use-issue-layout-store"; +import { useIssueRealtime } from "@/hooks/use-issue-realtime"; // local imports import { IssuePeekOverview } from "../../peek-overview"; import { CalendarLayout } from "../calendar/roots/project-root"; @@ -53,6 +54,13 @@ export const ProjectLayoutRoot = observer(function ProjectLayoutRoot() { const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined; const activeLayout = workItemFilters?.displayFilters?.layout; + // soft refresh the current issues when another actor changes them + useIssueRealtime(projectId, () => { + if (workspaceSlug && projectId) { + issues?.fetchIssuesWithExistingPagination(workspaceSlug, projectId, undefined); + } + }); + useSWR( workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null, async () => { diff --git a/apps/web/core/hooks/use-issue-realtime.ts b/apps/web/core/hooks/use-issue-realtime.ts new file mode 100644 index 00000000000..74917b3d309 --- /dev/null +++ b/apps/web/core/hooks/use-issue-realtime.ts @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect, useRef } from "react"; +import { HocuspocusProvider } from "@hocuspocus/provider"; +import { useParams } from "next/navigation"; +// plane imports +import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants"; +// hooks +import { useUser } from "@/hooks/store/user"; + +// Debounce window to coalesce bursts of issue events into a single refresh +const ISSUE_EVENT_DEBOUNCE_MS = 500; + +type TIssueRealtimePayload = { + event: string; + data: { + project_id: string; + issue_id: string | null; + type: string | null; + actor_id: string | null; + }; +}; + +/** + * Subscribes to realtime issue change events for a project over the live server + * and invokes `onIssueEvent` (debounced) whenever another actor changes an issue. + * @param projectId - The project to subscribe to + * @param onIssueEvent - Callback invoked (debounced) on remote issue changes + */ +export const useIssueRealtime = (projectId: string | undefined, onIssueEvent: () => void) => { + const { workspaceSlug: routerWorkspaceSlug } = useParams(); + const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined; + const { data: currentUser } = useUser(); + + // Keep the latest callback and user id in refs so the provider is not + // re-created when they change + const onIssueEventRef = useRef(onIssueEvent); + onIssueEventRef.current = onIssueEvent; + const currentUserIdRef = useRef(currentUser?.id); + currentUserIdRef.current = currentUser?.id; + + useEffect(() => { + if (typeof window === "undefined" || !projectId || !currentUser?.id) return; + + // Construct the WebSocket collaboration URL, mirroring the page editor + let wsLiveUrl: URL; + try { + const liveServerBaseUrl = LIVE_BASE_URL?.trim() || window.location.origin; + wsLiveUrl = new URL(liveServerBaseUrl); + const isSecureEnvironment = window.location.protocol === "https:"; + wsLiveUrl.protocol = isSecureEnvironment ? "wss" : "ws"; + wsLiveUrl.pathname = `${LIVE_BASE_PATH}/collaboration`; + if (workspaceSlug) wsLiveUrl.searchParams.set("workspaceSlug", workspaceSlug); + wsLiveUrl.searchParams.set("projectId", projectId); + wsLiveUrl.searchParams.set("documentType", "project_page"); + } catch (error) { + console.error("Error creating issue realtime config", error); + return; + } + + const provider = new HocuspocusProvider({ + name: `issue-events:${projectId}`, + token: JSON.stringify({ id: currentUser.id, name: currentUser.display_name ?? "" }), + url: wsLiveUrl.toString(), + }); + + let debounceTimer: ReturnType | null = null; + + const handleStateless = ({ payload }: { payload: string }) => { + try { + const parsedPayload = JSON.parse(payload) as TIssueRealtimePayload; + if (parsedPayload.event !== "issue_changed") return; + // ignore events triggered by the current user + if (parsedPayload.data?.actor_id && parsedPayload.data.actor_id === currentUserIdRef.current) return; + + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + onIssueEventRef.current(); + }, ISSUE_EVENT_DEBOUNCE_MS); + } catch (error) { + console.error("Error handling issue realtime event", error); + } + }; + + provider.on("stateless", handleStateless); + + return () => { + if (debounceTimer) clearTimeout(debounceTimer); + provider.off("stateless", handleStateless); + try { + provider.destroy(); + } catch (error) { + console.error("Error destroying issue realtime provider:", error); + } + }; + }, [projectId, workspaceSlug, currentUser?.id, currentUser?.display_name]); +}; diff --git a/apps/web/package.json b/apps/web/package.json index 273a7a8769d..7c04445a7df 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,6 +25,7 @@ "@fontsource/ibm-plex-mono": "catalog:", "@fontsource/material-symbols-rounded": "catalog:", "@headlessui/react": "catalog:", + "@hocuspocus/provider": "catalog:", "@plane/constants": "workspace:*", "@plane/editor": "workspace:*", "@plane/hooks": "workspace:*", diff --git a/deployments/aio/community/variables.env b/deployments/aio/community/variables.env index 0a74aa2a007..938ba692c6f 100644 --- a/deployments/aio/community/variables.env +++ b/deployments/aio/community/variables.env @@ -64,6 +64,10 @@ AUTHENTICATION_RATE_LIMIT=10/minute # placeholder value below is left unchanged. LIVE_SERVER_SECRET_KEY=change-this-key-on-deployment +# Internal API key for server-to-server broadcast calls from the API server to the +# live server (enables realtime issue updates). Leave empty to disable. +LIVE_INTERNAL_API_KEY= + # Webhook IP allowlist — comma-separated IPs or CIDR ranges allowed as webhook targets # even if they resolve to private networks (e.g. "10.0.0.0/8,192.168.1.0/24,172.16.0.5") WEBHOOK_ALLOWED_IPS= diff --git a/deployments/cli/community/docker-compose.yml b/deployments/cli/community/docker-compose.yml index cd41be85f0f..ea3c83330ef 100644 --- a/deployments/cli/community/docker-compose.yml +++ b/deployments/cli/community/docker-compose.yml @@ -45,6 +45,7 @@ x-mq-env: &mq-env # RabbitMQ Settings x-live-env: &live-env API_BASE_URL: ${API_BASE_URL:-http://api:8000} LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY} + LIVE_INTERNAL_API_KEY: ${LIVE_INTERNAL_API_KEY:-} x-app-env: &app-env WEB_URL: ${WEB_URL:-http://localhost} @@ -58,6 +59,7 @@ x-app-env: &app-env API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute} MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0} LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY} + LIVE_INTERNAL_API_KEY: ${LIVE_INTERNAL_API_KEY:-} WEBHOOK_ALLOWED_IPS: ${WEBHOOK_ALLOWED_IPS:-} WEBHOOK_ALLOWED_HOSTS: ${WEBHOOK_ALLOWED_HOSTS:-} diff --git a/deployments/cli/community/variables.env b/deployments/cli/community/variables.env index f141d01fb7d..1882739e85d 100644 --- a/deployments/cli/community/variables.env +++ b/deployments/cli/community/variables.env @@ -88,6 +88,10 @@ AUTHENTICATION_RATE_LIMIT=10/minute # Generate a secure value with: openssl rand -hex 32 LIVE_SERVER_SECRET_KEY=change-this-key-on-deployment +# Internal API key for server-to-server broadcast calls from the API server to the +# live server (enables realtime issue updates). Leave empty to disable. +LIVE_INTERNAL_API_KEY= + # Webhook IP allowlist — comma-separated IPs or CIDR ranges allowed as webhook targets # even if they resolve to private networks (e.g. "10.0.0.0/8,192.168.1.0/24,172.16.0.5") WEBHOOK_ALLOWED_IPS= diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbf11e23d18..38cbbf9ef9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1039,6 +1039,9 @@ importers: '@headlessui/react': specifier: 'catalog:' version: 1.7.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@hocuspocus/provider': + specifier: 'catalog:' + version: 2.15.2(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) '@plane/constants': specifier: workspace:* version: link:../../packages/constants @@ -6010,6 +6013,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-loader@7.1.4: resolution: {integrity: sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==}