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")) 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="text/typescript"
inline></script>
</>
Inside web/picker.ts, that script can use $data("userId") and await $hook("save", value).
Scripts
Mental Model
There are three common script shapes in Doors:
- browser script: the browser just loads and runs it
- managed script: Doors wraps it and gives it runtime helpers like
$data,$hook,$on, and$sys - module script: the browser treats it as an ES module
Use a managed script when the code is page-local and should participate in the Doors runtime.
Use a module when the code should be imported, exported, or wired through the import map.
Use a raw or plain browser script when you want normal browser behavior with no Doors runtime wrapper.
Types
Use:
- no
typefor regular JavaScript type="module"for ES modulestype="typescript"for TypeScripttype="module/typescript"for module TypeScript
Output
Script output is controlled with boolean attrs:
- omitted: normal built or prepared script resource
inline: treat a buildablesrcscript like a managed scriptbundle: bundle dependencies into one outputraw: skip script transformation and serve the source as-is
In practice:
- omitted is the normal choice for linked scripts
inlineis for code that should behave like page-local managed script codebundleis for modules with dependenciesrawis for exact browser behavior
Managed Scripts
A script is managed when it is:
- a plain inline JavaScript
<script>...</script>block with nosrc, noraw, and no non-JavaScripttype - or a buildable
srcscript withinline
Managed scripts get:
- helper variables like
$data,$hook,$fetch,$on, and$sys - top-level
await - subtree cleanup with
$sys.clean(...)
If you want the browser to handle the script normally, use raw or a direct browser-usable URL.
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 runtime wrapper.
Use inline when the code lives in a file or bytes but should behave the same way:
<script
src=(doors.ResourceLocalFS("web/picker.ts"))
type="text/typescript"
inline></script>
Inline rules:
- plain inline JavaScript is managed by default
- actual inline TypeScript bodies are not supported
- inline module bodies are not supported
- inline scripts cannot be bundled
rawleaves 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="text/typescript"></script>
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")for a direct browser URL that also participates in CSP source collection - handler-backed source:
doors.ResourceHook(...),doors.ResourceHandler(...) - proxy-backed source:
doors.ResourceProxy(...)
Buildable src scripts go through the JS pipeline unless raw is used. Plain strings are just direct URLs.
Raw TypeScript is not supported. If the source is TypeScript, let Doors build it.
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 inline scripts.
Modules
Use modules when you want import, export, or import-map based loading.
<script
src=(doors.ResourceLocalFS("web/app.ts"))
type="module"
bundle></script>
Use 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"
bundle
specifier="app"></script>
<script>
const { mount } = await import("app")
mount(document.getElementById("app"))
</script>
For module scripts,
specifieris usually the main way to wire modules together. If a module is not fully standalone, register it with a specifier and import it by that name.
On a regular <script> tag, specifier does not replace module typing. Use type="module" together with specifier.
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.
Import Without Execution
If a module should be available in the import map but should not be executed by a <script> tag, use rel="modulepreload" with specifier:
<link
rel="modulepreload"
href=(doors.ResourceLocalFS("web/app.ts"))
specifier="app">
That registers "app" in the import map and preloads the module, but it does not execute it as a page script.
Later, load it explicitly:
<script>
const app = await import("app")
app.mount()
</script>
Attrs
These attrs control script resource behavior:
- output attrs:
inline,bundle,raw specifier: 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"
bundle
name="react_app.js"
profile="react"
private
specifier="react_app"></script>
Plain string URLs are passed through as-is. doors.ResourceExternal(...) keeps the browser URL direct while also adding that host to CSP. Handler and proxy sources already produce hook-backed URLs.
Use private when the script should not be publicly reachable.
Use nocache for dynamically generated script output that should not use shared resource caching.
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
r.Context()for the underlying HTTP request context - 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>
The handler's
ctxparameter is the Doors runtime context. Use it with Doors APIs.r.Context()is the underlying HTTP request context.
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>