JavaScript
This page covers script resources and JavaScript runtime features in Doors.
If generic resource syntax is new, start with Resources. This page focuses on what is specific to <script> and modules.
It also covers the Go to JavaScript bridge: doors.AData, doors.AHook[...], doors.ARawHook, $data(...), $hook(...), $fetch(...), and $on(...).
Start
Most pages start with one of these:
- page-local code written directly in the template: plain inline
<script>...</script> - page-local code kept in a file or bytes:
<script src=(doors.ResourceLocalFS("web/app.ts")) output="inline"></script> - shorthand bytes:
<script src=(appJS)></script> - already-hosted URL:
<script src="/assets/app.js"></script>
Example:
//go:embed web/picker.ts
var pickerTS []byte
<>
~>(doors.AHook[string]{
Name: "save",
On: func(ctx context.Context, r doors.RequestHook[string]) (any, bool) {
_ = r.Data()
return nil, true
},
}) <script
data:userId=(userID)
src=(pickerTS)
type="typescript"
output="inline"></script>
</>
Inside web/picker.ts, that script can use $data("userId") and await $hook("save", value).
Scripts
Types
Use:
- no
typefor regular JavaScript type="module"for ES modulestype="typescript"for TypeScripttype="module/typescript"for module TypeScript
Inline
Use inline script when the code belongs only to this page:
<script>
await $sys.ready()
console.log("managed inline script")
</script>
By default this is a managed script resource. Doors builds the body, turns it into a src-backed resource, and runs it through the managed runtime wrapper.
Use output="inline" when the code lives in a file or bytes but should behave the same way:
<script
src=(doors.ResourceLocalFS("web/picker.ts"))
type="typescript"
output="inline"></script>
Inline rules:
- plain inline JavaScript is managed by default
output="inline"works only with buildablesrcsources- actual inline TypeScript bodies are not supported
- inline module bodies are not supported
- inline scripts cannot be bundled
output="raw"leaves the original tag alone
Linked src
Use a regular linked script when the code should not be loaded as a module:
<script
src=(doors.ResourceLocalFS("web/app.ts"))
type="typescript"></script>
Useful output values are:
output="default"or omitted: normal built or prepared script resourceoutput="bundle": bundle dependencies into one outputoutput="raw": skip the build pipeline and leave the script raw
Common src shapes are:
- buildable app content:
ResourceLocalFS,ResourceFS,ResourceBytes,ResourceString - already-hosted local URL: plain string such as
"/assets/app.js" - external URL:
doors.ResourceExternal("https://cdn.example.com/app.js") - handler-backed source:
doors.ResourceHook(...),doors.ResourceHandler(...) - proxy-backed source:
doors.ResourceProxy(...)
In practice:
- buildable sources go through the JS pipeline unless
output="raw"is used - plain strings are just direct URLs
ResourceExternal(...)is a direct URL plus automatic CSP source collection- handler and proxy sources become hook-backed URLs
Raw TypeScript is not supported. If the source is TypeScript, let Doors build it.
Managed runtime
Managed scripts get:
- helper variables like
$data,$hook, and$on - top-level
await - subtree cleanup with
$sys.clean(...) - build and resource handling through the JS pipeline
A script is managed when it is:
- a plain inline JavaScript
<script>...</script>block with nosrc, nooutput="raw", and no non-JavaScripttype - or a buildable
srcscript withoutput="inline"
If you set output="raw" or use an unsupported non-JavaScript type, Doors leaves the tag alone and the browser handles it as a normal raw script tag.
For managed TypeScript, editor tooling is nicer if you add ambient declarations for helpers like $data, $hook, $fetch, $on, and $sys. TSserver may still warn about top-level await; that is expected for managed inline script bodies and output="inline" scripts.
Modules
Use modules when you want import, export, or import-map based loading.
<script
src=(doors.ResourceLocalFS("web/app.ts"))
type="module"
output="bundle"></script>
Use output="bundle" when the module has dependencies that should be bundled into one output.
Use specifier when the module should be registered in the page import map:
<div id="app"></div>
<script
src=(doors.ResourceLocalFS("web/react/index.tsx"))
type="module"
output="bundle"
specifier="app"></script>
<script>
const { mount } = await import("app")
mount(document.getElementById("app"))
</script>
On a regular <script> tag, specifier also forces module mode.
specifier matters only during the initial render, before the browser starts resolving module specifiers. In practice, modules you want available through the import map should usually be declared in the page head.
On a regular <script> tag:
- if the tag has only control attrs, Doors omits the tag and registers only the import-map entry
- if the tag also has any other attr, the tag stays in the HTML and the import-map entry is also registered
Here, control attrs means attrs that only configure the resource, such as src, type, output, specifier, name, profile, private, or nocache.
So if you want the module both in the import map and loaded by a regular <script> tag, add any normal attr such as id, async, or crossorigin.
Example that keeps the tag:
<script
src=(doors.ResourceLocalFS("web/app.ts"))
type="module"
output="bundle"
id="app-module"
specifier="app"></script>
For preload:
<link
rel="modulepreload"
href=(doors.ResourceBytes(moduleJS))
specifier="app">
Use rel="modulepreload" when the module should be preloaded and also registered in the import map.
<link rel="modulepreload"> always stays rendered. With specifier, Doors also registers the import-map entry.
Attrs
These attrs control script resource behavior:
output:default,inline,bundle, orrawspecifier: register a module in the import mapname: readable output file nameprofile: named esbuild profileprivate: host through an instance-scoped hook URL while still using the resource pipelinenocache: host through an instance-scoped hook URL without shared resource caching
Example:
<script
src=(doors.ResourceLocalFS("web/react/index.tsx"))
type="module"
output="bundle"
name="react_app.js"
profile="react"
private
specifier="react_app"></script>
Build configuration itself is covered in Configuration.
Go Bridge
Data Binding
Use doors.AData or data:name=(...) when the script needs values from Go at render time:
<script
data:userId=(userID)
data:theme=(theme)>
const userId = $data("userId")
const theme = $data("theme")
console.log(userId, theme)
</script>
$data(...) returns the decoded value directly for string and JSON-backed values. For []byte, it returns a promise that resolves to an ArrayBuffer, so binary data still needs await.
GoX shorthand such as data:userId=(userID) is equivalent to attaching doors.AData{Name: "userId", Value: userID}.
Attach several AData attrs or several data:name=(...) entries when the script needs more than one server-provided value.
If the value is already known at render time, prefer AData over an extra hook call.
Hooks
Use hooks when JavaScript is already in control and needs to call back into Go. For normal clicks, inputs, and forms, stay with Events.
There are two independent choices:
- on the Go side:
AHook[T]orARawHook - on the JavaScript side:
$hook(...)or$fetch(...)
Use:
AHook[T]for typed JSON input and outputARawHookfor raw request or response control$hook(...)when the script wants the helper-style result$fetch(...)when the script wants the rawResponse
These are not a 1:1 pair. For example, $hook(...) can call ARawHook.
For AHook[T], the handler receives doors.RequestHook[T], so it can:
- read
r.Data() - read or set cookies
- schedule
r.After(...)actions
Return false to keep the hook active.
Return true to remove it after the call.
<script
(doors.AHook[string]{
Name: "visibility",
On: func(ctx context.Context, r doors.RequestHook[string]) (any, bool) {
println(r.Data())
return nil, false
},
})>
document.addEventListener("visibilitychange", async () => {
await $hook("visibility", document.visibilityState)
})
</script>
Raw Variant
Use doors.ARawHook when the Go side needs lower-level transport control.
That is the right fit for:
- raw request body access
- multipart handling
- uploads
- custom decoding
- custom response bodies or headers
The handler receives doors.RequestRawHook, which adds:
r.Body()for raw request bodiesr.Reader()orr.ParseForm(...)for multipart formsr.W()when you want to write the response yourself
When $hook(...) or $fetch(...) sends data, the client chooses the request body shape automatically:
undefined: no bodyFormData:multipart/form-dataURLSearchParams:application/x-www-form-urlencodedBlob: raw blob bodyFile: raw file bodyReadableStream:application/octet-stream- any other value: JSON
Client API
The main client-side helpers are:
$data<T = any>(name: string): T | Promise<ArrayBuffer>$hook(name: string, arg?: any): Promise<any>$fetch(name: string, arg?: any): Promise<Response>$on(name: string, handler: (arg: any, err?: HookErr) => any): void$sys.ready(): Promise<void>$sys.clean(fn: () => void | Promise<void>): void$sys.activateLinks(): voidHookErr
Manual $hook(...) and $fetch(...) calls throw HookErr. Catch it when failure is part of the normal flow.
Emit and cleanup
Use $on(name, handler) when Go should call JavaScript through doors.ActionEmit:
<script>
$on("alert", (message, err) => {
if (err) {
console.log(err.kind)
return
}
window.alert(message)
})
</script>
The nearest matching handler wins in the Door tree. $on(...) handlers used by actions must stay synchronous.
Use $sys.clean(...) for timers, global listeners, and embedded widgets that need teardown:
<script>
const onResize = () => {
console.log(window.innerWidth)
}
window.addEventListener("resize", onResize)
$sys.clean(() => {
window.removeEventListener("resize", onResize)
})
</script>
Choose
- Page-local code written here: plain inline
<script>...</script> - Page-local code kept in a file or bytes: buildable
src=(...)withoutput="inline" - Reusable or TypeScript code: buildable
src=(...) - Module loading:
type="module" - Import by name: add
specifier="..." - Keep a
specifierscript tag rendered: add any normal attr such asid - Preload and register a module:
rel="modulepreload"withspecifier - Shared public resource URL is not wanted: use
privateornocache