Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

push feed of some issues #6

Open
milahu opened this issue Feb 13, 2023 · 0 comments
Open

push feed of some issues #6

milahu opened this issue Feb 13, 2023 · 0 comments

Comments

@milahu
Copy link
Owner

milahu commented Feb 13, 2023

#1

i get push notifications when i have issues or PRs open in my browser = i see live edits and updates without page reload. maybe we can abuse that? but then, will that scale to a million subscriptions? a billion subscriptions? i guess github will not like this

push event callstack

d @ updatable-content.ts:49
setTimeout (async)    
(anonymous) @ updatable-content-observer.ts:23
y @ alive.ts:47
receive @ alive.ts:170
AliveSessionProxy.worker.port.onmessage @ alive.ts:67

https://github.githubassets.com/assets/ui/packages/alive/alive.ts

import type {AliveEvent, MetadataUpdate, Notifier, Subscription} from '@github/alive-client'
import {PresenceMetadataSet, SubscriptionSet, isPresenceChannel} from '@github/alive-client'
import {AliveSession} from './session'
import {debounce} from '@github/mini-throttle'
import {ready} from '@github-ui/document-ready'
import safeStorage from '@github-ui/safe-storage'
import {workerSrcRelPolicy, SourceRelNotFoundError} from '@github-ui/trusted-types-policies/worker-src-rel'

export interface Dispatchable {
  dispatchEvent: (e: Event) => unknown
}

function isSharedWorkerSupported(): boolean {
  return 'SharedWorker' in window && safeStorage('localStorage').getItem('bypassSharedWorker') !== 'true'
}

function workerSrc(): string | null {
  try {
    return workerSrcRelPolicy.createScriptURL('shared-web-socket-src')
  } catch (e) {
    if (e instanceof SourceRelNotFoundError) {
      return null
    }
    throw e
  }
}

function socketUrl(): string | null {
  return document.head.querySelector<HTMLLinkElement>('link[rel=shared-web-socket]')?.href ?? null
}

function socketRefreshUrl(): string | null {
  return (
    document.head.querySelector<HTMLLinkElement>('link[rel=shared-web-socket]')?.getAttribute('data-refresh-url') ??
    null
  )
}

function sessionIdentifier(): string | null {
  return (
    document.head.querySelector<HTMLLinkElement>('link[rel=shared-web-socket]')?.getAttribute('data-session-id') ?? null
  )
}

function notify(subscribers: Iterable<Dispatchable>, {channel, type, data}: AliveEvent) {
  for (const el of subscribers) {
    el.dispatchEvent(
      new CustomEvent(`socket:${type}`, {
        bubbles: false,
        cancelable: false,
        detail: {name: channel, data},
      }),
    )
  }
}

class AliveSessionProxy {
  private worker: SharedWorker
  private subscriptions = new SubscriptionSet<Dispatchable>()
  private presenceMetadata = new PresenceMetadataSet<Dispatchable>()
  private notify: Notifier<Dispatchable>

  constructor(src: string, url: string, refreshUrl: string, sessionId: string, notifier: Notifier<Dispatchable>) {
    this.notify = notifier
    // eslint-disable-next-line ssr-friendly/no-dom-globals-in-constructor
    this.worker = new SharedWorker(src, `github-socket-worker-v2-${sessionId}`)
    this.worker.port.onmessage = ({data}) => this.receive(data)
    this.worker.port.postMessage({connect: {url, refreshUrl}})
  }

  subscribe(subs: Array<Subscription<Dispatchable>>) {
    const added = this.subscriptions.add(...subs)
    if (added.length) {
      this.worker.port.postMessage({subscribe: added})
    }

    // We may be adding a subscription to a presence channel which is already subscribed.
    // In this case, we need to explicitly ask the SharedWorker to send us the presence data.
    const addedChannels = new Set(added.map(topic => topic.name))
    const redundantPresenceChannels = subs.reduce((redundantChannels, subscription) => {
      const channel = subscription.topic.name

      if (isPresenceChannel(channel) && !addedChannels.has(channel)) {
        redundantChannels.add(channel)
      }

      return redundantChannels
    }, new Set<string>())

    if (redundantPresenceChannels.size) {
      this.worker.port.postMessage({requestPresence: Array.from(redundantPresenceChannels)})
    }
  }

  unsubscribeAll(...subscribers: Dispatchable[]) {
    const removed = this.subscriptions.drain(...subscribers)
    if (removed.length) {
      this.worker.port.postMessage({unsubscribe: removed})
    }

    const updatedPresenceChannels = this.presenceMetadata.removeSubscribers(subscribers)
    this.sendPresenceMetadataUpdate(updatedPresenceChannels)
  }

