Skip to content

Commit

Permalink
feat(scripts)!: useScript overhaul, @unhead/scripts (#436)
Browse files Browse the repository at this point in the history
* chore: release v1.11.14

* progress commit

* chore: progress commit

* chore: broken tests

* chore: fix build

* chore: fix build

* doc: install fix

* fix: no longer augment as promise, export legacy

* fix: use forwarding proxy once loaded

* chore: read me

* feat: support event deduping
  • Loading branch information
harlan-zw authored Jan 9, 2025
1 parent d6bfebf commit c234e21
Show file tree
Hide file tree
Showing 30 changed files with 951 additions and 230 deletions.
71 changes: 20 additions & 51 deletions docs/content/1.usage/2.composables/4.use-script.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ title: useScript
description: Load third-party scripts with SSR support and a proxied API.
---

**Stable as of v1.9**

## Features

- 🪨 Turn a third-party script into a fully typed API
Expand All @@ -14,6 +12,26 @@ description: Load third-party scripts with SSR support and a proxied API.
- 🪝 Proxy API: Use a scripts functions before it's loaded (or while SSR)
- 🇹 Fully typed APIs

## Installation

As of Unhead v2, you will need to add the `@unhead/scripts` dependency to use `useScript`.

::code-group

```bash [yarn]
yarn add -D @unhead/scripts
```

```bash [npm]
npm install -D @unhead/scripts
```

```bash [pnpm]
pnpm add -D @unhead/scripts
```

::

## Background

Loading scripts using the `useHead` composable is easy.
Expand Down Expand Up @@ -314,28 +332,6 @@ const val = myScript.proxy.siteId // ❌ val will be a function
const user = myScript.proxy.loadUser() // ❌ the result of calling any function is always void
````

#### Stubbing

In cases where you're using the Proxy API, you can additionally hook into the resolving of the proxy using the `stub`
option.

For example, in a server context, we probably want to polyfill some returns so our scrits remains functional.

```ts
const analytics = useScript<{ event: ((arg: string) => boolean) }>('/analytics.js', {
use() { return window.analytics },
stub() {
if (import.meta.server) {
return {
event: (e) => {
console.log('event', e)
}
}
}
}
})
```

## API

```ts
Expand Down Expand Up @@ -420,33 +416,6 @@ fathom.then((api) => {
})
```

#### `stub`

A more advanced function used to stub out the logic of the API. This will be called on the server and client.

This is particularly useful when the API you want to use is a primitive and you need to access it on the server. For instance,
pushing to `dataLayer` when using Google Tag Manager.

```ts
const myScript = useScript<MyScriptApi>({
src: 'https://example.com/script.js',
}, {
use: () => window.myScript,
stub: ({ fn }) => {
// stub out behavior on server
if (process.server && fn === 'sendEvent')
return (opt: string) => fetch('https://api.example.com/event', { method: 'POST', body: opt })
}
})
const { sendEvent, doSomething } = myScript.proxy
// on server, will send a fetch to https://api.example.com/event
// on client it falls back to the real API
sendEvent('event')
// on server, will noop
// on client it falls back to the real API
doSomething()
```

## Script Instance API

The `useScript` composable returns the script instance that you can use to interact with the script.
Expand Down
4 changes: 0 additions & 4 deletions packages/schema/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { ScriptInstance } from './'
import type { CreateHeadOptions, HeadEntry, Unhead } from './head'
import type { HeadTag } from './tags'

Expand Down Expand Up @@ -53,7 +52,4 @@ export interface HeadHooks {
'ssr:beforeRender': (ctx: ShouldRenderContext) => HookResult
'ssr:render': (ctx: { tags: HeadTag[] }) => HookResult
'ssr:rendered': (ctx: SSRRenderContext) => HookResult

'script:updated': (ctx: { script: ScriptInstance<any> }) => HookResult
'script:instance-fn': (ctx: { script: ScriptInstance<any>, fn: string | symbol, exists: boolean }) => HookResult
}
3 changes: 0 additions & 3 deletions packages/schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,4 @@ export * from './head'
export * from './hooks'
export * from './safeSchema'
export * from './schema'
export * from './script'
export * from './tags'

export {}
7 changes: 7 additions & 0 deletions packages/scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# @unhead/scripts

Unhead Scripts allows you to load third-party scripts with better performance, privacy, and security.

## License

MIT License © 2022-PRESENT [Harlan Wilton](https://github.com/harlan-zw)
32 changes: 32 additions & 0 deletions packages/scripts/build.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { defineBuildConfig } from 'unbuild'

export default defineBuildConfig({
clean: true,
declaration: true,
rollup: {
emitCJS: true,
},
entries: [
{ input: 'src/index' },
{ input: 'src/vue/index', name: 'vue' },
{ input: 'src/legacy', name: 'legacy' },
{ input: 'src/vue-legacy', name: 'vue-legacy' },
],
externals: [
'vue',
'@vue/runtime-core',
'unplugin-vue-components',
'unhead',
'@unhead/vue',
'@unhead/schema',
'vite',
'vue-router',
'@unhead/vue',
'@unhead/schema',
'unplugin-ast',
'unplugin',
'unplugin-vue-components',
'vue',
'@vue/runtime-core',
],
})
1 change: 1 addition & 0 deletions packages/scripts/legacy.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './dist/legacy'
9 changes: 9 additions & 0 deletions packages/scripts/overrides.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
declare module '@unhead/schema' {
import type { ScriptInstance } from '@unhead/scripts'

export interface HeadHooks {
'script:updated': (ctx: { script: ScriptInstance<any> }) => void | Promise<void>
}
}

export {}
97 changes: 97 additions & 0 deletions packages/scripts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
{
"name": "@unhead/scripts",
"type": "module",
"version": "1.11.14",
"description": "Unhead Scripts allows you to load third-party scripts with better performance, privacy, and security.",
"author": "Harlan Wilton <harlan@harlanzw.com>",
"license": "MIT",
"funding": "https://github.com/sponsors/harlan-zw",
"homepage": "https://unhead.unjs.io",
"repository": {
"type": "git",
"url": "git+https://github.com/unjs/unhead.git",
"directory": "packages/schema-org"
},
"bugs": {
"url": "https://github.com/unjs/unhead/issues"
},
"keywords": [
"schema.org",
"node",
"seo"
],
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./vue": {
"types": "./dist/vue.d.ts",
"import": "./dist/vue.mjs",
"require": "./dist/vue.cjs"
},
"./legacy": {
"types": "./dist/legacy.d.ts",
"import": "./dist/legacy.mjs",
"require": "./dist/legacy.cjs"
},
"./vue-legacy": {
"types": "./dist/vue-legacy.d.ts",
"import": "./dist/vue-legacy.mjs",
"require": "./dist/vue-legacy.cjs"
}
},
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"typesVersions": {
"*": {
"vue": [
"dist/vue"
],
"legacy": [
"dist/legacy"
],
"vue-legacy": [
"dist/vue-legacy"
]
}
},
"files": [
"dist",
"legacy.d.ts",
"overrides.d.ts",
"vue.d.ts"
],
"scripts": {
"build": "unbuild .",
"stub": "unbuild . --stub",
"test": "vitest",
"release": "bumpp package.json --commit --push --tag",
"lint": "eslint \"{src,test}/**/*.{ts,vue,json,yml}\" --fix"
},
"peerDependencies": {
"@unhead/shared": "workspace:*",
"@unhead/vue": "workspace:*",
"unhead": "workspace:*"
},
"peerDependenciesMeta": {
"@unhead/vue": {
"optional": true
}
},
"build": {
"external": [
"vue"
]
},
"devDependencies": {
"@unhead/schema": "workspace:*",
"@unhead/shared": "workspace:*",
"@unhead/vue": "workspace:*",
"unhead": "workspace:*",
"unplugin-vue-components": "^0.27.5"
}
}
3 changes: 3 additions & 0 deletions packages/scripts/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './proxy'
export * from './types'
export * from './useScript'
95 changes: 95 additions & 0 deletions packages/scripts/src/legacy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { UseScriptOptions as CurrentUseScriptOptions, ScriptInstance, UseFunctionType, UseScriptInput } from './types'
import { useUnhead } from 'unhead'
import { useScript as _useScript } from './useScript'

