@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.
947 lines (827 loc) • 21.8 kB
JavaScript
/* global XMLHttpRequest */
/**
* @module commonjs.loader
*/
import { CacheCollection, Cache } from './cache.js'
import { defineBuiltin } from './builtins.js'
import InternalSymbols from '../internal/symbols.js'
import application from '../application.js'
import { Headers } from '../ipc.js'
import location from '../location.js'
import path from '../path.js'
import URL from '../url.js'
import fs from '../fs.js'
const RUNTIME_SERVICE_WORKER_FETCH_MODE = 'Runtime-ServiceWorker-Fetch-Mode'
const RUNTIME_REQUEST_SOURCE_HEADER = 'Runtime-Request-Source'
const textDecoder = new TextDecoder()
/**
* @typedef {{
* extensions?: string[] | Set<string>
* origin?: URL | string,
* statuses?: Cache
* cache?: { response?: Cache, status?: Cache },
* headers?: Headers | Map | object | string[][]
* }} LoaderOptions
*/
/**
* @typedef {{
* loader?: Loader,
* origin?: URL | string
* }} RequestOptions
*/
/**
* @typedef {{
* headers?: Headers | object | array[],
* status?: number
* }} RequestStatusOptions
*/
/**
* @typedef {{
* headers?: Headers | object
* }} RequestLoadOptions
*/
/**
* @typedef {{
* request?: Request,
* headers?: Headers,
* status?: number,
* buffer?: ArrayBuffer,
* text?: string
* }} ResponseOptions
*/
/**
* A container for the status of a CommonJS resource. A `RequestStatus` object
* represents meta data for a `Request` that comes from a preflight
* HTTP HEAD request.
*/
export class RequestStatus {
/**
* Creates a `RequestStatus` from JSON input.
* @param {object} json
* @return {RequestStatus}
*/
static from (json, options) {
const status = new this(null, json)
status.request = Request.from({ url: json.id }, { ...options, status })
return status
}
#status = undefined
#request = null
#headers = new Headers()
/**
* `RequestStatus` class constructor.
* @param {Request} request
* @param {RequestStatusOptions} [options]
*/
constructor (request, options = null) {
if (!options && request && !(request instanceof Request)) {
options = request
request = options.requesst
}
if (request && !(request instanceof Request)) {
throw new TypeError(
`Expecting 'request' to be a Request object. Received: ${request}`
)
}
this.#headers = options?.headers ? Headers.from(options.headers) : this.#headers
this.#status = options?.status ? options.status : undefined
if (request) {
this.request = request
}
}
/**
* The `Request` object associated with this `RequestStatus` object.
* @type {Request}
*/
get request () { return this.#request }
set request (request) {
this.#request = request
if (
!this.#status &&
request?.loader?.cache?.status?.has?.(request?.id)
) {
this.#status = (
request.status?.value ??
request.loader.cache.status.get(request.id)?.value ??
null
)
}
}
/**
* The unique ID of this `RequestStatus`, which is the absolute URL as a string.
* @type {string}
*/
get id () {
return this.#request?.id ?? null
}
/**
* The origin for this `RequestStatus` object.
* @type {string}
*/
get origin () {
return this.#request?.origin ?? null
}
/**
* A HTTP status code for this `RequestStatus` object.
* @type {number|undefined}
*/
get status () {
return this.#status
}
/**
* An alias for `status`.
* @type {number|undefined}
*/
get value () {
return this.#status
}
/**
* @ignore
*/
get valueOf () {
return this.status
}
/**
* The HTTP headers for this `RequestStatus` object.
* @type {Headers}
*/
get headers () {
return this.#headers
}
/**
* The resource location for this `RequestStatus` object. This value is
* determined from the 'Content-Location' header, if available, otherwise
* it is derived from the request URL pathname (including the query string).
* @type {string}
*/
get location () {
const contentLocation = this.#headers.get('content-location')
if (contentLocation) {
return contentLocation
}
if (this.#request) {
return this.#request.url.pathname + this.#request.url.search
}
return ''
}
/**
* `true` if the response status is considered OK, otherwise `false`.
* @type {boolean}
*/
get ok () {
return this.#status >= 200 && this.#status < 400
}
/**
* Loads the internal state for this `RequestStatus` object.
* @param {RequestLoadOptions|boolean} [options]
* @return {RequestStatus}
*/
load (options = null) {
// allow `load(true)` to force a reload of the state
if (this.#status && options !== true) {
return this
}
if (
this.#request.id.includes(`://${application.config.meta_bundle_identifier}`)
) {
try {
const id = this.#request.id.replace('https:', 'socket:')
fs.accessSync(id)
} catch {
this.#request.loader.cache.status.set(this.id, this)
this.#headers = new Headers()
this.#status = 404
return this
}
}
const request = new XMLHttpRequest()
request.open('HEAD', this.#request.id, false)
request.setRequestHeader(RUNTIME_REQUEST_SOURCE_HEADER, 'module')
if (globalThis.isServiceWorkerScope) {
request.setRequestHeader(RUNTIME_SERVICE_WORKER_FETCH_MODE, 'ignore')
}
if (this.#request?.loader) {
const entries = this.#request.loader.headers.entries()
for (const entry of entries) {
// @ts-ignore
request.setRequestHeader(...entry)
}
}
if (options?.headers && typeof options?.headers === 'object') {
const entries = typeof options.headers.entries === 'function'
? options.headers.entries()
: Object.entries(options.headers)
for (const entry of entries) {
// @ts-ignore
request.setRequestHeader(...entry)
}
}
request.send(null)
this.#headers = Headers.from(request)
this.#status = request.status
const contentLocation = this.#headers.get('content-location')
if (this.#request) {
this.#request.loader.cache.status.set(this.id, this)
}
// verify 'Content-Location' header if given in response
// @ts-ignore
if (this.#request && contentLocation && URL.canParse(contentLocation, this.origin)) {
const url = new URL(contentLocation, this.origin)
const extension = path.extname(url.pathname)
if (!this.#request.loader.extensions.has(extension)) {
this.#status = 404
return this
}
}
return this
}
/**
* Converts this `RequestStatus` to JSON.
* @ignore
* @return {{
* id: string,
* origin: string | null,
* status: number,
* headers: Array<string[]>
* request: object | null | undefined
* }}
*/
toJSON (includeRequest = true) {
if (includeRequest) {
return {
id: this.id,
origin: this.origin,
status: this.status,
headers: Array.from(this.headers.entries()),
request: this.#request ? this.#request.toJSON(false) : null
}
} else {
return {
id: this.id,
origin: this.origin,
status: this.status,
headers: Array.from(this.headers.entries())
}
}
}
/**
* Serializes this `Response`, suitable for `postMessage()` transfers.
* @ignore
* @return {{
* __type__: 'RequestStatus',
* id: string,
* origin: string | null,
* status: number,
* headers: Array<string[]>
* request: object | null
* }}
*/
[InternalSymbols.serialize] () {
return { __type__: 'RequestStatus', ...this.toJSON() }
}
}
/**
* A container for a synchronous CommonJS request to local resource or
* over the network.
*/
export class Request {
/**
* Creates a `Request` instance from JSON input
* @param {object} json
* @param {RequestOptions=} [options]
* @return {Request}
*/
static from (json, options) {
return new this(json.url, {
status: (
json.status &&
typeof json.status === 'object' &&
!(json.status instanceof RequestStatus)
// @ts-ignore
? RequestStatus.from(json.status)
: options?.status
),
...options
})
}
#url = null
#loader = null
#status = null
/**
* `Request` class constructor.
* @param {URL|string} url
* @param {URL|string=} [origin]
* @param {RequestOptions=} [options]
*/
constructor (url, origin, options = null) {
if (origin && typeof origin === 'object' && !(origin instanceof URL)) {
options = origin
origin = options.origin ?? null
}
if (!origin) {
origin = location.origin
}
if (String(origin).startsWith('blob:')) {
origin = new URL(origin).pathname
}
this.#url = new URL(url, origin)
this.#loader = options?.loader ?? null
this.#status = options?.status instanceof RequestStatus
? options.status
: new RequestStatus(options.status)
this.#status.request = this
}
/**
* The unique ID of this `Request`, which is the absolute URL as a string.
* @type {string}
*/
get id () {
return this.url.href
}
/**
* The absolute `URL` of this `Request` object.
* @type {URL}
*/
get url () {
return this.#url
}
/**
* The origin for this `Request`.
* @type {string}
*/
get origin () {
return this.url.origin
}
/**
* The `Loader` for this `Request` object.
* @type {Loader?}
*/
get loader () {
return this.#loader
}
/**
* The `RequestStatus` for this `Request`
* @type {RequestStatus}
*/
get status () {
return this.#status.load()
}
/**
* Loads the CommonJS source file, optionally checking the `Loader` cache
* first, unless ignored when `options.cache` is `false`.
* @param {RequestLoadOptions=} [options]
* @return {Response}
*/
load (options = null) {
// check loader cache first
if (options?.cache !== false && this.#loader !== null) {
if (this.#loader.cache.response.has(this.id)) {
return this.#loader.cache.response.get(this.id)
}
}
if (this.status.value >= 400) {
return new Response(this, {
status: this.status.value
})
}
if (
/^(socket:|https:)/.test(this.id) &&
this.id.includes(`//${application.config.meta_bundle_identifier}/`)
) {
try {
const id = this.id.replace('https:', 'socket:')
fs.accessSync(id)
} catch {
return new Response(this, {
status: 404
})
}
}
const request = new XMLHttpRequest()
request.open('GET', this.id, false)
request.setRequestHeader(RUNTIME_REQUEST_SOURCE_HEADER, 'module')
if (globalThis.isServiceWorkerScope) {
request.setRequestHeader(RUNTIME_SERVICE_WORKER_FETCH_MODE, 'ignore')
}
if (typeof options?.responseType === 'string') {
request.responseType = options.responseType
}
if (this.#loader) {
const entries = this.#loader.headers.entries()
for (const entry of entries) {
// @ts-ignore
request.setRequestHeader(...entry)
}
}
if (options?.headers && typeof options?.headers === 'object') {
const entries = typeof options.headers.entries === 'function'
? options.headers.entries()
: Object.entries(options.headers)
for (const entry of entries) {
// @ts-ignore
request.setRequestHeader(...entry)
}
}
request.send(null)
let responseText = null
try {
// @ts-ignore
responseText = request.responseText // can throw `InvalidStateError` error
} catch {
if (typeof request.response === 'string') {
responseText = request.response
}
}
return new Response(this, {
headers: Headers.from(request),
status: request.status,
buffer: request.response,
text: responseText ?? null
})
}
/**
* Converts this `Request` to JSON.
* @ignore
* @return {{
* url: string,
* status: object | undefined
* }}
*/
toJSON (includeStatus = true) {
if (includeStatus) {
return {
url: this.url.href,
status: this.status.toJSON(false)
}
} else {
return {
url: this.url.href
}
}
}
/**
* Serializes this `Response`, suitable for `postMessage()` transfers.
* @ignore
* @return {{
* __type__: 'Request',
* url: string,
* status: object | undefined
* }}
*/
[InternalSymbols.serialize] () {
return { __type__: 'Request', ...this.toJSON() }
}
}
/**
* A container for a synchronous CommonJS request response for a local resource
* or over the network.
*/
export class Response {
/**
* Creates a `Response` from JSON input
* @param {obejct} json
* @param {ResponseOptions=} [options]
* @return {Response}
*/
static from (json, options) {
return new this({
...json,
request: Request.from({ url: json.id }, options)
}, options)
}
#request = null
#headers = null
#status = 404
#buffer = null
#text = ''
/**
* `Response` class constructor.
* @param {Request|ResponseOptions} request
* @param {ResponseOptions=} [options]
*/
constructor (request, options = null) {
options = { ...options }
if (typeof request === 'object' && !(request instanceof Request)) {
options = request
request = options.request
}
if (!request || !(request instanceof Request)) {
throw new TypeError(
`Expecting 'request' to be a Request object. Received: ${request}`
)
}
this.#request = request
this.#headers = Headers.from(options.headers)
this.#status = options.status || 404
this.#buffer = options.buffer ? new Uint8Array(options.buffer).buffer : null
this.#text = options.text || ''
if (request.loader) {
// cache request response in the loader
request.loader.cache.response.set(request.id, this)
}
}
/**
* The unique ID of this `Response`, which is the absolute
* URL of the request as a string.
* @type {string}
*/
get id () {
return this.#request.id
}
/**
* The `Request` object associated with this `Response` object.
* @type {Request}
*/
get request () {
return this.#request
}
/**
* The response headers from the associated request.
* @type {Headers}
*/
get headers () {
return this.#headers
}
/**
* The `Loader` associated with this `Response` object.
* @type {Loader?}
*/
get loader () {
return this.request.loader
}
/**
* The `Response` status code from the associated `Request` object.
* @type {number}
*/
get status () {
return this.#status
}
/**
* The `Response` string from the associated `Request`
* @type {string}
*/
get text () {
if (this.#text) {
return this.#text
} else if (this.#buffer) {
return textDecoder.decode(this.#buffer)
}
return ''
}
/**
* The `Response` array buffer from the associated `Request`
* @type {ArrayBuffer?}
*/
get buffer () {
return this.#buffer ?? null
}
/**
* `true` if the response is considered OK, otherwise `false`.
* @type {boolean}
*/
get ok () {
return this.id && this.status >= 200 && this.status < 400
}
/**
* Converts this `Response` to JSON.
* @ignore
* @return {{
* id: string,
* text: string,
* status: number,
* buffer: number[] | null,
* headers: Array<string[]>
* }}
*/
toJSON () {
return {
id: this.id,
text: this.text,
status: this.status,
buffer: this.#buffer ? Array.from(new Uint8Array(this.#buffer)) : null,
headers: Array.from(this.#headers.entries())
}
}
/**
* Serializes this `Response`, suitable for `postMessage()` transfers.
* @ignore
* @return {{
* __type__: 'Response',
* id: string,
* text: string,
* status: number,
* buffer: number[] | null,
* headers: Array<string[]>
* }}
*/
[InternalSymbols.serialize] () {
return { __type__: 'Response', ...this.toJSON() }
}
}
/**
* A container for loading CommonJS module sources
*/
export class Loader {
/**
* A request class used by `Loader` objects.
* @type {typeof Request}
*/
static Request = Request
/**
* A response class used by `Loader` objects.
* @type {typeof Request}
*/
static Response = Response
/**
* Resolves a given module URL to an absolute URL with an optional `origin`.
* @param {URL|string} url
* @param {URL|string} [origin]
* @return {string}
*/
static resolve (url, origin = null) {
if (!origin) {
origin = location.origin
}
if (String(origin).startsWith('blob:')) {
origin = new URL(origin).pathname
}
if (String(url).startsWith('blob:')) {
url = new URL(url).pathname
}
return String(new URL(url, origin))
}
/**
* Default extensions for a loader.
* @type {Set<string>}
*/
static defaultExtensions = new Set([
'.js',
'.json',
'.mjs',
'.cjs',
'.jsx',
'.ts',
'.tsx',
'.wasm'
])
#cache = new CacheCollection()
#origin = null
#headers = new Headers()
#extensions = Loader.defaultExtensions
/**
* `Loader` class constructor.
* @param {string|URL|LoaderOptions} origin
* @param {LoaderOptions=} [options]
*/
constructor (origin, options = null) {
if (origin && typeof origin === 'object' && !(origin instanceof URL)) {
options = origin
origin = options.origin
}
this.#origin = Loader.resolve('.', origin)
if (options?.headers && typeof options.headers === 'object') {
if (Array.isArray(options.headers)) {
for (const entry of options.headers) {
// @ts-ignore
this.#headers.set(...entry)
}
} else if (typeof options.headers.entries === 'function') {
for (const entry of options.headers.entries()) {
// @ts-ignore
this.#headers.set(...entry)
}
} else {
for (const key in options.headers) {
this.#headers.set(key, options.headers[key])
}
}
}
if (options?.extensions && typeof options.extensions === 'object') {
if (Array.isArray(options.extensions) || options instanceof Set) {
for (const value of options.extensions) {
const extension = (!value.startsWith('.') ? `.${value}` : value).trim()
if (extension) {
this.#extensions.add(extension.trim())
}
}
}
}
this.#cache.add(
'status',
options?.cache?.status instanceof Cache
? options.cache.status
: new Cache('loader.status', { loader: this, types: { RequestStatus } })
)
this.#cache.add(
'response',
options?.cache?.response instanceof Cache
? options.cache.response
: new Cache('loader.response', { loader: this, types: { Response } })
)
this.#cache.restore()
}
/**
* The internal caches for this `Loader` object.
* @type {{ response: Cache, status: Cache }}
*/
get cache () {
return this.#cache
}
/**
* Headers used in too loader requests.
* @type {Headers}
*/
get headers () {
return this.#headers
}
/**
* A set of supported `Loader` extensions.
* @type {Set<string>}
*/
get extensions () {
return this.#extensions
}
/**
* The origin of this `Loader` object.
* @type {string}
*/
get origin () { return this.#origin }
set origin (origin) {
this.#origin = Loader.resolve(origin, location.origin)
}
/**
* Loads a CommonJS module source file at `url` with an optional `origin`, which
* defaults to the application origin.
* @param {URL|string} url
* @param {URL|string|object} [origin]
* @param {RequestOptions=} [options]
* @return {Response}
*/
load (url, origin, options) {
if (origin && typeof origin === 'object' && !(origin instanceof URL)) {
options = origin
origin = options.origin ?? this.origin
}
if (!origin) {
origin = this.origin
}
const request = new Request(url, {
loader: this,
origin
})
return request.load(options)
}
/**
* Queries the status of a CommonJS module source file at `url` with an
* optional `origin`, which defaults to the application origin.
* @param {URL|string} url
* @param {URL|string|object} [origin]
* @param {RequestOptions=} [options]
* @return {RequestStatus}
*/
status (url, origin, options = null) {
if (origin && typeof origin === 'object' && !(origin instanceof URL)) {
options = origin
origin = options.origin ?? this.origin
}
if (!origin) {
origin = this.origin
}
url = this.resolve(url, origin)
// @ts-ignore
if (this.#cache.status.has(url)) {
// @ts-ignore
return this.#cache.status.get(url)
}
const request = new Request(url, {
loader: this,
origin,
...options
})
// @ts-ignore
this.#cache.status.set(url, request.status)
return request.status
}
/**
* Resolves a given module URL to an absolute URL based on the loader origin.
* @param {URL|string} url
* @param {URL|string} [origin]
* @return {string}
*/
resolve (url, origin) {
return Loader.resolve(url, origin || this.origin)
}
/**
* @ignore
*/
[Symbol.for('socket.runtime.util.inspect.custom')] () {
return `Loader ('${this.origin}') { }`
}
}
export default Loader
defineBuiltin('commonjs/loader', {
RequestStatus,
Response,
Request,
Loader
})