  updatePresenceMetadata(metadataUpdates: Array<MetadataUpdate<Dispatchable>>) {
    const updatedChannels = new Set<string>()

    for (const update of metadataUpdates) {
      // update the local metadata for this specific element
      this.presenceMetadata.setMetadata(update)
      updatedChannels.add(update.channelName)
    }

    // Send the full local metadata for these channels to the SharedWorker
    this.sendPresenceMetadataUpdate(updatedChannels)
  }

  sendPresenceMetadataUpdate(channelNames: Set<string>) {
    if (!channelNames.size) {
      return
    }

    const updatesForSharedWorker: Array<Omit<MetadataUpdate<Element>, 'subscriber'>> = []

    for (const channelName of channelNames) {
      // get all metadata for this channel (from all elements) to send to the SharedWorker
      updatesForSharedWorker.push({
        channelName,
        metadata: this.presenceMetadata.getChannelMetadata(channelName),
      })
    }

    // Send the full metadata updates to the SharedWorker
    this.worker.port.postMessage({updatePresenceMetadata: updatesForSharedWorker})
  }

  online() {
    this.worker.port.postMessage({online: true})
  }

  offline() {
    this.worker.port.postMessage({online: false})
  }

  hangup() {
    this.worker.port.postMessage({hangup: true})
  }

  private notifyPresenceDebouncedByChannel = new Map<string, Notifier<Dispatchable>>()
  private receive(event: AliveEvent) {
    const {channel} = event

    if (event.type === 'presence') {
      // There are times when we get a flood of messages from the SharedWorker, such as a tab that has been idle for a long time and then comes back to the foreground.
      // Since each presence message for a channel contains the full list of users, we can debounce the events and only notify subscribers with the last one
      let debouncedNotify = this.notifyPresenceDebouncedByChannel.get(channel)
      if (!debouncedNotify) {
        debouncedNotify = debounce((subscribers, debouncedEvent) => {
          this.notify(subscribers, debouncedEvent)
          this.notifyPresenceDebouncedByChannel.delete(channel)
        }, 100)
        this.notifyPresenceDebouncedByChannel.set(channel, debouncedNotify)
      }

      debouncedNotify(this.subscriptions.subscribers(channel), event)
      return
    }

    // For non-presence messages, we can send them through immediately since they may contain different messages/data
    this.notify(this.subscriptions.subscribers(channel), event)
  }
}

async function connect() {
  const src = workerSrc()
  if (!src) return

  const url = socketUrl()
  if (!url) return

  const refreshUrl = socketRefreshUrl()
  if (!refreshUrl) return

  const sessionId = sessionIdentifier()
  if (!sessionId) return

  const createSession = () => {
    if (isSharedWorkerSupported()) {
      try {
        return new AliveSessionProxy(src, url, refreshUrl, sessionId, notify)
      } catch (_) {
        // ignore errors.  CSP will some times block SharedWorker creation. Fall back to standard AliveSession.
      }
    }

    return new AliveSession(url, refreshUrl, false, notify)
  }
  const session = createSession()

  window.addEventListener('online', () => session.online())
  window.addEventListener('offline', () => session.offline())
  window.addEventListener('pagehide', () => {
    if ('hangup' in session) session.hangup()
  })

  return session
}

async function connectWhenReady() {
  await ready
  return connect()
}

let sessionPromise: undefined | ReturnType<typeof connectWhenReady>

export function getSession() {
  return (sessionPromise ||= connectWhenReady())
}

https://github.githubassets.com/assets/app/assets/modules/github/behaviors/updatable-content-observer.ts

//
// Updatable Content
//
// Markup
//
//     <div class="js-socket-channel js-updatable-content"
//          data-channel="pull:123"
//          data-url="/pull/123?partial=commits">
//     </div>
//

import {fromEvent} from '../subscription'
// eslint-disable-next-line no-restricted-imports
import {observe} from '@github/selector-observer'
import {updateContent} from '../updatable-content'

observe('.js-socket-channel.js-updatable-content', {
  subscribe: el =>
    fromEvent(el, 'socket:message', function (event: Event) {
      const {gid, wait} = (event as CustomEvent).detail.data
      const container = event.target as HTMLElement
      const target = gid ? findByGid(container, gid) : container
      if (target) setTimeout(updateContent, wait || 0, target)
    }),
})

function findByGid(root: HTMLElement, gid: string): HTMLElement | null {
  if (root.getAttribute('data-gid') === gid) return root
  for (const el of root.querySelectorAll<HTMLElement>(`[data-url][data-gid]`)) {
    if (el.getAttribute('data-gid') === gid) {
      return el
    }
  }
  return null
}

