@socketsupply/socket
Version:
A Cross-Platform, Native Runtime for Desktop and Mobile Apps — Create apps using HTML, CSS, and JavaScript. Written from the ground up to be small and maintainable.
437 lines (373 loc) • 10.1 kB
JavaScript
/* global MessagePort */
import { Deferred } from '../async.js'
import { Context } from './context.js'
import application from '../application.js'
import location from '../location.js'
import state from './state.js'
import ipc from '../ipc.js'
export const textEncoder = new TextEncoderStream()
export const FETCH_EVENT_TIMEOUT = (
// TODO(@jwerle): document this
parseInt(application.config.webview_service_worker_fetch_event_timeout) ||
30000
)
export const FETCH_EVENT_MAX_RESPONSE_REDIRECTS = (
// TODO(@jwerle): document this
parseInt(application.config.webview_service_worker_fetch_event_max_response_redirects) ||
16 // this aligns with WebKit
)
/**
* The `ExtendableEvent` interface extends the lifetime of the "install" and
* "activate" events dispatched on the global scope as part of the service
* worker lifecycle.
*/
export class ExtendableEvent extends Event {
#promise = new Deferred()
#promises = []
#pendingPromiseCount = 0
#context = null
/**
* `ExtendableEvent` class constructor.
* @ignore
*/
constructor (...args) {
super(...args)
this.#context = new Context(this)
}
/**
* A context for this `ExtendableEvent` instance.
* @type {import('./context.js').Context}
*/
get context () {
return this.#context
}
/**
* A promise that can be awaited which waits for this `ExtendableEvent`
* instance no longer has pending promises.
* @type {Promise}
*/
get awaiting () {
return this.waitsFor()
}
/**
* The number of pending promises
* @type {number}
*/
get pendingPromises () {
return this.#pendingPromiseCount
}
/**
* `true` if the `ExtendableEvent` instance is considered "active",
* otherwise `false`.
* @type {boolean}
*/
get isActive () {
return (
this.#pendingPromiseCount > 0 ||
this.eventPhase === Event.AT_TARGET
)
}
/**
* Tells the event dispatcher that work is ongoing.
* It can also be used to detect whether that work was successful.
* @param {Promise} promise
*/
waitUntil (promise) {
// we ignore the isTrusted check here and just verify the event phase
if (this.eventPhase !== Event.AT_TARGET) {
throw new DOMException('Event is not active', 'InvalidStateError')
}
if (typeof promise?.then === 'function') {
this.#pendingPromiseCount++
this.#promises.push(promise)
promise.then(
() => queueMicrotask(() => {
if (--this.#pendingPromiseCount === 0) {
this.#promise.resolve()
}
}),
() => queueMicrotask(() => {
if (--this.#pendingPromiseCount === 0) {
this.#promise.resolve()
}
})
)
// handle 0 pending promises
}
}
/**
* Returns a promise that this `ExtendableEvent` instance is waiting for.
* @return {Promise}
*/
async waitsFor () {
if (this.#pendingPromiseCount === 0) {
this.#promise.resolve()
}
return await this.#promise
}
}
/**
* This is the event type for "fetch" events dispatched on the service worker
* global scope. It contains information about the fetch, including the
* request and how the receiver will treat the response.
*/
export class FetchEvent extends ExtendableEvent {
static defaultHeaders = new Headers()
#handled = new Deferred()
#request = null
#clientId = null
#isReload = false
#fetchId = null
#responded = false
#timeout = null
/**
* `FetchEvent` class constructor.
* @ignore
* @param {string=} [type = 'fetch']
* @param {object=} [options]
*/
constructor (type = 'fetch', options = null) {
super(type, options)
this.#fetchId = options?.fetchId ?? null
this.#request = options?.request ?? null
this.#clientId = options?.clientId ?? ''
this.#isReload = options?.isReload === true
this.#timeout = setTimeout(() => {
this.respondWith(new Response('Request Timeout', {
status: 408,
statusText: 'Request Timeout'
}))
}, FETCH_EVENT_TIMEOUT)
}
/**
* The handled property of the `FetchEvent` interface returns a promise
* indicating if the event has been handled by the fetch algorithm or not.
* This property allows executing code after the browser has consumed a
* response, and is usually used together with the `waitUntil()` method.
* @type {Promise}
*/
get handled () {
return this.#handled.then(Promise.resolve())
}
/**
* The request read-only property of the `FetchEvent` interface returns the
* `Request` that triggered the event handler.
* @type {Request}
*/
get request () {
return this.#request
}
/**
* The `clientId` read-only property of the `FetchEvent` interface returns
* the id of the Client that the current service worker is controlling.
* @type {string}
*/
get clientId () {
return this.#clientId
}
/**
* @ignore
* @type {string}
*/
get resultingClientId () {
return ''
}
/**
* @ignore
* @type {string}
*/
get replacesClientId () {
return ''
}
/**
* @ignore
* @type {boolean}
*/
get isReload () {
return this.#isReload
}
/**
* @ignore
* @type {Promise}
*/
get preloadResponse () {
return Promise.resolve(null)
}
/**
* The `respondWith()` method of `FetchEvent` prevents the webview's
* default fetch handling, and allows you to provide a promise for a
* `Response` yourself.
* @param {Response|Promise<Response>} response
*/
respondWith (response) {
if (this.#responded) {
return
}
this.#responded = true
clearTimeout(this.#timeout)
const clientId = this.#clientId
const handled = this.#handled
const id = this.#fetchId
queueMicrotask(async () => {
try {
response = await response
if (!response || !(response instanceof Response)) {
// TODO(@jwerle): handle this
return
}
if (response.type === 'error') {
const statusCode = 0
const headers = []
const params = {
statusCode,
clientId,
headers,
id
}
params['runtime-preload-injection'] = 'disabled'
const result = await ipc.request('serviceWorker.fetch.response', params)
if (result.err) {
state.reportError(result.err)
}
handled.resolve()
return
}
let arrayBuffer = null
let statusCode = response.status ?? 200
// just follow the redirect here now
if (statusCode >= 300 && statusCode < 400 && response.headers.has('location')) {
let previousResponse = response
let remainingRedirects = FETCH_EVENT_MAX_RESPONSE_REDIRECTS
while (remainingRedirects-- > 0) {
const redirectLocation = previousResponse.headers.get('location')
if (!redirectLocation) {
statusCode = 404
break
}
const url = new URL(redirectLocation, location.origin)
previousResponse = await fetch(url.href)
if (previousResponse.status >= 200 && previousResponse.status < 300) {
arrayBuffer = await previousResponse.arrayBuffer()
break
} else if (previousResponse.status >= 300 && statusCode < 400) {
continue
} else {
statusCode = previousResponse.statusCode
arrayBuffer = await previousResponse.arrayBuffer()
break
}
}
} else {
arrayBuffer = await response.arrayBuffer()
}
const headers = []
.concat(Array.from(response.headers.entries()))
.concat(Array.from(FetchEvent.defaultHeaders.entries()))
.map((entry) => entry.join(':'))
.concat('Runtime-Response-Source:serviceworker')
.join('\n')
const params = {
statusCode,
clientId,
headers,
id
}
params['runtime-preload-injection'] = (
response.headers.get('runtime-preload-injection') ||
'auto'
)
const result = await ipc.write(
'serviceWorker.fetch.response',
params,
new Uint8Array(arrayBuffer)
)
if (result.err) {
state.reportError(result.err)
}
handled.resolve()
} catch (err) {
state.reportError(err)
} finally {
handled.resolve()
}
})
}
}
export class ExtendableMessageEvent extends ExtendableEvent {
#data = null
#ports = []
#origin = null
#source = null
#lastEventId = ''
/**
* `ExtendableMessageEvent` class constructor.
* @param {string=} [type = 'message']
* @param {object=} [options]
*/
constructor (type = 'message', options = null) {
super(type, options)
this.#data = options?.data ?? null
if (Array.isArray(options?.ports)) {
for (const port of options.ports) {
if (port instanceof MessagePort) {
this.#ports.push(port)
}
}
}
if (options?.source) {
this.#source = options.source
}
}
/**
* @type {any}
*/
get data () {
return this.#data
}
/**
* @type {MessagePort[]}
*/
get ports () {
return this.#ports
}
/**
* @type {import('./clients.js').Client?}
*/
get source () {
return this.#source
}
/**
* @type {string?}
*/
get origin () {
return this.#origin
}
/**
* @type {string}
*/
get lastEventId () {
return this.#lastEventId
}
}
export class NotificationEvent extends ExtendableEvent {
#action = ''
#notification = null
constructor (type, options) {
super(type, options)
if (typeof options?.action === 'string') {
this.#action = options.action
}
this.#notification = options.notification
}
get action () {
return this.#action
}
get notification () {
return this.#notification
}
}
export default {
ExtendableMessageEvent,
ExtendableEvent,
FetchEvent
}