export interface UseScriptOptions<T extends BaseScriptApi = Record<string, any>> extends CurrentUseScriptOptions {
/**
* Stub the script instance. Useful for SSR or testing.
*/
stub?: ((ctx: { script: ScriptInstance<T>, fn: string | symbol }) => any)
}

type BaseScriptApi = Record<symbol | string, any>

export type AsAsyncFunctionValues<T extends BaseScriptApi> = {
[key in keyof T]:
T[key] extends any[] ? T[key] :
T[key] extends (...args: infer A) => infer R ? (...args: A) => R extends Promise<any> ? R : Promise<R> :
T[key] extends Record<any, any> ? AsAsyncFunctionValues<T[key]> :
never
}

export type UseScriptContext<T extends Record<symbol | string, any>> =
(Promise<T> & ScriptInstance<T>)
& AsAsyncFunctionValues<T>
& {
/**
* @deprecated Use top-level functions instead.
*/
$script: Promise<T> & ScriptInstance<T>
}

const ScriptProxyTarget = Symbol('ScriptProxyTarget')
function scriptProxy() {}
scriptProxy[ScriptProxyTarget] = true

export function useScript<T extends Record<symbol | string, any> = Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): UseScriptContext<UseFunctionType<UseScriptOptions<T>, T>> {
const head = _options?.head || useUnhead()
const script = _useScript(_input, _options) as any as UseScriptContext<T>
// support deprecated behavior
script.$script = script
const proxyChain = (instance: any, accessor?: string | symbol, accessors?: (string | symbol)[]) => {
return new Proxy((!accessor ? instance : instance?.[accessor]) || scriptProxy, {
get(_, k, r) {
// @ts-expect-error untyped
head.hooks.callHook('script:instance-fn', { script, fn: k, exists: k in _ })
if (!accessor) {
const stub = _options?.stub?.({ script, fn: k })
if (stub)
return stub
}
if (_ && k in _ && typeof _[k] !== 'undefined') {
return Reflect.get(_, k, r)
}
if (k === Symbol.iterator) {
return [][Symbol.iterator]
}
return proxyChain(accessor ? instance?.[accessor] : instance, k, accessors || [k])
},
async apply(_, _this, args) {
// we are faking, just return, avoid promise handles
if (head.ssr && _[ScriptProxyTarget])
return
let instance: any
const access = (fn?: T) => {
instance = fn || instance
for (let i = 0; i < (accessors || []).length; i++) {
const k = (accessors || [])[i]
fn = fn?.[k]
}
return fn
}
let fn = access(script.instance)
if (!fn) {
fn = await (new Promise<T | undefined>((resolve) => {
script.onLoaded((api) => {
resolve(access(api))
})
}))
}
return typeof fn === 'function' ? Reflect.apply(fn, instance, args) : fn
},
})
}
script.proxy = proxyChain(script.instance)
return new Proxy(Object.assign(script._loadPromise, script), {
get(_, k) {
// _ keys are reserved for internal overrides
const target = (k in script || String(k)[0] === '_') ? script : script.proxy
if (k === 'then' || k === 'catch') {
return script[k].bind(script)
}
return Reflect.get(target, k, target)
},
})
}
Loading

0 comments on commit c234e21

Please sign in to comment.