https://github.githubassets.com/assets/app/assets/modules/github/updatable-content.ts

import {hasInteractions} from './has-interactions'
// eslint-disable-next-line no-restricted-imports
import {observe} from '@github/selector-observer'
import {parseHTML} from './parse-html'
import {preserveAnchorNodePosition} from 'scroll-anchoring'
import {replaceState} from './history'

const pendingRequests = new WeakMap<HTMLElement, AbortController>()
const staleRecords: {[key: string]: string} = {}

// Wrapper around `window.location.reload()` that forceably cleans out the
// `staleRecords` state associated with the entry at the top of the history
// stack before reloading.
export function reload() {
  for (const key of Object.keys(staleRecords)) {
    delete staleRecords[key]
  }
  const stateObject = history.state || {}
  stateObject.staleRecords = staleRecords
  replaceState(stateObject, '', location.href)
  window.location.reload()
}

// Associates the `staleRecords` object, if it contains any entries, with the
// entry at top of the history stack.
export function registerStaleRecords() {
  if (Object.keys(staleRecords).length > 0) {
    const stateObject = history.state || {}
    stateObject.staleRecords = staleRecords
    replaceState(stateObject, '', location.href)
  }
}

// Fetch and replace container with its data-url.
//
// This replacement uses conservative checks to safely replace the element.
// If a user is interacting with any element within the container, the
// replacement will be aborted.
export async function updateContent(el: HTMLElement): Promise<void> {
  if (pendingRequests.get(el)) return

  const retainFocus = el.hasAttribute('data-retain-focus')
  const url = el.getAttribute('data-url')
  if (!url) throw new Error('could not get url')
  const controller = new AbortController()
  pendingRequests.set(el, controller)

  try {
    const response = await fetch(url, {
      signal: controller.signal,
      headers: {
        Accept: 'text/html',
        'X-Requested-With': 'XMLHttpRequest',
      },
    })
    if (!response.ok) return
    const data = await response.text()
    if (hasInteractions(el, retainFocus)) {
      // eslint-disable-next-line no-console
      console.warn('Failed to update content with interactions', el)
      return
    }
    staleRecords[url] = data
    return replace(el, data, retainFocus)
  } catch {
    // Ignore failed request.
  } finally {
    pendingRequests.delete(el)
  }
}

// Abort any in-flight replacements and replace element without any interaction checks.
export async function replaceContent(el: HTMLElement, data: string, wasStale = false): Promise<void> {
  const controller = pendingRequests.get(el)
  controller?.abort()

  const updatable = el.closest('.js-updatable-content[data-url], .js-updatable-content [data-url]')
  if (!wasStale && updatable && updatable === el) {
    staleRecords[updatable.getAttribute('data-url') || ''] = data
  }
  return replace(el, data)
}

function replace(el: HTMLElement, data: string, retainFocus = false): Promise<void> {
  return preserveAnchorNodePosition(document, () => {
    const newContent = parseHTML(document, data.trim())
    const elementToRefocus =
      retainFocus && el.ownerDocument && el === el.ownerDocument.activeElement ? newContent.querySelector('*') : null

    const detailsIds = Array.from(el.querySelectorAll('details[open][id]')).map(element => element.id)
    if (el.tagName === 'DETAILS' && el.id && el.hasAttribute('open')) detailsIds.push(el.id)

    // Check the elements we are about replace to see if we want to preserve the scroll position of any of them
    for (const preserveElement of el.querySelectorAll('.js-updatable-content-preserve-scroll-position')) {
      const id = preserveElement.getAttribute('data-updatable-content-scroll-position-id') || ''
      heights.set(id, preserveElement.scrollTop)
    }

    for (const id of detailsIds) {
      const details = newContent.querySelector(`#${id}`)
      if (details) details.setAttribute('open', '')
    }

    el.replaceWith(newContent)
    if (elementToRefocus instanceof HTMLElement) {
      elementToRefocus.focus()
    }
  })
}

const heights = new Map()
observe('.js-updatable-content-preserve-scroll-position', {
  constructor: HTMLElement,
  add(el) {
    // When element is added to the DOM, check the map for the last scroll position we have on record for it.
    const id = el.getAttribute('data-updatable-content-scroll-position-id')
    if (!id) return
    const height = heights.get(id)
    if (height == null) return

    el.scrollTop = height
  },
})

keywords: github push events per issue, service worker, alive client, alive.ts, websocket, pubsub, presence, liveupdate, live update, liveview, live view, server sent events, updatable-content.ts, updatable-content-observer.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant